diff --git a/.gitignore b/.gitignore index 3ecb3b25..0948ea22 100644 --- a/.gitignore +++ b/.gitignore @@ -9,11 +9,8 @@ node_modules/ /configs/code-server/.config/* !/configs/code-server/.config/.gitkeep -# MkDocs cache and built site (created by containers) -/mkdocs/.cache/* -!/mkdocs/.cache/.gitkeep -/mkdocs/site/* -!/mkdocs/site/.gitkeep +# Root assets (generated by containers) +/assets/ # Homepage logs (created by container) /configs/homepage/logs/* diff --git a/admin/src/pages/DocsPage.tsx b/admin/src/pages/DocsPage.tsx index b22d2d87..7ad1eb36 100644 --- a/admin/src/pages/DocsPage.tsx +++ b/admin/src/pages/DocsPage.tsx @@ -49,6 +49,8 @@ import { BuildOutlined, HolderOutlined, QuestionCircleOutlined, + UploadOutlined, + InboxOutlined, } from '@ant-design/icons'; import Editor from '@monaco-editor/react'; import type { OnMount } from '@monaco-editor/react'; @@ -72,6 +74,13 @@ const DEFAULT_TREE_WIDTH = 200; const MIN_TREE_WIDTH = 160; const MAX_TREE_WIDTH = 400; +const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico']); + +function isImageFile(filePath: string): boolean { + const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase(); + return IMAGE_EXTENSIONS.has(ext); +} + function filePathToMkDocsUrl(filePath: string): string { let url = filePath.replace(/\.md$/, ''); if (url.endsWith('/index') || url === 'index') { @@ -119,30 +128,49 @@ function invalidateTreeCache(): void { } } -// URL Preview Bar Component (shows production + localhost URLs above iframe) +// URL Preview Bar Component (shows full clickable URLs above iframe) const URLPreviewBar = ({ filePath, config }: { filePath: string | null; config: ServicesConfig | null }) => { const { token } = theme.useToken(); // Only show for markdown files if (!filePath || !filePath.endsWith('.md')) return null; - // Transform file path to URL path (reuse existing logic from filePathToMkDocsUrl) + // Transform file path to URL path let urlPath = filePath.replace(/\.md$/, ''); if (urlPath.endsWith('/index') || urlPath === 'index') { urlPath = urlPath.replace(/\/?index$/, ''); } + const suffix = urlPath ? `/${urlPath}/` : '/'; - // Use buildServiceUrl for environment-aware URL construction - const baseUrl = config - ? buildServiceUrl(config.mkdocsSubdomain, config.domain, config.mkdocsPort) + const hostname = window.location.hostname; + const isRealDomain = hostname.includes('.'); + + // Build URLs — production is the static site at root domain, not the dev preview + const productionUrl = config + ? `${window.location.protocol}//${config.domain}${suffix}` + : null; + const localhostUrl = config + ? `http://localhost:${config.mkdocsPort}${suffix}` : null; - const productionUrl = baseUrl ? `${baseUrl}/${urlPath}${urlPath ? '/' : ''}` : ''; - const localhostUrl = productionUrl; // Same URL works for both environments now const openUrl = (url: string) => { window.open(url, '_blank', 'noopener,noreferrer'); }; + const urlLinkStyle: React.CSSProperties = { + fontFamily: 'monospace', + fontSize: 11, + color: token.colorPrimary, + cursor: 'pointer', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + maxWidth: 360, + display: 'inline-flex', + alignItems: 'center', + gap: 4, + }; + return (
- - Preview: - - - - {/* Production URL Button */} + {productionUrl && ( - + openUrl(productionUrl)}> + + {productionUrl} + + )} - {/* Localhost URL Button */} + {/* Only show localhost URL when on localhost (on real domain they resolve the same) */} + {!isRealDomain && localhostUrl && ( - + openUrl(localhostUrl)}> + + {localhostUrl} + - + )}
); }; @@ -393,6 +399,10 @@ export default function DocsPage() { const [modalInput, setModalInput] = useState(''); const [contextPath, setContextPath] = useState(''); + const [dragOver, setDragOver] = useState(false); + const dragCounter = useRef(0); + const fileInputRef = useRef(null); + const containerRef = useRef(null); const dragging = useRef<'split' | 'tree' | false>(false); const editorRef = useRef(null); @@ -619,9 +629,39 @@ export default function DocsPage() { }; }, [ctxMenu]); + // Select an image file (no text loading, just set selectedFile) + const selectImageFile = useCallback((filePath: string) => { + setSelectedFile(filePath); + setFileContent(''); + setOriginalContent(''); + setDirty(false); + if (previewIframeRef.current) { + // Navigate preview to the image's parent page (if any) + previewIframeRef.current.src = '/mkdocs-proxy/'; + } + }, []); + const onTreeSelect = useCallback(async (keys: React.Key[]) => { if (keys.length === 0) return; const path = keys[0] as string; + + // Image files — just select, no text loading + if (isImageFile(path)) { + if (dirty) { + Modal.confirm({ + title: 'Unsaved Changes', + content: `Save changes to ${selectedFile} before switching?`, + okText: 'Save', + cancelText: 'Discard', + onOk: async () => { await saveFile(); selectImageFile(path); }, + onCancel: () => { setDirty(false); selectImageFile(path); }, + }); + return; + } + selectImageFile(path); + return; + } + if (dirty) { Modal.confirm({ title: 'Unsaved Changes', @@ -697,6 +737,10 @@ export default function DocsPage() { fetchTree(false, true); }, [fetchTree]); + const handleUploadButtonClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); + // File tree context menu const getContextMenuItems = useCallback((nodePath: string, isDirectory: boolean): MenuProps['items'] => { const items: MenuProps['items'] = []; @@ -704,6 +748,7 @@ export default function DocsPage() { items.push( { key: 'newFile', icon: , label: 'New File', onClick: () => { setContextPath(nodePath); setModalInput(''); setModalType('newFile'); } }, { key: 'newFolder', icon: , label: 'New Folder', onClick: () => { setContextPath(nodePath); setModalInput(''); setModalType('newFolder'); } }, + { key: 'upload', icon: , label: 'Upload File', onClick: () => { setSelectedFile(nodePath); handleUploadButtonClick(); } }, { type: 'divider' }, ); } @@ -712,7 +757,7 @@ export default function DocsPage() { { key: 'delete', icon: , label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) }, ); return items; - }, []); + }, [handleUploadButtonClick]); const handleDelete = useCallback(async (filePath: string) => { Modal.confirm({ @@ -807,14 +852,6 @@ export default function DocsPage() { }); }, []); - const expandAll = useCallback(() => { - setExpandedKeys(collectAllDirKeys(fileTree)); - }, [fileTree]); - - const collapseAll = useCallback(() => { - setExpandedKeys([]); - }, []); - const toggleExpand = useCallback((key: string) => { setExpandedKeys(prev => prev.includes(key) @@ -936,6 +973,105 @@ export default function DocsPage() { return find(fileTree); }, [fileTree]); + const allExpanded = expandedKeys.length > 0; + + const toggleExpandAll = useCallback(() => { + if (allExpanded) { + setExpandedKeys([]); + } else { + setExpandedKeys(collectAllDirKeys(fileTree)); + } + }, [allExpanded, fileTree]); + + // Upload files (images, pdfs, etc.) to the docs directory + const handleUploadFiles = useCallback(async (files: FileList | File[]) => { + const fileArray = Array.from(files); + if (fileArray.length === 0) return; + + // Determine target directory: selected folder, or parent of selected file, or root + let targetDir = ''; + if (selectedFile) { + if (isDirectoryPath(selectedFile)) { + targetDir = selectedFile; + } else if (selectedFile.includes('/')) { + targetDir = selectedFile.substring(0, selectedFile.lastIndexOf('/')); + } + } + + const hideLoading = messageApi.loading(`Uploading ${fileArray.length} file${fileArray.length > 1 ? 's' : ''}...`, 0); + let successCount = 0; + let lastMdPath: string | null = null; + + for (const file of fileArray) { + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('path', targetDir); + const res = await api.post<{ success: boolean; path: string }>('/docs/upload', formData); + successCount++; + if (file.name.endsWith('.md')) { + lastMdPath = res.data.path; + } + } catch (err: unknown) { + const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Upload failed'; + messageApi.error(`Failed to upload ${file.name}: ${msg}`); + } + } + + hideLoading(); + + if (successCount > 0) { + messageApi.success(`Uploaded ${successCount} file${successCount > 1 ? 's' : ''}`); + invalidateTreeCache(); + await fetchTree(false, true); + if (lastMdPath) { + await loadFile(lastMdPath); + } + } + }, [selectedFile, isDirectoryPath, messageApi, fetchTree, loadFile]); + + // Drag-and-drop handlers for the file tree panel + const handleTreeDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter.current++; + if (e.dataTransfer.types.includes('Files')) { + setDragOver(true); + } + }, []); + + const handleTreeDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleTreeDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter.current--; + if (dragCounter.current <= 0) { + dragCounter.current = 0; + setDragOver(false); + } + }, []); + + const handleTreeDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter.current = 0; + setDragOver(false); + if (e.dataTransfer.files.length > 0) { + handleUploadFiles(e.dataTransfer.files); + } + }, [handleUploadFiles]); + + const handleFileInputChange = useCallback((e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + handleUploadFiles(e.target.files); + e.target.value = ''; // Reset so same file can be re-selected + } + }, [handleUploadFiles]); + if (isMobile) { return ( @@ -1039,53 +1175,91 @@ export default function DocsPage() { flexDirection: 'column', background: token.colorBgContainer, flexShrink: 0, + position: 'relative', }} + onDragEnter={handleTreeDragEnter} + onDragOver={handleTreeDragOver} + onDragLeave={handleTreeDragLeave} + onDrop={handleTreeDrop} > + {/* Drag-and-drop overlay */} + {dragOver && ( +
+ + + Drop files here + +
+ )} + + {/* Hidden file input for upload button fallback */} + + {/* Toolbar */}
- - Files - - - -
{/* Filter input (toggled) */} @@ -1105,6 +1279,18 @@ export default function DocsPage() { )} {/* Tree */} + , label: 'New File', onClick: handleNewFileRoot }, + { key: 'newFolder', icon: , label: 'New Folder', onClick: handleNewFolderRoot }, + { type: 'divider' }, + { key: 'upload', icon: , label: 'Upload File', onClick: handleUploadButtonClick }, + { key: 'refresh', icon: , label: 'Refresh', onClick: refreshTree }, + ], + }} + trigger={['contextMenu']} + >
e.stopPropagation()} style={{ display: 'block', overflow: 'hidden', @@ -1155,6 +1342,7 @@ export default function DocsPage() { style={{ fontSize: 13 }} />
+
{/* Tree resize handle */} @@ -1318,12 +1506,48 @@ export default function DocsPage() { )} - {/* Monaco Editor */} + {/* Editor / Image Viewer */}
{fileLoading ? (
+ ) : selectedFile && isImageFile(selectedFile) ? ( + /* Image preview */ +
+ {selectedFile} +
+ + {selectedFile} + +
+ + MkDocs reference: {`![](${selectedFile.split('/').pop()})`} + +
+
+
) : selectedFile ? ( { + const ext = extname(relativePath).toLowerCase(); + if (!ALLOWED_UPLOAD_EXTENSIONS.has(ext)) { + throw new Error(`File type not allowed: ${ext}`); + } + + const fullPath = safeResolve(relativePath); + await mkdir(dirname(fullPath), { recursive: true }); + await copyFile(sourcePath, fullPath); + + // Invalidate tree cache (structure changed) + try { + await redis.del(TREE_CACHE_KEY); + } catch (err) { + logger.warn('Failed to invalidate tree cache after upload:', err); + } +} + async function invalidateTreeCache(): Promise { try { await redis.del(TREE_CACHE_KEY); @@ -265,6 +288,7 @@ export const docsFilesService = { createFile, deleteFile, renameFile, + uploadFile, safeResolve, isEditableFile, invalidateTreeCache, diff --git a/api/src/modules/docs/docs.routes.ts b/api/src/modules/docs/docs.routes.ts index 183ec484..42c2dbc9 100644 --- a/api/src/modules/docs/docs.routes.ts +++ b/api/src/modules/docs/docs.routes.ts @@ -1,4 +1,7 @@ import { Router, Request, Response, NextFunction } from 'express'; +import multer from 'multer'; +import { rm } from 'fs/promises'; +import { extname } from 'path'; import { authenticate } from '../../middleware/auth.middleware'; import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware'; import { env } from '../../config/env'; @@ -104,6 +107,57 @@ router.post( }, ); +// --- File Upload --- + +const ALLOWED_UPLOAD_EXTENSIONS = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', + '.pdf', '.zip', +]); + +const upload = multer({ + storage: multer.diskStorage({}), // temp dir + limits: { fileSize: 20 * 1024 * 1024 }, // 20MB + fileFilter: (_req, file, cb) => { + const ext = extname(file.originalname).toLowerCase(); + if (ALLOWED_UPLOAD_EXTENSIONS.has(ext)) { + cb(null, true); + } else { + cb(new Error(`File type not allowed: ${ext}`)); + } + }, +}); + +// POST /api/docs/upload — upload binary file (image, pdf, etc.) +router.post( + '/upload', + upload.single('file'), + async (req: Request, res: Response, next: NextFunction) => { + const tempPath = req.file?.path; + try { + cm_docs_operations.inc({ operation: 'upload' }); + if (!req.file) { + res.status(400).json({ error: { message: 'No file provided', code: 'VALIDATION_ERROR' } }); + return; + } + + const targetDir = (req.body as { path?: string }).path || ''; + const fileName = req.file.originalname; + const relativePath = targetDir ? `${targetDir}/${fileName}` : fileName; + + await docsFilesService.uploadFile(relativePath, req.file.path); + + // Clean up temp file + try { await rm(req.file.path); } catch { /* ignore */ } + + res.json({ success: true, path: relativePath }); + } catch (err) { + // Clean up temp file on error + if (tempPath) { try { await rm(tempPath); } catch { /* ignore */ } } + handleFileError(err, res, next); + } + }, +); + // --- File Management Endpoints --- // GET /api/docs/files — list file tree diff --git a/mkdocs/.cache/plugin/social/09bbbc93a961c0990fa7e3217673978f.png b/mkdocs/.cache/plugin/social/09bbbc93a961c0990fa7e3217673978f.png new file mode 100755 index 00000000..d19dffb8 Binary files /dev/null and b/mkdocs/.cache/plugin/social/09bbbc93a961c0990fa7e3217673978f.png differ diff --git a/mkdocs/.cache/plugin/social/10a5546a448a8a0b16de3eb978f8a68f.png b/mkdocs/.cache/plugin/social/10a5546a448a8a0b16de3eb978f8a68f.png new file mode 100755 index 00000000..3f11c634 Binary files /dev/null and b/mkdocs/.cache/plugin/social/10a5546a448a8a0b16de3eb978f8a68f.png differ diff --git a/mkdocs/.cache/plugin/social/2030a47afa1104093ebf519a6f22a7d1.png b/mkdocs/.cache/plugin/social/2030a47afa1104093ebf519a6f22a7d1.png new file mode 100755 index 00000000..8a6b5acc Binary files /dev/null and b/mkdocs/.cache/plugin/social/2030a47afa1104093ebf519a6f22a7d1.png differ diff --git a/mkdocs/.cache/plugin/social/3df345a41836bfa1f24aa074839aff71.png b/mkdocs/.cache/plugin/social/3df345a41836bfa1f24aa074839aff71.png new file mode 100755 index 00000000..37e5f35b Binary files /dev/null and b/mkdocs/.cache/plugin/social/3df345a41836bfa1f24aa074839aff71.png differ diff --git a/mkdocs/.cache/plugin/social/461dbf70704556ebdba00d9b93fdd71a.png b/mkdocs/.cache/plugin/social/461dbf70704556ebdba00d9b93fdd71a.png new file mode 100755 index 00000000..77e4acc5 Binary files /dev/null and b/mkdocs/.cache/plugin/social/461dbf70704556ebdba00d9b93fdd71a.png differ diff --git a/mkdocs/.cache/plugin/social/499785a5782a92d89dee51c0bf8b6995.png b/mkdocs/.cache/plugin/social/499785a5782a92d89dee51c0bf8b6995.png new file mode 100755 index 00000000..1d4020a6 Binary files /dev/null and b/mkdocs/.cache/plugin/social/499785a5782a92d89dee51c0bf8b6995.png differ diff --git a/mkdocs/.cache/plugin/social/49f28fa8303f79b46bfb7904c8e551a1.png b/mkdocs/.cache/plugin/social/49f28fa8303f79b46bfb7904c8e551a1.png new file mode 100755 index 00000000..b73e1750 Binary files /dev/null and b/mkdocs/.cache/plugin/social/49f28fa8303f79b46bfb7904c8e551a1.png differ diff --git a/mkdocs/.cache/plugin/social/4fec9fd5349ccccf8012393802a1a5bd.png b/mkdocs/.cache/plugin/social/4fec9fd5349ccccf8012393802a1a5bd.png new file mode 100755 index 00000000..c864a55f Binary files /dev/null and b/mkdocs/.cache/plugin/social/4fec9fd5349ccccf8012393802a1a5bd.png differ diff --git a/mkdocs/.cache/plugin/social/513e74590c0aaa12f169c3f283993a05.png b/mkdocs/.cache/plugin/social/513e74590c0aaa12f169c3f283993a05.png new file mode 100755 index 00000000..da7950b5 Binary files /dev/null and b/mkdocs/.cache/plugin/social/513e74590c0aaa12f169c3f283993a05.png differ diff --git a/mkdocs/.cache/plugin/social/5a026625721699a22ed4902c86e27264.png b/mkdocs/.cache/plugin/social/5a026625721699a22ed4902c86e27264.png new file mode 100755 index 00000000..4f37bd13 Binary files /dev/null and b/mkdocs/.cache/plugin/social/5a026625721699a22ed4902c86e27264.png differ diff --git a/mkdocs/.cache/plugin/social/5c8323641288ce96dac5e5d0c03d1d88.png b/mkdocs/.cache/plugin/social/5c8323641288ce96dac5e5d0c03d1d88.png new file mode 100755 index 00000000..58583ca3 Binary files /dev/null and b/mkdocs/.cache/plugin/social/5c8323641288ce96dac5e5d0c03d1d88.png differ diff --git a/mkdocs/.cache/plugin/social/5de16fced5aba77a2bd09132eb5fda0d.png b/mkdocs/.cache/plugin/social/5de16fced5aba77a2bd09132eb5fda0d.png new file mode 100755 index 00000000..8ca597c0 Binary files /dev/null and b/mkdocs/.cache/plugin/social/5de16fced5aba77a2bd09132eb5fda0d.png differ diff --git a/mkdocs/.cache/plugin/social/62372a9d5b433f883e1358d69aafb99d.png b/mkdocs/.cache/plugin/social/62372a9d5b433f883e1358d69aafb99d.png new file mode 100755 index 00000000..ddc14d9f Binary files /dev/null and b/mkdocs/.cache/plugin/social/62372a9d5b433f883e1358d69aafb99d.png differ diff --git a/mkdocs/.cache/plugin/social/630ed53169b0d638a0ecbc5a43b36dd5.png b/mkdocs/.cache/plugin/social/630ed53169b0d638a0ecbc5a43b36dd5.png new file mode 100755 index 00000000..3876e7df Binary files /dev/null and b/mkdocs/.cache/plugin/social/630ed53169b0d638a0ecbc5a43b36dd5.png differ diff --git a/mkdocs/.cache/plugin/social/63fe0d7764ab46b6b1a896c92f5f08ad.png b/mkdocs/.cache/plugin/social/63fe0d7764ab46b6b1a896c92f5f08ad.png new file mode 100755 index 00000000..3bf3c14a Binary files /dev/null and b/mkdocs/.cache/plugin/social/63fe0d7764ab46b6b1a896c92f5f08ad.png differ diff --git a/mkdocs/.cache/plugin/social/6e0a466e141c6410aa3b931db727ad5a.png b/mkdocs/.cache/plugin/social/6e0a466e141c6410aa3b931db727ad5a.png new file mode 100755 index 00000000..39fda7ca Binary files /dev/null and b/mkdocs/.cache/plugin/social/6e0a466e141c6410aa3b931db727ad5a.png differ diff --git a/mkdocs/.cache/plugin/social/6ff7c9a84364b85f150bfe85d21a1db8.png b/mkdocs/.cache/plugin/social/6ff7c9a84364b85f150bfe85d21a1db8.png new file mode 100755 index 00000000..09b00b2f Binary files /dev/null and b/mkdocs/.cache/plugin/social/6ff7c9a84364b85f150bfe85d21a1db8.png differ diff --git a/mkdocs/.cache/plugin/social/72eda47b0bb6ddeeba9c715ee9d857ab.png b/mkdocs/.cache/plugin/social/72eda47b0bb6ddeeba9c715ee9d857ab.png new file mode 100755 index 00000000..c3f439f9 Binary files /dev/null and b/mkdocs/.cache/plugin/social/72eda47b0bb6ddeeba9c715ee9d857ab.png differ diff --git a/mkdocs/.cache/plugin/social/7b06061b4b9b4a82384b4b9cf809471d.png b/mkdocs/.cache/plugin/social/7b06061b4b9b4a82384b4b9cf809471d.png new file mode 100755 index 00000000..493dfe47 Binary files /dev/null and b/mkdocs/.cache/plugin/social/7b06061b4b9b4a82384b4b9cf809471d.png differ diff --git a/mkdocs/.cache/plugin/social/7ca622286d4c40d181cd6c809308aadd.png b/mkdocs/.cache/plugin/social/7ca622286d4c40d181cd6c809308aadd.png new file mode 100755 index 00000000..5dd10c68 Binary files /dev/null and b/mkdocs/.cache/plugin/social/7ca622286d4c40d181cd6c809308aadd.png differ diff --git a/mkdocs/.cache/plugin/social/7cc7e1ec8732cd69b83aa549bfb13cc3.png b/mkdocs/.cache/plugin/social/7cc7e1ec8732cd69b83aa549bfb13cc3.png new file mode 100755 index 00000000..bf1ff946 Binary files /dev/null and b/mkdocs/.cache/plugin/social/7cc7e1ec8732cd69b83aa549bfb13cc3.png differ diff --git a/mkdocs/.cache/plugin/social/89cb9170565057569d85b76ef729d173.png b/mkdocs/.cache/plugin/social/89cb9170565057569d85b76ef729d173.png new file mode 100755 index 00000000..91abd878 Binary files /dev/null and b/mkdocs/.cache/plugin/social/89cb9170565057569d85b76ef729d173.png differ diff --git a/mkdocs/.cache/plugin/social/8b4d2b2992e85f6cc7dcfc9a7eb7c502.png b/mkdocs/.cache/plugin/social/8b4d2b2992e85f6cc7dcfc9a7eb7c502.png new file mode 100755 index 00000000..2b6baf55 Binary files /dev/null and b/mkdocs/.cache/plugin/social/8b4d2b2992e85f6cc7dcfc9a7eb7c502.png differ diff --git a/mkdocs/.cache/plugin/social/8e08f754f4d8c04a82391ae575aafaaa.png b/mkdocs/.cache/plugin/social/8e08f754f4d8c04a82391ae575aafaaa.png new file mode 100755 index 00000000..4ddeb630 Binary files /dev/null and b/mkdocs/.cache/plugin/social/8e08f754f4d8c04a82391ae575aafaaa.png differ diff --git a/mkdocs/.cache/plugin/social/9ce7dbc001bbf6d2aac4483d3c682a9b.png b/mkdocs/.cache/plugin/social/9ce7dbc001bbf6d2aac4483d3c682a9b.png new file mode 100755 index 00000000..4a3b3ae3 Binary files /dev/null and b/mkdocs/.cache/plugin/social/9ce7dbc001bbf6d2aac4483d3c682a9b.png differ diff --git a/mkdocs/.cache/plugin/social/a9aafe174d3666966343a65d9ae02f57.png b/mkdocs/.cache/plugin/social/a9aafe174d3666966343a65d9ae02f57.png new file mode 100755 index 00000000..0a49707a Binary files /dev/null and b/mkdocs/.cache/plugin/social/a9aafe174d3666966343a65d9ae02f57.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/adv/ansible.png b/mkdocs/.cache/plugin/social/assets/images/social/adv/ansible.png new file mode 100644 index 00000000..b206a422 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/adv/ansible.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/adv/index.png b/mkdocs/.cache/plugin/social/assets/images/social/adv/index.png new file mode 100644 index 00000000..22d0962c Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/adv/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/adv/vscode-ssh.png b/mkdocs/.cache/plugin/social/assets/images/social/adv/vscode-ssh.png new file mode 100644 index 00000000..1bed8ac4 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/adv/vscode-ssh.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/another-test-page.png b/mkdocs/.cache/plugin/social/assets/images/social/another-test-page.png new file mode 100644 index 00000000..3c55d652 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/another-test-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/blog/2025/07/03/blog-1.png b/mkdocs/.cache/plugin/social/assets/images/social/blog/2025/07/03/blog-1.png new file mode 100644 index 00000000..561c970f Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/blog/2025/07/03/blog-1.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/blog/2025/07/10/2.png b/mkdocs/.cache/plugin/social/assets/images/social/blog/2025/07/10/2.png new file mode 100644 index 00000000..86e512e3 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/blog/2025/07/10/2.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/blog/2025/08/01/3.png b/mkdocs/.cache/plugin/social/assets/images/social/blog/2025/08/01/3.png new file mode 100644 index 00000000..21f416a2 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/blog/2025/08/01/3.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/blog/2025/09/24/4.png b/mkdocs/.cache/plugin/social/assets/images/social/blog/2025/09/24/4.png new file mode 100644 index 00000000..0400f068 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/blog/2025/09/24/4.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/blog/archive/2025.png b/mkdocs/.cache/plugin/social/assets/images/social/blog/archive/2025.png new file mode 100644 index 00000000..424420cd Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/blog/archive/2025.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/blog/index.png b/mkdocs/.cache/plugin/social/assets/images/social/blog/index.png new file mode 100644 index 00000000..bdb7a3c9 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/blog/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/build/index.png b/mkdocs/.cache/plugin/social/assets/images/social/build/index.png new file mode 100644 index 00000000..dee06e08 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/build/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/build/influence.png b/mkdocs/.cache/plugin/social/assets/images/social/build/influence.png new file mode 100644 index 00000000..c3749f5e Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/build/influence.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/build/map.png b/mkdocs/.cache/plugin/social/assets/images/social/build/map.png new file mode 100644 index 00000000..ad2b50a8 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/build/map.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/build/server.png b/mkdocs/.cache/plugin/social/assets/images/social/build/server.png new file mode 100644 index 00000000..e9285987 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/build/server.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/build/site.png b/mkdocs/.cache/plugin/social/assets/images/social/build/site.png new file mode 100644 index 00000000..2aa79881 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/build/site.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/config/cloudflare-config.png b/mkdocs/.cache/plugin/social/assets/images/social/config/cloudflare-config.png new file mode 100644 index 00000000..a366df4a Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/config/cloudflare-config.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/config/coder.png b/mkdocs/.cache/plugin/social/assets/images/social/config/coder.png new file mode 100644 index 00000000..0a07ad37 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/config/coder.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/config/index.png b/mkdocs/.cache/plugin/social/assets/images/social/config/index.png new file mode 100644 index 00000000..86a363df Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/config/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/config/map.png b/mkdocs/.cache/plugin/social/assets/images/social/config/map.png new file mode 100644 index 00000000..e0d0cf89 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/config/map.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/config/mkdocs.png b/mkdocs/.cache/plugin/social/assets/images/social/config/mkdocs.png new file mode 100644 index 00000000..d8522518 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/config/mkdocs.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/how to/canvass.png b/mkdocs/.cache/plugin/social/assets/images/social/how to/canvass.png new file mode 100644 index 00000000..88742615 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/how to/canvass.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/index.png b/mkdocs/.cache/plugin/social/assets/images/social/index.png new file mode 100644 index 00000000..898613d8 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/lander.png b/mkdocs/.cache/plugin/social/assets/images/social/lander.png new file mode 100644 index 00000000..dda85385 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/lander.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/main.png b/mkdocs/.cache/plugin/social/assets/images/social/main.png new file mode 100644 index 00000000..40940850 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/main.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/manual/index.png b/mkdocs/.cache/plugin/social/assets/images/social/manual/index.png new file mode 100644 index 00000000..98ecad86 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/manual/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/manual/map.png b/mkdocs/.cache/plugin/social/assets/images/social/manual/map.png new file mode 100644 index 00000000..e0d0cf89 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/manual/map.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/phil/cost-comparison.png b/mkdocs/.cache/plugin/social/assets/images/social/phil/cost-comparison.png new file mode 100644 index 00000000..cb5e6395 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/phil/cost-comparison.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/phil/index.png b/mkdocs/.cache/plugin/social/assets/images/social/phil/index.png new file mode 100644 index 00000000..093c05ee Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/phil/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/services/code-server.png b/mkdocs/.cache/plugin/social/assets/images/social/services/code-server.png new file mode 100644 index 00000000..0a07ad37 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/services/code-server.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/services/gitea.png b/mkdocs/.cache/plugin/social/assets/images/social/services/gitea.png new file mode 100644 index 00000000..360a3262 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/services/gitea.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/services/homepage.png b/mkdocs/.cache/plugin/social/assets/images/social/services/homepage.png new file mode 100644 index 00000000..7254007d Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/services/homepage.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/services/index.png b/mkdocs/.cache/plugin/social/assets/images/social/services/index.png new file mode 100644 index 00000000..b05748be Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/services/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/services/listmonk.png b/mkdocs/.cache/plugin/social/assets/images/social/services/listmonk.png new file mode 100644 index 00000000..3d70a493 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/services/listmonk.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/services/map.png b/mkdocs/.cache/plugin/social/assets/images/social/services/map.png new file mode 100644 index 00000000..e0d0cf89 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/services/map.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/services/mini-qr.png b/mkdocs/.cache/plugin/social/assets/images/social/services/mini-qr.png new file mode 100644 index 00000000..c9cda7d3 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/services/mini-qr.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/services/mkdocs.png b/mkdocs/.cache/plugin/social/assets/images/social/services/mkdocs.png new file mode 100644 index 00000000..959b0d24 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/services/mkdocs.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/services/n8n.png b/mkdocs/.cache/plugin/social/assets/images/social/services/n8n.png new file mode 100644 index 00000000..8024a940 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/services/n8n.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/services/nocodb.png b/mkdocs/.cache/plugin/social/assets/images/social/services/nocodb.png new file mode 100644 index 00000000..4c1a3e11 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/services/nocodb.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/services/postgresql.png b/mkdocs/.cache/plugin/social/assets/images/social/services/postgresql.png new file mode 100644 index 00000000..da243c04 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/services/postgresql.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/services/static-server.png b/mkdocs/.cache/plugin/social/assets/images/social/services/static-server.png new file mode 100644 index 00000000..dddfe447 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/services/static-server.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/test-2.png b/mkdocs/.cache/plugin/social/assets/images/social/test-2.png new file mode 100644 index 00000000..06fed73c Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/test-2.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/test.png b/mkdocs/.cache/plugin/social/assets/images/social/test.png new file mode 100644 index 00000000..09bd98f4 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/test.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/adv/ansible.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/adv/ansible.png new file mode 100644 index 00000000..b206a422 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/adv/ansible.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/adv/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/adv/index.png new file mode 100644 index 00000000..22d0962c Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/adv/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/adv/vscode-ssh.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/adv/vscode-ssh.png new file mode 100644 index 00000000..1bed8ac4 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/adv/vscode-ssh.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/build/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/build/index.png new file mode 100644 index 00000000..dee06e08 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/build/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/build/influence.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/build/influence.png new file mode 100644 index 00000000..c3749f5e Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/build/influence.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/build/map.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/build/map.png new file mode 100644 index 00000000..ad2b50a8 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/build/map.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/build/server.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/build/server.png new file mode 100644 index 00000000..e9285987 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/build/server.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/build/site.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/build/site.png new file mode 100644 index 00000000..2aa79881 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/build/site.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/config/cloudflare-config.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/config/cloudflare-config.png new file mode 100644 index 00000000..a366df4a Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/config/cloudflare-config.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/config/coder.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/config/coder.png new file mode 100644 index 00000000..0a07ad37 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/config/coder.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/config/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/config/index.png new file mode 100644 index 00000000..86a363df Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/config/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/config/map.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/config/map.png new file mode 100644 index 00000000..e0d0cf89 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/config/map.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/config/mkdocs.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/config/mkdocs.png new file mode 100644 index 00000000..d8522518 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/config/mkdocs.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/index.png new file mode 100644 index 00000000..35dd0f0b Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/manual/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/manual/index.png new file mode 100644 index 00000000..98ecad86 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/manual/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/manual/map.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/manual/map.png new file mode 100644 index 00000000..e0d0cf89 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/manual/map.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/services/code-server.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/code-server.png new file mode 100644 index 00000000..0a07ad37 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/code-server.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/services/gitea.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/gitea.png new file mode 100644 index 00000000..360a3262 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/gitea.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/services/homepage.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/homepage.png new file mode 100644 index 00000000..7254007d Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/homepage.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/services/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/index.png new file mode 100644 index 00000000..b05748be Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/services/listmonk.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/listmonk.png new file mode 100644 index 00000000..3d70a493 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/listmonk.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/services/map.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/map.png new file mode 100644 index 00000000..e0d0cf89 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/map.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/services/mini-qr.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/mini-qr.png new file mode 100644 index 00000000..c9cda7d3 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/mini-qr.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/services/mkdocs.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/mkdocs.png new file mode 100644 index 00000000..959b0d24 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/mkdocs.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/services/n8n.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/n8n.png new file mode 100644 index 00000000..8024a940 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/n8n.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/services/nocodb.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/nocodb.png new file mode 100644 index 00000000..4c1a3e11 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/nocodb.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/services/postgresql.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/postgresql.png new file mode 100644 index 00000000..da243c04 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/postgresql.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v1/services/static-server.png b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/static-server.png new file mode 100644 index 00000000..dddfe447 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v1/services/static-server.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/api-reference/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/api-reference/index.png new file mode 100644 index 00000000..05083266 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/api-reference/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/architecture/authentication.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/architecture/authentication.png new file mode 100644 index 00000000..04d72f9e Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/architecture/authentication.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/architecture/dual-api.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/architecture/dual-api.png new file mode 100644 index 00000000..89f4a3d5 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/architecture/dual-api.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/architecture/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/architecture/index.png new file mode 100644 index 00000000..69e8040b Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/architecture/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/index.png new file mode 100644 index 00000000..fee34ac0 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/middleware/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/middleware/index.png new file mode 100644 index 00000000..23e33590 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/middleware/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/auth.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/auth.png new file mode 100644 index 00000000..1007098a Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/auth.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/campaigns.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/campaigns.png new file mode 100644 index 00000000..03974eba Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/campaigns.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/canvass.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/canvass.png new file mode 100644 index 00000000..165edb0f Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/canvass.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/index.png new file mode 100644 index 00000000..0281ab0d Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/locations.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/locations.png new file mode 100644 index 00000000..b2859bc7 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/locations.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/media.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/media.png new file mode 100644 index 00000000..9b0fea9f Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/media.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/pages.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/pages.png new file mode 100644 index 00000000..a9994402 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/pages.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/representatives.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/representatives.png new file mode 100644 index 00000000..3eae706a Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/representatives.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/responses.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/responses.png new file mode 100644 index 00000000..11dbaf6d Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/responses.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/settings.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/settings.png new file mode 100644 index 00000000..d5390802 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/settings.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/shifts.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/shifts.png new file mode 100644 index 00000000..8a926627 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/shifts.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/users.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/users.png new file mode 100644 index 00000000..34dbd2e5 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/modules/users.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/services/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/services/index.png new file mode 100644 index 00000000..913aa7d8 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/services/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/utilities/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/utilities/index.png new file mode 100644 index 00000000..1db564f2 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/backend/utilities/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/contributing/code-of-conduct.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/contributing/code-of-conduct.png new file mode 100644 index 00000000..3b7814a5 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/contributing/code-of-conduct.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/contributing/development-setup.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/contributing/development-setup.png new file mode 100644 index 00000000..c2dd796d Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/contributing/development-setup.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/contributing/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/contributing/index.png new file mode 100644 index 00000000..c8a8a201 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/contributing/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/contributing/pull-requests.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/contributing/pull-requests.png new file mode 100644 index 00000000..75fd4937 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/contributing/pull-requests.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/contributing/roadmap.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/contributing/roadmap.png new file mode 100644 index 00000000..3f4ac590 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/contributing/roadmap.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/database/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/index.png new file mode 100644 index 00000000..733f34c5 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/database/indexes.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/indexes.png new file mode 100644 index 00000000..69a6b1ca Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/indexes.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/database/migrations.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/migrations.png new file mode 100644 index 00000000..5f0410e0 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/migrations.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/auth.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/auth.png new file mode 100644 index 00000000..8be1b84d Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/auth.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/canvass.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/canvass.png new file mode 100644 index 00000000..40749900 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/canvass.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/email-templates.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/email-templates.png new file mode 100644 index 00000000..4d110c49 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/email-templates.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/index.png new file mode 100644 index 00000000..057c0539 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/influence.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/influence.png new file mode 100644 index 00000000..5f8b33d6 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/influence.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/map.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/map.png new file mode 100644 index 00000000..80357af4 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/map.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/media.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/media.png new file mode 100644 index 00000000..63aaeb85 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/media.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/pages.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/pages.png new file mode 100644 index 00000000..3c6fb3ca Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/pages.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/settings.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/settings.png new file mode 100644 index 00000000..19b86145 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/models/settings.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/database/schema.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/schema.png new file mode 100644 index 00000000..416a420c Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/schema.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/database/seeding.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/seeding.png new file mode 100644 index 00000000..b03955ed Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/database/seeding.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/backup-restore.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/backup-restore.png new file mode 100644 index 00000000..972e7d59 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/backup-restore.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/docker-compose.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/docker-compose.png new file mode 100644 index 00000000..a81f4d9d Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/docker-compose.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/environment-variables.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/environment-variables.png new file mode 100644 index 00000000..586acbd4 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/environment-variables.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/healthchecks.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/healthchecks.png new file mode 100644 index 00000000..7f060d3c Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/healthchecks.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/index.png new file mode 100644 index 00000000..b9137439 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/monitoring-stack.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/monitoring-stack.png new file mode 100644 index 00000000..434bfa03 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/monitoring-stack.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/nginx.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/nginx.png new file mode 100644 index 00000000..fb945d26 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/nginx.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/scaling.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/scaling.png new file mode 100644 index 00000000..d6611e51 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/scaling.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/ssl-tls.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/ssl-tls.png new file mode 100644 index 00000000..d68abac9 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/ssl-tls.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/tunneling.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/tunneling.png new file mode 100644 index 00000000..f2e3b984 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/deployment/tunneling.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/development/code-style.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/code-style.png new file mode 100644 index 00000000..3d6ffdbf Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/code-style.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/development/debugging.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/debugging.png new file mode 100644 index 00000000..1b9cd66d Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/debugging.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/development/docker-workflow.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/docker-workflow.png new file mode 100644 index 00000000..f7c44fee Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/docker-workflow.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/development/git-workflow.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/git-workflow.png new file mode 100644 index 00000000..006f4acb Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/git-workflow.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/development/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/index.png new file mode 100644 index 00000000..1dfe2246 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/development/local-setup.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/local-setup.png new file mode 100644 index 00000000..1d202ca6 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/local-setup.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/development/migrations.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/migrations.png new file mode 100644 index 00000000..5f0410e0 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/migrations.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/development/npm-commands.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/npm-commands.png new file mode 100644 index 00000000..45eb92e2 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/npm-commands.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/development/testing.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/testing.png new file mode 100644 index 00000000..70794f97 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/testing.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/development/typescript.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/typescript.png new file mode 100644 index 00000000..a2b5ca77 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/development/typescript.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/COMPLETION_STATUS.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/COMPLETION_STATUS.png new file mode 100644 index 00000000..0cc1a7ff Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/COMPLETION_STATUS.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/email-templates/editor.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/email-templates/editor.png new file mode 100644 index 00000000..ef694113 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/email-templates/editor.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/email-templates/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/email-templates/index.png new file mode 100644 index 00000000..25b6add8 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/email-templates/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/email-templates/template-system.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/email-templates/template-system.png new file mode 100644 index 00000000..c3d44649 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/email-templates/template-system.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/email-templates/variables.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/email-templates/variables.png new file mode 100644 index 00000000..3ac5dd11 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/email-templates/variables.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/email-templates/versioning.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/email-templates/versioning.png new file mode 100644 index 00000000..0fb97828 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/email-templates/versioning.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/index.png new file mode 100644 index 00000000..54e89144 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/campaigns.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/campaigns.png new file mode 100644 index 00000000..7def2cd1 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/campaigns.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/email-queue.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/email-queue.png new file mode 100644 index 00000000..d72edc9b Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/email-queue.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/index.png new file mode 100644 index 00000000..6a2283a7 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/postal-codes.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/postal-codes.png new file mode 100644 index 00000000..aa6e87f8 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/postal-codes.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/representatives.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/representatives.png new file mode 100644 index 00000000..4d4f24eb Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/representatives.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/responses.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/responses.png new file mode 100644 index 00000000..a939c087 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/influence/responses.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/landing-pages/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/landing-pages/index.png new file mode 100644 index 00000000..374a3168 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/landing-pages/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/MAP_FEATURES_STATUS.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/MAP_FEATURES_STATUS.png new file mode 100644 index 00000000..87f2aebc Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/MAP_FEATURES_STATUS.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/canvassing.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/canvassing.png new file mode 100644 index 00000000..1f52184e Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/canvassing.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/cuts.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/cuts.png new file mode 100644 index 00000000..e1b18378 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/cuts.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/data-quality.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/data-quality.png new file mode 100644 index 00000000..e14ab48e Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/data-quality.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/geocoding.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/geocoding.png new file mode 100644 index 00000000..eccb0704 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/geocoding.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/index.png new file mode 100644 index 00000000..4b378a2b Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/locations.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/locations.png new file mode 100644 index 00000000..513c72f0 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/locations.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/nar-import.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/nar-import.png new file mode 100644 index 00000000..d7c032d6 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/nar-import.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/shifts.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/shifts.png new file mode 100644 index 00000000..e17939ea Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/shifts.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/tracking.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/tracking.png new file mode 100644 index 00000000..a7cf51be Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/tracking.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/walk-sheets.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/walk-sheets.png new file mode 100644 index 00000000..6660d23c Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/map/walk-sheets.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/media/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/media/index.png new file mode 100644 index 00000000..2838f56b Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/media/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/media/jobs.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/media/jobs.png new file mode 100644 index 00000000..0e610fb0 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/media/jobs.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/media/public-gallery.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/media/public-gallery.png new file mode 100644 index 00000000..ee6f1ea6 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/media/public-gallery.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/media/upload.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/media/upload.png new file mode 100644 index 00000000..d9e05543 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/media/upload.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/media/video-library.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/media/video-library.png new file mode 100644 index 00000000..98232304 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/media/video-library.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/newsletter/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/newsletter/index.png new file mode 100644 index 00000000..86bf6eea Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/newsletter/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/observability/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/observability/index.png new file mode 100644 index 00000000..d2c6200d Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/observability/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/pages/block-library.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/pages/block-library.png new file mode 100644 index 00000000..a36c7720 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/pages/block-library.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/pages/grapes-editor.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/pages/grapes-editor.png new file mode 100644 index 00000000..0d6c39f7 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/pages/grapes-editor.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/pages/mkdocs-export.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/pages/mkdocs-export.png new file mode 100644 index 00000000..f4ca0633 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/pages/mkdocs-export.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/pages/page-builder.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/pages/page-builder.png new file mode 100644 index 00000000..2a8dc5d0 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/pages/page-builder.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/features/tunnel/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/tunnel/index.png new file mode 100644 index 00000000..192a327e Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/features/tunnel/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/components/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/components/index.png new file mode 100644 index 00000000..b18b5e28 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/components/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/index.png new file mode 100644 index 00000000..0343912c Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/layouts/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/layouts/index.png new file mode 100644 index 00000000..e7a70f39 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/layouts/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/campaigns-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/campaigns-page.png new file mode 100644 index 00000000..7def2cd1 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/campaigns-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/canvass-dashboard-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/canvass-dashboard-page.png new file mode 100644 index 00000000..7218f26c Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/canvass-dashboard-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/code-editor-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/code-editor-page.png new file mode 100644 index 00000000..54aa0504 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/code-editor-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/cut-export-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/cut-export-page.png new file mode 100644 index 00000000..00892c0a Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/cut-export-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/cuts-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/cuts-page.png new file mode 100644 index 00000000..e1b18378 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/cuts-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/dashboard-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/dashboard-page.png new file mode 100644 index 00000000..9ad145fd Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/dashboard-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/data-quality-dashboard-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/data-quality-dashboard-page.png new file mode 100644 index 00000000..f2e20a53 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/data-quality-dashboard-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/docs-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/docs-page.png new file mode 100644 index 00000000..6226c847 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/docs-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/email-queue-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/email-queue-page.png new file mode 100644 index 00000000..d72edc9b Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/email-queue-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/email-template-editor-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/email-template-editor-page.png new file mode 100644 index 00000000..8c98322a Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/email-template-editor-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/email-templates-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/email-templates-page.png new file mode 100644 index 00000000..25b6add8 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/email-templates-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/gitea-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/gitea-page.png new file mode 100644 index 00000000..360a3262 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/gitea-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/index.png new file mode 100644 index 00000000..96f2bca5 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/landing-pages-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/landing-pages-page.png new file mode 100644 index 00000000..35475cdb Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/landing-pages-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/listmonk-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/listmonk-page.png new file mode 100644 index 00000000..3d70a493 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/listmonk-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/locations-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/locations-page.png new file mode 100644 index 00000000..513c72f0 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/locations-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/mailhog-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/mailhog-page.png new file mode 100644 index 00000000..8195c5fc Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/mailhog-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/map-settings-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/map-settings-page.png new file mode 100644 index 00000000..589e39e8 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/map-settings-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/mini-qr-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/mini-qr-page.png new file mode 100644 index 00000000..c9cda7d3 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/mini-qr-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/mkdocs-settings-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/mkdocs-settings-page.png new file mode 100644 index 00000000..2daa318e Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/mkdocs-settings-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/n8n-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/n8n-page.png new file mode 100644 index 00000000..8024a940 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/n8n-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/nocodb-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/nocodb-page.png new file mode 100644 index 00000000..4c1a3e11 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/nocodb-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/observability-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/observability-page.png new file mode 100644 index 00000000..d62d63be Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/observability-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/page-editor-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/page-editor-page.png new file mode 100644 index 00000000..251d9dd8 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/page-editor-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/pangolin-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/pangolin-page.png new file mode 100644 index 00000000..8b9b1b3f Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/pangolin-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/representatives-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/representatives-page.png new file mode 100644 index 00000000..4d4f24eb Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/representatives-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/responses-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/responses-page.png new file mode 100644 index 00000000..a939c087 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/responses-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/settings-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/settings-page.png new file mode 100644 index 00000000..ea599c10 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/settings-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/shifts-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/shifts-page.png new file mode 100644 index 00000000..e17939ea Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/shifts-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/users-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/users-page.png new file mode 100644 index 00000000..dc55223a Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/users-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/walk-sheet-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/walk-sheet-page.png new file mode 100644 index 00000000..52f11c8b Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/admin/walk-sheet-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/index.png new file mode 100644 index 00000000..8c5f8d03 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/campaign-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/campaign-page.png new file mode 100644 index 00000000..15ef117e Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/campaign-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/campaigns-list-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/campaigns-list-page.png new file mode 100644 index 00000000..347da37b Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/campaigns-list-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/index.png new file mode 100644 index 00000000..b0c07e2b Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/landing-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/landing-page.png new file mode 100644 index 00000000..8efd0441 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/landing-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/map-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/map-page.png new file mode 100644 index 00000000..e0d0cf89 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/map-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/media-gallery-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/media-gallery-page.png new file mode 100644 index 00000000..11c8c8e0 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/media-gallery-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/media-viewer-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/media-viewer-page.png new file mode 100644 index 00000000..bb0ccdc4 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/media-viewer-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/response-wall-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/response-wall-page.png new file mode 100644 index 00000000..0c4aac91 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/response-wall-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/shifts-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/shifts-page.png new file mode 100644 index 00000000..e17939ea Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/public/shifts-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/volunteer/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/volunteer/index.png new file mode 100644 index 00000000..0f6fbdc1 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/volunteer/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/volunteer/my-activity-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/volunteer/my-activity-page.png new file mode 100644 index 00000000..7f625c2a Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/volunteer/my-activity-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/volunteer/my-routes-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/volunteer/my-routes-page.png new file mode 100644 index 00000000..7466d138 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/volunteer/my-routes-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/volunteer/volunteer-map-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/volunteer/volunteer-map-page.png new file mode 100644 index 00000000..74aeec5c Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/volunteer/volunteer-map-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/volunteer/volunteer-shifts-page.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/volunteer/volunteer-shifts-page.png new file mode 100644 index 00000000..c838c7a1 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/frontend/pages/volunteer/volunteer-shifts-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/getting-started/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/getting-started/index.png new file mode 100644 index 00000000..53a12d65 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/getting-started/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/getting-started/quick-start.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/getting-started/quick-start.png new file mode 100644 index 00000000..205b5f93 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/getting-started/quick-start.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/index.png new file mode 100644 index 00000000..4b6ed281 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/migration/api-changes.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/migration/api-changes.png new file mode 100644 index 00000000..1fd61d1e Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/migration/api-changes.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/migration/breaking-changes.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/migration/breaking-changes.png new file mode 100644 index 00000000..eba76473 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/migration/breaking-changes.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/migration/data-migration.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/migration/data-migration.png new file mode 100644 index 00000000..049fb2a1 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/migration/data-migration.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/migration/feature-parity.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/migration/feature-parity.png new file mode 100644 index 00000000..ae9196fc Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/migration/feature-parity.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/migration/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/migration/index.png new file mode 100644 index 00000000..8a2cac0f Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/migration/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/auth-issues.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/auth-issues.png new file mode 100644 index 00000000..384cd38b Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/auth-issues.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/common-errors.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/common-errors.png new file mode 100644 index 00000000..ef1db898 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/common-errors.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/database-issues.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/database-issues.png new file mode 100644 index 00000000..f4af058d Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/database-issues.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/docker-issues.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/docker-issues.png new file mode 100644 index 00000000..1e8e9b07 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/docker-issues.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/email-issues.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/email-issues.png new file mode 100644 index 00000000..00084f5a Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/email-issues.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/faq.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/faq.png new file mode 100644 index 00000000..803ebe7a Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/faq.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/geocoding-issues.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/geocoding-issues.png new file mode 100644 index 00000000..c0dc7445 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/geocoding-issues.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/index.png new file mode 100644 index 00000000..65f2a8ba Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/monitoring-issues.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/monitoring-issues.png new file mode 100644 index 00000000..e7a59a5a Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/monitoring-issues.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/performance-optimization.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/performance-optimization.png new file mode 100644 index 00000000..57fc5472 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/troubleshooting/performance-optimization.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/admin-guide.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/admin-guide.png new file mode 100644 index 00000000..e26c4865 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/admin-guide.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/campaign-manager-guide.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/campaign-manager-guide.png new file mode 100644 index 00000000..e87bff11 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/campaign-manager-guide.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/content-editor-guide.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/content-editor-guide.png new file mode 100644 index 00000000..42f2a188 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/content-editor-guide.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/index.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/index.png new file mode 100644 index 00000000..06697549 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/map-organizer-guide.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/map-organizer-guide.png new file mode 100644 index 00000000..fc4cd8ce Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/map-organizer-guide.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/volunteer-guide.png b/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/volunteer-guide.png new file mode 100644 index 00000000..8657bbc3 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/v2/user-guides/volunteer-guide.png differ diff --git a/mkdocs/.cache/plugin/social/bae6e5d76e4a4ee30a1f912a9b050b3a.png b/mkdocs/.cache/plugin/social/bae6e5d76e4a4ee30a1f912a9b050b3a.png new file mode 100755 index 00000000..3183deb6 Binary files /dev/null and b/mkdocs/.cache/plugin/social/bae6e5d76e4a4ee30a1f912a9b050b3a.png differ diff --git a/mkdocs/.cache/plugin/social/bededf2c367a86f373a8685ce19f8e12.png b/mkdocs/.cache/plugin/social/bededf2c367a86f373a8685ce19f8e12.png new file mode 100755 index 00000000..231ed019 Binary files /dev/null and b/mkdocs/.cache/plugin/social/bededf2c367a86f373a8685ce19f8e12.png differ diff --git a/mkdocs/.cache/plugin/social/c7a42e4b7c6d01803867d237fe2d8617.png b/mkdocs/.cache/plugin/social/c7a42e4b7c6d01803867d237fe2d8617.png new file mode 100755 index 00000000..8f73b796 Binary files /dev/null and b/mkdocs/.cache/plugin/social/c7a42e4b7c6d01803867d237fe2d8617.png differ diff --git a/mkdocs/.cache/plugin/social/ca221d210444f7caca141f1462c1634d.png b/mkdocs/.cache/plugin/social/ca221d210444f7caca141f1462c1634d.png new file mode 100755 index 00000000..ab1f8f49 Binary files /dev/null and b/mkdocs/.cache/plugin/social/ca221d210444f7caca141f1462c1634d.png differ diff --git a/mkdocs/.cache/plugin/social/df46ee213ba7b1f3bde0cf0978cb876f.png b/mkdocs/.cache/plugin/social/df46ee213ba7b1f3bde0cf0978cb876f.png new file mode 100755 index 00000000..0bceeaf8 Binary files /dev/null and b/mkdocs/.cache/plugin/social/df46ee213ba7b1f3bde0cf0978cb876f.png differ diff --git a/mkdocs/.cache/plugin/social/e659a8119de264bb926772b209a5b992.png b/mkdocs/.cache/plugin/social/e659a8119de264bb926772b209a5b992.png new file mode 100755 index 00000000..8f102c19 Binary files /dev/null and b/mkdocs/.cache/plugin/social/e659a8119de264bb926772b209a5b992.png differ diff --git a/mkdocs/.cache/plugin/social/f3cbac41242a5f4062687e6ebf8b69a9.png b/mkdocs/.cache/plugin/social/f3cbac41242a5f4062687e6ebf8b69a9.png new file mode 100755 index 00000000..8c4aaa6e Binary files /dev/null and b/mkdocs/.cache/plugin/social/f3cbac41242a5f4062687e6ebf8b69a9.png differ diff --git a/mkdocs/.cache/plugin/social/fb1ef6eb92689bdb34466fc79a8aebdf.png b/mkdocs/.cache/plugin/social/fb1ef6eb92689bdb34466fc79a8aebdf.png new file mode 100755 index 00000000..118ab818 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fb1ef6eb92689bdb34466fc79a8aebdf.png differ diff --git a/mkdocs/.cache/plugin/social/fd3474c8ee7ae0ad5529def83d0c8857.png b/mkdocs/.cache/plugin/social/fd3474c8ee7ae0ad5529def83d0c8857.png new file mode 100755 index 00000000..5a6e8d0a Binary files /dev/null and b/mkdocs/.cache/plugin/social/fd3474c8ee7ae0ad5529def83d0c8857.png differ diff --git a/mkdocs/.cache/plugin/social/fd4de0e14e62b2216135775537405340.png b/mkdocs/.cache/plugin/social/fd4de0e14e62b2216135775537405340.png new file mode 100755 index 00000000..54e88212 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fd4de0e14e62b2216135775537405340.png differ diff --git a/mkdocs/.cache/plugin/social/ffb13806ab8dc8c1835b9ebf4a4ba450.png b/mkdocs/.cache/plugin/social/ffb13806ab8dc8c1835b9ebf4a4ba450.png new file mode 100755 index 00000000..c9ab7c5f Binary files /dev/null and b/mkdocs/.cache/plugin/social/ffb13806ab8dc8c1835b9ebf4a4ba450.png differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt Black Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Black Italic.ttf new file mode 100755 index 00000000..b33602f1 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Black Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt Black.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Black.ttf new file mode 100755 index 00000000..89673de1 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Black.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt Bold Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Bold Italic.ttf new file mode 100755 index 00000000..0d19c1aa Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Bold Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt Bold.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Bold.ttf new file mode 100755 index 00000000..cd13f60c Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Bold.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt ExtraBold Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt ExtraBold Italic.ttf new file mode 100755 index 00000000..df450629 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt ExtraBold Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt ExtraBold.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt ExtraBold.ttf new file mode 100755 index 00000000..e71c601c Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt ExtraBold.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt ExtraLight Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt ExtraLight Italic.ttf new file mode 100755 index 00000000..275f305e Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt ExtraLight Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt ExtraLight.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt ExtraLight.ttf new file mode 100755 index 00000000..f9c6cfc5 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt ExtraLight.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Italic.ttf new file mode 100755 index 00000000..14d3595b Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt Light Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Light Italic.ttf new file mode 100755 index 00000000..f69e18b0 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Light Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt Light.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Light.ttf new file mode 100755 index 00000000..acae3612 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Light.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt Medium Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Medium Italic.ttf new file mode 100755 index 00000000..5c8c8b14 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Medium Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt Medium.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Medium.ttf new file mode 100755 index 00000000..71d90172 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Medium.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt Regular.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Regular.ttf new file mode 100755 index 00000000..ce097c82 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Regular.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt SemiBold Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt SemiBold Italic.ttf new file mode 100755 index 00000000..d9c9896d Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt SemiBold Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt SemiBold.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt SemiBold.ttf new file mode 100755 index 00000000..053185e5 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt SemiBold.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt Thin Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Thin Italic.ttf new file mode 100755 index 00000000..134e8372 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Thin Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/18pt Thin.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Thin.ttf new file mode 100755 index 00000000..e68ec470 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/18pt Thin.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt Black Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Black Italic.ttf new file mode 100755 index 00000000..b89d61c1 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Black Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt Black.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Black.ttf new file mode 100755 index 00000000..dbb1b3bc Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Black.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt Bold Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Bold Italic.ttf new file mode 100755 index 00000000..d1c0f539 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Bold Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt Bold.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Bold.ttf new file mode 100755 index 00000000..46b3583c Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Bold.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt ExtraBold Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt ExtraBold Italic.ttf new file mode 100755 index 00000000..3461a928 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt ExtraBold Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt ExtraBold.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt ExtraBold.ttf new file mode 100755 index 00000000..b775c084 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt ExtraBold.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt ExtraLight Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt ExtraLight Italic.ttf new file mode 100755 index 00000000..c634a5d2 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt ExtraLight Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt ExtraLight.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt ExtraLight.ttf new file mode 100755 index 00000000..2ec6ca3f Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt ExtraLight.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Italic.ttf new file mode 100755 index 00000000..1048b07a Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt Light Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Light Italic.ttf new file mode 100755 index 00000000..ded5a753 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Light Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt Light.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Light.ttf new file mode 100755 index 00000000..1a2a6f25 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Light.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt Medium Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Medium Italic.ttf new file mode 100755 index 00000000..be091b1d Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Medium Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt Medium.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Medium.ttf new file mode 100755 index 00000000..5c88739b Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Medium.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt Regular.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Regular.ttf new file mode 100755 index 00000000..6b088a71 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Regular.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt SemiBold Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt SemiBold Italic.ttf new file mode 100755 index 00000000..6921df22 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt SemiBold Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt SemiBold.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt SemiBold.ttf new file mode 100755 index 00000000..ceb8576a Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt SemiBold.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt Thin Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Thin Italic.ttf new file mode 100755 index 00000000..a3e6febe Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Thin Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/24pt Thin.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Thin.ttf new file mode 100755 index 00000000..3505b357 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/24pt Thin.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt Black Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Black Italic.ttf new file mode 100755 index 00000000..3c8fdf96 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Black Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt Black.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Black.ttf new file mode 100755 index 00000000..66a252f8 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Black.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt Bold Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Bold Italic.ttf new file mode 100755 index 00000000..6fce50a8 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Bold Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt Bold.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Bold.ttf new file mode 100755 index 00000000..d17828b2 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Bold.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt ExtraBold Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt ExtraBold Italic.ttf new file mode 100755 index 00000000..1a567359 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt ExtraBold Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt ExtraBold.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt ExtraBold.ttf new file mode 100755 index 00000000..6d87caec Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt ExtraBold.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt ExtraLight Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt ExtraLight Italic.ttf new file mode 100755 index 00000000..90e2f20c Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt ExtraLight Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt ExtraLight.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt ExtraLight.ttf new file mode 100755 index 00000000..d42b3f54 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt ExtraLight.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Italic.ttf new file mode 100755 index 00000000..c2a143ac Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt Light Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Light Italic.ttf new file mode 100755 index 00000000..6b90b766 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Light Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt Light.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Light.ttf new file mode 100755 index 00000000..5eeff3a5 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Light.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt Medium Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Medium Italic.ttf new file mode 100755 index 00000000..7481e7ba Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Medium Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt Medium.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Medium.ttf new file mode 100755 index 00000000..00120fe7 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Medium.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt Regular.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Regular.ttf new file mode 100755 index 00000000..855b6f47 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Regular.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt SemiBold Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt SemiBold Italic.ttf new file mode 100755 index 00000000..2e22c5ac Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt SemiBold Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt SemiBold.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt SemiBold.ttf new file mode 100755 index 00000000..8b84efcf Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt SemiBold.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt Thin Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Thin Italic.ttf new file mode 100755 index 00000000..d3d44cdb Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Thin Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/28pt Thin.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Thin.ttf new file mode 100755 index 00000000..94e61089 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/28pt Thin.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/Italic.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/Italic.ttf new file mode 100755 index 00000000..43ed4f5e Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/Italic.ttf differ diff --git a/mkdocs/.cache/plugin/social/fonts/Inter/Regular.ttf b/mkdocs/.cache/plugin/social/fonts/Inter/Regular.ttf new file mode 100755 index 00000000..e31b51e3 Binary files /dev/null and b/mkdocs/.cache/plugin/social/fonts/Inter/Regular.ttf differ diff --git a/mkdocs/.cache/plugin/social/manifest.json b/mkdocs/.cache/plugin/social/manifest.json new file mode 100644 index 00000000..0630d2ba --- /dev/null +++ b/mkdocs/.cache/plugin/social/manifest.json @@ -0,0 +1,241 @@ +{ + "assets/images/social/adv/ansible.png": "cb542ad9a3cc9a869258b3b1353966e1b9616a2b", + "assets/images/social/adv/index.png": "faa3ec092003114c031995ba6258c4d43f4262a4", + "assets/images/social/adv/vscode-ssh.png": "7c88c30c6bfb74736a308407d1a3b7cf3381d42b", + "assets/images/social/another-test-page.png": "ee89e9c2be657984ac4f2ee32191688afccef1cf", + "assets/images/social/blog/2025/07/03/blog-1.png": "83691b572a44afca8430e44b2cf386abfed31097", + "assets/images/social/blog/2025/07/10/2.png": "3c56e17d0c90ca7cd865b064d78c70e875ef70b9", + "assets/images/social/blog/2025/08/01/3.png": "332b80224e75bda48c92439ad6354e7ffcab52e1", + "assets/images/social/blog/2025/09/24/4.png": "840234a707ac182ce6b89203c658b312d03df58e", + "assets/images/social/blog/archive/2025.png": "08cbed159d450158ab4d79807f37adcda08bce39", + "assets/images/social/blog/index.png": "7a5388a2a7e752cce942be0455d9fbf7fa816d12", + "assets/images/social/build/index.png": "30e75040da55694ca6485d51a35fb4d19a26b408", + "assets/images/social/build/influence.png": "2961db1abb5f36d53086bf2bde9dfe19f1487809", + "assets/images/social/build/map.png": "ef5bf7b7e7c3e0e527d66b3d3292918de78b7198", + "assets/images/social/build/server.png": "dd492e5d54e18bee2ce13fd42d2ca647842e63b8", + "assets/images/social/build/site.png": "497fd0955298e23c06e26c91dad9b99e96993af2", + "assets/images/social/config/cloudflare-config.png": "4addb982d2a1bda60be7cc5a5b7f31a42ee81db6", + "assets/images/social/config/coder.png": "f5f93704685a0852de6e634da3ceb3d0fc549897", + "assets/images/social/config/index.png": "1903ccb7f8af2454c97b8a70059119c1527826fb", + "assets/images/social/config/map.png": "322276b5f574c461e2cafc4c5c8e8735bed2d25e", + "assets/images/social/config/mkdocs.png": "b23831844fca01c49624fa378a20453daae66604", + "assets/images/social/how%20to/canvass.png": "83854d6765f418b3cd83651cd8828034d5a5027d", + "assets/images/social/index.png": "e78b3d8cfb2c7529a587def60aaa7e158e0fb176", + "assets/images/social/lander.png": "6ca837e423f4f2e6f786243bddefc3e55ced0818", + "assets/images/social/main.png": "42e4265e567d9c39351f377e4b72c40f808f0bac", + "assets/images/social/manual/index.png": "82996eb5279275b45780cc6db42d91610eac6984", + "assets/images/social/manual/map.png": "322276b5f574c461e2cafc4c5c8e8735bed2d25e", + "assets/images/social/phil/cost-comparison.png": "91217f5184ec32a24c77b88153b8f30740ba68ec", + "assets/images/social/phil/index.png": "f26d709331027166d2fc17f40efc2470f47bd89f", + "assets/images/social/services/code-server.png": "f5f93704685a0852de6e634da3ceb3d0fc549897", + "assets/images/social/services/gitea.png": "128e229b436fb546179ec01395fc8eb33f3fcd44", + "assets/images/social/services/homepage.png": "f3f09ea27d805c62f1b678f1b31f29e659429932", + "assets/images/social/services/index.png": "63f56b81c23973a246b5c8a98aa0ef069407b692", + "assets/images/social/services/listmonk.png": "39793415fd1f2120ae40f57dfa882b3588fb4363", + "assets/images/social/services/map.png": "322276b5f574c461e2cafc4c5c8e8735bed2d25e", + "assets/images/social/services/mini-qr.png": "a528246e8479292da13b4c5d28dc223d9604b1c4", + "assets/images/social/services/mkdocs.png": "66d07b3288d15d8648c2e167c2c124eee19ba8e3", + "assets/images/social/services/n8n.png": "83318ae75b4f0d5134dec68486ff223c9c13beac", + "assets/images/social/services/nocodb.png": "6823aa920c9987d0e64d667fe62065e27cf22784", + "assets/images/social/services/postgresql.png": "831fb68dd3e01d9a017e59b100aaa8a455c8c112", + "assets/images/social/services/static-server.png": "f36d527c80adba4bcb7778784683f429acb4ce74", + "assets/images/social/test-2.png": "a6ae43d52d7c58fc106a562777e03b7da2263f83", + "assets/images/social/test.png": "18d63169d742e6321dc4bb2988b8c5de61e79a28", + "assets/images/social/v1/adv/ansible.png": "cb542ad9a3cc9a869258b3b1353966e1b9616a2b", + "assets/images/social/v1/adv/index.png": "faa3ec092003114c031995ba6258c4d43f4262a4", + "assets/images/social/v1/adv/vscode-ssh.png": "7c88c30c6bfb74736a308407d1a3b7cf3381d42b", + "assets/images/social/v1/build/index.png": "30e75040da55694ca6485d51a35fb4d19a26b408", + "assets/images/social/v1/build/influence.png": "2961db1abb5f36d53086bf2bde9dfe19f1487809", + "assets/images/social/v1/build/map.png": "ef5bf7b7e7c3e0e527d66b3d3292918de78b7198", + "assets/images/social/v1/build/server.png": "dd492e5d54e18bee2ce13fd42d2ca647842e63b8", + "assets/images/social/v1/build/site.png": "497fd0955298e23c06e26c91dad9b99e96993af2", + "assets/images/social/v1/config/cloudflare-config.png": "4addb982d2a1bda60be7cc5a5b7f31a42ee81db6", + "assets/images/social/v1/config/coder.png": "f5f93704685a0852de6e634da3ceb3d0fc549897", + "assets/images/social/v1/config/index.png": "1903ccb7f8af2454c97b8a70059119c1527826fb", + "assets/images/social/v1/config/map.png": "322276b5f574c461e2cafc4c5c8e8735bed2d25e", + "assets/images/social/v1/config/mkdocs.png": "b23831844fca01c49624fa378a20453daae66604", + "assets/images/social/v1/index.png": "a0db6825ffe3e6f526b55f3562bb7e58a5435e13", + "assets/images/social/v1/manual/index.png": "82996eb5279275b45780cc6db42d91610eac6984", + "assets/images/social/v1/manual/map.png": "322276b5f574c461e2cafc4c5c8e8735bed2d25e", + "assets/images/social/v1/services/code-server.png": "f5f93704685a0852de6e634da3ceb3d0fc549897", + "assets/images/social/v1/services/gitea.png": "128e229b436fb546179ec01395fc8eb33f3fcd44", + "assets/images/social/v1/services/homepage.png": "f3f09ea27d805c62f1b678f1b31f29e659429932", + "assets/images/social/v1/services/index.png": "63f56b81c23973a246b5c8a98aa0ef069407b692", + "assets/images/social/v1/services/listmonk.png": "39793415fd1f2120ae40f57dfa882b3588fb4363", + "assets/images/social/v1/services/map.png": "322276b5f574c461e2cafc4c5c8e8735bed2d25e", + "assets/images/social/v1/services/mini-qr.png": "a528246e8479292da13b4c5d28dc223d9604b1c4", + "assets/images/social/v1/services/mkdocs.png": "66d07b3288d15d8648c2e167c2c124eee19ba8e3", + "assets/images/social/v1/services/n8n.png": "83318ae75b4f0d5134dec68486ff223c9c13beac", + "assets/images/social/v1/services/nocodb.png": "6823aa920c9987d0e64d667fe62065e27cf22784", + "assets/images/social/v1/services/postgresql.png": "831fb68dd3e01d9a017e59b100aaa8a455c8c112", + "assets/images/social/v1/services/static-server.png": "f36d527c80adba4bcb7778784683f429acb4ce74", + "assets/images/social/v2/api-reference/index.png": "1b913240b02cf240db46a755ac65102c382aca3f", + "assets/images/social/v2/architecture/authentication.png": "0315336b5668efa978b0c7c4bbcc5bb1833a9070", + "assets/images/social/v2/architecture/dual-api.png": "d405789db149a563fa4797932a74b532ee949a89", + "assets/images/social/v2/architecture/index.png": "71efaaf3cf2e13df0112653f23ba49a56ae41a7b", + "assets/images/social/v2/backend/index.png": "602710f694b54710bf6d04fa53f70a2cf99727e5", + "assets/images/social/v2/backend/middleware/index.png": "87e9c65067dbf57e4d970ba4c312f3b2ab5acc31", + "assets/images/social/v2/backend/modules/auth.png": "e2fe85e0182ad06386ec40e1bc0e2911ec4bf2bf", + "assets/images/social/v2/backend/modules/campaigns.png": "b0a81d103901e8a49871eec6d7ce5f6fca92506f", + "assets/images/social/v2/backend/modules/canvass.png": "09dc2b1be4e61b046198f0380d23115af1be1d93", + "assets/images/social/v2/backend/modules/index.png": "e0a3628dd5f03889d3e0a59162cfa1c89dd43042", + "assets/images/social/v2/backend/modules/locations.png": "f6fbbc9c886ea354c090a85d232379d418c3175b", + "assets/images/social/v2/backend/modules/media.png": "67bebfdf09a4e31928e5897ab5dc5c9ec3abb3aa", + "assets/images/social/v2/backend/modules/pages.png": "69a19dea8eb53a5e02f2b671fd0801f6b11520c9", + "assets/images/social/v2/backend/modules/representatives.png": "a90c8391a902c7d0e34237134342a93593079694", + "assets/images/social/v2/backend/modules/responses.png": "cbf48ac8ed77112acdf421c9024d45ffbad1a480", + "assets/images/social/v2/backend/modules/settings.png": "deb7e793e6d51578252359706e3efbc3ff3e9d7a", + "assets/images/social/v2/backend/modules/shifts.png": "adc405c10de475d53dd7671d1f3cd41ea0d90e4f", + "assets/images/social/v2/backend/modules/users.png": "b821f836b6ab74acc2ce3b10e837b4136163d3be", + "assets/images/social/v2/backend/services/index.png": "24765844ac92cd246a2841903de2ffb5059f65a5", + "assets/images/social/v2/backend/utilities/index.png": "450cf44d1a19c3317a85867298adc817c28647d3", + "assets/images/social/v2/contributing/code-of-conduct.png": "e46d3ae793d350d08918dfd2c1982f1037aaa209", + "assets/images/social/v2/contributing/development-setup.png": "5083b27ae9e86718c7ca91ecca55d068de3e1d16", + "assets/images/social/v2/contributing/index.png": "7fcb54ad400d49e7b45ed16887b533f0349de1fa", + "assets/images/social/v2/contributing/pull-requests.png": "a66cb6cce8d461f85e2d9c0a2ce40c5ce0edb8df", + "assets/images/social/v2/contributing/roadmap.png": "d0a34dcddcce43ea9ae5f61f6856d9102d8f5656", + "assets/images/social/v2/database/index.png": "638de6fb769398a68a92b95a2daf7df6357e2421", + "assets/images/social/v2/database/indexes.png": "c87f131439ddc9312ee8725bedfbe23099fe3a8e", + "assets/images/social/v2/database/migrations.png": "5fa82c1d3c38f4149661438f70cceb7fbf86d01c", + "assets/images/social/v2/database/models/auth.png": "fd6cab7aad9e94bd4875c7c0d8834ea51a87028c", + "assets/images/social/v2/database/models/canvass.png": "528e955044b5260e2193e4d7ad62daab8cb8217f", + "assets/images/social/v2/database/models/email-templates.png": "1eb98ce6b301b6742f279648b20dbc3ac8d7647f", + "assets/images/social/v2/database/models/index.png": "f24da26770ad8793156192c2d41bf18099efd8c8", + "assets/images/social/v2/database/models/influence.png": "4eeb7d7aa7a261d2dab14216d63a808f07e11633", + "assets/images/social/v2/database/models/map.png": "72c1542595aae29655273e2eefc30cdc9a2fbc1a", + "assets/images/social/v2/database/models/media.png": "dd9257cd8a5a2eb8ef5f770efb5b804b23cdee4a", + "assets/images/social/v2/database/models/pages.png": "297b8a2069e4a622e22530b6b630b4a1da505516", + "assets/images/social/v2/database/models/settings.png": "db4b58f2c14227d4788209f7ff8e7c5d344e38cc", + "assets/images/social/v2/database/schema.png": "491821a2777eb06b24858c9fc818a6bdc39cc2a7", + "assets/images/social/v2/database/seeding.png": "527cad97ca0ed2b5150d0e0d8574cee32c83afbf", + "assets/images/social/v2/deployment/backup-restore.png": "3f30feae9d05dbeab4e6215b797f399c1f5629d8", + "assets/images/social/v2/deployment/docker-compose.png": "f80d39dcd6eaad032c6c17fc2ab57ae01bcc6523", + "assets/images/social/v2/deployment/environment-variables.png": "80eaf0b1ad5548ca907c61790e7a66425c698cd7", + "assets/images/social/v2/deployment/healthchecks.png": "f2b978d173bb329f5dcd80b80f24cfe2a2db4292", + "assets/images/social/v2/deployment/index.png": "5f4170374419ee6866d16ac2488c5cd0c7b1fdfb", + "assets/images/social/v2/deployment/monitoring-stack.png": "1b82cb96d038bb8827f70f977e3d9bc9818aa03e", + "assets/images/social/v2/deployment/nginx.png": "dd22be58d662ba007dd2151a84dcf1a4cefb302b", + "assets/images/social/v2/deployment/scaling.png": "010d22ebf61c18b653de015410aa945f1afd298a", + "assets/images/social/v2/deployment/ssl-tls.png": "b509b475cb2a5b35396b7fe09dba9df73810e146", + "assets/images/social/v2/deployment/tunneling.png": "87bc59a16edd8258a273706e56720a6e66c358fd", + "assets/images/social/v2/development/code-style.png": "652bf6c5e80f859f1649abea85686e0f6035867b", + "assets/images/social/v2/development/debugging.png": "82ef18ed55d260c3bc097fe33a1da4f4c280034f", + "assets/images/social/v2/development/docker-workflow.png": "44fc4ca919cf7041df0ff4bae9be0d7c861c7d0c", + "assets/images/social/v2/development/git-workflow.png": "f933f2c3f67e162d3c6aebc96b4c8e43de996f44", + "assets/images/social/v2/development/index.png": "2874a3f796195b2c67967a3c2423c2069522b6f3", + "assets/images/social/v2/development/local-setup.png": "24b56dbd39d62769699ac25dc2c7445fb0ec2aae", + "assets/images/social/v2/development/migrations.png": "5fa82c1d3c38f4149661438f70cceb7fbf86d01c", + "assets/images/social/v2/development/npm-commands.png": "0cf2ace96f26d074f28c2b8e7e2ca69d917f0b9b", + "assets/images/social/v2/development/testing.png": "53d1c9283a3557e3213d9c8cc28a2aa6d6824ce5", + "assets/images/social/v2/development/typescript.png": "505fd21b8692574405d0d37099a2b2e5451d5327", + "assets/images/social/v2/features/COMPLETION_STATUS.png": "67ad5cacb8194038b472076d4c78d201daca722b", + "assets/images/social/v2/features/email-templates/editor.png": "84e6d6659267da755cae3acee92b3bd0bede3e2e", + "assets/images/social/v2/features/email-templates/index.png": "a8e2fe0c56c404260a3fd0e480aa5c84f739a3d3", + "assets/images/social/v2/features/email-templates/template-system.png": "caada7d2773adee469ef19abed234650b19dc7e6", + "assets/images/social/v2/features/email-templates/variables.png": "f15649231b1caf6ee9cff93b4b34202d3d03271c", + "assets/images/social/v2/features/email-templates/versioning.png": "686cd0ec064974e7e8fb1a2f132dc95298e7224f", + "assets/images/social/v2/features/index.png": "4d7ac8d28aeab70aec7683cc29d6bdfd1e19b446", + "assets/images/social/v2/features/influence/campaigns.png": "7c1b368a0fa449b977e4678421e3199a1037cb33", + "assets/images/social/v2/features/influence/email-queue.png": "b7b686b30c93c0f37639385f515e27c0533266ad", + "assets/images/social/v2/features/influence/index.png": "258632ac5786822947292066cdfa3e5b41129b9d", + "assets/images/social/v2/features/influence/postal-codes.png": "8b26622c10c1e52a6118884b9811589f0589a26a", + "assets/images/social/v2/features/influence/representatives.png": "a0a510e5bbc00f8e270eb53ae8852e3765a29038", + "assets/images/social/v2/features/influence/responses.png": "f48aa0032bca318b316f85dd5f1428d7e34efffb", + "assets/images/social/v2/features/landing-pages/index.png": "feada14b627b5ab2647197a606614c0b3f50fc61", + "assets/images/social/v2/features/map/MAP_FEATURES_STATUS.png": "0dd53653ce92a5e7e866087873f0933625984235", + "assets/images/social/v2/features/map/canvassing.png": "62cddd55ce4f66d98ad88e969af256e4c7a4e9d4", + "assets/images/social/v2/features/map/cuts.png": "d72ac330662597c6b686f357df851df622f009eb", + "assets/images/social/v2/features/map/data-quality.png": "ca208736fe16afd9f2dac2a9246861418f3d06cc", + "assets/images/social/v2/features/map/geocoding.png": "52b06b69718eff7639fc92de02b3df7ab70b07ec", + "assets/images/social/v2/features/map/index.png": "b33a0c5326051f291e233592b2c67bb33fdb98d1", + "assets/images/social/v2/features/map/locations.png": "f6429b56e15eefcb2e481dd964702aa2c10ec67f", + "assets/images/social/v2/features/map/nar-import.png": "33be41bb7591da246a3797eafbf989ebd7139650", + "assets/images/social/v2/features/map/shifts.png": "8cccad1c9079998997ff8f84c453f7d836e850d7", + "assets/images/social/v2/features/map/tracking.png": "72408bb968892ecfe7dbb768c92494934f4054d9", + "assets/images/social/v2/features/map/walk-sheets.png": "ceb5e67238fdef9251c001606f8aef79eb5b1ead", + "assets/images/social/v2/features/media/index.png": "0140c5a8fb90f9a103e8da49c57a4215b594a79b", + "assets/images/social/v2/features/media/jobs.png": "0694f86b1e8d97d200cfe6ae4444162bb678d2d0", + "assets/images/social/v2/features/media/public-gallery.png": "dcdddb516a05ed34a31168bf483cb80f91b7f7a0", + "assets/images/social/v2/features/media/upload.png": "9eca977b3fd78ea534f0e73e043bc6a937655818", + "assets/images/social/v2/features/media/video-library.png": "3d02fcc28cf2a25af429feea38a608efdcfbb158", + "assets/images/social/v2/features/newsletter/index.png": "11ff73b46e4d25ce74dac964fefd9acd3f0ad6ca", + "assets/images/social/v2/features/observability/index.png": "78eb7e761f6a00594bbbac7a11419468086281a8", + "assets/images/social/v2/features/pages/block-library.png": "fc6ad5a039442d94d64a5ad5b8af559719e5ac33", + "assets/images/social/v2/features/pages/grapes-editor.png": "dea6d7c5ae8961b278c3494805d6352d73ab7980", + "assets/images/social/v2/features/pages/mkdocs-export.png": "cfb8e280e4917094f9be6bc1bf884fe0d332106e", + "assets/images/social/v2/features/pages/page-builder.png": "aa059dbfee0336ff6f320d699a6c97046a5971b2", + "assets/images/social/v2/features/tunnel/index.png": "af7d2e3f68336f622bda965893e4f30276131239", + "assets/images/social/v2/frontend/components/index.png": "864a2fdfb68d96e0a219f2e5b77cb78f4e41643f", + "assets/images/social/v2/frontend/index.png": "9630968e902f71b7fb116297222029de4a1c91af", + "assets/images/social/v2/frontend/layouts/index.png": "5e896da07d9db490998d92d299e628bcdd1c877e", + "assets/images/social/v2/frontend/pages/admin/campaigns-page.png": "7c1b368a0fa449b977e4678421e3199a1037cb33", + "assets/images/social/v2/frontend/pages/admin/canvass-dashboard-page.png": "61021329651bd89bf30a0e07a527843a50c3b1d3", + "assets/images/social/v2/frontend/pages/admin/code-editor-page.png": "5a166dc09a5c983a0e68afa185f853e99824d3f8", + "assets/images/social/v2/frontend/pages/admin/cut-export-page.png": "d5dd888ad2ba937416317064f98ce75f9c8bdc10", + "assets/images/social/v2/frontend/pages/admin/cuts-page.png": "d72ac330662597c6b686f357df851df622f009eb", + "assets/images/social/v2/frontend/pages/admin/dashboard-page.png": "04158b5ff5abaf319c04eb6eaf1a3164622e36ca", + "assets/images/social/v2/frontend/pages/admin/data-quality-dashboard-page.png": "d7d1c8f7daab64b7f2357402ac34d4d4b3cbb4cf", + "assets/images/social/v2/frontend/pages/admin/docs-page.png": "ecdd776b66fbba2a9b753bfff2b44081e0d2b520", + "assets/images/social/v2/frontend/pages/admin/email-queue-page.png": "b7b686b30c93c0f37639385f515e27c0533266ad", + "assets/images/social/v2/frontend/pages/admin/email-template-editor-page.png": "30dc630f766252490f4457bb45869d1930b0d1b2", + "assets/images/social/v2/frontend/pages/admin/email-templates-page.png": "a8e2fe0c56c404260a3fd0e480aa5c84f739a3d3", + "assets/images/social/v2/frontend/pages/admin/gitea-page.png": "128e229b436fb546179ec01395fc8eb33f3fcd44", + "assets/images/social/v2/frontend/pages/admin/index.png": "1565cb72a78e1496ccb6de1decb94051a9d3bc6c", + "assets/images/social/v2/frontend/pages/admin/landing-pages-page.png": "0a2fb5e475e57552f8dc6ca4201ea7ba707dc7ce", + "assets/images/social/v2/frontend/pages/admin/listmonk-page.png": "39793415fd1f2120ae40f57dfa882b3588fb4363", + "assets/images/social/v2/frontend/pages/admin/locations-page.png": "f6429b56e15eefcb2e481dd964702aa2c10ec67f", + "assets/images/social/v2/frontend/pages/admin/mailhog-page.png": "7b49df146ac51dfa8a2564f9dec1e6c0c1987c57", + "assets/images/social/v2/frontend/pages/admin/map-settings-page.png": "e8ef5d088717e7753d94c232efab5486649a3513", + "assets/images/social/v2/frontend/pages/admin/mini-qr-page.png": "a528246e8479292da13b4c5d28dc223d9604b1c4", + "assets/images/social/v2/frontend/pages/admin/mkdocs-settings-page.png": "a1b194db04e60b616dc960fe541ca07030acb6da", + "assets/images/social/v2/frontend/pages/admin/n8n-page.png": "83318ae75b4f0d5134dec68486ff223c9c13beac", + "assets/images/social/v2/frontend/pages/admin/nocodb-page.png": "6823aa920c9987d0e64d667fe62065e27cf22784", + "assets/images/social/v2/frontend/pages/admin/observability-page.png": "d19149123423c5bef9b8facaa4074b6ae2e04cde", + "assets/images/social/v2/frontend/pages/admin/page-editor-page.png": "6790b9cc33393ca5552f8ed44fddf0a7a7f70738", + "assets/images/social/v2/frontend/pages/admin/pangolin-page.png": "7c8c3e3a3b0b25e40d4fc7ca9c9a6fefd61d1023", + "assets/images/social/v2/frontend/pages/admin/representatives-page.png": "a0a510e5bbc00f8e270eb53ae8852e3765a29038", + "assets/images/social/v2/frontend/pages/admin/responses-page.png": "f48aa0032bca318b316f85dd5f1428d7e34efffb", + "assets/images/social/v2/frontend/pages/admin/settings-page.png": "d82251feb753d4c613e24b0aeb112c0330f1458a", + "assets/images/social/v2/frontend/pages/admin/shifts-page.png": "8cccad1c9079998997ff8f84c453f7d836e850d7", + "assets/images/social/v2/frontend/pages/admin/users-page.png": "7a4c03879fd6750b8b18230710581cf515237510", + "assets/images/social/v2/frontend/pages/admin/walk-sheet-page.png": "3f865540a8189193874065df6bf507404d230003", + "assets/images/social/v2/frontend/pages/index.png": "1656aba5a5ec34deb650b985ac22e3c3d1594522", + "assets/images/social/v2/frontend/pages/public/campaign-page.png": "e22e0370c323beb4eefaa4d2d4b21d27d29c536f", + "assets/images/social/v2/frontend/pages/public/campaigns-list-page.png": "d78184dc16cf86e1c7641264d6df8caa1449c7aa", + "assets/images/social/v2/frontend/pages/public/index.png": "91bd4cce5473d2a609448532ed033f1783efa750", + "assets/images/social/v2/frontend/pages/public/landing-page.png": "ae57ce1ab5ec3a5f8795a74a948b085f8bab4eea", + "assets/images/social/v2/frontend/pages/public/map-page.png": "322276b5f574c461e2cafc4c5c8e8735bed2d25e", + "assets/images/social/v2/frontend/pages/public/media-gallery-page.png": "f74aa1a478c120d6138ed68b4c2f2308294de124", + "assets/images/social/v2/frontend/pages/public/media-viewer-page.png": "a3f283488fd58f1dde456bd2ee66fc044a7ffa8f", + "assets/images/social/v2/frontend/pages/public/response-wall-page.png": "e67c6c336083f9066e72b5c1820d578de71c845b", + "assets/images/social/v2/frontend/pages/public/shifts-page.png": "8cccad1c9079998997ff8f84c453f7d836e850d7", + "assets/images/social/v2/frontend/pages/volunteer/index.png": "709b13b04ffc8fe05b18f5e1430bbb8ceacf72a4", + "assets/images/social/v2/frontend/pages/volunteer/my-activity-page.png": "c2215f2e487d8a440729c53c63c076642986104c", + "assets/images/social/v2/frontend/pages/volunteer/my-routes-page.png": "425b6df7a352f235003945d5d8438022dca1417a", + "assets/images/social/v2/frontend/pages/volunteer/volunteer-map-page.png": "0e2929593a6e4553899afe787b0cb0e718b2cd3c", + "assets/images/social/v2/frontend/pages/volunteer/volunteer-shifts-page.png": "5ba4b35943388fe98304d54057b064eb521cd1c1", + "assets/images/social/v2/getting-started/index.png": "48b8aab5634f793d8f291671d997849128e0d01d", + "assets/images/social/v2/getting-started/quick-start.png": "b440db6881fe7594783d54f5143f5021c28e4200", + "assets/images/social/v2/index.png": "45aa860cde316fce0e393327dd30a40ebc3f1d7a", + "assets/images/social/v2/migration/api-changes.png": "887e9de34f537f01537ec9fa3af455ee0440347f", + "assets/images/social/v2/migration/breaking-changes.png": "a106d83810e2e8a2171203c920f05ae926ee531e", + "assets/images/social/v2/migration/data-migration.png": "b14b90523d904af0a47285304059d674eb820b6e", + "assets/images/social/v2/migration/feature-parity.png": "3e475c6d78e0583d120a1680220825b5a17cef07", + "assets/images/social/v2/migration/index.png": "49fef688eb22d1e9156d8c47419f9be34129e557", + "assets/images/social/v2/troubleshooting/auth-issues.png": "ec0f9bb6b91f89f2ce00f519c6d206332718b546", + "assets/images/social/v2/troubleshooting/common-errors.png": "1e6e122e82e511f8ba38308ae1d7027a4a3c6527", + "assets/images/social/v2/troubleshooting/database-issues.png": "d42966efea9409c98d14fe1dc91ac7900c41b362", + "assets/images/social/v2/troubleshooting/docker-issues.png": "0fcb067bb08143e5931830c24343a985f6eaf4d7", + "assets/images/social/v2/troubleshooting/email-issues.png": "a1062259dfad903a6c461b61927c097b258ede07", + "assets/images/social/v2/troubleshooting/faq.png": "caa4b5be7ff190cb4b1b5a00be307c499662fc5e", + "assets/images/social/v2/troubleshooting/geocoding-issues.png": "5945ce4615d38c6a6b7d35df3a48618c3548537e", + "assets/images/social/v2/troubleshooting/index.png": "410fddd80ddcb704e30fc544fb561a191afbd515", + "assets/images/social/v2/troubleshooting/monitoring-issues.png": "498bd3ea04dc4dedfc89609786726a70da47bc14", + "assets/images/social/v2/troubleshooting/performance-optimization.png": "b25abb1e2b6ea63a3fc454d40b1194016c899e0c", + "assets/images/social/v2/user-guides/admin-guide.png": "c8eab3e3849c5705ab6eb90a5769b923019655c0", + "assets/images/social/v2/user-guides/campaign-manager-guide.png": "05d4512f4ef0e51d6d694fe8c380e925f8e8110f", + "assets/images/social/v2/user-guides/content-editor-guide.png": "50bdfa222f526c9c7d18a939321a6632bc6e6e4f", + "assets/images/social/v2/user-guides/index.png": "f397607b1a0a79cc58e5437b250488b87e7dd287", + "assets/images/social/v2/user-guides/map-organizer-guide.png": "850844077cb73531eb705be1c3636ba9010b70dc", + "assets/images/social/v2/user-guides/volunteer-guide.png": "1db27f29731379963524fb2110003827c9d5943d" +} \ No newline at end of file diff --git a/mkdocs/site/404.html b/mkdocs/site/404.html new file mode 100644 index 00000000..37c67a5a --- /dev/null +++ b/mkdocs/site/404.html @@ -0,0 +1,810 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + +
+ +

404 - Not found

+ +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/assets/built.png b/mkdocs/site/assets/built.png new file mode 100644 index 00000000..be3081eb Binary files /dev/null and b/mkdocs/site/assets/built.png differ diff --git a/mkdocs/site/assets/coder_square.png b/mkdocs/site/assets/coder_square.png new file mode 100644 index 00000000..c14e8009 Binary files /dev/null and b/mkdocs/site/assets/coder_square.png differ diff --git a/mkdocs/site/assets/css/video-player.css b/mkdocs/site/assets/css/video-player.css new file mode 100644 index 00000000..3f4f2fc4 --- /dev/null +++ b/mkdocs/site/assets/css/video-player.css @@ -0,0 +1,161 @@ +/** + * Video Player Styles for MkDocs + * + * Ensures video players from landing pages render properly in documentation + */ + +/* Video block container */ +.video-block { + margin: 2rem auto; + max-width: 100%; +} + +/* Advanced video container */ +.advanced-video-container { + margin: 0 auto; +} + +/* Video element styling */ +.video-block video, +.advanced-video-container video { + width: 100%; + height: auto; + max-width: 100%; + border-radius: 8px; + display: block; + background: #000; +} + +/* Video metadata */ +.video-meta { + margin-top: 0.5rem; + font-size: 0.875rem; + color: rgba(0, 0, 0, 0.6); +} + +[data-md-color-scheme="slate"] .video-meta { + color: rgba(255, 255, 255, 0.6); +} + +/* Video reactions bar */ +.video-reactions { + margin-top: 0.75rem; + padding: 0.75rem; + background: rgba(0, 0, 0, 0.03); + border-radius: 8px; + text-align: center; + font-size: 0.875rem; +} + +[data-md-color-scheme="slate"] .video-reactions { + background: rgba(255, 255, 255, 0.05); +} + +.video-reactions a { + color: var(--md-primary-fg-color); + text-decoration: none; + font-weight: 500; +} + +.video-reactions a:hover { + text-decoration: underline; +} + +/* Loading state */ +.video-loading { + padding: 2.5rem; + text-align: center; + color: rgba(0, 0, 0, 0.45); + font-size: 0.875rem; +} + +[data-md-color-scheme="slate"] .video-loading { + color: rgba(255, 255, 255, 0.45); +} + +/* Error state */ +.video-error { + padding: 2.5rem; + text-align: center; + background: #fff3f3; + border: 1px solid #ffccc7; + border-radius: 8px; + color: #cf1322; +} + +[data-md-color-scheme="slate"] .video-error { + background: rgba(207, 19, 34, 0.1); + border-color: rgba(207, 19, 34, 0.3); +} + +.video-error svg { + width: 48px; + height: 48px; + margin-bottom: 0.75rem; +} + +.video-error p { + margin: 0; + font-weight: 600; +} + +/* Responsive video containers */ +@media (max-width: 768px) { + .video-block, + .advanced-video-container { + margin: 1.5rem auto; + } + + .video-block video, + .advanced-video-container video { + border-radius: 4px; + } +} + +/* Video placeholder (shown during GrapesJS export before hydration) */ +.video-placeholder { + aspect-ratio: 16/9; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + color: #fff; + padding: 1.5rem; + text-align: center; +} + +.video-placeholder svg { + width: 64px; + height: 64px; + margin-bottom: 1rem; + opacity: 0.9; +} + +.video-placeholder p { + margin: 0; +} + +/* Ensure videos respect content width in Material theme */ +.md-content .video-block, +.md-content .advanced-video-container { + max-width: 100%; +} + +/* Video controls customization */ +video::-webkit-media-controls-panel { + background: rgba(0, 0, 0, 0.8); +} + +video::-webkit-media-controls-play-button { + border-radius: 50%; +} + +/* Accessibility: Focus styles */ +.video-block video:focus, +.advanced-video-container video:focus { + outline: 2px solid var(--md-primary-fg-color); + outline-offset: 2px; +} diff --git a/mkdocs/site/assets/favicon.png b/mkdocs/site/assets/favicon.png new file mode 100644 index 00000000..6b010504 Binary files /dev/null and b/mkdocs/site/assets/favicon.png differ diff --git a/mkdocs/site/assets/homepage_square.png b/mkdocs/site/assets/homepage_square.png new file mode 100644 index 00000000..b360a966 Binary files /dev/null and b/mkdocs/site/assets/homepage_square.png differ diff --git a/mkdocs/site/assets/images/favicon.png b/mkdocs/site/assets/images/favicon.png new file mode 100644 index 00000000..1cf13b9f Binary files /dev/null and b/mkdocs/site/assets/images/favicon.png differ diff --git a/mkdocs/site/assets/images/social/blog/2025/07/03/blog-1.png b/mkdocs/site/assets/images/social/blog/2025/07/03/blog-1.png new file mode 100644 index 00000000..561c970f Binary files /dev/null and b/mkdocs/site/assets/images/social/blog/2025/07/03/blog-1.png differ diff --git a/mkdocs/site/assets/images/social/blog/2025/07/10/2.png b/mkdocs/site/assets/images/social/blog/2025/07/10/2.png new file mode 100644 index 00000000..86e512e3 Binary files /dev/null and b/mkdocs/site/assets/images/social/blog/2025/07/10/2.png differ diff --git a/mkdocs/site/assets/images/social/blog/2025/08/01/3.png b/mkdocs/site/assets/images/social/blog/2025/08/01/3.png new file mode 100644 index 00000000..21f416a2 Binary files /dev/null and b/mkdocs/site/assets/images/social/blog/2025/08/01/3.png differ diff --git a/mkdocs/site/assets/images/social/blog/2025/09/24/4.png b/mkdocs/site/assets/images/social/blog/2025/09/24/4.png new file mode 100644 index 00000000..0400f068 Binary files /dev/null and b/mkdocs/site/assets/images/social/blog/2025/09/24/4.png differ diff --git a/mkdocs/site/assets/images/social/blog/archive/2025.png b/mkdocs/site/assets/images/social/blog/archive/2025.png new file mode 100644 index 00000000..424420cd Binary files /dev/null and b/mkdocs/site/assets/images/social/blog/archive/2025.png differ diff --git a/mkdocs/site/assets/images/social/blog/index.png b/mkdocs/site/assets/images/social/blog/index.png new file mode 100644 index 00000000..bdb7a3c9 Binary files /dev/null and b/mkdocs/site/assets/images/social/blog/index.png differ diff --git a/mkdocs/site/assets/images/social/how to/canvass.png b/mkdocs/site/assets/images/social/how to/canvass.png new file mode 100644 index 00000000..88742615 Binary files /dev/null and b/mkdocs/site/assets/images/social/how to/canvass.png differ diff --git a/mkdocs/site/assets/images/social/index.png b/mkdocs/site/assets/images/social/index.png new file mode 100644 index 00000000..898613d8 Binary files /dev/null and b/mkdocs/site/assets/images/social/index.png differ diff --git a/mkdocs/site/assets/images/social/lander.png b/mkdocs/site/assets/images/social/lander.png new file mode 100644 index 00000000..dda85385 Binary files /dev/null and b/mkdocs/site/assets/images/social/lander.png differ diff --git a/mkdocs/site/assets/images/social/phil/cost-comparison.png b/mkdocs/site/assets/images/social/phil/cost-comparison.png new file mode 100644 index 00000000..cb5e6395 Binary files /dev/null and b/mkdocs/site/assets/images/social/phil/cost-comparison.png differ diff --git a/mkdocs/site/assets/images/social/phil/index.png b/mkdocs/site/assets/images/social/phil/index.png new file mode 100644 index 00000000..093c05ee Binary files /dev/null and b/mkdocs/site/assets/images/social/phil/index.png differ diff --git a/mkdocs/site/assets/images/social/v1/adv/ansible.png b/mkdocs/site/assets/images/social/v1/adv/ansible.png new file mode 100644 index 00000000..b206a422 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/adv/ansible.png differ diff --git a/mkdocs/site/assets/images/social/v1/adv/index.png b/mkdocs/site/assets/images/social/v1/adv/index.png new file mode 100644 index 00000000..22d0962c Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/adv/index.png differ diff --git a/mkdocs/site/assets/images/social/v1/adv/vscode-ssh.png b/mkdocs/site/assets/images/social/v1/adv/vscode-ssh.png new file mode 100644 index 00000000..1bed8ac4 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/adv/vscode-ssh.png differ diff --git a/mkdocs/site/assets/images/social/v1/build/index.png b/mkdocs/site/assets/images/social/v1/build/index.png new file mode 100644 index 00000000..dee06e08 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/build/index.png differ diff --git a/mkdocs/site/assets/images/social/v1/build/influence.png b/mkdocs/site/assets/images/social/v1/build/influence.png new file mode 100644 index 00000000..c3749f5e Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/build/influence.png differ diff --git a/mkdocs/site/assets/images/social/v1/build/map.png b/mkdocs/site/assets/images/social/v1/build/map.png new file mode 100644 index 00000000..ad2b50a8 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/build/map.png differ diff --git a/mkdocs/site/assets/images/social/v1/build/server.png b/mkdocs/site/assets/images/social/v1/build/server.png new file mode 100644 index 00000000..e9285987 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/build/server.png differ diff --git a/mkdocs/site/assets/images/social/v1/build/site.png b/mkdocs/site/assets/images/social/v1/build/site.png new file mode 100644 index 00000000..2aa79881 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/build/site.png differ diff --git a/mkdocs/site/assets/images/social/v1/config/cloudflare-config.png b/mkdocs/site/assets/images/social/v1/config/cloudflare-config.png new file mode 100644 index 00000000..a366df4a Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/config/cloudflare-config.png differ diff --git a/mkdocs/site/assets/images/social/v1/config/coder.png b/mkdocs/site/assets/images/social/v1/config/coder.png new file mode 100644 index 00000000..0a07ad37 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/config/coder.png differ diff --git a/mkdocs/site/assets/images/social/v1/config/index.png b/mkdocs/site/assets/images/social/v1/config/index.png new file mode 100644 index 00000000..86a363df Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/config/index.png differ diff --git a/mkdocs/site/assets/images/social/v1/config/map.png b/mkdocs/site/assets/images/social/v1/config/map.png new file mode 100644 index 00000000..e0d0cf89 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/config/map.png differ diff --git a/mkdocs/site/assets/images/social/v1/config/mkdocs.png b/mkdocs/site/assets/images/social/v1/config/mkdocs.png new file mode 100644 index 00000000..d8522518 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/config/mkdocs.png differ diff --git a/mkdocs/site/assets/images/social/v1/index.png b/mkdocs/site/assets/images/social/v1/index.png new file mode 100644 index 00000000..35dd0f0b Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/index.png differ diff --git a/mkdocs/site/assets/images/social/v1/manual/index.png b/mkdocs/site/assets/images/social/v1/manual/index.png new file mode 100644 index 00000000..98ecad86 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/manual/index.png differ diff --git a/mkdocs/site/assets/images/social/v1/manual/map.png b/mkdocs/site/assets/images/social/v1/manual/map.png new file mode 100644 index 00000000..e0d0cf89 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/manual/map.png differ diff --git a/mkdocs/site/assets/images/social/v1/services/code-server.png b/mkdocs/site/assets/images/social/v1/services/code-server.png new file mode 100644 index 00000000..0a07ad37 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/services/code-server.png differ diff --git a/mkdocs/site/assets/images/social/v1/services/gitea.png b/mkdocs/site/assets/images/social/v1/services/gitea.png new file mode 100644 index 00000000..360a3262 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/services/gitea.png differ diff --git a/mkdocs/site/assets/images/social/v1/services/homepage.png b/mkdocs/site/assets/images/social/v1/services/homepage.png new file mode 100644 index 00000000..7254007d Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/services/homepage.png differ diff --git a/mkdocs/site/assets/images/social/v1/services/index.png b/mkdocs/site/assets/images/social/v1/services/index.png new file mode 100644 index 00000000..b05748be Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/services/index.png differ diff --git a/mkdocs/site/assets/images/social/v1/services/listmonk.png b/mkdocs/site/assets/images/social/v1/services/listmonk.png new file mode 100644 index 00000000..3d70a493 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/services/listmonk.png differ diff --git a/mkdocs/site/assets/images/social/v1/services/map.png b/mkdocs/site/assets/images/social/v1/services/map.png new file mode 100644 index 00000000..e0d0cf89 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/services/map.png differ diff --git a/mkdocs/site/assets/images/social/v1/services/mini-qr.png b/mkdocs/site/assets/images/social/v1/services/mini-qr.png new file mode 100644 index 00000000..c9cda7d3 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/services/mini-qr.png differ diff --git a/mkdocs/site/assets/images/social/v1/services/mkdocs.png b/mkdocs/site/assets/images/social/v1/services/mkdocs.png new file mode 100644 index 00000000..959b0d24 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/services/mkdocs.png differ diff --git a/mkdocs/site/assets/images/social/v1/services/n8n.png b/mkdocs/site/assets/images/social/v1/services/n8n.png new file mode 100644 index 00000000..8024a940 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/services/n8n.png differ diff --git a/mkdocs/site/assets/images/social/v1/services/nocodb.png b/mkdocs/site/assets/images/social/v1/services/nocodb.png new file mode 100644 index 00000000..4c1a3e11 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/services/nocodb.png differ diff --git a/mkdocs/site/assets/images/social/v1/services/postgresql.png b/mkdocs/site/assets/images/social/v1/services/postgresql.png new file mode 100644 index 00000000..da243c04 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/services/postgresql.png differ diff --git a/mkdocs/site/assets/images/social/v1/services/static-server.png b/mkdocs/site/assets/images/social/v1/services/static-server.png new file mode 100644 index 00000000..dddfe447 Binary files /dev/null and b/mkdocs/site/assets/images/social/v1/services/static-server.png differ diff --git a/mkdocs/site/assets/images/social/v2/api-reference/index.png b/mkdocs/site/assets/images/social/v2/api-reference/index.png new file mode 100644 index 00000000..05083266 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/api-reference/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/architecture/authentication.png b/mkdocs/site/assets/images/social/v2/architecture/authentication.png new file mode 100644 index 00000000..04d72f9e Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/architecture/authentication.png differ diff --git a/mkdocs/site/assets/images/social/v2/architecture/dual-api.png b/mkdocs/site/assets/images/social/v2/architecture/dual-api.png new file mode 100644 index 00000000..89f4a3d5 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/architecture/dual-api.png differ diff --git a/mkdocs/site/assets/images/social/v2/architecture/index.png b/mkdocs/site/assets/images/social/v2/architecture/index.png new file mode 100644 index 00000000..69e8040b Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/architecture/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/backend/index.png b/mkdocs/site/assets/images/social/v2/backend/index.png new file mode 100644 index 00000000..fee34ac0 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/backend/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/backend/middleware/index.png b/mkdocs/site/assets/images/social/v2/backend/middleware/index.png new file mode 100644 index 00000000..23e33590 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/backend/middleware/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/backend/modules/auth.png b/mkdocs/site/assets/images/social/v2/backend/modules/auth.png new file mode 100644 index 00000000..1007098a Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/backend/modules/auth.png differ diff --git a/mkdocs/site/assets/images/social/v2/backend/modules/campaigns.png b/mkdocs/site/assets/images/social/v2/backend/modules/campaigns.png new file mode 100644 index 00000000..03974eba Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/backend/modules/campaigns.png differ diff --git a/mkdocs/site/assets/images/social/v2/backend/modules/canvass.png b/mkdocs/site/assets/images/social/v2/backend/modules/canvass.png new file mode 100644 index 00000000..165edb0f Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/backend/modules/canvass.png differ diff --git a/mkdocs/site/assets/images/social/v2/backend/modules/index.png b/mkdocs/site/assets/images/social/v2/backend/modules/index.png new file mode 100644 index 00000000..0281ab0d Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/backend/modules/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/backend/modules/locations.png b/mkdocs/site/assets/images/social/v2/backend/modules/locations.png new file mode 100644 index 00000000..b2859bc7 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/backend/modules/locations.png differ diff --git a/mkdocs/site/assets/images/social/v2/backend/modules/media.png b/mkdocs/site/assets/images/social/v2/backend/modules/media.png new file mode 100644 index 00000000..9b0fea9f Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/backend/modules/media.png differ diff --git a/mkdocs/site/assets/images/social/v2/backend/modules/pages.png b/mkdocs/site/assets/images/social/v2/backend/modules/pages.png new file mode 100644 index 00000000..a9994402 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/backend/modules/pages.png differ diff --git a/mkdocs/site/assets/images/social/v2/backend/modules/representatives.png b/mkdocs/site/assets/images/social/v2/backend/modules/representatives.png new file mode 100644 index 00000000..3eae706a Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/backend/modules/representatives.png differ diff --git a/mkdocs/site/assets/images/social/v2/backend/modules/responses.png b/mkdocs/site/assets/images/social/v2/backend/modules/responses.png new file mode 100644 index 00000000..11dbaf6d Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/backend/modules/responses.png differ diff --git a/mkdocs/site/assets/images/social/v2/backend/modules/settings.png b/mkdocs/site/assets/images/social/v2/backend/modules/settings.png new file mode 100644 index 00000000..d5390802 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/backend/modules/settings.png differ diff --git a/mkdocs/site/assets/images/social/v2/backend/modules/shifts.png b/mkdocs/site/assets/images/social/v2/backend/modules/shifts.png new file mode 100644 index 00000000..8a926627 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/backend/modules/shifts.png differ diff --git a/mkdocs/site/assets/images/social/v2/backend/modules/users.png b/mkdocs/site/assets/images/social/v2/backend/modules/users.png new file mode 100644 index 00000000..34dbd2e5 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/backend/modules/users.png differ diff --git a/mkdocs/site/assets/images/social/v2/backend/services/index.png b/mkdocs/site/assets/images/social/v2/backend/services/index.png new file mode 100644 index 00000000..913aa7d8 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/backend/services/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/backend/utilities/index.png b/mkdocs/site/assets/images/social/v2/backend/utilities/index.png new file mode 100644 index 00000000..1db564f2 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/backend/utilities/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/contributing/code-of-conduct.png b/mkdocs/site/assets/images/social/v2/contributing/code-of-conduct.png new file mode 100644 index 00000000..3b7814a5 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/contributing/code-of-conduct.png differ diff --git a/mkdocs/site/assets/images/social/v2/contributing/development-setup.png b/mkdocs/site/assets/images/social/v2/contributing/development-setup.png new file mode 100644 index 00000000..c2dd796d Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/contributing/development-setup.png differ diff --git a/mkdocs/site/assets/images/social/v2/contributing/index.png b/mkdocs/site/assets/images/social/v2/contributing/index.png new file mode 100644 index 00000000..c8a8a201 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/contributing/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/contributing/pull-requests.png b/mkdocs/site/assets/images/social/v2/contributing/pull-requests.png new file mode 100644 index 00000000..75fd4937 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/contributing/pull-requests.png differ diff --git a/mkdocs/site/assets/images/social/v2/contributing/roadmap.png b/mkdocs/site/assets/images/social/v2/contributing/roadmap.png new file mode 100644 index 00000000..3f4ac590 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/contributing/roadmap.png differ diff --git a/mkdocs/site/assets/images/social/v2/database/index.png b/mkdocs/site/assets/images/social/v2/database/index.png new file mode 100644 index 00000000..733f34c5 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/database/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/database/indexes.png b/mkdocs/site/assets/images/social/v2/database/indexes.png new file mode 100644 index 00000000..69a6b1ca Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/database/indexes.png differ diff --git a/mkdocs/site/assets/images/social/v2/database/migrations.png b/mkdocs/site/assets/images/social/v2/database/migrations.png new file mode 100644 index 00000000..5f0410e0 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/database/migrations.png differ diff --git a/mkdocs/site/assets/images/social/v2/database/models/auth.png b/mkdocs/site/assets/images/social/v2/database/models/auth.png new file mode 100644 index 00000000..8be1b84d Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/database/models/auth.png differ diff --git a/mkdocs/site/assets/images/social/v2/database/models/canvass.png b/mkdocs/site/assets/images/social/v2/database/models/canvass.png new file mode 100644 index 00000000..40749900 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/database/models/canvass.png differ diff --git a/mkdocs/site/assets/images/social/v2/database/models/email-templates.png b/mkdocs/site/assets/images/social/v2/database/models/email-templates.png new file mode 100644 index 00000000..4d110c49 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/database/models/email-templates.png differ diff --git a/mkdocs/site/assets/images/social/v2/database/models/index.png b/mkdocs/site/assets/images/social/v2/database/models/index.png new file mode 100644 index 00000000..057c0539 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/database/models/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/database/models/influence.png b/mkdocs/site/assets/images/social/v2/database/models/influence.png new file mode 100644 index 00000000..5f8b33d6 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/database/models/influence.png differ diff --git a/mkdocs/site/assets/images/social/v2/database/models/map.png b/mkdocs/site/assets/images/social/v2/database/models/map.png new file mode 100644 index 00000000..80357af4 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/database/models/map.png differ diff --git a/mkdocs/site/assets/images/social/v2/database/models/media.png b/mkdocs/site/assets/images/social/v2/database/models/media.png new file mode 100644 index 00000000..63aaeb85 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/database/models/media.png differ diff --git a/mkdocs/site/assets/images/social/v2/database/models/pages.png b/mkdocs/site/assets/images/social/v2/database/models/pages.png new file mode 100644 index 00000000..3c6fb3ca Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/database/models/pages.png differ diff --git a/mkdocs/site/assets/images/social/v2/database/models/settings.png b/mkdocs/site/assets/images/social/v2/database/models/settings.png new file mode 100644 index 00000000..19b86145 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/database/models/settings.png differ diff --git a/mkdocs/site/assets/images/social/v2/database/schema.png b/mkdocs/site/assets/images/social/v2/database/schema.png new file mode 100644 index 00000000..416a420c Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/database/schema.png differ diff --git a/mkdocs/site/assets/images/social/v2/database/seeding.png b/mkdocs/site/assets/images/social/v2/database/seeding.png new file mode 100644 index 00000000..b03955ed Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/database/seeding.png differ diff --git a/mkdocs/site/assets/images/social/v2/deployment/backup-restore.png b/mkdocs/site/assets/images/social/v2/deployment/backup-restore.png new file mode 100644 index 00000000..972e7d59 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/deployment/backup-restore.png differ diff --git a/mkdocs/site/assets/images/social/v2/deployment/docker-compose.png b/mkdocs/site/assets/images/social/v2/deployment/docker-compose.png new file mode 100644 index 00000000..a81f4d9d Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/deployment/docker-compose.png differ diff --git a/mkdocs/site/assets/images/social/v2/deployment/environment-variables.png b/mkdocs/site/assets/images/social/v2/deployment/environment-variables.png new file mode 100644 index 00000000..586acbd4 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/deployment/environment-variables.png differ diff --git a/mkdocs/site/assets/images/social/v2/deployment/healthchecks.png b/mkdocs/site/assets/images/social/v2/deployment/healthchecks.png new file mode 100644 index 00000000..7f060d3c Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/deployment/healthchecks.png differ diff --git a/mkdocs/site/assets/images/social/v2/deployment/index.png b/mkdocs/site/assets/images/social/v2/deployment/index.png new file mode 100644 index 00000000..b9137439 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/deployment/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/deployment/monitoring-stack.png b/mkdocs/site/assets/images/social/v2/deployment/monitoring-stack.png new file mode 100644 index 00000000..434bfa03 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/deployment/monitoring-stack.png differ diff --git a/mkdocs/site/assets/images/social/v2/deployment/nginx.png b/mkdocs/site/assets/images/social/v2/deployment/nginx.png new file mode 100644 index 00000000..fb945d26 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/deployment/nginx.png differ diff --git a/mkdocs/site/assets/images/social/v2/deployment/scaling.png b/mkdocs/site/assets/images/social/v2/deployment/scaling.png new file mode 100644 index 00000000..d6611e51 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/deployment/scaling.png differ diff --git a/mkdocs/site/assets/images/social/v2/deployment/ssl-tls.png b/mkdocs/site/assets/images/social/v2/deployment/ssl-tls.png new file mode 100644 index 00000000..d68abac9 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/deployment/ssl-tls.png differ diff --git a/mkdocs/site/assets/images/social/v2/deployment/tunneling.png b/mkdocs/site/assets/images/social/v2/deployment/tunneling.png new file mode 100644 index 00000000..f2e3b984 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/deployment/tunneling.png differ diff --git a/mkdocs/site/assets/images/social/v2/development/code-style.png b/mkdocs/site/assets/images/social/v2/development/code-style.png new file mode 100644 index 00000000..3d6ffdbf Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/development/code-style.png differ diff --git a/mkdocs/site/assets/images/social/v2/development/debugging.png b/mkdocs/site/assets/images/social/v2/development/debugging.png new file mode 100644 index 00000000..1b9cd66d Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/development/debugging.png differ diff --git a/mkdocs/site/assets/images/social/v2/development/docker-workflow.png b/mkdocs/site/assets/images/social/v2/development/docker-workflow.png new file mode 100644 index 00000000..f7c44fee Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/development/docker-workflow.png differ diff --git a/mkdocs/site/assets/images/social/v2/development/git-workflow.png b/mkdocs/site/assets/images/social/v2/development/git-workflow.png new file mode 100644 index 00000000..006f4acb Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/development/git-workflow.png differ diff --git a/mkdocs/site/assets/images/social/v2/development/index.png b/mkdocs/site/assets/images/social/v2/development/index.png new file mode 100644 index 00000000..1dfe2246 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/development/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/development/local-setup.png b/mkdocs/site/assets/images/social/v2/development/local-setup.png new file mode 100644 index 00000000..1d202ca6 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/development/local-setup.png differ diff --git a/mkdocs/site/assets/images/social/v2/development/migrations.png b/mkdocs/site/assets/images/social/v2/development/migrations.png new file mode 100644 index 00000000..5f0410e0 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/development/migrations.png differ diff --git a/mkdocs/site/assets/images/social/v2/development/npm-commands.png b/mkdocs/site/assets/images/social/v2/development/npm-commands.png new file mode 100644 index 00000000..45eb92e2 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/development/npm-commands.png differ diff --git a/mkdocs/site/assets/images/social/v2/development/testing.png b/mkdocs/site/assets/images/social/v2/development/testing.png new file mode 100644 index 00000000..70794f97 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/development/testing.png differ diff --git a/mkdocs/site/assets/images/social/v2/development/typescript.png b/mkdocs/site/assets/images/social/v2/development/typescript.png new file mode 100644 index 00000000..a2b5ca77 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/development/typescript.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/COMPLETION_STATUS.png b/mkdocs/site/assets/images/social/v2/features/COMPLETION_STATUS.png new file mode 100644 index 00000000..0cc1a7ff Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/COMPLETION_STATUS.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/email-templates/editor.png b/mkdocs/site/assets/images/social/v2/features/email-templates/editor.png new file mode 100644 index 00000000..ef694113 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/email-templates/editor.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/email-templates/index.png b/mkdocs/site/assets/images/social/v2/features/email-templates/index.png new file mode 100644 index 00000000..25b6add8 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/email-templates/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/email-templates/template-system.png b/mkdocs/site/assets/images/social/v2/features/email-templates/template-system.png new file mode 100644 index 00000000..c3d44649 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/email-templates/template-system.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/email-templates/variables.png b/mkdocs/site/assets/images/social/v2/features/email-templates/variables.png new file mode 100644 index 00000000..3ac5dd11 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/email-templates/variables.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/email-templates/versioning.png b/mkdocs/site/assets/images/social/v2/features/email-templates/versioning.png new file mode 100644 index 00000000..0fb97828 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/email-templates/versioning.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/index.png b/mkdocs/site/assets/images/social/v2/features/index.png new file mode 100644 index 00000000..54e89144 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/influence/campaigns.png b/mkdocs/site/assets/images/social/v2/features/influence/campaigns.png new file mode 100644 index 00000000..7def2cd1 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/influence/campaigns.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/influence/email-queue.png b/mkdocs/site/assets/images/social/v2/features/influence/email-queue.png new file mode 100644 index 00000000..d72edc9b Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/influence/email-queue.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/influence/index.png b/mkdocs/site/assets/images/social/v2/features/influence/index.png new file mode 100644 index 00000000..6a2283a7 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/influence/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/influence/postal-codes.png b/mkdocs/site/assets/images/social/v2/features/influence/postal-codes.png new file mode 100644 index 00000000..aa6e87f8 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/influence/postal-codes.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/influence/representatives.png b/mkdocs/site/assets/images/social/v2/features/influence/representatives.png new file mode 100644 index 00000000..4d4f24eb Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/influence/representatives.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/influence/responses.png b/mkdocs/site/assets/images/social/v2/features/influence/responses.png new file mode 100644 index 00000000..a939c087 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/influence/responses.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/landing-pages/index.png b/mkdocs/site/assets/images/social/v2/features/landing-pages/index.png new file mode 100644 index 00000000..374a3168 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/landing-pages/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/map/MAP_FEATURES_STATUS.png b/mkdocs/site/assets/images/social/v2/features/map/MAP_FEATURES_STATUS.png new file mode 100644 index 00000000..87f2aebc Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/map/MAP_FEATURES_STATUS.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/map/canvassing.png b/mkdocs/site/assets/images/social/v2/features/map/canvassing.png new file mode 100644 index 00000000..1f52184e Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/map/canvassing.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/map/cuts.png b/mkdocs/site/assets/images/social/v2/features/map/cuts.png new file mode 100644 index 00000000..e1b18378 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/map/cuts.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/map/data-quality.png b/mkdocs/site/assets/images/social/v2/features/map/data-quality.png new file mode 100644 index 00000000..e14ab48e Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/map/data-quality.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/map/geocoding.png b/mkdocs/site/assets/images/social/v2/features/map/geocoding.png new file mode 100644 index 00000000..eccb0704 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/map/geocoding.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/map/index.png b/mkdocs/site/assets/images/social/v2/features/map/index.png new file mode 100644 index 00000000..4b378a2b Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/map/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/map/locations.png b/mkdocs/site/assets/images/social/v2/features/map/locations.png new file mode 100644 index 00000000..513c72f0 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/map/locations.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/map/nar-import.png b/mkdocs/site/assets/images/social/v2/features/map/nar-import.png new file mode 100644 index 00000000..d7c032d6 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/map/nar-import.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/map/shifts.png b/mkdocs/site/assets/images/social/v2/features/map/shifts.png new file mode 100644 index 00000000..e17939ea Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/map/shifts.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/map/tracking.png b/mkdocs/site/assets/images/social/v2/features/map/tracking.png new file mode 100644 index 00000000..a7cf51be Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/map/tracking.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/map/walk-sheets.png b/mkdocs/site/assets/images/social/v2/features/map/walk-sheets.png new file mode 100644 index 00000000..6660d23c Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/map/walk-sheets.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/media/index.png b/mkdocs/site/assets/images/social/v2/features/media/index.png new file mode 100644 index 00000000..2838f56b Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/media/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/media/jobs.png b/mkdocs/site/assets/images/social/v2/features/media/jobs.png new file mode 100644 index 00000000..0e610fb0 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/media/jobs.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/media/public-gallery.png b/mkdocs/site/assets/images/social/v2/features/media/public-gallery.png new file mode 100644 index 00000000..ee6f1ea6 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/media/public-gallery.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/media/upload.png b/mkdocs/site/assets/images/social/v2/features/media/upload.png new file mode 100644 index 00000000..d9e05543 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/media/upload.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/media/video-library.png b/mkdocs/site/assets/images/social/v2/features/media/video-library.png new file mode 100644 index 00000000..98232304 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/media/video-library.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/newsletter/index.png b/mkdocs/site/assets/images/social/v2/features/newsletter/index.png new file mode 100644 index 00000000..86bf6eea Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/newsletter/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/observability/index.png b/mkdocs/site/assets/images/social/v2/features/observability/index.png new file mode 100644 index 00000000..d2c6200d Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/observability/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/pages/block-library.png b/mkdocs/site/assets/images/social/v2/features/pages/block-library.png new file mode 100644 index 00000000..a36c7720 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/pages/block-library.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/pages/grapes-editor.png b/mkdocs/site/assets/images/social/v2/features/pages/grapes-editor.png new file mode 100644 index 00000000..0d6c39f7 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/pages/grapes-editor.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/pages/mkdocs-export.png b/mkdocs/site/assets/images/social/v2/features/pages/mkdocs-export.png new file mode 100644 index 00000000..f4ca0633 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/pages/mkdocs-export.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/pages/page-builder.png b/mkdocs/site/assets/images/social/v2/features/pages/page-builder.png new file mode 100644 index 00000000..2a8dc5d0 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/pages/page-builder.png differ diff --git a/mkdocs/site/assets/images/social/v2/features/tunnel/index.png b/mkdocs/site/assets/images/social/v2/features/tunnel/index.png new file mode 100644 index 00000000..192a327e Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/features/tunnel/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/components/index.png b/mkdocs/site/assets/images/social/v2/frontend/components/index.png new file mode 100644 index 00000000..b18b5e28 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/components/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/index.png b/mkdocs/site/assets/images/social/v2/frontend/index.png new file mode 100644 index 00000000..0343912c Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/layouts/index.png b/mkdocs/site/assets/images/social/v2/frontend/layouts/index.png new file mode 100644 index 00000000..e7a70f39 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/layouts/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/campaigns-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/campaigns-page.png new file mode 100644 index 00000000..7def2cd1 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/campaigns-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/canvass-dashboard-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/canvass-dashboard-page.png new file mode 100644 index 00000000..7218f26c Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/canvass-dashboard-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/code-editor-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/code-editor-page.png new file mode 100644 index 00000000..54aa0504 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/code-editor-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/cut-export-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/cut-export-page.png new file mode 100644 index 00000000..00892c0a Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/cut-export-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/cuts-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/cuts-page.png new file mode 100644 index 00000000..e1b18378 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/cuts-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/dashboard-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/dashboard-page.png new file mode 100644 index 00000000..9ad145fd Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/dashboard-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/data-quality-dashboard-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/data-quality-dashboard-page.png new file mode 100644 index 00000000..f2e20a53 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/data-quality-dashboard-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/docs-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/docs-page.png new file mode 100644 index 00000000..6226c847 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/docs-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/email-queue-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/email-queue-page.png new file mode 100644 index 00000000..d72edc9b Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/email-queue-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/email-template-editor-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/email-template-editor-page.png new file mode 100644 index 00000000..8c98322a Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/email-template-editor-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/email-templates-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/email-templates-page.png new file mode 100644 index 00000000..25b6add8 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/email-templates-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/gitea-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/gitea-page.png new file mode 100644 index 00000000..360a3262 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/gitea-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/index.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/index.png new file mode 100644 index 00000000..96f2bca5 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/landing-pages-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/landing-pages-page.png new file mode 100644 index 00000000..35475cdb Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/landing-pages-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/listmonk-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/listmonk-page.png new file mode 100644 index 00000000..3d70a493 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/listmonk-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/locations-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/locations-page.png new file mode 100644 index 00000000..513c72f0 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/locations-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/mailhog-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/mailhog-page.png new file mode 100644 index 00000000..8195c5fc Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/mailhog-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/map-settings-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/map-settings-page.png new file mode 100644 index 00000000..589e39e8 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/map-settings-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/mini-qr-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/mini-qr-page.png new file mode 100644 index 00000000..c9cda7d3 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/mini-qr-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/mkdocs-settings-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/mkdocs-settings-page.png new file mode 100644 index 00000000..2daa318e Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/mkdocs-settings-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/n8n-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/n8n-page.png new file mode 100644 index 00000000..8024a940 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/n8n-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/nocodb-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/nocodb-page.png new file mode 100644 index 00000000..4c1a3e11 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/nocodb-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/observability-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/observability-page.png new file mode 100644 index 00000000..d62d63be Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/observability-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/page-editor-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/page-editor-page.png new file mode 100644 index 00000000..251d9dd8 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/page-editor-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/pangolin-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/pangolin-page.png new file mode 100644 index 00000000..8b9b1b3f Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/pangolin-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/representatives-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/representatives-page.png new file mode 100644 index 00000000..4d4f24eb Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/representatives-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/responses-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/responses-page.png new file mode 100644 index 00000000..a939c087 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/responses-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/settings-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/settings-page.png new file mode 100644 index 00000000..ea599c10 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/settings-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/shifts-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/shifts-page.png new file mode 100644 index 00000000..e17939ea Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/shifts-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/users-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/users-page.png new file mode 100644 index 00000000..dc55223a Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/users-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/admin/walk-sheet-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/walk-sheet-page.png new file mode 100644 index 00000000..52f11c8b Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/admin/walk-sheet-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/index.png b/mkdocs/site/assets/images/social/v2/frontend/pages/index.png new file mode 100644 index 00000000..8c5f8d03 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/public/campaign-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/public/campaign-page.png new file mode 100644 index 00000000..15ef117e Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/public/campaign-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/public/campaigns-list-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/public/campaigns-list-page.png new file mode 100644 index 00000000..347da37b Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/public/campaigns-list-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/public/index.png b/mkdocs/site/assets/images/social/v2/frontend/pages/public/index.png new file mode 100644 index 00000000..b0c07e2b Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/public/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/public/landing-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/public/landing-page.png new file mode 100644 index 00000000..8efd0441 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/public/landing-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/public/map-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/public/map-page.png new file mode 100644 index 00000000..e0d0cf89 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/public/map-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/public/media-gallery-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/public/media-gallery-page.png new file mode 100644 index 00000000..11c8c8e0 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/public/media-gallery-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/public/media-viewer-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/public/media-viewer-page.png new file mode 100644 index 00000000..bb0ccdc4 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/public/media-viewer-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/public/response-wall-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/public/response-wall-page.png new file mode 100644 index 00000000..0c4aac91 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/public/response-wall-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/public/shifts-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/public/shifts-page.png new file mode 100644 index 00000000..e17939ea Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/public/shifts-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/volunteer/index.png b/mkdocs/site/assets/images/social/v2/frontend/pages/volunteer/index.png new file mode 100644 index 00000000..0f6fbdc1 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/volunteer/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/volunteer/my-activity-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/volunteer/my-activity-page.png new file mode 100644 index 00000000..7f625c2a Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/volunteer/my-activity-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/volunteer/my-routes-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/volunteer/my-routes-page.png new file mode 100644 index 00000000..7466d138 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/volunteer/my-routes-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/volunteer/volunteer-map-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/volunteer/volunteer-map-page.png new file mode 100644 index 00000000..74aeec5c Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/volunteer/volunteer-map-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/frontend/pages/volunteer/volunteer-shifts-page.png b/mkdocs/site/assets/images/social/v2/frontend/pages/volunteer/volunteer-shifts-page.png new file mode 100644 index 00000000..c838c7a1 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/frontend/pages/volunteer/volunteer-shifts-page.png differ diff --git a/mkdocs/site/assets/images/social/v2/getting-started/index.png b/mkdocs/site/assets/images/social/v2/getting-started/index.png new file mode 100644 index 00000000..53a12d65 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/getting-started/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/getting-started/quick-start.png b/mkdocs/site/assets/images/social/v2/getting-started/quick-start.png new file mode 100644 index 00000000..205b5f93 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/getting-started/quick-start.png differ diff --git a/mkdocs/site/assets/images/social/v2/index.png b/mkdocs/site/assets/images/social/v2/index.png new file mode 100644 index 00000000..4b6ed281 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/migration/api-changes.png b/mkdocs/site/assets/images/social/v2/migration/api-changes.png new file mode 100644 index 00000000..1fd61d1e Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/migration/api-changes.png differ diff --git a/mkdocs/site/assets/images/social/v2/migration/breaking-changes.png b/mkdocs/site/assets/images/social/v2/migration/breaking-changes.png new file mode 100644 index 00000000..eba76473 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/migration/breaking-changes.png differ diff --git a/mkdocs/site/assets/images/social/v2/migration/data-migration.png b/mkdocs/site/assets/images/social/v2/migration/data-migration.png new file mode 100644 index 00000000..049fb2a1 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/migration/data-migration.png differ diff --git a/mkdocs/site/assets/images/social/v2/migration/feature-parity.png b/mkdocs/site/assets/images/social/v2/migration/feature-parity.png new file mode 100644 index 00000000..ae9196fc Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/migration/feature-parity.png differ diff --git a/mkdocs/site/assets/images/social/v2/migration/index.png b/mkdocs/site/assets/images/social/v2/migration/index.png new file mode 100644 index 00000000..8a2cac0f Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/migration/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/troubleshooting/auth-issues.png b/mkdocs/site/assets/images/social/v2/troubleshooting/auth-issues.png new file mode 100644 index 00000000..384cd38b Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/troubleshooting/auth-issues.png differ diff --git a/mkdocs/site/assets/images/social/v2/troubleshooting/common-errors.png b/mkdocs/site/assets/images/social/v2/troubleshooting/common-errors.png new file mode 100644 index 00000000..ef1db898 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/troubleshooting/common-errors.png differ diff --git a/mkdocs/site/assets/images/social/v2/troubleshooting/database-issues.png b/mkdocs/site/assets/images/social/v2/troubleshooting/database-issues.png new file mode 100644 index 00000000..f4af058d Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/troubleshooting/database-issues.png differ diff --git a/mkdocs/site/assets/images/social/v2/troubleshooting/docker-issues.png b/mkdocs/site/assets/images/social/v2/troubleshooting/docker-issues.png new file mode 100644 index 00000000..1e8e9b07 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/troubleshooting/docker-issues.png differ diff --git a/mkdocs/site/assets/images/social/v2/troubleshooting/email-issues.png b/mkdocs/site/assets/images/social/v2/troubleshooting/email-issues.png new file mode 100644 index 00000000..00084f5a Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/troubleshooting/email-issues.png differ diff --git a/mkdocs/site/assets/images/social/v2/troubleshooting/faq.png b/mkdocs/site/assets/images/social/v2/troubleshooting/faq.png new file mode 100644 index 00000000..803ebe7a Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/troubleshooting/faq.png differ diff --git a/mkdocs/site/assets/images/social/v2/troubleshooting/geocoding-issues.png b/mkdocs/site/assets/images/social/v2/troubleshooting/geocoding-issues.png new file mode 100644 index 00000000..c0dc7445 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/troubleshooting/geocoding-issues.png differ diff --git a/mkdocs/site/assets/images/social/v2/troubleshooting/index.png b/mkdocs/site/assets/images/social/v2/troubleshooting/index.png new file mode 100644 index 00000000..65f2a8ba Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/troubleshooting/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/troubleshooting/monitoring-issues.png b/mkdocs/site/assets/images/social/v2/troubleshooting/monitoring-issues.png new file mode 100644 index 00000000..e7a59a5a Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/troubleshooting/monitoring-issues.png differ diff --git a/mkdocs/site/assets/images/social/v2/troubleshooting/performance-optimization.png b/mkdocs/site/assets/images/social/v2/troubleshooting/performance-optimization.png new file mode 100644 index 00000000..57fc5472 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/troubleshooting/performance-optimization.png differ diff --git a/mkdocs/site/assets/images/social/v2/user-guides/admin-guide.png b/mkdocs/site/assets/images/social/v2/user-guides/admin-guide.png new file mode 100644 index 00000000..e26c4865 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/user-guides/admin-guide.png differ diff --git a/mkdocs/site/assets/images/social/v2/user-guides/campaign-manager-guide.png b/mkdocs/site/assets/images/social/v2/user-guides/campaign-manager-guide.png new file mode 100644 index 00000000..e87bff11 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/user-guides/campaign-manager-guide.png differ diff --git a/mkdocs/site/assets/images/social/v2/user-guides/content-editor-guide.png b/mkdocs/site/assets/images/social/v2/user-guides/content-editor-guide.png new file mode 100644 index 00000000..42f2a188 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/user-guides/content-editor-guide.png differ diff --git a/mkdocs/site/assets/images/social/v2/user-guides/index.png b/mkdocs/site/assets/images/social/v2/user-guides/index.png new file mode 100644 index 00000000..06697549 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/user-guides/index.png differ diff --git a/mkdocs/site/assets/images/social/v2/user-guides/map-organizer-guide.png b/mkdocs/site/assets/images/social/v2/user-guides/map-organizer-guide.png new file mode 100644 index 00000000..fc4cd8ce Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/user-guides/map-organizer-guide.png differ diff --git a/mkdocs/site/assets/images/social/v2/user-guides/volunteer-guide.png b/mkdocs/site/assets/images/social/v2/user-guides/volunteer-guide.png new file mode 100644 index 00000000..8657bbc3 Binary files /dev/null and b/mkdocs/site/assets/images/social/v2/user-guides/volunteer-guide.png differ diff --git a/mkdocs/site/assets/javascripts/bundle.79ae519e.min.js b/mkdocs/site/assets/javascripts/bundle.79ae519e.min.js new file mode 100644 index 00000000..3df3e5e6 --- /dev/null +++ b/mkdocs/site/assets/javascripts/bundle.79ae519e.min.js @@ -0,0 +1,16 @@ +"use strict";(()=>{var Zi=Object.create;var _r=Object.defineProperty;var ea=Object.getOwnPropertyDescriptor;var ta=Object.getOwnPropertyNames,Bt=Object.getOwnPropertySymbols,ra=Object.getPrototypeOf,Ar=Object.prototype.hasOwnProperty,bo=Object.prototype.propertyIsEnumerable;var ho=(e,t,r)=>t in e?_r(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,P=(e,t)=>{for(var r in t||(t={}))Ar.call(t,r)&&ho(e,r,t[r]);if(Bt)for(var r of Bt(t))bo.call(t,r)&&ho(e,r,t[r]);return e};var vo=(e,t)=>{var r={};for(var o in e)Ar.call(e,o)&&t.indexOf(o)<0&&(r[o]=e[o]);if(e!=null&&Bt)for(var o of Bt(e))t.indexOf(o)<0&&bo.call(e,o)&&(r[o]=e[o]);return r};var Cr=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var oa=(e,t,r,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of ta(t))!Ar.call(e,n)&&n!==r&&_r(e,n,{get:()=>t[n],enumerable:!(o=ea(t,n))||o.enumerable});return e};var $t=(e,t,r)=>(r=e!=null?Zi(ra(e)):{},oa(t||!e||!e.__esModule?_r(r,"default",{value:e,enumerable:!0}):r,e));var go=(e,t,r)=>new Promise((o,n)=>{var i=c=>{try{a(r.next(c))}catch(p){n(p)}},s=c=>{try{a(r.throw(c))}catch(p){n(p)}},a=c=>c.done?o(c.value):Promise.resolve(c.value).then(i,s);a((r=r.apply(e,t)).next())});var xo=Cr((kr,yo)=>{(function(e,t){typeof kr=="object"&&typeof yo!="undefined"?t():typeof define=="function"&&define.amd?define(t):t()})(kr,(function(){"use strict";function e(r){var o=!0,n=!1,i=null,s={text:!0,search:!0,url:!0,tel:!0,email:!0,password:!0,number:!0,date:!0,month:!0,week:!0,time:!0,datetime:!0,"datetime-local":!0};function a(k){return!!(k&&k!==document&&k.nodeName!=="HTML"&&k.nodeName!=="BODY"&&"classList"in k&&"contains"in k.classList)}function c(k){var ut=k.type,je=k.tagName;return!!(je==="INPUT"&&s[ut]&&!k.readOnly||je==="TEXTAREA"&&!k.readOnly||k.isContentEditable)}function p(k){k.classList.contains("focus-visible")||(k.classList.add("focus-visible"),k.setAttribute("data-focus-visible-added",""))}function l(k){k.hasAttribute("data-focus-visible-added")&&(k.classList.remove("focus-visible"),k.removeAttribute("data-focus-visible-added"))}function f(k){k.metaKey||k.altKey||k.ctrlKey||(a(r.activeElement)&&p(r.activeElement),o=!0)}function u(k){o=!1}function d(k){a(k.target)&&(o||c(k.target))&&p(k.target)}function v(k){a(k.target)&&(k.target.classList.contains("focus-visible")||k.target.hasAttribute("data-focus-visible-added"))&&(n=!0,window.clearTimeout(i),i=window.setTimeout(function(){n=!1},100),l(k.target))}function S(k){document.visibilityState==="hidden"&&(n&&(o=!0),X())}function X(){document.addEventListener("mousemove",ee),document.addEventListener("mousedown",ee),document.addEventListener("mouseup",ee),document.addEventListener("pointermove",ee),document.addEventListener("pointerdown",ee),document.addEventListener("pointerup",ee),document.addEventListener("touchmove",ee),document.addEventListener("touchstart",ee),document.addEventListener("touchend",ee)}function re(){document.removeEventListener("mousemove",ee),document.removeEventListener("mousedown",ee),document.removeEventListener("mouseup",ee),document.removeEventListener("pointermove",ee),document.removeEventListener("pointerdown",ee),document.removeEventListener("pointerup",ee),document.removeEventListener("touchmove",ee),document.removeEventListener("touchstart",ee),document.removeEventListener("touchend",ee)}function ee(k){k.target.nodeName&&k.target.nodeName.toLowerCase()==="html"||(o=!1,re())}document.addEventListener("keydown",f,!0),document.addEventListener("mousedown",u,!0),document.addEventListener("pointerdown",u,!0),document.addEventListener("touchstart",u,!0),document.addEventListener("visibilitychange",S,!0),X(),r.addEventListener("focus",d,!0),r.addEventListener("blur",v,!0),r.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&r.host?r.host.setAttribute("data-js-focus-visible",""):r.nodeType===Node.DOCUMENT_NODE&&(document.documentElement.classList.add("js-focus-visible"),document.documentElement.setAttribute("data-js-focus-visible",""))}if(typeof window!="undefined"&&typeof document!="undefined"){window.applyFocusVisiblePolyfill=e;var t;try{t=new CustomEvent("focus-visible-polyfill-ready")}catch(r){t=document.createEvent("CustomEvent"),t.initCustomEvent("focus-visible-polyfill-ready",!1,!1,{})}window.dispatchEvent(t)}typeof document!="undefined"&&e(document)}))});var ro=Cr((jy,Rn)=>{"use strict";/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */var qa=/["'&<>]/;Rn.exports=Ka;function Ka(e){var t=""+e,r=qa.exec(t);if(!r)return t;var o,n="",i=0,s=0;for(i=r.index;i{/*! + * clipboard.js v2.0.11 + * https://clipboardjs.com/ + * + * Licensed MIT © Zeno Rocha + */(function(t,r){typeof Nt=="object"&&typeof io=="object"?io.exports=r():typeof define=="function"&&define.amd?define([],r):typeof Nt=="object"?Nt.ClipboardJS=r():t.ClipboardJS=r()})(Nt,function(){return(function(){var e={686:(function(o,n,i){"use strict";i.d(n,{default:function(){return Xi}});var s=i(279),a=i.n(s),c=i(370),p=i.n(c),l=i(817),f=i.n(l);function u(q){try{return document.execCommand(q)}catch(C){return!1}}var d=function(C){var _=f()(C);return u("cut"),_},v=d;function S(q){var C=document.documentElement.getAttribute("dir")==="rtl",_=document.createElement("textarea");_.style.fontSize="12pt",_.style.border="0",_.style.padding="0",_.style.margin="0",_.style.position="absolute",_.style[C?"right":"left"]="-9999px";var D=window.pageYOffset||document.documentElement.scrollTop;return _.style.top="".concat(D,"px"),_.setAttribute("readonly",""),_.value=q,_}var X=function(C,_){var D=S(C);_.container.appendChild(D);var N=f()(D);return u("copy"),D.remove(),N},re=function(C){var _=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body},D="";return typeof C=="string"?D=X(C,_):C instanceof HTMLInputElement&&!["text","search","url","tel","password"].includes(C==null?void 0:C.type)?D=X(C.value,_):(D=f()(C),u("copy")),D},ee=re;function k(q){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?k=function(_){return typeof _}:k=function(_){return _&&typeof Symbol=="function"&&_.constructor===Symbol&&_!==Symbol.prototype?"symbol":typeof _},k(q)}var ut=function(){var C=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},_=C.action,D=_===void 0?"copy":_,N=C.container,G=C.target,We=C.text;if(D!=="copy"&&D!=="cut")throw new Error('Invalid "action" value, use either "copy" or "cut"');if(G!==void 0)if(G&&k(G)==="object"&&G.nodeType===1){if(D==="copy"&&G.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if(D==="cut"&&(G.hasAttribute("readonly")||G.hasAttribute("disabled")))throw new Error(`Invalid "target" attribute. You can't cut text from elements with "readonly" or "disabled" attributes`)}else throw new Error('Invalid "target" value, use a valid Element');if(We)return ee(We,{container:N});if(G)return D==="cut"?v(G):ee(G,{container:N})},je=ut;function R(q){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?R=function(_){return typeof _}:R=function(_){return _&&typeof Symbol=="function"&&_.constructor===Symbol&&_!==Symbol.prototype?"symbol":typeof _},R(q)}function se(q,C){if(!(q instanceof C))throw new TypeError("Cannot call a class as a function")}function ce(q,C){for(var _=0;_0&&arguments[0]!==void 0?arguments[0]:{};this.action=typeof N.action=="function"?N.action:this.defaultAction,this.target=typeof N.target=="function"?N.target:this.defaultTarget,this.text=typeof N.text=="function"?N.text:this.defaultText,this.container=R(N.container)==="object"?N.container:document.body}},{key:"listenClick",value:function(N){var G=this;this.listener=p()(N,"click",function(We){return G.onClick(We)})}},{key:"onClick",value:function(N){var G=N.delegateTarget||N.currentTarget,We=this.action(G)||"copy",Yt=je({action:We,container:this.container,target:this.target(G),text:this.text(G)});this.emit(Yt?"success":"error",{action:We,text:Yt,trigger:G,clearSelection:function(){G&&G.focus(),window.getSelection().removeAllRanges()}})}},{key:"defaultAction",value:function(N){return Mr("action",N)}},{key:"defaultTarget",value:function(N){var G=Mr("target",N);if(G)return document.querySelector(G)}},{key:"defaultText",value:function(N){return Mr("text",N)}},{key:"destroy",value:function(){this.listener.destroy()}}],[{key:"copy",value:function(N){var G=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body};return ee(N,G)}},{key:"cut",value:function(N){return v(N)}},{key:"isSupported",value:function(){var N=arguments.length>0&&arguments[0]!==void 0?arguments[0]:["copy","cut"],G=typeof N=="string"?[N]:N,We=!!document.queryCommandSupported;return G.forEach(function(Yt){We=We&&!!document.queryCommandSupported(Yt)}),We}}]),_})(a()),Xi=Ji}),828:(function(o){var n=9;if(typeof Element!="undefined"&&!Element.prototype.matches){var i=Element.prototype;i.matches=i.matchesSelector||i.mozMatchesSelector||i.msMatchesSelector||i.oMatchesSelector||i.webkitMatchesSelector}function s(a,c){for(;a&&a.nodeType!==n;){if(typeof a.matches=="function"&&a.matches(c))return a;a=a.parentNode}}o.exports=s}),438:(function(o,n,i){var s=i(828);function a(l,f,u,d,v){var S=p.apply(this,arguments);return l.addEventListener(u,S,v),{destroy:function(){l.removeEventListener(u,S,v)}}}function c(l,f,u,d,v){return typeof l.addEventListener=="function"?a.apply(null,arguments):typeof u=="function"?a.bind(null,document).apply(null,arguments):(typeof l=="string"&&(l=document.querySelectorAll(l)),Array.prototype.map.call(l,function(S){return a(S,f,u,d,v)}))}function p(l,f,u,d){return function(v){v.delegateTarget=s(v.target,f),v.delegateTarget&&d.call(l,v)}}o.exports=c}),879:(function(o,n){n.node=function(i){return i!==void 0&&i instanceof HTMLElement&&i.nodeType===1},n.nodeList=function(i){var s=Object.prototype.toString.call(i);return i!==void 0&&(s==="[object NodeList]"||s==="[object HTMLCollection]")&&"length"in i&&(i.length===0||n.node(i[0]))},n.string=function(i){return typeof i=="string"||i instanceof String},n.fn=function(i){var s=Object.prototype.toString.call(i);return s==="[object Function]"}}),370:(function(o,n,i){var s=i(879),a=i(438);function c(u,d,v){if(!u&&!d&&!v)throw new Error("Missing required arguments");if(!s.string(d))throw new TypeError("Second argument must be a String");if(!s.fn(v))throw new TypeError("Third argument must be a Function");if(s.node(u))return p(u,d,v);if(s.nodeList(u))return l(u,d,v);if(s.string(u))return f(u,d,v);throw new TypeError("First argument must be a String, HTMLElement, HTMLCollection, or NodeList")}function p(u,d,v){return u.addEventListener(d,v),{destroy:function(){u.removeEventListener(d,v)}}}function l(u,d,v){return Array.prototype.forEach.call(u,function(S){S.addEventListener(d,v)}),{destroy:function(){Array.prototype.forEach.call(u,function(S){S.removeEventListener(d,v)})}}}function f(u,d,v){return a(document.body,u,d,v)}o.exports=c}),817:(function(o){function n(i){var s;if(i.nodeName==="SELECT")i.focus(),s=i.value;else if(i.nodeName==="INPUT"||i.nodeName==="TEXTAREA"){var a=i.hasAttribute("readonly");a||i.setAttribute("readonly",""),i.select(),i.setSelectionRange(0,i.value.length),a||i.removeAttribute("readonly"),s=i.value}else{i.hasAttribute("contenteditable")&&i.focus();var c=window.getSelection(),p=document.createRange();p.selectNodeContents(i),c.removeAllRanges(),c.addRange(p),s=c.toString()}return s}o.exports=n}),279:(function(o){function n(){}n.prototype={on:function(i,s,a){var c=this.e||(this.e={});return(c[i]||(c[i]=[])).push({fn:s,ctx:a}),this},once:function(i,s,a){var c=this;function p(){c.off(i,p),s.apply(a,arguments)}return p._=s,this.on(i,p,a)},emit:function(i){var s=[].slice.call(arguments,1),a=((this.e||(this.e={}))[i]||[]).slice(),c=0,p=a.length;for(c;c0&&i[i.length-1])&&(p[0]===6||p[0]===2)){r=0;continue}if(p[0]===3&&(!i||p[1]>i[0]&&p[1]=e.length&&(e=void 0),{value:e&&e[o++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function K(e,t){var r=typeof Symbol=="function"&&e[Symbol.iterator];if(!r)return e;var o=r.call(e),n,i=[],s;try{for(;(t===void 0||t-- >0)&&!(n=o.next()).done;)i.push(n.value)}catch(a){s={error:a}}finally{try{n&&!n.done&&(r=o.return)&&r.call(o)}finally{if(s)throw s.error}}return i}function B(e,t,r){if(r||arguments.length===2)for(var o=0,n=t.length,i;o1||c(d,S)})},v&&(n[d]=v(n[d])))}function c(d,v){try{p(o[d](v))}catch(S){u(i[0][3],S)}}function p(d){d.value instanceof dt?Promise.resolve(d.value.v).then(l,f):u(i[0][2],d)}function l(d){c("next",d)}function f(d){c("throw",d)}function u(d,v){d(v),i.shift(),i.length&&c(i[0][0],i[0][1])}}function To(e){if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var t=e[Symbol.asyncIterator],r;return t?t.call(e):(e=typeof Oe=="function"?Oe(e):e[Symbol.iterator](),r={},o("next"),o("throw"),o("return"),r[Symbol.asyncIterator]=function(){return this},r);function o(i){r[i]=e[i]&&function(s){return new Promise(function(a,c){s=e[i](s),n(a,c,s.done,s.value)})}}function n(i,s,a,c){Promise.resolve(c).then(function(p){i({value:p,done:a})},s)}}function I(e){return typeof e=="function"}function yt(e){var t=function(o){Error.call(o),o.stack=new Error().stack},r=e(t);return r.prototype=Object.create(Error.prototype),r.prototype.constructor=r,r}var Jt=yt(function(e){return function(r){e(this),this.message=r?r.length+` errors occurred during unsubscription: +`+r.map(function(o,n){return n+1+") "+o.toString()}).join(` + `):"",this.name="UnsubscriptionError",this.errors=r}});function Ze(e,t){if(e){var r=e.indexOf(t);0<=r&&e.splice(r,1)}}var qe=(function(){function e(t){this.initialTeardown=t,this.closed=!1,this._parentage=null,this._finalizers=null}return e.prototype.unsubscribe=function(){var t,r,o,n,i;if(!this.closed){this.closed=!0;var s=this._parentage;if(s)if(this._parentage=null,Array.isArray(s))try{for(var a=Oe(s),c=a.next();!c.done;c=a.next()){var p=c.value;p.remove(this)}}catch(S){t={error:S}}finally{try{c&&!c.done&&(r=a.return)&&r.call(a)}finally{if(t)throw t.error}}else s.remove(this);var l=this.initialTeardown;if(I(l))try{l()}catch(S){i=S instanceof Jt?S.errors:[S]}var f=this._finalizers;if(f){this._finalizers=null;try{for(var u=Oe(f),d=u.next();!d.done;d=u.next()){var v=d.value;try{So(v)}catch(S){i=i!=null?i:[],S instanceof Jt?i=B(B([],K(i)),K(S.errors)):i.push(S)}}}catch(S){o={error:S}}finally{try{d&&!d.done&&(n=u.return)&&n.call(u)}finally{if(o)throw o.error}}}if(i)throw new Jt(i)}},e.prototype.add=function(t){var r;if(t&&t!==this)if(this.closed)So(t);else{if(t instanceof e){if(t.closed||t._hasParent(this))return;t._addParent(this)}(this._finalizers=(r=this._finalizers)!==null&&r!==void 0?r:[]).push(t)}},e.prototype._hasParent=function(t){var r=this._parentage;return r===t||Array.isArray(r)&&r.includes(t)},e.prototype._addParent=function(t){var r=this._parentage;this._parentage=Array.isArray(r)?(r.push(t),r):r?[r,t]:t},e.prototype._removeParent=function(t){var r=this._parentage;r===t?this._parentage=null:Array.isArray(r)&&Ze(r,t)},e.prototype.remove=function(t){var r=this._finalizers;r&&Ze(r,t),t instanceof e&&t._removeParent(this)},e.EMPTY=(function(){var t=new e;return t.closed=!0,t})(),e})();var $r=qe.EMPTY;function Xt(e){return e instanceof qe||e&&"closed"in e&&I(e.remove)&&I(e.add)&&I(e.unsubscribe)}function So(e){I(e)?e():e.unsubscribe()}var De={onUnhandledError:null,onStoppedNotification:null,Promise:void 0,useDeprecatedSynchronousErrorHandling:!1,useDeprecatedNextContext:!1};var xt={setTimeout:function(e,t){for(var r=[],o=2;o0},enumerable:!1,configurable:!0}),t.prototype._trySubscribe=function(r){return this._throwIfClosed(),e.prototype._trySubscribe.call(this,r)},t.prototype._subscribe=function(r){return this._throwIfClosed(),this._checkFinalizedStatuses(r),this._innerSubscribe(r)},t.prototype._innerSubscribe=function(r){var o=this,n=this,i=n.hasError,s=n.isStopped,a=n.observers;return i||s?$r:(this.currentObservers=null,a.push(r),new qe(function(){o.currentObservers=null,Ze(a,r)}))},t.prototype._checkFinalizedStatuses=function(r){var o=this,n=o.hasError,i=o.thrownError,s=o.isStopped;n?r.error(i):s&&r.complete()},t.prototype.asObservable=function(){var r=new F;return r.source=this,r},t.create=function(r,o){return new Ho(r,o)},t})(F);var Ho=(function(e){ie(t,e);function t(r,o){var n=e.call(this)||this;return n.destination=r,n.source=o,n}return t.prototype.next=function(r){var o,n;(n=(o=this.destination)===null||o===void 0?void 0:o.next)===null||n===void 0||n.call(o,r)},t.prototype.error=function(r){var o,n;(n=(o=this.destination)===null||o===void 0?void 0:o.error)===null||n===void 0||n.call(o,r)},t.prototype.complete=function(){var r,o;(o=(r=this.destination)===null||r===void 0?void 0:r.complete)===null||o===void 0||o.call(r)},t.prototype._subscribe=function(r){var o,n;return(n=(o=this.source)===null||o===void 0?void 0:o.subscribe(r))!==null&&n!==void 0?n:$r},t})(T);var jr=(function(e){ie(t,e);function t(r){var o=e.call(this)||this;return o._value=r,o}return Object.defineProperty(t.prototype,"value",{get:function(){return this.getValue()},enumerable:!1,configurable:!0}),t.prototype._subscribe=function(r){var o=e.prototype._subscribe.call(this,r);return!o.closed&&r.next(this._value),o},t.prototype.getValue=function(){var r=this,o=r.hasError,n=r.thrownError,i=r._value;if(o)throw n;return this._throwIfClosed(),i},t.prototype.next=function(r){e.prototype.next.call(this,this._value=r)},t})(T);var Rt={now:function(){return(Rt.delegate||Date).now()},delegate:void 0};var It=(function(e){ie(t,e);function t(r,o,n){r===void 0&&(r=1/0),o===void 0&&(o=1/0),n===void 0&&(n=Rt);var i=e.call(this)||this;return i._bufferSize=r,i._windowTime=o,i._timestampProvider=n,i._buffer=[],i._infiniteTimeWindow=!0,i._infiniteTimeWindow=o===1/0,i._bufferSize=Math.max(1,r),i._windowTime=Math.max(1,o),i}return t.prototype.next=function(r){var o=this,n=o.isStopped,i=o._buffer,s=o._infiniteTimeWindow,a=o._timestampProvider,c=o._windowTime;n||(i.push(r),!s&&i.push(a.now()+c)),this._trimBuffer(),e.prototype.next.call(this,r)},t.prototype._subscribe=function(r){this._throwIfClosed(),this._trimBuffer();for(var o=this._innerSubscribe(r),n=this,i=n._infiniteTimeWindow,s=n._buffer,a=s.slice(),c=0;c0?e.prototype.schedule.call(this,r,o):(this.delay=o,this.state=r,this.scheduler.flush(this),this)},t.prototype.execute=function(r,o){return o>0||this.closed?e.prototype.execute.call(this,r,o):this._execute(r,o)},t.prototype.requestAsyncId=function(r,o,n){return n===void 0&&(n=0),n!=null&&n>0||n==null&&this.delay>0?e.prototype.requestAsyncId.call(this,r,o,n):(r.flush(this),0)},t})(St);var Ro=(function(e){ie(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t})(Ot);var Dr=new Ro(Po);var Io=(function(e){ie(t,e);function t(r,o){var n=e.call(this,r,o)||this;return n.scheduler=r,n.work=o,n}return t.prototype.requestAsyncId=function(r,o,n){return n===void 0&&(n=0),n!==null&&n>0?e.prototype.requestAsyncId.call(this,r,o,n):(r.actions.push(this),r._scheduled||(r._scheduled=Tt.requestAnimationFrame(function(){return r.flush(void 0)})))},t.prototype.recycleAsyncId=function(r,o,n){var i;if(n===void 0&&(n=0),n!=null?n>0:this.delay>0)return e.prototype.recycleAsyncId.call(this,r,o,n);var s=r.actions;o!=null&&o===r._scheduled&&((i=s[s.length-1])===null||i===void 0?void 0:i.id)!==o&&(Tt.cancelAnimationFrame(o),r._scheduled=void 0)},t})(St);var Fo=(function(e){ie(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t.prototype.flush=function(r){this._active=!0;var o;r?o=r.id:(o=this._scheduled,this._scheduled=void 0);var n=this.actions,i;r=r||n.shift();do if(i=r.execute(r.state,r.delay))break;while((r=n[0])&&r.id===o&&n.shift());if(this._active=!1,i){for(;(r=n[0])&&r.id===o&&n.shift();)r.unsubscribe();throw i}},t})(Ot);var ye=new Fo(Io);var y=new F(function(e){return e.complete()});function tr(e){return e&&I(e.schedule)}function Vr(e){return e[e.length-1]}function pt(e){return I(Vr(e))?e.pop():void 0}function Fe(e){return tr(Vr(e))?e.pop():void 0}function rr(e,t){return typeof Vr(e)=="number"?e.pop():t}var Lt=(function(e){return e&&typeof e.length=="number"&&typeof e!="function"});function or(e){return I(e==null?void 0:e.then)}function nr(e){return I(e[wt])}function ir(e){return Symbol.asyncIterator&&I(e==null?void 0:e[Symbol.asyncIterator])}function ar(e){return new TypeError("You provided "+(e!==null&&typeof e=="object"?"an invalid object":"'"+e+"'")+" where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.")}function fa(){return typeof Symbol!="function"||!Symbol.iterator?"@@iterator":Symbol.iterator}var sr=fa();function cr(e){return I(e==null?void 0:e[sr])}function pr(e){return wo(this,arguments,function(){var r,o,n,i;return Gt(this,function(s){switch(s.label){case 0:r=e.getReader(),s.label=1;case 1:s.trys.push([1,,9,10]),s.label=2;case 2:return[4,dt(r.read())];case 3:return o=s.sent(),n=o.value,i=o.done,i?[4,dt(void 0)]:[3,5];case 4:return[2,s.sent()];case 5:return[4,dt(n)];case 6:return[4,s.sent()];case 7:return s.sent(),[3,2];case 8:return[3,10];case 9:return r.releaseLock(),[7];case 10:return[2]}})})}function lr(e){return I(e==null?void 0:e.getReader)}function U(e){if(e instanceof F)return e;if(e!=null){if(nr(e))return ua(e);if(Lt(e))return da(e);if(or(e))return ha(e);if(ir(e))return jo(e);if(cr(e))return ba(e);if(lr(e))return va(e)}throw ar(e)}function ua(e){return new F(function(t){var r=e[wt]();if(I(r.subscribe))return r.subscribe(t);throw new TypeError("Provided object does not correctly implement Symbol.observable")})}function da(e){return new F(function(t){for(var r=0;r=2;return function(o){return o.pipe(e?g(function(n,i){return e(n,i,o)}):be,Ee(1),r?Qe(t):tn(function(){return new fr}))}}function Yr(e){return e<=0?function(){return y}:E(function(t,r){var o=[];t.subscribe(w(r,function(n){o.push(n),e=2,!0))}function le(e){e===void 0&&(e={});var t=e.connector,r=t===void 0?function(){return new T}:t,o=e.resetOnError,n=o===void 0?!0:o,i=e.resetOnComplete,s=i===void 0?!0:i,a=e.resetOnRefCountZero,c=a===void 0?!0:a;return function(p){var l,f,u,d=0,v=!1,S=!1,X=function(){f==null||f.unsubscribe(),f=void 0},re=function(){X(),l=u=void 0,v=S=!1},ee=function(){var k=l;re(),k==null||k.unsubscribe()};return E(function(k,ut){d++,!S&&!v&&X();var je=u=u!=null?u:r();ut.add(function(){d--,d===0&&!S&&!v&&(f=Br(ee,c))}),je.subscribe(ut),!l&&d>0&&(l=new bt({next:function(R){return je.next(R)},error:function(R){S=!0,X(),f=Br(re,n,R),je.error(R)},complete:function(){v=!0,X(),f=Br(re,s),je.complete()}}),U(k).subscribe(l))})(p)}}function Br(e,t){for(var r=[],o=2;oe.next(document)),e}function M(e,t=document){return Array.from(t.querySelectorAll(e))}function j(e,t=document){let r=ue(e,t);if(typeof r=="undefined")throw new ReferenceError(`Missing element: expected "${e}" to be present`);return r}function ue(e,t=document){return t.querySelector(e)||void 0}function Ne(){var e,t,r,o;return(o=(r=(t=(e=document.activeElement)==null?void 0:e.shadowRoot)==null?void 0:t.activeElement)!=null?r:document.activeElement)!=null?o:void 0}var Ra=L(h(document.body,"focusin"),h(document.body,"focusout")).pipe(Ae(1),Q(void 0),m(()=>Ne()||document.body),Z(1));function Ye(e){return Ra.pipe(m(t=>e.contains(t)),Y())}function it(e,t){return H(()=>L(h(e,"mouseenter").pipe(m(()=>!0)),h(e,"mouseleave").pipe(m(()=>!1))).pipe(t?jt(r=>He(+!r*t)):be,Q(e.matches(":hover"))))}function sn(e,t){if(typeof t=="string"||typeof t=="number")e.innerHTML+=t.toString();else if(t instanceof Node)e.appendChild(t);else if(Array.isArray(t))for(let r of t)sn(e,r)}function x(e,t,...r){let o=document.createElement(e);if(t)for(let n of Object.keys(t))typeof t[n]!="undefined"&&(typeof t[n]!="boolean"?o.setAttribute(n,t[n]):o.setAttribute(n,""));for(let n of r)sn(o,n);return o}function br(e){if(e>999){let t=+((e-950)%1e3>99);return`${((e+1e-6)/1e3).toFixed(t)}k`}else return e.toString()}function _t(e){let t=x("script",{src:e});return H(()=>(document.head.appendChild(t),L(h(t,"load"),h(t,"error").pipe(b(()=>Nr(()=>new ReferenceError(`Invalid script: ${e}`))))).pipe(m(()=>{}),A(()=>document.head.removeChild(t)),Ee(1))))}var cn=new T,Ia=H(()=>typeof ResizeObserver=="undefined"?_t("https://unpkg.com/resize-observer-polyfill"):$(void 0)).pipe(m(()=>new ResizeObserver(e=>e.forEach(t=>cn.next(t)))),b(e=>L(tt,$(e)).pipe(A(()=>e.disconnect()))),Z(1));function de(e){return{width:e.offsetWidth,height:e.offsetHeight}}function Le(e){let t=e;for(;t.clientWidth===0&&t.parentElement;)t=t.parentElement;return Ia.pipe(O(r=>r.observe(t)),b(r=>cn.pipe(g(o=>o.target===t),A(()=>r.unobserve(t)))),m(()=>de(e)),Q(de(e)))}function At(e){return{width:e.scrollWidth,height:e.scrollHeight}}function vr(e){let t=e.parentElement;for(;t&&(e.scrollWidth<=t.scrollWidth&&e.scrollHeight<=t.scrollHeight);)t=(e=t).parentElement;return t?e:void 0}function pn(e){let t=[],r=e.parentElement;for(;r;)(e.clientWidth>r.clientWidth||e.clientHeight>r.clientHeight)&&t.push(r),r=(e=r).parentElement;return t.length===0&&t.push(document.documentElement),t}function Be(e){return{x:e.offsetLeft,y:e.offsetTop}}function ln(e){let t=e.getBoundingClientRect();return{x:t.x+window.scrollX,y:t.y+window.scrollY}}function mn(e){return L(h(window,"load"),h(window,"resize")).pipe($e(0,ye),m(()=>Be(e)),Q(Be(e)))}function gr(e){return{x:e.scrollLeft,y:e.scrollTop}}function Ge(e){return L(h(e,"scroll"),h(window,"scroll"),h(window,"resize")).pipe($e(0,ye),m(()=>gr(e)),Q(gr(e)))}var fn=new T,Fa=H(()=>$(new IntersectionObserver(e=>{for(let t of e)fn.next(t)},{threshold:0}))).pipe(b(e=>L(tt,$(e)).pipe(A(()=>e.disconnect()))),Z(1));function mt(e){return Fa.pipe(O(t=>t.observe(e)),b(t=>fn.pipe(g(({target:r})=>r===e),A(()=>t.unobserve(e)),m(({isIntersecting:r})=>r))))}function un(e,t=16){return Ge(e).pipe(m(({y:r})=>{let o=de(e),n=At(e);return r>=n.height-o.height-t}),Y())}var yr={drawer:j("[data-md-toggle=drawer]"),search:j("[data-md-toggle=search]")};function dn(e){return yr[e].checked}function at(e,t){yr[e].checked!==t&&yr[e].click()}function Je(e){let t=yr[e];return h(t,"change").pipe(m(()=>t.checked),Q(t.checked))}function ja(e,t){switch(e.constructor){case HTMLInputElement:return e.type==="radio"?/^Arrow/.test(t):!0;case HTMLSelectElement:case HTMLTextAreaElement:return!0;default:return e.isContentEditable}}function Ua(){return L(h(window,"compositionstart").pipe(m(()=>!0)),h(window,"compositionend").pipe(m(()=>!1))).pipe(Q(!1))}function hn(){let e=h(window,"keydown").pipe(g(t=>!(t.metaKey||t.ctrlKey)),m(t=>({mode:dn("search")?"search":"global",type:t.key,claim(){t.preventDefault(),t.stopPropagation()}})),g(({mode:t,type:r})=>{if(t==="global"){let o=Ne();if(typeof o!="undefined")return!ja(o,r)}return!0}),le());return Ua().pipe(b(t=>t?y:e))}function we(){return new URL(location.href)}function st(e,t=!1){if(V("navigation.instant")&&!t){let r=x("a",{href:e.href});document.body.appendChild(r),r.click(),r.remove()}else location.href=e.href}function bn(){return new T}function vn(){return location.hash.slice(1)}function gn(e){let t=x("a",{href:e});t.addEventListener("click",r=>r.stopPropagation()),t.click()}function Zr(e){return L(h(window,"hashchange"),e).pipe(m(vn),Q(vn()),g(t=>t.length>0),Z(1))}function yn(e){return Zr(e).pipe(m(t=>ue(`[id="${t}"]`)),g(t=>typeof t!="undefined"))}function Wt(e){let t=matchMedia(e);return ur(r=>t.addListener(()=>r(t.matches))).pipe(Q(t.matches))}function xn(){let e=matchMedia("print");return L(h(window,"beforeprint").pipe(m(()=>!0)),h(window,"afterprint").pipe(m(()=>!1))).pipe(Q(e.matches))}function eo(e,t){return e.pipe(b(r=>r?t():y))}function to(e,t){return new F(r=>{let o=new XMLHttpRequest;return o.open("GET",`${e}`),o.responseType="blob",o.addEventListener("load",()=>{o.status>=200&&o.status<300?(r.next(o.response),r.complete()):r.error(new Error(o.statusText))}),o.addEventListener("error",()=>{r.error(new Error("Network error"))}),o.addEventListener("abort",()=>{r.complete()}),typeof(t==null?void 0:t.progress$)!="undefined"&&(o.addEventListener("progress",n=>{var i;if(n.lengthComputable)t.progress$.next(n.loaded/n.total*100);else{let s=(i=o.getResponseHeader("Content-Length"))!=null?i:0;t.progress$.next(n.loaded/+s*100)}}),t.progress$.next(5)),o.send(),()=>o.abort()})}function ze(e,t){return to(e,t).pipe(b(r=>r.text()),m(r=>JSON.parse(r)),Z(1))}function xr(e,t){let r=new DOMParser;return to(e,t).pipe(b(o=>o.text()),m(o=>r.parseFromString(o,"text/html")),Z(1))}function En(e,t){let r=new DOMParser;return to(e,t).pipe(b(o=>o.text()),m(o=>r.parseFromString(o,"text/xml")),Z(1))}function wn(){return{x:Math.max(0,scrollX),y:Math.max(0,scrollY)}}function Tn(){return L(h(window,"scroll",{passive:!0}),h(window,"resize",{passive:!0})).pipe(m(wn),Q(wn()))}function Sn(){return{width:innerWidth,height:innerHeight}}function On(){return h(window,"resize",{passive:!0}).pipe(m(Sn),Q(Sn()))}function Ln(){return z([Tn(),On()]).pipe(m(([e,t])=>({offset:e,size:t})),Z(1))}function Er(e,{viewport$:t,header$:r}){let o=t.pipe(ne("size")),n=z([o,r]).pipe(m(()=>Be(e)));return z([r,t,n]).pipe(m(([{height:i},{offset:s,size:a},{x:c,y:p}])=>({offset:{x:s.x-c,y:s.y-p+i},size:a})))}function Wa(e){return h(e,"message",t=>t.data)}function Da(e){let t=new T;return t.subscribe(r=>e.postMessage(r)),t}function Mn(e,t=new Worker(e)){let r=Wa(t),o=Da(t),n=new T;n.subscribe(o);let i=o.pipe(oe(),ae(!0));return n.pipe(oe(),Ve(r.pipe(W(i))),le())}var Va=j("#__config"),Ct=JSON.parse(Va.textContent);Ct.base=`${new URL(Ct.base,we())}`;function Te(){return Ct}function V(e){return Ct.features.includes(e)}function Me(e,t){return typeof t!="undefined"?Ct.translations[e].replace("#",t.toString()):Ct.translations[e]}function Ce(e,t=document){return j(`[data-md-component=${e}]`,t)}function me(e,t=document){return M(`[data-md-component=${e}]`,t)}function Na(e){let t=j(".md-typeset > :first-child",e);return h(t,"click",{once:!0}).pipe(m(()=>j(".md-typeset",e)),m(r=>({hash:__md_hash(r.innerHTML)})))}function _n(e){if(!V("announce.dismiss")||!e.childElementCount)return y;if(!e.hidden){let t=j(".md-typeset",e);__md_hash(t.innerHTML)===__md_get("__announce")&&(e.hidden=!0)}return H(()=>{let t=new T;return t.subscribe(({hash:r})=>{e.hidden=!0,__md_set("__announce",r)}),Na(e).pipe(O(r=>t.next(r)),A(()=>t.complete()),m(r=>P({ref:e},r)))})}function za(e,{target$:t}){return t.pipe(m(r=>({hidden:r!==e})))}function An(e,t){let r=new T;return r.subscribe(({hidden:o})=>{e.hidden=o}),za(e,t).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))}function Dt(e,t){return t==="inline"?x("div",{class:"md-tooltip md-tooltip--inline",id:e,role:"tooltip"},x("div",{class:"md-tooltip__inner md-typeset"})):x("div",{class:"md-tooltip",id:e,role:"tooltip"},x("div",{class:"md-tooltip__inner md-typeset"}))}function wr(...e){return x("div",{class:"md-tooltip2",role:"dialog"},x("div",{class:"md-tooltip2__inner md-typeset"},e))}function Cn(...e){return x("div",{class:"md-tooltip2",role:"tooltip"},x("div",{class:"md-tooltip2__inner md-typeset"},e))}function kn(e,t){if(t=t?`${t}_annotation_${e}`:void 0,t){let r=t?`#${t}`:void 0;return x("aside",{class:"md-annotation",tabIndex:0},Dt(t),x("a",{href:r,class:"md-annotation__index",tabIndex:-1},x("span",{"data-md-annotation-id":e})))}else return x("aside",{class:"md-annotation",tabIndex:0},Dt(t),x("span",{class:"md-annotation__index",tabIndex:-1},x("span",{"data-md-annotation-id":e})))}function Hn(e){return x("button",{class:"md-code__button",title:Me("clipboard.copy"),"data-clipboard-target":`#${e} > code`,"data-md-type":"copy"})}function $n(){return x("button",{class:"md-code__button",title:"Toggle line selection","data-md-type":"select"})}function Pn(){return x("nav",{class:"md-code__nav"})}var In=$t(ro());function oo(e,t){let r=t&2,o=t&1,n=Object.keys(e.terms).filter(c=>!e.terms[c]).reduce((c,p)=>[...c,x("del",null,(0,In.default)(p))," "],[]).slice(0,-1),i=Te(),s=new URL(e.location,i.base);V("search.highlight")&&s.searchParams.set("h",Object.entries(e.terms).filter(([,c])=>c).reduce((c,[p])=>`${c} ${p}`.trim(),""));let{tags:a}=Te();return x("a",{href:`${s}`,class:"md-search-result__link",tabIndex:-1},x("article",{class:"md-search-result__article md-typeset","data-md-score":e.score.toFixed(2)},r>0&&x("div",{class:"md-search-result__icon md-icon"}),r>0&&x("h1",null,e.title),r<=0&&x("h2",null,e.title),o>0&&e.text.length>0&&e.text,e.tags&&x("nav",{class:"md-tags"},e.tags.map(c=>{let p=a?c in a?`md-tag-icon md-tag--${a[c]}`:"md-tag-icon":"";return x("span",{class:`md-tag ${p}`},c)})),o>0&&n.length>0&&x("p",{class:"md-search-result__terms"},Me("search.result.term.missing"),": ",...n)))}function Fn(e){let t=e[0].score,r=[...e],o=Te(),n=r.findIndex(l=>!`${new URL(l.location,o.base)}`.includes("#")),[i]=r.splice(n,1),s=r.findIndex(l=>l.scoreoo(l,1)),...c.length?[x("details",{class:"md-search-result__more"},x("summary",{tabIndex:-1},x("div",null,c.length>0&&c.length===1?Me("search.result.more.one"):Me("search.result.more.other",c.length))),...c.map(l=>oo(l,1)))]:[]];return x("li",{class:"md-search-result__item"},p)}function jn(e){return x("ul",{class:"md-source__facts"},Object.entries(e).map(([t,r])=>x("li",{class:`md-source__fact md-source__fact--${t}`},typeof r=="number"?br(r):r)))}function no(e){let t=`tabbed-control tabbed-control--${e}`;return x("div",{class:t,hidden:!0},x("button",{class:"tabbed-button",tabIndex:-1,"aria-hidden":"true"}))}function Un(e){return x("div",{class:"md-typeset__scrollwrap"},x("div",{class:"md-typeset__table"},e))}function Qa(e){var o;let t=Te(),r=new URL(`../${e.version}/`,t.base);return x("li",{class:"md-version__item"},x("a",{href:`${r}`,class:"md-version__link"},e.title,((o=t.version)==null?void 0:o.alias)&&e.aliases.length>0&&x("span",{class:"md-version__alias"},e.aliases[0])))}function Wn(e,t){var o;let r=Te();return e=e.filter(n=>{var i;return!((i=n.properties)!=null&&i.hidden)}),x("div",{class:"md-version"},x("button",{class:"md-version__current","aria-label":Me("select.version")},t.title,((o=r.version)==null?void 0:o.alias)&&t.aliases.length>0&&x("span",{class:"md-version__alias"},t.aliases[0])),x("ul",{class:"md-version__list"},e.map(Qa)))}var Ya=0;function Ba(e,t=250){let r=z([Ye(e),it(e,t)]).pipe(m(([n,i])=>n||i),Y()),o=H(()=>pn(e)).pipe(J(Ge),gt(1),Pe(r),m(()=>ln(e)));return r.pipe(Re(n=>n),b(()=>z([r,o])),m(([n,i])=>({active:n,offset:i})),le())}function Vt(e,t,r=250){let{content$:o,viewport$:n}=t,i=`__tooltip2_${Ya++}`;return H(()=>{let s=new T,a=new jr(!1);s.pipe(oe(),ae(!1)).subscribe(a);let c=a.pipe(jt(l=>He(+!l*250,Dr)),Y(),b(l=>l?o:y),O(l=>l.id=i),le());z([s.pipe(m(({active:l})=>l)),c.pipe(b(l=>it(l,250)),Q(!1))]).pipe(m(l=>l.some(f=>f))).subscribe(a);let p=a.pipe(g(l=>l),te(c,n),m(([l,f,{size:u}])=>{let d=e.getBoundingClientRect(),v=d.width/2;if(f.role==="tooltip")return{x:v,y:8+d.height};if(d.y>=u.height/2){let{height:S}=de(f);return{x:v,y:-16-S}}else return{x:v,y:16+d.height}}));return z([c,s,p]).subscribe(([l,{offset:f},u])=>{l.style.setProperty("--md-tooltip-host-x",`${f.x}px`),l.style.setProperty("--md-tooltip-host-y",`${f.y}px`),l.style.setProperty("--md-tooltip-x",`${u.x}px`),l.style.setProperty("--md-tooltip-y",`${u.y}px`),l.classList.toggle("md-tooltip2--top",u.y<0),l.classList.toggle("md-tooltip2--bottom",u.y>=0)}),a.pipe(g(l=>l),te(c,(l,f)=>f),g(l=>l.role==="tooltip")).subscribe(l=>{let f=de(j(":scope > *",l));l.style.setProperty("--md-tooltip-width",`${f.width}px`),l.style.setProperty("--md-tooltip-tail","0px")}),a.pipe(Y(),xe(ye),te(c)).subscribe(([l,f])=>{f.classList.toggle("md-tooltip2--active",l)}),z([a.pipe(g(l=>l)),c]).subscribe(([l,f])=>{f.role==="dialog"?(e.setAttribute("aria-controls",i),e.setAttribute("aria-haspopup","dialog")):e.setAttribute("aria-describedby",i)}),a.pipe(g(l=>!l)).subscribe(()=>{e.removeAttribute("aria-controls"),e.removeAttribute("aria-describedby"),e.removeAttribute("aria-haspopup")}),Ba(e,r).pipe(O(l=>s.next(l)),A(()=>s.complete()),m(l=>P({ref:e},l)))})}function Xe(e,{viewport$:t},r=document.body){return Vt(e,{content$:new F(o=>{let n=e.title,i=Cn(n);return o.next(i),e.removeAttribute("title"),r.append(i),()=>{i.remove(),e.setAttribute("title",n)}}),viewport$:t},0)}function Ga(e,t){let r=H(()=>z([mn(e),Ge(t)])).pipe(m(([{x:o,y:n},i])=>{let{width:s,height:a}=de(e);return{x:o-i.x+s/2,y:n-i.y+a/2}}));return Ye(e).pipe(b(o=>r.pipe(m(n=>({active:o,offset:n})),Ee(+!o||1/0))))}function Dn(e,t,{target$:r}){let[o,n]=Array.from(e.children);return H(()=>{let i=new T,s=i.pipe(oe(),ae(!0));return i.subscribe({next({offset:a}){e.style.setProperty("--md-tooltip-x",`${a.x}px`),e.style.setProperty("--md-tooltip-y",`${a.y}px`)},complete(){e.style.removeProperty("--md-tooltip-x"),e.style.removeProperty("--md-tooltip-y")}}),mt(e).pipe(W(s)).subscribe(a=>{e.toggleAttribute("data-md-visible",a)}),L(i.pipe(g(({active:a})=>a)),i.pipe(Ae(250),g(({active:a})=>!a))).subscribe({next({active:a}){a?e.prepend(o):o.remove()},complete(){e.prepend(o)}}),i.pipe($e(16,ye)).subscribe(({active:a})=>{o.classList.toggle("md-tooltip--active",a)}),i.pipe(gt(125,ye),g(()=>!!e.offsetParent),m(()=>e.offsetParent.getBoundingClientRect()),m(({x:a})=>a)).subscribe({next(a){a?e.style.setProperty("--md-tooltip-0",`${-a}px`):e.style.removeProperty("--md-tooltip-0")},complete(){e.style.removeProperty("--md-tooltip-0")}}),h(n,"click").pipe(W(s),g(a=>!(a.metaKey||a.ctrlKey))).subscribe(a=>{a.stopPropagation(),a.preventDefault()}),h(n,"mousedown").pipe(W(s),te(i)).subscribe(([a,{active:c}])=>{var p;if(a.button!==0||a.metaKey||a.ctrlKey)a.preventDefault();else if(c){a.preventDefault();let l=e.parentElement.closest(".md-annotation");l instanceof HTMLElement?l.focus():(p=Ne())==null||p.blur()}}),r.pipe(W(s),g(a=>a===o),nt(125)).subscribe(()=>e.focus()),Ga(e,t).pipe(O(a=>i.next(a)),A(()=>i.complete()),m(a=>P({ref:e},a)))})}function Ja(e){let t=Te();if(e.tagName!=="CODE")return[e];let r=[".c",".c1",".cm"];if(t.annotate&&typeof t.annotate=="object"){let o=e.closest("[class|=language]");if(o)for(let n of Array.from(o.classList)){if(!n.startsWith("language-"))continue;let[,i]=n.split("-");i in t.annotate&&r.push(...t.annotate[i])}}return M(r.join(", "),e)}function Xa(e){let t=[];for(let r of Ja(e)){let o=[],n=document.createNodeIterator(r,NodeFilter.SHOW_TEXT);for(let i=n.nextNode();i;i=n.nextNode())o.push(i);for(let i of o){let s;for(;s=/(\(\d+\))(!)?/.exec(i.textContent);){let[,a,c]=s;if(typeof c=="undefined"){let p=i.splitText(s.index);i=p.splitText(a.length),t.push(p)}else{i.textContent=a,t.push(i);break}}}}return t}function Vn(e,t){t.append(...Array.from(e.childNodes))}function Tr(e,t,{target$:r,print$:o}){let n=t.closest("[id]"),i=n==null?void 0:n.id,s=new Map;for(let a of Xa(t)){let[,c]=a.textContent.match(/\((\d+)\)/);ue(`:scope > li:nth-child(${c})`,e)&&(s.set(c,kn(c,i)),a.replaceWith(s.get(c)))}return s.size===0?y:H(()=>{let a=new T,c=a.pipe(oe(),ae(!0)),p=[];for(let[l,f]of s)p.push([j(".md-typeset",f),j(`:scope > li:nth-child(${l})`,e)]);return o.pipe(W(c)).subscribe(l=>{e.hidden=!l,e.classList.toggle("md-annotation-list",l);for(let[f,u]of p)l?Vn(f,u):Vn(u,f)}),L(...[...s].map(([,l])=>Dn(l,t,{target$:r}))).pipe(A(()=>a.complete()),le())})}function Nn(e){if(e.nextElementSibling){let t=e.nextElementSibling;if(t.tagName==="OL")return t;if(t.tagName==="P"&&!t.children.length)return Nn(t)}}function zn(e,t){return H(()=>{let r=Nn(e);return typeof r!="undefined"?Tr(r,e,t):y})}var Kn=$t(ao());var Za=0,qn=L(h(window,"keydown").pipe(m(()=>!0)),L(h(window,"keyup"),h(window,"contextmenu")).pipe(m(()=>!1))).pipe(Q(!1),Z(1));function Qn(e){if(e.nextElementSibling){let t=e.nextElementSibling;if(t.tagName==="OL")return t;if(t.tagName==="P"&&!t.children.length)return Qn(t)}}function es(e){return Le(e).pipe(m(({width:t})=>({scrollable:At(e).width>t})),ne("scrollable"))}function Yn(e,t){let{matches:r}=matchMedia("(hover)"),o=H(()=>{let n=new T,i=n.pipe(Yr(1));n.subscribe(({scrollable:d})=>{d&&r?e.setAttribute("tabindex","0"):e.removeAttribute("tabindex")});let s=[],a=e.closest("pre"),c=a.closest("[id]"),p=c?c.id:Za++;a.id=`__code_${p}`;let l=[],f=e.closest(".highlight");if(f instanceof HTMLElement){let d=Qn(f);if(typeof d!="undefined"&&(f.classList.contains("annotate")||V("content.code.annotate"))){let v=Tr(d,e,t);l.push(Le(f).pipe(W(i),m(({width:S,height:X})=>S&&X),Y(),b(S=>S?v:y)))}}let u=M(":scope > span[id]",e);if(u.length&&(e.classList.add("md-code__content"),e.closest(".select")||V("content.code.select")&&!e.closest(".no-select"))){let d=+u[0].id.split("-").pop(),v=$n();s.push(v),V("content.tooltips")&&l.push(Xe(v,{viewport$}));let S=h(v,"click").pipe(Ut(R=>!R,!1),O(()=>v.blur()),le());S.subscribe(R=>{v.classList.toggle("md-code__button--active",R)});let X=fe(u).pipe(J(R=>it(R).pipe(m(se=>[R,se]))));S.pipe(b(R=>R?X:y)).subscribe(([R,se])=>{let ce=ue(".hll.select",R);if(ce&&!se)ce.replaceWith(...Array.from(ce.childNodes));else if(!ce&&se){let he=document.createElement("span");he.className="hll select",he.append(...Array.from(R.childNodes).slice(1)),R.append(he)}});let re=fe(u).pipe(J(R=>h(R,"mousedown").pipe(O(se=>se.preventDefault()),m(()=>R)))),ee=S.pipe(b(R=>R?re:y),te(qn),m(([R,se])=>{var he;let ce=u.indexOf(R)+d;if(se===!1)return[ce,ce];{let Se=M(".hll",e).map(Ue=>u.indexOf(Ue.parentElement)+d);return(he=window.getSelection())==null||he.removeAllRanges(),[Math.min(ce,...Se),Math.max(ce,...Se)]}})),k=Zr(y).pipe(g(R=>R.startsWith(`__codelineno-${p}-`)));k.subscribe(R=>{let[,,se]=R.split("-"),ce=se.split(":").map(Se=>+Se-d+1);ce.length===1&&ce.push(ce[0]);for(let Se of M(".hll:not(.select)",e))Se.replaceWith(...Array.from(Se.childNodes));let he=u.slice(ce[0]-1,ce[1]);for(let Se of he){let Ue=document.createElement("span");Ue.className="hll",Ue.append(...Array.from(Se.childNodes).slice(1)),Se.append(Ue)}}),k.pipe(Ee(1),xe(pe)).subscribe(R=>{if(R.includes(":")){let se=document.getElementById(R.split(":")[0]);se&&setTimeout(()=>{let ce=se,he=-64;for(;ce!==document.body;)he+=ce.offsetTop,ce=ce.offsetParent;window.scrollTo({top:he})},1)}});let je=fe(M('a[href^="#__codelineno"]',f)).pipe(J(R=>h(R,"click").pipe(O(se=>se.preventDefault()),m(()=>R)))).pipe(W(i),te(qn),m(([R,se])=>{let he=+j(`[id="${R.hash.slice(1)}"]`).parentElement.id.split("-").pop();if(se===!1)return[he,he];{let Se=M(".hll",e).map(Ue=>+Ue.parentElement.id.split("-").pop());return[Math.min(he,...Se),Math.max(he,...Se)]}}));L(ee,je).subscribe(R=>{let se=`#__codelineno-${p}-`;R[0]===R[1]?se+=R[0]:se+=`${R[0]}:${R[1]}`,history.replaceState({},"",se),window.dispatchEvent(new HashChangeEvent("hashchange",{newURL:window.location.origin+window.location.pathname+se,oldURL:window.location.href}))})}if(Kn.default.isSupported()&&(e.closest(".copy")||V("content.code.copy")&&!e.closest(".no-copy"))){let d=Hn(a.id);s.push(d),V("content.tooltips")&&l.push(Xe(d,{viewport$}))}if(s.length){let d=Pn();d.append(...s),a.insertBefore(d,e)}return es(e).pipe(O(d=>n.next(d)),A(()=>n.complete()),m(d=>P({ref:e},d)),Ve(L(...l).pipe(W(i))))});return V("content.lazy")?mt(e).pipe(g(n=>n),Ee(1),b(()=>o)):o}function ts(e,{target$:t,print$:r}){let o=!0;return L(t.pipe(m(n=>n.closest("details:not([open])")),g(n=>e===n),m(()=>({action:"open",reveal:!0}))),r.pipe(g(n=>n||!o),O(()=>o=e.open),m(n=>({action:n?"open":"close"}))))}function Bn(e,t){return H(()=>{let r=new T;return r.subscribe(({action:o,reveal:n})=>{e.toggleAttribute("open",o==="open"),n&&e.scrollIntoView()}),ts(e,t).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))})}var Gn=0;function rs(e){let t=document.createElement("h3");t.innerHTML=e.innerHTML;let r=[t],o=e.nextElementSibling;for(;o&&!(o instanceof HTMLHeadingElement);)r.push(o),o=o.nextElementSibling;return r}function os(e,t){for(let r of M("[href], [src]",e))for(let o of["href","src"]){let n=r.getAttribute(o);if(n&&!/^(?:[a-z]+:)?\/\//i.test(n)){r[o]=new URL(r.getAttribute(o),t).toString();break}}for(let r of M("[name^=__], [for]",e))for(let o of["id","for","name"]){let n=r.getAttribute(o);n&&r.setAttribute(o,`${n}$preview_${Gn}`)}return Gn++,$(e)}function Jn(e,t){let{sitemap$:r}=t;if(!(e instanceof HTMLAnchorElement))return y;if(!(V("navigation.instant.preview")||e.hasAttribute("data-preview")))return y;e.removeAttribute("title");let o=z([Ye(e),it(e)]).pipe(m(([i,s])=>i||s),Y(),g(i=>i));return rt([r,o]).pipe(b(([i])=>{let s=new URL(e.href);return s.search=s.hash="",i.has(`${s}`)?$(s):y}),b(i=>xr(i).pipe(b(s=>os(s,i)))),b(i=>{let s=e.hash?`article [id="${e.hash.slice(1)}"]`:"article h1",a=ue(s,i);return typeof a=="undefined"?y:$(rs(a))})).pipe(b(i=>{let s=new F(a=>{let c=wr(...i);return a.next(c),document.body.append(c),()=>c.remove()});return Vt(e,P({content$:s},t))}))}var Xn=".node circle,.node ellipse,.node path,.node polygon,.node rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}marker{fill:var(--md-mermaid-edge-color)!important}.edgeLabel .label rect{fill:#0000}.flowchartTitleText{fill:var(--md-mermaid-label-fg-color)}.label{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.label foreignObject{line-height:normal;overflow:visible}.label div .edgeLabel{color:var(--md-mermaid-label-fg-color)}.edgeLabel,.edgeLabel p,.label div .edgeLabel{background-color:var(--md-mermaid-label-bg-color)}.edgeLabel,.edgeLabel p{fill:var(--md-mermaid-label-bg-color);color:var(--md-mermaid-edge-color)}.edgePath .path,.flowchart-link{stroke:var(--md-mermaid-edge-color)}.edgePath .arrowheadPath{fill:var(--md-mermaid-edge-color);stroke:none}.cluster rect{fill:var(--md-default-fg-color--lightest);stroke:var(--md-default-fg-color--lighter)}.cluster span{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}g #flowchart-circleEnd,g #flowchart-circleStart,g #flowchart-crossEnd,g #flowchart-crossStart,g #flowchart-pointEnd,g #flowchart-pointStart{stroke:none}.classDiagramTitleText{fill:var(--md-mermaid-label-fg-color)}g.classGroup line,g.classGroup rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}g.classGroup text{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.classLabel .box{fill:var(--md-mermaid-label-bg-color);background-color:var(--md-mermaid-label-bg-color);opacity:1}.classLabel .label{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.node .divider{stroke:var(--md-mermaid-node-fg-color)}.relation{stroke:var(--md-mermaid-edge-color)}.cardinality{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.cardinality text{fill:inherit!important}defs marker.marker.composition.class path,defs marker.marker.dependency.class path,defs marker.marker.extension.class path{fill:var(--md-mermaid-edge-color)!important;stroke:var(--md-mermaid-edge-color)!important}defs marker.marker.aggregation.class path{fill:var(--md-mermaid-label-bg-color)!important;stroke:var(--md-mermaid-edge-color)!important}.statediagramTitleText{fill:var(--md-mermaid-label-fg-color)}g.stateGroup rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}g.stateGroup .state-title{fill:var(--md-mermaid-label-fg-color)!important;font-family:var(--md-mermaid-font-family)}g.stateGroup .composit{fill:var(--md-mermaid-label-bg-color)}.nodeLabel,.nodeLabel p{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}a .nodeLabel{text-decoration:underline}.node circle.state-end,.node circle.state-start,.start-state{fill:var(--md-mermaid-edge-color);stroke:none}.end-state-inner,.end-state-outer{fill:var(--md-mermaid-edge-color)}.end-state-inner,.node circle.state-end{stroke:var(--md-mermaid-label-bg-color)}.transition{stroke:var(--md-mermaid-edge-color)}[id^=state-fork] rect,[id^=state-join] rect{fill:var(--md-mermaid-edge-color)!important;stroke:none!important}.statediagram-cluster.statediagram-cluster .inner{fill:var(--md-default-bg-color)}.statediagram-cluster rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}.statediagram-state rect.divider{fill:var(--md-default-fg-color--lightest);stroke:var(--md-default-fg-color--lighter)}defs #statediagram-barbEnd{stroke:var(--md-mermaid-edge-color)}[id^=entity] path,[id^=entity] rect{fill:var(--md-default-bg-color)}.relationshipLine{stroke:var(--md-mermaid-edge-color)}defs .marker.oneOrMore.er *,defs .marker.onlyOne.er *,defs .marker.zeroOrMore.er *,defs .marker.zeroOrOne.er *{stroke:var(--md-mermaid-edge-color)!important}text:not([class]):last-child{fill:var(--md-mermaid-label-fg-color)}.actor{fill:var(--md-mermaid-sequence-actor-bg-color);stroke:var(--md-mermaid-sequence-actor-border-color)}text.actor>tspan{fill:var(--md-mermaid-sequence-actor-fg-color);font-family:var(--md-mermaid-font-family)}line{stroke:var(--md-mermaid-sequence-actor-line-color)}.actor-man circle,.actor-man line{fill:var(--md-mermaid-sequence-actorman-bg-color);stroke:var(--md-mermaid-sequence-actorman-line-color)}.messageLine0,.messageLine1{stroke:var(--md-mermaid-sequence-message-line-color)}.note{fill:var(--md-mermaid-sequence-note-bg-color);stroke:var(--md-mermaid-sequence-note-border-color)}.loopText,.loopText>tspan,.messageText,.noteText>tspan{stroke:none;font-family:var(--md-mermaid-font-family)!important}.messageText{fill:var(--md-mermaid-sequence-message-fg-color)}.loopText,.loopText>tspan{fill:var(--md-mermaid-sequence-loop-fg-color)}.noteText>tspan{fill:var(--md-mermaid-sequence-note-fg-color)}#arrowhead path{fill:var(--md-mermaid-sequence-message-line-color);stroke:none}.loopLine{fill:var(--md-mermaid-sequence-loop-bg-color);stroke:var(--md-mermaid-sequence-loop-border-color)}.labelBox{fill:var(--md-mermaid-sequence-label-bg-color);stroke:none}.labelText,.labelText>span{fill:var(--md-mermaid-sequence-label-fg-color);font-family:var(--md-mermaid-font-family)}.sequenceNumber{fill:var(--md-mermaid-sequence-number-fg-color)}rect.rect{fill:var(--md-mermaid-sequence-box-bg-color);stroke:none}rect.rect+text.text{fill:var(--md-mermaid-sequence-box-fg-color)}defs #sequencenumber{fill:var(--md-mermaid-sequence-number-bg-color)!important}";var so,is=0;function as(){return typeof mermaid=="undefined"||mermaid instanceof Element?_t("https://unpkg.com/mermaid@11/dist/mermaid.min.js"):$(void 0)}function Zn(e){return e.classList.remove("mermaid"),so||(so=as().pipe(O(()=>mermaid.initialize({startOnLoad:!1,themeCSS:Xn,sequence:{actorFontSize:"16px",messageFontSize:"16px",noteFontSize:"16px"}})),m(()=>{}),Z(1))),so.subscribe(()=>go(null,null,function*(){e.classList.add("mermaid");let t=`__mermaid_${is++}`,r=x("div",{class:"mermaid"}),o=e.textContent,{svg:n,fn:i}=yield mermaid.render(t,o),s=r.attachShadow({mode:"closed"});s.innerHTML=n,e.replaceWith(r),i==null||i(s)})),so.pipe(m(()=>({ref:e})))}var ei=x("table");function ti(e){return e.replaceWith(ei),ei.replaceWith(Un(e)),$({ref:e})}function ss(e){let t=e.find(r=>r.checked)||e[0];return L(...e.map(r=>h(r,"change").pipe(m(()=>j(`label[for="${r.id}"]`))))).pipe(Q(j(`label[for="${t.id}"]`)),m(r=>({active:r})))}function ri(e,{viewport$:t,target$:r}){let o=j(".tabbed-labels",e),n=M(":scope > input",e),i=no("prev");e.append(i);let s=no("next");return e.append(s),H(()=>{let a=new T,c=a.pipe(oe(),ae(!0));z([a,Le(e),mt(e)]).pipe(W(c),$e(1,ye)).subscribe({next([{active:p},l]){let f=Be(p),{width:u}=de(p);e.style.setProperty("--md-indicator-x",`${f.x}px`),e.style.setProperty("--md-indicator-width",`${u}px`);let d=gr(o);(f.xd.x+l.width)&&o.scrollTo({left:Math.max(0,f.x-16),behavior:"smooth"})},complete(){e.style.removeProperty("--md-indicator-x"),e.style.removeProperty("--md-indicator-width")}}),z([Ge(o),Le(o)]).pipe(W(c)).subscribe(([p,l])=>{let f=At(o);i.hidden=p.x<16,s.hidden=p.x>f.width-l.width-16}),L(h(i,"click").pipe(m(()=>-1)),h(s,"click").pipe(m(()=>1))).pipe(W(c)).subscribe(p=>{let{width:l}=de(o);o.scrollBy({left:l*p,behavior:"smooth"})}),r.pipe(W(c),g(p=>n.includes(p))).subscribe(p=>p.click()),o.classList.add("tabbed-labels--linked");for(let p of n){let l=j(`label[for="${p.id}"]`);l.replaceChildren(x("a",{href:`#${l.htmlFor}`,tabIndex:-1},...Array.from(l.childNodes))),h(l.firstElementChild,"click").pipe(W(c),g(f=>!(f.metaKey||f.ctrlKey)),O(f=>{f.preventDefault(),f.stopPropagation()})).subscribe(()=>{history.replaceState({},"",`#${l.htmlFor}`),l.click()})}return V("content.tabs.link")&&a.pipe(Ie(1),te(t)).subscribe(([{active:p},{offset:l}])=>{let f=p.innerText.trim();if(p.hasAttribute("data-md-switching"))p.removeAttribute("data-md-switching");else{let u=e.offsetTop-l.y;for(let v of M("[data-tabs]"))for(let S of M(":scope > input",v)){let X=j(`label[for="${S.id}"]`);if(X!==p&&X.innerText.trim()===f){X.setAttribute("data-md-switching",""),S.click();break}}window.scrollTo({top:e.offsetTop-u});let d=__md_get("__tabs")||[];__md_set("__tabs",[...new Set([f,...d])])}}),a.pipe(W(c)).subscribe(()=>{for(let p of M("audio, video",e))p.offsetWidth&&p.autoplay?p.play().catch(()=>{}):p.pause()}),ss(n).pipe(O(p=>a.next(p)),A(()=>a.complete()),m(p=>P({ref:e},p)))}).pipe(et(pe))}function oi(e,t){let{viewport$:r,target$:o,print$:n}=t;return L(...M(".annotate:not(.highlight)",e).map(i=>zn(i,{target$:o,print$:n})),...M("pre:not(.mermaid) > code",e).map(i=>Yn(i,{target$:o,print$:n})),...M("a",e).map(i=>Jn(i,t)),...M("pre.mermaid",e).map(i=>Zn(i)),...M("table:not([class])",e).map(i=>ti(i)),...M("details",e).map(i=>Bn(i,{target$:o,print$:n})),...M("[data-tabs]",e).map(i=>ri(i,{viewport$:r,target$:o})),...M("[title]:not([data-preview])",e).filter(()=>V("content.tooltips")).map(i=>Xe(i,{viewport$:r})),...M(".footnote-ref",e).filter(()=>V("content.footnote.tooltips")).map(i=>Vt(i,{content$:new F(s=>{let a=new URL(i.href).hash.slice(1),c=Array.from(document.getElementById(a).cloneNode(!0).children),p=wr(...c);return s.next(p),document.body.append(p),()=>p.remove()}),viewport$:r})))}function cs(e,{alert$:t}){return t.pipe(b(r=>L($(!0),$(!1).pipe(nt(2e3))).pipe(m(o=>({message:r,active:o})))))}function ni(e,t){let r=j(".md-typeset",e);return H(()=>{let o=new T;return o.subscribe(({message:n,active:i})=>{e.classList.toggle("md-dialog--active",i),r.textContent=n}),cs(e,t).pipe(O(n=>o.next(n)),A(()=>o.complete()),m(n=>P({ref:e},n)))})}var ps=0;function ls(e,t){document.body.append(e);let{width:r}=de(e);e.style.setProperty("--md-tooltip-width",`${r}px`),e.remove();let o=vr(t),n=typeof o!="undefined"?Ge(o):$({x:0,y:0}),i=L(Ye(t),it(t)).pipe(Y());return z([i,n]).pipe(m(([s,a])=>{let{x:c,y:p}=Be(t),l=de(t),f=t.closest("table");return f&&t.parentElement&&(c+=f.offsetLeft+t.parentElement.offsetLeft,p+=f.offsetTop+t.parentElement.offsetTop),{active:s,offset:{x:c-a.x+l.width/2-r/2,y:p-a.y+l.height+8}}}))}function ii(e){let t=e.title;if(!t.length)return y;let r=`__tooltip_${ps++}`,o=Dt(r,"inline"),n=j(".md-typeset",o);return n.innerHTML=t,H(()=>{let i=new T;return i.subscribe({next({offset:s}){o.style.setProperty("--md-tooltip-x",`${s.x}px`),o.style.setProperty("--md-tooltip-y",`${s.y}px`)},complete(){o.style.removeProperty("--md-tooltip-x"),o.style.removeProperty("--md-tooltip-y")}}),L(i.pipe(g(({active:s})=>s)),i.pipe(Ae(250),g(({active:s})=>!s))).subscribe({next({active:s}){s?(e.insertAdjacentElement("afterend",o),e.setAttribute("aria-describedby",r),e.removeAttribute("title")):(o.remove(),e.removeAttribute("aria-describedby"),e.setAttribute("title",t))},complete(){o.remove(),e.removeAttribute("aria-describedby"),e.setAttribute("title",t)}}),i.pipe($e(16,ye)).subscribe(({active:s})=>{o.classList.toggle("md-tooltip--active",s)}),i.pipe(gt(125,ye),g(()=>!!e.offsetParent),m(()=>e.offsetParent.getBoundingClientRect()),m(({x:s})=>s)).subscribe({next(s){s?o.style.setProperty("--md-tooltip-0",`${-s}px`):o.style.removeProperty("--md-tooltip-0")},complete(){o.style.removeProperty("--md-tooltip-0")}}),ls(o,e).pipe(O(s=>i.next(s)),A(()=>i.complete()),m(s=>P({ref:e},s)))}).pipe(et(pe))}function ms({viewport$:e}){if(!V("header.autohide"))return $(!1);let t=e.pipe(m(({offset:{y:n}})=>n),ot(2,1),m(([n,i])=>[nMath.abs(i-n.y)>100),m(([,[n]])=>n),Y()),o=Je("search");return z([e,o]).pipe(m(([{offset:n},i])=>n.y>400&&!i),Y(),b(n=>n?r:$(!1)),Q(!1))}function ai(e,t){return H(()=>z([Le(e),ms(t)])).pipe(m(([{height:r},o])=>({height:r,hidden:o})),Y((r,o)=>r.height===o.height&&r.hidden===o.hidden),Z(1))}function si(e,{header$:t,main$:r}){return H(()=>{let o=new T,n=o.pipe(oe(),ae(!0));o.pipe(ne("active"),Pe(t)).subscribe(([{active:s},{hidden:a}])=>{e.classList.toggle("md-header--shadow",s&&!a),e.hidden=a});let i=fe(M("[title]",e)).pipe(g(()=>V("content.tooltips")),J(s=>ii(s)));return r.subscribe(o),t.pipe(W(n),m(s=>P({ref:e},s)),Ve(i.pipe(W(n))))})}function fs(e,{viewport$:t,header$:r}){return Er(e,{viewport$:t,header$:r}).pipe(m(({offset:{y:o}})=>{let{height:n}=de(e);return{active:n>0&&o>=n}}),ne("active"))}function ci(e,t){return H(()=>{let r=new T;r.subscribe({next({active:n}){e.classList.toggle("md-header__title--active",n)},complete(){e.classList.remove("md-header__title--active")}});let o=ue(".md-content h1");return typeof o=="undefined"?y:fs(o,t).pipe(O(n=>r.next(n)),A(()=>r.complete()),m(n=>P({ref:e},n)))})}function pi(e,{viewport$:t,header$:r}){let o=r.pipe(m(({height:i})=>i),Y()),n=o.pipe(b(()=>Le(e).pipe(m(({height:i})=>({top:e.offsetTop,bottom:e.offsetTop+i})),ne("bottom"))));return z([o,n,t]).pipe(m(([i,{top:s,bottom:a},{offset:{y:c},size:{height:p}}])=>(p=Math.max(0,p-Math.max(0,s-c,i)-Math.max(0,p+c-a)),{offset:s-i,height:p,active:s-i<=c})),Y((i,s)=>i.offset===s.offset&&i.height===s.height&&i.active===s.active))}function us(e){let t=__md_get("__palette")||{index:e.findIndex(o=>matchMedia(o.getAttribute("data-md-color-media")).matches)},r=Math.max(0,Math.min(t.index,e.length-1));return $(...e).pipe(J(o=>h(o,"change").pipe(m(()=>o))),Q(e[r]),m(o=>({index:e.indexOf(o),color:{media:o.getAttribute("data-md-color-media"),scheme:o.getAttribute("data-md-color-scheme"),primary:o.getAttribute("data-md-color-primary"),accent:o.getAttribute("data-md-color-accent")}})),Z(1))}function li(e){let t=M("input",e),r=x("meta",{name:"theme-color"});document.head.appendChild(r);let o=x("meta",{name:"color-scheme"});document.head.appendChild(o);let n=Wt("(prefers-color-scheme: light)");return H(()=>{let i=new T;return i.subscribe(s=>{if(document.body.setAttribute("data-md-color-switching",""),s.color.media==="(prefers-color-scheme)"){let a=matchMedia("(prefers-color-scheme: light)"),c=document.querySelector(a.matches?"[data-md-color-media='(prefers-color-scheme: light)']":"[data-md-color-media='(prefers-color-scheme: dark)']");s.color.scheme=c.getAttribute("data-md-color-scheme"),s.color.primary=c.getAttribute("data-md-color-primary"),s.color.accent=c.getAttribute("data-md-color-accent")}for(let[a,c]of Object.entries(s.color))document.body.setAttribute(`data-md-color-${a}`,c);for(let a=0;as.key==="Enter"),te(i,(s,a)=>a)).subscribe(({index:s})=>{s=(s+1)%t.length,t[s].click(),t[s].focus()}),i.pipe(m(()=>{let s=Ce("header"),a=window.getComputedStyle(s);return o.content=a.colorScheme,a.backgroundColor.match(/\d+/g).map(c=>(+c).toString(16).padStart(2,"0")).join("")})).subscribe(s=>r.content=`#${s}`),i.pipe(xe(pe)).subscribe(()=>{document.body.removeAttribute("data-md-color-switching")}),us(t).pipe(W(n.pipe(Ie(1))),vt(),O(s=>i.next(s)),A(()=>i.complete()),m(s=>P({ref:e},s)))})}function mi(e,{progress$:t}){return H(()=>{let r=new T;return r.subscribe(({value:o})=>{e.style.setProperty("--md-progress-value",`${o}`)}),t.pipe(O(o=>r.next({value:o})),A(()=>r.complete()),m(o=>({ref:e,value:o})))})}function fi(e,t){return e.protocol=t.protocol,e.hostname=t.hostname,e}function ds(e,t){let r=new Map;for(let o of M("url",e)){let n=j("loc",o),i=[fi(new URL(n.textContent),t)];r.set(`${i[0]}`,i);for(let s of M("[rel=alternate]",o)){let a=s.getAttribute("href");a!=null&&i.push(fi(new URL(a),t))}}return r}function kt(e){return En(new URL("sitemap.xml",e)).pipe(m(t=>ds(t,new URL(e))),ve(()=>$(new Map)),le())}function ui({document$:e}){let t=new Map;e.pipe(b(()=>M("link[rel=alternate]")),m(r=>new URL(r.href)),g(r=>!t.has(r.toString())),J(r=>kt(r).pipe(m(o=>[r,o]),ve(()=>y)))).subscribe(([r,o])=>{t.set(r.toString().replace(/\/$/,""),o)}),h(document.body,"click").pipe(g(r=>!r.metaKey&&!r.ctrlKey),b(r=>{if(r.target instanceof Element){let o=r.target.closest("a");if(o&&!o.target){let n=[...t].find(([f])=>o.href.startsWith(`${f}/`));if(typeof n=="undefined")return y;let[i,s]=n,a=we();if(a.href.startsWith(i))return y;let c=Te(),p=a.href.replace(c.base,"");p=`${i}/${p}`;let l=s.has(p.split("#")[0])?new URL(p,c.base):new URL(i);return r.preventDefault(),$(l)}}return y})).subscribe(r=>st(r,!0))}var co=$t(ao());function hs(e){e.setAttribute("data-md-copying","");let t=e.closest("[data-copy]"),r=t?t.getAttribute("data-copy"):e.innerText;return e.removeAttribute("data-md-copying"),r.trimEnd()}function di({alert$:e}){co.default.isSupported()&&new F(t=>{new co.default("[data-clipboard-target], [data-clipboard-text]",{text:r=>r.getAttribute("data-clipboard-text")||hs(j(r.getAttribute("data-clipboard-target")))}).on("success",r=>t.next(r))}).pipe(O(t=>{t.trigger.focus()}),m(()=>Me("clipboard.copied"))).subscribe(e)}function hi(e,t){if(!(e.target instanceof Element))return y;let r=e.target.closest("a");if(r===null)return y;if(r.target||e.metaKey||e.ctrlKey)return y;let o=new URL(r.href);return o.search=o.hash="",t.has(`${o}`)?(e.preventDefault(),$(r)):y}function bi(e){let t=new Map;for(let r of M(":scope > *",e.head))t.set(r.outerHTML,r);return t}function vi(e){for(let t of M("[href], [src]",e))for(let r of["href","src"]){let o=t.getAttribute(r);if(o&&!/^(?:[a-z]+:)?\/\//i.test(o)){t[r]=t[r];break}}return $(e)}function bs(e){for(let o of["[data-md-component=announce]","[data-md-component=container]","[data-md-component=header-topic]","[data-md-component=outdated]","[data-md-component=logo]","[data-md-component=skip]",...V("navigation.tabs.sticky")?["[data-md-component=tabs]"]:[]]){let n=ue(o),i=ue(o,e);typeof n!="undefined"&&typeof i!="undefined"&&n.replaceWith(i)}let t=bi(document);for(let[o,n]of bi(e))t.has(o)?t.delete(o):document.head.appendChild(n);for(let o of t.values()){let n=o.getAttribute("name");n!=="theme-color"&&n!=="color-scheme"&&o.remove()}let r=Ce("container");return Ke(M("script",r)).pipe(b(o=>{let n=e.createElement("script");if(o.src){for(let i of o.getAttributeNames())n.setAttribute(i,o.getAttribute(i));return o.replaceWith(n),new F(i=>{n.onload=()=>i.complete()})}else return n.textContent=o.textContent,o.replaceWith(n),y}),oe(),ae(document))}function gi({sitemap$:e,location$:t,viewport$:r,progress$:o}){if(location.protocol==="file:")return y;$(document).subscribe(vi);let n=h(document.body,"click").pipe(Pe(e),b(([a,c])=>hi(a,c)),m(({href:a})=>new URL(a)),le()),i=h(window,"popstate").pipe(m(we),le());n.pipe(te(r)).subscribe(([a,{offset:c}])=>{history.replaceState(c,""),history.pushState(null,"",a)}),L(n,i).subscribe(t);let s=t.pipe(ne("pathname"),b(a=>xr(a,{progress$:o}).pipe(ve(()=>(st(a,!0),y)))),b(vi),b(bs),le());return L(s.pipe(te(t,(a,c)=>c)),s.pipe(b(()=>t),ne("hash")),t.pipe(Y((a,c)=>a.pathname===c.pathname&&a.hash===c.hash),b(()=>n),O(()=>history.back()))).subscribe(a=>{var c,p;history.state!==null||!a.hash?window.scrollTo(0,(p=(c=history.state)==null?void 0:c.y)!=null?p:0):(history.scrollRestoration="auto",gn(a.hash),history.scrollRestoration="manual")}),t.subscribe(()=>{history.scrollRestoration="manual"}),h(window,"beforeunload").subscribe(()=>{history.scrollRestoration="auto"}),r.pipe(ne("offset"),Ae(100)).subscribe(({offset:a})=>{history.replaceState(a,"")}),V("navigation.instant.prefetch")&&L(h(document.body,"mousemove"),h(document.body,"focusin")).pipe(Pe(e),b(([a,c])=>hi(a,c)),Ae(25),Qr(({href:a})=>a),hr(a=>{let c=document.createElement("link");return c.rel="prefetch",c.href=a.toString(),document.head.appendChild(c),h(c,"load").pipe(m(()=>c),Ee(1))})).subscribe(a=>a.remove()),s}var yi=$t(ro());function xi(e){let t=e.separator.split("|").map(n=>n.replace(/(\(\?[!=<][^)]+\))/g,"").length===0?"\uFFFD":n).join("|"),r=new RegExp(t,"img"),o=(n,i,s)=>`${i}${s}`;return n=>{n=n.replace(/[\s*+\-:~^]+/g," ").replace(/&/g,"&").trim();let i=new RegExp(`(^|${e.separator}|)(${n.replace(/[|\\{}()[\]^$+*?.-]/g,"\\$&").replace(r,"|")})`,"img");return s=>(0,yi.default)(s).replace(i,o).replace(/<\/mark>(\s+)]*>/img,"$1")}}function zt(e){return e.type===1}function Sr(e){return e.type===3}function Ei(e,t){let r=Mn(e);return L($(location.protocol!=="file:"),Je("search")).pipe(Re(o=>o),b(()=>t)).subscribe(({config:o,docs:n})=>r.next({type:0,data:{config:o,docs:n,options:{suggest:V("search.suggest")}}})),r}function wi(e){var l;let{selectedVersionSitemap:t,selectedVersionBaseURL:r,currentLocation:o,currentBaseURL:n}=e,i=(l=po(n))==null?void 0:l.pathname;if(i===void 0)return;let s=ys(o.pathname,i);if(s===void 0)return;let a=Es(t.keys());if(!t.has(a))return;let c=po(s,a);if(!c||!t.has(c.href))return;let p=po(s,r);if(p)return p.hash=o.hash,p.search=o.search,p}function po(e,t){try{return new URL(e,t)}catch(r){return}}function ys(e,t){if(e.startsWith(t))return e.slice(t.length)}function xs(e,t){let r=Math.min(e.length,t.length),o;for(o=0;oy)),o=r.pipe(m(n=>{let[,i]=t.base.match(/([^/]+)\/?$/);return n.find(({version:s,aliases:a})=>s===i||a.includes(i))||n[0]}));r.pipe(m(n=>new Map(n.map(i=>[`${new URL(`../${i.version}/`,t.base)}`,i]))),b(n=>h(document.body,"click").pipe(g(i=>!i.metaKey&&!i.ctrlKey),te(o),b(([i,s])=>{if(i.target instanceof Element){let a=i.target.closest("a");if(a&&!a.target&&n.has(a.href)){let c=a.href;return!i.target.closest(".md-version")&&n.get(c)===s?y:(i.preventDefault(),$(new URL(c)))}}return y}),b(i=>kt(i).pipe(m(s=>{var a;return(a=wi({selectedVersionSitemap:s,selectedVersionBaseURL:i,currentLocation:we(),currentBaseURL:t.base}))!=null?a:i})))))).subscribe(n=>st(n,!0)),z([r,o]).subscribe(([n,i])=>{j(".md-header__topic").appendChild(Wn(n,i))}),e.pipe(b(()=>o)).subscribe(n=>{var a;let i=new URL(t.base),s=__md_get("__outdated",sessionStorage,i);if(s===null){s=!0;let c=((a=t.version)==null?void 0:a.default)||"latest";Array.isArray(c)||(c=[c]);e:for(let p of c)for(let l of n.aliases.concat(n.version))if(new RegExp(p,"i").test(l)){s=!1;break e}__md_set("__outdated",s,sessionStorage,i)}if(s)for(let c of me("outdated"))c.hidden=!1})}function ws(e,{worker$:t}){let{searchParams:r}=we();r.has("q")&&(at("search",!0),e.value=r.get("q"),e.focus(),Je("search").pipe(Re(i=>!i)).subscribe(()=>{let i=we();i.searchParams.delete("q"),history.replaceState({},"",`${i}`)}));let o=Ye(e),n=L(t.pipe(Re(zt)),h(e,"keyup"),o).pipe(m(()=>e.value),Y());return z([n,o]).pipe(m(([i,s])=>({value:i,focus:s})),Z(1))}function Si(e,{worker$:t}){let r=new T,o=r.pipe(oe(),ae(!0));z([t.pipe(Re(zt)),r],(i,s)=>s).pipe(ne("value")).subscribe(({value:i})=>t.next({type:2,data:i})),r.pipe(ne("focus")).subscribe(({focus:i})=>{i&&at("search",i)}),h(e.form,"reset").pipe(W(o)).subscribe(()=>e.focus());let n=j("header [for=__search]");return h(n,"click").subscribe(()=>e.focus()),ws(e,{worker$:t}).pipe(O(i=>r.next(i)),A(()=>r.complete()),m(i=>P({ref:e},i)),Z(1))}function Oi(e,{worker$:t,query$:r}){let o=new T,n=un(e.parentElement).pipe(g(Boolean)),i=e.parentElement,s=j(":scope > :first-child",e),a=j(":scope > :last-child",e);Je("search").subscribe(l=>{a.setAttribute("role",l?"list":"presentation"),a.hidden=!l}),o.pipe(te(r),Gr(t.pipe(Re(zt)))).subscribe(([{items:l},{value:f}])=>{switch(l.length){case 0:s.textContent=f.length?Me("search.result.none"):Me("search.result.placeholder");break;case 1:s.textContent=Me("search.result.one");break;default:let u=br(l.length);s.textContent=Me("search.result.other",u)}});let c=o.pipe(O(()=>a.innerHTML=""),b(({items:l})=>L($(...l.slice(0,10)),$(...l.slice(10)).pipe(ot(4),Xr(n),b(([f])=>f)))),m(Fn),le());return c.subscribe(l=>a.appendChild(l)),c.pipe(J(l=>{let f=ue("details",l);return typeof f=="undefined"?y:h(f,"toggle").pipe(W(o),m(()=>f))})).subscribe(l=>{l.open===!1&&l.offsetTop<=i.scrollTop&&i.scrollTo({top:l.offsetTop})}),t.pipe(g(Sr),m(({data:l})=>l)).pipe(O(l=>o.next(l)),A(()=>o.complete()),m(l=>P({ref:e},l)))}function Ts(e,{query$:t}){return t.pipe(m(({value:r})=>{let o=we();return o.hash="",r=r.replace(/\s+/g,"+").replace(/&/g,"%26").replace(/=/g,"%3D"),o.search=`q=${r}`,{url:o}}))}function Li(e,t){let r=new T,o=r.pipe(oe(),ae(!0));return r.subscribe(({url:n})=>{e.setAttribute("data-clipboard-text",e.href),e.href=`${n}`}),h(e,"click").pipe(W(o)).subscribe(n=>n.preventDefault()),Ts(e,t).pipe(O(n=>r.next(n)),A(()=>r.complete()),m(n=>P({ref:e},n)))}function Mi(e,{worker$:t,keyboard$:r}){let o=new T,n=Ce("search-query"),i=L(h(n,"keydown"),h(n,"focus")).pipe(xe(pe),m(()=>n.value),Y());return o.pipe(Pe(i),m(([{suggest:a},c])=>{let p=c.split(/([\s-]+)/);if(a!=null&&a.length&&p[p.length-1]){let l=a[a.length-1];l.startsWith(p[p.length-1])&&(p[p.length-1]=l)}else p.length=0;return p})).subscribe(a=>e.innerHTML=a.join("").replace(/\s/g," ")),r.pipe(g(({mode:a})=>a==="search")).subscribe(a=>{a.type==="ArrowRight"&&e.innerText.length&&n.selectionStart===n.value.length&&(n.value=e.innerText)}),t.pipe(g(Sr),m(({data:a})=>a)).pipe(O(a=>o.next(a)),A(()=>o.complete()),m(()=>({ref:e})))}function _i(e,{index$:t,keyboard$:r}){let o=Te();try{let n=Ei(o.search,t),i=Ce("search-query",e),s=Ce("search-result",e);h(e,"click").pipe(g(({target:c})=>c instanceof Element&&!!c.closest("a"))).subscribe(()=>at("search",!1)),r.pipe(g(({mode:c})=>c==="search")).subscribe(c=>{let p=Ne();switch(c.type){case"Enter":if(p===i){let l=new Map;for(let f of M(":first-child [href]",s)){let u=f.firstElementChild;l.set(f,parseFloat(u.getAttribute("data-md-score")))}if(l.size){let[[f]]=[...l].sort(([,u],[,d])=>d-u);f.click()}c.claim()}break;case"Escape":case"Tab":at("search",!1),i.blur();break;case"ArrowUp":case"ArrowDown":if(typeof p=="undefined")i.focus();else{let l=[i,...M(":not(details) > [href], summary, details[open] [href]",s)],f=Math.max(0,(Math.max(0,l.indexOf(p))+l.length+(c.type==="ArrowUp"?-1:1))%l.length);l[f].focus()}c.claim();break;default:i!==Ne()&&i.focus()}}),r.pipe(g(({mode:c})=>c==="global")).subscribe(c=>{switch(c.type){case"f":case"s":case"/":i.focus(),i.select(),c.claim();break}});let a=Si(i,{worker$:n});return L(a,Oi(s,{worker$:n,query$:a})).pipe(Ve(...me("search-share",e).map(c=>Li(c,{query$:a})),...me("search-suggest",e).map(c=>Mi(c,{worker$:n,keyboard$:r}))))}catch(n){return e.hidden=!0,tt}}function Ai(e,{index$:t,location$:r}){return z([t,r.pipe(Q(we()),g(o=>!!o.searchParams.get("h")))]).pipe(m(([o,n])=>xi(o.config)(n.searchParams.get("h"))),m(o=>{var s;let n=new Map,i=document.createNodeIterator(e,NodeFilter.SHOW_TEXT);for(let a=i.nextNode();a;a=i.nextNode())if((s=a.parentElement)!=null&&s.offsetHeight){let c=a.textContent,p=o(c);p.length>c.length&&n.set(a,p)}for(let[a,c]of n){let{childNodes:p}=x("span",null,c);a.replaceWith(...Array.from(p))}return{ref:e,nodes:n}}))}function Ss(e,{viewport$:t,main$:r}){let o=e.closest(".md-grid"),n=o.offsetTop-o.parentElement.offsetTop;return z([r,t]).pipe(m(([{offset:i,height:s},{offset:{y:a}}])=>(s=s+Math.min(n,Math.max(0,a-i))-n,{height:s,locked:a>=i+n})),Y((i,s)=>i.height===s.height&&i.locked===s.locked))}function lo(e,o){var n=o,{header$:t}=n,r=vo(n,["header$"]);let i=j(".md-sidebar__scrollwrap",e),{y:s}=Be(i);return H(()=>{let a=new T,c=a.pipe(oe(),ae(!0)),p=a.pipe($e(0,ye));return p.pipe(te(t)).subscribe({next([{height:l},{height:f}]){i.style.height=`${l-2*s}px`,e.style.top=`${f}px`},complete(){i.style.height="",e.style.top=""}}),p.pipe(Re()).subscribe(()=>{for(let l of M(".md-nav__link--active[href]",e)){if(!l.clientHeight)continue;let f=l.closest(".md-sidebar__scrollwrap");if(typeof f!="undefined"){let u=l.offsetTop-f.offsetTop,{height:d}=de(f);f.scrollTo({top:u-d/2})}}}),fe(M("label[tabindex]",e)).pipe(J(l=>h(l,"click").pipe(xe(pe),m(()=>l),W(c)))).subscribe(l=>{let f=j(`[id="${l.htmlFor}"]`);j(`[aria-labelledby="${l.id}"]`).setAttribute("aria-expanded",`${f.checked}`)}),V("content.tooltips")&&fe(M("abbr[title]",e)).pipe(J(l=>Xe(l,{viewport$})),W(c)).subscribe(),Ss(e,r).pipe(O(l=>a.next(l)),A(()=>a.complete()),m(l=>P({ref:e},l)))})}function Ci(e,t){if(typeof t!="undefined"){let r=`https://api.github.com/repos/${e}/${t}`;return rt(ze(`${r}/releases/latest`).pipe(ve(()=>y),m(o=>({version:o.tag_name})),Qe({})),ze(r).pipe(ve(()=>y),m(o=>({stars:o.stargazers_count,forks:o.forks_count})),Qe({}))).pipe(m(([o,n])=>P(P({},o),n)))}else{let r=`https://api.github.com/users/${e}`;return ze(r).pipe(m(o=>({repositories:o.public_repos})),Qe({}))}}function ki(e,t){let r=`https://${e}/api/v4/projects/${encodeURIComponent(t)}`;return rt(ze(`${r}/releases/permalink/latest`).pipe(ve(()=>y),m(({tag_name:o})=>({version:o})),Qe({})),ze(r).pipe(ve(()=>y),m(({star_count:o,forks_count:n})=>({stars:o,forks:n})),Qe({}))).pipe(m(([o,n])=>P(P({},o),n)))}function Hi(e){let t=e.match(/^.+github\.com\/([^/]+)\/?([^/]+)?/i);if(t){let[,r,o]=t;return Ci(r,o)}if(t=e.match(/^.+?([^/]*gitlab[^/]+)\/(.+?)\/?$/i),t){let[,r,o]=t;return ki(r,o)}return y}var Os;function Ls(e){return Os||(Os=H(()=>{let t=__md_get("__source",sessionStorage);if(t)return $(t);if(me("consent").length){let o=__md_get("__consent");if(!(o&&o.github))return y}return Hi(e.href).pipe(O(o=>__md_set("__source",o,sessionStorage)))}).pipe(ve(()=>y),g(t=>Object.keys(t).length>0),m(t=>({facts:t})),Z(1)))}function $i(e){let t=j(":scope > :last-child",e);return H(()=>{let r=new T;return r.subscribe(({facts:o})=>{t.appendChild(jn(o)),t.classList.add("md-source__repository--active")}),Ls(e).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))})}function Ms(e,{viewport$:t,header$:r}){return Le(document.body).pipe(b(()=>Er(e,{header$:r,viewport$:t})),m(({offset:{y:o}})=>({hidden:o>=10})),ne("hidden"))}function Pi(e,t){return H(()=>{let r=new T;return r.subscribe({next({hidden:o}){e.hidden=o},complete(){e.hidden=!1}}),(V("navigation.tabs.sticky")?$({hidden:!1}):Ms(e,t)).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))})}function _s(e,{viewport$:t,header$:r}){let o=new Map,n=M(".md-nav__link",e);for(let a of n){let c=decodeURIComponent(a.hash.substring(1)),p=ue(`[id="${c}"]`);typeof p!="undefined"&&o.set(a,p)}let i=r.pipe(ne("height"),m(({height:a})=>{let c=Ce("main"),p=j(":scope > :first-child",c);return a+.8*(p.offsetTop-c.offsetTop)}),le());return Le(document.body).pipe(ne("height"),b(a=>H(()=>{let c=[];return $([...o].reduce((p,[l,f])=>{for(;c.length&&o.get(c[c.length-1]).tagName>=f.tagName;)c.pop();let u=f.offsetTop;for(;!u&&f.parentElement;)f=f.parentElement,u=f.offsetTop;let d=f.offsetParent;for(;d;d=d.offsetParent)u+=d.offsetTop;return p.set([...c=[...c,l]].reverse(),u)},new Map))}).pipe(m(c=>new Map([...c].sort(([,p],[,l])=>p-l))),Pe(i),b(([c,p])=>t.pipe(Ut(([l,f],{offset:{y:u},size:d})=>{let v=u+d.height>=Math.floor(a.height);for(;f.length;){let[,S]=f[0];if(S-p=u&&!v)f=[l.pop(),...f];else break}return[l,f]},[[],[...c]]),Y((l,f)=>l[0]===f[0]&&l[1]===f[1])))))).pipe(m(([a,c])=>({prev:a.map(([p])=>p),next:c.map(([p])=>p)})),Q({prev:[],next:[]}),ot(2,1),m(([a,c])=>a.prev.length{let i=new T,s=i.pipe(oe(),ae(!0));if(i.subscribe(({prev:a,next:c})=>{for(let[p]of c)p.classList.remove("md-nav__link--passed"),p.classList.remove("md-nav__link--active");for(let[p,[l]]of a.entries())l.classList.add("md-nav__link--passed"),l.classList.toggle("md-nav__link--active",p===a.length-1)}),V("toc.follow")){let a=L(t.pipe(Ae(1),m(()=>{})),t.pipe(Ae(250),m(()=>"smooth")));i.pipe(g(({prev:c})=>c.length>0),Pe(o.pipe(xe(pe))),te(a)).subscribe(([[{prev:c}],p])=>{let[l]=c[c.length-1];if(l.offsetHeight){let f=vr(l);if(typeof f!="undefined"){let u=l.offsetTop-f.offsetTop,{height:d}=de(f);f.scrollTo({top:u-d/2,behavior:p})}}})}return V("navigation.tracking")&&t.pipe(W(s),ne("offset"),Ae(250),Ie(1),W(n.pipe(Ie(1))),vt({delay:250}),te(i)).subscribe(([,{prev:a}])=>{let c=we(),p=a[a.length-1];if(p&&p.length){let[l]=p,{hash:f}=new URL(l.href);c.hash!==f&&(c.hash=f,history.replaceState({},"",`${c}`))}else c.hash="",history.replaceState({},"",`${c}`)}),_s(e,{viewport$:t,header$:r}).pipe(O(a=>i.next(a)),A(()=>i.complete()),m(a=>P({ref:e},a)))})}function As(e,{viewport$:t,main$:r,target$:o}){let n=t.pipe(m(({offset:{y:s}})=>s),ot(2,1),m(([s,a])=>s>a&&a>0),Y()),i=r.pipe(m(({active:s})=>s));return z([i,n]).pipe(m(([s,a])=>!(s&&a)),Y(),W(o.pipe(Ie(1))),ae(!0),vt({delay:250}),m(s=>({hidden:s})))}function Ii(e,{viewport$:t,header$:r,main$:o,target$:n}){let i=new T,s=i.pipe(oe(),ae(!0));return i.subscribe({next({hidden:a}){e.hidden=a,a?(e.setAttribute("tabindex","-1"),e.blur()):e.removeAttribute("tabindex")},complete(){e.style.top="",e.hidden=!0,e.removeAttribute("tabindex")}}),r.pipe(W(s),ne("height")).subscribe(({height:a})=>{e.style.top=`${a+16}px`}),h(e,"click").subscribe(a=>{a.preventDefault(),window.scrollTo({top:0})}),As(e,{viewport$:t,main$:o,target$:n}).pipe(O(a=>i.next(a)),A(()=>i.complete()),m(a=>P({ref:e},a)))}function Fi({document$:e,viewport$:t}){e.pipe(b(()=>M(".md-ellipsis")),J(r=>mt(r).pipe(W(e.pipe(Ie(1))),g(o=>o),m(()=>r),Ee(1))),g(r=>r.offsetWidth{let o=r.innerText,n=r.closest("a")||r;return n.title=o,V("content.tooltips")?Xe(n,{viewport$:t}).pipe(W(e.pipe(Ie(1))),A(()=>n.removeAttribute("title"))):y})).subscribe(),V("content.tooltips")&&e.pipe(b(()=>M(".md-status")),J(r=>Xe(r,{viewport$:t}))).subscribe()}function ji({document$:e,tablet$:t}){e.pipe(b(()=>M(".md-toggle--indeterminate")),O(r=>{r.indeterminate=!0,r.checked=!1}),J(r=>h(r,"change").pipe(Jr(()=>r.classList.contains("md-toggle--indeterminate")),m(()=>r))),te(t)).subscribe(([r,o])=>{r.classList.remove("md-toggle--indeterminate"),o&&(r.checked=!1)})}function Cs(){return/(iPad|iPhone|iPod)/.test(navigator.userAgent)}function Ui({document$:e}){e.pipe(b(()=>M("[data-md-scrollfix]")),O(t=>t.removeAttribute("data-md-scrollfix")),g(Cs),J(t=>h(t,"touchstart").pipe(m(()=>t)))).subscribe(t=>{let r=t.scrollTop;r===0?t.scrollTop=1:r+t.offsetHeight===t.scrollHeight&&(t.scrollTop=r-1)})}function Wi({viewport$:e,tablet$:t}){z([Je("search"),t]).pipe(m(([r,o])=>r&&!o),b(r=>$(r).pipe(nt(r?400:100))),te(e)).subscribe(([r,{offset:{y:o}}])=>{if(r)document.body.setAttribute("data-md-scrolllock",""),document.body.style.top=`-${o}px`;else{let n=-1*parseInt(document.body.style.top,10);document.body.removeAttribute("data-md-scrolllock"),document.body.style.top="",n&&window.scrollTo(0,n)}})}Object.entries||(Object.entries=function(e){let t=[];for(let r of Object.keys(e))t.push([r,e[r]]);return t});Object.values||(Object.values=function(e){let t=[];for(let r of Object.keys(e))t.push(e[r]);return t});typeof Element!="undefined"&&(Element.prototype.scrollTo||(Element.prototype.scrollTo=function(e,t){typeof e=="object"?(this.scrollLeft=e.left,this.scrollTop=e.top):(this.scrollLeft=e,this.scrollTop=t)}),Element.prototype.replaceWith||(Element.prototype.replaceWith=function(...e){let t=this.parentNode;if(t){e.length===0&&t.removeChild(this);for(let r=e.length-1;r>=0;r--){let o=e[r];typeof o=="string"?o=document.createTextNode(o):o.parentNode&&o.parentNode.removeChild(o),r?t.insertBefore(this.previousSibling,o):t.replaceChild(o,this)}}}));function ks(){return location.protocol==="file:"?_t(`${new URL("search/search_index.js",Or.base)}`).pipe(m(()=>__index),Z(1)):ze(new URL("search/search_index.json",Or.base))}document.documentElement.classList.remove("no-js");document.documentElement.classList.add("js");var ct=an(),Kt=bn(),Ht=yn(Kt),mo=hn(),ke=Ln(),Lr=Wt("(min-width: 60em)"),Vi=Wt("(min-width: 76.25em)"),Ni=xn(),Or=Te(),zi=document.forms.namedItem("search")?ks():tt,fo=new T;di({alert$:fo});ui({document$:ct});var uo=new T,qi=kt(Or.base);V("navigation.instant")&&gi({sitemap$:qi,location$:Kt,viewport$:ke,progress$:uo}).subscribe(ct);var Di;((Di=Or.version)==null?void 0:Di.provider)==="mike"&&Ti({document$:ct});L(Kt,Ht).pipe(nt(125)).subscribe(()=>{at("drawer",!1),at("search",!1)});mo.pipe(g(({mode:e})=>e==="global")).subscribe(e=>{switch(e.type){case"p":case",":let t=ue("link[rel=prev]");typeof t!="undefined"&&st(t);break;case"n":case".":let r=ue("link[rel=next]");typeof r!="undefined"&&st(r);break;case"Enter":let o=Ne();o instanceof HTMLLabelElement&&o.click()}});Fi({viewport$:ke,document$:ct});ji({document$:ct,tablet$:Lr});Ui({document$:ct});Wi({viewport$:ke,tablet$:Lr});var ft=ai(Ce("header"),{viewport$:ke}),qt=ct.pipe(m(()=>Ce("main")),b(e=>pi(e,{viewport$:ke,header$:ft})),Z(1)),Hs=L(...me("consent").map(e=>An(e,{target$:Ht})),...me("dialog").map(e=>ni(e,{alert$:fo})),...me("palette").map(e=>li(e)),...me("progress").map(e=>mi(e,{progress$:uo})),...me("search").map(e=>_i(e,{index$:zi,keyboard$:mo})),...me("source").map(e=>$i(e))),$s=H(()=>L(...me("announce").map(e=>_n(e)),...me("content").map(e=>oi(e,{sitemap$:qi,viewport$:ke,target$:Ht,print$:Ni})),...me("content").map(e=>V("search.highlight")?Ai(e,{index$:zi,location$:Kt}):y),...me("header").map(e=>si(e,{viewport$:ke,header$:ft,main$:qt})),...me("header-title").map(e=>ci(e,{viewport$:ke,header$:ft})),...me("sidebar").map(e=>e.getAttribute("data-md-type")==="navigation"?eo(Vi,()=>lo(e,{viewport$:ke,header$:ft,main$:qt})):eo(Lr,()=>lo(e,{viewport$:ke,header$:ft,main$:qt}))),...me("tabs").map(e=>Pi(e,{viewport$:ke,header$:ft})),...me("toc").map(e=>Ri(e,{viewport$:ke,header$:ft,main$:qt,target$:Ht})),...me("top").map(e=>Ii(e,{viewport$:ke,header$:ft,main$:qt,target$:Ht})))),Ki=ct.pipe(b(()=>$s),Ve(Hs),Z(1));Ki.subscribe();window.document$=ct;window.location$=Kt;window.target$=Ht;window.keyboard$=mo;window.viewport$=ke;window.tablet$=Lr;window.screen$=Vi;window.print$=Ni;window.alert$=fo;window.progress$=uo;window.component$=Ki;})(); +//# sourceMappingURL=bundle.79ae519e.min.js.map + diff --git a/mkdocs/site/assets/javascripts/bundle.79ae519e.min.js.map b/mkdocs/site/assets/javascripts/bundle.79ae519e.min.js.map new file mode 100644 index 00000000..5cf02892 --- /dev/null +++ b/mkdocs/site/assets/javascripts/bundle.79ae519e.min.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["node_modules/focus-visible/dist/focus-visible.js", "node_modules/escape-html/index.js", "node_modules/clipboard/dist/clipboard.js", "src/templates/assets/javascripts/bundle.ts", "node_modules/tslib/tslib.es6.mjs", "node_modules/rxjs/src/internal/util/isFunction.ts", "node_modules/rxjs/src/internal/util/createErrorClass.ts", "node_modules/rxjs/src/internal/util/UnsubscriptionError.ts", "node_modules/rxjs/src/internal/util/arrRemove.ts", "node_modules/rxjs/src/internal/Subscription.ts", "node_modules/rxjs/src/internal/config.ts", "node_modules/rxjs/src/internal/scheduler/timeoutProvider.ts", "node_modules/rxjs/src/internal/util/reportUnhandledError.ts", "node_modules/rxjs/src/internal/util/noop.ts", "node_modules/rxjs/src/internal/NotificationFactories.ts", "node_modules/rxjs/src/internal/util/errorContext.ts", "node_modules/rxjs/src/internal/Subscriber.ts", "node_modules/rxjs/src/internal/symbol/observable.ts", "node_modules/rxjs/src/internal/util/identity.ts", "node_modules/rxjs/src/internal/util/pipe.ts", "node_modules/rxjs/src/internal/Observable.ts", "node_modules/rxjs/src/internal/util/lift.ts", "node_modules/rxjs/src/internal/operators/OperatorSubscriber.ts", "node_modules/rxjs/src/internal/scheduler/animationFrameProvider.ts", "node_modules/rxjs/src/internal/util/ObjectUnsubscribedError.ts", "node_modules/rxjs/src/internal/Subject.ts", "node_modules/rxjs/src/internal/BehaviorSubject.ts", "node_modules/rxjs/src/internal/scheduler/dateTimestampProvider.ts", "node_modules/rxjs/src/internal/ReplaySubject.ts", "node_modules/rxjs/src/internal/scheduler/Action.ts", "node_modules/rxjs/src/internal/scheduler/intervalProvider.ts", "node_modules/rxjs/src/internal/scheduler/AsyncAction.ts", "node_modules/rxjs/src/internal/Scheduler.ts", "node_modules/rxjs/src/internal/scheduler/AsyncScheduler.ts", "node_modules/rxjs/src/internal/scheduler/async.ts", "node_modules/rxjs/src/internal/scheduler/QueueAction.ts", "node_modules/rxjs/src/internal/scheduler/QueueScheduler.ts", "node_modules/rxjs/src/internal/scheduler/queue.ts", "node_modules/rxjs/src/internal/scheduler/AnimationFrameAction.ts", "node_modules/rxjs/src/internal/scheduler/AnimationFrameScheduler.ts", "node_modules/rxjs/src/internal/scheduler/animationFrame.ts", "node_modules/rxjs/src/internal/observable/empty.ts", "node_modules/rxjs/src/internal/util/isScheduler.ts", "node_modules/rxjs/src/internal/util/args.ts", "node_modules/rxjs/src/internal/util/isArrayLike.ts", "node_modules/rxjs/src/internal/util/isPromise.ts", "node_modules/rxjs/src/internal/util/isInteropObservable.ts", "node_modules/rxjs/src/internal/util/isAsyncIterable.ts", "node_modules/rxjs/src/internal/util/throwUnobservableError.ts", "node_modules/rxjs/src/internal/symbol/iterator.ts", "node_modules/rxjs/src/internal/util/isIterable.ts", "node_modules/rxjs/src/internal/util/isReadableStreamLike.ts", "node_modules/rxjs/src/internal/observable/innerFrom.ts", "node_modules/rxjs/src/internal/util/executeSchedule.ts", "node_modules/rxjs/src/internal/operators/observeOn.ts", "node_modules/rxjs/src/internal/operators/subscribeOn.ts", "node_modules/rxjs/src/internal/scheduled/scheduleObservable.ts", "node_modules/rxjs/src/internal/scheduled/schedulePromise.ts", "node_modules/rxjs/src/internal/scheduled/scheduleArray.ts", "node_modules/rxjs/src/internal/scheduled/scheduleIterable.ts", "node_modules/rxjs/src/internal/scheduled/scheduleAsyncIterable.ts", "node_modules/rxjs/src/internal/scheduled/scheduleReadableStreamLike.ts", "node_modules/rxjs/src/internal/scheduled/scheduled.ts", "node_modules/rxjs/src/internal/observable/from.ts", "node_modules/rxjs/src/internal/observable/of.ts", "node_modules/rxjs/src/internal/observable/throwError.ts", "node_modules/rxjs/src/internal/util/EmptyError.ts", "node_modules/rxjs/src/internal/util/isDate.ts", "node_modules/rxjs/src/internal/operators/map.ts", "node_modules/rxjs/src/internal/util/mapOneOrManyArgs.ts", "node_modules/rxjs/src/internal/util/argsArgArrayOrObject.ts", "node_modules/rxjs/src/internal/util/createObject.ts", "node_modules/rxjs/src/internal/observable/combineLatest.ts", "node_modules/rxjs/src/internal/operators/mergeInternals.ts", "node_modules/rxjs/src/internal/operators/mergeMap.ts", "node_modules/rxjs/src/internal/operators/mergeAll.ts", "node_modules/rxjs/src/internal/operators/concatAll.ts", "node_modules/rxjs/src/internal/observable/concat.ts", "node_modules/rxjs/src/internal/observable/defer.ts", "node_modules/rxjs/src/internal/observable/fromEvent.ts", "node_modules/rxjs/src/internal/observable/fromEventPattern.ts", "node_modules/rxjs/src/internal/observable/timer.ts", "node_modules/rxjs/src/internal/observable/merge.ts", "node_modules/rxjs/src/internal/observable/never.ts", "node_modules/rxjs/src/internal/util/argsOrArgArray.ts", "node_modules/rxjs/src/internal/operators/filter.ts", "node_modules/rxjs/src/internal/observable/zip.ts", "node_modules/rxjs/src/internal/operators/audit.ts", "node_modules/rxjs/src/internal/operators/auditTime.ts", "node_modules/rxjs/src/internal/operators/bufferCount.ts", "node_modules/rxjs/src/internal/operators/catchError.ts", "node_modules/rxjs/src/internal/operators/scanInternals.ts", "node_modules/rxjs/src/internal/operators/combineLatest.ts", "node_modules/rxjs/src/internal/operators/combineLatestWith.ts", "node_modules/rxjs/src/internal/operators/debounce.ts", "node_modules/rxjs/src/internal/operators/debounceTime.ts", "node_modules/rxjs/src/internal/operators/defaultIfEmpty.ts", "node_modules/rxjs/src/internal/operators/take.ts", "node_modules/rxjs/src/internal/operators/ignoreElements.ts", "node_modules/rxjs/src/internal/operators/mapTo.ts", "node_modules/rxjs/src/internal/operators/delayWhen.ts", "node_modules/rxjs/src/internal/operators/delay.ts", "node_modules/rxjs/src/internal/operators/distinct.ts", "node_modules/rxjs/src/internal/operators/distinctUntilChanged.ts", "node_modules/rxjs/src/internal/operators/distinctUntilKeyChanged.ts", "node_modules/rxjs/src/internal/operators/throwIfEmpty.ts", "node_modules/rxjs/src/internal/operators/endWith.ts", "node_modules/rxjs/src/internal/operators/exhaustMap.ts", "node_modules/rxjs/src/internal/operators/finalize.ts", "node_modules/rxjs/src/internal/operators/first.ts", "node_modules/rxjs/src/internal/operators/takeLast.ts", "node_modules/rxjs/src/internal/operators/merge.ts", "node_modules/rxjs/src/internal/operators/mergeWith.ts", "node_modules/rxjs/src/internal/operators/repeat.ts", "node_modules/rxjs/src/internal/operators/scan.ts", "node_modules/rxjs/src/internal/operators/share.ts", "node_modules/rxjs/src/internal/operators/shareReplay.ts", "node_modules/rxjs/src/internal/operators/skip.ts", "node_modules/rxjs/src/internal/operators/skipUntil.ts", "node_modules/rxjs/src/internal/operators/startWith.ts", "node_modules/rxjs/src/internal/operators/switchMap.ts", "node_modules/rxjs/src/internal/operators/takeUntil.ts", "node_modules/rxjs/src/internal/operators/takeWhile.ts", "node_modules/rxjs/src/internal/operators/tap.ts", "node_modules/rxjs/src/internal/operators/throttle.ts", "node_modules/rxjs/src/internal/operators/throttleTime.ts", "node_modules/rxjs/src/internal/operators/withLatestFrom.ts", "node_modules/rxjs/src/internal/operators/zip.ts", "node_modules/rxjs/src/internal/operators/zipWith.ts", "src/templates/assets/javascripts/browser/document/index.ts", "src/templates/assets/javascripts/browser/element/_/index.ts", "src/templates/assets/javascripts/browser/element/focus/index.ts", "src/templates/assets/javascripts/browser/element/hover/index.ts", "src/templates/assets/javascripts/utilities/h/index.ts", "src/templates/assets/javascripts/utilities/round/index.ts", "src/templates/assets/javascripts/browser/script/index.ts", "src/templates/assets/javascripts/browser/element/size/_/index.ts", "src/templates/assets/javascripts/browser/element/size/content/index.ts", "src/templates/assets/javascripts/browser/element/offset/_/index.ts", "src/templates/assets/javascripts/browser/element/offset/content/index.ts", "src/templates/assets/javascripts/browser/element/visibility/index.ts", "src/templates/assets/javascripts/browser/toggle/index.ts", "src/templates/assets/javascripts/browser/keyboard/index.ts", "src/templates/assets/javascripts/browser/location/_/index.ts", "src/templates/assets/javascripts/browser/location/hash/index.ts", "src/templates/assets/javascripts/browser/media/index.ts", "src/templates/assets/javascripts/browser/request/index.ts", "src/templates/assets/javascripts/browser/viewport/offset/index.ts", "src/templates/assets/javascripts/browser/viewport/size/index.ts", "src/templates/assets/javascripts/browser/viewport/_/index.ts", "src/templates/assets/javascripts/browser/viewport/at/index.ts", "src/templates/assets/javascripts/browser/worker/index.ts", "src/templates/assets/javascripts/_/index.ts", "src/templates/assets/javascripts/components/_/index.ts", "src/templates/assets/javascripts/components/announce/index.ts", "src/templates/assets/javascripts/components/consent/index.ts", "src/templates/assets/javascripts/templates/tooltip/index.tsx", "src/templates/assets/javascripts/templates/annotation/index.tsx", "src/templates/assets/javascripts/templates/clipboard/index.tsx", "src/templates/assets/javascripts/templates/search/index.tsx", "src/templates/assets/javascripts/templates/source/index.tsx", "src/templates/assets/javascripts/templates/tabbed/index.tsx", "src/templates/assets/javascripts/templates/table/index.tsx", "src/templates/assets/javascripts/templates/version/index.tsx", "src/templates/assets/javascripts/components/tooltip2/index.ts", "src/templates/assets/javascripts/components/content/annotation/_/index.ts", "src/templates/assets/javascripts/components/content/annotation/list/index.ts", "src/templates/assets/javascripts/components/content/annotation/block/index.ts", "src/templates/assets/javascripts/components/content/code/_/index.ts", "src/templates/assets/javascripts/components/content/details/index.ts", "src/templates/assets/javascripts/components/content/link/index.ts", "src/templates/assets/javascripts/components/content/mermaid/index.css", "src/templates/assets/javascripts/components/content/mermaid/index.ts", "src/templates/assets/javascripts/components/content/table/index.ts", "src/templates/assets/javascripts/components/content/tabs/index.ts", "src/templates/assets/javascripts/components/content/_/index.ts", "src/templates/assets/javascripts/components/dialog/index.ts", "src/templates/assets/javascripts/components/tooltip/index.ts", "src/templates/assets/javascripts/components/header/_/index.ts", "src/templates/assets/javascripts/components/header/title/index.ts", "src/templates/assets/javascripts/components/main/index.ts", "src/templates/assets/javascripts/components/palette/index.ts", "src/templates/assets/javascripts/components/progress/index.ts", "src/templates/assets/javascripts/integrations/sitemap/index.ts", "src/templates/assets/javascripts/integrations/alternate/index.ts", "src/templates/assets/javascripts/integrations/clipboard/index.ts", "src/templates/assets/javascripts/integrations/instant/index.ts", "src/templates/assets/javascripts/integrations/search/highlighter/index.ts", "src/templates/assets/javascripts/integrations/search/worker/message/index.ts", "src/templates/assets/javascripts/integrations/search/worker/_/index.ts", "src/templates/assets/javascripts/integrations/version/findurl/index.ts", "src/templates/assets/javascripts/integrations/version/index.ts", "src/templates/assets/javascripts/components/search/query/index.ts", "src/templates/assets/javascripts/components/search/result/index.ts", "src/templates/assets/javascripts/components/search/share/index.ts", "src/templates/assets/javascripts/components/search/suggest/index.ts", "src/templates/assets/javascripts/components/search/_/index.ts", "src/templates/assets/javascripts/components/search/highlight/index.ts", "src/templates/assets/javascripts/components/sidebar/index.ts", "src/templates/assets/javascripts/components/source/facts/github/index.ts", "src/templates/assets/javascripts/components/source/facts/gitlab/index.ts", "src/templates/assets/javascripts/components/source/facts/_/index.ts", "src/templates/assets/javascripts/components/source/_/index.ts", "src/templates/assets/javascripts/components/tabs/index.ts", "src/templates/assets/javascripts/components/toc/index.ts", "src/templates/assets/javascripts/components/top/index.ts", "src/templates/assets/javascripts/patches/ellipsis/index.ts", "src/templates/assets/javascripts/patches/indeterminate/index.ts", "src/templates/assets/javascripts/patches/scrollfix/index.ts", "src/templates/assets/javascripts/patches/scrolllock/index.ts", "src/templates/assets/javascripts/polyfills/index.ts"], + "sourcesContent": ["(function (global, factory) {\n typeof exports === 'object' && typeof module !== 'undefined' ? factory() :\n typeof define === 'function' && define.amd ? define(factory) :\n (factory());\n}(this, (function () { 'use strict';\n\n /**\n * Applies the :focus-visible polyfill at the given scope.\n * A scope in this case is either the top-level Document or a Shadow Root.\n *\n * @param {(Document|ShadowRoot)} scope\n * @see https://github.com/WICG/focus-visible\n */\n function applyFocusVisiblePolyfill(scope) {\n var hadKeyboardEvent = true;\n var hadFocusVisibleRecently = false;\n var hadFocusVisibleRecentlyTimeout = null;\n\n var inputTypesAllowlist = {\n text: true,\n search: true,\n url: true,\n tel: true,\n email: true,\n password: true,\n number: true,\n date: true,\n month: true,\n week: true,\n time: true,\n datetime: true,\n 'datetime-local': true\n };\n\n /**\n * Helper function for legacy browsers and iframes which sometimes focus\n * elements like document, body, and non-interactive SVG.\n * @param {Element} el\n */\n function isValidFocusTarget(el) {\n if (\n el &&\n el !== document &&\n el.nodeName !== 'HTML' &&\n el.nodeName !== 'BODY' &&\n 'classList' in el &&\n 'contains' in el.classList\n ) {\n return true;\n }\n return false;\n }\n\n /**\n * Computes whether the given element should automatically trigger the\n * `focus-visible` class being added, i.e. whether it should always match\n * `:focus-visible` when focused.\n * @param {Element} el\n * @return {boolean}\n */\n function focusTriggersKeyboardModality(el) {\n var type = el.type;\n var tagName = el.tagName;\n\n if (tagName === 'INPUT' && inputTypesAllowlist[type] && !el.readOnly) {\n return true;\n }\n\n if (tagName === 'TEXTAREA' && !el.readOnly) {\n return true;\n }\n\n if (el.isContentEditable) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Add the `focus-visible` class to the given element if it was not added by\n * the author.\n * @param {Element} el\n */\n function addFocusVisibleClass(el) {\n if (el.classList.contains('focus-visible')) {\n return;\n }\n el.classList.add('focus-visible');\n el.setAttribute('data-focus-visible-added', '');\n }\n\n /**\n * Remove the `focus-visible` class from the given element if it was not\n * originally added by the author.\n * @param {Element} el\n */\n function removeFocusVisibleClass(el) {\n if (!el.hasAttribute('data-focus-visible-added')) {\n return;\n }\n el.classList.remove('focus-visible');\n el.removeAttribute('data-focus-visible-added');\n }\n\n /**\n * If the most recent user interaction was via the keyboard;\n * and the key press did not include a meta, alt/option, or control key;\n * then the modality is keyboard. Otherwise, the modality is not keyboard.\n * Apply `focus-visible` to any current active element and keep track\n * of our keyboard modality state with `hadKeyboardEvent`.\n * @param {KeyboardEvent} e\n */\n function onKeyDown(e) {\n if (e.metaKey || e.altKey || e.ctrlKey) {\n return;\n }\n\n if (isValidFocusTarget(scope.activeElement)) {\n addFocusVisibleClass(scope.activeElement);\n }\n\n hadKeyboardEvent = true;\n }\n\n /**\n * If at any point a user clicks with a pointing device, ensure that we change\n * the modality away from keyboard.\n * This avoids the situation where a user presses a key on an already focused\n * element, and then clicks on a different element, focusing it with a\n * pointing device, while we still think we're in keyboard modality.\n * @param {Event} e\n */\n function onPointerDown(e) {\n hadKeyboardEvent = false;\n }\n\n /**\n * On `focus`, add the `focus-visible` class to the target if:\n * - the target received focus as a result of keyboard navigation, or\n * - the event target is an element that will likely require interaction\n * via the keyboard (e.g. a text box)\n * @param {Event} e\n */\n function onFocus(e) {\n // Prevent IE from focusing the document or HTML element.\n if (!isValidFocusTarget(e.target)) {\n return;\n }\n\n if (hadKeyboardEvent || focusTriggersKeyboardModality(e.target)) {\n addFocusVisibleClass(e.target);\n }\n }\n\n /**\n * On `blur`, remove the `focus-visible` class from the target.\n * @param {Event} e\n */\n function onBlur(e) {\n if (!isValidFocusTarget(e.target)) {\n return;\n }\n\n if (\n e.target.classList.contains('focus-visible') ||\n e.target.hasAttribute('data-focus-visible-added')\n ) {\n // To detect a tab/window switch, we look for a blur event followed\n // rapidly by a visibility change.\n // If we don't see a visibility change within 100ms, it's probably a\n // regular focus change.\n hadFocusVisibleRecently = true;\n window.clearTimeout(hadFocusVisibleRecentlyTimeout);\n hadFocusVisibleRecentlyTimeout = window.setTimeout(function() {\n hadFocusVisibleRecently = false;\n }, 100);\n removeFocusVisibleClass(e.target);\n }\n }\n\n /**\n * If the user changes tabs, keep track of whether or not the previously\n * focused element had .focus-visible.\n * @param {Event} e\n */\n function onVisibilityChange(e) {\n if (document.visibilityState === 'hidden') {\n // If the tab becomes active again, the browser will handle calling focus\n // on the element (Safari actually calls it twice).\n // If this tab change caused a blur on an element with focus-visible,\n // re-apply the class when the user switches back to the tab.\n if (hadFocusVisibleRecently) {\n hadKeyboardEvent = true;\n }\n addInitialPointerMoveListeners();\n }\n }\n\n /**\n * Add a group of listeners to detect usage of any pointing devices.\n * These listeners will be added when the polyfill first loads, and anytime\n * the window is blurred, so that they are active when the window regains\n * focus.\n */\n function addInitialPointerMoveListeners() {\n document.addEventListener('mousemove', onInitialPointerMove);\n document.addEventListener('mousedown', onInitialPointerMove);\n document.addEventListener('mouseup', onInitialPointerMove);\n document.addEventListener('pointermove', onInitialPointerMove);\n document.addEventListener('pointerdown', onInitialPointerMove);\n document.addEventListener('pointerup', onInitialPointerMove);\n document.addEventListener('touchmove', onInitialPointerMove);\n document.addEventListener('touchstart', onInitialPointerMove);\n document.addEventListener('touchend', onInitialPointerMove);\n }\n\n function removeInitialPointerMoveListeners() {\n document.removeEventListener('mousemove', onInitialPointerMove);\n document.removeEventListener('mousedown', onInitialPointerMove);\n document.removeEventListener('mouseup', onInitialPointerMove);\n document.removeEventListener('pointermove', onInitialPointerMove);\n document.removeEventListener('pointerdown', onInitialPointerMove);\n document.removeEventListener('pointerup', onInitialPointerMove);\n document.removeEventListener('touchmove', onInitialPointerMove);\n document.removeEventListener('touchstart', onInitialPointerMove);\n document.removeEventListener('touchend', onInitialPointerMove);\n }\n\n /**\n * When the polfyill first loads, assume the user is in keyboard modality.\n * If any event is received from a pointing device (e.g. mouse, pointer,\n * touch), turn off keyboard modality.\n * This accounts for situations where focus enters the page from the URL bar.\n * @param {Event} e\n */\n function onInitialPointerMove(e) {\n // Work around a Safari quirk that fires a mousemove on whenever the\n // window blurs, even if you're tabbing out of the page. \u00AF\\_(\u30C4)_/\u00AF\n if (e.target.nodeName && e.target.nodeName.toLowerCase() === 'html') {\n return;\n }\n\n hadKeyboardEvent = false;\n removeInitialPointerMoveListeners();\n }\n\n // For some kinds of state, we are interested in changes at the global scope\n // only. For example, global pointer input, global key presses and global\n // visibility change should affect the state at every scope:\n document.addEventListener('keydown', onKeyDown, true);\n document.addEventListener('mousedown', onPointerDown, true);\n document.addEventListener('pointerdown', onPointerDown, true);\n document.addEventListener('touchstart', onPointerDown, true);\n document.addEventListener('visibilitychange', onVisibilityChange, true);\n\n addInitialPointerMoveListeners();\n\n // For focus and blur, we specifically care about state changes in the local\n // scope. This is because focus / blur events that originate from within a\n // shadow root are not re-dispatched from the host element if it was already\n // the active element in its own scope:\n scope.addEventListener('focus', onFocus, true);\n scope.addEventListener('blur', onBlur, true);\n\n // We detect that a node is a ShadowRoot by ensuring that it is a\n // DocumentFragment and also has a host property. This check covers native\n // implementation and polyfill implementation transparently. If we only cared\n // about the native implementation, we could just check if the scope was\n // an instance of a ShadowRoot.\n if (scope.nodeType === Node.DOCUMENT_FRAGMENT_NODE && scope.host) {\n // Since a ShadowRoot is a special kind of DocumentFragment, it does not\n // have a root element to add a class to. So, we add this attribute to the\n // host element instead:\n scope.host.setAttribute('data-js-focus-visible', '');\n } else if (scope.nodeType === Node.DOCUMENT_NODE) {\n document.documentElement.classList.add('js-focus-visible');\n document.documentElement.setAttribute('data-js-focus-visible', '');\n }\n }\n\n // It is important to wrap all references to global window and document in\n // these checks to support server-side rendering use cases\n // @see https://github.com/WICG/focus-visible/issues/199\n if (typeof window !== 'undefined' && typeof document !== 'undefined') {\n // Make the polyfill helper globally available. This can be used as a signal\n // to interested libraries that wish to coordinate with the polyfill for e.g.,\n // applying the polyfill to a shadow root:\n window.applyFocusVisiblePolyfill = applyFocusVisiblePolyfill;\n\n // Notify interested libraries of the polyfill's presence, in case the\n // polyfill was loaded lazily:\n var event;\n\n try {\n event = new CustomEvent('focus-visible-polyfill-ready');\n } catch (error) {\n // IE11 does not support using CustomEvent as a constructor directly:\n event = document.createEvent('CustomEvent');\n event.initCustomEvent('focus-visible-polyfill-ready', false, false, {});\n }\n\n window.dispatchEvent(event);\n }\n\n if (typeof document !== 'undefined') {\n // Apply the polyfill to the global document, so that no JavaScript\n // coordination is required to use the polyfill in the top-level document:\n applyFocusVisiblePolyfill(document);\n }\n\n})));\n", "/*!\n * escape-html\n * Copyright(c) 2012-2013 TJ Holowaychuk\n * Copyright(c) 2015 Andreas Lubbe\n * Copyright(c) 2015 Tiancheng \"Timothy\" Gu\n * MIT Licensed\n */\n\n'use strict';\n\n/**\n * Module variables.\n * @private\n */\n\nvar matchHtmlRegExp = /[\"'&<>]/;\n\n/**\n * Module exports.\n * @public\n */\n\nmodule.exports = escapeHtml;\n\n/**\n * Escape special characters in the given string of html.\n *\n * @param {string} string The string to escape for inserting into HTML\n * @return {string}\n * @public\n */\n\nfunction escapeHtml(string) {\n var str = '' + string;\n var match = matchHtmlRegExp.exec(str);\n\n if (!match) {\n return str;\n }\n\n var escape;\n var html = '';\n var index = 0;\n var lastIndex = 0;\n\n for (index = match.index; index < str.length; index++) {\n switch (str.charCodeAt(index)) {\n case 34: // \"\n escape = '"';\n break;\n case 38: // &\n escape = '&';\n break;\n case 39: // '\n escape = ''';\n break;\n case 60: // <\n escape = '<';\n break;\n case 62: // >\n escape = '>';\n break;\n default:\n continue;\n }\n\n if (lastIndex !== index) {\n html += str.substring(lastIndex, index);\n }\n\n lastIndex = index + 1;\n html += escape;\n }\n\n return lastIndex !== index\n ? html + str.substring(lastIndex, index)\n : html;\n}\n", "/*!\n * clipboard.js v2.0.11\n * https://clipboardjs.com/\n *\n * Licensed MIT \u00A9 Zeno Rocha\n */\n(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"ClipboardJS\"] = factory();\n\telse\n\t\troot[\"ClipboardJS\"] = factory();\n})(this, function() {\nreturn /******/ (function() { // webpackBootstrap\n/******/ \tvar __webpack_modules__ = ({\n\n/***/ 686:\n/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {\n\n\"use strict\";\n\n// EXPORTS\n__webpack_require__.d(__webpack_exports__, {\n \"default\": function() { return /* binding */ clipboard; }\n});\n\n// EXTERNAL MODULE: ./node_modules/tiny-emitter/index.js\nvar tiny_emitter = __webpack_require__(279);\nvar tiny_emitter_default = /*#__PURE__*/__webpack_require__.n(tiny_emitter);\n// EXTERNAL MODULE: ./node_modules/good-listener/src/listen.js\nvar listen = __webpack_require__(370);\nvar listen_default = /*#__PURE__*/__webpack_require__.n(listen);\n// EXTERNAL MODULE: ./node_modules/select/src/select.js\nvar src_select = __webpack_require__(817);\nvar select_default = /*#__PURE__*/__webpack_require__.n(src_select);\n;// CONCATENATED MODULE: ./src/common/command.js\n/**\n * Executes a given operation type.\n * @param {String} type\n * @return {Boolean}\n */\nfunction command(type) {\n try {\n return document.execCommand(type);\n } catch (err) {\n return false;\n }\n}\n;// CONCATENATED MODULE: ./src/actions/cut.js\n\n\n/**\n * Cut action wrapper.\n * @param {String|HTMLElement} target\n * @return {String}\n */\n\nvar ClipboardActionCut = function ClipboardActionCut(target) {\n var selectedText = select_default()(target);\n command('cut');\n return selectedText;\n};\n\n/* harmony default export */ var actions_cut = (ClipboardActionCut);\n;// CONCATENATED MODULE: ./src/common/create-fake-element.js\n/**\n * Creates a fake textarea element with a value.\n * @param {String} value\n * @return {HTMLElement}\n */\nfunction createFakeElement(value) {\n var isRTL = document.documentElement.getAttribute('dir') === 'rtl';\n var fakeElement = document.createElement('textarea'); // Prevent zooming on iOS\n\n fakeElement.style.fontSize = '12pt'; // Reset box model\n\n fakeElement.style.border = '0';\n fakeElement.style.padding = '0';\n fakeElement.style.margin = '0'; // Move element out of screen horizontally\n\n fakeElement.style.position = 'absolute';\n fakeElement.style[isRTL ? 'right' : 'left'] = '-9999px'; // Move element to the same position vertically\n\n var yPosition = window.pageYOffset || document.documentElement.scrollTop;\n fakeElement.style.top = \"\".concat(yPosition, \"px\");\n fakeElement.setAttribute('readonly', '');\n fakeElement.value = value;\n return fakeElement;\n}\n;// CONCATENATED MODULE: ./src/actions/copy.js\n\n\n\n/**\n * Create fake copy action wrapper using a fake element.\n * @param {String} target\n * @param {Object} options\n * @return {String}\n */\n\nvar fakeCopyAction = function fakeCopyAction(value, options) {\n var fakeElement = createFakeElement(value);\n options.container.appendChild(fakeElement);\n var selectedText = select_default()(fakeElement);\n command('copy');\n fakeElement.remove();\n return selectedText;\n};\n/**\n * Copy action wrapper.\n * @param {String|HTMLElement} target\n * @param {Object} options\n * @return {String}\n */\n\n\nvar ClipboardActionCopy = function ClipboardActionCopy(target) {\n var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {\n container: document.body\n };\n var selectedText = '';\n\n if (typeof target === 'string') {\n selectedText = fakeCopyAction(target, options);\n } else if (target instanceof HTMLInputElement && !['text', 'search', 'url', 'tel', 'password'].includes(target === null || target === void 0 ? void 0 : target.type)) {\n // If input type doesn't support `setSelectionRange`. Simulate it. https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setSelectionRange\n selectedText = fakeCopyAction(target.value, options);\n } else {\n selectedText = select_default()(target);\n command('copy');\n }\n\n return selectedText;\n};\n\n/* harmony default export */ var actions_copy = (ClipboardActionCopy);\n;// CONCATENATED MODULE: ./src/actions/default.js\nfunction _typeof(obj) { \"@babel/helpers - typeof\"; if (typeof Symbol === \"function\" && typeof Symbol.iterator === \"symbol\") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === \"function\" && obj.constructor === Symbol && obj !== Symbol.prototype ? \"symbol\" : typeof obj; }; } return _typeof(obj); }\n\n\n\n/**\n * Inner function which performs selection from either `text` or `target`\n * properties and then executes copy or cut operations.\n * @param {Object} options\n */\n\nvar ClipboardActionDefault = function ClipboardActionDefault() {\n var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};\n // Defines base properties passed from constructor.\n var _options$action = options.action,\n action = _options$action === void 0 ? 'copy' : _options$action,\n container = options.container,\n target = options.target,\n text = options.text; // Sets the `action` to be performed which can be either 'copy' or 'cut'.\n\n if (action !== 'copy' && action !== 'cut') {\n throw new Error('Invalid \"action\" value, use either \"copy\" or \"cut\"');\n } // Sets the `target` property using an element that will be have its content copied.\n\n\n if (target !== undefined) {\n if (target && _typeof(target) === 'object' && target.nodeType === 1) {\n if (action === 'copy' && target.hasAttribute('disabled')) {\n throw new Error('Invalid \"target\" attribute. Please use \"readonly\" instead of \"disabled\" attribute');\n }\n\n if (action === 'cut' && (target.hasAttribute('readonly') || target.hasAttribute('disabled'))) {\n throw new Error('Invalid \"target\" attribute. You can\\'t cut text from elements with \"readonly\" or \"disabled\" attributes');\n }\n } else {\n throw new Error('Invalid \"target\" value, use a valid Element');\n }\n } // Define selection strategy based on `text` property.\n\n\n if (text) {\n return actions_copy(text, {\n container: container\n });\n } // Defines which selection strategy based on `target` property.\n\n\n if (target) {\n return action === 'cut' ? actions_cut(target) : actions_copy(target, {\n container: container\n });\n }\n};\n\n/* harmony default export */ var actions_default = (ClipboardActionDefault);\n;// CONCATENATED MODULE: ./src/clipboard.js\nfunction clipboard_typeof(obj) { \"@babel/helpers - typeof\"; if (typeof Symbol === \"function\" && typeof Symbol.iterator === \"symbol\") { clipboard_typeof = function _typeof(obj) { return typeof obj; }; } else { clipboard_typeof = function _typeof(obj) { return obj && typeof Symbol === \"function\" && obj.constructor === Symbol && obj !== Symbol.prototype ? \"symbol\" : typeof obj; }; } return clipboard_typeof(obj); }\n\nfunction _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError(\"Cannot call a class as a function\"); } }\n\nfunction _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if (\"value\" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }\n\nfunction _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }\n\nfunction _inherits(subClass, superClass) { if (typeof superClass !== \"function\" && superClass !== null) { throw new TypeError(\"Super expression must either be null or a function\"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); }\n\nfunction _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }\n\nfunction _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }\n\nfunction _possibleConstructorReturn(self, call) { if (call && (clipboard_typeof(call) === \"object\" || typeof call === \"function\")) { return call; } return _assertThisInitialized(self); }\n\nfunction _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError(\"this hasn't been initialised - super() hasn't been called\"); } return self; }\n\nfunction _isNativeReflectConstruct() { if (typeof Reflect === \"undefined\" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === \"function\") return true; try { Date.prototype.toString.call(Reflect.construct(Date, [], function () {})); return true; } catch (e) { return false; } }\n\nfunction _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }\n\n\n\n\n\n\n/**\n * Helper function to retrieve attribute value.\n * @param {String} suffix\n * @param {Element} element\n */\n\nfunction getAttributeValue(suffix, element) {\n var attribute = \"data-clipboard-\".concat(suffix);\n\n if (!element.hasAttribute(attribute)) {\n return;\n }\n\n return element.getAttribute(attribute);\n}\n/**\n * Base class which takes one or more elements, adds event listeners to them,\n * and instantiates a new `ClipboardAction` on each click.\n */\n\n\nvar Clipboard = /*#__PURE__*/function (_Emitter) {\n _inherits(Clipboard, _Emitter);\n\n var _super = _createSuper(Clipboard);\n\n /**\n * @param {String|HTMLElement|HTMLCollection|NodeList} trigger\n * @param {Object} options\n */\n function Clipboard(trigger, options) {\n var _this;\n\n _classCallCheck(this, Clipboard);\n\n _this = _super.call(this);\n\n _this.resolveOptions(options);\n\n _this.listenClick(trigger);\n\n return _this;\n }\n /**\n * Defines if attributes would be resolved using internal setter functions\n * or custom functions that were passed in the constructor.\n * @param {Object} options\n */\n\n\n _createClass(Clipboard, [{\n key: \"resolveOptions\",\n value: function resolveOptions() {\n var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};\n this.action = typeof options.action === 'function' ? options.action : this.defaultAction;\n this.target = typeof options.target === 'function' ? options.target : this.defaultTarget;\n this.text = typeof options.text === 'function' ? options.text : this.defaultText;\n this.container = clipboard_typeof(options.container) === 'object' ? options.container : document.body;\n }\n /**\n * Adds a click event listener to the passed trigger.\n * @param {String|HTMLElement|HTMLCollection|NodeList} trigger\n */\n\n }, {\n key: \"listenClick\",\n value: function listenClick(trigger) {\n var _this2 = this;\n\n this.listener = listen_default()(trigger, 'click', function (e) {\n return _this2.onClick(e);\n });\n }\n /**\n * Defines a new `ClipboardAction` on each click event.\n * @param {Event} e\n */\n\n }, {\n key: \"onClick\",\n value: function onClick(e) {\n var trigger = e.delegateTarget || e.currentTarget;\n var action = this.action(trigger) || 'copy';\n var text = actions_default({\n action: action,\n container: this.container,\n target: this.target(trigger),\n text: this.text(trigger)\n }); // Fires an event based on the copy operation result.\n\n this.emit(text ? 'success' : 'error', {\n action: action,\n text: text,\n trigger: trigger,\n clearSelection: function clearSelection() {\n if (trigger) {\n trigger.focus();\n }\n\n window.getSelection().removeAllRanges();\n }\n });\n }\n /**\n * Default `action` lookup function.\n * @param {Element} trigger\n */\n\n }, {\n key: \"defaultAction\",\n value: function defaultAction(trigger) {\n return getAttributeValue('action', trigger);\n }\n /**\n * Default `target` lookup function.\n * @param {Element} trigger\n */\n\n }, {\n key: \"defaultTarget\",\n value: function defaultTarget(trigger) {\n var selector = getAttributeValue('target', trigger);\n\n if (selector) {\n return document.querySelector(selector);\n }\n }\n /**\n * Allow fire programmatically a copy action\n * @param {String|HTMLElement} target\n * @param {Object} options\n * @returns Text copied.\n */\n\n }, {\n key: \"defaultText\",\n\n /**\n * Default `text` lookup function.\n * @param {Element} trigger\n */\n value: function defaultText(trigger) {\n return getAttributeValue('text', trigger);\n }\n /**\n * Destroy lifecycle.\n */\n\n }, {\n key: \"destroy\",\n value: function destroy() {\n this.listener.destroy();\n }\n }], [{\n key: \"copy\",\n value: function copy(target) {\n var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {\n container: document.body\n };\n return actions_copy(target, options);\n }\n /**\n * Allow fire programmatically a cut action\n * @param {String|HTMLElement} target\n * @returns Text cutted.\n */\n\n }, {\n key: \"cut\",\n value: function cut(target) {\n return actions_cut(target);\n }\n /**\n * Returns the support of the given action, or all actions if no action is\n * given.\n * @param {String} [action]\n */\n\n }, {\n key: \"isSupported\",\n value: function isSupported() {\n var action = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ['copy', 'cut'];\n var actions = typeof action === 'string' ? [action] : action;\n var support = !!document.queryCommandSupported;\n actions.forEach(function (action) {\n support = support && !!document.queryCommandSupported(action);\n });\n return support;\n }\n }]);\n\n return Clipboard;\n}((tiny_emitter_default()));\n\n/* harmony default export */ var clipboard = (Clipboard);\n\n/***/ }),\n\n/***/ 828:\n/***/ (function(module) {\n\nvar DOCUMENT_NODE_TYPE = 9;\n\n/**\n * A polyfill for Element.matches()\n */\nif (typeof Element !== 'undefined' && !Element.prototype.matches) {\n var proto = Element.prototype;\n\n proto.matches = proto.matchesSelector ||\n proto.mozMatchesSelector ||\n proto.msMatchesSelector ||\n proto.oMatchesSelector ||\n proto.webkitMatchesSelector;\n}\n\n/**\n * Finds the closest parent that matches a selector.\n *\n * @param {Element} element\n * @param {String} selector\n * @return {Function}\n */\nfunction closest (element, selector) {\n while (element && element.nodeType !== DOCUMENT_NODE_TYPE) {\n if (typeof element.matches === 'function' &&\n element.matches(selector)) {\n return element;\n }\n element = element.parentNode;\n }\n}\n\nmodule.exports = closest;\n\n\n/***/ }),\n\n/***/ 438:\n/***/ (function(module, __unused_webpack_exports, __webpack_require__) {\n\nvar closest = __webpack_require__(828);\n\n/**\n * Delegates event to a selector.\n *\n * @param {Element} element\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @param {Boolean} useCapture\n * @return {Object}\n */\nfunction _delegate(element, selector, type, callback, useCapture) {\n var listenerFn = listener.apply(this, arguments);\n\n element.addEventListener(type, listenerFn, useCapture);\n\n return {\n destroy: function() {\n element.removeEventListener(type, listenerFn, useCapture);\n }\n }\n}\n\n/**\n * Delegates event to a selector.\n *\n * @param {Element|String|Array} [elements]\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @param {Boolean} useCapture\n * @return {Object}\n */\nfunction delegate(elements, selector, type, callback, useCapture) {\n // Handle the regular Element usage\n if (typeof elements.addEventListener === 'function') {\n return _delegate.apply(null, arguments);\n }\n\n // Handle Element-less usage, it defaults to global delegation\n if (typeof type === 'function') {\n // Use `document` as the first parameter, then apply arguments\n // This is a short way to .unshift `arguments` without running into deoptimizations\n return _delegate.bind(null, document).apply(null, arguments);\n }\n\n // Handle Selector-based usage\n if (typeof elements === 'string') {\n elements = document.querySelectorAll(elements);\n }\n\n // Handle Array-like based usage\n return Array.prototype.map.call(elements, function (element) {\n return _delegate(element, selector, type, callback, useCapture);\n });\n}\n\n/**\n * Finds closest match and invokes callback.\n *\n * @param {Element} element\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @return {Function}\n */\nfunction listener(element, selector, type, callback) {\n return function(e) {\n e.delegateTarget = closest(e.target, selector);\n\n if (e.delegateTarget) {\n callback.call(element, e);\n }\n }\n}\n\nmodule.exports = delegate;\n\n\n/***/ }),\n\n/***/ 879:\n/***/ (function(__unused_webpack_module, exports) {\n\n/**\n * Check if argument is a HTML element.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.node = function(value) {\n return value !== undefined\n && value instanceof HTMLElement\n && value.nodeType === 1;\n};\n\n/**\n * Check if argument is a list of HTML elements.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.nodeList = function(value) {\n var type = Object.prototype.toString.call(value);\n\n return value !== undefined\n && (type === '[object NodeList]' || type === '[object HTMLCollection]')\n && ('length' in value)\n && (value.length === 0 || exports.node(value[0]));\n};\n\n/**\n * Check if argument is a string.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.string = function(value) {\n return typeof value === 'string'\n || value instanceof String;\n};\n\n/**\n * Check if argument is a function.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.fn = function(value) {\n var type = Object.prototype.toString.call(value);\n\n return type === '[object Function]';\n};\n\n\n/***/ }),\n\n/***/ 370:\n/***/ (function(module, __unused_webpack_exports, __webpack_require__) {\n\nvar is = __webpack_require__(879);\nvar delegate = __webpack_require__(438);\n\n/**\n * Validates all params and calls the right\n * listener function based on its target type.\n *\n * @param {String|HTMLElement|HTMLCollection|NodeList} target\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listen(target, type, callback) {\n if (!target && !type && !callback) {\n throw new Error('Missing required arguments');\n }\n\n if (!is.string(type)) {\n throw new TypeError('Second argument must be a String');\n }\n\n if (!is.fn(callback)) {\n throw new TypeError('Third argument must be a Function');\n }\n\n if (is.node(target)) {\n return listenNode(target, type, callback);\n }\n else if (is.nodeList(target)) {\n return listenNodeList(target, type, callback);\n }\n else if (is.string(target)) {\n return listenSelector(target, type, callback);\n }\n else {\n throw new TypeError('First argument must be a String, HTMLElement, HTMLCollection, or NodeList');\n }\n}\n\n/**\n * Adds an event listener to a HTML element\n * and returns a remove listener function.\n *\n * @param {HTMLElement} node\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenNode(node, type, callback) {\n node.addEventListener(type, callback);\n\n return {\n destroy: function() {\n node.removeEventListener(type, callback);\n }\n }\n}\n\n/**\n * Add an event listener to a list of HTML elements\n * and returns a remove listener function.\n *\n * @param {NodeList|HTMLCollection} nodeList\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenNodeList(nodeList, type, callback) {\n Array.prototype.forEach.call(nodeList, function(node) {\n node.addEventListener(type, callback);\n });\n\n return {\n destroy: function() {\n Array.prototype.forEach.call(nodeList, function(node) {\n node.removeEventListener(type, callback);\n });\n }\n }\n}\n\n/**\n * Add an event listener to a selector\n * and returns a remove listener function.\n *\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenSelector(selector, type, callback) {\n return delegate(document.body, selector, type, callback);\n}\n\nmodule.exports = listen;\n\n\n/***/ }),\n\n/***/ 817:\n/***/ (function(module) {\n\nfunction select(element) {\n var selectedText;\n\n if (element.nodeName === 'SELECT') {\n element.focus();\n\n selectedText = element.value;\n }\n else if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {\n var isReadOnly = element.hasAttribute('readonly');\n\n if (!isReadOnly) {\n element.setAttribute('readonly', '');\n }\n\n element.select();\n element.setSelectionRange(0, element.value.length);\n\n if (!isReadOnly) {\n element.removeAttribute('readonly');\n }\n\n selectedText = element.value;\n }\n else {\n if (element.hasAttribute('contenteditable')) {\n element.focus();\n }\n\n var selection = window.getSelection();\n var range = document.createRange();\n\n range.selectNodeContents(element);\n selection.removeAllRanges();\n selection.addRange(range);\n\n selectedText = selection.toString();\n }\n\n return selectedText;\n}\n\nmodule.exports = select;\n\n\n/***/ }),\n\n/***/ 279:\n/***/ (function(module) {\n\nfunction E () {\n // Keep this empty so it's easier to inherit from\n // (via https://github.com/lipsmack from https://github.com/scottcorgan/tiny-emitter/issues/3)\n}\n\nE.prototype = {\n on: function (name, callback, ctx) {\n var e = this.e || (this.e = {});\n\n (e[name] || (e[name] = [])).push({\n fn: callback,\n ctx: ctx\n });\n\n return this;\n },\n\n once: function (name, callback, ctx) {\n var self = this;\n function listener () {\n self.off(name, listener);\n callback.apply(ctx, arguments);\n };\n\n listener._ = callback\n return this.on(name, listener, ctx);\n },\n\n emit: function (name) {\n var data = [].slice.call(arguments, 1);\n var evtArr = ((this.e || (this.e = {}))[name] || []).slice();\n var i = 0;\n var len = evtArr.length;\n\n for (i; i < len; i++) {\n evtArr[i].fn.apply(evtArr[i].ctx, data);\n }\n\n return this;\n },\n\n off: function (name, callback) {\n var e = this.e || (this.e = {});\n var evts = e[name];\n var liveEvents = [];\n\n if (evts && callback) {\n for (var i = 0, len = evts.length; i < len; i++) {\n if (evts[i].fn !== callback && evts[i].fn._ !== callback)\n liveEvents.push(evts[i]);\n }\n }\n\n // Remove event from queue to prevent memory leak\n // Suggested by https://github.com/lazd\n // Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910\n\n (liveEvents.length)\n ? e[name] = liveEvents\n : delete e[name];\n\n return this;\n }\n};\n\nmodule.exports = E;\nmodule.exports.TinyEmitter = E;\n\n\n/***/ })\n\n/******/ \t});\n/************************************************************************/\n/******/ \t// The module cache\n/******/ \tvar __webpack_module_cache__ = {};\n/******/ \t\n/******/ \t// The require function\n/******/ \tfunction __webpack_require__(moduleId) {\n/******/ \t\t// Check if module is in cache\n/******/ \t\tif(__webpack_module_cache__[moduleId]) {\n/******/ \t\t\treturn __webpack_module_cache__[moduleId].exports;\n/******/ \t\t}\n/******/ \t\t// Create a new module (and put it into the cache)\n/******/ \t\tvar module = __webpack_module_cache__[moduleId] = {\n/******/ \t\t\t// no module.id needed\n/******/ \t\t\t// no module.loaded needed\n/******/ \t\t\texports: {}\n/******/ \t\t};\n/******/ \t\n/******/ \t\t// Execute the module function\n/******/ \t\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n/******/ \t\n/******/ \t\t// Return the exports of the module\n/******/ \t\treturn module.exports;\n/******/ \t}\n/******/ \t\n/************************************************************************/\n/******/ \t/* webpack/runtime/compat get default export */\n/******/ \t!function() {\n/******/ \t\t// getDefaultExport function for compatibility with non-harmony modules\n/******/ \t\t__webpack_require__.n = function(module) {\n/******/ \t\t\tvar getter = module && module.__esModule ?\n/******/ \t\t\t\tfunction() { return module['default']; } :\n/******/ \t\t\t\tfunction() { return module; };\n/******/ \t\t\t__webpack_require__.d(getter, { a: getter });\n/******/ \t\t\treturn getter;\n/******/ \t\t};\n/******/ \t}();\n/******/ \t\n/******/ \t/* webpack/runtime/define property getters */\n/******/ \t!function() {\n/******/ \t\t// define getter functions for harmony exports\n/******/ \t\t__webpack_require__.d = function(exports, definition) {\n/******/ \t\t\tfor(var key in definition) {\n/******/ \t\t\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n/******/ \t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n/******/ \t\t\t\t}\n/******/ \t\t\t}\n/******/ \t\t};\n/******/ \t}();\n/******/ \t\n/******/ \t/* webpack/runtime/hasOwnProperty shorthand */\n/******/ \t!function() {\n/******/ \t\t__webpack_require__.o = function(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); }\n/******/ \t}();\n/******/ \t\n/************************************************************************/\n/******/ \t// module exports must be returned from runtime so entry inlining is disabled\n/******/ \t// startup\n/******/ \t// Load entry module and return exports\n/******/ \treturn __webpack_require__(686);\n/******/ })()\n.default;\n});", "/*\n * Copyright (c) 2016-2025 Martin Donath \n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to\n * deal in the Software without restriction, including without limitation the\n * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or\n * sell copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in\n * all copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\n * IN THE SOFTWARE.\n */\n\nimport \"focus-visible\"\n\nimport {\n EMPTY,\n NEVER,\n Observable,\n Subject,\n defer,\n delay,\n filter,\n map,\n merge,\n mergeWith,\n shareReplay,\n switchMap\n} from \"rxjs\"\n\nimport { configuration, feature } from \"./_\"\nimport {\n at,\n getActiveElement,\n getOptionalElement,\n requestJSON,\n setLocation,\n setToggle,\n watchDocument,\n watchKeyboard,\n watchLocation,\n watchLocationTarget,\n watchMedia,\n watchPrint,\n watchScript,\n watchViewport\n} from \"./browser\"\nimport {\n getComponentElement,\n getComponentElements,\n mountAnnounce,\n mountBackToTop,\n mountConsent,\n mountContent,\n mountDialog,\n mountHeader,\n mountHeaderTitle,\n mountPalette,\n mountProgress,\n mountSearch,\n mountSearchHiglight,\n mountSidebar,\n mountSource,\n mountTableOfContents,\n mountTabs,\n watchHeader,\n watchMain\n} from \"./components\"\nimport {\n SearchIndex,\n fetchSitemap,\n setupAlternate,\n setupClipboardJS,\n setupInstantNavigation,\n setupVersionSelector\n} from \"./integrations\"\nimport {\n patchEllipsis,\n patchIndeterminate,\n patchScrollfix,\n patchScrolllock\n} from \"./patches\"\nimport \"./polyfills\"\n\n/* ----------------------------------------------------------------------------\n * Functions - @todo refactor\n * ------------------------------------------------------------------------- */\n\n/**\n * Fetch search index\n *\n * @returns Search index observable\n */\nfunction fetchSearchIndex(): Observable {\n if (location.protocol === \"file:\") {\n return watchScript(\n `${new URL(\"search/search_index.js\", config.base)}`\n )\n .pipe(\n // @ts-ignore - @todo fix typings\n map(() => __index),\n shareReplay(1)\n )\n } else {\n return requestJSON(\n new URL(\"search/search_index.json\", config.base)\n )\n }\n}\n\n/* ----------------------------------------------------------------------------\n * Application\n * ------------------------------------------------------------------------- */\n\n/* Yay, JavaScript is available */\ndocument.documentElement.classList.remove(\"no-js\")\ndocument.documentElement.classList.add(\"js\")\n\n/* Set up navigation observables and subjects */\nconst document$ = watchDocument()\nconst location$ = watchLocation()\nconst target$ = watchLocationTarget(location$)\nconst keyboard$ = watchKeyboard()\n\n/* Set up media observables */\nconst viewport$ = watchViewport()\nconst tablet$ = watchMedia(\"(min-width: 60em)\")\nconst screen$ = watchMedia(\"(min-width: 76.25em)\")\nconst print$ = watchPrint()\n\n/* Retrieve search index, if search is enabled */\nconst config = configuration()\nconst index$ = document.forms.namedItem(\"search\")\n ? fetchSearchIndex()\n : NEVER\n\n/* Set up Clipboard.js integration */\nconst alert$ = new Subject()\nsetupClipboardJS({ alert$ })\n\n/* Set up language selector */\nsetupAlternate({ document$ })\n\n/* Set up progress indicator */\nconst progress$ = new Subject()\n\n/* Set up sitemap for instant navigation and previews */\nconst sitemap$ = fetchSitemap(config.base)\n\n/* Set up instant navigation, if enabled */\nif (feature(\"navigation.instant\"))\n setupInstantNavigation({ sitemap$, location$, viewport$, progress$ })\n .subscribe(document$)\n\n/* Set up version selector */\nif (config.version?.provider === \"mike\")\n setupVersionSelector({ document$ })\n\n/* Always close drawer and search on navigation */\nmerge(location$, target$)\n .pipe(\n delay(125)\n )\n .subscribe(() => {\n setToggle(\"drawer\", false)\n setToggle(\"search\", false)\n })\n\n/* Set up global keyboard handlers */\nkeyboard$\n .pipe(\n filter(({ mode }) => mode === \"global\")\n )\n .subscribe(key => {\n switch (key.type) {\n\n /* Go to previous page */\n case \"p\":\n case \",\":\n const prev = getOptionalElement(\"link[rel=prev]\")\n if (typeof prev !== \"undefined\")\n setLocation(prev)\n break\n\n /* Go to next page */\n case \"n\":\n case \".\":\n const next = getOptionalElement(\"link[rel=next]\")\n if (typeof next !== \"undefined\")\n setLocation(next)\n break\n\n /* Expand navigation, see https://bit.ly/3ZjG5io */\n case \"Enter\":\n const active = getActiveElement()\n if (active instanceof HTMLLabelElement)\n active.click()\n }\n })\n\n/* Set up patches */\npatchEllipsis({ viewport$, document$ })\npatchIndeterminate({ document$, tablet$ })\npatchScrollfix({ document$ })\npatchScrolllock({ viewport$, tablet$ })\n\n/* Set up header and main area observable */\nconst header$ = watchHeader(getComponentElement(\"header\"), { viewport$ })\nconst main$ = document$\n .pipe(\n map(() => getComponentElement(\"main\")),\n switchMap(el => watchMain(el, { viewport$, header$ })),\n shareReplay(1)\n )\n\n/* Set up control component observables */\nconst control$ = merge(\n\n /* Consent */\n ...getComponentElements(\"consent\")\n .map(el => mountConsent(el, { target$ })),\n\n /* Dialog */\n ...getComponentElements(\"dialog\")\n .map(el => mountDialog(el, { alert$ })),\n\n /* Color palette */\n ...getComponentElements(\"palette\")\n .map(el => mountPalette(el)),\n\n /* Progress bar */\n ...getComponentElements(\"progress\")\n .map(el => mountProgress(el, { progress$ })),\n\n /* Search */\n ...getComponentElements(\"search\")\n .map(el => mountSearch(el, { index$, keyboard$ })),\n\n /* Repository information */\n ...getComponentElements(\"source\")\n .map(el => mountSource(el))\n)\n\n/* Set up content component observables */\nconst content$ = defer(() => merge(\n\n /* Announcement bar */\n ...getComponentElements(\"announce\")\n .map(el => mountAnnounce(el)),\n\n /* Content */\n ...getComponentElements(\"content\")\n .map(el => mountContent(el, { sitemap$, viewport$, target$, print$ })),\n\n /* Search highlighting */\n ...getComponentElements(\"content\")\n .map(el => feature(\"search.highlight\")\n ? mountSearchHiglight(el, { index$, location$ })\n : EMPTY\n ),\n\n /* Header */\n ...getComponentElements(\"header\")\n .map(el => mountHeader(el, { viewport$, header$, main$ })),\n\n /* Header title */\n ...getComponentElements(\"header-title\")\n .map(el => mountHeaderTitle(el, { viewport$, header$ })),\n\n /* Sidebar */\n ...getComponentElements(\"sidebar\")\n .map(el => el.getAttribute(\"data-md-type\") === \"navigation\"\n ? at(screen$, () => mountSidebar(el, { viewport$, header$, main$ }))\n : at(tablet$, () => mountSidebar(el, { viewport$, header$, main$ }))\n ),\n\n /* Navigation tabs */\n ...getComponentElements(\"tabs\")\n .map(el => mountTabs(el, { viewport$, header$ })),\n\n /* Table of contents */\n ...getComponentElements(\"toc\")\n .map(el => mountTableOfContents(el, {\n viewport$, header$, main$, target$\n })),\n\n /* Back-to-top button */\n ...getComponentElements(\"top\")\n .map(el => mountBackToTop(el, { viewport$, header$, main$, target$ }))\n))\n\n/* Set up component observables */\nconst component$ = document$\n .pipe(\n switchMap(() => content$),\n mergeWith(control$),\n shareReplay(1)\n )\n\n/* Subscribe to all components */\ncomponent$.subscribe()\n\n/* ----------------------------------------------------------------------------\n * Exports\n * ------------------------------------------------------------------------- */\n\nwindow.document$ = document$ /* Document observable */\nwindow.location$ = location$ /* Location subject */\nwindow.target$ = target$ /* Location target observable */\nwindow.keyboard$ = keyboard$ /* Keyboard observable */\nwindow.viewport$ = viewport$ /* Viewport observable */\nwindow.tablet$ = tablet$ /* Media tablet observable */\nwindow.screen$ = screen$ /* Media screen observable */\nwindow.print$ = print$ /* Media print observable */\nwindow.alert$ = alert$ /* Alert subject */\nwindow.progress$ = progress$ /* Progress indicator subject */\nwindow.component$ = component$ /* Component observable */\n", "/******************************************************************************\nCopyright (c) Microsoft Corporation.\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY\nAND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM\nLOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR\nOTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR\nPERFORMANCE OF THIS SOFTWARE.\n***************************************************************************** */\n/* global Reflect, Promise, SuppressedError, Symbol, Iterator */\n\nvar extendStatics = function(d, b) {\n extendStatics = Object.setPrototypeOf ||\n ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||\n function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };\n return extendStatics(d, b);\n};\n\nexport function __extends(d, b) {\n if (typeof b !== \"function\" && b !== null)\n throw new TypeError(\"Class extends value \" + String(b) + \" is not a constructor or null\");\n extendStatics(d, b);\n function __() { this.constructor = d; }\n d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());\n}\n\nexport var __assign = function() {\n __assign = Object.assign || function __assign(t) {\n for (var s, i = 1, n = arguments.length; i < n; i++) {\n s = arguments[i];\n for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];\n }\n return t;\n }\n return __assign.apply(this, arguments);\n}\n\nexport function __rest(s, e) {\n var t = {};\n for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)\n t[p] = s[p];\n if (s != null && typeof Object.getOwnPropertySymbols === \"function\")\n for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {\n if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))\n t[p[i]] = s[p[i]];\n }\n return t;\n}\n\nexport function __decorate(decorators, target, key, desc) {\n var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;\n if (typeof Reflect === \"object\" && typeof Reflect.decorate === \"function\") r = Reflect.decorate(decorators, target, key, desc);\n else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;\n return c > 3 && r && Object.defineProperty(target, key, r), r;\n}\n\nexport function __param(paramIndex, decorator) {\n return function (target, key) { decorator(target, key, paramIndex); }\n}\n\nexport function __esDecorate(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {\n function accept(f) { if (f !== void 0 && typeof f !== \"function\") throw new TypeError(\"Function expected\"); return f; }\n var kind = contextIn.kind, key = kind === \"getter\" ? \"get\" : kind === \"setter\" ? \"set\" : \"value\";\n var target = !descriptorIn && ctor ? contextIn[\"static\"] ? ctor : ctor.prototype : null;\n var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});\n var _, done = false;\n for (var i = decorators.length - 1; i >= 0; i--) {\n var context = {};\n for (var p in contextIn) context[p] = p === \"access\" ? {} : contextIn[p];\n for (var p in contextIn.access) context.access[p] = contextIn.access[p];\n context.addInitializer = function (f) { if (done) throw new TypeError(\"Cannot add initializers after decoration has completed\"); extraInitializers.push(accept(f || null)); };\n var result = (0, decorators[i])(kind === \"accessor\" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);\n if (kind === \"accessor\") {\n if (result === void 0) continue;\n if (result === null || typeof result !== \"object\") throw new TypeError(\"Object expected\");\n if (_ = accept(result.get)) descriptor.get = _;\n if (_ = accept(result.set)) descriptor.set = _;\n if (_ = accept(result.init)) initializers.unshift(_);\n }\n else if (_ = accept(result)) {\n if (kind === \"field\") initializers.unshift(_);\n else descriptor[key] = _;\n }\n }\n if (target) Object.defineProperty(target, contextIn.name, descriptor);\n done = true;\n};\n\nexport function __runInitializers(thisArg, initializers, value) {\n var useValue = arguments.length > 2;\n for (var i = 0; i < initializers.length; i++) {\n value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);\n }\n return useValue ? value : void 0;\n};\n\nexport function __propKey(x) {\n return typeof x === \"symbol\" ? x : \"\".concat(x);\n};\n\nexport function __setFunctionName(f, name, prefix) {\n if (typeof name === \"symbol\") name = name.description ? \"[\".concat(name.description, \"]\") : \"\";\n return Object.defineProperty(f, \"name\", { configurable: true, value: prefix ? \"\".concat(prefix, \" \", name) : name });\n};\n\nexport function __metadata(metadataKey, metadataValue) {\n if (typeof Reflect === \"object\" && typeof Reflect.metadata === \"function\") return Reflect.metadata(metadataKey, metadataValue);\n}\n\nexport function __awaiter(thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n}\n\nexport function __generator(thisArg, body) {\n var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === \"function\" ? Iterator : Object).prototype);\n return g.next = verb(0), g[\"throw\"] = verb(1), g[\"return\"] = verb(2), typeof Symbol === \"function\" && (g[Symbol.iterator] = function() { return this; }), g;\n function verb(n) { return function (v) { return step([n, v]); }; }\n function step(op) {\n if (f) throw new TypeError(\"Generator is already executing.\");\n while (g && (g = 0, op[0] && (_ = 0)), _) try {\n if (f = 1, y && (t = op[0] & 2 ? y[\"return\"] : op[0] ? y[\"throw\"] || ((t = y[\"return\"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;\n if (y = 0, t) op = [op[0] & 2, t.value];\n switch (op[0]) {\n case 0: case 1: t = op; break;\n case 4: _.label++; return { value: op[1], done: false };\n case 5: _.label++; y = op[1]; op = [0]; continue;\n case 7: op = _.ops.pop(); _.trys.pop(); continue;\n default:\n if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }\n if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }\n if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }\n if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }\n if (t[2]) _.ops.pop();\n _.trys.pop(); continue;\n }\n op = body.call(thisArg, _);\n } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }\n if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };\n }\n}\n\nexport var __createBinding = Object.create ? (function(o, m, k, k2) {\n if (k2 === undefined) k2 = k;\n var desc = Object.getOwnPropertyDescriptor(m, k);\n if (!desc || (\"get\" in desc ? !m.__esModule : desc.writable || desc.configurable)) {\n desc = { enumerable: true, get: function() { return m[k]; } };\n }\n Object.defineProperty(o, k2, desc);\n}) : (function(o, m, k, k2) {\n if (k2 === undefined) k2 = k;\n o[k2] = m[k];\n});\n\nexport function __exportStar(m, o) {\n for (var p in m) if (p !== \"default\" && !Object.prototype.hasOwnProperty.call(o, p)) __createBinding(o, m, p);\n}\n\nexport function __values(o) {\n var s = typeof Symbol === \"function\" && Symbol.iterator, m = s && o[s], i = 0;\n if (m) return m.call(o);\n if (o && typeof o.length === \"number\") return {\n next: function () {\n if (o && i >= o.length) o = void 0;\n return { value: o && o[i++], done: !o };\n }\n };\n throw new TypeError(s ? \"Object is not iterable.\" : \"Symbol.iterator is not defined.\");\n}\n\nexport function __read(o, n) {\n var m = typeof Symbol === \"function\" && o[Symbol.iterator];\n if (!m) return o;\n var i = m.call(o), r, ar = [], e;\n try {\n while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);\n }\n catch (error) { e = { error: error }; }\n finally {\n try {\n if (r && !r.done && (m = i[\"return\"])) m.call(i);\n }\n finally { if (e) throw e.error; }\n }\n return ar;\n}\n\n/** @deprecated */\nexport function __spread() {\n for (var ar = [], i = 0; i < arguments.length; i++)\n ar = ar.concat(__read(arguments[i]));\n return ar;\n}\n\n/** @deprecated */\nexport function __spreadArrays() {\n for (var s = 0, i = 0, il = arguments.length; i < il; i++) s += arguments[i].length;\n for (var r = Array(s), k = 0, i = 0; i < il; i++)\n for (var a = arguments[i], j = 0, jl = a.length; j < jl; j++, k++)\n r[k] = a[j];\n return r;\n}\n\nexport function __spreadArray(to, from, pack) {\n if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {\n if (ar || !(i in from)) {\n if (!ar) ar = Array.prototype.slice.call(from, 0, i);\n ar[i] = from[i];\n }\n }\n return to.concat(ar || Array.prototype.slice.call(from));\n}\n\nexport function __await(v) {\n return this instanceof __await ? (this.v = v, this) : new __await(v);\n}\n\nexport function __asyncGenerator(thisArg, _arguments, generator) {\n if (!Symbol.asyncIterator) throw new TypeError(\"Symbol.asyncIterator is not defined.\");\n var g = generator.apply(thisArg, _arguments || []), i, q = [];\n return i = Object.create((typeof AsyncIterator === \"function\" ? AsyncIterator : Object).prototype), verb(\"next\"), verb(\"throw\"), verb(\"return\", awaitReturn), i[Symbol.asyncIterator] = function () { return this; }, i;\n function awaitReturn(f) { return function (v) { return Promise.resolve(v).then(f, reject); }; }\n function verb(n, f) { if (g[n]) { i[n] = function (v) { return new Promise(function (a, b) { q.push([n, v, a, b]) > 1 || resume(n, v); }); }; if (f) i[n] = f(i[n]); } }\n function resume(n, v) { try { step(g[n](v)); } catch (e) { settle(q[0][3], e); } }\n function step(r) { r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r); }\n function fulfill(value) { resume(\"next\", value); }\n function reject(value) { resume(\"throw\", value); }\n function settle(f, v) { if (f(v), q.shift(), q.length) resume(q[0][0], q[0][1]); }\n}\n\nexport function __asyncDelegator(o) {\n var i, p;\n return i = {}, verb(\"next\"), verb(\"throw\", function (e) { throw e; }), verb(\"return\"), i[Symbol.iterator] = function () { return this; }, i;\n function verb(n, f) { i[n] = o[n] ? function (v) { return (p = !p) ? { value: __await(o[n](v)), done: false } : f ? f(v) : v; } : f; }\n}\n\nexport function __asyncValues(o) {\n if (!Symbol.asyncIterator) throw new TypeError(\"Symbol.asyncIterator is not defined.\");\n var m = o[Symbol.asyncIterator], i;\n return m ? m.call(o) : (o = typeof __values === \"function\" ? __values(o) : o[Symbol.iterator](), i = {}, verb(\"next\"), verb(\"throw\"), verb(\"return\"), i[Symbol.asyncIterator] = function () { return this; }, i);\n function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }\n function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }\n}\n\nexport function __makeTemplateObject(cooked, raw) {\n if (Object.defineProperty) { Object.defineProperty(cooked, \"raw\", { value: raw }); } else { cooked.raw = raw; }\n return cooked;\n};\n\nvar __setModuleDefault = Object.create ? (function(o, v) {\n Object.defineProperty(o, \"default\", { enumerable: true, value: v });\n}) : function(o, v) {\n o[\"default\"] = v;\n};\n\nexport function __importStar(mod) {\n if (mod && mod.__esModule) return mod;\n var result = {};\n if (mod != null) for (var k in mod) if (k !== \"default\" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);\n __setModuleDefault(result, mod);\n return result;\n}\n\nexport function __importDefault(mod) {\n return (mod && mod.__esModule) ? mod : { default: mod };\n}\n\nexport function __classPrivateFieldGet(receiver, state, kind, f) {\n if (kind === \"a\" && !f) throw new TypeError(\"Private accessor was defined without a getter\");\n if (typeof state === \"function\" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError(\"Cannot read private member from an object whose class did not declare it\");\n return kind === \"m\" ? f : kind === \"a\" ? f.call(receiver) : f ? f.value : state.get(receiver);\n}\n\nexport function __classPrivateFieldSet(receiver, state, value, kind, f) {\n if (kind === \"m\") throw new TypeError(\"Private method is not writable\");\n if (kind === \"a\" && !f) throw new TypeError(\"Private accessor was defined without a setter\");\n if (typeof state === \"function\" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError(\"Cannot write private member to an object whose class did not declare it\");\n return (kind === \"a\" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;\n}\n\nexport function __classPrivateFieldIn(state, receiver) {\n if (receiver === null || (typeof receiver !== \"object\" && typeof receiver !== \"function\")) throw new TypeError(\"Cannot use 'in' operator on non-object\");\n return typeof state === \"function\" ? receiver === state : state.has(receiver);\n}\n\nexport function __addDisposableResource(env, value, async) {\n if (value !== null && value !== void 0) {\n if (typeof value !== \"object\" && typeof value !== \"function\") throw new TypeError(\"Object expected.\");\n var dispose, inner;\n if (async) {\n if (!Symbol.asyncDispose) throw new TypeError(\"Symbol.asyncDispose is not defined.\");\n dispose = value[Symbol.asyncDispose];\n }\n if (dispose === void 0) {\n if (!Symbol.dispose) throw new TypeError(\"Symbol.dispose is not defined.\");\n dispose = value[Symbol.dispose];\n if (async) inner = dispose;\n }\n if (typeof dispose !== \"function\") throw new TypeError(\"Object not disposable.\");\n if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };\n env.stack.push({ value: value, dispose: dispose, async: async });\n }\n else if (async) {\n env.stack.push({ async: true });\n }\n return value;\n}\n\nvar _SuppressedError = typeof SuppressedError === \"function\" ? SuppressedError : function (error, suppressed, message) {\n var e = new Error(message);\n return e.name = \"SuppressedError\", e.error = error, e.suppressed = suppressed, e;\n};\n\nexport function __disposeResources(env) {\n function fail(e) {\n env.error = env.hasError ? new _SuppressedError(e, env.error, \"An error was suppressed during disposal.\") : e;\n env.hasError = true;\n }\n var r, s = 0;\n function next() {\n while (r = env.stack.pop()) {\n try {\n if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);\n if (r.dispose) {\n var result = r.dispose.call(r.value);\n if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });\n }\n else s |= 1;\n }\n catch (e) {\n fail(e);\n }\n }\n if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();\n if (env.hasError) throw env.error;\n }\n return next();\n}\n\nexport default {\n __extends,\n __assign,\n __rest,\n __decorate,\n __param,\n __metadata,\n __awaiter,\n __generator,\n __createBinding,\n __exportStar,\n __values,\n __read,\n __spread,\n __spreadArrays,\n __spreadArray,\n __await,\n __asyncGenerator,\n __asyncDelegator,\n __asyncValues,\n __makeTemplateObject,\n __importStar,\n __importDefault,\n __classPrivateFieldGet,\n __classPrivateFieldSet,\n __classPrivateFieldIn,\n __addDisposableResource,\n __disposeResources,\n};\n", "/**\n * Returns true if the object is a function.\n * @param value The value to check\n */\nexport function isFunction(value: any): value is (...args: any[]) => any {\n return typeof value === 'function';\n}\n", "/**\n * Used to create Error subclasses until the community moves away from ES5.\n *\n * This is because compiling from TypeScript down to ES5 has issues with subclassing Errors\n * as well as other built-in types: https://github.com/Microsoft/TypeScript/issues/12123\n *\n * @param createImpl A factory function to create the actual constructor implementation. The returned\n * function should be a named function that calls `_super` internally.\n */\nexport function createErrorClass(createImpl: (_super: any) => any): T {\n const _super = (instance: any) => {\n Error.call(instance);\n instance.stack = new Error().stack;\n };\n\n const ctorFunc = createImpl(_super);\n ctorFunc.prototype = Object.create(Error.prototype);\n ctorFunc.prototype.constructor = ctorFunc;\n return ctorFunc;\n}\n", "import { createErrorClass } from './createErrorClass';\n\nexport interface UnsubscriptionError extends Error {\n readonly errors: any[];\n}\n\nexport interface UnsubscriptionErrorCtor {\n /**\n * @deprecated Internal implementation detail. Do not construct error instances.\n * Cannot be tagged as internal: https://github.com/ReactiveX/rxjs/issues/6269\n */\n new (errors: any[]): UnsubscriptionError;\n}\n\n/**\n * An error thrown when one or more errors have occurred during the\n * `unsubscribe` of a {@link Subscription}.\n */\nexport const UnsubscriptionError: UnsubscriptionErrorCtor = createErrorClass(\n (_super) =>\n function UnsubscriptionErrorImpl(this: any, errors: (Error | string)[]) {\n _super(this);\n this.message = errors\n ? `${errors.length} errors occurred during unsubscription:\n${errors.map((err, i) => `${i + 1}) ${err.toString()}`).join('\\n ')}`\n : '';\n this.name = 'UnsubscriptionError';\n this.errors = errors;\n }\n);\n", "/**\n * Removes an item from an array, mutating it.\n * @param arr The array to remove the item from\n * @param item The item to remove\n */\nexport function arrRemove(arr: T[] | undefined | null, item: T) {\n if (arr) {\n const index = arr.indexOf(item);\n 0 <= index && arr.splice(index, 1);\n }\n}\n", "import { isFunction } from './util/isFunction';\nimport { UnsubscriptionError } from './util/UnsubscriptionError';\nimport { SubscriptionLike, TeardownLogic, Unsubscribable } from './types';\nimport { arrRemove } from './util/arrRemove';\n\n/**\n * Represents a disposable resource, such as the execution of an Observable. A\n * Subscription has one important method, `unsubscribe`, that takes no argument\n * and just disposes the resource held by the subscription.\n *\n * Additionally, subscriptions may be grouped together through the `add()`\n * method, which will attach a child Subscription to the current Subscription.\n * When a Subscription is unsubscribed, all its children (and its grandchildren)\n * will be unsubscribed as well.\n */\nexport class Subscription implements SubscriptionLike {\n public static EMPTY = (() => {\n const empty = new Subscription();\n empty.closed = true;\n return empty;\n })();\n\n /**\n * A flag to indicate whether this Subscription has already been unsubscribed.\n */\n public closed = false;\n\n private _parentage: Subscription[] | Subscription | null = null;\n\n /**\n * The list of registered finalizers to execute upon unsubscription. Adding and removing from this\n * list occurs in the {@link #add} and {@link #remove} methods.\n */\n private _finalizers: Exclude[] | null = null;\n\n /**\n * @param initialTeardown A function executed first as part of the finalization\n * process that is kicked off when {@link #unsubscribe} is called.\n */\n constructor(private initialTeardown?: () => void) {}\n\n /**\n * Disposes the resources held by the subscription. May, for instance, cancel\n * an ongoing Observable execution or cancel any other type of work that\n * started when the Subscription was created.\n */\n unsubscribe(): void {\n let errors: any[] | undefined;\n\n if (!this.closed) {\n this.closed = true;\n\n // Remove this from it's parents.\n const { _parentage } = this;\n if (_parentage) {\n this._parentage = null;\n if (Array.isArray(_parentage)) {\n for (const parent of _parentage) {\n parent.remove(this);\n }\n } else {\n _parentage.remove(this);\n }\n }\n\n const { initialTeardown: initialFinalizer } = this;\n if (isFunction(initialFinalizer)) {\n try {\n initialFinalizer();\n } catch (e) {\n errors = e instanceof UnsubscriptionError ? e.errors : [e];\n }\n }\n\n const { _finalizers } = this;\n if (_finalizers) {\n this._finalizers = null;\n for (const finalizer of _finalizers) {\n try {\n execFinalizer(finalizer);\n } catch (err) {\n errors = errors ?? [];\n if (err instanceof UnsubscriptionError) {\n errors = [...errors, ...err.errors];\n } else {\n errors.push(err);\n }\n }\n }\n }\n\n if (errors) {\n throw new UnsubscriptionError(errors);\n }\n }\n }\n\n /**\n * Adds a finalizer to this subscription, so that finalization will be unsubscribed/called\n * when this subscription is unsubscribed. If this subscription is already {@link #closed},\n * because it has already been unsubscribed, then whatever finalizer is passed to it\n * will automatically be executed (unless the finalizer itself is also a closed subscription).\n *\n * Closed Subscriptions cannot be added as finalizers to any subscription. Adding a closed\n * subscription to a any subscription will result in no operation. (A noop).\n *\n * Adding a subscription to itself, or adding `null` or `undefined` will not perform any\n * operation at all. (A noop).\n *\n * `Subscription` instances that are added to this instance will automatically remove themselves\n * if they are unsubscribed. Functions and {@link Unsubscribable} objects that you wish to remove\n * will need to be removed manually with {@link #remove}\n *\n * @param teardown The finalization logic to add to this subscription.\n */\n add(teardown: TeardownLogic): void {\n // Only add the finalizer if it's not undefined\n // and don't add a subscription to itself.\n if (teardown && teardown !== this) {\n if (this.closed) {\n // If this subscription is already closed,\n // execute whatever finalizer is handed to it automatically.\n execFinalizer(teardown);\n } else {\n if (teardown instanceof Subscription) {\n // We don't add closed subscriptions, and we don't add the same subscription\n // twice. Subscription unsubscribe is idempotent.\n if (teardown.closed || teardown._hasParent(this)) {\n return;\n }\n teardown._addParent(this);\n }\n (this._finalizers = this._finalizers ?? []).push(teardown);\n }\n }\n }\n\n /**\n * Checks to see if a this subscription already has a particular parent.\n * This will signal that this subscription has already been added to the parent in question.\n * @param parent the parent to check for\n */\n private _hasParent(parent: Subscription) {\n const { _parentage } = this;\n return _parentage === parent || (Array.isArray(_parentage) && _parentage.includes(parent));\n }\n\n /**\n * Adds a parent to this subscription so it can be removed from the parent if it\n * unsubscribes on it's own.\n *\n * NOTE: THIS ASSUMES THAT {@link _hasParent} HAS ALREADY BEEN CHECKED.\n * @param parent The parent subscription to add\n */\n private _addParent(parent: Subscription) {\n const { _parentage } = this;\n this._parentage = Array.isArray(_parentage) ? (_parentage.push(parent), _parentage) : _parentage ? [_parentage, parent] : parent;\n }\n\n /**\n * Called on a child when it is removed via {@link #remove}.\n * @param parent The parent to remove\n */\n private _removeParent(parent: Subscription) {\n const { _parentage } = this;\n if (_parentage === parent) {\n this._parentage = null;\n } else if (Array.isArray(_parentage)) {\n arrRemove(_parentage, parent);\n }\n }\n\n /**\n * Removes a finalizer from this subscription that was previously added with the {@link #add} method.\n *\n * Note that `Subscription` instances, when unsubscribed, will automatically remove themselves\n * from every other `Subscription` they have been added to. This means that using the `remove` method\n * is not a common thing and should be used thoughtfully.\n *\n * If you add the same finalizer instance of a function or an unsubscribable object to a `Subscription` instance\n * more than once, you will need to call `remove` the same number of times to remove all instances.\n *\n * All finalizer instances are removed to free up memory upon unsubscription.\n *\n * @param teardown The finalizer to remove from this subscription\n */\n remove(teardown: Exclude): void {\n const { _finalizers } = this;\n _finalizers && arrRemove(_finalizers, teardown);\n\n if (teardown instanceof Subscription) {\n teardown._removeParent(this);\n }\n }\n}\n\nexport const EMPTY_SUBSCRIPTION = Subscription.EMPTY;\n\nexport function isSubscription(value: any): value is Subscription {\n return (\n value instanceof Subscription ||\n (value && 'closed' in value && isFunction(value.remove) && isFunction(value.add) && isFunction(value.unsubscribe))\n );\n}\n\nfunction execFinalizer(finalizer: Unsubscribable | (() => void)) {\n if (isFunction(finalizer)) {\n finalizer();\n } else {\n finalizer.unsubscribe();\n }\n}\n", "import { Subscriber } from './Subscriber';\nimport { ObservableNotification } from './types';\n\n/**\n * The {@link GlobalConfig} object for RxJS. It is used to configure things\n * like how to react on unhandled errors.\n */\nexport const config: GlobalConfig = {\n onUnhandledError: null,\n onStoppedNotification: null,\n Promise: undefined,\n useDeprecatedSynchronousErrorHandling: false,\n useDeprecatedNextContext: false,\n};\n\n/**\n * The global configuration object for RxJS, used to configure things\n * like how to react on unhandled errors. Accessible via {@link config}\n * object.\n */\nexport interface GlobalConfig {\n /**\n * A registration point for unhandled errors from RxJS. These are errors that\n * cannot were not handled by consuming code in the usual subscription path. For\n * example, if you have this configured, and you subscribe to an observable without\n * providing an error handler, errors from that subscription will end up here. This\n * will _always_ be called asynchronously on another job in the runtime. This is because\n * we do not want errors thrown in this user-configured handler to interfere with the\n * behavior of the library.\n */\n onUnhandledError: ((err: any) => void) | null;\n\n /**\n * A registration point for notifications that cannot be sent to subscribers because they\n * have completed, errored or have been explicitly unsubscribed. By default, next, complete\n * and error notifications sent to stopped subscribers are noops. However, sometimes callers\n * might want a different behavior. For example, with sources that attempt to report errors\n * to stopped subscribers, a caller can configure RxJS to throw an unhandled error instead.\n * This will _always_ be called asynchronously on another job in the runtime. This is because\n * we do not want errors thrown in this user-configured handler to interfere with the\n * behavior of the library.\n */\n onStoppedNotification: ((notification: ObservableNotification, subscriber: Subscriber) => void) | null;\n\n /**\n * The promise constructor used by default for {@link Observable#toPromise toPromise} and {@link Observable#forEach forEach}\n * methods.\n *\n * @deprecated As of version 8, RxJS will no longer support this sort of injection of a\n * Promise constructor. If you need a Promise implementation other than native promises,\n * please polyfill/patch Promise as you see appropriate. Will be removed in v8.\n */\n Promise?: PromiseConstructorLike;\n\n /**\n * If true, turns on synchronous error rethrowing, which is a deprecated behavior\n * in v6 and higher. This behavior enables bad patterns like wrapping a subscribe\n * call in a try/catch block. It also enables producer interference, a nasty bug\n * where a multicast can be broken for all observers by a downstream consumer with\n * an unhandled error. DO NOT USE THIS FLAG UNLESS IT'S NEEDED TO BUY TIME\n * FOR MIGRATION REASONS.\n *\n * @deprecated As of version 8, RxJS will no longer support synchronous throwing\n * of unhandled errors. All errors will be thrown on a separate call stack to prevent bad\n * behaviors described above. Will be removed in v8.\n */\n useDeprecatedSynchronousErrorHandling: boolean;\n\n /**\n * If true, enables an as-of-yet undocumented feature from v5: The ability to access\n * `unsubscribe()` via `this` context in `next` functions created in observers passed\n * to `subscribe`.\n *\n * This is being removed because the performance was severely problematic, and it could also cause\n * issues when types other than POJOs are passed to subscribe as subscribers, as they will likely have\n * their `this` context overwritten.\n *\n * @deprecated As of version 8, RxJS will no longer support altering the\n * context of next functions provided as part of an observer to Subscribe. Instead,\n * you will have access to a subscription or a signal or token that will allow you to do things like\n * unsubscribe and test closed status. Will be removed in v8.\n */\n useDeprecatedNextContext: boolean;\n}\n", "import type { TimerHandle } from './timerHandle';\ntype SetTimeoutFunction = (handler: () => void, timeout?: number, ...args: any[]) => TimerHandle;\ntype ClearTimeoutFunction = (handle: TimerHandle) => void;\n\ninterface TimeoutProvider {\n setTimeout: SetTimeoutFunction;\n clearTimeout: ClearTimeoutFunction;\n delegate:\n | {\n setTimeout: SetTimeoutFunction;\n clearTimeout: ClearTimeoutFunction;\n }\n | undefined;\n}\n\nexport const timeoutProvider: TimeoutProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n setTimeout(handler: () => void, timeout?: number, ...args) {\n const { delegate } = timeoutProvider;\n if (delegate?.setTimeout) {\n return delegate.setTimeout(handler, timeout, ...args);\n }\n return setTimeout(handler, timeout, ...args);\n },\n clearTimeout(handle) {\n const { delegate } = timeoutProvider;\n return (delegate?.clearTimeout || clearTimeout)(handle as any);\n },\n delegate: undefined,\n};\n", "import { config } from '../config';\nimport { timeoutProvider } from '../scheduler/timeoutProvider';\n\n/**\n * Handles an error on another job either with the user-configured {@link onUnhandledError},\n * or by throwing it on that new job so it can be picked up by `window.onerror`, `process.on('error')`, etc.\n *\n * This should be called whenever there is an error that is out-of-band with the subscription\n * or when an error hits a terminal boundary of the subscription and no error handler was provided.\n *\n * @param err the error to report\n */\nexport function reportUnhandledError(err: any) {\n timeoutProvider.setTimeout(() => {\n const { onUnhandledError } = config;\n if (onUnhandledError) {\n // Execute the user-configured error handler.\n onUnhandledError(err);\n } else {\n // Throw so it is picked up by the runtime's uncaught error mechanism.\n throw err;\n }\n });\n}\n", "/* tslint:disable:no-empty */\nexport function noop() { }\n", "import { CompleteNotification, NextNotification, ErrorNotification } from './types';\n\n/**\n * A completion object optimized for memory use and created to be the\n * same \"shape\" as other notifications in v8.\n * @internal\n */\nexport const COMPLETE_NOTIFICATION = (() => createNotification('C', undefined, undefined) as CompleteNotification)();\n\n/**\n * Internal use only. Creates an optimized error notification that is the same \"shape\"\n * as other notifications.\n * @internal\n */\nexport function errorNotification(error: any): ErrorNotification {\n return createNotification('E', undefined, error) as any;\n}\n\n/**\n * Internal use only. Creates an optimized next notification that is the same \"shape\"\n * as other notifications.\n * @internal\n */\nexport function nextNotification(value: T) {\n return createNotification('N', value, undefined) as NextNotification;\n}\n\n/**\n * Ensures that all notifications created internally have the same \"shape\" in v8.\n *\n * TODO: This is only exported to support a crazy legacy test in `groupBy`.\n * @internal\n */\nexport function createNotification(kind: 'N' | 'E' | 'C', value: any, error: any) {\n return {\n kind,\n value,\n error,\n };\n}\n", "import { config } from '../config';\n\nlet context: { errorThrown: boolean; error: any } | null = null;\n\n/**\n * Handles dealing with errors for super-gross mode. Creates a context, in which\n * any synchronously thrown errors will be passed to {@link captureError}. Which\n * will record the error such that it will be rethrown after the call back is complete.\n * TODO: Remove in v8\n * @param cb An immediately executed function.\n */\nexport function errorContext(cb: () => void) {\n if (config.useDeprecatedSynchronousErrorHandling) {\n const isRoot = !context;\n if (isRoot) {\n context = { errorThrown: false, error: null };\n }\n cb();\n if (isRoot) {\n const { errorThrown, error } = context!;\n context = null;\n if (errorThrown) {\n throw error;\n }\n }\n } else {\n // This is the general non-deprecated path for everyone that\n // isn't crazy enough to use super-gross mode (useDeprecatedSynchronousErrorHandling)\n cb();\n }\n}\n\n/**\n * Captures errors only in super-gross mode.\n * @param err the error to capture\n */\nexport function captureError(err: any) {\n if (config.useDeprecatedSynchronousErrorHandling && context) {\n context.errorThrown = true;\n context.error = err;\n }\n}\n", "import { isFunction } from './util/isFunction';\nimport { Observer, ObservableNotification } from './types';\nimport { isSubscription, Subscription } from './Subscription';\nimport { config } from './config';\nimport { reportUnhandledError } from './util/reportUnhandledError';\nimport { noop } from './util/noop';\nimport { nextNotification, errorNotification, COMPLETE_NOTIFICATION } from './NotificationFactories';\nimport { timeoutProvider } from './scheduler/timeoutProvider';\nimport { captureError } from './util/errorContext';\n\n/**\n * Implements the {@link Observer} interface and extends the\n * {@link Subscription} class. While the {@link Observer} is the public API for\n * consuming the values of an {@link Observable}, all Observers get converted to\n * a Subscriber, in order to provide Subscription-like capabilities such as\n * `unsubscribe`. Subscriber is a common type in RxJS, and crucial for\n * implementing operators, but it is rarely used as a public API.\n */\nexport class Subscriber extends Subscription implements Observer {\n /**\n * A static factory for a Subscriber, given a (potentially partial) definition\n * of an Observer.\n * @param next The `next` callback of an Observer.\n * @param error The `error` callback of an\n * Observer.\n * @param complete The `complete` callback of an\n * Observer.\n * @return A Subscriber wrapping the (partially defined)\n * Observer represented by the given arguments.\n * @deprecated Do not use. Will be removed in v8. There is no replacement for this\n * method, and there is no reason to be creating instances of `Subscriber` directly.\n * If you have a specific use case, please file an issue.\n */\n static create(next?: (x?: T) => void, error?: (e?: any) => void, complete?: () => void): Subscriber {\n return new SafeSubscriber(next, error, complete);\n }\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n protected isStopped: boolean = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n protected destination: Subscriber | Observer; // this `any` is the escape hatch to erase extra type param (e.g. R)\n\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n * There is no reason to directly create an instance of Subscriber. This type is exported for typings reasons.\n */\n constructor(destination?: Subscriber | Observer) {\n super();\n if (destination) {\n this.destination = destination;\n // Automatically chain subscriptions together here.\n // if destination is a Subscription, then it is a Subscriber.\n if (isSubscription(destination)) {\n destination.add(this);\n }\n } else {\n this.destination = EMPTY_OBSERVER;\n }\n }\n\n /**\n * The {@link Observer} callback to receive notifications of type `next` from\n * the Observable, with a value. The Observable may call this method 0 or more\n * times.\n * @param value The `next` value.\n */\n next(value: T): void {\n if (this.isStopped) {\n handleStoppedNotification(nextNotification(value), this);\n } else {\n this._next(value!);\n }\n }\n\n /**\n * The {@link Observer} callback to receive notifications of type `error` from\n * the Observable, with an attached `Error`. Notifies the Observer that\n * the Observable has experienced an error condition.\n * @param err The `error` exception.\n */\n error(err?: any): void {\n if (this.isStopped) {\n handleStoppedNotification(errorNotification(err), this);\n } else {\n this.isStopped = true;\n this._error(err);\n }\n }\n\n /**\n * The {@link Observer} callback to receive a valueless notification of type\n * `complete` from the Observable. Notifies the Observer that the Observable\n * has finished sending push-based notifications.\n */\n complete(): void {\n if (this.isStopped) {\n handleStoppedNotification(COMPLETE_NOTIFICATION, this);\n } else {\n this.isStopped = true;\n this._complete();\n }\n }\n\n unsubscribe(): void {\n if (!this.closed) {\n this.isStopped = true;\n super.unsubscribe();\n this.destination = null!;\n }\n }\n\n protected _next(value: T): void {\n this.destination.next(value);\n }\n\n protected _error(err: any): void {\n try {\n this.destination.error(err);\n } finally {\n this.unsubscribe();\n }\n }\n\n protected _complete(): void {\n try {\n this.destination.complete();\n } finally {\n this.unsubscribe();\n }\n }\n}\n\n/**\n * This bind is captured here because we want to be able to have\n * compatibility with monoid libraries that tend to use a method named\n * `bind`. In particular, a library called Monio requires this.\n */\nconst _bind = Function.prototype.bind;\n\nfunction bind any>(fn: Fn, thisArg: any): Fn {\n return _bind.call(fn, thisArg);\n}\n\n/**\n * Internal optimization only, DO NOT EXPOSE.\n * @internal\n */\nclass ConsumerObserver implements Observer {\n constructor(private partialObserver: Partial>) {}\n\n next(value: T): void {\n const { partialObserver } = this;\n if (partialObserver.next) {\n try {\n partialObserver.next(value);\n } catch (error) {\n handleUnhandledError(error);\n }\n }\n }\n\n error(err: any): void {\n const { partialObserver } = this;\n if (partialObserver.error) {\n try {\n partialObserver.error(err);\n } catch (error) {\n handleUnhandledError(error);\n }\n } else {\n handleUnhandledError(err);\n }\n }\n\n complete(): void {\n const { partialObserver } = this;\n if (partialObserver.complete) {\n try {\n partialObserver.complete();\n } catch (error) {\n handleUnhandledError(error);\n }\n }\n }\n}\n\nexport class SafeSubscriber extends Subscriber {\n constructor(\n observerOrNext?: Partial> | ((value: T) => void) | null,\n error?: ((e?: any) => void) | null,\n complete?: (() => void) | null\n ) {\n super();\n\n let partialObserver: Partial>;\n if (isFunction(observerOrNext) || !observerOrNext) {\n // The first argument is a function, not an observer. The next\n // two arguments *could* be observers, or they could be empty.\n partialObserver = {\n next: (observerOrNext ?? undefined) as ((value: T) => void) | undefined,\n error: error ?? undefined,\n complete: complete ?? undefined,\n };\n } else {\n // The first argument is a partial observer.\n let context: any;\n if (this && config.useDeprecatedNextContext) {\n // This is a deprecated path that made `this.unsubscribe()` available in\n // next handler functions passed to subscribe. This only exists behind a flag\n // now, as it is *very* slow.\n context = Object.create(observerOrNext);\n context.unsubscribe = () => this.unsubscribe();\n partialObserver = {\n next: observerOrNext.next && bind(observerOrNext.next, context),\n error: observerOrNext.error && bind(observerOrNext.error, context),\n complete: observerOrNext.complete && bind(observerOrNext.complete, context),\n };\n } else {\n // The \"normal\" path. Just use the partial observer directly.\n partialObserver = observerOrNext;\n }\n }\n\n // Wrap the partial observer to ensure it's a full observer, and\n // make sure proper error handling is accounted for.\n this.destination = new ConsumerObserver(partialObserver);\n }\n}\n\nfunction handleUnhandledError(error: any) {\n if (config.useDeprecatedSynchronousErrorHandling) {\n captureError(error);\n } else {\n // Ideal path, we report this as an unhandled error,\n // which is thrown on a new call stack.\n reportUnhandledError(error);\n }\n}\n\n/**\n * An error handler used when no error handler was supplied\n * to the SafeSubscriber -- meaning no error handler was supplied\n * do the `subscribe` call on our observable.\n * @param err The error to handle\n */\nfunction defaultErrorHandler(err: any) {\n throw err;\n}\n\n/**\n * A handler for notifications that cannot be sent to a stopped subscriber.\n * @param notification The notification being sent.\n * @param subscriber The stopped subscriber.\n */\nfunction handleStoppedNotification(notification: ObservableNotification, subscriber: Subscriber) {\n const { onStoppedNotification } = config;\n onStoppedNotification && timeoutProvider.setTimeout(() => onStoppedNotification(notification, subscriber));\n}\n\n/**\n * The observer used as a stub for subscriptions where the user did not\n * pass any arguments to `subscribe`. Comes with the default error handling\n * behavior.\n */\nexport const EMPTY_OBSERVER: Readonly> & { closed: true } = {\n closed: true,\n next: noop,\n error: defaultErrorHandler,\n complete: noop,\n};\n", "/**\n * Symbol.observable or a string \"@@observable\". Used for interop\n *\n * @deprecated We will no longer be exporting this symbol in upcoming versions of RxJS.\n * Instead polyfill and use Symbol.observable directly *or* use https://www.npmjs.com/package/symbol-observable\n */\nexport const observable: string | symbol = (() => (typeof Symbol === 'function' && Symbol.observable) || '@@observable')();\n", "/**\n * This function takes one parameter and just returns it. Simply put,\n * this is like `(x: T): T => x`.\n *\n * ## Examples\n *\n * This is useful in some cases when using things like `mergeMap`\n *\n * ```ts\n * import { interval, take, map, range, mergeMap, identity } from 'rxjs';\n *\n * const source$ = interval(1000).pipe(take(5));\n *\n * const result$ = source$.pipe(\n * map(i => range(i)),\n * mergeMap(identity) // same as mergeMap(x => x)\n * );\n *\n * result$.subscribe({\n * next: console.log\n * });\n * ```\n *\n * Or when you want to selectively apply an operator\n *\n * ```ts\n * import { interval, take, identity } from 'rxjs';\n *\n * const shouldLimit = () => Math.random() < 0.5;\n *\n * const source$ = interval(1000);\n *\n * const result$ = source$.pipe(shouldLimit() ? take(5) : identity);\n *\n * result$.subscribe({\n * next: console.log\n * });\n * ```\n *\n * @param x Any value that is returned by this function\n * @returns The value passed as the first parameter to this function\n */\nexport function identity(x: T): T {\n return x;\n}\n", "import { identity } from './identity';\nimport { UnaryFunction } from '../types';\n\nexport function pipe(): typeof identity;\nexport function pipe(fn1: UnaryFunction): UnaryFunction;\nexport function pipe(fn1: UnaryFunction, fn2: UnaryFunction): UnaryFunction;\nexport function pipe(fn1: UnaryFunction, fn2: UnaryFunction, fn3: UnaryFunction): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction,\n fn9: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction,\n fn9: UnaryFunction,\n ...fns: UnaryFunction[]\n): UnaryFunction;\n\n/**\n * pipe() can be called on one or more functions, each of which can take one argument (\"UnaryFunction\")\n * and uses it to return a value.\n * It returns a function that takes one argument, passes it to the first UnaryFunction, and then\n * passes the result to the next one, passes that result to the next one, and so on. \n */\nexport function pipe(...fns: Array>): UnaryFunction {\n return pipeFromArray(fns);\n}\n\n/** @internal */\nexport function pipeFromArray(fns: Array>): UnaryFunction {\n if (fns.length === 0) {\n return identity as UnaryFunction;\n }\n\n if (fns.length === 1) {\n return fns[0];\n }\n\n return function piped(input: T): R {\n return fns.reduce((prev: any, fn: UnaryFunction) => fn(prev), input as any);\n };\n}\n", "import { Operator } from './Operator';\nimport { SafeSubscriber, Subscriber } from './Subscriber';\nimport { isSubscription, Subscription } from './Subscription';\nimport { TeardownLogic, OperatorFunction, Subscribable, Observer } from './types';\nimport { observable as Symbol_observable } from './symbol/observable';\nimport { pipeFromArray } from './util/pipe';\nimport { config } from './config';\nimport { isFunction } from './util/isFunction';\nimport { errorContext } from './util/errorContext';\n\n/**\n * A representation of any set of values over any amount of time. This is the most basic building block\n * of RxJS.\n */\nexport class Observable implements Subscribable {\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n */\n source: Observable | undefined;\n\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n */\n operator: Operator | undefined;\n\n /**\n * @param subscribe The function that is called when the Observable is\n * initially subscribed to. This function is given a Subscriber, to which new values\n * can be `next`ed, or an `error` method can be called to raise an error, or\n * `complete` can be called to notify of a successful completion.\n */\n constructor(subscribe?: (this: Observable, subscriber: Subscriber) => TeardownLogic) {\n if (subscribe) {\n this._subscribe = subscribe;\n }\n }\n\n // HACK: Since TypeScript inherits static properties too, we have to\n // fight against TypeScript here so Subject can have a different static create signature\n /**\n * Creates a new Observable by calling the Observable constructor\n * @param subscribe the subscriber function to be passed to the Observable constructor\n * @return A new observable.\n * @deprecated Use `new Observable()` instead. Will be removed in v8.\n */\n static create: (...args: any[]) => any = (subscribe?: (subscriber: Subscriber) => TeardownLogic) => {\n return new Observable(subscribe);\n };\n\n /**\n * Creates a new Observable, with this Observable instance as the source, and the passed\n * operator defined as the new observable's operator.\n * @param operator the operator defining the operation to take on the observable\n * @return A new observable with the Operator applied.\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n * If you have implemented an operator using `lift`, it is recommended that you create an\n * operator by simply returning `new Observable()` directly. See \"Creating new operators from\n * scratch\" section here: https://rxjs.dev/guide/operators\n */\n lift(operator?: Operator): Observable {\n const observable = new Observable();\n observable.source = this;\n observable.operator = operator;\n return observable;\n }\n\n subscribe(observerOrNext?: Partial> | ((value: T) => void)): Subscription;\n /** @deprecated Instead of passing separate callback arguments, use an observer argument. Signatures taking separate callback arguments will be removed in v8. Details: https://rxjs.dev/deprecations/subscribe-arguments */\n subscribe(next?: ((value: T) => void) | null, error?: ((error: any) => void) | null, complete?: (() => void) | null): Subscription;\n /**\n * Invokes an execution of an Observable and registers Observer handlers for notifications it will emit.\n *\n * Use it when you have all these Observables, but still nothing is happening.\n *\n * `subscribe` is not a regular operator, but a method that calls Observable's internal `subscribe` function. It\n * might be for example a function that you passed to Observable's constructor, but most of the time it is\n * a library implementation, which defines what will be emitted by an Observable, and when it be will emitted. This means\n * that calling `subscribe` is actually the moment when Observable starts its work, not when it is created, as it is often\n * the thought.\n *\n * Apart from starting the execution of an Observable, this method allows you to listen for values\n * that an Observable emits, as well as for when it completes or errors. You can achieve this in two\n * of the following ways.\n *\n * The first way is creating an object that implements {@link Observer} interface. It should have methods\n * defined by that interface, but note that it should be just a regular JavaScript object, which you can create\n * yourself in any way you want (ES6 class, classic function constructor, object literal etc.). In particular, do\n * not attempt to use any RxJS implementation details to create Observers - you don't need them. Remember also\n * that your object does not have to implement all methods. If you find yourself creating a method that doesn't\n * do anything, you can simply omit it. Note however, if the `error` method is not provided and an error happens,\n * it will be thrown asynchronously. Errors thrown asynchronously cannot be caught using `try`/`catch`. Instead,\n * use the {@link onUnhandledError} configuration option or use a runtime handler (like `window.onerror` or\n * `process.on('error)`) to be notified of unhandled errors. Because of this, it's recommended that you provide\n * an `error` method to avoid missing thrown errors.\n *\n * The second way is to give up on Observer object altogether and simply provide callback functions in place of its methods.\n * This means you can provide three functions as arguments to `subscribe`, where the first function is equivalent\n * of a `next` method, the second of an `error` method and the third of a `complete` method. Just as in case of an Observer,\n * if you do not need to listen for something, you can omit a function by passing `undefined` or `null`,\n * since `subscribe` recognizes these functions by where they were placed in function call. When it comes\n * to the `error` function, as with an Observer, if not provided, errors emitted by an Observable will be thrown asynchronously.\n *\n * You can, however, subscribe with no parameters at all. This may be the case where you're not interested in terminal events\n * and you also handled emissions internally by using operators (e.g. using `tap`).\n *\n * Whichever style of calling `subscribe` you use, in both cases it returns a Subscription object.\n * This object allows you to call `unsubscribe` on it, which in turn will stop the work that an Observable does and will clean\n * up all resources that an Observable used. Note that cancelling a subscription will not call `complete` callback\n * provided to `subscribe` function, which is reserved for a regular completion signal that comes from an Observable.\n *\n * Remember that callbacks provided to `subscribe` are not guaranteed to be called asynchronously.\n * It is an Observable itself that decides when these functions will be called. For example {@link of}\n * by default emits all its values synchronously. Always check documentation for how given Observable\n * will behave when subscribed and if its default behavior can be modified with a `scheduler`.\n *\n * #### Examples\n *\n * Subscribe with an {@link guide/observer Observer}\n *\n * ```ts\n * import { of } from 'rxjs';\n *\n * const sumObserver = {\n * sum: 0,\n * next(value) {\n * console.log('Adding: ' + value);\n * this.sum = this.sum + value;\n * },\n * error() {\n * // We actually could just remove this method,\n * // since we do not really care about errors right now.\n * },\n * complete() {\n * console.log('Sum equals: ' + this.sum);\n * }\n * };\n *\n * of(1, 2, 3) // Synchronously emits 1, 2, 3 and then completes.\n * .subscribe(sumObserver);\n *\n * // Logs:\n * // 'Adding: 1'\n * // 'Adding: 2'\n * // 'Adding: 3'\n * // 'Sum equals: 6'\n * ```\n *\n * Subscribe with functions ({@link deprecations/subscribe-arguments deprecated})\n *\n * ```ts\n * import { of } from 'rxjs'\n *\n * let sum = 0;\n *\n * of(1, 2, 3).subscribe(\n * value => {\n * console.log('Adding: ' + value);\n * sum = sum + value;\n * },\n * undefined,\n * () => console.log('Sum equals: ' + sum)\n * );\n *\n * // Logs:\n * // 'Adding: 1'\n * // 'Adding: 2'\n * // 'Adding: 3'\n * // 'Sum equals: 6'\n * ```\n *\n * Cancel a subscription\n *\n * ```ts\n * import { interval } from 'rxjs';\n *\n * const subscription = interval(1000).subscribe({\n * next(num) {\n * console.log(num)\n * },\n * complete() {\n * // Will not be called, even when cancelling subscription.\n * console.log('completed!');\n * }\n * });\n *\n * setTimeout(() => {\n * subscription.unsubscribe();\n * console.log('unsubscribed!');\n * }, 2500);\n *\n * // Logs:\n * // 0 after 1s\n * // 1 after 2s\n * // 'unsubscribed!' after 2.5s\n * ```\n *\n * @param observerOrNext Either an {@link Observer} with some or all callback methods,\n * or the `next` handler that is called for each value emitted from the subscribed Observable.\n * @param error A handler for a terminal event resulting from an error. If no error handler is provided,\n * the error will be thrown asynchronously as unhandled.\n * @param complete A handler for a terminal event resulting from successful completion.\n * @return A subscription reference to the registered handlers.\n */\n subscribe(\n observerOrNext?: Partial> | ((value: T) => void) | null,\n error?: ((error: any) => void) | null,\n complete?: (() => void) | null\n ): Subscription {\n const subscriber = isSubscriber(observerOrNext) ? observerOrNext : new SafeSubscriber(observerOrNext, error, complete);\n\n errorContext(() => {\n const { operator, source } = this;\n subscriber.add(\n operator\n ? // We're dealing with a subscription in the\n // operator chain to one of our lifted operators.\n operator.call(subscriber, source)\n : source\n ? // If `source` has a value, but `operator` does not, something that\n // had intimate knowledge of our API, like our `Subject`, must have\n // set it. We're going to just call `_subscribe` directly.\n this._subscribe(subscriber)\n : // In all other cases, we're likely wrapping a user-provided initializer\n // function, so we need to catch errors and handle them appropriately.\n this._trySubscribe(subscriber)\n );\n });\n\n return subscriber;\n }\n\n /** @internal */\n protected _trySubscribe(sink: Subscriber): TeardownLogic {\n try {\n return this._subscribe(sink);\n } catch (err) {\n // We don't need to return anything in this case,\n // because it's just going to try to `add()` to a subscription\n // above.\n sink.error(err);\n }\n }\n\n /**\n * Used as a NON-CANCELLABLE means of subscribing to an observable, for use with\n * APIs that expect promises, like `async/await`. You cannot unsubscribe from this.\n *\n * **WARNING**: Only use this with observables you *know* will complete. If the source\n * observable does not complete, you will end up with a promise that is hung up, and\n * potentially all of the state of an async function hanging out in memory. To avoid\n * this situation, look into adding something like {@link timeout}, {@link take},\n * {@link takeWhile}, or {@link takeUntil} amongst others.\n *\n * #### Example\n *\n * ```ts\n * import { interval, take } from 'rxjs';\n *\n * const source$ = interval(1000).pipe(take(4));\n *\n * async function getTotal() {\n * let total = 0;\n *\n * await source$.forEach(value => {\n * total += value;\n * console.log('observable -> ' + value);\n * });\n *\n * return total;\n * }\n *\n * getTotal().then(\n * total => console.log('Total: ' + total)\n * );\n *\n * // Expected:\n * // 'observable -> 0'\n * // 'observable -> 1'\n * // 'observable -> 2'\n * // 'observable -> 3'\n * // 'Total: 6'\n * ```\n *\n * @param next A handler for each value emitted by the observable.\n * @return A promise that either resolves on observable completion or\n * rejects with the handled error.\n */\n forEach(next: (value: T) => void): Promise;\n\n /**\n * @param next a handler for each value emitted by the observable\n * @param promiseCtor a constructor function used to instantiate the Promise\n * @return a promise that either resolves on observable completion or\n * rejects with the handled error\n * @deprecated Passing a Promise constructor will no longer be available\n * in upcoming versions of RxJS. This is because it adds weight to the library, for very\n * little benefit. If you need this functionality, it is recommended that you either\n * polyfill Promise, or you create an adapter to convert the returned native promise\n * to whatever promise implementation you wanted. Will be removed in v8.\n */\n forEach(next: (value: T) => void, promiseCtor: PromiseConstructorLike): Promise;\n\n forEach(next: (value: T) => void, promiseCtor?: PromiseConstructorLike): Promise {\n promiseCtor = getPromiseCtor(promiseCtor);\n\n return new promiseCtor((resolve, reject) => {\n const subscriber = new SafeSubscriber({\n next: (value) => {\n try {\n next(value);\n } catch (err) {\n reject(err);\n subscriber.unsubscribe();\n }\n },\n error: reject,\n complete: resolve,\n });\n this.subscribe(subscriber);\n }) as Promise;\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): TeardownLogic {\n return this.source?.subscribe(subscriber);\n }\n\n /**\n * An interop point defined by the es7-observable spec https://github.com/zenparsing/es-observable\n * @return This instance of the observable.\n */\n [Symbol_observable]() {\n return this;\n }\n\n /* tslint:disable:max-line-length */\n pipe(): Observable;\n pipe(op1: OperatorFunction): Observable;\n pipe(op1: OperatorFunction, op2: OperatorFunction): Observable;\n pipe(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction,\n op9: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction,\n op9: OperatorFunction,\n ...operations: OperatorFunction[]\n ): Observable;\n /* tslint:enable:max-line-length */\n\n /**\n * Used to stitch together functional operators into a chain.\n *\n * ## Example\n *\n * ```ts\n * import { interval, filter, map, scan } from 'rxjs';\n *\n * interval(1000)\n * .pipe(\n * filter(x => x % 2 === 0),\n * map(x => x + x),\n * scan((acc, x) => acc + x)\n * )\n * .subscribe(x => console.log(x));\n * ```\n *\n * @return The Observable result of all the operators having been called\n * in the order they were passed in.\n */\n pipe(...operations: OperatorFunction[]): Observable {\n return pipeFromArray(operations)(this);\n }\n\n /* tslint:disable:max-line-length */\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(): Promise;\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(PromiseCtor: typeof Promise): Promise;\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(PromiseCtor: PromiseConstructorLike): Promise;\n /* tslint:enable:max-line-length */\n\n /**\n * Subscribe to this Observable and get a Promise resolving on\n * `complete` with the last emission (if any).\n *\n * **WARNING**: Only use this with observables you *know* will complete. If the source\n * observable does not complete, you will end up with a promise that is hung up, and\n * potentially all of the state of an async function hanging out in memory. To avoid\n * this situation, look into adding something like {@link timeout}, {@link take},\n * {@link takeWhile}, or {@link takeUntil} amongst others.\n *\n * @param [promiseCtor] a constructor function used to instantiate\n * the Promise\n * @return A Promise that resolves with the last value emit, or\n * rejects on an error. If there were no emissions, Promise\n * resolves with undefined.\n * @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise\n */\n toPromise(promiseCtor?: PromiseConstructorLike): Promise {\n promiseCtor = getPromiseCtor(promiseCtor);\n\n return new promiseCtor((resolve, reject) => {\n let value: T | undefined;\n this.subscribe(\n (x: T) => (value = x),\n (err: any) => reject(err),\n () => resolve(value)\n );\n }) as Promise;\n }\n}\n\n/**\n * Decides between a passed promise constructor from consuming code,\n * A default configured promise constructor, and the native promise\n * constructor and returns it. If nothing can be found, it will throw\n * an error.\n * @param promiseCtor The optional promise constructor to passed by consuming code\n */\nfunction getPromiseCtor(promiseCtor: PromiseConstructorLike | undefined) {\n return promiseCtor ?? config.Promise ?? Promise;\n}\n\nfunction isObserver(value: any): value is Observer {\n return value && isFunction(value.next) && isFunction(value.error) && isFunction(value.complete);\n}\n\nfunction isSubscriber(value: any): value is Subscriber {\n return (value && value instanceof Subscriber) || (isObserver(value) && isSubscription(value));\n}\n", "import { Observable } from '../Observable';\nimport { Subscriber } from '../Subscriber';\nimport { OperatorFunction } from '../types';\nimport { isFunction } from './isFunction';\n\n/**\n * Used to determine if an object is an Observable with a lift function.\n */\nexport function hasLift(source: any): source is { lift: InstanceType['lift'] } {\n return isFunction(source?.lift);\n}\n\n/**\n * Creates an `OperatorFunction`. Used to define operators throughout the library in a concise way.\n * @param init The logic to connect the liftedSource to the subscriber at the moment of subscription.\n */\nexport function operate(\n init: (liftedSource: Observable, subscriber: Subscriber) => (() => void) | void\n): OperatorFunction {\n return (source: Observable) => {\n if (hasLift(source)) {\n return source.lift(function (this: Subscriber, liftedSource: Observable) {\n try {\n return init(liftedSource, this);\n } catch (err) {\n this.error(err);\n }\n });\n }\n throw new TypeError('Unable to lift unknown Observable type');\n };\n}\n", "import { Subscriber } from '../Subscriber';\n\n/**\n * Creates an instance of an `OperatorSubscriber`.\n * @param destination The downstream subscriber.\n * @param onNext Handles next values, only called if this subscriber is not stopped or closed. Any\n * error that occurs in this function is caught and sent to the `error` method of this subscriber.\n * @param onError Handles errors from the subscription, any errors that occur in this handler are caught\n * and send to the `destination` error handler.\n * @param onComplete Handles completion notification from the subscription. Any errors that occur in\n * this handler are sent to the `destination` error handler.\n * @param onFinalize Additional teardown logic here. This will only be called on teardown if the\n * subscriber itself is not already closed. This is called after all other teardown logic is executed.\n */\nexport function createOperatorSubscriber(\n destination: Subscriber,\n onNext?: (value: T) => void,\n onComplete?: () => void,\n onError?: (err: any) => void,\n onFinalize?: () => void\n): Subscriber {\n return new OperatorSubscriber(destination, onNext, onComplete, onError, onFinalize);\n}\n\n/**\n * A generic helper for allowing operators to be created with a Subscriber and\n * use closures to capture necessary state from the operator function itself.\n */\nexport class OperatorSubscriber extends Subscriber {\n /**\n * Creates an instance of an `OperatorSubscriber`.\n * @param destination The downstream subscriber.\n * @param onNext Handles next values, only called if this subscriber is not stopped or closed. Any\n * error that occurs in this function is caught and sent to the `error` method of this subscriber.\n * @param onError Handles errors from the subscription, any errors that occur in this handler are caught\n * and send to the `destination` error handler.\n * @param onComplete Handles completion notification from the subscription. Any errors that occur in\n * this handler are sent to the `destination` error handler.\n * @param onFinalize Additional finalization logic here. This will only be called on finalization if the\n * subscriber itself is not already closed. This is called after all other finalization logic is executed.\n * @param shouldUnsubscribe An optional check to see if an unsubscribe call should truly unsubscribe.\n * NOTE: This currently **ONLY** exists to support the strange behavior of {@link groupBy}, where unsubscription\n * to the resulting observable does not actually disconnect from the source if there are active subscriptions\n * to any grouped observable. (DO NOT EXPOSE OR USE EXTERNALLY!!!)\n */\n constructor(\n destination: Subscriber,\n onNext?: (value: T) => void,\n onComplete?: () => void,\n onError?: (err: any) => void,\n private onFinalize?: () => void,\n private shouldUnsubscribe?: () => boolean\n ) {\n // It's important - for performance reasons - that all of this class's\n // members are initialized and that they are always initialized in the same\n // order. This will ensure that all OperatorSubscriber instances have the\n // same hidden class in V8. This, in turn, will help keep the number of\n // hidden classes involved in property accesses within the base class as\n // low as possible. If the number of hidden classes involved exceeds four,\n // the property accesses will become megamorphic and performance penalties\n // will be incurred - i.e. inline caches won't be used.\n //\n // The reasons for ensuring all instances have the same hidden class are\n // further discussed in this blog post from Benedikt Meurer:\n // https://benediktmeurer.de/2018/03/23/impact-of-polymorphism-on-component-based-frameworks-like-react/\n super(destination);\n this._next = onNext\n ? function (this: OperatorSubscriber, value: T) {\n try {\n onNext(value);\n } catch (err) {\n destination.error(err);\n }\n }\n : super._next;\n this._error = onError\n ? function (this: OperatorSubscriber, err: any) {\n try {\n onError(err);\n } catch (err) {\n // Send any errors that occur down stream.\n destination.error(err);\n } finally {\n // Ensure finalization.\n this.unsubscribe();\n }\n }\n : super._error;\n this._complete = onComplete\n ? function (this: OperatorSubscriber) {\n try {\n onComplete();\n } catch (err) {\n // Send any errors that occur down stream.\n destination.error(err);\n } finally {\n // Ensure finalization.\n this.unsubscribe();\n }\n }\n : super._complete;\n }\n\n unsubscribe() {\n if (!this.shouldUnsubscribe || this.shouldUnsubscribe()) {\n const { closed } = this;\n super.unsubscribe();\n // Execute additional teardown if we have any and we didn't already do so.\n !closed && this.onFinalize?.();\n }\n }\n}\n", "import { Subscription } from '../Subscription';\n\ninterface AnimationFrameProvider {\n schedule(callback: FrameRequestCallback): Subscription;\n requestAnimationFrame: typeof requestAnimationFrame;\n cancelAnimationFrame: typeof cancelAnimationFrame;\n delegate:\n | {\n requestAnimationFrame: typeof requestAnimationFrame;\n cancelAnimationFrame: typeof cancelAnimationFrame;\n }\n | undefined;\n}\n\nexport const animationFrameProvider: AnimationFrameProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n schedule(callback) {\n let request = requestAnimationFrame;\n let cancel: typeof cancelAnimationFrame | undefined = cancelAnimationFrame;\n const { delegate } = animationFrameProvider;\n if (delegate) {\n request = delegate.requestAnimationFrame;\n cancel = delegate.cancelAnimationFrame;\n }\n const handle = request((timestamp) => {\n // Clear the cancel function. The request has been fulfilled, so\n // attempting to cancel the request upon unsubscription would be\n // pointless.\n cancel = undefined;\n callback(timestamp);\n });\n return new Subscription(() => cancel?.(handle));\n },\n requestAnimationFrame(...args) {\n const { delegate } = animationFrameProvider;\n return (delegate?.requestAnimationFrame || requestAnimationFrame)(...args);\n },\n cancelAnimationFrame(...args) {\n const { delegate } = animationFrameProvider;\n return (delegate?.cancelAnimationFrame || cancelAnimationFrame)(...args);\n },\n delegate: undefined,\n};\n", "import { createErrorClass } from './createErrorClass';\n\nexport interface ObjectUnsubscribedError extends Error {}\n\nexport interface ObjectUnsubscribedErrorCtor {\n /**\n * @deprecated Internal implementation detail. Do not construct error instances.\n * Cannot be tagged as internal: https://github.com/ReactiveX/rxjs/issues/6269\n */\n new (): ObjectUnsubscribedError;\n}\n\n/**\n * An error thrown when an action is invalid because the object has been\n * unsubscribed.\n *\n * @see {@link Subject}\n * @see {@link BehaviorSubject}\n *\n * @class ObjectUnsubscribedError\n */\nexport const ObjectUnsubscribedError: ObjectUnsubscribedErrorCtor = createErrorClass(\n (_super) =>\n function ObjectUnsubscribedErrorImpl(this: any) {\n _super(this);\n this.name = 'ObjectUnsubscribedError';\n this.message = 'object unsubscribed';\n }\n);\n", "import { Operator } from './Operator';\nimport { Observable } from './Observable';\nimport { Subscriber } from './Subscriber';\nimport { Subscription, EMPTY_SUBSCRIPTION } from './Subscription';\nimport { Observer, SubscriptionLike, TeardownLogic } from './types';\nimport { ObjectUnsubscribedError } from './util/ObjectUnsubscribedError';\nimport { arrRemove } from './util/arrRemove';\nimport { errorContext } from './util/errorContext';\n\n/**\n * A Subject is a special type of Observable that allows values to be\n * multicasted to many Observers. Subjects are like EventEmitters.\n *\n * Every Subject is an Observable and an Observer. You can subscribe to a\n * Subject, and you can call next to feed values as well as error and complete.\n */\nexport class Subject extends Observable implements SubscriptionLike {\n closed = false;\n\n private currentObservers: Observer[] | null = null;\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n observers: Observer[] = [];\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n isStopped = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n hasError = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n thrownError: any = null;\n\n /**\n * Creates a \"subject\" by basically gluing an observer to an observable.\n *\n * @deprecated Recommended you do not use. Will be removed at some point in the future. Plans for replacement still under discussion.\n */\n static create: (...args: any[]) => any = (destination: Observer, source: Observable): AnonymousSubject => {\n return new AnonymousSubject(destination, source);\n };\n\n constructor() {\n // NOTE: This must be here to obscure Observable's constructor.\n super();\n }\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n lift(operator: Operator): Observable {\n const subject = new AnonymousSubject(this, this);\n subject.operator = operator as any;\n return subject as any;\n }\n\n /** @internal */\n protected _throwIfClosed() {\n if (this.closed) {\n throw new ObjectUnsubscribedError();\n }\n }\n\n next(value: T) {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n if (!this.currentObservers) {\n this.currentObservers = Array.from(this.observers);\n }\n for (const observer of this.currentObservers) {\n observer.next(value);\n }\n }\n });\n }\n\n error(err: any) {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n this.hasError = this.isStopped = true;\n this.thrownError = err;\n const { observers } = this;\n while (observers.length) {\n observers.shift()!.error(err);\n }\n }\n });\n }\n\n complete() {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n this.isStopped = true;\n const { observers } = this;\n while (observers.length) {\n observers.shift()!.complete();\n }\n }\n });\n }\n\n unsubscribe() {\n this.isStopped = this.closed = true;\n this.observers = this.currentObservers = null!;\n }\n\n get observed() {\n return this.observers?.length > 0;\n }\n\n /** @internal */\n protected _trySubscribe(subscriber: Subscriber): TeardownLogic {\n this._throwIfClosed();\n return super._trySubscribe(subscriber);\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n this._throwIfClosed();\n this._checkFinalizedStatuses(subscriber);\n return this._innerSubscribe(subscriber);\n }\n\n /** @internal */\n protected _innerSubscribe(subscriber: Subscriber) {\n const { hasError, isStopped, observers } = this;\n if (hasError || isStopped) {\n return EMPTY_SUBSCRIPTION;\n }\n this.currentObservers = null;\n observers.push(subscriber);\n return new Subscription(() => {\n this.currentObservers = null;\n arrRemove(observers, subscriber);\n });\n }\n\n /** @internal */\n protected _checkFinalizedStatuses(subscriber: Subscriber) {\n const { hasError, thrownError, isStopped } = this;\n if (hasError) {\n subscriber.error(thrownError);\n } else if (isStopped) {\n subscriber.complete();\n }\n }\n\n /**\n * Creates a new Observable with this Subject as the source. You can do this\n * to create custom Observer-side logic of the Subject and conceal it from\n * code that uses the Observable.\n * @return Observable that this Subject casts to.\n */\n asObservable(): Observable {\n const observable: any = new Observable();\n observable.source = this;\n return observable;\n }\n}\n\nexport class AnonymousSubject extends Subject {\n constructor(\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n public destination?: Observer,\n source?: Observable\n ) {\n super();\n this.source = source;\n }\n\n next(value: T) {\n this.destination?.next?.(value);\n }\n\n error(err: any) {\n this.destination?.error?.(err);\n }\n\n complete() {\n this.destination?.complete?.();\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n return this.source?.subscribe(subscriber) ?? EMPTY_SUBSCRIPTION;\n }\n}\n", "import { Subject } from './Subject';\nimport { Subscriber } from './Subscriber';\nimport { Subscription } from './Subscription';\n\n/**\n * A variant of Subject that requires an initial value and emits its current\n * value whenever it is subscribed to.\n */\nexport class BehaviorSubject extends Subject {\n constructor(private _value: T) {\n super();\n }\n\n get value(): T {\n return this.getValue();\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n const subscription = super._subscribe(subscriber);\n !subscription.closed && subscriber.next(this._value);\n return subscription;\n }\n\n getValue(): T {\n const { hasError, thrownError, _value } = this;\n if (hasError) {\n throw thrownError;\n }\n this._throwIfClosed();\n return _value;\n }\n\n next(value: T): void {\n super.next((this._value = value));\n }\n}\n", "import { TimestampProvider } from '../types';\n\ninterface DateTimestampProvider extends TimestampProvider {\n delegate: TimestampProvider | undefined;\n}\n\nexport const dateTimestampProvider: DateTimestampProvider = {\n now() {\n // Use the variable rather than `this` so that the function can be called\n // without being bound to the provider.\n return (dateTimestampProvider.delegate || Date).now();\n },\n delegate: undefined,\n};\n", "import { Subject } from './Subject';\nimport { TimestampProvider } from './types';\nimport { Subscriber } from './Subscriber';\nimport { Subscription } from './Subscription';\nimport { dateTimestampProvider } from './scheduler/dateTimestampProvider';\n\n/**\n * A variant of {@link Subject} that \"replays\" old values to new subscribers by emitting them when they first subscribe.\n *\n * `ReplaySubject` has an internal buffer that will store a specified number of values that it has observed. Like `Subject`,\n * `ReplaySubject` \"observes\" values by having them passed to its `next` method. When it observes a value, it will store that\n * value for a time determined by the configuration of the `ReplaySubject`, as passed to its constructor.\n *\n * When a new subscriber subscribes to the `ReplaySubject` instance, it will synchronously emit all values in its buffer in\n * a First-In-First-Out (FIFO) manner. The `ReplaySubject` will also complete, if it has observed completion; and it will\n * error if it has observed an error.\n *\n * There are two main configuration items to be concerned with:\n *\n * 1. `bufferSize` - This will determine how many items are stored in the buffer, defaults to infinite.\n * 2. `windowTime` - The amount of time to hold a value in the buffer before removing it from the buffer.\n *\n * Both configurations may exist simultaneously. So if you would like to buffer a maximum of 3 values, as long as the values\n * are less than 2 seconds old, you could do so with a `new ReplaySubject(3, 2000)`.\n *\n * ### Differences with BehaviorSubject\n *\n * `BehaviorSubject` is similar to `new ReplaySubject(1)`, with a couple of exceptions:\n *\n * 1. `BehaviorSubject` comes \"primed\" with a single value upon construction.\n * 2. `ReplaySubject` will replay values, even after observing an error, where `BehaviorSubject` will not.\n *\n * @see {@link Subject}\n * @see {@link BehaviorSubject}\n * @see {@link shareReplay}\n */\nexport class ReplaySubject extends Subject {\n private _buffer: (T | number)[] = [];\n private _infiniteTimeWindow = true;\n\n /**\n * @param _bufferSize The size of the buffer to replay on subscription\n * @param _windowTime The amount of time the buffered items will stay buffered\n * @param _timestampProvider An object with a `now()` method that provides the current timestamp. This is used to\n * calculate the amount of time something has been buffered.\n */\n constructor(\n private _bufferSize = Infinity,\n private _windowTime = Infinity,\n private _timestampProvider: TimestampProvider = dateTimestampProvider\n ) {\n super();\n this._infiniteTimeWindow = _windowTime === Infinity;\n this._bufferSize = Math.max(1, _bufferSize);\n this._windowTime = Math.max(1, _windowTime);\n }\n\n next(value: T): void {\n const { isStopped, _buffer, _infiniteTimeWindow, _timestampProvider, _windowTime } = this;\n if (!isStopped) {\n _buffer.push(value);\n !_infiniteTimeWindow && _buffer.push(_timestampProvider.now() + _windowTime);\n }\n this._trimBuffer();\n super.next(value);\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n this._throwIfClosed();\n this._trimBuffer();\n\n const subscription = this._innerSubscribe(subscriber);\n\n const { _infiniteTimeWindow, _buffer } = this;\n // We use a copy here, so reentrant code does not mutate our array while we're\n // emitting it to a new subscriber.\n const copy = _buffer.slice();\n for (let i = 0; i < copy.length && !subscriber.closed; i += _infiniteTimeWindow ? 1 : 2) {\n subscriber.next(copy[i] as T);\n }\n\n this._checkFinalizedStatuses(subscriber);\n\n return subscription;\n }\n\n private _trimBuffer() {\n const { _bufferSize, _timestampProvider, _buffer, _infiniteTimeWindow } = this;\n // If we don't have an infinite buffer size, and we're over the length,\n // use splice to truncate the old buffer values off. Note that we have to\n // double the size for instances where we're not using an infinite time window\n // because we're storing the values and the timestamps in the same array.\n const adjustedBufferSize = (_infiniteTimeWindow ? 1 : 2) * _bufferSize;\n _bufferSize < Infinity && adjustedBufferSize < _buffer.length && _buffer.splice(0, _buffer.length - adjustedBufferSize);\n\n // Now, if we're not in an infinite time window, remove all values where the time is\n // older than what is allowed.\n if (!_infiniteTimeWindow) {\n const now = _timestampProvider.now();\n let last = 0;\n // Search the array for the first timestamp that isn't expired and\n // truncate the buffer up to that point.\n for (let i = 1; i < _buffer.length && (_buffer[i] as number) <= now; i += 2) {\n last = i;\n }\n last && _buffer.splice(0, last + 1);\n }\n }\n}\n", "import { Scheduler } from '../Scheduler';\nimport { Subscription } from '../Subscription';\nimport { SchedulerAction } from '../types';\n\n/**\n * A unit of work to be executed in a `scheduler`. An action is typically\n * created from within a {@link SchedulerLike} and an RxJS user does not need to concern\n * themselves about creating and manipulating an Action.\n *\n * ```ts\n * class Action extends Subscription {\n * new (scheduler: Scheduler, work: (state?: T) => void);\n * schedule(state?: T, delay: number = 0): Subscription;\n * }\n * ```\n */\nexport class Action extends Subscription {\n constructor(scheduler: Scheduler, work: (this: SchedulerAction, state?: T) => void) {\n super();\n }\n /**\n * Schedules this action on its parent {@link SchedulerLike} for execution. May be passed\n * some context object, `state`. May happen at some point in the future,\n * according to the `delay` parameter, if specified.\n * @param state Some contextual data that the `work` function uses when called by the\n * Scheduler.\n * @param delay Time to wait before executing the work, where the time unit is implicit\n * and defined by the Scheduler.\n * @return A subscription in order to be able to unsubscribe the scheduled work.\n */\n public schedule(state?: T, delay: number = 0): Subscription {\n return this;\n }\n}\n", "import type { TimerHandle } from './timerHandle';\ntype SetIntervalFunction = (handler: () => void, timeout?: number, ...args: any[]) => TimerHandle;\ntype ClearIntervalFunction = (handle: TimerHandle) => void;\n\ninterface IntervalProvider {\n setInterval: SetIntervalFunction;\n clearInterval: ClearIntervalFunction;\n delegate:\n | {\n setInterval: SetIntervalFunction;\n clearInterval: ClearIntervalFunction;\n }\n | undefined;\n}\n\nexport const intervalProvider: IntervalProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n setInterval(handler: () => void, timeout?: number, ...args) {\n const { delegate } = intervalProvider;\n if (delegate?.setInterval) {\n return delegate.setInterval(handler, timeout, ...args);\n }\n return setInterval(handler, timeout, ...args);\n },\n clearInterval(handle) {\n const { delegate } = intervalProvider;\n return (delegate?.clearInterval || clearInterval)(handle as any);\n },\n delegate: undefined,\n};\n", "import { Action } from './Action';\nimport { SchedulerAction } from '../types';\nimport { Subscription } from '../Subscription';\nimport { AsyncScheduler } from './AsyncScheduler';\nimport { intervalProvider } from './intervalProvider';\nimport { arrRemove } from '../util/arrRemove';\nimport { TimerHandle } from './timerHandle';\n\nexport class AsyncAction extends Action {\n public id: TimerHandle | undefined;\n public state?: T;\n // @ts-ignore: Property has no initializer and is not definitely assigned\n public delay: number;\n protected pending: boolean = false;\n\n constructor(protected scheduler: AsyncScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n public schedule(state?: T, delay: number = 0): Subscription {\n if (this.closed) {\n return this;\n }\n\n // Always replace the current state with the new state.\n this.state = state;\n\n const id = this.id;\n const scheduler = this.scheduler;\n\n //\n // Important implementation note:\n //\n // Actions only execute once by default, unless rescheduled from within the\n // scheduled callback. This allows us to implement single and repeat\n // actions via the same code path, without adding API surface area, as well\n // as mimic traditional recursion but across asynchronous boundaries.\n //\n // However, JS runtimes and timers distinguish between intervals achieved by\n // serial `setTimeout` calls vs. a single `setInterval` call. An interval of\n // serial `setTimeout` calls can be individually delayed, which delays\n // scheduling the next `setTimeout`, and so on. `setInterval` attempts to\n // guarantee the interval callback will be invoked more precisely to the\n // interval period, regardless of load.\n //\n // Therefore, we use `setInterval` to schedule single and repeat actions.\n // If the action reschedules itself with the same delay, the interval is not\n // canceled. If the action doesn't reschedule, or reschedules with a\n // different delay, the interval will be canceled after scheduled callback\n // execution.\n //\n if (id != null) {\n this.id = this.recycleAsyncId(scheduler, id, delay);\n }\n\n // Set the pending flag indicating that this action has been scheduled, or\n // has recursively rescheduled itself.\n this.pending = true;\n\n this.delay = delay;\n // If this action has already an async Id, don't request a new one.\n this.id = this.id ?? this.requestAsyncId(scheduler, this.id, delay);\n\n return this;\n }\n\n protected requestAsyncId(scheduler: AsyncScheduler, _id?: TimerHandle, delay: number = 0): TimerHandle {\n return intervalProvider.setInterval(scheduler.flush.bind(scheduler, this), delay);\n }\n\n protected recycleAsyncId(_scheduler: AsyncScheduler, id?: TimerHandle, delay: number | null = 0): TimerHandle | undefined {\n // If this action is rescheduled with the same delay time, don't clear the interval id.\n if (delay != null && this.delay === delay && this.pending === false) {\n return id;\n }\n // Otherwise, if the action's delay time is different from the current delay,\n // or the action has been rescheduled before it's executed, clear the interval id\n if (id != null) {\n intervalProvider.clearInterval(id);\n }\n\n return undefined;\n }\n\n /**\n * Immediately executes this action and the `work` it contains.\n */\n public execute(state: T, delay: number): any {\n if (this.closed) {\n return new Error('executing a cancelled action');\n }\n\n this.pending = false;\n const error = this._execute(state, delay);\n if (error) {\n return error;\n } else if (this.pending === false && this.id != null) {\n // Dequeue if the action didn't reschedule itself. Don't call\n // unsubscribe(), because the action could reschedule later.\n // For example:\n // ```\n // scheduler.schedule(function doWork(counter) {\n // /* ... I'm a busy worker bee ... */\n // var originalAction = this;\n // /* wait 100ms before rescheduling the action */\n // setTimeout(function () {\n // originalAction.schedule(counter + 1);\n // }, 100);\n // }, 1000);\n // ```\n this.id = this.recycleAsyncId(this.scheduler, this.id, null);\n }\n }\n\n protected _execute(state: T, _delay: number): any {\n let errored: boolean = false;\n let errorValue: any;\n try {\n this.work(state);\n } catch (e) {\n errored = true;\n // HACK: Since code elsewhere is relying on the \"truthiness\" of the\n // return here, we can't have it return \"\" or 0 or false.\n // TODO: Clean this up when we refactor schedulers mid-version-8 or so.\n errorValue = e ? e : new Error('Scheduled action threw falsy error');\n }\n if (errored) {\n this.unsubscribe();\n return errorValue;\n }\n }\n\n unsubscribe() {\n if (!this.closed) {\n const { id, scheduler } = this;\n const { actions } = scheduler;\n\n this.work = this.state = this.scheduler = null!;\n this.pending = false;\n\n arrRemove(actions, this);\n if (id != null) {\n this.id = this.recycleAsyncId(scheduler, id, null);\n }\n\n this.delay = null!;\n super.unsubscribe();\n }\n }\n}\n", "import { Action } from './scheduler/Action';\nimport { Subscription } from './Subscription';\nimport { SchedulerLike, SchedulerAction } from './types';\nimport { dateTimestampProvider } from './scheduler/dateTimestampProvider';\n\n/**\n * An execution context and a data structure to order tasks and schedule their\n * execution. Provides a notion of (potentially virtual) time, through the\n * `now()` getter method.\n *\n * Each unit of work in a Scheduler is called an `Action`.\n *\n * ```ts\n * class Scheduler {\n * now(): number;\n * schedule(work, delay?, state?): Subscription;\n * }\n * ```\n *\n * @deprecated Scheduler is an internal implementation detail of RxJS, and\n * should not be used directly. Rather, create your own class and implement\n * {@link SchedulerLike}. Will be made internal in v8.\n */\nexport class Scheduler implements SchedulerLike {\n public static now: () => number = dateTimestampProvider.now;\n\n constructor(private schedulerActionCtor: typeof Action, now: () => number = Scheduler.now) {\n this.now = now;\n }\n\n /**\n * A getter method that returns a number representing the current time\n * (at the time this function was called) according to the scheduler's own\n * internal clock.\n * @return A number that represents the current time. May or may not\n * have a relation to wall-clock time. May or may not refer to a time unit\n * (e.g. milliseconds).\n */\n public now: () => number;\n\n /**\n * Schedules a function, `work`, for execution. May happen at some point in\n * the future, according to the `delay` parameter, if specified. May be passed\n * some context object, `state`, which will be passed to the `work` function.\n *\n * The given arguments will be processed an stored as an Action object in a\n * queue of actions.\n *\n * @param work A function representing a task, or some unit of work to be\n * executed by the Scheduler.\n * @param delay Time to wait before executing the work, where the time unit is\n * implicit and defined by the Scheduler itself.\n * @param state Some contextual data that the `work` function uses when called\n * by the Scheduler.\n * @return A subscription in order to be able to unsubscribe the scheduled work.\n */\n public schedule(work: (this: SchedulerAction, state?: T) => void, delay: number = 0, state?: T): Subscription {\n return new this.schedulerActionCtor(this, work).schedule(state, delay);\n }\n}\n", "import { Scheduler } from '../Scheduler';\nimport { Action } from './Action';\nimport { AsyncAction } from './AsyncAction';\nimport { TimerHandle } from './timerHandle';\n\nexport class AsyncScheduler extends Scheduler {\n public actions: Array> = [];\n /**\n * A flag to indicate whether the Scheduler is currently executing a batch of\n * queued actions.\n * @internal\n */\n public _active: boolean = false;\n /**\n * An internal ID used to track the latest asynchronous task such as those\n * coming from `setTimeout`, `setInterval`, `requestAnimationFrame`, and\n * others.\n * @internal\n */\n public _scheduled: TimerHandle | undefined;\n\n constructor(SchedulerAction: typeof Action, now: () => number = Scheduler.now) {\n super(SchedulerAction, now);\n }\n\n public flush(action: AsyncAction): void {\n const { actions } = this;\n\n if (this._active) {\n actions.push(action);\n return;\n }\n\n let error: any;\n this._active = true;\n\n do {\n if ((error = action.execute(action.state, action.delay))) {\n break;\n }\n } while ((action = actions.shift()!)); // exhaust the scheduler queue\n\n this._active = false;\n\n if (error) {\n while ((action = actions.shift()!)) {\n action.unsubscribe();\n }\n throw error;\n }\n }\n}\n", "import { AsyncAction } from './AsyncAction';\nimport { AsyncScheduler } from './AsyncScheduler';\n\n/**\n *\n * Async Scheduler\n *\n * Schedule task as if you used setTimeout(task, duration)\n *\n * `async` scheduler schedules tasks asynchronously, by putting them on the JavaScript\n * event loop queue. It is best used to delay tasks in time or to schedule tasks repeating\n * in intervals.\n *\n * If you just want to \"defer\" task, that is to perform it right after currently\n * executing synchronous code ends (commonly achieved by `setTimeout(deferredTask, 0)`),\n * better choice will be the {@link asapScheduler} scheduler.\n *\n * ## Examples\n * Use async scheduler to delay task\n * ```ts\n * import { asyncScheduler } from 'rxjs';\n *\n * const task = () => console.log('it works!');\n *\n * asyncScheduler.schedule(task, 2000);\n *\n * // After 2 seconds logs:\n * // \"it works!\"\n * ```\n *\n * Use async scheduler to repeat task in intervals\n * ```ts\n * import { asyncScheduler } from 'rxjs';\n *\n * function task(state) {\n * console.log(state);\n * this.schedule(state + 1, 1000); // `this` references currently executing Action,\n * // which we reschedule with new state and delay\n * }\n *\n * asyncScheduler.schedule(task, 3000, 0);\n *\n * // Logs:\n * // 0 after 3s\n * // 1 after 4s\n * // 2 after 5s\n * // 3 after 6s\n * ```\n */\n\nexport const asyncScheduler = new AsyncScheduler(AsyncAction);\n\n/**\n * @deprecated Renamed to {@link asyncScheduler}. Will be removed in v8.\n */\nexport const async = asyncScheduler;\n", "import { AsyncAction } from './AsyncAction';\nimport { Subscription } from '../Subscription';\nimport { QueueScheduler } from './QueueScheduler';\nimport { SchedulerAction } from '../types';\nimport { TimerHandle } from './timerHandle';\n\nexport class QueueAction extends AsyncAction {\n constructor(protected scheduler: QueueScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n public schedule(state?: T, delay: number = 0): Subscription {\n if (delay > 0) {\n return super.schedule(state, delay);\n }\n this.delay = delay;\n this.state = state;\n this.scheduler.flush(this);\n return this;\n }\n\n public execute(state: T, delay: number): any {\n return delay > 0 || this.closed ? super.execute(state, delay) : this._execute(state, delay);\n }\n\n protected requestAsyncId(scheduler: QueueScheduler, id?: TimerHandle, delay: number = 0): TimerHandle {\n // If delay exists and is greater than 0, or if the delay is null (the\n // action wasn't rescheduled) but was originally scheduled as an async\n // action, then recycle as an async action.\n\n if ((delay != null && delay > 0) || (delay == null && this.delay > 0)) {\n return super.requestAsyncId(scheduler, id, delay);\n }\n\n // Otherwise flush the scheduler starting with this action.\n scheduler.flush(this);\n\n // HACK: In the past, this was returning `void`. However, `void` isn't a valid\n // `TimerHandle`, and generally the return value here isn't really used. So the\n // compromise is to return `0` which is both \"falsy\" and a valid `TimerHandle`,\n // as opposed to refactoring every other instanceo of `requestAsyncId`.\n return 0;\n }\n}\n", "import { AsyncScheduler } from './AsyncScheduler';\n\nexport class QueueScheduler extends AsyncScheduler {\n}\n", "import { QueueAction } from './QueueAction';\nimport { QueueScheduler } from './QueueScheduler';\n\n/**\n *\n * Queue Scheduler\n *\n * Put every next task on a queue, instead of executing it immediately\n *\n * `queue` scheduler, when used with delay, behaves the same as {@link asyncScheduler} scheduler.\n *\n * When used without delay, it schedules given task synchronously - executes it right when\n * it is scheduled. However when called recursively, that is when inside the scheduled task,\n * another task is scheduled with queue scheduler, instead of executing immediately as well,\n * that task will be put on a queue and wait for current one to finish.\n *\n * This means that when you execute task with `queue` scheduler, you are sure it will end\n * before any other task scheduled with that scheduler will start.\n *\n * ## Examples\n * Schedule recursively first, then do something\n * ```ts\n * import { queueScheduler } from 'rxjs';\n *\n * queueScheduler.schedule(() => {\n * queueScheduler.schedule(() => console.log('second')); // will not happen now, but will be put on a queue\n *\n * console.log('first');\n * });\n *\n * // Logs:\n * // \"first\"\n * // \"second\"\n * ```\n *\n * Reschedule itself recursively\n * ```ts\n * import { queueScheduler } from 'rxjs';\n *\n * queueScheduler.schedule(function(state) {\n * if (state !== 0) {\n * console.log('before', state);\n * this.schedule(state - 1); // `this` references currently executing Action,\n * // which we reschedule with new state\n * console.log('after', state);\n * }\n * }, 0, 3);\n *\n * // In scheduler that runs recursively, you would expect:\n * // \"before\", 3\n * // \"before\", 2\n * // \"before\", 1\n * // \"after\", 1\n * // \"after\", 2\n * // \"after\", 3\n *\n * // But with queue it logs:\n * // \"before\", 3\n * // \"after\", 3\n * // \"before\", 2\n * // \"after\", 2\n * // \"before\", 1\n * // \"after\", 1\n * ```\n */\n\nexport const queueScheduler = new QueueScheduler(QueueAction);\n\n/**\n * @deprecated Renamed to {@link queueScheduler}. Will be removed in v8.\n */\nexport const queue = queueScheduler;\n", "import { AsyncAction } from './AsyncAction';\nimport { AnimationFrameScheduler } from './AnimationFrameScheduler';\nimport { SchedulerAction } from '../types';\nimport { animationFrameProvider } from './animationFrameProvider';\nimport { TimerHandle } from './timerHandle';\n\nexport class AnimationFrameAction extends AsyncAction {\n constructor(protected scheduler: AnimationFrameScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n protected requestAsyncId(scheduler: AnimationFrameScheduler, id?: TimerHandle, delay: number = 0): TimerHandle {\n // If delay is greater than 0, request as an async action.\n if (delay !== null && delay > 0) {\n return super.requestAsyncId(scheduler, id, delay);\n }\n // Push the action to the end of the scheduler queue.\n scheduler.actions.push(this);\n // If an animation frame has already been requested, don't request another\n // one. If an animation frame hasn't been requested yet, request one. Return\n // the current animation frame request id.\n return scheduler._scheduled || (scheduler._scheduled = animationFrameProvider.requestAnimationFrame(() => scheduler.flush(undefined)));\n }\n\n protected recycleAsyncId(scheduler: AnimationFrameScheduler, id?: TimerHandle, delay: number = 0): TimerHandle | undefined {\n // If delay exists and is greater than 0, or if the delay is null (the\n // action wasn't rescheduled) but was originally scheduled as an async\n // action, then recycle as an async action.\n if (delay != null ? delay > 0 : this.delay > 0) {\n return super.recycleAsyncId(scheduler, id, delay);\n }\n // If the scheduler queue has no remaining actions with the same async id,\n // cancel the requested animation frame and set the scheduled flag to\n // undefined so the next AnimationFrameAction will request its own.\n const { actions } = scheduler;\n if (id != null && id === scheduler._scheduled && actions[actions.length - 1]?.id !== id) {\n animationFrameProvider.cancelAnimationFrame(id as number);\n scheduler._scheduled = undefined;\n }\n // Return undefined so the action knows to request a new async id if it's rescheduled.\n return undefined;\n }\n}\n", "import { AsyncAction } from './AsyncAction';\nimport { AsyncScheduler } from './AsyncScheduler';\n\nexport class AnimationFrameScheduler extends AsyncScheduler {\n public flush(action?: AsyncAction): void {\n this._active = true;\n // The async id that effects a call to flush is stored in _scheduled.\n // Before executing an action, it's necessary to check the action's async\n // id to determine whether it's supposed to be executed in the current\n // flush.\n // Previous implementations of this method used a count to determine this,\n // but that was unsound, as actions that are unsubscribed - i.e. cancelled -\n // are removed from the actions array and that can shift actions that are\n // scheduled to be executed in a subsequent flush into positions at which\n // they are executed within the current flush.\n let flushId;\n if (action) {\n flushId = action.id;\n } else {\n flushId = this._scheduled;\n this._scheduled = undefined;\n }\n\n const { actions } = this;\n let error: any;\n action = action || actions.shift()!;\n\n do {\n if ((error = action.execute(action.state, action.delay))) {\n break;\n }\n } while ((action = actions[0]) && action.id === flushId && actions.shift());\n\n this._active = false;\n\n if (error) {\n while ((action = actions[0]) && action.id === flushId && actions.shift()) {\n action.unsubscribe();\n }\n throw error;\n }\n }\n}\n", "import { AnimationFrameAction } from './AnimationFrameAction';\nimport { AnimationFrameScheduler } from './AnimationFrameScheduler';\n\n/**\n *\n * Animation Frame Scheduler\n *\n * Perform task when `window.requestAnimationFrame` would fire\n *\n * When `animationFrame` scheduler is used with delay, it will fall back to {@link asyncScheduler} scheduler\n * behaviour.\n *\n * Without delay, `animationFrame` scheduler can be used to create smooth browser animations.\n * It makes sure scheduled task will happen just before next browser content repaint,\n * thus performing animations as efficiently as possible.\n *\n * ## Example\n * Schedule div height animation\n * ```ts\n * // html:
\n * import { animationFrameScheduler } from 'rxjs';\n *\n * const div = document.querySelector('div');\n *\n * animationFrameScheduler.schedule(function(height) {\n * div.style.height = height + \"px\";\n *\n * this.schedule(height + 1); // `this` references currently executing Action,\n * // which we reschedule with new state\n * }, 0, 0);\n *\n * // You will see a div element growing in height\n * ```\n */\n\nexport const animationFrameScheduler = new AnimationFrameScheduler(AnimationFrameAction);\n\n/**\n * @deprecated Renamed to {@link animationFrameScheduler}. Will be removed in v8.\n */\nexport const animationFrame = animationFrameScheduler;\n", "import { Observable } from '../Observable';\nimport { SchedulerLike } from '../types';\n\n/**\n * A simple Observable that emits no items to the Observer and immediately\n * emits a complete notification.\n *\n * Just emits 'complete', and nothing else.\n *\n * ![](empty.png)\n *\n * A simple Observable that only emits the complete notification. It can be used\n * for composing with other Observables, such as in a {@link mergeMap}.\n *\n * ## Examples\n *\n * Log complete notification\n *\n * ```ts\n * import { EMPTY } from 'rxjs';\n *\n * EMPTY.subscribe({\n * next: () => console.log('Next'),\n * complete: () => console.log('Complete!')\n * });\n *\n * // Outputs\n * // Complete!\n * ```\n *\n * Emit the number 7, then complete\n *\n * ```ts\n * import { EMPTY, startWith } from 'rxjs';\n *\n * const result = EMPTY.pipe(startWith(7));\n * result.subscribe(x => console.log(x));\n *\n * // Outputs\n * // 7\n * ```\n *\n * Map and flatten only odd numbers to the sequence `'a'`, `'b'`, `'c'`\n *\n * ```ts\n * import { interval, mergeMap, of, EMPTY } from 'rxjs';\n *\n * const interval$ = interval(1000);\n * const result = interval$.pipe(\n * mergeMap(x => x % 2 === 1 ? of('a', 'b', 'c') : EMPTY),\n * );\n * result.subscribe(x => console.log(x));\n *\n * // Results in the following to the console:\n * // x is equal to the count on the interval, e.g. (0, 1, 2, 3, ...)\n * // x will occur every 1000ms\n * // if x % 2 is equal to 1, print a, b, c (each on its own)\n * // if x % 2 is not equal to 1, nothing will be output\n * ```\n *\n * @see {@link Observable}\n * @see {@link NEVER}\n * @see {@link of}\n * @see {@link throwError}\n */\nexport const EMPTY = new Observable((subscriber) => subscriber.complete());\n\n/**\n * @param scheduler A {@link SchedulerLike} to use for scheduling\n * the emission of the complete notification.\n * @deprecated Replaced with the {@link EMPTY} constant or {@link scheduled} (e.g. `scheduled([], scheduler)`). Will be removed in v8.\n */\nexport function empty(scheduler?: SchedulerLike) {\n return scheduler ? emptyScheduled(scheduler) : EMPTY;\n}\n\nfunction emptyScheduled(scheduler: SchedulerLike) {\n return new Observable((subscriber) => scheduler.schedule(() => subscriber.complete()));\n}\n", "import { SchedulerLike } from '../types';\nimport { isFunction } from './isFunction';\n\nexport function isScheduler(value: any): value is SchedulerLike {\n return value && isFunction(value.schedule);\n}\n", "import { SchedulerLike } from '../types';\nimport { isFunction } from './isFunction';\nimport { isScheduler } from './isScheduler';\n\nfunction last(arr: T[]): T | undefined {\n return arr[arr.length - 1];\n}\n\nexport function popResultSelector(args: any[]): ((...args: unknown[]) => unknown) | undefined {\n return isFunction(last(args)) ? args.pop() : undefined;\n}\n\nexport function popScheduler(args: any[]): SchedulerLike | undefined {\n return isScheduler(last(args)) ? args.pop() : undefined;\n}\n\nexport function popNumber(args: any[], defaultValue: number): number {\n return typeof last(args) === 'number' ? args.pop()! : defaultValue;\n}\n", "export const isArrayLike = ((x: any): x is ArrayLike => x && typeof x.length === 'number' && typeof x !== 'function');", "import { isFunction } from \"./isFunction\";\n\n/**\n * Tests to see if the object is \"thennable\".\n * @param value the object to test\n */\nexport function isPromise(value: any): value is PromiseLike {\n return isFunction(value?.then);\n}\n", "import { InteropObservable } from '../types';\nimport { observable as Symbol_observable } from '../symbol/observable';\nimport { isFunction } from './isFunction';\n\n/** Identifies an input as being Observable (but not necessary an Rx Observable) */\nexport function isInteropObservable(input: any): input is InteropObservable {\n return isFunction(input[Symbol_observable]);\n}\n", "import { isFunction } from './isFunction';\n\nexport function isAsyncIterable(obj: any): obj is AsyncIterable {\n return Symbol.asyncIterator && isFunction(obj?.[Symbol.asyncIterator]);\n}\n", "/**\n * Creates the TypeError to throw if an invalid object is passed to `from` or `scheduled`.\n * @param input The object that was passed.\n */\nexport function createInvalidObservableTypeError(input: any) {\n // TODO: We should create error codes that can be looked up, so this can be less verbose.\n return new TypeError(\n `You provided ${\n input !== null && typeof input === 'object' ? 'an invalid object' : `'${input}'`\n } where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.`\n );\n}\n", "export function getSymbolIterator(): symbol {\n if (typeof Symbol !== 'function' || !Symbol.iterator) {\n return '@@iterator' as any;\n }\n\n return Symbol.iterator;\n}\n\nexport const iterator = getSymbolIterator();\n", "import { iterator as Symbol_iterator } from '../symbol/iterator';\nimport { isFunction } from './isFunction';\n\n/** Identifies an input as being an Iterable */\nexport function isIterable(input: any): input is Iterable {\n return isFunction(input?.[Symbol_iterator]);\n}\n", "import { ReadableStreamLike } from '../types';\nimport { isFunction } from './isFunction';\n\nexport async function* readableStreamLikeToAsyncGenerator(readableStream: ReadableStreamLike): AsyncGenerator {\n const reader = readableStream.getReader();\n try {\n while (true) {\n const { value, done } = await reader.read();\n if (done) {\n return;\n }\n yield value!;\n }\n } finally {\n reader.releaseLock();\n }\n}\n\nexport function isReadableStreamLike(obj: any): obj is ReadableStreamLike {\n // We don't want to use instanceof checks because they would return\n // false for instances from another Realm, like an +
+ +
+ + +
+
+
🗺️
+
+

BNKops Map

+
Interactive Canvassing System
+
+
+

Turn voter data into visual intelligence your canvassers can use.

+
    +
  • Real-time GPS tracking
  • +
  • Support level heat maps
  • +
  • Instant data entry
  • +
  • Offline-capable
  • +
+ +
+ +
+
+
📊
+
+

Voter Database

+
NocoDB Spreadsheet Interface
+
+
+

Manage voter data like a spreadsheet, access it like a database.

+
    +
  • Familiar Excel-like interface
  • +
  • Custom forms for data entry
  • +
  • Advanced filtering & search
  • +
  • API access for automation
  • +
+ +
+ +
+
+
📧
+
+

Email Command Center

+
Listmonk Email Platform
+
+
+

Professional email & messenger campaigns without the professional price tag.

+
    +
  • Unlimited subscribers
  • +
  • Beautiful templates
  • +
  • Open & click tracking
  • +
  • Automated sequences
  • +
+ +
+ +
+
+
🤖
+
+

Campaign Automation

+
n8n Workflow Engine
+
+
+

Automate repetitive tasks so your team can focus on voters.

+
    +
  • Visual workflow builder
  • +
  • Connect any service
  • +
  • Trigger-based automation
  • +
  • No coding required
  • +
+ +
+ +
+
+
🗃️
+
+

Version Control

+
Gitea Repository
+
+
+

Track changes, collaborate safely, and never lose work again.

+
    +
  • Full version history
  • +
  • Collaborative editing
  • +
  • Backup everything
  • +
  • Roll back changes
  • +
+ +
+ +
+
+
🏛️
+
+

Influence Campaign Tool

+
Representative Advocacy Platform
+
+
+

Connect Alberta residents with their elected officials for effective advocacy campaigns.

+
    +
  • Multi-level representative lookup
  • +
  • Campaign management dashboard
  • +
  • Email tracking & analytics
  • +
  • Custom campaign templates
  • +
+ +
+
+ + + + +
+
+
+

Canadian Tech for Canadian Campaigns

+

Why trust your movement's future to foreign corporations?

+
+ +
+
+
🇨🇦
+

100% Canadian

+

Built in Edmonton, Alberta. Supported by Canadian developers. Hosted on Canadian soil. Subject only to Canadian law.

+
+
+
🔐
+

True Ownership

+

Your data never leaves your control. Export everything anytime. No algorithms, no surveillance, no corporate oversight.

+
+
+
🛡️
+

Privacy First

+

Built to respect privacy from day one. Your supporters' data protected by design, not by policy.

+
+
+ +
+

The Sovereignty Difference

+
+
+

US Corporate Platforms

+
    +
  • ❌ Subject to Patriot Act
  • +
  • ❌ NSA surveillance
  • +
  • ❌ Corporate data mining
  • +
  • ❌ Foreign jurisdiction
  • +
+
+
+

Changemaker Lite

+
    +
  • ✅ Canadian sovereignty
  • +
  • ✅ Zero surveillance
  • +
  • ✅ Complete privacy
  • +
  • ✅ Your servers, your rules
  • +
+
+
+
+
+
+ + +
+
+
+

Simple, Transparent Pricing

+

No hidden fees. No usage limits. No surprises.

+
+ +
+
+
+

Self-Hosted

+
$0
+
forever
+
+
    +
  • ✓ All 11 campaign tools
  • +
  • ✓ Unlimited users
  • +
  • ✓ Unlimited data
  • +
  • ✓ Complete documentation
  • +
  • ✓ Community support
  • +
  • ✓ Your infrastructure
  • +
  • ✓ 100% open source
  • +
+ Installation Guide +

Perfect for tech-savvy campaigns

+
+ + + +
+
+

Managed Hosting

+
Custom
+
monthly
+
+
    +
  • ✓ We handle everything
  • +
  • ✓ Canadian data centers
  • +
  • ✓ Daily backups
  • +
  • ✓ 24/7 monitoring
  • +
  • ✓ Security updates
  • +
  • ✓ Priority support
  • +
  • ✓ Still your data
  • +
+ Contact Sales +

For larger campaigns

+
+
+ +
+

Compare Your Savings

+

Average campaign using corporate tools: $1,200-$4,000/month

+

Same capabilities with Changemaker Lite: $0 (self-hosted)

+ See detailed cost breakdown → +
+
+
+ + +
+
+
+

Everything Works Together

+

One login. One system. Infinite possibilities.

+
+ +
+
+
+ All systems communicate and build on one another +
+
+

+ 🎯 30-minute setup • 🔒 Your data stays yours • 🚀 No monthly fees +

+
+
+
+ + +
+ +
+ + +
+
+

Ready to Power Up Your Campaign?

+

Join hundreds of campaigns using open-source tools to win elections and save money.

+ +

+ 🎯 30-minute setup • 🔒 Your data stays yours • 🚀 No monthly fees +

+
+
+ + + + + + + \ No newline at end of file diff --git a/mkdocs/site/javascripts/gitea-widget.js b/mkdocs/site/javascripts/gitea-widget.js new file mode 100644 index 00000000..74b7faca --- /dev/null +++ b/mkdocs/site/javascripts/gitea-widget.js @@ -0,0 +1,132 @@ +document.addEventListener('DOMContentLoaded', function() { + const widgets = document.querySelectorAll('.gitea-widget'); + + widgets.forEach(widget => { + const repo = widget.dataset.repo; + + // Auto-generate data file path from repo name + const dataFile = `/assets/repo-data/${repo.replace('/', '-')}.json`; + const showDescription = widget.dataset.showDescription !== 'false'; + const showLanguage = widget.dataset.showLanguage !== 'false'; + const showLastUpdate = widget.dataset.showLastUpdate !== 'false'; + + // Show loading state + widget.innerHTML = ` +
+
+ Loading ${repo}... +
+ `; + + // Fetch repository data + fetch(dataFile) + .then(response => { + if (!response.ok) { + throw new Error(`Could not load data for ${repo}`); + } + return response.json(); + }) + .then(data => { + renderWidget(widget, data, { showDescription, showLanguage, showLastUpdate }); + }) + .catch(error => { + renderError(widget, repo, error.message); + }); + }); +}); + +function renderWidget(widget, data, options) { + const lastUpdate = new Date(data.updated_at).toLocaleDateString(); + const language = data.language || 'Not specified'; + + widget.innerHTML = ` +
+
+ +
+ + + + + ${data.stars_count.toLocaleString()} + + + + + + ${data.forks_count.toLocaleString()} + + + + + + + ${data.open_issues_count.toLocaleString()} + +
+
+ ${options.showDescription && data.description ? ` +
+ ${data.description} +
+ ` : ''} + +
+ `; +} + +function renderError(widget, repo, error) { + widget.innerHTML = ` +
+ + + + Failed to load ${repo} + ${error} +
+ `; +} + +function getLanguageColor(language) { + const colors = { + 'JavaScript': '#f1e05a', + 'TypeScript': '#2b7489', + 'Python': '#3572A5', + 'Java': '#b07219', + 'C++': '#f34b7d', + 'C': '#555555', + 'C#': '#239120', + 'PHP': '#4F5D95', + 'Ruby': '#701516', + 'Go': '#00ADD8', + 'Rust': '#dea584', + 'Swift': '#ffac45', + 'Kotlin': '#F18E33', + 'Scala': '#c22d40', + 'Shell': '#89e051', + 'HTML': '#e34c26', + 'CSS': '#563d7c', + 'Vue': '#2c3e50', + 'React': '#61dafb', + 'Dockerfile': '#384d54', + 'Markdown': '#083fa1' + }; + return colors[language] || '#586069'; +} \ No newline at end of file diff --git a/mkdocs/site/javascripts/github-widget.js b/mkdocs/site/javascripts/github-widget.js new file mode 100644 index 00000000..c4a69df1 --- /dev/null +++ b/mkdocs/site/javascripts/github-widget.js @@ -0,0 +1,167 @@ +let widgetsInitialized = new Set(); + +function initializeGitHubWidgets() { + const widgets = document.querySelectorAll('.github-widget'); + + widgets.forEach(widget => { + // Skip if already initialized + const widgetId = widget.dataset.repo + '-' + Math.random().toString(36).substr(2, 9); + if (widget.dataset.initialized) return; + + widget.dataset.initialized = 'true'; + const repo = widget.dataset.repo; + + // Auto-generate data file path from repo name + const dataFile = `/assets/repo-data/${repo.replace('/', '-')}.json`; + const showDescription = widget.dataset.showDescription !== 'false'; + const showLanguage = widget.dataset.showLanguage !== 'false'; + const showLastUpdate = widget.dataset.showLastUpdate !== 'false'; + + // Show loading state + widget.innerHTML = ` +
+
+ Loading ${repo}... +
+ `; + + // Fetch repository data from pre-generated JSON file + fetch(dataFile) + .then(response => { + if (!response.ok) { + throw new Error(`Could not load data for ${repo}`); + } + return response.json(); + }) + .then(data => { + const lastUpdate = new Date(data.updated_at).toLocaleDateString(); + const language = data.language || 'Not specified'; + + widget.innerHTML = ` +
+
+ +
+ + + + + ${data.stars_count.toLocaleString()} + + + + + + ${data.forks_count.toLocaleString()} + + + + + + + ${data.open_issues_count.toLocaleString()} + +
+
+ ${showDescription && data.description ? ` +
+ ${data.description} +
+ ` : ''} + +
+ `; + }) + .catch(error => { + console.error('Error loading repository data:', error); + widget.innerHTML = ` +
+ + + + Failed to load ${repo} + ${error.message} +
+ `; + }); + }); +} + +// Initialize on DOM ready +document.addEventListener('DOMContentLoaded', initializeGitHubWidgets); + +// Watch for DOM changes (MkDocs Material dynamic content) +const observer = new MutationObserver((mutations) => { + let shouldReinitialize = false; + + mutations.forEach((mutation) => { + if (mutation.type === 'childList') { + // Check if any github-widget elements were added + mutation.addedNodes.forEach((node) => { + if (node.nodeType === 1 && // Element node + (node.classList?.contains('github-widget') || + node.querySelector?.('.github-widget'))) { + shouldReinitialize = true; + } + }); + } + }); + + if (shouldReinitialize) { + // Small delay to ensure DOM is stable + setTimeout(initializeGitHubWidgets, 100); + } +}); + +// Start observing +observer.observe(document.body, { + childList: true, + subtree: true +}); + +// Language color mapping (simplified version) +function getLanguageColor(language) { + const colors = { + 'JavaScript': '#f1e05a', + 'TypeScript': '#2b7489', + 'Python': '#3572A5', + 'Java': '#b07219', + 'C++': '#f34b7d', + 'C': '#555555', + 'C#': '#239120', + 'PHP': '#4F5D95', + 'Ruby': '#701516', + 'Go': '#00ADD8', + 'Rust': '#dea584', + 'Swift': '#ffac45', + 'Kotlin': '#F18E33', + 'Scala': '#c22d40', + 'Shell': '#89e051', + 'HTML': '#e34c26', + 'CSS': '#563d7c', + 'Vue': '#2c3e50', + 'React': '#61dafb', + 'Dockerfile': '#384d54' + }; + return colors[language] || '#586069'; +} diff --git a/mkdocs/site/javascripts/home.js b/mkdocs/site/javascripts/home.js new file mode 100644 index 00000000..e755e12c --- /dev/null +++ b/mkdocs/site/javascripts/home.js @@ -0,0 +1,338 @@ +// Changemaker Lite - Minimal Interactions + +document.addEventListener('DOMContentLoaded', function() { + // Terminal copy functionality + const terminals = document.querySelectorAll('.terminal-box'); + terminals.forEach(terminal => { + terminal.addEventListener('click', function() { + const code = this.textContent.trim(); + navigator.clipboard.writeText(code).then(() => { + // Quick visual feedback + this.style.background = '#0a0a0a'; + setTimeout(() => { + this.style.background = '#000'; + }, 200); + }); + }); + }); + + // Smooth scroll for anchors + document.querySelectorAll('a[href^="#"]').forEach(anchor => { + anchor.addEventListener('click', function (e) { + e.preventDefault(); + const target = document.querySelector(this.getAttribute('href')); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + }); + + // Reduced motion support + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + document.documentElement.style.scrollBehavior = 'auto'; + const style = document.createElement('style'); + style.textContent = '*, *::before, *::after { animation: none !important; transition: none !important; }'; + document.head.appendChild(style); + } +}); + +// Changemaker Lite - Smooth Grid Interactions + +document.addEventListener('DOMContentLoaded', function() { + // Smooth scroll for anchors + document.querySelectorAll('a[href^="#"]').forEach(anchor => { + anchor.addEventListener('click', function (e) { + e.preventDefault(); + const target = document.querySelector(this.getAttribute('href')); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + }); + + // Add stagger animation to grid cards on scroll + const observerOptions = { + threshold: 0.1, + rootMargin: '0px 0px -50px 0px' + }; + + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry, index) => { + if (entry.isIntersecting) { + setTimeout(() => { + entry.target.style.opacity = '1'; + entry.target.style.transform = 'translateY(0)'; + }, index * 50); + observer.unobserve(entry.target); + } + }); + }, observerOptions); + + // Observe all grid cards (exclude site-card and stat-card which have their own observer) + document.querySelectorAll('.grid-card:not(.site-card):not(.stat-card)').forEach((card, index) => { + card.style.opacity = '0'; + card.style.transform = 'translateY(20px)'; + card.style.transition = 'opacity 0.5s ease, transform 0.5s ease'; + observer.observe(card); + }); + + // Neon hover effect for service cards + document.querySelectorAll('.service-card').forEach(card => { + card.addEventListener('mouseenter', function(e) { + const rect = this.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const ripple = document.createElement('div'); + ripple.style.position = 'absolute'; + ripple.style.left = x + 'px'; + ripple.style.top = y + 'px'; + ripple.style.width = '0'; + ripple.style.height = '0'; + ripple.style.borderRadius = '50%'; + ripple.style.background = 'rgba(91, 206, 250, 0.3)'; + ripple.style.transform = 'translate(-50%, -50%)'; + ripple.style.pointerEvents = 'none'; + ripple.style.transition = 'width 0.6s, height 0.6s, opacity 0.6s'; + + this.appendChild(ripple); + + setTimeout(() => { + ripple.style.width = '200px'; + ripple.style.height = '200px'; + ripple.style.opacity = '0'; + }, 10); + + setTimeout(() => { + ripple.remove(); + }, 600); + }); + }); + + // Animated counter for hero stats (the smaller stat grid) + const animateValue = (element, start, end, duration) => { + const range = end - start; + const increment = range / (duration / 16); + let current = start; + + const timer = setInterval(() => { + current += increment; + if (current >= end) { + current = end; + clearInterval(timer); + } + element.textContent = Math.round(current); + }, 16); + }; + + // Animate hero stat numbers on scroll (only for .stat-item, not .stat-card) + const heroStatObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const statNumber = entry.target.querySelector('.stat-number'); + if (statNumber && !statNumber.animated) { + statNumber.animated = true; + const value = parseInt(statNumber.textContent); + if (!isNaN(value)) { + statNumber.textContent = '0'; + animateValue(statNumber, 0, value, 1000); + } + } + heroStatObserver.unobserve(entry.target); + } + }); + }, observerOptions); + + document.querySelectorAll('.stat-item').forEach(stat => { + heroStatObserver.observe(stat); + }); + + // Animated counter for proof stats - improved version + const proofStatObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const statCard = entry.target; + const counter = statCard.querySelector('.stat-counter'); + const statValue = statCard.getAttribute('data-stat'); + + if (counter && statValue && !counter.hasAttribute('data-animated')) { + counter.setAttribute('data-animated', 'true'); + + // Add counting class for visual effect + counter.classList.add('counting'); + + // Special handling for different stat types + if (statValue === '1') { + // AI Ready - just animate the text + counter.style.opacity = '0'; + counter.style.transform = 'scale(0.5)'; + setTimeout(() => { + counter.style.transition = 'all 0.8s ease'; + counter.style.opacity = '1'; + counter.style.transform = 'scale(1)'; + }, 200); + } else if (statValue === '30' || statValue === '2') { + // Time values like "30min", "2hr" + const originalText = counter.textContent; + counter.textContent = '0'; + counter.style.opacity = '0'; + setTimeout(() => { + counter.style.transition = 'all 0.8s ease'; + counter.style.opacity = '1'; + counter.textContent = originalText; + }, 200); + } else { + // Numeric values - animate the counting + const value = parseInt(statValue); + const originalText = counter.textContent; + counter.textContent = '0'; + + // Simple counting animation + let current = 0; + const increment = value / 30; // 30 steps + const timer = setInterval(() => { + current += increment; + if (current >= value) { + current = value; + clearInterval(timer); + // Restore original formatted text + setTimeout(() => { + counter.textContent = originalText; + }, 200); + } else { + counter.textContent = Math.floor(current).toLocaleString(); + } + }, 50); + } + } + proofStatObserver.unobserve(entry.target); + } + }); + }, { + threshold: 0.3, + rootMargin: '0px 0px -20px 0px' + }); + + // Observe proof stat cards (only those with data-stat attribute) + document.querySelectorAll('.stat-card[data-stat]').forEach(stat => { + proofStatObserver.observe(stat); + }); + + // Site card hover effects + document.querySelectorAll('.site-card').forEach(card => { + card.addEventListener('mouseenter', function() { + const icon = this.querySelector('.site-icon'); + if (icon) { + icon.style.animation = 'none'; + setTimeout(() => { + icon.style.animation = 'site-float 3s ease-in-out infinite'; + }, 10); + } + }); + }); + + // Staggered animation for proof cards - improved + const proofCardObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const container = entry.target.closest('.sites-grid, .stats-grid'); + if (container) { + const allCards = Array.from(container.children); + const index = allCards.indexOf(entry.target); + + setTimeout(() => { + entry.target.style.opacity = '1'; + entry.target.style.transform = 'translateY(0)'; + }, index * 150); // Slightly longer delay for better effect + } + + proofCardObserver.unobserve(entry.target); + } + }); + }, { + threshold: 0.2, + rootMargin: '0px 0px -30px 0px' + }); + + // Observe site cards and stat cards for stagger animation + document.querySelectorAll('.site-card, .stat-card').forEach((card) => { + // Set initial state + card.style.opacity = '0'; + card.style.transform = 'translateY(30px)'; + card.style.transition = 'opacity 0.8s ease, transform 0.8s ease'; + proofCardObserver.observe(card); + }); + + // Add parallax effect to hero section + let ticking = false; + function updateParallax() { + const scrolled = window.pageYOffset; + const hero = document.querySelector('.hero-grid'); + if (hero) { + hero.style.transform = `translateY(${scrolled * 0.3}px)`; + } + ticking = false; + } + + function requestTick() { + if (!ticking) { + window.requestAnimationFrame(updateParallax); + ticking = true; + } + } + + // Only add parallax on desktop + if (window.innerWidth > 768) { + window.addEventListener('scroll', requestTick); + } + + // Button ripple effect + document.querySelectorAll('.btn').forEach(button => { + button.addEventListener('click', function(e) { + const rect = this.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const ripple = document.createElement('span'); + ripple.style.position = 'absolute'; + ripple.style.left = x + 'px'; + ripple.style.top = y + 'px'; + ripple.className = 'btn-ripple'; + + this.appendChild(ripple); + + setTimeout(() => { + ripple.remove(); + }, 600); + }); + }); + + // Reduced motion support + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + document.documentElement.style.scrollBehavior = 'auto'; + window.removeEventListener('scroll', requestTick); + } +}); + +// Add CSS for button ripple +const style = document.createElement('style'); +style.textContent = ` + .btn-ripple { + position: absolute; + width: 20px; + height: 20px; + border-radius: 50%; + background: rgba(111, 66, 193, 0.5); /* mkdocs purple */ + transform: translate(-50%, -50%) scale(0); + animation: ripple-animation 0.6s ease-out; + pointer-events: none; + } + + @keyframes ripple-animation { + to { + transform: translate(-50%, -50%) scale(10); + opacity: 0; + } + } +`; +document.head.appendChild(style); \ No newline at end of file diff --git a/mkdocs/site/lander/index.html b/mkdocs/site/lander/index.html new file mode 100644 index 00000000..532ed071 --- /dev/null +++ b/mkdocs/site/lander/index.html @@ -0,0 +1,2194 @@ + + + + + + Changemaker Lite - Campaign Power Tools + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+
+
🚀 Hardware Up This Site Served by Changemaker - Lite
+

Power Tools for Modern Campaigns

+ +

+ Complete political independence on your own infrastructure. Own every byte of data, control every system, scale at your own pace. No corporate surveillance, no foreign interference, no monthly ransoms. Deploy unlimited sites, send unlimited messages, organize unlimited supporters. Free and open source software for community organizers wanting to make change. +

+ +
+ + + + +
+
+
100%
+
Local Data Ownership
+
+
+
$0
+
Free to Self-Host
+
+
+
Canadian
+
Built by Locals
+
+
+
Open-Source
+
Built for Campaigns
+
+
+
+ +
+ + +
+
+
+

Your Supporters Are Struggling

+

Traditional campaign tools weren't built for the reality of political action

+
+
+
+
📱
+

Can't Find Answers Fast

+

Voters ask tough questions. Your team fumbles through PDFs, emails, and scattered Google Docs while the voter loses interest.

+
+
+
🗺️
+

Disconnected Data

+

Walk lists in one app, voter info in another, campaign policies somewhere else. Nothing talks to each other.

+
+
+
💸
+

Death by Subscription

+

$100 here, $500 there. Before you know it, you're spending thousands monthly on tools that don't even work together.

+
+
+
🔒
+

No Data Control

+

Your data is locked behind expensive subscriptions. Your strategies in corporate clouds. Your movement's future in someone else's hands.

+
+
+
📵
+

Not Mobile-Ready

+

Desktop-first tools that barely work on phones. Canvassers struggling with tiny text and broken interfaces.

+
+
+
🏢
+

Foreign Dependencies

+

US companies with US regulations. Your Canadian campaign data subject to foreign laws and surveillance.

+
+
+
+
+ + +
+
+
+

Political Documentation That Works

+

Everything your team needs, instantly searchable, always accessible, and easy to communicate

+
+ +
+
+

Communicate on Scale

+

Full email and messenger campaign systems with unlimited users

+
    +
  • Drop in replacement for mailchimp, sendgrid, etc.
  • +
  • Track emails, clicks, and user actions
  • +
  • Unlimited contact, asset, and file storage
  • +
  • Compatible with all major email providers
  • +
  • Fully extensible with API's & webhooks
  • +
+
+
+ Phone showing mobile-optimized interface with large touch targets, clear typography, and instant search results +
+
+ +
+
+

Mobile-First Everything

+

Built for phones first, because that's what your supporters carry. Every feature, every interface, optimized for one-handed use in the field.

+
    +
  • Touch-optimized interfaces
  • +
  • Offline-capable after first load
  • +
  • Simple, easy to read, and clear buttons
  • +
  • One-thumb navigation
  • +
  • Instant load times
  • +
+
+
+ Phone showing mobile-optimized interface with large touch targets, clear typography, and instant search results +
+
+ +
+
+

Your Data, Your Servers, Your Rules

+

Complete Data Ownership. Run it on Canadian soil, in your office, or anywhere you trust. No foreign surveillance, no corporate access, no compromises.

+
    +
  • 100% self-hosted infrastructure
  • +
  • Canadian data residency
  • +
  • Complete export capabilities
  • +
  • No vendor lock-in ever
  • +
  • Privacy-first architecture
  • +
+
+
+ Server dashboard showing Canadian hosting location, data ownership controls, and privacy settings +
+
+ +
+
+

Instant Search Everything

+

Your entire campaign knowledge base at your fingertips. Policy positions, talking points, FAQs - all searchable in milliseconds.

+
    +
  • Full-text search across all documentation
  • +
  • Smart categorization and tagging
  • +
  • Works offline after first load
  • +
  • Mobile-optimized interface
  • +
+
+
+ Mobile view showing instant search results for healthcare policy with highlighted snippets and quick access buttons +
+
+ +
+
+

Interactive Canvassing Maps

+

See everything about a neighborhood before you knock. Previous interactions, support levels, local issues - all on one map.

+
    +
  • Real-time location tracking
  • +
  • Color-coded support levels
  • +
  • Add notes directly from the field
  • +
  • Track door knocks & interactions
  • +
+
+
+ Map interface showing color-coded houses, with popup showing voter details and previous interaction history +
+
+ +
+
+

Living Documentation

+

Your campaign evolves daily. Your documentation should too. Update once, everyone gets it instantly.

+
    +
  • Real-time collaborative editing
  • +
  • Version control and history
  • +
  • Automatic mobile optimization
  • +
  • Beautiful, branded output
  • +
  • Thousands of plugins available
  • +
  • Local AI
  • +
+
+
+ Split view showing markdown editor on left, live preview on right with campaign branding +
+
+ +
+
+

Beautiul Websites

+

Build and deploy beautiful websites & documentation using the tools already used by the worlds largest organizations.

+
    +
  • Social media cards
  • +
  • Fully customizable
  • +
  • Custom pages and landers
  • +
  • Integrated blogging
  • +
  • Supports 60+ languages
  • +
+
+
+ Map interface showing color-coded houses, with popup showing voter details and previous interaction history +
+
+ +
+
+

Connect Albertans to Their Representatives

+

Help Alberta residents take action by connecting them directly with their elected officials at all government levels. Perfect for advocacy campaigns and issue-based organizing.

+
    +
  • Postal code lookup for federal, provincial & municipal representatives
  • +
  • Create unlimited advocacy campaigns with custom email templates
  • +
  • Track engagement metrics and email delivery
  • +
  • Smart caching for fast performance
  • +
  • Mobile-first design for on-the-go advocacy
  • +
  • Safe email testing mode for development
  • +
+
+
+ [Insert photo: Screenshot of Influence app showing representative lookup results with contact cards for MP, MLA, and City Councillor, with "Send Email" buttons] +
+
+ +
+
+ + +
+
+
+

Your Complete Campaign Power Tool Suite

+

Everything works together. No integrations needed. No monthly fees.

+
+ +
+ +
+ +
+
+
+
📝
+
+

Smart Documentation Hub

+
MkDocs + Code Server
+ mkdocs/docs/blog
+
+

Create beautiful, searchable documentation that your team will actually use.

+
    +
  • Instant search across everything
  • +
  • Mobile-first responsive design
  • +
  • Automatic table of contents
  • +
  • Deep linking to any section
  • +
+ +
+ +
+
+
🗺️
+
+

BNKops Map

+
Interactive Canvassing System
+
+
+

Turn voter data into visual intelligence your canvassers can use.

+
    +
  • Real-time GPS tracking
  • +
  • Support level heat maps
  • +
  • Instant data entry
  • +
  • Offline-capable
  • +
+ +
+ +
+
+
📊
+
+

Voter Database

+
NocoDB Spreadsheet Interface
+
+
+

Manage voter data like a spreadsheet, access it like a database.

+
    +
  • Familiar Excel-like interface
  • +
  • Custom forms for data entry
  • +
  • Advanced filtering & search
  • +
  • API access for automation
  • +
+ +
+ +
+
+
📧
+
+

Email Command Center

+
Listmonk Email Platform
+
+
+

Professional email & messenger campaigns without the professional price tag.

+
    +
  • Unlimited subscribers
  • +
  • Beautiful templates
  • +
  • Open & click tracking
  • +
  • Automated sequences
  • +
+ +
+ +
+
+
🤖
+
+

Campaign Automation

+
n8n Workflow Engine
+
+
+

Automate repetitive tasks so your team can focus on voters.

+
    +
  • Visual workflow builder
  • +
  • Connect any service
  • +
  • Trigger-based automation
  • +
  • No coding required
  • +
+ +
+ +
+
+
🗃️
+
+

Version Control

+
Gitea Repository
+
+
+

Track changes, collaborate safely, and never lose work again.

+
    +
  • Full version history
  • +
  • Collaborative editing
  • +
  • Backup everything
  • +
  • Roll back changes
  • +
+ +
+ +
+
+
🏛️
+
+

Influence Campaign Tool

+
Representative Advocacy Platform
+
+
+

Connect Alberta residents with their elected officials for effective advocacy campaigns.

+
    +
  • Multi-level representative lookup
  • +
  • Campaign management dashboard
  • +
  • Email tracking & analytics
  • +
  • Custom campaign templates
  • +
+ +
+
+
+
+ + +
+
+
+

Canadian Tech for Canadian Campaigns

+

Why trust your movement's future to foreign corporations?

+
+ +
+
+
🇨🇦
+

100% Canadian

+

Built in Edmonton, Alberta. Supported by Canadian developers. Hosted on Canadian soil. Subject only to Canadian law.

+
+
+
🔐
+

True Ownership

+

Your data never leaves your control. Export everything anytime. No algorithms, no surveillance, no corporate oversight.

+
+
+
🛡️
+

Privacy First

+

Built to respect privacy from day one. Your supporters' data protected by design, not by policy.

+
+
+ +
+

The Sovereignty Difference

+
+
+

US Corporate Platforms

+
    +
  • ❌ Subject to Patriot Act
  • +
  • ❌ NSA surveillance
  • +
  • ❌ Corporate data mining
  • +
  • ❌ Foreign jurisdiction
  • +
+
+
+

Changemaker Lite

+
    +
  • ✅ Canadian sovereignty
  • +
  • ✅ Zero surveillance
  • +
  • ✅ Complete privacy
  • +
  • ✅ Your servers, your rules
  • +
+
+
+
+
+
+ + +
+
+
+

Simple, Transparent Pricing

+

No hidden fees. No usage limits. No surprises.

+
+ +
+
+
+

Self-Hosted

+
$0
+
forever
+
+
    +
  • ✓ All 11 campaign tools
  • +
  • ✓ Unlimited users
  • +
  • ✓ Unlimited data
  • +
  • ✓ Complete documentation
  • +
  • ✓ Community support
  • +
  • ✓ Your infrastructure
  • +
  • ✓ 100% open source
  • +
+ Installation Guide +

Perfect for tech-savvy campaigns

+
+ + + +
+
+

Managed Hosting

+
Custom
+
monthly
+
+
    +
  • ✓ We handle everything
  • +
  • ✓ Canadian data centers
  • +
  • ✓ Daily backups
  • +
  • ✓ 24/7 monitoring
  • +
  • ✓ Security updates
  • +
  • ✓ Priority support
  • +
  • ✓ Still your data
  • +
+ Contact Sales +

For larger campaigns

+
+
+ +
+

Compare Your Savings

+

Average campaign using corporate tools: $1,200-$4,000/month

+

Same capabilities with Changemaker Lite: $0 (self-hosted)

+ See detailed cost breakdown → +
+
+
+ + +
+
+
+

Everything Works Together

+

One login. One system. Infinite possibilities.

+
+ +
+
+
+ All systems communicate and build on one another +
+
+

+ 🎯 30-minute setup • 🔒 Your data stays yours • 🚀 No monthly fees +

+
+
+
+ + +
+ +
+ + +
+
+

Ready to Power Up Your Campaign?

+

Join hundreds of campaigns using open-source tools to win elections and save money.

+ +

+ 🎯 30-minute setup • 🔒 Your data stays yours • 🚀 No monthly fees +

+
+
+ + + + + + + \ No newline at end of file diff --git a/mkdocs/site/overrides/lander.html b/mkdocs/site/overrides/lander.html new file mode 100644 index 00000000..ef0e09e0 --- /dev/null +++ b/mkdocs/site/overrides/lander.html @@ -0,0 +1,2181 @@ + + + + + + Changemaker Lite - Campaign Power Tools + + + + + +
+ +
+ + +
+
+
🚀 Hardware Up This Site Served by Changemaker - Lite
+

Power Tools for Modern Campaigns

+ +

+ Complete political independence on your own infrastructure. Own every byte of data, control every system, scale at your own pace. No corporate surveillance, no foreign interference, no monthly ransoms. Deploy unlimited sites, send unlimited messages, organize unlimited supporters. Free and open source software for community organizers wanting to make change. +

+ +
+ + + + +
+
+
100%
+
Local Data Ownership
+
+
+
$0
+
Free to Self-Host
+
+
+
Canadian
+
Built by Locals
+
+
+
Open-Source
+
Built for Campaigns
+
+
+
+ +
+ + +
+
+
+

Your Supporters Are Struggling

+

Traditional campaign tools weren't built for the reality of political action

+
+
+
+
📱
+

Can't Find Answers Fast

+

Voters ask tough questions. Your team fumbles through PDFs, emails, and scattered Google Docs while the voter loses interest.

+
+
+
🗺️
+

Disconnected Data

+

Walk lists in one app, voter info in another, campaign policies somewhere else. Nothing talks to each other.

+
+
+
💸
+

Death by Subscription

+

$100 here, $500 there. Before you know it, you're spending thousands monthly on tools that don't even work together.

+
+
+
🔒
+

No Data Control

+

Your data is locked behind expensive subscriptions. Your strategies in corporate clouds. Your movement's future in someone else's hands.

+
+
+
📵
+

Not Mobile-Ready

+

Desktop-first tools that barely work on phones. Canvassers struggling with tiny text and broken interfaces.

+
+
+
🏢
+

Foreign Dependencies

+

US companies with US regulations. Your Canadian campaign data subject to foreign laws and surveillance.

+
+
+
+
+ + +
+
+
+

Political Documentation That Works

+

Everything your team needs, instantly searchable, always accessible, and easy to communicate

+
+ +
+
+

Communicate on Scale

+

Full email and messenger campaign systems with unlimited users

+
    +
  • Drop in replacement for mailchimp, sendgrid, etc.
  • +
  • Track emails, clicks, and user actions
  • +
  • Unlimited contact, asset, and file storage
  • +
  • Compatible with all major email providers
  • +
  • Fully extensible with API's & webhooks
  • +
+
+
+ Phone showing mobile-optimized interface with large touch targets, clear typography, and instant search results +
+
+ +
+
+

Mobile-First Everything

+

Built for phones first, because that's what your supporters carry. Every feature, every interface, optimized for one-handed use in the field.

+
    +
  • Touch-optimized interfaces
  • +
  • Offline-capable after first load
  • +
  • Simple, easy to read, and clear buttons
  • +
  • One-thumb navigation
  • +
  • Instant load times
  • +
+
+
+ Phone showing mobile-optimized interface with large touch targets, clear typography, and instant search results +
+
+ +
+
+

Your Data, Your Servers, Your Rules

+

Complete Data Ownership. Run it on Canadian soil, in your office, or anywhere you trust. No foreign surveillance, no corporate access, no compromises.

+
    +
  • 100% self-hosted infrastructure
  • +
  • Canadian data residency
  • +
  • Complete export capabilities
  • +
  • No vendor lock-in ever
  • +
  • Privacy-first architecture
  • +
+
+
+ Server dashboard showing Canadian hosting location, data ownership controls, and privacy settings +
+
+ +
+
+

Instant Search Everything

+

Your entire campaign knowledge base at your fingertips. Policy positions, talking points, FAQs - all searchable in milliseconds.

+
    +
  • Full-text search across all documentation
  • +
  • Smart categorization and tagging
  • +
  • Works offline after first load
  • +
  • Mobile-optimized interface
  • +
+
+
+ Mobile view showing instant search results for healthcare policy with highlighted snippets and quick access buttons +
+
+ +
+
+

Interactive Canvassing Maps

+

See everything about a neighborhood before you knock. Previous interactions, support levels, local issues - all on one map.

+
    +
  • Real-time location tracking
  • +
  • Color-coded support levels
  • +
  • Add notes directly from the field
  • +
  • Track door knocks & interactions
  • +
+
+
+ Map interface showing color-coded houses, with popup showing voter details and previous interaction history +
+
+ +
+
+

Living Documentation

+

Your campaign evolves daily. Your documentation should too. Update once, everyone gets it instantly.

+
    +
  • Real-time collaborative editing
  • +
  • Version control and history
  • +
  • Automatic mobile optimization
  • +
  • Beautiful, branded output
  • +
  • Thousands of plugins available
  • +
  • Local AI
  • +
+
+
+ Split view showing markdown editor on left, live preview on right with campaign branding +
+
+ +
+
+

Beautiul Websites

+

Build and deploy beautiful websites & documentation using the tools already used by the worlds largest organizations.

+
    +
  • Social media cards
  • +
  • Fully customizable
  • +
  • Custom pages and landers
  • +
  • Integrated blogging
  • +
  • Supports 60+ languages
  • +
+
+
+ Map interface showing color-coded houses, with popup showing voter details and previous interaction history +
+
+ +
+
+

Connect Albertans to Their Representatives

+

Help Alberta residents take action by connecting them directly with their elected officials at all government levels. Perfect for advocacy campaigns and issue-based organizing.

+
    +
  • Postal code lookup for federal, provincial & municipal representatives
  • +
  • Create unlimited advocacy campaigns with custom email templates
  • +
  • Track engagement metrics and email delivery
  • +
  • Smart caching for fast performance
  • +
  • Mobile-first design for on-the-go advocacy
  • +
  • Safe email testing mode for development
  • +
+
+
+ [Insert photo: Screenshot of Influence app showing representative lookup results with contact cards for MP, MLA, and City Councillor, with "Send Email" buttons] +
+
+ +
+
+ + +
+
+
+

Your Complete Campaign Power Tool Suite

+

Everything works together. No integrations needed. No monthly fees.

+
+ +
+ +
+ +
+
+
+
📝
+
+

Smart Documentation Hub

+
MkDocs + Code Server
+ mkdocs/docs/blog
+
+

Create beautiful, searchable documentation that your team will actually use.

+
    +
  • Instant search across everything
  • +
  • Mobile-first responsive design
  • +
  • Automatic table of contents
  • +
  • Deep linking to any section
  • +
+ +
+ +
+
+
🗺️
+
+

BNKops Map

+
Interactive Canvassing System
+
+
+

Turn voter data into visual intelligence your canvassers can use.

+
    +
  • Real-time GPS tracking
  • +
  • Support level heat maps
  • +
  • Instant data entry
  • +
  • Offline-capable
  • +
+ +
+ +
+
+
📊
+
+

Voter Database

+
NocoDB Spreadsheet Interface
+
+
+

Manage voter data like a spreadsheet, access it like a database.

+
    +
  • Familiar Excel-like interface
  • +
  • Custom forms for data entry
  • +
  • Advanced filtering & search
  • +
  • API access for automation
  • +
+ +
+ +
+
+
📧
+
+

Email Command Center

+
Listmonk Email Platform
+
+
+

Professional email & messenger campaigns without the professional price tag.

+
    +
  • Unlimited subscribers
  • +
  • Beautiful templates
  • +
  • Open & click tracking
  • +
  • Automated sequences
  • +
+ +
+ +
+
+
🤖
+
+

Campaign Automation

+
n8n Workflow Engine
+
+
+

Automate repetitive tasks so your team can focus on voters.

+
    +
  • Visual workflow builder
  • +
  • Connect any service
  • +
  • Trigger-based automation
  • +
  • No coding required
  • +
+ +
+ +
+
+
🗃️
+
+

Version Control

+
Gitea Repository
+
+
+

Track changes, collaborate safely, and never lose work again.

+
    +
  • Full version history
  • +
  • Collaborative editing
  • +
  • Backup everything
  • +
  • Roll back changes
  • +
+ +
+ +
+
+
🏛️
+
+

Influence Campaign Tool

+
Representative Advocacy Platform
+
+
+

Connect Alberta residents with their elected officials for effective advocacy campaigns.

+
    +
  • Multi-level representative lookup
  • +
  • Campaign management dashboard
  • +
  • Email tracking & analytics
  • +
  • Custom campaign templates
  • +
+ +
+
+
+
+ + +
+
+
+

Canadian Tech for Canadian Campaigns

+

Why trust your movement's future to foreign corporations?

+
+ +
+
+
🇨🇦
+

100% Canadian

+

Built in Edmonton, Alberta. Supported by Canadian developers. Hosted on Canadian soil. Subject only to Canadian law.

+
+
+
🔐
+

True Ownership

+

Your data never leaves your control. Export everything anytime. No algorithms, no surveillance, no corporate oversight.

+
+
+
🛡️
+

Privacy First

+

Built to respect privacy from day one. Your supporters' data protected by design, not by policy.

+
+
+ +
+

The Sovereignty Difference

+
+
+

US Corporate Platforms

+
    +
  • ❌ Subject to Patriot Act
  • +
  • ❌ NSA surveillance
  • +
  • ❌ Corporate data mining
  • +
  • ❌ Foreign jurisdiction
  • +
+
+
+

Changemaker Lite

+
    +
  • ✅ Canadian sovereignty
  • +
  • ✅ Zero surveillance
  • +
  • ✅ Complete privacy
  • +
  • ✅ Your servers, your rules
  • +
+
+
+
+
+
+ + +
+
+
+

Simple, Transparent Pricing

+

No hidden fees. No usage limits. No surprises.

+
+ +
+
+
+

Self-Hosted

+
$0
+
forever
+
+
    +
  • ✓ All 11 campaign tools
  • +
  • ✓ Unlimited users
  • +
  • ✓ Unlimited data
  • +
  • ✓ Complete documentation
  • +
  • ✓ Community support
  • +
  • ✓ Your infrastructure
  • +
  • ✓ 100% open source
  • +
+ Installation Guide +

Perfect for tech-savvy campaigns

+
+ + + +
+
+

Managed Hosting

+
Custom
+
monthly
+
+
    +
  • ✓ We handle everything
  • +
  • ✓ Canadian data centers
  • +
  • ✓ Daily backups
  • +
  • ✓ 24/7 monitoring
  • +
  • ✓ Security updates
  • +
  • ✓ Priority support
  • +
  • ✓ Still your data
  • +
+ Contact Sales +

For larger campaigns

+
+
+ +
+

Compare Your Savings

+

Average campaign using corporate tools: $1,200-$4,000/month

+

Same capabilities with Changemaker Lite: $0 (self-hosted)

+ See detailed cost breakdown → +
+
+
+ + +
+
+
+

Everything Works Together

+

One login. One system. Infinite possibilities.

+
+ +
+
+
+ All systems communicate and build on one another +
+
+

+ 🎯 30-minute setup • 🔒 Your data stays yours • 🚀 No monthly fees +

+
+
+
+ + +
+ +
+ + +
+
+

Ready to Power Up Your Campaign?

+

Join hundreds of campaigns using open-source tools to win elections and save money.

+ +

+ 🎯 30-minute setup • 🔒 Your data stays yours • 🚀 No monthly fees +

+
+
+ + + + + + + \ No newline at end of file diff --git a/mkdocs/site/phil/cost-comparison/index.html b/mkdocs/site/phil/cost-comparison/index.html new file mode 100644 index 00000000..06f4f060 --- /dev/null +++ b/mkdocs/site/phil/cost-comparison/index.html @@ -0,0 +1,2358 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cost Comparison - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Cost Comparison: Corporation vs. Community

+

The True Cost of Corporate Dependency

+

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

+

Monthly Cost Analysis

+

Small Campaign (50 supporters, 5,000 emails/month)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Service CategoryCorporate SolutionMonthly CostChangemaker LiteMonthly Cost
Email MarketingMailchimp$59/monthListmonk$0*
Database & CRMAirtable Pro$240/monthNocoDB$0*
Website HostingSquarespace$40/monthStatic Server$0*
DocumentationNotion Team$96/monthMkDocs$0*
DevelopmentGitHub Codespaces$87/monthCode Server$0*
AutomationZapier Professional$73/monthn8n$0*
File StorageGoogle Workspace$72/monthPostgreSQL + Storage$0*
AnalyticsCorporate trackingPrivacy cost†Self-hosted$0*
TOTAL$667/month$50/month
+

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

+
+

Medium Campaign (500 supporters, 50,000 emails/month)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Service CategoryCorporate SolutionMonthly CostChangemaker LiteMonthly Cost
Email MarketingMailchimp$299/monthListmonk$0*
Database & CRMAirtable Pro$600/monthNocoDB$0*
Website HostingSquarespace$65/monthStatic Server$0*
DocumentationNotion Team$240/monthMkDocs$0*
DevelopmentGitHub Codespaces$174/monthCode Server$0*
AutomationZapier Professional$146/monthn8n$0*
File StorageGoogle Workspace$144/monthPostgreSQL + Storage$0*
AnalyticsCorporate trackingPrivacy cost†Self-hosted$0*
TOTAL$1,668/month$75/month
+
+

Large Campaign (5,000 supporters, 500,000 emails/month)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Service CategoryCorporate SolutionMonthly CostChangemaker LiteMonthly Cost
Email MarketingMailchimp$1,499/monthListmonk$0*
Database & CRMAirtable Pro$1,200/monthNocoDB$0*
Website HostingSquarespace + CDN$120/monthStatic Server$0*
DocumentationNotion Team$480/monthMkDocs$0*
DevelopmentGitHub Codespaces$348/monthCode Server$0*
AutomationZapier Professional$292/monthn8n$0*
File StorageGoogle Workspace$288/monthPostgreSQL + Storage$0*
AnalyticsCorporate trackingPrivacy cost†Self-hosted$0*
TOTAL$4,227/month$150/month
+

Annual Savings Breakdown

+

3-Year Cost Comparison

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Campaign SizeCorporate TotalChangemaker TotalSavings
Small$24,012$1,800$22,212
Medium$60,048$2,700$57,348
Large$152,172$5,400$146,772
+

Hidden Costs of Corporate Software

+

What You Can't Put a Price On

+

Privacy Violations

+
    +
  • Data Harvesting: Every interaction monitored and stored
  • +
  • Behavioral Profiling: Your community mapped and analyzed
  • +
  • Third-Party Sales: Your data sold to unknown entities
  • +
  • Government Access: Warrantless surveillance through corporate partnerships
  • +
+

Political Manipulation

+
    +
  • Algorithmic Suppression: Your content reach artificially limited
  • +
  • Narrative Control: Corporate interests shape what your community sees
  • +
  • Shadow Banning: Activists systematically de-platformed
  • +
  • Counter-Intelligence: Your strategies monitored by opposition
  • +
+

Movement Disruption

+
    +
  • Dependency Creation: Critical infrastructure controlled by adversaries
  • +
  • Community Fragmentation: Platforms designed to extract attention, not build power
  • +
  • Organizing Interference: Corporate algorithms prioritize engagement over solidarity
  • +
  • Cultural Assimilation: Movement culture shaped by corporate values
  • +
+

The Changemaker Advantage

+

What You Get for $50-150/month

+

Complete Infrastructure

+
    +
  • Email System: Unlimited contacts, unlimited sends
  • +
  • Database Power: Unlimited records, unlimited complexity
  • +
  • Web Presence: Unlimited sites, unlimited traffic
  • +
  • Development Environment: Full coding environment with AI assistance
  • +
  • Documentation Platform: Beautiful, searchable knowledge base
  • +
  • Automation Engine: Connect everything, automate everything
  • +
  • File Storage: Unlimited files, unlimited backups
  • +
+

True Ownership

+
    +
  • Your Domain: No corporate branding or limitations
  • +
  • Your Data: Complete export capability, no lock-in
  • +
  • Your Rules: No terms of service to violate
  • +
  • Your Community: No algorithmic manipulation
  • +
+

Community Support

+
    +
  • Open Documentation: Complete guides and tutorials available
  • +
  • Community-Driven Development: Built by and for liberation movements
  • +
  • Technical Support: Professional assistance from BNKops cooperative
  • +
  • Political Alignment: Technology designed with movement values
  • +
+

The Compound Effect

+

Year Over Year Savings

+

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

+

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

+

10-Year Projection

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
YearCorporate (Medium Campaign)Changemaker LiteAnnual 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
+

Calculate Your Own Savings

+

Current Corporate Costs Worksheet

+

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

+

Monthly Total: $____
+Annual Total: $____

+

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

+

Beyond the Numbers

+

What Movements Do With Their Savings

+

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

+
    +
  • Hire organizers instead of paying corporate executives
  • +
  • Fund direct actions instead of funding surveillance infrastructure
  • +
  • Support community members instead of enriching shareholders
  • +
  • Build lasting power instead of temporary platform dependency
  • +
+

Making the Switch

+

Transition Strategy

+

You don't have to switch everything at once:

+
    +
  1. Start with documentation - Move your knowledge base to MkDocs
  2. +
  3. Add email infrastructure - Set up Listmonk for newsletters
  4. +
  5. Build your database - Move contact management to NocoDB
  6. +
  7. Automate connections - Use n8n to integrate everything
  8. +
  9. Phase out corporate tools - Cancel subscriptions as you replicate functionality
  10. +
+

Investment Timeline

+
    +
  • Month 1: Initial setup and learning ($150 including setup time)
  • +
  • Month 2-3: Data migration and team training ($100/month)
  • +
  • Month 4+: Full operation at optimal cost ($50-150/month based on scale)
  • +
+

ROI Calculation

+

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

+
+

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

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/phil/index.html b/mkdocs/site/phil/index.html new file mode 100644 index 00000000..a29f4a49 --- /dev/null +++ b/mkdocs/site/phil/index.html @@ -0,0 +1,1615 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Philosophy: Your Secrets, Your Power, Your Movement - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Philosophy: Your Secrets, Your Power, Your Movement

+

The Question That Changes Everything!

+

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

+

This isn't just a technical question—it'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?

+

The Corporate Extraction Machine

+

How They Hook You

+

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

+
    +
  1. Free Trials - They lure you in with "free" accounts
  2. +
  3. Feature Creep - Essential features require paid tiers
  4. +
  5. Data Lock-In - Your data becomes harder to export
  6. +
  7. Price Escalation - $40/month becomes $750/month as you grow
  8. +
  9. Surveillance Integration - Your organizing becomes their intelligence
  10. +
+

The Real Product

+
+

You Are Not the Customer

+

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

+
+

Corporate platforms don't make money from your subscription fees—they make money from:

+
    +
  • Data Sales to third parties
  • +
  • Algorithmic Manipulation for corporate and political interests
  • +
  • Surveillance Contracts with governments and corporations
  • +
  • Predictive Analytics about your community and movement
  • +
+

The BNKops Alternative

+

Who We Are

+

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

+

Our Principles

+

🏳️‍⚧️ 🏳️‍🌈 🇵🇸 Liberation First

+

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?

+

🤝 Community Over Profit

+

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

+

⚡ Data Sovereignty

+

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

+

🔒 Security Culture

+

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

+

Why This Matters

+

When you control your technology infrastructure:

+
    +
  • Your secrets stay secret - No corporate access to sensitive organizing data
  • +
  • Your community stays connected - No algorithmic manipulation of your reach
  • +
  • Your costs stay low - No extraction-based pricing as you grow
  • +
  • Your future stays yours - No vendor lock-in or platform dependency
  • +
+

The Philosophy in Practice

+

Security Culture Meets Technology

+

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

+

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

+

Community Technology

+

We believe in community technology - tools that:

+
    +
  • Are owned and controlled by the communities that use them
  • +
  • Are designed with liberation politics from the ground up using free and open source software
  • +
  • Prioritize care, consent, and collective power
  • +
  • Can be understood, modified, and improved by community members
  • +
+

Prefigurative Politics

+

The tools we use shape the movements we build. Corporate tools create corporate movements—hierarchical, surveilled, and dependent. Community-controlled tools create community-controlled movements—democratic, secure, and sovereign.

+

Common Questions

+

"Isn't this just for tech people?"

+

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

+

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

+

"What about convenience?"

+

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—but it's the same learning curve as learning to organize, learning to build power, learning to create change.

+

"Can't we just use corporate tools carefully?"

+

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.

+

"What about security?"

+

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

+
    +
  • You decide what gets logged and what doesn't
  • +
  • You choose who has access and who doesn't
  • +
  • You know exactly where your data is and who can see it
  • +
  • You can't be de-platformed or locked out of your own data
  • +
+

The Surveillance Capitalism Trap

+

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:

+
    +
  • Political data predicts behavior
  • +
  • Movement intelligence can be used to counter-organize
  • +
  • Community networks can be mapped and disrupted
  • +
  • Organizing strategies can be monitored and neutralized
  • +
+

Taking Action

+

Start Where You Are

+

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

+

Build Community Capacity

+

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

+

Connect with Others

+

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.

+

Remember Why

+

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

+
+

Resources for Deeper Learning

+

Essential Reading

+ +

Community Resources

+ +

Technical Learning

+ +
+

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

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/search/search_index.json b/mkdocs/site/search/search_index.json new file mode 100644 index 00000000..6cdc4cb7 --- /dev/null +++ b/mkdocs/site/search/search_index.json @@ -0,0 +1 @@ +{"config":{"lang":["en"],"separator":"[\\s\\u200b\\-_,:!=\\[\\]()\"`/]+|\\.(?!\\d)|&[lg]t;|(?!\\b)(?=[A-Z][a-z])","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"","title":"Welcome to Changemaker Lite","text":"

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

Changemaker Lite V2 is Now Available

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

\u2192 Explore V2 Documentation \u2192 Quick Start Guide

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

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

"},{"location":"#modern-architecture","title":"\u2728 Modern Architecture","text":"
  • Dual API Design: Express.js (main features) + Fastify (media library)
  • TypeScript Throughout: Type-safe development with better IDE support
  • Prisma + Drizzle ORM: Direct database access (no NocoDB middleware)
  • React Admin: Modern UI with Vite + Ant Design + Zustand
"},{"location":"#comprehensive-features","title":"\ud83d\ude80 Comprehensive Features","text":"
  • Influence Module: Email advocacy campaigns targeting elected representatives
  • Map Module: Geographic mapping with GPS-tracked canvassing
  • Landing Pages: GrapesJS page builder with MkDocs export
  • Email Templates: Template management system
  • Media Library: Video management with public gallery
  • Newsletter Integration: Listmonk sync for email marketing
  • Observability: Prometheus + Grafana monitoring stack
"},{"location":"#production-ready","title":"\ud83d\udd12 Production Ready","text":"
  • Security Audited: 13 findings addressed (Feb 2026)
  • JWT Authentication: Role-based access control with refresh tokens
  • Password Policy: Enforced complexity requirements
  • Rate Limiting: Per-endpoint protection
  • Monitoring: 12 custom metrics + 3 Grafana dashboards
"},{"location":"#complete-documentation","title":"\ud83d\udcda Complete Documentation","text":"
  • Getting Started - Installation and setup
  • Architecture - System design and components
  • Features - Module documentation
  • API Reference - Complete endpoint docs
  • User Guides - Role-based manuals

Explore V2 Documentation \u2192

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

V1 is Deprecated

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

\u2192 View Migration Guide

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

Get V1 running in minutes:

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

Changemaker Lite includes these essential services:

"},{"location":"#core-services","title":"Core Services","text":"
  • Homepage (Port 3010) - Central dashboard and service monitoring
  • Code Server (Port 8888) - VS Code in your browser
  • MkDocs (Port 4000) - Documentation with live preview
  • Static Server (Port 4001) - Production documentation site
"},{"location":"#communication-automation","title":"Communication & Automation","text":"
  • Listmonk (Port 9000) - Newsletter and email campaign management
  • n8n (Port 5678) - Workflow automation platform
"},{"location":"#data-development","title":"Data & Development","text":"
  • NocoDB (Port 8090) - No-code database platform
  • PostgreSQL (Port 5432) - Database backend for Listmonk
  • Gitea (Port 3030) - Self-hosted Git service
"},{"location":"#interactive-tools","title":"Interactive Tools","text":"
  • Map Viewer (Port 3000) - Interactive map with NocoDB integration
  • Mini QR (Port 8089) - QR code generator
"},{"location":"#getting-started-v1","title":"Getting Started (V1)","text":"
  1. Setup: Run ./config.sh to configure your environment
  2. Launch: Start services with docker compose up -d
  3. Dashboard: Access the Homepage at http://localhost:3010
  4. Production: Deploy with Cloudflare tunnels using ./start-production.sh
"},{"location":"#project-structure","title":"Project Structure","text":"
changemaker.lite/\n\u251c\u2500\u2500 docker-compose.yml    # Service definitions\n\u251c\u2500\u2500 config.sh            # Configuration wizard\n\u251c\u2500\u2500 start-production.sh  # Production deployment script\n\u251c\u2500\u2500 mkdocs/              # Documentation source\n\u2502   \u251c\u2500\u2500 docs/            # Markdown files\n\u2502   \u2514\u2500\u2500 mkdocs.yml       # MkDocs configuration\n\u251c\u2500\u2500 configs/             # Service configurations\n\u2502   \u251c\u2500\u2500 homepage/        # Homepage dashboard config\n\u2502   \u251c\u2500\u2500 code-server/     # VS Code settings\n\u2502   \u2514\u2500\u2500 cloudflare/      # Tunnel configurations\n\u251c\u2500\u2500 map/                 # Map application\n\u2502   \u251c\u2500\u2500 app/             # Node.js application\n\u2502   \u251c\u2500\u2500 Dockerfile       # Container definition\n\u2502   \u2514\u2500\u2500 .env             # Map configuration\n\u2514\u2500\u2500 assets/              # Shared assets\n    \u251c\u2500\u2500 images/          # Image files\n    \u251c\u2500\u2500 icons/           # Service icons\n    \u2514\u2500\u2500 uploads/         # Listmonk uploads\n
"},{"location":"#key-features","title":"Key Features","text":"
  • \ud83d\udc33 Fully Containerized - All services run in Docker containers
  • \ud83d\udd12 Production Ready - Built-in Cloudflare tunnel support for secure access
  • \ud83d\udce6 All-in-One - Everything you need for documentation, development, and campaigns
  • \ud83d\uddfa\ufe0f Geographic Data - Interactive maps with real-time location tracking
  • \ud83d\udce7 Email Campaigns - Professional newsletter management
  • \ud83d\udd04 Automation - Connect services and automate workflows
  • \ud83d\udcbe Version Control - Self-hosted Git repository
  • \ud83c\udfaf No-Code Database - Build applications without programming
"},{"location":"#system-requirements","title":"System Requirements","text":"
  • OS: Ubuntu 24.04 LTS (Noble Numbat) or compatible Linux distribution
  • Docker: Version 24.0+ with Docker Compose v2
  • Memory: Minimum 4GB RAM (8GB recommended)
  • Storage: 20GB+ available disk space
  • Network: Internet connection for initial setup
"},{"location":"#learn-more-v1","title":"Learn More (V1)","text":"
  • Getting Started - Detailed installation guide
  • Services Overview - Deep dive into each service
  • Blog - Updates and tutorials
  • GitHub Repository - Source code
"},{"location":"blog/2025/07/03/blog-1/","title":"Blog 1","text":"

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

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

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

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

Other updates recently include:

  • Fully reworked backend server.js into modular components.
  • Bunch of mobile related fixes and improvements.
  • Bi-directional saving of configs fixed up
  • Some style upgrades

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

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

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

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

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

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

  • Admin Panel & NocoDB Integration: Major updates to the admin section, including a new NocoDB admin area, improved database search, and code cleanups.
  • Website & UI Updates: Numerous updates to the website, including language tweaks, mobile friendliness, and new frontend features.
  • Shifts Management: Comprehensive volunteer shift management system added, with calendar/grid views, admin controls, and real-time updates.
  • Authentication & User Management: Enhanced login system, password recovery via SMTP, user management panel for admins, and role-based access control.
  • Map & Geocoding: Improved map display, apartment views, geocoding integration, and address confirmation system.
  • Unified Search System: Powerful search bar (Ctrl+K) for docs and address search, with real-time results, caching, and QR code generation.
  • Data Import & Conversion: CSV data import with batch geocoding and visual progress, plus a new data converter tool.
  • Email & Notifications: SMTP integration for email notifications and password recovery.
  • Performance & Bug Fixes: Numerous bug fixes, code cleanups, and performance improvements across the stack.
  • Docker & Deployment: Docker containerization, improved build scripts, and easier multi-instance deployment.
  • Documentation: Expanded and updated documentation, including new manuals and guides.

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

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

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

  • Custom Theming: Light/dark mode toggle with persistent user preference.
  • Sticky Header & Navigation: Fixed header with smooth scroll and navigation links.
  • Hero Section: Prominent introduction with call-to-action buttons.
  • Search Integration: Inline MkDocs search with real-time results and keyboard shortcuts.
  • Feature Showcases: Sections for problems, solutions, power tools, data ownership, pricing, integrations, testimonials, and live examples.
  • Responsive Design: Mobile-friendly layout with adaptive grids and cards.
  • Animations: Intersection observer for fade-in effects on cards and sections.
  • Video & Media: Embedded video showcase and rich media support.
  • Footer: Informative footer with links and contact info.

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

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

The map application has received significant upgrades:

  • Interactive Map: Real-time visualization with OpenStreetMap and Leaflet.js.
  • Unified Search: Docs and address search in one bar, with keyboard shortcuts and smart caching.
  • Geolocation & Add Locations: Real-time user geolocation and ability to add new locations directly from the map.
  • Auto-Refresh: Map data auto-refreshes every 30 seconds.
  • Responsive & Mobile Ready: Fully responsive design for all devices.
  • Secure API Proxy: Protects credentials and secures API access.
  • Admin Panel: System configuration, user management, and shift management for admins.
  • Walk Sheet Generator: For door-to-door canvassing, with customizable titles and QR code integration.
  • Volunteer Shifts: Calendar/grid views, signup/cancellation, admin shift creation, and real-time updates.
  • Role-Based Access: Admin vs. user permissions throughout the app.
  • Email Notifications: SMTP-based notifications and password recovery.
  • CSV Import & Geocoding: Batch import with geocoding and progress tracking.
  • Dockerized Deployment: Easy setup and scaling with Docker.
  • Open Source: 100% open source, no proprietary dependencies.

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

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

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

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

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

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

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

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

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

"},{"location":"blog/2025/09/24/4/#influence-app-major-launch","title":"Influence App - Major Launch","text":"
  • Complete UI Overhaul: Built an entirely new user interface and user system from the ground up
  • 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
  • Campaign Management: Created new system for creating campaigns from the main site dashboard with campaign cover photos and phone numbers
  • Social Features: Added social share buttons and site info improvements
  • Geocoding Enhancements: Implemented automatic scanning of NocoDB locations to build geo-locations, plus premium Mapbox option for better street address matching
  • User Management: Built password updater for users/admins and improved overall user management
  • Network Integration: Integrated Influence into the Changemaker network
  • Monitoring & Maintenance: Added health check utility, logger, metrics, backup, and SMTP toggle scripts
"},{"location":"blog/2025/09/24/4/#map-app-production-ready","title":"Map App - Production Ready","text":"
  • Map Cuts Feature: Built a comprehensive \"cuts\" system for dividing territories, including assignment workflows, print views, and spatial data handling
  • Public Shifts: Implemented new public shifts system for volunteer coordination
  • Performance: Optimized loading for maps with 1000+ locations and improved shift loading speeds
  • Admin Improvements: Major refactor of admin.js into readable, maintainable files, plus new NocoDB admin section with database search
  • Temp Users: Enhanced temporary user system with proper access controls and limited data sending
  • Data Tools: Added CSV import reporting and ListMonk synchronization
  • UI/UX: Standardized z-indexes, updated pop-ups, fixed menu bugs, and improved cut overlays
  • CORS & Auth: Fixed authentication, lockouts, and CORS for local dev access
"},{"location":"blog/2025/09/24/4/#infrastructure-devops","title":"Infrastructure & DevOps","text":"
  • Documentation: Updated MkDocs documentation with search functionality
  • Build System: Improved build-nocodb script to migrate data and auto-input URLs to .env
  • Docker: Cleaned up docker-compose configuration and fixed container duplication issues
  • Configuration: Updated homepage configs, Cloudflare tunnel settings, and general system configs

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

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

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

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

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

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

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

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

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

You Are Not the Customer

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

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

  • Data Sales to third parties
  • Algorithmic Manipulation for corporate and political interests
  • Surveillance Contracts with governments and corporations
  • Predictive Analytics about your community and movement
"},{"location":"phil/#the-bnkops-alternative","title":"The BNKops Alternative","text":""},{"location":"phil/#who-we-are","title":"Who We Are","text":"

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

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

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

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

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

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

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

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

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

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

When you control your technology infrastructure:

  • Your secrets stay secret - No corporate access to sensitive organizing data
  • Your community stays connected - No algorithmic manipulation of your reach
  • Your costs stay low - No extraction-based pricing as you grow
  • Your future stays yours - No vendor lock-in or platform dependency
"},{"location":"phil/#the-philosophy-in-practice","title":"The Philosophy in Practice","text":""},{"location":"phil/#security-culture-meets-technology","title":"Security Culture Meets Technology","text":"

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

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

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

We believe in community technology - tools that:

  • Are owned and controlled by the communities that use them
  • Are designed with liberation politics from the ground up using free and open source software
  • Prioritize care, consent, and collective power
  • Can be understood, modified, and improved by community members
"},{"location":"phil/#prefigurative-politics","title":"Prefigurative Politics","text":"

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

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

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

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

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

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

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

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

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

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

  • You decide what gets logged and what doesn't
  • You choose who has access and who doesn't
  • You know exactly where your data is and who can see it
  • You can't be de-platformed or locked out of your own data
"},{"location":"phil/#the-surveillance-capitalism-trap","title":"The Surveillance Capitalism Trap","text":"

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

  • Political data predicts behavior
  • Movement intelligence can be used to counter-organize
  • Community networks can be mapped and disrupted
  • Organizing strategies can be monitored and neutralized
"},{"location":"phil/#taking-action","title":"Taking Action","text":""},{"location":"phil/#start-where-you-are","title":"Start Where You Are","text":"

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

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

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

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

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

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

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

"},{"location":"phil/#resources-for-deeper-learning","title":"Resources for Deeper Learning","text":""},{"location":"phil/#essential-reading","title":"Essential Reading","text":"
  • De-corp Your Software Stack - Our full manifesto
  • The Age of Surveillance Capitalism by Shoshana Zuboff
  • Security Culture Handbook
"},{"location":"phil/#community-resources","title":"Community Resources","text":"
  • BNKops Repository - Documentation and knowledge base
  • Activist Handbook - Movement building resources
  • EFF Surveillance Self-Defense - Digital security guides
"},{"location":"phil/#technical-learning","title":"Technical Learning","text":"
  • Self-Hosted Awesome List - Open source alternatives
  • Linux Journey - Learn Linux basics
  • Docker Curriculum - Learn containerization

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

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

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

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

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

"},{"location":"phil/cost-comparison/#medium-campaign-500-supporters-50000-emailsmonth","title":"Medium Campaign (500 supporters, 50,000 emails/month)","text":"Service Category Corporate Solution Monthly Cost Changemaker Lite Monthly Cost Email Marketing Mailchimp $299/month Listmonk $0* Database & CRM Airtable Pro $600/month NocoDB $0* Website Hosting Squarespace $65/month Static Server $0* Documentation Notion Team $240/month MkDocs $0* Development GitHub Codespaces $174/month Code Server $0* Automation Zapier Professional $146/month n8n $0* File Storage Google Workspace $144/month PostgreSQL + Storage $0* Analytics Corporate tracking Privacy cost\u2020 Self-hosted $0* TOTAL $1,668/month $75/month"},{"location":"phil/cost-comparison/#large-campaign-5000-supporters-500000-emailsmonth","title":"Large Campaign (5,000 supporters, 500,000 emails/month)","text":"Service Category Corporate Solution Monthly Cost Changemaker Lite Monthly Cost Email Marketing Mailchimp $1,499/month Listmonk $0* Database & CRM Airtable Pro $1,200/month NocoDB $0* Website Hosting Squarespace + CDN $120/month Static Server $0* Documentation Notion Team $480/month MkDocs $0* Development GitHub Codespaces $348/month Code Server $0* Automation Zapier Professional $292/month n8n $0* File Storage Google Workspace $288/month PostgreSQL + Storage $0* Analytics Corporate tracking Privacy cost\u2020 Self-hosted $0* TOTAL $4,227/month $150/month"},{"location":"phil/cost-comparison/#annual-savings-breakdown","title":"Annual Savings Breakdown","text":""},{"location":"phil/cost-comparison/#3-year-cost-comparison","title":"3-Year Cost Comparison","text":"Campaign Size Corporate Total Changemaker Total Savings Small $24,012 $1,800 $22,212 Medium $60,048 $2,700 $57,348 Large $152,172 $5,400 $146,772"},{"location":"phil/cost-comparison/#hidden-costs-of-corporate-software","title":"Hidden Costs of Corporate Software","text":""},{"location":"phil/cost-comparison/#what-you-cant-put-a-price-on","title":"What You Can't Put a Price On","text":""},{"location":"phil/cost-comparison/#privacy-violations","title":"Privacy Violations","text":"
  • Data Harvesting: Every interaction monitored and stored
  • Behavioral Profiling: Your community mapped and analyzed
  • Third-Party Sales: Your data sold to unknown entities
  • Government Access: Warrantless surveillance through corporate partnerships
"},{"location":"phil/cost-comparison/#political-manipulation","title":"Political Manipulation","text":"
  • Algorithmic Suppression: Your content reach artificially limited
  • Narrative Control: Corporate interests shape what your community sees
  • Shadow Banning: Activists systematically de-platformed
  • Counter-Intelligence: Your strategies monitored by opposition
"},{"location":"phil/cost-comparison/#movement-disruption","title":"Movement Disruption","text":"
  • Dependency Creation: Critical infrastructure controlled by adversaries
  • Community Fragmentation: Platforms designed to extract attention, not build power
  • Organizing Interference: Corporate algorithms prioritize engagement over solidarity
  • Cultural Assimilation: Movement culture shaped by corporate values
"},{"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":"
  • Email System: Unlimited contacts, unlimited sends
  • Database Power: Unlimited records, unlimited complexity
  • Web Presence: Unlimited sites, unlimited traffic
  • Development Environment: Full coding environment with AI assistance
  • Documentation Platform: Beautiful, searchable knowledge base
  • Automation Engine: Connect everything, automate everything
  • File Storage: Unlimited files, unlimited backups
"},{"location":"phil/cost-comparison/#true-ownership","title":"True Ownership","text":"
  • Your Domain: No corporate branding or limitations
  • Your Data: Complete export capability, no lock-in
  • Your Rules: No terms of service to violate
  • Your Community: No algorithmic manipulation
"},{"location":"phil/cost-comparison/#community-support","title":"Community Support","text":"
  • Open Documentation: Complete guides and tutorials available
  • Community-Driven Development: Built by and for liberation movements
  • Technical Support: Professional assistance from BNKops cooperative
  • Political Alignment: Technology designed with movement values
"},{"location":"phil/cost-comparison/#the-compound-effect","title":"The Compound Effect","text":""},{"location":"phil/cost-comparison/#year-over-year-savings","title":"Year Over Year Savings","text":"

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

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

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

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

Monthly Total: $____ Annual Total: $____

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

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

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

  • Hire organizers instead of paying corporate executives
  • Fund direct actions instead of funding surveillance infrastructure
  • Support community members instead of enriching shareholders
  • Build lasting power instead of temporary platform dependency
"},{"location":"phil/cost-comparison/#making-the-switch","title":"Making the Switch","text":""},{"location":"phil/cost-comparison/#transition-strategy","title":"Transition Strategy","text":"

You don't have to switch everything at once:

  1. Start with documentation - Move your knowledge base to MkDocs
  2. Add email infrastructure - Set up Listmonk for newsletters
  3. Build your database - Move contact management to NocoDB
  4. Automate connections - Use n8n to integrate everything
  5. Phase out corporate tools - Cancel subscriptions as you replicate functionality
"},{"location":"phil/cost-comparison/#investment-timeline","title":"Investment Timeline","text":"
  • Month 1: Initial setup and learning ($150 including setup time)
  • Month 2-3: Data migration and team training ($100/month)
  • Month 4+: Full operation at optimal cost ($50-150/month based on scale)
"},{"location":"phil/cost-comparison/#roi-calculation","title":"ROI Calculation","text":"

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

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

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

V1 is Legacy

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

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

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

A quick test because why not.

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

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

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

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

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

View complete V2 documentation \u2192

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

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

\u2192 V1 to V2 Migration Guide

The migration guide covers:

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

These docs are preserved for existing V1 installations:

"},{"location":"v1/#build-guides","title":"Build Guides","text":"
  • Build Overview
  • Build Server
  • Build Map
  • Build Influence
  • Build Site
"},{"location":"v1/#services","title":"Services","text":"
  • Services Overview
  • Homepage
  • Code Server
  • MKDocs
  • Listmonk
  • PostgreSQL
  • n8n
  • NocoDB
  • Gitea
  • Map
  • Mini QR
"},{"location":"v1/#configuration","title":"Configuration","text":"
  • Config Overview
  • Cloudflare
  • MKdocs
  • Code Server
  • Map
"},{"location":"v1/#manuals","title":"Manuals","text":"
  • Manual Overview
  • Map Manual
"},{"location":"v1/#advanced","title":"Advanced","text":"
  • Advanced Overview
  • SSH + Tailscale + Ansible
  • SSH + VScode
"},{"location":"v1/#v1-architecture-reference","title":"V1 Architecture (Reference)","text":"

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

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

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

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

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

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

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

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

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

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

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

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

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

"},{"location":"v1/adv/ansible/#what-youll-learn","title":"What You'll Learn","text":"
  • How to set up Ansible for infrastructure automation
  • How to configure secure remote access using Tailscale
  • How to troubleshoot common SSH and networking issues
  • Why this approach is better than alternatives like Cloudflare Tunnels for simple SSH access
"},{"location":"v1/adv/ansible/#prerequisites","title":"Prerequisites","text":"
  • Master Node: Your main computer running Ubuntu/Linux (control machine)
  • Target Nodes: Remote servers/ThinkCentres running Ubuntu/Linux
  • Both machines: Must have internet access
  • User Account: Same username on all machines (recommended)
"},{"location":"v1/adv/ansible/#part-1-initial-setup-on-master-node","title":"Part 1: Initial Setup on Master Node","text":""},{"location":"v1/adv/ansible/#1-create-ansible-directory-structure","title":"1. Create Ansible Directory Structure","text":"
# Create project directory\nmkdir ~/ansible_quickstart\ncd ~/ansible_quickstart\n\n# Create directory structure\nmkdir -p group_vars host_vars roles playbooks\n
"},{"location":"v1/adv/ansible/#2-install-ansible","title":"2. Install Ansible","text":"
sudo apt update\nsudo apt install ansible\n
"},{"location":"v1/adv/ansible/#3-generate-ssh-keys-if-not-already-done","title":"3. Generate SSH Keys (if not already done)","text":"
# Generate SSH key pair\nssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa\n\n# Display public key (save this for later)\ncat ~/.ssh/id_rsa.pub\n
"},{"location":"v1/adv/ansible/#part-2-target-node-setup-physical-access-required-initially","title":"Part 2: Target Node Setup (Physical Access Required Initially)","text":""},{"location":"v1/adv/ansible/#1-enable-ssh-on-target-node","title":"1. Enable SSH on Target Node","text":"

Access each target node physically (monitor + keyboard):

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

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

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

You should see SSH listening on port 22.

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

Paste your public key from the master node, then:

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

Ensure these lines are uncommented:

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

Before proceeding with remote access, test SSH connectivity locally:

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

Common Issues and Solutions:

  • Connection hangs: Check firewall rules (sudo ufw allow ssh)
  • Permission denied: Verify SSH keys and file permissions
  • SSH config errors: Ensure PubkeyAuthentication yes is set
"},{"location":"v1/adv/ansible/#part-4-set-up-tailscale-for-remote-access","title":"Part 4: Set Up Tailscale for Remote Access","text":""},{"location":"v1/adv/ansible/#why-tailscale-over-alternatives","title":"Why Tailscale Over Alternatives","text":"

We initially tried Cloudflare Tunnels but encountered complexity with:

  • DNS routing issues
  • Complex configuration for SSH
  • Same-network testing problems
  • Multiple configuration approaches with varying success

Tailscale is superior because:

  • Zero configuration mesh networking
  • Works from any network
  • Persistent IP addresses
  • No port forwarding needed
  • Free for personal use
"},{"location":"v1/adv/ansible/#1-install-tailscale-on-master-node","title":"1. Install Tailscale on Master Node","text":"
# Install Tailscale\ncurl -fsSL https://tailscale.com/install.sh | sh\n\n# Connect to Tailscale network\nsudo tailscale up\n

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

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

On each target node:

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

Authenticate each device through the provided URL.

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

On each machine:

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

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

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

Content:

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

Replace:

  • 100.x.x.x with actual Tailscale IPs
  • your-username with your actual username
"},{"location":"v1/adv/ansible/#2-test-ansible-connectivity","title":"2. Test Ansible Connectivity","text":"
# Test connection to all nodes\nansible all -i inventory.ini -m ping\n

Expected output:

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

Content:

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

Content:

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

Problem: SSH connection hangs

  • Check firewall: sudo ufw status and sudo ufw allow ssh
  • Verify SSH service: sudo systemctl status ssh
  • Test local connectivity first

Problem: Permission denied (publickey)

  • Check SSH key permissions: chmod 600 ~/.ssh/authorized_keys
  • Verify home directory permissions: chmod 755 ~/
  • Ensure SSH config allows key auth: PubkeyAuthentication yes

Problem: Bad owner or permissions on SSH config

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

Problem: Host key verification failed

  • Add to inventory: ansible_host_key_checking=False

Problem: Ansible command not found

sudo apt install ansible\n

Problem: Connection timeouts

  • Verify Tailscale connectivity: ping <tailscale-ip>
  • Check if both nodes are connected: tailscale status
"},{"location":"v1/adv/ansible/#tailscale-issues","title":"Tailscale Issues","text":"

Problem: Can't connect to Tailscale IP

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

Run playbooks on specific groups:

ansible-playbook -i inventory.ini -l webservers playbook.yml\n
"},{"location":"v1/adv/ansible/#best-practices","title":"Best Practices","text":""},{"location":"v1/adv/ansible/#security","title":"Security","text":"
  • Use SSH keys, not passwords
  • Keep Tailscale client updated
  • Regular security updates via Ansible
  • Use become: yes only when necessary
"},{"location":"v1/adv/ansible/#organization","title":"Organization","text":"
ansible_quickstart/\n\u251c\u2500\u2500 inventory.ini\n\u251c\u2500\u2500 group_vars/\n\u251c\u2500\u2500 host_vars/\n\u251c\u2500\u2500 roles/\n\u2514\u2500\u2500 playbooks/\n    \u251c\u2500\u2500 info-playbook.yml\n    \u251c\u2500\u2500 setup-node.yml\n    \u2514\u2500\u2500 maintenance.yml\n
"},{"location":"v1/adv/ansible/#monitoring-and-maintenance","title":"Monitoring and Maintenance","text":"

Create regular maintenance playbooks:

- name: System maintenance\n  hosts: all\n  become: yes\n  tasks:\n    - name: Update all packages\n      apt:\n        upgrade: dist\n        update_cache: yes\n\n    - name: Clean package cache\n      apt:\n        autoclean: yes\n        autoremove: yes\n
"},{"location":"v1/adv/ansible/#alternative-approaches-we-considered","title":"Alternative Approaches We Considered","text":""},{"location":"v1/adv/ansible/#cloudflare-tunnels","title":"Cloudflare Tunnels","text":"
  • Pros: Good for web services, handles NAT traversal
  • Cons: Complex SSH setup, DNS routing issues, same-network problems
  • Use case: Better for web applications than SSH access
"},{"location":"v1/adv/ansible/#traditional-vpn","title":"Traditional VPN","text":"
  • Pros: Full network access
  • Cons: Complex setup, port forwarding required, router configuration
  • Use case: When you control the network infrastructure
"},{"location":"v1/adv/ansible/#ssh-reverse-tunnels","title":"SSH Reverse Tunnels","text":"
  • Pros: Simple concept
  • Cons: Requires VPS, single point of failure, manual setup
  • Use case: Temporary access or when other methods fail
"},{"location":"v1/adv/ansible/#conclusion","title":"Conclusion","text":"

This setup provides:

  • Reliable remote access from anywhere
  • Secure mesh networking with Tailscale
  • Infrastructure automation with Ansible
  • Easy scaling to multiple nodes
  • No complex networking required

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

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

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

"},{"location":"v1/adv/vscode-ssh/#what-youll-learn","title":"What You'll Learn","text":"
  • How to configure VSCode for remote SSH connections
  • How to set up remote development environments
  • How to manage multiple remote servers efficiently
  • How to troubleshoot common remote development issues
  • Best practices for remote development workflows
"},{"location":"v1/adv/vscode-ssh/#prerequisites","title":"Prerequisites","text":"
  • Ansible + Tailscale setup completed (see previous guide)
  • VSCode installed on the local machine (master node)
  • Working SSH access to remote servers via Tailscale
  • Tailscale running on both local and remote machines
"},{"location":"v1/adv/vscode-ssh/#verify-prerequisites","title":"Verify Prerequisites","text":"

Before starting, verify the setup:

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

Option A: Install Remote Development Pack (Recommended)

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

This pack includes:

  • Remote - SSH
  • Remote - SSH: Editing Configuration Files
  • Remote - Containers
  • Remote - WSL (Windows only)

Option B: Install Individual Extension

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

After installation, the following should be visible:

  • Remote Explorer icon in the Activity Bar (left sidebar)
  • \"Remote-SSH\" commands in Command Palette (Ctrl+Shift+P)
"},{"location":"v1/adv/vscode-ssh/#part-2-configure-ssh-connections","title":"Part 2: Configure SSH Connections","text":""},{"location":"v1/adv/vscode-ssh/#1-access-ssh-configuration","title":"1. Access SSH Configuration","text":"

Method A: Through VSCode

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

Method B: Direct File Editing

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

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

Add servers to the SSH config file:

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

Configuration Options Explained:

  • Host: Friendly name for the connection
  • HostName: Tailscale IP address
  • User: Username on the remote server
  • IdentityFile: Path to the SSH private key
  • ForwardAgent: Enables SSH agent forwarding for Git operations
  • ServerAliveInterval: Keeps connection alive (prevents timeouts)
  • ServerAliveCountMax: Number of keepalive attempts
"},{"location":"v1/adv/vscode-ssh/#3-set-proper-ssh-key-permissions","title":"3. Set Proper SSH Key Permissions","text":"
# Ensure SSH config has correct permissions\nchmod 600 ~/.ssh/config\n\n# Verify SSH key permissions\nchmod 600 ~/.ssh/id_rsa\nchmod 644 ~/.ssh/id_rsa.pub\n
"},{"location":"v1/adv/vscode-ssh/#part-3-connect-to-remote-servers","title":"Part 3: Connect to Remote Servers","text":""},{"location":"v1/adv/vscode-ssh/#1-connect-via-command-palette","title":"1. Connect via Command Palette","text":"
  1. Press Ctrl+Shift+P
  2. Type \"Remote-SSH: Connect to Host...\"
  3. Select the server (e.g., node1)
  4. VSCode will open a new window connected to the remote server
"},{"location":"v1/adv/vscode-ssh/#2-connect-via-remote-explorer","title":"2. Connect via Remote Explorer","text":"
  1. Click the Remote Explorer icon in Activity Bar
  2. Expand SSH Targets
  3. Click the connect icon next to the server name
"},{"location":"v1/adv/vscode-ssh/#3-connect-via-quick-menu","title":"3. Connect via Quick Menu","text":"
  1. Click the remote indicator in bottom-left corner (looks like ><)
  2. Select \"Connect to Host...\"
  3. Choose the server from the list
"},{"location":"v1/adv/vscode-ssh/#4-first-connection-process","title":"4. First Connection Process","text":"

On first connection, VSCode will:

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

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

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

Once connected:

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

Extensions must be installed separately on the remote server:

Essential Development Extensions:

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

To Install:

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

File Explorer:

  • Shows remote server's file system
  • Create, edit, delete files directly
  • Drag and drop between local and remote (limited)

File Transfer:

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

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

Integrated Terminal:

  • Press Ctrl+` to open terminal
  • Runs directly on remote server
  • Multiple terminals supported
  • Full shell access (bash, zsh, etc.)

Common Remote Terminal Commands:

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

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

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

Manual Port Forwarding:

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

Example: Web Development

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

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

Python Debugging:

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

Node.js Debugging:

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

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

Create remote-specific settings:

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

Switch Between Servers:

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

Compare Files Across Servers:

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

Settings Sync:

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

VSCode Python Configuration:

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

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

VSCode Docker Integration:

  • Install Docker extension on remote
  • Right-click Dockerfile \u2192 \"Build Image\"
  • Manage containers from VSCode interface
"},{"location":"v1/adv/vscode-ssh/#part-8-troubleshooting-guide","title":"Part 8: Troubleshooting Guide","text":""},{"location":"v1/adv/vscode-ssh/#common-connection-issues","title":"Common Connection Issues","text":"

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

Solutions:

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

Problem: \"Permission denied (publickey)\"

Solutions:

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

Problem: \"Host key verification failed\"

Solutions:

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

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

Problem: Extensions not working on remote

Solutions:

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

Problem: Slow performance

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

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

Problem: Terminal not starting

Solutions:

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

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

Problem: Connection timeouts

Solutions: Add to SSH config:

ServerAliveInterval 60\nServerAliveCountMax 3\nTCPKeepAlive yes\n

Problem: File transfer slow

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

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

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

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

  2. Use remote workspace for large projects

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

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

  2. Environment separation:

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

  3. Backup important work:

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

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

SSH Config for Team:

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

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

This remote development setup provides:

  • Full development environment on remote servers
  • Seamless file access and editing capabilities
  • Integrated debugging and terminal access
  • Port forwarding for web development
  • Extension ecosystem available remotely
  • Secure connections through Tailscale network

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

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

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

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

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

  • Build: Instructions on how to build the cm-lite on your own hardware
  • Services: Overview of all the services that are installed when you install cm-lite
  • Configuration: Information on how to configure all the services that you install in cm-lite
  • Manuals: Manuals on how to use the applications inside cm-lite (with videos!)

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

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

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

Cloudflare Credentials

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

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

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

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

Cloudflare

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

For secure public access, use the production deployment script:

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

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

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

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

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

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

cd map\ndocker compose up -d\n

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

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

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

The Reality

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

"},{"location":"v1/build/#what-youre-getting","title":"What You're Getting","text":"
  • Data Sovereignty: Your data stays on your servers
  • Cost Savings: $50/month instead of $2,000+/month for corporate solutions
  • Community Control: Technology that serves movements, not shareholders
  • Trans Liberation: Tools built with radical politics and care
"},{"location":"v1/build/#what-youre-leaving-behind","title":"What You're Leaving Behind","text":"
  • \u274c Corporate surveillance and data extraction
  • \u274c Escalating subscription fees and vendor lock-in
  • \u274c Algorithmic manipulation of your community
  • \u274c Terms of service that can silence you anytime
"},{"location":"v1/build/#system-requirements","title":"System Requirements","text":""},{"location":"v1/build/#operating-system","title":"Operating System","text":"
  • Ubuntu 24.04 LTS (Noble Numbat) - Recommended and tested

Getting Started on Ubuntu

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

  • Other Linux distributions with systemd support
  • WSL2 on Windows (limited functionality)
  • Mac OS

New to Linux?

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

"},{"location":"v1/build/#hardware-requirements","title":"Hardware Requirements","text":"
  • CPU: 2+ cores (4+ recommended)
  • RAM: 4GB minimum (8GB recommended)
  • Storage: 20GB+ available disk space
  • Network: Stable internet connection

Cloud Hosting

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

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

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

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

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

./config.sh\n

This wizard will:

  • \u2705 Create a .env file with secure defaults
  • \u2705 Scan for available ports to avoid conflicts
  • \u2705 Set up your domain configuration
  • \u2705 Generate secure passwords for databases
  • \u2705 Configure Cloudflare credentials (optional)
  • \u2705 Update all configuration files with your settings
"},{"location":"v1/build/#configuration-options","title":"Configuration Options","text":"

During setup, you'll be prompted for:

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

Launch all services with Docker Compose:

docker compose up -d\n

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

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

Check that all services are running:

docker compose ps\n

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

  • code-server-changemaker
  • listmonk_app
  • listmonk_db
  • mkdocs-changemaker
  • mkdocs-site-server-changemaker
  • n8n-changemaker
  • nocodb
  • root_db
  • homepage-changemaker
  • gitea_changemaker
  • gitea_mysql_changemaker
  • mini-qr
"},{"location":"v1/build/#local-access","title":"Local Access","text":"

Once services are running, access them locally:

"},{"location":"v1/build/#homepage-dashboard","title":"\ud83c\udfe0 Homepage Dashboard","text":"
  • URL: http://localhost:3010
  • Purpose: Central hub for all services
  • Features: Service status, quick links, monitoring
"},{"location":"v1/build/#development-tools","title":"\ud83d\udcbb Development Tools","text":"
  • Code Server: http://localhost:8888 \u2014 VS Code in browser
  • Gitea: http://localhost:3030 \u2014 Git repository management
  • MkDocs Dev: http://localhost:4000 \u2014 Live documentation preview
  • MkDocs Prod: http://localhost:4001 \u2014 Built documentation
"},{"location":"v1/build/#communication","title":"\ud83d\udce7 Communication","text":"
  • Listmonk: http://localhost:9000 \u2014 Email campaigns Login with credentials set during configuration
"},{"location":"v1/build/#automation-data","title":"\ud83d\udd04 Automation & Data","text":"
  • n8n: http://localhost:5678 \u2014 Workflow automation Login with credentials set during configuration
  • NocoDB: http://localhost:8090 \u2014 No-code database
"},{"location":"v1/build/#interactive-tools","title":"\ud83d\udee0\ufe0f Interactive Tools","text":"
  • Mini QR: http://localhost:8089 \u2014 QR code generator
"},{"location":"v1/build/#map_1","title":"Map","text":"

Map

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

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

For secure public access, use the production deployment script:

./start-production.sh\n

This script will:

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

After successful deployment, services will be available at:

Public Services:

  • https://yourdomain.com - Main documentation site
  • https://listmonk.yourdomain.com - Email campaigns
  • https://docs.yourdomain.com - Documentation preview
  • https://n8n.yourdomain.com - Automation platform
  • https://db.yourdomain.com - NocoDB
  • https://git.yourdomain.com - Gitea
  • https://map.yourdomain.com - Map viewer
  • https://qr.yourdomain.com - QR generator

Protected Services (require authentication):

  • https://homepage.yourdomain.com - Dashboard
  • https://code.yourdomain.com - Code Server
"},{"location":"v1/build/#configuration-management","title":"Configuration Management","text":""},{"location":"v1/build/#environment-variables","title":"Environment Variables","text":"

Key settings in .env file:

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

To update configuration:

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

If services fail to start due to port conflicts:

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

Fix permission problems:

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

Debug service issues:

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

Now that your Changemaker Lite instance is running:

  1. Set up Listmonk - Configure SMTP and create your first campaign
  2. Create workflows - Build automations in n8n
  3. Import data - Set up your NocoDB databases
  4. Configure map - Add location data for the map viewer
  5. Write documentation - Start creating content in MkDocs
  6. Set up Git - Initialize repositories in Gitea
"},{"location":"v1/build/#getting-help","title":"Getting Help","text":"
  • Check the Services documentation for detailed guides
  • Review container logs for specific error messages
  • Ensure all prerequisites are properly installed
  • Verify your domain DNS settings for production deployment
"},{"location":"v1/build/influence/","title":"Influence Build Guide","text":"

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

Complete Configuration

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

Email Testing

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

"},{"location":"v1/build/influence/#prerequisites","title":"Prerequisites","text":"
  • Docker and Docker Compose installed
  • NocoDB instance with API access
  • SMTP email configuration (or use MailHog for testing)
  • Domain name (optional but recommended for production)
"},{"location":"v1/build/influence/#quick-build-process","title":"Quick Build Process","text":""},{"location":"v1/build/influence/#1-get-nocodb-api-token","title":"1. Get NocoDB API Token","text":"
  1. Login to your NocoDB instance
  2. Click user icon \u2192 Account Settings \u2192 API Tokens
  3. Create new token with read/write permissions
  4. Copy the token for the next step
"},{"location":"v1/build/influence/#2-configure-environment","title":"2. Configure Environment","text":"

Navigate to the influence directory and create your environment file:

cd influence\ncp example.env .env\n

Edit the .env file with your configuration:

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

For development and testing, use MailHog to catch emails:

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

Run the build script to create required NocoDB tables:

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

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

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

Build the Docker image and start the application:

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

    docker compose ps\n

  2. View logs:

    docker compose logs -f app\n

  3. Access the application:

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

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

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

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

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

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

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

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

When ready to deploy to production:

  1. Update .env with production SMTP settings:

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

  2. Restart the application:

    docker compose restart\n

"},{"location":"v1/build/influence/#key-features","title":"Key Features","text":""},{"location":"v1/build/influence/#representative-lookup","title":"Representative Lookup","text":"
  • Search by Alberta postal code (T prefix)
  • Display federal MPs, provincial MLAs, municipal representatives
  • Smart caching with NocoDB for fast performance
  • Graceful fallback to Represent API when cache unavailable
"},{"location":"v1/build/influence/#campaign-system","title":"Campaign System","text":"
  • Create unlimited advocacy campaigns
  • Upload cover photos for campaign pages
  • Customizable email templates
  • Optional user information collection
  • Toggle email count display for engagement metrics
  • Multi-level government targeting
"},{"location":"v1/build/influence/#email-integration","title":"Email Integration","text":"
  • SMTP email sending with delivery confirmation
  • Mailto link support for browser-based email
  • Comprehensive email logging
  • Rate limiting for API protection
  • Test mode for safe development
"},{"location":"v1/build/influence/#api-endpoints","title":"API Endpoints","text":""},{"location":"v1/build/influence/#public-endpoints","title":"Public Endpoints","text":"
  • GET / - Homepage with representative lookup
  • GET /campaign/:slug - Individual campaign page
  • GET /api/public/campaigns - List active campaigns
  • GET /api/representatives/by-postal/:postalCode - Find representatives
  • POST /api/emails/send - Send campaign email
"},{"location":"v1/build/influence/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"
  • GET /admin.html - Campaign management dashboard
  • GET /email-test.html - Email testing interface
  • POST /api/emails/preview - Preview email without sending
  • POST /api/emails/test - Send test email
  • GET /api/test-smtp - Test SMTP connection
"},{"location":"v1/build/influence/#maintenance-commands","title":"Maintenance Commands","text":""},{"location":"v1/build/influence/#update-application","title":"Update Application","text":"
docker compose down\ngit pull origin main\ndocker compose build\ndocker compose up -d\n
"},{"location":"v1/build/influence/#development-mode","title":"Development Mode","text":"
cd app\nnpm install\nnpm run dev\n
"},{"location":"v1/build/influence/#view-logs","title":"View Logs","text":"
# Follow application logs\ndocker compose logs -f app\n\n# View MailHog logs (development)\ndocker compose logs -f mailhog\n
"},{"location":"v1/build/influence/#database-backup","title":"Database Backup","text":"
# Backup is handled through NocoDB\n# Access NocoDB admin panel to export tables\n
"},{"location":"v1/build/influence/#health-check","title":"Health Check","text":"
curl http://localhost:3333/api/health\n
"},{"location":"v1/build/influence/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/build/influence/#nocodb-connection-issues","title":"NocoDB Connection Issues","text":"
  • Verify NOCODB_API_URL and NOCODB_API_TOKEN in .env
  • Run ./scripts/build-nocodb.sh to ensure tables exist
  • Application works without NocoDB (API fallback mode)
"},{"location":"v1/build/influence/#email-not-sending","title":"Email Not Sending","text":"
  • In development: Check MailHog UI at http://localhost:8025
  • Verify SMTP credentials in .env
  • Use /email-test.html interface for diagnostics
  • Check email logs via admin panel
  • Review docker compose logs -f app for errors
"},{"location":"v1/build/influence/#no-representatives-found","title":"No Representatives Found","text":"
  • Ensure postal code starts with 'T' (Alberta only)
  • Try different postal code format (remove spaces)
  • Check Represent API status: curl http://localhost:3333/api/test-represent
  • Review application logs for API errors
"},{"location":"v1/build/influence/#campaign-not-appearing","title":"Campaign Not Appearing","text":"
  • Verify campaign status is set to \"Active\"
  • Check campaign configuration in admin panel
  • Clear browser cache and reload homepage
  • Review console for JavaScript errors
"},{"location":"v1/build/influence/#production-deployment","title":"Production Deployment","text":""},{"location":"v1/build/influence/#environment-configuration","title":"Environment Configuration","text":"
NODE_ENV=production\nEMAIL_TEST_MODE=false\nPORT=3333\n\n# Use production SMTP settings\nSMTP_HOST=smtp.your-provider.com\nSMTP_PORT=587\nSMTP_SECURE=false\nSMTP_USER=your-production-email@domain.com\nSMTP_PASS=your-production-password\n
"},{"location":"v1/build/influence/#docker-production","title":"Docker Production","text":"
# Build and start in production mode\ndocker compose -f docker-compose.yml up -d --build\n\n# View logs\ndocker compose logs -f app\n\n# Monitor health\nwatch curl http://localhost:3333/api/health\n
"},{"location":"v1/build/influence/#monitoring","title":"Monitoring","text":"
  • Health check endpoint: /api/health
  • Email logs via admin panel
  • NocoDB integration status in logs
  • Rate limiting metrics in application logs
"},{"location":"v1/build/influence/#security-considerations","title":"Security Considerations","text":"
  • \ud83d\udd12 Always use strong passwords for admin accounts
  • \ud83d\udd12 Enable HTTPS in production (use reverse proxy)
  • \ud83d\udd12 Rotate SMTP credentials regularly
  • \ud83d\udd12 Monitor email logs for suspicious activity
  • \ud83d\udd12 Set appropriate rate limits based on expected traffic
  • \ud83d\udd12 Keep NocoDB API tokens secure and rotate periodically
  • \ud83d\udd12 Use EMAIL_TEST_MODE=false only in production
"},{"location":"v1/build/influence/#support","title":"Support","text":"

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

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

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

Complete Configuration

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

Clean NocoDB

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

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

Edit the .env file in the map/ directory:

cd map\n

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

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

Run the build script to create required tables:

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

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

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

After the script completes:

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

Edit your .env file and add the table URLs:

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

Build the Docker image and start the application:

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

    docker-compose ps\n

  2. View logs:

    docker-compose logs -f map-viewer\n

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

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

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

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

Purpose: a Ubuntu server build-out for general application

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

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

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

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

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

Post installation, run update:

sudo apt update\n

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

Further configurations:

  • User profile was updated to Automatically Login
  • Remote Desktop, Sharing, and Login have all been enabled.
  • Default system settings have been set to dark mode.
"},{"location":"v1/build/server/#vscode-insiders","title":"VSCode Insiders","text":"

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

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

The free and flexible app for your private\u00a0thoughts.

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

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

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

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

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

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

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

Run syncthing as a system service.

sudo systemctl start syncthing@yourusername\n

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

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

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

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

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

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

Login to Cloudflare

cloudflared login\n

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

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

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

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

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

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

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

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

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

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

  • All documentation in the mkdocs/docs folder is included automatically.
  • The site uses the beautiful and easy-to-use Material for MkDocs theme.

Material for MkDocs Documentation

Build vs Serve

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

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

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

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

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

If you want to start fresh:

  1. Delete all folders EXCEPT these folders:

    • /blog
    • /javascripts
    • /hooks
    • /assets
    • /stylesheets
    • /overrides
  2. Reset the landing page:

    • Open the main index.md file and remove everything at the very top (the \"front matter\").
    • Or edit /overrides/home.html to change the landing page.
  3. Reset the mkdocs.yml

    • Open mkdocs.yml and delete the nav section entirely.
    • This action will enable mkdocs to build your site navigation based on file names in the root directory.
"},{"location":"v1/build/site/#using-ai-to-help-build-your-site","title":"\ud83e\udd16 Using AI to Help Build Your Site","text":"
  • If you have a claude.ai subscription, you can use powerful AI in your Coder terminal to write or rewrite pages, including a new home.html.
  • All you need to do is open the terminal and type:
    claude\n
  • You can also try local AI tools like Ollama for on-demand help.
"},{"location":"v1/build/site/#first-time-setup-tips","title":"\ud83d\udee0\ufe0f First-Time Setup Tips","text":"
  • Navigation: Open mkdocs.yml and remove the nav section to start with a blank menu. Add your own pages as you go.
  • Customize the look: Check out the Material for MkDocs customization guide.
  • Live preview: Use mkdocs serve (see above) to see changes instantly as you edit.
  • Custom files: Put your own CSS, JavaScript, or HTML in /assets, /stylesheets, /javascripts, or /overrides.

Quick Start Guide

"},{"location":"v1/build/site/#more-resources","title":"\ud83d\udcda More Resources","text":"
  • MkDocs User Guide
  • Material for MkDocs Features
  • BNKops MKdocs Configuration & Customization

Happy building!

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

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

In the order we suggest doing them:

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

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

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

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

"},{"location":"v1/config/cloudflare-config/#1-cloudflare-api-token","title":"1. Cloudflare API Token","text":"
  • Purpose: Used to authenticate API requests to Cloudflare for managing DNS records, tunnels, and access policies.
  • Required Permissions:
    • Zone.DNS (Read/Write)
    • Account.Cloudflare Tunnel (Read/Write)
    • Access (Read/Write)
  • How to Obtain:
    • Log in to your Cloudflare account.
    • Go to My Profile > API Tokens > Create Token.
    • Use the Edit zone DNS template and add Cloudflare Tunnel permissions.
"},{"location":"v1/config/cloudflare-config/#2-cloudflare-zone-id","title":"2. Cloudflare Zone ID","text":"
  • Purpose: Identifies the specific DNS zone (domain) in Cloudflare where DNS records will be created.
  • How to Obtain:
    • Log in to your Cloudflare account.
    • Select the domain you want to use.
    • The Zone ID is displayed in the Overview section under API.
"},{"location":"v1/config/cloudflare-config/#3-cloudflare-account-id","title":"3. Cloudflare Account ID","text":"
  • Purpose: Identifies your Cloudflare account for tunnel creation and management.
  • How to Obtain:
    • Log in to your Cloudflare account.
    • Go to My Profile > API Tokens.
    • The Account ID is displayed at the top of the page.
"},{"location":"v1/config/cloudflare-config/#4-cloudflare-tunnel-id-optional-in-configsh-required-in-start-productionsh","title":"4. Cloudflare Tunnel ID (Optional in config.sh, Required in start-production.sh)","text":"

Automatic Configuration of Tunnel

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

  • Purpose: Identifies the specific Cloudflare Tunnel that will be used to route traffic to your services.
  • How to Obtain:
    • This is automatically generated when you create a tunnel using cloudflared tunnel create or via the Cloudflare dashboard.
  • The start-production.sh script will create this for you if it doesn't exist.
"},{"location":"v1/config/cloudflare-config/#summary-of-required-credentials","title":"Summary of Required Credentials:","text":"
# In .env file\nCF_API_TOKEN=your_cloudflare_api_token\nCF_ZONE_ID=your_cloudflare_zone_id\nCF_ACCOUNT_ID=your_cloudflare_account_id\nCF_TUNNEL_ID=will_be_set_by_start_production  # This will be set by start-production.sh\n
"},{"location":"v1/config/cloudflare-config/#notes","title":"Notes:","text":"
  • The config.sh script will prompt you for these credentials during setup.
  • The start-production.sh script will verify these credentials and use them to configure DNS records, create tunnels, and set up access policies.
  • Ensure that the API token has the correct permissions, or the scripts will fail to configure Cloudflare services.
"},{"location":"v1/config/coder/","title":"Coder Server Configuration","text":"

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

"},{"location":"v1/config/coder/#accessing-code-server","title":"Accessing Code Server","text":"
  • URL: http://localhost:8080
  • Authentication: Password-based (see below for password retrieval)
"},{"location":"v1/config/coder/#retrieving-the-code-server-password","title":"Retrieving the Code Server Password","text":"

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

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

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

password: 0c0dca951a2d12eff1665817\n

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

"},{"location":"v1/config/coder/#main-configuration-options","title":"Main Configuration Options","text":"
  • bind-addr: The address and port code-server listens on (default: 127.0.0.1:8080)
  • auth: Authentication method (default: password)
  • password: The login password (see above)
  • cert: Whether to use HTTPS (default: false)
"},{"location":"v1/config/coder/#installed-tools-and-features","title":"Installed Tools and Features","text":"

The code-server environment includes:

  • Node.js 18+ and npm
  • Claude Code (@anthropic-ai/claude-code) globally installed
  • Python 3 and tools:
  • python3-pip, python3-venv, python3-full, pipx
  • Image and PDF processing libraries:
  • CairoSVG, Pillow, libcairo2-dev, libfreetype6-dev, libjpeg-dev, libpng-dev, libwebp-dev, libtiff5-dev, libopenjp2-7-dev, liblcms2-dev
  • weasyprint, fonts-roboto
  • Git for version control and plugin management
  • Build tools: build-essential, pkg-config, python3-dev, zlib1g-dev
  • MkDocs Material and a wide range of MkDocs plugins, installed in a dedicated Python virtual environment at /home/coder/.venv/mkdocs
  • Convenience script: run-mkdocs for running MkDocs commands easily
"},{"location":"v1/config/coder/#using-mkdocs","title":"Using MkDocs","text":"

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

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

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

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

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

"},{"location":"v1/config/coder/#usage","title":"Usage","text":"
  • Access Claude Code features through the command palette or sidebar in code-server.
  • Use Claude Code to generate code, explain code snippets, or assist with documentation and refactoring tasks.
  • For more information, refer to the Claude Code documentation.

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

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

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

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

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

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

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

  • IntelliSense: Smart code completions based on variable types, function definitions, and imported modules.
  • Code Navigation: Easily navigate to definitions, references, and symbol searches within your codebase.
  • Debugging Support: Integrated debugging support for Node.js and Python, with breakpoints, call stacks, and interactive consoles.
  • Terminal Access: Built-in terminal access to run commands, scripts, and version control operations.
"},{"location":"v1/config/coder/#collaboration-features","title":"Collaboration Features","text":"

Code-server includes features to support collaboration:

  • Live Share: Collaborate in real-time with others, sharing your code and terminal sessions.
  • ChatGPT Integration: AI-powered code assistance and chat-based collaboration.
"},{"location":"v1/config/coder/#security-considerations","title":"Security Considerations","text":"

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

  • Password Management: The default password is securely generated. Do not share it or expose it in public repositories.
  • Network Security: Ensure that your firewall settings allow access to the code-server port (default: 8080) only from trusted networks.
  • Data Privacy: Be cautious when uploading sensitive data or code to the server. Use environment variables or secure vaults for sensitive information.
"},{"location":"v1/config/coder/#ollama-integration","title":"Ollama Integration","text":"

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

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

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

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

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

ollama serve\n

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

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

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

ollama pull gemma2:2b\n

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

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

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

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

Popular models available through Ollama include:

  • Gemma 2 (1B, 2B, 9B, 27B): Google's efficient language models
  • Llama 3.2 (1B, 3B, 11B, 90B): Meta's latest language models
  • Qwen 2.5 (0.5B, 1.5B, 3B, 7B, 14B, 32B, 72B): Alibaba's multilingual models
  • Phi 3.5 (3.8B): Microsoft's compact language model
  • Code Llama (7B, 13B, 34B): Specialized for code generation
"},{"location":"v1/config/coder/#using-ollama-in-your-development-workflow","title":"Using Ollama in Your Development Workflow","text":""},{"location":"v1/config/coder/#api-access","title":"API Access","text":"

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

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

List installed models:

ollama list\n

Remove a model:

ollama rm gemma2:2b\n

Show model information:

ollama show gemma2:2b\n

"},{"location":"v1/config/coder/#resource-considerations","title":"Resource Considerations","text":"
  • 1B models: Require ~1GB RAM, suitable for basic tasks and resource-constrained environments
  • 2B models: Require ~2GB RAM, good balance of capability and resource usage
  • Larger models: Provide better performance but require significantly more resources
"},{"location":"v1/config/coder/#integration-with-development-tools","title":"Integration with Development Tools","text":"

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

  • Code completion and generation
  • Documentation writing assistance
  • Code review and explanation
  • Automated testing suggestions

For more information, visit the Ollama documentation.

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

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

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

"},{"location":"v1/config/map/#features","title":"Features","text":"
  • \ud83d\uddfa\ufe0f Interactive map visualization with OpenStreetMap
  • \ud83d\udccd Real-time geolocation support for adding locations
  • \u2795 Add new locations directly from the map interface
  • \ud83d\udd04 Auto-refresh every 30 seconds
  • \ud83d\udcf1 Responsive design for mobile devices
  • \ud83d\udd12 Secure API proxy to protect NocoDB credentials
  • \ud83d\udc64 User authentication with login system
  • \u2699\ufe0f Admin panel for system configuration
  • \ud83c\udfaf Configurable map start location
  • \ud83d\udcc4 Walk Sheet generator for door-to-door canvassing
  • \ud83d\udd17 QR code integration for digital resources
  • \ud83d\udc33 Docker containerization for easy deployment
  • \ud83c\udd93 100% open source (no proprietary dependencies)
"},{"location":"v1/config/map/#setup-process-overview","title":"Setup Process Overview","text":"

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

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

Token Security

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

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

Edit the .env file in the map/ directory:

# NocoDB API Configuration\nNOCODB_API_URL=https://your-nocodb-instance.com/api/v1\nNOCODB_API_TOKEN=your-api-token-here\n\n# These URLs will be populated after running build-nocodb.sh\nNOCODB_VIEW_URL=\nNOCODB_LOGIN_SHEET=\nNOCODB_SETTINGS_SHEET=\n\n# Server Configuration\nPORT=3000\nNODE_ENV=production\n\n# Session Secret (generate with: openssl rand -hex 32)\nSESSION_SECRET=your-secure-random-string\n\n# Map Defaults (Edmonton, Alberta, Canada)\nDEFAULT_LAT=53.5461\nDEFAULT_LNG=-113.4938\nDEFAULT_ZOOM=11\n\n# Optional: Map Boundaries (prevents users from adding points outside area)\n# BOUND_NORTH=53.7\n# BOUND_SOUTH=53.4\n# BOUND_EAST=-113.3\n# BOUND_WEST=-113.7\n\n# Production Settings\nTRUST_PROXY=true\nCOOKIE_DOMAIN=.yourdomain.com\nALLOWED_ORIGINS=https://map.yourdomain.com,http://localhost:3000\n
"},{"location":"v1/config/map/#required-configuration","title":"Required Configuration","text":"
  • NOCODB_API_URL: Your NocoDB instance API URL (usually ends with /api/v1)
  • NOCODB_API_TOKEN: The token you created in Step 1
  • SESSION_SECRET: Generate a secure random string for session encryption
"},{"location":"v1/config/map/#optional-configuration","title":"Optional Configuration","text":"
  • DEFAULT_LAT/LNG/ZOOM: Default map center and zoom level
  • BOUND_*: Map boundaries to restrict where users can add points
  • COOKIE_DOMAIN: Your domain for cookie security
  • ALLOWED_ORIGINS: Comma-separated list of allowed origins for CORS
"},{"location":"v1/config/map/#step-3-auto-create-database-structure","title":"Step 3: Auto-Create Database Structure","text":"

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

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

The script creates three tables with the following structure:

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

Main table for storing map data:

  • Geo-Location (Geo-Data): Format \"latitude;longitude\"
  • latitude (Decimal): Precision 10, Scale 8
  • longitude (Decimal): Precision 11, Scale 8
  • First Name (Single Line Text): Person's first name
  • Last Name (Single Line Text): Person's last name
  • Email (Email): Email address
  • Phone (Single Line Text): Phone number
  • Unit Number (Single Line Text): Unit or apartment number
  • Address (Single Line Text): Street address
  • Support Level (Single Select): Options: \"1\", \"2\", \"3\", \"4\"
  • 1 = Strong Support (Green)
  • 2 = Moderate Support (Yellow)
  • 3 = Low Support (Orange)
  • 4 = No Support (Red)
  • Sign (Checkbox): Has campaign sign
  • Sign Size (Single Select): Options: \"Regular\", \"Large\", \"Unsure\"
  • Notes (Long Text): Additional details and comments
"},{"location":"v1/config/map/#2-login-table","title":"2. Login Table","text":"

User authentication table:

  • Email (Email): User email address (Primary)
  • Name (Single Line Text): User display name
  • Admin (Checkbox): Admin privileges
"},{"location":"v1/config/map/#3-settings-table","title":"3. Settings Table","text":"

Admin configuration table:

  • key (Single Line Text): Setting identifier
  • title (Single Line Text): Display name
  • value (Long Text): Setting value
  • Geo-Location (Text): Format \"latitude;longitude\"
  • latitude (Decimal): Precision 10, Scale 8
  • longitude (Decimal): Precision 11, Scale 8
  • zoom (Number): Map zoom level
  • category (Single Select): Setting category
  • updated_by (Single Line Text): Last updater email
  • updated_at (DateTime): Last update time
  • qr_code_1_image (Attachment): QR code 1 image
  • qr_code_2_image (Attachment): QR code 2 image
  • qr_code_3_image (Attachment): QR code 3 image
"},{"location":"v1/config/map/#default-data","title":"Default Data","text":"

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

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

After the script completes successfully:

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

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

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

Edit your .env file and add the table URLs:

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

URL Format

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

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

Build the Docker image and start the application:

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

    docker-compose ps\n

  2. Check the logs:

    docker-compose logs -f map-viewer\n

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

"},{"location":"v1/config/map/#using-the-map-system","title":"Using the Map System","text":""},{"location":"v1/config/map/#user-interface","title":"User Interface","text":""},{"location":"v1/config/map/#main-map-view","title":"Main Map View","text":"
  • Interactive Map: Click and drag to navigate
  • Add Location: Click on the map to add a new location
  • Search: Use the search bar to find addresses
  • Refresh: Data refreshes automatically every 30 seconds
"},{"location":"v1/config/map/#location-markers","title":"Location Markers","text":"
  • Green: Strong Support (Level 1)
  • Yellow: Moderate Support (Level 2)
  • Orange: Low Support (Level 3)
  • Red: No Support (Level 4)
"},{"location":"v1/config/map/#adding-locations","title":"Adding Locations","text":"
  1. Click on the map where you want to add a location
  2. Fill out the form with contact information
  3. Select support level and sign information
  4. Add any relevant notes
  5. Click \"Save Location\"
"},{"location":"v1/config/map/#authentication","title":"Authentication","text":""},{"location":"v1/config/map/#user-login","title":"User Login","text":"
  • Users must be added to the Login table in NocoDB
  • Login with email address (no password required for simplified setup)
  • Admin users have additional privileges
"},{"location":"v1/config/map/#admin-access","title":"Admin Access","text":"
  • Admin users can access /admin.html
  • Configure map start location
  • Set up walk sheet generator
  • Manage QR codes and settings
"},{"location":"v1/config/map/#admin-panel-features","title":"Admin Panel Features","text":""},{"location":"v1/config/map/#start-location-configuration","title":"Start Location Configuration","text":"
  • Interactive Map: Visual interface for selecting coordinates
  • Real-time Preview: See changes immediately
  • Validation: Built-in coordinate and zoom level validation
"},{"location":"v1/config/map/#walk-sheet-generator","title":"Walk Sheet Generator","text":"
  • Printable Forms: Generate 8.5x11 walk sheets for door-to-door canvassing
  • QR Code Integration: Add up to 3 QR codes with custom URLs and labels
  • Form Field Matching: Automatically matches fields from the main location form
  • Live Preview: See changes as you type
  • Print Optimization: Proper formatting for printing or PDF export
"},{"location":"v1/config/map/#api-endpoints","title":"API Endpoints","text":""},{"location":"v1/config/map/#public-endpoints","title":"Public Endpoints","text":"
  • GET /api/locations - Fetch all locations (requires auth)
  • POST /api/locations - Create new location (requires auth)
  • GET /api/locations/:id - Get single location (requires auth)
  • PUT /api/locations/:id - Update location (requires auth)
  • DELETE /api/locations/:id - Delete location (requires auth)
  • GET /api/config/start-location - Get map start location
  • GET /health - Health check
"},{"location":"v1/config/map/#authentication-endpoints","title":"Authentication Endpoints","text":"
  • POST /api/auth/login - User login
  • GET /api/auth/check - Check authentication status
  • POST /api/auth/logout - User logout
"},{"location":"v1/config/map/#admin-endpoints-requires-admin-privileges","title":"Admin Endpoints (requires admin privileges)","text":"
  • GET /api/admin/start-location - Get start location with source info
  • POST /api/admin/start-location - Update map start location
  • GET /api/admin/walk-sheet-config - Get walk sheet configuration
  • POST /api/admin/walk-sheet-config - Save walk sheet configuration
"},{"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":"
  • Verify table has required columns (Geo-Location, latitude, longitude)
  • Check that coordinates are valid numbers
  • Ensure API token has read permissions
  • Verify NOCODB_VIEW_URL is correct
"},{"location":"v1/config/map/#cannot-add-locations","title":"Cannot add locations","text":"
  • Verify API token has write permissions
  • Check browser console for errors
  • Ensure coordinates are within valid ranges
  • Verify user is authenticated
"},{"location":"v1/config/map/#authentication-issues","title":"Authentication issues","text":"
  • Verify login table is properly configured
  • Check that user email exists in Login table
  • Ensure NOCODB_LOGIN_SHEET URL is correct
"},{"location":"v1/config/map/#build-script-failures","title":"Build script failures","text":"
  • Check that NOCODB_API_URL and NOCODB_API_TOKEN are correct
  • Verify NocoDB instance is accessible
  • Check network connectivity
  • Review script output for specific error messages
"},{"location":"v1/config/map/#development-mode","title":"Development Mode","text":"

For development and debugging:

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

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

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

View application logs:

docker-compose logs -f map-viewer\n

Check health status:

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

"},{"location":"v1/config/map/#security-considerations","title":"Security Considerations","text":"
  1. API Token Security: Keep tokens secure and rotate regularly
  2. HTTPS: Use HTTPS in production
  3. CORS Configuration: Set appropriate ALLOWED_ORIGINS
  4. Cookie Security: Configure COOKIE_DOMAIN properly
  5. Input Validation: All inputs are validated server-side
  6. Rate Limiting: API endpoints have rate limiting
  7. Session Security: Use a strong SESSION_SECRET
"},{"location":"v1/config/map/#maintenance","title":"Maintenance","text":""},{"location":"v1/config/map/#regular-updates","title":"Regular Updates","text":"
# Stop the application\ndocker-compose down\n\n# Pull updates (if using git)\ngit pull origin main\n\n# Rebuild and restart\ndocker-compose build\ndocker-compose up -d\n
"},{"location":"v1/config/map/#backup-considerations","title":"Backup Considerations","text":"
  • NocoDB data is stored in your NocoDB instance
  • Back up your .env file securely
  • Consider backing up QR code images from the Settings table
"},{"location":"v1/config/map/#performance-tips","title":"Performance Tips","text":"
  • Monitor NocoDB performance and scaling
  • Consider enabling caching for high-traffic deployments
  • Use CDN for static assets if needed
  • Monitor Docker container resource usage
"},{"location":"v1/config/map/#support","title":"Support","text":"

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

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

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

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

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

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

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

Example (for a Gitea repository):

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

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

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

Example with options:

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

For GitHub repositories, use the github-widget class:

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

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

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

  • Login and Git Code Buttons: Custom styles for .login-button and .git-code-button to create visually distinct, modern buttons with hover effects.

  • Code Block Improvements: Forces code blocks to wrap text (white-space: pre-wrap) and ensures inline code and tables with code display correctly on all devices.

  • GitHub Widget Styles: Styles for .github-widget and its subcomponents, including:

  • Card-like container with gradient backgrounds and subtle box-shadows.
  • Header with icon, repo link, and stats (stars, forks, issues).
  • Description area with accent border.
  • Footer with language, last update, and license info.
  • Loading and error states with spinners and error messages.
  • Responsive grid layout for multiple widgets.
  • Compact variant for smaller displays.
  • Dark mode adjustments.

  • Gitea Widget Styles: Similar to GitHub widget, but with Gitea branding (green accents). Includes .gitea-widget, .gitea-widget-container, and related classes for header, stats, description, footer, loading, and error states.

  • Responsive Design: Media queries ensure widgets and tables look good on mobile devices.

"},{"location":"v1/config/mkdocs/#repository-widgets","title":"Repository Widgets","text":""},{"location":"v1/config/mkdocs/#data-generation-hooksrepo_widget_hookpy","title":"Data Generation (hooks/repo_widget_hook.py)","text":"
  • Purpose: During the MkDocs build, this hook fetches metadata for a list of GitHub and Gitea repositories and writes JSON files to docs/assets/repo-data/.
  • How it works:
  • Runs before build (unless in serve mode).
  • Fetches repo data (stars, forks, issues, language, etc.) via GitHub/Gitea APIs.
  • Outputs a JSON file per repo (e.g., lyqht-mini-qr.json).
  • Used by frontend widgets for fast, client-side rendering.
"},{"location":"v1/config/mkdocs/#github-widget-javascriptsgithub-widgetjs","title":"GitHub Widget (javascripts/github-widget.js)","text":"
  • Purpose: Renders a card for each GitHub repository using the pre-generated JSON data.
  • Features:
  • Displays repo name, link, stars, forks, open issues, language, last update, and license.
  • Shows loading spinner while fetching data.
  • Handles errors gracefully.
  • Supports dynamic content (re-initializes on DOM changes).
  • Language color coding for popular languages.
"},{"location":"v1/config/mkdocs/#gitea-widget-javascriptsgitea-widgetjs","title":"Gitea Widget (javascripts/gitea-widget.js)","text":"
  • Purpose: Renders a card for each Gitea repository using the pre-generated JSON data.
  • Features:
  • Similar to GitHub widget, but styled for Gitea.
  • Shows repo name, link, stars, forks, open issues, language, last update.
  • Loading and error states.
  • Language color coding.
"},{"location":"v1/config/mkdocs/#mkdocs-features-mkdocsyml","title":"MkDocs Features (mkdocs.yml)","text":"

Key features and plugins enabled:

  • Material Theme: Modern, responsive UI with dark/light mode toggle, custom fonts, and accent colors.

  • Navigation Enhancements:

  • Tabs, sticky navigation, instant loading, breadcrumbs, and sectioned navigation.
  • Table of contents with permalinks.

  • Content Features:

  • Code annotation, copy buttons, tooltips, and improved code highlighting.
  • Admonitions, tabbed content, task lists, and emoji support.

  • Plugins:

  • Search: Advanced search with custom tokenization.
  • Social: OpenGraph/social card generation.
  • Blog: Blogging support with archives and categories.
  • Tags: Tagging for content organization.

  • Custom Hooks:

  • repo_widget_hook.py for repository widget data.

  • Extra CSS/JS:

  • Custom styles and scripts for widgets and homepage.

  • Extra Configuration:

  • Social links, copyright.
"},{"location":"v1/config/mkdocs/#summary","title":"Summary","text":"

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Full IDE experience
  • Extensions support
  • Git integration
  • Terminal access
"},{"location":"v1/services/#listmonk","title":"Listmonk","text":"

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

  • Email campaigns
  • Subscriber management
  • Analytics
  • Template system
"},{"location":"v1/services/#postgresql","title":"PostgreSQL","text":"

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

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

Port: 4000 | Documentation site generator with live preview

  • Material Design theme
  • Live reload
  • Search functionality
  • Markdown support
"},{"location":"v1/services/#static-site-server","title":"Static Site Server","text":"

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

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

Port: 5678 | Workflow automation tool

  • Visual workflow editor
  • 400+ integrations
  • Custom code execution
  • Webhook support
"},{"location":"v1/services/#nocodb","title":"NocoDB","text":"

Port: 8090 | No-code database platform

  • Smart spreadsheet interface
  • Form builder and API generation
  • Real-time collaboration
  • Multi-database support
"},{"location":"v1/services/#homepage","title":"Homepage","text":"

Port: 3010 | Modern dashboard for all services

  • Service dashboard and monitoring
  • Docker integration
  • Customizable layout
  • Quick search and bookmarks
"},{"location":"v1/services/#gitea","title":"Gitea","text":"

Port: 3030 | Self-hosted Git service

  • Git repository hosting
  • Web-based interface
  • Issue tracking
  • Pull requests
  • Wiki and code review
  • Lightweight and easy to deploy
"},{"location":"v1/services/#mini-qr","title":"Mini QR","text":"

Port: 8089 | Simple QR code generator service

  • Generate QR codes for text or URLs
  • Download QR codes as images
  • Simple and fast interface
  • No user registration required
"},{"location":"v1/services/#map","title":"Map","text":"

Port: 3000 | Canvassing and community organizing application

  • Interactive map for door-to-door canvassing
  • Location and contact management
  • Admin panel and QR code walk sheets
  • NocoDB integration for data storage
  • User authentication and access control
"},{"location":"v1/services/#service-architecture","title":"Service Architecture","text":"
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510    \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510    \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502   Homepage      \u2502    \u2502   Code Server   \u2502    \u2502     MkDocs      \u2502\n\u2502     :3010       \u2502    \u2502     :8888       \u2502    \u2502     :4000       \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518    \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518    \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510    \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510    \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Static Server   \u2502    \u2502    Listmonk     \u2502    \u2502      n8n        \u2502\n\u2502     :4001       \u2502    \u2502     :9000       \u2502    \u2502     :5678       \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518    \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518    \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510    \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510    \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502     NocoDB      \u2502    \u2502 PostgreSQL      \u2502    \u2502 PostgreSQL      \u2502\n\u2502     :8090       \u2502    \u2502 (listmonk-db)   \u2502    \u2502 (root_db)       \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518    \u2502     :5432       \u2502    \u2502     :5432       \u2502\n                      \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518    \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502      Map        \u2502\n\u2502     :3000       \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
"},{"location":"v1/services/code-server/","title":"Code Server","text":""},{"location":"v1/services/code-server/#overview","title":"Overview","text":"

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

"},{"location":"v1/services/code-server/#features","title":"Features","text":"
  • Full VS Code experience in the browser
  • Extensions support
  • Terminal access
  • Git integration
  • File editing and management
  • Multi-language support
"},{"location":"v1/services/code-server/#access","title":"Access","text":"
  • Default Port: 8888
  • URL: http://localhost:8888
  • Default Workspace: /home/coder/mkdocs/
"},{"location":"v1/services/code-server/#configuration","title":"Configuration","text":""},{"location":"v1/services/code-server/#environment-variables","title":"Environment Variables","text":"
  • DOCKER_USER: The user to run code-server as (default: coder)
  • DEFAULT_WORKSPACE: Default workspace directory
  • USER_ID: User ID for file permissions
  • GROUP_ID: Group ID for file permissions
"},{"location":"v1/services/code-server/#volumes","title":"Volumes","text":"
  • ./configs/code-server/.config: VS Code configuration
  • ./configs/code-server/.local: Local data
  • ./mkdocs: Main workspace directory
"},{"location":"v1/services/code-server/#usage","title":"Usage","text":"
  1. Access Code Server at http://localhost:8888
  2. Open the /home/coder/mkdocs/ workspace
  3. Start editing your documentation files
  4. Install extensions as needed
  5. Use the integrated terminal for commands
"},{"location":"v1/services/code-server/#useful-extensions","title":"Useful Extensions","text":"

Consider installing these extensions for better documentation work:

  • Markdown All in One
  • Material Design Icons
  • GitLens
  • Docker
  • YAML
"},{"location":"v1/services/code-server/#official-documentation","title":"Official Documentation","text":"

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

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

Self-hosted Git service for collaborative development.

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

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

"},{"location":"v1/services/gitea/#features","title":"Features","text":"
  • Git repository hosting
  • Web-based interface
  • Issue tracking
  • Pull requests
  • Wiki and code review
  • Lightweight and easy to deploy
"},{"location":"v1/services/gitea/#access","title":"Access","text":"
  • Default Web Port: ${GITEA_WEB_PORT:-3030} (default: 3030)
  • Default SSH Port: ${GITEA_SSH_PORT:-2222} (default: 2222)
  • URL: http://localhost:${GITEA_WEB_PORT:-3030}
  • Default Data Directory: /data/gitea
"},{"location":"v1/services/gitea/#configuration","title":"Configuration","text":""},{"location":"v1/services/gitea/#environment-variables","title":"Environment Variables","text":"
  • GITEA__database__DB_TYPE: Database type (e.g., sqlite3, mysql, postgres)
  • GITEA__database__HOST: Database host (default: ${GITEA_DB_HOST:-gitea-db:3306})
  • GITEA__database__NAME: Database name (default: ${GITEA_DB_NAME:-gitea})
  • GITEA__database__USER: Database user (default: ${GITEA_DB_USER:-gitea})
  • GITEA__database__PASSWD: Database password (from .env)
  • GITEA__server__ROOT_URL: Root URL (e.g., ${GITEA_ROOT_URL})
  • GITEA__server__HTTP_PORT: Web port (default: 3000 inside container)
  • GITEA__server__DOMAIN: Domain (e.g., ${GITEA_DOMAIN})
"},{"location":"v1/services/gitea/#volumes","title":"Volumes","text":"
  • gitea_data:/data: Gitea configuration and data
  • /etc/timezone:/etc/timezone:ro
  • /etc/localtime:/etc/localtime:ro
"},{"location":"v1/services/gitea/#usage","title":"Usage","text":"
  1. Access Gitea at http://localhost:${GITEA_WEB_PORT:-3030}
  2. Register or log in as an admin user
  3. Create or import repositories
  4. Collaborate with your team
"},{"location":"v1/services/gitea/#official-documentation","title":"Official Documentation","text":"

For more details, visit the official Gitea documentation.

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

Modern dashboard for accessing all your self-hosted services.

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

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

"},{"location":"v1/services/homepage/#features","title":"Features","text":"
  • Service Dashboard: Central hub for all your applications
  • Docker Integration: Automatic service discovery and monitoring
  • Customizable Layout: Flexible grid-based layout system
  • Service Widgets: Live status and metrics for services
  • Quick Search: Fast navigation with built-in search
  • Bookmarks: Organize frequently used links
  • Dark/Light Themes: Multiple color schemes available
  • Responsive Design: Works on desktop and mobile devices
"},{"location":"v1/services/homepage/#access","title":"Access","text":"
  • Default Port: 3010
  • URL: http://localhost:3010
  • Configuration: YAML-based configuration files
"},{"location":"v1/services/homepage/#configuration","title":"Configuration","text":""},{"location":"v1/services/homepage/#environment-variables","title":"Environment Variables","text":"
  • HOMEPAGE_PORT: External port mapping (default: 3010)
  • PUID: User ID for file permissions (default: 1000)
  • PGID: Group ID for file permissions (default: 1000)
  • TZ: Timezone setting (default: Etc/UTC)
  • HOMEPAGE_ALLOWED_HOSTS: Allowed hosts for the dashboard
"},{"location":"v1/services/homepage/#configuration-files","title":"Configuration Files","text":"

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

  • settings.yaml: Global settings and theme configuration
  • services.yaml: Service definitions and widgets
  • bookmarks.yaml: Bookmark categories and links
  • widgets.yaml: Dashboard widgets configuration
  • docker.yaml: Docker integration settings
"},{"location":"v1/services/homepage/#volumes","title":"Volumes","text":"
  • ./configs/homepage:/app/config: Configuration files
  • ./assets/icons:/app/public/icons: Custom service icons
  • ./assets/images:/app/public/images: Background images and assets
  • /var/run/docker.sock:/var/run/docker.sock: Docker socket for container monitoring
"},{"location":"v1/services/homepage/#changemaker-lite-services","title":"Changemaker Lite Services","text":"

Homepage is pre-configured with all Changemaker Lite services:

"},{"location":"v1/services/homepage/#essential-tools","title":"Essential Tools","text":"
  • Code Server (Port 8888): VS Code in the browser
  • Listmonk (Port 9000): Newsletter & mailing list manager
  • NocoDB (Port 8090): No-code database platform
"},{"location":"v1/services/homepage/#content-documentation","title":"Content & Documentation","text":"
  • MkDocs (Port 4000): Live documentation server
  • Static Site (Port 4001): Built documentation hosting
"},{"location":"v1/services/homepage/#automation-data","title":"Automation & Data","text":"
  • n8n (Port 5678): Workflow automation platform
  • PostgreSQL (Port 5432): Database backends
"},{"location":"v1/services/homepage/#customization","title":"Customization","text":""},{"location":"v1/services/homepage/#adding-custom-services","title":"Adding Custom Services","text":"

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

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

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

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

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

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

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

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

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

  • Docker Integration: Container status and resource usage
  • HTTP Ping: Service availability monitoring
  • Custom APIs: Integration with service-specific APIs
"},{"location":"v1/services/homepage/#docker-integration","title":"Docker Integration","text":"

Homepage monitors Docker containers automatically when configured:

  1. Ensure Docker socket is mounted (/var/run/docker.sock)
  2. Configure container mappings in docker.yaml
  3. Add widget configurations to services.yaml
"},{"location":"v1/services/homepage/#security-considerations","title":"Security Considerations","text":"
  • Homepage runs with limited privileges
  • Configuration files should have appropriate permissions
  • Consider network isolation for production deployments
  • Use HTTPS for external access
  • Regularly update the Homepage image
"},{"location":"v1/services/homepage/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/services/homepage/#common-issues","title":"Common Issues","text":"

Configuration not loading: Check YAML syntax in configuration files

docker logs homepage-changemaker\n

Icons not displaying: Verify icon paths and file permissions

ls -la ./assets/icons/\n

Services not reachable: Verify network connectivity between containers

docker exec homepage-changemaker ping service-name\n

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

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

For comprehensive configuration guides and advanced features:

  • Homepage Documentation
  • GitHub Repository
  • Configuration Examples
  • Widget Integrations
"},{"location":"v1/services/listmonk/","title":"Listmonk","text":"

Self-hosted newsletter and mailing list manager.

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

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

"},{"location":"v1/services/listmonk/#features","title":"Features","text":"
  • Newsletter and email campaign management
  • Subscriber list management
  • Template system with HTML/markdown support
  • Campaign analytics and tracking
  • API for integration
  • Multi-list support
  • Bounce handling
  • Privacy-focused design
"},{"location":"v1/services/listmonk/#access","title":"Access","text":"
  • Default Port: 9000
  • URL: http://localhost:9000
  • Admin User: Set via LISTMONK_ADMIN_USER environment variable
  • Admin Password: Set via LISTMONK_ADMIN_PASSWORD environment variable
"},{"location":"v1/services/listmonk/#configuration","title":"Configuration","text":""},{"location":"v1/services/listmonk/#environment-variables","title":"Environment Variables","text":"
  • LISTMONK_ADMIN_USER: Admin username
  • LISTMONK_ADMIN_PASSWORD: Admin password
  • POSTGRES_USER: Database username
  • POSTGRES_PASSWORD: Database password
  • POSTGRES_DB: Database name
"},{"location":"v1/services/listmonk/#database","title":"Database","text":"

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

"},{"location":"v1/services/listmonk/#uploads","title":"Uploads","text":"
  • Upload directory: ./assets/uploads
  • Used for media files, templates, and attachments
"},{"location":"v1/services/listmonk/#getting-started","title":"Getting Started","text":"
  1. Access Listmonk at http://localhost:9000
  2. Log in with your admin credentials
  3. Set up your first mailing list
  4. Configure SMTP settings for sending emails
  5. Import subscribers or create subscription forms
  6. Create your first campaign
"},{"location":"v1/services/listmonk/#important-notes","title":"Important Notes","text":"
  • Configure SMTP settings before sending emails
  • Set up proper domain authentication (SPF, DKIM) for better deliverability
  • Regularly backup your subscriber data and campaigns
  • Monitor bounce rates and maintain list hygiene
"},{"location":"v1/services/listmonk/#official-documentation","title":"Official Documentation","text":"

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

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

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

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

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

"},{"location":"v1/services/map/#features","title":"Features","text":"
  • Interactive map visualization with OpenStreetMap
  • Real-time geolocation support
  • Add new locations directly from the map
  • Auto-refresh every 30 seconds
  • Responsive design for mobile devices
  • Secure API proxy to protect credentials
  • Docker containerization for easy deployment
"},{"location":"v1/services/map/#access","title":"Access","text":"
  • Default Port: ${MAP_PORT:-3000} (default: 3000)
  • URL: http://localhost:${MAP_PORT:-3000}
  • Default Workspace: /app/public/
"},{"location":"v1/services/map/#configuration","title":"Configuration","text":"

All configuration is done via environment variables:

Variable Description Default NOCODB_API_URL NocoDB API base URL Required NOCODB_API_TOKEN API authentication token Required NOCODB_VIEW_URL Full NocoDB view URL Required PORT Server port 3000 DEFAULT_LAT Default map latitude 53.5461 DEFAULT_LNG Default map longitude -113.4938 DEFAULT_ZOOM Default map zoom level 11"},{"location":"v1/services/map/#volumes","title":"Volumes","text":"
  • ./map/app/public: Map public assets
"},{"location":"v1/services/map/#usage","title":"Usage","text":"
  1. Access the map at http://localhost:${MAP_PORT:-3000}
  2. Search for locations or addresses
  3. Add or view custom markers
  4. Analyze geospatial data as needed
"},{"location":"v1/services/map/#nocodb-table-setup","title":"NocoDB Table Setup","text":""},{"location":"v1/services/map/#required-columns","title":"Required Columns","text":"
  • geodata (Text): Format \"latitude;longitude\"
  • latitude (Decimal): Precision 10, Scale 8
  • longitude (Decimal): Precision 11, Scale 8
"},{"location":"v1/services/map/#form-fields-as-seen-in-the-interface","title":"Form Fields (as seen in the interface)","text":"
  • First Name (Text): Person's first name
  • Last Name (Text): Person's last name
  • Email (Email): Contact email address
  • Unit Number (Text): Apartment/unit number
  • Support Level (Single Select):
  • 1 - Strong Support (Green)
  • 2 - Moderate Support (Yellow)
  • 3 - Low Support (Orange)
  • 4 - No Support (Red)
  • Address (Text): Full street address
  • Sign (Checkbox): Has campaign sign (true/false)
  • Sign Size (Single Select): Small, Medium, Large
  • Geo-Location (Text): Formatted as \"latitude;longitude\"
"},{"location":"v1/services/map/#api-endpoints","title":"API Endpoints","text":"
  • GET /api/locations - Fetch all locations
  • POST /api/locations - Create new location
  • GET /api/locations/:id - Get single location
  • PUT /api/locations/:id - Update location
  • DELETE /api/locations/:id - Delete location
  • GET /health - Health check
"},{"location":"v1/services/map/#security-considerations","title":"Security Considerations","text":"
  • API tokens are kept server-side only
  • CORS is configured for security
  • Rate limiting prevents abuse
  • Input validation on all endpoints
  • Helmet.js for security headers
"},{"location":"v1/services/map/#troubleshooting","title":"Troubleshooting","text":"
  • Ensure NocoDB table has required columns and valid coordinates
  • Check API token permissions and network connectivity
"},{"location":"v1/services/mini-qr/","title":"Mini QR","text":"

Simple QR code generator service.

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

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

"},{"location":"v1/services/mini-qr/#features","title":"Features","text":"
  • Generate QR codes for text or URLs
  • Download QR codes as images
  • Simple and fast interface
  • No user registration required
"},{"location":"v1/services/mini-qr/#access","title":"Access","text":"
  • Default Port: ${MINI_QR_PORT:-8089} (default: 8089)
  • URL: http://localhost:${MINI_QR_PORT:-8089}
"},{"location":"v1/services/mini-qr/#configuration","title":"Configuration","text":""},{"location":"v1/services/mini-qr/#environment-variables","title":"Environment Variables","text":"
  • QR_DEFAULT_SIZE: Default size of generated QR codes
  • QR_IMAGE_FORMAT: Image format (e.g., png, svg)
"},{"location":"v1/services/mini-qr/#volumes","title":"Volumes","text":"
  • ./configs/mini-qr: QR code service configuration
"},{"location":"v1/services/mini-qr/#usage","title":"Usage","text":"
  1. Access Mini QR at http://localhost:${MINI_QR_PORT:-8089}
  2. Enter the text or URL to encode
  3. Download or share the generated QR code
"},{"location":"v1/services/mkdocs/","title":"MkDocs Material","text":"

Modern documentation site generator with live preview.

Looking for more info on BNKops code-server integration?

\u2192 Code Server Configuration

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

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

"},{"location":"v1/services/mkdocs/#features","title":"Features","text":"
  • Material Design theme
  • Live preview during development
  • Search functionality
  • Navigation and organization
  • Code syntax highlighting
  • Mathematical expressions support
  • Responsive design
  • Customizable themes and colors
"},{"location":"v1/services/mkdocs/#access","title":"Access","text":"
  • Development Port: 4000
  • Development URL: http://localhost:4000
  • Live Reload: Automatically refreshes on file changes
"},{"location":"v1/services/mkdocs/#configuration","title":"Configuration","text":""},{"location":"v1/services/mkdocs/#main-configuration","title":"Main Configuration","text":"

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

"},{"location":"v1/services/mkdocs/#volumes","title":"Volumes","text":"
  • ./mkdocs: Documentation source files
  • ./assets/images: Shared images directory
"},{"location":"v1/services/mkdocs/#environment-variables","title":"Environment Variables","text":"
  • SITE_URL: Base domain for the site
  • USER_ID: User ID for file permissions
  • GROUP_ID: Group ID for file permissions
"},{"location":"v1/services/mkdocs/#directory-structure","title":"Directory Structure","text":"
mkdocs/\n\u251c\u2500\u2500 mkdocs.yml          # Configuration file\n\u251c\u2500\u2500 docs/               # Documentation source\n\u2502   \u251c\u2500\u2500 index.md       # Homepage\n\u2502   \u251c\u2500\u2500 services/      # Service documentation\n\u2502   \u251c\u2500\u2500 blog/          # Blog posts\n\u2502   \u2514\u2500\u2500 overrides/     # Template overrides\n\u2514\u2500\u2500 site/              # Built static site\n
"},{"location":"v1/services/mkdocs/#writing-documentation","title":"Writing Documentation","text":""},{"location":"v1/services/mkdocs/#markdown-basics","title":"Markdown Basics","text":"
  • Use standard Markdown syntax
  • Support for tables, code blocks, and links
  • Mathematical expressions with MathJax
  • Admonitions for notes and warnings
"},{"location":"v1/services/mkdocs/#example-page","title":"Example Page","text":"
# Page Title\n\nThis is a sample documentation page.\n\n## Section\n\nContent goes here with **bold** and *italic* text.\n\n### Code Example\n\n```python\ndef hello_world():\n    print(\"Hello, World!\")\n

Note

This is an informational note.

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

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

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

Customize appearance in mkdocs.yml:

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

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

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

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

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

Workflow automation tool for connecting services and automating tasks.

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

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

"},{"location":"v1/services/n8n/#features","title":"Features","text":"
  • Visual workflow editor
  • 400+ integrations
  • Custom code execution (JavaScript/Python)
  • Webhook support
  • Scheduled workflows
  • Error handling and retries
  • User management
  • API access
  • Self-hosted and privacy-focused
"},{"location":"v1/services/n8n/#access","title":"Access","text":"
  • Default Port: 5678
  • URL: http://localhost:5678
  • Default User Email: Set via N8N_DEFAULT_USER_EMAIL
  • Default User Password: Set via N8N_DEFAULT_USER_PASSWORD
"},{"location":"v1/services/n8n/#configuration","title":"Configuration","text":""},{"location":"v1/services/n8n/#environment-variables","title":"Environment Variables","text":"
  • N8N_HOST: Hostname for n8n (default: n8n.${DOMAIN})
  • N8N_PORT: Internal port (5678)
  • N8N_PROTOCOL: Protocol for webhooks (https)
  • NODE_ENV: Environment (production)
  • WEBHOOK_URL: Base URL for webhooks
  • GENERIC_TIMEZONE: Timezone setting
  • N8N_ENCRYPTION_KEY: Encryption key for credentials
  • N8N_USER_MANAGEMENT_DISABLED: Enable/disable user management
  • N8N_DEFAULT_USER_EMAIL: Default admin email
  • N8N_DEFAULT_USER_PASSWORD: Default admin password
"},{"location":"v1/services/n8n/#volumes","title":"Volumes","text":"
  • n8n_data: Persistent data storage
  • ./local-files: Local file access for workflows
"},{"location":"v1/services/n8n/#getting-started","title":"Getting Started","text":"
  1. Access n8n at http://localhost:5678
  2. Log in with your admin credentials
  3. Create your first workflow
  4. Add nodes for different services
  5. Configure connections between nodes
  6. Test and activate your workflow
"},{"location":"v1/services/n8n/#common-use-cases","title":"Common Use Cases","text":""},{"location":"v1/services/n8n/#documentation-automation","title":"Documentation Automation","text":"
  • Auto-generate documentation from code comments
  • Sync documentation between different platforms
  • Notify team when documentation is updated
"},{"location":"v1/services/n8n/#email-campaign-integration","title":"Email Campaign Integration","text":"
  • Connect Listmonk with external data sources
  • Automate subscriber management
  • Trigger campaigns based on events
"},{"location":"v1/services/n8n/#database-management-with-nocodb","title":"Database Management with NocoDB","text":"
  • Sync data between NocoDB and external APIs
  • Automate data entry and validation
  • Create backup workflows for database content
  • Generate reports from NocoDB data
"},{"location":"v1/services/n8n/#development-workflows","title":"Development Workflows","text":"
  • Auto-deploy documentation on git push
  • Sync code changes with documentation
  • Backup automation
"},{"location":"v1/services/n8n/#data-processing","title":"Data Processing","text":"
  • Process CSV files and import to databases
  • Transform data between different formats
  • Schedule regular data updates
"},{"location":"v1/services/n8n/#example-workflows","title":"Example Workflows","text":""},{"location":"v1/services/n8n/#simple-webhook-to-email","title":"Simple Webhook to Email","text":"
Webhook \u2192 Email\n
"},{"location":"v1/services/n8n/#scheduled-documentation-backup","title":"Scheduled Documentation Backup","text":"
Schedule \u2192 Read Files \u2192 Compress \u2192 Upload to Storage\n
"},{"location":"v1/services/n8n/#git-integration","title":"Git Integration","text":"
Git Webhook \u2192 Process Changes \u2192 Update Documentation \u2192 Notify Team\n
"},{"location":"v1/services/n8n/#security-considerations","title":"Security Considerations","text":"
  • Use strong encryption keys
  • Secure webhook URLs
  • Regularly update credentials
  • Monitor workflow executions
  • Implement proper error handling
"},{"location":"v1/services/n8n/#integration-with-other-services","title":"Integration with Other Services","text":"

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

  • Listmonk: Manage subscribers and campaigns
  • PostgreSQL: Read/write database operations
  • Code Server: File operations and git integration
  • MkDocs: Documentation generation and updates
"},{"location":"v1/services/n8n/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/services/n8n/#common-issues","title":"Common Issues","text":"
  • Workflow Execution Errors: Check node configurations and credentials
  • Webhook Issues: Verify URLs and authentication
  • Connection Problems: Check network connectivity between services
"},{"location":"v1/services/n8n/#debugging","title":"Debugging","text":"
# Check container logs\ndocker logs n8n-changemaker\n\n# Access container shell\ndocker exec -it n8n-changemaker sh\n\n# Check workflow executions in the UI\n# Visit http://localhost:5678 \u2192 Executions\n
"},{"location":"v1/services/n8n/#official-documentation","title":"Official Documentation","text":"

For comprehensive n8n documentation:

  • n8n Documentation
  • Community Workflows
  • Node Reference
  • GitHub Repository
"},{"location":"v1/services/nocodb/","title":"NocoDB","text":"

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

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

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

"},{"location":"v1/services/nocodb/#features","title":"Features","text":"
  • Smart Spreadsheet Interface: Transform databases into intuitive spreadsheets
  • Form Builder: Create custom forms for data entry
  • API Generation: Auto-generated REST APIs for all tables
  • Collaboration: Real-time collaboration with team members
  • Access Control: Role-based permissions and sharing
  • Data Visualization: Charts and dashboard creation
  • Webhooks: Integration with external services
  • Import/Export: Support for CSV, Excel, and other formats
  • Multi-Database Support: Works with PostgreSQL, MySQL, SQLite, and more
"},{"location":"v1/services/nocodb/#access","title":"Access","text":"
  • Default Port: 8090
  • URL: http://localhost:8090
  • Database: PostgreSQL (dedicated root_db instance)
"},{"location":"v1/services/nocodb/#configuration","title":"Configuration","text":""},{"location":"v1/services/nocodb/#environment-variables","title":"Environment Variables","text":"
  • NOCODB_PORT: External port mapping (default: 8090)
  • NC_DB: Database connection string for PostgreSQL backend
"},{"location":"v1/services/nocodb/#database-backend","title":"Database Backend","text":"

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

  • Database Name: root_db
  • Username: postgres
  • Password: password
  • Host: root_db (internal container name)
"},{"location":"v1/services/nocodb/#volumes","title":"Volumes","text":"
  • nc_data: Application data and configuration storage
  • db_data: PostgreSQL database files
"},{"location":"v1/services/nocodb/#getting-started","title":"Getting Started","text":"
  1. Access NocoDB: Navigate to http://localhost:8090
  2. Initial Setup: Complete the onboarding process
  3. Create Project: Start with a new project or connect existing databases
  4. Add Tables: Import data or create new tables
  5. Configure Views: Set up different views (Grid, Form, Gallery, etc.)
  6. Set Permissions: Configure user access and sharing settings
"},{"location":"v1/services/nocodb/#common-use-cases","title":"Common Use Cases","text":""},{"location":"v1/services/nocodb/#content-management","title":"Content Management","text":"
  • Create content databases for blogs and websites
  • Manage product catalogs and inventories
  • Track customer information and interactions
"},{"location":"v1/services/nocodb/#project-management","title":"Project Management","text":"
  • Task and project tracking systems
  • Team collaboration workspaces
  • Resource and timeline management
"},{"location":"v1/services/nocodb/#data-collection","title":"Data Collection","text":"
  • Custom forms for surveys and feedback
  • Event registration and management
  • Lead capture and CRM systems
"},{"location":"v1/services/nocodb/#integration-with-other-services","title":"Integration with Other Services","text":"

NocoDB can integrate well with other Changemaker Lite services:

  • n8n Integration: Use NocoDB as a data source/destination in automation workflows
  • Listmonk Integration: Manage subscriber lists and campaign data
  • Documentation: Store and manage documentation metadata
"},{"location":"v1/services/nocodb/#api-usage","title":"API Usage","text":"

NocoDB automatically generates REST APIs for all your tables:

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

Since NocoDB uses PostgreSQL, you can backup the database:

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

Application settings and metadata are stored in the nc_data volume.

"},{"location":"v1/services/nocodb/#security-considerations","title":"Security Considerations","text":"
  • Change default database credentials in production
  • Configure proper access controls within NocoDB
  • Use HTTPS for production deployments
  • Regularly backup both database and application data
  • Monitor access logs and user activities
"},{"location":"v1/services/nocodb/#performance-tips","title":"Performance Tips","text":"
  • Regular database maintenance and optimization
  • Monitor memory usage for large datasets
  • Use appropriate indexing for frequently queried fields
  • Consider database connection pooling for high-traffic scenarios
"},{"location":"v1/services/nocodb/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/services/nocodb/#common-issues","title":"Common Issues","text":"

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

docker logs root_db\n

Database connection errors: Verify database credentials and network connectivity

docker exec nocodb nc_data nc\n

Performance issues: Monitor resource usage and optimize queries

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

For comprehensive guides and advanced features:

  • NocoDB Documentation
  • GitHub Repository
  • Community Forum
"},{"location":"v1/services/postgresql/","title":"PostgreSQL Database","text":"

Reliable database backend for applications.

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

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

"},{"location":"v1/services/postgresql/#features","title":"Features","text":"
  • ACID compliance
  • Advanced SQL features
  • JSON/JSONB support
  • Full-text search
  • Extensibility
  • High performance
  • Reliability and data integrity
"},{"location":"v1/services/postgresql/#access","title":"Access","text":"
  • Default Port: 5432
  • Host: listmonk-db (internal container name)
  • Database: Set via POSTGRES_DB environment variable
  • Username: Set via POSTGRES_USER environment variable
  • Password: Set via POSTGRES_PASSWORD environment variable
"},{"location":"v1/services/postgresql/#configuration","title":"Configuration","text":""},{"location":"v1/services/postgresql/#environment-variables","title":"Environment Variables","text":"
  • POSTGRES_USER: Database username
  • POSTGRES_PASSWORD: Database password
  • POSTGRES_DB: Database name
"},{"location":"v1/services/postgresql/#health-checks","title":"Health Checks","text":"

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

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

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

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

You can connect to PostgreSQL from your host machine using:

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

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

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

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

"},{"location":"v1/services/postgresql/#security-considerations","title":"Security Considerations","text":"
  • Use strong passwords
  • Regularly update PostgreSQL version
  • Monitor access logs
  • Implement regular backups
  • Consider network isolation
"},{"location":"v1/services/postgresql/#official-documentation","title":"Official Documentation","text":"

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

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

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

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

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

"},{"location":"v1/services/static-server/#features","title":"Features","text":"
  • High-performance static file serving
  • Automatic index file handling
  • Gzip compression
  • Caching headers
  • Security headers
  • Custom error pages
  • URL rewriting support
"},{"location":"v1/services/static-server/#access","title":"Access","text":"
  • Default Port: 4001
  • URL: http://localhost:4001
  • Document Root: /config/www (mounted from ./mkdocs/site)
"},{"location":"v1/services/static-server/#configuration","title":"Configuration","text":""},{"location":"v1/services/static-server/#environment-variables","title":"Environment Variables","text":"
  • PUID: User ID for file permissions (default: 1000)
  • PGID: Group ID for file permissions (default: 1000)
  • TZ: Timezone setting (default: Etc/UTC)
"},{"location":"v1/services/static-server/#volumes","title":"Volumes","text":"
  • ./mkdocs/site:/config/www: Static site files
  • Built MkDocs site is automatically served
"},{"location":"v1/services/static-server/#usage","title":"Usage","text":"
  1. Build your MkDocs site: docker exec mkdocs-changemaker mkdocs build
  2. The built site is automatically available at http://localhost:4001
  3. Any files in ./mkdocs/site/ will be served statically
"},{"location":"v1/services/static-server/#file-structure","title":"File Structure","text":"
mkdocs/site/           # Served at /\n\u251c\u2500\u2500 index.html         # Homepage\n\u251c\u2500\u2500 assets/           # CSS, JS, images\n\u251c\u2500\u2500 services/         # Service documentation\n\u2514\u2500\u2500 search/           # Search functionality\n
"},{"location":"v1/services/static-server/#performance-features","title":"Performance Features","text":"
  • Gzip Compression: Automatic compression for text files
  • Browser Caching: Optimized cache headers
  • Fast Static Serving: Nginx optimized for static content
  • Security Headers: Basic security header configuration
"},{"location":"v1/services/static-server/#custom-configuration","title":"Custom Configuration","text":"

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

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

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

"},{"location":"v1/services/static-server/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/services/static-server/#common-issues","title":"Common Issues","text":"
  • 404 Errors: Ensure MkDocs site is built and files exist in ./mkdocs/site/
  • Permission Issues: Check PUID and PGID settings
  • File Not Found: Verify file paths and case sensitivity
"},{"location":"v1/services/static-server/#debugging","title":"Debugging","text":"
# Check container logs\ndocker logs mkdocs-site-server-changemaker\n\n# Verify files are present\ndocker exec mkdocs-site-server-changemaker ls -la /config/www\n\n# Test file serving\ncurl -I http://localhost:4001\n
"},{"location":"v1/services/static-server/#official-documentation","title":"Official Documentation","text":"

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

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

V2 is Production Ready

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

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

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

"},{"location":"v2/#key-highlights","title":"Key Highlights","text":"
  • Dual API Architecture: Express.js (main features) + Fastify (media library)
  • Modern Stack: TypeScript, Prisma + Drizzle ORM, PostgreSQL 16, Redis
  • React Admin: Vite + Ant Design + Zustand state management
  • JWT Authentication: Secure role-based access control with refresh tokens
  • Comprehensive Features: 14 backend modules, 42 frontend pages, 8 critical services
  • Production Monitoring: Prometheus, Grafana, Alertmanager with 12 custom metrics
  • Security Audited: 13 findings addressed (Feb 2026)
"},{"location":"v2/#architecture-diagram","title":"Architecture Diagram","text":"
graph TB\n    User[User Browser]\n    Nginx[Nginx Reverse Proxy]\n    Admin[React Admin GUI<br/>port 3000]\n    ExpressAPI[Express API<br/>port 4000<br/>Prisma ORM]\n    FastifyAPI[Fastify Media API<br/>port 4100<br/>Drizzle ORM]\n    Postgres[(PostgreSQL 16)]\n    Redis[(Redis)]\n    BullMQ[BullMQ Queues]\n    Listmonk[Listmonk<br/>Newsletter]\n    Prometheus[Prometheus<br/>Monitoring]\n\n    User --> Nginx\n    Nginx --> |app.cmlite.org| Admin\n    Nginx --> |api.cmlite.org| ExpressAPI\n    Nginx --> |media.cmlite.org| FastifyAPI\n\n    Admin --> ExpressAPI\n    Admin --> FastifyAPI\n\n    ExpressAPI --> Postgres\n    ExpressAPI --> Redis\n    ExpressAPI --> BullMQ\n\n    FastifyAPI --> Postgres\n    FastifyAPI --> Redis\n\n    BullMQ --> Redis\n    ExpressAPI --> Listmonk\n    ExpressAPI --> Prometheus\n    FastifyAPI --> Prometheus
"},{"location":"v2/#feature-modules","title":"Feature Modules","text":""},{"location":"v2/#influence-module","title":"Influence Module","text":"

Email advocacy campaigns targeting elected representatives with:

  • Campaign management with rich text editor
  • Canadian representative lookup (postal code \u2192 MP/MPP/councillor)
  • Public campaign pages with email submission
  • Response wall with upvoting and moderation
  • BullMQ async email queue with SMTP delivery
  • Email verification and tracking

Learn more \u2192

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

Geographic mapping and volunteer canvassing with:

  • Location CRUD with multi-provider geocoding (6 providers)
  • Cut (polygon) management with spatial queries
  • Volunteer shift scheduling and signup system
  • Full canvassing system with GPS tracking and visit recording
  • Walk sheets and QR codes for printable forms
  • NAR 2025 data import (Canadian electoral data)

Learn more \u2192

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

GrapesJS-based page builder with:

  • WYSIWYG editor with custom blocks
  • Public rendering at /p/:slug
  • MkDocs export for static documentation
  • Mobile-responsive templates

Learn more \u2192

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

Template management system with:

  • GrapesJS email editor
  • Variable substitution
  • Integration with campaign emails
  • Version control

Learn more \u2192

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

Video library management with:

  • Dual API architecture (Fastify microservice)
  • Shared media public gallery
  • Reaction system (6 standard emojis)
  • Job queue monitoring
  • Bulk operations

Learn more \u2192

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

Listmonk sync with:

  • Participant/location/user syncing
  • Subscriber list management
  • Health monitoring
  • API integration

Learn more \u2192

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

Comprehensive monitoring with:

  • Prometheus metrics (12 custom metrics)
  • Grafana dashboards (3 pre-configured)
  • Alertmanager notifications
  • Service health checks
  • Data quality dashboard

Learn more \u2192

"},{"location":"v2/#quick-links","title":"Quick Links","text":""},{"location":"v2/#getting-started","title":"Getting Started","text":"
  • Quick Start Guide - Get running in 5 minutes
  • Installation - Detailed setup instructions
  • Environment Setup - Configure your .env file
  • First Login - Access the admin interface
"},{"location":"v2/#architecture","title":"Architecture","text":"
  • Architecture Overview - System design and components
  • Dual API Design - Express + Fastify architecture
  • Database Schema - Prisma + Drizzle models
  • Authentication Flow - JWT security model
  • Frontend Architecture - React + Vite + Ant Design
"},{"location":"v2/#development","title":"Development","text":"
  • Local Development - Set up your dev environment
  • npm Commands - Common development tasks
  • Database Migrations - Schema changes workflow
  • Testing - Test strategy and execution
  • Code Style - Standards and patterns
"},{"location":"v2/#deployment","title":"Deployment","text":"
  • Docker Compose - Service orchestration
  • Nginx Configuration - Reverse proxy setup
  • Environment Variables - Complete reference
  • Monitoring Stack - Prometheus + Grafana
  • Backup & Restore - Data protection
"},{"location":"v2/#api-reference","title":"API Reference","text":"
  • Authentication API - Login, register, refresh
  • Campaigns API - Campaign CRUD
  • Locations API - Location management
  • Media API - Video library
  • Complete API Index - All endpoints
"},{"location":"v2/#user-guides","title":"User Guides","text":"
  • Admin Guide - Platform administration
  • Volunteer Guide - Canvassing workflows
  • Campaign Manager Guide - Running campaigns
  • Map Organizer Guide - Location management
  • Content Editor Guide - Landing pages
"},{"location":"v2/#technology-stack","title":"Technology Stack","text":""},{"location":"v2/#backend","title":"Backend","text":"
  • Express.js - Main API server (TypeScript, port 4000)
  • Fastify - Media API microservice (TypeScript, port 4100)
  • Prisma ORM - Database modeling and migrations (27+ models)
  • Drizzle ORM - Media tables (lightweight schema-first)
  • PostgreSQL 16 - Primary database
  • Redis - Caching, sessions, rate limiting, BullMQ backend
  • BullMQ - Job queues (email sending, geocoding)
  • Winston - Structured logging
"},{"location":"v2/#frontend","title":"Frontend","text":"
  • React 19 - UI library
  • Vite - Build tool and dev server
  • Ant Design 5 - Component library
  • Zustand - State management
  • React Router - Client-side routing
  • Axios - HTTP client with interceptors
  • Leaflet - Interactive maps
  • GrapesJS - WYSIWYG page builder
"},{"location":"v2/#infrastructure","title":"Infrastructure","text":"
  • Docker Compose - Service orchestration (20+ containers)
  • Nginx - Reverse proxy with subdomain routing
  • Prometheus - Metrics collection
  • Grafana - Metrics visualization
  • Alertmanager - Alert routing
  • Listmonk - Newsletter platform
  • MailHog - Email testing (development)
"},{"location":"v2/#project-status","title":"Project Status","text":""},{"location":"v2/#completed-phases-1-14","title":"Completed Phases (1-14)","text":"

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

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

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

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

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

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

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

  • Breaking changes (NocoDB \u2192 Prisma, sessions \u2192 JWT)
  • Data migration strategy
  • API endpoint mapping
  • Feature parity comparison
"},{"location":"v2/#contributing","title":"Contributing","text":"

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

  • Development setup
  • Code standards
  • Pull request process
  • Roadmap (Phase 15+)
"},{"location":"v2/#support","title":"Support","text":"
  • Documentation Issues: Report on GitHub
  • Security Issues: See Security Policy
  • General Questions: Check Troubleshooting and FAQ

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

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

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

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

Changemaker Lite V2 provides two REST APIs:

  • Express API (Port 4000) - Main application API
  • Fastify Media API (Port 4100) - Media library operations

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

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

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

"},{"location":"v2/api-reference/#authentication-endpoints","title":"Authentication Endpoints","text":"
  • POST /api/auth/register - User registration
  • POST /api/auth/login - User login
  • POST /api/auth/refresh - Refresh access token
  • POST /api/auth/logout - User logout
  • GET /api/auth/me - Get current user
"},{"location":"v2/api-reference/#user-endpoints","title":"User Endpoints","text":"
  • GET /api/users - List users
  • POST /api/users - Create user
  • GET /api/users/:id - Get user
  • PATCH /api/users/:id - Update user
  • DELETE /api/users/:id - Delete user
"},{"location":"v2/api-reference/#campaign-endpoints","title":"Campaign Endpoints","text":"
  • GET /api/campaigns - List campaigns
  • POST /api/campaigns - Create campaign
  • GET /api/campaigns/:id - Get campaign
  • PATCH /api/campaigns/:id - Update campaign
  • DELETE /api/campaigns/:id - Delete campaign
  • GET /api/campaigns/public - List public campaigns
  • POST /api/campaigns/:id/send-email - Send campaign email
"},{"location":"v2/api-reference/#location-endpoints","title":"Location Endpoints","text":"
  • GET /api/locations - List locations
  • POST /api/locations - Create location
  • GET /api/locations/:id - Get location
  • PATCH /api/locations/:id - Update location
  • DELETE /api/locations/:id - Delete location
  • POST /api/locations/import - CSV import
  • GET /api/locations/export - CSV export
  • POST /api/locations/geocode - Bulk geocode
"},{"location":"v2/api-reference/#map-endpoints","title":"Map Endpoints","text":"
  • GET /api/cuts - List cuts
  • POST /api/cuts - Create cut
  • GET /api/shifts - List shifts
  • POST /api/shifts - Create shift
  • GET /api/canvass/session - Get active session
  • POST /api/canvass/session/start - Start session
  • POST /api/canvass/visit - Record visit
"},{"location":"v2/api-reference/#content-endpoints","title":"Content Endpoints","text":"
  • GET /api/pages - List pages
  • POST /api/pages - Create page
  • GET /api/pages/public/:slug - Get published page
  • GET /api/email-templates - List templates
  • POST /api/email-templates - Create template
"},{"location":"v2/api-reference/#media-endpoints-port-4100","title":"Media Endpoints (Port 4100)","text":"
  • GET /media-api/videos - List videos
  • POST /media-api/upload - Upload video
  • GET /media-api/public/videos - List public videos
  • POST /media-api/reactions - Add reaction
"},{"location":"v2/api-reference/#authentication","title":"Authentication","text":"

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

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

  3. Access Protected Resource - Include token in header

  4. Token verified by authenticate middleware

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

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

  8. Logout - POST /api/auth/logout

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

Endpoints are protected by role requirements:

  • Public - No authentication required
  • Authenticated - Any logged-in user
  • Admin - SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN
  • Role-Specific - Specific role required
"},{"location":"v2/api-reference/#request-format","title":"Request Format","text":""},{"location":"v2/api-reference/#json-body","title":"JSON Body","text":"
POST /api/campaigns\nContent-Type: application/json\nAuthorization: Bearer <token>\n\n{\n  \"name\": \"Save the Parks\",\n  \"description\": \"Campaign description\",\n  \"published\": true\n}\n
"},{"location":"v2/api-reference/#query-parameters","title":"Query Parameters","text":"
GET /api/campaigns?page=1&limit=20&search=parks\n
"},{"location":"v2/api-reference/#path-parameters","title":"Path Parameters","text":"
GET /api/campaigns/:id\n
"},{"location":"v2/api-reference/#response-format","title":"Response Format","text":""},{"location":"v2/api-reference/#success-response","title":"Success Response","text":"
{\n  \"id\": 1,\n  \"name\": \"Save the Parks\",\n  \"description\": \"Campaign description\",\n  \"published\": true,\n  \"createdAt\": \"2026-01-01T00:00:00.000Z\",\n  \"updatedAt\": \"2026-01-01T00:00:00.000Z\"\n}\n
"},{"location":"v2/api-reference/#paginated-response","title":"Paginated Response","text":"
{\n  \"data\": [...],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 100,\n    \"totalPages\": 5\n  }\n}\n
"},{"location":"v2/api-reference/#error-response","title":"Error Response","text":"
{\n  \"error\": \"Validation error\",\n  \"details\": \"Invalid email format\",\n  \"statusCode\": 400\n}\n
"},{"location":"v2/api-reference/#status-codes","title":"Status Codes","text":"
  • 200 OK - Success
  • 201 Created - Resource created
  • 204 No Content - Success with no body
  • 400 Bad Request - Validation error
  • 401 Unauthorized - Authentication required
  • 403 Forbidden - Insufficient permissions
  • 404 Not Found - Resource not found
  • 429 Too Many Requests - Rate limit exceeded
  • 500 Internal Server Error - Server error
"},{"location":"v2/api-reference/#rate-limiting","title":"Rate Limiting","text":"

Rate limits vary by endpoint:

  • Auth endpoints - 10 requests/minute per IP
  • Canvass visits - 30 requests/minute per IP
  • Public endpoints - 60 requests/minute per IP
  • Authenticated endpoints - 120 requests/minute per user

Rate limit headers:

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

CORS is enabled for all origins in development:

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

Production should restrict to known domains.

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

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

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

List endpoints support pagination:

  • page - Page number (default: 1)
  • limit - Items per page (default: 20, max: 100)

Example:

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

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

List endpoints support search and filtering:

  • search - Text search (varies by endpoint)
  • filter - Field-specific filters

Example:

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

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

List endpoints support sorting:

  • sort - Field to sort by
  • order - Sort direction (asc/desc)

Example:

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

"},{"location":"v2/api-reference/#api-endpoints-by-module","title":"API Endpoints by Module","text":""},{"location":"v2/api-reference/#authentication_1","title":"Authentication","text":"
  • Login, register, refresh, logout, current user
"},{"location":"v2/api-reference/#users","title":"Users","text":"
  • CRUD operations, pagination, search, role management
"},{"location":"v2/api-reference/#settings","title":"Settings","text":"
  • Site settings singleton
"},{"location":"v2/api-reference/#campaigns","title":"Campaigns","text":"
  • CRUD, public listing, email sending
"},{"location":"v2/api-reference/#representatives","title":"Representatives","text":"
  • Postal code lookup, cache management
"},{"location":"v2/api-reference/#responses","title":"Responses","text":"
  • CRUD, verification, upvoting, moderation
"},{"location":"v2/api-reference/#postal-codes","title":"Postal Codes","text":"
  • Cache service
"},{"location":"v2/api-reference/#campaign-emails","title":"Campaign Emails","text":"
  • Email tracking, statistics
"},{"location":"v2/api-reference/#email-queue","title":"Email Queue","text":"
  • Queue monitoring, pause/resume, cleanup
"},{"location":"v2/api-reference/#locations","title":"Locations","text":"
  • CRUD, CSV import/export, geocoding, NAR import
"},{"location":"v2/api-reference/#cuts","title":"Cuts","text":"
  • CRUD, spatial queries, location assignment
"},{"location":"v2/api-reference/#shifts","title":"Shifts","text":"
  • CRUD, signups, email notifications
"},{"location":"v2/api-reference/#canvass","title":"Canvass","text":"
  • Sessions, visits, routes, dashboard
"},{"location":"v2/api-reference/#tracking","title":"Tracking","text":"
  • GPS tracking (future)
"},{"location":"v2/api-reference/#map-settings","title":"Map Settings","text":"
  • Map configuration
"},{"location":"v2/api-reference/#pages","title":"Pages","text":"
  • CRUD, block library, MkDocs export, public rendering
"},{"location":"v2/api-reference/#email-templates","title":"Email Templates","text":"
  • CRUD, versioning (future)
"},{"location":"v2/api-reference/#media-port-4100","title":"Media (Port 4100)","text":"
  • Videos, upload, shared media, reactions, jobs
"},{"location":"v2/api-reference/#listmonk","title":"Listmonk","text":"
  • Status, sync, test connection
"},{"location":"v2/api-reference/#pangolin","title":"Pangolin","text":"
  • Tunnel management, setup, configuration
"},{"location":"v2/api-reference/#docs","title":"Docs","text":"
  • MkDocs/Code Server status
"},{"location":"v2/api-reference/#qr","title":"QR","text":"
  • QR code generation
"},{"location":"v2/api-reference/#observability","title":"Observability","text":"
  • Prometheus/Grafana/Alertmanager integration
"},{"location":"v2/api-reference/#services","title":"Services","text":"
  • Health checks
"},{"location":"v2/api-reference/#openapi-specification","title":"OpenAPI Specification","text":"

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

  • Interactive API explorer
  • Auto-generated client libraries
  • Comprehensive endpoint documentation
  • Request/response examples
"},{"location":"v2/api-reference/#testing","title":"Testing","text":"

Test API endpoints using:

  • curl - Command-line HTTP client
  • Postman - GUI API client
  • HTTPie - User-friendly CLI
  • Insomnia - API design/testing tool

Example with curl:

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

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

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

Purpose: Routes HTTP requests to appropriate services based on subdomain

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

Configuration: /nginx/conf.d/

Learn more \u2192

"},{"location":"v2/architecture/#2-frontend-layer","title":"2. Frontend Layer","text":""},{"location":"v2/architecture/#admin-gui-port-3000","title":"Admin GUI (Port 3000)","text":"
  • Framework: React 19 with Vite build tool
  • UI Library: Ant Design 5 (Table, Form, Modal, Drawer components)
  • State Management: Zustand stores (auth, canvass)
  • Routing: React Router v6
  • HTTP Client: Axios with 401 refresh interceptor

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

Learn more \u2192

"},{"location":"v2/architecture/#public-pages","title":"Public Pages","text":"
  • Dark blue/teal theme (consistent with V1 branding)
  • No authentication required
  • Mobile-responsive layouts
  • Public campaign submission
  • Response wall with upvoting
  • Public map with location markers
  • Shift signup forms
"},{"location":"v2/architecture/#volunteer-portal","title":"Volunteer Portal","text":"
  • Top navigation layout
  • Mobile-optimized (hamburger menu)
  • GPS-tracked canvassing
  • Full-screen map interface
  • Visit recording forms
  • Activity tracking
"},{"location":"v2/architecture/#3-backend-layer-dual-api-design","title":"3. Backend Layer - Dual API Design","text":""},{"location":"v2/architecture/#express-api-port-4000","title":"Express API (Port 4000)","text":"

Main application server handling core features:

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

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

ORM: Prisma (27+ models)

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

Learn more \u2192

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

Specialized microservice for media library:

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

ORM: Drizzle (lightweight schema-first)

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

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

Learn more \u2192

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

Primary database with two ORM schemas:

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

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

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

Learn more \u2192

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

Multi-purpose cache and queue backend:

  • Caching: Postal codes (7-day TTL), representatives
  • Rate Limiting: Per-endpoint limits (Redis-backed)
  • BullMQ Queues: Email sending, bulk geocoding
  • Sessions: Future session storage (if needed)

Authentication: Required (REDIS_PASSWORD env var)

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

Async job processing for long-running operations:

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

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

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

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

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

Dev Mode: MailHog captures emails (EMAIL_TEST_MODE=true)

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

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

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

Multi-provider geocoding with fallback:

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

Strategy: Try each provider in order until success

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

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

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

Metrics collection with custom instrumentation:

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

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

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

Learn more \u2192

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

Visualization dashboards:

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

Auto-provisioned: Dashboards in /configs/grafana/

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

Alert routing and notifications:

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

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

"},{"location":"v2/architecture/#request-lifecycle","title":"Request Lifecycle","text":""},{"location":"v2/architecture/#example-public-campaign-email-submission","title":"Example: Public Campaign Email Submission","text":"
sequenceDiagram\n    participant User as User Browser\n    participant Nginx\n    participant Admin as Admin GUI\n    participant Express as Express API\n    participant DB as PostgreSQL\n    participant Redis\n    participant Queue as BullMQ\n    participant SMTP as SMTP Server\n    participant Rep as Represent API\n\n    User->>Nginx: Visit /campaigns/123\n    Nginx->>Admin: Route to React app\n    Admin->>Express: GET /api/campaigns/123 (public)\n    Express->>DB: Query campaign\n    DB-->>Express: Campaign data\n    Express-->>Admin: Campaign JSON\n    Admin-->>User: Render campaign page\n\n    User->>Admin: Enter postal code + submit\n    Admin->>Express: POST /api/postal-codes (lookup)\n    Express->>Redis: Check cache\n    Redis-->>Express: Cache miss\n    Express->>Rep: GET /representatives/postal-code\n    Rep-->>Express: Representative list\n    Express->>Redis: Cache for 7 days\n    Express-->>Admin: Representatives JSON\n    Admin-->>User: Show rep selection\n\n    User->>Admin: Select rep + write email + submit\n    Admin->>Express: POST /api/responses (create)\n    Express->>DB: Insert response\n    Express->>Queue: Enqueue verification email\n    Express->>DB: Insert campaign email record\n    DB-->>Express: Response created\n    Express-->>Admin: Success response\n    Admin-->>User: Show success message\n\n    Queue->>SMTP: Send verification email\n    SMTP-->>Queue: Delivery confirmed\n\n    User->>User: Click verification link (email)\n    User->>Nginx: GET /verify-response/:token\n    Nginx->>Admin: Route to React app\n    Admin->>Express: POST /api/responses/:id/verify\n    Express->>DB: Update response (verified=true)\n    Express->>Queue: Enqueue campaign email to rep\n    DB-->>Express: Response verified\n    Express-->>Admin: Success\n    Admin-->>User: Email sent confirmation\n\n    Queue->>SMTP: Send campaign email to rep\n    SMTP-->>Queue: Delivery confirmed
"},{"location":"v2/architecture/#technology-decisions","title":"Technology Decisions","text":""},{"location":"v2/architecture/#why-typescript","title":"Why TypeScript?","text":"
  • Type safety reduces runtime errors
  • Better IDE support and autocomplete
  • Easier refactoring
  • Self-documenting code
"},{"location":"v2/architecture/#why-prisma-drizzle","title":"Why Prisma + Drizzle?","text":"
  • Prisma: Great for complex models, migrations, auto-generated types
  • Drizzle: Lightweight, perfect for simple media tables
  • Evaluate both ORMs in production
"},{"location":"v2/architecture/#why-dual-api","title":"Why Dual API?","text":"
  • Separation of concerns: Media ops isolated from core features
  • Performance: Video processing doesn't block main API
  • Scalability: Independent horizontal scaling
  • Technology evaluation: Compare Express vs Fastify
"},{"location":"v2/architecture/#why-jwt-over-sessions","title":"Why JWT over Sessions?","text":"
  • Stateless (scales horizontally)
  • No session storage overhead
  • Works across multiple API servers
  • Standard claims (iat, exp, sub)
"},{"location":"v2/architecture/#why-bullmq-over-bull","title":"Why BullMQ over Bull?","text":"
  • Better TypeScript support
  • Improved performance
  • Active maintenance
  • Better documentation
"},{"location":"v2/architecture/#why-postgresql-over-nosql","title":"Why PostgreSQL over NoSQL?","text":"
  • Complex relational data (campaigns, locations, users)
  • ACID transactions (critical for email queue)
  • Full-text search
  • Spatial queries (PostGIS for future geo features)
"},{"location":"v2/architecture/#deployment-architecture","title":"Deployment Architecture","text":""},{"location":"v2/architecture/#docker-compose","title":"Docker Compose","text":"

All services orchestrated in docker-compose.yml:

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

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

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

Learn more \u2192

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

Subdomain-based routing:

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

Learn more \u2192

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

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

Learn more \u2192

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

Learn more \u2192

"},{"location":"v2/architecture/#scalability-considerations","title":"Scalability Considerations","text":""},{"location":"v2/architecture/#horizontal-scaling","title":"Horizontal Scaling","text":"
  • Stateless APIs: JWT auth allows multiple API instances
  • Redis-backed queues: Share job queues across workers
  • Database connection pooling: Prisma manages connections
  • Nginx load balancing: Distribute requests across API instances
"},{"location":"v2/architecture/#vertical-scaling","title":"Vertical Scaling","text":"
  • Increase container resources (CPU, memory)
  • Optimize database queries (indexes, query planning)
  • Redis memory limits (LRU eviction policy)
"},{"location":"v2/architecture/#bottlenecks","title":"Bottlenecks","text":"
  • PostgreSQL: Single primary (future: read replicas)
  • Redis: Single instance (future: Redis Cluster)
  • File uploads: Local disk (future: S3-compatible storage)
"},{"location":"v2/architecture/#monitoring-observability","title":"Monitoring & Observability","text":""},{"location":"v2/architecture/#golden-signals","title":"Golden Signals","text":"
  1. Latency: Request duration histograms
  2. Traffic: Request rate by endpoint
  3. Errors: Error rate (5xx responses)
  4. Saturation: Database connections, Redis memory, queue depth
"},{"location":"v2/architecture/#slos-service-level-objectives","title":"SLOs (Service Level Objectives)","text":"
  • Availability: 99.9% uptime (8.76 hours downtime/year)
  • Latency: p95 < 500ms, p99 < 1000ms
  • Error Rate: < 0.1% (1 error per 1000 requests)
"},{"location":"v2/architecture/#alerting-strategy","title":"Alerting Strategy","text":"
  • Critical: Page on-call (service down, database unavailable)
  • Warning: Create ticket (queue growing, elevated errors)
  • Info: Log only (slow query, cache miss)

Learn more \u2192

"},{"location":"v2/architecture/#further-reading","title":"Further Reading","text":"
  • Dual API Architecture - Express + Fastify design
  • Database Schema - Complete ER diagram
  • Authentication Flow - JWT security model
  • Frontend Architecture - React + Vite + Ant Design
  • Networking - Nginx routing and subdomains
  • Security Model - Comprehensive security audit
  • Monitoring Stack - Prometheus + Grafana + Alertmanager
  • Data Flow - Request lifecycle examples

Next: Set up your development environment \u2192

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

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

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

Key Features:

  • JWT Tokens - Stateless authentication (no session storage)
  • Dual Token System - Short-lived access tokens (15min) + long-lived refresh tokens (7 days)
  • Refresh Token Rotation - Atomic transaction prevents race conditions
  • Password Policy - Enforced 12+ characters with complexity requirements
  • Rate Limiting - 10 requests/min on auth endpoints
  • User Enumeration Prevention - Consistent 401 responses
  • RBAC - Role-based access control with 5 roles
"},{"location":"v2/architecture/authentication/#authentication-architecture","title":"Authentication Architecture","text":"
graph TB\n    subgraph \"Client Layer\"\n        Browser[Web Browser]\n        Storage[LocalStorage<br/>Zustand Persist]\n    end\n\n    subgraph \"API Layer\"\n        AuthRoutes[Auth Routes<br/>/api/auth/*]\n        AuthMiddleware[Auth Middleware<br/>JWT Verification]\n        RBACMiddleware[RBAC Middleware<br/>Role Check]\n    end\n\n    subgraph \"Data Layer\"\n        PG[(PostgreSQL<br/>User + RefreshToken)]\n        Redis[(Redis<br/>Rate Limiting)]\n    end\n\n    Browser -->|POST /auth/login| AuthRoutes\n    AuthRoutes -->|Check rate limit| Redis\n    AuthRoutes -->|Verify credentials| PG\n    AuthRoutes -->|Generate tokens| AuthRoutes\n    AuthRoutes -->|Store refresh token| PG\n    AuthRoutes -->|Return tokens| Browser\n    Browser -->|Store| Storage\n\n    Browser -->|API requests| AuthMiddleware\n    AuthMiddleware -->|Verify JWT| AuthMiddleware\n    AuthMiddleware -->|Check role| RBACMiddleware\n    RBACMiddleware -->|Authorized| Handler[Route Handler]\n\n    style AuthRoutes fill:#61dafb,stroke:#333,stroke-width:2px\n    style AuthMiddleware fill:#ffd700,stroke:#333,stroke-width:2px\n    style RBACMiddleware fill:#ff6b6b,stroke:#333,stroke-width:2px
"},{"location":"v2/architecture/authentication/#user-roles","title":"User Roles","text":""},{"location":"v2/architecture/authentication/#role-hierarchy","title":"Role Hierarchy","text":"
enum UserRole {\n  SUPER_ADMIN      = 'SUPER_ADMIN',      // Full system access\n  INFLUENCE_ADMIN  = 'INFLUENCE_ADMIN',  // Campaign management\n  MAP_ADMIN        = 'MAP_ADMIN',        // Location + canvassing management\n  USER             = 'USER',             // Standard user (limited access)\n  TEMP             = 'TEMP'              // Temporary user (public signups, auto-expires)\n}\n
"},{"location":"v2/architecture/authentication/#role-permissions","title":"Role Permissions","text":"Role Campaign CRUD Response Moderation Location Management User Management System Settings SUPER_ADMIN \u2705 \u2705 \u2705 \u2705 \u2705 INFLUENCE_ADMIN \u2705 \u2705 \u274c \u274c \u274c MAP_ADMIN \u274c \u274c \u2705 \u274c \u274c USER \u274c \u274c \u274c \u274c \u274c TEMP \u274c \u274c \u274c \u274c \u274c

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

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

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

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

Enforced at Zod schema level:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Usage:

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

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

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

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

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

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

bcryptjs with automatic salt generation:

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

Rounds: 10 (balanced between security and performance)

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

Prevents replay attacks:

  • Old refresh token deleted immediately after use (atomic transaction)
  • New refresh token issued with each refresh
  • If old token reused \u2192 401 error
"},{"location":"v2/architecture/authentication/#4-token-expiration","title":"4. Token Expiration","text":"Token Type Lifetime Storage Purpose Access 15 minutes Not stored (JWT only) API authentication Refresh 7 days Database + localStorage Token renewal

Short access token lifetime limits damage if token is stolen.

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

Redis requires password authentication:

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

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

Solution:

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

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

Solution:

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

Cause: Access token missing, invalid, or expired.

Debug:

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

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

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

"},{"location":"v2/architecture/authentication/#further-reading","title":"Further Reading","text":"
  • RBAC Patterns - Advanced role checks
  • Security Model - Comprehensive security audit
  • Database Schema - User and RefreshToken models
  • Frontend State Management - Zustand auth store
  • API Reference: Auth - Complete endpoint docs
"},{"location":"v2/architecture/dual-api/","title":"Dual API Architecture","text":"

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

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

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

  • Video uploads don't block campaign email sending
  • Media job processing doesn't affect map rendering
  • Large file transfers have separate connection pools
"},{"location":"v2/architecture/dual-api/#technology-evaluation","title":"Technology Evaluation","text":"

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

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

Each API can scale independently:

  • Express API scales with user activity (campaigns, canvassing)
  • Media API scales with video library size
  • Horizontal scaling: run multiple instances behind nginx load balancer
"},{"location":"v2/architecture/dual-api/#clear-service-boundaries","title":"Clear Service Boundaries","text":"

Microservice preparation without full microservices complexity:

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

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

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

14 Feature Modules:

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

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

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

Layered Structure:

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

Example: Campaign Module

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

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

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

Connection Pooling:

Prisma manages connection pool automatically:

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

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

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

4 Feature Modules:

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

Plugin-Based:

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

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

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

Connection:

Drizzle uses the same PostgreSQL connection pool:

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

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

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

Single Database, Multiple Schemas:

  • Prisma Tables \u2014 Main schema (User, Campaign, Location, etc.)
  • Drizzle Tables \u2014 Media schema (videos, jobs, reactions)

Both ORMs connect to the same changemaker_v2 database:

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

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

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

Both APIs use Redis for:

  • Caching \u2014 Postal codes (Express), video metadata (Fastify)
  • Rate Limiting \u2014 Redis-backed limits (Express: 30/hour, Fastify: 100/min)
  • BullMQ Queues \u2014 Email queue (Express), job queue (Fastify)
// Shared Redis connection\nimport Redis from 'ioredis';\n\nexport const redis = new Redis({\n  host: 'redis-changemaker',\n  port: 6379,\n  password: process.env.REDIS_PASSWORD,\n  maxRetriesPerRequest: 3\n});\n
"},{"location":"v2/architecture/dual-api/#jwt-authentication","title":"JWT Authentication","text":"

Both APIs verify the same JWT tokens:

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

Shared Secret: JWT_ACCESS_SECRET environment variable

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

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

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

Why Order Matters:

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

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

Simple GET Request (JSON response):

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

Large Upload (1GB file):

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

Real-World Usage:

  • Express handles 95% of requests (campaigns, users, locations)
  • Fastify handles 5% of requests (video uploads, media library)
  • Both run comfortably on single-core containers
"},{"location":"v2/architecture/dual-api/#future-full-microservices","title":"Future: Full Microservices","text":"

The dual API design prepares for future microservices migration:

"},{"location":"v2/architecture/dual-api/#potential-split","title":"Potential Split","text":"
\u251c\u2500\u2500 campaign-service/     # Express API (Influence module)\n\u251c\u2500\u2500 map-service/          # Express API (Map module)\n\u251c\u2500\u2500 media-service/        # Fastify API (Media module)\n\u251c\u2500\u2500 auth-service/         # Shared authentication\n\u2514\u2500\u2500 api-gateway/          # Nginx or Kong\n
"},{"location":"v2/architecture/dual-api/#benefits","title":"Benefits","text":"
  • Independent deployment \u2014 Ship campaign features without redeploying map
  • Technology flexibility \u2014 Use Go for high-throughput, Python for ML
  • Team ownership \u2014 Separate teams own separate services
  • Fault isolation \u2014 Media service crash doesn't affect campaigns
"},{"location":"v2/architecture/dual-api/#trade-offs","title":"Trade-offs","text":"
  • Operational complexity \u2014 More containers, more monitoring
  • Network latency \u2014 Inter-service calls over HTTP
  • Data consistency \u2014 Distributed transactions harder
  • Development overhead \u2014 Multiple repos, versioning

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

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

Both APIs expose Prometheus metrics:

  • Express: http://localhost:4000/api/metrics
  • Fastify: http://localhost:4100/metrics

Custom Metrics:

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

Prometheus scrapes both endpoints every 15 seconds.

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

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

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

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

Cause: client_max_body_size too small.

Fix: Increase in nginx config:

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

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

Fix: Increase connection limit in DATABASE_URL:

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

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

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

Cause: Different JWT_ACCESS_SECRET values.

Fix: Ensure both APIs use the same secret:

# .env\nJWT_ACCESS_SECRET=<same-value-for-both>\n
"},{"location":"v2/architecture/dual-api/#further-reading","title":"Further Reading","text":"
  • Database Architecture \u2014 Prisma vs Drizzle schemas
  • Authentication Flow \u2014 JWT implementation
  • Monitoring Stack \u2014 Prometheus metrics
  • Nginx Configuration \u2014 Reverse proxy setup
  • Scaling Strategies \u2014 Horizontal scaling
"},{"location":"v2/backend/","title":"Backend Overview","text":"

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

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

The backend consists of two complementary API servers:

  • Express API (Port 4000) - Main V2 features with Prisma ORM + PostgreSQL
  • Fastify Media API (Port 4100) - Video library with Drizzle ORM (shared database)

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

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

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

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

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

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

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

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

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

"},{"location":"v2/backend/#technology-stack","title":"Technology Stack","text":"
  • Runtime: Node.js 20+ with TypeScript 5.x
  • Main Framework: Express.js (TypeScript)
  • Media Framework: Fastify (TypeScript)
  • ORMs:
  • Prisma (main API)
  • Drizzle (media API)
  • Database: PostgreSQL 16
  • Cache/Queue: Redis 7 with BullMQ
  • Validation: Zod schemas
  • Authentication: JWT with bcrypt
"},{"location":"v2/backend/#api-structure","title":"API Structure","text":"
api/\n\u251c\u2500\u2500 src/\n\u2502   \u251c\u2500\u2500 server.ts              # Express API entry point (port 4000)\n\u2502   \u251c\u2500\u2500 media-server.ts        # Fastify media API (port 4100)\n\u2502   \u251c\u2500\u2500 config/\n\u2502   \u2502   \u2514\u2500\u2500 env.ts             # Environment configuration\n\u2502   \u251c\u2500\u2500 middleware/            # Auth, RBAC, validation, rate limiting\n\u2502   \u251c\u2500\u2500 modules/               # Feature modules\n\u2502   \u251c\u2500\u2500 services/              # Shared services\n\u2502   \u251c\u2500\u2500 types/                 # TypeScript definitions\n\u2502   \u2514\u2500\u2500 utils/                 # Helper utilities\n\u251c\u2500\u2500 prisma/\n\u2502   \u251c\u2500\u2500 schema.prisma          # Main database schema (30+ models)\n\u2502   \u2514\u2500\u2500 migrations/            # Database migrations\n\u2514\u2500\u2500 drizzle/                   # Media API schema\n
"},{"location":"v2/backend/#related-documentation","title":"Related Documentation","text":"
  • Architecture Overview
  • Database Schema
  • API Reference
  • Development Guide
"},{"location":"v2/backend/#quick-links","title":"Quick Links","text":"
  • Authentication Module
  • Campaign Management
  • Location Services
  • Media Management
  • Email Service
  • Geocoding Service
"},{"location":"v2/backend/middleware/","title":"Backend Middleware","text":"

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

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

Express middleware functions are composed in the request pipeline to:

  • Authenticate users via JWT tokens
  • Authorize access based on user roles
  • Validate request bodies against Zod schemas
  • Apply rate limits to prevent abuse
  • Handle errors consistently
  • Log requests and responses
"},{"location":"v2/backend/middleware/#core-middleware","title":"Core Middleware","text":""},{"location":"v2/backend/middleware/#authentication","title":"Authentication","text":"

authenticate (middleware/auth.ts)

  • Verifies JWT access tokens from Authorization header
  • Attaches req.user object with user ID, email, and role
  • Returns 401 Unauthorized for missing/invalid tokens
  • Supports both admin and public route protection
router.get('/profile', authenticate, async (req, res) => {\n  // req.user is guaranteed to exist\n  const userId = req.user.id;\n});\n
"},{"location":"v2/backend/middleware/#authorization","title":"Authorization","text":"

requireRole (middleware/auth.ts)

  • Checks if authenticated user has one of the required roles
  • Returns 403 Forbidden if role check fails
  • Supports multiple roles (OR logic)
router.post(\n  '/campaigns',\n  authenticate,\n  requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'),\n  async (req, res) => {\n    // Only admins can create campaigns\n  }\n);\n

requireNonTemp (middleware/auth.ts)

  • Blocks TEMP users from accessing endpoints
  • Used for routes that require full user accounts
  • TEMP users are created during shift signups
router.post(\n  '/shifts/:id/signup',\n  authenticate,\n  requireNonTemp,\n  async (req, res) => {\n    // TEMP users cannot sign up for shifts\n  }\n);\n
"},{"location":"v2/backend/middleware/#validation","title":"Validation","text":"

validate (middleware/validate.ts)

  • Validates request body against Zod schemas
  • Returns 400 Bad Request with sanitized error messages
  • Supports nested validation and type coercion
  • Prevents injection attacks by sanitizing output
import { validate } from '../middleware/validate';\nimport { createCampaignSchema } from './campaigns.schemas';\n\nrouter.post(\n  '/campaigns',\n  authenticate,\n  validate(createCampaignSchema),\n  async (req, res) => {\n    // req.body is type-safe and validated\n  }\n);\n
"},{"location":"v2/backend/middleware/#rate-limiting","title":"Rate Limiting","text":"

rateLimit (middleware/rate-limit.ts)

  • Uses Redis for distributed rate limiting
  • Configurable window size and max requests
  • Returns 429 Too Many Requests when limit exceeded
  • Per-IP tracking with X-RateLimit headers

Common Configurations:

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

errorHandler (middleware/error-handler.ts)

  • Catches all unhandled errors in routes
  • Formats errors consistently as JSON
  • Logs errors with Winston
  • Sanitizes error messages in production
  • Returns appropriate HTTP status codes
"},{"location":"v2/backend/middleware/#request-logging","title":"Request Logging","text":"

requestLogger (middleware/logger.ts)

  • Logs all incoming requests with Morgan
  • Tracks response times
  • Formats logs with Winston
  • Separates error logs from access logs
"},{"location":"v2/backend/middleware/#middleware-composition","title":"Middleware Composition","text":"

Middleware is applied in order and can be composed:

// Global middleware (applies to all routes)\napp.use(helmet());\napp.use(cors());\napp.use(requestLogger);\n\n// Route-specific middleware\nrouter.post(\n  '/api/campaigns',\n  rateLimit({ max: 100 }),\n  authenticate,\n  requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'),\n  validate(createCampaignSchema),\n  campaignController.create\n);\n\n// Global error handler (last middleware)\napp.use(errorHandler);\n
"},{"location":"v2/backend/middleware/#security-features","title":"Security Features","text":""},{"location":"v2/backend/middleware/#password-policy","title":"Password Policy","text":"
  • Enforced at schema validation level
  • 12+ characters required
  • Must include uppercase, lowercase, and digit
  • Validated in auth.schemas.ts
"},{"location":"v2/backend/middleware/#user-enumeration-prevention","title":"User Enumeration Prevention","text":"
  • Returns 401 instead of 404 for missing users
  • Consistent response times for invalid credentials
  • No detailed error messages about which field failed
"},{"location":"v2/backend/middleware/#refresh-token-rotation","title":"Refresh Token Rotation","text":"
  • Atomic transaction to prevent race conditions
  • Invalidates old refresh token on rotation
  • Tracks token family for security
  • Automatic cleanup of expired tokens
"},{"location":"v2/backend/middleware/#redis-authentication","title":"Redis Authentication","text":"
  • All Redis connections require password
  • Configured via REDIS_PASSWORD environment variable
  • Prevents unauthorized cache/queue access
"},{"location":"v2/backend/middleware/#middleware-chain","title":"Middleware Chain","text":"

Typical middleware chain for protected routes:

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

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

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

Each module typically contains:

  • Schemas (*.schemas.ts) - Zod validation schemas for requests/responses
  • Service (*.service.ts) - Business logic and database operations
  • Routes (*.routes.ts) - Express router definitions with middleware
  • Types - TypeScript interfaces (when needed beyond Prisma types)

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

"},{"location":"v2/backend/modules/#core-modules","title":"Core Modules","text":""},{"location":"v2/backend/modules/#authentication-user-management","title":"Authentication & User Management","text":"
  • Auth Module - JWT authentication, login, register, refresh tokens, logout
  • Users Module - User CRUD, pagination, search, role management
  • Settings Module - Global site settings singleton
"},{"location":"v2/backend/modules/#influence-advocacy-campaigns","title":"Influence (Advocacy Campaigns)","text":"
  • Campaigns Module - Campaign CRUD, targeting, public views
  • Representatives Module - Represent API integration, representative cache
  • Responses Module - Response wall, moderation, verification, upvoting
"},{"location":"v2/backend/modules/#map-location-services","title":"Map & Location Services","text":"
  • Locations Module - Location CRUD, geocoding, NAR import, CSV operations
  • Cuts Module - Polygon CRUD, spatial queries, point-in-polygon
  • Shifts Module - Shift CRUD, volunteer signups, email notifications
  • Canvass Module - Canvassing sessions, visit tracking, walking routes
"},{"location":"v2/backend/modules/#content-management","title":"Content Management","text":"
  • Pages Module - Landing page CRUD, block library, MkDocs export
  • Email Templates Module - Template CRUD, variable processing, versioning
  • Media Module - Video library, upload, metadata, reactions (Fastify API)
"},{"location":"v2/backend/modules/#supporting-modules","title":"Supporting Modules","text":""},{"location":"v2/backend/modules/#infrastructure","title":"Infrastructure","text":"
  • Services Module - Service health checks and monitoring
  • QR Module - QR code PNG generation
  • Docs Module - MkDocs and Code Server integration
"},{"location":"v2/backend/modules/#integrations","title":"Integrations","text":"
  • Listmonk Module - Newsletter sync, list management
  • Pangolin Module - Tunnel management, resource configuration
  • Observability Module - Prometheus/Grafana integration
"},{"location":"v2/backend/modules/#email-queuing","title":"Email & Queuing","text":"
  • Campaign Emails Module - Email tracking, statistics
  • Email Queue Module - BullMQ queue administration
  • Postal Codes Module - Postal code caching service
"},{"location":"v2/backend/modules/#geocoding-spatial","title":"Geocoding & Spatial","text":"
  • Geocoding Module - Multi-provider geocoding (6 providers)
  • Tracking Module - GPS tracking sessions (volunteer + admin)
  • Map Settings Module - Map configuration singleton
"},{"location":"v2/backend/modules/#module-list","title":"Module List","text":"Module Purpose Routes auth Authentication & sessions /api/auth/* users User management /api/users/* settings Site settings /api/settings/* campaigns Advocacy campaigns /api/campaigns/* representatives Representative lookup /api/representatives/* responses Response wall /api/responses/* locations Location database /api/locations/* cuts Geographic cuts /api/cuts/* shifts Volunteer shifts /api/shifts/* canvass Canvassing system /api/canvass/* pages Landing pages /api/pages/* media Video library /media-api/* (port 4100)"},{"location":"v2/backend/modules/#related-documentation","title":"Related Documentation","text":"
  • Backend Overview
  • Services
  • Middleware
  • Database Models
  • API Reference
"},{"location":"v2/backend/modules/auth/","title":"Auth Module","text":""},{"location":"v2/backend/modules/auth/#overview","title":"Overview","text":"

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

Key Features:

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

Authenticate user with email and password.

Request Body:

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

Response (200 OK):

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

Error Responses:

  • 401 Unauthorized: Invalid email or password (prevents user enumeration)
  • 403 Forbidden: Account is suspended/banned or expired
  • 429 Too Many Requests: Rate limit exceeded

Implementation:

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

Security Features:

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

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

Request Body:

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

Response (201 Created):

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

Error Responses:

  • 409 Conflict: Email already registered
  • 400 Bad Request: Password doesn't meet complexity requirements
  • 429 Too Many Requests: Rate limit exceeded

Password Policy:

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

Security Notes:

  • Role is always set to USER server-side (not user-controllable)
  • Password hashed with bcrypt (12 salt rounds)
  • Immediately issues access + refresh tokens (auto-login after registration)
"},{"location":"v2/backend/modules/auth/#post-apiauthrefresh","title":"POST /api/auth/refresh","text":"

Refresh access token using a valid refresh token. Implements token rotation for security.

Request Body:

{\n  \"refreshToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n}\n

Response (200 OK):

{\n  \"user\": {\n    \"id\": \"clx1234567890\",\n    \"email\": \"user@example.com\",\n    \"name\": \"John Doe\",\n    \"role\": \"USER\",\n    \"status\": \"ACTIVE\",\n    ...\n  },\n  \"accessToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n  \"refreshToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n}\n

Error Responses:

  • 401 Unauthorized: Invalid, expired, or not found refresh token

Token Rotation Flow:

// Atomic transaction ensures no race condition\nconst tokens = await prisma.$transaction(async (tx) => {\n  // 1. Delete old refresh token\n  await tx.refreshToken.delete({ where: { id: stored.id } });\n\n  // 2. Generate new token pair\n  const accessToken = this.generateAccessToken(stored.user);\n  const refreshToken = jwt.sign(refreshPayload, env.JWT_REFRESH_SECRET, {\n    expiresIn: env.JWT_REFRESH_EXPIRY,\n  });\n\n  // 3. Store new refresh token\n  await tx.refreshToken.create({\n    data: {\n      token: refreshToken,\n      userId: stored.user.id,\n      expiresAt: new Date(decoded.exp * 1000),\n    },\n  });\n\n  return { accessToken, refreshToken };\n});\n

Security Features:

  1. Atomic Rotation: Old token deleted and new token created in single transaction
  2. Expiration Check: Validates refresh token hasn't expired
  3. Database Validation: Checks token exists in database (prevents replay attacks)
  4. Automatic Cleanup: Expired tokens deleted on access attempt
"},{"location":"v2/backend/modules/auth/#post-apiauthlogout","title":"POST /api/auth/logout","text":"

Invalidate a refresh token.

Request Body:

{\n  \"refreshToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n}\n

Response (200 OK):

{\n  \"message\": \"Logged out\"\n}\n

Implementation:

async logout(refreshToken: string) {\n  await prisma.refreshToken.deleteMany({ where: { token: refreshToken } });\n}\n

Notes:

  • Uses deleteMany (safe if token doesn't exist)
  • Client should discard access token immediately
  • Access tokens remain valid until expiry (15 minutes)
"},{"location":"v2/backend/modules/auth/#get-apiauthme","title":"GET /api/auth/me","text":"

Get current authenticated user's profile.

Request Headers:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\n

Response (200 OK):

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

Error Responses:

  • 401 Unauthorized: Missing, invalid, or expired access token
  • 401 Unauthorized: User not found (prevents user enumeration - same code as invalid token)

Security Note:

Returns 401 instead of 404 when user not found to prevent user enumeration.

"},{"location":"v2/backend/modules/auth/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/auth/#authserviceloginemail-password","title":"authService.login(email, password)","text":"

Purpose: Authenticate user and generate token pair.

Flow:

  1. Find user by email
  2. Compare password with bcrypt
  3. Validate account status (ACTIVE, not expired)
  4. Record login metrics
  5. Update lastLoginAt timestamp
  6. Generate access + refresh token pair
  7. Return user (without password) + tokens

Error Handling:

if (!user) {\n  recordLoginAttempt('failure');\n  throw new AppError(401, 'Invalid email or password', 'INVALID_CREDENTIALS');\n}\n\nconst valid = await bcrypt.compare(password, user.password);\nif (!valid) {\n  recordLoginAttempt('failure');\n  throw new AppError(401, 'Invalid email or password', 'INVALID_CREDENTIALS');\n}\n\nif (user.status !== UserStatus.ACTIVE) {\n  recordLoginAttempt('failure');\n  throw new AppError(403, `Account is ${user.status.toLowerCase()}`, 'ACCOUNT_INACTIVE');\n}\n
"},{"location":"v2/backend/modules/auth/#authserviceregisterdata","title":"authService.register(data)","text":"

Purpose: Create new user account with hashed password.

Flow:

  1. Check if email already exists
  2. Hash password with bcrypt (12 salt rounds)
  3. Create user with USER role
  4. Generate token pair
  5. Return user (without password) + tokens

Implementation:

const hashedPassword = await bcrypt.hash(data.password, 12);\n\nconst user = await prisma.user.create({\n  data: {\n    email: data.email,\n    password: hashedPassword,\n    name: data.name,\n    phone: data.phone,\n    role: UserRole.USER, // Always USER for public registration\n  },\n});\n
"},{"location":"v2/backend/modules/auth/#authservicerefreshtokensrefreshtoken","title":"authService.refreshTokens(refreshToken)","text":"

Purpose: Rotate refresh token and issue new access token.

Security:

  • Atomic transaction (delete old + create new)
  • Validates token signature with JWT_REFRESH_SECRET
  • Checks database for token existence
  • Validates expiration timestamp
  • Prevents replay attacks
"},{"location":"v2/backend/modules/auth/#authservicegenerateaccesstokenuser","title":"authService.generateAccessToken(user)","text":"

Purpose: Create short-lived JWT for API authentication.

Token Payload:

interface TokenPayload {\n  id: string;\n  email: string;\n  role: UserRole;\n}\n

Configuration:

  • Secret: JWT_ACCESS_SECRET environment variable
  • Expiry: JWT_ACCESS_EXPIRY (default: 15m)
  • Algorithm: HS256 (HMAC with SHA-256)

Usage:

const accessToken = authService.generateAccessToken(user);\n// Returns: \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n
"},{"location":"v2/backend/modules/auth/#authservicegeneraterefreshtokenuser","title":"authService.generateRefreshToken(user)","text":"

Purpose: Create long-lived JWT and store in database.

Configuration:

  • Secret: JWT_REFRESH_SECRET (must differ from access secret)
  • Expiry: JWT_REFRESH_EXPIRY (default: 7d)
  • Storage: Database (RefreshToken table)

Implementation:

const token = jwt.sign(payload, env.JWT_REFRESH_SECRET, {\n  expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'],\n});\n\nconst decoded = jwt.decode(token) as { exp: number };\nconst expiresAt = new Date(decoded.exp * 1000);\n\nawait prisma.refreshToken.create({\n  data: {\n    token,\n    userId: user.id,\n    expiresAt,\n  },\n});\n
"},{"location":"v2/backend/modules/auth/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/auth/#complete-login-flow","title":"Complete Login Flow","text":"
// Client: Login request\nconst response = await axios.post('/api/auth/login', {\n  email: 'user@example.com',\n  password: 'SecurePass123'\n});\n\nconst { user, accessToken, refreshToken } = response.data;\n\n// Store tokens\nlocalStorage.setItem('accessToken', accessToken);\nlocalStorage.setItem('refreshToken', refreshToken);\n\n// Use access token for API requests\naxios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;\n
"},{"location":"v2/backend/modules/auth/#token-refresh-flow","title":"Token Refresh Flow","text":"
// Client: 401 interceptor for automatic token refresh\naxios.interceptors.response.use(\n  response => response,\n  async (error) => {\n    if (error.response?.status === 401 && !error.config._retry) {\n      error.config._retry = true;\n\n      const refreshToken = localStorage.getItem('refreshToken');\n      if (!refreshToken) {\n        // Redirect to login\n        window.location.href = '/login';\n        return Promise.reject(error);\n      }\n\n      try {\n        const { data } = await axios.post('/api/auth/refresh', { refreshToken });\n\n        // Update stored tokens\n        localStorage.setItem('accessToken', data.accessToken);\n        localStorage.setItem('refreshToken', data.refreshToken);\n\n        // Retry original request with new token\n        error.config.headers['Authorization'] = `Bearer ${data.accessToken}`;\n        return axios(error.config);\n      } catch (refreshError) {\n        // Refresh failed, redirect to login\n        localStorage.removeItem('accessToken');\n        localStorage.removeItem('refreshToken');\n        window.location.href = '/login';\n        return Promise.reject(refreshError);\n      }\n    }\n\n    return Promise.reject(error);\n  }\n);\n
"},{"location":"v2/backend/modules/auth/#protected-route-middleware","title":"Protected Route Middleware","text":"
// Server: Protect routes with authentication\nimport { authenticate } from '../../middleware/auth.middleware';\n\nrouter.get('/protected', authenticate, async (req, res) => {\n  // req.user is populated by authenticate middleware\n  const userId = req.user!.id;\n  const userRole = req.user!.role;\n\n  res.json({ message: 'Authenticated!', userId, userRole });\n});\n
"},{"location":"v2/backend/modules/auth/#role-based-access-control","title":"Role-Based Access Control","text":"
import { requireRole } from '../../middleware/rbac.middleware';\nimport { UserRole } from '@prisma/client';\n\n// Only SUPER_ADMIN can access\nrouter.delete('/users/:id',\n  authenticate,\n  requireRole(UserRole.SUPER_ADMIN),\n  async (req, res) => {\n    // Delete user logic\n  }\n);\n\n// Multiple roles allowed\nrouter.post('/campaigns',\n  authenticate,\n  requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN),\n  async (req, res) => {\n    // Create campaign logic\n  }\n);\n
"},{"location":"v2/backend/modules/auth/#environment-configuration","title":"Environment Configuration","text":"

Required environment variables:

# JWT Access Token (15 minutes)\nJWT_ACCESS_SECRET=<random-32-byte-hex>\nJWT_ACCESS_EXPIRY=15m\n\n# JWT Refresh Token (7 days)\nJWT_REFRESH_SECRET=<different-random-32-byte-hex>\nJWT_REFRESH_EXPIRY=7d\n\n# Database\nDATABASE_URL=postgresql://user:password@localhost:5432/changemaker_v2\n\n# Redis (for rate limiting)\nREDIS_URL=redis://:password@localhost:6379\nREDIS_PASSWORD=<redis-password>\n

Generate secrets:

# Generate random secrets (macOS/Linux)\nopenssl rand -hex 32  # For JWT_ACCESS_SECRET\nopenssl rand -hex 32  # For JWT_REFRESH_SECRET (must differ!)\n
"},{"location":"v2/backend/modules/auth/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/backend/modules/auth/#password-policy","title":"Password Policy","text":"
  • Minimum length: 12 characters
  • Complexity: Uppercase, lowercase, digit required
  • Hashing: bcrypt with 12 salt rounds
  • Enforcement: Schema-level validation (cannot be bypassed)
"},{"location":"v2/backend/modules/auth/#rate-limiting","title":"Rate Limiting","text":"
// 10 requests per minute per IP\nexport const authRateLimit = rateLimit({\n  windowMs: 60 * 1000,\n  max: 10,\n  message: 'Too many requests, please try again later',\n  standardHeaders: true,\n  legacyHeaders: false,\n  keyGenerator: (req) => req.ip,\n  store: new RedisStore({\n    client: redis,\n    prefix: 'rl:auth:',\n  }),\n});\n
"},{"location":"v2/backend/modules/auth/#user-enumeration-prevention","title":"User Enumeration Prevention","text":"
  • Login/register errors don't reveal if email exists
  • /api/auth/me returns 401 (not 404) when user not found
  • Consistent error messages and response times
"},{"location":"v2/backend/modules/auth/#token-security","title":"Token Security","text":"
  • Access tokens: Short-lived (15 min), stored in memory
  • Refresh tokens: Long-lived (7 days), stored in database + httpOnly cookie
  • Rotation: Refresh tokens rotated on each use (atomic transaction)
  • Secrets: Access and refresh use different secrets (prevents cross-contamination)
  • Expiration: Automatic cleanup of expired tokens
"},{"location":"v2/backend/modules/auth/#database-security","title":"Database Security","text":"
  • Passwords never returned in API responses (excluded via select or destructuring)
  • Refresh tokens cascade deleted when user deleted
  • Unique constraint on email prevents duplicates
  • Foreign key constraints ensure referential integrity
"},{"location":"v2/backend/modules/auth/#related-documentation","title":"Related Documentation","text":"
  • Architecture: Authentication - Auth flow diagrams
  • Middleware: Auth - JWT verification middleware
  • Middleware: RBAC - Role-based access control
  • Middleware: Rate Limit - Rate limiting configuration
  • Frontend: Auth Store - Zustand auth state management
  • API Reference: Auth - Complete endpoint reference
  • User Guide: Admin - Managing user accounts
  • Security Audit - Feb 2026 security review
"},{"location":"v2/backend/modules/campaigns/","title":"Campaigns Module","text":""},{"location":"v2/backend/modules/campaigns/#overview","title":"Overview","text":"

The Campaigns module manages advocacy email campaigns targeting elected representatives. It provides comprehensive CRUD operations with rich feature flags, automatic slug generation, and role-based visibility controls. Campaigns integrate with the representative lookup system, email sending queue, and public response wall.

Key Features:

  • Full CRUD with pagination, search, and status filtering
  • Auto-generated slugs from campaign titles (collision-safe)
  • Feature flags (SMTP email, mailto links, response wall, highlighting, etc.)
  • Government level targeting (Federal, Provincial, Municipal, School Board)
  • Email count and call count tracking
  • Public vs admin visibility (non-admins see only their own campaigns)
  • Integration with email queue, representatives, and responses modules
  • Cover photo support (URL-based)
"},{"location":"v2/backend/modules/campaigns/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/influence/campaigns/campaigns.routes.ts Admin router with 5 CRUD endpoints api/src/modules/influence/campaigns/campaigns-public.routes.ts Public router (2 endpoints, no auth) api/src/modules/influence/campaigns/campaigns.service.ts Campaign business logic api/src/modules/influence/campaigns/campaigns.schemas.ts Zod validation schemas"},{"location":"v2/backend/modules/campaigns/#database-model","title":"Database Model","text":"
model Campaign {\n  id                      String              @id @default(cuid())\n  slug                    String              @unique\n  title                   String\n  description             String?\n  emailSubject            String\n  emailBody               String\n  callToAction            String?\n  coverPhoto              String?\n  status                  CampaignStatus      @default(DRAFT)\n  targetGovernmentLevels  GovernmentLevel[]\n\n  // Feature flags\n  allowSmtpEmail          Boolean             @default(true)\n  allowMailtoLink         Boolean             @default(true)\n  collectUserInfo         Boolean             @default(true)\n  showEmailCount          Boolean             @default(true)\n  showCallCount           Boolean             @default(true)\n  allowEmailEditing       Boolean             @default(false)\n  allowCustomRecipients   Boolean             @default(false)\n  showResponseWall        Boolean             @default(false)\n  highlightCampaign       Boolean             @default(false)\n\n  // Creator tracking\n  createdByUserId         String\n  createdByUserEmail      String\n  createdByUserName       String?\n\n  // Relations\n  emails                  CampaignEmail[]\n  responses               Response[]\n  customRecipients        CustomRecipient[]\n\n  createdAt               DateTime            @default(now())\n  updatedAt               DateTime            @updatedAt\n\n  @@index([status])\n  @@index([createdByUserId])\n}\n\nenum CampaignStatus {\n  DRAFT      // Not visible to public\n  ACTIVE     // Live on public site\n  PAUSED     // Temporarily hidden\n  ARCHIVED   // Completed/historical\n}\n\nenum GovernmentLevel {\n  FEDERAL        // MPs, Prime Minister\n  PROVINCIAL     // MPPs, MLAs, Premier\n  MUNICIPAL      // Councillors, Mayor\n  SCHOOL_BOARD   // School board trustees\n}\n
"},{"location":"v2/backend/modules/campaigns/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/campaigns/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"Method Path Auth Description GET /api/campaigns Admin roles List campaigns with pagination/filters GET /api/campaigns/:id Admin roles Get single campaign by ID POST /api/campaigns Admin roles Create new campaign PUT /api/campaigns/:id Admin roles Update campaign DELETE /api/campaigns/:id Admin roles Delete campaign

Admin Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

"},{"location":"v2/backend/modules/campaigns/#public-endpoints-no-authentication","title":"Public Endpoints (No Authentication)","text":"Method Path Auth Description GET /api/public/campaigns None List active/highlighted campaigns GET /api/public/campaigns/:slug None Get campaign by slug"},{"location":"v2/backend/modules/campaigns/#admin-endpoint-details","title":"Admin Endpoint Details","text":""},{"location":"v2/backend/modules/campaigns/#get-apicampaigns","title":"GET /api/campaigns","text":"

List campaigns with pagination, search, and filtering. Non-admin users see only their own campaigns.

Query Parameters:

Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) search string No - Search title or description status CampaignStatus No - Filter by status

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/campaigns?page=1&limit=10&search=climate&status=ACTIVE\"\n

Response (200 OK):

{\n  \"campaigns\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"slug\": \"climate-action-now\",\n      \"title\": \"Climate Action Now\",\n      \"description\": \"Demand bold climate policies from your representatives\",\n      \"emailSubject\": \"Pass the Climate Emergency Bill\",\n      \"emailBody\": \"Dear [Representative Name],\\n\\n...\",\n      \"callToAction\": \"Send your email now!\",\n      \"coverPhoto\": \"https://example.com/climate.jpg\",\n      \"status\": \"ACTIVE\",\n      \"targetGovernmentLevels\": [\"FEDERAL\", \"PROVINCIAL\"],\n      \"allowSmtpEmail\": true,\n      \"allowMailtoLink\": true,\n      \"collectUserInfo\": true,\n      \"showEmailCount\": true,\n      \"showCallCount\": false,\n      \"allowEmailEditing\": false,\n      \"allowCustomRecipients\": false,\n      \"showResponseWall\": true,\n      \"highlightCampaign\": true,\n      \"createdByUserId\": \"clx0987654321\",\n      \"createdByUserEmail\": \"admin@example.com\",\n      \"createdByUserName\": \"Admin User\",\n      \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n      \"updatedAt\": \"2026-02-11T12:00:00.000Z\",\n      \"_count\": {\n        \"emails\": 342,\n        \"responses\": 89\n      }\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 10,\n    \"total\": 15,\n    \"totalPages\": 2\n  }\n}\n

Visibility Rules:

// Non-admin users only see their own campaigns\nconst adminRoles: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];\nif (user && !adminRoles.includes(user.role)) {\n  where.createdByUserId = user.id;\n}\n
"},{"location":"v2/backend/modules/campaigns/#post-apicampaigns","title":"POST /api/campaigns","text":"

Create new campaign with auto-generated slug.

Request Body:

{\n  \"title\": \"Climate Action Now\",\n  \"description\": \"Demand bold climate policies\",\n  \"emailSubject\": \"Pass the Climate Emergency Bill\",\n  \"emailBody\": \"Dear [Representative Name],\\n\\nI urge you to...\",\n  \"callToAction\": \"Send your email now!\",\n  \"coverPhoto\": \"https://example.com/climate.jpg\",\n  \"status\": \"DRAFT\",\n  \"targetGovernmentLevels\": [\"FEDERAL\", \"PROVINCIAL\"],\n  \"allowSmtpEmail\": true,\n  \"allowMailtoLink\": true,\n  \"showResponseWall\": true,\n  \"highlightCampaign\": true\n}\n

Response (201 Created):

Returns created campaign object (same format as GET).

Slug Generation:

function generateSlug(title: string): string {\n  return title\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, '-')  // Replace non-alphanumeric with -\n    .replace(/^-+|-+$/g, '')      // Remove leading/trailing -\n    .slice(0, 80);                // Max 80 chars\n}\n\n// Collision detection\nasync function resolveSlugCollision(slug: string, excludeId?: string): Promise<string> {\n  let candidate = slug;\n  let suffix = 2;\n\n  while (true) {\n    const existing = await prisma.campaign.findUnique({ where: { slug: candidate } });\n    if (!existing || (excludeId && existing.id === excludeId)) {\n      return candidate;\n    }\n    candidate = `${slug}-${suffix}`;  // climate-action-now-2\n    suffix++;\n  }\n}\n

Example Slug Transformations:

  • \"Climate Action NOW!\" \u2192 climate-action-now
  • \"Email Your MP: Support Bill C-12\" \u2192 email-your-mp-support-bill-c-12
  • \"Climate Action Now\" (2nd with same title) \u2192 climate-action-now-2
"},{"location":"v2/backend/modules/campaigns/#put-apicampaignsid","title":"PUT /api/campaigns/:id","text":"

Update campaign. Partial updates supported. Slug regenerated if title changes.

Request Body (Partial):

{\n  \"status\": \"ACTIVE\",\n  \"highlightCampaign\": true,\n  \"showResponseWall\": true\n}\n

Response (200 OK):

Returns updated campaign object.

"},{"location":"v2/backend/modules/campaigns/#delete-apicampaignsid","title":"DELETE /api/campaigns/:id","text":"

Delete campaign and cascade to related records.

Response (204 No Content):

No response body.

Cascading Deletes:

  • Campaign emails (all email send records)
  • Responses (all user responses)
  • Custom recipients
"},{"location":"v2/backend/modules/campaigns/#public-endpoint-details","title":"Public Endpoint Details","text":""},{"location":"v2/backend/modules/campaigns/#get-apipubliccampaigns","title":"GET /api/public/campaigns","text":"

List active and highlighted campaigns (no auth required).

Query Parameters:

Parameter Type Description highlighted boolean Filter to highlighted campaigns only limit number Results per page (max 50, default 20)

Example Request:

curl http://api.cmlite.org/api/public/campaigns?highlighted=true&limit=10\n

Response (200 OK):

{\n  \"campaigns\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"slug\": \"climate-action-now\",\n      \"title\": \"Climate Action Now\",\n      \"description\": \"Demand bold climate policies\",\n      \"callToAction\": \"Send your email now!\",\n      \"coverPhoto\": \"https://example.com/climate.jpg\",\n      \"status\": \"ACTIVE\",\n      \"highlightCampaign\": true,\n      \"showEmailCount\": true,\n      \"showCallCount\": false,\n      \"_count\": {\n        \"emails\": 342,\n        \"responses\": 89\n      }\n    }\n  ]\n}\n

Filtering:

const where: Prisma.CampaignWhereInput = {\n  status: CampaignStatus.ACTIVE,  // Only active campaigns\n};\n\nif (highlighted === 'true') {\n  where.highlightCampaign = true;\n}\n
"},{"location":"v2/backend/modules/campaigns/#get-apipubliccampaignsslug","title":"GET /api/public/campaigns/:slug","text":"

Get campaign by slug (no auth required).

Path Parameters:

  • slug (string): Campaign slug

Example Request:

curl http://api.cmlite.org/api/public/campaigns/climate-action-now\n

Response (200 OK):

Returns full campaign object (same as admin GET).

Error Responses:

  • 404 Not Found: Campaign not found or not ACTIVE
"},{"location":"v2/backend/modules/campaigns/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/campaigns/#campaignsservicefindallfilters-user","title":"campaignsService.findAll(filters, user)","text":"

List campaigns with role-based visibility.

Visibility Logic:

// Admin users see all campaigns\n// Non-admin users see only their own campaigns\nconst adminRoles: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];\nif (user && !adminRoles.includes(user.role)) {\n  where.createdByUserId = user.id;\n}\n
"},{"location":"v2/backend/modules/campaigns/#campaignsservicecreatedata-user","title":"campaignsService.create(data, user)","text":"

Create campaign with auto-generated slug and creator tracking.

Creator Tracking:

const campaign = await prisma.campaign.create({\n  data: {\n    ...data,\n    slug: await resolveSlugCollision(generateSlug(data.title)),\n    createdByUserId: user.id,\n    createdByUserEmail: user.email,\n    createdByUserName: user.name || null,\n  },\n  select: campaignSelect,\n});\n
"},{"location":"v2/backend/modules/campaigns/#campaignsserviceupdateid-data","title":"campaignsService.update(id, data)","text":"

Update campaign. Regenerates slug if title changes.

Slug Regeneration:

if (data.title) {\n  const newSlug = generateSlug(data.title);\n  updateData.slug = await resolveSlugCollision(newSlug, id);\n}\n
"},{"location":"v2/backend/modules/campaigns/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/campaigns/#create-campaign-schema","title":"Create Campaign Schema","text":"
export const createCampaignSchema = z.object({\n  title: z.string().min(1, 'Title is required'),\n  description: z.string().optional(),\n  emailSubject: z.string().min(1, 'Email subject is required'),\n  emailBody: z.string().min(1, 'Email body is required'),\n  callToAction: z.string().optional(),\n  status: z.nativeEnum(CampaignStatus).optional().default(CampaignStatus.DRAFT),\n  targetGovernmentLevels: z.array(z.nativeEnum(GovernmentLevel)).optional().default([]),\n  allowSmtpEmail: z.boolean().optional().default(true),\n  allowMailtoLink: z.boolean().optional().default(true),\n  collectUserInfo: z.boolean().optional().default(true),\n  showEmailCount: z.boolean().optional().default(true),\n  showCallCount: z.boolean().optional().default(true),\n  allowEmailEditing: z.boolean().optional().default(false),\n  allowCustomRecipients: z.boolean().optional().default(false),\n  showResponseWall: z.boolean().optional().default(false),\n  highlightCampaign: z.boolean().optional().default(false),\n  coverPhoto: z.string().optional(),\n});\n
"},{"location":"v2/backend/modules/campaigns/#feature-flags","title":"Feature Flags","text":"Flag Default Description allowSmtpEmail true Enable direct SMTP email sending via queue allowMailtoLink true Show mailto: link option (opens default email client) collectUserInfo true Collect sender name, email, postal code showEmailCount true Display email send count on public page showCallCount true Display call count (future feature) allowEmailEditing false Let users edit email template before sending allowCustomRecipients false Allow manual recipient selection (overrides postal code lookup) showResponseWall false Enable public response submission + display highlightCampaign false Featured campaign (shown on homepage)"},{"location":"v2/backend/modules/campaigns/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/campaigns/#admin-create-campaign","title":"Admin: Create Campaign","text":"
import { api } from '@/lib/api';\n\nconst createCampaign = async () => {\n  const { data } = await api.post('/api/campaigns', {\n    title: 'Climate Action Now',\n    emailSubject: 'Pass the Climate Emergency Bill',\n    emailBody: 'Dear [Representative Name],\\n\\nI urge you to support immediate climate action...',\n    targetGovernmentLevels: ['FEDERAL', 'PROVINCIAL'],\n    status: 'DRAFT',\n    showResponseWall: true,\n    highlightCampaign: true,\n  });\n\n  console.log(`Campaign created: ${data.slug}`);\n  return data;\n};\n
"},{"location":"v2/backend/modules/campaigns/#public-list-active-campaigns","title":"Public: List Active Campaigns","text":"
import axios from 'axios';\n\nconst fetchActiveCampaigns = async () => {\n  const { data } = await axios.get('/api/public/campaigns?highlighted=true');\n  return data.campaigns;\n};\n
"},{"location":"v2/backend/modules/campaigns/#admin-update-campaign-status","title":"Admin: Update Campaign Status","text":"
import { api } from '@/lib/api';\n\nconst publishCampaign = async (id: string) => {\n  const { data } = await api.put(`/api/campaigns/${id}`, {\n    status: 'ACTIVE',\n  });\n\n  message.success('Campaign published!');\n  return data;\n};\n
"},{"location":"v2/backend/modules/campaigns/#frontend-integration","title":"Frontend Integration","text":"

The CampaignsPage component (admin/src/pages/CampaignsPage.tsx) provides:

  • Paginated table with search and status filter
  • Feature flag badges (SMTP, Response Wall, Highlighted, etc.)
  • Create campaign modal with rich text editor (TinyMCE/Quill)
  • Edit campaign modal (pre-populated form)
  • Delete confirmation modal
  • Email count drawer (shows campaign email stats)
  • Publish/archive actions (status toggle)

State Management:

const [campaigns, setCampaigns] = useState<Campaign[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });\nconst [filters, setFilters] = useState({ search: '', status: null });\n
"},{"location":"v2/backend/modules/campaigns/#related-documentation","title":"Related Documentation","text":"
  • Representatives Module - Postal code \u2192 rep lookup
  • Responses Module - Response wall + moderation
  • Campaign Emails Module - Email tracking
  • Email Queue Module - BullMQ email sending
  • Frontend: CampaignsPage - Campaign management UI
  • Frontend: Public Campaign Page - Public campaign view
  • API Reference: Campaigns - Complete endpoint reference
  • User Guide: Campaign Manager - Creating campaigns guide
"},{"location":"v2/backend/modules/canvass/","title":"Canvass Module","text":""},{"location":"v2/backend/modules/canvass/#overview","title":"Overview","text":"

The Canvass module powers the volunteer canvassing system, enabling door-to-door outreach with GPS tracking, visit recording, walking route optimization, and real-time progress monitoring. It features role-based permissions, automated session management, and comprehensive analytics for campaign organizers.

Key Features:

  • Canvass session management (start, end, abandon detection)
  • Visit recording with outcomes (CONTACTED, SUPPORTER, NOT_HOME, REFUSED, etc.)
  • Bulk visit recording (mark entire building as NOT_HOME)
  • Walking route optimization (nearest-neighbor algorithm)
  • GPS-enabled location tracking
  • Role-gated field editing (volunteers update support data, admins update PII)
  • Real-time cut completion percentage calculation
  • Admin dashboard (stats, activity feed, volunteer leaderboard)
  • Shift-based assignments (volunteers assigned to cuts via shifts)
  • Rate limiting (30 visits/min per IP, 10 bulk visits/min)
  • Abandoned session cleanup (ACTIVE > 12h \u2192 ABANDONED)
"},{"location":"v2/backend/modules/canvass/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/map/canvass/canvass.routes.ts 2 routers (volunteer + admin) with 22 endpoints api/src/modules/map/canvass/canvass.service.ts Canvass business logic + session management api/src/modules/map/canvass/canvass.schemas.ts Zod validation schemas api/src/modules/map/canvass/canvass-route.service.ts Walking route optimization algorithm"},{"location":"v2/backend/modules/canvass/#database-models","title":"Database Models","text":""},{"location":"v2/backend/modules/canvass/#canvasssession","title":"CanvassSession","text":"
model CanvassSession {\n  id             String                @id @default(cuid())\n  userId         String\n  user           User                  @relation(fields: [userId], references: [id], onDelete: Cascade)\n  cutId          String\n  cut            Cut                   @relation(fields: [cutId], references: [id], onDelete: Cascade)\n  shiftId        String?\n  shift          Shift?                @relation(fields: [shiftId], references: [id], onDelete: SetNull)\n  status         CanvassSessionStatus  @default(ACTIVE)\n  startLatitude  Float?\n  startLongitude Float?\n  startedAt      DateTime              @default(now())\n  endedAt        DateTime?\n  visits         CanvassVisit[]\n\n  @@index([userId])\n  @@index([cutId])\n  @@index([status])\n  @@map(\"canvass_sessions\")\n}\n\nenum CanvassSessionStatus {\n  ACTIVE     // Currently canvassing\n  COMPLETED  // Ended by volunteer\n  ABANDONED  // Auto-closed after 12h\n}\n
"},{"location":"v2/backend/modules/canvass/#canvassvisit","title":"CanvassVisit","text":"
model CanvassVisit {\n  id              String         @id @default(cuid())\n  addressId       String         // Changed from locationId to support multi-unit buildings\n  address         Address        @relation(fields: [addressId], references: [id], onDelete: Cascade)\n  userId          String\n  user            User           @relation(fields: [userId], references: [id], onDelete: Cascade)\n  sessionId       String?\n  session         CanvassSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)\n  shiftId         String?\n  shift           Shift?         @relation(fields: [shiftId], references: [id], onDelete: SetNull)\n  outcome         VisitOutcome\n  supportLevel    SupportLevel?\n  signRequested   Boolean        @default(false)\n  signSize        String?\n  notes           String?        @db.Text\n  durationSeconds Int?\n  visitedAt       DateTime       @default(now())\n\n  @@index([addressId])\n  @@index([userId])\n  @@index([sessionId])\n  @@index([outcome])\n  @@map(\"canvass_visits\")\n}\n\nenum VisitOutcome {\n  CONTACTED      // Successful conversation\n  SUPPORTER      // Supporter identified\n  NOT_HOME       // No answer\n  REFUSED        // Declined conversation\n  MOVED          // No longer at address\n  WRONG_ADDRESS  // Address doesn't exist\n  CALLBACK       // Requested follow-up\n  INACCESSIBLE   // Cannot access (locked building, no entry)\n}\n
"},{"location":"v2/backend/modules/canvass/#address-model-multi-unit-support","title":"Address Model (Multi-Unit Support)","text":"
model Address {\n  id           String        @id @default(cuid())\n  locationId   String\n  location     Location      @relation(fields: [locationId], references: [id], onDelete: Cascade)\n  unitNumber   String?\n  firstName    String?\n  lastName     String?\n  email        String?\n  phone        String?\n  supportLevel SupportLevel?\n  sign         Boolean       @default(false)\n  signSize     String?\n  notes        String?       @db.Text\n  visits       CanvassVisit[]\n\n  @@index([locationId])\n  @@map(\"addresses\")\n}\n

Multi-Unit Building Support:

  • Location \u2014 Physical building (lat/lng, address, buildingNotes)
  • Address \u2014 Individual unit within building (unitNumber, firstName, lastName, supportLevel, etc.)
  • CanvassVisit \u2014 Links to Address (not Location) for per-unit tracking
"},{"location":"v2/backend/modules/canvass/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/canvass/#volunteer-endpoints-authentication-required-any-role","title":"Volunteer Endpoints (Authentication Required, Any Role)","text":"Method Path Description GET /api/map/canvass/my/assignments Get assigned shifts with cuts GET /api/map/canvass/my/stats Get volunteer statistics GET /api/map/canvass/my/visits List my visit history (paginated) GET /api/map/canvass/my/session Get active canvass session POST /api/map/canvass/sessions Start new canvass session POST /api/map/canvass/sessions/:id/end End canvass session GET /api/map/canvass/cuts/:cutId/locations Get locations in cut for canvassing GET /api/map/canvass/cuts/:cutId/route Get optimized walking route GET /api/map/canvass/locations Get all locations with visit annotations PUT /api/map/canvass/locations/:id Update location (role-gated fields) POST /api/map/canvass/locations Create location (role-gated fields) POST /api/map/canvass/reverse-geocode Reverse geocode lat/lng POST /api/map/canvass/geocode-search Geocode address for map search POST /api/map/canvass/visits Record visit (rate-limited: 30/min) POST /api/map/canvass/visits/bulk Bulk record visits for building (rate-limited: 10/min)"},{"location":"v2/backend/modules/canvass/#admin-endpoints-authentication-required-map_admin-roles","title":"Admin Endpoints (Authentication Required, MAP_ADMIN Roles)","text":"Method Path Description GET /api/map/canvass/stats Get admin statistics GET /api/map/canvass/stats/cuts/:cutId Get cut-specific statistics GET /api/map/canvass/activity Get recent activity feed (paginated) GET /api/map/canvass/volunteers List volunteers with visit counts GET /api/map/canvass/volunteers/:userId Get volunteer statistics GET /api/map/canvass/visits List all visits (paginated, filtered)

Admin Roles: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/backend/modules/canvass/#volunteer-endpoint-details","title":"Volunteer Endpoint Details","text":""},{"location":"v2/backend/modules/canvass/#post-apimapcanvasssessions","title":"POST /api/map/canvass/sessions","text":"

Start new canvass session for a cut.

Request Body:

{\n  \"cutId\": \"clxCut123\",\n  \"shiftId\": \"clxShift456\",\n  \"startLatitude\": 43.6532,\n  \"startLongitude\": -79.3832\n}\n

Response (201 Created):

{\n  \"id\": \"clxSession789\",\n  \"userId\": \"clxUser123\",\n  \"cutId\": \"clxCut123\",\n  \"shiftId\": \"clxShift456\",\n  \"status\": \"ACTIVE\",\n  \"startLatitude\": 43.6532,\n  \"startLongitude\": -79.3832,\n  \"startedAt\": \"2026-02-11T14:00:00.000Z\",\n  \"endedAt\": null,\n  \"cut\": {\n    \"id\": \"clxCut123\",\n    \"name\": \"Downtown Ward 5\"\n  },\n  \"shift\": {\n    \"id\": \"clxShift456\",\n    \"title\": \"Saturday Canvass\"\n  }\n}\n

Validation:

  • Only one active session per user allowed
  • Cut must exist
  • Shift is optional (can canvass outside scheduled shifts)

Error Responses:

  • 409 Conflict: User already has active session
  • 404 Not Found: Cut not found
"},{"location":"v2/backend/modules/canvass/#post-apimapcanvasssessionsidend","title":"POST /api/map/canvass/sessions/:id/end","text":"

End active canvass session.

Path Parameters:

  • id (string): Session ID

Response (200 OK):

Returns updated session with status: COMPLETED and endedAt timestamp.

Post-Processing:

  • Recalculates cut completion percentage
  • Updates Prometheus metrics (active sessions gauge)

Validation:

  • Session must belong to authenticated user
  • Session must be ACTIVE (not already completed/abandoned)
"},{"location":"v2/backend/modules/canvass/#get-apimapcanvassmyassignments","title":"GET /api/map/canvass/my/assignments","text":"

Get volunteer's assigned shifts with associated cuts.

Example Response (200 OK):

[\n  {\n    \"shiftId\": \"clxShift456\",\n    \"shiftTitle\": \"Saturday Canvass\",\n    \"shiftDate\": \"2026-02-15\",\n    \"startTime\": \"10:00\",\n    \"endTime\": \"14:00\",\n    \"location\": \"Community Center, 123 Main St\",\n    \"cutId\": \"clxCut123\",\n    \"cutName\": \"Downtown Ward 5\",\n    \"completionPercentage\": 42\n  }\n]\n

Filtering:

  • Only returns confirmed signups (status: CONFIRMED)
  • Only returns shifts with associated cuts (cutId not null)
  • Ordered by shift date ascending (upcoming shifts first)
"},{"location":"v2/backend/modules/canvass/#get-apimapcanvasscutscutidlocations","title":"GET /api/map/canvass/cuts/:cutId/locations","text":"

Get locations within cut for canvassing with visit annotations.

Path Parameters:

  • cutId (string): Cut ID

Query Parameters:

  • minLat, maxLat, minLng, maxLng (optional): Bounding box for visible map area

Example Response (200 OK):

[\n  {\n    \"id\": \"clxAddress123\",\n    \"unitNumber\": \"Apt 4\",\n    \"firstName\": \"John\",\n    \"lastName\": \"Doe\",\n    \"email\": \"john@example.com\",\n    \"phone\": \"416-555-1234\",\n    \"supportLevel\": \"LEVEL_1\",\n    \"sign\": true,\n    \"signSize\": \"Large\",\n    \"notes\": \"Willing to volunteer\",\n    \"location\": {\n      \"id\": \"clxLocation456\",\n      \"latitude\": 43.6532,\n      \"longitude\": -79.3832,\n      \"address\": \"123 Main St, Toronto, ON\",\n      \"buildingNotes\": \"Intercom code: 1234\"\n    },\n    \"lastVisit\": {\n      \"outcome\": \"CONTACTED\",\n      \"visitedAt\": \"2026-02-10T14:30:00.000Z\",\n      \"visitorName\": \"Jane Smith\",\n      \"isMyVisit\": false\n    }\n  }\n]\n

Two-Stage Filtering:

  1. Database bounds filter \u2014 Fast WHERE clause on lat/lng
  2. Polygon filter \u2014 In-memory point-in-polygon check

Visit Annotations:

  • lastVisit \u2014 Most recent visit to this address (any volunteer)
  • isMyVisit \u2014 True if authenticated user made last visit
  • Null if address never visited
"},{"location":"v2/backend/modules/canvass/#get-apimapcanvasscutscutidroute","title":"GET /api/map/canvass/cuts/:cutId/route","text":"

Get optimized walking route for cut.

Path Parameters:

  • cutId (string): Cut ID

Query Parameters:

  • excludeVisited (boolean, default: false): Exclude already-visited addresses
  • startLatitude (number, optional): Starting position latitude
  • startLongitude (number, optional): Starting position longitude

Example Response (200 OK):

{\n  \"route\": [\n    {\n      \"id\": \"clxAddress123\",\n      \"latitude\": 43.6532,\n      \"longitude\": -79.3832,\n      \"address\": \"123 Main St\",\n      \"unitNumber\": \"Apt 4\",\n      \"distanceFromPrevious\": 0\n    },\n    {\n      \"id\": \"clxAddress124\",\n      \"latitude\": 43.6540,\n      \"longitude\": -79.3825,\n      \"address\": \"125 Main St\",\n      \"unitNumber\": null,\n      \"distanceFromPrevious\": 92.3\n    }\n  ],\n  \"totalDistance\": 1847.6,\n  \"estimatedDuration\": 1680\n}\n

Walking Route Algorithm:

Nearest-neighbor greedy algorithm:

// Start at provided coordinates or first location\nlet current = startCoords || locations[0];\nconst route: RouteStop[] = [];\n\nwhile (unvisited.length > 0) {\n  // Find nearest unvisited location\n  const nearest = findNearest(current, unvisited);\n  const distance = haversineDistance(current, nearest);\n\n  route.push({\n    ...nearest,\n    distanceFromPrevious: distance,\n  });\n\n  current = nearest;\n  unvisited = unvisited.filter(loc => loc.id !== nearest.id);\n}\n\n// Calculate total distance and duration\nconst totalDistance = route.reduce((sum, stop) => sum + stop.distanceFromPrevious, 0);\nconst estimatedDuration = Math.ceil(totalDistance / WALKING_SPEED_MPS); // 1.4 m/s\n

Performance:

  • O(n\u00b2) complexity (acceptable for typical cut sizes <500 locations)
  • Uses haversine distance (meters) for accurate walking distances
  • Assumes walking speed: 1.4 m/s (5 km/h)
"},{"location":"v2/backend/modules/canvass/#post-apimapcanvassvisits","title":"POST /api/map/canvass/visits","text":"

Record visit to an address.

Rate Limiting: 30 requests per minute per IP

Request Body:

{\n  \"addressId\": \"clxAddress123\",\n  \"outcome\": \"CONTACTED\",\n  \"supportLevel\": \"LEVEL_2\",\n  \"signRequested\": true,\n  \"signSize\": \"Large\",\n  \"notes\": \"Interested in volunteering for phone banks\",\n  \"durationSeconds\": 180,\n  \"sessionId\": \"clxSession789\",\n  \"shiftId\": \"clxShift456\",\n  \"updateLocation\": true\n}\n

Field Descriptions:

  • addressId (required): Address ID (unit within building)
  • outcome (required): Visit outcome enum
  • supportLevel (optional): Support level identified during visit
  • signRequested (optional, default: false): Lawn sign requested
  • signSize (optional): Sign size if requested
  • notes (optional): Visit notes
  • durationSeconds (optional): Time spent at door
  • sessionId (optional): Active canvass session ID
  • shiftId (optional): Associated shift ID
  • updateLocation (optional, default: true): Update address record with visit data

Response (201 Created):

Returns created visit object.

Address Update Logic:

If updateLocation=true and outcome is CONTACTED or SUPPORTER:

await prisma.address.update({\n  where: { id: addressId },\n  data: {\n    supportLevel: data.supportLevel || undefined,\n    sign: data.signRequested || undefined,\n    signSize: data.signRequested ? data.signSize : undefined,\n  },\n});\n

Metrics:

  • Increments cm_canvass_visits_total counter with outcome label
  • Updates cut completion percentage
"},{"location":"v2/backend/modules/canvass/#post-apimapcanvassvisitsbulk","title":"POST /api/map/canvass/visits/bulk","text":"

Record visit to all unvisited units in a building.

Rate Limiting: 10 requests per minute per IP (stricter than single visits)

Request Body:

{\n  \"locationId\": \"clxLocation456\",\n  \"outcome\": \"NOT_HOME\",\n  \"notes\": \"Building-wide: No answer at any unit\",\n  \"sessionId\": \"clxSession789\",\n  \"shiftId\": \"clxShift456\"\n}\n

Allowed Outcomes:

Only non-contact outcomes: - NOT_HOME - REFUSED - MOVED

Logic:

  1. Find all addresses at location (building)
  2. Filter to unvisited addresses (no existing visit records)
  3. Create visit records for all unvisited addresses in bulk

Response (201 Created):

{\n  \"created\": 8,\n  \"skipped\": 2,\n  \"locationId\": \"clxLocation456\"\n}\n

Use Cases:

  • Large apartment buildings where no one answers buzzer
  • Entire building marked as MOVED (demolished/vacant)
  • Save time: record 10+ units with single action
"},{"location":"v2/backend/modules/canvass/#put-apimapcanvasslocationsid","title":"PUT /api/map/canvass/locations/:id","text":"

Update location with role-gated field restrictions.

Path Parameters:

  • id (string): Address ID

Request Body (Volunteer):

{\n  \"supportLevel\": \"LEVEL_2\",\n  \"sign\": true,\n  \"signSize\": \"Large\",\n  \"notes\": \"Willing to volunteer\"\n}\n

Request Body (Admin):

{\n  \"firstName\": \"John\",\n  \"lastName\": \"Doe\",\n  \"address\": \"123 Main St, Unit 4\",\n  \"unitNumber\": \"4\",\n  \"email\": \"john@example.com\",\n  \"phone\": \"416-555-1234\",\n  \"supportLevel\": \"LEVEL_2\",\n  \"sign\": true\n}\n

Role-Gated Fields:

All Authenticated Users: - supportLevel - sign - signSize - notes

Admins Only (SUPER_ADMIN, MAP_ADMIN): - firstName - lastName - address - unitNumber - email - phone

TEMP Users:

  • Cannot update any fields (read-only canvassing)

Service-Level Field Stripping:

const isAdmin = role === UserRole.SUPER_ADMIN || role === UserRole.MAP_ADMIN;\nconst isTemp = role === UserRole.TEMP;\n\nif (isTemp) {\n  throw new AppError(403, 'TEMP users cannot edit locations', 'FORBIDDEN');\n}\n\nconst updateData: Prisma.AddressUpdateInput = {};\n\n// Volunteer fields (all authenticated users)\nif (data.supportLevel !== undefined) updateData.supportLevel = data.supportLevel;\nif (data.sign !== undefined) updateData.sign = data.sign;\nif (data.signSize !== undefined) updateData.signSize = data.signSize;\nif (data.notes !== undefined) updateData.notes = data.notes;\n\n// Admin-only PII fields\nif (isAdmin) {\n  if (data.firstName !== undefined) updateData.firstName = data.firstName;\n  if (data.lastName !== undefined) updateData.lastName = data.lastName;\n  if (data.email !== undefined) updateData.email = data.email;\n  if (data.phone !== undefined) updateData.phone = data.phone;\n}\n
"},{"location":"v2/backend/modules/canvass/#admin-endpoint-details","title":"Admin Endpoint Details","text":""},{"location":"v2/backend/modules/canvass/#get-apimapcanvassstats","title":"GET /api/map/canvass/stats","text":"

Get aggregate canvassing statistics.

Example Response (200 OK):

{\n  \"totalVisits\": 3847,\n  \"totalVolunteers\": 42,\n  \"activeSessions\": 7,\n  \"byOutcome\": {\n    \"CONTACTED\": 1892,\n    \"SUPPORTER\": 542,\n    \"NOT_HOME\": 987,\n    \"REFUSED\": 234,\n    \"MOVED\": 89,\n    \"WRONG_ADDRESS\": 43,\n    \"CALLBACK\": 34,\n    \"INACCESSIBLE\": 26\n  },\n  \"topVolunteers\": [\n    {\n      \"userId\": \"clxUser123\",\n      \"name\": \"Jane Smith\",\n      \"visitCount\": 247\n    }\n  ],\n  \"cutProgress\": [\n    {\n      \"cutId\": \"clxCut123\",\n      \"cutName\": \"Downtown Ward 5\",\n      \"completionPercentage\": 68,\n      \"visitCount\": 342,\n      \"totalAddresses\": 503\n    }\n  ]\n}\n
"},{"location":"v2/backend/modules/canvass/#get-apimapcanvassactivity","title":"GET /api/map/canvass/activity","text":"

Get recent canvass activity feed.

Query Parameters:

  • page (default: 1): Page number
  • limit (default: 20, max: 100): Results per page
  • cutId (optional): Filter by cut
  • userId (optional): Filter by volunteer
  • outcome (optional): Filter by outcome

Example Response (200 OK):

{\n  \"activities\": [\n    {\n      \"id\": \"clxVisit789\",\n      \"userId\": \"clxUser123\",\n      \"user\": {\n        \"name\": \"Jane Smith\",\n        \"email\": \"jane@example.com\"\n      },\n      \"addressId\": \"clxAddress456\",\n      \"address\": {\n        \"address\": \"123 Main St\",\n        \"unitNumber\": \"Apt 4\"\n      },\n      \"outcome\": \"CONTACTED\",\n      \"supportLevel\": \"LEVEL_2\",\n      \"visitedAt\": \"2026-02-11T14:30:00.000Z\",\n      \"durationSeconds\": 180\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 3847,\n    \"totalPages\": 193\n  }\n}\n
"},{"location":"v2/backend/modules/canvass/#get-apimapcanvassvolunteers","title":"GET /api/map/canvass/volunteers","text":"

List volunteers with visit counts.

Example Response (200 OK):

[\n  {\n    \"userId\": \"clxUser123\",\n    \"name\": \"Jane Smith\",\n    \"email\": \"jane@example.com\",\n    \"totalVisits\": 247,\n    \"todayVisits\": 18,\n    \"activeSessions\": 1\n  }\n]\n
"},{"location":"v2/backend/modules/canvass/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/canvass/#canvassservicestartsessionuserid-data","title":"canvassService.startSession(userId, data)","text":"

Start new canvass session.

Validation:

// Check for existing active session\nconst existing = await prisma.canvassSession.findFirst({\n  where: { userId, status: CanvassSessionStatus.ACTIVE },\n});\nif (existing) {\n  throw new AppError(409, 'You already have an active canvass session', 'SESSION_ACTIVE');\n}\n\n// Verify cut exists\nconst cut = await prisma.cut.findUnique({ where: { id: data.cutId } });\nif (!cut) {\n  throw new AppError(404, 'Cut not found', 'CUT_NOT_FOUND');\n}\n
"},{"location":"v2/backend/modules/canvass/#canvassserviceendsessionsessionid-userid","title":"canvassService.endSession(sessionId, userId)","text":"

End canvass session and recalculate cut completion.

Post-Processing:

// End session\nawait prisma.canvassSession.update({\n  where: { id: sessionId },\n  data: { status: CanvassSessionStatus.COMPLETED, endedAt: new Date() },\n});\n\n// Recalculate cut completion percentage\nawait this.recalculateCutCompletion(session.cutId);\n

Cut Completion Calculation:

async recalculateCutCompletion(cutId: string) {\n  // Get all addresses in cut\n  const totalAddresses = await this.countAddressesInCut(cutId);\n\n  // Get visited addresses (distinct addressId from visits)\n  const visitedCount = await prisma.canvassVisit.findMany({\n    where: { address: { location: { cuts: { some: { id: cutId } } } } },\n    distinct: ['addressId'],\n  }).then(visits => visits.length);\n\n  const completionPercentage = totalAddresses > 0\n    ? Math.round((visitedCount / totalAddresses) * 100)\n    : 0;\n\n  await prisma.cut.update({\n    where: { id: cutId },\n    data: { completionPercentage },\n  });\n}\n
"},{"location":"v2/backend/modules/canvass/#canvassservicerecordvisituserid-data","title":"canvassService.recordVisit(userId, data)","text":"

Record visit to address with optional location update.

Address Update Logic:

if (data.updateLocation && (data.outcome === VisitOutcome.CONTACTED || data.outcome === VisitOutcome.SUPPORTER)) {\n  await prisma.address.update({\n    where: { id: data.addressId },\n    data: {\n      supportLevel: data.supportLevel || undefined,\n      sign: data.signRequested || undefined,\n      signSize: data.signRequested ? data.signSize : undefined,\n    },\n  });\n}\n

Metrics:

recordCanvassVisit(data.outcome); // Prometheus counter\n
"},{"location":"v2/backend/modules/canvass/#canvassservicegetwalkingroutecutid-userid-options","title":"canvassService.getWalkingRoute(cutId, userId, options)","text":"

Get optimized walking route for cut.

Algorithm:

import { calculateWalkingRoute } from './canvass-route.service';\n\nconst addresses = await this.getCutLocationsForCanvass(cutId, userId);\n\n// Filter to unvisited if requested\nconst unvisited = options.excludeVisited\n  ? addresses.filter(addr => !addr.lastVisit)\n  : addresses;\n\n// Calculate route using nearest-neighbor algorithm\nconst route = calculateWalkingRoute(\n  unvisited,\n  options.startLatitude,\n  options.startLongitude,\n);\n\nreturn route;\n
"},{"location":"v2/backend/modules/canvass/#abandoned-session-cleanup","title":"Abandoned Session Cleanup","text":"

Scheduled Task:

Runs on API startup and every hour:

// api/src/server.ts\nasync function closeAbandonedSessions() {\n  const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);\n\n  const result = await prisma.canvassSession.updateMany({\n    where: {\n      status: CanvassSessionStatus.ACTIVE,\n      startedAt: { lt: twelveHoursAgo },\n    },\n    data: {\n      status: CanvassSessionStatus.ABANDONED,\n      endedAt: new Date(),\n    },\n  });\n\n  if (result.count > 0) {\n    logger.info(`Closed ${result.count} abandoned canvass sessions`);\n  }\n}\n\n// Run on startup\ncloseAbandonedSessions();\n\n// Run every hour\nsetInterval(closeAbandonedSessions, 60 * 60 * 1000);\n
"},{"location":"v2/backend/modules/canvass/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/canvass/#record-visit-schema","title":"Record Visit Schema","text":"
export const recordVisitSchema = z.object({\n  addressId: z.string().min(1),\n  outcome: z.nativeEnum(VisitOutcome),\n  supportLevel: z.nativeEnum(SupportLevel).optional(),\n  signRequested: z.boolean().optional().default(false),\n  signSize: z.string().optional(),\n  notes: z.string().optional(),\n  durationSeconds: z.number().int().optional(),\n  sessionId: z.string().optional(),\n  shiftId: z.string().optional(),\n  updateLocation: z.boolean().optional().default(true),\n});\n
"},{"location":"v2/backend/modules/canvass/#bulk-record-visit-schema","title":"Bulk Record Visit Schema","text":"
export const bulkRecordVisitSchema = z.object({\n  locationId: z.string().min(1), // Building ID\n  outcome: z.enum(['NOT_HOME', 'REFUSED', 'MOVED']), // Only non-contact outcomes\n  notes: z.string().optional(),\n  sessionId: z.string().optional(),\n  shiftId: z.string().optional(),\n});\n
"},{"location":"v2/backend/modules/canvass/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/canvass/#volunteer-start-canvass-session","title":"Volunteer: Start Canvass Session","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst startSession = async (cutId: string, shiftId?: string) => {\n  // Get current GPS position\n  navigator.geolocation.getCurrentPosition(async (position) => {\n    try {\n      const { data } = await api.post('/api/map/canvass/sessions', {\n        cutId,\n        shiftId,\n        startLatitude: position.coords.latitude,\n        startLongitude: position.coords.longitude,\n      });\n\n      message.success('Canvass session started');\n      console.log(`Session ID: ${data.id}`);\n    } catch (error: any) {\n      if (error.response?.status === 409) {\n        message.error('You already have an active session');\n      } else {\n        message.error('Failed to start session');\n      }\n    }\n  });\n};\n
"},{"location":"v2/backend/modules/canvass/#volunteer-record-visit","title":"Volunteer: Record Visit","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst recordVisit = async (addressId: string, outcome: string, sessionId: string) => {\n  try {\n    const { data } = await api.post('/api/map/canvass/visits', {\n      addressId,\n      outcome,\n      supportLevel: 'LEVEL_2',\n      signRequested: true,\n      signSize: 'Large',\n      notes: 'Interested in volunteering',\n      durationSeconds: 180,\n      sessionId,\n      updateLocation: true,\n    });\n\n    message.success('Visit recorded');\n    return data;\n  } catch (error: any) {\n    if (error.response?.status === 429) {\n      message.error('Rate limit exceeded. Please wait a moment.');\n    } else {\n      message.error('Failed to record visit');\n    }\n  }\n};\n
"},{"location":"v2/backend/modules/canvass/#admin-get-canvass-statistics","title":"Admin: Get Canvass Statistics","text":"
import { api } from '@/lib/api';\n\nconst getStats = async () => {\n  const { data } = await api.get('/api/map/canvass/stats');\n\n  console.log(`Total Visits: ${data.totalVisits}`);\n  console.log(`Active Sessions: ${data.activeSessions}`);\n  console.log(`Top Volunteer: ${data.topVolunteers[0]?.name} (${data.topVolunteers[0]?.visitCount} visits)`);\n\n  return data;\n};\n
"},{"location":"v2/backend/modules/canvass/#frontend-integration","title":"Frontend Integration","text":""},{"location":"v2/backend/modules/canvass/#volunteer-portal","title":"Volunteer Portal","text":"

VolunteerMapPage (admin/src/pages/volunteer/VolunteerMapPage.tsx):

  • Full-screen Leaflet map (no AppLayout)
  • GPS tracking (blue dot follows volunteer)
  • Location markers (color-coded by visit status)
  • Walking route visualization (dashed blue line)
  • Bottom sheet toolbar (floating panel)
  • Visit recording form (outcome, notes, duration)
  • Optimized route toggle (exclude visited addresses)
  • Session timer (displays elapsed time)

MyAssignmentsPage (admin/src/pages/volunteer/MyAssignmentsPage.tsx):

  • Assigned shifts table
  • Cut names + completion percentage
  • \"Start Canvassing\" button (opens map, starts session)

MyActivityPage (admin/src/pages/volunteer/MyActivityPage.tsx):

  • Visit history table (paginated)
  • Outcome breakdown (pie chart)
  • Today's visit count vs. total

State Management:

// admin/src/stores/canvass.store.ts\ninterface CanvassState {\n  session: CanvassSession | null;\n  locations: CanvassLocation[];\n  route: WalkingRoute | null;\n  gpsPosition: { lat: number; lng: number } | null;\n  selectedAddress: string | null;\n  showVisitRecording: boolean;\n}\n
"},{"location":"v2/backend/modules/canvass/#admin-dashboard","title":"Admin Dashboard","text":"

CanvassDashboardPage (admin/src/pages/CanvassDashboardPage.tsx):

  • Statistics cards (total visits, active sessions, volunteers, completion %)
  • Recent activity feed (realtime visit stream)
  • Cut progress table (completionPercentage, visitCount)
  • Volunteer leaderboard (sorted by visit count)
"},{"location":"v2/backend/modules/canvass/#performance-considerations","title":"Performance Considerations","text":"

Rate Limiting:

  • Single visits: 30/min per IP (prevents spam)
  • Bulk visits: 10/min per IP (stricter for building-wide operations)
  • Geocoding: 10/min per IP (prevents geocoding API abuse)

Abandoned Session Cleanup:

  • Runs hourly (low overhead)
  • Only updates sessions older than 12 hours
  • Prevents stale ACTIVE sessions blocking new sessions

Walking Route Algorithm:

  • O(n\u00b2) complexity acceptable for typical cuts (<500 locations)
  • Uses haversine distance (meters) for accuracy
  • Pre-filters visited addresses when excludeVisited=true

Cut Completion Calculation:

  • Triggered on session end (not every visit)
  • Uses distinct: ['addressId'] to count unique addresses
  • Caches result in Cut.completionPercentage field
"},{"location":"v2/backend/modules/canvass/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/canvass/#issue-you-already-have-an-active-canvass-session","title":"Issue: \"You already have an active canvass session\"","text":"

Cause: Volunteer forgot to end previous session

Solution:

  • Admin: Find session in CanvassDashboardPage, manually mark as COMPLETED
  • Wait for automatic cleanup (12h timeout)
  • Volunteer: Navigate to session end screen and click \"End Session\"
"},{"location":"v2/backend/modules/canvass/#issue-rate-limit-exceeded-429-when-recording-visits","title":"Issue: Rate limit exceeded (429) when recording visits","text":"

Cause: Recording visits too quickly (>30/min)

Solution:

  • Slow down visit recording (realistic door-knocking speed: ~10-15/hr)
  • Use bulk visit endpoint for buildings (NOT_HOME for entire building)
"},{"location":"v2/backend/modules/canvass/#issue-walking-route-skips-some-addresses","title":"Issue: Walking route skips some addresses","text":"

Cause: excludeVisited=true filters out already-visited addresses

Solution:

  • Set excludeVisited=false to see all addresses
  • Verify addresses have visits recorded (check lastVisit field)
"},{"location":"v2/backend/modules/canvass/#issue-cut-completion-percentage-not-updating","title":"Issue: Cut completion percentage not updating","text":"

Cause: Completion calculated on session end, not per-visit

Solution:

  • End canvass session to trigger recalculation
  • Admin: View cut stats to verify visitCount vs. totalAddresses
"},{"location":"v2/backend/modules/canvass/#related-documentation","title":"Related Documentation","text":"
  • Shifts Module - Shift CRUD + signup system
  • Cuts Module - Polygon filtering
  • Locations Module - Location management
  • Spatial Utils - Point-in-polygon, haversine distance
  • Frontend: VolunteerMapPage - Canvassing map UI
  • Frontend: CanvassDashboardPage - Admin dashboard
  • API Reference: Canvass - Complete endpoint reference
  • Feature: Volunteer Canvassing - Canvassing feature guide
"},{"location":"v2/backend/modules/locations/","title":"Locations Module","text":""},{"location":"v2/backend/modules/locations/#overview","title":"Overview","text":"

The Locations module manages geographic locations for organizing campaigns, mapping volunteers, and tracking supporter data. It features multi-provider geocoding, NAR (National Address Register) bulk import with 2025 format support, CSV import/export, location history tracking, and comprehensive filtering with spatial queries.

Key Features:

  • Location CRUD with automatic geocoding
  • Multi-provider geocoding (Nominatim, Mapbox, ArcGIS, Photon, Google, LocationIQ)
  • Batch geocoding with BullMQ queue integration
  • NAR 2025 bulk import (Canadian electoral data with Lambert projection support)
  • CSV import/export with flexible column mapping
  • Location history tracking (audit trail for all changes)
  • Reverse geocoding (lat/lng \u2192 address)
  • Spatial filtering (cut polygons, bounding boxes, postal codes)
  • Deduplication (coordinate-based with configurable radius)
  • Support level tracking (LEVEL_1 through LEVEL_4)
  • Sign tracking (lawn signs, sizes)
  • Public map API (PII-filtered)
  • Statistics dashboard (geocoding quality, provider distribution, confidence levels)
"},{"location":"v2/backend/modules/locations/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/map/locations/locations.routes.ts 2 routers (admin + public) with 20 endpoints api/src/modules/map/locations/locations.service.ts Location business logic + geocoding + NAR import (1,100 lines) api/src/modules/map/locations/locations.schemas.ts Zod validation schemas api/src/modules/map/locations/nar-import.service.ts NAR import service (server-side streaming, legacy support) api/src/modules/map/locations/nar-import.routes.ts NAR import admin routes api/src/modules/map/locations/bulk-geocode.routes.ts Bulk geocoding queue routes api/src/modules/map/locations/bulk-geocode.schemas.ts Bulk geocoding schemas"},{"location":"v2/backend/modules/locations/#database-models","title":"Database Models","text":""},{"location":"v2/backend/modules/locations/#location","title":"Location","text":"
model Location {\n  id                  String         @id @default(cuid())\n  address             String\n  unitNumber          String?\n  firstName           String?\n  lastName            String?\n  email               String?\n  phone               String?\n  supportLevel        SupportLevel?\n  sign                Boolean        @default(false)\n  signSize            String?\n  notes               String?        @db.Text\n  buildingNotes       String?        @db.Text\n\n  // Geocoding\n  latitude            Float?\n  longitude           Float?\n  geocodeConfidence   Int?\n  geocodeProvider     GeocodeProvider?\n\n  // NAR fields (2025 format support)\n  postalCode          String?\n  province            String?\n  federalDistrict     String?\n  buildingUse         Int?           // 1=Residential, 2=Commercial, 3=Mixed\n\n  // Audit\n  createdByUserId     String?\n  updatedByUserId     String?\n  createdAt           DateTime       @default(now())\n  updatedAt           DateTime       @updatedAt\n\n  // Relations\n  createdByUser       User?          @relation(\"LocationCreator\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n  updatedByUser       User?          @relation(\"LocationUpdater\", fields: [updatedByUserId], references: [id], onDelete: SetNull)\n  history             LocationHistory[]\n\n  @@index([latitude, longitude])\n  @@index([supportLevel])\n  @@index([sign])\n  @@index([geocodeConfidence])\n  @@map(\"locations\")\n}\n\nenum SupportLevel {\n  LEVEL_1  // Strong support\n  LEVEL_2  // Moderate support\n  LEVEL_3  // Undecided\n  LEVEL_4  // Opposed\n}\n\nenum GeocodeProvider {\n  NOMINATIM\n  MAPBOX\n  ARCGIS\n  PHOTON\n  GOOGLE\n  LOCATIONIQ\n  UNKNOWN\n}\n
"},{"location":"v2/backend/modules/locations/#locationhistory","title":"LocationHistory","text":"
model LocationHistory {\n  id         String                @id @default(cuid())\n  locationId String\n  location   Location              @relation(fields: [locationId], references: [id], onDelete: Cascade)\n  userId     String?\n  user       User?                 @relation(fields: [userId], references: [id], onDelete: SetNull)\n  action     LocationHistoryAction\n  field      String?\n  oldValue   String?\n  newValue   String?\n  metadata   Json?\n  createdAt  DateTime              @default(now())\n\n  @@index([locationId])\n  @@index([userId])\n  @@index([action])\n  @@map(\"location_history\")\n}\n\nenum LocationHistoryAction {\n  CREATED\n  UPDATED\n  GEOCODED\n  MOVED_ON_MAP\n  DELETED\n}\n

History Tracking:

  • All location changes recorded with before/after values
  • CREATED \u2014 Location created (manual or import)
  • UPDATED \u2014 Field changed
  • GEOCODED \u2014 Address geocoded (auto or bulk geocoding)
  • MOVED_ON_MAP \u2014 Lat/lng changed via map drag
  • DELETED \u2014 Location deleted
"},{"location":"v2/backend/modules/locations/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/locations/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"Method Path Description GET /api/map/locations List locations (paginated, filtered) GET /api/map/locations/stats Location statistics GET /api/map/locations/export-csv Export CSV download GET /api/map/locations/all All geocoded locations for map (admin, 5000 limit) GET /api/map/locations/:id Get single location GET /api/map/locations/:id/history Get location edit history POST /api/map/locations Create location (auto-geocodes if no lat/lng) POST /api/map/locations/geocode Geocode single address POST /api/map/locations/geocode-missing Geocode all ungeocoded locations POST /api/map/locations/import-csv Upload + import CSV (10MB limit) POST /api/map/locations/import-bulk Bulk import NAR or CSV (100MB limit, 5min timeout) POST /api/map/locations/reverse-geocode Reverse geocode lat/lng to address POST /api/map/locations/bulk-delete Delete multiple locations PUT /api/map/locations/:id Update location DELETE /api/map/locations/:id Delete location

Admin Roles: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/backend/modules/locations/#public-endpoints-no-authentication","title":"Public Endpoints (No Authentication)","text":"Method Path Description GET /api/map/locations/public Public locations for map (PII-filtered, 5000 limit)"},{"location":"v2/backend/modules/locations/#admin-endpoint-details","title":"Admin Endpoint Details","text":""},{"location":"v2/backend/modules/locations/#get-apimaplocations","title":"GET /api/map/locations","text":"

List locations with pagination, search, and filtering.

Query Parameters:

Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) search string No - Search address, first/last name, email supportLevel SupportLevel No - Filter by support level hasSign boolean No - Filter by sign presence confidenceLevel string No - Filter by geocode confidence: high (85+), medium (60-84), low (<60), none (0 or null) sortBy string No createdAt Sort field: createdAt, address, supportLevel sortOrder string No desc Sort order: asc, desc

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/locations?page=1&limit=20&supportLevel=LEVEL_1&hasSign=true&confidenceLevel=high\"\n

Response (200 OK):

{\n  \"locations\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"address\": \"123 Main St, Toronto, ON\",\n      \"unitNumber\": \"Apt 4\",\n      \"firstName\": \"John\",\n      \"lastName\": \"Doe\",\n      \"email\": \"john@example.com\",\n      \"phone\": \"416-555-1234\",\n      \"supportLevel\": \"LEVEL_1\",\n      \"sign\": true,\n      \"signSize\": \"Large\",\n      \"notes\": \"Willing to volunteer\",\n      \"buildingNotes\": \"Apartment building, intercom required\",\n      \"latitude\": 43.6532,\n      \"longitude\": -79.3832,\n      \"geocodeConfidence\": 95,\n      \"geocodeProvider\": \"NOMINATIM\",\n      \"postalCode\": \"M5H 2N2\",\n      \"province\": \"ON\",\n      \"federalDistrict\": \"Toronto Centre\",\n      \"buildingUse\": 1,\n      \"createdByUserId\": \"clxUser123\",\n      \"updatedByUserId\": null,\n      \"createdAt\": \"2026-02-08T12:00:00.000Z\",\n      \"updatedAt\": \"2026-02-08T12:00:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 342,\n    \"totalPages\": 18\n  }\n}\n

Search Logic:

if (search) {\n  where.OR = [\n    { address: { contains: search, mode: 'insensitive' } },\n    { firstName: { contains: search, mode: 'insensitive' } },\n    { lastName: { contains: search, mode: 'insensitive' } },\n    { email: { contains: search, mode: 'insensitive' } },\n  ];\n}\n

Confidence Level Filtering:

if (confidenceLevel === 'high') {\n  where.geocodeConfidence = { gte: 85 };\n} else if (confidenceLevel === 'medium') {\n  where.geocodeConfidence = { gte: 60, lt: 85 };\n} else if (confidenceLevel === 'low') {\n  where.geocodeConfidence = { lt: 60, gt: 0 };\n} else if (confidenceLevel === 'none') {\n  where.OR = [{ geocodeConfidence: null }, { geocodeConfidence: 0 }];\n}\n
"},{"location":"v2/backend/modules/locations/#get-apimaplocationsstats","title":"GET /api/map/locations/stats","text":"

Get aggregate statistics for locations.

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/locations/stats\"\n

Response (200 OK):

{\n  \"total\": 1247,\n  \"supportLevels\": {\n    \"LEVEL_1\": 342,\n    \"LEVEL_2\": 189,\n    \"LEVEL_3\": 276,\n    \"LEVEL_4\": 98,\n    \"NONE\": 342\n  },\n  \"signs\": 142,\n  \"geocoded\": 1189,\n  \"ungeocoded\": 58,\n  \"confidence\": {\n    \"high\": 892,\n    \"medium\": 213,\n    \"low\": 84,\n    \"none\": 58,\n    \"average\": 87\n  },\n  \"providers\": {\n    \"nominatim\": 654,\n    \"mapbox\": 312,\n    \"arcgis\": 98,\n    \"photon\": 76,\n    \"google\": 34,\n    \"locationiq\": 15,\n    \"manual\": 58\n  }\n}\n

Field Descriptions:

  • total \u2014 Total location count
  • supportLevels \u2014 Breakdown by support level
  • signs \u2014 Locations with sign=true
  • geocoded \u2014 Locations with lat/lng
  • ungeocoded \u2014 Locations without lat/lng
  • confidence.high \u2014 Geocode confidence \u2265 85
  • confidence.medium \u2014 Geocode confidence 60-84
  • confidence.low \u2014 Geocode confidence < 60
  • confidence.none \u2014 No geocode confidence (0 or null)
  • confidence.average \u2014 Average geocode confidence (excludes 0/null)
  • providers \u2014 Breakdown by geocode provider
"},{"location":"v2/backend/modules/locations/#post-apimaplocations","title":"POST /api/map/locations","text":"

Create new location with automatic geocoding.

Request Body:

{\n  \"address\": \"123 Main St, Toronto, ON\",\n  \"unitNumber\": \"Apt 4\",\n  \"firstName\": \"John\",\n  \"lastName\": \"Doe\",\n  \"email\": \"john@example.com\",\n  \"phone\": \"416-555-1234\",\n  \"supportLevel\": \"LEVEL_1\",\n  \"sign\": true,\n  \"signSize\": \"Large\",\n  \"notes\": \"Willing to volunteer\",\n  \"buildingNotes\": \"Apartment building, intercom required\"\n}\n

Response (201 Created):

Returns created location object.

Auto-Geocoding:

If address provided and no latitude/longitude, automatically geocodes:

if (data.address && data.latitude == null && data.longitude == null) {\n  const result = await geocodingService.geocode(data.address);\n  if (result) {\n    createData.latitude = result.latitude;\n    createData.longitude = result.longitude;\n    createData.geocodeConfidence = result.confidence;\n    createData.geocodeProvider = result.provider;\n  }\n}\n

History Tracking:

Creates LocationHistory record with action GEOCODED (if geocoded) or CREATED (if manual coordinates).

"},{"location":"v2/backend/modules/locations/#put-apimaplocationsid","title":"PUT /api/map/locations/:id","text":"

Update location. Re-geocodes if address changes without explicit lat/lng.

Request Body (Partial):

{\n  \"address\": \"456 Oak St, Toronto, ON\",\n  \"supportLevel\": \"LEVEL_2\"\n}\n

Response (200 OK):

Returns updated location object.

Smart Geocoding:

  • If address changes and no explicit lat/lng provided: re-geocode automatically
  • If lat/lng provided: use provided coordinates (manual override)

History Tracking:

Records field changes with before/after values:

// Track changes\nconst changes: { field: string; oldValue: unknown; newValue: unknown }[] = [];\n\nif (data.address && data.address !== existing.address) {\n  changes.push({ field: 'address', oldValue: existing.address, newValue: data.address });\n}\n\n// Determine action based on changes\nlet action: LocationHistoryAction = LocationHistoryAction.UPDATED;\n\nif (data.latitude !== undefined && data.latitude !== existing.latitude) {\n  action = LocationHistoryAction.MOVED_ON_MAP; // Explicit coordinate change (map drag)\n}\n\nif (address changed && auto-geocoded) {\n  action = LocationHistoryAction.GEOCODED;\n}\n
"},{"location":"v2/backend/modules/locations/#post-apimaplocationsimport-csv","title":"POST /api/map/locations/import-csv","text":"

Upload and import CSV file with flexible column mapping.

Multipart Form Data:

  • file (required): CSV file (max 10MB)

Supported Column Names (Case-Insensitive):

Field Column Names address address, street, street address firstName first name, firstname, first lastName last name, lastname, last email email, e-mail phone phone, telephone, tel, phone number unitNumber unit, unit number, apt, apartment, suite supportLevel support level, supportlevel, support, level sign sign, lawn sign signSize sign size, signsize notes notes, note, comments latitude latitude, lat longitude longitude, lng, lon

Example CSV:

address,first name,last name,email,phone,support level,sign\n\"123 Main St, Toronto, ON\",John,Doe,john@example.com,416-555-1234,LEVEL_1,true\n\"456 Oak St, Toronto, ON\",Jane,Smith,jane@example.com,416-555-5678,LEVEL_2,false\n

Example Request:

curl -X POST -H \"Authorization: Bearer <token>\" \\\n  -F \"file=@locations.csv\" \\\n  \"http://api.cmlite.org/api/map/locations/import-csv\"\n

Response (200 OK):

{\n  \"total\": 1000,\n  \"success\": 942,\n  \"warnings\": 34,\n  \"failed\": 24,\n  \"errors\": [\n    \"Row 12: Missing address\",\n    \"Row 45: Invalid email format\",\n    \"Row 89: Geocoding failed\"\n  ]\n}\n

Field Descriptions:

  • total \u2014 Total rows in CSV
  • success \u2014 Successfully created locations
  • warnings \u2014 Created but geocoding failed (no lat/lng)
  • failed \u2014 Failed to create (validation errors)
  • errors \u2014 First 50 error messages (row numbers 1-indexed)

Geocoding:

  • If CSV has latitude/longitude columns: uses provided coordinates
  • Otherwise: auto-geocodes each address (slow for large files, consider NAR import for bulk)
"},{"location":"v2/backend/modules/locations/#post-apimaplocationsimport-bulk","title":"POST /api/map/locations/import-bulk","text":"

Bulk import NAR (National Address Register) or standard CSV with advanced filtering.

Multipart Form Data:

  • file (required): CSV file (max 100MB)
  • format (required): nar or standard
  • filterType (optional): none, cut, mapArea, city, province
  • cutId (optional): Cut ID for filterType=cut
  • filterCity (optional): City name for filterType=city
  • filterProvince (optional): Province code for filterType=province (e.g., ON, BC)
  • residentialOnly (optional, default: false): Skip non-residential buildings (NAR only)
  • deduplicateRadius (optional, default: 5): Coordinate deduplication radius in meters
  • skipGeocoding (optional, default: true): Skip geocoding (NAR files have coordinates)
  • batchSize (optional, default: 1000): Database batch insert size

Request Timeout: 5 minutes (extended for large files)

Example Request (NAR Import with Cut Filter):

curl -X POST -H \"Authorization: Bearer <token>\" \\\n  -F \"file=@Address_24_part_1.csv\" \\\n  -F \"format=nar\" \\\n  -F \"filterType=cut\" \\\n  -F \"cutId=clxCut123\" \\\n  -F \"residentialOnly=true\" \\\n  -F \"deduplicateRadius=5\" \\\n  \"http://api.cmlite.org/api/map/locations/import-bulk\"\n

Response (200 OK):

{\n  \"total\": 50000,\n  \"created\": 12847,\n  \"skippedDuplicate\": 1243,\n  \"skippedOutOfBounds\": 34892,\n  \"skippedInvalid\": 1018,\n  \"errors\": [\n    \"Row 234: Invalid coordinates\",\n    \"Row 1892: Missing civic number\"\n  ]\n}\n

NAR Format Support:

2025 NAR Format (Recommended):

  • Address File Columns: CIVIC_NO, CIVIC_NO_SUFFIX, OFFICIAL_STREET_NAME, OFFICIAL_STREET_TYPE, OFFICIAL_STREET_DIR, APT_NO_LABEL, BG_X, BG_Y, MAIL_MUN_NAME, MAIL_PROV_ABVN, MAIL_POSTAL_CODE, FED_ENG_NAME, BU_USE
  • Location File Columns: BG_LATITUDE, BG_LONGITUDE (WGS84), LOC_GUID
  • Coordinate Systems:
  • BG_X/BG_Y \u2014 EPSG:3347 Lambert Conformal Conic (converted to WGS84)
  • BG_LATITUDE/BG_LONGITUDE \u2014 WGS84 (used directly)

Legacy NAR Format (Backward Compatible):

  • Columns: STR_NBR, STR_NME, STR_TYP, STR_DIR, LAT, LNG, MUN_NME, PRV_NME

Auto-Detection:

If 3+ NAR-specific columns detected, automatically treats as NAR format.

Lambert Projection Conversion:

import proj4 from 'proj4';\n\n// Define EPSG:3347 (Statistics Canada Lambert Conformal Conic)\nproj4.defs('EPSG:3347', '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=63.390675 +lon_0=-91.86666666666666 +x_0=6200000 +y_0=3000000 +ellps=GRS80 +units=m +no_defs');\n\nfunction lambertToLatLng(bgX: number, bgY: number): [number, number] {\n  const [lng, lat] = proj4('EPSG:3347', 'EPSG:4326', [bgX, bgY]);\n  return [lat, lng];\n}\n

Filtering Options:

  1. Cut Filter (filterType=cut):
  2. Only imports locations inside specified cut polygon
  3. Uses point-in-polygon ray-casting algorithm

  4. Map Area Filter (filterType=mapArea):

  5. Imports locations visible on current map view
  6. Calculates bounding box from MapSettings (center, zoom)

  7. City Filter (filterType=city):

  8. Imports locations matching city name (case-insensitive)

  9. Province Filter (filterType=province):

  10. Imports locations matching province code (e.g., ON, BC)

Deduplication:

Prevents duplicate locations at same coordinates:

const coordKey = `${roundCoord(lat, 5)}:${roundCoord(lng, 5)}`; // 5 decimal places = ~1.1m precision\n\nif (existingCoords.has(coordKey) || inFileCoords.has(coordKey)) {\n  skippedDuplicate++;\n  continue;\n}\n

Batch Processing:

Inserts locations in batches (default 1000) for performance:

const batch: Prisma.LocationCreateManyInput[] = [];\n\n// ... collect locations ...\n\nif (batch.length >= options.batchSize) {\n  await prisma.location.createMany({ data: batch, skipDuplicates: true });\n  batch.length = 0;\n}\n
"},{"location":"v2/backend/modules/locations/#get-apimaplocationsexport-csv","title":"GET /api/map/locations/export-csv","text":"

Export locations as CSV download.

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/locations/export-csv\" \\\n  -o locations.csv\n

Response (200 OK):

CSV file with headers:

address,firstName,lastName,email,phone,unitNumber,supportLevel,sign,signSize,notes,latitude,longitude,geocodeConfidence,geocodeProvider,createdAt\n\"123 Main St, Toronto, ON\",John,Doe,john@example.com,416-555-1234,Apt 4,LEVEL_1,Yes,Large,Willing to volunteer,43.6532,-79.3832,95,NOMINATIM,2026-02-08T12:00:00.000Z\n
"},{"location":"v2/backend/modules/locations/#post-apimaplocationsreverse-geocode","title":"POST /api/map/locations/reverse-geocode","text":"

Reverse geocode coordinates to address.

Request Body:

{\n  \"latitude\": 43.6532,\n  \"longitude\": -79.3832\n}\n

Response (200 OK):

{\n  \"address\": \"123 Main St, Toronto, ON M5H 2N2, Canada\",\n  \"provider\": \"NOMINATIM\",\n  \"confidence\": 85\n}\n

Use Cases:

  • Click-to-add location on map (get address from coordinates)
  • Move location on map (update address after drag)
  • Verify coordinates match expected address
"},{"location":"v2/backend/modules/locations/#get-apimaplocationsall","title":"GET /api/map/locations/all","text":"

Get all geocoded locations for admin map view.

Query Parameters:

Parameter Type Description minLat number Minimum latitude (bounding box) maxLat number Maximum latitude minLng number Minimum longitude maxLng number Maximum longitude

Example Request:

# All locations\ncurl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/locations/all\"\n\n# Bounding box (visible map area)\ncurl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/locations/all?minLat=43.6&maxLat=43.7&minLng=-79.4&maxLng=-79.3\"\n

Response (200 OK):

Returns array of location objects (max 5000).

Safety Limit:

If result hits 5000 locations, adds header X-Location-Limit-Hit: true to warn client.

"},{"location":"v2/backend/modules/locations/#get-apimaplocationsidhistory","title":"GET /api/map/locations/:id/history","text":"

Get location edit history with audit trail.

Query Parameters:

  • page (optional, default: 1): Page number
  • limit (optional, default: 20): Results per page

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/locations/clx1234567890/history?page=1&limit=20\"\n

Response (200 OK):

{\n  \"history\": [\n    {\n      \"id\": \"clxHistory123\",\n      \"locationId\": \"clx1234567890\",\n      \"userId\": \"clxUser123\",\n      \"user\": {\n        \"id\": \"clxUser123\",\n        \"email\": \"admin@example.com\",\n        \"name\": \"Admin User\",\n        \"role\": \"SUPER_ADMIN\"\n      },\n      \"action\": \"MOVED_ON_MAP\",\n      \"field\": \"latitude\",\n      \"oldValue\": \"43.6532\",\n      \"newValue\": \"43.6540\",\n      \"metadata\": null,\n      \"createdAt\": \"2026-02-11T12:00:00.000Z\"\n    },\n    {\n      \"id\": \"clxHistory124\",\n      \"locationId\": \"clx1234567890\",\n      \"userId\": \"clxUser123\",\n      \"user\": {...},\n      \"action\": \"GEOCODED\",\n      \"field\": \"latitude\",\n      \"oldValue\": null,\n      \"newValue\": \"43.6532\",\n      \"metadata\": {\n        \"provider\": \"NOMINATIM\",\n        \"confidence\": 95,\n        \"geocoded\": true\n      },\n      \"createdAt\": \"2026-02-08T12:00:00.000Z\"\n    },\n    {\n      \"id\": \"clxHistory125\",\n      \"locationId\": \"clx1234567890\",\n      \"userId\": \"clxUser123\",\n      \"user\": {...},\n      \"action\": \"CREATED\",\n      \"field\": null,\n      \"oldValue\": null,\n      \"newValue\": null,\n      \"metadata\": null,\n      \"createdAt\": \"2026-02-08T12:00:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 7,\n    \"totalPages\": 1\n  }\n}\n

History Actions:

  • CREATED \u2014 Location created
  • UPDATED \u2014 Field changed (address, name, email, etc.)
  • GEOCODED \u2014 Auto-geocoded (address \u2192 lat/lng)
  • MOVED_ON_MAP \u2014 Coordinates changed via map drag
  • DELETED \u2014 Location deleted (orphaned history records)
"},{"location":"v2/backend/modules/locations/#public-endpoint-details","title":"Public Endpoint Details","text":""},{"location":"v2/backend/modules/locations/#get-apimaplocationspublic","title":"GET /api/map/locations/public","text":"

Get locations for public map (PII-filtered).

Query Parameters:

  • minLat, maxLat, minLng, maxLng (optional): Bounding box

Example Request:

curl \"http://api.cmlite.org/api/public/map/locations?minLat=43.6&maxLat=43.7&minLng=-79.4&maxLng=-79.3\"\n

Response (200 OK):

[\n  {\n    \"id\": \"clx1234567890\",\n    \"latitude\": 43.6532,\n    \"longitude\": -79.3832,\n    \"supportLevel\": \"LEVEL_1\",\n    \"sign\": true,\n    \"signSize\": \"Large\",\n    \"unitNumber\": \"Apt 4\",\n    \"address\": \"123 Main St, Toronto, ON\"\n  }\n]\n

PII Filtering:

Only returns non-sensitive fields:

  • Included: id, latitude, longitude, supportLevel, sign, signSize, unitNumber, address
  • Excluded: firstName, lastName, email, phone, notes, buildingNotes, geocodeConfidence, geocodeProvider, createdByUserId, postalCode, province, federalDistrict, buildingUse
"},{"location":"v2/backend/modules/locations/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/locations/#locationsservicecreatedata-userid","title":"locationsService.create(data, userId)","text":"

Create location with auto-geocoding.

Auto-Geocoding Logic:

if (data.address && data.latitude == null && data.longitude == null) {\n  const result = await geocodingService.geocode(data.address);\n  if (result) {\n    createData.latitude = result.latitude;\n    createData.longitude = result.longitude;\n    createData.geocodeConfidence = result.confidence;\n    createData.geocodeProvider = result.provider;\n  }\n}\n

History Recording:

Creates history record in transaction:

const location = await prisma.$transaction(async (tx) => {\n  const newLocation = await tx.location.create({ data: createData });\n\n  await tx.locationHistory.create({\n    data: {\n      locationId: newLocation.id,\n      userId,\n      action: geocodeMetadata ? LocationHistoryAction.GEOCODED : LocationHistoryAction.CREATED,\n      metadata: geocodeMetadata,\n    },\n  });\n\n  return newLocation;\n});\n
"},{"location":"v2/backend/modules/locations/#locationsserviceupdateid-data-userid","title":"locationsService.update(id, data, userId)","text":"

Update location with smart geocoding and history tracking.

Smart Geocoding:

  • If address changes and no explicit lat/lng: re-geocode
  • If lat/lng provided: use provided coordinates (manual override)

Action Detection:

let action: LocationHistoryAction = LocationHistoryAction.UPDATED;\n\n// Explicit coordinate change (map drag)\nif (data.latitude !== undefined && data.latitude !== existing.latitude) {\n  action = LocationHistoryAction.MOVED_ON_MAP;\n}\n\n// Auto-geocode on address change\nif (data.address && data.address !== existing.address && !data.latitude && !data.longitude) {\n  const result = await geocodingService.geocode(data.address);\n  if (result) {\n    updateData.latitude = result.latitude;\n    updateData.longitude = result.longitude;\n    action = LocationHistoryAction.GEOCODED;\n  }\n}\n

Change Tracking:

const changes: { field: string; oldValue: unknown; newValue: unknown }[] = [];\n\nconst fieldsToTrack = ['address', 'firstName', 'lastName', 'email', 'phone', 'unitNumber', 'supportLevel', 'sign', 'signSize', 'notes'];\n\nfor (const field of fieldsToTrack) {\n  if (data[field] !== undefined && data[field] !== existing[field]) {\n    changes.push({ field, oldValue: existing[field], newValue: data[field] });\n  }\n}\n\n// Record all changes in transaction\nawait tx.locationHistory.createMany({ data: historyRecords });\n
"},{"location":"v2/backend/modules/locations/#locationsserviceimportfromcsvbuffer-userid","title":"locationsService.importFromCsv(buffer, userId)","text":"

Import CSV with flexible column mapping.

Column Mapping:

const CSV_HEADER_MAP: Record<string, keyof CsvRow> = {\n  'address': 'address',\n  'street': 'address',\n  'street address': 'address',\n  'first name': 'firstName',\n  'firstname': 'firstName',\n  // ... 50+ mappings\n};\n

Processing:

  1. Parse CSV with csv-parse library
  2. Detect column mapping from headers
  3. For each row:
  4. Validate required fields (address)
  5. Parse support level, sign boolean
  6. Use provided lat/lng or geocode address
  7. Create location in database
  8. Return summary statistics
"},{"location":"v2/backend/modules/locations/#locationsserviceimportbulkbuffer-userid-options-filters","title":"locationsService.importBulk(buffer, userId, options, filters)","text":"

Bulk import NAR or standard CSV with advanced filtering.

NAR Format Detection:

function detectNarFormat(headers: string[]): boolean {\n  const NAR_DETECT_COLUMNS = [\n    'CIVIC_NO', 'OFFICIAL_STREET_NAME', 'BG_X', 'BG_Y', // 2025 format\n    'STR_NBR', 'STR_NME', 'LAT', 'LNG',                 // Legacy format\n  ];\n\n  const normalizedHeaders = headers.map((h) => h.trim().toUpperCase());\n  let matchCount = 0;\n\n  for (const col of NAR_DETECT_COLUMNS) {\n    if (normalizedHeaders.includes(col)) matchCount++;\n  }\n\n  return matchCount >= 3; // At least 3 NAR columns\n}\n

3-Phase Processing:

Phase 1: Parse & Filter

// Parse all records\nfor (const record of records) {\n  // Build address from NAR fields\n  const civicNo = getValue('CIVIC_NO');\n  const streetName = getValue('STREET_NAME');\n  const address = [civicNo, streetName, ...].join(' ');\n\n  // Apply filters\n  if (filters?.city && !matchesCity(address, filters.city)) {\n    skippedOutOfBounds++;\n    continue;\n  }\n\n  // Residential filter\n  if (options.residentialOnly && buildingUse === 3) {\n    skippedOutOfBounds++;\n    continue;\n  }\n\n  parsedRecords.push({ address, lat, lng, needsGeocoding });\n}\n

Phase 2: Batch Geocode

// Collect addresses needing geocoding\nconst addressesToGeocode: string[] = parsedRecords\n  .filter(r => r.needsGeocoding)\n  .map(r => r.address);\n\n// Batch geocode (parallel)\nconst geocodeResults = await geocodingService.geocodeBatch(addressesToGeocode);\n

Phase 3: Create Records

const batch: Prisma.LocationCreateManyInput[] = [];\n\nfor (const parsed of parsedRecords) {\n  // Apply geocoding result\n  if (parsed.needsGeocoding) {\n    const result = geocodeResults[geocodeIndex];\n    if (result) {\n      lat = result.latitude;\n      lng = result.longitude;\n    }\n  }\n\n  // Cut polygon filter\n  if (filters?.cutPolygon) {\n    if (!isPointInPolygon(lat, lng, cutPolygon)) {\n      skippedOutOfBounds++;\n      continue;\n    }\n  }\n\n  // Deduplication\n  if (existingCoords.has(coordKey)) {\n    skippedDuplicate++;\n    continue;\n  }\n\n  batch.push({ address, lat, lng, ... });\n\n  // Flush batch\n  if (batch.length >= options.batchSize) {\n    await prisma.location.createMany({ data: batch });\n    batch.length = 0;\n  }\n}\n
"},{"location":"v2/backend/modules/locations/#locationsserviceexporttocsvfilters","title":"locationsService.exportToCsv(filters?)","text":"

Export locations as CSV.

CSV Generation:

import { stringify } from 'csv-stringify/sync';\n\nconst rows = locations.map((loc) => ({\n  address: loc.address || '',\n  firstName: loc.firstName || '',\n  lastName: loc.lastName || '',\n  email: loc.email || '',\n  phone: loc.phone || '',\n  unitNumber: loc.unitNumber || '',\n  supportLevel: loc.supportLevel || '',\n  sign: loc.sign ? 'Yes' : 'No',\n  signSize: loc.signSize || '',\n  notes: loc.notes || '',\n  latitude: loc.latitude?.toString() || '',\n  longitude: loc.longitude?.toString() || '',\n  geocodeConfidence: loc.geocodeConfidence?.toString() || '',\n  geocodeProvider: loc.geocodeProvider || '',\n  createdAt: loc.createdAt.toISOString(),\n}));\n\nreturn stringify(rows, { header: true });\n
"},{"location":"v2/backend/modules/locations/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/locations/#create-location-schema","title":"Create Location Schema","text":"
export const createLocationSchema = z.object({\n  address: z.string().min(1, 'Address is required'),\n  firstName: z.string().optional(),\n  lastName: z.string().optional(),\n  email: z.string().email().optional().or(z.literal('')),\n  phone: z.string().optional(),\n  unitNumber: z.string().optional(),\n  supportLevel: z.nativeEnum(SupportLevel).optional(),\n  sign: z.boolean().optional().default(false),\n  signSize: z.string().optional(),\n  notes: z.string().optional(),\n  buildingNotes: z.string().max(2000).optional(),\n  latitude: z.number().min(-90).max(90).optional(),\n  longitude: z.number().min(-180).max(180).optional(),\n});\n
"},{"location":"v2/backend/modules/locations/#bulk-import-schema","title":"Bulk Import Schema","text":"
export const bulkImportSchema = z.object({\n  format: z.enum(['standard', 'nar']).default('standard'),\n  filterType: z.enum(['none', 'cut', 'mapArea', 'city', 'province']).default('none'),\n  cutId: z.string().optional(),\n  filterCity: z.string().optional(),\n  filterProvince: z.string().optional(),\n  residentialOnly: z.coerce.boolean().default(false),\n  deduplicateRadius: z.coerce.number().min(0).max(100).default(5),\n  skipGeocoding: z.coerce.boolean().default(true),\n  batchSize: z.coerce.number().int().min(100).max(5000).default(1000),\n});\n
"},{"location":"v2/backend/modules/locations/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/locations/#admin-create-location-with-auto-geocoding","title":"Admin: Create Location with Auto-Geocoding","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst createLocation = async () => {\n  try {\n    const { data } = await api.post('/api/map/locations', {\n      address: '123 Main St, Toronto, ON',\n      firstName: 'John',\n      lastName: 'Doe',\n      email: 'john@example.com',\n      supportLevel: 'LEVEL_1',\n      sign: true,\n    });\n\n    message.success('Location created and geocoded');\n    console.log(`Created at: ${data.latitude}, ${data.longitude}`);\n    console.log(`Confidence: ${data.geocodeConfidence}%`);\n  } catch (error) {\n    message.error('Failed to create location');\n  }\n};\n
"},{"location":"v2/backend/modules/locations/#admin-import-nar-file-with-cut-filter","title":"Admin: Import NAR File with Cut Filter","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst importNAR = async (file: File, cutId: string) => {\n  const formData = new FormData();\n  formData.append('file', file);\n  formData.append('format', 'nar');\n  formData.append('filterType', 'cut');\n  formData.append('cutId', cutId);\n  formData.append('residentialOnly', 'true');\n  formData.append('deduplicateRadius', '5');\n\n  try {\n    const { data } = await api.post('/api/map/locations/import-bulk', formData, {\n      headers: { 'Content-Type': 'multipart/form-data' },\n      timeout: 300000, // 5 minutes\n    });\n\n    message.success(`Created ${data.created} locations`);\n    console.log(`Skipped ${data.skippedDuplicate} duplicates`);\n    console.log(`Skipped ${data.skippedOutOfBounds} out of bounds`);\n  } catch (error) {\n    message.error('NAR import failed');\n  }\n};\n
"},{"location":"v2/backend/modules/locations/#admin-export-locations","title":"Admin: Export Locations","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst exportLocations = async () => {\n  try {\n    const { data } = await api.get('/api/map/locations/export-csv', {\n      responseType: 'blob',\n    });\n\n    const url = window.URL.createObjectURL(new Blob([data]));\n    const link = document.createElement('a');\n    link.href = url;\n    link.setAttribute('download', 'locations.csv');\n    document.body.appendChild(link);\n    link.click();\n    link.remove();\n\n    message.success('Locations exported');\n  } catch (error) {\n    message.error('Export failed');\n  }\n};\n
"},{"location":"v2/backend/modules/locations/#frontend-integration","title":"Frontend Integration","text":"

The LocationsPage component (admin/src/pages/LocationsPage.tsx) provides:

  • Location table with pagination (20 results/page)
  • Search (address, name, email)
  • Filters (support level, sign, confidence level)
  • Sorting (createdAt, address, supportLevel)
  • Statistics dashboard (total, support levels, signs, geocoded, confidence breakdown, provider distribution)
  • Create location modal (form with auto-geocoding preview)
  • Edit location modal (pre-populated form)
  • Delete location action
  • Bulk delete (select multiple rows)
  • CSV import (10MB limit)
  • NAR bulk import (100MB limit, cut/city/province filters)
  • CSV export (download button)
  • Geocode missing button (batch geocodes all ungeocoded)
  • Location history drawer (audit trail with user, action, field changes)
  • Map integration (shows all geocoded locations, click-to-add, drag-to-move)

State Management:

const [locations, setLocations] = useState<Location[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });\nconst [filters, setFilters] = useState({ search: '', supportLevel: null, hasSign: null, confidenceLevel: null });\nconst [stats, setStats] = useState({ total: 0, supportLevels: {}, signs: 0, geocoded: 0, ungeocoded: 0, confidence: {}, providers: {} });\n
"},{"location":"v2/backend/modules/locations/#performance-considerations","title":"Performance Considerations","text":"

Batch Processing:

  • NAR import uses 1000-record batches (configurable)
  • Reduces transaction overhead
  • Improves import speed (10,000+ locations/minute)

Deduplication:

  • Coordinate-based (5 decimal places = ~1.1m precision)
  • In-memory Set for fast lookups
  • Prevents duplicate imports within same file

Indexing:

  • @@index([latitude, longitude]) \u2014 Fast map bounds queries
  • @@index([supportLevel]) \u2014 Fast filtering by support level
  • @@index([sign]) \u2014 Fast sign filtering
  • @@index([geocodeConfidence]) \u2014 Fast confidence filtering

Safety Limits:

  • Map queries limited to 5000 locations
  • CSV import limited to 10MB
  • Bulk import limited to 100MB (5-minute timeout)
  • Bulk import warning header when limit hit

Geocoding:

  • Auto-geocodes on create/update (individual addresses)
  • Batch geocoding for bulk imports (parallel processing)
  • Uses BullMQ queue for background geocoding (separate service)
"},{"location":"v2/backend/modules/locations/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/locations/#issue-csv-import-fails-with-invalid-csv-file-format","title":"Issue: CSV import fails with \"Invalid CSV file format\"","text":"

Cause: CSV not UTF-8 encoded or has malformed rows

Solution:

  • Save CSV as UTF-8 in Excel/LibreOffice
  • Ensure no missing quote delimiters
  • Remove empty rows at end of file
"},{"location":"v2/backend/modules/locations/#issue-nar-import-skips-all-records-skippedoutofbounds-total","title":"Issue: NAR import skips all records (skippedOutOfBounds = total)","text":"

Cause: Cut/city/province filter doesn't match any records

Solution:

  • Verify cut ID is correct
  • Check city/province spelling matches NAR data (case-insensitive)
  • Try without filters first to verify file format
"},{"location":"v2/backend/modules/locations/#issue-geocoding-confidence-is-low-60-for-many-locations","title":"Issue: Geocoding confidence is low (<60) for many locations","text":"

Cause: Incomplete addresses or geocoding provider limitations

Solution:

  • Use NAR import (has pre-geocoded coordinates)
  • Add city/province to addresses
  • Try different geocoding provider (see settings)
  • Use \"Geocode Missing\" button to retry with fallback providers
"},{"location":"v2/backend/modules/locations/#issue-bulk-import-times-out-after-5-minutes","title":"Issue: Bulk import times out after 5 minutes","text":"

Cause: File too large or too many locations to geocode

Solution:

  • Set skipGeocoding=true for NAR imports (coordinates included)
  • Split large files into smaller batches
  • Use cut filter to reduce import size
  • Increase batchSize parameter (1000 \u2192 2000)
"},{"location":"v2/backend/modules/locations/#related-documentation","title":"Related Documentation","text":"
  • Geocoding Service - Multi-provider geocoding
  • Cuts Module - Polygon filtering
  • Spatial Utils - Point-in-polygon, bounds calculation
  • Frontend: LocationsPage - Location management UI
  • Frontend: Public Map Page - Public location map
  • API Reference: Locations - Complete endpoint reference
  • Feature: Location Management - Location management feature guide
  • Feature: NAR Import - NAR bulk import guide
"},{"location":"v2/backend/modules/media/","title":"Media Module (Fastify Video Library API)","text":""},{"location":"v2/backend/modules/media/#overview","title":"Overview","text":"

The Media module is a separate Fastify microservice running on port 4100 (separate from the main Express API on port 4000). It provides a complete video library management system with public gallery features, reaction tracking, and job queue for video processing. The module uses Drizzle ORM (unlike the main API's Prisma ORM) and shares the same PostgreSQL database.

Key Features:

  • Dual API architecture:
  • Main Express API (port 4000) \u2014 Prisma ORM
  • Media Fastify API (port 4100) \u2014 Drizzle ORM
  • Shared PostgreSQL 16 database
  • Video library management:
  • Directory-based organization (studios, gifs, private, inbox, curated, etc.)
  • Metadata tracking (duration, quality, orientation, file size, dimensions)
  • Thumbnail generation and storage
  • File hash-based deduplication
  • Public gallery system:
  • Category-based organization
  • Engagement tracking (views, upvotes, comments, watch time)
  • Lock/unlock system for controlling public visibility
  • Session-based upvoting (no auth required)
  • Reaction system:
  • 6 emoji reactions (\ud83d\udc4d like, \u2764\ufe0f love, \ud83d\ude02 laugh, \ud83d\ude2e wow, \ud83d\ude22 sad, \ud83d\ude20 angry)
  • Timestamped reactions (mark specific moments in videos)
  • User-based tracking (authenticated users)
  • Job queue:
  • Video processing job management
  • Resource category allocation (GPU AI, GPU encode, CPU)
  • Queue position tracking with VRAM requirements
  • Pipeline integration for multi-step processing
  • Compilation management:
  • Multi-video compilation tracking
  • Settings preservation
  • Feature flag: ENABLE_MEDIA_FEATURES=true (opt-in)
"},{"location":"v2/backend/modules/media/#file-paths","title":"File Paths","text":"File Purpose api/src/media-server.ts Fastify server entry point (port 4100) api/src/modules/media/db/schema.ts Drizzle schema (15+ tables, 1,400+ lines) api/src/modules/media/routes/videos.routes.ts Video CRUD routes (99 lines) api/src/modules/media/routes/public-media.routes.ts Public gallery routes (12,852 lines) api/src/modules/media/routes/reactions.routes.ts Reaction routes (135 lines) api/src/modules/media/routes/comments.routes.ts Comment routes (4,827 lines) api/src/modules/media/middleware/auth.ts Fastify auth middleware (JWT verification) api/src/modules/media/types/enums.ts Shared enums"},{"location":"v2/backend/modules/media/#database-models-drizzle-orm","title":"Database Models (Drizzle ORM)","text":""},{"location":"v2/backend/modules/media/#videos-table","title":"Videos Table","text":"
export const videos = pgTable('videos', {\n  id: serial('id').primaryKey(),\n  path: text('path').notNull().unique(),\n  filename: text('filename').notNull(),\n  producer: text('producer'),\n  creator: text('creator'),\n  title: text('title'),\n  durationSeconds: integer('duration_seconds'),\n  quality: text('quality'),\n  orientation: text('orientation'),\n  hasAudio: boolean('has_audio').default(true),\n  fileSize: bigint('file_size', { mode: 'number' }),\n  fileHash: text('file_hash'),\n  width: integer('width'),\n  height: integer('height'),\n  lastValidated: timestamp('last_validated', { withTimezone: true }),\n  isValid: boolean('is_valid').default(true),\n  thumbnailPath: text('thumbnail_path'),\n  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),\n  tags: jsonb('tags').$type<string[]>(),\n\n  // Directory type for efficient filtering\n  directoryType: text('directory_type').$type<DirectoryType>(),\n\n  // Historical engagement stats (preserved when moved from public media)\n  publicViewCount: integer('public_view_count'),\n  publicUpvoteCount: integer('public_upvote_count'),\n  publicCommentCount: integer('public_comment_count'),\n  publicCompletionCount: integer('public_completion_count'),\n  publicTotalWatchTime: integer('public_total_watch_time'),\n  movedFromPublicAt: timestamp('moved_from_public_at', { withTimezone: true }),\n\n  // Name standardization tracking\n  originalFilename: text('original_filename'),\n  originalPath: text('original_path'),\n  standardizedAt: timestamp('standardized_at', { withTimezone: true }),\n}, (table) => ({\n  orientationIdx: index('idx_orientation').on(table.orientation),\n  producerIdx: index('idx_producer').on(table.producer),\n  isValidIdx: index('idx_is_valid').on(table.isValid),\n  directoryTypeIdx: index('idx_directory_type').on(table.directoryType),\n  fingerprintIdx: index('idx_videos_fingerprint').on(\n    table.durationSeconds, table.fileSize, table.width, table.height\n  ),\n  directoryValidOrientationIdx: index('idx_videos_directory_valid_orientation').on(\n    table.directoryType, table.isValid, table.orientation\n  ),\n}));\n\n// Directory types\nexport const DIRECTORY_TYPES = [\n  'studios', 'gifs', 'private', 'inbox', 'curated',\n  'playback', 'compilations', 'videos', 'highlights'\n] as const;\nexport type DirectoryType = typeof DIRECTORY_TYPES[number];\n

Key Features:

  • Unique path constraint \u2014 Prevents duplicate entries
  • File hash \u2014 Enables deduplication based on content
  • Fingerprint index \u2014 Fast duplicate detection (duration + fileSize + width + height)
  • Directory type \u2014 Efficient filtering by category
  • Historical stats \u2014 Preserves engagement metrics when moving from public gallery
  • Standardization tracking \u2014 Tracks original filename before renaming
"},{"location":"v2/backend/modules/media/#public-media-table","title":"Public Media Table","text":"
export const publicMedia = pgTable('public_media', {\n  id: serial('id').primaryKey(),\n  path: text('path').notNull().unique(),\n  filename: text('filename').notNull(),\n  category: text('category').notNull(),\n  durationSeconds: integer('duration_seconds'),\n  quality: text('quality'),\n  orientation: text('orientation'),\n  thumbnailPath: text('thumbnail_path'),\n  fileSize: bigint('file_size', { mode: 'number' }),\n\n  // Denormalized counters for performance\n  viewCount: integer('view_count').default(0),\n  upvoteCount: integer('upvote_count').default(0),\n  commentCount: integer('comment_count').default(0),\n  finishCount: integer('finish_count').default(0),\n  totalWatchTime: integer('total_watch_time').default(0),\n\n  // Lock system\n  isLocked: boolean('is_locked').default(false),\n  lockedAt: timestamp('locked_at', { withTimezone: true }),\n  lockedReason: text('locked_reason'),\n\n  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),\n  updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),\n}, (table) => ({\n  categoryIdx: index('idx_public_media_category').on(table.category),\n  orientationIdx: index('idx_public_media_orientation').on(table.orientation),\n  viewCountIdx: index('idx_public_media_views').on(table.viewCount),\n  upvoteCountIdx: index('idx_public_media_upvotes').on(table.upvoteCount),\n  isLockedIdx: index('idx_public_media_locked').on(table.isLocked),\n}));\n

Key Features:

  • Denormalized counters \u2014 Fast sorting by popularity (no joins)
  • Lock system \u2014 Admin can lock videos to prevent public access
  • Category organization \u2014 Flexible categorization system
  • Performance indexes \u2014 Optimized for sorting by views/upvotes
"},{"location":"v2/backend/modules/media/#upvotes-table","title":"Upvotes Table","text":"
export const upvotes = pgTable('upvotes', {\n  id: serial('id').primaryKey(),\n  mediaId: integer('media_id').notNull(),\n  sessionId: text('session_id').notNull(),\n  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),\n}, (table) => ({\n  uniqueVoteIdx: index('idx_upvotes_unique').on(table.mediaId, table.sessionId),\n  mediaIdx: index('idx_upvotes_media').on(table.mediaId),\n}));\n

Key Features:

  • Session-based \u2014 No authentication required (anonymous upvoting)
  • Unique constraint \u2014 One upvote per session per media item
  • Denormalized \u2014 upvoteCount in publicMedia table updated via trigger or application logic
"},{"location":"v2/backend/modules/media/#video-reactions-table","title":"Video Reactions Table","text":"
export const REACTION_TYPES = ['like', 'love', 'laugh', 'wow', 'sad', 'angry'] as const;\nexport type ReactionType = typeof REACTION_TYPES[number];\n\nexport const videoReactions = pgTable('video_reactions', {\n  id: serial('id').primaryKey(),\n  userId: integer('user_id').notNull(),\n  mediaId: integer('media_id').notNull(),\n  reactionType: text('reaction_type').notNull(),\n  videoTimestamp: integer('video_timestamp').notNull(), // seconds into video\n  createdAt: timestamp('created_at', { withTimezone: true }).notNull(),\n}, (table) => ({\n  userMediaTypeIdx: index('idx_video_reactions_user_media_type').on(\n    table.userId, table.mediaId, table.reactionType\n  ),\n  mediaTimestampIdx: index('idx_video_reactions_media_timestamp').on(\n    table.mediaId, table.videoTimestamp\n  ),\n  mediaIdx: index('idx_video_reactions_media').on(table.mediaId),\n  createdAtIdx: index('idx_video_reactions_created').on(table.createdAt),\n}));\n

Reaction Emojis:

Type Emoji Label like \ud83d\udc4d Like love \u2764\ufe0f Love laugh \ud83d\ude02 Laugh wow \ud83d\ude2e Wow sad \ud83d\ude22 Sad angry \ud83d\ude20 Angry

Key Features:

  • Timestamped reactions \u2014 Mark specific moments in videos
  • User-based \u2014 Requires authentication
  • Timeline visualization \u2014 Can show reaction heatmap across video timeline
"},{"location":"v2/backend/modules/media/#jobs-table","title":"Jobs Table","text":"
export type ResourceCategory = 'gpu_ai' | 'gpu_encode' | 'cpu';\nexport type JobStatus = 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';\n\nexport const jobs = pgTable('jobs', {\n  id: serial('id').primaryKey(),\n  type: text('type').notNull(),\n  status: text('status').default('pending').$type<JobStatus>(),\n  progress: integer('progress').default(0),\n  log: text('log'),\n  params: jsonb('params').$type<Record<string, unknown>>(),\n  startedAt: timestamp('started_at', { withTimezone: true }),\n  completedAt: timestamp('completed_at', { withTimezone: true }),\n  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),\n\n  // Queue management\n  resourceCategory: text('resource_category').default('cpu').$type<ResourceCategory>(),\n  vramRequired: integer('vram_required').default(0),\n  queuePosition: integer('queue_position'),\n  waitingReason: text('waiting_reason'),\n  priority: integer('priority').default(5),\n\n  // Pipeline integration\n  pipelineId: integer('pipeline_id'),\n  pipelineStepId: integer('pipeline_step_id'),\n}, (table) => ({\n  queueIdx: index('idx_jobs_queue').on(table.status, table.priority, table.createdAt),\n  resourceIdx: index('idx_jobs_resource').on(table.resourceCategory, table.status),\n  pipelineIdx: index('idx_jobs_pipeline').on(table.pipelineId),\n}));\n

Job Types:

  • compilation \u2014 Multi-video compilation
  • scan, public_scan \u2014 Video library scanning
  • organize, organize_studio \u2014 Automatic organization
  • reencode_streaming \u2014 Transcode for web streaming
  • compile_random, compile_quad, compile_quad_horizontal, etc. \u2014 Compilation variants
  • generate_gif, fetch, digest, clip_generate, highlight_generate \u2014 Content generation
  • tag_generation, scene_extract, clip_extract_only, auto_organize_publish \u2014 AI-powered tasks

Resource Categories:

  • gpu_ai \u2014 AI/ML tasks (scene detection, tagging, etc.) \u2014 High VRAM
  • gpu_encode \u2014 Video encoding/transcoding \u2014 Medium VRAM
  • cpu \u2014 General processing \u2014 No GPU required
"},{"location":"v2/backend/modules/media/#compilations-table","title":"Compilations Table","text":"
export const compilations = pgTable('compilations', {\n  id: serial('id').primaryKey(),\n  filename: text('filename').notNull(),\n  path: text('path'),\n  durationSeconds: integer('duration_seconds'),\n  videoIds: jsonb('video_ids').$type<number[]>(),\n  settings: jsonb('settings').$type<Record<string, unknown>>(),\n  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),\n});\n

Key Features:

  • Multi-video tracking \u2014 Stores array of source video IDs
  • Settings preservation \u2014 Stores compilation parameters (layout, transitions, etc.)
"},{"location":"v2/backend/modules/media/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/media/#admin-endpoints-videos","title":"Admin Endpoints (Videos)","text":"Method Path Auth Description GET /api/videos Admin roles List videos with pagination GET /api/videos/:id Admin roles Get single video GET /api/videos/health None Health check

Admin Roles: Requires admin role via Fastify auth middleware

"},{"location":"v2/backend/modules/media/#public-media-endpoints","title":"Public Media Endpoints","text":"Method Path Auth Description GET /api/media/public None List shared media (paginated, filterable, sorted) GET /api/media/public/:id None Get single media + increment view count POST /api/media/public/:id/upvote None Upvote media (session-based) DELETE /api/media/public/:id/upvote None Remove upvote POST /api/media/public/:id/finish None Mark video as finished POST /api/media/public/:id/watch-time None Track watch time"},{"location":"v2/backend/modules/media/#reaction-endpoints","title":"Reaction Endpoints","text":"Method Path Auth Description POST /api/reactions Required Add reaction to video GET /api/reactions None Get reactions (filterable by mediaId/userId) GET /api/reactions/config None Get available reaction types"},{"location":"v2/backend/modules/media/#comment-endpoints","title":"Comment Endpoints","text":"Method Path Auth Description POST /api/media/comments Optional Add comment (auth optional, session-based) GET /api/media/comments None List comments for media"},{"location":"v2/backend/modules/media/#endpoint-details","title":"Endpoint Details","text":""},{"location":"v2/backend/modules/media/#get-apivideos","title":"GET /api/videos","text":"

List videos with pagination and search (admin only).

Query Parameters:

Parameter Type Default Description limit number 50 Results per page (max 100) offset number 0 Skip N results search string - Search title (case-insensitive)

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://localhost:4100/api/videos?limit=20&offset=0&search=demo\"\n

Response (200 OK):

{\n  \"videos\": [\n    {\n      \"id\": 123,\n      \"title\": \"Demo Video\",\n      \"filename\": \"demo-video.mp4\",\n      \"duration\": 300,\n      \"fileSize\": 52428800,\n      \"width\": 1920,\n      \"height\": 1080,\n      \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n      \"updatedAt\": \"2026-02-11T14:30:00.000Z\"\n    }\n  ],\n  \"total\": 45,\n  \"limit\": 20,\n  \"offset\": 0\n}\n
"},{"location":"v2/backend/modules/media/#get-apimediapublic","title":"GET /api/media/public","text":"

List shared media with pagination, filtering, and sorting (no auth required).

Query Parameters:

Parameter Type Default Description category string - Filter by category search string - Search filename/path sort enum recent Sort: recent, popular, most_viewed orientation string - Filter by orientation limit number 24 Results per page (max 100) offset number 0 Skip N results

Example Request:

curl \"http://localhost:4100/api/media/public?category=highlights&sort=popular&limit=12\"\n

Response (200 OK):

{\n  \"videos\": [\n    {\n      \"id\": 456,\n      \"filename\": \"highlight-2024-01-15.mp4\",\n      \"category\": \"highlights\",\n      \"durationSeconds\": 45,\n      \"quality\": \"1080p\",\n      \"orientation\": \"landscape\",\n      \"thumbnailPath\": \"/thumbnails/highlight-2024-01-15.jpg\",\n      \"viewCount\": 1250,\n      \"upvoteCount\": 89,\n      \"commentCount\": 12,\n      \"isLocked\": false,\n      \"createdAt\": \"2026-01-15T10:00:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"total\": 145,\n    \"limit\": 12,\n    \"offset\": 0,\n    \"hasMore\": true\n  }\n}\n

Sort Modes:

switch (sort) {\n  case 'popular':\n    orderBy = [desc(publicMedia.upvoteCount), desc(publicMedia.createdAt)];\n    break;\n  case 'most_viewed':\n    orderBy = [desc(publicMedia.viewCount), desc(publicMedia.createdAt)];\n    break;\n  case 'recent':\n  default:\n    orderBy = [desc(publicMedia.createdAt)];\n    break;\n}\n
"},{"location":"v2/backend/modules/media/#get-apimediapublicid","title":"GET /api/media/public/:id","text":"

Get single media details and increment view count (no auth required).

Path Parameters:

  • id (number): Media ID

Example Request:

curl \"http://localhost:4100/api/media/public/456\"\n

Response (200 OK):

{\n  \"id\": 456,\n  \"path\": \"/public/highlights/highlight-2024-01-15.mp4\",\n  \"filename\": \"highlight-2024-01-15.mp4\",\n  \"category\": \"highlights\",\n  \"durationSeconds\": 45,\n  \"quality\": \"1080p\",\n  \"orientation\": \"landscape\",\n  \"thumbnailPath\": \"/thumbnails/highlight-2024-01-15.jpg\",\n  \"fileSize\": 15728640,\n  \"viewCount\": 1251,\n  \"upvoteCount\": 89,\n  \"commentCount\": 12,\n  \"finishCount\": 420,\n  \"totalWatchTime\": 48600,\n  \"isLocked\": false,\n  \"createdAt\": \"2026-01-15T10:00:00.000Z\",\n  \"updatedAt\": \"2026-02-11T15:45:00.000Z\"\n}\n

Side Effect:

View count is incremented fire-and-forget (does not block response):

// Increment view count (fire and forget)\ndb.update(publicMedia)\n  .set({ viewCount: sql`${publicMedia.viewCount} + 1` })\n  .where(eq(publicMedia.id, mediaId))\n  .execute()\n  .catch(err => logger.error({ err }, 'Failed to increment view count'));\n
"},{"location":"v2/backend/modules/media/#post-apimediapublicidupvote","title":"POST /api/media/public/:id/upvote","text":"

Upvote media (session-based, no auth required).

Path Parameters:

  • id (number): Media ID

Request Body:

{\n  \"sessionId\": \"sess_abc123def456\"\n}\n

Response (200 OK):

{\n  \"success\": true,\n  \"upvoted\": true,\n  \"upvoteCount\": 90\n}\n

Behavior:

  • Idempotent \u2014 If already upvoted, returns existing upvote
  • Denormalized counter \u2014 Updates publicMedia.upvoteCount atomically
  • Session-based \u2014 No authentication required

Duplicate Prevention:

// Check if already upvoted\nconst [existingVote] = await db\n  .select()\n  .from(upvotes)\n  .where(and(\n    eq(upvotes.mediaId, mediaId),\n    eq(upvotes.sessionId, sessionId)\n  ));\n\nif (existingVote) {\n  return reply.send({ success: true, upvoted: true, upvoteCount: media.upvoteCount });\n}\n
"},{"location":"v2/backend/modules/media/#delete-apimediapublicidupvote","title":"DELETE /api/media/public/:id/upvote","text":"

Remove upvote (session-based).

Path Parameters:

  • id (number): Media ID

Query Parameters:

  • sessionId (string): Session ID

Response (200 OK):

{\n  \"success\": true,\n  \"upvoted\": false,\n  \"upvoteCount\": 89\n}\n
"},{"location":"v2/backend/modules/media/#post-apireactions","title":"POST /api/reactions","text":"

Add reaction to video (authenticated users only).

Request Body:

{\n  \"mediaId\": 456,\n  \"reactionType\": \"love\",\n  \"videoTimestamp\": 27\n}\n

Response (200 OK):

{\n  \"success\": true,\n  \"reaction\": {\n    \"id\": 789,\n    \"mediaId\": 456,\n    \"userId\": 123,\n    \"reactionType\": \"love\",\n    \"videoTimestamp\": 27,\n    \"emoji\": \"\u2764\ufe0f\",\n    \"formattedTime\": \"0:27\",\n    \"createdAt\": \"2026-02-11T15:50:00.000Z\"\n  }\n}\n

Validation:

const REACTION_EMOJIS: Record<string, string> = {\n  like: '\ud83d\udc4d',\n  love: '\u2764\ufe0f',\n  laugh: '\ud83d\ude02',\n  wow: '\ud83d\ude2e',\n  sad: '\ud83d\ude22',\n  angry: '\ud83d\ude20',\n};\n\nif (!REACTION_EMOJIS[reactionType]) {\n  return fastify.httpErrors.badRequest('Invalid reaction type');\n}\n

Time Formatting:

function formatVideoTime(seconds: number): string {\n  const h = Math.floor(seconds / 3600);\n  const m = Math.floor((seconds % 3600) / 60);\n  const s = seconds % 60;\n\n  if (h > 0) {\n    return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;\n  }\n  return `${m}:${s.toString().padStart(2, '0')}`;\n}\n\n// Examples:\n// 27 \u2192 \"0:27\"\n// 90 \u2192 \"1:30\"\n// 3661 \u2192 \"1:01:01\"\n
"},{"location":"v2/backend/modules/media/#get-apireactions","title":"GET /api/reactions","text":"

Get reactions (filterable by mediaId/userId).

Query Parameters:

Parameter Type Description mediaId number Filter by media ID userId string Filter by user ID limit number Results per page (default 50)

Example Request:

curl \"http://localhost:4100/api/reactions?mediaId=456&limit=20\"\n

Response (200 OK):

{\n  \"reactions\": [\n    {\n      \"id\": 789,\n      \"mediaId\": 456,\n      \"userId\": 123,\n      \"reactionType\": \"love\",\n      \"videoTimestamp\": 27,\n      \"emoji\": \"\u2764\ufe0f\",\n      \"formattedTime\": \"0:27\",\n      \"createdAt\": \"2026-02-11T15:50:00.000Z\"\n    },\n    {\n      \"id\": 790,\n      \"mediaId\": 456,\n      \"userId\": 124,\n      \"reactionType\": \"laugh\",\n      \"videoTimestamp\": 42,\n      \"emoji\": \"\ud83d\ude02\",\n      \"formattedTime\": \"0:42\",\n      \"createdAt\": \"2026-02-11T15:51:00.000Z\"\n    }\n  ]\n}\n
"},{"location":"v2/backend/modules/media/#get-apireactionsconfig","title":"GET /api/reactions/config","text":"

Get available reaction types.

Example Request:

curl \"http://localhost:4100/api/reactions/config\"\n

Response (200 OK):

{\n  \"reactions\": [\n    { \"type\": \"like\", \"emoji\": \"\ud83d\udc4d\", \"label\": \"Like\" },\n    { \"type\": \"love\", \"emoji\": \"\u2764\ufe0f\", \"label\": \"Love\" },\n    { \"type\": \"laugh\", \"emoji\": \"\ud83d\ude02\", \"label\": \"Laugh\" },\n    { \"type\": \"wow\", \"emoji\": \"\ud83d\ude2e\", \"label\": \"Wow\" },\n    { \"type\": \"sad\", \"emoji\": \"\ud83d\ude22\", \"label\": \"Sad\" },\n    { \"type\": \"angry\", \"emoji\": \"\ud83d\ude20\", \"label\": \"Angry\" }\n  ]\n}\n
"},{"location":"v2/backend/modules/media/#fastify-vs-express-differences","title":"Fastify vs Express Differences","text":"Feature Express API (port 4000) Fastify Media API (port 4100) Framework Express 5 Fastify ORM Prisma Drizzle Schema Validation Zod + middleware Fastify built-in Auth Middleware authenticate, requireRole authenticate, requireAdminRole, optionalAuth Error Handling AppError class + error handler middleware fastify.httpErrors + decorators Route Registration router.get(...) fastify.register(routes, { prefix }) Request Handler (req, res, next) => {} async (request, reply) => {} Database Client import { prisma } import { db } Query Builder Prisma fluent API Drizzle query builder"},{"location":"v2/backend/modules/media/#code-pattern-comparison","title":"Code Pattern Comparison","text":"

Express (Prisma):

import { Router } from 'express';\nimport { prisma } from '../../config/database';\nimport { authenticate, requireRole } from '../../middleware/auth.middleware';\n\nconst router = Router();\n\nrouter.get('/', authenticate, requireRole('ADMIN'), async (req, res, next) => {\n  try {\n    const users = await prisma.user.findMany({\n      where: { role: 'ADMIN' },\n      select: { id: true, email: true },\n    });\n    res.json(users);\n  } catch (err) {\n    next(err);\n  }\n});\n\nexport default router;\n

Fastify (Drizzle):

import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';\nimport { db } from '../db';\nimport { users } from '../db/schema';\nimport { eq } from 'drizzle-orm';\nimport { requireAdminRole } from '../middleware/auth';\n\nexport async function usersRoutes(fastify: FastifyInstance) {\n  fastify.get(\n    '/',\n    { preHandler: requireAdminRole },\n    async (request: FastifyRequest, reply: FastifyReply) => {\n      const results = await db\n        .select({ id: users.id, email: users.email })\n        .from(users)\n        .where(eq(users.role, 'ADMIN'));\n\n      return reply.send(results);\n    }\n  );\n}\n
"},{"location":"v2/backend/modules/media/#frontend-integration","title":"Frontend Integration","text":"

The Media module integrates with multiple frontend pages:

"},{"location":"v2/backend/modules/media/#admin-pages","title":"Admin Pages","text":"
  • LibraryPage (admin/src/pages/media/LibraryPage.tsx)
  • Video grid with thumbnails
  • Filter by directory type
  • Search by filename
  • Bulk operations (lock, unlock, delete)

  • SharedMediaPage (admin/src/pages/media/SharedMediaPage.tsx)

  • Public gallery admin
  • Category management
  • Lock/unlock controls
  • Engagement metrics display

  • MediaJobsPage (admin/src/pages/media/MediaJobsPage.tsx)

  • Job queue monitoring
  • Job status tracking (pending, queued, running, completed, failed)
  • Progress visualization
  • Resource category filtering
"},{"location":"v2/backend/modules/media/#public-pages","title":"Public Pages","text":"
  • MediaGalleryPage (admin/src/pages/public/MediaGalleryPage.tsx)
  • Public video gallery
  • Category filtering
  • Sort by recent/popular/most viewed
  • Upvote functionality (session-based)
  • View count display

  • MediaViewerPage (admin/src/pages/public/MediaViewerPage.tsx)

  • Video player with reactions
  • Timestamped reactions overlay
  • Comment section
  • Related videos
  • Share functionality

State Management:

// Admin: useMediaApi hook\nconst { videos, loading, error } = useMediaApi('/api/videos', {\n  limit: 24,\n  offset: 0,\n  search: '',\n});\n\n// Public: Direct axios calls to media API\nconst { data } = await axios.get('http://localhost:4100/api/media/public', {\n  params: { category: 'highlights', sort: 'popular', limit: 12 },\n});\n
"},{"location":"v2/backend/modules/media/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/backend/modules/media/#denormalized-counters","title":"Denormalized Counters","text":"

The publicMedia table uses denormalized counters for engagement metrics:

viewCount: integer('view_count').default(0),\nupvoteCount: integer('upvote_count').default(0),\ncommentCount: integer('comment_count').default(0),\nfinishCount: integer('finish_count').default(0),\ntotalWatchTime: integer('total_watch_time').default(0),\n

Pros:

  • Fast sorting \u2014 No joins or aggregations needed
  • Instant popularity ranking \u2014 Direct sorting on indexed columns
  • Simple queries \u2014 No complex GROUP BY clauses

Cons:

  • Consistency risk \u2014 Counters can drift if transactions fail
  • Update overhead \u2014 Must update counter on every upvote/view

Mitigation:

  • Use atomic updates: sql\\${publicMedia.viewCount} + 1``
  • Run periodic reconciliation job to fix drift
"},{"location":"v2/backend/modules/media/#fire-and-forget-view-tracking","title":"Fire-and-Forget View Tracking","text":"

View count increments are fire-and-forget to avoid blocking response:

// Increment view count (fire and forget)\ndb.update(publicMedia)\n  .set({ viewCount: sql`${publicMedia.viewCount} + 1` })\n  .where(eq(publicMedia.id, mediaId))\n  .execute()\n  .catch(err => logger.error({ err }, 'Failed to increment view count'));\n\n// Return immediately (don't await)\nreturn reply.send(media);\n

Trade-off:

  • Faster response \u2014 User doesn't wait for view count update
  • Eventual consistency \u2014 View count may be slightly behind
"},{"location":"v2/backend/modules/media/#fingerprint-based-deduplication","title":"Fingerprint-Based Deduplication","text":"

The videos table includes a composite index for fast duplicate detection:

fingerprintIdx: index('idx_videos_fingerprint').on(\n  table.durationSeconds, table.fileSize, table.width, table.height\n),\n

Usage:

const duplicates = await db\n  .select()\n  .from(videos)\n  .where(and(\n    eq(videos.durationSeconds, newVideo.durationSeconds),\n    eq(videos.fileSize, newVideo.fileSize),\n    eq(videos.width, newVideo.width),\n    eq(videos.height, newVideo.height),\n  ));\n\nif (duplicates.length > 0 && duplicates[0].fileHash === newVideo.fileHash) {\n  throw new Error('Duplicate video detected');\n}\n

Why Fingerprint Index:

  • Fast pre-filter \u2014 Index lookup narrows candidates
  • File hash check \u2014 Confirms exact duplicate (expensive, only on candidates)
  • Two-stage approach \u2014 Balances speed and accuracy
"},{"location":"v2/backend/modules/media/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/media/#media-api-not-starting","title":"Media API Not Starting","text":"

Problem:

Docker logs show \"Media API server closed\" immediately.

Diagnosis:

Check env vars:

docker compose exec api printenv | grep MEDIA\n

Required vars:

MEDIA_API_PORT=4100\nENABLE_MEDIA_FEATURES=true\nMAX_UPLOAD_SIZE_GB=10\n

Solution:

  • Verify ENABLE_MEDIA_FEATURES=true in .env
  • Check port conflicts: lsof -i :4100
  • Check database connection (shares same DATABASE_URL)
"},{"location":"v2/backend/modules/media/#cors-errors-on-media-api","title":"CORS Errors on Media API","text":"

Problem:

Frontend gets CORS errors when calling media API endpoints.

Diagnosis:

Check CORS origins:

CORS_ORIGINS=http://localhost:3000,http://localhost:3010\n

Behavior:

await fastify.register(cors, {\n  origin: (origin, cb) => {\n    if (!origin) {\n      cb(null, true);  // Allow no origin (mobile, curl)\n      return;\n    }\n\n    if (allowedOrigins.includes(origin)) {\n      cb(null, true);\n    } else {\n      cb(new Error('CORS not allowed'), false);\n    }\n  },\n  credentials: true,\n});\n

Solution:

Add missing origins to CORS_ORIGINS in .env:

CORS_ORIGINS=http://localhost:3000,http://localhost:3010,http://localhost:3100\n
"},{"location":"v2/backend/modules/media/#upvote-not-working","title":"Upvote Not Working","text":"

Problem:

Upvote button doesn't work, returns 400 error.

Diagnosis:

Check request body:

curl -X POST \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"sessionId\":\"sess_abc123\"}' \\\n  http://localhost:4100/api/media/public/456/upvote\n

Common Issues:

  1. Missing sessionId:

    { \"error\": \"sessionId is required\" }\n

  2. Media not found:

    { \"error\": \"Media not found\" }\n

  3. Locked media:

    { \"error\": \"Media is locked\" }\n

Solution:

  • Generate session ID in frontend: crypto.randomUUID() or nanoid()
  • Verify media exists in public_media table
  • Check isLocked status
"},{"location":"v2/backend/modules/media/#reactions-not-appearing","title":"Reactions Not Appearing","text":"

Problem:

Reactions submitted but not appearing in frontend.

Diagnosis:

Check reaction data:

SELECT * FROM video_reactions WHERE \"mediaId\" = 456 ORDER BY \"createdAt\" DESC LIMIT 10;\n

Verify:

  • userId matches authenticated user
  • mediaId matches video ID
  • reactionType is valid emoji type

Common Issues:

  1. Authentication failed:
  2. Reaction requires auth
  3. Check JWT token in Authorization header

  4. Invalid reaction type:

    { \"error\": \"Invalid reaction type\" }\n

  5. Video not found:

    { \"error\": \"Video not found\" }\n

Solution:

  • Verify JWT token is valid and not expired
  • Use valid reaction types: like, love, laugh, wow, sad, angry
  • Check video exists in videos table (not just public_media)
"},{"location":"v2/backend/modules/media/#job-queue-not-processing","title":"Job Queue Not Processing","text":"

Problem:

Jobs stuck in pending status, never transition to running.

Diagnosis:

Check job queue:

SELECT id, type, status, \"resourceCategory\", \"queuePosition\", \"waitingReason\"\nFROM jobs\nWHERE status IN ('pending', 'queued')\nORDER BY priority DESC, \"createdAt\" ASC;\n

Common Issues:

  1. No worker running:
  2. Check if job worker process is running
  3. Verify ENABLE_MEDIA_FEATURES=true

  4. Resource exhaustion:

  5. GPU jobs waiting for VRAM
  6. Check vramRequired vs available VRAM

  7. Pipeline blocking:

  8. Pipeline step depends on previous step completion

Solution:

  • Start job worker: npm run worker:media or check Docker Compose
  • Adjust resource limits or priority
  • Check pipeline configuration for blocking issues
"},{"location":"v2/backend/modules/media/#related-documentation","title":"Related Documentation","text":"
  • Dual API Architecture - Express + Fastify architecture
  • Drizzle ORM - Drizzle query builder (media tables)
  • Frontend: LibraryPage - Video library management UI
  • Frontend: MediaGalleryPage - Public gallery
  • Frontend: MediaViewerPage - Video player with reactions
  • Features: Media Manager - Complete feature guide
  • API Reference: Media - Complete endpoint reference
  • User Guide: Media Admin - Managing video library
  • Troubleshooting: Media API Issues - Debugging guide
"},{"location":"v2/backend/modules/pages/","title":"Pages Module (Landing Page Builder)","text":""},{"location":"v2/backend/modules/pages/#overview","title":"Overview","text":"

The Pages module provides a complete landing page builder with dual editing modes (WYSIWYG GrapesJS + direct HTML), automatic MkDocs export, and reusable block library. It enables admins to create custom landing pages visually or with code, publish them to public URLs (/p/:slug), and optionally export them to the MkDocs documentation site as Material theme overrides.

Key Features:

  • Dual editor modes:
  • VISUAL \u2014 GrapesJS drag-and-drop WYSIWYG editor with custom blocks
  • CODE \u2014 Direct HTML editing for advanced users
  • Automatic slug generation from titles (collision-safe)
  • MkDocs export system:
  • Exports pages to mkdocs/overrides/ directory
  • Creates .md stub files with front matter for MkDocs Material
  • Two export modes: THEMED (Jinja2 extends main.html) or STANDALONE (full HTML document)
  • Configurable nav/TOC hiding via Material theme front matter
  • Reusable block library (hero, text, image, CTA, features, testimonials, form)
  • SEO metadata (title, description, image)
  • Public rendering at /p/:slug route
  • Sync & validation tools for managing MkDocs exports
  • Path traversal protection (null bytes, .., encoded sequences)
  • Published/draft workflow
"},{"location":"v2/backend/modules/pages/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/pages/pages-admin.routes.ts Admin router with 7 endpoints (114 lines) api/src/modules/pages/pages-public.routes.ts Public router (1 endpoint, 21 lines) api/src/modules/pages/blocks.routes.ts Block library router (5 endpoints, 88 lines) api/src/modules/pages/pages.service.ts Landing page business logic + MkDocs export (637 lines) api/src/modules/pages/blocks.service.ts Block CRUD service (89 lines) api/src/modules/pages/pages.schemas.ts Zod validation schemas (83 lines)"},{"location":"v2/backend/modules/pages/#database-models","title":"Database Models","text":"
model LandingPage {\n  id                String            @id @default(cuid())\n  slug              String            @unique\n  title             String\n  description       String?           @db.Text\n  blocks            Json              // JSON from GrapesJS editor\n  htmlOutput        String?           @db.Text\n  cssOutput         String?           @db.Text\n  editorMode        EditorMode        @default(VISUAL)\n  mkdocsPath        String?           // Path in mkdocs/overrides/\n  mkdocsStubPath    String?           // Path to .md stub in mkdocs/docs/\n  mkdocsExportMode  MkdocsExportMode  @default(THEMED)\n  mkdocsHideNav     Boolean           @default(true)\n  mkdocsHideToc     Boolean           @default(true)\n  mkdocsSkipExport  Boolean           @default(false)\n  published         Boolean           @default(false)\n  seoTitle          String?\n  seoDescription    String?           @db.Text\n  seoImage          String?\n  createdAt         DateTime          @default(now())\n  updatedAt         DateTime          @updatedAt\n\n  @@map(\"landing_pages\")\n}\n\nenum EditorMode {\n  VISUAL      // GrapesJS drag-and-drop editor\n  CODE        // Direct HTML editing\n}\n\nenum MkdocsExportMode {\n  THEMED      // Jinja2 extends main.html (Material theme integration)\n  STANDALONE  // Full HTML document (no Jinja2 inheritance)\n}\n\nmodel PageBlock {\n  id           String   @id @default(cuid())\n  type         String   // hero, text, image, cta, features, testimonials, form\n  label        String\n  schema       Json     // Block configuration schema (GrapesJS component definition)\n  defaults     Json     // Default values for new instances\n  thumbnail    String?\n  category     String?\n  sortOrder    Int      @default(0)\n  createdAt    DateTime @default(now())\n  updatedAt    DateTime @updatedAt\n\n  @@map(\"page_blocks\")\n}\n

Key Fields:

  • blocks \u2014 GrapesJS JSON state (saved on Ctrl+S in editor)
  • htmlOutput \u2014 Rendered HTML (generated by GrapesJS or manually entered in CODE mode)
  • cssOutput \u2014 Extracted CSS (from GrapesJS styles or manual entry)
  • mkdocsPath \u2014 Relative path in mkdocs/overrides/ (e.g., landing-page.html)
  • mkdocsStubPath \u2014 Relative path to .md stub (e.g., landing-page.md)
  • mkdocsExportMode \u2014 THEMED (Jinja2) or STANDALONE (full HTML)
  • mkdocsSkipExport \u2014 Skip MkDocs export (for internal pages only accessible via /p/:slug)

Slug Generation:

function generateSlug(title: string): string {\n  return title\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, '-')  // Replace non-alphanumeric with -\n    .replace(/^-+|-+$/g, '')      // Remove leading/trailing -\n    .slice(0, 80);                // Max 80 chars\n}\n

Example Transformations:

  • \"Landing Page\" \u2192 landing-page
  • \"About Us \u2014 Contact Info\" \u2192 about-us-contact-info
  • \"Landing Page\" (duplicate) \u2192 landing-page-2
"},{"location":"v2/backend/modules/pages/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/pages/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"Method Path Auth Description GET /api/pages Admin roles List landing pages with pagination/filters GET /api/pages/:id Admin roles Get single landing page POST /api/pages Admin roles Create landing page PUT /api/pages/:id Admin roles Update landing page (triggers MkDocs export) DELETE /api/pages/:id Admin roles Delete landing page (removes MkDocs export) POST /api/pages/sync Admin roles Sync MkDocs overrides to database POST /api/pages/validate Admin roles Validate and repair MkDocs exports

Admin Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

"},{"location":"v2/backend/modules/pages/#block-library-endpoints-admin-only","title":"Block Library Endpoints (Admin Only)","text":"Method Path Auth Description GET /api/page-blocks Admin roles List blocks with category filter GET /api/page-blocks/:id Admin roles Get single block POST /api/page-blocks Admin roles Create block PUT /api/page-blocks/:id Admin roles Update block DELETE /api/page-blocks/:id Admin roles Delete block"},{"location":"v2/backend/modules/pages/#public-endpoints-no-authentication","title":"Public Endpoints (No Authentication)","text":"Method Path Auth Description GET /api/pages/:slug/view None Get published page by slug"},{"location":"v2/backend/modules/pages/#admin-endpoint-details","title":"Admin Endpoint Details","text":""},{"location":"v2/backend/modules/pages/#get-apipages","title":"GET /api/pages","text":"

List landing pages with pagination, search, and filtering.

Query Parameters:

Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) search string No - Search title, description, or slug published enum No - Filter by status: 'true', 'false'

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/pages?page=1&limit=10&published=true&search=about\"\n

Response (200 OK):

{\n  \"pages\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"slug\": \"about-us\",\n      \"title\": \"About Us\",\n      \"description\": \"Learn about our organization\",\n      \"editorMode\": \"VISUAL\",\n      \"blocks\": {\n        \"assets\": [],\n        \"pages\": [/* GrapesJS page structure */],\n        \"styles\": [/* GrapesJS styles */]\n      },\n      \"htmlOutput\": \"<div class=\\\"hero\\\">...</div>\",\n      \"cssOutput\": \".hero { background: #3498db; }\",\n      \"mkdocsPath\": \"about-us.html\",\n      \"mkdocsStubPath\": \"about-us.md\",\n      \"mkdocsExportMode\": \"THEMED\",\n      \"mkdocsHideNav\": true,\n      \"mkdocsHideToc\": true,\n      \"mkdocsSkipExport\": false,\n      \"published\": true,\n      \"seoTitle\": \"About Us \u2014 Changemaker Lite\",\n      \"seoDescription\": \"Learn about our mission and values\",\n      \"seoImage\": \"https://example.com/og-image.jpg\",\n      \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n      \"updatedAt\": \"2026-02-11T14:30:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 10,\n    \"total\": 5,\n    \"totalPages\": 1\n  }\n}\n

Search Behavior:

if (search) {\n  where.OR = [\n    { title: { contains: search, mode: 'insensitive' } },\n    { description: { contains: search, mode: 'insensitive' } },\n    { slug: { contains: search, mode: 'insensitive' } },\n  ];\n}\n
"},{"location":"v2/backend/modules/pages/#get-apipagesid","title":"GET /api/pages/:id","text":"

Get single landing page with full editor state.

Path Parameters:

  • id (string): Landing page ID

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/pages/clx1234567890\"\n

Response (200 OK):

Returns full landing page object (same format as GET list).

Error Responses:

  • 404 Not Found: Page not found
"},{"location":"v2/backend/modules/pages/#post-apipages","title":"POST /api/pages","text":"

Create landing page with auto-generated slug.

Request Body:

{\n  \"title\": \"About Us\",\n  \"description\": \"Learn about our organization\",\n  \"editorMode\": \"VISUAL\",\n  \"blocks\": {},\n  \"htmlOutput\": null,\n  \"cssOutput\": null,\n  \"mkdocsExportMode\": \"THEMED\",\n  \"mkdocsHideNav\": true,\n  \"mkdocsHideToc\": true,\n  \"published\": false,\n  \"seoTitle\": \"About Us \u2014 Changemaker Lite\",\n  \"seoDescription\": \"Learn about our mission and values\",\n  \"seoImage\": \"https://example.com/og-image.jpg\"\n}\n

Response (201 Created):

Returns created landing page object.

Auto-Generated Fields:

  • slug \u2014 Generated from title (collision-safe)
  • mkdocsPath \u2014 Defaults to ${slug}.html if not provided

Validation:

  • title is required
  • mkdocsPath must end with .html
  • mkdocsPath must not contain path traversal sequences (.., null bytes, encoded traversal)
"},{"location":"v2/backend/modules/pages/#put-apipagesid","title":"PUT /api/pages/:id","text":"

Update landing page. Triggers MkDocs export if published.

Request Body (Partial):

{\n  \"htmlOutput\": \"<div class=\\\"hero\\\">Updated content</div>\",\n  \"cssOutput\": \".hero { background: #e74c3c; }\",\n  \"published\": true\n}\n

Response (200 OK):

Returns updated landing page object.

Side Effects:

  1. Slug regeneration if title changes (preserves old slug if collision):

    if (data.title && data.title !== existing.title) {\n  const baseSlug = generateSlug(data.title);\n  const newSlug = await resolveSlugCollision(baseSlug, id);\n  updateData.slug = newSlug;\n\n  // Update mkdocsPath if auto-generated\n  if (existing.mkdocsPath === `${existing.slug}.html`) {\n    updateData.mkdocsPath = `${newSlug}.html`;\n    await removeFromMkDocs(existing.mkdocsPath, existing.mkdocsStubPath);\n  }\n}\n

  2. MkDocs export if published === true && mkdocsSkipExport === false:

    if (page.published && !page.mkdocsSkipExport && page.mkdocsPath && page.htmlOutput) {\n  const stubPath = await exportToMkDocs({\n    mkdocsPath: page.mkdocsPath,\n    html: page.htmlOutput,\n    css: page.cssOutput,\n    editorMode: page.editorMode,\n    exportMode: page.mkdocsExportMode,\n    title: page.title,\n    seoTitle: page.seoTitle,\n    seoDescription: page.seoDescription,\n    hideNav: page.mkdocsHideNav,\n    hideToc: page.mkdocsHideToc,\n  });\n}\n

  3. MkDocs cleanup if published === false || mkdocsSkipExport === true:

    await removeFromMkDocs(existing.mkdocsPath, existing.mkdocsStubPath);\n

Export Workflow:

graph TD\n    A[Update Landing Page] --> B{Published?}\n    B -->|No| C[Remove MkDocs Export]\n    B -->|Yes| D{Skip Export?}\n    D -->|Yes| C\n    D -->|No| E{Has HTML Output?}\n    E -->|No| F[No Action]\n    E -->|Yes| G[Export to MkDocs]\n    G --> H[Write Override HTML]\n    H --> I[Write .md Stub]\n    I --> J[Update stubPath in DB]
"},{"location":"v2/backend/modules/pages/#delete-apipagesid","title":"DELETE /api/pages/:id","text":"

Delete landing page and remove MkDocs export.

Path Parameters:

  • id (string): Landing page ID

Response (204 No Content):

No response body.

Side Effects:

  • Removes MkDocs override HTML file (mkdocs/overrides/{mkdocsPath})
  • Removes .md stub file (mkdocs/docs/{mkdocsStubPath})
"},{"location":"v2/backend/modules/pages/#post-apipagessync","title":"POST /api/pages/sync","text":"

Sync MkDocs override files to database (import untracked files, update CODE pages).

Example Request:

curl -X POST \\\n  -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/pages/sync\"\n

Response (200 OK):

{\n  \"imported\": 2,\n  \"updated\": 1,\n  \"stubs\": 3\n}\n

Behavior:

  1. Scan mkdocs/overrides/ directory for .html files:

    const files = await scanOverrideFiles(MKDOCS_OVERRIDES);\n// Returns: [{ relativePath: 'foo.html', fullPath: '/full/path/foo.html' }, ...]\n

  2. Import untracked files as CODE pages:

    if (!tracked) {\n  // New file not in database\n  const title = path.basename(file.relativePath, '.html');\n  const baseSlug = generateSlug(title);\n  const slug = await resolveSlugCollision(baseSlug);\n\n  await prisma.landingPage.create({\n    data: {\n      slug,\n      title,\n      editorMode: 'CODE',\n      htmlOutput: content,\n      mkdocsPath: file.relativePath,\n      published: true,\n      blocks: {},\n    },\n  });\n\n  imported++;\n}\n

  3. Update CODE pages from disk (disk wins):

    else if (tracked.editorMode === 'CODE') {\n  // Tracked CODE page \u2014 sync from disk\n  await prisma.landingPage.update({\n    where: { id: tracked.id },\n    data: { htmlOutput: content },\n  });\n\n  updated++;\n}\n// VISUAL pages: don't overwrite from disk (managed by GrapesJS)\n

  4. Backfill missing .md stubs for published pages:

    for (const page of existingPages) {\n  if (!page.published || !page.mkdocsPath) continue;\n\n  const expectedStubPath = page.mkdocsStubPath || deriveStubPath(page.mkdocsPath);\n  const exists = await stubExistsOnDisk(expectedStubPath);\n  if (!exists) {\n    await writeStubFile(expectedStubPath, stubContent);\n    stubs++;\n  }\n}\n

Use Cases:

  • Manual file creation \u2014 Admin creates .html file directly in mkdocs/overrides/, then syncs to database
  • Git pull \u2014 After pulling changes that add override files, sync to database
  • Stub recovery \u2014 Re-create missing .md stub files
"},{"location":"v2/backend/modules/pages/#post-apipagesvalidate","title":"POST /api/pages/validate","text":"

Validate MkDocs exports and repair missing files.

Example Request:

curl -X POST \\\n  -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/pages/validate\"\n

Response (200 OK):

{\n  \"validated\": 10,\n  \"repaired\": 2,\n  \"errors\": [\n    {\n      \"pageId\": \"clx1234567890\",\n      \"slug\": \"broken-page\",\n      \"error\": \"ENOENT: no such file or directory\"\n    }\n  ]\n}\n

Behavior:

  1. Query all published pages with mkdocsSkipExport === false:

    const pages = await prisma.landingPage.findMany({\n  where: {\n    published: true,\n    mkdocsSkipExport: false,\n    mkdocsPath: { not: null },\n    htmlOutput: { not: null },\n  },\n});\n

  2. Check override HTML exists:

    const overridePath = path.join(MKDOCS_OVERRIDES, page.mkdocsPath);\nawait fs.access(overridePath);  // Throws if missing\n

  3. Check .md stub exists:

    const expectedStubPath = page.mkdocsStubPath || deriveStubPath(page.mkdocsPath);\nconst stubExists = await stubExistsOnDisk(expectedStubPath);\n

  4. Repair if either missing:

    if (!overrideExists || !stubExists) {\n  await exportToMkDocs({\n    mkdocsPath: page.mkdocsPath,\n    html: page.htmlOutput,\n    css: page.cssOutput,\n    // ...\n  });\n\n  repaired++;\n}\n

Use Cases:

  • Missing exports after deploy \u2014 MkDocs volume lost, re-export all pages
  • Manual deletion \u2014 Admin accidentally deleted override file, repair from database
  • Health check \u2014 Verify all published pages have correct exports
"},{"location":"v2/backend/modules/pages/#block-library-endpoint-details","title":"Block Library Endpoint Details","text":""},{"location":"v2/backend/modules/pages/#get-apipage-blocks","title":"GET /api/page-blocks","text":"

List blocks with optional category filter.

Query Parameters:

Parameter Type Required Description category string No Filter by category

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/page-blocks?category=hero\"\n

Response (200 OK):

[\n  {\n    \"id\": \"clx1234567890\",\n    \"type\": \"hero\",\n    \"label\": \"Hero Section\",\n    \"schema\": {\n      \"type\": \"div\",\n      \"classes\": [\"hero\"],\n      \"attributes\": { \"data-gjs-type\": \"hero\" },\n      \"components\": [/* ... */],\n      \"traits\": [\n        { \"type\": \"text\", \"name\": \"heading\", \"label\": \"Heading\" },\n        { \"type\": \"text\", \"name\": \"subheading\", \"label\": \"Subheading\" }\n      ]\n    },\n    \"defaults\": {\n      \"heading\": \"Welcome to our site\",\n      \"subheading\": \"Your journey starts here\"\n    },\n    \"thumbnail\": \"https://example.com/hero-thumb.jpg\",\n    \"category\": \"hero\",\n    \"sortOrder\": 1,\n    \"createdAt\": \"2026-01-15T12:00:00.000Z\",\n    \"updatedAt\": \"2026-01-20T10:00:00.000Z\"\n  }\n]\n

Sort Order:

Blocks are sorted by sortOrder ASC. Lower numbers appear first in block library panel.

"},{"location":"v2/backend/modules/pages/#post-apipage-blocks","title":"POST /api/page-blocks","text":"

Create block.

Request Body:

{\n  \"type\": \"hero\",\n  \"label\": \"Hero Section\",\n  \"schema\": {\n    \"type\": \"div\",\n    \"classes\": [\"hero\"],\n    \"attributes\": { \"data-gjs-type\": \"hero\" }\n  },\n  \"defaults\": {\n    \"heading\": \"Welcome\",\n    \"subheading\": \"Your journey starts here\"\n  },\n  \"thumbnail\": \"https://example.com/hero-thumb.jpg\",\n  \"category\": \"hero\",\n  \"sortOrder\": 1\n}\n

Response (201 Created):

Returns created block object.

"},{"location":"v2/backend/modules/pages/#public-endpoint-details","title":"Public Endpoint Details","text":""},{"location":"v2/backend/modules/pages/#get-apipagesslugview","title":"GET /api/pages/:slug/view","text":"

Get published landing page by slug (no auth required).

Path Parameters:

  • slug (string): Landing page slug

Example Request:

curl http://api.cmlite.org/api/pages/about-us/view\n

Response (200 OK):

Returns full landing page object (same format as admin GET).

Filtering:

  • Only returns pages with published === true
  • Throws 404 if page not found or not published

Error Responses:

  • 404 Not Found: Page not found or not published
"},{"location":"v2/backend/modules/pages/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/pages/#pagesservicefindallfilters","title":"pagesService.findAll(filters)","text":"

List landing pages with pagination, search, and filtering.

Usage:

import { pagesService } from './pages.service';\n\nconst result = await pagesService.findAll({\n  page: 1,\n  limit: 20,\n  search: 'about',\n  published: 'true',\n});\n\nconsole.log(result.pages.length);    // Array of pages\nconsole.log(result.pagination);      // { page, limit, total, totalPages }\n
"},{"location":"v2/backend/modules/pages/#pagesservicecreatedata","title":"pagesService.create(data)","text":"

Create landing page with auto-generated slug and mkdocsPath.

Usage:

const page = await pagesService.create({\n  title: 'About Us',\n  description: 'Learn about our organization',\n  editorMode: 'VISUAL',\n  blocks: {},\n  published: false,\n});\n\nconsole.log(page.slug);          // 'about-us'\nconsole.log(page.mkdocsPath);    // 'about-us.html'\n
"},{"location":"v2/backend/modules/pages/#pagesserviceupdateid-data","title":"pagesService.update(id, data)","text":"

Update landing page with MkDocs export/cleanup side effects.

Usage:

const page = await pagesService.update('clx1234567890', {\n  htmlOutput: '<div class=\"hero\">Updated</div>',\n  cssOutput: '.hero { background: #e74c3c; }',\n  published: true,\n});\n\n// Side effect: Exports to mkdocs/overrides/{mkdocsPath}\n// Side effect: Creates .md stub in mkdocs/docs/{mkdocsStubPath}\n

Export Trigger:

  • Export happens if published === true && mkdocsSkipExport === false && mkdocsPath && htmlOutput
  • Cleanup happens if published === false || mkdocsSkipExport === true
"},{"location":"v2/backend/modules/pages/#pagesservicesyncoverrides","title":"pagesService.syncOverrides()","text":"

Sync MkDocs override files to database.

Usage:

const result = await pagesService.syncOverrides();\n\nconsole.log(`Imported: ${result.imported}`);  // New CODE pages imported\nconsole.log(`Updated: ${result.updated}`);    // CODE pages synced from disk\nconsole.log(`Stubs: ${result.stubs}`);        // Missing stubs created\n

Workflow:

  1. Scan mkdocs/overrides/ for .html files
  2. Import untracked files as CODE pages
  3. Update tracked CODE pages from disk (disk wins)
  4. Don't overwrite VISUAL pages (managed by GrapesJS)
  5. Backfill missing .md stubs
"},{"location":"v2/backend/modules/pages/#pagesservicevalidateexports","title":"pagesService.validateExports()","text":"

Validate and repair MkDocs exports.

Usage:

const result = await pagesService.validateExports();\n\nconsole.log(`Validated: ${result.validated}`);  // Pages checked\nconsole.log(`Repaired: ${result.repaired}`);    // Missing exports repaired\nconsole.log(`Errors: ${result.errors.length}`);  // Failed repairs\n

Repair Logic:

// Check override HTML exists\nconst overridePath = path.join(MKDOCS_OVERRIDES, page.mkdocsPath);\nconst overrideExists = await fs.access(overridePath).then(() => true, () => false);\n\n// Check stub exists\nconst stubExists = await stubExistsOnDisk(expectedStubPath);\n\n// Repair if either missing\nif (!overrideExists || !stubExists) {\n  await exportToMkDocs({/* ... */});\n  repaired++;\n}\n
"},{"location":"v2/backend/modules/pages/#mkdocs-export-system","title":"MkDocs Export System","text":""},{"location":"v2/backend/modules/pages/#export-modes","title":"Export Modes","text":"

1. THEMED (Default)

Wraps HTML in Jinja2 template extending MkDocs Material theme:

{% extends \"main.html\" %}\n{% block content %}\n<style>\n{{ css }}\n</style>\n{{ html }}\n{% endblock %}\n

Pros:

  • Inherits Material theme navigation, footer, search
  • Consistent branding with main docs
  • Responsive out of the box

Cons:

  • Limited control over layout
  • Must work within Material theme constraints

2. STANDALONE

Full HTML document without Jinja2 inheritance:

<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>{{ seoTitle || title }}</title>\n    <meta name=\"description\" content=\"{{ seoDescription }}\">\n    <style>\n{{ css }}\n    </style>\n</head>\n<body>\n{{ html }}\n</body>\n</html>\n

Pros:

  • Full control over layout
  • No Material theme constraints
  • Custom navigation/footer

Cons:

  • No Material theme features (search, nav, etc.)
  • Must implement responsive design
  • Separate branding
"},{"location":"v2/backend/modules/pages/#md-stub-file-format","title":".md Stub File Format","text":"

The .md stub file is required for MkDocs to recognize the override template. It uses Material theme front matter to configure page appearance.

Example:

---\ntemplate: about-us.html\nhide:\n  - navigation\n  - toc\ntitle: \"About Us \u2014 Changemaker Lite\"\ndescription: \"Learn about our mission and values\"\n---\n

Front Matter Fields:

  • template \u2014 Override filename (relative to custom_dir/overrides)
  • hide \u2014 Hide Material theme elements (navigation, toc)
  • title \u2014 Page title (SEO)
  • description \u2014 Page description (SEO)

Generation:

function generateMdStub(opts: StubOptions): string {\n  const hideItems: string[] = [];\n  if (opts.hideNav) hideItems.push('  - navigation');\n  if (opts.hideToc) hideItems.push('  - toc');\n\n  const hideBlock = hideItems.length > 0 ? `hide:\\n${hideItems.join('\\n')}\\n` : '';\n  const descLine = opts.description ? `description: \"${opts.description.replace(/\"/g, '\\\\\"')}\"\\n` : '';\n\n  return `---\ntemplate: ${opts.overrideFilename}\n${hideBlock}title: \"${opts.title.replace(/\"/g, '\\\\\"')}\"\n${descLine}---\n`;\n}\n
"},{"location":"v2/backend/modules/pages/#path-validation","title":"Path Validation","text":"

All mkdocsPath values are validated to prevent path traversal attacks:

function validateMkdocsPath(mkdocsPath: string): void {\n  // Check for null bytes\n  if (mkdocsPath.includes('\\0')) {\n    throw new AppError(400, 'Invalid path: null byte detected', 'INVALID_MKDOCS_PATH');\n  }\n\n  // Normalize and check for traversal\n  const normalized = path.normalize(mkdocsPath);\n  if (normalized.includes('..') || path.isAbsolute(normalized)) {\n    throw new AppError(400, 'Path traversal not allowed', 'INVALID_MKDOCS_PATH');\n  }\n\n  // Check for encoded traversal sequences\n  if (mkdocsPath.includes('%2e') || mkdocsPath.includes('%2E')) {\n    throw new AppError(400, 'Encoded path traversal not allowed', 'INVALID_MKDOCS_PATH');\n  }\n\n  if (!mkdocsPath.endsWith('.html')) {\n    throw new AppError(400, 'Path must end with .html', 'INVALID_MKDOCS_PATH');\n  }\n}\n

Blocked Patterns:

  • Null bytes (\\0)
  • Path traversal (..)
  • Absolute paths (/etc/passwd)
  • Encoded traversal (%2e%2e/, %2E%2E/)
  • Non-HTML files (must end with .html)
"},{"location":"v2/backend/modules/pages/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/pages/#create-landing-page-schema","title":"Create Landing Page Schema","text":"
export const createLandingPageSchema = z.object({\n  title: z.string().min(1, 'Title is required'),\n  description: z.string().optional(),\n  editorMode: z.enum(['VISUAL', 'CODE']).optional().default('VISUAL'),\n  blocks: z.any().optional().default({}),\n  htmlOutput: z.string().optional(),\n  cssOutput: z.string().optional(),\n  mkdocsPath: z.string().optional(),\n  mkdocsExportMode: z.enum(['THEMED', 'STANDALONE']).optional().default('THEMED'),\n  mkdocsHideNav: z.boolean().optional().default(true),\n  mkdocsHideToc: z.boolean().optional().default(true),\n  mkdocsSkipExport: z.boolean().optional().default(false),\n  published: z.boolean().optional().default(false),\n  seoTitle: z.string().optional(),\n  seoDescription: z.string().optional(),\n  seoImage: z.string().optional(),\n});\n

Defaults:

  • editorMode: VISUAL
  • blocks: {}
  • mkdocsExportMode: THEMED
  • mkdocsHideNav: true
  • mkdocsHideToc: true
  • mkdocsSkipExport: false
  • published: false
"},{"location":"v2/backend/modules/pages/#create-page-block-schema","title":"Create Page Block Schema","text":"
export const createPageBlockSchema = z.object({\n  type: z.string().min(1, 'Type is required'),\n  label: z.string().min(1, 'Label is required'),\n  schema: z.any().optional().default({}),\n  defaults: z.any().optional().default({}),\n  thumbnail: z.string().optional(),\n  category: z.string().optional(),\n  sortOrder: z.number().int().optional().default(0),\n});\n

Example Valid Input:

{\n  \"type\": \"hero\",\n  \"label\": \"Hero Section\",\n  \"schema\": {\n    \"type\": \"div\",\n    \"classes\": [\"hero\"]\n  },\n  \"defaults\": {\n    \"heading\": \"Welcome\"\n  },\n  \"category\": \"hero\",\n  \"sortOrder\": 1\n}\n
"},{"location":"v2/backend/modules/pages/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/pages/#admin-create-landing-page","title":"Admin: Create Landing Page","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst createPage = async () => {\n  try {\n    const { data } = await api.post('/api/pages', {\n      title: 'About Us',\n      description: 'Learn about our organization',\n      editorMode: 'VISUAL',\n      mkdocsExportMode: 'THEMED',\n      mkdocsHideNav: true,\n      mkdocsHideToc: true,\n      published: false,\n      seoTitle: 'About Us \u2014 Changemaker Lite',\n      seoDescription: 'Learn about our mission and values',\n    });\n\n    message.success(`Page created: ${data.slug}`);\n    return data;\n  } catch (error) {\n    message.error('Failed to create page');\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/pages/#admin-publish-page-triggers-mkdocs-export","title":"Admin: Publish Page (Triggers MkDocs Export)","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst publishPage = async (pageId: string, htmlOutput: string, cssOutput: string) => {\n  try {\n    const { data } = await api.put(`/api/pages/${pageId}`, {\n      htmlOutput,\n      cssOutput,\n      published: true,\n    });\n\n    message.success(`Page published and exported to MkDocs!`);\n    return data;\n  } catch (error) {\n    message.error('Failed to publish page');\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/pages/#admin-sync-mkdocs-overrides","title":"Admin: Sync MkDocs Overrides","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst syncOverrides = async () => {\n  try {\n    const { data } = await api.post('/api/pages/sync');\n\n    message.success(\n      `Sync complete: ${data.imported} imported, ${data.updated} updated, ${data.stubs} stubs created`\n    );\n    return data;\n  } catch (error) {\n    message.error('Failed to sync overrides');\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/pages/#admin-validate-and-repair-exports","title":"Admin: Validate and Repair Exports","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst validateExports = async () => {\n  try {\n    const { data } = await api.post('/api/pages/validate');\n\n    if (data.errors.length > 0) {\n      message.warning(`Validation complete: ${data.repaired} repaired, ${data.errors.length} errors`);\n    } else {\n      message.success(`Validation complete: ${data.validated} validated, ${data.repaired} repaired`);\n    }\n\n    return data;\n  } catch (error) {\n    message.error('Failed to validate exports');\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/pages/#public-render-landing-page","title":"Public: Render Landing Page","text":"
import axios from 'axios';\nimport { useParams } from 'react-router-dom';\nimport { useEffect, useState } from 'react';\n\ninterface LandingPage {\n  id: string;\n  slug: string;\n  title: string;\n  htmlOutput: string;\n  cssOutput: string | null;\n  seoTitle: string | null;\n  seoDescription: string | null;\n}\n\nconst LandingPageRenderer = () => {\n  const { slug } = useParams<{ slug: string }>();\n  const [page, setPage] = useState<LandingPage | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  useEffect(() => {\n    const fetchPage = async () => {\n      try {\n        const { data } = await axios.get(`/api/pages/${slug}/view`);\n        setPage(data);\n      } catch (error) {\n        console.error('Page not found:', error);\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    fetchPage();\n  }, [slug]);\n\n  if (loading) return <div>Loading...</div>;\n  if (!page) return <div>Page not found</div>;\n\n  return (\n    <>\n      {/* Inject CSS */}\n      {page.cssOutput && <style dangerouslySetInnerHTML={{ __html: page.cssOutput }} />}\n\n      {/* Render HTML */}\n      <div dangerouslySetInnerHTML={{ __html: page.htmlOutput }} />\n    </>\n  );\n};\n
"},{"location":"v2/backend/modules/pages/#frontend-integration","title":"Frontend Integration","text":"

The LandingPagesPage component (admin/src/pages/LandingPagesPage.tsx) provides:

  • Paginated pages table with search and published filter
  • Create page button (opens modal with title input)
  • Edit button (navigates to full-screen GrapesJS editor)
  • Publish/unpublish toggle (triggers MkDocs export)
  • Delete confirmation modal
  • Sync button (syncs MkDocs overrides to database)
  • Validate button (repairs missing exports)
  • Settings modal (configure MkDocs export options)

State Management:

const [pages, setPages] = useState<LandingPage[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });\nconst [filters, setFilters] = useState({ search: '', published: null });\nconst [settingsModalOpen, setSettingsModalOpen] = useState(false);\n

Page Editor:

The PageEditorPage component (admin/src/pages/PageEditorPage.tsx) provides:

  • Full-screen GrapesJS editor (no AppLayout)
  • Custom block library (hero, text, image, CTA, features, testimonials, form)
  • Ctrl+S save (forwardRef to GrapesJS instance)
  • Mobile warning (GrapesJS is desktop-only)
  • Visual/Code mode toggle
  • Auto-save on blur (optional)

Public Renderer:

The LandingPage component (admin/src/pages/public/LandingPage.tsx) provides:

  • Public route at /p/:slug
  • Renders htmlOutput with cssOutput
  • SEO metadata from seoTitle, seoDescription, seoImage
  • 404 handling for unpublished or missing pages
"},{"location":"v2/backend/modules/pages/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/backend/modules/pages/#mkdocs-export-caching","title":"MkDocs Export Caching","text":"

MkDocs exports are triggered on update, not on every GET request. This avoids I/O overhead.

Export Trigger:

if (page.published && !page.mkdocsSkipExport && page.mkdocsPath && page.htmlOutput) {\n  await exportToMkDocs({/* ... */});\n}\n

No Export:

  • Draft pages (published === false)
  • Skipped pages (mkdocsSkipExport === true)
  • Pages without HTML output
"},{"location":"v2/backend/modules/pages/#slug-collision-handling","title":"Slug Collision Handling","text":"

The slug collision resolver loops until unique slug found. To avoid infinite loops, it uses suffix counter:

async function resolveSlugCollision(slug: string, excludeId?: string): Promise<string> {\n  let candidate = slug;\n  let suffix = 2;\n\n  while (true) {\n    const existing = await prisma.landingPage.findUnique({ where: { slug: candidate } });\n    if (!existing || (excludeId && existing.id === excludeId)) {\n      return candidate;\n    }\n    candidate = `${slug}-${suffix}`;  // about-us-2, about-us-3, ...\n    suffix++;\n  }\n}\n

Worst-case:

  • O(n) queries where n = number of pages with same base slug
  • In practice, n is very small (< 10)
"},{"location":"v2/backend/modules/pages/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/pages/#mkdocs-override-not-appearing","title":"MkDocs Override Not Appearing","text":"

Problem:

Page is published but doesn't appear on MkDocs site.

Diagnosis:

  1. Check override file exists:

    ls mkdocs/overrides/about-us.html\n

  2. Check stub file exists:

    ls mkdocs/docs/about-us.md\n

  3. Check stub front matter:

    cat mkdocs/docs/about-us.md\n

Verify template: points to override filename (not path):

template: about-us.html  # Correct\ntemplate: overrides/about-us.html  # WRONG \u2014 causes TemplateNotFound\n

  1. Check MkDocs logs:
    docker compose logs -f mkdocs\n

Solutions:

  • Missing files: Run validate endpoint to repair:

    curl -X POST -H \"Authorization: Bearer <token>\" \\\n  http://api.cmlite.org/api/pages/validate\n

  • Wrong template path: Front matter template: value is relative to template search paths. Use filename only.

  • MkDocs rebuild: Restart MkDocs container:

    docker compose restart mkdocs\n

"},{"location":"v2/backend/modules/pages/#path-traversal-validation-error","title":"Path Traversal Validation Error","text":"

Problem:

Creating page fails with \"Path traversal not allowed\" error.

Diagnosis:

Check mkdocsPath value for blocked patterns:

// Blocked:\nmkdocsPath: '../etc/passwd.html'       // Path traversal\nmkdocsPath: '/etc/passwd.html'         // Absolute path\nmkdocsPath: '%2e%2e/etc/passwd.html'   // Encoded traversal\nmkdocsPath: 'foo\\0bar.html'            // Null byte\n\n// Allowed:\nmkdocsPath: 'about-us.html'            // Simple filename\nmkdocsPath: 'subfolder/about-us.html'  // Subdirectory (no traversal)\n

Solution:

Use safe filenames without path traversal sequences. Subfolders are allowed but must not contain ...

"},{"location":"v2/backend/modules/pages/#code-page-overwritten-by-disk","title":"CODE Page Overwritten by Disk","text":"

Problem:

Manual edits to CODE page in database are lost after sync.

Diagnosis:

Check editorMode:

SELECT id, slug, \"editorMode\" FROM landing_pages WHERE slug = 'my-page';\n

Behavior:

  • CODE pages: Disk wins. Sync overwrites database htmlOutput from disk.
  • VISUAL pages: Database wins. Sync does not overwrite GrapesJS-managed pages.

Solution:

  • Option 1: Edit file on disk directly:

    vim mkdocs/overrides/my-page.html\n# Then sync\ncurl -X POST -H \"Authorization: Bearer <token>\" http://api.cmlite.org/api/pages/sync\n

  • Option 2: Change editorMode to VISUAL if you want database to be source of truth:

    UPDATE landing_pages SET \"editorMode\" = 'VISUAL' WHERE slug = 'my-page';\n

"},{"location":"v2/backend/modules/pages/#stub-template-not-found","title":"Stub Template Not Found","text":"

Problem:

MkDocs build fails with TemplateNotFound error.

Diagnosis:

Check stub front matter:

cat mkdocs/docs/about-us.md\n

Common Mistakes:

# WRONG \u2014 includes directory path\ntemplate: overrides/about-us.html\n\n# CORRECT \u2014 filename only\ntemplate: about-us.html\n

Why:

MkDocs Material template: searches in custom_dir (which includes /overrides). Using overrides/ in the template value causes it to look for overrides/overrides/about-us.html.

Solution:

Re-export page to fix stub:

curl -X POST -H \"Authorization: Bearer <token>\" \\\n  http://api.cmlite.org/api/pages/validate\n
"},{"location":"v2/backend/modules/pages/#related-documentation","title":"Related Documentation","text":"
  • Frontend: LandingPagesPage - Landing page manager UI
  • Frontend: PageEditorPage - GrapesJS editor wrapper
  • Frontend: Public Landing Page - Public renderer
  • Features: Landing Page Builder - Complete feature guide
  • MkDocs Integration - MkDocs export system
  • API Reference: Pages - Complete endpoint reference
  • User Guide: Content Editor - Creating landing pages
  • Troubleshooting: MkDocs Issues - MkDocs debugging guide
"},{"location":"v2/backend/modules/representatives/","title":"Representatives Module","text":""},{"location":"v2/backend/modules/representatives/#overview","title":"Overview","text":"

The Representatives module integrates with the Canadian Represent API to provide elected official lookup by postal code. It features intelligent caching, rate limiting, deduplication, and both public and admin endpoints for managing representative data.

Key Features:

  • Canadian representative lookup via Represent API (MPs, MPPs, councillors)
  • Intelligent cache-first strategy with fire-and-forget cache writes
  • Rate limiting (55 requests/minute, under Represent API's 60/min limit)
  • Representative deduplication (centroid + concordance results)
  • Public postal code lookup (no auth required)
  • Admin cache management (view, clear, stats)
  • Integration with postal codes module for location metadata
  • Health check endpoint for API connectivity testing
"},{"location":"v2/backend/modules/representatives/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/influence/representatives/representatives.routes.ts Router with 8 endpoints (2 public, 6 admin) api/src/modules/influence/representatives/representatives.service.ts Representative business logic + Represent API integration api/src/modules/influence/representatives/representatives.schemas.ts Zod validation schemas api/src/modules/influence/representatives/represent-api.client.ts Represent API HTTP client with rate limiting"},{"location":"v2/backend/modules/representatives/#database-model","title":"Database Model","text":"
model Representative {\n  id                    String    @id @default(cuid())\n  postalCode            String\n  name                  String?\n  email                 String?\n  districtName          String?\n  electedOffice         String?\n  partyName             String?\n  representativeSetName String?\n  url                   String?\n  photoUrl              String?\n  offices               Json?     // JSON array of office contact info\n  cachedAt              DateTime  @default(now())\n\n  @@index([postalCode])\n  @@map(\"representatives\")\n}\n

Field Descriptions:

  • postalCode \u2014 Canadian postal code (e.g., \"M5H 2N2\")
  • name \u2014 Representative's full name
  • email \u2014 Contact email address
  • districtName \u2014 Electoral district name (e.g., \"Toronto Centre\")
  • electedOffice \u2014 Position (e.g., \"MP\", \"MPP\", \"Councillor\")
  • partyName \u2014 Political party affiliation
  • representativeSetName \u2014 Data source identifier (e.g., \"House of Commons\")
  • url \u2014 Representative's official website
  • photoUrl \u2014 Profile photo URL
  • offices \u2014 JSON array of office locations with contact info
  • cachedAt \u2014 Timestamp when cached from Represent API
"},{"location":"v2/backend/modules/representatives/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/representatives/#public-endpoints-no-authentication","title":"Public Endpoints (No Authentication)","text":"Method Path Description GET /api/representatives/by-postal/:postalCode Lookup representatives by postal code (cache-first) GET /api/representatives/test-connection Test Represent API connectivity"},{"location":"v2/backend/modules/representatives/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"Method Path Auth Description GET /api/representatives/cache-stats Admin roles Get cache statistics GET /api/representatives Admin roles List all cached representatives (paginated) GET /api/representatives/:id Admin roles Get single cached representative DELETE /api/representatives/by-postal/:postalCode Admin roles Clear cache for postal code DELETE /api/representatives/:id Admin roles Delete single cached representative

Admin Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

"},{"location":"v2/backend/modules/representatives/#public-endpoint-details","title":"Public Endpoint Details","text":""},{"location":"v2/backend/modules/representatives/#get-apirepresentativesby-postalpostalcode","title":"GET /api/representatives/by-postal/:postalCode","text":"

Lookup representatives by Canadian postal code. Uses cache-first strategy: returns cached results if available, otherwise calls Represent API and caches results asynchronously.

Path Parameters:

  • postalCode (string): Canadian postal code (e.g., \"M5H2N2\" or \"M5H 2N2\")

Query Parameters:

Parameter Type Required Default Description refresh boolean No false Force API call even if cached data exists

Example Request:

# Cache-first lookup\ncurl \"http://api.cmlite.org/api/representatives/by-postal/M5H2N2\"\n\n# Force refresh from API\ncurl \"http://api.cmlite.org/api/representatives/by-postal/M5H2N2?refresh=true\"\n

Response (200 OK):

{\n  \"source\": \"cache\",\n  \"postalCode\": \"M5H2N2\",\n  \"location\": {\n    \"city\": \"Toronto\",\n    \"province\": \"ON\"\n  },\n  \"representatives\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"postalCode\": \"M5H2N2\",\n      \"name\": \"Chrystia Freeland\",\n      \"email\": \"chrystia.freeland@parl.gc.ca\",\n      \"districtName\": \"University\u2014Rosedale\",\n      \"electedOffice\": \"MP\",\n      \"partyName\": \"Liberal\",\n      \"representativeSetName\": \"House of Commons\",\n      \"url\": \"https://www.ourcommons.ca/members/en/chrystia-freeland(71619)\",\n      \"photoUrl\": \"https://www.ourcommons.ca/Content/Parliamentarians/Images/OfficialMPPhotos/44/FreelendeC_Lib.jpg\",\n      \"offices\": [\n        {\n          \"type\": \"constituency\",\n          \"tel\": \"416-656-2424\",\n          \"fax\": \"416-656-2425\",\n          \"postal\": \"703-2005 Sheppard Ave E, Toronto ON M2J 5B4\"\n        }\n      ],\n      \"cachedAt\": \"2026-02-11T12:00:00.000Z\"\n    },\n    {\n      \"id\": \"clx0987654321\",\n      \"postalCode\": \"M5H2N2\",\n      \"name\": \"Suze Morrison\",\n      \"email\": \"smorrisons@ola.org\",\n      \"districtName\": \"Toronto Centre\",\n      \"electedOffice\": \"MPP\",\n      \"partyName\": \"NDP\",\n      \"representativeSetName\": \"Legislative Assembly of Ontario\",\n      \"url\": \"https://www.ola.org/en/members/all/suze-morrison\",\n      \"photoUrl\": null,\n      \"offices\": [],\n      \"cachedAt\": \"2026-02-11T12:00:00.000Z\"\n    }\n  ]\n}\n

Response Fields:

  • source \u2014 Data source: \"cache\" (from database) or \"api\" (fresh from Represent API)
  • postalCode \u2014 Normalized postal code
  • location \u2014 City and province from PostalCodeCache table
  • representatives \u2014 Array of representative objects

Error Responses:

  • 400 Bad Request: Invalid postal code format
  • 404 Not Found: Postal code not found in Represent API
  • 429 Too Many Requests: Rate limit exceeded (55/min)
  • 500 Internal Server Error: Represent API unreachable or other error

Caching Strategy:

// 1. Check cache first (unless forceRefresh)\nconst cached = await prisma.representative.findMany({ where: { postalCode: code } });\nif (cached.length > 0 && !forceRefresh) {\n  return { source: 'cache', representatives: cached };\n}\n\n// 2. Call Represent API\nconst apiResponse = await representApiClient.getByPostalCode(code);\n\n// 3. Fire-and-forget cache write (don't await)\ncacheWrite(); // Deletes old cache, creates new entries\n\n// 4. Return API results immediately (don't wait for cache)\nreturn { source: 'api', representatives: uniqueReps };\n

Deduplication:

Representatives from both representatives_centroid and representatives_concordance are merged and deduplicated by name|elected_office key to avoid duplicate entries.

function deduplicateReps(reps: RepresentRepresentative[]): RepresentRepresentative[] {\n  const seen = new Set<string>();\n  return reps.filter((rep) => {\n    const key = `${rep.name}|${rep.elected_office}`;\n    if (seen.has(key)) return false;\n    seen.add(key);\n    return true;\n  });\n}\n
"},{"location":"v2/backend/modules/representatives/#get-apirepresentativestest-connection","title":"GET /api/representatives/test-connection","text":"

Test connectivity to the Represent API.

Example Request:

curl \"http://api.cmlite.org/api/representatives/test-connection\"\n

Response (200 OK):

{\n  \"ok\": true,\n  \"message\": \"Represent API is reachable\"\n}\n

Response (200 OK, API Down):

{\n  \"ok\": false,\n  \"message\": \"HTTP 503\"\n}\n

Use Cases:

  • Health checks for monitoring dashboards
  • Troubleshooting representative lookup issues
  • Verifying API configuration in admin settings
"},{"location":"v2/backend/modules/representatives/#admin-endpoint-details","title":"Admin Endpoint Details","text":""},{"location":"v2/backend/modules/representatives/#get-apirepresentativescache-stats","title":"GET /api/representatives/cache-stats","text":"

Get cache statistics for the representatives cache.

Authentication: Required (Admin roles)

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/representatives/cache-stats\"\n

Response (200 OK):

{\n  \"totalRepresentatives\": 1247,\n  \"postalCodesWithRepresentatives\": 412,\n  \"totalPostalCodes\": 450\n}\n

Field Descriptions:

  • totalRepresentatives \u2014 Total cached representative records
  • postalCodesWithRepresentatives \u2014 Unique postal codes with cached representatives
  • totalPostalCodes \u2014 Total postal codes in PostalCodeCache table (includes codes without representatives)
"},{"location":"v2/backend/modules/representatives/#get-apirepresentatives","title":"GET /api/representatives","text":"

List all cached representatives with pagination and search.

Authentication: Required (Admin roles)

Query Parameters:

Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) search string No - Search name, email, district, or office postalCode string No - Filter by postal code

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/representatives?page=1&limit=10&search=Toronto&postalCode=M5H2N2\"\n

Response (200 OK):

{\n  \"representatives\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"postalCode\": \"M5H2N2\",\n      \"name\": \"Chrystia Freeland\",\n      \"email\": \"chrystia.freeland@parl.gc.ca\",\n      \"districtName\": \"University\u2014Rosedale\",\n      \"electedOffice\": \"MP\",\n      \"partyName\": \"Liberal\",\n      \"representativeSetName\": \"House of Commons\",\n      \"url\": \"https://www.ourcommons.ca/members/en/chrystia-freeland(71619)\",\n      \"photoUrl\": \"https://www.ourcommons.ca/Content/Parliamentarians/Images/OfficialMPPhotos/44/FreelendeC_Lib.jpg\",\n      \"offices\": [...],\n      \"cachedAt\": \"2026-02-11T12:00:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 10,\n    \"total\": 15,\n    \"totalPages\": 2\n  }\n}\n

Search Logic:

Search term is matched against name, email, district name, or elected office (case-insensitive):

if (search) {\n  where.OR = [\n    { name: { contains: search, mode: 'insensitive' } },\n    { email: { contains: search, mode: 'insensitive' } },\n    { districtName: { contains: search, mode: 'insensitive' } },\n    { electedOffice: { contains: search, mode: 'insensitive' } },\n  ];\n}\n
"},{"location":"v2/backend/modules/representatives/#get-apirepresentativesid","title":"GET /api/representatives/:id","text":"

Get single cached representative by ID.

Authentication: Required (Admin roles)

Path Parameters:

  • id (string): Representative ID (cuid)

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/representatives/clx1234567890\"\n

Response (200 OK):

Returns single representative object (same format as list).

Error Responses:

  • 404 Not Found: Representative not found
"},{"location":"v2/backend/modules/representatives/#delete-apirepresentativesby-postalpostalcode","title":"DELETE /api/representatives/by-postal/:postalCode","text":"

Clear all cached representatives for a specific postal code.

Authentication: Required (Admin roles)

Path Parameters:

  • postalCode (string): Canadian postal code

Example Request:

curl -X DELETE -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/representatives/by-postal/M5H2N2\"\n

Response (200 OK):

{\n  \"deleted\": 3,\n  \"postalCode\": \"M5H2N2\"\n}\n

Use Cases:

  • Force cache refresh for specific postal code
  • Remove stale data after election
  • Troubleshoot incorrect representative data
"},{"location":"v2/backend/modules/representatives/#delete-apirepresentativesid","title":"DELETE /api/representatives/:id","text":"

Delete single cached representative by ID.

Authentication: Required (Admin roles)

Path Parameters:

  • id (string): Representative ID (cuid)

Example Request:

curl -X DELETE -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/representatives/clx1234567890\"\n

Response (204 No Content):

No response body.

Error Responses:

  • 404 Not Found: Representative not found
"},{"location":"v2/backend/modules/representatives/#represent-api-integration","title":"Represent API Integration","text":""},{"location":"v2/backend/modules/representatives/#api-client","title":"API Client","text":"

The represent-api.client.ts file provides a typed HTTP client for the Represent API.

Base URL:

const REPRESENT_API_URL = 'https://represent.opennorth.ca';\n

Configuration:

Set REPRESENT_API_URL in .env to override (default: https://represent.opennorth.ca).

Methods:

class RepresentApiClient {\n  // Lookup by postal code\n  async getByPostalCode(code: string): Promise<RepresentPostalCodeResponse>;\n\n  // Health check\n  async testConnection(): Promise<{ ok: boolean; message: string }>;\n}\n
"},{"location":"v2/backend/modules/representatives/#rate-limiting","title":"Rate Limiting","text":"

Limits:

  • Represent API: 60 requests/minute
  • Changemaker Lite: 55 requests/minute (safety margin)

Implementation:

In-memory sliding window rate limiter:

const RATE_LIMIT = 55;\nconst RATE_WINDOW_MS = 60_000;\nconst requestTimestamps: number[] = [];\n\nfunction checkRateLimit(): boolean {\n  const now = Date.now();\n  // Remove timestamps outside the window\n  while (requestTimestamps.length > 0 && requestTimestamps[0] < now - RATE_WINDOW_MS) {\n    requestTimestamps.shift();\n  }\n  return requestTimestamps.length < RATE_LIMIT;\n}\n\nfunction recordRequest(): void {\n  requestTimestamps.push(Date.now());\n}\n

Behavior:

  • If rate limit exceeded: throws Error('Represent API rate limit reached. Please try again in a minute.')
  • Returns 429 status to client
  • Resets after 1 minute
"},{"location":"v2/backend/modules/representatives/#response-schema","title":"Response Schema","text":"
interface RepresentPostalCodeResponse {\n  city: string | null;\n  province: string | null;\n  centroid: { type: string; coordinates: [number, number] } | null;\n  representatives_centroid: RepresentRepresentative[];\n  representatives_concordance: RepresentRepresentative[];\n}\n\ninterface RepresentRepresentative {\n  name: string;\n  email: string | null;\n  elected_office: string;\n  district_name: string;\n  party_name: string | null;\n  representative_set_name: string;\n  url: string;\n  photo_url: string | null;\n  offices: RepresentOffice[];\n}\n\ninterface RepresentOffice {\n  type?: string;       // \"constituency\" or \"legislature\"\n  tel?: string;        // Phone number\n  fax?: string;        // Fax number\n  postal?: string;     // Mailing address\n}\n

Centroid vs. Concordance:

  • representatives_centroid \u2014 Representatives found using the postal code's geographic centroid
  • representatives_concordance \u2014 Representatives found using postal code concordance tables (may be more accurate for boundary-edge postal codes)
  • Both arrays are merged and deduplicated by Changemaker Lite
"},{"location":"v2/backend/modules/representatives/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/representatives/#representativesservicelookupbypostalcodecode-forcerefresh","title":"representativesService.lookupByPostalCode(code, forceRefresh)","text":"

Cache-first representative lookup.

Parameters:

  • code (string): Canadian postal code
  • forceRefresh (boolean, default: false): Skip cache and force API call

Returns:

{\n  source: 'cache' | 'api';\n  postalCode: string;\n  location: { city: string | null; province: string | null };\n  representatives: Representative[];\n}\n

Logic Flow:

  1. Check cache unless forceRefresh=true
  2. If cached data found, return immediately with source: 'cache'
  3. If no cache or forceRefresh, call Represent API
  4. Merge centroid + concordance representatives and deduplicate
  5. Fire-and-forget cache write (delete old, insert new, upsert postal code)
  6. Return API results with source: 'api' (don't wait for cache)

Fire-and-Forget Caching:

const cacheWrite = async () => {\n  try {\n    // Delete old cached reps for this postal code\n    await prisma.representative.deleteMany({ where: { postalCode: code } });\n\n    // Cache new reps\n    await prisma.representative.createMany({\n      data: uniqueReps.map((rep) => ({\n        postalCode: code,\n        name: rep.name || null,\n        email: rep.email || null,\n        districtName: rep.district_name || null,\n        electedOffice: rep.elected_office || null,\n        partyName: rep.party_name || null,\n        representativeSetName: rep.representative_set_name || null,\n        url: rep.url || null,\n        photoUrl: rep.photo_url || null,\n        offices: rep.offices ? (rep.offices as unknown as Prisma.InputJsonValue) : Prisma.JsonNull,\n      })),\n    });\n\n    // Upsert postal code cache (city, province, centroid)\n    await postalCodesService.upsert({\n      postalCode: code,\n      city: apiResponse.city,\n      province: apiResponse.province,\n      centroidLat: coords ? coords[1] : null,\n      centroidLng: coords ? coords[0] : null,\n    });\n  } catch (err) {\n    logger.error('Failed to cache representatives', { postalCode: code, error: err });\n  }\n};\n\n// Don't await \u2014 fire and forget\ncacheWrite();\n

Why Fire-and-Forget?

  • Returns API results to user immediately (faster response)
  • Cache failures don't block user requests
  • Next lookup will use cached data if write succeeds
  • Errors logged for monitoring but don't propagate to user
"},{"location":"v2/backend/modules/representatives/#representativesservicefindallfilters","title":"representativesService.findAll(filters)","text":"

List cached representatives with pagination and search.

Parameters:

{\n  page: number;      // Page number (default: 1)\n  limit: number;     // Results per page (max 100, default: 20)\n  search?: string;   // Search term (optional)\n  postalCode?: string; // Filter by postal code (optional)\n}\n

Returns:

{\n  representatives: Representative[];\n  pagination: {\n    page: number;\n    limit: number;\n    total: number;\n    totalPages: number;\n  };\n}\n
"},{"location":"v2/backend/modules/representatives/#representativesservicefindbyidid","title":"representativesService.findById(id)","text":"

Get single cached representative by ID.

Throws: AppError(404) if not found

"},{"location":"v2/backend/modules/representatives/#representativesserviceclearbypostalcodecode","title":"representativesService.clearByPostalCode(code)","text":"

Delete all cached representatives for a postal code.

Returns:

{\n  deleted: number;      // Count of deleted records\n  postalCode: string;\n}\n
"},{"location":"v2/backend/modules/representatives/#representativesservicedeletebyidid","title":"representativesService.deleteById(id)","text":"

Delete single cached representative by ID.

Throws: AppError(404) if not found

"},{"location":"v2/backend/modules/representatives/#representativesservicetestapiconnection","title":"representativesService.testApiConnection()","text":"

Test connectivity to Represent API.

Returns:

{\n  ok: boolean;\n  message: string;\n}\n

Implementation:

Calls Represent API's /boundary-sets/?limit=1 endpoint (lightweight health check).

"},{"location":"v2/backend/modules/representatives/#representativesservicegetcachestats","title":"representativesService.getCacheStats()","text":"

Get cache statistics.

Returns:

{\n  totalRepresentatives: number;         // Total cached representative records\n  postalCodesWithRepresentatives: number; // Unique postal codes with reps\n  totalPostalCodes: number;              // Total postal codes in cache\n}\n

Implementation:

const [totalReps, postalCodesWithReps, totalPostalCodes] = await Promise.all([\n  prisma.representative.count(),\n  prisma.representative.groupBy({ by: ['postalCode'] }).then((g) => g.length),\n  prisma.postalCodeCache.count(),\n]);\n\nreturn {\n  totalRepresentatives: totalReps,\n  postalCodesWithRepresentatives: postalCodesWithReps,\n  totalPostalCodes,\n};\n
"},{"location":"v2/backend/modules/representatives/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/representatives/#list-representatives-schema","title":"List Representatives Schema","text":"
export const listRepresentativesSchema = z.object({\n  page: z.coerce.number().int().positive().default(1),\n  limit: z.coerce.number().int().positive().max(100).default(20),\n  search: z.string().optional(),\n  postalCode: z.string().optional(),\n});\n\nexport type ListRepresentativesInput = z.infer<typeof listRepresentativesSchema>;\n

Coercion:

  • page and limit coerced from query string to number
  • Invalid values fallback to defaults
"},{"location":"v2/backend/modules/representatives/#integration-with-postal-codes-module","title":"Integration with Postal Codes Module","text":"

The representatives module integrates with the postal codes module (api/src/modules/influence/postal-codes/) for location metadata.

PostalCodeCache Model:

model PostalCodeCache {\n  id          String    @id @default(cuid())\n  postalCode  String    @unique\n  city        String?\n  province    String?\n  centroidLat Float?\n  centroidLng Float?\n  cachedAt    DateTime  @default(now())\n}\n

Integration Points:

  1. Lookup: When returning cached representatives, fetch city/province from PostalCodeCache:
const postalInfo = await postalCodesService.findByPostalCode(code);\nreturn {\n  source: 'cache',\n  location: {\n    city: postalInfo?.city ?? null,\n    province: postalInfo?.province ?? null,\n  },\n  representatives: cached,\n};\n
  1. Cache Write: After calling Represent API, upsert postal code with location data:
await postalCodesService.upsert({\n  postalCode: code,\n  city: apiResponse.city,\n  province: apiResponse.province,\n  centroidLat: coords ? coords[1] : null,\n  centroidLng: coords ? coords[0] : null,\n});\n
"},{"location":"v2/backend/modules/representatives/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/representatives/#public-lookup-representatives-by-postal-code","title":"Public: Lookup Representatives by Postal Code","text":"
import axios from 'axios';\n\nconst lookupRepresentatives = async (postalCode: string) => {\n  const { data } = await axios.get(\n    `/api/representatives/by-postal/${postalCode}`\n  );\n\n  console.log(`Source: ${data.source}`); // \"cache\" or \"api\"\n  console.log(`Location: ${data.location.city}, ${data.location.province}`);\n\n  data.representatives.forEach((rep) => {\n    console.log(`${rep.name} (${rep.electedOffice}) - ${rep.email}`);\n  });\n\n  return data;\n};\n\n// Cache-first lookup\nawait lookupRepresentatives('M5H2N2');\n\n// Force refresh from API\nconst { data } = await axios.get('/api/representatives/by-postal/M5H2N2?refresh=true');\n
"},{"location":"v2/backend/modules/representatives/#admin-get-cache-statistics","title":"Admin: Get Cache Statistics","text":"
import { api } from '@/lib/api';\n\nconst getCacheStats = async () => {\n  const { data } = await api.get('/api/representatives/cache-stats');\n\n  console.log(`Total Representatives: ${data.totalRepresentatives}`);\n  console.log(`Postal Codes with Reps: ${data.postalCodesWithRepresentatives}`);\n  console.log(`Total Postal Codes: ${data.totalPostalCodes}`);\n\n  return data;\n};\n
"},{"location":"v2/backend/modules/representatives/#admin-clear-cache-for-postal-code","title":"Admin: Clear Cache for Postal Code","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst clearPostalCodeCache = async (postalCode: string) => {\n  try {\n    const { data } = await api.delete(`/api/representatives/by-postal/${postalCode}`);\n    message.success(`Cleared ${data.deleted} representatives for ${postalCode}`);\n  } catch (error) {\n    message.error('Failed to clear cache');\n  }\n};\n
"},{"location":"v2/backend/modules/representatives/#admin-search-cached-representatives","title":"Admin: Search Cached Representatives","text":"
import { api } from '@/lib/api';\n\nconst searchRepresentatives = async (search: string, page: number = 1) => {\n  const { data } = await api.get('/api/representatives', {\n    params: { search, page, limit: 20 },\n  });\n\n  return {\n    representatives: data.representatives,\n    pagination: data.pagination,\n  };\n};\n
"},{"location":"v2/backend/modules/representatives/#frontend-integration","title":"Frontend Integration","text":"

The RepresentativesPage component (admin/src/pages/RepresentativesPage.tsx) provides:

  • Cache statistics dashboard (total reps, postal codes, coverage)
  • Representative cache table with pagination
  • Search by name, email, district, or office
  • Filter by postal code
  • Clear cache by postal code (bulk action)
  • Delete individual cached representatives
  • Postal code lookup tool (test Represent API)
  • Connection test (verify API reachability)
  • Refresh button (force API call for postal code)

State Management:

const [representatives, setRepresentatives] = useState<Representative[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });\nconst [filters, setFilters] = useState({ search: '', postalCode: '' });\nconst [stats, setStats] = useState({ totalRepresentatives: 0, postalCodesWithRepresentatives: 0, totalPostalCodes: 0 });\n
"},{"location":"v2/backend/modules/representatives/#performance-considerations","title":"Performance Considerations","text":"

Cache-First Strategy:

  • Cached lookups: <10ms (database query)
  • API lookups: 200-500ms (external API call)
  • Fire-and-forget writes don't block user response

Rate Limiting:

  • 55 requests/minute limit prevents Represent API 429 errors
  • In-memory sliding window (no Redis overhead)
  • Returns 429 status to client when limit exceeded

Database Indexing:

  • @@index([postalCode]) \u2014 Fast lookup by postal code
  • Ordered by cachedAt DESC \u2014 Recent lookups first

Deduplication:

  • Prevents duplicate representatives from centroid + concordance results
  • Reduces database storage and frontend rendering load
"},{"location":"v2/backend/modules/representatives/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/representatives/#issue-represent-api-rate-limit-reached","title":"Issue: \"Represent API rate limit reached\"","text":"

Cause: More than 55 requests in 60-second window

Solution:

  • Wait 1 minute and retry
  • Use cached data (don't force refresh)
  • Batch postal code lookups instead of sequential
"},{"location":"v2/backend/modules/representatives/#issue-cached-data-is-stale","title":"Issue: Cached data is stale","text":"

Cause: Representative changed after election

Solution:

  • Force refresh: GET /api/representatives/by-postal/:postalCode?refresh=true
  • Admin clear cache: DELETE /api/representatives/by-postal/:postalCode
  • Cache will be refreshed on next lookup
"},{"location":"v2/backend/modules/representatives/#issue-postal-code-returns-no-representatives","title":"Issue: Postal code returns no representatives","text":"

Cause: Invalid postal code or Represent API doesn't have data

Solution:

  • Verify postal code format (e.g., \"M5H2N2\" or \"M5H 2N2\")
  • Check Represent API directly: https://represent.opennorth.ca/postcodes/M5H2N2/
  • Ensure postal code is Canadian (Represent API is Canada-only)
"},{"location":"v2/backend/modules/representatives/#issue-duplicate-representatives-in-cache","title":"Issue: Duplicate representatives in cache","text":"

Cause: Deduplication bug or manual database insertion

Solution:

  • Clear cache: DELETE /api/representatives/by-postal/:postalCode
  • Next lookup will re-deduplicate from API
"},{"location":"v2/backend/modules/representatives/#related-documentation","title":"Related Documentation","text":"
  • Postal Codes Module - Postal code cache integration
  • Campaigns Module - Campaign email sending to representatives
  • Frontend: RepresentativesPage - Cache management UI
  • Frontend: Public Campaign Page - Public representative lookup
  • API Reference: Representatives - Complete endpoint reference
  • Feature: Influence System - Representative lookup feature guide
  • Represent API Documentation - Official Represent API docs
"},{"location":"v2/backend/modules/responses/","title":"Responses Module","text":""},{"location":"v2/backend/modules/responses/#overview","title":"Overview","text":"

The Responses module manages the public response wall for advocacy campaigns, allowing users to share representative responses (emails, letters, phone calls, etc.) with email verification, upvoting, and admin moderation. It features a dual verification system (verify or report links), IP-based and user-based upvoting, and comprehensive moderation tools.

Key Features:

  • Public response submission with representative verification emails
  • Email verification flow (30-day expiry, verify or report links)
  • Upvoting system (IP-based for anonymous, user-based for logged-in users)
  • Admin moderation (PENDING \u2192 APPROVED/REJECTED workflow)
  • Response statistics (total, verified, upvotes, level breakdown)
  • Public response listing with sorting (recent, upvotes, verified)
  • Rate limiting (prevents spam submissions)
  • Anonymous submissions (submitter name/email hidden)
  • Response types (email, letter, phone call, meeting, social media, other)
  • HTML result pages for email verification links
"},{"location":"v2/backend/modules/responses/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/influence/responses/responses.routes.ts 3 routers (campaign public, responses public, admin) with 12 endpoints api/src/modules/influence/responses/responses.service.ts Response business logic + email verification api/src/modules/influence/responses/responses.schemas.ts Zod validation schemas"},{"location":"v2/backend/modules/responses/#database-models","title":"Database Models","text":""},{"location":"v2/backend/modules/responses/#representativeresponse","title":"RepresentativeResponse","text":"
model RepresentativeResponse {\n  id                   String           @id @default(cuid())\n  campaignId           String\n  campaign             Campaign         @relation(fields: [campaignId], references: [id], onDelete: Cascade)\n  campaignSlug         String\n\n  representativeName   String\n  representativeTitle  String?\n  representativeLevel  GovernmentLevel\n  representativeEmail  String?\n\n  responseType         ResponseType\n  responseText         String           @db.Text\n  userComment          String?          @db.Text\n  screenshotUrl        String?\n\n  // Submitter info\n  submittedByUserId    String?\n  submittedByUser      User?            @relation(\"ResponseSubmitter\", fields: [submittedByUserId], references: [id], onDelete: SetNull)\n  submittedByName      String?\n  submittedByEmail     String?\n  isAnonymous          Boolean          @default(false)\n  submittedIp          String?\n\n  // Moderation\n  status               ResponseStatus   @default(PENDING)\n\n  // Verification\n  isVerified           Boolean          @default(false)\n  verificationToken    String?\n  verificationSentAt   DateTime?\n  verifiedAt           DateTime?\n  verifiedBy           String?\n\n  // Upvoting\n  upvoteCount          Int              @default(0)\n  upvotes              ResponseUpvote[]\n\n  createdAt            DateTime         @default(now())\n  updatedAt            DateTime         @updatedAt\n\n  @@index([campaignId])\n  @@index([campaignSlug])\n  @@index([status])\n  @@map(\"representative_responses\")\n}\n\nenum ResponseType {\n  EMAIL\n  LETTER\n  PHONE_CALL\n  MEETING\n  SOCIAL_MEDIA\n  OTHER\n}\n\nenum ResponseStatus {\n  PENDING   // Awaiting moderation\n  APPROVED  // Visible on public wall\n  REJECTED  // Removed/disputed\n}\n
"},{"location":"v2/backend/modules/responses/#responseupvote","title":"ResponseUpvote","text":"
model ResponseUpvote {\n  id         String  @id @default(cuid())\n  responseId String\n  response   RepresentativeResponse @relation(fields: [responseId], references: [id], onDelete: Cascade)\n  userId     String?\n  user       User?   @relation(fields: [userId], references: [id], onDelete: SetNull)\n  userEmail  String?\n  upvotedIp  String?\n\n  @@unique([responseId, userId])      // Logged-in users: one upvote per response\n  @@unique([responseId, upvotedIp])   // Anonymous users: one upvote per IP per response\n  @@map(\"response_upvotes\")\n}\n

Upvoting Logic:

  • Logged-in users: tracked by userId (allows upvoting from multiple devices)
  • Anonymous users: tracked by upvotedIp (prevents duplicate upvotes from same IP)
  • Unique constraints ensure users can't upvote same response multiple times
"},{"location":"v2/backend/modules/responses/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/responses/#campaign-scoped-public-endpoints-no-authentication","title":"Campaign-Scoped Public Endpoints (No Authentication)","text":"Method Path Description GET /api/campaigns/:slug/responses List approved responses for campaign GET /api/campaigns/:slug/response-stats Get response statistics for campaign POST /api/campaigns/:slug/responses Submit new response (rate-limited)"},{"location":"v2/backend/modules/responses/#response-scoped-public-endpoints-optional-authentication","title":"Response-Scoped Public Endpoints (Optional Authentication)","text":"Method Path Description POST /api/responses/:id/upvote Upvote a response DELETE /api/responses/:id/upvote Remove upvote from response GET /api/responses/:id/verify/:token Verify response (returns HTML page) GET /api/responses/:id/report/:token Report response as invalid (returns HTML page)"},{"location":"v2/backend/modules/responses/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"Method Path Auth Description GET /api/responses Admin roles List all responses (paginated, filtered) PATCH /api/responses/:id/status Admin roles Update response status POST /api/responses/:id/resend-verification Admin roles Resend verification email DELETE /api/responses/:id Admin roles Delete response

Admin Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

"},{"location":"v2/backend/modules/responses/#public-endpoint-details","title":"Public Endpoint Details","text":""},{"location":"v2/backend/modules/responses/#post-apicampaignsslugresponses","title":"POST /api/campaigns/:slug/responses","text":"

Submit a new representative response to a campaign.

Rate Limiting: 10 requests per minute per IP

Path Parameters:

  • slug (string): Campaign slug

Request Body:

{\n  \"representativeName\": \"Chrystia Freeland\",\n  \"representativeTitle\": \"Deputy Prime Minister\",\n  \"representativeLevel\": \"FEDERAL\",\n  \"representativeEmail\": \"chrystia.freeland@parl.gc.ca\",\n  \"responseType\": \"EMAIL\",\n  \"responseText\": \"Thank you for writing. I appreciate your concerns regarding climate change and am committed to...\",\n  \"userComment\": \"Received this response 2 days after sending my email!\",\n  \"submittedByName\": \"Jane Doe\",\n  \"submittedByEmail\": \"jane@example.com\",\n  \"isAnonymous\": false,\n  \"sendVerification\": true\n}\n

Field Descriptions:

  • representativeName (required): Representative's full name
  • representativeLevel (required): Government level (FEDERAL, PROVINCIAL, MUNICIPAL, SCHOOL_BOARD)
  • responseType (required): Response type (EMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHER)
  • responseText (required): Full text of representative's response
  • representativeTitle (optional): Representative's title/position
  • representativeEmail (optional): Representative's email (required if sendVerification=true)
  • userComment (optional): Submitter's comment about the response
  • submittedByName (optional): Submitter's name (not shown if isAnonymous=true)
  • submittedByEmail (optional): Submitter's email (not shown publicly)
  • isAnonymous (optional, default: false): Hide submitter name on public wall
  • sendVerification (optional, default: false): Send verification email to representative

Response (201 Created):

{\n  \"id\": \"clx1234567890\",\n  \"status\": \"PENDING\",\n  \"verificationSent\": true\n}\n

Verification Email Flow:

If sendVerification=true and representativeEmail is provided, an email is sent to the representative with:

  • Verify Link: Marks response as APPROVED and verified
  • Report Link: Marks response as REJECTED (representative disputes it)
  • 30-day expiry: Verification token expires after 30 days

Example Verification Email:

Subject: Please verify this response submission for \"Climate Action Now\" campaign\n\nDear Representative,\n\nA constituent has submitted a response from you for the \"Climate Action Now\" campaign on Changemaker Lite.\n\nResponse Type: Email\nResponse Text: \"Thank you for writing. I appreciate your concerns regarding...\"\nSubmitted By: Jane Doe\n\nIf this is a genuine response from you, please verify it:\nhttps://api.cmlite.org/api/responses/clx1234567890/verify/abc123...\n\nIf you did not send this response, or it is inaccurate, please report it:\nhttps://api.cmlite.org/api/responses/clx1234567890/report/abc123...\n\nThis link expires in 30 days.\n

Error Responses:

  • 400 Bad Request: Campaign not active, response wall disabled, or validation error
  • 404 Not Found: Campaign not found
  • 429 Too Many Requests: Rate limit exceeded (10/min)

Campaign Requirements:

  • Campaign must have status=ACTIVE
  • Campaign must have showResponseWall=true
"},{"location":"v2/backend/modules/responses/#get-apicampaignsslugresponses","title":"GET /api/campaigns/:slug/responses","text":"

List approved responses for a campaign.

Path Parameters:

  • slug (string): Campaign slug

Query Parameters:

Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) sort string No recent Sort order: recent, upvotes, verified level GovernmentLevel No - Filter by government level

Example Request:

# Recent responses\ncurl \"http://api.cmlite.org/api/campaigns/climate-action-now/responses?page=1&limit=10\"\n\n# Sort by upvotes\ncurl \"http://api.cmlite.org/api/campaigns/climate-action-now/responses?sort=upvotes\"\n\n# Filter by federal only\ncurl \"http://api.cmlite.org/api/campaigns/climate-action-now/responses?level=FEDERAL\"\n

Response (200 OK):

{\n  \"responses\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"representativeName\": \"Chrystia Freeland\",\n      \"representativeTitle\": \"Deputy Prime Minister\",\n      \"representativeLevel\": \"FEDERAL\",\n      \"responseType\": \"EMAIL\",\n      \"responseText\": \"Thank you for writing. I appreciate your concerns...\",\n      \"userComment\": \"Received this response 2 days after sending!\",\n      \"submittedByName\": \"Jane Doe\",\n      \"isAnonymous\": false,\n      \"isVerified\": true,\n      \"verifiedAt\": \"2026-02-10T12:00:00.000Z\",\n      \"upvoteCount\": 42,\n      \"createdAt\": \"2026-02-08T12:00:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 10,\n    \"total\": 89,\n    \"totalPages\": 9\n  }\n}\n

Response Fields:

  • Only APPROVED responses are returned
  • submittedByName is null if isAnonymous=true
  • submittedByEmail never exposed on public routes
  • representativeEmail never exposed on public routes

Sorting:

switch (sort) {\n  case 'upvotes':\n    orderBy = { upvoteCount: 'desc' };\n    break;\n  case 'verified':\n    orderBy = { isVerified: 'desc' };\n    break;\n  default: // 'recent'\n    orderBy = { createdAt: 'desc' };\n}\n
"},{"location":"v2/backend/modules/responses/#get-apicampaignsslugresponse-stats","title":"GET /api/campaigns/:slug/response-stats","text":"

Get aggregate statistics for campaign responses.

Path Parameters:

  • slug (string): Campaign slug

Example Request:

curl \"http://api.cmlite.org/api/campaigns/climate-action-now/response-stats\"\n

Response (200 OK):

{\n  \"total\": 89,\n  \"verified\": 42,\n  \"totalUpvotes\": 347,\n  \"byLevel\": {\n    \"FEDERAL\": 32,\n    \"PROVINCIAL\": 28,\n    \"MUNICIPAL\": 21,\n    \"SCHOOL_BOARD\": 8\n  }\n}\n

Field Descriptions:

  • total: Total APPROVED responses for campaign
  • verified: Count of APPROVED responses with isVerified=true
  • totalUpvotes: Sum of all upvoteCount values
  • byLevel: Breakdown by government level
"},{"location":"v2/backend/modules/responses/#post-apiresponsesidupvote","title":"POST /api/responses/:id/upvote","text":"

Upvote a response.

Authentication: Optional (supports both logged-in and anonymous users)

Path Parameters:

  • id (string): Response ID

Example Request:

# Anonymous upvote (tracked by IP)\ncurl -X POST \"http://api.cmlite.org/api/responses/clx1234567890/upvote\"\n\n# Logged-in upvote (tracked by user ID)\ncurl -X POST -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/responses/clx1234567890/upvote\"\n

Response (200 OK):

{\n  \"success\": true\n}\n

Response (200 OK, Already Upvoted):

{\n  \"success\": false,\n  \"alreadyUpvoted\": true\n}\n

Upvoting Logic:

  1. Verify response exists and is APPROVED
  2. Create ResponseUpvote record:
  3. Logged-in: userId + responseId (allows upvoting from multiple IPs)
  4. Anonymous: upvotedIp + responseId (prevents duplicate upvotes from same IP)
  5. Increment upvoteCount on response
  6. If duplicate (Prisma P2002 error), return alreadyUpvoted: true

Error Responses:

  • 400 Bad Request: Response is not approved
  • 404 Not Found: Response not found
"},{"location":"v2/backend/modules/responses/#delete-apiresponsesidupvote","title":"DELETE /api/responses/:id/upvote","text":"

Remove upvote from a response.

Authentication: Optional (supports both logged-in and anonymous users)

Path Parameters:

  • id (string): Response ID

Example Request:

# Remove anonymous upvote\ncurl -X DELETE \"http://api.cmlite.org/api/responses/clx1234567890/upvote\"\n\n# Remove logged-in upvote\ncurl -X DELETE -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/responses/clx1234567890/upvote\"\n

Response (200 OK):

{\n  \"success\": true\n}\n

Response (200 OK, Not Upvoted):

{\n  \"success\": false\n}\n

Logic:

  1. Delete ResponseUpvote record matching responseId + userId (or upvotedIp if anonymous)
  2. Decrement upvoteCount if deleted
  3. Return success: false if no upvote record found
"},{"location":"v2/backend/modules/responses/#get-apiresponsesidverifytoken","title":"GET /api/responses/:id/verify/:token","text":"

Verify a response (representative confirms authenticity). Returns HTML result page.

Path Parameters:

  • id (string): Response ID
  • token (string): Verification token (64-char hex)

Example URL:

https://api.cmlite.org/api/responses/clx1234567890/verify/abc123...\n

Response (200 OK, Success):

Returns HTML page with success message:

<!DOCTYPE html>\n<html>\n<head>\n  <title>Response Verified - Changemaker Lite</title>\n  ...\n</head>\n<body>\n  <div class=\"container\">\n    <div class=\"card\">\n      <div class=\"icon\">\u2713</div>\n      <h1 style=\"color: #16a34a\">Response Verified</h1>\n      <p>Thank you for verifying this response for the \"Climate Action Now\" campaign. The response has been approved and will now appear on the public response wall.</p>\n    </div>\n    <div class=\"brand\">Powered by <strong>Changemaker Lite</strong></div>\n  </div>\n</body>\n</html>\n

Response (200 OK, Failed):

Returns HTML page with error message:

  • reason: \"Invalid verification link\" \u2014 Token doesn't match
  • reason: \"Verification link has expired\" \u2014 More than 30 days since sent

Database Changes on Success:

await prisma.representativeResponse.update({\n  where: { id: responseId },\n  data: {\n    isVerified: true,\n    verifiedAt: new Date(),\n    verifiedBy: response.representativeEmail || 'Representative',\n    status: ResponseStatus.APPROVED,\n  },\n});\n

Expiry Logic:

const VERIFICATION_EXPIRY_DAYS = 30;\n\nif (response.verificationSentAt) {\n  const daysSinceSent = (Date.now() - response.verificationSentAt.getTime()) / (1000 * 60 * 60 * 24);\n  if (daysSinceSent > VERIFICATION_EXPIRY_DAYS) {\n    return { success: false, reason: 'Verification link has expired' };\n  }\n}\n
"},{"location":"v2/backend/modules/responses/#get-apiresponsesidreporttoken","title":"GET /api/responses/:id/report/:token","text":"

Report a response as invalid (representative disputes it). Returns HTML result page.

Path Parameters:

  • id (string): Response ID
  • token (string): Verification token (same token as verify link)

Example URL:

https://api.cmlite.org/api/responses/clx1234567890/report/abc123...\n

Response (200 OK, Success):

Returns HTML page with confirmation:

<h1 style=\"color: #dc2626\">Response Reported</h1>\n<p>This response for the \"Climate Action Now\" campaign has been flagged as invalid and removed from the public response wall. Thank you for letting us know.</p>\n

Database Changes on Success:

await prisma.representativeResponse.update({\n  where: { id: responseId },\n  data: {\n    status: ResponseStatus.REJECTED,\n    isVerified: false,\n    verifiedBy: `Disputed by ${response.representativeEmail || 'representative'}`,\n  },\n});\n

Use Cases:

  • Representative never sent the response (fake submission)
  • Response text is inaccurate or fabricated
  • Response was sent by someone else impersonating the representative
"},{"location":"v2/backend/modules/responses/#admin-endpoint-details","title":"Admin Endpoint Details","text":""},{"location":"v2/backend/modules/responses/#get-apiresponses","title":"GET /api/responses","text":"

List all responses with admin filters.

Authentication: Required (Admin roles)

Query Parameters:

Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) status ResponseStatus No - Filter by status (PENDING, APPROVED, REJECTED) campaignId string No - Filter by campaign ID search string No - Search name, response text, or submitter

Example Request:

# Pending responses\ncurl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/responses?status=PENDING&page=1&limit=10\"\n\n# Search\ncurl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/responses?search=climate\"\n\n# Campaign-specific\ncurl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/responses?campaignId=clxCampaign123\"\n

Response (200 OK):

{\n  \"responses\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"representativeName\": \"Chrystia Freeland\",\n      \"representativeTitle\": \"Deputy Prime Minister\",\n      \"representativeLevel\": \"FEDERAL\",\n      \"representativeEmail\": \"chrystia.freeland@parl.gc.ca\",\n      \"responseType\": \"EMAIL\",\n      \"responseText\": \"Thank you for writing...\",\n      \"userComment\": \"Received this response 2 days after sending!\",\n      \"submittedByName\": \"Jane Doe\",\n      \"submittedByEmail\": \"jane@example.com\",\n      \"isAnonymous\": false,\n      \"status\": \"PENDING\",\n      \"isVerified\": false,\n      \"verifiedAt\": null,\n      \"verifiedBy\": null,\n      \"upvoteCount\": 0,\n      \"createdAt\": \"2026-02-08T12:00:00.000Z\",\n      \"campaign\": {\n        \"id\": \"clxCampaign123\",\n        \"title\": \"Climate Action Now\",\n        \"slug\": \"climate-action-now\"\n      }\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 10,\n    \"total\": 23,\n    \"totalPages\": 3\n  }\n}\n

Differences from Public Route:

  • Includes representativeEmail, submittedByEmail (sensitive fields)
  • Returns all statuses (not just APPROVED)
  • Includes campaign relation
  • Search across name, response text, submitter name

Search Logic:

if (search) {\n  where.OR = [\n    { representativeName: { contains: search, mode: 'insensitive' } },\n    { responseText: { contains: search, mode: 'insensitive' } },\n    { submittedByName: { contains: search, mode: 'insensitive' } },\n  ];\n}\n
"},{"location":"v2/backend/modules/responses/#patch-apiresponsesidstatus","title":"PATCH /api/responses/:id/status","text":"

Update response status (approve or reject).

Authentication: Required (Admin roles)

Path Parameters:

  • id (string): Response ID

Request Body:

{\n  \"status\": \"APPROVED\"\n}\n

Valid Statuses: PENDING, APPROVED, REJECTED

Example Request:

# Approve response\ncurl -X PATCH -H \"Authorization: Bearer <token>\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"status\":\"APPROVED\"}' \\\n  \"http://api.cmlite.org/api/responses/clx1234567890/status\"\n\n# Reject response\ncurl -X PATCH -H \"Authorization: Bearer <token>\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"status\":\"REJECTED\"}' \\\n  \"http://api.cmlite.org/api/responses/clx1234567890/status\"\n

Response (200 OK):

Returns updated response object (same format as GET).

Error Responses:

  • 404 Not Found: Response not found

Use Cases:

  • Manual moderation: approve legitimate responses, reject spam
  • Bulk approval after reviewing pending queue
  • Reject disputed responses without representative verification
"},{"location":"v2/backend/modules/responses/#post-apiresponsesidresend-verification","title":"POST /api/responses/:id/resend-verification","text":"

Resend verification email to representative.

Authentication: Required (Admin roles)

Path Parameters:

  • id (string): Response ID

Example Request:

curl -X POST -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/responses/clx1234567890/resend-verification\"\n

Response (200 OK):

{\n  \"success\": true\n}\n

Error Responses:

  • 400 Bad Request: No representative email on record
  • 404 Not Found: Response not found

Logic:

  1. Retrieve existing response
  2. Regenerate verification token (or reuse existing)
  3. Update verificationToken and verificationSentAt in database
  4. Send verification email to representativeEmail

Use Cases:

  • Verification email wasn't delivered
  • Representative lost the original email
  • Token expired (more than 30 days old)
"},{"location":"v2/backend/modules/responses/#delete-apiresponsesid","title":"DELETE /api/responses/:id","text":"

Delete a response permanently.

Authentication: Required (Admin roles)

Path Parameters:

  • id (string): Response ID

Example Request:

curl -X DELETE -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/responses/clx1234567890\"\n

Response (204 No Content):

No response body.

Error Responses:

  • 404 Not Found: Response not found

Cascading Deletes:

  • All ResponseUpvote records for this response (via Prisma cascade)
"},{"location":"v2/backend/modules/responses/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/responses/#responsesservicesubmitresponseslug-data-senderip","title":"responsesService.submitResponse(slug, data, senderIp)","text":"

Submit new response to campaign.

Parameters:

  • slug (string): Campaign slug
  • data (SubmitResponseInput): Response data
  • senderIp (string, optional): Submitter's IP address

Returns:

{\n  id: string;\n  status: ResponseStatus;\n  verificationSent: boolean;\n}\n

Validation:

  • Campaign must exist and be ACTIVE
  • Campaign must have showResponseWall=true
  • If sendVerification=true, representativeEmail is required

Verification Token:

let verificationToken: string | null = null;\n\nif (data.sendVerification && data.representativeEmail) {\n  verificationToken = randomBytes(32).toString('hex'); // 64-char hex string\n}\n

Metrics:

Calls recordResponseSubmission() to increment Prometheus counter.

"},{"location":"v2/backend/modules/responses/#responsesservicelistapprovedslug-filters","title":"responsesService.listApproved(slug, filters)","text":"

List approved responses for campaign with sorting.

Parameters:

{\n  page: number;\n  limit: number;\n  sort: 'recent' | 'upvotes' | 'verified';\n  level?: GovernmentLevel;\n}\n

Returns:

{\n  responses: Response[];\n  pagination: Pagination;\n}\n
"},{"location":"v2/backend/modules/responses/#responsesservicegetstatsslug","title":"responsesService.getStats(slug)","text":"

Get aggregate statistics for campaign responses.

Returns:

{\n  total: number;\n  verified: number;\n  totalUpvotes: number;\n  byLevel: Record<string, number>;\n}\n
"},{"location":"v2/backend/modules/responses/#responsesserviceupvoteresponseid-userip-userid","title":"responsesService.upvote(responseId, userIp, userId)","text":"

Upvote a response.

Parameters:

  • responseId (string): Response ID
  • userIp (string, optional): User's IP address
  • userId (string, optional): User ID (if logged in)

Returns:

{\n  success: boolean;\n  alreadyUpvoted?: boolean; // True if duplicate upvote attempt\n}\n

Logic:

try {\n  await prisma.responseUpvote.create({\n    data: {\n      responseId,\n      userId: userId || null,\n      upvotedIp: !userId ? (userIp || null) : null,\n    },\n  });\n\n  await prisma.representativeResponse.update({\n    where: { id: responseId },\n    data: { upvoteCount: { increment: 1 } },\n  });\n\n  return { success: true };\n} catch (err: any) {\n  if (err.code === 'P2002') {  // Prisma unique constraint violation\n    return { success: false, alreadyUpvoted: true };\n  }\n  throw err;\n}\n
"},{"location":"v2/backend/modules/responses/#responsesserviceremoveupvoteresponseid-userip-userid","title":"responsesService.removeUpvote(responseId, userIp, userId)","text":"

Remove upvote from response.

Parameters:

  • responseId (string): Response ID
  • userIp (string, optional): User's IP address
  • userId (string, optional): User ID (if logged in)

Returns:

{\n  success: boolean; // True if upvote was found and removed\n}\n
"},{"location":"v2/backend/modules/responses/#responsesserviceverifyresponseid-token","title":"responsesService.verify(responseId, token)","text":"

Verify a response via email link.

Parameters:

  • responseId (string): Response ID
  • token (string): Verification token

Returns:

{\n  success: boolean;\n  campaignTitle?: string; // On success\n  reason?: string;        // On failure\n}\n

Failure Reasons:

  • \"Invalid verification link\" \u2014 Token doesn't match
  • \"Verification link has expired\" \u2014 More than 30 days old
"},{"location":"v2/backend/modules/responses/#responsesservicereportresponseid-token","title":"responsesService.report(responseId, token)","text":"

Report a response as invalid via email link.

Parameters:

  • responseId (string): Response ID
  • token (string): Verification token (same as verify link)

Returns:

{\n  success: boolean;\n  campaignTitle?: string;\n  reason?: string;\n}\n

Database Changes:

  • Sets status=REJECTED
  • Sets isVerified=false
  • Sets verifiedBy to \"Disputed by {email}\"
"},{"location":"v2/backend/modules/responses/#responsesservicefindallfilters-admin","title":"responsesService.findAll(filters) (Admin)","text":"

List all responses with admin filters.

Parameters:

{\n  page: number;\n  limit: number;\n  status?: ResponseStatus;\n  campaignId?: string;\n  search?: string;\n}\n

Returns:

{\n  responses: Response[];\n  pagination: Pagination;\n}\n
"},{"location":"v2/backend/modules/responses/#responsesserviceupdatestatusid-data-admin","title":"responsesService.updateStatus(id, data) (Admin)","text":"

Update response status.

Throws: AppError(404) if not found

"},{"location":"v2/backend/modules/responses/#responsesservicedeleteresponseid-admin","title":"responsesService.deleteResponse(id) (Admin)","text":"

Delete response permanently.

Throws: AppError(404) if not found

"},{"location":"v2/backend/modules/responses/#responsesserviceresendverificationid-admin","title":"responsesService.resendVerification(id) (Admin)","text":"

Resend verification email to representative.

Throws:

  • AppError(404) if response not found
  • AppError(400) if no representative email on record
"},{"location":"v2/backend/modules/responses/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/responses/#submit-response-schema","title":"Submit Response Schema","text":"
export const submitResponseSchema = z.object({\n  representativeName: z.string().min(1, 'Representative name is required'),\n  representativeLevel: z.nativeEnum(GovernmentLevel),\n  responseType: z.nativeEnum(ResponseType),\n  responseText: z.string().min(1, 'Response text is required'),\n  representativeTitle: z.string().optional(),\n  representativeEmail: z.string().email().optional(),\n  userComment: z.string().optional(),\n  submittedByName: z.string().optional(),\n  submittedByEmail: z.string().email().optional(),\n  isAnonymous: z.boolean().optional().default(false),\n  sendVerification: z.boolean().optional().default(false),\n});\n
"},{"location":"v2/backend/modules/responses/#list-public-responses-schema","title":"List Public Responses Schema","text":"
export const listPublicResponsesSchema = z.object({\n  page: z.coerce.number().int().positive().default(1),\n  limit: z.coerce.number().int().positive().max(100).default(20),\n  sort: z.enum(['recent', 'upvotes', 'verified']).optional().default('recent'),\n  level: z.nativeEnum(GovernmentLevel).optional(),\n});\n
"},{"location":"v2/backend/modules/responses/#list-admin-responses-schema","title":"List Admin Responses Schema","text":"
export const listAdminResponsesSchema = z.object({\n  page: z.coerce.number().int().positive().default(1),\n  limit: z.coerce.number().int().positive().max(100).default(20),\n  status: z.nativeEnum(ResponseStatus).optional(),\n  campaignId: z.string().optional(),\n  search: z.string().optional(),\n});\n
"},{"location":"v2/backend/modules/responses/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/responses/#public-submit-response-with-verification","title":"Public: Submit Response with Verification","text":"
import axios from 'axios';\n\nconst submitResponse = async (campaignSlug: string) => {\n  const { data } = await axios.post(\n    `/api/campaigns/${campaignSlug}/responses`,\n    {\n      representativeName: 'Chrystia Freeland',\n      representativeTitle: 'Deputy Prime Minister',\n      representativeLevel: 'FEDERAL',\n      representativeEmail: 'chrystia.freeland@parl.gc.ca',\n      responseType: 'EMAIL',\n      responseText: 'Thank you for writing. I appreciate your concerns regarding...',\n      userComment: 'Received this response 2 days after sending!',\n      submittedByName: 'Jane Doe',\n      submittedByEmail: 'jane@example.com',\n      isAnonymous: false,\n      sendVerification: true,\n    }\n  );\n\n  console.log(`Response submitted: ${data.id}`);\n  console.log(`Verification sent: ${data.verificationSent}`);\n\n  return data;\n};\n
"},{"location":"v2/backend/modules/responses/#public-upvote-response","title":"Public: Upvote Response","text":"
import axios from 'axios';\nimport { message } from 'antd';\n\nconst upvoteResponse = async (responseId: string) => {\n  try {\n    const { data } = await axios.post(`/api/responses/${responseId}/upvote`);\n\n    if (data.success) {\n      message.success('Upvoted!');\n    } else if (data.alreadyUpvoted) {\n      message.info('You already upvoted this response');\n    }\n  } catch (error) {\n    message.error('Failed to upvote');\n  }\n};\n
"},{"location":"v2/backend/modules/responses/#admin-approve-response","title":"Admin: Approve Response","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst approveResponse = async (responseId: string) => {\n  try {\n    await api.patch(`/api/responses/${responseId}/status`, {\n      status: 'APPROVED',\n    });\n\n    message.success('Response approved');\n  } catch (error) {\n    message.error('Failed to approve response');\n  }\n};\n
"},{"location":"v2/backend/modules/responses/#admin-resend-verification","title":"Admin: Resend Verification","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst resendVerification = async (responseId: string) => {\n  try {\n    await api.post(`/api/responses/${responseId}/resend-verification`);\n    message.success('Verification email resent');\n  } catch (error: any) {\n    if (error.response?.status === 400) {\n      message.error('No representative email on record');\n    } else {\n      message.error('Failed to resend verification');\n    }\n  }\n};\n
"},{"location":"v2/backend/modules/responses/#frontend-integration","title":"Frontend Integration","text":""},{"location":"v2/backend/modules/responses/#responsespage-admin","title":"ResponsesPage (Admin)","text":"

The ResponsesPage component (admin/src/pages/ResponsesPage.tsx) provides:

  • Response table with pagination
  • Status filter (PENDING, APPROVED, REJECTED)
  • Campaign filter
  • Search by name, response text, or submitter
  • Response detail drawer (shows full response + verification status)
  • Status update actions (approve, reject)
  • Resend verification button
  • Delete response action
  • Verification status badges (verified/unverified)
"},{"location":"v2/backend/modules/responses/#responsewallpage-public","title":"ResponseWallPage (Public)","text":"

The ResponseWallPage component (admin/src/pages/public/ResponseWallPage.tsx) provides:

  • Response card grid layout
  • Sort controls (recent, upvotes, verified)
  • Government level filter
  • Upvote buttons (IP-based for anonymous)
  • Verified badges
  • Submit response modal (opens from campaign page)
  • Response statistics (total, verified, upvotes)
  • Anonymous submission toggle
"},{"location":"v2/backend/modules/responses/#performance-considerations","title":"Performance Considerations","text":"

Upvote Constraints:

  • Unique constraints prevent duplicate upvotes at database level
  • No need for application-level deduplication logic
  • Concurrent upvote attempts return alreadyUpvoted: true

Indexing:

  • @@index([campaignId]) \u2014 Fast filtering by campaign
  • @@index([campaignSlug]) \u2014 Fast public lookup
  • @@index([status]) \u2014 Fast admin filtering

Pagination:

  • Max 100 results per page prevents excessive data transfer
  • Default 20 results balances performance and UX
"},{"location":"v2/backend/modules/responses/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/responses/#issue-verification-email-not-delivered","title":"Issue: Verification email not delivered","text":"

Cause: SMTP configuration issue or email blocked by spam filter

Solution:

  • Check EMAIL_TEST_MODE=true in .env (emails go to MailHog)
  • Verify SMTP credentials in site settings
  • Check spam folder on representative's email
  • Admin: Use \"Resend Verification\" button
"},{"location":"v2/backend/modules/responses/#issue-verification-link-expired","title":"Issue: Verification link expired","text":"

Cause: More than 30 days since verification email sent

Solution:

  • Admin: Use \"Resend Verification\" to generate new token
  • New verification email sent with fresh 30-day expiry
"},{"location":"v2/backend/modules/responses/#issue-cant-upvote-response","title":"Issue: Can't upvote response","text":"

Cause: Already upvoted, or response not approved

Solution:

  • Check alreadyUpvoted: true in response
  • Remove existing upvote first (DELETE endpoint)
  • Verify response has status=APPROVED
"},{"location":"v2/backend/modules/responses/#issue-response-not-appearing-on-public-wall","title":"Issue: Response not appearing on public wall","text":"

Cause: Status is PENDING or REJECTED

Solution:

  • Admin: Check response status in ResponsesPage
  • Admin: Approve response manually if legitimate
  • If verification email sent, representative must click verify link
"},{"location":"v2/backend/modules/responses/#related-documentation","title":"Related Documentation","text":"
  • Campaigns Module - Campaign configuration with response wall flag
  • Email Service - Verification email sending
  • Frontend: ResponsesPage - Admin moderation UI
  • Frontend: ResponseWallPage - Public response wall
  • Frontend: Public Campaign Page - Submit response integration
  • API Reference: Responses - Complete endpoint reference
  • Feature: Response Wall - Response wall feature guide
"},{"location":"v2/backend/modules/settings/","title":"Settings Module","text":""},{"location":"v2/backend/modules/settings/#overview","title":"Overview","text":"

The Settings module provides site-wide configuration management with a singleton pattern. It handles organization branding, theme customization, SMTP configuration, and feature toggles. The module implements field-level encryption for sensitive data (SMTP passwords) and provides separate endpoints for public and admin access.

Key Features:

  • Singleton pattern (one settings record per installation)
  • Field-level encryption (SMTP passwords encrypted at rest)
  • Public vs. admin endpoints (strips credentials from public responses)
  • SMTP configuration with test connection/send
  • Email service integration (auto-rebuild transporter on changes)
  • Organization branding (name, logo, favicon)
  • Theme customization (admin + public color schemes)
  • Feature toggles (Influence, Map, Newsletter, Landing Pages)
"},{"location":"v2/backend/modules/settings/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/settings/settings.routes.ts Express router with 5 endpoints api/src/modules/settings/settings.service.ts Settings business logic with encryption api/src/modules/settings/settings.schemas.ts Zod validation schema api/src/utils/crypto.ts AES-256-GCM encryption/decryption"},{"location":"v2/backend/modules/settings/#database-model","title":"Database Model","text":"
model SiteSettings {\n  id  String @id @default(cuid())\n\n  // Organization\n  organizationName         String   @default(\"Changemaker Lite\")\n  organizationShortName    String   @default(\"CM\")\n  organizationLogoUrl      String?\n  organizationFaviconUrl   String?\n\n  // Admin theme\n  adminColorPrimary        String   @default(\"#1890ff\")\n  adminColorBgBase         String   @default(\"#ffffff\")\n\n  // Public theme\n  publicColorPrimary       String   @default(\"#3498db\")\n  publicColorBgBase        String   @default(\"#0d1b2a\")\n  publicColorBgContainer   String   @default(\"#1b2838\")\n  publicHeaderGradient     String?\n\n  // Text\n  footerText               String   @default(\"\u00a9 2026 Changemaker Lite\")\n  loginSubtitle            String   @default(\"Political Infrastructure Platform\")\n\n  // Email branding\n  emailFromName            String   @default(\"Changemaker Lite\")\n\n  // SMTP configuration (encrypted at rest)\n  smtpHost                 String   @default(\"mailhog\")\n  smtpPort                 Int      @default(1025)\n  smtpUser                 String   @default(\"\")\n  smtpPass                 String   @default(\"\")  // Encrypted with ENCRYPTION_KEY\n  smtpFromAddress          String   @default(\"noreply@cmlite.org\")\n  smtpActiveProvider       String   @default(\"mailhog\")\n  emailTestMode            Boolean  @default(true)\n  testEmailRecipient       String?\n\n  // Feature toggles\n  enableInfluence          Boolean  @default(true)\n  enableMap                Boolean  @default(true)\n  enableNewsletter         Boolean  @default(false)\n  enableLandingPages       Boolean  @default(true)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n}\n

Singleton Pattern:

  • Only one SiteSettings record exists in the database
  • Auto-created with defaults on first access if missing
  • All updates modify the existing record

Encryption:

  • smtpPass encrypted at rest with AES-256-GCM
  • Uses ENCRYPTION_KEY environment variable (must NOT reuse JWT secrets)
  • Decrypted on read, re-encrypted on write
"},{"location":"v2/backend/modules/settings/#api-endpoints","title":"API Endpoints","text":"Method Path Auth Description GET /api/settings None Get public settings (strips SMTP credentials) GET /api/settings/admin SUPER_ADMIN Get full settings (includes SMTP credentials) PUT /api/settings SUPER_ADMIN Update settings POST /api/settings/email/test-connection SUPER_ADMIN Test SMTP connection POST /api/settings/email/test-send SUPER_ADMIN Send test email"},{"location":"v2/backend/modules/settings/#endpoint-details","title":"Endpoint Details","text":""},{"location":"v2/backend/modules/settings/#get-apisettings","title":"GET /api/settings","text":"

Get public-safe settings (no authentication required). Used by login page and public pages.

Security: Strips SMTP credentials before returning: - smtpHost - smtpPort - smtpUser - smtpPass - smtpFromAddress - testEmailRecipient

Example Request:

curl http://api.cmlite.org/api/settings\n

Response (200 OK):

{\n  \"id\": \"clx1234567890\",\n  \"organizationName\": \"Changemaker Lite\",\n  \"organizationShortName\": \"CM\",\n  \"organizationLogoUrl\": \"https://example.com/logo.png\",\n  \"organizationFaviconUrl\": \"https://example.com/favicon.ico\",\n  \"adminColorPrimary\": \"#1890ff\",\n  \"adminColorBgBase\": \"#ffffff\",\n  \"publicColorPrimary\": \"#3498db\",\n  \"publicColorBgBase\": \"#0d1b2a\",\n  \"publicColorBgContainer\": \"#1b2838\",\n  \"publicHeaderGradient\": \"linear-gradient(135deg, #667eea 0%, #764ba2 100%)\",\n  \"footerText\": \"\u00a9 2026 Changemaker Lite\",\n  \"loginSubtitle\": \"Political Infrastructure Platform\",\n  \"emailFromName\": \"Changemaker Lite\",\n  \"enableInfluence\": true,\n  \"enableMap\": true,\n  \"enableNewsletter\": false,\n  \"enableLandingPages\": true,\n  \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n  \"updatedAt\": \"2026-02-11T12:00:00.000Z\"\n}\n

Implementation:

router.get(\n  '/',\n  async (_req: Request, res: Response, next: NextFunction) => {\n    try {\n      const settings = await siteSettingsService.getPublic();\n      res.json(settings);\n    } catch (err) {\n      next(err);\n    }\n  }\n);\n

Service Logic:

async getPublic(): Promise<Omit<SiteSettings, typeof SENSITIVE_FIELDS[number]>> {\n  const settings = await this.get();\n  const result = { ...settings } as Record<string, unknown>;\n  for (const field of SENSITIVE_FIELDS) {\n    delete result[field];\n  }\n  return result as Omit<SiteSettings, typeof SENSITIVE_FIELDS[number]>;\n}\n
"},{"location":"v2/backend/modules/settings/#get-apisettingsadmin","title":"GET /api/settings/admin","text":"

Get full settings including SMTP credentials (SUPER_ADMIN only).

Request Headers:

Authorization: Bearer <access_token>\n

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  http://api.cmlite.org/api/settings/admin\n

Response (200 OK):

{\n  \"id\": \"clx1234567890\",\n  \"organizationName\": \"Changemaker Lite\",\n  \"organizationShortName\": \"CM\",\n  \"organizationLogoUrl\": \"https://example.com/logo.png\",\n  \"organizationFaviconUrl\": \"https://example.com/favicon.ico\",\n  \"adminColorPrimary\": \"#1890ff\",\n  \"adminColorBgBase\": \"#ffffff\",\n  \"publicColorPrimary\": \"#3498db\",\n  \"publicColorBgBase\": \"#0d1b2a\",\n  \"publicColorBgContainer\": \"#1b2838\",\n  \"publicHeaderGradient\": \"linear-gradient(135deg, #667eea 0%, #764ba2 100%)\",\n  \"footerText\": \"\u00a9 2026 Changemaker Lite\",\n  \"loginSubtitle\": \"Political Infrastructure Platform\",\n  \"emailFromName\": \"Changemaker Lite\",\n  \"smtpHost\": \"smtp.sendgrid.net\",\n  \"smtpPort\": 587,\n  \"smtpUser\": \"apikey\",\n  \"smtpPass\": \"SG.xxxxxxxxxxxx\",\n  \"smtpFromAddress\": \"noreply@cmlite.org\",\n  \"smtpActiveProvider\": \"production\",\n  \"emailTestMode\": false,\n  \"testEmailRecipient\": \"admin@example.com\",\n  \"enableInfluence\": true,\n  \"enableMap\": true,\n  \"enableNewsletter\": false,\n  \"enableLandingPages\": true,\n  \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n  \"updatedAt\": \"2026-02-11T12:00:00.000Z\"\n}\n

Error Responses:

  • 401 Unauthorized: Missing or invalid access token
  • 403 Forbidden: Non-SUPER_ADMIN user

Implementation:

router.get(\n  '/admin',\n  authenticate,\n  requireRole(UserRole.SUPER_ADMIN),\n  async (_req: Request, res: Response, next: NextFunction) => {\n    try {\n      const settings = await siteSettingsService.get();\n      res.json(settings);\n    } catch (err) {\n      next(err);\n    }\n  }\n);\n

Decryption:

/** Full settings with encrypted fields decrypted (admin use) */\nasync get() {\n  let settings = await prisma.siteSettings.findFirst();\n  if (!settings) {\n    settings = await prisma.siteSettings.create({ data: {} });\n  }\n  return decryptSettings(settings);\n}\n\nfunction decryptSettings(settings: SiteSettings): SiteSettings {\n  for (const field of ENCRYPTED_FIELDS) {\n    const value = settings[field];\n    if (typeof value === 'string' && value) {\n      (settings as Record<string, unknown>)[field] = decrypt(value);\n    }\n  }\n  return settings;\n}\n
"},{"location":"v2/backend/modules/settings/#put-apisettings","title":"PUT /api/settings","text":"

Update site settings (SUPER_ADMIN only). Automatically rebuilds email transporter if SMTP fields change.

Request Headers:

Authorization: Bearer <access_token>\nContent-Type: application/json\n

Request Body (Partial Update):

{\n  \"organizationName\": \"My Campaign\",\n  \"organizationShortName\": \"MC\",\n  \"organizationLogoUrl\": \"https://example.com/new-logo.png\",\n  \"publicColorPrimary\": \"#ff6b6b\",\n  \"smtpHost\": \"smtp.sendgrid.net\",\n  \"smtpPort\": 587,\n  \"smtpUser\": \"apikey\",\n  \"smtpPass\": \"SG.new_api_key\",\n  \"smtpFromAddress\": \"hello@mycampaign.org\",\n  \"smtpActiveProvider\": \"production\",\n  \"emailTestMode\": false,\n  \"enableNewsletter\": true\n}\n

All fields are optional (partial updates supported).

Response (200 OK):

Returns updated settings (same format as GET /api/settings/admin).

Error Responses:

  • 401 Unauthorized: Missing or invalid access token
  • 403 Forbidden: Non-SUPER_ADMIN user
  • 400 Bad Request: Invalid field values

Implementation:

router.put(\n  '/',\n  authenticate,\n  requireRole(UserRole.SUPER_ADMIN),\n  validate(updateSiteSettingsSchema),\n  async (req: Request, res: Response, next: NextFunction) => {\n    try {\n      const settings = await siteSettingsService.update(req.body);\n\n      // If SMTP-related fields were updated, rebuild the transporter\n      const smtpFields = ['smtpHost', 'smtpPort', 'smtpUser', 'smtpPass', 'smtpFromAddress', 'smtpActiveProvider', 'emailTestMode', 'testEmailRecipient'];\n      const hasSmtpChanges = smtpFields.some((f) => f in req.body);\n      if (hasSmtpChanges) {\n        await emailService.rebuildTransporter();\n      }\n\n      res.json(settings);\n    } catch (err) {\n      next(err);\n    }\n  }\n);\n

Encryption on Write:

async update(data: UpdateSiteSettingsInput) {\n  // Encrypt sensitive fields before writing to DB\n  const toWrite = { ...data };\n  for (const field of ENCRYPTED_FIELDS) {\n    if (field in toWrite && typeof toWrite[field] === 'string' && toWrite[field]) {\n      (toWrite as Record<string, unknown>)[field] = encrypt(toWrite[field] as string);\n    }\n  }\n\n  const existing = await prisma.siteSettings.findFirst();\n  let settings: SiteSettings;\n  if (existing) {\n    settings = await prisma.siteSettings.update({\n      where: { id: existing.id },\n      data: toWrite,\n    });\n  } else {\n    settings = await prisma.siteSettings.create({ data: toWrite });\n  }\n  return decryptSettings(settings);\n}\n

Email Transporter Rebuild:

When SMTP settings change, the email service transporter is automatically rebuilt:

const smtpFields = ['smtpHost', 'smtpPort', 'smtpUser', 'smtpPass', 'smtpFromAddress', 'smtpActiveProvider', 'emailTestMode', 'testEmailRecipient'];\nconst hasSmtpChanges = smtpFields.some((f) => f in req.body);\nif (hasSmtpChanges) {\n  await emailService.rebuildTransporter();\n}\n
"},{"location":"v2/backend/modules/settings/#post-apisettingsemailtest-connection","title":"POST /api/settings/email/test-connection","text":"

Test SMTP connection (SUPER_ADMIN only).

Request Headers:

Authorization: Bearer <access_token>\n

Example Request:

curl -X POST \\\n  -H \"Authorization: Bearer <token>\" \\\n  http://api.cmlite.org/api/settings/email/test-connection\n

Response (200 OK):

{\n  \"success\": true,\n  \"message\": \"SMTP connection verified\"\n}\n

Response (Failure):

{\n  \"success\": false,\n  \"message\": \"SMTP connection failed\"\n}\n

Implementation:

router.post(\n  '/email/test-connection',\n  authenticate,\n  requireRole(UserRole.SUPER_ADMIN),\n  async (_req: Request, res: Response, next: NextFunction) => {\n    try {\n      const success = await emailService.testConnection();\n      res.json({ success, message: success ? 'SMTP connection verified' : 'SMTP connection failed' });\n    } catch (err) {\n      next(err);\n    }\n  }\n);\n
"},{"location":"v2/backend/modules/settings/#post-apisettingsemailtest-send","title":"POST /api/settings/email/test-send","text":"

Send test email to verify SMTP configuration (SUPER_ADMIN only).

Request Headers:

Authorization: Bearer <access_token>\nContent-Type: application/json\n

Request Body (Optional):

{\n  \"to\": \"test@example.com\"\n}\n

If to is not provided, uses testEmailRecipient from settings or defaults to admin@cmlite.org.

Example Request:

curl -X POST \\\n  -H \"Authorization: Bearer <token>\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"to\":\"test@example.com\"}' \\\n  http://api.cmlite.org/api/settings/email/test-send\n

Response (200 OK):

{\n  \"success\": true,\n  \"messageId\": \"<20260211120000.1.abcd1234@cmlite.org>\",\n  \"testMode\": false,\n  \"recipient\": \"test@example.com\"\n}\n

Response (Test Mode):

{\n  \"success\": true,\n  \"messageId\": \"test-mode-1234567890\",\n  \"testMode\": true,\n  \"recipient\": \"test@example.com\"\n}\n

Implementation:

router.post(\n  '/email/test-send',\n  authenticate,\n  requireRole(UserRole.SUPER_ADMIN),\n  async (req: Request, res: Response, next: NextFunction) => {\n    try {\n      const { to } = req.body as { to?: string };\n      const settings = await siteSettingsService.get();\n      const recipient = to || settings.testEmailRecipient || 'admin@cmlite.org';\n\n      const result = await emailService.sendEmail({\n        to: recipient,\n        subject: 'Changemaker Lite \u2014 Test Email',\n        html: `<h2>SMTP Test Successful</h2><p>This email confirms that your SMTP configuration is working correctly.</p><p>Sent at: ${new Date().toISOString()}</p>`,\n        text: `SMTP Test Successful\\n\\nThis email confirms that your SMTP configuration is working correctly.\\n\\nSent at: ${new Date().toISOString()}`,\n      });\n\n      res.json({\n        success: result.success,\n        messageId: result.messageId,\n        testMode: result.testMode,\n        recipient,\n      });\n    } catch (err) {\n      next(err);\n    }\n  }\n);\n

Test Mode:

If emailTestMode is true, emails are sent to MailHog instead of actual SMTP server:

  • Development: MailHog captures emails at http://localhost:8025
  • Production: Should set emailTestMode: false to use real SMTP
"},{"location":"v2/backend/modules/settings/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/settings/#sitesettingsserviceget","title":"siteSettingsService.get()","text":"

Purpose: Get full settings with decrypted SMTP password (admin use).

Auto-Creation:

let settings = await prisma.siteSettings.findFirst();\nif (!settings) {\n  settings = await prisma.siteSettings.create({ data: {} });\n}\nreturn decryptSettings(settings);\n
"},{"location":"v2/backend/modules/settings/#sitesettingsservicegetpublic","title":"siteSettingsService.getPublic()","text":"

Purpose: Get settings without sensitive SMTP fields (public use).

Stripped Fields:

  • smtpHost
  • smtpPort
  • smtpUser
  • smtpPass
  • smtpFromAddress
  • testEmailRecipient
"},{"location":"v2/backend/modules/settings/#sitesettingsserviceupdatedata","title":"siteSettingsService.update(data)","text":"

Purpose: Update settings with encryption for sensitive fields.

Encryption:

const toWrite = { ...data };\nfor (const field of ENCRYPTED_FIELDS) {\n  if (field in toWrite && typeof toWrite[field] === 'string' && toWrite[field]) {\n    (toWrite as Record<string, unknown>)[field] = encrypt(toWrite[field] as string);\n  }\n}\n

Upsert Logic:

const existing = await prisma.siteSettings.findFirst();\nlet settings: SiteSettings;\nif (existing) {\n  settings = await prisma.siteSettings.update({\n    where: { id: existing.id },\n    data: toWrite,\n  });\n} else {\n  settings = await prisma.siteSettings.create({ data: toWrite });\n}\nreturn decryptSettings(settings);\n
"},{"location":"v2/backend/modules/settings/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/settings/#frontend-load-public-settings","title":"Frontend: Load Public Settings","text":"
import axios from 'axios';\n\nconst loadSettings = async () => {\n  const { data } = await axios.get('/api/settings');\n\n  // Apply theme to Ant Design ConfigProvider\n  document.title = data.organizationName;\n  if (data.organizationFaviconUrl) {\n    const link = document.querySelector(\"link[rel='icon']\") as HTMLLinkElement;\n    if (link) link.href = data.organizationFaviconUrl;\n  }\n\n  return data;\n};\n
"},{"location":"v2/backend/modules/settings/#admin-update-settings","title":"Admin: Update Settings","text":"
import { api } from '@/lib/api';\n\nconst updateSettings = async (updates: Partial<SiteSettings>) => {\n  const { data } = await api.put('/api/settings', updates);\n\n  message.success('Settings updated successfully');\n  return data;\n};\n\n// Usage\nawait updateSettings({\n  organizationName: 'My Campaign',\n  publicColorPrimary: '#ff6b6b',\n  enableNewsletter: true,\n});\n
"},{"location":"v2/backend/modules/settings/#admin-test-smtp-connection","title":"Admin: Test SMTP Connection","text":"
import { api } from '@/lib/api';\n\nconst testSmtpConnection = async () => {\n  try {\n    const { data } = await api.post('/api/settings/email/test-connection');\n\n    if (data.success) {\n      message.success('SMTP connection verified');\n    } else {\n      message.error('SMTP connection failed');\n    }\n\n    return data.success;\n  } catch (error) {\n    message.error('Failed to test SMTP connection');\n    return false;\n  }\n};\n
"},{"location":"v2/backend/modules/settings/#admin-send-test-email","title":"Admin: Send Test Email","text":"
import { api } from '@/lib/api';\n\nconst sendTestEmail = async (recipient?: string) => {\n  try {\n    const { data } = await api.post('/api/settings/email/test-send', {\n      to: recipient,\n    });\n\n    if (data.success) {\n      if (data.testMode) {\n        message.success(`Test email sent (MailHog mode) to ${data.recipient}`);\n      } else {\n        message.success(`Test email sent to ${data.recipient}`);\n      }\n    }\n\n    return data;\n  } catch (error) {\n    message.error('Failed to send test email');\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/settings/#validation-schema","title":"Validation Schema","text":"
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Must be a hex color (e.g. #ff00ff)');\n\nexport const updateSiteSettingsSchema = z.object({\n  // Organization\n  organizationName: z.string().min(1).max(100).optional(),\n  organizationShortName: z.string().min(1).max(10).optional(),\n  organizationLogoUrl: z.string().url().nullable().optional().or(z.literal('')),\n  organizationFaviconUrl: z.string().url().nullable().optional().or(z.literal('')),\n\n  // Admin theme\n  adminColorPrimary: hexColor.optional(),\n  adminColorBgBase: hexColor.optional(),\n\n  // Public theme\n  publicColorPrimary: hexColor.optional(),\n  publicColorBgBase: hexColor.optional(),\n  publicColorBgContainer: hexColor.optional(),\n  publicHeaderGradient: z.string().max(500).optional(),\n\n  // Text\n  footerText: z.string().max(200).optional(),\n  loginSubtitle: z.string().max(50).optional(),\n\n  // Email branding\n  emailFromName: z.string().min(1).max(100).optional(),\n\n  // SMTP configuration\n  smtpHost: z.string().max(255).optional(),\n  smtpPort: z.number().int().min(0).max(65535).optional(),\n  smtpUser: z.string().max(255).optional(),\n  smtpPass: z.string().max(500).optional(),\n  smtpFromAddress: z.string().max(255).optional(),\n  smtpActiveProvider: z.enum(['mailhog', 'production']).optional(),\n  emailTestMode: z.boolean().optional(),\n  testEmailRecipient: z.string().max(255).optional(),\n\n  // Feature toggles\n  enableInfluence: z.boolean().optional(),\n  enableMap: z.boolean().optional(),\n  enableNewsletter: z.boolean().optional(),\n  enableLandingPages: z.boolean().optional(),\n});\n
"},{"location":"v2/backend/modules/settings/#encryption","title":"Encryption","text":""},{"location":"v2/backend/modules/settings/#aes-256-gcm-encryption","title":"AES-256-GCM Encryption","text":"

The smtpPass field is encrypted at rest using AES-256-GCM (authenticated encryption).

Environment Configuration:

ENCRYPTION_KEY=<32-byte-hex>  # Must NOT reuse JWT secrets\n

Generate Encryption Key:

openssl rand -hex 32\n

Encryption Utility:

import crypto from 'crypto';\n\nconst ALGORITHM = 'aes-256-gcm';\nconst IV_LENGTH = 12;\nconst TAG_LENGTH = 16;\nconst SALT_LENGTH = 32;\nconst KEY_LENGTH = 32;\n\nexport function encrypt(plaintext: string): string {\n  const iv = crypto.randomBytes(IV_LENGTH);\n  const salt = crypto.randomBytes(SALT_LENGTH);\n\n  const key = crypto.pbkdf2Sync(env.ENCRYPTION_KEY, salt, 100000, KEY_LENGTH, 'sha256');\n  const cipher = crypto.createCipheriv(ALGORITHM, key, iv);\n\n  let encrypted = cipher.update(plaintext, 'utf8', 'base64');\n  encrypted += cipher.final('base64');\n\n  const tag = cipher.getAuthTag();\n\n  // Format: iv:salt:tag:ciphertext\n  return `${iv.toString('base64')}:${salt.toString('base64')}:${tag.toString('base64')}:${encrypted}`;\n}\n\nexport function decrypt(ciphertext: string): string {\n  const parts = ciphertext.split(':');\n  if (parts.length !== 4) throw new Error('Invalid ciphertext format');\n\n  const [ivB64, saltB64, tagB64, encrypted] = parts;\n  const iv = Buffer.from(ivB64, 'base64');\n  const salt = Buffer.from(saltB64, 'base64');\n  const tag = Buffer.from(tagB64, 'base64');\n\n  const key = crypto.pbkdf2Sync(env.ENCRYPTION_KEY, salt, 100000, KEY_LENGTH, 'sha256');\n  const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);\n\n  decipher.setAuthTag(tag);\n\n  let decrypted = decipher.update(encrypted, 'base64', 'utf8');\n  decrypted += decipher.final('utf8');\n\n  return decrypted;\n}\n
"},{"location":"v2/backend/modules/settings/#feature-toggles","title":"Feature Toggles","text":"Toggle Default Description enableInfluence true Advocacy campaigns + response wall enableMap true Location mapping + canvassing enableNewsletter false Listmonk integration enableLandingPages true GrapesJS page builder

Frontend Usage:

const settings = await loadSettings();\n\nif (settings.enableInfluence) {\n  // Show Influence menu items\n}\n\nif (settings.enableMap) {\n  // Show Map menu items\n}\n
"},{"location":"v2/backend/modules/settings/#environment-configuration","title":"Environment Configuration","text":"

Required environment variables:

# Encryption (for smtpPass field)\nENCRYPTION_KEY=<32-byte-hex>  # Must differ from JWT secrets\n\n# Database\nDATABASE_URL=postgresql://user:password@localhost:5432/changemaker_v2\n
"},{"location":"v2/backend/modules/settings/#related-documentation","title":"Related Documentation","text":"
  • Email Service - SMTP email sending
  • Crypto Utilities - Encryption/decryption
  • Frontend: SettingsPage - Settings management UI
  • API Reference: Settings - Complete endpoint reference
  • User Guide: Admin - Configuring site settings
"},{"location":"v2/backend/modules/shifts/","title":"Shifts Module","text":""},{"location":"v2/backend/modules/shifts/#overview","title":"Overview","text":"

The Shifts module manages volunteer shift scheduling with public signup capabilities. It provides comprehensive CRUD operations for shift management, volunteer signup tracking, and automatic status updates based on capacity. The module includes three separate routers for admin management, authenticated volunteer portal access, and public signup flows.

Key Features:

  • Full shift CRUD with pagination, search, and filtering
  • Automatic status management (OPEN \u2192 FULL based on capacity)
  • Cut association for canvassing shifts (optional)
  • Three signup sources: admin-added, authenticated user, public
  • Temporary user creation for public signups (auto-expires after shift date)
  • Email confirmation system with readable passwords for new users
  • Capacity tracking (currentVolunteers / maxVolunteers)
  • Cancellation system with capacity recalculation
  • Email all volunteers functionality
  • Rate limiting on signup endpoints (5/min per IP)
  • Prometheus metrics tracking (cm_shift_signups_total)
"},{"location":"v2/backend/modules/shifts/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/map/shifts/shifts.routes.ts 3 routers: admin, volunteer, public (242 lines) api/src/modules/map/shifts/shifts.service.ts Shift business logic with signup flows (754 lines) api/src/modules/map/shifts/shifts.schemas.ts Zod validation schemas (55 lines)"},{"location":"v2/backend/modules/shifts/#database-models","title":"Database Models","text":"
model Shift {\n  id                String      @id @default(cuid())\n  title             String\n  description       String?     @db.Text\n  date              DateTime    @db.Date\n  startTime         String      // HH:MM format\n  endTime           String      // HH:MM format\n  location          String?\n  maxVolunteers     Int\n  currentVolunteers Int         @default(0)\n  status            ShiftStatus @default(OPEN)\n  isPublic          Boolean     @default(false)\n  cutId             String?\n  cut               Cut?        @relation(fields: [cutId], references: [id], onDelete: SetNull)\n  createdBy         String?\n  createdAt         DateTime    @default(now())\n  updatedAt         DateTime    @updatedAt\n\n  signups           ShiftSignup[]\n  canvassVisits     CanvassVisit[]\n  canvassSessions   CanvassSession[]\n\n  @@index([cutId])\n  @@map(\"shifts\")\n}\n\nenum ShiftStatus {\n  OPEN       // Accepting signups\n  FULL       // Max capacity reached\n  CANCELLED  // Shift cancelled\n}\n\nmodel ShiftSignup {\n  id           String       @id @default(cuid())\n  shiftId      String\n  shift        Shift        @relation(fields: [shiftId], references: [id], onDelete: Cascade)\n  shiftTitle   String?\n  userId       String?\n  user         User?        @relation(fields: [userId], references: [id], onDelete: SetNull)\n  userEmail    String\n  userName     String?\n  userPhone    String?\n  signupDate   DateTime     @default(now())\n  status       SignupStatus @default(CONFIRMED)\n  signupSource SignupSource @default(AUTHENTICATED)\n\n  @@unique([shiftId, userEmail])\n  @@index([shiftId])\n  @@map(\"shift_signups\")\n}\n\nenum SignupStatus {\n  CONFIRMED  // Active signup\n  CANCELLED  // Cancelled (can be re-activated)\n}\n\nenum SignupSource {\n  AUTHENTICATED  // Logged-in user signup\n  PUBLIC         // Anonymous public signup\n  ADMIN          // Added by admin\n}\n

Key Relationships:

  • Shift \u2192 ShiftSignup: One-to-many (cascade delete)
  • Shift \u2192 Cut: Optional many-to-one (cut assignment for canvassing, set null on delete)
  • Shift \u2192 CanvassSession/CanvassVisit: One-to-many (canvassing data linked to shifts)
  • ShiftSignup \u2192 User: Optional many-to-one (set null on user delete, preserves signup record)

Unique Constraints:

  • [shiftId, userEmail] \u2014 One signup per email per shift (allows re-activation of cancelled signups)
"},{"location":"v2/backend/modules/shifts/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/shifts/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"Method Path Auth Description GET /api/map/shifts MAP_ADMIN List paginated shifts GET /api/map/shifts/stats MAP_ADMIN Shift statistics GET /api/map/shifts/:id MAP_ADMIN Get shift with signups POST /api/map/shifts MAP_ADMIN Create shift PUT /api/map/shifts/:id MAP_ADMIN Update shift DELETE /api/map/shifts/:id MAP_ADMIN Delete shift POST /api/map/shifts/:id/signups MAP_ADMIN Add volunteer signup DELETE /api/map/shifts/:id/signups/:signupId MAP_ADMIN Remove signup POST /api/map/shifts/:id/email-details MAP_ADMIN Email all volunteers

Admin Roles: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/backend/modules/shifts/#volunteer-endpoints-authentication-required","title":"Volunteer Endpoints (Authentication Required)","text":"Method Path Auth Description GET /api/map/shifts/volunteer/upcoming Any logged-in Upcoming shifts with signup status GET /api/map/shifts/volunteer/my-signups Any logged-in Own confirmed signups POST /api/map/shifts/volunteer/:id/signup Any logged-in Sign up for shift DELETE /api/map/shifts/volunteer/:id/signup Any logged-in Cancel own signup"},{"location":"v2/backend/modules/shifts/#public-endpoints-no-authentication","title":"Public Endpoints (No Authentication)","text":"Method Path Auth Description GET /api/map/shifts/public None List public upcoming shifts POST /api/map/shifts/public/:id/signup None Public signup (creates temp user if needed)"},{"location":"v2/backend/modules/shifts/#admin-endpoint-details","title":"Admin Endpoint Details","text":""},{"location":"v2/backend/modules/shifts/#get-apimapshifts","title":"GET /api/map/shifts","text":"

List shifts with pagination, search, and filtering.

Query Parameters:

Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) search string No - Search title or location status ShiftStatus No - Filter by status upcoming boolean No - Filter to shifts with date >= today sortBy enum No date Sort field: date, createdAt, title sortOrder enum No desc Sort direction: asc, desc

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/shifts?upcoming=true&status=OPEN&page=1&limit=10\"\n

Response (200 OK):

{\n  \"shifts\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"title\": \"Door Knocking \u2014 Ward 5\",\n      \"description\": \"Canvassing residential areas in Ward 5. Meet at campaign office.\",\n      \"date\": \"2026-02-15T00:00:00.000Z\",\n      \"startTime\": \"10:00\",\n      \"endTime\": \"14:00\",\n      \"location\": \"123 Main St (Campaign Office)\",\n      \"maxVolunteers\": 15,\n      \"currentVolunteers\": 8,\n      \"status\": \"OPEN\",\n      \"isPublic\": true,\n      \"cutId\": \"clx0987654321\",\n      \"cut\": {\n        \"id\": \"clx0987654321\",\n        \"name\": \"Ward 5 Residential\"\n      },\n      \"createdBy\": \"clx1111111111\",\n      \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n      \"updatedAt\": \"2026-02-11T14:30:00.000Z\",\n      \"_count\": {\n        \"signups\": 8\n      }\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 10,\n    \"total\": 23,\n    \"totalPages\": 3\n  }\n}\n
"},{"location":"v2/backend/modules/shifts/#get-apimapshiftsstats","title":"GET /api/map/shifts/stats","text":"

Get shift statistics.

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/shifts/stats\"\n

Response (200 OK):

{\n  \"total\": 45,\n  \"open\": 12,\n  \"full\": 3,\n  \"cancelled\": 2,\n  \"upcoming\": 15,\n  \"totalSignups\": 287\n}\n
"},{"location":"v2/backend/modules/shifts/#get-apimapshiftsid","title":"GET /api/map/shifts/:id","text":"

Get single shift with signups list.

Path Parameters:

  • id (string): Shift ID

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/shifts/clx1234567890\"\n

Response (200 OK):

{\n  \"id\": \"clx1234567890\",\n  \"title\": \"Door Knocking \u2014 Ward 5\",\n  \"description\": \"Canvassing residential areas in Ward 5\",\n  \"date\": \"2026-02-15T00:00:00.000Z\",\n  \"startTime\": \"10:00\",\n  \"endTime\": \"14:00\",\n  \"location\": \"123 Main St\",\n  \"maxVolunteers\": 15,\n  \"currentVolunteers\": 8,\n  \"status\": \"OPEN\",\n  \"isPublic\": true,\n  \"cutId\": \"clx0987654321\",\n  \"cut\": {\n    \"id\": \"clx0987654321\",\n    \"name\": \"Ward 5 Residential\"\n  },\n  \"signups\": [\n    {\n      \"id\": \"clx2222222222\",\n      \"shiftId\": \"clx1234567890\",\n      \"shiftTitle\": \"Door Knocking \u2014 Ward 5\",\n      \"userId\": \"clx3333333333\",\n      \"user\": {\n        \"id\": \"clx3333333333\",\n        \"email\": \"volunteer@example.com\",\n        \"name\": \"Jane Volunteer\",\n        \"phone\": \"+1234567890\"\n      },\n      \"userEmail\": \"volunteer@example.com\",\n      \"userName\": \"Jane Volunteer\",\n      \"userPhone\": \"+1234567890\",\n      \"signupDate\": \"2026-02-05T10:30:00.000Z\",\n      \"status\": \"CONFIRMED\",\n      \"signupSource\": \"PUBLIC\"\n    }\n  ],\n  \"_count\": {\n    \"signups\": 8\n  }\n}\n

Error Responses:

  • 404 Not Found: Shift not found
"},{"location":"v2/backend/modules/shifts/#post-apimapshifts","title":"POST /api/map/shifts","text":"

Create new shift.

Request Body:

{\n  \"title\": \"Door Knocking \u2014 Ward 5\",\n  \"description\": \"Canvassing residential areas in Ward 5. Meet at campaign office.\",\n  \"date\": \"2026-02-15\",\n  \"startTime\": \"10:00\",\n  \"endTime\": \"14:00\",\n  \"location\": \"123 Main St (Campaign Office)\",\n  \"maxVolunteers\": 15,\n  \"isPublic\": true,\n  \"cutId\": \"clx0987654321\"\n}\n

Response (201 Created):

Returns created shift object (same format as GET).

Validation:

  • date must be YYYY-MM-DD format
  • startTime, endTime must be HH:MM format
  • maxVolunteers must be >= 1
  • cutId is optional (for non-canvassing shifts)
"},{"location":"v2/backend/modules/shifts/#put-apimapshiftsid","title":"PUT /api/map/shifts/:id","text":"

Update shift. Auto-updates status if capacity changes.

Request Body (Partial):

{\n  \"maxVolunteers\": 20,\n  \"status\": \"OPEN\"\n}\n

Response (200 OK):

Returns updated shift object.

Auto-Status Logic:

// When maxVolunteers is updated:\nif (currentVolunteers >= newMaxVolunteers && status === OPEN) {\n  status = FULL;\n} else if (currentVolunteers < newMaxVolunteers && status === FULL) {\n  status = OPEN;\n}\n
"},{"location":"v2/backend/modules/shifts/#delete-apimapshiftsid","title":"DELETE /api/map/shifts/:id","text":"

Delete shift. Cascade deletes all signups.

Response (204 No Content):

No response body.

"},{"location":"v2/backend/modules/shifts/#post-apimapshiftsidsignups","title":"POST /api/map/shifts/:id/signups","text":"

Admin add volunteer signup.

Request Body:

{\n  \"userEmail\": \"volunteer@example.com\",\n  \"userName\": \"Jane Volunteer\"\n}\n

Response (201 Created):

{\n  \"id\": \"clx2222222222\",\n  \"shiftId\": \"clx1234567890\",\n  \"shiftTitle\": \"Door Knocking \u2014 Ward 5\",\n  \"userId\": \"clx3333333333\",\n  \"userEmail\": \"volunteer@example.com\",\n  \"userName\": \"Jane Volunteer\",\n  \"userPhone\": null,\n  \"signupDate\": \"2026-02-11T15:00:00.000Z\",\n  \"status\": \"CONFIRMED\",\n  \"signupSource\": \"ADMIN\"\n}\n

Behavior:

  • Looks up user by email (if exists, links via userId)
  • If signup was previously cancelled, re-activates it
  • Increments currentVolunteers
  • Auto-updates shift status to FULL if capacity reached
  • Transaction ensures atomicity

Error Responses:

  • 400 Bad Request: Shift is full
  • 404 Not Found: Shift not found
  • 409 Conflict: Volunteer already signed up
"},{"location":"v2/backend/modules/shifts/#delete-apimapshiftsidsignupssignupid","title":"DELETE /api/map/shifts/:id/signups/:signupId","text":"

Admin remove volunteer signup.

Path Parameters:

  • id (string): Shift ID
  • signupId (string): Signup ID

Response (204 No Content):

No response body.

Behavior:

  • Updates signup status to CANCELLED (does not delete record)
  • Decrements currentVolunteers
  • Auto-updates shift status to OPEN
  • Transaction ensures atomicity

Error Responses:

  • 400 Bad Request: Signup already cancelled
  • 404 Not Found: Signup not found
"},{"location":"v2/backend/modules/shifts/#post-apimapshiftsidemail-details","title":"POST /api/map/shifts/:id/email-details","text":"

Email shift details to all confirmed volunteers.

Example Request:

curl -X POST \\\n  -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/shifts/clx1234567890/email-details\"\n

Response (200 OK):

{\n  \"sent\": 8,\n  \"failed\": 0\n}\n

Email Template:

Uses shift-details.html and shift-details.txt templates with variables:

  • USER_NAME \u2014 Volunteer name
  • SHIFT_TITLE \u2014 Shift title
  • SHIFT_DATE \u2014 Formatted date
  • SHIFT_START_TIME \u2014 Start time
  • SHIFT_END_TIME \u2014 End time
  • SHIFT_LOCATION \u2014 Location
  • SHIFT_DESCRIPTION \u2014 Description
  • CURRENT_VOLUNTEERS \u2014 Current signup count
  • MAX_VOLUNTEERS \u2014 Max capacity
  • SHIFT_STATUS \u2014 Status
  • ORGANIZATION_NAME \u2014 Site settings org name
"},{"location":"v2/backend/modules/shifts/#volunteer-endpoint-details","title":"Volunteer Endpoint Details","text":""},{"location":"v2/backend/modules/shifts/#get-apimapshiftsvolunteerupcoming","title":"GET /api/map/shifts/volunteer/upcoming","text":"

Get upcoming public shifts with signup status for current user.

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/shifts/volunteer/upcoming\"\n

Response (200 OK):

[\n  {\n    \"id\": \"clx1234567890\",\n    \"title\": \"Door Knocking \u2014 Ward 5\",\n    \"description\": \"Canvassing residential areas\",\n    \"date\": \"2026-02-15T00:00:00.000Z\",\n    \"startTime\": \"10:00\",\n    \"endTime\": \"14:00\",\n    \"location\": \"123 Main St\",\n    \"maxVolunteers\": 15,\n    \"currentVolunteers\": 8,\n    \"status\": \"OPEN\",\n    \"isSignedUp\": true\n  },\n  {\n    \"id\": \"clx9876543210\",\n    \"title\": \"Phone Banking\",\n    \"description\": \"Call voters for GOTV\",\n    \"date\": \"2026-02-16T00:00:00.000Z\",\n    \"startTime\": \"18:00\",\n    \"endTime\": \"20:00\",\n    \"location\": \"Virtual (Zoom)\",\n    \"maxVolunteers\": 25,\n    \"currentVolunteers\": 12,\n    \"status\": \"OPEN\",\n    \"isSignedUp\": false\n  }\n]\n

Filtering:

  • Only public shifts (isPublic: true)
  • Only non-cancelled shifts
  • Only shifts with date >= today
  • Sorted by date ASC, then startTime ASC
"},{"location":"v2/backend/modules/shifts/#get-apimapshiftsvolunteermy-signups","title":"GET /api/map/shifts/volunteer/my-signups","text":"

Get current user's confirmed signups for upcoming shifts.

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/shifts/volunteer/my-signups\"\n

Response (200 OK):

[\n  {\n    \"id\": \"clx2222222222\",\n    \"shiftId\": \"clx1234567890\",\n    \"shiftTitle\": \"Door Knocking \u2014 Ward 5\",\n    \"userId\": \"clx3333333333\",\n    \"userEmail\": \"volunteer@example.com\",\n    \"userName\": \"Jane Volunteer\",\n    \"userPhone\": \"+1234567890\",\n    \"signupDate\": \"2026-02-05T10:30:00.000Z\",\n    \"status\": \"CONFIRMED\",\n    \"signupSource\": \"PUBLIC\",\n    \"shift\": {\n      \"id\": \"clx1234567890\",\n      \"title\": \"Door Knocking \u2014 Ward 5\",\n      \"description\": \"Canvassing residential areas\",\n      \"date\": \"2026-02-15T00:00:00.000Z\",\n      \"startTime\": \"10:00\",\n      \"endTime\": \"14:00\",\n      \"location\": \"123 Main St\",\n      \"maxVolunteers\": 15,\n      \"currentVolunteers\": 8,\n      \"status\": \"OPEN\"\n    }\n  }\n]\n

Filtering:

  • Signups by current user's email
  • Only confirmed signups
  • Only shifts with date >= today
  • Only non-cancelled shifts
  • Sorted by shift date ASC
"},{"location":"v2/backend/modules/shifts/#post-apimapshiftsvolunteeridsignup","title":"POST /api/map/shifts/volunteer/:id/signup","text":"

Authenticated user signs up for shift.

Path Parameters:

  • id (string): Shift ID

Rate Limiting:

5 requests/min per IP (shiftSignupRateLimit middleware)

Example Request:

curl -X POST \\\n  -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/shifts/volunteer/clx1234567890/signup\"\n

Response (201 Created):

{\n  \"id\": \"clx2222222222\",\n  \"shiftId\": \"clx1234567890\",\n  \"shiftTitle\": \"Door Knocking \u2014 Ward 5\",\n  \"userId\": \"clx3333333333\",\n  \"userEmail\": \"volunteer@example.com\",\n  \"userName\": \"Jane Volunteer\",\n  \"userPhone\": \"+1234567890\",\n  \"signupDate\": \"2026-02-11T15:00:00.000Z\",\n  \"status\": \"CONFIRMED\",\n  \"signupSource\": \"AUTHENTICATED\"\n}\n

Validation:

  • Shift must be public (isPublic: true)
  • Shift must not be cancelled
  • Shift must not have passed (date >= today)
  • Shift must not be full
  • User must not already be signed up

Behavior:

  • If previously cancelled signup exists, re-activates it
  • Sends confirmation email (no temp password)
  • Increments currentVolunteers
  • Auto-updates shift status to FULL if capacity reached
  • Records Prometheus metric cm_shift_signups_total

Error Responses:

  • 400 Bad Request: Shift is full, cancelled, or past
  • 403 Forbidden: Shift is not public
  • 404 Not Found: Shift not found
  • 409 Conflict: Already signed up
  • 429 Too Many Requests: Rate limit exceeded
"},{"location":"v2/backend/modules/shifts/#delete-apimapshiftsvolunteeridsignup","title":"DELETE /api/map/shifts/volunteer/:id/signup","text":"

Cancel own signup.

Path Parameters:

  • id (string): Shift ID

Example Request:

curl -X DELETE \\\n  -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/map/shifts/volunteer/clx1234567890/signup\"\n

Response (204 No Content):

No response body.

Behavior:

  • Updates signup status to CANCELLED
  • Decrements currentVolunteers
  • Auto-updates shift status to OPEN

Error Responses:

  • 400 Bad Request: Already cancelled
  • 404 Not Found: Signup not found
"},{"location":"v2/backend/modules/shifts/#public-endpoint-details","title":"Public Endpoint Details","text":""},{"location":"v2/backend/modules/shifts/#get-apimapshiftspublic","title":"GET /api/map/shifts/public","text":"

List public upcoming shifts (no auth required).

Example Request:

curl http://api.cmlite.org/api/map/shifts/public\n

Response (200 OK):

[\n  {\n    \"id\": \"clx1234567890\",\n    \"title\": \"Door Knocking \u2014 Ward 5\",\n    \"description\": \"Canvassing residential areas in Ward 5\",\n    \"date\": \"2026-02-15T00:00:00.000Z\",\n    \"startTime\": \"10:00\",\n    \"endTime\": \"14:00\",\n    \"location\": \"123 Main St\",\n    \"maxVolunteers\": 15,\n    \"currentVolunteers\": 8,\n    \"status\": \"OPEN\"\n  }\n]\n

Filtering:

  • Only public shifts (isPublic: true)
  • Only non-cancelled shifts
  • Only shifts with date >= today
  • Sorted by date ASC, then startTime ASC
"},{"location":"v2/backend/modules/shifts/#post-apimapshiftspublicidsignup","title":"POST /api/map/shifts/public/:id/signup","text":"

Public signup with temporary user creation.

Path Parameters:

  • id (string): Shift ID

Rate Limiting:

5 requests/min per IP (shiftSignupRateLimit middleware)

Request Body:

{\n  \"email\": \"newvolunteer@example.com\",\n  \"name\": \"John Doe\",\n  \"phone\": \"+1234567890\"\n}\n

Response (201 Created):

{\n  \"signup\": {\n    \"id\": \"clx2222222222\",\n    \"shiftId\": \"clx1234567890\",\n    \"shiftTitle\": \"Door Knocking \u2014 Ward 5\",\n    \"userId\": \"clx4444444444\",\n    \"userEmail\": \"newvolunteer@example.com\",\n    \"userName\": \"John Doe\",\n    \"userPhone\": \"+1234567890\",\n    \"signupDate\": \"2026-02-11T15:00:00.000Z\",\n    \"status\": \"CONFIRMED\",\n    \"signupSource\": \"PUBLIC\"\n  },\n  \"isNewUser\": true\n}\n

Validation:

  • Shift must be public
  • Shift must be OPEN status
  • Shift date must not have passed
  • Shift must not be full
  • Email must not already be signed up

Behavior \u2014 New User:

If email does not exist in database:

  1. Generate readable password:

    const adjectives = ['Blue', 'Red', 'Green', 'Swift', 'Bright', 'Bold', 'Calm', 'Fair'];\nconst nouns = ['Eagle', 'River', 'Mountain', 'Star', 'Forest', 'Lake', 'Wolf', 'Hawk'];\n\nfunction generateReadablePassword(): string {\n  const adj = adjectives[Math.floor(Math.random() * adjectives.length)];\n  const noun = nouns[Math.floor(Math.random() * nouns.length)];\n  const num = Math.floor(Math.random() * 90) + 10;\n  return `${adj}${noun}${num}`;  // e.g., \"BlueEagle42\"\n}\n

  2. Create TEMP user:

    const hashedPassword = await bcrypt.hash(tempPassword, 12);\nconst shiftDate = new Date(shift.date);\nshiftDate.setDate(shiftDate.getDate() + 1);  // Expires day after shift\n\nconst user = await prisma.user.create({\n  data: {\n    email: data.email,\n    password: hashedPassword,\n    name: data.name,\n    phone: data.phone,\n    role: 'TEMP',\n    createdVia: 'PUBLIC_SHIFT_SIGNUP',\n    expiresAt: shiftDate,\n  },\n});\n

  3. Send confirmation email with temp password:

    const vars = {\n  USER_NAME: data.name,\n  USER_EMAIL: data.email,\n  SHIFT_TITLE: shift.title,\n  SHIFT_DATE: '...',\n  SHIFT_TIME: '...',\n  SHIFT_LOCATION: shift.location || 'TBD',\n  IS_NEW_USER: 'true',\n  TEMP_PASSWORD: tempPassword,  // Only included for new users\n  LOGIN_URL: `${siteUrl}/login`,\n  ORGANIZATION_NAME: orgName,\n};\n

Behavior \u2014 Existing User:

If email exists in database:

  • Links signup to existing user via userId
  • No password generated or sent
  • Sets signupSource to AUTHENTICATED

Behavior \u2014 Re-activation:

If cancelled signup exists:

  • Re-activates existing signup record (status \u2192 CONFIRMED)
  • Does not create duplicate signup

Transaction:

  • Signup creation + currentVolunteers increment + status update are atomic

Error Responses:

  • 400 Bad Request: Shift full, not open, or past
  • 403 Forbidden: Shift not public
  • 404 Not Found: Shift not found
  • 409 Conflict: Already signed up
  • 429 Too Many Requests: Rate limit exceeded
"},{"location":"v2/backend/modules/shifts/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/shifts/#shiftsservicefindallfilters","title":"shiftsService.findAll(filters)","text":"

List shifts with pagination, search, and filtering.

Usage:

import { shiftsService } from './shifts.service';\n\nconst result = await shiftsService.findAll({\n  page: 1,\n  limit: 20,\n  search: 'ward 5',\n  status: ShiftStatus.OPEN,\n  upcoming: true,\n  sortBy: 'date',\n  sortOrder: 'asc',\n});\n\nconsole.log(result.shifts.length);  // Array of shifts\nconsole.log(result.pagination);     // { page, limit, total, totalPages }\n

Search Behavior:

if (search) {\n  where.OR = [\n    { title: { contains: search, mode: 'insensitive' } },\n    { location: { contains: search, mode: 'insensitive' } },\n  ];\n}\n
"},{"location":"v2/backend/modules/shifts/#shiftsservicefindbyidid","title":"shiftsService.findById(id)","text":"

Get single shift with signups list.

Usage:

const shift = await shiftsService.findById('clx1234567890');\nconsole.log(shift.signups.length);  // Confirmed signups only\nconsole.log(shift.cut?.name);       // Cut name if associated\n

Throws:

  • AppError(404) if shift not found
"},{"location":"v2/backend/modules/shifts/#shiftsservicecreatedata-userid","title":"shiftsService.create(data, userId)","text":"

Create shift.

Usage:

const shift = await shiftsService.create({\n  title: 'Door Knocking \u2014 Ward 5',\n  description: 'Canvassing residential areas',\n  date: '2026-02-15',\n  startTime: '10:00',\n  endTime: '14:00',\n  location: '123 Main St',\n  maxVolunteers: 15,\n  isPublic: true,\n  cutId: 'clx0987654321',\n}, req.user.id);\n
"},{"location":"v2/backend/modules/shifts/#shiftsserviceupdateid-data","title":"shiftsService.update(id, data)","text":"

Update shift with auto-status management.

Usage:

const shift = await shiftsService.update('clx1234567890', {\n  maxVolunteers: 20,\n});\n\n// If currentVolunteers was 15 and maxVolunteers was 15:\n// - Old status: FULL\n// - New status: OPEN (because 15 < 20)\n

Auto-Status Logic:

if (data.maxVolunteers !== undefined) {\n  if (existing.currentVolunteers >= data.maxVolunteers && existing.status === ShiftStatus.OPEN) {\n    updateData.status = ShiftStatus.FULL;\n  } else if (existing.currentVolunteers < data.maxVolunteers && existing.status === ShiftStatus.FULL) {\n    updateData.status = ShiftStatus.OPEN;\n  }\n}\n
"},{"location":"v2/backend/modules/shifts/#shiftsserviceaddsignupshiftid-data","title":"shiftsService.addSignup(shiftId, data)","text":"

Admin add volunteer signup.

Usage:

const signup = await shiftsService.addSignup('clx1234567890', {\n  userEmail: 'volunteer@example.com',\n  userName: 'Jane Volunteer',\n});\n\nconsole.log(signup.signupSource);  // 'ADMIN'\n

Behavior:

  • Looks up user by email
  • Re-activates cancelled signup if exists
  • Atomic transaction (signup + capacity + status)

Throws:

  • AppError(400) if shift full
  • AppError(404) if shift not found
  • AppError(409) if already signed up
"},{"location":"v2/backend/modules/shifts/#shiftsservicepublicsignupshiftid-data","title":"shiftsService.publicSignup(shiftId, data)","text":"

Public signup with temp user creation.

Usage:

const result = await shiftsService.publicSignup('clx1234567890', {\n  email: 'newuser@example.com',\n  name: 'John Doe',\n  phone: '+1234567890',\n});\n\nif (result.isNewUser) {\n  console.log('Created TEMP user with readable password');\n  console.log('Confirmation email sent with credentials');\n}\n

Temp User Expiry:

const shiftDate = new Date(shift.date);\nshiftDate.setDate(shiftDate.getDate() + 1);  // Expires day after shift\n

Email Template Variables:

const vars: Record<string, string> = {\n  USER_NAME: data.name,\n  USER_EMAIL: data.email,\n  SHIFT_TITLE: shift.title,\n  SHIFT_DATE: dateStr,\n  SHIFT_TIME: `${shift.startTime} \u2014 ${shift.endTime}`,\n  SHIFT_LOCATION: shift.location || 'TBD',\n  IS_NEW_USER: isNewUser ? 'true' : '',  // Conditional content in template\n  TEMP_PASSWORD: tempPassword || '',     // Only included for new users\n  LOGIN_URL: `${baseUrl}/login`,\n  ORGANIZATION_NAME: orgName,\n};\n

Metrics:

Records cm_shift_signups_total Prometheus counter.

Throws:

  • AppError(400) if shift full, not open, or past
  • AppError(403) if not public
  • AppError(404) if shift not found
  • AppError(409) if duplicate signup
"},{"location":"v2/backend/modules/shifts/#shiftsserviceremovesignupsignupid","title":"shiftsService.removeSignup(signupId)","text":"

Cancel signup (admin).

Usage:

await shiftsService.removeSignup('clx2222222222');\n\n// Signup status \u2192 CANCELLED\n// currentVolunteers decremented\n// Shift status \u2192 OPEN\n

Atomic Transaction:

await prisma.$transaction([\n  prisma.shiftSignup.update({\n    where: { id: signupId },\n    data: { status: SignupStatus.CANCELLED },\n  }),\n  prisma.shift.update({\n    where: { id: signup.shiftId },\n    data: {\n      currentVolunteers: { decrement: 1 },\n      status: ShiftStatus.OPEN,\n    },\n  }),\n]);\n
"},{"location":"v2/backend/modules/shifts/#shiftsserviceemailshiftdetailsshiftid","title":"shiftsService.emailShiftDetails(shiftId)","text":"

Email shift details to all confirmed volunteers.

Usage:

const result = await shiftsService.emailShiftDetails('clx1234567890');\nconsole.log(`Sent: ${result.sent}, Failed: ${result.failed}`);\n

Email Template:

Uses shift-details.html and shift-details.txt with variables:

  • USER_NAME
  • SHIFT_TITLE
  • SHIFT_DATE
  • SHIFT_START_TIME
  • SHIFT_END_TIME
  • SHIFT_LOCATION
  • SHIFT_DESCRIPTION
  • CURRENT_VOLUNTEERS
  • MAX_VOLUNTEERS
  • SHIFT_STATUS
  • ORGANIZATION_NAME

Error Handling:

Individual email failures are logged but do not stop batch processing.

"},{"location":"v2/backend/modules/shifts/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/shifts/#create-shift-schema","title":"Create Shift Schema","text":"
export const createShiftSchema = z.object({\n  title: z.string().min(1, 'Title is required'),\n  description: z.string().optional(),\n  date: z.string().regex(/^\\d{4}-\\d{2}-\\d{2}$/, 'Date must be YYYY-MM-DD'),\n  startTime: z.string().regex(/^\\d{2}:\\d{2}$/, 'Start time must be HH:MM'),\n  endTime: z.string().regex(/^\\d{2}:\\d{2}$/, 'End time must be HH:MM'),\n  location: z.string().optional(),\n  maxVolunteers: z.number().int().min(1, 'Must have at least 1 volunteer spot'),\n  isPublic: z.boolean().optional().default(false),\n  cutId: z.string().optional(),\n});\n

Example Valid Input:

{\n  \"title\": \"Door Knocking \u2014 Ward 5\",\n  \"description\": \"Canvassing residential areas\",\n  \"date\": \"2026-02-15\",\n  \"startTime\": \"10:00\",\n  \"endTime\": \"14:00\",\n  \"location\": \"123 Main St\",\n  \"maxVolunteers\": 15,\n  \"isPublic\": true,\n  \"cutId\": \"clx0987654321\"\n}\n
"},{"location":"v2/backend/modules/shifts/#update-shift-schema","title":"Update Shift Schema","text":"
export const updateShiftSchema = z.object({\n  title: z.string().min(1).optional(),\n  description: z.string().nullable().optional(),\n  date: z.string().regex(/^\\d{4}-\\d{2}-\\d{2}$/).optional(),\n  startTime: z.string().regex(/^\\d{2}:\\d{2}$/).optional(),\n  endTime: z.string().regex(/^\\d{2}:\\d{2}$/).optional(),\n  location: z.string().nullable().optional(),\n  maxVolunteers: z.number().int().min(1).optional(),\n  isPublic: z.boolean().optional(),\n  status: z.nativeEnum(ShiftStatus).optional(),\n  cutId: z.string().nullable().optional(),\n});\n

Partial Updates:

All fields optional. Only provided fields are updated.

"},{"location":"v2/backend/modules/shifts/#public-signup-schema","title":"Public Signup Schema","text":"
export const publicSignupSchema = z.object({\n  email: z.string().email('Valid email is required'),\n  name: z.string().min(1, 'Name is required'),\n  phone: z.string().optional(),\n});\n

Example Valid Input:

{\n  \"email\": \"volunteer@example.com\",\n  \"name\": \"Jane Volunteer\",\n  \"phone\": \"+1234567890\"\n}\n
"},{"location":"v2/backend/modules/shifts/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/shifts/#admin-create-shift-with-cut-association","title":"Admin: Create Shift with Cut Association","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst createShift = async () => {\n  try {\n    const { data } = await api.post('/api/map/shifts', {\n      title: 'Door Knocking \u2014 Ward 5',\n      description: 'Canvassing residential areas in Ward 5. Meet at campaign office.',\n      date: '2026-02-15',\n      startTime: '10:00',\n      endTime: '14:00',\n      location: '123 Main St (Campaign Office)',\n      maxVolunteers: 15,\n      isPublic: true,\n      cutId: 'clx0987654321',  // Associate with cut\n    });\n\n    message.success(`Shift created: ${data.title}`);\n    return data;\n  } catch (error) {\n    message.error('Failed to create shift');\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/shifts/#volunteer-sign-up-for-shift","title":"Volunteer: Sign Up for Shift","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst signUpForShift = async (shiftId: string) => {\n  try {\n    const { data } = await api.post(`/api/map/shifts/volunteer/${shiftId}/signup`);\n\n    message.success('Signed up successfully! Check your email for confirmation.');\n    return data;\n  } catch (error: any) {\n    if (error.response?.data?.code === 'SHIFT_FULL') {\n      message.error('This shift is full');\n    } else if (error.response?.data?.code === 'DUPLICATE_SIGNUP') {\n      message.warning('You are already signed up for this shift');\n    } else {\n      message.error('Failed to sign up');\n    }\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/shifts/#public-sign-up-without-account","title":"Public: Sign Up Without Account","text":"
import axios from 'axios';\n\nconst publicSignup = async (shiftId: string, formData: { email: string; name: string; phone?: string }) => {\n  try {\n    const { data } = await axios.post(\n      `/api/map/shifts/public/${shiftId}/signup`,\n      formData\n    );\n\n    if (data.isNewUser) {\n      alert(`Account created! Check your email for your temporary password.`);\n    } else {\n      alert('Signed up successfully!');\n    }\n\n    return data;\n  } catch (error: any) {\n    if (error.response?.status === 429) {\n      alert('Too many signups. Please try again in a minute.');\n    } else if (error.response?.data?.code === 'SHIFT_FULL') {\n      alert('This shift is full');\n    } else {\n      alert('Failed to sign up');\n    }\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/shifts/#admin-email-all-volunteers","title":"Admin: Email All Volunteers","text":"
import { api } from '@/lib/api';\nimport { message } from 'antd';\n\nconst emailAllVolunteers = async (shiftId: string) => {\n  try {\n    const { data } = await api.post(`/api/map/shifts/${shiftId}/email-details`);\n\n    message.success(`Sent ${data.sent} emails successfully. ${data.failed} failed.`);\n    return data;\n  } catch (error) {\n    message.error('Failed to send emails');\n    throw error;\n  }\n};\n
"},{"location":"v2/backend/modules/shifts/#frontend-integration","title":"Frontend Integration","text":"

The ShiftsPage component (admin/src/pages/ShiftsPage.tsx) provides:

  • Paginated shifts table with search and status filter
  • Cut association dropdown (optional, for canvassing shifts)
  • Capacity badges (8/15 with OPEN/FULL status)
  • Create shift modal with date/time pickers
  • Edit shift modal (pre-populated form)
  • Delete confirmation modal
  • Signups drawer (shows volunteers, email all button, remove signup)
  • Public/private toggle (controls isPublic flag)
  • Status badges (OPEN=green, FULL=orange, CANCELLED=red)

State Management:

const [shifts, setShifts] = useState<Shift[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });\nconst [filters, setFilters] = useState({ search: '', status: null, upcoming: true });\nconst [signupsDrawerOpen, setSignupsDrawerOpen] = useState(false);\nconst [selectedShift, setSelectedShift] = useState<Shift | null>(null);\n

Volunteer Portal:

The VolunteerShiftsPage component (admin/src/pages/volunteer/VolunteerShiftsPage.tsx) provides:

  • Upcoming shifts cards with shift details
  • Signup status badges (\"Signed Up\" vs \"Join Now\")
  • Capacity indicators (8/15 volunteers)
  • Signup confirmation modal
  • Cancel signup functionality
  • My signups tab (shows user's confirmed signups)

Public Page:

The ShiftsPage component (admin/src/pages/public/ShiftsPage.tsx) provides:

  • Public shift cards with date/time/location
  • Signup modal (collects email, name, phone)
  • Capacity indicators
  • Status badges
  • Responsive grid layout
"},{"location":"v2/backend/modules/shifts/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/backend/modules/shifts/#capacity-tracking","title":"Capacity Tracking","text":"

The currentVolunteers field is denormalized for performance:

// Instead of counting signups on every query:\nconst count = await prisma.shiftSignup.count({ where: { shiftId, status: 'CONFIRMED' } });\n\n// We maintain a counter:\ndata: {\n  currentVolunteers: { increment: 1 },  // On signup\n  currentVolunteers: { decrement: 1 },  // On cancel\n}\n

Pros:

  • No joins or aggregations needed for shift listings
  • Instant capacity checks
  • Fast status filtering

Cons:

  • Must maintain consistency in transactions
  • Risk of drift if transactions fail

Consistency Checks:

Run periodic reconciliation:

UPDATE shifts\nSET \"currentVolunteers\" = (\n  SELECT COUNT(*) FROM shift_signups\n  WHERE \"shiftId\" = shifts.id AND status = 'CONFIRMED'\n)\nWHERE \"currentVolunteers\" != (\n  SELECT COUNT(*) FROM shift_signups\n  WHERE \"shiftId\" = shifts.id AND status = 'CONFIRMED'\n);\n
"},{"location":"v2/backend/modules/shifts/#unique-constraint-performance","title":"Unique Constraint Performance","text":"

The [shiftId, userEmail] unique constraint enables fast duplicate checks:

const existing = await prisma.shiftSignup.findUnique({\n  where: { shiftId_userEmail: { shiftId, userEmail: data.email } },\n});\n

Index Usage:

  • PostgreSQL uses the unique index for lookups
  • O(log n) lookup time
  • No full table scan
"},{"location":"v2/backend/modules/shifts/#rate-limiting","title":"Rate Limiting","text":"

The shiftSignupRateLimit middleware protects against signup spam:

// From api/src/middleware/rate-limit.ts\nexport const shiftSignupRateLimit = createRateLimiter({\n  windowMs: 60 * 1000,  // 1 minute\n  max: 5,               // 5 signups per minute\n  message: 'Too many signup requests. Please try again later.',\n});\n

Why 5/min?

  • Allows legitimate users to sign up for multiple shifts quickly
  • Prevents automated abuse
  • Balances UX with security
"},{"location":"v2/backend/modules/shifts/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/shifts/#shift-status-not-updating-automatically","title":"Shift Status Not Updating Automatically","text":"

Problem:

Shift status stays FULL even after volunteer cancels.

Diagnosis:

Check transaction logic in removeSignup:

await prisma.$transaction([\n  prisma.shiftSignup.update({ /* ... */ }),\n  prisma.shift.update({\n    where: { id: signup.shiftId },\n    data: {\n      currentVolunteers: { decrement: 1 },\n      status: ShiftStatus.OPEN,  // Always set to OPEN on cancel\n    },\n  }),\n]);\n

Solution:

Status is always set to OPEN on cancel. If shift should remain FULL (e.g., still at capacity), check if another transaction occurred simultaneously.

"},{"location":"v2/backend/modules/shifts/#duplicate-signups","title":"Duplicate Signups","text":"

Problem:

User signed up twice for same shift.

Diagnosis:

Check unique constraint enforcement:

SELECT * FROM shift_signups\nWHERE \"shiftId\" = 'clx1234567890' AND \"userEmail\" = 'volunteer@example.com';\n

Possible Causes:

  • Constraint disabled
  • Race condition (two requests hit database before first commit)
  • Manual database insertion bypassing constraint

Solution:

  • Verify constraint exists: \\d shift_signups in psql
  • Add application-level locking if race conditions persist:
    await prisma.$transaction([\n  prisma.shiftSignup.findUnique({ /* check */ }),\n  prisma.shiftSignup.create({ /* create */ }),\n], { isolationLevel: 'Serializable' });\n
"},{"location":"v2/backend/modules/shifts/#confirmation-emails-not-sending","title":"Confirmation Emails Not Sending","text":"

Problem:

Volunteers sign up but don't receive confirmation emails.

Diagnosis:

Check email service logs:

docker compose logs -f api | grep \"shift signup confirmation\"\n

Common Causes:

  1. MailHog mode enabled:

    EMAIL_TEST_MODE=true  # Emails go to MailHog, not SMTP\n

  2. SMTP misconfiguration:

    SMTP_HOST=smtp.gmail.com\nSMTP_PORT=587\nSMTP_USER=your-email@gmail.com\nSMTP_PASSWORD=your-app-password  # Must be app password, not account password\n

  3. Template missing:

    # Check template exists\nls api/src/templates/shift-signup-confirmation.html\nls api/src/templates/shift-signup-confirmation.txt\n

  4. Email service crash:

    try {\n  await emailService.sendEmail({ /* ... */ });\n} catch (err) {\n  logger.error('Failed to send shift signup confirmation email:', err);\n  // Signup succeeds even if email fails\n}\n

Solution:

  • Set EMAIL_TEST_MODE=false for production
  • Verify SMTP credentials
  • Ensure templates exist
  • Check email logs for detailed errors
"},{"location":"v2/backend/modules/shifts/#temp-users-not-expiring","title":"Temp Users Not Expiring","text":"

Problem:

TEMP users created via public signup still active long after shift.

Diagnosis:

Check expiresAt value:

SELECT id, email, role, \"expiresAt\", \"createdAt\"\nFROM users\nWHERE role = 'TEMP' AND \"expiresAt\" < NOW() AND status = 'ACTIVE';\n

Expected Behavior:

  • expiresAt is set to shift date + 1 day
  • Expired users should be marked EXPIRED by auth middleware

Solution:

Run cleanup script or add cron job:

// Expire temp users\nawait prisma.user.updateMany({\n  where: {\n    role: UserRole.TEMP,\n    expiresAt: { lte: new Date() },\n    status: { not: UserStatus.EXPIRED },\n  },\n  data: { status: UserStatus.EXPIRED },\n});\n
"},{"location":"v2/backend/modules/shifts/#rate-limit-too-strict","title":"Rate Limit Too Strict","text":"

Problem:

Users get rate-limited when legitimately signing up for multiple shifts.

Diagnosis:

Check rate limit config:

export const shiftSignupRateLimit = createRateLimiter({\n  windowMs: 60 * 1000,  // 1 minute\n  max: 5,               // 5 signups per minute\n});\n

Solution:

Increase limit if legitimate use case:

max: 10,  // Allow 10 signups per minute\n

Alternative:

Whitelist admin IPs:

skip: (req) => {\n  const ip = req.ip || req.connection.remoteAddress;\n  return ['127.0.0.1', '::1', ADMIN_IP].includes(ip);\n},\n
"},{"location":"v2/backend/modules/shifts/#related-documentation","title":"Related Documentation","text":"
  • Canvass Module - Volunteer canvassing system (uses Shift.cutId)
  • Cuts Module - Polygon management (cut association)
  • Users Module - User CRUD (temp user management)
  • Auth Module - JWT authentication (temp user login)
  • Settings Module - Organization name for emails
  • Email Service - Email sending (confirmation emails)
  • Frontend: ShiftsPage - Admin shift management UI
  • Frontend: Volunteer Portal - Volunteer shift signup UI
  • Frontend: Public Shifts Page - Public shift listings
  • API Reference: Shifts - Complete endpoint reference
  • User Guide: Volunteer Manager - Managing volunteers
  • Troubleshooting: Email Issues - Email debugging guide
"},{"location":"v2/backend/modules/users/","title":"Users Module","text":""},{"location":"v2/backend/modules/users/#overview","title":"Overview","text":"

The Users module provides comprehensive user management with role-based access control, pagination, search, and filtering. It supports CRUD operations with granular permissions allowing admins to manage all users while regular users can only view/update their own profile.

Key Features:

  • Full CRUD operations with role-based permissions
  • Paginated list with search (email, name) and filters (role, status)
  • Self-service profile updates for regular users
  • Admin-only role and status changes
  • Password hashing with bcrypt (12 salt rounds)
  • Temporary user expiration handling
  • Email uniqueness validation
  • Cascading delete for related records
"},{"location":"v2/backend/modules/users/#file-paths","title":"File Paths","text":"File Purpose api/src/modules/users/users.routes.ts Express router with 5 CRUD endpoints api/src/modules/users/users.service.ts User management business logic api/src/modules/users/users.schemas.ts Zod validation schemas"},{"location":"v2/backend/modules/users/#database-model","title":"Database Model","text":"

The Users module uses the User model from the Auth module:

model User {\n  id              String         @id @default(cuid())\n  email           String         @unique\n  password        String\n  name            String?\n  phone           String?\n  role            UserRole       @default(USER)\n  status          UserStatus     @default(ACTIVE)\n  permissions     Json?\n  createdVia      String?        @default(\"web\")\n  emailVerified   Boolean        @default(false)\n  expiresAt       DateTime?      // For TEMP users\n  expireDays      Int?           // Days until expiration\n  lastLoginAt     DateTime?\n  createdAt       DateTime       @default(now())\n  updatedAt       DateTime       @updatedAt\n\n  // Relations\n  refreshTokens   RefreshToken[]\n  createdCampaigns Campaign[]    @relation(\"CreatedBy\")\n  createdLocations Location[]    @relation(\"CreatedBy\")\n  // ... other relations\n}\n\nenum UserRole {\n  SUPER_ADMIN      // Full system access\n  INFLUENCE_ADMIN  // Campaign management\n  MAP_ADMIN        // Location/canvass management\n  USER             // Standard authenticated user\n  TEMP             // Temporary user (e.g., shift signups)\n}\n\nenum UserStatus {\n  ACTIVE      // Normal operation\n  SUSPENDED   // Temporarily disabled\n  BANNED      // Permanently disabled\n}\n
"},{"location":"v2/backend/modules/users/#api-endpoints","title":"API Endpoints","text":"Method Path Auth Permissions Description GET /api/users Required Admin roles List users with pagination/filters GET /api/users/:id Required Admin or self Get user by ID POST /api/users Required Admin roles Create new user PUT /api/users/:id Required Admin or self Update user DELETE /api/users/:id Required Admin roles Delete user

Admin Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

"},{"location":"v2/backend/modules/users/#endpoint-details","title":"Endpoint Details","text":""},{"location":"v2/backend/modules/users/#get-apiusers","title":"GET /api/users","text":"

List users with pagination, search, and filtering (admin only).

Request Headers:

Authorization: Bearer <access_token>\n

Query Parameters:

Parameter Type Required Default Description page number No 1 Page number (1-indexed) limit number No 20 Results per page (max 100) search string No - Search email or name (case-insensitive) role UserRole No - Filter by role status UserStatus No - Filter by status

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/users?page=1&limit=20&search=john&role=USER&status=ACTIVE\"\n

Response (200 OK):

{\n  \"users\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"email\": \"john.doe@example.com\",\n      \"name\": \"John Doe\",\n      \"phone\": \"+1234567890\",\n      \"role\": \"USER\",\n      \"status\": \"ACTIVE\",\n      \"permissions\": null,\n      \"createdVia\": \"web\",\n      \"expiresAt\": null,\n      \"expireDays\": null,\n      \"lastLoginAt\": \"2026-02-11T12:00:00.000Z\",\n      \"emailVerified\": true,\n      \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n      \"updatedAt\": \"2026-02-11T12:00:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 150,\n    \"totalPages\": 8\n  }\n}\n

Implementation:

router.get(\n  '/',\n  requireRole(...ADMIN_ROLES),\n  validate(listUsersSchema, 'query'),\n  async (req: Request, res: Response, next: NextFunction) => {\n    try {\n      const result = await usersService.findAll(req.query as any);\n      res.json(result);\n    } catch (err) {\n      next(err);\n    }\n  }\n);\n

Search Logic:

if (search) {\n  where.OR = [\n    { email: { contains: search, mode: 'insensitive' } },\n    { name: { contains: search, mode: 'insensitive' } },\n  ];\n}\n
"},{"location":"v2/backend/modules/users/#get-apiusersid","title":"GET /api/users/:id","text":"

Get user by ID. Admins can view any user, regular users can only view themselves.

Request Headers:

Authorization: Bearer <access_token>\n

Path Parameters:

  • id (string): User ID

Example Request:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/users/clx1234567890\"\n

Response (200 OK):

{\n  \"id\": \"clx1234567890\",\n  \"email\": \"john.doe@example.com\",\n  \"name\": \"John Doe\",\n  \"phone\": \"+1234567890\",\n  \"role\": \"USER\",\n  \"status\": \"ACTIVE\",\n  \"permissions\": null,\n  \"createdVia\": \"web\",\n  \"expiresAt\": null,\n  \"expireDays\": null,\n  \"lastLoginAt\": \"2026-02-11T12:00:00.000Z\",\n  \"emailVerified\": true,\n  \"createdAt\": \"2026-02-01T12:00:00.000Z\",\n  \"updatedAt\": \"2026-02-11T12:00:00.000Z\"\n}\n

Error Responses:

  • 403 Forbidden: Non-admin trying to view another user
  • 404 Not Found: User ID does not exist

Permission Logic:

const isAdmin = ADMIN_ROLES.includes(req.user!.role);\nconst isSelf = req.user!.id === id;\n\nif (!isAdmin && !isSelf) {\n  res.status(403).json({ error: { message: 'Insufficient permissions', code: 'FORBIDDEN' } });\n  return;\n}\n
"},{"location":"v2/backend/modules/users/#post-apiusers","title":"POST /api/users","text":"

Create new user account (admin only). Unlike public registration, admins can set any role.

Request Headers:

Authorization: Bearer <access_token>\nContent-Type: application/json\n

Request Body:

{\n  \"email\": \"newuser@example.com\",\n  \"password\": \"TempPass123\",\n  \"name\": \"New User\",\n  \"phone\": \"+1234567890\",\n  \"role\": \"MAP_ADMIN\",\n  \"status\": \"ACTIVE\",\n  \"expiresAt\": \"2026-12-31T23:59:59Z\",\n  \"expireDays\": 365\n}\n

Field Details:

Field Type Required Description email string Yes Unique email address password string Yes Minimum 8 characters (admin creation has relaxed policy) name string No Full name phone string No Phone number role UserRole No Default: USER status UserStatus No Default: ACTIVE expiresAt ISO 8601 No Expiration timestamp (for TEMP users) expireDays number No Days until expiration

Response (201 Created):

{\n  \"id\": \"clx0987654321\",\n  \"email\": \"newuser@example.com\",\n  \"name\": \"New User\",\n  \"phone\": \"+1234567890\",\n  \"role\": \"MAP_ADMIN\",\n  \"status\": \"ACTIVE\",\n  \"permissions\": null,\n  \"createdVia\": \"web\",\n  \"expiresAt\": \"2026-12-31T23:59:59.000Z\",\n  \"expireDays\": 365,\n  \"lastLoginAt\": null,\n  \"emailVerified\": false,\n  \"createdAt\": \"2026-02-11T12:00:00.000Z\",\n  \"updatedAt\": \"2026-02-11T12:00:00.000Z\"\n}\n

Error Responses:

  • 409 Conflict: Email already registered
  • 403 Forbidden: Non-admin trying to create user
"},{"location":"v2/backend/modules/users/#put-apiusersid","title":"PUT /api/users/:id","text":"

Update user. Admins can update any user and change role/status. Regular users can update their own profile (except role/status).

Request Headers:

Authorization: Bearer <access_token>\nContent-Type: application/json\n

Path Parameters:

  • id (string): User ID to update

Request Body (Partial Update):

{\n  \"name\": \"Updated Name\",\n  \"phone\": \"+0987654321\",\n  \"email\": \"newemail@example.com\",\n  \"password\": \"NewPass123\",\n  \"role\": \"INFLUENCE_ADMIN\",\n  \"status\": \"SUSPENDED\"\n}\n

All fields are optional (partial updates supported).

Response (200 OK):

{\n  \"id\": \"clx1234567890\",\n  \"email\": \"newemail@example.com\",\n  \"name\": \"Updated Name\",\n  \"phone\": \"+0987654321\",\n  \"role\": \"INFLUENCE_ADMIN\",\n  \"status\": \"SUSPENDED\",\n  ...\n}\n

Error Responses:

  • 403 Forbidden: Non-admin trying to update another user or change role/status
  • 404 Not Found: User ID does not exist
  • 409 Conflict: Email already in use by another user

Permission Logic:

const isAdmin = ADMIN_ROLES.includes(req.user!.role);\nconst isSelf = req.user!.id === id;\n\nif (!isAdmin && !isSelf) {\n  return res.status(403).json({ error: { message: 'Insufficient permissions', code: 'FORBIDDEN' } });\n}\n\n// Non-admins cannot change role or status\nif (!isAdmin) {\n  delete req.body.role;\n  delete req.body.status;\n}\n

Password Handling:

if (data.password) {\n  updateData.password = await bcrypt.hash(data.password, 12);\n}\n
"},{"location":"v2/backend/modules/users/#delete-apiusersid","title":"DELETE /api/users/:id","text":"

Delete user (admin only). Cascades to related records (refresh tokens, created campaigns, etc.).

Request Headers:

Authorization: Bearer <access_token>\n

Path Parameters:

  • id (string): User ID to delete

Example Request:

curl -X DELETE \\\n  -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/users/clx1234567890\"\n

Response (204 No Content):

No response body.

Error Responses:

  • 403 Forbidden: Non-admin trying to delete user
  • 404 Not Found: User ID does not exist

Cascading Deletes:

Deleting a user automatically deletes: - Refresh tokens - Created campaigns (if createdByUserId relation) - Created locations (if createdByUserId relation) - Campaign emails - Responses - Shift signups

"},{"location":"v2/backend/modules/users/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/users/#usersservicefindallfilters","title":"usersService.findAll(filters)","text":"

Purpose: Paginated user listing with search and filters.

Parameters:

interface ListUsersInput {\n  page: number;         // Default: 1\n  limit: number;        // Default: 20, max: 100\n  search?: string;      // Search email or name\n  role?: UserRole;      // Filter by role\n  status?: UserStatus;  // Filter by status\n}\n

Returns:

{\n  users: User[];\n  pagination: {\n    page: number;\n    limit: number;\n    total: number;\n    totalPages: number;\n  };\n}\n

Implementation:

const { page, limit, search, role, status } = filters;\nconst skip = (page - 1) * limit;\n\nconst where: Prisma.UserWhereInput = {};\n\nif (search) {\n  where.OR = [\n    { email: { contains: search, mode: 'insensitive' } },\n    { name: { contains: search, mode: 'insensitive' } },\n  ];\n}\n\nif (role) where.role = role;\nif (status) where.status = status;\n\nconst [users, total] = await Promise.all([\n  prisma.user.findMany({\n    where,\n    select: userSelect,\n    skip,\n    take: limit,\n    orderBy: { createdAt: 'desc' },\n  }),\n  prisma.user.count({ where }),\n]);\n\nreturn {\n  users,\n  pagination: {\n    page,\n    limit,\n    total,\n    totalPages: Math.ceil(total / limit),\n  },\n};\n
"},{"location":"v2/backend/modules/users/#usersservicefindbyidid","title":"usersService.findById(id)","text":"

Purpose: Get single user by ID.

Returns: User object or throws 404 error.

Security: Password excluded via select (never returned in API responses).

"},{"location":"v2/backend/modules/users/#usersservicecreatedata","title":"usersService.create(data)","text":"

Purpose: Create new user with hashed password.

Flow:

  1. Check if email already exists (409 if duplicate)
  2. Hash password with bcrypt (12 salt rounds)
  3. Create user in database
  4. Return user (password excluded)

Expiration Handling:

const user = await prisma.user.create({\n  data: {\n    ...data,\n    password: hashedPassword,\n    expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined,\n  },\n  select: userSelect,\n});\n
"},{"location":"v2/backend/modules/users/#usersserviceupdateid-data","title":"usersService.update(id, data)","text":"

Purpose: Update existing user (partial updates supported).

Validation:

  • Check user exists (404 if not found)
  • Check email uniqueness if changing email (409 if taken)
  • Hash password if provided
  • Convert expiresAt string to Date

Email Change:

if (data.email && data.email !== existing.email) {\n  const emailTaken = await prisma.user.findUnique({ where: { email: data.email } });\n  if (emailTaken) {\n    throw new AppError(409, 'Email already in use', 'EMAIL_EXISTS');\n  }\n}\n
"},{"location":"v2/backend/modules/users/#usersservicedeleteid","title":"usersService.delete(id)","text":"

Purpose: Delete user and cascade to related records.

Error Handling:

const existing = await prisma.user.findUnique({ where: { id } });\nif (!existing) {\n  throw new AppError(404, 'User not found', 'USER_NOT_FOUND');\n}\n\nawait prisma.user.delete({ where: { id } });\n
"},{"location":"v2/backend/modules/users/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/users/#admin-list-users-with-filters","title":"Admin: List Users with Filters","text":"
import { api } from '@/lib/api';\n\nconst fetchUsers = async (page = 1, search = '', role = null, status = null) => {\n  const params = new URLSearchParams({\n    page: page.toString(),\n    limit: '20',\n  });\n\n  if (search) params.append('search', search);\n  if (role) params.append('role', role);\n  if (status) params.append('status', status);\n\n  const { data } = await api.get(`/api/users?${params}`);\n  return data;\n};\n\n// Usage\nconst result = await fetchUsers(1, 'john', 'USER', 'ACTIVE');\nconsole.log(`Total users: ${result.pagination.total}`);\nconsole.log(`Users on page 1:`, result.users);\n
"},{"location":"v2/backend/modules/users/#admin-create-user","title":"Admin: Create User","text":"
import { api } from '@/lib/api';\n\nconst createUser = async (userData) => {\n  const { data } = await api.post('/api/users', {\n    email: userData.email,\n    password: userData.password,\n    name: userData.name,\n    phone: userData.phone,\n    role: userData.role || 'USER',\n    status: userData.status || 'ACTIVE',\n  });\n\n  return data;\n};\n\n// Usage\nconst newUser = await createUser({\n  email: 'volunteer@example.com',\n  password: 'TempPass123',\n  name: 'Jane Volunteer',\n  role: 'USER',\n});\n\nconsole.log(`Created user: ${newUser.id}`);\n
"},{"location":"v2/backend/modules/users/#admin-update-user-role","title":"Admin: Update User Role","text":"
import { api } from '@/lib/api';\n\nconst promoteToAdmin = async (userId: string, adminRole: string) => {\n  const { data } = await api.put(`/api/users/${userId}`, {\n    role: adminRole,\n  });\n\n  return data;\n};\n\n// Usage\nconst updatedUser = await promoteToAdmin('clx1234567890', 'MAP_ADMIN');\nconsole.log(`User promoted to ${updatedUser.role}`);\n
"},{"location":"v2/backend/modules/users/#user-update-own-profile","title":"User: Update Own Profile","text":"
import { api } from '@/lib/api';\n\nconst updateProfile = async (name: string, phone: string) => {\n  const { data } = await api.put(`/api/users/${currentUser.id}`, {\n    name,\n    phone,\n  });\n\n  return data;\n};\n\n// Usage (non-admin user updating self)\nconst updated = await updateProfile('Updated Name', '+1234567890');\nconsole.log('Profile updated:', updated);\n
"},{"location":"v2/backend/modules/users/#admin-suspend-user","title":"Admin: Suspend User","text":"
import { api } from '@/lib/api';\n\nconst suspendUser = async (userId: string) => {\n  const { data } = await api.put(`/api/users/${userId}`, {\n    status: 'SUSPENDED',\n  });\n\n  return data;\n};\n\n// Usage\nconst suspended = await suspendUser('clx1234567890');\nconsole.log(`User ${suspended.email} suspended`);\n
"},{"location":"v2/backend/modules/users/#admin-delete-user","title":"Admin: Delete User","text":"
import { api } from '@/lib/api';\n\nconst deleteUser = async (userId: string) => {\n  await api.delete(`/api/users/${userId}`);\n  console.log(`User ${userId} deleted`);\n};\n\n// Usage\nawait deleteUser('clx1234567890');\n
"},{"location":"v2/backend/modules/users/#frontend-integration","title":"Frontend Integration","text":"

The UsersPage component (admin/src/pages/UsersPage.tsx) provides a comprehensive UI for user management:

Features:

  • Paginated table with role/status badges
  • Search by email or name (300ms debounce)
  • Filter dropdowns (role, status)
  • Create user modal with form validation
  • Edit user modal (pre-populated form)
  • Delete confirmation modal
  • Responsive design (mobile-friendly)

State Management:

const [users, setUsers] = useState<User[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });\nconst [filters, setFilters] = useState({ search: '', role: null, status: null });\n

API Integration:

const fetchUsers = async () => {\n  setLoading(true);\n  try {\n    const params = new URLSearchParams({\n      page: pagination.page.toString(),\n      limit: pagination.limit.toString(),\n    });\n\n    if (filters.search) params.append('search', filters.search);\n    if (filters.role) params.append('role', filters.role);\n    if (filters.status) params.append('status', filters.status);\n\n    const { data } = await api.get(`/api/users?${params}`);\n    setUsers(data.users);\n    setPagination(data.pagination);\n  } catch (error) {\n    message.error('Failed to fetch users');\n  } finally {\n    setLoading(false);\n  }\n};\n
"},{"location":"v2/backend/modules/users/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/users/#create-user-schema","title":"Create User Schema","text":"
export const createUserSchema = z.object({\n  email: z.string().email(),\n  password: z.string().min(8, 'Password must be at least 8 characters'),\n  name: z.string().optional(),\n  phone: z.string().optional(),\n  role: z.nativeEnum(UserRole).optional(),\n  status: z.nativeEnum(UserStatus).optional(),\n  expiresAt: z.string().datetime().optional(),\n  expireDays: z.number().int().positive().optional(),\n});\n

Note: Admin user creation has relaxed password requirements (8 chars vs. 12 for public registration).

"},{"location":"v2/backend/modules/users/#update-user-schema","title":"Update User Schema","text":"
export const updateUserSchema = z.object({\n  email: z.string().email().optional(),\n  password: z.string().min(8).optional(),\n  name: z.string().optional(),\n  phone: z.string().optional(),\n  role: z.nativeEnum(UserRole).optional(),\n  status: z.nativeEnum(UserStatus).optional(),\n  expiresAt: z.string().datetime().nullable().optional(),\n  expireDays: z.number().int().positive().nullable().optional(),\n});\n
"},{"location":"v2/backend/modules/users/#list-users-schema","title":"List Users Schema","text":"
export const listUsersSchema = z.object({\n  page: z.coerce.number().int().positive().default(1),\n  limit: z.coerce.number().int().positive().max(100).default(20),\n  search: z.string().optional(),\n  role: z.nativeEnum(UserRole).optional(),\n  status: z.nativeEnum(UserStatus).optional(),\n});\n
"},{"location":"v2/backend/modules/users/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/backend/modules/users/#password-security","title":"Password Security","text":"
  • Hashing: bcrypt with 12 salt rounds (admin creation) or enforced by registration schema
  • Never Returned: Password excluded from all API responses via select clause
  • Updates: Re-hashed when changed
"},{"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":"
  • Enforced at database level (@unique constraint)
  • Checked before creation (409 Conflict)
  • Checked before email change (409 Conflict)
"},{"location":"v2/backend/modules/users/#cascading-deletes","title":"Cascading Deletes","text":"

Deleting a user automatically deletes related records via Prisma onDelete: Cascade:

  • RefreshTokens
  • Created campaigns
  • Created locations
  • Campaign emails
  • Responses
  • Shift signups
"},{"location":"v2/backend/modules/users/#related-documentation","title":"Related Documentation","text":"
  • Auth Module - Authentication and JWT tokens
  • Middleware: RBAC - Role-based access control
  • Frontend: UsersPage - User management UI
  • Database: User Model - User schema documentation
  • API Reference: Users - Complete endpoint reference
  • User Guide: Admin - Managing users guide
"},{"location":"v2/backend/services/","title":"Backend Services","text":"

Shared services provide cross-cutting functionality across the Changemaker Lite platform. These services handle external integrations, background processing, and common operations.

"},{"location":"v2/backend/services/#service-architecture","title":"Service Architecture","text":"

Services are singleton instances that provide:

  • External API integrations (email, geocoding, newsletters)
  • Background job processing (email queues, geocoding queues)
  • Caching and data management
  • Infrastructure operations (Docker, tunneling)
"},{"location":"v2/backend/services/#core-services","title":"Core Services","text":""},{"location":"v2/backend/services/#email-services","title":"Email Services","text":"

Email Service (email.service.ts)

  • Nodemailer SMTP wrapper
  • Template processing with variable substitution
  • Test mode support (MailHog integration)
  • HTML email generation
  • Attachment handling

Email Queue Service (email-queue.service.ts)

  • BullMQ queue management
  • Worker process for async email sending
  • Job retry logic with exponential backoff
  • Queue monitoring and statistics
  • Batch email processing
"},{"location":"v2/backend/services/#geocoding-services","title":"Geocoding Services","text":"

Geocoding Service (geocoding.service.ts)

  • Multi-provider geocoding (6 providers)
  • Nominatim (OpenStreetMap)
  • ArcGIS
  • Photon
  • Mapbox
  • Google Geocoding API
  • Pelias
  • Provider fallback chain
  • Rate limiting per provider
  • Result caching
  • Batch geocoding support

Geocode Queue Service (geocode-queue.service.ts)

  • BullMQ queue for async geocoding
  • Worker process with provider rotation
  • Progress tracking
  • Error handling and retry logic
  • Batch processing optimization
"},{"location":"v2/backend/services/#integration-services","title":"Integration Services","text":"

Listmonk Client (listmonk.client.ts)

  • Typed HTTP client for Listmonk REST API
  • Basic auth integration
  • List management operations
  • Subscriber CRUD
  • Campaign operations

Listmonk Sync Service (listmonk-sync.service.ts)

  • Opt-in sync (controlled by LISTMONK_SYNC_ENABLED)
  • Participant \u2192 subscriber sync
  • Location \u2192 list management
  • User role \u2192 list assignment
  • Automated sync on campaign actions

Pangolin Client (pangolin.client.ts)

  • Typed HTTP client for Pangolin Integration API
  • API key authentication
  • Tunnel management
  • Site configuration
  • Resource operations
"},{"location":"v2/backend/services/#infrastructure-services","title":"Infrastructure Services","text":"

Docker Service (docker.service.ts)

  • Container lifecycle management
  • Health check monitoring
  • Service status queries
  • Container operations (start, stop, restart)
  • Resource monitoring
"},{"location":"v2/backend/services/#service-list","title":"Service List","text":"Service Purpose Dependencies Email Service SMTP email delivery Nodemailer Email Queue Async email processing BullMQ, Redis Geocoding Address \u2192 coordinates Multiple providers Geocode Queue Async geocoding BullMQ, Redis Listmonk Client Newsletter API Native fetch Listmonk Sync Automated list sync Listmonk Client Pangolin Client Tunnel API Native fetch Docker Service Container ops Docker API"},{"location":"v2/backend/services/#configuration","title":"Configuration","text":"

Services are configured via environment variables in api/src/config/env.ts:

// Email\nEMAIL_TEST_MODE=true          // Use MailHog instead of SMTP\nSMTP_HOST=smtp.example.com\nSMTP_PORT=587\n\n// Geocoding\nMAPBOX_ACCESS_TOKEN=pk_...\nGOOGLE_GEOCODE_API_KEY=...\n\n// Listmonk\nLISTMONK_SYNC_ENABLED=true\nLISTMONK_API_URL=http://listmonk:9000\nLISTMONK_API_USER=api_user\nLISTMONK_API_TOKEN=secret\n\n// Pangolin\nPANGOLIN_API_URL=https://api.bnkserve.org/v1\nPANGOLIN_API_KEY=...\n
"},{"location":"v2/backend/services/#usage-patterns","title":"Usage Patterns","text":""},{"location":"v2/backend/services/#email-service","title":"Email Service","text":"
import { emailService } from '../services/email.service';\n\nawait emailService.sendEmail({\n  to: 'user@example.com',\n  subject: 'Welcome',\n  html: '<p>Welcome to our platform</p>',\n});\n
"},{"location":"v2/backend/services/#email-queue","title":"Email Queue","text":"
import { emailQueueService } from '../services/email-queue.service';\n\nawait emailQueueService.addEmailJob({\n  to: 'user@example.com',\n  subject: 'Campaign Update',\n  template: 'campaign-email',\n  variables: { campaignName: 'Save the Parks' },\n});\n
"},{"location":"v2/backend/services/#geocoding-service","title":"Geocoding Service","text":"
import { geocodingService } from '../services/geocoding.service';\n\nconst result = await geocodingService.geocode({\n  address: '123 Main St, Toronto, ON',\n  provider: 'nominatim',\n});\n\nif (result.success) {\n  console.log(result.coordinates); // { lat: 43.65, lng: -79.38 }\n}\n
"},{"location":"v2/backend/services/#related-documentation","title":"Related Documentation","text":"
  • Backend Overview
  • Modules
  • Environment Variables
  • Email Queue Feature
  • Geocoding Feature
  • Newsletter Integration
"},{"location":"v2/backend/utilities/","title":"Backend Utilities","text":"

Utility modules provide common functionality for spatial calculations, logging, metrics collection, and data processing across the Changemaker Lite platform.

"},{"location":"v2/backend/utilities/#utility-modules","title":"Utility Modules","text":""},{"location":"v2/backend/utilities/#spatial-utilities","title":"Spatial Utilities","text":"

spatial.ts (utils/spatial.ts)

Provides geospatial calculations and polygon operations:

Point-in-Polygon - Ray-casting algorithm for polygon containment - Supports GeoJSON polygon format - Handles holes in polygons - Used for cut assignment

import { isPointInPolygon } from '../utils/spatial';\n\nconst inside = isPointInPolygon(\n  { lat: 43.65, lng: -79.38 },\n  geoJsonPolygon\n);\n

Haversine Distance - Calculate distance between two coordinates - Returns distance in kilometers - Great-circle distance calculation

import { haversineDistance } from '../utils/spatial';\n\nconst distance = haversineDistance(\n  { lat: 43.65, lng: -79.38 },\n  { lat: 43.66, lng: -79.39 }\n);\n// Returns: 1.23 (km)\n

Bounds Calculation - Calculate bounding box for set of locations - Returns min/max lat/lng - Used for map centering

import { calculateBounds } from '../utils/spatial';\n\nconst bounds = calculateBounds(locations);\n// Returns: { minLat, maxLat, minLng, maxLng }\n

Centroid Calculation - Calculate center point of locations - Geographic mean of coordinates - Used for map initial center

import { calculateCentroid } from '../utils/spatial';\n\nconst center = calculateCentroid(locations);\n// Returns: { lat, lng }\n

GeoJSON Parsing - Parse GeoJSON geometry to coordinate arrays - Support for Polygon and MultiPolygon - Coordinate validation

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

logger.ts (utils/logger.ts)

Winston-based logging with multiple transports:

Log Levels - error - Error conditions - warn - Warning messages - info - Informational messages - http - HTTP request logs - debug - Debug-level messages

Usage

import logger from '../utils/logger';\n\nlogger.info('Campaign created', { campaignId: 123 });\nlogger.error('Failed to send email', { error: err.message });\nlogger.debug('Geocoding result', { lat, lng });\n

Features - JSON formatting for production - Colorized console output for development - File rotation for error logs - Separate error log file - Timestamp on all logs - Request ID tracking

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

metrics.ts (utils/metrics.ts)

Prometheus metrics collection with 12 custom cm_* metrics:

Counter Metrics - cm_api_uptime_seconds - API uptime counter - cm_canvass_visits_total - Total canvass visits - cm_campaign_emails_sent_total - Total campaign emails - cm_geocode_requests_total - Total geocode requests

Gauge Metrics - cm_canvass_sessions_active - Active canvass sessions - cm_email_queue_size - Email queue depth - cm_geocode_queue_size - Geocode queue depth - cm_external_service_health - Service health status (0/1)

Histogram Metrics - cm_geocode_duration_seconds - Geocoding request duration - http_request_duration_ms - HTTP request duration

Usage

import { metrics } from '../utils/metrics';\n\n// Increment counter\nmetrics.campaignEmailsSent.inc();\n\n// Set gauge\nmetrics.emailQueueSize.set(42);\n\n// Observe histogram\nconst end = metrics.geocodeDuration.startTimer();\nawait geocode(address);\nend();\n

HTTP Metrics

Automatic tracking of: - Request count by method, route, status - Request duration percentiles - Active requests gauge

"},{"location":"v2/backend/utilities/#path-validation","title":"Path Validation","text":"

path-validator.ts (utils/path-validator.ts)

Security utilities for path validation:

Features - Null byte detection - Path traversal prevention (../ patterns) - Encoded traversal detection (%2e%2e) - Path normalization

import { validatePath } from '../utils/path-validator';\n\nconst safe = validatePath(userInput);\nif (!safe) {\n  throw new Error('Invalid path');\n}\n
"},{"location":"v2/backend/utilities/#html-sanitization","title":"HTML Sanitization","text":"

sanitize.ts (utils/sanitize.ts)

XSS prevention utilities:

import { escapeHtml } from '../utils/sanitize';\n\nconst safe = escapeHtml(userInput);\n// Escapes: < > & \" ' to HTML entities\n
"},{"location":"v2/backend/utilities/#utility-functions-summary","title":"Utility Functions Summary","text":"Utility Function Purpose Spatial isPointInPolygon() Check if point is inside polygon haversineDistance() Calculate distance between points calculateBounds() Calculate bounding box calculateCentroid() Calculate center point parseGeoJSON() Parse GeoJSON to coordinates Logging logger.info() Log informational message logger.error() Log error message logger.debug() Log debug message Metrics metrics.*.inc() Increment counter metrics.*.set() Set gauge value metrics.*.startTimer() Start histogram timer Security validatePath() Validate file path safety escapeHtml() Sanitize HTML content"},{"location":"v2/backend/utilities/#configuration","title":"Configuration","text":"

Utilities are configured via environment variables:

# Logging\nLOG_LEVEL=info              # Minimum log level\nNODE_ENV=production         # Environment mode\n\n# Metrics\nMETRICS_ENABLED=true        # Enable Prometheus metrics\n
"},{"location":"v2/backend/utilities/#related-documentation","title":"Related Documentation","text":"
  • Backend Overview
  • Observability
  • Security
  • Map Features
  • Monitoring Stack
"},{"location":"v2/contributing/","title":"Contributing to Changemaker Lite","text":"

Thank you for your interest in contributing to Changemaker Lite! This guide will help you get started with contributing code, documentation, bug reports, and feature requests.

"},{"location":"v2/contributing/#welcome","title":"Welcome!","text":"

Changemaker Lite is an open-source political campaign platform built by volunteers for organizers. We welcome contributions from developers, designers, writers, and community organizers of all experience levels.

Our mission: Provide free, self-hosted tools for grassroots political campaigns to compete with well-funded opponents.

"},{"location":"v2/contributing/#ways-to-contribute","title":"Ways to Contribute","text":""},{"location":"v2/contributing/#1-code-contributions","title":"1. Code Contributions","text":"

Help build new features or fix bugs in:

  • Backend API (TypeScript + Express + Prisma)
  • Admin Frontend (React + Vite + Ant Design)
  • Media API (TypeScript + Fastify + Drizzle)
  • Infrastructure (Docker, Nginx, PostgreSQL, Redis)

\u2192 Development Setup Guide

"},{"location":"v2/contributing/#2-documentation","title":"2. Documentation","text":"

Improve guides, tutorials, and API documentation:

  • User guides - Help organizers use the platform
  • Developer docs - API reference, architecture guides
  • Tutorials - Step-by-step walkthroughs
  • Translations - Localize docs for other languages

\u2192 Documentation Guide

"},{"location":"v2/contributing/#3-bug-reports","title":"3. Bug Reports","text":"

Found a bug? Help us fix it:

  • Search existing issues first
  • Provide clear reproduction steps
  • Include error messages and logs
  • Test on latest version

\u2192 Report a Bug

"},{"location":"v2/contributing/#4-feature-requests","title":"4. Feature Requests","text":"

Suggest new features or enhancements:

  • Check roadmap first
  • Describe the use case
  • Explain why it's valuable
  • Consider implementation complexity

\u2192 Request a Feature

"},{"location":"v2/contributing/#5-testing","title":"5. Testing","text":"

Help test new features and releases:

  • Test beta releases on staging
  • Verify bug fixes
  • Test migration procedures
  • Report edge cases
"},{"location":"v2/contributing/#6-community-support","title":"6. Community Support","text":"

Help other users:

  • Answer questions in Discussions
  • Share your setup experiences
  • Write blog posts or tutorials
  • Present at community calls
"},{"location":"v2/contributing/#7-design","title":"7. Design","text":"

Improve user experience:

  • UI/UX design mockups
  • User flow improvements
  • Accessibility enhancements
  • Mobile responsiveness
"},{"location":"v2/contributing/#code-of-conduct","title":"Code of Conduct","text":"

Changemaker Lite is committed to providing a welcoming and inclusive environment for all contributors.

Our values:

  • Respect: Treat everyone with kindness and professionalism
  • Inclusivity: Welcome contributors from all backgrounds
  • Collaboration: Work together constructively
  • Constructive feedback: Focus on improvement, not criticism

\u2192 Full Code of Conduct

Unacceptable behavior:

  • Harassment, discrimination, or hate speech
  • Personal attacks or trolling
  • Publishing private information
  • Spam or self-promotion

Enforcement: Violations will result in warnings, temporary bans, or permanent bans depending on severity.

Reporting: Email conduct@cmlite.org to report violations confidentially.

"},{"location":"v2/contributing/#getting-started","title":"Getting Started","text":""},{"location":"v2/contributing/#prerequisites","title":"Prerequisites","text":"

Before contributing code, ensure you have:

  • Node.js 20+ installed
  • Docker Desktop (or Docker + Docker Compose)
  • Git for version control
  • Code editor (VSCode recommended)
  • GitHub account for pull requests
"},{"location":"v2/contributing/#quick-start","title":"Quick Start","text":"
  1. Fork the repository on GitHub
  2. Clone your fork locally
  3. Set up development environment (guide)
  4. Find an issue to work on
  5. Create a branch for your changes
  6. Make your changes with tests
  7. Submit a pull request (guide)
"},{"location":"v2/contributing/#finding-issues-to-work-on","title":"Finding Issues to Work On","text":"

Good first issues: Look for issues tagged good-first-issue in GitHub Issues.

Help wanted: Issues tagged help-wanted need contributors.

By skill level: - beginner - Simple fixes, documentation - intermediate - Feature enhancements, refactoring - advanced - Architecture changes, performance optimization

By area: - backend - API, database, services - frontend - React components, UI/UX - infrastructure - Docker, Nginx, deployment - documentation - Guides, tutorials, API docs

\u2192 Browse Issues

"},{"location":"v2/contributing/#contribution-workflow","title":"Contribution Workflow","text":""},{"location":"v2/contributing/#1-claim-an-issue","title":"1. Claim an Issue","text":"

Before starting work:

  1. Comment on the issue: \"I'd like to work on this\"
  2. Wait for assignment: Maintainer will assign you
  3. Ask questions: Clarify requirements before coding

Avoid Duplicate Work

Always check if someone is already assigned before starting work.

"},{"location":"v2/contributing/#2-create-a-branch","title":"2. Create a Branch","text":"
# Update main branch\ngit checkout main\ngit pull upstream main\n\n# Create feature branch\ngit checkout -b feature/campaign-export\n\n# Or for bug fixes\ngit checkout -b fix/geocoding-error\n

Branch naming: - feature/description - New features - fix/description - Bug fixes - docs/description - Documentation - refactor/description - Code refactoring - test/description - Test additions

"},{"location":"v2/contributing/#3-make-changes","title":"3. Make Changes","text":"

Follow our coding standards:

  • TypeScript: Strict mode, type all functions
  • ESLint: Run npm run lint before committing
  • Prettier: Auto-format with npm run format
  • Tests: Add tests for new features
  • Comments: Document complex logic
// Good: Type-safe function with comments\n/**\n * Geocodes an address using the specified provider.\n * Falls back to next provider if the first fails.\n *\n * @param address - Full address string\n * @param provider - Geocoding provider (default: nominatim)\n * @returns Promise resolving to { lat, lng, quality }\n */\nasync function geocodeAddress(\n  address: string,\n  provider: GeocodingProvider = 'nominatim'\n): Promise<GeocodingResult> {\n  // Implementation\n}\n
"},{"location":"v2/contributing/#4-test-your-changes","title":"4. Test Your Changes","text":"
# Backend tests\ncd api && npm test\n\n# Frontend tests\ncd admin && npm test\n\n# Type checking\ncd api && npx tsc --noEmit\ncd admin && npx tsc --noEmit\n\n# Linting\ncd api && npm run lint\ncd admin && npm run lint\n\n# Integration tests\ndocker compose up -d\n./scripts/test-integration.sh\n
"},{"location":"v2/contributing/#5-commit-your-changes","title":"5. Commit Your Changes","text":"

Commit message format (Conventional Commits):

type(scope): short description\n\nLonger description (optional)\n\nFixes #123\n

Types: - feat - New feature - fix - Bug fix - docs - Documentation - style - Formatting, whitespace - refactor - Code restructuring - test - Test additions - chore - Build, tooling

Examples:

feat(campaigns): add campaign export to CSV\n\nAdds a new export button to the campaigns page that downloads\nall campaigns as a CSV file.\n\nFixes #456\n\n---\n\nfix(geocoding): handle null responses from Nominatim\n\nPrevents crash when Nominatim returns empty result for\ninvalid addresses.\n\nFixes #789\n\n---\n\ndocs(api): document campaign endpoints\n\nAdds comprehensive API documentation for all campaign endpoints\nincluding request/response examples.\n

"},{"location":"v2/contributing/#6-push-and-create-pull-request","title":"6. Push and Create Pull Request","text":"
# Push to your fork\ngit push origin feature/campaign-export\n\n# Create pull request on GitHub\n# Fill out the PR template\n

\u2192 Pull Request Guidelines

"},{"location":"v2/contributing/#7-code-review","title":"7. Code Review","text":"

After submitting your PR:

  1. Automated checks run (lint, tests, build)
  2. Maintainer review provides feedback
  3. Address feedback with new commits
  4. Request re-review after changes
  5. Merge after approval

Be patient: Reviews may take 1-3 business days. If no response after 5 days, politely ping the maintainer.

"},{"location":"v2/contributing/#development-guidelines","title":"Development Guidelines","text":""},{"location":"v2/contributing/#code-style","title":"Code Style","text":"

TypeScript:

// Use interfaces for object shapes\ninterface Campaign {\n  id: string;\n  title: string;\n  slug: string;\n  active: boolean;\n}\n\n// Use types for unions/aliases\ntype SupportLevel = 'STRONG_SUPPORT' | 'SUPPORT' | 'UNDECIDED' | 'OPPOSED' | 'STRONG_OPPOSED';\n\n// Prefer async/await over promises\nasync function getCampaigns(): Promise<Campaign[]> {\n  const campaigns = await prisma.campaign.findMany();\n  return campaigns;\n}\n

React:

// Use functional components\nconst CampaignsPage: React.FC = () => {\n  const [campaigns, setCampaigns] = useState<Campaign[]>([]);\n\n  useEffect(() => {\n    fetchCampaigns();\n  }, []);\n\n  return <Table dataSource={campaigns} />;\n};\n\n// Extract reusable components\nconst CampaignCard: React.FC<{ campaign: Campaign }> = ({ campaign }) => {\n  return <Card title={campaign.title} />;\n};\n

Prisma:

// Use type-safe queries\nconst campaigns = await prisma.campaign.findMany({\n  where: { active: true },\n  include: { createdBy: true },\n  orderBy: { createdAt: 'desc' }\n});\n\n// Use transactions for multi-step operations\nawait prisma.$transaction(async (tx) => {\n  await tx.campaign.update({ where: { id }, data: { active: false } });\n  await tx.campaignEmail.updateMany({ where: { campaignId: id }, data: { status: 'CANCELLED' } });\n});\n

"},{"location":"v2/contributing/#testing-guidelines","title":"Testing Guidelines","text":"

Unit tests:

// api/src/modules/campaigns/campaigns.service.test.ts\ndescribe('CampaignService', () => {\n  it('should create campaign with valid data', async () => {\n    const campaign = await campaignService.create({\n      title: 'Test Campaign',\n      slug: 'test-campaign',\n      createdByUserId: 'user-id'\n    });\n\n    expect(campaign).toHaveProperty('id');\n    expect(campaign.title).toBe('Test Campaign');\n  });\n\n  it('should throw error for duplicate slug', async () => {\n    await expect(\n      campaignService.create({ title: 'Test', slug: 'existing', createdByUserId: 'user-id' })\n    ).rejects.toThrow('Slug already exists');\n  });\n});\n

Integration tests:

// api/tests/campaigns.integration.test.ts\ndescribe('Campaigns API', () => {\n  it('GET /api/influence/campaigns returns campaigns', async () => {\n    const response = await request(app)\n      .get('/api/influence/campaigns')\n      .set('Authorization', `Bearer ${adminToken}`);\n\n    expect(response.status).toBe(200);\n    expect(response.body.success).toBe(true);\n    expect(Array.isArray(response.body.data)).toBe(true);\n  });\n});\n

"},{"location":"v2/contributing/#communication-channels","title":"Communication Channels","text":""},{"location":"v2/contributing/#github","title":"GitHub","text":"
  • Issues: Bug reports, feature requests
  • Discussions: General questions, ideas
  • Pull Requests: Code contributions
"},{"location":"v2/contributing/#email","title":"Email","text":"
  • General: hello@cmlite.org
  • Security: security@cmlite.org
  • Code of Conduct: conduct@cmlite.org
"},{"location":"v2/contributing/#community-calls","title":"Community Calls","text":"
  • Monthly Contributors Call: First Tuesday of month, 7pm UTC
  • Quarterly Community Call: Last Friday of quarter, 6pm UTC

\u2192 Join Calls

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

We appreciate all contributors! Your name will be:

  • Added to CONTRIBUTORS.md after first merged PR
  • Listed in release notes for significant contributions
  • Featured on website for major features
  • Invited to community calls as a contributor
"},{"location":"v2/contributing/#hall-of-fame","title":"Hall of Fame","text":"

Top Contributors (all time):

  1. @contributor1 - 234 commits
  2. @contributor2 - 189 commits
  3. @contributor3 - 156 commits

\u2192 Full Contributors List

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

By contributing to Changemaker Lite, you agree that your contributions will be licensed under the MIT License.

This means: - Your code can be used by anyone - Attribution is required (copyright notice) - No warranty is provided

See LICENSE for full terms.

"},{"location":"v2/contributing/#questions","title":"Questions?","text":"
  • Need help getting started? Ask in Discussions
  • Have a question about an issue? Comment on the issue
  • Stuck on development setup? Check Development Setup Guide
  • Want to chat? Join our monthly contributors call
"},{"location":"v2/contributing/#related-documentation","title":"Related Documentation","text":"
  • Code of Conduct - Community standards
  • Development Setup - Environment setup
  • Pull Request Guidelines - PR process
  • Roadmap - Future plans
"},{"location":"v2/contributing/#next-steps","title":"Next Steps","text":"

Ready to contribute?

  1. Read the Code of Conduct - Understand community standards
  2. Set up your environment - Install dependencies
  3. Find an issue - Pick something to work on
  4. Submit your first PR - Make your contribution

Thank you for contributing to Changemaker Lite! Together, we're building tools for democratic change.

"},{"location":"v2/contributing/code-of-conduct/","title":"Code of Conduct","text":""},{"location":"v2/contributing/code-of-conduct/#our-pledge","title":"Our Pledge","text":"

We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.

We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.

"},{"location":"v2/contributing/code-of-conduct/#our-standards","title":"Our Standards","text":""},{"location":"v2/contributing/code-of-conduct/#examples-of-positive-behavior","title":"Examples of Positive Behavior","text":"

Behavior that contributes to a positive environment includes:

  • Demonstrating empathy and kindness toward other people
  • Being respectful of differing opinions, viewpoints, and experiences
  • Giving and gracefully accepting constructive feedback
  • Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
  • Focusing on what is best not just for us as individuals, but for the overall community
  • Using welcoming and inclusive language
  • Being patient and helpful with newcomers
  • Showing appreciation for contributions, no matter how small
"},{"location":"v2/contributing/code-of-conduct/#examples-of-unacceptable-behavior","title":"Examples of Unacceptable Behavior","text":"

Behavior that will not be tolerated includes:

  • Harassment: The use of sexualized language or imagery, and sexual attention or advances of any kind
  • Trolling: Inflammatory, insulting, or derogatory comments, and personal or political attacks
  • Discrimination: Discriminatory jokes, slurs, or language targeting any group
  • Privacy violations: Publishing others' private information (addresses, phone numbers, email) without explicit permission
  • Spam: Unsolicited promotion of products, services, or websites
  • Doxxing: Publishing someone's personal information with malicious intent
  • Intimidation: Threats of violence or deliberate intimidation
  • Disruption: Deliberately disrupting discussions, meetings, or events
  • Other conduct which could reasonably be considered inappropriate in a professional setting
"},{"location":"v2/contributing/code-of-conduct/#enforcement-responsibilities","title":"Enforcement Responsibilities","text":"

Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.

Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.

"},{"location":"v2/contributing/code-of-conduct/#scope","title":"Scope","text":"

This Code of Conduct applies within all community spaces, including but not limited to:

  • GitHub repositories: Issues, pull requests, discussions, wikis
  • Communication channels: Email, community calls, chat platforms
  • Events: Meetups, conferences, online gatherings
  • Public spaces: When representing the project (social media, forums, etc.)

This Code of Conduct also applies when an individual is officially representing the community in public spaces. Examples include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event.

"},{"location":"v2/contributing/code-of-conduct/#enforcement","title":"Enforcement","text":""},{"location":"v2/contributing/code-of-conduct/#reporting-violations","title":"Reporting Violations","text":"

Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at conduct@cmlite.org.

All complaints will be reviewed and investigated promptly and fairly.

What to include in a report:

  1. Your contact information (for follow-up)
  2. Names of people involved (or pseudonyms)
  3. Description of behavior (what happened)
  4. When and where it occurred
  5. Links or screenshots (if applicable)
  6. Any witnesses
  7. Whether you've reported elsewhere (e.g., to GitHub)

Confidentiality: All community leaders are obligated to respect the privacy and security of the reporter of any incident.

"},{"location":"v2/contributing/code-of-conduct/#investigation-process","title":"Investigation Process","text":"

Upon receiving a report:

  1. Acknowledgment: We will acknowledge receipt within 24 hours
  2. Investigation: We will review the report and gather additional information
  3. Decision: Community leaders will determine appropriate action
  4. Communication: We will inform the reporter of the outcome
  5. Action: We will enforce the decision

Timeline: Most investigations complete within 7 days.

"},{"location":"v2/contributing/code-of-conduct/#enforcement-guidelines","title":"Enforcement Guidelines","text":"

Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:

"},{"location":"v2/contributing/code-of-conduct/#1-correction","title":"1. Correction","text":"

Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.

Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.

Example: - Using mildly offensive language - Being dismissive of others' contributions - Minor disruptions in discussions

"},{"location":"v2/contributing/code-of-conduct/#2-warning","title":"2. Warning","text":"

Community Impact: A violation through a single incident or series of actions.

Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.

Example: - Repeated inappropriate language after correction - Personal attacks or insults - Sustained disruption of discussions

Duration: 7-30 days, depending on severity.

"},{"location":"v2/contributing/code-of-conduct/#3-temporary-ban","title":"3. Temporary Ban","text":"

Community Impact: A serious violation of community standards, including sustained inappropriate behavior.

Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.

Example: - Harassment or discrimination - Publishing private information - Threats or intimidation - Pattern of violations after warning

Duration: 30 days to 6 months.

"},{"location":"v2/contributing/code-of-conduct/#4-permanent-ban","title":"4. Permanent Ban","text":"

Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.

Consequence: A permanent ban from any sort of public interaction within the community.

Example: - Severe harassment or threats - Doxxing or privacy violations - Repeated violations after temporary ban - Violent or discriminatory content

Duration: Permanent.

"},{"location":"v2/contributing/code-of-conduct/#appeals","title":"Appeals","text":"

If you believe an enforcement decision was made in error, you may appeal by:

  1. Emailing conduct@cmlite.org within 14 days of the decision
  2. Providing your reasoning for why the decision was incorrect
  3. Suggesting alternative resolution (if applicable)

Appeals will be reviewed by a different community leader when possible. The appeal decision is final.

Note: Appeals are not guaranteed to result in a changed decision.

"},{"location":"v2/contributing/code-of-conduct/#attribution","title":"Attribution","text":"

This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.

Community Impact Guidelines were inspired by Mozilla's code of conduct enforcement ladder.

For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.

"},{"location":"v2/contributing/code-of-conduct/#contact","title":"Contact","text":"

Enforcement Team: conduct@cmlite.org

Project Maintainers: - Lead Maintainer: [Name] (email@cmlite.org) - Community Manager: [Name] (email@cmlite.org)

Response Time: We aim to respond to all reports within 24 hours.

"},{"location":"v2/contributing/code-of-conduct/#acknowledgments","title":"Acknowledgments","text":"

We thank all contributors who help maintain a welcoming and inclusive community. Special thanks to:

  • The Contributor Covenant team for the foundational code of conduct
  • The Mozilla community for enforcement guidelines
  • All community members who report violations to help keep our space safe
"},{"location":"v2/contributing/code-of-conduct/#version-history","title":"Version History","text":"
  • v2.1 (2026-02-13): Adopted from Contributor Covenant 2.1
  • Future updates will be announced via GitHub Discussions

Last updated: February 13, 2026

By participating in this community, you agree to abide by this Code of Conduct.

"},{"location":"v2/contributing/development-setup/","title":"Development Setup","text":"

This guide will help you set up a complete development environment for contributing to Changemaker Lite V2.

"},{"location":"v2/contributing/development-setup/#prerequisites","title":"Prerequisites","text":"

Before beginning, ensure you have the following installed:

"},{"location":"v2/contributing/development-setup/#required-software","title":"Required Software","text":"
  • Node.js 20+ (download)

    node --version  # Should be v20.x.x or higher\n

  • npm 10+ (comes with Node.js)

    npm --version  # Should be 10.x.x or higher\n

  • Docker Desktop (download)

    docker --version        # Should be 20.10.x or higher\ndocker compose version  # Should be 2.0.x or higher\n

  • Git (download)

    git --version  # Should be 2.x.x or higher\n

"},{"location":"v2/contributing/development-setup/#recommended-software","title":"Recommended Software","text":"
  • VSCode (download) - Recommended code editor
  • GitHub CLI (download) - Simplifies GitHub operations
  • Postman or Thunder Client - API testing (Thunder Client is VSCode extension)
"},{"location":"v2/contributing/development-setup/#system-requirements","title":"System Requirements","text":"
  • Operating System: Linux, macOS, or Windows (with WSL2)
  • RAM: 8GB minimum (16GB recommended)
  • Disk Space: 20GB free space
  • Internet: Required for npm packages and Docker images
"},{"location":"v2/contributing/development-setup/#fork-and-clone","title":"Fork and Clone","text":""},{"location":"v2/contributing/development-setup/#1-fork-the-repository","title":"1. Fork the Repository","text":"
  1. Visit https://github.com/changemaker-lite/v2
  2. Click Fork button (top right)
  3. Select your GitHub account as the destination
"},{"location":"v2/contributing/development-setup/#2-clone-your-fork","title":"2. Clone Your Fork","text":"
# Clone your fork (replace YOUR-USERNAME)\ngit clone https://github.com/YOUR-USERNAME/changemaker-lite.git\ncd changemaker-lite\n\n# Add upstream remote (original repository)\ngit remote add upstream https://github.com/changemaker-lite/v2.git\n\n# Verify remotes\ngit remote -v\n# Should show:\n# origin    https://github.com/YOUR-USERNAME/changemaker-lite.git (fetch)\n# origin    https://github.com/YOUR-USERNAME/changemaker-lite.git (push)\n# upstream  https://github.com/changemaker-lite/v2.git (fetch)\n# upstream  https://github.com/changemaker-lite/v2.git (push)\n
"},{"location":"v2/contributing/development-setup/#3-checkout-v2-branch","title":"3. Checkout V2 Branch","text":"
# Switch to v2 branch\ngit checkout v2\n\n# Verify you're on v2\ngit branch\n# Should show: * v2\n
"},{"location":"v2/contributing/development-setup/#environment-setup","title":"Environment Setup","text":""},{"location":"v2/contributing/development-setup/#1-create-environment-file","title":"1. Create Environment File","text":"
# Copy example environment file\ncp .env.example .env\n\n# Edit .env with your preferred editor\nnano .env  # or: code .env (VSCode)\n
"},{"location":"v2/contributing/development-setup/#2-configure-environment-variables","title":"2. Configure Environment Variables","text":"

Minimal development configuration:

# Database\nDATABASE_URL=postgresql://changemaker:devpassword@localhost:5433/changemaker_v2?schema=public\nV2_POSTGRES_USER=changemaker\nV2_POSTGRES_PASSWORD=devpassword\nV2_POSTGRES_DB=changemaker_v2\n\n# Redis\nREDIS_URL=redis://:devpassword@localhost:6379\nREDIS_PASSWORD=devpassword\n\n# JWT Secrets (generate with: openssl rand -hex 32)\nJWT_ACCESS_SECRET=your_access_secret_here_32_chars_min\nJWT_REFRESH_SECRET=your_refresh_secret_here_32_chars_min\nENCRYPTION_KEY=your_encryption_key_here_32_chars_min\n\n# API\nAPI_PORT=4000\nMEDIA_API_PORT=4100\nNODE_ENV=development\n\n# Email (test mode - uses MailHog)\nEMAIL_TEST_MODE=true\nSMTP_HOST=localhost\nSMTP_PORT=1025\nSMTP_FROM=dev@localhost\n\n# Feature Flags\nENABLE_MEDIA_FEATURES=true\nLISTMONK_SYNC_ENABLED=false\n

Generate secrets:

# Generate JWT secrets (run 3 times for each secret)\nopenssl rand -hex 32\n\n# On Windows (PowerShell):\n[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 }))\n

Security

Use different secrets for JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, and ENCRYPTION_KEY. Never commit .env to Git!

"},{"location":"v2/contributing/development-setup/#install-dependencies","title":"Install Dependencies","text":""},{"location":"v2/contributing/development-setup/#api-dependencies","title":"API Dependencies","text":"
cd api\n\n# Install npm packages\nnpm install\n\n# Verify installation\nnpm list prisma  # Should show @prisma/client and prisma packages\n
"},{"location":"v2/contributing/development-setup/#admin-dependencies","title":"Admin Dependencies","text":"
cd ../admin\n\n# Install npm packages\nnpm install\n\n# Verify installation\nnpm list react  # Should show react 19.x.x\n
"},{"location":"v2/contributing/development-setup/#database-setup","title":"Database Setup","text":""},{"location":"v2/contributing/development-setup/#option-1-docker-recommended-for-development","title":"Option 1: Docker (Recommended for Development)","text":"

Start PostgreSQL and Redis in Docker:

cd /home/bunker-admin/changemaker.lite\n\n# Start database services\ndocker compose up -d v2-postgres redis\n\n# Wait for PostgreSQL to be ready (about 10 seconds)\nsleep 10\n\n# Verify services running\ndocker compose ps\n# Should show v2-postgres and redis as \"running\"\n

Run Prisma migrations:

cd api\n\n# Run all migrations\nnpx prisma migrate deploy\n\n# Seed initial data (admin user, settings, blocks)\nnpx prisma db seed\n\n# Verify database\nnpx prisma studio\n# Opens browser at http://localhost:5555\n

Default admin credentials (from seed): - Email: admin@example.com - Password: Admin123!

Change Default Password

Change the default admin password immediately after first login in development.

"},{"location":"v2/contributing/development-setup/#option-2-local-postgresql-advanced","title":"Option 2: Local PostgreSQL (Advanced)","text":"

If you have PostgreSQL 16 installed locally:

# Create database and user\npsql -U postgres\nCREATE USER changemaker WITH PASSWORD 'devpassword';\nCREATE DATABASE changemaker_v2 OWNER changemaker;\n\\q\n\n# Update .env DATABASE_URL\nDATABASE_URL=postgresql://changemaker:devpassword@localhost:5432/changemaker_v2?schema=public\n\n# Run migrations\ncd api\nnpx prisma migrate deploy\nnpx prisma db seed\n
"},{"location":"v2/contributing/development-setup/#running-development-servers","title":"Running Development Servers","text":""},{"location":"v2/contributing/development-setup/#method-1-docker-compose-full-stack","title":"Method 1: Docker Compose (Full Stack)","text":"

Run all services in Docker:

# Start all services\ndocker compose up -d\n\n# View logs\ndocker compose logs -f api admin\n\n# Stop services\ndocker compose down\n

Access points: - Admin: http://localhost:3000 - API: http://localhost:4000 - Media API: http://localhost:4100 - Prisma Studio: cd api && npx prisma studio - MailHog: http://localhost:8025

"},{"location":"v2/contributing/development-setup/#method-2-local-development-hot-reload","title":"Method 2: Local Development (Hot Reload)","text":"

Run services locally for faster development:

Terminal 1 - API:

cd api\nnpm run dev\n\n# Runs on http://localhost:4000\n# Hot reload enabled (nodemon)\n

Terminal 2 - Admin:

cd admin\nnpm run dev\n\n# Runs on http://localhost:3000\n# Hot reload enabled (Vite HMR)\n

Terminal 3 - Media API (optional):

cd api\nnpm run dev:media\n\n# Runs on http://localhost:4100\n# Hot reload enabled (nodemon)\n

Terminal 4 - Database (Docker):

# Keep PostgreSQL and Redis running\ndocker compose up -d v2-postgres redis\n

Recommended Workflow

Use Method 2 (local) for frontend/backend development (faster hot reload). Use Method 1 (Docker) for testing full stack integration.

"},{"location":"v2/contributing/development-setup/#vscode-setup","title":"VSCode Setup","text":""},{"location":"v2/contributing/development-setup/#recommended-extensions","title":"Recommended Extensions","text":"

Install these VSCode extensions for better development experience:

{\n  \"recommendations\": [\n    \"dbaeumer.vscode-eslint\",           // ESLint\n    \"esbenp.prettier-vscode\",           // Prettier\n    \"prisma.prisma\",                    // Prisma syntax\n    \"bradlc.vscode-tailwindcss\",        // Tailwind (if using)\n    \"ms-azuretools.vscode-docker\",      // Docker\n    \"rangav.vscode-thunder-client\",     // API testing\n    \"editorconfig.editorconfig\",        // EditorConfig\n    \"streetsidesoftware.code-spell-checker\" // Spell check\n  ]\n}\n
"},{"location":"v2/contributing/development-setup/#workspace-settings","title":"Workspace Settings","text":"

Create .vscode/settings.json:

{\n  \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n  \"editor.formatOnSave\": true,\n  \"editor.codeActionsOnSave\": {\n    \"source.fixAll.eslint\": true\n  },\n  \"typescript.tsdk\": \"node_modules/typescript/lib\",\n  \"typescript.enablePromptUseWorkspaceTsdk\": true,\n  \"files.exclude\": {\n    \"**/.git\": true,\n    \"**/.DS_Store\": true,\n    \"**/node_modules\": true,\n    \"**/dist\": true\n  }\n}\n
"},{"location":"v2/contributing/development-setup/#debug-configuration","title":"Debug Configuration","text":"

Create .vscode/launch.json for debugging:

{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"name\": \"API (Node)\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"runtimeExecutable\": \"npm\",\n      \"runtimeArgs\": [\"run\", \"dev\"],\n      \"cwd\": \"${workspaceFolder}/api\",\n      \"console\": \"integratedTerminal\",\n      \"internalConsoleOptions\": \"neverOpen\",\n      \"skipFiles\": [\"<node_internals>/**\"]\n    },\n    {\n      \"name\": \"Admin (Chrome)\",\n      \"type\": \"chrome\",\n      \"request\": \"launch\",\n      \"url\": \"http://localhost:3000\",\n      \"webRoot\": \"${workspaceFolder}/admin/src\",\n      \"sourceMapPathOverrides\": {\n        \"webpack:///src/*\": \"${webRoot}/*\"\n      }\n    }\n  ]\n}\n
"},{"location":"v2/contributing/development-setup/#making-changes","title":"Making Changes","text":""},{"location":"v2/contributing/development-setup/#1-create-feature-branch","title":"1. Create Feature Branch","text":"
# Fetch latest changes\ngit fetch upstream\ngit checkout v2\ngit merge upstream/v2\n\n# Create feature branch\ngit checkout -b feature/your-feature-name\n\n# Or for bug fix\ngit checkout -b fix/bug-description\n
"},{"location":"v2/contributing/development-setup/#2-code-style","title":"2. Code Style","text":"

Follow project conventions:

Run linter:

cd api && npm run lint        # Backend\ncd admin && npm run lint      # Frontend\n

Auto-fix:

cd api && npm run lint:fix    # Backend\ncd admin && npm run lint:fix  # Frontend\n

Format code:

cd api && npm run format      # Backend\ncd admin && npm run format    # Frontend\n

Type check:

cd api && npx tsc --noEmit    # Backend\ncd admin && npx tsc --noEmit  # Frontend\n

"},{"location":"v2/contributing/development-setup/#3-run-tests","title":"3. Run Tests","text":"
# API unit tests\ncd api && npm test\n\n# API integration tests\ncd api && npm run test:integration\n\n# Frontend tests\ncd admin && npm test\n\n# End-to-end tests\nnpm run test:e2e\n
"},{"location":"v2/contributing/development-setup/#4-test-your-changes","title":"4. Test Your Changes","text":"

Manual testing checklist:

  • API endpoints work (use Postman/Thunder Client)
  • Frontend renders correctly
  • No console errors
  • Works in Chrome, Firefox, Safari
  • Responsive design (mobile, tablet, desktop)
  • Accessibility (keyboard navigation, screen reader)
  • Error handling (try invalid inputs)
  • Loading states (try slow network)

Integration testing:

# Start full stack\ndocker compose up -d\n\n# Run integration tests\n./scripts/test-integration.sh\n\n# Check logs for errors\ndocker compose logs -f\n

"},{"location":"v2/contributing/development-setup/#staying-synced-with-upstream","title":"Staying Synced with Upstream","text":"

Regularly sync your fork with the upstream repository:

# Fetch upstream changes\ngit fetch upstream\n\n# Merge into your local v2 branch\ngit checkout v2\ngit merge upstream/v2\n\n# Push to your fork\ngit push origin v2\n\n# Rebase your feature branch (optional, cleaner history)\ngit checkout feature/your-feature-name\ngit rebase v2\n

Rebase vs Merge

Use git rebase v2 for cleaner commit history. Use git merge v2 if you're unsure about rebasing.

"},{"location":"v2/contributing/development-setup/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/contributing/development-setup/#port-conflicts","title":"Port Conflicts","text":"

Error: Port 3000 already in use

Solution:

# Find process using port\nlsof -i :3000  # macOS/Linux\nnetstat -ano | findstr :3000  # Windows\n\n# Kill process or change port\n# Edit .env: ADMIN_PORT=3001\n

"},{"location":"v2/contributing/development-setup/#database-connection-errors","title":"Database Connection Errors","text":"

Error: Can't reach database server at localhost:5433

Solution:

# Check if PostgreSQL is running\ndocker compose ps v2-postgres\n\n# Restart PostgreSQL\ndocker compose restart v2-postgres\n\n# Check logs\ndocker compose logs v2-postgres\n\n# Verify DATABASE_URL in .env\n

"},{"location":"v2/contributing/development-setup/#migration-errors","title":"Migration Errors","text":"

Error: Migration failed

Solution:

# Reset database (WARNING: deletes all data)\ncd api\nnpx prisma migrate reset\n\n# Or manually drop and recreate\ndocker compose exec v2-postgres psql -U changemaker -d postgres -c \"DROP DATABASE changemaker_v2;\"\ndocker compose exec v2-postgres psql -U changemaker -d postgres -c \"CREATE DATABASE changemaker_v2 OWNER changemaker;\"\nnpx prisma migrate deploy\nnpx prisma db seed\n

"},{"location":"v2/contributing/development-setup/#dependency-installation-errors","title":"Dependency Installation Errors","text":"

Error: npm install fails

Solution:

# Clear npm cache\nnpm cache clean --force\n\n# Remove node_modules and package-lock.json\nrm -rf node_modules package-lock.json\n\n# Reinstall\nnpm install\n\n# If still fails, try older Node version\nnvm install 20.11.0\nnvm use 20.11.0\nnpm install\n

"},{"location":"v2/contributing/development-setup/#docker-issues","title":"Docker Issues","text":"

Error: Docker daemon not running

Solution: - macOS/Windows: Start Docker Desktop - Linux: sudo systemctl start docker

Error: Permission denied (Docker)

Solution (Linux):

# Add user to docker group\nsudo usermod -aG docker $USER\n\n# Log out and back in, or:\nnewgrp docker\n

"},{"location":"v2/contributing/development-setup/#next-steps","title":"Next Steps","text":"

Now that your environment is set up:

  1. Find an issue to work on
  2. Review code style guidelines
  3. Create your first PR
  4. Join the community
"},{"location":"v2/contributing/development-setup/#related-documentation","title":"Related Documentation","text":"
  • Contributing Guide - Contribution overview
  • Pull Request Guidelines - PR process
  • Code of Conduct - Community standards
  • Architecture - System design
"},{"location":"v2/contributing/development-setup/#getting-help","title":"Getting Help","text":"

Stuck on setup? Ask for help:

  • GitHub Discussions: Ask a question
  • Discord: Join our server
  • Email: dev@cmlite.org

Happy coding! \ud83d\ude80

"},{"location":"v2/contributing/pull-requests/","title":"Pull Request Guidelines","text":"

This guide covers the complete pull request (PR) process for contributing code to Changemaker Lite V2, from creation to merge.

"},{"location":"v2/contributing/pull-requests/#before-submitting-a-pr","title":"Before Submitting a PR","text":""},{"location":"v2/contributing/pull-requests/#1-create-or-find-an-issue","title":"1. Create or Find an Issue","text":"

For features: - Search existing issues first - If none exists, create a feature request - Wait for maintainer approval before implementing - Discuss implementation approach in the issue

For bugs: - Search existing bug reports first - If none exists, create a bug report - Include reproduction steps and expected vs actual behavior - Verify bug still exists on latest v2 branch

Avoid Wasted Effort

Always create an issue and get approval before spending time on a large feature. Maintainers may have alternative approaches or priorities.

"},{"location":"v2/contributing/pull-requests/#2-test-your-changes","title":"2. Test Your Changes","text":"

Run all checks locally before submitting:

# Type checking\ncd api && npx tsc --noEmit\ncd admin && npx tsc --noEmit\n\n# Linting\ncd api && npm run lint\ncd admin && npm run lint\n\n# Unit tests\ncd api && npm test\ncd admin && npm test\n\n# Integration tests (if applicable)\ncd api && npm run test:integration\n\n# Build\ncd api && npm run build\ncd admin && npm run build\n

All checks must pass before submitting PR.

"},{"location":"v2/contributing/pull-requests/#3-update-documentation","title":"3. Update Documentation","text":"

If your changes affect:

  • API endpoints: Update API reference
  • User features: Update user guides
  • Environment variables: Update .env.example
  • Database schema: Update database docs
  • Configuration: Update deployment guides

Documentation is Required

PRs with new features will not be merged without corresponding documentation updates.

"},{"location":"v2/contributing/pull-requests/#pr-title-format","title":"PR Title Format","text":"

Use Conventional Commits format:

type(scope): short description\n
"},{"location":"v2/contributing/pull-requests/#types","title":"Types","text":"Type When to Use feat New feature for users fix Bug fix docs Documentation changes style Code formatting (no behavior change) refactor Code restructuring (no behavior change) perf Performance improvements test Test additions or fixes chore Build process, tooling, dependencies ci CI/CD configuration revert Reverting a previous commit"},{"location":"v2/contributing/pull-requests/#scopes","title":"Scopes","text":"

Common scopes by area:

Backend: - api - General API changes - auth - Authentication/authorization - campaigns - Campaign module - locations - Location module - shifts - Shift module - canvass - Canvassing module - email - Email sending - database - Database schema/migrations

Frontend: - admin - Admin pages - public - Public pages - volunteer - Volunteer portal - components - React components - store - Zustand stores - ui - UI/UX changes

Infrastructure: - docker - Docker/Docker Compose - nginx - Nginx configuration - monitoring - Prometheus/Grafana - deployment - Deployment scripts/config

"},{"location":"v2/contributing/pull-requests/#examples","title":"Examples","text":"

Good titles: - \u2705 feat(campaigns): add campaign export to CSV - \u2705 fix(geocoding): handle null responses from Nominatim - \u2705 docs(api): document campaign endpoints - \u2705 refactor(auth): extract JWT middleware to separate file - \u2705 perf(locations): add database index on postalCode

Bad titles: - \u274c Update campaigns.tsx (too vague) - \u274c Bug fix (no scope or description) - \u274c WIP: New feature (don't submit WIP PRs) - \u274c Fixed everything (not descriptive)

"},{"location":"v2/contributing/pull-requests/#pr-description-template","title":"PR Description Template","text":"

Use this template for your PR description:

## What\n\n[Clear description of what this PR does]\n\n## Why\n\n[Why this change is needed, link to issue]\n\n## How\n\n[Brief explanation of implementation approach]\n\n## Testing\n\n[How to test these changes]\n\n## Screenshots\n\n[For UI changes, include before/after screenshots]\n\n## Checklist\n\n- [ ] Tests added/updated\n- [ ] Documentation updated\n- [ ] No console errors\n- [ ] All CI checks pass\n- [ ] Follows code style guidelines\n\nFixes #[issue-number]\n
"},{"location":"v2/contributing/pull-requests/#example-pr-description","title":"Example PR Description","text":"
## What\n\nAdds a CSV export button to the campaigns page that allows admins to download all campaigns with their metadata.\n\n## Why\n\nUsers need to export campaign data for reporting and analysis in external tools like Excel.\n\nFixes #456\n\n## How\n\n- Added export button to CampaignsPage header\n- Created `/api/influence/campaigns/export` endpoint\n- Implemented CSV generation using `csv-stringify` library\n- Added SUPER_ADMIN/INFLUENCE_ADMIN role check\n\n## Testing\n\n1. Login as admin user\n2. Navigate to Campaigns page (/app/influence/campaigns)\n3. Click \"Export CSV\" button in page header\n4. Verify CSV file downloads with correct data\n5. Open CSV in Excel/Google Sheets to verify formatting\n\n## Screenshots\n\n![Export button in campaigns page](https://user-images.githubusercontent.com/export-button.png)\n\n## Checklist\n\n- [x] Tests added (export endpoint integration test)\n- [x] Documentation updated (API reference, admin guide)\n- [x] No console errors\n- [x] All CI checks pass\n- [x] Follows code style guidelines\n\nFixes #456\n
"},{"location":"v2/contributing/pull-requests/#creating-the-pr","title":"Creating the PR","text":""},{"location":"v2/contributing/pull-requests/#1-push-your-branch","title":"1. Push Your Branch","text":"
# Ensure your branch is up to date\ngit fetch upstream\ngit rebase upstream/v2  # or: git merge upstream/v2\n\n# Push to your fork\ngit push origin feature/your-feature-name\n\n# If you rebased, force push (with care!)\ngit push --force-with-lease origin feature/your-feature-name\n
"},{"location":"v2/contributing/pull-requests/#2-open-pr-on-github","title":"2. Open PR on GitHub","text":"
  1. Go to your fork on GitHub
  2. Click \"Pull requests\" tab
  3. Click \"New pull request\"
  4. Base repository: changemaker-lite/v2 base: v2
  5. Head repository: YOUR-USERNAME/changemaker-lite compare: feature/your-feature-name
  6. Click \"Create pull request\"
  7. Fill out the PR template (see above)
  8. Click \"Create pull request\"
"},{"location":"v2/contributing/pull-requests/#3-request-reviewers","title":"3. Request Reviewers","text":"
  • PRs are automatically assigned to maintainers
  • You can request specific reviewers if you know who to ask
  • For urgent PRs, mention @changemaker-lite/maintainers
"},{"location":"v2/contributing/pull-requests/#code-review-process","title":"Code Review Process","text":""},{"location":"v2/contributing/pull-requests/#automated-checks","title":"Automated Checks","text":"

After submitting, CI/CD runs these checks:

  1. Lint: ESLint rules
  2. Type Check: TypeScript compilation
  3. Tests: Unit + integration tests
  4. Build: Production build
  5. Security: Dependency vulnerability scan

Status badges appear on your PR:

  • \u2705 Green checkmark: All checks passed
  • \u274c Red X: Some checks failed
  • \ud83d\udfe1 Yellow dot: Checks in progress

Fix failing checks before requesting review.

"},{"location":"v2/contributing/pull-requests/#maintainer-review","title":"Maintainer Review","text":"

A maintainer will review your code and provide feedback:

Review categories:

  1. Code quality:
  2. Follows code style guidelines
  3. No unnecessary complexity
  4. Proper error handling
  5. No security vulnerabilities

  6. Functionality:

  7. Solves the problem correctly
  8. Edge cases handled
  9. No regressions

  10. Tests:

  11. Adequate test coverage (>80%)
  12. Tests are meaningful
  13. Tests pass consistently

  14. Documentation:

  15. Code comments for complex logic
  16. API documentation updated
  17. User guide updated (if needed)
"},{"location":"v2/contributing/pull-requests/#review-outcomes","title":"Review Outcomes","text":"

Approved \u2705: - Maintainer approves PR - Ready to merge (after squash)

Request Changes \ud83d\udd04: - Maintainer requests modifications - Address feedback and push new commits - Re-request review after changes

Comment \ud83d\udcac: - Feedback without blocking merge - Optional to address

"},{"location":"v2/contributing/pull-requests/#addressing-feedback","title":"Addressing Feedback","text":""},{"location":"v2/contributing/pull-requests/#1-read-feedback-carefully","title":"1. Read Feedback Carefully","text":"
  • Understand the requested change
  • Ask clarifying questions if unclear
  • Don't take criticism personally (it's about code, not you)
"},{"location":"v2/contributing/pull-requests/#2-make-changes","title":"2. Make Changes","text":"
# Make requested changes\n# Edit files...\n\n# Commit changes\ngit add .\ngit commit -m \"refactor: address review feedback\n\n- Extracted duplicate logic into helper function\n- Added error handling for edge case\n- Updated tests to cover new scenario\"\n\n# Push to same branch\ngit push origin feature/your-feature-name\n

Commits are added to existing PR automatically.

"},{"location":"v2/contributing/pull-requests/#3-respond-to-comments","title":"3. Respond to Comments","text":"
  • Acknowledge feedback: \"Good catch, fixed in abc1234\"
  • Explain changes: \"Refactored this to use a switch statement instead\"
  • Ask questions: \"I'm not sure how to handle X, suggestions?\"
  • Mark resolved: Click \"Resolve conversation\" after addressing
"},{"location":"v2/contributing/pull-requests/#4-re-request-review","title":"4. Re-Request Review","text":"

After addressing all feedback:

  1. Click \"Reviewers\" section
  2. Click circular arrow next to reviewer's name
  3. Or comment @reviewer Ready for re-review
"},{"location":"v2/contributing/pull-requests/#common-review-feedback","title":"Common Review Feedback","text":""},{"location":"v2/contributing/pull-requests/#code-quality-issues","title":"Code Quality Issues","text":"

Issue: Large function with too many responsibilities

Feedback:

This function is doing too much. Can you extract the geocoding logic into a separate function?

Fix:

// Before\nasync function createLocation(data) {\n  // 50 lines of validation, geocoding, database insert...\n}\n\n// After\nasync function createLocation(data) {\n  const validated = validateLocationData(data);\n  const geocoded = await geocodeAddress(validated.address);\n  return insertLocation({ ...validated, ...geocoded });\n}\n

Issue: Magic numbers or strings

Feedback:

What does 30 represent here? Use a named constant.

Fix:

// Before\nif (visits.length >= 30) { }\n\n// After\nconst VISIT_RATE_LIMIT = 30;\nif (visits.length >= VISIT_RATE_LIMIT) { }\n

Issue: Missing error handling

Feedback:

What happens if the API call fails? Add error handling.

Fix:

// Before\nconst reps = await fetch(url).then(r => r.json());\n\n// After\ntry {\n  const response = await fetch(url);\n  if (!response.ok) {\n    throw new Error(`API returned ${response.status}`);\n  }\n  const reps = await response.json();\n} catch (error) {\n  logger.error('Failed to fetch representatives', error);\n  throw new Error('Unable to lookup representatives');\n}\n

"},{"location":"v2/contributing/pull-requests/#test-coverage-issues","title":"Test Coverage Issues","text":"

Issue: Missing test for edge case

Feedback:

Add a test for when postal code is invalid.

Fix:

it('should return 400 for invalid postal code', async () => {\n  const response = await request(app)\n    .post('/api/influence/representatives/lookup')\n    .send({ postalCode: 'INVALID' });\n\n  expect(response.status).toBe(400);\n  expect(response.body.success).toBe(false);\n});\n

"},{"location":"v2/contributing/pull-requests/#documentation-issues","title":"Documentation Issues","text":"

Issue: Missing API documentation

Feedback:

Add this endpoint to the API reference docs.

Fix: Update docs/v2/api-reference/campaigns.md with new endpoint.

"},{"location":"v2/contributing/pull-requests/#performance-issues","title":"Performance Issues","text":"

Issue: N+1 query problem

Feedback:

This causes N+1 queries. Use Prisma include to join.

Fix:

// Before (N+1)\nconst campaigns = await prisma.campaign.findMany();\nfor (const campaign of campaigns) {\n  campaign.createdBy = await prisma.user.findUnique({ where: { id: campaign.createdByUserId } });\n}\n\n// After (single query)\nconst campaigns = await prisma.campaign.findMany({\n  include: { createdBy: true }\n});\n

"},{"location":"v2/contributing/pull-requests/#merge-process","title":"Merge Process","text":""},{"location":"v2/contributing/pull-requests/#squash-and-merge","title":"Squash and Merge","text":"

Changemaker Lite uses squash and merge for all PRs:

  1. Maintainer clicks \"Squash and merge\"
  2. All commits in PR are squashed into one commit
  3. Commit message = PR title + description summary
  4. Merged to v2 branch

Why squash? - Clean linear history - Easier to revert if needed - No messy \"WIP\" or \"fix typo\" commits

"},{"location":"v2/contributing/pull-requests/#after-merge","title":"After Merge","text":"

Once your PR is merged:

  1. Celebrate! \ud83c\udf89 You've contributed to Changemaker Lite
  2. Update your fork:
    git checkout v2\ngit pull upstream v2\ngit push origin v2\n
  3. Delete feature branch (optional):
    git branch -d feature/your-feature-name\ngit push origin --delete feature/your-feature-name\n
  4. Update issue: GitHub auto-closes issue with Fixes #N
  5. Check release notes: Your contribution will be mentioned in next release
"},{"location":"v2/contributing/pull-requests/#pr-checklist","title":"PR Checklist","text":"

Use this before submitting:

"},{"location":"v2/contributing/pull-requests/#pre-submission","title":"Pre-Submission","text":"
  • Issue created and approved by maintainer
  • Branch created from latest v2
  • Changes implemented following code style
  • Self-review - read your own code critically
  • Manual testing - verify changes work as expected
"},{"location":"v2/contributing/pull-requests/#code-quality","title":"Code Quality","text":"
  • TypeScript: No type errors (npx tsc --noEmit)
  • Linting: No lint errors (npm run lint)
  • Formatting: Code formatted (npm run format)
  • No console logs: Remove debug statements
  • No commented code: Remove old code
  • Error handling: All errors caught and logged
"},{"location":"v2/contributing/pull-requests/#tests","title":"Tests","text":"
  • Unit tests: Added/updated tests
  • Tests pass: npm test succeeds
  • Coverage: Maintained or improved (>80%)
  • Integration tests: Added if needed
  • Edge cases: Tested invalid inputs
"},{"location":"v2/contributing/pull-requests/#documentation","title":"Documentation","text":"
  • Code comments: Complex logic documented
  • API docs: New endpoints documented
  • User docs: User guide updated (if user-facing)
  • README: Updated if needed
  • .env.example: New env vars added
"},{"location":"v2/contributing/pull-requests/#ui-if-applicable","title":"UI (if applicable)","text":"
  • Responsive: Works on mobile/tablet/desktop
  • Accessibility: Keyboard navigation works
  • Browser testing: Works in Chrome, Firefox, Safari
  • Loading states: Spinners for async operations
  • Error states: Error messages shown to user
  • Screenshots: Included in PR description
"},{"location":"v2/contributing/pull-requests/#final-checks","title":"Final Checks","text":"
  • CI passing: All automated checks green
  • PR template: Description complete
  • Commit messages: Follow conventional commits
  • No merge conflicts: Branch rebased/merged with v2
  • Reviewers requested: Maintainers notified
"},{"location":"v2/contributing/pull-requests/#troubleshooting-prs","title":"Troubleshooting PRs","text":""},{"location":"v2/contributing/pull-requests/#ci-checks-failing","title":"CI Checks Failing","text":"

Lint failures:

cd api && npm run lint:fix\ncd admin && npm run lint:fix\ngit add . && git commit -m \"chore: fix lint errors\" && git push\n

Type errors:

cd api && npx tsc --noEmit  # Shows errors\n# Fix type errors in code\ngit add . && git commit -m \"fix: resolve type errors\" && git push\n

Test failures:

cd api && npm test  # Run locally to see errors\n# Fix failing tests\ngit add . && git commit -m \"test: fix failing tests\" && git push\n

"},{"location":"v2/contributing/pull-requests/#merge-conflicts","title":"Merge Conflicts","text":"

Resolving conflicts:

# Fetch latest upstream\ngit fetch upstream\n\n# Rebase onto v2\ngit rebase upstream/v2\n\n# If conflicts, resolve them\n# Edit conflicted files, then:\ngit add .\ngit rebase --continue\n\n# Force push (since history changed)\ngit push --force-with-lease origin feature/your-feature-name\n

"},{"location":"v2/contributing/pull-requests/#pr-not-getting-reviewed","title":"PR Not Getting Reviewed","text":"

If no review after 5 business days:

  1. Check CI: Ensure all checks pass
  2. Ping maintainer: Comment \"@changemaker-lite/maintainers Friendly ping for review\"
  3. Join Discord: Ask in #contributors channel
  4. Email: dev@cmlite.org for urgent PRs

Reasons for delays: - Maintainers busy with other priorities - PR too large (break into smaller PRs) - Missing context (add more details to description) - Waiting on related PRs to merge first

"},{"location":"v2/contributing/pull-requests/#related-documentation","title":"Related Documentation","text":"
  • Contributing Guide - Overview
  • Development Setup - Environment setup
  • Code of Conduct - Community standards
  • Roadmap - Future plans
"},{"location":"v2/contributing/pull-requests/#questions","title":"Questions?","text":"
  • Discord: #contributors channel
  • Discussions: Ask in Q&A
  • Email: dev@cmlite.org

Thank you for contributing! Every PR helps make Changemaker Lite better. \ud83d\ude80

"},{"location":"v2/contributing/roadmap/","title":"Changemaker Lite V2 Roadmap","text":"

This roadmap outlines the development journey of Changemaker Lite V2, including completed phases, current work, and future plans.

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

V2 is a complete rebuild of Changemaker Lite, transitioning from two separate Express apps to a unified modern TypeScript stack. The rebuild began in January 2025 and Phase 14 completed in February 2026.

Current Status: \u2705 Phase 1-14 Complete | \ud83d\udea7 Phase 15 In Progress

"},{"location":"v2/contributing/roadmap/#completed-phases-1-14","title":"Completed Phases (1-14)","text":""},{"location":"v2/contributing/roadmap/#phase-1-foundation-complete","title":"Phase 1: Foundation \u2705 COMPLETE","text":"

Timeline: January 2025

Deliverables: - Initialized api/ with TypeScript, Express, Prisma - Created comprehensive Prisma schema (30+ models) - Set up environment configuration (Zod validation) - Implemented middleware (error handling, validation, rate limiting) - Built utility modules (logger, metrics) - Initialized admin/ with Vite + React + Ant Design - Created Docker Compose orchestration - Wrote .env.example with 100+ variables - Backed up V1 to docker-compose.v1.yml

Key Achievements: - Clean-room architecture established - Type-safe foundation with TypeScript - Scalable project structure

"},{"location":"v2/contributing/roadmap/#phase-2-auth-user-management-complete","title":"Phase 2: Auth + User Management \u2705 COMPLETE","text":"

Timeline: January 2025

Deliverables: - Express Request type augmentation - Zod auth schemas - JWT auth service (login, register, refresh, logout) - Auth middleware (JWT verification) - RBAC middleware (role-based access) - User CRUD service + routes - Integration tested (Postman)

Key Achievements: - JWT refresh token rotation (atomic transaction) - 5 user roles (SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP) - Secure bcrypt password hashing - User enumeration prevention (401 for invalid credentials)

"},{"location":"v2/contributing/roadmap/#phase-3-admin-gui-foundation-complete","title":"Phase 3: Admin GUI Foundation \u2705 COMPLETE","text":"

Timeline: January 2025

Deliverables: - Zustand auth store with token management - Login page with form validation - Protected route wrapper - AppLayout with sidebar navigation - UsersPage with CRUD operations - Axios client with 401 refresh interceptor (callback pattern)

Key Achievements: - Automatic token refresh (seamless UX) - Role-based sidebar navigation - Responsive Ant Design components

"},{"location":"v2/contributing/roadmap/#phase-4-influence-campaigns-complete","title":"Phase 4: Influence \u2014 Campaigns \u2705 COMPLETE","text":"

Timeline: January 2025

Deliverables: - Campaign Zod schemas - Campaign service (CRUD, slug generation, toggle highlighting) - Campaign admin routes - CampaignsPage (table, filters, CRUD modals) - Feature flag integration

Key Achievements: - Unique slug generation - Highlighted campaign toggle - Response wall enable/disable per campaign

"},{"location":"v2/contributing/roadmap/#phase-5-influence-representatives-postal-codes-complete","title":"Phase 5: Influence \u2014 Representatives + Postal Codes \u2705 COMPLETE","text":"

Timeline: January 2025

Deliverables: - Postal code validation schemas (Canadian format) - Postal code cache service (Prisma) - Represent API client (typed, rate-limited 55/min) - Representative service (cache-first lookup, fire-and-forget writes) - Representative admin routes (list, stats, detail, delete) - RepresentativesPage (lookup, stats cards, table, detail modal)

Key Achievements: - Redis cache (60min TTL, ~20ms lookup) - In-memory rate limiter (Represent API limit) - Cache stats dashboard (total, by level, by party)

"},{"location":"v2/contributing/roadmap/#phase-6-influence-email-sending-complete","title":"Phase 6: Influence \u2014 Email Sending \u2705 COMPLETE","text":"

Timeline: January 2025

Deliverables: - BullMQ email queue setup - Email worker (SMTP via nodemailer) - Campaign email service (compose, queue, track) - Campaign email routes (send, track mailto, list, stats) - Email queue admin routes (stats, pause, resume, clean) - EmailQueuePage (monitoring, controls) - CampaignEmailsDrawer (stats + list from CampaignsPage)

Key Achievements: - Async email processing (BullMQ) - Email test mode (MailHog) - Rate limiting (30 req/hour per IP) - Job retry with exponential backoff

"},{"location":"v2/contributing/roadmap/#phase-7-influence-response-wall-public-campaign-view-complete","title":"Phase 7: Influence \u2014 Response Wall + Public Campaign View \u2705 COMPLETE","text":"

Timeline: January-February 2025

Deliverables: - Response service (submit, moderate, verify) - Response routes (3 routers: campaign-public, response-public, admin) - Email verification (HTML templates, verify/report endpoints) - ResponsesPage (filters, approve/reject/delete, detail drawer) - ResponseWallPage (sort, filter, submit modal, upvote) - Upvoting system (IP + user dedup, optimistic UI) - CampaignPage (postal code lookup, email sending) - CampaignsListPage (hero, featured, grid) - PublicLayout (dark theme for public pages)

Key Achievements: - Moderation workflow (PENDING \u2192 APPROVED/REJECTED) - Upvote deduplication (IP address + user ID) - Public campaign discovery

"},{"location":"v2/contributing/roadmap/#phase-8-map-locations-complete","title":"Phase 8: Map \u2014 Locations \u2705 COMPLETE","text":"

Timeline: February 2025

Deliverables: - Multi-provider geocoding service (Nominatim, ArcGIS, Photon, Mapbox, Google, OpenCage) - Location service (CRUD, geocoding, stats, bulk operations) - Location routes (admin + public) - MapSettings service + routes (singleton config) - LocationsPage (table, stats, CRUD, geocode button, CSV import/export) - MapSettingsPage (center/zoom, walk sheet config) - Public MapPage (Leaflet, circle markers, color-coded, multi-unit grouping, cut overlays, geolocate, fullscreen) - MapLegend component - MapControls (click-to-add, move, geolocate, fullscreen) - CutDrawingMode (polygon drawing with close detection) - CutOverlays + CutOverlayControls

Key Achievements: - 6 geocoding providers with automatic fallback - Geocoding quality tracking (provider, timestamp, quality score) - CSV import with flexible column mapping - Admin map enhancements (click-to-add, drag-to-move) - Point-in-polygon spatial queries (ray-casting algorithm)

"},{"location":"v2/contributing/roadmap/#phase-9-map-shifts-complete","title":"Phase 9: Map \u2014 Shifts \u2705 COMPLETE","text":"

Timeline: February 2025

Deliverables: - Shift service (CRUD, signup management) - Shift routes (admin + public) - ShiftsPage (CRUD, signups drawer, email all signups) - Public ShiftsPage (calendar view, signup cards, signup modal) - Temp user creation (30-day expiry) - Confirmation emails

Key Achievements: - Cut assignment (link shift to territory) - Signup status tracking (PENDING, CONFIRMED, CANCELLED, COMPLETED, NO_SHOW) - Public signup flow with temp user auto-creation - Email all shift signups (broadcast feature)

"},{"location":"v2/contributing/roadmap/#phase-10-walk-sheets-qr-codes-complete","title":"Phase 10: Walk Sheets & QR Codes \u2705 COMPLETE","text":"

Timeline: February 2025

Deliverables: - QR code generation endpoint (GET /api/qr, public, no auth) - WalkSheetPage (printable form with QR codes, browser print) - CutExportPage (printable location report with stats + table) - Sidebar navigation + route wiring

Key Achievements: - QR codes encode location data (address, coordinates, notes) - Print-optimized CSS (page breaks, hide buttons) - Cut-specific walk sheets (filter by cut)

"},{"location":"v2/contributing/roadmap/#phase-11-listmonk-integration-complete","title":"Phase 11: Listmonk Integration \u2705 COMPLETE","text":"

Timeline: February 2025

Deliverables: - Listmonk API client (typed HTTP, basic auth, native fetch) - Sync service (campaign participants, locations, users \u2192 subscriber lists) - Admin routes (status, stats, sync triggers, test connection, reinitialize) - ListmonkPage (status dashboard, sync buttons, list stats) - Opt-in sync flag (LISTMONK_SYNC_ENABLED)

Key Achievements: - Newsletter integration (advocacy campaigns \u2192 subscriber lists) - Automatic list creation/sync - Proton Mail SMTP configuration (listmonk-init auto-configures)

"},{"location":"v2/contributing/roadmap/#phase-12-landing-page-builder-complete","title":"Phase 12: Landing Page Builder \u2705 COMPLETE","text":"

Timeline: February 2025

Deliverables: - Landing page service (CRUD, slug generation, MkDocs export) - Page block service (seed blocks, CRUD, library API) - GrapesJS editor integration (custom blocks, Ctrl+S save, error boundary) - LandingPagesPage (table, search, settings modal) - PageEditorPage (full-screen GrapesJS, desktop-only, forwardRef) - Public LandingPage renderer (/p/:slug) - MkDocs export (Jinja2 Material override template, themed + standalone modes) - DocsPage (management, status cards, export table)

Key Achievements: - Visual page builder (drag-and-drop) - Custom block library (Hero, Features, CTA, Testimonials, etc.) - MkDocs integration (static site generation) - Jinja2 template export for Material theme

"},{"location":"v2/contributing/roadmap/#phase-13-volunteer-canvassing-system-complete","title":"Phase 13: Volunteer Canvassing System \u2705 COMPLETE","text":"

Timeline: February 2025

Deliverables: - Prisma models (CanvassSession, CanvassVisit, TrackingSession, TrackPoint) - Canvass API (volunteer routes: start/end session, record visits, walking route) - Canvass API (admin routes: dashboard stats, activity feed, cut progress, leaderboard) - Walking route algorithm (nearest-neighbor with haversine distance) - GPS tracking routes (volunteer + admin) - Abandoned session cleanup (startup + hourly, ACTIVE > 12h \u2192 ABANDONED) - Old tracking data cleanup (30-day retention, daily) - Stale tracking session cleanup (no data for 2h, hourly) - VolunteerLayout (top-nav, dark theme, mobile hamburger) - VolunteerMapPage (full-screen Leaflet, GPS, markers, route, bottom sheet visit recording) - VolunteerShiftsPage (assigned shifts, view only) - MyActivityPage (visit history, outcome breakdown) - MyRoutesPage (past session routes) - CanvassDashboardPage (stats, activity feed, cut progress, leaderboard) - ShiftsPage cutId dropdown (link shifts to cuts) - Role-aware login redirect (ADMIN_ROLES \u2192 /app, USER/TEMP \u2192 /volunteer)

Key Achievements: - Complete field canvassing workflow - Real-time GPS tracking with trail visualization - Optimized walking routes (nearest-neighbor algorithm) - Visit outcome tracking (8 outcomes: CONTACT_MADE, NOT_HOME, REFUSED, etc.) - Volunteer leaderboard (by visits, filterable by period) - Rate limiting (30 visits/min per IP)

"},{"location":"v2/contributing/roadmap/#phase-14-monitoring-devops-complete","title":"Phase 14: Monitoring + DevOps \u2705 COMPLETE","text":"

Timeline: February 2026

Pangolin Tunnel: - Pangolin Integration API client (typescript) - Admin pangolin routes (status, config, sites, resources, setup, sync, delete) - PangolinPage (setup wizard + resource dashboard) - Newt container in docker-compose.yml - Env vars (PANGOLIN_API_URL, API_KEY, ORG_ID, SITE_ID, ENDPOINT, NEWT_ID, NEWT_SECRET) - Retired Cloudflare scripts \u2192 scripts/legacy/

Prometheus Metrics: - 12 domain-specific cm_* metrics (emails, auth, canvass, services, etc.) - Instrumented modules (email-queue, auth, campaigns, responses, canvass, shifts, services) - HTTP request metrics (duration, count, errors)

Monitoring Configs: - Prometheus V2 API scrape job (removed V1 influence-app) - Alert rules (rewritten for V2 metric names) - Alertmanager Gotify webhook (commented, ready to enable) - Grafana dashboards (3 dashboards: system-health, application-overview, api-performance)

Docker Healthchecks: - 7 services with healthchecks (API, admin, nginx, NocoDB, n8n, Gitea, Listmonk)

Backup: - scripts/backup.sh (V2 PostgreSQL + Listmonk + uploads archive) - Manifest with timestamps, sizes, SHA256 checksums - Configurable retention (default 30 days) - Optional S3 upload (--s3 flag)

Key Achievements: - Self-hosted tunnel alternative (Pangolin replaces Cloudflare) - Comprehensive observability (Prometheus + Grafana) - Production-ready monitoring stack - Automated backup procedures

"},{"location":"v2/contributing/roadmap/#current-phase-15","title":"Current Phase (15)","text":""},{"location":"v2/contributing/roadmap/#phase-15-testing-polish-in-progress","title":"Phase 15: Testing + Polish \ud83d\udea7 IN PROGRESS","text":"

Timeline: February-March 2026

Goals: - Comprehensive testing (unit, integration, E2E) - Performance optimization - Security hardening - Documentation polish - Bug fixes

Planned Deliverables:

Testing: - [ ] API integration tests (Jest/Vitest) - Auth flow tests (login, refresh, logout) - Campaign CRUD tests - Location CRUD + geocoding tests - Canvass workflow tests - [ ] Admin E2E tests (Playwright/Cypress) - Login flow - Campaign creation flow - Location management flow - Canvass session flow - [ ] Test coverage reports (>80% target) - [ ] Load testing (k6 or Artillery) - API endpoint stress tests - Database query performance - Email queue throughput

Performance: - [ ] Database query optimization - Review Prisma queries for N+1 issues - Add missing indexes - Optimize spatial queries - [ ] Frontend bundle size reduction - Code splitting - Lazy loading - Tree shaking optimization - [ ] Redis cache tuning - Cache hit rate analysis - TTL optimization - Memory usage monitoring - [ ] Image optimization - WebP conversion - Lazy loading - Responsive images

Security: - [ ] Dependency audit (npm audit, Snyk) - [ ] OWASP Top 10 review - [ ] Security headers verification - [ ] Rate limiting verification - [ ] Input validation audit - [ ] SQL injection prevention check - [ ] XSS protection verification

Documentation: - [ ] API reference completion (all endpoints documented) - [ ] User guide polish (screenshots, videos) - [ ] Developer docs review (architecture, database) - [ ] Migration guide testing (V1\u2192V2 procedure verification) - [ ] Troubleshooting guide expansion (common issues)

Bug Fixes: - [ ] Review and fix open GitHub issues - [ ] Fix reported bugs (priority: critical > high > medium > low) - [ ] Address edge cases - [ ] Improve error messages

Polish: - [ ] UI/UX refinements (spacing, alignment, colors) - [ ] Accessibility improvements (keyboard nav, screen reader) - [ ] Mobile responsiveness fixes - [ ] Loading states improvements - [ ] Error state improvements

Progress: 20% (security audit complete, NAR import complete, media upload complete)

"},{"location":"v2/contributing/roadmap/#future-roadmap-phase-16","title":"Future Roadmap (Phase 16+)","text":""},{"location":"v2/contributing/roadmap/#phase-16-multi-tenancy-planned","title":"Phase 16: Multi-Tenancy (Planned)","text":"

Goal: Support multiple organizations on single instance

Features: - [ ] Tenant isolation (database row-level security) - [ ] Subdomain routing (org1.cmlite.org, org2.cmlite.org) - [ ] Tenant-specific settings - [ ] Billing integration (optional) - [ ] Admin cross-tenant management - [ ] Tenant signup flow

Technical Challenges: - Database schema changes (add tenantId to all tables) - Prisma middleware for automatic tenant filtering - JWT token tenant claim - File upload isolation (per-tenant directories)

Timeline: 2-3 months (tentative Q2 2026)

"},{"location":"v2/contributing/roadmap/#phase-17-mobile-apps-planned","title":"Phase 17: Mobile Apps (Planned)","text":"

Goal: Native iOS and Android apps for volunteers

Features: - [ ] React Native app (iOS + Android) - [ ] Volunteer canvassing optimized for mobile - [ ] Offline mode (sync when online) - [ ] Push notifications (shift reminders, campaign updates) - [ ] Location services integration - [ ] QR code scanning (walk sheets) - [ ] Photo upload (location photos)

Technical Stack: - React Native + Expo - AsyncStorage for offline data - React Query for sync - Expo Notifications - Expo Camera

Timeline: 3-4 months (tentative Q3 2026)

"},{"location":"v2/contributing/roadmap/#phase-18-advanced-analytics-planned","title":"Phase 18: Advanced Analytics (Planned)","text":"

Goal: Campaign performance and volunteer metrics

Features: - [ ] Campaign analytics dashboard - Email open rates - Response submission trends - Geographic distribution - [ ] Volunteer analytics - Canvassing efficiency metrics - Top volunteers leaderboard - Activity heatmaps - [ ] Location analytics - Support level trends over time - Geocoding quality reports - Coverage maps - [ ] Export to BI tools (Metabase, Superset)

Technical Stack: - Prisma aggregations - Chart.js or Recharts - CSV/Excel export - Optional: Metabase integration

Timeline: 2 months (tentative Q4 2026)

"},{"location":"v2/contributing/roadmap/#phase-19-ai-integration-exploratory","title":"Phase 19: AI Integration (Exploratory)","text":"

Goal: AI-powered features for campaign optimization

Potential Features: - [ ] Campaign email drafting (GPT-4 integration) - [ ] Response sentiment analysis - [ ] Canvassing route optimization (ML algorithm) - [ ] Volunteer assignment suggestions - [ ] Predictive support level classification - [ ] Automated data quality checks

Technical Considerations: - OpenAI API integration (cost considerations) - Privacy concerns (user data in AI models) - Ethical AI usage guidelines - Opt-in for AI features

Timeline: TBD (community feedback needed)

"},{"location":"v2/contributing/roadmap/#phase-20-additional-integrations-planned","title":"Phase 20: Additional Integrations (Planned)","text":"

Goal: Connect to other campaign tools

Potential Integrations: - [ ] Social media: Facebook, Twitter, Instagram posting - [ ] SMS campaigns: Twilio integration for text banking - [ ] Phone banking: VoIP integration for call tracking - [ ] Donation tracking: ActBlue, Stripe integration - [ ] Event management: Rally, town hall scheduling - [ ] Voter files: VAN/Votebuilder import - [ ] Peer-to-peer texting: Spoke, Relay integration

Timeline: Ongoing (community-driven priorities)

"},{"location":"v2/contributing/roadmap/#feature-requests","title":"Feature Requests","text":"

Have an idea for a new feature? We'd love to hear it!

"},{"location":"v2/contributing/roadmap/#how-to-request","title":"How to Request","text":"
  1. Search existing requests: Check Discussions
  2. Create new discussion: Start a discussion
  3. Provide details:
  4. Problem: What problem does this solve?
  5. Use case: Who would use this feature?
  6. Implementation ideas: How might it work?
  7. Alternatives: What workarounds exist today?
"},{"location":"v2/contributing/roadmap/#prioritization-process","title":"Prioritization Process","text":"

Features are prioritized based on:

  1. Impact: How many users benefit?
  2. Effort: How complex to implement?
  3. Strategic fit: Aligns with mission?
  4. Community votes: Upvote discussions
  5. Funding: Sponsored development

High-priority features: - Requested by many users - Low implementation effort - Core to mission (campaign advocacy, volunteer management)

Low-priority features: - Niche use cases - High complexity - Available via integrations

"},{"location":"v2/contributing/roadmap/#community-voting","title":"Community Voting","text":"

Upvote feature requests in GitHub Discussions:

  1. Go to Ideas category
  2. Click \ud83d\udc4d on discussions you want
  3. Comment with your use case

Most-upvoted features are considered for roadmap.

"},{"location":"v2/contributing/roadmap/#contribution-opportunities","title":"Contribution Opportunities","text":"

Want to contribute to the roadmap?

"},{"location":"v2/contributing/roadmap/#code-contributions","title":"Code Contributions","text":"
  • Phase 15 (Testing): Write integration tests, E2E tests
  • Phase 15 (Performance): Optimize queries, reduce bundle size
  • Phase 15 (Documentation): Improve guides, add tutorials

\u2192 Find Issues

"},{"location":"v2/contributing/roadmap/#design-contributions","title":"Design Contributions","text":"
  • UI/UX mockups: Design future features
  • User research: Interview campaign organizers
  • Accessibility audit: Test with screen readers
"},{"location":"v2/contributing/roadmap/#documentation-contributions","title":"Documentation Contributions","text":"
  • User guides: Write how-to guides
  • Video tutorials: Create walkthrough videos
  • Translations: Translate docs to other languages
"},{"location":"v2/contributing/roadmap/#sponsorship","title":"Sponsorship","text":"

Support development of specific features:

  • Individual sponsors: $10/month (GitHub Sponsors)
  • Organization sponsors: $500+/month (custom features, priority support)
  • One-time donations: Sponsor specific features

\u2192 Sponsor on GitHub

"},{"location":"v2/contributing/roadmap/#release-schedule","title":"Release Schedule","text":""},{"location":"v2/contributing/roadmap/#version-numbering","title":"Version Numbering","text":"

Changemaker Lite uses Semantic Versioning:

  • Major (1.0.0): Breaking changes
  • Minor (1.1.0): New features (backward compatible)
  • Patch (1.1.1): Bug fixes

Current version: 2.0.0-beta.1 (Phase 15 in progress)

"},{"location":"v2/contributing/roadmap/#release-cycle","title":"Release Cycle","text":"

Major releases: 6-12 months (major new features, breaking changes)

Minor releases: 1-2 months (new features, no breaking changes)

Patch releases: 1-2 weeks (bug fixes, security patches)

"},{"location":"v2/contributing/roadmap/#upcoming-releases","title":"Upcoming Releases","text":"

v2.0.0 (stable release): - Target: March 2026 - Requires: Phase 15 complete (testing, polish) - Breaking changes from beta: TBD

v2.1.0: - Target: May 2026 - Features: TBD based on community feedback

v2.2.0: - Target: July 2026 - Features: Possibly multi-tenancy (Phase 16)

"},{"location":"v2/contributing/roadmap/#long-term-vision","title":"Long-Term Vision","text":"

Mission: Provide free, self-hosted tools for grassroots political campaigns.

5-Year Vision (2026-2031):

  1. Year 1 (2026): V2 stable, 100+ organizations using Changemaker Lite
  2. Year 2 (2027): Multi-tenancy, mobile apps, 500+ organizations
  3. Year 3 (2028): Advanced analytics, AI features, 1000+ organizations
  4. Year 4 (2029): Ecosystem of integrations, international campaigns
  5. Year 5 (2030): Changemaker Lite as standard platform for grassroots advocacy

Success Metrics: - Number of organizations using platform - Number of campaigns run - Number of volunteers coordinated - Number of emails sent to representatives - Community contributions (PRs, issues, discussions)

"},{"location":"v2/contributing/roadmap/#breaking-changes-policy","title":"Breaking Changes Policy","text":""},{"location":"v2/contributing/roadmap/#commitment","title":"Commitment","text":"

We strive to minimize breaking changes in V2 minor releases. When breaking changes are necessary:

  1. Advance notice: Announced 2 releases prior (e.g., deprecation in v2.1.0, removal in v2.3.0)
  2. Migration guide: Detailed upgrade guide provided
  3. Deprecation warnings: Console warnings in code
  4. Major version bumps: Breaking changes only in major releases (v2\u2192v3)
"},{"location":"v2/contributing/roadmap/#deprecation-process","title":"Deprecation Process","text":"
  1. Deprecate: Mark feature as deprecated (console warnings)
  2. Announce: Publish deprecation notice in release notes
  3. Wait: Keep deprecated feature for 2 releases minimum
  4. Remove: Remove in next major version

Example: - v2.1.0: Deprecate /api/old-endpoint (with warnings) - v2.2.0: Still supported, warnings continue - v2.3.0: Still supported, migration guide published - v3.0.0: Removed (breaking change)

"},{"location":"v2/contributing/roadmap/#related-documentation","title":"Related Documentation","text":"
  • Contributing Guide - How to contribute
  • Development Setup - Environment setup
  • Pull Request Guidelines - PR process
  • V2 Plan - Original roadmap document
"},{"location":"v2/contributing/roadmap/#feedback","title":"Feedback","text":"

Have feedback on the roadmap?

  • Discuss features: GitHub Discussions
  • Report priorities: Email roadmap@cmlite.org
  • Vote on features: Upvote discussions

Together, we're building the future of grassroots political campaigns! \ud83d\ude80

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

Changemaker Lite V2 uses a dual ORM architecture with PostgreSQL 16 as the backing database:

  • Prisma ORM (Express API, port 4000) \u2014 30 models for auth, influence, map, canvassing, email templates, landing pages, and tracking
  • Drizzle ORM (Fastify Media API, port 4100) \u2014 3 models for video library, compilations, and job queue

Both ORMs share the same PostgreSQL database but maintain separate schemas and migration workflows.

"},{"location":"v2/database/#database-architecture","title":"Database Architecture","text":"

Database: PostgreSQL 16 Connection: DATABASE_URL environment variable Total Models: 33 models organized into 9 groups Migration Tools: Prisma Migrate (main API), Drizzle Kit (media API)

"},{"location":"v2/database/#key-design-patterns","title":"Key Design Patterns","text":"
  1. Audit Fields \u2014 Most models include:
  2. createdAt / updatedAt timestamps
  3. createdByUserId / updatedByUserId user references
  4. Automatic tracking via Prisma middleware

  5. Soft Deletes \u2014 Some models use status fields instead of hard deletes:

  6. User: status (ACTIVE/INACTIVE/SUSPENDED/EXPIRED)
  7. Campaign: status (DRAFT/ACTIVE/PAUSED/ARCHIVED)
  8. Shift: status (OPEN/FULL/CANCELLED)

  9. JSON Fields \u2014 Used for flexible schema:

  10. permissions (User) \u2014 granular per-app permissions
  11. offices (Representative) \u2014 array of office contact info
  12. tags (videos) \u2014 array of tag strings
  13. geojson (Cut) \u2014 GeoJSON polygon coordinates
  14. blocks (LandingPage) \u2014 GrapesJS editor output

  15. Enums \u2014 18 enums for type safety:

  16. UserRole, UserStatus, CampaignStatus, GovernmentLevel, EmailMethod, ResponseType, ResponseStatus, SupportLevel, GeocodeProvider, BuildingType, LocationHistoryAction, ShiftStatus, SignupStatus, SignupSource, CutCategory, VisitOutcome, CanvassSessionStatus, TrackPointEvent, EmailTemplateCategory, EditorMode, MkdocsExportMode

  17. Cascade Deletes \u2014 Foreign keys with onDelete: Cascade:

  18. Deleting a Campaign deletes all CampaignEmail, RepresentativeResponse, CustomRecipient, Call records
  19. Deleting a Location deletes all Address and LocationHistory records
  20. Deleting a Shift deletes all ShiftSignup records
  21. Deleting a CanvassSession deletes all CanvassVisit records

  22. Indexes \u2014 Strategic indexing for performance:

  23. All foreign keys indexed (userId, campaignId, locationId, etc.)
  24. Composite indexes for common queries (latitude+longitude, locationId+unitNumber, etc.)
  25. Unique constraints (email, slug, postalCode, token, etc.)
"},{"location":"v2/database/#complete-entity-relationship-diagram","title":"Complete Entity Relationship Diagram","text":"
erDiagram\n    %% ============================================================================\n    %% AUTH & USERS\n    %% ============================================================================\n\n    User ||--o{ RefreshToken : has\n    User ||--o{ Campaign : creates\n    User ||--o{ CampaignEmail : sends\n    User ||--o{ RepresentativeResponse : submits\n    User ||--o{ ResponseUpvote : upvotes\n    User ||--o{ ShiftSignup : \"signs up for\"\n    User ||--o{ Location : creates\n    User ||--o{ Location : updates\n    User ||--o{ Address : \"creates (addresses)\"\n    User ||--o{ Address : \"updates (addresses)\"\n    User ||--o{ LocationHistory : edits\n    User ||--o{ Cut : \"creates (cuts)\"\n    User ||--o{ CanvassVisit : visits\n    User ||--o{ CanvassSession : \"has (sessions)\"\n    User ||--o{ TrackingSession : \"tracks (gps)\"\n    User ||--o{ EmailTemplate : \"creates (templates)\"\n    User ||--o{ EmailTemplate : \"updates (templates)\"\n    User ||--o{ EmailTemplateVersion : \"versions (templates)\"\n    User ||--o{ EmailTemplateTestLog : \"tests (templates)\"\n\n    User {\n        String id PK\n        String email UK \"bcrypt hashed\"\n        String password \"bcrypt\"\n        String name\n        String phone\n        UserRole role \"SUPER_ADMIN | INFLUENCE_ADMIN | MAP_ADMIN | USER | TEMP\"\n        UserStatus status \"ACTIVE | INACTIVE | SUSPENDED | EXPIRED\"\n        Json permissions \"granular per-app\"\n        UserCreatedVia createdVia \"ADMIN | PUBLIC_SHIFT_SIGNUP | STANDARD\"\n        DateTime expiresAt \"for TEMP users\"\n        Int expireDays\n        DateTime lastLoginAt\n        Boolean emailVerified\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    RefreshToken {\n        String id PK\n        String token UK \"JWT refresh token\"\n        String userId FK\n        DateTime expiresAt\n        DateTime createdAt\n    }\n\n    %% ============================================================================\n    %% INFLUENCE \u2014 CAMPAIGNS\n    %% ============================================================================\n\n    Campaign ||--o{ CampaignEmail : sends\n    Campaign ||--o{ RepresentativeResponse : receives\n    Campaign ||--o{ CustomRecipient : targets\n    Campaign ||--o{ Call : tracks\n\n    Campaign {\n        String id PK\n        String slug UK\n        String title\n        String description\n        String emailSubject\n        String emailBody\n        String callToAction\n        String coverPhoto\n        CampaignStatus status \"DRAFT | ACTIVE | PAUSED | ARCHIVED\"\n        Boolean allowSmtpEmail \"default: true\"\n        Boolean allowMailtoLink \"default: true\"\n        Boolean collectUserInfo \"default: true\"\n        Boolean showEmailCount \"default: true\"\n        Boolean showCallCount \"default: true\"\n        Boolean allowEmailEditing \"default: false\"\n        Boolean allowCustomRecipients \"default: false\"\n        Boolean showResponseWall \"default: false\"\n        Boolean highlightCampaign \"default: false\"\n        GovernmentLevel[] targetGovernmentLevels\n        String createdByUserId FK\n        String createdByUserEmail\n        String createdByUserName\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    CampaignEmail {\n        String id PK\n        String campaignId FK\n        String campaignSlug\n        String userId FK\n        String userEmail\n        String userName\n        String userPostalCode\n        String recipientEmail\n        String recipientName\n        String recipientTitle\n        GovernmentLevel recipientLevel\n        EmailMethod emailMethod \"SMTP | MAILTO\"\n        String subject\n        String message\n        CampaignEmailStatus status \"QUEUED | SENT | FAILED | CLICKED | USER_INFO_CAPTURED\"\n        String senderIp\n        DateTime sentAt\n    }\n\n    Representative {\n        String id PK\n        String postalCode IDX\n        String name\n        String email\n        String districtName\n        String electedOffice\n        String partyName\n        String representativeSetName\n        String url\n        String photoUrl\n        Json offices \"array of office contact info\"\n        DateTime cachedAt\n    }\n\n    CustomRecipient {\n        String id PK\n        String campaignId FK\n        String campaignSlug\n        String recipientName\n        String recipientEmail\n        String recipientTitle\n        String recipientOrganization\n        String notes\n        Boolean isActive\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    PostalCodeCache {\n        String id PK\n        String postalCode UK\n        String city\n        String province\n        Decimal centroidLat\n        Decimal centroidLng\n        DateTime lastUpdated\n    }\n\n    Call {\n        String id PK\n        String representativeName\n        String representativeTitle\n        String phoneNumber\n        String officeType\n        String callerName\n        String callerEmail\n        String postalCode\n        String campaignId FK\n        String campaignSlug\n        String callerIp\n        DateTime calledAt\n    }\n\n    %% ============================================================================\n    %% INFLUENCE \u2014 RESPONSE WALL\n    %% ============================================================================\n\n    RepresentativeResponse ||--o{ ResponseUpvote : gets\n\n    RepresentativeResponse {\n        String id PK\n        String campaignId FK\n        String campaignSlug\n        String representativeName\n        String representativeTitle\n        GovernmentLevel representativeLevel\n        String representativeEmail\n        ResponseType responseType \"EMAIL | LETTER | PHONE_CALL | MEETING | SOCIAL_MEDIA | OTHER\"\n        String responseText\n        String userComment\n        String screenshotUrl\n        String submittedByUserId FK\n        String submittedByName\n        String submittedByEmail\n        Boolean isAnonymous\n        ResponseStatus status \"PENDING | APPROVED | REJECTED\"\n        Boolean isVerified\n        String verificationToken\n        DateTime verificationSentAt\n        DateTime verifiedAt\n        String verifiedBy\n        Int upvoteCount\n        String submittedIp\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    ResponseUpvote {\n        String id PK\n        String responseId FK\n        String userId FK\n        String userEmail\n        String upvotedIp\n    }\n\n    EmailLog {\n        String id PK\n        String recipientEmail\n        String senderName\n        String senderEmail\n        String subject\n        String message\n        String postalCode\n        String status \"sent | failed | previewed\"\n        String senderIp\n        DateTime sentAt\n    }\n\n    EmailVerification {\n        String id PK\n        String token UK\n        String email\n        String tempCampaignData \"JSON\"\n        DateTime createdAt\n        DateTime expiresAt\n        Boolean used\n    }\n\n    %% ============================================================================\n    %% MAP \u2014 LOCATIONS\n    %% ============================================================================\n\n    Location ||--o{ Address : contains\n    Location ||--o{ LocationHistory : logs\n\n    Location {\n        String id PK\n        Decimal latitude \"required, precision: 10,8\"\n        Decimal longitude \"required, precision: 11,8\"\n        String address \"base street address, no unit\"\n        String postalCode\n        String province\n        String federalDistrict\n        Int buildingUse \"NAR BU_USE: 1=Residential, 2=Partial, 3=Non-Residential, 4=Unknown\"\n        String locGuid UK \"NAR LOC_GUID\"\n        BuildingType buildingType \"SINGLE_FAMILY | MULTI_UNIT | MIXED_USE | COMMERCIAL\"\n        Int totalUnits\n        String buildingNotes \"access codes, manager contact\"\n        Int geocodeConfidence \"0-100\"\n        GeocodeProvider geocodeProvider\n        String createdByUserId FK\n        String updatedByUserId FK\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    Address {\n        String id PK\n        String locationId FK\n        String unitNumber\n        String addrGuid UK \"NAR ADDR_GUID\"\n        String firstName\n        String lastName\n        String email\n        String phone\n        SupportLevel supportLevel \"1 | 2 | 3 | 4\"\n        Boolean sign\n        String signSize\n        String notes\n        String createdByUserId FK\n        String updatedByUserId FK\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    LocationHistory {\n        String id PK\n        String locationId FK\n        String userId FK\n        LocationHistoryAction action \"CREATED | UPDATED | GEOCODED | BULK_GEOCODED | MOVED_ON_MAP | IMPORTED_CSV | IMPORTED_NAR\"\n        String field \"which field changed\"\n        String oldValue\n        String newValue\n        Json metadata \"provider, confidence, etc\"\n        DateTime createdAt\n    }\n\n    %% ============================================================================\n    %% MAP \u2014 SHIFTS & CUTS\n    %% ============================================================================\n\n    Cut ||--o{ Shift : schedules\n    Shift ||--o{ ShiftSignup : has\n    Shift ||--o{ CanvassVisit : \"visits (shift)\"\n    Shift ||--o{ CanvassSession : \"sessions (shift)\"\n\n    Shift {\n        String id PK\n        String title\n        String description\n        DateTime date\n        String startTime \"HH:MM\"\n        String endTime \"HH:MM\"\n        String location\n        Int maxVolunteers\n        Int currentVolunteers\n        ShiftStatus status \"OPEN | FULL | CANCELLED\"\n        Boolean isPublic\n        String cutId FK\n        String createdBy\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    ShiftSignup {\n        String id PK\n        String shiftId FK\n        String shiftTitle\n        String userId FK\n        String userEmail\n        String userName\n        String userPhone\n        DateTime signupDate\n        SignupStatus status \"CONFIRMED | CANCELLED\"\n        SignupSource signupSource \"AUTHENTICATED | PUBLIC | ADMIN\"\n    }\n\n    Cut {\n        String id PK\n        String name\n        String description\n        String color\n        Decimal opacity\n        CutCategory category \"CUSTOM | WARD | NEIGHBORHOOD | DISTRICT\"\n        Boolean isPublic\n        Boolean isOfficial\n        String geojson \"GeoJSON polygon data\"\n        String bounds \"bounding box JSON\"\n        Boolean showLocations\n        Boolean exportEnabled\n        String assignedTo\n        Json filterSettings\n        DateTime lastCanvassed\n        Int completionPercentage\n        String createdByUserId FK\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    MapSettings {\n        String id PK\n        Decimal latitude\n        Decimal longitude\n        Int zoom\n        String walkSheetTitle\n        String walkSheetSubtitle\n        String walkSheetFooter\n        String qrCode1Url\n        String qrCode1Label\n        String qrCode2Url\n        String qrCode2Label\n        String qrCode3Url\n        String qrCode3Label\n        String createdBy\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    %% ============================================================================\n    %% CANVASSING\n    %% ============================================================================\n\n    Cut ||--o{ CanvassSession : \"sessions (cut)\"\n    CanvassSession ||--o{ CanvassVisit : records\n    CanvassSession ||--|| TrackingSession : tracks\n    Address ||--o{ CanvassVisit : \"visited (address)\"\n\n    CanvassSession {\n        String id PK\n        String userId FK\n        String cutId FK\n        String shiftId FK\n        CanvassSessionStatus status \"ACTIVE | COMPLETED | ABANDONED\"\n        DateTime startedAt\n        DateTime endedAt\n        Decimal startLatitude\n        Decimal startLongitude\n    }\n\n    CanvassVisit {\n        String id PK\n        String addressId FK\n        String userId FK\n        String shiftId FK\n        String sessionId FK\n        VisitOutcome outcome \"NOT_HOME | REFUSED | MOVED | ALREADY_VOTED | SPOKE_WITH | LEFT_LITERATURE | COME_BACK_LATER\"\n        SupportLevel supportLevel\n        Boolean signRequested\n        String signSize\n        String notes\n        Int durationSeconds\n        DateTime visitedAt\n    }\n\n    TrackingSession {\n        String id PK\n        String userId FK\n        String canvassSessionId UK\n        DateTime startedAt\n        DateTime endedAt\n        Boolean isActive\n        Int totalPoints\n        Float totalDistanceM\n        Decimal lastLatitude\n        Decimal lastLongitude\n        DateTime lastRecordedAt\n    }\n\n    TrackingSession ||--o{ TrackPoint : logs\n\n    TrackPoint {\n        String id PK\n        String trackingSessionId FK\n        Decimal latitude\n        Decimal longitude\n        Float accuracy\n        DateTime recordedAt\n        TrackPointEvent eventType \"LOCATION_ADDED | VISIT_RECORDED | SESSION_STARTED | SESSION_ENDED\"\n    }\n\n    %% ============================================================================\n    %% EMAIL TEMPLATES\n    %% ============================================================================\n\n    EmailTemplate ||--o{ EmailTemplateVariable : defines\n    EmailTemplate ||--o{ EmailTemplateVersion : versions\n    EmailTemplate ||--o{ EmailTemplateTestLog : tests\n\n    EmailTemplate {\n        String id PK\n        String key UK \"e.g., campaign-email\"\n        String name \"display name\"\n        String description\n        EmailTemplateCategory category \"INFLUENCE | MAP | SYSTEM\"\n        String subjectLine \"with {{VAR}} support\"\n        String htmlContent\n        String textContent\n        Boolean isSystem \"prevent deletion\"\n        Boolean isActive\n        String createdByUserId FK\n        String updatedByUserId FK\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    EmailTemplateVariable {\n        String id PK\n        String templateId FK\n        String key \"e.g., USER_NAME\"\n        String label \"e.g., User Name\"\n        String description\n        Boolean isRequired\n        Boolean isConditional \"used in {{#if}} blocks\"\n        String sampleValue\n        Int sortOrder\n    }\n\n    EmailTemplateVersion {\n        String id PK\n        String templateId FK\n        Int versionNumber \"auto-increment per template\"\n        String subjectLine\n        String htmlContent\n        String textContent\n        String changeNotes\n        String createdByUserId FK\n        DateTime createdAt\n    }\n\n    EmailTemplateTestLog {\n        String id PK\n        String templateId FK\n        String recipientEmail\n        Json testData \"sample variable values\"\n        Boolean success\n        String errorMessage\n        String messageId \"nodemailer message ID\"\n        String sentByUserId FK\n        DateTime sentAt\n    }\n\n    %% ============================================================================\n    %% LANDING PAGES\n    %% ============================================================================\n\n    LandingPage {\n        String id PK\n        String slug UK\n        String title\n        String description\n        Json blocks \"GrapesJS editor JSON\"\n        String htmlOutput\n        String cssOutput\n        EditorMode editorMode \"VISUAL | CODE\"\n        String mkdocsPath \"path in mkdocs/overrides/\"\n        String mkdocsStubPath \"path to .md stub\"\n        MkdocsExportMode mkdocsExportMode \"THEMED | STANDALONE\"\n        Boolean mkdocsHideNav\n        Boolean mkdocsHideToc\n        Boolean mkdocsSkipExport\n        Boolean published\n        String seoTitle\n        String seoDescription\n        String seoImage\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    PageBlock {\n        String id PK\n        String type \"hero | text | image | cta | features | testimonials | form\"\n        String label\n        Json schema \"block configuration schema\"\n        Json defaults \"default values\"\n        String thumbnail\n        String category\n        Int sortOrder\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    %% ============================================================================\n    %% SITE SETTINGS\n    %% ============================================================================\n\n    SiteSettings {\n        String id PK\n        String organizationName\n        String organizationShortName\n        String organizationLogoUrl\n        String organizationFaviconUrl\n        String adminColorPrimary\n        String adminColorBgBase\n        String publicColorPrimary\n        String publicColorBgBase\n        String publicColorBgContainer\n        String publicHeaderGradient\n        String footerText\n        String loginSubtitle\n        String emailFromName\n        String smtpHost\n        Int smtpPort\n        String smtpUser\n        String smtpPass\n        String smtpFromAddress\n        String smtpActiveProvider \"mailhog | production\"\n        Boolean emailTestMode\n        String testEmailRecipient\n        Boolean enableInfluence\n        Boolean enableMap\n        Boolean enableNewsletter\n        Boolean enableLandingPages\n        DateTime createdAt\n        DateTime updatedAt\n    }
"},{"location":"v2/database/#model-groups","title":"Model Groups","text":"

The database is organized into 9 logical groups:

"},{"location":"v2/database/#1-auth-users","title":"1. Auth & Users","text":"
  • User \u2014 User accounts with roles (SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP)
  • RefreshToken \u2014 JWT refresh token storage with rotation

Key Features: bcrypt passwords (12+ chars policy), role-based access control, temp user expiration, email verification

"},{"location":"v2/database/#2-influence","title":"2. Influence","text":"
  • Campaign \u2014 Advocacy campaigns with 12 feature flags
  • Representative \u2014 Cached representative data from Represent API
  • CampaignEmail \u2014 Email tracking (SMTP vs MAILTO)
  • RepresentativeResponse \u2014 Response wall with moderation
  • ResponseUpvote \u2014 Upvote tracking with IP + user uniqueness
  • CustomRecipient \u2014 Custom email targets
  • PostalCodeCache \u2014 Postal code geocoding cache
  • EmailLog \u2014 Email audit trail
  • EmailVerification \u2014 Verification token storage
  • Call \u2014 Phone call tracking

Key Features: Multi-government-level targeting, response moderation workflow (PENDING \u2192 APPROVED/REJECTED), BullMQ integration for email queue, upvote deduplication

"},{"location":"v2/database/#3-map-locations","title":"3. Map \u2014 Locations","text":"
  • Location \u2014 Building-level data with lat/lng, NAR integration
  • Address \u2014 Unit-level data with support levels
  • LocationHistory \u2014 Audit trail with 7 action types
  • Shift \u2014 Volunteer shifts with cut relation
  • ShiftSignup \u2014 Signup tracking
  • Cut \u2014 GeoJSON polygon overlays
  • MapSettings \u2014 Singleton for map center/zoom + walk sheet config

Key Features: Building vs unit architecture, multi-provider geocoding (6 providers), NAR 2025 import support, spatial indexing, GeoJSON storage

"},{"location":"v2/database/#4-canvassing","title":"4. Canvassing","text":"
  • CanvassSession \u2014 Session lifecycle (ACTIVE \u2192 COMPLETED/ABANDONED)
  • CanvassVisit \u2014 Visit recording with 7 outcome types
  • TrackingSession \u2014 GPS tracking integration
  • TrackPoint \u2014 GPS breadcrumb trail

Key Features: Walking route algorithm, session abandonment logic (12h timeout), distance calculation, support level tracking

"},{"location":"v2/database/#5-email-templates","title":"5. Email Templates","text":"
  • EmailTemplate \u2014 Template master with categories
  • EmailTemplateVariable \u2014 Variable definitions with validation
  • EmailTemplateVersion \u2014 Version history
  • EmailTemplateTestLog \u2014 Test email audit

Key Features: Handlebars-style variable interpolation ({{VAR}}), conditional variables, system template protection, version auto-increment

"},{"location":"v2/database/#6-landing-pages","title":"6. Landing Pages","text":"
  • LandingPage \u2014 GrapesJS editor output with MkDocs export
  • PageBlock \u2014 Reusable block library

Key Features: GrapesJS JSON storage, MkDocs export modes (THEMED vs STANDALONE), SEO metadata, slug-based routing

"},{"location":"v2/database/#7-settings","title":"7. Settings","text":"
  • SiteSettings \u2014 Org branding + theme + SMTP + feature toggles
  • MapSettings \u2014 Map center/zoom + walk sheet config

Key Features: Singleton pattern, SMTP override hierarchy (SiteSettings \u2192 .env), feature flags

"},{"location":"v2/database/#8-media-drizzle-orm","title":"8. Media (Drizzle ORM)","text":"
  • videos \u2014 Video library with metadata, directory types, engagement stats
  • compilations \u2014 Video compilation tracking
  • jobs \u2014 Job queue with resource categories

Key Features: Dual ORM architecture, FFprobe metadata extraction, directory type enum (9 types), job queue with GPU/CPU resource tracking

"},{"location":"v2/database/#9-sharedstandalone-models","title":"9. Shared/Standalone Models","text":"
  • Representative \u2014 Shared across campaigns
  • PostalCodeCache \u2014 Shared geocoding cache
  • EmailLog \u2014 Audit trail (no relations)
  • EmailVerification \u2014 Standalone verification tokens
"},{"location":"v2/database/#field-types-reference","title":"Field Types Reference","text":"Prisma Type PostgreSQL Type Description Example String text Variable-length text \"admin@cmlite.org\" String @db.Text text Long-form text (no char limit) Campaign descriptions Int integer 32-bit integer 42 BigInt bigint 64-bit integer (Node: number mode) File sizes Boolean boolean True/false true Decimal numeric Arbitrary precision decimal Lat/lng coordinates Decimal @db.Decimal(10, 8) numeric(10, 8) 10 digits, 8 after decimal 53.54612345 DateTime timestamp with time zone Timestamp 2025-02-11T10:30:00Z DateTime @db.Date date Date only (no time) Shift dates Json jsonb JSON data (binary storage) Arrays, objects Enum enum Enumerated type UserRole.SUPER_ADMIN"},{"location":"v2/database/#enum-definitions","title":"Enum Definitions","text":""},{"location":"v2/database/#auth-users","title":"Auth & Users","text":"
  • UserRole: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP
  • UserStatus: ACTIVE, INACTIVE, SUSPENDED, EXPIRED
  • UserCreatedVia: ADMIN, PUBLIC_SHIFT_SIGNUP, STANDARD
"},{"location":"v2/database/#influence","title":"Influence","text":"
  • CampaignStatus: DRAFT, ACTIVE, PAUSED, ARCHIVED
  • GovernmentLevel: FEDERAL, PROVINCIAL, MUNICIPAL, SCHOOL_BOARD
  • EmailMethod: SMTP, MAILTO
  • CampaignEmailStatus: QUEUED, SENT, FAILED, CLICKED, USER_INFO_CAPTURED
  • ResponseType: EMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHER
  • ResponseStatus: PENDING, APPROVED, REJECTED
"},{"location":"v2/database/#map","title":"Map","text":"
  • SupportLevel: LEVEL_1 (mapped to \"1\"), LEVEL_2, LEVEL_3, LEVEL_4
  • GeocodeProvider: GOOGLE, MAPBOX, NOMINATIM, PHOTON, LOCATIONIQ, ARCGIS, UNKNOWN
  • BuildingType: SINGLE_FAMILY, MULTI_UNIT, MIXED_USE, COMMERCIAL
  • LocationHistoryAction: CREATED, UPDATED, GEOCODED, BULK_GEOCODED, MOVED_ON_MAP, IMPORTED_CSV, IMPORTED_NAR
  • ShiftStatus: OPEN, FULL, CANCELLED
  • SignupStatus: CONFIRMED, CANCELLED
  • SignupSource: AUTHENTICATED, PUBLIC, ADMIN
  • CutCategory: CUSTOM, WARD, NEIGHBORHOOD, DISTRICT
"},{"location":"v2/database/#canvassing","title":"Canvassing","text":"
  • VisitOutcome: NOT_HOME, REFUSED, MOVED, ALREADY_VOTED, SPOKE_WITH, LEFT_LITERATURE, COME_BACK_LATER
  • CanvassSessionStatus: ACTIVE, COMPLETED, ABANDONED
  • TrackPointEvent: LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED
"},{"location":"v2/database/#email-templates","title":"Email Templates","text":"
  • EmailTemplateCategory: INFLUENCE, MAP, SYSTEM
"},{"location":"v2/database/#landing-pages","title":"Landing Pages","text":"
  • EditorMode: VISUAL, CODE
  • MkdocsExportMode: THEMED, STANDALONE
"},{"location":"v2/database/#media-drizzle","title":"Media (Drizzle)","text":"
  • DirectoryType (TypeScript literal): 'studios', 'gifs', 'private', 'inbox', 'curated', 'playback', 'compilations', 'videos', 'highlights'
  • ResourceCategory (TypeScript literal): 'gpu_ai', 'gpu_encode', 'cpu'
  • JobStatus (TypeScript literal): 'pending', 'queued', 'running', 'completed', 'failed', 'cancelled'
"},{"location":"v2/database/#index-strategy-overview","title":"Index Strategy Overview","text":""},{"location":"v2/database/#foreign-key-indexes","title":"Foreign Key Indexes","text":"

All foreign key fields are indexed for join performance: - userId, campaignId, locationId, addressId, shiftId, cutId, sessionId, templateId, trackingSessionId

"},{"location":"v2/database/#composite-indexes","title":"Composite Indexes","text":"

Strategic multi-column indexes for common query patterns: - [latitude, longitude] (Location) \u2014 spatial queries - [locationId, unitNumber] (Address) \u2014 unit lookups - [campaignId, status] (RepresentativeResponse) \u2014 filtered response lists - [isActive, lastRecordedAt] (TrackingSession) \u2014 active session cleanup - [templateId, createdAt(sort: Desc)] (EmailTemplateVersion) \u2014 version history - [directoryType, isValid, orientation] (videos) \u2014 media library filtering

"},{"location":"v2/database/#unique-constraints","title":"Unique Constraints","text":"

Enforce data integrity: - email (User) - slug (Campaign, LandingPage) - postalCode (PostalCodeCache) - token (RefreshToken, EmailVerification) - key (EmailTemplate) - [responseId, userId] (ResponseUpvote) \u2014 prevent duplicate upvotes from logged-in users - [responseId, upvotedIp] (ResponseUpvote) \u2014 prevent duplicate upvotes from same IP - [shiftId, userEmail] (ShiftSignup) \u2014 prevent duplicate shift signups - [templateId, key] (EmailTemplateVariable) \u2014 unique variable keys per template - [templateId, versionNumber] (EmailTemplateVersion) \u2014 sequential version numbers

"},{"location":"v2/database/#foreign-key-conventions","title":"Foreign Key Conventions","text":""},{"location":"v2/database/#cascade-deletes","title":"Cascade Deletes","text":"

onDelete: Cascade\n
Used when child records should be deleted with parent: - RefreshToken \u2192 User - CampaignEmail \u2192 Campaign - RepresentativeResponse \u2192 Campaign - CustomRecipient \u2192 Campaign - Call \u2192 Campaign (SetNull) - Address \u2192 Location - LocationHistory \u2192 Location - ShiftSignup \u2192 Shift - CanvassVisit \u2192 Address, CanvassSession - TrackPoint \u2192 TrackingSession - EmailTemplateVariable \u2192 EmailTemplate - EmailTemplateVersion \u2192 EmailTemplate - EmailTemplateTestLog \u2192 EmailTemplate

"},{"location":"v2/database/#set-null","title":"Set Null","text":"

onDelete: SetNull\n
Used when child records should remain but orphan the reference: - Campaign.createdByUserId \u2192 User - CampaignEmail.userId \u2192 User - RepresentativeResponse.submittedByUserId \u2192 User - Location.createdByUserId/updatedByUserId \u2192 User - Shift.cutId \u2192 Cut - CanvassSession.shiftId \u2192 Shift - TrackingSession.canvassSessionId \u2192 CanvassSession

"},{"location":"v2/database/#related-documentation","title":"Related Documentation","text":"
  • Schema Reference \u2014 Complete table and field listing
  • Migration Workflow \u2014 Prisma and Drizzle migration processes
  • Seeding \u2014 Default data and seed script
  • Indexes \u2014 Detailed index strategy and performance notes
  • Auth Models \u2014 User and authentication tables
  • Influence Models \u2014 Campaign and advocacy tables
  • Map Models \u2014 Location, shift, and cut tables
  • Canvassing Models \u2014 Session and visit tracking
  • Email Template Models \u2014 Template system
  • Landing Page Models \u2014 Page builder and blocks
  • Settings Models \u2014 Site and map settings
  • Media Models \u2014 Video library (Drizzle ORM)
"},{"location":"v2/database/#quick-links","title":"Quick Links","text":"
  • Prisma Schema File
  • Drizzle Schema File
  • API Documentation
  • Admin GUI Documentation
"},{"location":"v2/database/indexes/","title":"Index Strategy & Performance","text":""},{"location":"v2/database/indexes/#overview","title":"Overview","text":"

Changemaker Lite V2 uses strategic indexing across 33 models to optimize query performance. This document catalogs all indexes, explains their purpose, and provides query optimization guidance.

Total Indexes: 60+ (Prisma: 50+, Drizzle: 10+)

Index Types: - Unique indexes \u2014 Enforce uniqueness constraints (email, slug, token, etc.) - Foreign key indexes \u2014 Optimize JOIN operations (userId, campaignId, locationId, etc.) - Composite indexes \u2014 Multi-column indexes for complex queries - Spatial indexes \u2014 Latitude/longitude for geographic queries

"},{"location":"v2/database/indexes/#index-catalog","title":"Index Catalog","text":""},{"location":"v2/database/indexes/#auth-users","title":"Auth & Users","text":""},{"location":"v2/database/indexes/#user","title":"User","text":"
  • Unique: email \u2014 Login lookups (WHERE email = ?)
"},{"location":"v2/database/indexes/#refreshtoken","title":"RefreshToken","text":"
  • Unique: token \u2014 Refresh endpoint lookups (WHERE token = ?)
  • Foreign Key: userId \u2014 User deletion cascades
"},{"location":"v2/database/indexes/#influence","title":"Influence","text":""},{"location":"v2/database/indexes/#campaign","title":"Campaign","text":"
  • Unique: slug \u2014 Public campaign page lookups (WHERE slug = ?)
"},{"location":"v2/database/indexes/#representative","title":"Representative","text":"
  • Non-unique: postalCode \u2014 Postal code lookups (WHERE postalCode = ?)
"},{"location":"v2/database/indexes/#campaignemail","title":"CampaignEmail","text":"
  • Foreign Key: campaignId \u2014 Campaign email stats (JOIN campaign_emails ON campaign_id = ?)
  • Non-unique: campaignSlug \u2014 Slug-based queries
"},{"location":"v2/database/indexes/#representativeresponse","title":"RepresentativeResponse","text":"
  • Foreign Key: campaignId \u2014 Campaign response wall (JOIN representative_responses ON campaign_id = ?)
  • Non-unique: campaignSlug \u2014 Slug-based queries
"},{"location":"v2/database/indexes/#responseupvote","title":"ResponseUpvote","text":"
  • Unique: [responseId, userId] \u2014 Prevent duplicate upvotes from logged-in users
  • Unique: [responseId, upvotedIp] \u2014 Prevent duplicate upvotes from same IP
"},{"location":"v2/database/indexes/#customrecipient","title":"CustomRecipient","text":"
  • Foreign Key: campaignId \u2014 Campaign custom recipients (JOIN custom_recipients ON campaign_id = ?)
"},{"location":"v2/database/indexes/#postalcodecache","title":"PostalCodeCache","text":"
  • Unique: postalCode \u2014 Postal code cache lookups (WHERE postal_code = ?)
"},{"location":"v2/database/indexes/#call","title":"Call","text":"
  • Foreign Key: campaignId \u2014 Campaign call tracking (JOIN calls ON campaign_id = ?)
"},{"location":"v2/database/indexes/#map-locations","title":"Map \u2014 Locations","text":""},{"location":"v2/database/indexes/#location","title":"Location","text":"
  • Unique: locGuid \u2014 NAR location GUID lookups
  • Composite: [latitude, longitude] \u2014 Spatial queries (nearby locations, bounding box searches)
  • Non-unique: postalCode \u2014 Postal code filtering

Query Optimization:

-- Uses composite index for bounding box queries\nSELECT * FROM locations\nWHERE latitude BETWEEN ? AND ?\n  AND longitude BETWEEN ? AND ?;\n

"},{"location":"v2/database/indexes/#address","title":"Address","text":"
  • Unique: addrGuid \u2014 NAR address GUID lookups
  • Foreign Key: locationId \u2014 Location addresses (JOIN addresses ON location_id = ?)
  • Composite: [locationId, unitNumber] \u2014 Unit lookups within building

Query Optimization:

-- Uses composite index for unit-specific queries\nSELECT * FROM addresses\nWHERE location_id = ? AND unit_number = ?;\n

"},{"location":"v2/database/indexes/#locationhistory","title":"LocationHistory","text":"
  • Foreign Key: locationId \u2014 Location history (JOIN location_history ON location_id = ?)
  • Foreign Key: userId \u2014 User edit history (JOIN location_history ON user_id = ?)
  • Non-unique: createdAt \u2014 Temporal queries (recent edits, audit trails)
"},{"location":"v2/database/indexes/#map-shifts-cuts","title":"Map \u2014 Shifts & Cuts","text":""},{"location":"v2/database/indexes/#shift","title":"Shift","text":"
  • Foreign Key: cutId \u2014 Cut shifts (JOIN shifts ON cut_id = ?)
"},{"location":"v2/database/indexes/#shiftsignup","title":"ShiftSignup","text":"
  • Unique: [shiftId, userEmail] \u2014 Prevent duplicate shift signups
  • Foreign Key: shiftId \u2014 Shift signups (JOIN shift_signups ON shift_id = ?)
"},{"location":"v2/database/indexes/#canvassing","title":"Canvassing","text":""},{"location":"v2/database/indexes/#canvasssession","title":"CanvassSession","text":"
  • Foreign Key: userId \u2014 User canvass sessions (JOIN canvass_sessions ON user_id = ?)
  • Foreign Key: cutId \u2014 Cut canvass sessions (JOIN canvass_sessions ON cut_id = ?)
  • Foreign Key: shiftId \u2014 Shift canvass sessions (JOIN canvass_sessions ON shift_id = ?)
"},{"location":"v2/database/indexes/#canvassvisit","title":"CanvassVisit","text":"
  • Foreign Key: addressId \u2014 Address visit history (JOIN canvass_visits ON address_id = ?)
  • Foreign Key: userId \u2014 User visit history (JOIN canvass_visits ON user_id = ?)
  • Foreign Key: shiftId \u2014 Shift visits (JOIN canvass_visits ON shift_id = ?)
  • Foreign Key: sessionId \u2014 Session visits (JOIN canvass_visits ON session_id = ?)
  • Non-unique: visitedAt \u2014 Temporal queries (recent visits, activity feeds)
"},{"location":"v2/database/indexes/#trackingsession","title":"TrackingSession","text":"
  • Unique: canvassSessionId \u2014 One-to-one relationship with CanvassSession
  • Foreign Key: userId \u2014 User GPS sessions (JOIN tracking_sessions ON user_id = ?)
  • Non-unique: isActive \u2014 Active session filtering (WHERE is_active = true)
  • Composite: [isActive, lastRecordedAt] \u2014 Session cleanup queries (abandoned sessions)

Query Optimization:

-- Uses composite index for abandoned session cleanup\nSELECT * FROM tracking_sessions\nWHERE is_active = true\n  AND last_recorded_at < NOW() - INTERVAL '12 hours';\n

"},{"location":"v2/database/indexes/#trackpoint","title":"TrackPoint","text":"
  • Composite: [trackingSessionId, recordedAt] \u2014 Temporal GPS queries (session breadcrumb trail)
  • Non-unique: recordedAt \u2014 Cross-session temporal queries
"},{"location":"v2/database/indexes/#email-templates","title":"Email Templates","text":""},{"location":"v2/database/indexes/#emailtemplate","title":"EmailTemplate","text":"
  • Unique: key \u2014 Template key lookups (WHERE key = 'campaign-email')
  • Non-unique: category \u2014 Category filtering (WHERE category = 'INFLUENCE')
  • Non-unique: isActive \u2014 Active template filtering (WHERE is_active = true)
"},{"location":"v2/database/indexes/#emailtemplatevariable","title":"EmailTemplateVariable","text":"
  • Unique: [templateId, key] \u2014 Unique variable keys per template
  • Foreign Key: templateId \u2014 Template variables (JOIN email_template_variables ON template_id = ?)
"},{"location":"v2/database/indexes/#emailtemplateversion","title":"EmailTemplateVersion","text":"
  • Unique: [templateId, versionNumber] \u2014 Sequential version numbers per template
  • Composite: [templateId, createdAt(sort: Desc)] \u2014 Recent version history

Query Optimization:

-- Uses composite index for recent version queries\nSELECT * FROM email_template_versions\nWHERE template_id = ?\nORDER BY created_at DESC\nLIMIT 10;\n

"},{"location":"v2/database/indexes/#emailtemplatetestlog","title":"EmailTemplateTestLog","text":"
  • Composite: [templateId, sentAt(sort: Desc)] \u2014 Recent test logs
"},{"location":"v2/database/indexes/#landing-pages","title":"Landing Pages","text":""},{"location":"v2/database/indexes/#landingpage","title":"LandingPage","text":"
  • Unique: slug \u2014 Public page lookups (WHERE slug = 'about')
"},{"location":"v2/database/indexes/#media-drizzle-orm","title":"Media (Drizzle ORM)","text":""},{"location":"v2/database/indexes/#videos","title":"videos","text":"
  • Unique: path \u2014 File path lookups (WHERE path = '/media/local/videos/file.mp4')
  • Non-unique: orientation \u2014 Orientation filtering (WHERE orientation = 'landscape')
  • Non-unique: producer \u2014 Producer filtering (WHERE producer = 'Studio A')
  • Non-unique: isValid \u2014 Valid video filtering (WHERE is_valid = true)
  • Non-unique: directoryType \u2014 Directory type filtering (WHERE directory_type = 'studios')
  • Composite: [durationSeconds, fileSize, width, height] \u2014 Fingerprint matching (duplicate detection)
  • Composite: [directoryType, isValid, orientation] \u2014 Common filtering pattern

Query Optimization:

-- Uses composite index for common video library queries\nSELECT * FROM videos\nWHERE directory_type = 'studios'\n  AND is_valid = true\n  AND orientation = 'landscape';\n

"},{"location":"v2/database/indexes/#jobs","title":"jobs","text":"
  • Composite: [status, priority, createdAt] \u2014 Job queue processing
  • Composite: [resourceCategory, status] \u2014 Resource-based filtering
  • Non-unique: pipelineId \u2014 Pipeline job filtering

Query Optimization:

-- Uses composite index for job queue queries\nSELECT * FROM jobs\nWHERE status = 'pending'\nORDER BY priority ASC, created_at ASC\nLIMIT 10;\n

"},{"location":"v2/database/indexes/#query-optimization-patterns","title":"Query Optimization Patterns","text":""},{"location":"v2/database/indexes/#1-use-indexes-for-where-clauses","title":"1. Use Indexes for WHERE Clauses","text":"
// \u2705 Uses email unique index\nawait prisma.user.findUnique({ where: { email: 'user@example.com' } });\n\n// \u274c Full table scan (no index on name)\nawait prisma.user.findMany({ where: { name: 'John' } });\n
"},{"location":"v2/database/indexes/#2-use-composite-indexes-for-multi-column-filters","title":"2. Use Composite Indexes for Multi-Column Filters","text":"
// \u2705 Uses [latitude, longitude] composite index\nawait prisma.location.findMany({\n  where: {\n    latitude: { gte: 53.5, lte: 53.6 },\n    longitude: { gte: -113.5, lte: -113.4 },\n  },\n});\n\n// \u274c Less efficient (only uses latitude index)\nawait prisma.location.findMany({\n  where: {\n    latitude: { gte: 53.5, lte: 53.6 },\n    // longitude filter applied after index scan\n  },\n});\n
"},{"location":"v2/database/indexes/#3-use-foreign-key-indexes-for-joins","title":"3. Use Foreign Key Indexes for JOINs","text":"
// \u2705 Uses campaignId foreign key index\nawait prisma.campaign.findUnique({\n  where: { id: campaignId },\n  include: { emails: true }, // JOIN uses index\n});\n\n// \u274c N+1 query (loads emails one-by-one)\nconst campaign = await prisma.campaign.findUnique({ where: { id: campaignId } });\nconst emails = await prisma.campaignEmail.findMany({ where: { campaignId: campaign.id } });\n
"},{"location":"v2/database/indexes/#4-use-unique-indexes-for-deduplication","title":"4. Use Unique Indexes for Deduplication","text":"
// \u2705 Uses [responseId, userId] unique index\nawait prisma.responseUpvote.create({\n  data: { responseId, userId, upvotedIp },\n});\n// Throws error if user already upvoted (database-level check)\n\n// \u274c Application-level check (race condition)\nconst existing = await prisma.responseUpvote.findFirst({\n  where: { responseId, userId },\n});\nif (existing) throw new Error('Already upvoted');\nawait prisma.responseUpvote.create({ data: { responseId, userId } });\n
"},{"location":"v2/database/indexes/#5-use-temporal-indexes-for-date-filtering","title":"5. Use Temporal Indexes for Date Filtering","text":"
// \u2705 Uses createdAt index\nawait prisma.locationHistory.findMany({\n  where: {\n    createdAt: { gte: new Date('2025-01-01') },\n  },\n  orderBy: { createdAt: 'desc' },\n  take: 100,\n});\n\n// \u274c Full table scan (no index on field)\nawait prisma.locationHistory.findMany({\n  where: {\n    oldValue: { contains: 'Calgary' }, // No index\n  },\n});\n
"},{"location":"v2/database/indexes/#index-selectivity","title":"Index Selectivity","text":"

Selectivity = Percentage of unique values in indexed column. Higher selectivity = better index performance.

"},{"location":"v2/database/indexes/#high-selectivity-good","title":"High Selectivity (Good)","text":"
  • email (User) \u2014 100% unique (1 user per email)
  • token (RefreshToken) \u2014 100% unique (1 token per record)
  • slug (Campaign, LandingPage) \u2014 100% unique (1 record per slug)
  • [responseId, userId] (ResponseUpvote) \u2014 High uniqueness (1 upvote per user per response)
"},{"location":"v2/database/indexes/#medium-selectivity-okay","title":"Medium Selectivity (Okay)","text":"
  • postalCode (Location) \u2014 ~50% unique (multiple locations per postal code)
  • campaignId (CampaignEmail) \u2014 ~10% unique (100s of emails per campaign)
  • directoryType (videos) \u2014 ~11% unique (9 directory types)
"},{"location":"v2/database/indexes/#low-selectivity-poor-for-filtering-good-for-covering-index","title":"Low Selectivity (Poor for filtering, good for covering index)","text":"
  • isActive (TrackingSession) \u2014 ~50% unique (active vs inactive)
  • status (Campaign) \u2014 ~25% unique (4 statuses: DRAFT, ACTIVE, PAUSED, ARCHIVED)
  • role (User) \u2014 ~20% unique (5 roles)

Optimization: - Use low-selectivity indexes as first column in composite index only - Example: [isActive, lastRecordedAt] uses isActive to narrow search, then lastRecordedAt for ordering

"},{"location":"v2/database/indexes/#index-maintenance","title":"Index Maintenance","text":""},{"location":"v2/database/indexes/#prisma-indexes-automatic","title":"Prisma Indexes (Automatic)","text":"

Prisma migrations automatically create indexes defined in schema.prisma:

model Location {\n  latitude  Decimal\n  longitude Decimal\n\n  @@index([latitude, longitude])  // Composite index\n}\n

"},{"location":"v2/database/indexes/#drizzle-indexes-manual-in-schema","title":"Drizzle Indexes (Manual in Schema)","text":"

Drizzle indexes defined in schema.ts:

export const videos = pgTable('videos', {\n  directoryType: text('directory_type'),\n  isValid: boolean('is_valid'),\n  orientation: text('orientation'),\n}, (table) => ({\n  directoryValidOrientationIdx: index('idx_videos_directory_valid_orientation')\n    .on(table.directoryType, table.isValid, table.orientation),\n}));\n

"},{"location":"v2/database/indexes/#index-size-monitoring","title":"Index Size Monitoring","text":"
-- Check index sizes\nSELECT\n  tablename,\n  indexname,\n  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size\nFROM pg_stat_user_indexes\nWHERE schemaname = 'public'\nORDER BY pg_relation_size(indexrelid) DESC;\n
"},{"location":"v2/database/indexes/#unused-index-detection","title":"Unused Index Detection","text":"
-- Find indexes with zero scans (unused)\nSELECT\n  schemaname,\n  tablename,\n  indexname,\n  idx_scan,\n  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size\nFROM pg_stat_user_indexes\nWHERE schemaname = 'public'\n  AND idx_scan = 0\n  AND indexrelid NOT IN (\n    SELECT conindid FROM pg_constraint WHERE contype IN ('p', 'u')\n  )\nORDER BY pg_relation_size(indexrelid) DESC;\n
"},{"location":"v2/database/indexes/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/database/indexes/#index-trade-offs","title":"Index Trade-offs","text":"
  • Pros: Faster SELECT queries, enforces uniqueness, prevents N+1
  • Cons: Slower INSERT/UPDATE/DELETE (index must be updated), increased storage

Rule of Thumb: - Index all foreign keys (JOIN performance) - Index all unique constraints (data integrity) - Index columns used in WHERE clauses frequently - Avoid indexing low-selectivity columns alone - Avoid indexing large text fields (use full-text search instead)

"},{"location":"v2/database/indexes/#query-planning","title":"Query Planning","text":"

Use EXPLAIN ANALYZE to verify index usage:

EXPLAIN ANALYZE\nSELECT * FROM locations\nWHERE latitude BETWEEN 53.5 AND 53.6\n  AND longitude BETWEEN -113.5 AND -113.4;\n\n-- Output should show \"Index Scan using locations_latitude_longitude_idx\"\n

"},{"location":"v2/database/indexes/#index-bloat","title":"Index Bloat","text":"

Over time, indexes can become bloated (unused space). Monitor with:

SELECT\n  schemaname,\n  tablename,\n  indexname,\n  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,\n  idx_scan,\n  idx_tup_read,\n  idx_tup_fetch\nFROM pg_stat_user_indexes\nWHERE schemaname = 'public'\nORDER BY pg_relation_size(indexrelid) DESC;\n

Fix bloat: REINDEX INDEX index_name; (requires table lock)

"},{"location":"v2/database/indexes/#common-performance-issues","title":"Common Performance Issues","text":""},{"location":"v2/database/indexes/#issue-slow-campaign-email-stats-query","title":"Issue: Slow campaign email stats query","text":"

Query:

SELECT COUNT(*) FROM campaign_emails WHERE campaign_id = ?;\n

Solution: Already optimized (uses campaignId foreign key index)

"},{"location":"v2/database/indexes/#issue-slow-location-bounding-box-queries","title":"Issue: Slow location bounding box queries","text":"

Query:

SELECT * FROM locations WHERE latitude > ? AND latitude < ? AND longitude > ? AND longitude < ?;\n

Solution: Already optimized (uses [latitude, longitude] composite index)

"},{"location":"v2/database/indexes/#issue-slow-active-session-cleanup","title":"Issue: Slow active session cleanup","text":"

Query:

SELECT * FROM tracking_sessions WHERE is_active = true AND last_recorded_at < ?;\n

Solution: Already optimized (uses [isActive, lastRecordedAt] composite index)

"},{"location":"v2/database/indexes/#issue-slow-template-version-history","title":"Issue: Slow template version history","text":"

Query:

SELECT * FROM email_template_versions WHERE template_id = ? ORDER BY created_at DESC LIMIT 10;\n

Solution: Already optimized (uses [templateId, createdAt(sort: Desc)] composite index)

"},{"location":"v2/database/indexes/#related-documentation","title":"Related Documentation","text":"
  • Database Overview \u2014 Complete ER diagram
  • Schema Reference \u2014 All model fields
  • Migration Workflow \u2014 Creating indexes in migrations
  • Common Queries \u2014 Query examples with index usage
  • PostgreSQL Index Documentation
"},{"location":"v2/database/migrations/","title":"Migration Workflow","text":""},{"location":"v2/database/migrations/#overview","title":"Overview","text":"

Changemaker Lite V2 uses a dual ORM architecture with separate migration workflows:

  • Prisma Migrate \u2014 Main API (Express, 30 models)
  • Drizzle Kit \u2014 Media API (Fastify, 3 models)

Both ORMs share the same PostgreSQL database but maintain independent migration histories.

"},{"location":"v2/database/migrations/#prisma-migration-workflow","title":"Prisma Migration Workflow","text":""},{"location":"v2/database/migrations/#development-workflow","title":"Development Workflow","text":""},{"location":"v2/database/migrations/#1-modify-schema","title":"1. Modify Schema","text":"

Edit api/prisma/schema.prisma:

model Location {\n  id       String  @id @default(cuid())\n  address  String\n  // Add new field:\n  province String?\n  // ...\n}\n

"},{"location":"v2/database/migrations/#2-create-migration","title":"2. Create Migration","text":"
cd api\nnpx prisma migrate dev --name add_province_to_location\n

This command: - Generates SQL migration file in prisma/migrations/ - Applies migration to database - Regenerates Prisma Client - Updates _prisma_migrations table

Output:

Prisma schema loaded from prisma/schema.prisma\nDatasource \"db\": PostgreSQL database \"changemaker_v2\", schema \"public\"\n\nApplying migration `20260213120000_add_province_to_location`\n\nThe following migration(s) have been created and applied from new schema changes:\n\nmigrations/\n  \u2514\u2500 20260213120000_add_province_to_location/\n      \u2514\u2500 migration.sql\n\nYour database is now in sync with your schema.\n

"},{"location":"v2/database/migrations/#3-review-migration-sql","title":"3. Review Migration SQL","text":"
-- migrations/20260213120000_add_province_to_location/migration.sql\n-- AlterTable\nALTER TABLE \"locations\" ADD COLUMN \"province\" TEXT;\n
"},{"location":"v2/database/migrations/#4-commit-migration","title":"4. Commit Migration","text":"
git add prisma/migrations/\ngit commit -m \"Add province field to Location model\"\n
"},{"location":"v2/database/migrations/#production-workflow","title":"Production Workflow","text":""},{"location":"v2/database/migrations/#1-deploy-migration","title":"1. Deploy Migration","text":"
docker compose exec api npx prisma migrate deploy\n

This command: - Applies pending migrations from prisma/migrations/ - Does NOT create new migrations - Does NOT prompt for confirmations - Safe for production/CI pipelines

"},{"location":"v2/database/migrations/#2-verify-migration-status","title":"2. Verify Migration Status","text":"
docker compose exec api npx prisma migrate status\n

Output:

1 migration found in prisma/migrations\n\nFollowing migration have been applied:\n\n20260213120000_add_province_to_location\n\nDatabase schema is up to date!\n

"},{"location":"v2/database/migrations/#common-migration-scenarios","title":"Common Migration Scenarios","text":""},{"location":"v2/database/migrations/#add-field-nullable","title":"Add Field (Nullable)","text":"

model Location {\n  federalDistrict String?  // Add nullable field\n}\n
Migration:
ALTER TABLE \"locations\" ADD COLUMN \"federal_district\" TEXT;\n

"},{"location":"v2/database/migrations/#add-field-required-with-default","title":"Add Field (Required with Default)","text":"

model Location {\n  buildingType BuildingType @default(SINGLE_FAMILY)\n}\n
Migration:
ALTER TABLE \"locations\" ADD COLUMN \"building_type\" TEXT NOT NULL DEFAULT 'SINGLE_FAMILY';\n

"},{"location":"v2/database/migrations/#add-relation","title":"Add Relation","text":"

model Shift {\n  cutId String?\n  cut   Cut?    @relation(fields: [cutId], references: [id], onDelete: SetNull)\n}\n
Migration:
ALTER TABLE \"shifts\" ADD COLUMN \"cut_id\" TEXT;\nCREATE INDEX \"shifts_cut_id_idx\" ON \"shifts\"(\"cut_id\");\nALTER TABLE \"shifts\" ADD CONSTRAINT \"shifts_cut_id_fkey\" FOREIGN KEY (\"cut_id\") REFERENCES \"cuts\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n

"},{"location":"v2/database/migrations/#change-field-type","title":"Change Field Type","text":"

model Location {\n  geocodeConfidence Int?  // Changed from String? to Int?\n}\n
Migration (requires data migration):
-- Step 1: Add new column\nALTER TABLE \"locations\" ADD COLUMN \"geocode_confidence_new\" INTEGER;\n\n-- Step 2: Migrate data (custom logic)\nUPDATE \"locations\" SET \"geocode_confidence_new\" = CAST(\"geocode_confidence\" AS INTEGER)\nWHERE \"geocode_confidence\" ~ '^[0-9]+$';\n\n-- Step 3: Drop old column\nALTER TABLE \"locations\" DROP COLUMN \"geocode_confidence\";\n\n-- Step 4: Rename new column\nALTER TABLE \"locations\" RENAME COLUMN \"geocode_confidence_new\" TO \"geocode_confidence\";\n

"},{"location":"v2/database/migrations/#add-enum","title":"Add Enum","text":"

enum BuildingType {\n  SINGLE_FAMILY\n  MULTI_UNIT\n  MIXED_USE\n  COMMERCIAL\n}\n
Migration:
CREATE TYPE \"BuildingType\" AS ENUM ('SINGLE_FAMILY', 'MULTI_UNIT', 'MIXED_USE', 'COMMERCIAL');\n

"},{"location":"v2/database/migrations/#add-index","title":"Add Index","text":"

model Location {\n  latitude  Decimal\n  longitude Decimal\n\n  @@index([latitude, longitude])\n}\n
Migration:
CREATE INDEX \"locations_latitude_longitude_idx\" ON \"locations\"(\"latitude\", \"longitude\");\n

"},{"location":"v2/database/migrations/#migration-commands-reference","title":"Migration Commands Reference","text":"Command Description Environment npx prisma migrate dev Create + apply migration Development npx prisma migrate deploy Apply pending migrations Production/CI npx prisma migrate status Check migration status All npx prisma migrate reset Reset DB + apply all migrations Development only npx prisma db push Push schema without migrations Prototyping only npx prisma studio Open Prisma Studio (DB GUI) Development"},{"location":"v2/database/migrations/#safe-migration-practices","title":"Safe Migration Practices","text":""},{"location":"v2/database/migrations/#do","title":"\u2705 DO","text":"
  • Always review generated SQL before committing
  • Test migrations on dev database first
  • Back up production database before deploying migrations
  • Use nullable fields for new columns on existing tables
  • Use @default() for new required fields
  • Commit migration files to version control
"},{"location":"v2/database/migrations/#dont","title":"\u274c DON'T","text":"
  • Use prisma db push in production (skips migrations)
  • Use prisma migrate reset in production (deletes data)
  • Manually edit migration files after applying
  • Delete old migration files (breaks history)
  • Change field names without data migration plan
"},{"location":"v2/database/migrations/#drizzle-migration-workflow","title":"Drizzle Migration Workflow","text":""},{"location":"v2/database/migrations/#development-workflow_1","title":"Development Workflow","text":""},{"location":"v2/database/migrations/#1-modify-schema_1","title":"1. Modify Schema","text":"

Edit api/src/modules/media/db/schema.ts:

export const videos = pgTable('videos', {\n  id: serial('id').primaryKey(),\n  path: text('path').notNull().unique(),\n  // Add new field:\n  description: text('description'),\n  // ...\n});\n

"},{"location":"v2/database/migrations/#2-push-schema-changes","title":"2. Push Schema Changes","text":"
cd api\nnpx drizzle-kit push\n

This command: - Generates SQL diff from schema - Applies changes directly to database - Does NOT create migration files (Drizzle push mode) - Updates database schema immediately

Output:

Reading config file '/home/bunker-admin/changemaker.lite/api/drizzle.config.ts'\nPulling schema from database...\u2713\nApplying changes...\n\n[\u2713] Applying: ALTER TABLE \"videos\" ADD COLUMN \"description\" text;\n\nSchema applied successfully!\n

"},{"location":"v2/database/migrations/#3-verify-schema","title":"3. Verify Schema","text":"

npx drizzle-kit studio\n
Opens Drizzle Studio at https://local.drizzle.studio/ for database inspection.

"},{"location":"v2/database/migrations/#production-workflow_1","title":"Production Workflow","text":"

Same as development:

docker compose exec media-api npx drizzle-kit push\n

"},{"location":"v2/database/migrations/#drizzle-vs-prisma-migrate","title":"Drizzle vs Prisma Migrate","text":"Feature Prisma Migrate Drizzle Kit Push Migration files \u2713 Generated \u2717 Not generated Migration history \u2713 Tracked in _prisma_migrations \u2717 No history table Rollback support \u2713 Via migration files \u2717 Manual only Production safety \u2713 Explicit deploy step \u26a0\ufe0f Direct push Best for Main API (schema stability) Media API (rapid iteration)

Why Drizzle for Media API? - Smaller schema (3 tables vs 30) - Faster iteration during development - Simpler deployment (no migration history to manage) - Media API is newer (less risk of breaking changes)

"},{"location":"v2/database/migrations/#drizzle-commands-reference","title":"Drizzle Commands Reference","text":"Command Description npx drizzle-kit push Push schema changes to DB npx drizzle-kit studio Open Drizzle Studio npx drizzle-kit generate Generate migrations (not used)"},{"location":"v2/database/migrations/#migration-file-structure","title":"Migration File Structure","text":""},{"location":"v2/database/migrations/#prisma-migrations","title":"Prisma Migrations","text":"
api/prisma/migrations/\n\u251c\u2500\u2500 20260211120000_initial/\n\u2502   \u2514\u2500\u2500 migration.sql\n\u251c\u2500\u2500 20260211125000_add_refresh_tokens/\n\u2502   \u2514\u2500\u2500 migration.sql\n\u251c\u2500\u2500 20260212100000_add_canvass_system/\n\u2502   \u2514\u2500\u2500 migration.sql\n\u2514\u2500\u2500 migration_lock.toml\n

File naming: YYYYMMDDHHMMSS_description/migration.sql

migration_lock.toml:

# Please do not edit this file manually\nprovider = \"postgresql\"\n

"},{"location":"v2/database/migrations/#drizzle-schema-no-migrations","title":"Drizzle Schema (No Migrations)","text":"
api/src/modules/media/db/\n\u251c\u2500\u2500 schema.ts          # Source of truth\n\u2514\u2500\u2500 drizzle.config.ts  # Drizzle config\n
"},{"location":"v2/database/migrations/#rollback-strategies","title":"Rollback Strategies","text":""},{"location":"v2/database/migrations/#prisma-rollback-manual","title":"Prisma Rollback (Manual)","text":"

Scenario: Migration 20260213120000_add_province caused issues.

Step 1: Identify last good migration

npx prisma migrate status\n

Step 2: Manually revert migration SQL

-- Reverse of migration.sql\nALTER TABLE \"locations\" DROP COLUMN \"province\";\n

Step 3: Mark migration as rolled back

DELETE FROM \"_prisma_migrations\" WHERE migration_name = '20260213120000_add_province';\n

Step 4: Remove migration file

rm -rf prisma/migrations/20260213120000_add_province/\n

Step 5: Fix schema Edit prisma/schema.prisma to remove province field.

Step 6: Create new migration

npx prisma migrate dev --name remove_province_from_location\n

"},{"location":"v2/database/migrations/#drizzle-rollback-manual","title":"Drizzle Rollback (Manual)","text":"

Step 1: Revert schema changes in schema.ts

Step 2: Push reverted schema

npx drizzle-kit push\n

Step 3: If data loss occurred, restore from backup

"},{"location":"v2/database/migrations/#common-migration-errors","title":"Common Migration Errors","text":""},{"location":"v2/database/migrations/#error-migration-failed-to-apply-cleanly","title":"Error: \"Migration failed to apply cleanly\"","text":"

Cause: Database state doesn't match expected state Solution:

npx prisma migrate resolve --applied <migration-name>  # Mark as applied\n# OR\nnpx prisma migrate resolve --rolled-back <migration-name>  # Mark as rolled back\n

"},{"location":"v2/database/migrations/#error-unique-constraint-violation","title":"Error: \"Unique constraint violation\"","text":"

Cause: Trying to add unique constraint on column with duplicate values Solution: 1. Clean up duplicate data first 2. Run migration

"},{"location":"v2/database/migrations/#error-column-cannot-be-not-null","title":"Error: \"Column cannot be NOT NULL\"","text":"

Cause: Trying to add required field to table with existing rows Solution: Use @default() or make field nullable

"},{"location":"v2/database/migrations/#error-foreign-key-constraint-failed","title":"Error: \"Foreign key constraint failed\"","text":"

Cause: Referencing non-existent records Solution: Ensure related records exist before adding FK

"},{"location":"v2/database/migrations/#database-backup-before-migration","title":"Database Backup Before Migration","text":""},{"location":"v2/database/migrations/#development","title":"Development","text":"
docker compose exec v2-postgres pg_dump -U changemaker_v2 changemaker_v2 > backup.sql\n
"},{"location":"v2/database/migrations/#production","title":"Production","text":"
# Via docker-compose\ndocker compose exec v2-postgres pg_dump -U changemaker_v2 changemaker_v2 | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz\n\n# Via backup script\n./scripts/backup.sh\n
"},{"location":"v2/database/migrations/#restore-from-backup","title":"Restore from Backup","text":"
# Stop API services\ndocker compose stop api media-api\n\n# Restore database\ndocker compose exec -T v2-postgres psql -U changemaker_v2 changemaker_v2 < backup.sql\n\n# Restart services\ndocker compose up -d api media-api\n
"},{"location":"v2/database/migrations/#cicd-integration","title":"CI/CD Integration","text":""},{"location":"v2/database/migrations/#github-actions-example","title":"GitHub Actions Example","text":"
name: Deploy V2\n\non:\n  push:\n    branches: [main]\n\njobs:\n  migrate:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-node@v3\n        with:\n          node-version: 20\n\n      - name: Install dependencies\n        run: cd api && npm ci\n\n      - name: Run Prisma migrations\n        run: cd api && npx prisma migrate deploy\n        env:\n          DATABASE_URL: ${{ secrets.DATABASE_URL }}\n\n      - name: Run Drizzle push\n        run: cd api && npx drizzle-kit push\n        env:\n          DATABASE_URL: ${{ secrets.DATABASE_URL }}\n
"},{"location":"v2/database/migrations/#related-documentation","title":"Related Documentation","text":"
  • Database Overview \u2014 Architecture and models
  • Schema Reference \u2014 All model fields
  • Seeding \u2014 Default data
  • Prisma Documentation
  • Drizzle Documentation
"},{"location":"v2/database/schema/","title":"Complete Schema Reference","text":"

This page provides a comprehensive listing of all 33 models across both Prisma and Drizzle ORMs.

"},{"location":"v2/database/schema/#models-summary","title":"Models Summary","text":"Group Model Table Name Description ORM Auth & Users User users User accounts with role-based access control Prisma RefreshToken refresh_tokens JWT refresh token storage Prisma Influence Campaign campaigns Advocacy campaigns with feature flags Prisma Representative representatives Cached representative data from Represent API Prisma CampaignEmail campaign_emails Email tracking and delivery logs Prisma RepresentativeResponse representative_responses Response wall submissions with moderation Prisma ResponseUpvote response_upvotes Upvote tracking with deduplication Prisma CustomRecipient custom_recipients Custom email targets for campaigns Prisma PostalCodeCache postal_code_cache Postal code geocoding cache Prisma EmailLog email_logs Global email audit trail Prisma EmailVerification email_verifications Email verification tokens Prisma Call calls Phone call tracking Prisma Map \u2014 Locations Location locations Building-level address data with geocoding Prisma Address addresses Unit-level data with support levels Prisma LocationHistory location_history Audit trail for location changes Prisma Map \u2014 Shifts & Cuts Shift shifts Volunteer shifts with scheduling Prisma ShiftSignup shift_signups Shift signup tracking Prisma Cut cuts GeoJSON polygon overlays for map filtering Prisma MapSettings map_settings Singleton for map configuration Prisma Canvassing CanvassSession canvass_sessions Canvassing session lifecycle Prisma CanvassVisit canvass_visits Visit recording with outcomes Prisma TrackingSession tracking_sessions GPS tracking sessions Prisma TrackPoint track_points GPS breadcrumb trail Prisma Email Templates EmailTemplate email_templates Email template master records Prisma EmailTemplateVariable email_template_variables Template variable definitions Prisma EmailTemplateVersion email_template_versions Template version history Prisma EmailTemplateTestLog email_template_test_logs Test email audit logs Prisma Landing Pages LandingPage landing_pages GrapesJS editor output with MkDocs export Prisma PageBlock page_blocks Reusable block library Prisma Site Settings SiteSettings site_settings Global site configuration singleton Prisma Media videos videos Video library with metadata Drizzle compilations compilations Video compilation tracking Drizzle jobs jobs Job queue with resource management Drizzle

Total: 33 models (30 Prisma + 3 Drizzle)

"},{"location":"v2/database/schema/#auth-users","title":"Auth & Users","text":""},{"location":"v2/database/schema/#user","title":"User","text":"

Table: users Description: User accounts with role-based access control, temporary user support, and audit tracking.

Field Type Required Default Description id String \u2713 cuid() Primary key email String \u2713 \u2014 Unique email address password String \u2713 \u2014 bcrypt hashed password (12+ chars policy) name String \u2717 null User display name phone String \u2717 null Phone number role UserRole \u2713 USER Role: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP status UserStatus \u2713 ACTIVE Status: ACTIVE, INACTIVE, SUSPENDED, EXPIRED permissions Json \u2717 null Granular per-app permissions object createdVia UserCreatedVia \u2713 STANDARD Creation source: ADMIN, PUBLIC_SHIFT_SIGNUP, STANDARD expiresAt DateTime \u2717 null Expiration date for TEMP users expireDays Int \u2717 null Days until expiration for TEMP users lastLoginAt DateTime \u2717 null Last login timestamp emailVerified Boolean \u2713 false Email verification status createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Unique: email

Relations (33 total): - refreshTokens \u2192 RefreshToken[] - campaignsCreated \u2192 Campaign[] - campaignEmails \u2192 CampaignEmail[] - responses \u2192 RepresentativeResponse[] - responseUpvotes \u2192 ResponseUpvote[] - shiftSignups \u2192 ShiftSignup[] - locationsCreated \u2192 Location[] - locationsUpdated \u2192 Location[] - addressesCreated \u2192 Address[] - addressesUpdated \u2192 Address[] - locationEdits \u2192 LocationHistory[] - cutsCreated \u2192 Cut[] - canvassVisits \u2192 CanvassVisit[] - canvassSessions \u2192 CanvassSession[] - trackingSessions \u2192 TrackingSession[] - templatesCreated \u2192 EmailTemplate[] - templatesUpdated \u2192 EmailTemplate[] - templateVersionsCreated \u2192 EmailTemplateVersion[] - templateTestsSent \u2192 EmailTemplateTestLog[]

"},{"location":"v2/database/schema/#refreshtoken","title":"RefreshToken","text":"

Table: refresh_tokens Description: JWT refresh token storage with expiration tracking.

Field Type Required Default Description id String \u2713 cuid() Primary key token String \u2713 \u2014 JWT refresh token (unique) userId String \u2713 \u2014 Foreign key to User expiresAt DateTime \u2713 \u2014 Token expiration timestamp createdAt DateTime \u2713 now() Creation timestamp

Indexes: - Unique: token - Foreign key: userId

Relations: - user \u2192 User (onDelete: Cascade)

"},{"location":"v2/database/schema/#influence","title":"Influence","text":""},{"location":"v2/database/schema/#campaign","title":"Campaign","text":"

Table: campaigns Description: Advocacy campaigns with 12 feature flags and government-level targeting.

Field Type Required Default Description id String \u2713 cuid() Primary key slug String \u2713 \u2014 URL-friendly slug (unique) title String \u2713 \u2014 Campaign title description String \u2717 null Campaign description (long text) emailSubject String \u2713 \u2014 Default email subject line emailBody String \u2713 \u2014 Default email body (long text) callToAction String \u2717 null Call-to-action text (long text) coverPhoto String \u2717 null Cover photo URL status CampaignStatus \u2713 DRAFT Status: DRAFT, ACTIVE, PAUSED, ARCHIVED allowSmtpEmail Boolean \u2713 true Allow SMTP email sending allowMailtoLink Boolean \u2713 true Allow mailto: links collectUserInfo Boolean \u2713 true Collect user information showEmailCount Boolean \u2713 true Show email sent count showCallCount Boolean \u2713 true Show call made count allowEmailEditing Boolean \u2713 false Allow users to edit email content allowCustomRecipients Boolean \u2713 false Allow custom email recipients showResponseWall Boolean \u2713 false Show public response wall highlightCampaign Boolean \u2713 false Highlight on campaign list page targetGovernmentLevels GovernmentLevel[] \u2713 [] Target levels: FEDERAL, PROVINCIAL, MUNICIPAL, SCHOOL_BOARD createdByUserId String \u2717 null Foreign key to User (creator) createdByUserEmail String \u2717 null Creator email (denormalized) createdByUserName String \u2717 null Creator name (denormalized) createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Unique: slug

Relations: - createdByUser \u2192 User (onDelete: SetNull) - emails \u2192 CampaignEmail[] - responses \u2192 RepresentativeResponse[] - customRecipients \u2192 CustomRecipient[] - calls \u2192 Call[]

"},{"location":"v2/database/schema/#representative","title":"Representative","text":"

Table: representatives Description: Cached representative data from Represent API.

Field Type Required Default Description id String \u2713 cuid() Primary key postalCode String \u2713 \u2014 Canadian postal code (indexed) name String \u2717 null Representative name email String \u2717 null Representative email districtName String \u2717 null Electoral district name electedOffice String \u2717 null Office title partyName String \u2717 null Political party representativeSetName String \u2717 null Representative set from Represent API url String \u2717 null Official website URL photoUrl String \u2717 null Photo URL offices Json \u2717 null Array of office contact info objects cachedAt DateTime \u2713 now() Cache timestamp

Indexes: - Non-unique: postalCode

Relations: None (standalone cache)

"},{"location":"v2/database/schema/#campaignemail","title":"CampaignEmail","text":"

Table: campaign_emails Description: Email tracking and delivery logs for campaign emails.

Field Type Required Default Description id String \u2713 cuid() Primary key campaignId String \u2713 \u2014 Foreign key to Campaign campaignSlug String \u2713 \u2014 Denormalized campaign slug userId String \u2717 null Foreign key to User (sender) userEmail String \u2717 null Sender email userName String \u2717 null Sender name userPostalCode String \u2717 null Sender postal code recipientEmail String \u2713 \u2014 Recipient email address recipientName String \u2717 null Recipient name recipientTitle String \u2717 null Recipient title recipientLevel GovernmentLevel \u2717 null Government level emailMethod EmailMethod \u2713 \u2014 Method: SMTP, MAILTO subject String \u2713 \u2014 Email subject line message String \u2713 \u2014 Email message body (long text) status CampaignEmailStatus \u2713 SENT Status: QUEUED, SENT, FAILED, CLICKED, USER_INFO_CAPTURED senderIp String \u2717 null Sender IP address sentAt DateTime \u2713 now() Send timestamp

Indexes: - Foreign key: campaignId - Non-unique: campaignSlug

Relations: - campaign \u2192 Campaign (onDelete: Cascade) - user \u2192 User (onDelete: SetNull)

"},{"location":"v2/database/schema/#representativeresponse","title":"RepresentativeResponse","text":"

Table: representative_responses Description: Response wall submissions with moderation workflow.

Field Type Required Default Description id String \u2713 cuid() Primary key campaignId String \u2713 \u2014 Foreign key to Campaign campaignSlug String \u2713 \u2014 Denormalized campaign slug representativeName String \u2713 \u2014 Representative name representativeTitle String \u2717 null Representative title representativeLevel GovernmentLevel \u2713 \u2014 Government level representativeEmail String \u2717 null Representative email responseType ResponseType \u2713 \u2014 Type: EMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHER responseText String \u2713 \u2014 Response text (long text) userComment String \u2717 null User comment (long text) screenshotUrl String \u2717 null Screenshot URL submittedByUserId String \u2717 null Foreign key to User submittedByName String \u2717 null Submitter name submittedByEmail String \u2717 null Submitter email isAnonymous Boolean \u2713 false Anonymous submission flag status ResponseStatus \u2713 PENDING Status: PENDING, APPROVED, REJECTED isVerified Boolean \u2713 false Email verification status verificationToken String \u2717 null Verification token verificationSentAt DateTime \u2717 null Verification email timestamp verifiedAt DateTime \u2717 null Verification timestamp verifiedBy String \u2717 null Email address that verified upvoteCount Int \u2713 0 Upvote count (denormalized) submittedIp String \u2717 null Submitter IP address createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Foreign key: campaignId - Non-unique: campaignSlug

Relations: - campaign \u2192 Campaign (onDelete: Cascade) - submittedByUser \u2192 User (onDelete: SetNull) - upvotes \u2192 ResponseUpvote[]

"},{"location":"v2/database/schema/#responseupvote","title":"ResponseUpvote","text":"

Table: response_upvotes Description: Upvote tracking with deduplication by user ID and IP address.

Field Type Required Default Description id String \u2713 cuid() Primary key responseId String \u2713 \u2014 Foreign key to RepresentativeResponse userId String \u2717 null Foreign key to User userEmail String \u2717 null User email (for guest upvotes) upvotedIp String \u2717 null Upvoter IP address

Indexes: - Unique: [responseId, userId] (prevent duplicate upvotes from logged-in users) - Unique: [responseId, upvotedIp] (prevent duplicate upvotes from same IP)

Relations: - response \u2192 RepresentativeResponse (onDelete: Cascade) - user \u2192 User (onDelete: SetNull)

"},{"location":"v2/database/schema/#customrecipient","title":"CustomRecipient","text":"

Table: custom_recipients Description: Custom email targets for campaigns (when allowCustomRecipients enabled).

Field Type Required Default Description id String \u2713 cuid() Primary key campaignId String \u2713 \u2014 Foreign key to Campaign campaignSlug String \u2713 \u2014 Denormalized campaign slug recipientName String \u2713 \u2014 Recipient name recipientEmail String \u2713 \u2014 Recipient email address recipientTitle String \u2717 null Recipient title recipientOrganization String \u2717 null Recipient organization notes String \u2717 null Admin notes (long text) isActive Boolean \u2713 true Active status createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Foreign key: campaignId

Relations: - campaign \u2192 Campaign (onDelete: Cascade)

"},{"location":"v2/database/schema/#postalcodecache","title":"PostalCodeCache","text":"

Table: postal_code_cache Description: Postal code geocoding cache for centroid lookups.

Field Type Required Default Description id String \u2713 cuid() Primary key postalCode String \u2713 \u2014 Canadian postal code (unique) city String \u2717 null City name province String \u2717 null Province code (e.g., \"AB\") centroidLat Decimal(10,8) \u2717 null Centroid latitude centroidLng Decimal(11,8) \u2717 null Centroid longitude lastUpdated DateTime \u2713 now() Last cache update

Indexes: - Unique: postalCode

Relations: None (standalone cache)

"},{"location":"v2/database/schema/#emaillog","title":"EmailLog","text":"

Table: email_logs Description: Global email audit trail (all email types).

Field Type Required Default Description id String \u2713 cuid() Primary key recipientEmail String \u2713 \u2014 Recipient email address senderName String \u2713 \u2014 Sender name senderEmail String \u2713 \u2014 Sender email address subject String \u2717 null Email subject line message String \u2717 null Email message body (long text) postalCode String \u2717 null Sender postal code status String \u2713 \"sent\" Status: sent, failed, previewed senderIp String \u2717 null Sender IP address sentAt DateTime \u2713 now() Send timestamp

Indexes: None

Relations: None (audit log only)

"},{"location":"v2/database/schema/#emailverification","title":"EmailVerification","text":"

Table: email_verifications Description: Email verification tokens for response wall submissions.

Field Type Required Default Description id String \u2713 cuid() Primary key token String \u2713 \u2014 Verification token (unique) email String \u2713 \u2014 Email address to verify tempCampaignData String \u2717 null Temporary campaign data JSON (long text) createdAt DateTime \u2713 now() Creation timestamp expiresAt DateTime \u2713 \u2014 Token expiration timestamp used Boolean \u2713 false Token used flag

Indexes: - Unique: token

Relations: None (standalone)

"},{"location":"v2/database/schema/#call","title":"Call","text":"

Table: calls Description: Phone call tracking for advocacy campaigns.

Field Type Required Default Description id String \u2713 cuid() Primary key representativeName String \u2713 \u2014 Representative name representativeTitle String \u2717 null Representative title phoneNumber String \u2713 \u2014 Phone number called officeType String \u2717 null Office type (constituency, legislative, etc.) callerName String \u2717 null Caller name callerEmail String \u2717 null Caller email postalCode String \u2717 null Caller postal code campaignId String \u2717 null Foreign key to Campaign campaignSlug String \u2717 null Denormalized campaign slug callerIp String \u2717 null Caller IP address calledAt DateTime \u2713 now() Call timestamp

Indexes: - Foreign key: campaignId

Relations: - campaign \u2192 Campaign (onDelete: SetNull)

"},{"location":"v2/database/schema/#map-locations","title":"Map \u2014 Locations","text":""},{"location":"v2/database/schema/#location","title":"Location","text":"

Table: locations Description: Building-level address data with geocoding and NAR integration.

Field Type Required Default Description id String \u2713 cuid() Primary key latitude Decimal(10,8) \u2713 \u2014 Latitude coordinate (required) longitude Decimal(11,8) \u2713 \u2014 Longitude coordinate (required) address String \u2713 \u2014 Base street address (no unit number) postalCode String \u2717 null Canadian postal code province String \u2717 null Province code (e.g., \"AB\") federalDistrict String \u2717 null Federal electoral district name buildingUse Int \u2717 null NAR BU_USE: 1=Residential, 2=Partial, 3=Non-Residential, 4=Unknown locGuid String \u2717 null NAR LOC_GUID (unique) buildingType BuildingType \u2713 SINGLE_FAMILY Type: SINGLE_FAMILY, MULTI_UNIT, MIXED_USE, COMMERCIAL totalUnits Int \u2713 1 Total units in building buildingNotes String \u2717 null Access codes, manager contact (long text) geocodeConfidence Int \u2717 null Geocoding confidence (0-100) geocodeProvider GeocodeProvider \u2717 null Provider: GOOGLE, MAPBOX, NOMINATIM, PHOTON, LOCATIONIQ, ARCGIS, UNKNOWN createdByUserId String \u2717 null Foreign key to User (creator) updatedByUserId String \u2717 null Foreign key to User (last updater) createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Unique: locGuid - Composite: [latitude, longitude] (spatial queries) - Non-unique: postalCode

Relations: - createdByUser \u2192 User (onDelete: SetNull) - updatedByUser \u2192 User (onDelete: SetNull) - addresses \u2192 Address[] - history \u2192 LocationHistory[]

"},{"location":"v2/database/schema/#address","title":"Address","text":"

Table: addresses Description: Unit-level data with support levels and canvassing information.

Field Type Required Default Description id String \u2713 cuid() Primary key locationId String \u2713 \u2014 Foreign key to Location unitNumber String \u2717 null Unit/apartment number addrGuid String \u2717 null NAR ADDR_GUID (unique) firstName String \u2717 null Occupant first name lastName String \u2717 null Occupant last name email String \u2717 null Occupant email phone String \u2717 null Occupant phone supportLevel SupportLevel \u2717 null Support level: 1, 2, 3, 4 sign Boolean \u2713 false Sign requested flag signSize String \u2717 null Sign size notes String \u2717 null Canvassing notes (long text) createdByUserId String \u2717 null Foreign key to User (creator) updatedByUserId String \u2717 null Foreign key to User (last updater) createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Unique: addrGuid - Foreign key: locationId - Composite: [locationId, unitNumber] (unit lookups)

Relations: - location \u2192 Location (onDelete: Cascade) - createdByUser \u2192 User (onDelete: SetNull) - updatedByUser \u2192 User (onDelete: SetNull) - canvassVisits \u2192 CanvassVisit[]

"},{"location":"v2/database/schema/#locationhistory","title":"LocationHistory","text":"

Table: location_history Description: Audit trail for location changes with action types.

Field Type Required Default Description id String \u2713 cuid() Primary key locationId String \u2713 \u2014 Foreign key to Location userId String \u2717 null Foreign key to User action LocationHistoryAction \u2713 \u2014 Action: CREATED, UPDATED, GEOCODED, BULK_GEOCODED, MOVED_ON_MAP, IMPORTED_CSV, IMPORTED_NAR field String \u2717 null Field name that changed oldValue String \u2717 null Old value (long text) newValue String \u2717 null New value (long text) metadata Json \u2717 null Provider, confidence, etc. createdAt DateTime \u2713 now() Timestamp

Indexes: - Foreign key: locationId - Foreign key: userId - Non-unique: createdAt (temporal queries)

Relations: - location \u2192 Location (onDelete: Cascade) - user \u2192 User (onDelete: SetNull)

"},{"location":"v2/database/schema/#map-shifts-cuts","title":"Map \u2014 Shifts & Cuts","text":""},{"location":"v2/database/schema/#shift","title":"Shift","text":"

Table: shifts Description: Volunteer shifts with scheduling and capacity tracking.

Field Type Required Default Description id String \u2713 cuid() Primary key title String \u2713 \u2014 Shift title description String \u2717 null Shift description (long text) date DateTime \u2713 \u2014 Shift date (date only, no time) startTime String \u2713 \u2014 Start time (HH:MM format) endTime String \u2713 \u2014 End time (HH:MM format) location String \u2717 null Shift location description maxVolunteers Int \u2713 \u2014 Maximum volunteer capacity currentVolunteers Int \u2713 0 Current signup count status ShiftStatus \u2713 OPEN Status: OPEN, FULL, CANCELLED isPublic Boolean \u2713 false Public signup allowed cutId String \u2717 null Foreign key to Cut createdBy String \u2717 null Creator user ID (string, not FK) createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Foreign key: cutId

Relations: - cut \u2192 Cut (onDelete: SetNull) - signups \u2192 ShiftSignup[] - canvassVisits \u2192 CanvassVisit[] - canvassSessions \u2192 CanvassSession[]

"},{"location":"v2/database/schema/#shiftsignup","title":"ShiftSignup","text":"

Table: shift_signups Description: Shift signup tracking with source attribution.

Field Type Required Default Description id String \u2713 cuid() Primary key shiftId String \u2713 \u2014 Foreign key to Shift shiftTitle String \u2717 null Denormalized shift title userId String \u2717 null Foreign key to User userEmail String \u2713 \u2014 User email (for guest signups) userName String \u2717 null User name userPhone String \u2717 null User phone signupDate DateTime \u2713 now() Signup timestamp status SignupStatus \u2713 CONFIRMED Status: CONFIRMED, CANCELLED signupSource SignupSource \u2713 AUTHENTICATED Source: AUTHENTICATED, PUBLIC, ADMIN

Indexes: - Unique: [shiftId, userEmail] (prevent duplicate signups) - Foreign key: shiftId

Relations: - shift \u2192 Shift (onDelete: Cascade) - user \u2192 User (onDelete: SetNull)

"},{"location":"v2/database/schema/#cut","title":"Cut","text":"

Table: cuts Description: GeoJSON polygon overlays for map filtering and canvassing.

Field Type Required Default Description id String \u2713 cuid() Primary key name String \u2713 \u2014 Cut name description String \u2717 null Cut description (long text) color String \u2713 \"#3388ff\" Polygon fill color (hex) opacity Decimal(3,2) \u2713 0.3 Polygon opacity (0.00-1.00) category CutCategory \u2717 null Category: CUSTOM, WARD, NEIGHBORHOOD, DISTRICT isPublic Boolean \u2713 false Public visibility flag isOfficial Boolean \u2713 false Official boundary flag geojson String \u2713 \u2014 GeoJSON polygon data (long text) bounds String \u2717 null Bounding box JSON (long text) showLocations Boolean \u2713 true Show locations on map exportEnabled Boolean \u2713 true Export enabled flag assignedTo String \u2717 null Assigned user ID (string, not FK) filterSettings Json \u2717 null Filter configuration object lastCanvassed DateTime \u2717 null Last canvass timestamp completionPercentage Int \u2713 0 Canvass completion percentage createdByUserId String \u2717 null Foreign key to User (creator) createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: None

Relations: - createdByUser \u2192 User (onDelete: SetNull) - shifts \u2192 Shift[] - canvassSessions \u2192 CanvassSession[]

"},{"location":"v2/database/schema/#mapsettings","title":"MapSettings","text":"

Table: map_settings Description: Singleton for map center/zoom and walk sheet configuration.

Field Type Required Default Description id String \u2713 cuid() Primary key (always \"default\") latitude Decimal(10,8) \u2717 null Map center latitude longitude Decimal(11,8) \u2717 null Map center longitude zoom Int \u2717 null Default map zoom level walkSheetTitle String \u2717 null Walk sheet header title walkSheetSubtitle String \u2717 null Walk sheet header subtitle walkSheetFooter String \u2717 null Walk sheet footer text (long text) qrCode1Url String \u2717 null QR code 1 URL qrCode1Label String \u2717 null QR code 1 label qrCode2Url String \u2717 null QR code 2 URL qrCode2Label String \u2717 null QR code 2 label qrCode3Url String \u2717 null QR code 3 URL qrCode3Label String \u2717 null QR code 3 label createdBy String \u2717 null Creator user ID (string, not FK) createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: None

Relations: None (singleton)

"},{"location":"v2/database/schema/#canvassing","title":"Canvassing","text":""},{"location":"v2/database/schema/#canvasssession","title":"CanvassSession","text":"

Table: canvass_sessions Description: Canvassing session lifecycle with status tracking.

Field Type Required Default Description id String \u2713 cuid() Primary key userId String \u2713 \u2014 Foreign key to User cutId String \u2713 \u2014 Foreign key to Cut shiftId String \u2717 null Foreign key to Shift status CanvassSessionStatus \u2713 ACTIVE Status: ACTIVE, COMPLETED, ABANDONED startedAt DateTime \u2713 now() Session start timestamp endedAt DateTime \u2717 null Session end timestamp startLatitude Decimal(10,8) \u2717 null Starting latitude startLongitude Decimal(11,8) \u2717 null Starting longitude

Indexes: - Foreign key: userId - Foreign key: cutId - Foreign key: shiftId

Relations: - user \u2192 User (onDelete: Cascade) - cut \u2192 Cut (onDelete: Cascade) - shift \u2192 Shift (onDelete: SetNull) - visits \u2192 CanvassVisit[] - trackingSession \u2192 TrackingSession (one-to-one)

"},{"location":"v2/database/schema/#canvassvisit","title":"CanvassVisit","text":"

Table: canvass_visits Description: Visit recording with outcome tracking.

Field Type Required Default Description id String \u2713 cuid() Primary key addressId String \u2713 \u2014 Foreign key to Address userId String \u2713 \u2014 Foreign key to User shiftId String \u2717 null Foreign key to Shift sessionId String \u2717 null Foreign key to CanvassSession outcome VisitOutcome \u2713 \u2014 Outcome: NOT_HOME, REFUSED, MOVED, ALREADY_VOTED, SPOKE_WITH, LEFT_LITERATURE, COME_BACK_LATER supportLevel SupportLevel \u2717 null Support level: 1, 2, 3, 4 signRequested Boolean \u2713 false Sign requested flag signSize String \u2717 null Sign size notes String \u2717 null Visit notes (long text) durationSeconds Int \u2717 null Visit duration in seconds visitedAt DateTime \u2713 now() Visit timestamp

Indexes: - Foreign key: addressId - Foreign key: userId - Foreign key: shiftId - Foreign key: sessionId - Non-unique: visitedAt (temporal queries)

Relations: - address \u2192 Address (onDelete: Cascade) - user \u2192 User (onDelete: Cascade) - shift \u2192 Shift (onDelete: SetNull) - session \u2192 CanvassSession (onDelete: SetNull)

"},{"location":"v2/database/schema/#trackingsession","title":"TrackingSession","text":"

Table: tracking_sessions Description: GPS tracking sessions with distance calculation.

Field Type Required Default Description id String \u2713 cuid() Primary key userId String \u2713 \u2014 Foreign key to User canvassSessionId String \u2717 null Foreign key to CanvassSession (unique, one-to-one) startedAt DateTime \u2713 now() Tracking start timestamp endedAt DateTime \u2717 null Tracking end timestamp isActive Boolean \u2713 true Active tracking flag totalPoints Int \u2713 0 Total GPS points recorded totalDistanceM Float \u2713 0 Total distance in meters lastLatitude Decimal(10,8) \u2717 null Last recorded latitude lastLongitude Decimal(11,8) \u2717 null Last recorded longitude lastRecordedAt DateTime \u2717 null Last GPS point timestamp

Indexes: - Unique: canvassSessionId - Foreign key: userId - Non-unique: isActive - Composite: [isActive, lastRecordedAt] (cleanup queries)

Relations: - user \u2192 User (onDelete: Cascade) - canvassSession \u2192 CanvassSession (onDelete: SetNull) - trackPoints \u2192 TrackPoint[]

"},{"location":"v2/database/schema/#trackpoint","title":"TrackPoint","text":"

Table: track_points Description: GPS breadcrumb trail with event types.

Field Type Required Default Description id String \u2713 cuid() Primary key trackingSessionId String \u2713 \u2014 Foreign key to TrackingSession latitude Decimal(10,8) \u2713 \u2014 GPS latitude longitude Decimal(11,8) \u2713 \u2014 GPS longitude accuracy Float \u2717 null GPS accuracy in meters recordedAt DateTime \u2713 now() GPS point timestamp eventType TrackPointEvent \u2717 null Event: LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED

Indexes: - Composite: [trackingSessionId, recordedAt] (temporal queries) - Non-unique: recordedAt

Relations: - trackingSession \u2192 TrackingSession (onDelete: Cascade)

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

Table: email_templates Description: Email template master records with category organization.

Field Type Required Default Description id String \u2713 cuid() Primary key key String \u2713 \u2014 Template key (unique, e.g., \"campaign-email\") name String \u2713 \u2014 Display name description String \u2717 null Template description (long text) category EmailTemplateCategory \u2713 \u2014 Category: INFLUENCE, MAP, SYSTEM subjectLine String \u2713 \u2014 Subject line with {{VAR}} support htmlContent String \u2713 \u2014 HTML template (long text) textContent String \u2713 \u2014 Plain text template (long text) isSystem Boolean \u2713 false System template (prevent deletion) isActive Boolean \u2713 true Active status createdByUserId String \u2713 \u2014 Foreign key to User (creator) updatedByUserId String \u2717 null Foreign key to User (last updater) createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Unique: key - Non-unique: category - Non-unique: isActive

Relations: - createdBy \u2192 User - updatedBy \u2192 User - variables \u2192 EmailTemplateVariable[] - versions \u2192 EmailTemplateVersion[] - testLogs \u2192 EmailTemplateTestLog[]

"},{"location":"v2/database/schema/#emailtemplatevariable","title":"EmailTemplateVariable","text":"

Table: email_template_variables Description: Template variable definitions with validation.

Field Type Required Default Description id String \u2713 cuid() Primary key templateId String \u2713 \u2014 Foreign key to EmailTemplate key String \u2713 \u2014 Variable key (e.g., \"USER_NAME\") label String \u2713 \u2014 Variable label (e.g., \"User Name\") description String \u2717 null Variable description (long text) isRequired Boolean \u2713 true Required flag isConditional Boolean \u2713 false Conditional variable (used in {{#if}}) sampleValue String \u2717 null Sample value for testing (long text) sortOrder Int \u2713 0 Display order

Indexes: - Unique: [templateId, key] (unique variable keys per template) - Foreign key: templateId

Relations: - template \u2192 EmailTemplate (onDelete: Cascade)

"},{"location":"v2/database/schema/#emailtemplateversion","title":"EmailTemplateVersion","text":"

Table: email_template_versions Description: Template version history with auto-increment version numbers.

Field Type Required Default Description id String \u2713 cuid() Primary key templateId String \u2713 \u2014 Foreign key to EmailTemplate versionNumber Int \u2713 \u2014 Auto-increment version number per template subjectLine String \u2713 \u2014 Subject line snapshot htmlContent String \u2713 \u2014 HTML content snapshot (long text) textContent String \u2713 \u2014 Plain text snapshot (long text) changeNotes String \u2717 null Version notes (long text) createdByUserId String \u2713 \u2014 Foreign key to User createdAt DateTime \u2713 now() Version timestamp

Indexes: - Unique: [templateId, versionNumber] (sequential version numbers) - Composite: [templateId, createdAt(sort: Desc)] (recent versions)

Relations: - template \u2192 EmailTemplate (onDelete: Cascade) - createdBy \u2192 User

"},{"location":"v2/database/schema/#emailtemplatetestlog","title":"EmailTemplateTestLog","text":"

Table: email_template_test_logs Description: Test email audit logs.

Field Type Required Default Description id String \u2713 cuid() Primary key templateId String \u2713 \u2014 Foreign key to EmailTemplate recipientEmail String \u2713 \u2014 Test recipient email testData Json \u2713 \u2014 Sample variable values JSON success Boolean \u2713 \u2014 Test success flag errorMessage String \u2717 null Error message (long text) messageId String \u2717 null Nodemailer message ID sentByUserId String \u2713 \u2014 Foreign key to User sentAt DateTime \u2713 now() Send timestamp

Indexes: - Composite: [templateId, sentAt(sort: Desc)] (recent tests)

Relations: - template \u2192 EmailTemplate (onDelete: Cascade) - sentBy \u2192 User

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

Table: landing_pages Description: GrapesJS editor output with MkDocs export support.

Field Type Required Default Description id String \u2713 cuid() Primary key slug String \u2713 \u2014 URL slug (unique) title String \u2713 \u2014 Page title description String \u2717 null Page description (long text) blocks Json \u2713 \u2014 GrapesJS editor JSON htmlOutput String \u2717 null Rendered HTML (long text) cssOutput String \u2717 null Rendered CSS (long text) editorMode EditorMode \u2713 VISUAL Editor mode: VISUAL, CODE mkdocsPath String \u2717 null Path in mkdocs/overrides/ mkdocsStubPath String \u2717 null Path to .md stub in mkdocs/docs/ mkdocsExportMode MkdocsExportMode \u2713 THEMED Export mode: THEMED, STANDALONE mkdocsHideNav Boolean \u2713 true Hide navigation in MkDocs mkdocsHideToc Boolean \u2713 true Hide table of contents in MkDocs mkdocsSkipExport Boolean \u2713 false Skip MkDocs export flag published Boolean \u2713 false Published status seoTitle String \u2717 null SEO title override seoDescription String \u2717 null SEO description (long text) seoImage String \u2717 null SEO image URL createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: - Unique: slug

Relations: None

"},{"location":"v2/database/schema/#pageblock","title":"PageBlock","text":"

Table: page_blocks Description: Reusable block library for GrapesJS editor.

Field Type Required Default Description id String \u2713 cuid() Primary key type String \u2713 \u2014 Block type (hero, text, image, cta, features, testimonials, form) label String \u2713 \u2014 Block label schema Json \u2713 \u2014 Block configuration schema JSON defaults Json \u2713 \u2014 Default values JSON thumbnail String \u2717 null Thumbnail URL category String \u2717 null Block category sortOrder Int \u2713 0 Display order createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: None

Relations: None

"},{"location":"v2/database/schema/#site-settings","title":"Site Settings","text":""},{"location":"v2/database/schema/#sitesettings","title":"SiteSettings","text":"

Table: site_settings Description: Global site configuration singleton for branding, theme, SMTP, and feature toggles.

Field Type Required Default Description id String \u2713 cuid() Primary key (always \"default\") organizationName String \u2713 \"Changemaker Lite\" Organization name organizationShortName String \u2713 \"CML\" Short name/acronym organizationLogoUrl String \u2717 null Logo URL organizationFaviconUrl String \u2717 null Favicon URL adminColorPrimary String \u2713 \"#9d4edd\" Admin primary color (hex) adminColorBgBase String \u2713 \"#1a1025\" Admin background color (hex) publicColorPrimary String \u2713 \"#3498db\" Public primary color (hex) publicColorBgBase String \u2713 \"#0d1b2a\" Public background color (hex) publicColorBgContainer String \u2713 \"#1b2838\" Public container color (hex) publicHeaderGradient String \u2713 \"linear-gradient(135deg, #005a9c 0%, #007acc 100%)\" Public header gradient (CSS) footerText String \u2713 \"Powered by Changemaker Lite\" Footer text loginSubtitle String \u2713 \"Admin\" Login page subtitle emailFromName String \u2713 \"Changemaker Lite\" Email from name smtpHost String \u2713 \"\" SMTP host (empty = use env) smtpPort Int \u2713 0 SMTP port (0 = use env) smtpUser String \u2713 \"\" SMTP username (empty = use env) smtpPass String \u2713 \"\" SMTP password (empty = use env) smtpFromAddress String \u2713 \"\" SMTP from address (empty = use env) smtpActiveProvider String \u2713 \"mailhog\" Active provider: \"mailhog\", \"production\" emailTestMode Boolean \u2713 true Email test mode flag testEmailRecipient String \u2713 \"\" Test email recipient enableInfluence Boolean \u2713 true Enable Influence module enableMap Boolean \u2713 true Enable Map module enableNewsletter Boolean \u2713 true Enable Newsletter module enableLandingPages Boolean \u2713 true Enable Landing Pages module createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp

Indexes: None

Relations: None (singleton)

"},{"location":"v2/database/schema/#media-drizzle-orm","title":"Media (Drizzle ORM)","text":""},{"location":"v2/database/schema/#videos","title":"videos","text":"

Table: videos Description: Video library with metadata extraction and engagement tracking.

Field Type Required Default Description id serial \u2713 Auto Primary key (auto-increment) path text \u2713 \u2014 File path (unique) filename text \u2713 \u2014 File name producer text \u2717 null Producer name creator text \u2717 null Creator name title text \u2717 null Video title durationSeconds integer \u2717 null Duration in seconds (FFprobe) quality text \u2717 null Quality string (e.g., \"1080p\") orientation text \u2717 null Orientation: portrait, landscape, square hasAudio boolean \u2713 true Audio track present flag fileSize bigint \u2717 null File size in bytes fileHash text \u2717 null MD5 hash width integer \u2717 null Video width (FFprobe) height integer \u2717 null Video height (FFprobe) lastValidated timestamp \u2717 null Last validation timestamp isValid boolean \u2713 true Valid file flag thumbnailPath text \u2717 null Thumbnail file path createdAt timestamp \u2713 now() Creation timestamp tags jsonb \u2717 null Array of tag strings directoryType text \u2717 null Directory type: studios, gifs, private, inbox, curated, playback, compilations, videos, highlights publicViewCount integer \u2717 null Public view count (historical) publicUpvoteCount integer \u2717 null Public upvote count (historical) publicCommentCount integer \u2717 null Public comment count (historical) publicCompletionCount integer \u2717 null Public completion count (historical) publicTotalWatchTime integer \u2717 null Public total watch time (historical) movedFromPublicAt timestamp \u2717 null Timestamp when moved from public media originalFilename text \u2717 null Original filename before standardization originalPath text \u2717 null Original path before standardization standardizedAt timestamp \u2717 null Standardization timestamp

Indexes: - Unique: path - Non-unique: orientation - Non-unique: producer - Non-unique: isValid - Non-unique: directoryType - Composite: [durationSeconds, fileSize, width, height] (fingerprint) - Composite: [directoryType, isValid, orientation] (common filtering)

Relations: None (standalone)

"},{"location":"v2/database/schema/#compilations","title":"compilations","text":"

Table: compilations Description: Video compilation tracking.

Field Type Required Default Description id serial \u2713 Auto Primary key (auto-increment) filename text \u2713 \u2014 Compilation filename path text \u2717 null Compilation file path durationSeconds integer \u2717 null Total duration in seconds videoIds jsonb \u2717 null Array of video IDs included settings jsonb \u2717 null Compilation settings object createdAt timestamp \u2713 now() Creation timestamp

Indexes: None

Relations: None (video IDs stored as JSON array)

"},{"location":"v2/database/schema/#jobs","title":"jobs","text":"

Table: jobs Description: Job queue with resource category management.

Field Type Required Default Description id serial \u2713 Auto Primary key (auto-increment) type text \u2713 \u2014 Job type (compilation, scan, organize, etc.) status text \u2713 \"pending\" Status: pending, queued, running, completed, failed, cancelled progress integer \u2713 0 Progress percentage (0-100) log text \u2717 null Job log output params jsonb \u2717 null Job parameters object startedAt timestamp \u2717 null Job start timestamp completedAt timestamp \u2717 null Job completion timestamp createdAt timestamp \u2713 now() Creation timestamp resourceCategory text \u2713 \"cpu\" Resource category: gpu_ai, gpu_encode, cpu vramRequired integer \u2713 0 VRAM required in MB queuePosition integer \u2717 null Queue position waitingReason text \u2717 null Reason for waiting priority integer \u2713 5 Job priority (lower = higher priority) pipelineId integer \u2717 null Pipeline ID (for pipeline jobs) pipelineStepId integer \u2717 null Pipeline step ID

Indexes: - Composite: [status, priority, createdAt] (queue processing) - Composite: [resourceCategory, status] (resource filtering) - Non-unique: pipelineId

Relations: None (pipeline relations are external)

"},{"location":"v2/database/schema/#related-documentation","title":"Related Documentation","text":"
  • Database Overview \u2014 Complete ER diagram and architecture
  • Migration Workflow \u2014 Prisma and Drizzle migration processes
  • Seeding \u2014 Default data and seed script
  • Indexes \u2014 Index strategy and performance
  • Auth Models \u2014 User and authentication models
  • Influence Models \u2014 Campaign and advocacy models
  • Map Models \u2014 Location, shift, and cut models
  • Canvassing Models \u2014 Session and visit tracking
  • Email Template Models \u2014 Template system models
  • Landing Page Models \u2014 Page builder models
  • Settings Models \u2014 Site and map settings
  • Media Models \u2014 Video library models (Drizzle)
"},{"location":"v2/database/seeding/","title":"Database Seeding","text":""},{"location":"v2/database/seeding/#overview","title":"Overview","text":"

The database seeding process populates initial data required for the application to function. Seeding runs automatically after migrations in development but must be run manually in production.

Seed Script: api/prisma/seed.ts

Seed Data: - Default super admin user - Default map settings (Edmonton coordinates) - 6 page blocks for landing page builder - 4 email templates (campaign email, response verification, shift signup confirmation, shift details reminder)

"},{"location":"v2/database/seeding/#running-seed","title":"Running Seed","text":""},{"location":"v2/database/seeding/#development","title":"Development","text":"
cd api\nnpm run seed\n# OR\nnpx prisma db seed\n
"},{"location":"v2/database/seeding/#production-docker","title":"Production (Docker)","text":"
docker compose exec api npx prisma db seed\n
"},{"location":"v2/database/seeding/#cicd","title":"CI/CD","text":"

Seed runs automatically after prisma migrate deploy if configured in package.json:

{\n  \"prisma\": {\n    \"seed\": \"ts-node prisma/seed.ts\"\n  }\n}\n

"},{"location":"v2/database/seeding/#seed-data-details","title":"Seed Data Details","text":""},{"location":"v2/database/seeding/#1-default-admin-user","title":"1. Default Admin User","text":"

Email: admin@cmlite.org Password: ChangeMe2025! Role: SUPER_ADMIN Status: ACTIVE Email Verified: true

Code:

const hashedPassword = await bcrypt.hash('ChangeMe2025!', 10);\n\nconst admin = await prisma.user.upsert({\n  where: { email: 'admin@cmlite.org' },\n  update: {\n    password: hashedPassword,\n    emailVerified: true,\n    status: 'ACTIVE',\n  },\n  create: {\n    email: 'admin@cmlite.org',\n    password: hashedPassword,\n    name: 'Admin',\n    role: UserRole.SUPER_ADMIN,\n    emailVerified: true,\n  },\n});\n

Security Note: Change default password immediately after first login!

"},{"location":"v2/database/seeding/#2-default-map-settings","title":"2. Default Map Settings","text":"

ID: default (singleton) Coordinates: Edmonton, AB (53.5461\u00b0N, 113.4938\u00b0W) Zoom: 11 Walk Sheet: Blank titles/footers

Code:

await prisma.mapSettings.upsert({\n  where: { id: 'default' },\n  update: {},\n  create: {\n    id: 'default',\n    latitude: 53.5461,\n    longitude: -113.4938,\n    zoom: 11,\n    walkSheetTitle: 'Walk Sheet',\n    walkSheetSubtitle: '',\n    walkSheetFooter: '',\n  },\n});\n

"},{"location":"v2/database/seeding/#3-page-blocks-6-blocks","title":"3. Page Blocks (6 blocks)","text":""},{"location":"v2/database/seeding/#hero-section","title":"Hero Section","text":"
{\n  id: 'default-hero',\n  type: 'hero',\n  label: 'Hero Section',\n  category: 'Headers',\n  sortOrder: 1,\n  schema: {\n    title: { type: 'string', label: 'Title' },\n    subtitle: { type: 'string', label: 'Subtitle' },\n    backgroundImage: { type: 'string', label: 'Background Image URL' },\n    ctaText: { type: 'string', label: 'Button Text' },\n    ctaUrl: { type: 'string', label: 'Button URL' },\n  },\n  defaults: {\n    title: 'Welcome to Our Campaign',\n    subtitle: 'Join us in making a difference in your community.',\n    backgroundImage: '',\n    ctaText: 'Get Involved',\n    ctaUrl: '#',\n  },\n}\n
"},{"location":"v2/database/seeding/#text-block","title":"Text Block","text":"
{\n  id: 'default-text',\n  type: 'text',\n  label: 'Text Block',\n  category: 'Content',\n  sortOrder: 2,\n  schema: {\n    heading: { type: 'string', label: 'Heading' },\n    body: { type: 'text', label: 'Body Text' },\n  },\n  defaults: {\n    heading: 'About Us',\n    body: 'Tell your story here...',\n  },\n}\n
"},{"location":"v2/database/seeding/#features-grid","title":"Features Grid","text":"
{\n  id: 'default-features',\n  type: 'features',\n  label: 'Features Grid',\n  category: 'Content',\n  sortOrder: 3,\n  schema: {\n    features: {\n      type: 'array',\n      label: 'Features',\n      items: { title: 'string', description: 'string', icon: 'string' }\n    },\n  },\n  defaults: {\n    features: [\n      { title: 'Community Action', description: 'Organize local events...', icon: '' },\n      { title: 'Advocacy', description: 'Email your representatives...', icon: '' },\n      { title: 'Volunteer', description: 'Sign up for shifts...', icon: '' },\n    ],\n  },\n}\n
"},{"location":"v2/database/seeding/#call-to-action","title":"Call to Action","text":"
{\n  id: 'default-cta',\n  type: 'cta',\n  label: 'Call to Action',\n  category: 'Actions',\n  sortOrder: 4,\n  // ... (see seed.ts for full schema)\n}\n
"},{"location":"v2/database/seeding/#testimonials","title":"Testimonials","text":"
{\n  id: 'default-testimonials',\n  type: 'testimonials',\n  label: 'Testimonials',\n  category: 'Content',\n  sortOrder: 5,\n  // ... (see seed.ts for full schema)\n}\n
"},{"location":"v2/database/seeding/#contact-form","title":"Contact Form","text":"
{\n  id: 'default-contact-form',\n  type: 'contact-form',\n  label: 'Contact Form',\n  category: 'Actions',\n  sortOrder: 6,\n  // ... (see seed.ts for full schema)\n}\n
"},{"location":"v2/database/seeding/#4-email-templates-4-templates","title":"4. Email Templates (4 templates)","text":""},{"location":"v2/database/seeding/#campaign-email-to-representative","title":"Campaign Email to Representative","text":"

Key: campaign-email Category: INFLUENCE Variables: CAMPAIGN_TITLE, MESSAGE, USER_NAME, USER_EMAIL, POSTAL_CODE, RECIPIENT_NAME, RECIPIENT_LEVEL, ORGANIZATION_NAME, TIMESTAMP

File Locations: - HTML: api/src/templates/email/campaign-email.html - Text: api/src/templates/email/campaign-email.txt

Seeding Logic:

const templateDef = {\n  key: 'campaign-email',\n  name: 'Campaign Email to Representative',\n  description: 'Email sent when a constituent contacts their elected representative through a campaign',\n  category: EmailTemplateCategory.INFLUENCE,\n  subjectLine: '{{CAMPAIGN_TITLE}} - Message from {{USER_NAME}}',\n  isSystem: true,\n  variables: [\n    { key: 'CAMPAIGN_TITLE', label: 'Campaign Title', isRequired: true, sampleValue: 'Support Climate Action Bill C-12' },\n    { key: 'MESSAGE', label: 'Message Body', isRequired: true, sampleValue: 'I urge you to support...' },\n    // ... 7 more variables\n  ],\n};\n\nconst htmlContent = fs.readFileSync(path.join(templatesDir, `${templateDef.key}.html`), 'utf-8');\nconst textContent = fs.readFileSync(path.join(templatesDir, `${templateDef.key}.txt`), 'utf-8');\n\nconst template = await prisma.emailTemplate.create({\n  data: {\n    ...templateDef,\n    htmlContent,\n    textContent,\n    createdByUserId: admin.id,\n    variables: {\n      create: templateDef.variables,\n    },\n  },\n});\n

"},{"location":"v2/database/seeding/#response-verification","title":"Response Verification","text":"

Key: response-verification Category: INFLUENCE Variables: CAMPAIGN_TITLE, RESPONSE_TYPE, RESPONSE_TEXT, SUBMITTER_NAME, SUBMITTED_DATE, VERIFICATION_URL, REPORT_URL, ORGANIZATION_NAME, TIMESTAMP

"},{"location":"v2/database/seeding/#shift-signup-confirmation","title":"Shift Signup Confirmation","text":"

Key: shift-signup-confirmation Category: MAP Variables: ORGANIZATION_NAME, USER_NAME, USER_EMAIL, SHIFT_TITLE, SHIFT_DATE, SHIFT_TIME, SHIFT_LOCATION, IS_NEW_USER, TEMP_PASSWORD, LOGIN_URL

"},{"location":"v2/database/seeding/#shift-details-reminder","title":"Shift Details Reminder","text":"

Key: shift-details Category: MAP Variables: ORGANIZATION_NAME, USER_NAME, SHIFT_TITLE, SHIFT_DATE, SHIFT_START_TIME, SHIFT_END_TIME, SHIFT_LOCATION, SHIFT_DESCRIPTION, CURRENT_VOLUNTEERS, MAX_VOLUNTEERS, SHIFT_STATUS

"},{"location":"v2/database/seeding/#seed-script-structure","title":"Seed Script Structure","text":""},{"location":"v2/database/seeding/#main-function","title":"Main Function","text":"
async function main() {\n  console.log('Seeding database...');\n\n  // 1. Create admin user\n  const admin = await createAdminUser();\n\n  // 2. Create map settings\n  await createMapSettings();\n\n  // 3. Create page blocks\n  await createPageBlocks();\n\n  // 4. Seed email templates\n  await seedEmailTemplates(admin);\n\n  console.log('Seed complete.');\n}\n
"},{"location":"v2/database/seeding/#upsert-pattern","title":"Upsert Pattern","text":"

All seed operations use upsert to be idempotent:

await prisma.pageBlock.upsert({\n  where: { id: block.id },\n  update: {},  // Don't update if exists\n  create: block,  // Create if doesn't exist\n});\n

Benefits: - Safe to run multiple times - Won't duplicate data - Won't overwrite user changes (empty update clause)

"},{"location":"v2/database/seeding/#error-handling","title":"Error Handling","text":"
main()\n  .catch((e) => {\n    console.error('Seed error:', e);\n    process.exit(1);\n  })\n  .finally(async () => {\n    await prisma.$disconnect();\n  });\n
"},{"location":"v2/database/seeding/#customizing-seed-data","title":"Customizing Seed Data","text":""},{"location":"v2/database/seeding/#change-admin-credentials","title":"Change Admin Credentials","text":"

Edit api/prisma/seed.ts:

const hashedPassword = await bcrypt.hash('YourSecurePassword123!', 10);\n\nconst admin = await prisma.user.upsert({\n  where: { email: 'your-email@example.com' },  // Change email\n  update: {\n    password: hashedPassword,\n    emailVerified: true,\n    status: 'ACTIVE',\n  },\n  create: {\n    email: 'your-email@example.com',  // Change email\n    password: hashedPassword,\n    name: 'Your Name',  // Change name\n    role: UserRole.SUPER_ADMIN,\n    emailVerified: true,\n  },\n});\n

"},{"location":"v2/database/seeding/#change-map-default-location","title":"Change Map Default Location","text":"

Edit api/prisma/seed.ts:

await prisma.mapSettings.upsert({\n  where: { id: 'default' },\n  update: {},\n  create: {\n    id: 'default',\n    latitude: 51.0447,    // Calgary, AB\n    longitude: -114.0719,\n    zoom: 11,\n    walkSheetTitle: 'Calgary Canvass Walk Sheet',\n    walkSheetSubtitle: 'District Canvassing',\n    walkSheetFooter: 'Thank you for volunteering!',\n  },\n});\n

"},{"location":"v2/database/seeding/#add-custom-page-blocks","title":"Add Custom Page Blocks","text":"
const customBlocks = [\n  {\n    id: 'custom-video',\n    type: 'video',\n    label: 'Video Embed',\n    category: 'Media',\n    sortOrder: 7,\n    schema: {\n      videoUrl: { type: 'string', label: 'Video URL' },\n      caption: { type: 'string', label: 'Caption' },\n    },\n    defaults: {\n      videoUrl: 'https://www.youtube.com/embed/...',\n      caption: 'Watch our video',\n    },\n  },\n];\n\nfor (const block of customBlocks) {\n  await prisma.pageBlock.upsert({\n    where: { id: block.id },\n    update: {},\n    create: block,\n  });\n}\n
"},{"location":"v2/database/seeding/#verifying-seed-data","title":"Verifying Seed Data","text":""},{"location":"v2/database/seeding/#check-admin-user","title":"Check Admin User","text":"
docker compose exec api npx prisma studio\n# Navigate to users table, filter by role = \"SUPER_ADMIN\"\n
"},{"location":"v2/database/seeding/#check-map-settings","title":"Check Map Settings","text":"
docker compose exec v2-postgres psql -U changemaker_v2 changemaker_v2 -c \"SELECT * FROM map_settings;\"\n
"},{"location":"v2/database/seeding/#check-page-blocks","title":"Check Page Blocks","text":"
docker compose exec v2-postgres psql -U changemaker_v2 changemaker_v2 -c \"SELECT id, type, label FROM page_blocks ORDER BY sort_order;\"\n
"},{"location":"v2/database/seeding/#check-email-templates","title":"Check Email Templates","text":"
docker compose exec v2-postgres psql -U changemaker_v2 changemaker_v2 -c \"SELECT key, name, category FROM email_templates;\"\n
"},{"location":"v2/database/seeding/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/database/seeding/#error-unique-constraint-failed-on-email","title":"Error: \"Unique constraint failed on email\"","text":"

Cause: Admin user already exists Solution: Seed uses upsert, so this shouldn't happen. Check seed script for typos.

"},{"location":"v2/database/seeding/#error-template-files-not-found","title":"Error: \"Template files not found\"","text":"

Cause: Email template .html/.txt files missing Solution: Ensure api/src/templates/email/ directory contains: - campaign-email.html - campaign-email.txt - response-verification.html - response-verification.txt - shift-signup-confirmation.html - shift-signup-confirmation.txt - shift-details.html - shift-details.txt

"},{"location":"v2/database/seeding/#error-cannot-find-module-bcryptjs","title":"Error: \"Cannot find module 'bcryptjs'\"","text":"

Cause: Dependencies not installed Solution:

cd api && npm install\n

"},{"location":"v2/database/seeding/#seed-doesnt-run-after-migration","title":"Seed doesn't run after migration","text":"

Cause: package.json missing prisma.seed config Solution: Add to api/package.json:

{\n  \"prisma\": {\n    \"seed\": \"ts-node prisma/seed.ts\"\n  }\n}\n

"},{"location":"v2/database/seeding/#production-seeding","title":"Production Seeding","text":""},{"location":"v2/database/seeding/#initial-deployment","title":"Initial Deployment","text":"
# 1. Deploy migrations\ndocker compose exec api npx prisma migrate deploy\n\n# 2. Run seed\ndocker compose exec api npx prisma db seed\n\n# 3. Change admin password immediately\n# Login at https://app.cmlite.org with admin@cmlite.org / ChangeMe2025!\n# Navigate to /app/profile, update password\n
"},{"location":"v2/database/seeding/#subsequent-deployments","title":"Subsequent Deployments","text":"

Don't re-run seed unless adding new seed data (new page blocks, email templates, etc.). Existing seed data uses upsert with empty update clause, so it won't overwrite user changes.

"},{"location":"v2/database/seeding/#related-documentation","title":"Related Documentation","text":"
  • Database Overview \u2014 Complete ER diagram
  • Schema Reference \u2014 All model fields
  • Migration Workflow \u2014 Prisma migrations
  • Auth Models \u2014 User model details
  • Settings Models \u2014 MapSettings details
  • Landing Page Models \u2014 PageBlock details
  • Email Template Models \u2014 EmailTemplate details
"},{"location":"v2/database/models/","title":"Database Models","text":"

Changemaker Lite V2 uses a comprehensive PostgreSQL database schema with 30+ models across authentication, campaigns, locations, media, and content management. The schema is managed via Prisma ORM (main API) and Drizzle ORM (media API).

"},{"location":"v2/database/models/#model-organization","title":"Model Organization","text":"

Models are organized by feature area:

"},{"location":"v2/database/models/#authentication-users","title":"Authentication & Users","text":"

Core authentication and user management:

  • User - User accounts with roles and authentication
  • RefreshToken - JWT refresh token tracking
  • Session - User session management (future)
"},{"location":"v2/database/models/#influence-module","title":"Influence Module","text":"

Advocacy campaign models:

  • Campaign - Campaign definitions and settings
  • CampaignEmail - Sent email tracking
  • Response - Public response wall submissions
  • PostalCodeCache - Representative lookup cache
"},{"location":"v2/database/models/#map-module","title":"Map Module","text":"

Location and geographic models:

  • Location - Address database with geocoding
  • Cut - Geographic polygon organization
  • Shift - Volunteer shift scheduling
  • MapSettings - Map configuration singleton
"},{"location":"v2/database/models/#canvassing","title":"Canvassing","text":"

Door-to-door canvassing models:

  • CanvassSession - Canvassing session tracking
  • CanvassVisit - Visit outcome recording
  • TrackingSession - GPS tracking (future)
"},{"location":"v2/database/models/#content-management","title":"Content Management","text":"

Landing pages and content:

  • Page - Landing page definitions
  • PageBlock - Reusable content blocks
"},{"location":"v2/database/models/#email-templates","title":"Email Templates","text":"

Email template system:

  • EmailTemplate - Template definitions
  • EmailTemplateVersion - Version history (future)
"},{"location":"v2/database/models/#media","title":"Media","text":"

Video library (Drizzle ORM):

  • videos - Video metadata and files
  • shared_media - Public gallery assignments
  • media_reactions - Emoji reactions
  • media_jobs - Background job queue
"},{"location":"v2/database/models/#settings","title":"Settings","text":"

Global configuration:

  • Settings - Site-wide settings singleton
"},{"location":"v2/database/models/#orm-architecture","title":"ORM Architecture","text":""},{"location":"v2/database/models/#prisma-main-api","title":"Prisma (Main API)","text":"

Used for 95% of models:

  • Schema: api/prisma/schema.prisma
  • Migrations: api/prisma/migrations/
  • Client: Auto-generated TypeScript types
  • Database: PostgreSQL 16
"},{"location":"v2/database/models/#drizzle-media-api","title":"Drizzle (Media API)","text":"

Used for media models only:

  • Schema: api/src/modules/media/db/schema.ts
  • Migrations: None (push-based)
  • Client: Manual schema definition
  • Database: Same PostgreSQL 16
"},{"location":"v2/database/models/#common-patterns","title":"Common Patterns","text":""},{"location":"v2/database/models/#timestamps","title":"Timestamps","text":"

Most models include:

createdAt DateTime @default(now())\nupdatedAt DateTime @updatedAt\n
"},{"location":"v2/database/models/#foreign-keys","title":"Foreign Keys","text":"

Relations use explicit foreign key fields:

model Campaign {\n  id              Int     @id @default(autoincrement())\n  createdByUserId Int\n  createdBy       User    @relation(fields: [createdByUserId], references: [id])\n}\n
"},{"location":"v2/database/models/#json-fields","title":"JSON Fields","text":"

Flexible data stored as JSON:

model Campaign {\n  emailTemplate Json?\n  settings      Json?\n}\n

TypeScript types:

import { Prisma } from '@prisma/client';\n\nconst template: Prisma.InputJsonValue = {\n  subject: 'Email subject',\n  body: 'Email body',\n};\n
"},{"location":"v2/database/models/#enums","title":"Enums","text":"

Type-safe enumerations:

enum Role {\n  SUPER_ADMIN\n  INFLUENCE_ADMIN\n  MAP_ADMIN\n  USER\n  TEMP\n}\n\nenum VisitOutcome {\n  SUCCESS\n  NOT_HOME\n  MOVED\n  REFUSED\n  WRONG_ADDRESS\n  INACCESSIBLE\n  OTHER\n}\n
"},{"location":"v2/database/models/#model-count-by-category","title":"Model Count by Category","text":"Category Models ORM Authentication 3 Prisma Influence 4 Prisma Map 4 Prisma Canvassing 3 Prisma Content 2 Prisma Email Templates 2 Prisma Media 4 Drizzle Settings 2 Prisma Total 24 Mixed"},{"location":"v2/database/models/#database-operations","title":"Database Operations","text":""},{"location":"v2/database/models/#migrations-prisma","title":"Migrations (Prisma)","text":"
# Create migration\ncd api && npx prisma migrate dev --name add_field\n\n# Deploy migrations\ncd api && npx prisma migrate deploy\n\n# Reset database (dev only)\ncd api && npx prisma migrate reset\n
"},{"location":"v2/database/models/#schema-push-drizzle","title":"Schema Push (Drizzle)","text":"
# Push schema changes (media API)\ncd api && npx drizzle-kit push\n
"},{"location":"v2/database/models/#database-browser","title":"Database Browser","text":"

View data via:

  • Prisma Studio: npx prisma studio
  • NocoDB: http://localhost:8091 (read-only)
"},{"location":"v2/database/models/#indexes","title":"Indexes","text":"

Key indexes for performance:

model Location {\n  @@index([cutId])\n  @@index([lastVisitedAt])\n}\n\nmodel Campaign {\n  @@index([published])\n  @@index([createdByUserId])\n}\n\nmodel CanvassSession {\n  @@index([userId])\n  @@index([status])\n}\n
"},{"location":"v2/database/models/#constraints","title":"Constraints","text":""},{"location":"v2/database/models/#unique-constraints","title":"Unique Constraints","text":"
model User {\n  email String @unique\n}\n\nmodel Page {\n  slug String @unique\n}\n\nmodel Cut {\n  name String @unique\n}\n
"},{"location":"v2/database/models/#check-constraints","title":"Check Constraints","text":"

Enforced at application level:

  • Email format validation
  • Password complexity (12+ chars)
  • Coordinate bounds (-90 to 90 lat, -180 to 180 lng)
"},{"location":"v2/database/models/#relations","title":"Relations","text":""},{"location":"v2/database/models/#one-to-many","title":"One-to-Many","text":"
model User {\n  id        Int        @id @default(autoincrement())\n  campaigns Campaign[]\n}\n\nmodel Campaign {\n  id              Int  @id @default(autoincrement())\n  createdByUserId Int\n  createdBy       User @relation(fields: [createdByUserId], references: [id])\n}\n
"},{"location":"v2/database/models/#many-to-many","title":"Many-to-Many","text":"

Via junction tables:

model Shift {\n  id      Int            @id @default(autoincrement())\n  signups ShiftSignup[]\n}\n\nmodel User {\n  id      Int            @id @default(autoincrement())\n  signups ShiftSignup[]\n}\n\nmodel ShiftSignup {\n  id      Int   @id @default(autoincrement())\n  shiftId Int\n  userId  Int\n  shift   Shift @relation(fields: [shiftId], references: [id])\n  user    User  @relation(fields: [userId], references: [id])\n\n  @@unique([shiftId, userId])\n}\n
"},{"location":"v2/database/models/#seeding","title":"Seeding","text":"

Initial data in api/prisma/seed.ts:

  • Admin user (admin@example.com)
  • Default settings
  • Sample page blocks
  • System email templates
# Run seed\ncd api && npx prisma db seed\n
"},{"location":"v2/database/models/#data-types","title":"Data Types","text":""},{"location":"v2/database/models/#common-types","title":"Common Types","text":"
  • ID: Int @id @default(autoincrement())
  • String: String or String @db.Text (long text)
  • Number: Int or Float
  • Boolean: Boolean @default(false)
  • Date: DateTime @default(now())
  • JSON: Json or Json?
  • Enum: Role, VisitOutcome, etc.
"},{"location":"v2/database/models/#spatial-data","title":"Spatial Data","text":"

GeoJSON stored as JSON:

model Cut {\n  geometry Json  // GeoJSON Polygon\n}\n

Coordinates as separate fields:

model Location {\n  latitude  Float\n  longitude Float\n}\n
"},{"location":"v2/database/models/#database-configuration","title":"Database Configuration","text":""},{"location":"v2/database/models/#connection-string","title":"Connection String","text":"
DATABASE_URL=\"postgresql://user:password@localhost:5432/changemaker_v2?schema=public\"\n
"},{"location":"v2/database/models/#connection-pool","title":"Connection Pool","text":"

Prisma connection pool:

// api/src/server.ts\nconst prisma = new PrismaClient({\n  log: ['error', 'warn'],\n});\n
"},{"location":"v2/database/models/#related-documentation","title":"Related Documentation","text":"
  • Authentication Models
  • Influence Models
  • Map Models
  • Canvassing Models
  • Content Models
  • Email Template Models
  • Media Models
  • Settings Models
  • Database Overview
  • Migrations Guide
  • Backend Modules
"},{"location":"v2/database/models/auth/","title":"Auth & Users Models","text":""},{"location":"v2/database/models/auth/#overview","title":"Overview","text":"

The Auth & Users module provides JWT-based authentication with role-based access control (RBAC), temporary user support for public shift signups, and refresh token rotation for enhanced security.

Models: - User \u2014 User accounts with roles and permissions - RefreshToken \u2014 JWT refresh token storage with expiration

Key Features: - bcrypt password hashing (12+ character policy enforced at schema level) - JWT access tokens (15min) + refresh tokens (7 days) - Refresh token rotation with atomic transactions - Role hierarchy: SUPER_ADMIN > INFLUENCE_ADMIN > MAP_ADMIN > USER > TEMP - Temporary user support with auto-expiration - Email verification workflow - User enumeration prevention (401 not 404)

"},{"location":"v2/database/models/auth/#models-summary","title":"Models Summary","text":"Model Table Description User users User accounts with RBAC, permissions, temp user support RefreshToken refresh_tokens JWT refresh tokens with expiration tracking"},{"location":"v2/database/models/auth/#user-model","title":"User Model","text":""},{"location":"v2/database/models/auth/#purpose","title":"Purpose","text":"

The User model represents all system users, from super admins to temporary volunteers created via public shift signup. It supports role-based access control, granular permissions, temporary user expiration, and comprehensive audit tracking via 33 relation fields.

"},{"location":"v2/database/models/auth/#fields","title":"Fields","text":"Field Type Required Default Description Identity id String \u2713 cuid() Primary key email String \u2713 \u2014 Unique email address (lowercase) password String \u2713 \u2014 bcrypt hashed (12+ chars, 1 uppercase, 1 lowercase, 1 digit) name String \u2717 null User display name phone String \u2717 null Phone number Authorization role UserRole \u2713 USER User role (see enum below) status UserStatus \u2713 ACTIVE Account status (see enum below) permissions Json \u2717 null Granular per-app permissions object User Lifecycle createdVia UserCreatedVia \u2713 STANDARD Creation source (see enum below) expiresAt DateTime \u2717 null Expiration date for TEMP users expireDays Int \u2717 null Days until expiration (for TEMP users) lastLoginAt DateTime \u2717 null Last login timestamp emailVerified Boolean \u2713 false Email verification status Audit createdAt DateTime \u2713 now() Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp"},{"location":"v2/database/models/auth/#enums","title":"Enums","text":""},{"location":"v2/database/models/auth/#userrole","title":"UserRole","text":"

Role hierarchy (descending):

enum UserRole {\n  SUPER_ADMIN       // Full system access, can manage all users\n  INFLUENCE_ADMIN   // Manage influence module (campaigns, responses)\n  MAP_ADMIN         // Manage map module (locations, shifts, cuts)\n  USER              // Standard volunteer with assigned permissions\n  TEMP              // Temporary user (auto-expires, restricted access)\n}\n

Role Capabilities: - SUPER_ADMIN: Full access to all modules, user management, settings - INFLUENCE_ADMIN: Campaign CRUD, response moderation, email queue admin - MAP_ADMIN: Location CRUD, shift management, cut creation, canvass oversight - USER: Public campaign actions, shift signup, canvass sessions (via assigned cut) - TEMP: Canvass sessions only (via shift signup), auto-expires after X days

"},{"location":"v2/database/models/auth/#userstatus","title":"UserStatus","text":"
enum UserStatus {\n  ACTIVE     // Normal active user\n  INACTIVE   // Manually deactivated (login blocked)\n  SUSPENDED  // Temporarily suspended (login blocked)\n  EXPIRED    // Auto-expired temp user (login blocked)\n}\n
"},{"location":"v2/database/models/auth/#usercreatedvia","title":"UserCreatedVia","text":"
enum UserCreatedVia {\n  ADMIN                 // Created by admin in user management\n  PUBLIC_SHIFT_SIGNUP   // Auto-created via public shift signup\n  STANDARD              // Self-registered (if enabled)\n}\n
"},{"location":"v2/database/models/auth/#relations-33-total","title":"Relations (33 total)","text":"

Authentication: - refreshTokens \u2192 RefreshToken[] (onDelete: Cascade)

Influence Module (6): - campaignsCreated \u2192 Campaign[] (creator, onDelete: SetNull) - campaignEmails \u2192 CampaignEmail[] (sender, onDelete: SetNull) - responses \u2192 RepresentativeResponse[] (submitter, onDelete: SetNull) - responseUpvotes \u2192 ResponseUpvote[] (onDelete: SetNull)

Map Module (8): - locationsCreated \u2192 Location[] (creator, onDelete: SetNull) - locationsUpdated \u2192 Location[] (updater, onDelete: SetNull) - addressesCreated \u2192 Address[] (creator, onDelete: SetNull) - addressesUpdated \u2192 Address[] (updater, onDelete: SetNull) - locationEdits \u2192 LocationHistory[] (editor, onDelete: SetNull) - cutsCreated \u2192 Cut[] (creator, onDelete: SetNull) - shiftSignups \u2192 ShiftSignup[] (onDelete: SetNull)

Canvassing Module (4): - canvassVisits \u2192 CanvassVisit[] (visitor, onDelete: Cascade) - canvassSessions \u2192 CanvassSession[] (onDelete: Cascade) - trackingSessions \u2192 TrackingSession[] (onDelete: Cascade)

Email Templates Module (4): - templatesCreated \u2192 EmailTemplate[] (creator) - templatesUpdated \u2192 EmailTemplate[] (updater) - templateVersionsCreated \u2192 EmailTemplateVersion[] - templateTestsSent \u2192 EmailTemplateTestLog[]

"},{"location":"v2/database/models/auth/#indexes","title":"Indexes","text":"
  • Unique: email (case-insensitive via Prisma transform)
"},{"location":"v2/database/models/auth/#constraints","title":"Constraints","text":"
  • Email must be unique across all users
  • Password must meet policy: 12+ chars, 1 uppercase, 1 lowercase, 1 digit (enforced by Zod schema)
  • TEMP users must have expiresAt set
  • EXPIRED status auto-applied when expiresAt < now()
"},{"location":"v2/database/models/auth/#refreshtoken-model","title":"RefreshToken Model","text":""},{"location":"v2/database/models/auth/#purpose_1","title":"Purpose","text":"

The RefreshToken model stores JWT refresh tokens for token rotation. When a user logs in, both an access token (15min expiry, stored client-side) and a refresh token (7 day expiry, stored in DB) are issued. When the access token expires, the client uses the refresh token to obtain a new access token. For security, refresh tokens are rotated on each refresh (old token deleted, new token issued) using atomic Prisma transactions.

"},{"location":"v2/database/models/auth/#fields_1","title":"Fields","text":"Field Type Required Default Description id String \u2713 cuid() Primary key token String \u2713 \u2014 JWT refresh token string (unique, 512 chars) userId String \u2713 \u2014 Foreign key to User expiresAt DateTime \u2713 \u2014 Token expiration timestamp (7 days from issue) createdAt DateTime \u2713 now() Token creation timestamp"},{"location":"v2/database/models/auth/#relations","title":"Relations","text":"
  • user \u2192 User (onDelete: Cascade) \u2014 deleting user deletes all refresh tokens
"},{"location":"v2/database/models/auth/#indexes_1","title":"Indexes","text":"
  • Unique: token (fast lookup for refresh endpoint)
  • Foreign Key: userId (join to User)
"},{"location":"v2/database/models/auth/#constraints_1","title":"Constraints","text":"
  • Token must be unique (prevents replay attacks)
  • ExpiresAt must be > now() for valid tokens
  • Expired tokens cleaned up via cron job (daily)
"},{"location":"v2/database/models/auth/#relationships-diagram","title":"Relationships Diagram","text":"
erDiagram\n    User ||--o{ RefreshToken : has\n    User ||--o{ Campaign : creates\n    User ||--o{ CampaignEmail : sends\n    User ||--o{ RepresentativeResponse : submits\n    User ||--o{ ResponseUpvote : upvotes\n    User ||--o{ ShiftSignup : \"signs up for\"\n    User ||--o{ Location : creates\n    User ||--o{ Location : updates\n    User ||--o{ Address : \"creates (addresses)\"\n    User ||--o{ Address : \"updates (addresses)\"\n    User ||--o{ LocationHistory : edits\n    User ||--o{ Cut : \"creates (cuts)\"\n    User ||--o{ CanvassVisit : visits\n    User ||--o{ CanvassSession : \"has (sessions)\"\n    User ||--o{ TrackingSession : \"tracks (gps)\"\n    User ||--o{ EmailTemplate : \"creates (templates)\"\n    User ||--o{ EmailTemplate : \"updates (templates)\"\n    User ||--o{ EmailTemplateVersion : \"versions (templates)\"\n    User ||--o{ EmailTemplateTestLog : \"tests (templates)\"\n\n    User {\n        String id PK\n        String email UK \"unique, lowercase\"\n        String password \"bcrypt hashed\"\n        String name\n        String phone\n        UserRole role \"SUPER_ADMIN | INFLUENCE_ADMIN | MAP_ADMIN | USER | TEMP\"\n        UserStatus status \"ACTIVE | INACTIVE | SUSPENDED | EXPIRED\"\n        Json permissions \"granular per-app\"\n        UserCreatedVia createdVia\n        DateTime expiresAt \"TEMP user expiration\"\n        Int expireDays\n        DateTime lastLoginAt\n        Boolean emailVerified\n        DateTime createdAt\n        DateTime updatedAt\n    }\n\n    RefreshToken {\n        String id PK\n        String token UK\n        String userId FK\n        DateTime expiresAt\n        DateTime createdAt\n    }
"},{"location":"v2/database/models/auth/#common-queries","title":"Common Queries","text":""},{"location":"v2/database/models/auth/#create-user-admin","title":"Create User (Admin)","text":"
const user = await prisma.user.create({\n  data: {\n    email: 'volunteer@example.com',\n    password: await bcrypt.hash('SecurePass123!', 10),\n    name: 'Jane Volunteer',\n    phone: '555-0100',\n    role: UserRole.USER,\n    status: UserStatus.ACTIVE,\n    createdVia: UserCreatedVia.ADMIN,\n    emailVerified: true,\n  },\n});\n
"},{"location":"v2/database/models/auth/#create-temp-user-public-shift-signup","title":"Create Temp User (Public Shift Signup)","text":"
const tempUser = await prisma.user.create({\n  data: {\n    email: 'temp@example.com',\n    password: await bcrypt.hash(randomPassword, 10), // Generated password\n    name: 'Temp Volunteer',\n    role: UserRole.TEMP,\n    status: UserStatus.ACTIVE,\n    createdVia: UserCreatedVia.PUBLIC_SHIFT_SIGNUP,\n    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days\n    expireDays: 30,\n  },\n});\n
"},{"location":"v2/database/models/auth/#find-user-with-relations","title":"Find User with Relations","text":"
const user = await prisma.user.findUnique({\n  where: { email: 'admin@example.com' },\n  include: {\n    campaignsCreated: { take: 5, orderBy: { createdAt: 'desc' } },\n    canvassSessions: { take: 10, orderBy: { startedAt: 'desc' } },\n    shiftSignups: { include: { shift: true } },\n  },\n});\n
"},{"location":"v2/database/models/auth/#update-last-login","title":"Update Last Login","text":"
await prisma.user.update({\n  where: { id: userId },\n  data: { lastLoginAt: new Date() },\n});\n
"},{"location":"v2/database/models/auth/#store-refresh-token","title":"Store Refresh Token","text":"
const refreshToken = await prisma.refreshToken.create({\n  data: {\n    token: jwtRefreshToken,\n    userId: user.id,\n    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days\n  },\n});\n
"},{"location":"v2/database/models/auth/#refresh-token-rotation-atomic","title":"Refresh Token Rotation (Atomic)","text":"
const newTokens = await prisma.$transaction(async (tx) => {\n  // 1. Verify old token exists and is valid\n  const oldToken = await tx.refreshToken.findUnique({\n    where: { token: oldRefreshToken },\n    include: { user: true },\n  });\n\n  if (!oldToken || oldToken.expiresAt < new Date()) {\n    throw new Error('Invalid or expired refresh token');\n  }\n\n  // 2. Delete old token\n  await tx.refreshToken.delete({\n    where: { id: oldToken.id },\n  });\n\n  // 3. Generate new access + refresh tokens\n  const newAccessToken = jwt.sign({ userId: oldToken.userId }, ACCESS_SECRET, { expiresIn: '15m' });\n  const newRefreshToken = jwt.sign({ userId: oldToken.userId }, REFRESH_SECRET, { expiresIn: '7d' });\n\n  // 4. Store new refresh token\n  await tx.refreshToken.create({\n    data: {\n      token: newRefreshToken,\n      userId: oldToken.userId,\n      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),\n    },\n  });\n\n  return { accessToken: newAccessToken, refreshToken: newRefreshToken };\n});\n
"},{"location":"v2/database/models/auth/#expire-temp-users-cron","title":"Expire Temp Users (Cron)","text":"
await prisma.user.updateMany({\n  where: {\n    role: UserRole.TEMP,\n    expiresAt: { lt: new Date() },\n    status: { not: UserStatus.EXPIRED },\n  },\n  data: {\n    status: UserStatus.EXPIRED,\n  },\n});\n
"},{"location":"v2/database/models/auth/#clean-expired-refresh-tokens-cron","title":"Clean Expired Refresh Tokens (Cron)","text":"
await prisma.refreshToken.deleteMany({\n  where: {\n    expiresAt: { lt: new Date() },\n  },\n});\n
"},{"location":"v2/database/models/auth/#data-flow","title":"Data Flow","text":""},{"location":"v2/database/models/auth/#user-registration-flow","title":"User Registration Flow","text":"
sequenceDiagram\n    participant Client\n    participant API\n    participant Prisma\n    participant bcrypt\n    participant JWT\n\n    Client->>API: POST /api/auth/register (email, password, name)\n    API->>bcrypt: hash(password)\n    bcrypt-->>API: hashedPassword\n    API->>Prisma: user.create({ email, password: hashed, role: USER })\n    Prisma-->>API: user\n    API->>JWT: sign(accessToken, { userId, email, role })\n    API->>JWT: sign(refreshToken, { userId })\n    JWT-->>API: tokens\n    API->>Prisma: refreshToken.create({ token, userId, expiresAt })\n    Prisma-->>API: refreshToken\n    API-->>Client: { user, accessToken, refreshToken }
"},{"location":"v2/database/models/auth/#login-flow","title":"Login Flow","text":"
sequenceDiagram\n    participant Client\n    participant API\n    participant Prisma\n    participant bcrypt\n    participant JWT\n\n    Client->>API: POST /api/auth/login (email, password)\n    API->>Prisma: user.findUnique({ where: { email } })\n    Prisma-->>API: user | null\n    API->>bcrypt: compare(password, user.password)\n    bcrypt-->>API: isValid\n    alt Invalid credentials\n        API-->>Client: 401 Unauthorized\n    else Valid credentials\n        API->>Prisma: user.update({ lastLoginAt: now() })\n        API->>JWT: sign(accessToken, { userId, email, role })\n        API->>JWT: sign(refreshToken, { userId })\n        JWT-->>API: tokens\n        API->>Prisma: refreshToken.create({ token, userId, expiresAt })\n        Prisma-->>API: refreshToken\n        API-->>Client: { user, accessToken, refreshToken }\n    end
"},{"location":"v2/database/models/auth/#token-refresh-flow","title":"Token Refresh Flow","text":"
sequenceDiagram\n    participant Client\n    participant API\n    participant Prisma\n    participant JWT\n\n    Client->>API: POST /api/auth/refresh (refreshToken)\n    API->>JWT: verify(refreshToken)\n    JWT-->>API: payload | error\n    alt Invalid token\n        API-->>Client: 401 Unauthorized\n    else Valid token\n        API->>Prisma: $transaction start\n        API->>Prisma: refreshToken.findUnique({ where: { token } })\n        Prisma-->>API: oldToken | null\n        alt Token not found or expired\n            API->>Prisma: $transaction rollback\n            API-->>Client: 401 Unauthorized\n        else Token valid\n            API->>Prisma: refreshToken.delete({ where: { id: oldToken.id } })\n            API->>JWT: sign(newAccessToken, { userId, email, role })\n            API->>JWT: sign(newRefreshToken, { userId })\n            JWT-->>API: newTokens\n            API->>Prisma: refreshToken.create({ token: newRefreshToken, userId, expiresAt })\n            API->>Prisma: $transaction commit\n            Prisma-->>API: success\n            API-->>Client: { accessToken, refreshToken }\n        end\n    end
"},{"location":"v2/database/models/auth/#performance-notes","title":"Performance Notes","text":""},{"location":"v2/database/models/auth/#index-usage","title":"Index Usage","text":"
  • email unique index: Used for login lookups (WHERE email = ?)
  • refreshToken.token unique index: Used for refresh endpoint (WHERE token = ?)
  • refreshToken.userId index: Used for user deletion cascades
"},{"location":"v2/database/models/auth/#query-optimization","title":"Query Optimization","text":"
  • Avoid loading all 33 user relations by default \u2014 use selective include or select
  • Use findFirst instead of findMany().take(1) for single record queries
  • Paginate user lists with skip + take + cursor-based pagination for large datasets
"},{"location":"v2/database/models/auth/#n1-prevention","title":"N+1 Prevention","text":"
// \u274c N+1 query (loads campaigns one-by-one)\nconst users = await prisma.user.findMany();\nfor (const user of users) {\n  const campaigns = await prisma.campaign.findMany({ where: { createdByUserId: user.id } });\n}\n\n// \u2705 Single query with include\nconst users = await prisma.user.findMany({\n  include: {\n    campaignsCreated: true,\n  },\n});\n
"},{"location":"v2/database/models/auth/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/database/models/auth/#password-policy","title":"Password Policy","text":"

Enforced at API schema level (auth.schemas.ts):

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

"},{"location":"v2/database/models/auth/#user-enumeration-prevention","title":"User Enumeration Prevention","text":"
  • /api/auth/me returns 401 (not 404) for missing users
  • Login endpoint returns generic \"Invalid credentials\" (not \"Email not found\")
  • Registration endpoint returns generic \"Email already exists\" (no user details)
"},{"location":"v2/database/models/auth/#refresh-token-security","title":"Refresh Token Security","text":"
  • Tokens stored in database (not just signed JWTs)
  • Rotation on every refresh (old token deleted)
  • Atomic transaction prevents race conditions
  • 7-day expiration with daily cleanup cron
"},{"location":"v2/database/models/auth/#role-based-access-control","title":"Role-Based Access Control","text":"

Middleware enforces role requirements:

// Requires SUPER_ADMIN or MAP_ADMIN\nrouter.get('/api/locations', requireRole(UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN), ...)\n\n// Requires any non-TEMP user\nrouter.get('/api/campaigns', requireNonTemp, ...)\n\n// Requires any authenticated user\nrouter.get('/api/profile', authenticate, ...)\n

"},{"location":"v2/database/models/auth/#temp-user-restrictions","title":"TEMP User Restrictions","text":"
  • Cannot access admin routes (blocked by requireNonTemp middleware)
  • Cannot create campaigns, locations, or templates
  • Can only canvass within assigned cut (verified by canvass service)
  • Auto-expire after expireDays (default 30)
"},{"location":"v2/database/models/auth/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/database/models/auth/#email-already-exists-on-registration","title":"\"Email already exists\" on registration","text":"

Cause: Email uniqueness constraint violated Solution: Check for existing user: prisma.user.findUnique({ where: { email } })

"},{"location":"v2/database/models/auth/#invalid-refresh-token-on-refresh","title":"\"Invalid refresh token\" on refresh","text":"

Cause: Token already used (rotation), expired, or manually deleted Solution: User must re-login to obtain new token pair

"},{"location":"v2/database/models/auth/#password-does-not-meet-policy-on-update","title":"\"Password does not meet policy\" on update","text":"

Cause: Password validation regex mismatch Solution: Ensure new password has 12+ chars, 1 uppercase, 1 lowercase, 1 digit

"},{"location":"v2/database/models/auth/#temp-user-cannot-access-route","title":"TEMP user cannot access route","text":"

Cause: Route uses requireNonTemp middleware Solution: Upgrade user to USER role via admin panel

"},{"location":"v2/database/models/auth/#circular-dependency-auth-store-api-client","title":"Circular dependency: auth store \u2194 api client","text":"

Cause: Both modules import each other Solution: Use callback registration pattern (see admin/src/lib/api.ts + admin/src/stores/auth.store.ts)

"},{"location":"v2/database/models/auth/#related-documentation","title":"Related Documentation","text":"
  • Database Overview \u2014 Complete ER diagram
  • Schema Reference \u2014 All model fields
  • Influence Models \u2014 Campaign relations
  • Map Models \u2014 Location relations
  • Canvassing Models \u2014 Session relations
  • Email Template Models \u2014 Template relations
  • API Auth Routes \u2014 Authentication endpoints
  • Security Audit \u2014 Security findings and fixes
"},{"location":"v2/database/models/canvass/","title":"Canvassing Models","text":""},{"location":"v2/database/models/canvass/#overview","title":"Overview","text":"

The Canvassing module provides GPS-tracked volunteer canvassing with session management, visit recording, walking route algorithms, and automatic session abandonment.

Models (4): - CanvassSession \u2014 Session lifecycle (ACTIVE \u2192 COMPLETED/ABANDONED) - CanvassVisit \u2014 Visit recording with 7 outcome types - TrackingSession \u2014 GPS tracking integration - TrackPoint \u2014 GPS breadcrumb trail

Key Features: - Session lifecycle management (ACTIVE \u2192 COMPLETED/ABANDONED) - 7 visit outcomes (NOT_HOME, REFUSED, MOVED, ALREADY_VOTED, SPOKE_WITH, LEFT_LITERATURE, COME_BACK_LATER) - Walking route algorithm (nearest-neighbor with haversine distance) - GPS breadcrumb trail with event markers - Support level tracking (1-4) - Sign request tracking - Session abandonment (12h timeout, auto-ABANDONED status) - Distance calculation (meters)

See Schema Reference for complete field listings.

"},{"location":"v2/database/models/canvass/#session-lifecycle","title":"Session Lifecycle","text":"
stateDiagram-v2\n    [*] --> ACTIVE : Start session\n    ACTIVE --> COMPLETED : End session (user action)\n    ACTIVE --> ABANDONED : 12h timeout (cron)\n    COMPLETED --> [*]\n    ABANDONED --> [*]

Status: CanvassSessionStatus - ACTIVE \u2014 Session in progress - COMPLETED \u2014 Session ended by user - ABANDONED \u2014 Session inactive > 12h (auto-expired by cron)

"},{"location":"v2/database/models/canvass/#visit-outcomes","title":"Visit Outcomes","text":"
enum VisitOutcome {\n  NOT_HOME          // No one home\n  REFUSED           // Refused to talk\n  MOVED             // Resident moved away\n  ALREADY_VOTED     // Already voted (early voting)\n  SPOKE_WITH        // Successful conversation\n  LEFT_LITERATURE   // Left campaign literature\n  COME_BACK_LATER   // Asked to come back later\n}\n

Support Level Mapping: - Outcome: SPOKE_WITH \u2192 Record support level (1-4) - Outcome: REFUSED \u2192 Support level defaults to null or 1 - Outcome: NOT_HOME \u2192 No support level

"},{"location":"v2/database/models/canvass/#walking-route-algorithm","title":"Walking Route Algorithm","text":"

Algorithm: Nearest-neighbor with haversine distance calculation

Steps: 1. Get all unvisited addresses in cut 2. Start from session start coordinates (or cut centroid) 3. Find nearest unvisited address (haversine distance) 4. Add to route, mark as visited 5. Repeat from new position until all addresses visited

Implementation: api/src/modules/map/canvass/walking-route.service.ts

function calculateWalkingRoute(\n  addresses: Address[],\n  startLat: number,\n  startLng: number,\n  visitedAddressIds: string[]\n): WalkingRoute {\n  const unvisited = addresses.filter(a => !visitedAddressIds.includes(a.id));\n  const route: Address[] = [];\n  let currentLat = startLat;\n  let currentLng = startLng;\n\n  while (unvisited.length > 0) {\n    // Find nearest unvisited address\n    const nearest = findNearestAddress(currentLat, currentLng, unvisited);\n    route.push(nearest);\n    currentLat = nearest.location.latitude;\n    currentLng = nearest.location.longitude;\n    unvisited.splice(unvisited.indexOf(nearest), 1);\n  }\n\n  return {\n    addresses: route,\n    totalDistanceM: calculateTotalDistance(route),\n  };\n}\n
"},{"location":"v2/database/models/canvass/#gps-tracking","title":"GPS Tracking","text":"

TrackingSession = One-to-one with CanvassSession - Stores total points, distance, last position - isActive flag for active tracking

TrackPoint = GPS breadcrumb - Latitude, longitude, accuracy - Event type markers (LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED)

Event Flow:

sequenceDiagram\n    participant Volunteer\n    participant API\n    participant GPS\n\n    Volunteer->>API: POST /api/canvass/sessions (start session)\n    API-->>Volunteer: sessionId\n    loop Every 30 seconds\n        GPS->>API: POST /api/tracking/:sessionId/points (lat, lng)\n        API-->>GPS: 200 OK\n    end\n    Volunteer->>API: POST /api/canvass/visits (record visit)\n    API->>GPS: POST /api/tracking/:sessionId/points (eventType: VISIT_RECORDED)\n    Volunteer->>API: POST /api/canvass/sessions/:id/end\n    API->>GPS: POST /api/tracking/:sessionId/points (eventType: SESSION_ENDED)\n    API-->>Volunteer: session (status: COMPLETED)

"},{"location":"v2/database/models/canvass/#session-abandonment","title":"Session Abandonment","text":"

Cron Job: Runs hourly via api/src/server.ts startup + interval

async function abandonStaleSessions() {\n  const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);\n\n  await prisma.canvassSession.updateMany({\n    where: {\n      status: CanvassSessionStatus.ACTIVE,\n      startedAt: { lt: twelveHoursAgo },\n    },\n    data: {\n      status: CanvassSessionStatus.ABANDONED,\n      endedAt: new Date(),\n    },\n  });\n}\n

Trigger Conditions: - Status = ACTIVE - StartedAt < 12 hours ago - No explicit end by user

"},{"location":"v2/database/models/canvass/#common-queries","title":"Common Queries","text":""},{"location":"v2/database/models/canvass/#start-canvass-session","title":"Start Canvass Session","text":"
const session = await prisma.canvassSession.create({\n  data: {\n    userId: user.id,\n    cutId: cut.id,\n    shiftId: shift?.id,\n    status: CanvassSessionStatus.ACTIVE,\n    startLatitude: startLat,\n    startLongitude: startLng,\n    trackingSession: {\n      create: {\n        userId: user.id,\n        isActive: true,\n      },\n    },\n  },\n});\n
"},{"location":"v2/database/models/canvass/#record-visit","title":"Record Visit","text":"
const visit = await prisma.canvassVisit.create({\n  data: {\n    addressId: address.id,\n    userId: user.id,\n    sessionId: session.id,\n    shiftId: shift?.id,\n    outcome: VisitOutcome.SPOKE_WITH,\n    supportLevel: SupportLevel.LEVEL_4,\n    signRequested: true,\n    signSize: 'Large',\n    notes: 'Very supportive, wants to volunteer',\n    durationSeconds: 180,\n  },\n});\n\n// Update address support level\nawait prisma.address.update({\n  where: { id: address.id },\n  data: {\n    supportLevel: SupportLevel.LEVEL_4,\n    sign: true,\n    signSize: 'Large',\n    notes: 'Very supportive, wants to volunteer',\n    updatedByUserId: user.id,\n  },\n});\n
"},{"location":"v2/database/models/canvass/#end-session","title":"End Session","text":"
await prisma.canvassSession.update({\n  where: { id: sessionId },\n  data: {\n    status: CanvassSessionStatus.COMPLETED,\n    endedAt: new Date(),\n    trackingSession: {\n      update: {\n        isActive: false,\n        endedAt: new Date(),\n      },\n    },\n  },\n});\n
"},{"location":"v2/database/models/canvass/#get-walking-route","title":"Get Walking Route","text":"
const session = await prisma.canvassSession.findUnique({\n  where: { id: sessionId },\n  include: {\n    visits: { include: { address: true } },\n  },\n});\n\nconst visitedAddressIds = session.visits.map(v => v.addressId);\n\nconst addresses = await prisma.address.findMany({\n  where: {\n    location: {\n      // Point-in-polygon check for cut\n      latitude: { gte: cutBounds.south, lte: cutBounds.north },\n      longitude: { gte: cutBounds.west, lte: cutBounds.east },\n    },\n  },\n  include: { location: true },\n});\n\nconst route = calculateWalkingRoute(\n  addresses,\n  session.startLatitude,\n  session.startLongitude,\n  visitedAddressIds\n);\n
"},{"location":"v2/database/models/canvass/#related-documentation","title":"Related Documentation","text":"
  • Schema Reference \u2014 Complete field listings
  • Database Overview \u2014 ER diagram
  • API Canvass Routes \u2014 REST endpoints
  • Volunteer Canvass Map \u2014 Full-screen canvass UI
  • Admin Canvass Dashboard \u2014 Admin oversight UI
"},{"location":"v2/database/models/email-templates/","title":"Email Template Models","text":""},{"location":"v2/database/models/email-templates/#overview","title":"Overview","text":"

The Email Template module provides a reusable template system with Handlebars-style variable interpolation, version history, and test email functionality.

Models (4): - EmailTemplate \u2014 Template master with categories - EmailTemplateVariable \u2014 Variable definitions - EmailTemplateVersion \u2014 Version history - EmailTemplateTestLog \u2014 Test email audit

Key Features: - Handlebars-style {{VAR}} interpolation - 3 categories: INFLUENCE, MAP, SYSTEM - System template protection (isSystem flag prevents deletion) - Version history with auto-increment version numbers - Conditional variables for {{#if}} blocks - Test email sending with sample data - HTML + plain text content

See Schema Reference for complete field listings.

"},{"location":"v2/database/models/email-templates/#template-categories","title":"Template Categories","text":"
enum EmailTemplateCategory {\n  INFLUENCE  // Campaign emails, response verification\n  MAP        // Shift confirmations, reminders\n  SYSTEM     // Password resets, welcome emails\n}\n
"},{"location":"v2/database/models/email-templates/#variable-interpolation","title":"Variable Interpolation","text":"

Syntax: Handlebars-style {{VARIABLE_NAME}}

Example Template:

<p>Hello {{USER_NAME}},</p>\n<p>Thank you for signing up for the shift:</p>\n<ul>\n  <li>Title: {{SHIFT_TITLE}}</li>\n  <li>Date: {{SHIFT_DATE}}</li>\n  <li>Time: {{SHIFT_TIME}}</li>\n</ul>\n{{#if IS_NEW_USER}}\n<p>Your temporary password is: {{TEMP_PASSWORD}}</p>\n{{/if}}\n

Variable Record:

{\n  key: 'USER_NAME',\n  label: 'User Name',\n  description: 'Name of the volunteer',\n  isRequired: true,\n  isConditional: false,\n  sampleValue: 'Jane Doe',\n  sortOrder: 0,\n}\n

"},{"location":"v2/database/models/email-templates/#version-history","title":"Version History","text":"

Auto-Increment Version Numbers:

const latestVersion = await prisma.emailTemplateVersion.findFirst({\n  where: { templateId },\n  orderBy: { versionNumber: 'desc' },\n});\n\nconst newVersion = await prisma.emailTemplateVersion.create({\n  data: {\n    templateId,\n    versionNumber: (latestVersion?.versionNumber || 0) + 1,\n    subjectLine,\n    htmlContent,\n    textContent,\n    changeNotes: 'Updated call-to-action wording',\n    createdByUserId: user.id,\n  },\n});\n

"},{"location":"v2/database/models/email-templates/#system-templates-4-seeded","title":"System Templates (4 seeded)","text":"

1. campaign-email (INFLUENCE) - Variables: CAMPAIGN_TITLE, MESSAGE, USER_NAME, USER_EMAIL, POSTAL_CODE, RECIPIENT_NAME, RECIPIENT_LEVEL, ORGANIZATION_NAME, TIMESTAMP - Used by: Campaign email sending

2. response-verification (INFLUENCE) - Variables: CAMPAIGN_TITLE, RESPONSE_TYPE, RESPONSE_TEXT, SUBMITTER_NAME, SUBMITTED_DATE, VERIFICATION_URL, REPORT_URL, ORGANIZATION_NAME, TIMESTAMP - Used by: Response wall submission verification

3. shift-signup-confirmation (MAP) - Variables: ORGANIZATION_NAME, USER_NAME, USER_EMAIL, SHIFT_TITLE, SHIFT_DATE, SHIFT_TIME, SHIFT_LOCATION, IS_NEW_USER, TEMP_PASSWORD, LOGIN_URL - Used by: Public shift signup

4. shift-details (MAP) - Variables: ORGANIZATION_NAME, USER_NAME, SHIFT_TITLE, SHIFT_DATE, SHIFT_START_TIME, SHIFT_END_TIME, SHIFT_LOCATION, SHIFT_DESCRIPTION, CURRENT_VOLUNTEERS, MAX_VOLUNTEERS, SHIFT_STATUS - Used by: Shift reminder emails

"},{"location":"v2/database/models/email-templates/#related-documentation","title":"Related Documentation","text":"
  • Schema Reference \u2014 Complete field listings
  • Seeding \u2014 Default templates
  • API Email Template Routes \u2014 REST endpoints
"},{"location":"v2/database/models/influence/","title":"Influence Models","text":""},{"location":"v2/database/models/influence/#overview","title":"Overview","text":"

The Influence module provides advocacy campaign management with multi-government-level targeting, email/call tracking, response wall with moderation, and representative caching.

Models (10): - Campaign \u2014 Advocacy campaigns with 12 feature flags - Representative \u2014 Cached rep data from Represent API - CampaignEmail \u2014 Email tracking (SMTP vs MAILTO) - RepresentativeResponse \u2014 Response wall submissions - ResponseUpvote \u2014 Upvote tracking with deduplication - CustomRecipient \u2014 Custom email targets - PostalCodeCache \u2014 Postal code geocoding cache - EmailLog \u2014 Email audit trail - EmailVerification \u2014 Email verification tokens - Call \u2014 Phone call tracking

Key Features: - Multi-government-level targeting (Federal, Provincial, Municipal, School Board) - Dual email methods: SMTP (async BullMQ queue) + mailto: links - Response moderation workflow (PENDING \u2192 APPROVED/REJECTED) - Email verification for response wall submissions - Upvote deduplication (user ID + IP address) - Represent API integration for Canadian representatives - Postal code \u2192 representative lookup

See Schema Reference for complete field listings.

"},{"location":"v2/database/models/influence/#campaign-feature-flags-12-total","title":"Campaign Feature Flags (12 total)","text":"Flag Default Description allowSmtpEmail true Enable SMTP email sending via BullMQ allowMailtoLink true Enable mailto: links for client-side email collectUserInfo true Collect sender name/email/postal code showEmailCount true Display email sent count on public page showCallCount true Display call made count on public page allowEmailEditing false Allow users to edit email subject/body allowCustomRecipients false Enable custom recipient management showResponseWall false Enable public response wall highlightCampaign false Highlight on campaigns list page"},{"location":"v2/database/models/influence/#government-level-targeting","title":"Government Level Targeting","text":"
enum GovernmentLevel {\n  FEDERAL\n  PROVINCIAL\n  MUNICIPAL\n  SCHOOL_BOARD\n}\n

Campaigns can target multiple levels:

const campaign = await prisma.campaign.create({\n  data: {\n    title: 'Support Climate Action',\n    targetGovernmentLevels: [GovernmentLevel.FEDERAL, GovernmentLevel.PROVINCIAL],\n    // ...\n  },\n});\n

Representative lookup filters by targeted levels:

const reps = await representativeService.lookup(postalCode, campaign.targetGovernmentLevels);\n

"},{"location":"v2/database/models/influence/#email-methods","title":"Email Methods","text":""},{"location":"v2/database/models/influence/#smtp-async-queue","title":"SMTP (Async Queue)","text":"
  • Queued via BullMQ (Redis backend)
  • Worker sends via Nodemailer
  • Supports templates with variable interpolation
  • Tracks delivery status (QUEUED \u2192 SENT/FAILED)
  • Rate limiting (10 emails/min per IP)
"},{"location":"v2/database/models/influence/#mailto-client-side","title":"MAILTO (Client-Side)","text":"
  • Generates mailto: link with pre-filled subject/body
  • Tracked when link clicked (status: CLICKED)
  • No server-side email sending
  • User's default email client used
"},{"location":"v2/database/models/influence/#response-moderation-workflow","title":"Response Moderation Workflow","text":"
stateDiagram-v2\n    [*] --> PENDING : Submit response\n    PENDING --> APPROVED : Admin approves\n    PENDING --> REJECTED : Admin rejects\n    APPROVED --> [*]\n    REJECTED --> [*]

Status: PENDING (default) \u2192 APPROVED | REJECTED

Admin moderation via /app/influence/responses: - Filter by status, campaign, date range - Bulk approve/reject - View submitter details - Screenshot attachments

"},{"location":"v2/database/models/influence/#upvote-deduplication","title":"Upvote Deduplication","text":"

Two unique constraints prevent duplicate upvotes:

model ResponseUpvote {\n  @@unique([responseId, userId])      // Logged-in users\n  @@unique([responseId, upvotedIp])  // Guest users\n}\n

Logic: - Logged-in user: Check [responseId, userId] - Guest user: Check [responseId, upvotedIp] - Database-level enforcement (no race conditions)

"},{"location":"v2/database/models/influence/#represent-api-integration","title":"Represent API Integration","text":"

Representative Cache: - Cached in representatives table - TTL: 30 days (check cachedAt field) - Re-fetched if cache miss or stale

Lookup Flow:

sequenceDiagram\n    participant Client\n    participant API\n    participant Cache\n    participant Represent\n\n    Client->>API: GET /api/representatives/lookup?postalCode=K1A0B1\n    API->>Cache: findMany({ where: { postalCode } })\n    alt Cache hit (cachedAt < 30 days ago)\n        Cache-->>API: representatives[]\n        API-->>Client: representatives[]\n    else Cache miss or stale\n        API->>Represent: GET /representatives/?point=K1A0B1\n        Represent-->>API: representatives[]\n        API->>Cache: upsert({ postalCode, ... })\n        API-->>Client: representatives[]\n    end

"},{"location":"v2/database/models/influence/#common-queries","title":"Common Queries","text":""},{"location":"v2/database/models/influence/#create-campaign","title":"Create Campaign","text":"
const campaign = await prisma.campaign.create({\n  data: {\n    slug: 'climate-action',\n    title: 'Support Climate Action Bill C-12',\n    emailSubject: 'Support Climate Action',\n    emailBody: 'I urge you to support...',\n    status: CampaignStatus.ACTIVE,\n    targetGovernmentLevels: [GovernmentLevel.FEDERAL],\n    allowSmtpEmail: true,\n    showResponseWall: true,\n    createdByUserId: user.id,\n  },\n});\n
"},{"location":"v2/database/models/influence/#queue-campaign-email-smtp","title":"Queue Campaign Email (SMTP)","text":"
await emailQueueService.addCampaignEmail({\n  campaignId: campaign.id,\n  recipientEmail: 'rep@example.com',\n  recipientName: 'Hon. Jane Smith',\n  subject: 'Support Climate Action',\n  message: 'I urge you to...',\n  userEmail: 'voter@example.com',\n  userName: 'John Voter',\n  userPostalCode: 'K1A0B1',\n});\n
"},{"location":"v2/database/models/influence/#submit-response","title":"Submit Response","text":"
const response = await prisma.representativeResponse.create({\n  data: {\n    campaignId: campaign.id,\n    campaignSlug: campaign.slug,\n    representativeName: 'Hon. Jane Smith',\n    representativeLevel: GovernmentLevel.FEDERAL,\n    responseType: ResponseType.EMAIL,\n    responseText: 'Thank you for your letter...',\n    submittedByUserId: user.id,\n    submittedByEmail: 'voter@example.com',\n    status: ResponseStatus.PENDING,\n  },\n});\n
"},{"location":"v2/database/models/influence/#upvote-response","title":"Upvote Response","text":"
await prisma.responseUpvote.create({\n  data: {\n    responseId: response.id,\n    userId: user?.id,  // Null for guests\n    userEmail: user?.email,\n    upvotedIp: req.ip,\n  },\n});\n\n// Increment upvote count (denormalized)\nawait prisma.representativeResponse.update({\n  where: { id: response.id },\n  data: { upvoteCount: { increment: 1 } },\n});\n
"},{"location":"v2/database/models/influence/#related-documentation","title":"Related Documentation","text":"
  • Schema Reference \u2014 Complete field listings
  • Database Overview \u2014 ER diagram
  • API Influence Routes \u2014 REST endpoints
  • Admin Campaigns Page \u2014 Campaign management UI
  • Public Campaign Page \u2014 Public-facing campaign UI
"},{"location":"v2/database/models/map/","title":"Map Models","text":""},{"location":"v2/database/models/map/#overview","title":"Overview","text":"

The Map module provides building-level and unit-level location management with multi-provider geocoding, volunteer shift scheduling, GeoJSON polygon cuts for map filtering, and comprehensive audit trails.

Models (7): - Location \u2014 Building-level with lat/lng, NAR integration - Address \u2014 Unit-level with support levels - LocationHistory \u2014 Audit trail (7 action types) - Shift \u2014 Volunteer shifts with cut relation - ShiftSignup \u2014 Signup tracking - Cut \u2014 GeoJSON polygon overlays - MapSettings \u2014 Singleton configuration

Key Features: - Building vs unit architecture (1 Location \u2192 many Addresses) - Multi-provider geocoding (6 providers: Google, Mapbox, Nominatim, Photon, LocationIQ, ArcGIS) - NAR 2025 Canadian electoral data import - Spatial indexing (latitude/longitude composite index) - GeoJSON polygon storage for cuts - Walk sheet generation with QR codes - CSV import/export

See Schema Reference for complete field listings.

"},{"location":"v2/database/models/map/#building-vs-unit-architecture","title":"Building vs Unit Architecture","text":"

Location = Building-level data: - Single lat/lng coordinate - Street address (no unit number) - Building type (SINGLE_FAMILY, MULTI_UNIT, MIXED_USE, COMMERCIAL) - Total units count - Building notes (access codes, manager contact)

Address = Unit-level data: - Unit number (apartment #, suite #) - Occupant name/email/phone - Support level (1-4) - Sign request flag - Canvassing notes

Relationship: Location ||--o{ Address (one-to-many)

Example:

// 1 Location: 123 Main St (4-unit apartment building)\nconst location = {\n  address: '123 Main St',\n  latitude: 53.5461,\n  longitude: -113.4938,\n  buildingType: 'MULTI_UNIT',\n  totalUnits: 4,\n};\n\n// 4 Addresses: Units 101-104\nconst addresses = [\n  { locationId, unitNumber: '101', firstName: 'Alice', supportLevel: '4' },\n  { locationId, unitNumber: '102', firstName: 'Bob', supportLevel: '3' },\n  { locationId, unitNumber: '103', firstName: 'Carol', supportLevel: '2' },\n  { locationId, unitNumber: '104', firstName: 'Dave', supportLevel: '1' },\n];\n

"},{"location":"v2/database/models/map/#geocoding-providers","title":"Geocoding Providers","text":"
enum GeocodeProvider {\n  GOOGLE        // Google Maps Geocoding API\n  MAPBOX        // Mapbox Geocoding API\n  NOMINATIM     // OpenStreetMap Nominatim\n  PHOTON        // Photon (OSM-based)\n  LOCATIONIQ    // LocationIQ (OSM-based)\n  ARCGIS        // ArcGIS Geocoding Service\n  UNKNOWN       // Manually entered or unknown source\n}\n

Provider Priority: 1. Google (highest accuracy, paid) 2. Mapbox (high accuracy, paid) 3. ArcGIS (high accuracy, free tier) 4. Nominatim (medium accuracy, free) 5. Photon (medium accuracy, free) 6. LocationIQ (medium accuracy, free tier)

Confidence Score: 0-100 (stored in geocodeConfidence field)

"},{"location":"v2/database/models/map/#nar-2025-import","title":"NAR 2025 Import","text":"

NAR = National Address Register (Canadian electoral data)

Import Features: - Streams large CSV files (no memory limit) - Joins Location + Address files on LOC_GUID - Converts BG_X/BG_Y (EPSG:3347 Lambert projection) \u2192 lat/lng - Province selector (codes 10-62) - City/postal/cut filters - Residential-only toggle (buildingUse = 1)

New Location Fields: - postalCode \u2014 Canadian postal code - province \u2014 Province code (e.g., \"AB\") - federalDistrict \u2014 Federal electoral district - buildingUse \u2014 NAR BU_USE (1=Residential, 2=Partial, 3=Non-Residential, 4=Unknown) - locGuid \u2014 NAR LOC_GUID (unique)

New Address Fields: - addrGuid \u2014 NAR ADDR_GUID (unique)

"},{"location":"v2/database/models/map/#locationhistory-actions","title":"LocationHistory Actions","text":"
enum LocationHistoryAction {\n  CREATED          // Location created\n  UPDATED          // Location updated\n  GEOCODED         // Single location geocoded\n  BULK_GEOCODED    // Batch geocode operation\n  MOVED_ON_MAP     // Dragged on admin map\n  IMPORTED_CSV     // CSV import\n  IMPORTED_NAR     // NAR import\n}\n

Audit Fields: - field \u2014 Which field changed (e.g., \"latitude\") - oldValue \u2014 Previous value - newValue \u2014 New value - metadata \u2014 JSON with provider, confidence, etc.

"},{"location":"v2/database/models/map/#cut-geojson-storage","title":"Cut GeoJSON Storage","text":"

Cut stores GeoJSON polygon coordinates:

{\n  \"type\": \"Polygon\",\n  \"coordinates\": [\n    [\n      [-113.5, 53.5],\n      [-113.4, 53.5],\n      [-113.4, 53.6],\n      [-113.5, 53.6],\n      [-113.5, 53.5]\n    ]\n  ]\n}\n

Bounds: Calculated bounding box for quick filtering:

{\n  \"north\": 53.6,\n  \"south\": 53.5,\n  \"east\": -113.4,\n  \"west\": -113.5\n}\n

"},{"location":"v2/database/models/map/#shift-status-workflow","title":"Shift Status Workflow","text":"
stateDiagram-v2\n    [*] --> OPEN : Create shift\n    OPEN --> FULL : currentVolunteers >= maxVolunteers\n    OPEN --> CANCELLED : Admin cancels\n    FULL --> OPEN : Volunteer cancels (currentVolunteers < maxVolunteers)\n    FULL --> CANCELLED : Admin cancels\n    CANCELLED --> [*]
"},{"location":"v2/database/models/map/#common-queries","title":"Common Queries","text":""},{"location":"v2/database/models/map/#create-location-with-geocoding","title":"Create Location with Geocoding","text":"
const location = await prisma.location.create({\n  data: {\n    address: '123 Main St, Edmonton, AB',\n    latitude: 53.5461,\n    longitude: -113.4938,\n    geocodeProvider: GeocodeProvider.GOOGLE,\n    geocodeConfidence: 95,\n    buildingType: BuildingType.SINGLE_FAMILY,\n    totalUnits: 1,\n    createdByUserId: user.id,\n    history: {\n      create: {\n        userId: user.id,\n        action: LocationHistoryAction.GEOCODED,\n        metadata: { provider: 'google', confidence: 95 },\n      },\n    },\n  },\n});\n
"},{"location":"v2/database/models/map/#find-locations-in-bounding-box","title":"Find Locations in Bounding Box","text":"
const locations = await prisma.location.findMany({\n  where: {\n    latitude: { gte: 53.5, lte: 53.6 },\n    longitude: { gte: -113.5, lte: -113.4 },\n  },\n  include: { addresses: true },\n});\n
"},{"location":"v2/database/models/map/#create-shift-with-cut","title":"Create Shift with Cut","text":"
const shift = await prisma.shift.create({\n  data: {\n    title: 'Weekend Canvassing - Downtown',\n    date: new Date('2025-02-15'),\n    startTime: '10:00',\n    endTime: '14:00',\n    maxVolunteers: 10,\n    isPublic: true,\n    cutId: cut.id,  // Assign to cut\n  },\n});\n
"},{"location":"v2/database/models/map/#public-shift-signup-creates-temp-user","title":"Public Shift Signup (Creates TEMP User)","text":"
// 1. Create TEMP user with random password\nconst tempPassword = generatePassword(); // \"SwiftEagle42\"\nconst tempUser = await prisma.user.create({\n  data: {\n    email: 'volunteer@example.com',\n    password: await bcrypt.hash(tempPassword, 10),\n    name: 'Jane Volunteer',\n    role: UserRole.TEMP,\n    createdVia: UserCreatedVia.PUBLIC_SHIFT_SIGNUP,\n    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days\n    expireDays: 30,\n  },\n});\n\n// 2. Create shift signup\nawait prisma.shiftSignup.create({\n  data: {\n    shiftId: shift.id,\n    userId: tempUser.id,\n    userEmail: 'volunteer@example.com',\n    userName: 'Jane Volunteer',\n    signupSource: SignupSource.PUBLIC,\n  },\n});\n\n// 3. Send confirmation email with temp password\nawait emailService.send({\n  template: 'shift-signup-confirmation',\n  variables: {\n    USER_NAME: 'Jane Volunteer',\n    SHIFT_TITLE: shift.title,\n    IS_NEW_USER: 'true',\n    TEMP_PASSWORD: tempPassword,\n    // ...\n  },\n});\n
"},{"location":"v2/database/models/map/#related-documentation","title":"Related Documentation","text":"
  • Schema Reference \u2014 Complete field listings
  • Database Overview \u2014 ER diagram
  • API Map Routes \u2014 REST endpoints
  • Admin Locations Page \u2014 Location management UI
  • Admin Cuts Page \u2014 Cut editor UI
  • Public Map Page \u2014 Public map UI
"},{"location":"v2/database/models/media/","title":"Media Models (Drizzle ORM)","text":""},{"location":"v2/database/models/media/#overview","title":"Overview","text":"

The Media module uses Drizzle ORM (separate from Prisma) to manage video library, compilations, and job queue.

Models (3): - videos \u2014 Video library with metadata - compilations \u2014 Video compilation tracking - jobs \u2014 Job queue with resource management

ORM: Drizzle (not Prisma) API: Fastify (port 4100, separate from Express main API) Migration: npx drizzle-kit push (no migration files)

Key Features: - FFprobe metadata extraction (duration, dimensions, orientation, quality, audio) - Directory type enum (9 types: studios, gifs, private, inbox, curated, playback, compilations, videos, highlights) - Public media engagement stats (historical) - Job queue with GPU/CPU resource categories - Video upload with automatic metadata extraction

See Schema Reference for complete field listings.

"},{"location":"v2/database/models/media/#directory-types","title":"Directory Types","text":"
export const DIRECTORY_TYPES = [\n  'studios',\n  'gifs',\n  'private',\n  'inbox',\n  'curated',\n  'playback',\n  'compilations',\n  'videos',\n  'highlights'\n] as const;\n

Usage: - Efficient filtering (indexed) - Replaces LIKE patterns (e.g., path LIKE '%/studios/%')

"},{"location":"v2/database/models/media/#video-metadata-ffprobe","title":"Video Metadata (FFprobe)","text":"

Extracted Fields: - durationSeconds \u2014 Video duration in seconds - width / height \u2014 Video dimensions (pixels) - orientation \u2014 portrait, landscape, square - quality \u2014 1080p, 720p, 480p, etc. - hasAudio \u2014 Audio track present flag

Extraction Service: api/src/modules/media/services/ffprobe.service.ts Timeout: 30 seconds for metadata extraction Validation: Decodes 5 frames with 60s timeout

"},{"location":"v2/database/models/media/#job-queue","title":"Job Queue","text":"

Resource Categories:

export type ResourceCategory = 'gpu_ai' | 'gpu_encode' | 'cpu';\n

Job Status:

export type JobStatus = 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';\n

Job Types (21 total): - compilation, scan, public_scan, organize, organize_studio - reencode_streaming, compile_random, compile_quad, compile_quad_horizontal - compile_triple_vertical, compile_mega, compile_gif, generate_gif - fetch, digest, digest_generate, clip_generate, highlight_generate - tag_generation, scene_extract, clip_extract_only, auto_organize_publish

Queue Processing: - Ordered by: status (pending first), priority (lower = higher), createdAt (FIFO) - Uses composite index: [status, priority, createdAt]

"},{"location":"v2/database/models/media/#video-upload-flow","title":"Video Upload Flow","text":"
sequenceDiagram\n    participant Client\n    participant API\n    participant FFprobe\n    participant DB\n\n    Client->>API: POST /api/media/upload (multipart/form-data)\n    API->>API: Stream file to /media/local/inbox\n    API->>FFprobe: Extract metadata (duration, width, height, etc.)\n    FFprobe-->>API: metadata\n    API->>DB: INSERT INTO videos (path, filename, durationSeconds, ...)\n    DB-->>API: video record\n    API-->>Client: { id, path, metadata }

Volume Mount: /media/local/inbox:rw (read-write), library remains :ro

"},{"location":"v2/database/models/media/#drizzle-schema-example","title":"Drizzle Schema Example","text":"
export const videos = pgTable('videos', {\n  id: serial('id').primaryKey(),\n  path: text('path').notNull().unique(),\n  filename: text('filename').notNull(),\n  durationSeconds: integer('duration_seconds'),\n  quality: text('quality'),\n  orientation: text('orientation'),\n  hasAudio: boolean('has_audio').default(true),\n  width: integer('width'),\n  height: integer('height'),\n  directoryType: text('directory_type').$type<DirectoryType>(),\n  tags: jsonb('tags').$type<string[]>(),\n  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),\n}, (table) => ({\n  directoryTypeIdx: index('idx_directory_type').on(table.directoryType),\n  fingerprintIdx: index('idx_videos_fingerprint').on(\n    table.durationSeconds,\n    table.fileSize,\n    table.width,\n    table.height\n  ),\n}));\n
"},{"location":"v2/database/models/media/#related-documentation","title":"Related Documentation","text":"
  • Schema Reference \u2014 Complete field listings
  • Migration Workflow \u2014 Drizzle Kit push
  • API Media Routes \u2014 REST endpoints
  • Admin Media Library \u2014 Video management UI
  • Public Media Gallery \u2014 Public video gallery
"},{"location":"v2/database/models/pages/","title":"Landing Page Models","text":""},{"location":"v2/database/models/pages/#overview","title":"Overview","text":"

The Landing Page module provides a WYSIWYG page builder with GrapesJS editor integration, reusable block library, and MkDocs export functionality.

Models (2): - LandingPage \u2014 GrapesJS editor output with MkDocs export - PageBlock \u2014 Reusable block library

Key Features: - GrapesJS WYSIWYG editor (desktop only) - Visual + code editor modes - MkDocs export (THEMED vs STANDALONE modes) - SEO metadata (title, description, image) - Reusable block library (6 default blocks) - Jinja2 Material theme integration

See Schema Reference for complete field listings.

"},{"location":"v2/database/models/pages/#editor-modes","title":"Editor Modes","text":"
enum EditorMode {\n  VISUAL  // GrapesJS visual editor (default)\n  CODE    // Raw HTML/CSS code editor\n}\n
"},{"location":"v2/database/models/pages/#mkdocs-export-modes","title":"MkDocs Export Modes","text":"
enum MkdocsExportMode {\n  THEMED      // Extends main.html, content block only (default)\n  STANDALONE  // Full HTML document, no Jinja2 inheritance\n}\n

THEMED Mode:

{% extends \"main.html\" %}\n{% block content %}\n  <div class=\"landing-page\">\n    <!-- Page HTML here -->\n  </div>\n{% endblock %}\n

STANDALONE Mode:

<!DOCTYPE html>\n<html>\n<head>\n  <title>Page Title</title>\n  <!-- Full HTML document -->\n</head>\n<body>\n  <!-- Page HTML here -->\n</body>\n</html>\n

"},{"location":"v2/database/models/pages/#page-blocks-6-default","title":"Page Blocks (6 default)","text":"

1. Hero Section (Headers) - Schema: title, subtitle, backgroundImage, ctaText, ctaUrl - Defaults: \"Welcome to Our Campaign\", \"Get Involved\"

2. Text Block (Content) - Schema: heading, body - Defaults: \"About Us\", \"Tell your story here...\"

3. Features Grid (Content) - Schema: features[] (title, description, icon) - Defaults: 3 features (Community Action, Advocacy, Volunteer)

4. Call to Action (Actions) - Schema: heading, description, buttonText, buttonUrl - Defaults: \"Ready to Take Action?\", \"Join Now\"

5. Testimonials (Content) - Schema: quotes[] (text, author, role) - Defaults: 2 quotes

6. Contact Form (Actions) - Schema: heading, fields[] (name, type, required) - Defaults: Name, Email, Message fields

"},{"location":"v2/database/models/pages/#grapesjs-json-format","title":"GrapesJS JSON Format","text":"

blocks Field:

{\n  \"pages\": [\n    {\n      \"id\": \"page-main\",\n      \"component\": {\n        \"type\": \"wrapper\",\n        \"components\": [\n          {\n            \"tagName\": \"section\",\n            \"classes\": [\"hero\"],\n            \"components\": [\n              {\n                \"tagName\": \"h1\",\n                \"content\": \"Welcome to Our Campaign\"\n              }\n            ]\n          }\n        ]\n      }\n    }\n  ]\n}\n

"},{"location":"v2/database/models/pages/#related-documentation","title":"Related Documentation","text":"
  • Schema Reference \u2014 Complete field listings
  • Seeding \u2014 Default page blocks
  • API Pages Routes \u2014 REST endpoints
  • Admin Landing Pages \u2014 Page list UI
  • Admin Page Editor \u2014 GrapesJS editor UI
  • Public Landing Page \u2014 Public page renderer
"},{"location":"v2/database/models/settings/","title":"Settings Models","text":""},{"location":"v2/database/models/settings/#overview","title":"Overview","text":"

The Settings module provides two singleton configuration models for global site settings and map-specific settings.

Models (2): - SiteSettings \u2014 Org branding + theme + SMTP + feature toggles - MapSettings \u2014 Map center/zoom + walk sheet config

Key Features: - Singleton pattern (always ID \"default\") - SMTP override hierarchy (SiteSettings \u2192 env vars) - Feature flags (enableInfluence, enableMap, enableNewsletter, enableLandingPages) - Theme color customization (admin + public) - Walk sheet customization (title, subtitle, footer, QR codes)

See Schema Reference for complete field listings.

"},{"location":"v2/database/models/settings/#sitesettings-singleton","title":"SiteSettings (Singleton)","text":"

ID: Always \"default\" (enforced by seed + UI)

Sections: 1. Organization \u2014 Name, logo, favicon 2. Admin Theme \u2014 Primary color, background color 3. Public Theme \u2014 Primary color, background color, container color, header gradient 4. Email Branding \u2014 From name, footer text, login subtitle 5. SMTP Configuration \u2014 Host, port, user, pass, from address, active provider, test mode 6. Feature Toggles \u2014 Enable/disable modules

SMTP Hierarchy: - If SiteSettings.smtpHost is set \u2192 use SiteSettings SMTP - Else \u2192 fallback to env vars (SMTP_HOST, SMTP_PORT, etc.)

"},{"location":"v2/database/models/settings/#mapsettings-singleton","title":"MapSettings (Singleton)","text":"

ID: Always \"default\" (enforced by seed + UI)

Sections: 1. Map Center \u2014 Latitude, longitude, zoom (default: Edmonton, AB) 2. Walk Sheet \u2014 Title, subtitle, footer text 3. QR Codes \u2014 3 QR code slots (URL + label each)

QR Code Usage: - Rendered on printable walk sheets - Typically links to volunteer portal, shift signup, campaign page - Generated via Mini QR service (GET /api/qr?url=...)

"},{"location":"v2/database/models/settings/#related-documentation","title":"Related Documentation","text":"
  • Schema Reference \u2014 Complete field listings
  • Seeding \u2014 Default settings
  • API Settings Routes \u2014 REST endpoints
  • Admin Settings Page \u2014 Settings UI
  • Admin Map Settings Page \u2014 Map settings UI
"},{"location":"v2/deployment/","title":"Deployment Overview","text":"

This section covers deploying Changemaker Lite V2 to production, including Docker orchestration, environment configuration, SSL/TLS setup, monitoring, backups, and scaling strategies.

"},{"location":"v2/deployment/#deployment-guide","title":"Deployment Guide","text":""},{"location":"v2/deployment/#docker-compose","title":"Docker Compose","text":"

Complete Docker orchestration for all services:

  • 20+ containers
  • Service dependencies
  • Health checks
  • Restart policies
  • Network configuration
  • Volume management
"},{"location":"v2/deployment/#environment-variables","title":"Environment Variables","text":"

Comprehensive environment configuration:

  • 100+ environment variables
  • Required vs optional
  • Security considerations
  • Service-specific config
  • Feature flags
"},{"location":"v2/deployment/#nginx-configuration","title":"Nginx Configuration","text":"

Reverse proxy and routing:

  • Subdomain routing (12+ subdomains)
  • SSL/TLS termination
  • Security headers
  • Proxy settings
  • Static file serving
"},{"location":"v2/deployment/#ssltls-setup","title":"SSL/TLS Setup","text":"

HTTPS configuration:

  • Let's Encrypt integration
  • Certificate management
  • Auto-renewal
  • Security best practices
  • HSTS configuration
"},{"location":"v2/deployment/#tunneling","title":"Tunneling","text":"

Public access via tunneling:

  • Pangolin tunnel setup
  • Newt container deployment
  • Resource configuration
  • Alternative to Cloudflare
  • DNS-free setup
"},{"location":"v2/deployment/#backup-restore","title":"Backup & Restore","text":"

Data protection:

  • PostgreSQL backups
  • Listmonk backups
  • Media file backups
  • S3 upload (optional)
  • Restore procedures
  • Automated schedules
"},{"location":"v2/deployment/#monitoring-stack","title":"Monitoring Stack","text":"

Observability and alerting:

  • Prometheus metrics
  • Grafana dashboards
  • Alertmanager alerts
  • Service health checks
  • Log aggregation
"},{"location":"v2/deployment/#healthchecks","title":"Healthchecks","text":"

Container health monitoring:

  • Docker healthchecks
  • Service-specific checks
  • Restart on failure
  • Dependency management
"},{"location":"v2/deployment/#scaling","title":"Scaling","text":"

Horizontal and vertical scaling:

  • Multi-instance deployment
  • Load balancing
  • Database replication
  • Cache scaling
  • Performance optimization
"},{"location":"v2/deployment/#quick-start","title":"Quick Start","text":""},{"location":"v2/deployment/#initial-deployment","title":"Initial Deployment","text":"
  1. Prepare Server

    # Ubuntu/Debian server with Docker installed\napt update && apt install docker.io docker-compose git\n

  2. Clone Repository

    git clone <repo-url> changemaker.lite\ncd changemaker.lite\ngit checkout v2\n

  3. Configure Environment

    cp .env.example .env\n# Edit .env with your settings\n

  4. Start Services

    docker compose up -d v2-postgres redis\ndocker compose up -d api admin\ndocker compose exec api npx prisma migrate deploy\ndocker compose exec api npx prisma db seed\n

  5. Access Application

    http://server-ip:3000\nLogin: admin@example.com / Admin123!\n

"},{"location":"v2/deployment/#production-deployment","title":"Production Deployment","text":"
  1. Configure Tunneling (for public access)
  2. Set up Pangolin account
  3. Configure tunnel in admin UI
  4. Deploy Newt container

  5. Enable Monitoring

    docker compose --profile monitoring up -d\n

  6. Set Up Backups

    # Configure backup.sh\n./scripts/backup.sh\n\n# Add to crontab\n0 2 * * * /path/to/backup.sh\n

  7. Secure Installation

  8. Change default passwords
  9. Enable Redis auth
  10. Configure firewall
  11. Review security audit
"},{"location":"v2/deployment/#architecture-overview","title":"Architecture Overview","text":""},{"location":"v2/deployment/#service-topology","title":"Service Topology","text":"
Internet\n  \u2193\nPangolin Tunnel / Cloudflare\n  \u2193\nNewt Container / Tunnel Daemon\n  \u2193\nNginx (Reverse Proxy)\n  \u2193\n  \u251c\u2192 Admin GUI (React, port 3000)\n  \u251c\u2192 Express API (TypeScript, port 4000)\n  \u251c\u2192 Media API (Fastify, port 4100)\n  \u251c\u2192 MkDocs (Documentation, port 4003)\n  \u251c\u2192 Grafana (Monitoring, port 3001)\n  \u2514\u2192 Other Services...\n  \u2193\n  \u251c\u2192 PostgreSQL 16 (Database, port 5433)\n  \u251c\u2192 Redis 7 (Cache/Queue, port 6379)\n  \u2514\u2192 Supporting Services...\n
"},{"location":"v2/deployment/#port-reference","title":"Port Reference","text":"Port Service Access 3000 Admin GUI Public 4000 Express API Public 4100 Media API Public 5433 PostgreSQL Internal 6379 Redis Internal 3001 Grafana Public 9090 Prometheus Internal 8091 NocoDB Public 9001 Listmonk Public"},{"location":"v2/deployment/#subdomain-routing","title":"Subdomain Routing","text":"Subdomain Target Purpose app.cmlite.org Admin:3000 Admin interface api.cmlite.org API:4000 Express API media.cmlite.org Media:4100 Media API docs.cmlite.org MkDocs:4003 Documentation grafana.cmlite.org Grafana:3001 Monitoring db.cmlite.org NocoDB:8091 Data browser listmonk.cmlite.org Listmonk:9001 Newsletters"},{"location":"v2/deployment/#production-checklist","title":"Production Checklist","text":""},{"location":"v2/deployment/#security","title":"Security","text":"
  • Change default admin password
  • Set strong PostgreSQL password
  • Set strong Redis password
  • Generate unique JWT secrets
  • Generate unique encryption key
  • Enable Redis authentication
  • Configure firewall rules
  • Review security audit findings
"},{"location":"v2/deployment/#environment","title":"Environment","text":"
  • Set production NODE_ENV
  • Configure SMTP settings
  • Set up geocoding API keys
  • Configure Listmonk (if enabled)
  • Set media storage paths
  • Configure backup destinations
"},{"location":"v2/deployment/#services","title":"Services","text":"
  • Start core services
  • Run database migrations
  • Seed initial data
  • Test admin login
  • Verify API connectivity
  • Check service health
"},{"location":"v2/deployment/#monitoring","title":"Monitoring","text":"
  • Enable monitoring stack
  • Configure Grafana dashboards
  • Set up Alertmanager
  • Test alert notifications
  • Review metrics collection
"},{"location":"v2/deployment/#backups","title":"Backups","text":"
  • Configure backup script
  • Test backup/restore
  • Set up automated schedule
  • Configure S3 (optional)
  • Document restore procedure
"},{"location":"v2/deployment/#public-access","title":"Public Access","text":"
  • Configure tunnel (Pangolin/Cloudflare)
  • Test public URLs
  • Verify SSL/TLS
  • Check subdomain routing
  • Test from external network
"},{"location":"v2/deployment/#maintenance","title":"Maintenance","text":""},{"location":"v2/deployment/#regular-tasks","title":"Regular Tasks","text":"

Daily: - Monitor service health - Review error logs - Check disk space

Weekly: - Review backup success - Check queue depths - Update dependencies (if needed)

Monthly: - Security updates - Database optimization - Log rotation - Certificate renewal check

"},{"location":"v2/deployment/#updates","title":"Updates","text":"
  1. Pull Latest Code

    git pull origin v2\n

  2. Rebuild Containers

    docker compose build\ndocker compose up -d\n

  3. Run Migrations

    docker compose exec api npx prisma migrate deploy\n

  4. Verify Services

    docker compose ps\ncurl http://localhost:4000/health\n

"},{"location":"v2/deployment/#troubleshooting","title":"Troubleshooting","text":"

Common deployment issues:

  • Container fails to start - Check logs, environment variables
  • Database connection error - Verify PostgreSQL password, port
  • Redis connection error - Check Redis password, authentication
  • Nginx routing issues - Review nginx config, test upstream services
  • Tunnel connection fails - Verify Pangolin credentials, Newt config
  • SSL certificate errors - Check Let's Encrypt rate limits, renewal

See Troubleshooting Guide for detailed solutions.

"},{"location":"v2/deployment/#resource-requirements","title":"Resource Requirements","text":""},{"location":"v2/deployment/#minimum","title":"Minimum","text":"
  • CPU: 2 cores
  • RAM: 4 GB
  • Disk: 20 GB SSD
  • Network: 10 Mbps
"},{"location":"v2/deployment/#recommended","title":"Recommended","text":"
  • CPU: 4 cores
  • RAM: 8 GB
  • Disk: 50 GB SSD
  • Network: 100 Mbps
"},{"location":"v2/deployment/#high-load","title":"High Load","text":"
  • CPU: 8+ cores
  • RAM: 16+ GB
  • Disk: 100+ GB SSD
  • Network: 1 Gbps
"},{"location":"v2/deployment/#related-documentation","title":"Related Documentation","text":"
  • Docker Compose
  • Environment Variables
  • Nginx Configuration
  • SSL/TLS Setup
  • Tunneling
  • Backup & Restore
  • Monitoring Stack
  • Healthchecks
  • Scaling
  • Troubleshooting
"},{"location":"v2/deployment/backup-restore/","title":"Backup & Restore Procedures","text":""},{"location":"v2/deployment/backup-restore/#overview","title":"Overview","text":"

The scripts/backup.sh script provides automated backups of: - V2 PostgreSQL database (pg_dump) - Listmonk PostgreSQL database (pg_dump) - Uploads directory (tar.gz) - Backup manifest (SHA256 checksums)

Optional S3 upload for offsite storage.

"},{"location":"v2/deployment/backup-restore/#quick-start","title":"Quick Start","text":""},{"location":"v2/deployment/backup-restore/#manual-backup","title":"Manual Backup","text":"
# Basic backup (local only)\n./scripts/backup.sh\n\n# With S3 upload\n./scripts/backup.sh --s3\n\n# Custom retention (60 days)\n./scripts/backup.sh --retention 60\n

Output: backups/changemaker-v2-backup-YYYYMMDD_HHMMSS.tar.gz

"},{"location":"v2/deployment/backup-restore/#automated-backups-cron","title":"Automated Backups (Cron)","text":"
# Edit crontab\ncrontab -e\n\n# Daily backup at 2 AM + S3 upload\n0 2 * * * /home/user/changemaker.lite/scripts/backup.sh --s3 >> /var/log/changemaker-backup.log 2>&1\n\n# Weekly backup on Sundays at 3 AM\n0 3 * * 0 /home/user/changemaker.lite/scripts/backup.sh --s3 --retention 90\n
"},{"location":"v2/deployment/backup-restore/#backup-script-walkthrough","title":"Backup Script Walkthrough","text":""},{"location":"v2/deployment/backup-restore/#configuration","title":"Configuration","text":"

Location: scripts/backup.sh

Variables:

BACKUP_DIR=\"${BACKUP_DIR:-./backups}\"     # Backup output directory\nRETENTION_DAYS=\"${RETENTION_DAYS:-30}\"    # Delete backups older than N days\nTIMESTAMP=\"$(date +%Y%m%d_%H%M%S)\"        # Backup timestamp\n

Environment: Loads .env automatically (safe parsing handles quotes/special chars).

"},{"location":"v2/deployment/backup-restore/#backup-steps","title":"Backup Steps","text":""},{"location":"v2/deployment/backup-restore/#1-v2-postgresql-dump","title":"1. V2 PostgreSQL Dump","text":"
docker exec changemaker-v2-postgres \\\n  pg_dump -U changemaker -d changemaker_v2 --no-owner --no-acl \\\n  | gzip > v2-postgres.sql.gz\n

Options: - --no-owner: Skip ownership commands (easier restore) - --no-acl: Skip permissions (easier restore) - gzip: Compress (70-80% reduction)

Size estimate: 100MB-2GB (depends on data volume).

"},{"location":"v2/deployment/backup-restore/#2-listmonk-postgresql-dump","title":"2. Listmonk PostgreSQL Dump","text":"
docker exec listmonk-db \\\n  pg_dump -U listmonk -d listmonk --no-owner --no-acl \\\n  | gzip > listmonk-postgres.sql.gz\n

Optional: Skipped if Listmonk container not running.

Size estimate: 10MB-500MB (depends on subscriber count + campaigns).

"},{"location":"v2/deployment/backup-restore/#3-uploads-archive","title":"3. Uploads Archive","text":"
tar -czf uploads.tar.gz -C assets/ uploads/\n

Includes: - Campaign email attachments - Response wall images - Listmonk campaign uploads

Size estimate: 100MB-10GB (depends on file uploads).

"},{"location":"v2/deployment/backup-restore/#4-backup-manifest","title":"4. Backup Manifest","text":"

Format: JSON with file list + SHA256 checksums.

{\n  \"timestamp\": \"20260213_140530\",\n  \"backup_name\": \"changemaker-v2-backup-20260213_140530\",\n  \"files\": [\n    {\n      \"file\": \"v2-postgres.sql.gz\",\n      \"size_bytes\": 123456789,\n      \"sha256\": \"abc123...\"\n    },\n    {\n      \"file\": \"listmonk-postgres.sql.gz\",\n      \"size_bytes\": 987654,\n      \"sha256\": \"def456...\"\n    },\n    {\n      \"file\": \"uploads.tar.gz\",\n      \"size_bytes\": 555666777,\n      \"sha256\": \"ghi789...\"\n    }\n  ],\n  \"v2_database\": \"changemaker_v2\",\n  \"listmonk_database\": \"listmonk\",\n  \"retention_days\": 30\n}\n

Purpose: Verify backup integrity + metadata.

"},{"location":"v2/deployment/backup-restore/#final-archive","title":"Final Archive","text":"

Creates single tar.gz:

tar -czf changemaker-v2-backup-20260213_140530.tar.gz \\\n  changemaker-v2-backup-20260213_140530/\n

Removes temp directory after archiving.

"},{"location":"v2/deployment/backup-restore/#optional-s3-upload","title":"Optional S3 Upload","text":"

Requires: - AWS CLI installed (apt install awscli) - Credentials configured (aws configure) - S3_BUCKET env var set

Command:

aws s3 cp changemaker-v2-backup-20260213_140530.tar.gz \\\n  s3://${S3_BUCKET}/${S3_PREFIX}/\n

S3 prefix: ${S3_PREFIX:-changemaker-backups} (customizable).

"},{"location":"v2/deployment/backup-restore/#retention-cleanup","title":"Retention Cleanup","text":"

Deletes backups older than RETENTION_DAYS:

find backups/ -name \"changemaker-v2-backup-*.tar.gz\" -mtime +30 -delete\n

Local only (S3 has its own lifecycle policies).

"},{"location":"v2/deployment/backup-restore/#restore-procedures","title":"Restore Procedures","text":""},{"location":"v2/deployment/backup-restore/#full-restore-new-server","title":"Full Restore (New Server)","text":""},{"location":"v2/deployment/backup-restore/#1-extract-backup","title":"1. Extract Backup","text":"
# Download from S3 (if needed)\naws s3 cp s3://my-bucket/changemaker-backups/changemaker-v2-backup-20260213_140530.tar.gz ./\n\n# Extract archive\ntar -xzf changemaker-v2-backup-20260213_140530.tar.gz\ncd changemaker-v2-backup-20260213_140530/\n
"},{"location":"v2/deployment/backup-restore/#2-restore-v2-database","title":"2. Restore V2 Database","text":"
# Start PostgreSQL container\ndocker compose up -d v2-postgres\n\n# Wait for healthy\ndocker compose ps v2-postgres\n\n# Restore dump\ngunzip -c v2-postgres.sql.gz | \\\n  docker exec -i changemaker-v2-postgres \\\n  psql -U changemaker -d changemaker_v2\n\n# Verify\ndocker compose exec v2-postgres \\\n  psql -U changemaker -d changemaker_v2 -c \"\\dt\"\n
"},{"location":"v2/deployment/backup-restore/#3-restore-listmonk-database","title":"3. Restore Listmonk Database","text":"
# Start Listmonk DB\ndocker compose up -d listmonk-db\n\n# Restore dump\ngunzip -c listmonk-postgres.sql.gz | \\\n  docker exec -i listmonk-db \\\n  psql -U listmonk -d listmonk\n\n# Verify\ndocker compose exec listmonk-db \\\n  psql -U listmonk -d listmonk -c \"SELECT COUNT(*) FROM subscribers\"\n
"},{"location":"v2/deployment/backup-restore/#4-restore-uploads","title":"4. Restore Uploads","text":"
# Extract uploads\ntar -xzf uploads.tar.gz -C ./assets/\n\n# Verify\nls -lh assets/uploads/\n
"},{"location":"v2/deployment/backup-restore/#5-start-services","title":"5. Start Services","text":"
# Start all services\ndocker compose up -d\n\n# Run migrations (if needed)\ndocker compose exec api npx prisma migrate deploy\n\n# Check health\ndocker compose ps\ncurl http://localhost:4000/api/health\n
"},{"location":"v2/deployment/backup-restore/#partial-restore-specific-data","title":"Partial Restore (Specific Data)","text":""},{"location":"v2/deployment/backup-restore/#restore-single-table","title":"Restore Single Table","text":"
# Extract table from dump\npg_restore -U changemaker -d changemaker_v2 \\\n  --table=campaigns \\\n  v2-postgres.sql.gz\n\n# Or: restore from SQL dump\ngunzip -c v2-postgres.sql.gz | \\\n  grep -A9999 \"CREATE TABLE campaigns\" | \\\n  grep -B9999 \"CREATE TABLE \" | \\\n  docker exec -i changemaker-v2-postgres \\\n  psql -U changemaker -d changemaker_v2\n
"},{"location":"v2/deployment/backup-restore/#restore-specific-files","title":"Restore Specific Files","text":"
# List files in upload archive\ntar -tzf uploads.tar.gz\n\n# Extract specific file\ntar -xzf uploads.tar.gz uploads/campaigns/logo.png\n\n# Copy to container\ndocker cp uploads/campaigns/logo.png \\\n  changemaker-v2-api:/app/uploads/campaigns/\n
"},{"location":"v2/deployment/backup-restore/#backup-verification","title":"Backup Verification","text":""},{"location":"v2/deployment/backup-restore/#integrity-check","title":"Integrity Check","text":"
# Verify checksums from manifest\ncd changemaker-v2-backup-20260213_140530/\n\n# Check v2-postgres.sql.gz\necho \"abc123...  v2-postgres.sql.gz\" | sha256sum -c\n\n# Check all files\njq -r '.files[] | \"\\(.sha256)  \\(.file)\"' manifest.json | sha256sum -c\n

Expected output: OK for each file.

"},{"location":"v2/deployment/backup-restore/#test-restore-dry-run","title":"Test Restore (Dry Run)","text":"

Best practice: Periodically test restores.

# Restore to test database\ndocker compose up -d v2-postgres\n\n# Create test DB\ndocker compose exec v2-postgres \\\n  psql -U changemaker -c \"CREATE DATABASE changemaker_v2_test\"\n\n# Restore to test DB\ngunzip -c v2-postgres.sql.gz | \\\n  docker exec -i changemaker-v2-postgres \\\n  psql -U changemaker -d changemaker_v2_test\n\n# Verify data\ndocker compose exec v2-postgres \\\n  psql -U changemaker -d changemaker_v2_test -c \"SELECT COUNT(*) FROM users\"\n\n# Drop test DB\ndocker compose exec v2-postgres \\\n  psql -U changemaker -c \"DROP DATABASE changemaker_v2_test\"\n
"},{"location":"v2/deployment/backup-restore/#s3-configuration","title":"S3 Configuration","text":""},{"location":"v2/deployment/backup-restore/#setup-aws-cli","title":"Setup AWS CLI","text":"
# Install\nsudo apt install awscli\n\n# Configure credentials\naws configure\n# AWS Access Key ID: <your-key>\n# AWS Secret Access Key: <your-secret>\n# Default region: us-east-1\n# Default output format: json\n
"},{"location":"v2/deployment/backup-restore/#create-s3-bucket","title":"Create S3 Bucket","text":"
# Create bucket\naws s3 mb s3://changemaker-backups\n\n# Set lifecycle policy (auto-delete old backups)\ncat > lifecycle.json <<EOF\n{\n  \"Rules\": [\n    {\n      \"Id\": \"DeleteOldBackups\",\n      \"Status\": \"Enabled\",\n      \"Prefix\": \"changemaker-backups/\",\n      \"Expiration\": {\n        \"Days\": 90\n      }\n    }\n  ]\n}\nEOF\n\naws s3api put-bucket-lifecycle-configuration \\\n  --bucket changemaker-backups \\\n  --lifecycle-configuration file://lifecycle.json\n
"},{"location":"v2/deployment/backup-restore/#environment-variables","title":"Environment Variables","text":"
# Add to .env\nS3_BUCKET=changemaker-backups\nS3_PREFIX=changemaker-backups\nAWS_ACCESS_KEY_ID=<your-key>\nAWS_SECRET_ACCESS_KEY=<your-secret>\nAWS_DEFAULT_REGION=us-east-1\n
"},{"location":"v2/deployment/backup-restore/#retention-policies","title":"Retention Policies","text":""},{"location":"v2/deployment/backup-restore/#recommended-strategy","title":"Recommended Strategy","text":"

Daily backups: Keep 7 days Weekly backups: Keep 4 weeks Monthly backups: Keep 12 months

Implementation (via cron):

# Daily (keep 7 days)\n0 2 * * * /path/to/backup.sh --retention 7\n\n# Weekly (Sundays, keep 28 days)\n0 3 * * 0 /path/to/backup.sh --retention 28 --s3\n\n# Monthly (1st of month, keep 365 days)\n0 4 1 * * /path/to/backup.sh --retention 365 --s3\n

"},{"location":"v2/deployment/backup-restore/#s3-lifecycle","title":"S3 Lifecycle","text":"

Glacier transition (archive old backups):

{\n  \"Rules\": [\n    {\n      \"Id\": \"ArchiveOldBackups\",\n      \"Status\": \"Enabled\",\n      \"Transitions\": [\n        {\n          \"Days\": 30,\n          \"StorageClass\": \"GLACIER\"\n        }\n      ],\n      \"Expiration\": {\n        \"Days\": 365\n      }\n    }\n  ]\n}\n

Apply:

aws s3api put-bucket-lifecycle-configuration \\\n  --bucket changemaker-backups \\\n  --lifecycle-configuration file://lifecycle.json\n

"},{"location":"v2/deployment/backup-restore/#disaster-recovery","title":"Disaster Recovery","text":""},{"location":"v2/deployment/backup-restore/#complete-server-loss","title":"Complete Server Loss","text":"

Scenario: Server crashes, all data lost.

Recovery Steps:

  1. Provision new server (same OS, Docker installed)
  2. Clone repository:
    git clone <repo> changemaker.lite\ncd changemaker.lite\ngit checkout v2\n
  3. Restore .env file (from secure backup location)
  4. Download latest backup from S3:
    aws s3 cp s3://changemaker-backups/changemaker-backups/latest.tar.gz ./\n
  5. Extract + restore (see Full Restore above)
  6. Start services:
    docker compose up -d\n
  7. Verify:
    docker compose ps\ncurl http://localhost:4000/api/health\n

RTO (Recovery Time Objective): 30-60 minutes RPO (Recovery Point Objective): Last backup (e.g., 24h for daily backups)

"},{"location":"v2/deployment/backup-restore/#database-corruption","title":"Database Corruption","text":"

Scenario: PostgreSQL data corruption detected.

Recovery:

# Stop services\ndocker compose stop api admin\n\n# Drop corrupted database\ndocker compose exec v2-postgres \\\n  psql -U changemaker -c \"DROP DATABASE changemaker_v2\"\n\n# Recreate database\ndocker compose exec v2-postgres \\\n  psql -U changemaker -c \"CREATE DATABASE changemaker_v2\"\n\n# Restore from backup\ngunzip -c backups/latest/v2-postgres.sql.gz | \\\n  docker exec -i changemaker-v2-postgres \\\n  psql -U changemaker -d changemaker_v2\n\n# Restart services\ndocker compose up -d api admin\n

"},{"location":"v2/deployment/backup-restore/#monitoring-backup-success","title":"Monitoring Backup Success","text":""},{"location":"v2/deployment/backup-restore/#log-files","title":"Log Files","text":"

Cron output:

# View last backup log\ntail -f /var/log/changemaker-backup.log\n\n# Check for errors\ngrep -i error /var/log/changemaker-backup.log\n

"},{"location":"v2/deployment/backup-restore/#prometheus-metrics-custom","title":"Prometheus Metrics (Custom)","text":"

Add to api/src/utils/metrics.ts:

export const lastBackupTimestamp = new client.Gauge({\n  name: 'cm_last_backup_timestamp',\n  help: 'Unix timestamp of last successful backup',\n});\n\nexport const backupSizeBytes = new client.Gauge({\n  name: 'cm_backup_size_bytes',\n  help: 'Size of last backup in bytes',\n});\n

Alert rule:

- alert: BackupTooOld\n  expr: time() - cm_last_backup_timestamp > 86400 * 2  # 2 days\n  for: 1h\n  labels:\n    severity: warning\n  annotations:\n    summary: \"Backup older than 2 days\"\n

"},{"location":"v2/deployment/backup-restore/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/backup-restore/#pg_dump-permission-denied","title":"pg_dump: permission denied","text":"

Symptoms: Backup fails with \"permission denied for database\"

Cause: PostgreSQL user lacks dump privileges.

Solution:

# Grant privileges\ndocker compose exec v2-postgres \\\n  psql -U changemaker -c \"GRANT ALL ON DATABASE changemaker_v2 TO changemaker\"\n\n# Retry backup\n./scripts/backup.sh\n

"},{"location":"v2/deployment/backup-restore/#s3-upload-fails-invalidaccesskeyid","title":"S3 upload fails: InvalidAccessKeyId","text":"

Symptoms: AWS CLI authentication error

Solution:

# Verify credentials\naws sts get-caller-identity\n\n# Reconfigure\naws configure\n\n# Test S3 access\naws s3 ls s3://changemaker-backups/\n

"},{"location":"v2/deployment/backup-restore/#restore-fails-relation-already-exists","title":"Restore fails: relation already exists","text":"

Symptoms: psql: ERROR: relation \"users\" already exists

Cause: Restoring to non-empty database.

Solution:

# Drop and recreate database\ndocker compose exec v2-postgres \\\n  psql -U changemaker <<SQL\nDROP DATABASE changemaker_v2;\nCREATE DATABASE changemaker_v2;\nSQL\n\n# Retry restore\ngunzip -c v2-postgres.sql.gz | \\\n  docker exec -i changemaker-v2-postgres \\\n  psql -U changemaker -d changemaker_v2\n

"},{"location":"v2/deployment/backup-restore/#best-practices","title":"Best Practices","text":""},{"location":"v2/deployment/backup-restore/#security","title":"Security","text":"
  • Encrypt backups at rest (S3 encryption enabled)
  • Restrict .env file access (chmod 600 .env)
  • Store S3 credentials securely (not in .env committed to Git)
  • Test restore procedures monthly
  • Document recovery procedures (this guide!)
"},{"location":"v2/deployment/backup-restore/#automation","title":"Automation","text":"
  • Schedule daily backups via cron
  • Monitor backup success (log files + metrics)
  • Alert on backup failures
  • Rotate local backups (retention policy)
  • Offsite storage (S3 or alternative)
"},{"location":"v2/deployment/backup-restore/#documentation","title":"Documentation","text":"
  • Document .env restoration procedure
  • Keep list of critical files to backup
  • Document service dependencies
  • Test disaster recovery plan annually
"},{"location":"v2/deployment/backup-restore/#related-documentation","title":"Related Documentation","text":"
  • Docker Compose \u2014 Service orchestration
  • Environment Variables \u2014 .env restoration
  • Monitoring Stack \u2014 Backup monitoring metrics
"},{"location":"v2/deployment/docker-compose/","title":"Docker Compose Orchestration","text":""},{"location":"v2/deployment/docker-compose/#overview","title":"Overview","text":"

Changemaker Lite V2 uses Docker Compose to orchestrate 20+ microservices in a single unified stack. This approach simplifies deployment, provides service isolation, and ensures consistent environments across development and production.

Key Benefits:

  • Single Configuration File: All services defined in docker-compose.yml
  • Automatic Networking: All containers communicate via a shared bridge network
  • Health Checks: 7 critical services have automated health monitoring
  • Volume Persistence: Database, uploads, and configuration data persisted across restarts
  • Profile Support: Optional monitoring stack behind --profile monitoring flag
  • Container Dependencies: Services start in correct order via depends_on relationships

Architecture:

The V2 stack consolidates all services into a single Docker Compose file, replacing the fragmented V1 approach. Services are organized into logical groups: Core (API, database, admin), Supporting (NocoDB, Listmonk, Gitea), Media (media-api, public-media), and Monitoring (Prometheus, Grafana, exporters).

"},{"location":"v2/deployment/docker-compose/#service-architecture","title":"Service Architecture","text":"
graph TB\n    subgraph \"Core Services\"\n        NGINX[nginx<br/>:80, :443]\n        API[api<br/>Express :4000]\n        MEDIA[media-api<br/>Fastify :4100]\n        ADMIN[admin<br/>Vite :3000]\n        PG[v2-postgres<br/>PostgreSQL 16]\n        REDIS[redis<br/>:6379]\n    end\n\n    subgraph \"Supporting Services\"\n        NOCODB[nocodb-v2<br/>:8091]\n        LISTMONK[listmonk-app<br/>:9000]\n        LISTMONK_DB[listmonk-db<br/>PostgreSQL 17]\n        MAILHOG[mailhog<br/>:8025]\n        GITEA[gitea-app<br/>:3000]\n        GITEA_DB[gitea-db<br/>MySQL 8]\n        N8N[n8n<br/>:5678]\n        MKDOCS[mkdocs<br/>:8000]\n        CODE[code-server<br/>:8080]\n        HOMEPAGE[homepage<br/>:3000]\n        MINIQR[mini-qr<br/>:8080]\n    end\n\n    subgraph \"Media Services\"\n        PUBLIC_MEDIA[public-media<br/>:80]\n    end\n\n    subgraph \"Tunnel Services\"\n        NEWT[newt<br/>Pangolin connector]\n    end\n\n    subgraph \"Monitoring Services (profile: monitoring)\"\n        PROMETHEUS[prometheus<br/>:9090]\n        GRAFANA[grafana<br/>:3000]\n        CADVISOR[cadvisor<br/>:8080]\n        NODE_EXPORTER[node-exporter<br/>:9100]\n        REDIS_EXPORTER[redis-exporter<br/>:9121]\n        ALERTMANAGER[alertmanager<br/>:9093]\n        GOTIFY[gotify<br/>:80]\n    end\n\n    NGINX --> API\n    NGINX --> MEDIA\n    NGINX --> ADMIN\n    NGINX --> NOCODB\n    NGINX --> LISTMONK\n    NGINX --> GITEA\n    NGINX --> N8N\n    NGINX --> MKDOCS\n    NGINX --> CODE\n    NGINX --> HOMEPAGE\n    NGINX --> MINIQR\n    NGINX --> MAILHOG\n    NGINX --> PUBLIC_MEDIA\n\n    API --> PG\n    API --> REDIS\n    MEDIA --> PG\n    ADMIN --> API\n    ADMIN --> MEDIA\n    NOCODB --> PG\n    LISTMONK --> LISTMONK_DB\n    GITEA --> GITEA_DB\n    NEWT --> NGINX\n\n    PROMETHEUS --> API\n    PROMETHEUS --> REDIS_EXPORTER\n    PROMETHEUS --> CADVISOR\n    PROMETHEUS --> NODE_EXPORTER\n    GRAFANA --> PROMETHEUS\n    ALERTMANAGER --> PROMETHEUS
"},{"location":"v2/deployment/docker-compose/#core-services","title":"Core Services","text":""},{"location":"v2/deployment/docker-compose/#v2-postgres","title":"v2-postgres","text":"

Purpose: PostgreSQL 16 database for V2 platform (main app + NocoDB metadata)

Configuration:

v2-postgres:\n  image: postgres:16-alpine\n  container_name: changemaker-v2-postgres\n  restart: unless-stopped\n  ports:\n    - \"127.0.0.1:5433:5432\"  # Localhost only\n  environment:\n    POSTGRES_USER: ${V2_POSTGRES_USER:-changemaker}\n    POSTGRES_PASSWORD: ${V2_POSTGRES_PASSWORD}\n    POSTGRES_DB: ${V2_POSTGRES_DB:-changemaker_v2}\n  volumes:\n    - v2-postgres-data:/var/lib/postgresql/data\n    - ./api/prisma/init-nocodb-db.sh:/docker-entrypoint-initdb.d/init-nocodb-db.sh:ro\n  healthcheck:\n    test: [\"CMD-SHELL\", \"pg_isready -U changemaker\"]\n    interval: 10s\n    timeout: 5s\n    retries: 5\n

Key Features: - Alpine image for minimal footprint - init-nocodb-db.sh creates separate nocodb_meta database on first startup - Health check uses pg_isready for fast readiness detection - Port bound to 127.0.0.1 to prevent external access

Volumes: - v2-postgres-data: Persistent PostgreSQL data directory

Dependencies: None (starts first)

"},{"location":"v2/deployment/docker-compose/#redis","title":"redis","text":"

Purpose: Shared Redis instance for sessions, BullMQ job queues, rate limiting, and geocoding cache

Configuration:

redis:\n  image: redis:7-alpine\n  container_name: redis-changemaker\n  command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru --requirepass \"${REDIS_PASSWORD}\"\n  ports:\n    - \"6379:6379\"\n  volumes:\n    - redis-data:/data\n  healthcheck:\n    test: [\"CMD\", \"redis-cli\", \"-a\", \"${REDIS_PASSWORD}\", \"ping\"]\n    interval: 10s\n    timeout: 5s\n    retries: 5\n  deploy:\n    resources:\n      limits:\n        cpus: '1'\n        memory: 512M\n      reservations:\n        cpus: '0.25'\n        memory: 256M\n

Key Features: - Authentication required: --requirepass flag enforces password on all connections - AOF persistence: --appendonly yes writes every command to disk - Memory limits: 512MB max with LRU eviction policy - Resource constraints: Prevents Redis from consuming excessive host resources

Volumes: - redis-data: Persistent AOF log and RDB snapshots

Security Note: As of Security Audit 2025-02-11, Redis authentication is REQUIRED in production. Set a strong REDIS_PASSWORD in .env.

"},{"location":"v2/deployment/docker-compose/#api","title":"api","text":"

Purpose: Unified Express.js API (TypeScript, Prisma ORM)

Configuration:

api:\n  build:\n    context: ./api\n    target: development\n  container_name: changemaker-v2-api\n  restart: unless-stopped\n  ports:\n    - \"${API_PORT:-4000}:4000\"\n    - \"${LISTMONK_PROXY_PORT:-9002}:9002\"\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:4000/api/health\"]\n    interval: 15s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n  environment:\n    - NODE_ENV=${NODE_ENV:-development}\n    - PORT=4000\n    - DATABASE_URL=postgresql://${V2_POSTGRES_USER}:${V2_POSTGRES_PASSWORD}@changemaker-v2-postgres:5432/${V2_POSTGRES_DB}\n    - REDIS_URL=redis://:${REDIS_PASSWORD}@redis-changemaker:6379\n    - JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}\n    - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}\n    # ... 30+ additional env vars (see .env.example)\n  volumes:\n    - ./api:/app\n    - /app/node_modules\n    - ./assets/uploads:/app/uploads\n    - ./mkdocs:/mkdocs:rw\n    - ./data:/data:ro\n    - /var/run/docker.sock:/var/run/docker.sock  # For Docker service management\n  depends_on:\n    v2-postgres:\n      condition: service_healthy\n    redis:\n      condition: service_healthy\n

Key Features: - Waits for PostgreSQL + Redis to be healthy before starting - Mounts source code for live reloading in development - Docker socket access for managing MkDocs/Code Server containers - Health check on /api/health endpoint with 30s startup grace period - Exposes Listmonk proxy on port 9002 (OAuth integration)

Volumes: - ./api:/app: Live code reloading - /app/node_modules: Prevents host node_modules conflicts - ./assets/uploads:/app/uploads: Shared upload directory - ./mkdocs:/mkdocs:rw: MkDocs export target - ./data:/data:ro: NAR import data (read-only) - /var/run/docker.sock: Docker API access

Environment Variables: See Environment Variables for complete reference.

"},{"location":"v2/deployment/docker-compose/#media-api","title":"media-api","text":"

Purpose: Fastify microservice for video library management (Drizzle ORM)

Configuration:

media-api:\n  build:\n    context: ./api\n    dockerfile: Dockerfile.media\n    target: development\n  container_name: changemaker-media-api\n  restart: unless-stopped\n  ports:\n    - \"${MEDIA_API_PORT:-4100}:4100\"\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://127.0.0.1:4100/health\"]\n    interval: 15s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n  environment:\n    - NODE_ENV=${NODE_ENV:-development}\n    - MEDIA_API_PORT=4100\n    - DATABASE_URL=postgresql://...  # Same DB as main API\n    - ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true}\n    - MAX_UPLOAD_SIZE_GB=${MAX_UPLOAD_SIZE_GB:-10}\n  volumes:\n    - ./api:/app\n    - /app/node_modules\n    - ${MEDIA_ROOT:-./media}:/media:ro\n    - ${MEDIA_ROOT:-./media}/local/inbox:/media/local/inbox:rw  # Upload inbox\n  depends_on:\n    v2-postgres:\n      condition: service_healthy\n

Key Features: - Separate Dockerfile (Dockerfile.media) with FFmpeg/FFprobe installed - Shares PostgreSQL database with main API (different ORM) - Media library mounted read-only, inbox writable for uploads - 10GB upload size limit (configurable)

Volumes: - ${MEDIA_ROOT}:/media:ro: Read-only media library - ${MEDIA_ROOT}/local/inbox:/media/local/inbox:rw: RW mount required for video uploads

Important: The inbox directory must have :rw flag; main library stays :ro for security.

"},{"location":"v2/deployment/docker-compose/#admin","title":"admin","text":"

Purpose: React admin GUI (Vite dev server in development, Nginx in production)

Configuration:

admin:\n  build:\n    context: ./admin\n    target: development\n  container_name: changemaker-v2-admin\n  restart: unless-stopped\n  ports:\n    - \"${ADMIN_PORT:-3000}:3000\"\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://127.0.0.1:3000/\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 20s\n  environment:\n    - VITE_API_URL=http://changemaker-v2-api:4000\n    - VITE_MEDIA_API_URL=http://changemaker-media-api:4100\n    - VITE_MKDOCS_URL=http://mkdocs-changemaker:8000\n  volumes:\n    - ./admin:/app\n    - /app/node_modules\n  depends_on:\n    - api\n

Key Features: - Vite environment variables use container hostnames (not localhost) - Health check on root path (Vite dev server responds with HTML) - Live reloading via mounted source code

Environment Variables: - VITE_API_URL: Points to API container (not localhost) - VITE_MEDIA_API_URL: Points to media-api container - VITE_MKDOCS_URL: Points to MkDocs container for iframe embed

Production Build: Swap target: development to target: production and serve static files via Nginx.

"},{"location":"v2/deployment/docker-compose/#nginx","title":"nginx","text":"

Purpose: Reverse proxy with subdomain routing, SSL termination, and iframe embedding support

Configuration:

nginx:\n  build:\n    context: ./nginx\n  container_name: changemaker-v2-nginx\n  restart: unless-stopped\n  ports:\n    - \"${NGINX_HTTP_PORT:-80}:80\"\n    - \"${NGINX_HTTPS_PORT:-443}:443\"\n    - \"8881:8881\"  # NocoDB embed proxy\n    - \"8882:8882\"  # n8n embed proxy\n    - \"8883:8883\"  # Gitea embed proxy\n    - \"8884:8884\"  # MailHog embed proxy\n    - \"8885:8885\"  # Mini QR embed proxy\n  healthcheck:\n    test: [\"CMD\", \"sh\", \"-c\", \"wget -q --spider http://127.0.0.1:80/ && pgrep crond\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n  environment:\n    - PANGOLIN_SITE_ID=${PANGOLIN_SITE_ID:-}\n  volumes:\n    - ./nginx/conf.d:/etc/nginx/conf.d:ro\n    - ./public-web:/usr/share/nginx/public-web:ro\n    - ./configs/pangolin:/etc/pangolin:ro\n  depends_on:\n    - api\n    - admin\n

Key Features: - Subdomain routing: api.cmlite.org, app.cmlite.org, db.cmlite.org, etc. - Embed proxy ports: 888x ports strip X-Frame-Options for iframe embedding - Health check: Validates both HTTP server + cron daemon (for cert renewal) - Read-only configs: Prevents accidental modification

Configuration Files: - nginx.conf: Global settings, gzip, security headers - conf.d/default.conf: Localhost fallback + path-based routing - conf.d/api.conf: API subdomain routing (media endpoints must come before /api/) - conf.d/services.conf: All supporting services + CSP headers

See Nginx Configuration for complete routing details.

"},{"location":"v2/deployment/docker-compose/#nocodb-v2","title":"nocodb-v2","text":"

Purpose: Read-only database browser for V2 schema

Configuration:

nocodb-v2:\n  image: nocodb/nocodb:latest\n  container_name: changemaker-v2-nocodb\n  restart: unless-stopped\n  ports:\n    - \"${NOCODB_V2_PORT:-8091}:8080\"\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:8080/api/v1/health\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n  environment:\n    NC_DB: \"pg://changemaker-v2-postgres:5432?u=${V2_POSTGRES_USER}&p=${V2_POSTGRES_PASSWORD}&d=nocodb_meta\"\n    NC_ADMIN_EMAIL: ${NC_ADMIN_EMAIL:-admin@cmlite.org}\n    NC_ADMIN_PASSWORD: ${NC_ADMIN_PASSWORD}\n    NC_PUBLIC_URL: ${NC_PUBLIC_URL:-http://localhost:8091}\n  volumes:\n    - nocodb-v2-data:/usr/app/data\n  depends_on:\n    v2-postgres:\n      condition: service_healthy\n

Key Features: - Uses separate nocodb_meta database (auto-created by init-nocodb-db.sh) - Health check via NocoDB API endpoint - Read-only access recommended (grant SELECT only in production)

Volumes: - nocodb-v2-data: NocoDB's internal file storage

Access: http://localhost:8091 or http://db.cmlite.org (via subdomain routing)

"},{"location":"v2/deployment/docker-compose/#supporting-services","title":"Supporting Services","text":""},{"location":"v2/deployment/docker-compose/#listmonk-app","title":"listmonk-app","text":"

Purpose: Email marketing platform for newsletters (V2 syncs subscribers via REST API)

Configuration:

listmonk-app:\n  image: listmonk/listmonk:latest\n  container_name: listmonk-app\n  restart: unless-stopped\n  ports:\n    - \"${LISTMONK_PORT:-9001}:9000\"\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:9000/\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n  depends_on:\n    - listmonk-db\n  command: [sh, -c, \"./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''\"]\n  environment:\n    LISTMONK_app__address: 0.0.0.0:9000\n    LISTMONK_db__host: listmonk-db\n    LISTMONK_db__user: ${LISTMONK_DB_USER:-listmonk}\n    LISTMONK_db__password: ${LISTMONK_DB_PASSWORD}\n    LISTMONK_ADMIN_USER: ${LISTMONK_WEB_ADMIN_USER:-admin}\n    LISTMONK_ADMIN_PASSWORD: ${LISTMONK_WEB_ADMIN_PASSWORD}\n  volumes:\n    - ./assets/uploads:/listmonk/uploads:rw\n

Key Features: - Idempotent init: --install --idempotent runs migrations on every start (safe) - Auto-upgrade: --upgrade --yes applies schema upgrades - Shared uploads: Uses same upload directory as main API

Database: Uses separate PostgreSQL 17 instance (listmonk-db)

API Integration: V2 API syncs participants/locations to Listmonk lists via REST API (opt-in via LISTMONK_SYNC_ENABLED=true)

"},{"location":"v2/deployment/docker-compose/#listmonk-db","title":"listmonk-db","text":"

Purpose: PostgreSQL 17 database for Listmonk

Configuration:

listmonk-db:\n  image: postgres:17-alpine\n  container_name: listmonk-db\n  restart: unless-stopped\n  ports:\n    - \"127.0.0.1:5432:5432\"  # Localhost only\n  environment:\n    POSTGRES_USER: ${LISTMONK_DB_USER:-listmonk}\n    POSTGRES_PASSWORD: ${LISTMONK_DB_PASSWORD}\n    POSTGRES_DB: ${LISTMONK_DB_NAME:-listmonk}\n  healthcheck:\n    test: [\"CMD-SHELL\", \"pg_isready -U listmonk\"]\n    interval: 10s\n    timeout: 5s\n    retries: 6\n  volumes:\n    - listmonk-data:/var/lib/postgresql/data\n

Key Features: - Separate PostgreSQL instance (not shared with V2 database) - Port bound to 127.0.0.1 for security

Volumes: - listmonk-data: Persistent Listmonk database

"},{"location":"v2/deployment/docker-compose/#listmonk-init","title":"listmonk-init","text":"

Purpose: One-shot container to create Listmonk API user for V2 integration

Configuration:

listmonk-init:\n  image: postgres:17-alpine\n  container_name: listmonk-init\n  depends_on:\n    listmonk-app:\n      condition: service_started\n  restart: \"no\"  # Runs once and exits\n  environment:\n    PGPASSWORD: ${LISTMONK_DB_PASSWORD}\n    LISTMONK_API_USER: ${LISTMONK_API_USER:-v2-api}\n    LISTMONK_API_TOKEN: ${LISTMONK_API_TOKEN}\n  entrypoint: [\"/bin/sh\", \"-c\"]\n  command:\n    - |\n      # Wait for Listmonk to create tables\n      for i in $(seq 1 30); do\n        if psql -h listmonk-db -U listmonk -d listmonk -c \"SELECT 1 FROM users LIMIT 1\" >/dev/null 2>&1; then\n          break\n        fi\n        sleep 2\n      done\n\n      # Upsert API user\n      psql -h listmonk-db -U listmonk -d listmonk -q <<SQL\n      INSERT INTO users (username, password, password_login, email, name, type, user_role_id, status)\n      VALUES ('$LISTMONK_API_USER', '$LISTMONK_API_TOKEN', true, '$LISTMONK_API_USER@api.internal', '$LISTMONK_API_USER', 'api', 1, 'enabled')\n      ON CONFLICT (username) DO UPDATE SET password = EXCLUDED.password, status = 'enabled';\n      SQL\n

Key Features: - Idempotent: Safe to run multiple times (upserts API user) - Auto-configuration: Also configures SMTP providers (MailHog + production) - Exit on completion: restart: \"no\" prevents restart after success

Important: Listmonk API users store tokens as plaintext (not bcrypt), so direct SQL upsert works.

"},{"location":"v2/deployment/docker-compose/#gitea-app","title":"gitea-app","text":"

Purpose: Self-hosted Git repository hosting

Configuration:

gitea-app:\n  image: gitea/gitea:1.23.7\n  container_name: gitea-changemaker\n  healthcheck:\n    test: [\"CMD\", \"curl\", \"-f\", \"http://localhost:3000/\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n  environment:\n    - GITEA__database__DB_TYPE=mysql\n    - GITEA__database__HOST=gitea-db:3306\n    - GITEA__server__ROOT_URL=${GITEA_ROOT_URL}\n    - GITEA__server__X_FRAME_OPTIONS=  # Allow iframe embedding\n    - GITEA__server__LFS_MAX_FILE_SIZE=1024  # 1GB LFS\n  ports:\n    - \"${GITEA_WEB_PORT:-3030}:3000\"\n    - \"${GITEA_SSH_PORT:-2222}:22\"\n  volumes:\n    - gitea-data:/data\n  depends_on:\n    - gitea-db\n

Key Features: - MySQL backend: Uses separate MySQL 8 container - LFS support: 1GB max file size for large binaries - SSH access: Port 2222 for Git push/pull - Iframe embedding: X_FRAME_OPTIONS disabled for admin iframe

Health Check: Uses curl (Debian-based image) not wget

Volumes: - gitea-data: Git repositories + attachments

"},{"location":"v2/deployment/docker-compose/#n8n","title":"n8n","text":"

Purpose: Workflow automation platform

Configuration:

n8n:\n  image: docker.n8n.io/n8nio/n8n\n  container_name: n8n-changemaker\n  restart: unless-stopped\n  ports:\n    - \"${N8N_PORT:-5678}:5678\"\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:5678/healthz\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n  environment:\n    - N8N_HOST=${N8N_HOST:-n8n.cmlite.org}\n    - N8N_PROTOCOL=https\n    - WEBHOOK_URL=https://${N8N_HOST}/\n    - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}\n    - N8N_DEFAULT_USER_EMAIL=${N8N_USER_EMAIL}\n    - N8N_DEFAULT_USER_PASSWORD=${N8N_USER_PASSWORD}\n  volumes:\n    - n8n-data:/home/node/.n8n\n    - ./local-files:/files\n

Key Features: - HTTPS required: N8N_PROTOCOL=https for webhook security - User management: Creates default admin user on first start - File access: /files directory for workflow file operations

Health Check: /healthz endpoint (Alpine image uses wget)

Volumes: - n8n-data: Workflow definitions + credentials - ./local-files:/files: Shared file directory for workflows

"},{"location":"v2/deployment/docker-compose/#mkdocs","title":"mkdocs","text":"

Purpose: Live documentation preview server (Material theme)

Configuration:

mkdocs:\n  image: squidfunk/mkdocs-material\n  container_name: mkdocs-changemaker\n  volumes:\n    - ./mkdocs:/docs:rw\n    - ./assets/images:/docs/assets/images:rw\n  user: \"${USER_ID:-1000}:${GROUP_ID:-1000}\"\n  ports:\n    - \"${MKDOCS_PORT:-4003}:8000\"\n  environment:\n    - SITE_URL=${BASE_DOMAIN:-https://cmlite.org}\n  command: serve --dev-addr=0.0.0.0:8000 --watch-theme --livereload\n  restart: unless-stopped\n

Key Features: - Live reloading: --livereload watches for file changes - User mapping: Runs as host user to prevent permission issues - Port 4003: Changed from 4000 (conflicted with API in V1)

Volumes: - ./mkdocs:/docs:rw: Documentation source (writable for MkDocs export) - ./assets/images:/docs/assets/images:rw: Shared image directory

Access: http://localhost:4003 or http://docs.cmlite.org (via subdomain routing)

"},{"location":"v2/deployment/docker-compose/#code-server","title":"code-server","text":"

Purpose: VS Code in the browser for documentation editing

Configuration:

code-server:\n  build:\n    context: .\n    dockerfile: Dockerfile.code-server\n  container_name: code-server-changemaker\n  command: /home/coder/project\n  environment:\n    - DOCKER_USER=${USER_NAME:-coder}\n  user: \"${USER_ID:-1000}:${GROUP_ID:-1000}\"\n  volumes:\n    - ./configs/code-server/.config:/home/coder/.config\n    - ./configs/code-server/.local:/home/coder/.local\n    - .:/home/coder/project\n  ports:\n    - \"${CODE_SERVER_PORT:-8888}:8080\"\n  restart: unless-stopped\n

Key Features: - User mapping: Runs as host user (prevents permission conflicts) - Project mount: Entire repository mounted at /home/coder/project - Persistent config: .config and .local directories preserved

Access: http://localhost:8888 or http://code.cmlite.org (via subdomain routing)

"},{"location":"v2/deployment/docker-compose/#mailhog","title":"mailhog","text":"

Purpose: Email capture for development/testing

Configuration:

mailhog:\n  image: mailhog/mailhog:latest\n  container_name: mailhog-changemaker\n  ports:\n    - \"${MAILHOG_WEB_PORT:-8025}:8025\"\n    # SMTP port 1025 only exposed on Docker network\n  restart: unless-stopped\n  logging:\n    driver: \"json-file\"\n    options:\n      max-size: \"5m\"\n      max-file: \"2\"\n

Key Features: - SMTP on port 1025: Accessible only from Docker network (not exposed to host) - Web UI on port 8025: View captured emails - Log rotation: 5MB max size, 2 files

Usage: Set EMAIL_TEST_MODE=true in .env to route all emails to MailHog

Access: http://localhost:8025 or http://mail.cmlite.org (via subdomain routing)

"},{"location":"v2/deployment/docker-compose/#mini-qr","title":"mini-qr","text":"

Purpose: QR code generation service (used by walk sheets + cut exports)

Configuration:

mini-qr:\n  image: ghcr.io/lyqht/mini-qr:latest\n  container_name: mini-qr\n  ports:\n    - \"${MINI_QR_PORT:-8089}:8080\"\n  restart: unless-stopped\n

Key Features: - Stateless: No volumes or persistent data - Lightweight: Alpine-based image

API Integration: V2 API has dedicated /api/qr routes for direct PNG generation; mini-qr used for admin iframe

Access: http://localhost:8089 or http://qr.cmlite.org (via subdomain routing)

"},{"location":"v2/deployment/docker-compose/#homepage","title":"homepage","text":"

Purpose: Service dashboard with container status

Configuration:

homepage:\n  image: ghcr.io/gethomepage/homepage:latest\n  container_name: homepage-changemaker\n  ports:\n    - \"${HOMEPAGE_PORT:-3010}:3000\"\n  volumes:\n    - ./configs/homepage:/app/config\n    - ./assets/icons:/app/public/icons\n    - ./assets/images:/app/public/images\n    - /var/run/docker.sock:/var/run/docker.sock\n  environment:\n    - PUID=${USER_ID:-1000}\n    - PGID=${DOCKER_GROUP_ID:-984}\n    - HOMEPAGE_ALLOWED_HOSTS=*\n  restart: unless-stopped\n

Key Features: - Docker socket access: Reads container status - User mapping: Runs as host user with Docker group - Custom dashboard: Configure in configs/homepage/

Access: http://localhost:3010 or http://home.cmlite.org (via subdomain routing)

"},{"location":"v2/deployment/docker-compose/#media-services","title":"Media Services","text":""},{"location":"v2/deployment/docker-compose/#public-media","title":"public-media","text":"

Purpose: Public video gallery frontend (React production build)

Configuration:

public-media:\n  build:\n    context: ./public-media\n  container_name: changemaker-public-media\n  restart: unless-stopped\n  ports:\n    - \"${PUBLIC_MEDIA_PORT:-3100}:80\"\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://127.0.0.1:80/\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 10s\n  depends_on:\n    - api\n    - media-api\n

Key Features: - Static build: React app served by Nginx (not Vite dev server) - Fast startup: 10s start period (static files load quickly)

Access: http://localhost:3100 or /gallery/ path via main Nginx

"},{"location":"v2/deployment/docker-compose/#tunnel-services","title":"Tunnel Services","text":""},{"location":"v2/deployment/docker-compose/#newt","title":"newt","text":"

Purpose: Pangolin tunnel connector (replaces Cloudflare Tunnel)

Configuration:

newt:\n  image: fosrl/newt\n  container_name: newt-changemaker\n  restart: unless-stopped\n  environment:\n    - PANGOLIN_ENDPOINT=${PANGOLIN_ENDPOINT}\n    - NEWT_ID=${PANGOLIN_NEWT_ID}\n    - NEWT_SECRET=${PANGOLIN_NEWT_SECRET}\n  depends_on:\n    - nginx\n

Key Features: - Self-hosted: Connects to Pangolin server at api.bnkserve.org - Nginx dependency: All traffic routes through nginx:80 - Auto-reconnect: restart: unless-stopped handles connection drops

Setup: Use admin PangolinPage.tsx wizard to configure org \u2192 site \u2192 endpoint \u2192 resource

See Tunneling for complete setup guide.

"},{"location":"v2/deployment/docker-compose/#monitoring-services-profile-monitoring","title":"Monitoring Services (profile: monitoring)","text":""},{"location":"v2/deployment/docker-compose/#prometheus","title":"prometheus","text":"

Purpose: Metrics collection and alerting

Configuration:

prometheus:\n  image: prom/prometheus:latest\n  container_name: prometheus-changemaker\n  command:\n    - '--config.file=/etc/prometheus/prometheus.yml'\n    - '--storage.tsdb.path=/prometheus'\n    - '--storage.tsdb.retention.time=30d'\n  ports:\n    - \"${PROMETHEUS_PORT:-9090}:9090\"\n  volumes:\n    - ./configs/prometheus:/etc/prometheus\n    - prometheus-data:/prometheus\n  restart: always\n  profiles:\n    - monitoring\n

Key Features: - 30-day retention: --storage.tsdb.retention.time=30d - Custom metrics: 12 cm_* metrics from API - Alert rules: alerts.yml defines 12+ alert conditions

Scrape Targets: - changemaker-v2-api:4000/api/metrics (10s interval) - redis-exporter:9121 (15s interval) - cadvisor:8080 (15s interval) - node-exporter:9100 (15s interval)

Access: http://localhost:9090

See Monitoring Stack for complete configuration.

"},{"location":"v2/deployment/docker-compose/#grafana","title":"grafana","text":"

Purpose: Metrics visualization

Configuration:

grafana:\n  image: grafana/grafana:latest\n  container_name: grafana-changemaker\n  ports:\n    - \"${GRAFANA_PORT:-3001}:3000\"\n  environment:\n    - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}\n    - GF_USERS_ALLOW_SIGN_UP=false\n    - GF_SECURITY_ALLOW_EMBEDDING=true  # For admin iframe\n  volumes:\n    - grafana-data:/var/lib/grafana\n    - ./configs/grafana:/etc/grafana/provisioning\n  restart: always\n  depends_on:\n    - prometheus\n  profiles:\n    - monitoring\n

Key Features: - Auto-provisioning: Dashboards from configs/grafana/ auto-load on startup - 3 dashboards: Application Overview, API Performance, System Health - Prometheus datasource: Auto-configured via datasources.yml

Access: http://localhost:3001 (admin/admin default)

"},{"location":"v2/deployment/docker-compose/#cadvisor","title":"cadvisor","text":"

Purpose: Container resource metrics

Configuration:

cadvisor:\n  image: gcr.io/cadvisor/cadvisor:latest\n  container_name: cadvisor-changemaker\n  ports:\n    - \"${CADVISOR_PORT:-8080}:8080\"\n  volumes:\n    - /:/rootfs:ro\n    - /var/run:/var/run:ro\n    - /sys:/sys:ro\n    - /var/lib/docker/:/var/lib/docker:ro\n    - /dev/disk/:/dev/disk:ro\n  privileged: true\n  devices:\n    - /dev/kmsg\n  restart: always\n  profiles:\n    - monitoring\n

Key Features: - Privileged mode: Required for full system access - Host filesystem: Read-only mounts for metrics collection

Access: http://localhost:8080

"},{"location":"v2/deployment/docker-compose/#node-exporter","title":"node-exporter","text":"

Purpose: Host system metrics (CPU, memory, disk, network)

Configuration:

node-exporter:\n  image: prom/node-exporter:latest\n  container_name: node-exporter-changemaker\n  ports:\n    - \"${NODE_EXPORTER_PORT:-9100}:9100\"\n  command:\n    - '--path.rootfs=/host'\n    - '--path.procfs=/host/proc'\n    - '--path.sysfs=/host/sys'\n    - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'\n  volumes:\n    - /proc:/host/proc:ro\n    - /sys:/host/sys:ro\n    - /:/rootfs:ro\n  restart: always\n  profiles:\n    - monitoring\n

Key Features: - Host metrics: CPU, memory, disk, network from host (not container) - Filesystem filters: Excludes virtual filesystems

Access: http://localhost:9100/metrics

"},{"location":"v2/deployment/docker-compose/#redis-exporter","title":"redis-exporter","text":"

Purpose: Redis metrics (memory, commands, connections)

Configuration:

redis-exporter:\n  image: oliver006/redis_exporter:latest\n  container_name: redis-exporter-changemaker\n  ports:\n    - \"${REDIS_EXPORTER_PORT:-9121}:9121\"\n  environment:\n    - REDIS_ADDR=redis:6379\n    - REDIS_PASSWORD=${REDIS_PASSWORD}  # Required for authenticated Redis\n  restart: always\n  depends_on:\n    - redis\n  profiles:\n    - monitoring\n

Key Features: - Authenticated connection: Uses REDIS_PASSWORD env var - Memory metrics: Tracks Redis memory usage

Access: http://localhost:9121/metrics

"},{"location":"v2/deployment/docker-compose/#alertmanager","title":"alertmanager","text":"

Purpose: Alert routing and notification

Configuration:

alertmanager:\n  image: prom/alertmanager:latest\n  container_name: alertmanager-changemaker\n  ports:\n    - \"${ALERTMANAGER_PORT:-9093}:9093\"\n  volumes:\n    - ./configs/alertmanager:/etc/alertmanager\n    - alertmanager-data:/alertmanager\n  command:\n    - '--config.file=/etc/alertmanager/alertmanager.yml'\n    - '--storage.path=/alertmanager'\n  restart: always\n  profiles:\n    - monitoring\n

Key Features: - Alert grouping: Prevents notification spam - Multiple receivers: Email, Slack, webhook, Gotify

Configuration: Edit configs/alertmanager/alertmanager.yml

Access: http://localhost:9093

"},{"location":"v2/deployment/docker-compose/#gotify","title":"gotify","text":"

Purpose: Push notification server (optional alert receiver)

Configuration:

gotify:\n  image: gotify/server:latest\n  container_name: gotify-changemaker\n  ports:\n    - \"${GOTIFY_PORT:-8889}:80\"\n  environment:\n    - GOTIFY_DEFAULTUSER_NAME=${GOTIFY_ADMIN_USER:-admin}\n    - GOTIFY_DEFAULTUSER_PASS=${GOTIFY_ADMIN_PASSWORD:-admin}\n  volumes:\n    - gotify-data:/app/data\n  restart: always\n  profiles:\n    - monitoring\n

Key Features: - Push notifications: Mobile app support (iOS/Android) - Webhook receiver: Integrates with Alertmanager

Access: http://localhost:8889

"},{"location":"v2/deployment/docker-compose/#networks-volumes","title":"Networks & Volumes","text":""},{"location":"v2/deployment/docker-compose/#networks","title":"Networks","text":"

changemaker-lite: Bridge network shared by all services

networks:\n  changemaker-lite:\n    driver: bridge\n

Features: - Automatic DNS: Containers resolve each other by name (e.g., changemaker-v2-api:4000) - Isolation: No external network access unless ports explicitly exposed - Service discovery: Docker's internal DNS server (127.0.0.11)

"},{"location":"v2/deployment/docker-compose/#volumes","title":"Volumes","text":"

Named volumes (Docker-managed, persistent across container recreation):

Volume Purpose Size Estimate v2-postgres-data V2 PostgreSQL database 1-10GB (depends on data) nocodb-v2-data NocoDB metadata + uploads 100MB-1GB redis-data Redis AOF log + RDB snapshots 50-500MB listmonk-data Listmonk PostgreSQL database 100MB-5GB n8n-data n8n workflows + credentials 10-100MB gitea-data Git repositories + attachments 1-50GB mysql-data Gitea MySQL database 100MB-2GB prometheus-data Prometheus TSDB (30 days) 1-5GB grafana-data Grafana dashboards + config 10-100MB alertmanager-data Alert state + silences 1-10MB gotify-data Gotify messages + apps 10-100MB

Bind mounts (host directories):

Bind Mount Container Path Purpose Permissions ./api /app API source code rw ./admin /app Admin source code rw ./assets/uploads /app/uploads, /listmonk/uploads Shared uploads rw ./mkdocs /docs, /mkdocs Documentation source rw ./data /data NAR import data ro ./nginx/conf.d /etc/nginx/conf.d Nginx config ro ./configs/prometheus /etc/prometheus Prometheus config ro ./configs/grafana /etc/grafana/provisioning Grafana config ro /var/run/docker.sock /var/run/docker.sock Docker API rw

Important: Media library requires special mount:

- ${MEDIA_ROOT:-./media}:/media:ro              # Main library (read-only)\n- ${MEDIA_ROOT:-./media}/local/inbox:/media/local/inbox:rw  # Upload inbox (writable)\n

"},{"location":"v2/deployment/docker-compose/#starting-services","title":"Starting Services","text":""},{"location":"v2/deployment/docker-compose/#basic-commands","title":"Basic Commands","text":"

Start all core services:

docker compose up -d\n

Start with monitoring stack:

docker compose --profile monitoring up -d\n

Start specific service:

docker compose up -d api\n

Start with rebuild:

docker compose up -d --build api admin\n

Stop all services:

docker compose down\n

Stop and remove volumes (\u26a0\ufe0f destroys all data):

docker compose down -v\n

"},{"location":"v2/deployment/docker-compose/#development-workflow","title":"Development Workflow","text":"

1. Initial setup (first time only):

# Start core services\ndocker compose up -d v2-postgres redis api admin\n\n# Wait for API to be healthy\ndocker compose ps api  # Check status\n\n# Run migrations\ndocker compose exec api npx prisma migrate deploy\n\n# Seed database\ndocker compose exec api npx prisma db seed\n

2. Daily development:

# Start services\ndocker compose up -d v2-postgres redis api admin\n\n# View logs (live tail)\ndocker compose logs -f api\n\n# Restart single service\ndocker compose restart api\n\n# Check health status\ndocker compose ps\n

3. Full stack with monitoring:

# Start everything\ndocker compose --profile monitoring up -d\n\n# Check Prometheus targets\ncurl http://localhost:9090/api/v1/targets\n\n# View Grafana dashboards\nopen http://localhost:3001\n

"},{"location":"v2/deployment/docker-compose/#log-management","title":"Log Management","text":"

View logs:

# All services (last 50 lines)\ndocker compose logs --tail=50\n\n# Specific service (live tail)\ndocker compose logs -f api\n\n# Multiple services\ndocker compose logs -f api media-api\n\n# With timestamps\ndocker compose logs -f --timestamps api\n\n# Since timestamp\ndocker compose logs --since 2024-01-01T00:00:00 api\n

Log rotation: Configured in docker-compose.yml for Redis + MailHog:

logging:\n  driver: \"json-file\"\n  options:\n    max-size: \"5m\"\n    max-file: \"2\"\n

"},{"location":"v2/deployment/docker-compose/#health-checks","title":"Health Checks","text":"

Check service health:

# All services (shows health status)\ndocker compose ps\n\n# Filter unhealthy services\ndocker compose ps | grep unhealthy\n\n# Inspect health check details\ndocker inspect changemaker-v2-api | jq '.[0].State.Health'\n

Services with health checks: - api: wget http://localhost:4000/api/health (30s start period) - media-api: wget http://127.0.0.1:4100/health (30s start period) - admin: wget http://127.0.0.1:3000/ (20s start period) - v2-postgres: pg_isready -U changemaker (5 retries) - redis: redis-cli -a ${REDIS_PASSWORD} ping (5 retries) - gitea-app: curl http://localhost:3000/ (30s start period) - n8n: wget http://localhost:5678/healthz (30s start period)

Dependency chains (via depends_on with condition: service_healthy): - api waits for v2-postgres + redis - media-api waits for v2-postgres - nocodb-v2 waits for v2-postgres

See Health Checks for detailed configuration.

"},{"location":"v2/deployment/docker-compose/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/docker-compose/#port-conflicts","title":"Port Conflicts","text":"

Problem: Error: bind: address already in use

Solution:

# Find process using port\nsudo lsof -i :4000\nsudo netstat -tulpn | grep :4000\n\n# Change port in .env\necho \"API_PORT=4002\" >> .env\n\n# Restart service\ndocker compose up -d api\n

Common conflicts: - Port 3000: Homepage, Grafana, admin (set ADMIN_PORT=3005) - Port 4000: API, MkDocs v1 (set MKDOCS_PORT=4003) - Port 5432: Listmonk DB, system PostgreSQL (bind to 127.0.0.1 in compose file)

"},{"location":"v2/deployment/docker-compose/#volume-permission-issues","title":"Volume Permission Issues","text":"

Problem: EACCES: permission denied or mkdir: cannot create directory

Cause: Container user mismatch with host filesystem

Solution:

# Fix ownership (run on host)\nsudo chown -R $USER:$USER ./api ./admin ./mkdocs ./assets\n\n# Set USER_ID/GROUP_ID in .env\nid -u  # Get your UID\nid -g  # Get your GID\necho \"USER_ID=$(id -u)\" >> .env\necho \"GROUP_ID=$(id -g)\" >> .env\n\n# Recreate containers\ndocker compose up -d --force-recreate\n

Services using user mapping: - mkdocs: user: \"${USER_ID}:${GROUP_ID}\" - code-server: user: \"${USER_ID}:${GROUP_ID}\" - homepage: PUID=${USER_ID}, PGID=${DOCKER_GROUP_ID}

"},{"location":"v2/deployment/docker-compose/#network-issues","title":"Network Issues","text":"

Problem: Containers can't communicate (e.g., API can't reach Redis)

Solution:

# Verify network exists\ndocker network ls | grep changemaker-lite\n\n# Inspect network\ndocker network inspect changemaker-lite\n\n# Check container connectivity\ndocker compose exec api ping redis-changemaker\n\n# Recreate network\ndocker compose down\ndocker compose up -d\n

DNS resolution: Containers use Docker's internal DNS (127.0.0.11). Reference services by container name: - \u2705 redis-changemaker:6379 - \u274c localhost:6379 (only works if port exposed to host)

"},{"location":"v2/deployment/docker-compose/#database-migration-failures","title":"Database Migration Failures","text":"

Problem: prisma migrate deploy fails with \"relation already exists\"

Solution:

# Reset database (\u26a0\ufe0f destroys data)\ndocker compose exec api npx prisma migrate reset --force\n\n# Or: Fix manually\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2\n\n# Check migration status\ndocker compose exec api npx prisma migrate status\n\n# Force resolve migration\ndocker compose exec api npx prisma migrate resolve --applied \"20240101000000_init\"\n

"},{"location":"v2/deployment/docker-compose/#container-crashes-restart-loops","title":"Container Crashes / Restart Loops","text":"

Problem: Container repeatedly restarting

Diagnosis:

# Check logs for crash reason\ndocker compose logs --tail=100 api\n\n# Check exit code\ndocker inspect changemaker-v2-api | jq '.[0].State'\n\n# Check resource limits\ndocker stats changemaker-v2-api\n

Common causes: - Missing env vars: Check .env file for required secrets - Health check failing: Inspect health check logs - Out of memory: Increase Docker memory limit or add resource constraints - Port binding failure: Check for port conflicts

Fix:

# Restart with fresh logs\ndocker compose up -d --force-recreate api\n\n# Check health\ndocker compose ps api\n

"},{"location":"v2/deployment/docker-compose/#monitoring-stack-not-starting","title":"Monitoring Stack Not Starting","text":"

Problem: Prometheus/Grafana containers missing

Cause: Monitoring services behind profiles: [monitoring]

Solution:

# Start with monitoring profile\ndocker compose --profile monitoring up -d\n\n# Or: Explicitly start monitoring services\ndocker compose up -d prometheus grafana\n

"},{"location":"v2/deployment/docker-compose/#media-upload-failures","title":"Media Upload Failures","text":"

Problem: Video uploads fail with EACCES or timeout

Diagnosis:

# Check media-api logs\ndocker compose logs -f media-api\n\n# Verify inbox permissions\nls -la ./media/local/inbox\n\n# Check disk space\ndf -h\n

Solution:

# Ensure inbox is writable\nchmod 755 ./media/local/inbox\n\n# Verify RW mount in docker-compose.yml\ngrep \"inbox:rw\" docker-compose.yml\n\n# Recreate container\ndocker compose up -d --force-recreate media-api\n

Important: Inbox must have :rw flag; main library stays :ro.

"},{"location":"v2/deployment/docker-compose/#production-deployment","title":"Production Deployment","text":""},{"location":"v2/deployment/docker-compose/#resource-limits","title":"Resource Limits","text":"

Production recommendations:

# Add to services in docker-compose.yml\ndeploy:\n  resources:\n    limits:\n      cpus: '2'\n      memory: 2G\n    reservations:\n      cpus: '0.5'\n      memory: 512M\n

Recommended limits: - api: 2 CPU, 2GB RAM - media-api: 2 CPU, 2GB RAM (for FFprobe) - v2-postgres: 2 CPU, 4GB RAM - redis: 1 CPU, 512MB RAM (already set) - listmonk-app: 1 CPU, 1GB RAM - grafana: 1 CPU, 512MB RAM

"},{"location":"v2/deployment/docker-compose/#healthcheck-tuning","title":"Healthcheck Tuning","text":"

Production healthcheck configuration:

healthcheck:\n  interval: 30s      # Check every 30s (default: 15s)\n  timeout: 10s       # Allow 10s for response (default: 5s)\n  retries: 5         # 5 failures before unhealthy (default: 3)\n  start_period: 60s  # 60s grace period on startup (default: 30s)\n

Rationale: - Longer intervals reduce overhead - Higher retries prevent false positives - Longer start periods for slow database migrations

"},{"location":"v2/deployment/docker-compose/#log-management_1","title":"Log Management","text":"

Production logging configuration:

# Add to all services\nlogging:\n  driver: \"json-file\"\n  options:\n    max-size: \"10m\"\n    max-file: \"5\"\n

Alternative: Use centralized logging (e.g., Loki + Promtail):

logging:\n  driver: \"loki\"\n  options:\n    loki-url: \"http://loki:3100/loki/api/v1/push\"\n

"},{"location":"v2/deployment/docker-compose/#restart-policies","title":"Restart Policies","text":"

Production restart policies: - restart: always \u2014 For critical services (db, redis, api) - restart: unless-stopped \u2014 For most services (respects manual stops) - restart: on-failure \u2014 For optional services (monitoring)

Current configuration: Most services use unless-stopped (allows manual shutdown).

"},{"location":"v2/deployment/docker-compose/#backup-strategy","title":"Backup Strategy","text":"

Automated backups (via cron):

# Add to crontab\n0 2 * * * /home/user/changemaker.lite/scripts/backup.sh --s3 >> /var/log/changemaker-backup.log 2>&1\n

What gets backed up: - V2 PostgreSQL database (pg_dump) - Listmonk PostgreSQL database (pg_dump) - Uploads directory (tar.gz)

See Backup & Restore for complete procedures.

"},{"location":"v2/deployment/docker-compose/#security-hardening","title":"Security Hardening","text":"

Production checklist: - [ ] Change all default passwords in .env - [ ] Set strong REDIS_PASSWORD (required since Security Audit 2025-02-11) - [ ] Bind PostgreSQL ports to 127.0.0.1 (not 0.0.0.0) - [ ] Enable SSL/TLS via Nginx (see SSL/TLS) - [ ] Set ENCRYPTION_KEY (must differ from JWT secrets) - [ ] Disable EMAIL_TEST_MODE (use real SMTP) - [ ] Set NODE_ENV=production - [ ] Review Nginx security headers (CSP, HSTS, Permissions-Policy) - [ ] Restrict NocoDB to read-only access (revoke INSERT/UPDATE/DELETE) - [ ] Enable Prometheus scraping authentication (basic auth)

"},{"location":"v2/deployment/docker-compose/#related-documentation","title":"Related Documentation","text":"
  • Environment Variables \u2014 Complete .env reference
  • Nginx Configuration \u2014 Reverse proxy setup + subdomain routing
  • SSL/TLS \u2014 Certificate management + HTTPS setup
  • Tunneling \u2014 Pangolin tunnel deployment
  • Monitoring Stack \u2014 Prometheus + Grafana configuration
  • Backup & Restore \u2014 Database backup procedures
  • Health Checks \u2014 Docker health check configuration
  • Scaling \u2014 Horizontal scaling strategies
"},{"location":"v2/deployment/environment-variables/","title":"Environment Variables Reference","text":""},{"location":"v2/deployment/environment-variables/#overview","title":"Overview","text":"

Changemaker Lite V2 uses over 100 environment variables to configure services, credentials, and feature flags. This document provides a complete reference organized by functional area.

Configuration File: .env (never committed to Git)

Template: .env.example (committed, safe to share)

Validation: api/src/config/env.ts (Zod schema validates all variables on startup)

"},{"location":"v2/deployment/environment-variables/#quick-start","title":"Quick Start","text":""},{"location":"v2/deployment/environment-variables/#initial-setup","title":"Initial Setup","text":"
# Copy template\ncp .env.example .env\n\n# Generate secrets\nopenssl rand -hex 32  # For JWT_ACCESS_SECRET\nopenssl rand -hex 32  # For JWT_REFRESH_SECRET\nopenssl rand -hex 32  # For ENCRYPTION_KEY (must differ from JWT secrets!)\nopenssl rand -hex 16  # For LISTMONK_API_TOKEN\n\n# Edit .env\nnano .env\n
"},{"location":"v2/deployment/environment-variables/#minimal-required-variables","title":"Minimal Required Variables","text":"

Must set before first start:

V2_POSTGRES_PASSWORD=<strong-password>\nREDIS_PASSWORD=<strong-password>\nJWT_ACCESS_SECRET=<openssl-rand-hex-32>\nJWT_REFRESH_SECRET=<openssl-rand-hex-32>\nENCRYPTION_KEY=<openssl-rand-hex-32>  # Production only\n

All other variables have safe defaults for development.

"},{"location":"v2/deployment/environment-variables/#general-configuration","title":"General Configuration","text":"Variable Default Required Description NODE_ENV development No Environment mode (development | production) DOMAIN cmlite.org No Base domain for subdomain routing USER_ID 1000 No Host user ID for volume permissions GROUP_ID 1000 No Host group ID for volume permissions DOCKER_GROUP_ID 984 No Docker group ID (for homepage container)

Usage:

NODE_ENV=production docker compose up -d\n

"},{"location":"v2/deployment/environment-variables/#v2-postgresql","title":"V2 PostgreSQL","text":"Variable Default Required Description V2_POSTGRES_USER changemaker No PostgreSQL username V2_POSTGRES_PASSWORD CHANGE_ME_STRONG_PASSWORD Yes PostgreSQL password V2_POSTGRES_DB changemaker_v2 No Database name V2_POSTGRES_PORT 5433 No Host port (container always 5432)

Connection String (auto-generated in docker-compose.yml):

postgresql://changemaker:PASSWORD@changemaker-v2-postgres:5432/changemaker_v2\n

Port Binding: 127.0.0.1:5433:5432 (localhost only for security)

Important: Change V2_POSTGRES_PASSWORD before production deployment.

"},{"location":"v2/deployment/environment-variables/#jwt-authentication","title":"JWT Authentication","text":"Variable Default Required Description JWT_ACCESS_SECRET GENERATE_WITH_openssl_rand_hex_32 Yes Access token secret (15min lifespan) JWT_REFRESH_SECRET GENERATE_WITH_openssl_rand_hex_32 Yes Refresh token secret (7 day lifespan) JWT_ACCESS_EXPIRY 15m No Access token expiration (15m, 1h, etc.) JWT_REFRESH_EXPIRY 7d No Refresh token expiration (7d, 30d, etc.) ENCRYPTION_KEY GENERATE_WITH_openssl_rand_hex_32 Yes (prod) DB encryption key for SMTP passwords, etc.

Security Requirements (enforced by Zod schema): - JWT_ACCESS_SECRET must be 32+ characters - JWT_REFRESH_SECRET must be 32+ characters - ENCRYPTION_KEY must be 32+ characters and differ from JWT secrets

Generation:

export JWT_ACCESS_SECRET=$(openssl rand -hex 32)\nexport JWT_REFRESH_SECRET=$(openssl rand -hex 32)\nexport ENCRYPTION_KEY=$(openssl rand -hex 32)\necho \"JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}\" >> .env\necho \"JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}\" >> .env\necho \"ENCRYPTION_KEY=${ENCRYPTION_KEY}\" >> .env\n

Production Note: ENCRYPTION_KEY required in production (dev mode allows empty for testing).

"},{"location":"v2/deployment/environment-variables/#redis","title":"Redis","text":"Variable Default Required Description REDIS_PASSWORD CHANGE_ME_REDIS_PASSWORD Yes Redis authentication password REDIS_URL redis://:PASSWORD@redis-changemaker:6379 No Full connection URL (auto-generated)

Format: redis://[:<password>@]<host>:<port>[/<db>]

Example:

REDIS_PASSWORD=mySecurePassword123\nREDIS_URL=redis://:mySecurePassword123@redis-changemaker:6379\n

Security Note: As of Security Audit 2025-02-11, Redis requires authentication in production.

Docker Command (in docker-compose.yml):

command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru --requirepass \"${REDIS_PASSWORD}\"\n

"},{"location":"v2/deployment/environment-variables/#api-configuration","title":"API Configuration","text":"Variable Default Required Description API_PORT 4000 No Express API port (host) API_URL http://localhost:4000 No Public API URL (for emails, OAuth redirects) CORS_ORIGINS http://localhost:3000,http://localhost No Allowed CORS origins (comma-separated)

Production Example:

API_PORT=4000\nAPI_URL=https://api.cmlite.org\nCORS_ORIGINS=https://app.cmlite.org,https://cmlite.org\n

CORS Note: List all frontend origins (admin, public site, media gallery).

"},{"location":"v2/deployment/environment-variables/#admin-gui","title":"Admin GUI","text":"Variable Default Required Description ADMIN_PORT 3000 No Admin GUI port (host) ADMIN_URL http://localhost:3000 No Public admin URL VITE_API_URL http://changemaker-v2-api:4000 No API URL for Vite proxy (Docker internal) VITE_MEDIA_API_URL http://changemaker-media-api:4100 No Media API URL for Vite proxy VITE_MKDOCS_URL http://mkdocs-changemaker:8000 No MkDocs URL for iframe embed

Development vs Production:

Development (Docker):

VITE_API_URL=http://changemaker-v2-api:4000  # Container name\nVITE_MEDIA_API_URL=http://changemaker-media-api:4100\n

Development (local):

VITE_API_URL=http://localhost:4000  # Localhost\nVITE_MEDIA_API_URL=http://localhost:4100\n

Production: Vite build embeds these URLs at build time.

"},{"location":"v2/deployment/environment-variables/#nginx","title":"Nginx","text":"Variable Default Required Description NGINX_HTTP_PORT 80 No HTTP port NGINX_HTTPS_PORT 443 No HTTPS port

Port Mapping (docker-compose.yml):

nginx:\n  ports:\n    - \"80:80\"\n    - \"443:443\"\n    - \"8881:8881\"  # NocoDB embed proxy\n    - \"8882:8882\"  # n8n embed proxy\n    - \"8883:8883\"  # Gitea embed proxy\n    - \"8884:8884\"  # MailHog embed proxy\n    - \"8885:8885\"  # Mini QR embed proxy\n

Custom Ports (if 80/443 occupied):

NGINX_HTTP_PORT=8080\nNGINX_HTTPS_PORT=8443\n

"},{"location":"v2/deployment/environment-variables/#smtp-email","title":"SMTP / Email","text":"Variable Default Required Description SMTP_HOST mailhog-changemaker No SMTP server hostname SMTP_PORT 1025 No SMTP server port SMTP_USER `` No SMTP username (empty for MailHog) SMTP_PASS `` No SMTP password SMTP_FROM noreply@cmlite.org No Default sender email SMTP_FROM_NAME Changemaker Lite No Default sender name EMAIL_TEST_MODE true No Route all emails to MailHog (dev mode) TEST_EMAIL_RECIPIENT admin@cmlite.org No Override recipient in test mode

Development (MailHog):

SMTP_HOST=mailhog-changemaker\nSMTP_PORT=1025\nSMTP_USER=\nSMTP_PASS=\nEMAIL_TEST_MODE=true\n

Production (e.g., ProtonMail):

SMTP_HOST=smtp.protonmail.ch\nSMTP_PORT=587\nSMTP_USER=your@email.com\nSMTP_PASS=your-app-password\nEMAIL_TEST_MODE=false\n

Test Mode Behavior: - true: All emails sent to MailHog (visible at http://localhost:8025) - false: Emails sent to real recipients via SMTP

SiteSettings Override: Admins can override SMTP config via /app/settings (stored encrypted in DB).

"},{"location":"v2/deployment/environment-variables/#listmonk","title":"Listmonk","text":""},{"location":"v2/deployment/environment-variables/#database","title":"Database","text":"Variable Default Required Description LISTMONK_DB_PORT 5432 No Listmonk PostgreSQL port LISTMONK_DB_USER listmonk No Database username LISTMONK_DB_PASSWORD CHANGE_ME_LISTMONK_PASSWORD Yes Database password LISTMONK_DB_NAME listmonk No Database name"},{"location":"v2/deployment/environment-variables/#web-admin","title":"Web Admin","text":"Variable Default Required Description LISTMONK_PORT 9001 No Listmonk web UI port LISTMONK_WEB_ADMIN_USER admin No Web UI username LISTMONK_WEB_ADMIN_PASSWORD CHANGE_ME_LISTMONK_ADMIN Yes Web UI password"},{"location":"v2/deployment/environment-variables/#api-integration","title":"API Integration","text":"Variable Default Required Description LISTMONK_API_USER v2-api No API user (auto-created by listmonk-init) LISTMONK_API_TOKEN GENERATE_WITH_openssl_rand_hex_16 Yes API token (plaintext, not bcrypt) LISTMONK_ADMIN_USER v2-api No Alias for API user (V2 uses this) LISTMONK_ADMIN_PASSWORD SAME_AS_LISTMONK_API_TOKEN Yes Alias for API token LISTMONK_SYNC_ENABLED false No Enable participant/location sync LISTMONK_PROXY_PORT 9002 No OAuth proxy port (for future integrations)

API User Setup: The listmonk-init container auto-creates the API user by directly inserting into PostgreSQL.

Token Generation:

export LISTMONK_API_TOKEN=$(openssl rand -hex 16)\necho \"LISTMONK_API_TOKEN=${LISTMONK_API_TOKEN}\" >> .env\necho \"LISTMONK_ADMIN_PASSWORD=${LISTMONK_API_TOKEN}\" >> .env\n

Sync Behavior: - false: Manual sync only (default) - true: Auto-sync participants/locations to Listmonk lists on signup/create

"},{"location":"v2/deployment/environment-variables/#smtp-configuration","title":"SMTP Configuration","text":"Variable Default Required Description LISTMONK_SMTP_HOST mailhog-changemaker No SMTP server for newsletters LISTMONK_SMTP_PORT 1025 No SMTP port LISTMONK_SMTP_USER `` No SMTP username LISTMONK_SMTP_PASSWORD `` No SMTP password LISTMONK_SMTP_TLS_TYPE none No TLS mode (none | STARTTLS | TLS) LISTMONK_SMTP_FROM Changemaker Lite <noreply@cmlite.org> No Newsletter sender

listmonk-init Behavior: Configures dual SMTP providers (MailHog + production if credentials set).

"},{"location":"v2/deployment/environment-variables/#represent-api","title":"Represent API","text":"Variable Default Required Description REPRESENT_API_URL https://represent.opennorth.ca No Represent API endpoint (Canadian electoral data)

Free Public API: No authentication required.

Usage: Postal code \u2192 representative lookup for Influence campaigns.

"},{"location":"v2/deployment/environment-variables/#nocodb","title":"NocoDB","text":"Variable Default Required Description NOCODB_V2_PORT 8091 No NocoDB web UI port NOCODB_URL http://changemaker-v2-nocodb:8080 No Internal NocoDB URL NC_ADMIN_EMAIL admin@cmlite.org No Admin email NC_ADMIN_PASSWORD CHANGE_ME_NOCODB_PASSWORD Yes Admin password NC_PUBLIC_URL http://localhost:8091 No Public NocoDB URL

Database Connection: Uses separate nocodb_meta database (auto-created by init-nocodb-db.sh).

Connection String:

pg://changemaker-v2-postgres:5432?u=changemaker&p=PASSWORD&d=nocodb_meta\n

"},{"location":"v2/deployment/environment-variables/#media-management","title":"Media Management","text":"Variable Default Required Description ENABLE_MEDIA_FEATURES false No Enable media manager features MEDIA_API_PORT 4100 No Fastify media API port MEDIA_API_PUBLIC_URL http://media-api:4100 No Public media API URL MEDIA_ROOT /media/library No Media library root path MEDIA_UPLOADS /media/uploads No Upload staging directory MAX_UPLOAD_SIZE_GB 10 No Max video upload size (GB) PUBLIC_MEDIA_PORT 3100 No Public media gallery port VIDEO_PLAYER_DEBUG false No Enable video.js debug logging

Feature Flag: Set ENABLE_MEDIA_FEATURES=true to activate media routes.

Volume Mounts (in docker-compose.yml):

volumes:\n  - ${MEDIA_ROOT:-./media}:/media:ro              # Library (read-only)\n  - ${MEDIA_ROOT:-./media}/local/inbox:/media/local/inbox:rw  # Inbox (writable)\n

Supported Formats: MP4, MOV, AVI, MKV, WebM, M4V, FLV

"},{"location":"v2/deployment/environment-variables/#gitea","title":"Gitea","text":"Variable Default Required Description GITEA_URL http://gitea-changemaker:3000 No Internal Gitea URL GITEA_WEB_PORT 3030 No Gitea web UI port GITEA_SSH_PORT 2222 No Gitea SSH port (for git push/pull) GITEA_DB_TYPE mysql No Database type GITEA_DB_HOST gitea-db:3306 No MySQL hostname GITEA_DB_NAME gitea No Database name GITEA_DB_USER gitea No Database username GITEA_DB_PASSWD CHANGE_ME_GITEA_DB Yes Database password GITEA_DB_ROOT_PASSWORD CHANGE_ME_GITEA_ROOT Yes MySQL root password GITEA_ROOT_URL https://git.cmlite.org No Public Gitea URL GITEA_DOMAIN git.cmlite.org No Gitea domain

First-Time Setup: Visit http://localhost:3030 to create admin account.

Git Commands:

# Clone via HTTP\ngit clone http://localhost:3030/user/repo.git\n\n# Clone via SSH\ngit clone ssh://git@localhost:2222/user/repo.git\n

"},{"location":"v2/deployment/environment-variables/#n8n","title":"n8n","text":"Variable Default Required Description N8N_URL http://n8n-changemaker:5678 No Internal n8n URL N8N_PORT 5678 No n8n port N8N_HOST n8n.cmlite.org No Public n8n hostname N8N_ENCRYPTION_KEY CHANGE_ME_N8N_KEY Yes Workflow encryption key N8N_USER_EMAIL admin@example.com No Default admin email N8N_USER_PASSWORD CHANGE_ME_N8N_PASSWORD Yes Default admin password GENERIC_TIMEZONE UTC No Workflow timezone

First Start: n8n creates admin user with N8N_USER_EMAIL/N8N_USER_PASSWORD automatically.

Encryption Key: Used to encrypt credentials in workflows.

"},{"location":"v2/deployment/environment-variables/#mkdocs","title":"MkDocs","text":"Variable Default Required Description MKDOCS_PORT 4003 No MkDocs live preview port MKDOCS_SITE_SERVER_PORT 4001 No MkDocs static site port BASE_DOMAIN https://cmlite.org No Site URL for sitemap/canonical MKDOCS_PREVIEW_URL http://mkdocs:8000 No Internal preview URL MKDOCS_DOCS_PATH /mkdocs/docs No Documentation source path

Port Change: Was 4000 in V1, changed to 4003 to avoid conflict with API.

Live Reload: http://localhost:4003 (updates on file save)

Static Build: http://localhost:4001 (Nginx-served production build)

"},{"location":"v2/deployment/environment-variables/#code-server","title":"Code Server","text":"Variable Default Required Description CODE_SERVER_PORT 8888 No Code Server port CODE_SERVER_URL http://code-server:8080 No Internal Code Server URL USER_NAME coder No Code Server username

Access: http://localhost:8888

Password: Set in configs/code-server/.config/code-server/config.yaml

"},{"location":"v2/deployment/environment-variables/#homepage","title":"Homepage","text":"Variable Default Required Description HOMEPAGE_PORT 3010 No Homepage dashboard port HOMEPAGE_VAR_BASE_URL http://localhost No Base URL for service links

Configuration: Edit configs/homepage/services.yaml to customize dashboard.

"},{"location":"v2/deployment/environment-variables/#mini-qr","title":"Mini QR","text":"Variable Default Required Description MINI_QR_PORT 8089 No Mini QR service port MINI_QR_URL http://mini-qr:8080 No Internal Mini QR URL MINI_QR_EMBED_PORT 8885 No Nginx embed proxy port

Usage: Walk sheets + cut exports embed QR codes via API or iframe.

"},{"location":"v2/deployment/environment-variables/#mailhog","title":"MailHog","text":"Variable Default Required Description MAILHOG_SMTP_PORT 1025 No SMTP port (internal only) MAILHOG_WEB_PORT 8025 No Web UI port

Web UI: http://localhost:8025

SMTP: Only accessible from Docker network (not exposed to host).

"},{"location":"v2/deployment/environment-variables/#nar-import","title":"NAR Import","text":"Variable Default Required Description NAR_DATA_DIR /data No Path to NAR data directory (in container)

Host Mount (in docker-compose.yml):

volumes:\n  - ./data:/data:ro  # Read-only NAR data\n

Data Structure:

./data/\n\u2514\u2500 202501/  (YYYYMM)\n   \u251c\u2500 Addresses/\n   \u2502  \u251c\u2500 Address_10.txt  (PEI)\n   \u2502  \u251c\u2500 Address_24_part_1.txt  (Quebec part 1)\n   \u2502  \u2514\u2500 ...\n   \u2514\u2500 Locations/\n      \u251c\u2500 Location_10.txt\n      \u2514\u2500 ...\n

Download: https://www150.statcan.gc.ca/n1/pub/46-26-0002/462600022022001-eng.htm

"},{"location":"v2/deployment/environment-variables/#geocoding","title":"Geocoding","text":"Variable Default Required Description MAPBOX_API_KEY `` No Mapbox API key (optional, 100k free/month) GEOCODING_RATE_LIMIT_MS 1100 No Delay between provider requests (ms) GEOCODING_CACHE_ENABLED true No Enable Redis caching GEOCODING_CACHE_TTL_HOURS 24 No Cache TTL in hours GOOGLE_MAPS_API_KEY `` No Google Maps API key (optional, paid) GOOGLE_MAPS_ENABLED false No Enable Google geocoding provider GEOCODING_PARALLEL_ENABLED true No Parallel geocoding for bulk imports GEOCODING_BATCH_SIZE 10 No Batch size for parallel geocoding BULK_GEOCODE_ENABLED true No Enable bulk re-geocode feature BULK_GEOCODE_MAX_BATCH 5000 No Max locations per bulk geocode batch

Providers (in fallback order): 1. Nominatim (OpenStreetMap, free) 2. ArcGIS (free tier) 3. Photon (free) 4. Mapbox (100k free/month, requires API key) 5. LocationIQ (free tier) 6. Google (paid, most accurate)

Recommendation: Add MAPBOX_API_KEY for better accuracy without cost.

"},{"location":"v2/deployment/environment-variables/#pangolin-tunnel","title":"Pangolin Tunnel","text":"Variable Default Required Description PANGOLIN_API_URL https://api.bnkserve.org/v1 No Pangolin API endpoint PANGOLIN_API_KEY `` No Pangolin API key PANGOLIN_ORG_ID `` No Organization ID (from setup wizard) PANGOLIN_SITE_ID `` No Site ID (from setup wizard) PANGOLIN_ENDPOINT https://pangolin.bnkserve.org No Tunnel endpoint URL PANGOLIN_NEWT_ID `` No Newt connector ID PANGOLIN_NEWT_SECRET `` No Newt connector secret

Setup Workflow: 1. Visit /app/pangolin in admin GUI 2. Enter PANGOLIN_API_KEY 3. Create org \u2192 site \u2192 endpoint \u2192 resource 4. Copy NEWT_ID/NEWT_SECRET to .env 5. Restart Newt container

Manual Setup:

# Set API key\nexport PANGOLIN_API_KEY=your-api-key\n\n# Create org (returns ORG_ID)\ncurl -H \"Authorization: Bearer $PANGOLIN_API_KEY\" \\\n  https://api.bnkserve.org/v1/orgs \\\n  -d '{\"name\":\"My Organization\"}'\n\n# Create site (returns SITE_ID)\ncurl -H \"Authorization: Bearer $PANGOLIN_API_KEY\" \\\n  https://api.bnkserve.org/v1/sites \\\n  -d '{\"org_id\":\"ORG_ID\",\"name\":\"Production Site\"}'\n\n# Continue setup...\n

See Tunneling for complete guide.

"},{"location":"v2/deployment/environment-variables/#monitoring","title":"Monitoring","text":""},{"location":"v2/deployment/environment-variables/#prometheus","title":"Prometheus","text":"Variable Default Required Description PROMETHEUS_PORT 9090 No Prometheus port

Scrape Targets (configured in configs/prometheus/prometheus.yml): - changemaker-v2-api:4000/api/metrics (10s interval) - redis-exporter:9121 (15s interval) - cadvisor:8080 (15s interval) - node-exporter:9100 (15s interval)

Retention: 30 days (configured in docker-compose.yml command).

"},{"location":"v2/deployment/environment-variables/#grafana","title":"Grafana","text":"Variable Default Required Description GRAFANA_PORT 3001 No Grafana port GRAFANA_ADMIN_PASSWORD admin No Admin password GRAFANA_ROOT_URL http://localhost:3001 No Public Grafana URL

Default Login: admin / admin (change on first login)

Dashboards: 3 pre-configured dashboards auto-provisioned from configs/grafana/

"},{"location":"v2/deployment/environment-variables/#exporters","title":"Exporters","text":"Variable Default Required Description CADVISOR_PORT 8080 No cAdvisor container metrics port NODE_EXPORTER_PORT 9100 No Node exporter system metrics port REDIS_EXPORTER_PORT 9121 No Redis exporter port"},{"location":"v2/deployment/environment-variables/#alertmanager","title":"Alertmanager","text":"Variable Default Required Description ALERTMANAGER_PORT 9093 No Alertmanager port

Configuration: Edit configs/alertmanager/alertmanager.yml for notification receivers.

"},{"location":"v2/deployment/environment-variables/#gotify","title":"Gotify","text":"Variable Default Required Description GOTIFY_PORT 8889 No Gotify push notification server port GOTIFY_ADMIN_USER admin No Gotify admin username GOTIFY_ADMIN_PASSWORD admin No Gotify admin password

Usage: Create apps in Gotify UI, add webhook URL to Alertmanager.

"},{"location":"v2/deployment/environment-variables/#security-checklist","title":"Security Checklist","text":"

Before production deployment:

  • Change all CHANGE_ME_* passwords
  • Generate strong JWT_ACCESS_SECRET (32+ chars)
  • Generate strong JWT_REFRESH_SECRET (32+ chars)
  • Generate strong ENCRYPTION_KEY (32+ chars, different from JWT secrets)
  • Set strong REDIS_PASSWORD
  • Set strong V2_POSTGRES_PASSWORD
  • Set strong LISTMONK_DB_PASSWORD
  • Set strong LISTMONK_API_TOKEN
  • Set strong GITEA_DB_PASSWD + GITEA_DB_ROOT_PASSWORD
  • Set strong N8N_ENCRYPTION_KEY + N8N_USER_PASSWORD
  • Set strong NC_ADMIN_PASSWORD (NocoDB)
  • Set strong GRAFANA_ADMIN_PASSWORD
  • Disable EMAIL_TEST_MODE (set to false)
  • Configure real SMTP credentials
  • Set NODE_ENV=production
  • Review CORS_ORIGINS (whitelist only trusted domains)

Validation:

# Check for remaining placeholders\ngrep -r \"CHANGE_ME\" .env\n\n# Verify secrets are different\necho \"JWT_ACCESS_SECRET: $(grep JWT_ACCESS_SECRET .env)\"\necho \"JWT_REFRESH_SECRET: $(grep JWT_REFRESH_SECRET .env)\"\necho \"ENCRYPTION_KEY: $(grep ENCRYPTION_KEY .env)\"\n

"},{"location":"v2/deployment/environment-variables/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/environment-variables/#missing-env-file","title":"Missing .env File","text":"

Symptoms: Containers fail to start with \"missing environment variable\" errors

Solution:

# Create from template\ncp .env.example .env\n\n# Verify file exists\nls -la .env\n

"},{"location":"v2/deployment/environment-variables/#invalid-environment-variables","title":"Invalid Environment Variables","text":"

Symptoms: API fails to start with Zod validation errors

Diagnosis:

# View API startup logs\ndocker compose logs api | grep -A10 \"Environment validation\"\n

Common errors: - JWT_ACCESS_SECRET too short (must be 32+ chars) - ENCRYPTION_KEY same as JWT_ACCESS_SECRET (must differ) - Invalid URL format (API_URL must start with http:// or https://)

Solution:

# Regenerate secrets\nexport JWT_ACCESS_SECRET=$(openssl rand -hex 32)\nexport ENCRYPTION_KEY=$(openssl rand -hex 32)\n\n# Update .env\nsed -i \"s/^JWT_ACCESS_SECRET=.*/JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}/\" .env\nsed -i \"s/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${ENCRYPTION_KEY}/\" .env\n\n# Restart API\ndocker compose restart api\n

"},{"location":"v2/deployment/environment-variables/#postgresql-connection-failures","title":"PostgreSQL Connection Failures","text":"

Symptoms: API logs show ECONNREFUSED or authentication failed

Diagnosis:

# Check PostgreSQL is running\ndocker compose ps v2-postgres\n\n# Test connection\ndocker compose exec api npx prisma db pull\n\n# Verify DATABASE_URL\ndocker compose exec api printenv | grep DATABASE_URL\n

Solution:

# Verify password matches in .env\ngrep V2_POSTGRES_PASSWORD .env\n\n# Restart PostgreSQL\ndocker compose restart v2-postgres\n\n# Wait for healthcheck\ndocker compose ps v2-postgres  # Should show (healthy)\n

"},{"location":"v2/deployment/environment-variables/#redis-connection-failures","title":"Redis Connection Failures","text":"

Symptoms: API logs show ECONNREFUSED or WRONGPASS invalid password

Diagnosis:

# Check Redis is running\ndocker compose ps redis\n\n# Test connection\ndocker compose exec redis redis-cli -a \"${REDIS_PASSWORD}\" ping\n

Solution:

# Verify password in .env\ngrep REDIS_PASSWORD .env\n\n# Ensure REDIS_URL includes password\ngrep REDIS_URL .env  # Should be redis://:PASSWORD@redis-changemaker:6379\n\n# Restart Redis\ndocker compose restart redis\n

"},{"location":"v2/deployment/environment-variables/#environment-variables-not-updating","title":"Environment Variables Not Updating","text":"

Symptoms: Changed .env but service still uses old value

Cause: Docker Compose reads .env at startup, not runtime

Solution:

# Recreate container (picks up new env vars)\ndocker compose up -d --force-recreate api\n\n# Or: stop and start\ndocker compose down\ndocker compose up -d\n

"},{"location":"v2/deployment/environment-variables/#related-documentation","title":"Related Documentation","text":"
  • Docker Compose \u2014 Service orchestration
  • SSL/TLS \u2014 Certificate management
  • Tunneling \u2014 Pangolin setup
  • Backup & Restore \u2014 Data protection
  • Security Audit \u2014 Security requirements
"},{"location":"v2/deployment/healthchecks/","title":"Docker Health Check Configuration","text":""},{"location":"v2/deployment/healthchecks/#overview","title":"Overview","text":"

Docker health checks provide automatic service monitoring and restart capabilities. Changemaker Lite V2 includes health checks for 7 critical services.

Benefits: - Automatic restart of unhealthy containers - Dependency management (depends_on with service_healthy) - Monitoring integration (Prometheus can scrape health status)

"},{"location":"v2/deployment/healthchecks/#services-with-health-checks","title":"Services with Health Checks","text":"Service Healthcheck Command Interval Timeout Retries Start Period api wget http://localhost:4000/api/health 15s 5s 3 30s media-api wget http://127.0.0.1:4100/health 15s 5s 3 30s admin wget http://127.0.0.1:3000/ 30s 5s 3 20s v2-postgres pg_isready -U changemaker 10s 5s 5 - redis redis-cli -a $REDIS_PASSWORD ping 10s 5s 5 - gitea-app curl http://localhost:3000/ 30s 5s 3 30s n8n wget http://localhost:5678/healthz 30s 5s 3 30s"},{"location":"v2/deployment/healthchecks/#health-check-configuration","title":"Health Check Configuration","text":""},{"location":"v2/deployment/healthchecks/#api-express","title":"API (Express)","text":"

docker-compose.yml:

api:\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:4000/api/health\"]\n    interval: 15s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n

Explanation: - test: Runs wget (Alpine image standard) to check /api/health endpoint - interval: Check every 15 seconds - timeout: Fail if no response in 5 seconds - retries: Mark unhealthy after 3 consecutive failures - start_period: 30s grace period on startup (allows migrations to run)

Health endpoint (api/src/server.ts):

app.get('/api/health', (req, res) => {\n  res.json({ status: 'ok', timestamp: new Date().toISOString() });\n});\n

Health states: - starting: Within start_period (30s) - healthy: Check passed - unhealthy: 3 consecutive failures

"},{"location":"v2/deployment/healthchecks/#media-api-fastify","title":"Media API (Fastify)","text":"

docker-compose.yml:

media-api:\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://127.0.0.1:4100/health\"]\n    interval: 15s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n

Health endpoint (api/src/media-server.ts):

app.get('/health', async (req, reply) => {\n  return { status: 'ok' };\n});\n

Note: Uses 127.0.0.1 instead of localhost (Alpine's wget prefers IP).

"},{"location":"v2/deployment/healthchecks/#admin-vite-dev-server","title":"Admin (Vite Dev Server)","text":"

docker-compose.yml:

admin:\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://127.0.0.1:3000/\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 20s\n

Explanation: - 30s interval: Less critical than backend (frontend can tolerate brief downtime) - 20s start period: Vite dev server starts quickly - Root path: Checks Vite is serving HTML (no dedicated /health endpoint)

"},{"location":"v2/deployment/healthchecks/#v2-postgresql","title":"V2 PostgreSQL","text":"

docker-compose.yml:

v2-postgres:\n  healthcheck:\n    test: [\"CMD-SHELL\", \"pg_isready -U changemaker\"]\n    interval: 10s\n    timeout: 5s\n    retries: 5\n

Explanation: - pg_isready: Built-in PostgreSQL health check utility - 10s interval: Fast detection of database issues - 5 retries: More tolerant (database startup can be slow) - No start_period: PostgreSQL has its own startup delay

pg_isready output:

# Healthy\n/var/run/postgresql:5432 - accepting connections\n\n# Unhealthy\n/var/run/postgresql:5432 - rejecting connections\n

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

docker-compose.yml:

redis:\n  healthcheck:\n    test: [\"CMD\", \"redis-cli\", \"-a\", \"${REDIS_PASSWORD}\", \"ping\"]\n    interval: 10s\n    timeout: 5s\n    retries: 5\n

Explanation: - redis-cli ping: Returns PONG if healthy - -a ${REDIS_PASSWORD}: Authenticates with password (required since Security Audit) - 10s interval: Fast detection for critical cache service

PING output:

# Healthy\nPONG\n\n# Unhealthy\n(error) NOAUTH Authentication required\n

"},{"location":"v2/deployment/healthchecks/#gitea","title":"Gitea","text":"

docker-compose.yml:

gitea-app:\n  healthcheck:\n    test: [\"CMD\", \"curl\", \"-f\", \"http://localhost:3000/\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n

Explanation: - curl: Debian-based image (no wget) - -f: Fail on HTTP errors (non-200 response) - 30s interval: Supporting service (less critical)

Important: Gitea uses curl (not wget) because it's a Debian image, not Alpine.

"},{"location":"v2/deployment/healthchecks/#n8n","title":"n8n","text":"

docker-compose.yml:

n8n:\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:5678/healthz\"]\n    interval: 30s\n    timeout: 5s\n    retries: 3\n    start_period: 30s\n

Explanation: - /healthz: n8n's built-in health endpoint - 30s interval: Workflow automation (not user-facing)

"},{"location":"v2/deployment/healthchecks/#dependency-chains","title":"Dependency Chains","text":""},{"location":"v2/deployment/healthchecks/#api-depends-on-database-redis","title":"API Depends on Database + Redis","text":"

docker-compose.yml:

api:\n  depends_on:\n    v2-postgres:\n      condition: service_healthy\n    redis:\n      condition: service_healthy\n

Effect: API container waits for PostgreSQL + Redis to be healthy before starting.

Startup sequence: 1. PostgreSQL starts \u2192 health checks begin 2. After 5 successful checks \u2192 marked healthy 3. Redis starts \u2192 health checks begin 4. After 5 successful checks \u2192 marked healthy 5. API starts (both dependencies healthy)

"},{"location":"v2/deployment/healthchecks/#media-api-depends-on-database","title":"Media API Depends on Database","text":"

docker-compose.yml:

media-api:\n  depends_on:\n    v2-postgres:\n      condition: service_healthy\n

Effect: Media API waits for PostgreSQL to be healthy.

"},{"location":"v2/deployment/healthchecks/#nocodb-depends-on-database","title":"NocoDB Depends on Database","text":"

docker-compose.yml:

nocodb-v2:\n  depends_on:\n    v2-postgres:\n      condition: service_healthy\n

Effect: NocoDB waits for its metadata database to be ready.

"},{"location":"v2/deployment/healthchecks/#monitoring-healthcheck-status","title":"Monitoring Healthcheck Status","text":""},{"location":"v2/deployment/healthchecks/#view-health-status","title":"View Health Status","text":"
# All services (shows health in STATUS column)\ndocker compose ps\n\n# Example output:\n# NAME                    STATUS\n# changemaker-v2-api      Up 2 hours (healthy)\n# changemaker-v2-postgres Up 2 hours (healthy)\n# redis-changemaker       Up 2 hours (healthy)\n

Health states: - (healthy): All checks passing - (unhealthy): Multiple checks failed - (health: starting): Within start_period

"},{"location":"v2/deployment/healthchecks/#filter-unhealthy-services","title":"Filter Unhealthy Services","text":"
# Show only unhealthy\ndocker compose ps | grep unhealthy\n\n# Count unhealthy\ndocker compose ps -q --status unhealthy | wc -l\n
"},{"location":"v2/deployment/healthchecks/#inspect-health-check-details","title":"Inspect Health Check Details","text":"
# Full health info for API\ndocker inspect changemaker-v2-api | jq '.[0].State.Health'\n\n# Example output:\n{\n  \"Status\": \"healthy\",\n  \"FailingStreak\": 0,\n  \"Log\": [\n    {\n      \"Start\": \"2026-02-13T14:30:00Z\",\n      \"End\": \"2026-02-13T14:30:01Z\",\n      \"ExitCode\": 0,\n      \"Output\": \"\"\n    }\n  ]\n}\n

Key fields: - Status: healthy, unhealthy, or starting - FailingStreak: Consecutive failed checks - Log: Last 5 health check results

"},{"location":"v2/deployment/healthchecks/#health-check-logs","title":"Health Check Logs","text":"
# View health check output\ndocker inspect changemaker-v2-api | jq '.[0].State.Health.Log[-1]'\n\n# Example (success):\n{\n  \"Start\": \"2026-02-13T14:30:00Z\",\n  \"End\": \"2026-02-13T14:30:01Z\",\n  \"ExitCode\": 0,\n  \"Output\": \"\"\n}\n\n# Example (failure):\n{\n  \"Start\": \"2026-02-13T14:35:00Z\",\n  \"End\": \"2026-02-13T14:35:05Z\",\n  \"ExitCode\": 1,\n  \"Output\": \"wget: can't connect to remote host (127.0.0.1): Connection refused\"\n}\n
"},{"location":"v2/deployment/healthchecks/#custom-health-checks","title":"Custom Health Checks","text":""},{"location":"v2/deployment/healthchecks/#advanced-api-health-check","title":"Advanced API Health Check","text":"

Check database + Redis connectivity:

api/src/server.ts:

app.get('/api/health', async (req, res) => {\n  const checks = {\n    database: false,\n    redis: false,\n  };\n\n  try {\n    await prisma.$queryRaw`SELECT 1`;\n    checks.database = true;\n  } catch (err) {\n    console.error('DB health check failed:', err);\n  }\n\n  try {\n    await redis.ping();\n    checks.redis = true;\n  } catch (err) {\n    console.error('Redis health check failed:', err);\n  }\n\n  const healthy = checks.database && checks.redis;\n  res.status(healthy ? 200 : 503).json({\n    status: healthy ? 'ok' : 'degraded',\n    checks,\n    timestamp: new Date().toISOString(),\n  });\n});\n

docker-compose.yml (no change needed \u2014 still checks /api/health):

healthcheck:\n  test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:4000/api/health\"]\n

"},{"location":"v2/deployment/healthchecks/#readiness-vs-liveness","title":"Readiness vs Liveness","text":"

Readiness: Service is ready to accept traffic (used by Kubernetes) Liveness: Service is running (Docker health checks)

Example (separate endpoints):

// Liveness (minimal check)\napp.get('/api/health', (req, res) => {\n  res.json({ status: 'ok' });\n});\n\n// Readiness (comprehensive check)\napp.get('/api/ready', async (req, res) => {\n  const dbReady = await checkDatabase();\n  const redisReady = await checkRedis();\n  const ready = dbReady && redisReady;\n  res.status(ready ? 200 : 503).json({ ready, dbReady, redisReady });\n});\n

Docker uses liveness (/api/health). Load balancer uses readiness (/api/ready).

"},{"location":"v2/deployment/healthchecks/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/healthchecks/#service-marked-unhealthy","title":"Service Marked Unhealthy","text":"

Diagnosis:

# Check logs\ndocker compose logs --tail=50 api\n\n# Check health check output\ndocker inspect changemaker-v2-api | jq '.[0].State.Health.Log[-1].Output'\n\n# Manually run health check\ndocker compose exec api wget -O- http://localhost:4000/api/health\n

Common causes: - Service crashed (check logs) - Health endpoint broken (test manually) - Timeout too short (increase in docker-compose.yml) - Database migration running (increase start_period)

"},{"location":"v2/deployment/healthchecks/#container-restarting-loop","title":"Container Restarting Loop","text":"

Symptoms: Container repeatedly marked unhealthy \u2192 restart \u2192 unhealthy

Diagnosis:

# Check restart count\ndocker inspect changemaker-v2-api | jq '.[0].RestartCount'\n\n# Check logs for errors\ndocker compose logs api | grep -i error\n

Common causes: - Health check too aggressive (increase retries/interval) - Service genuinely broken (fix code issue) - Resource limits too low (increase memory/CPU)

Solution:

# Temporarily disable health check\nhealthcheck:\n  disable: true\n\n# Or increase tolerance\nhealthcheck:\n  retries: 10\n  start_period: 60s\n

"},{"location":"v2/deployment/healthchecks/#health-check-command-not-found","title":"Health Check Command Not Found","text":"

Symptoms: Health check fails with \"wget: not found\" or \"curl: not found\"

Cause: Using wrong command for image type (Alpine vs Debian)

Solution:

Alpine images (api, media-api, redis, v2-postgres):

test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://...\"]\n

Debian images (gitea-app):

test: [\"CMD\", \"curl\", \"-f\", \"http://...\"]\n

"},{"location":"v2/deployment/healthchecks/#start-period-too-short","title":"Start Period Too Short","text":"

Symptoms: Service marked unhealthy immediately on startup

Cause: Database migrations or slow startup exceed start_period

Solution:

# Increase start_period\nhealthcheck:\n  start_period: 60s  # Was 30s\n

Monitor startup time:

# Measure time to first healthy\ndocker compose up -d api && \\\n  while ! docker compose ps api | grep -q healthy; do sleep 1; done && \\\n  echo \"Startup took $SECONDS seconds\"\n

"},{"location":"v2/deployment/healthchecks/#production-recommendations","title":"Production Recommendations","text":""},{"location":"v2/deployment/healthchecks/#timeout-configuration","title":"Timeout Configuration","text":"

Critical services (database, redis, api): - interval: 10-15s - timeout: 5s - retries: 3-5 - start_period: 30-60s

Supporting services (n8n, gitea, mailhog): - interval: 30-60s - timeout: 10s - retries: 3 - start_period: 30s

"},{"location":"v2/deployment/healthchecks/#restart-policies","title":"Restart Policies","text":"

Combine with restart policies:

api:\n  restart: unless-stopped  # Auto-restart on failure\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:4000/api/health\"]\n

Effect: Unhealthy container \u2192 restart \u2192 health checks resume.

"},{"location":"v2/deployment/healthchecks/#monitoring-integration","title":"Monitoring Integration","text":"

Prometheus exporter (future):

# Expose health check status as metrics\ndocker_healthcheck_status{container=\"changemaker-v2-api\"} 1\n

Alert on unhealthy:

- alert: ContainerUnhealthy\n  expr: docker_healthcheck_status == 0\n  for: 5m\n  labels:\n    severity: warning\n  annotations:\n    summary: \"Container {{ $labels.container }} unhealthy\"\n

"},{"location":"v2/deployment/healthchecks/#testing-health-checks","title":"Testing Health Checks","text":""},{"location":"v2/deployment/healthchecks/#manual-test","title":"Manual Test","text":"
# Start service\ndocker compose up -d api\n\n# Watch health status\nwatch -n2 'docker compose ps api'\n\n# Should see:\n# (health: starting) \u2192 (healthy)\n
"},{"location":"v2/deployment/healthchecks/#simulate-failure","title":"Simulate Failure","text":"
# Stop backend service\ndocker compose stop v2-postgres\n\n# Wait 15s (API health check interval)\nsleep 15\n\n# Check API status\ndocker compose ps api\n# Should show (unhealthy) after 3 failures (45s)\n\n# Restart backend\ndocker compose start v2-postgres\n\n# API should recover\ndocker compose ps api\n# Should show (healthy) after successful check\n
"},{"location":"v2/deployment/healthchecks/#related-documentation","title":"Related Documentation","text":"
  • Docker Compose \u2014 Service orchestration
  • Monitoring Stack \u2014 Health metrics
  • Troubleshooting \u2014 Debug failing services
"},{"location":"v2/deployment/monitoring-stack/","title":"Monitoring Stack (Prometheus + Grafana)","text":""},{"location":"v2/deployment/monitoring-stack/#overview","title":"Overview","text":"

Changemaker Lite V2 includes a complete observability stack for production monitoring:

  • Prometheus: Metrics collection + alerting rules
  • Grafana: Visualization + pre-configured dashboards
  • Alertmanager: Alert routing + notifications
  • cAdvisor: Docker container metrics
  • Node Exporter: Host system metrics
  • Redis Exporter: Redis-specific metrics
  • Gotify: Push notifications (optional)

All monitoring services behind Docker Compose profile flag (opt-in).

"},{"location":"v2/deployment/monitoring-stack/#architecture","title":"Architecture","text":"
graph LR\n    subgraph \"Application Metrics\"\n        API[API<br/>:4000/api/metrics]\n        MEDIA[Media API<br/>:4100/metrics]\n    end\n\n    subgraph \"Infrastructure Metrics\"\n        CADVISOR[cAdvisor<br/>Container Stats]\n        NODE[Node Exporter<br/>Host Stats]\n        REDIS_EXP[Redis Exporter<br/>Redis Stats]\n    end\n\n    subgraph \"Monitoring Stack\"\n        PROM[Prometheus<br/>:9090]\n        GRAFANA[Grafana<br/>:3001]\n        ALERT[Alertmanager<br/>:9093]\n        GOTIFY[Gotify<br/>:8889]\n    end\n\n    API --> PROM\n    MEDIA --> PROM\n    CADVISOR --> PROM\n    NODE --> PROM\n    REDIS_EXP --> PROM\n\n    PROM --> GRAFANA\n    PROM --> ALERT\n    ALERT --> GOTIFY
"},{"location":"v2/deployment/monitoring-stack/#quick-start","title":"Quick Start","text":""},{"location":"v2/deployment/monitoring-stack/#enable-monitoring","title":"Enable Monitoring","text":"
# Start with monitoring profile\ndocker compose --profile monitoring up -d\n\n# Check services\ndocker compose ps | grep monitoring\n\n# Access dashboards\nopen http://localhost:3001  # Grafana (admin/admin)\nopen http://localhost:9090  # Prometheus\nopen http://localhost:9093  # Alertmanager\n
"},{"location":"v2/deployment/monitoring-stack/#prometheus-configuration","title":"Prometheus Configuration","text":""},{"location":"v2/deployment/monitoring-stack/#scrape-targets","title":"Scrape Targets","text":"

File: configs/prometheus/prometheus.yml

scrape_configs:\n  # V2 Unified API Metrics (10s interval)\n  - job_name: 'changemaker-v2-api'\n    static_configs:\n      - targets: ['changemaker-v2-api:4000']\n    metrics_path: '/api/metrics'\n    scrape_interval: 10s\n    scrape_timeout: 5s\n\n  # Redis Metrics (15s interval)\n  - job_name: 'redis'\n    static_configs:\n      - targets: ['redis-exporter:9121']\n    scrape_interval: 15s\n\n  # cAdvisor - Docker container metrics\n  - job_name: 'cadvisor'\n    static_configs:\n      - targets: ['cadvisor:8080']\n    scrape_interval: 15s\n\n  # Node Exporter - System metrics\n  - job_name: 'node'\n    static_configs:\n      - targets: ['node-exporter:9100']\n    scrape_interval: 15s\n\n  # Prometheus self-monitoring\n  - job_name: 'prometheus'\n    static_configs:\n      - targets: ['localhost:9090']\n\n  # Alertmanager monitoring\n  - job_name: 'alertmanager'\n    static_configs:\n      - targets: ['alertmanager:9093']\n    scrape_interval: 30s\n

Intervals: - 10s: API (real-time application metrics) - 15s: Infrastructure (host + containers + Redis) - 30s: Monitoring stack itself

"},{"location":"v2/deployment/monitoring-stack/#custom-metrics-cm_","title":"Custom Metrics (cm_*)","text":"

File: api/src/utils/metrics.ts

12 custom metrics for domain-specific monitoring:

Metric Type Labels Description cm_emails_sent_total Counter campaign_id Campaign emails sent successfully cm_emails_failed_total Counter campaign_id, error_type Failed email sends cm_email_queue_size Gauge - Current email queue size cm_email_send_duration_seconds Histogram - Email send latency cm_login_attempts_total Counter status Login attempts (success/failure) cm_active_sessions Gauge - Active refresh tokens cm_campaign_emails_total Counter campaign_id Total campaign emails created cm_response_submissions_total Counter - Response wall submissions cm_canvass_visits_total Counter outcome Canvass visits by outcome cm_active_canvass_sessions Gauge - Active canvass sessions cm_shift_signups_total Counter - Shift signups cm_external_service_up Gauge service External service health (1=up, 0=down)

HTTP metrics (standard prom-client): - http_requests_total - http_request_duration_seconds

Geocoding metrics: - cm_geocode_cache_hits_total - cm_geocode_cache_misses_total - cm_geocode_requests_total - cm_geocode_duration_seconds

Email template metrics: - cm_email_templates_updated_total - cm_email_test_sent_total - cm_email_template_rollback_total - cm_email_template_cache_hit/miss_total

Location query metrics: - cm_map_location_query_duration_seconds - cm_map_location_query_count_total - cm_map_location_result_count

"},{"location":"v2/deployment/monitoring-stack/#alert-rules","title":"Alert Rules","text":"

File: configs/prometheus/alerts.yml

12 alert rules across 4 groups:

"},{"location":"v2/deployment/monitoring-stack/#application-alerts","title":"Application Alerts","text":"
  1. ApplicationDown: API unreachable for 2 minutes
  2. HighErrorRate: >10% 5xx errors for 5 minutes
  3. EmailQueueBacklog: Queue size >100 for 10 minutes
  4. HighEmailFailureRate: >20% email failures for 10 minutes
  5. SuspiciousLoginActivity: >5 failed logins/sec for 2 minutes
  6. HighAPILatency: P95 latency >2s for 5 minutes
  7. ExternalServiceDown: External service unreachable for 5 minutes
"},{"location":"v2/deployment/monitoring-stack/#system-alerts","title":"System Alerts","text":"
  1. RedisDown: Redis unreachable for 1 minute
  2. DiskSpaceLow: <15% disk space for 5 minutes
  3. DiskSpaceCritical: <10% disk space for 2 minutes
  4. HighCPUUsage: >85% CPU for 10 minutes
  5. HighMemoryUsage: >85% memory for 10 minutes

Example Alert:

- alert: ApplicationDown\n  expr: up{job=\"changemaker-v2-api\"} == 0\n  for: 2m\n  labels:\n    severity: critical\n  annotations:\n    summary: \"V2 API is down\"\n    description: \"The Changemaker V2 API has been down for more than 2 minutes.\"\n

"},{"location":"v2/deployment/monitoring-stack/#data-retention","title":"Data Retention","text":"

docker-compose.yml:

prometheus:\n  command:\n    - '--storage.tsdb.retention.time=30d'  # 30 days\n

Disk usage: ~1-5GB for 30 days (depends on scrape frequency + cardinality).

Increase retention:

# Edit docker-compose.yml\n# Change to '--storage.tsdb.retention.time=90d'\n\n# Recreate container\ndocker compose --profile monitoring up -d --force-recreate prometheus\n

"},{"location":"v2/deployment/monitoring-stack/#grafana-configuration","title":"Grafana Configuration","text":""},{"location":"v2/deployment/monitoring-stack/#datasource","title":"Datasource","text":"

File: configs/grafana/datasources.yml

apiVersion: 1\n\ndatasources:\n  - name: Prometheus\n    type: prometheus\n    access: proxy\n    url: http://prometheus:9090\n    isDefault: true\n    editable: false\n

Auto-provisioned on Grafana startup.

"},{"location":"v2/deployment/monitoring-stack/#dashboards","title":"Dashboards","text":"

File: configs/grafana/dashboards.yml

apiVersion: 1\n\nproviders:\n  - name: 'Default'\n    folder: 'Changemaker Lite'\n    type: file\n    options:\n      path: /etc/grafana/provisioning/dashboards\n

3 pre-configured dashboards:

"},{"location":"v2/deployment/monitoring-stack/#1-application-overview","title":"1. Application Overview","text":"

File: configs/grafana/application-overview.json

Panels: - API uptime (last 24h) - Request rate (req/sec) - Error rate (%) - Email queue size - Active sessions - Campaign emails sent

Refresh: 10s

"},{"location":"v2/deployment/monitoring-stack/#2-api-performance","title":"2. API Performance","text":"

File: configs/grafana/api-performance.json

Panels: - Request latency (P50, P95, P99) - Requests by status code - Top 10 slowest endpoints - HTTP errors by route - Geocoding cache hit rate - Email send duration

Refresh: 30s

"},{"location":"v2/deployment/monitoring-stack/#3-system-health","title":"3. System Health","text":"

File: configs/grafana/system-health.json

Panels: - CPU usage (%) - Memory usage (%) - Disk space (GB free) - Network I/O (MB/s) - Container CPU throttling - Redis memory usage

Refresh: 1m

"},{"location":"v2/deployment/monitoring-stack/#first-login","title":"First Login","text":"
# Access Grafana\nopen http://localhost:3001\n\n# Default credentials\nUsername: admin\nPassword: admin\n\n# Change password on first login\n

Navigate: Dashboards \u2192 Changemaker Lite folder \u2192 Select dashboard

"},{"location":"v2/deployment/monitoring-stack/#alertmanager-configuration","title":"Alertmanager Configuration","text":""},{"location":"v2/deployment/monitoring-stack/#notification-receivers","title":"Notification Receivers","text":"

File: configs/alertmanager/alertmanager.yml

global:\n  resolve_timeout: 5m\n\nroute:\n  receiver: 'default'\n  group_by: ['alertname', 'severity']\n  group_wait: 30s\n  group_interval: 5m\n  repeat_interval: 4h\n\nreceivers:\n  - name: 'default'\n    # Email (example)\n    email_configs:\n      - to: 'admin@cmlite.org'\n        from: 'alerts@cmlite.org'\n        smarthost: 'smtp.example.com:587'\n        auth_username: 'alerts@cmlite.org'\n        auth_password: 'your-password'\n\n    # Slack (example)\n    slack_configs:\n      - api_url: 'https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK'\n        channel: '#alerts'\n        title: '{{ .GroupLabels.alertname }}'\n        text: '{{ range .Alerts }}{{ .Annotations.summary }}\\n{{ end }}'\n\n    # Gotify (push notifications)\n    webhook_configs:\n      - url: 'http://gotify:80/message?token=YOUR_GOTIFY_TOKEN'\n

Grouping: Combines similar alerts (prevents spam).

Repeat: Re-sends unresolved alerts every 4 hours.

"},{"location":"v2/deployment/monitoring-stack/#testing-alerts","title":"Testing Alerts","text":"

Manual test:

# Trigger test alert\ncurl -X POST http://localhost:9093/api/v1/alerts \\\n  -d '[{\n    \"labels\": {\"alertname\":\"TestAlert\",\"severity\":\"warning\"},\n    \"annotations\": {\"summary\":\"Test alert from curl\"}\n  }]'\n\n# Check Alertmanager UI\nopen http://localhost:9093\n

Force alert (stop API):

# Stop API (triggers ApplicationDown alert after 2m)\ndocker compose stop api\n\n# Check Prometheus alerts\nopen http://localhost:9090/alerts\n\n# Wait 2 minutes \u2192 Alert fires \u2192 Notification sent\n

"},{"location":"v2/deployment/monitoring-stack/#exporters","title":"Exporters","text":""},{"location":"v2/deployment/monitoring-stack/#cadvisor-container-metrics","title":"cAdvisor (Container Metrics)","text":"

Metrics: - CPU usage per container - Memory usage per container - Network I/O - Disk I/O

Access: http://localhost:8080

Configuration (docker-compose.yml):

cadvisor:\n  image: gcr.io/cadvisor/cadvisor:latest\n  container_name: cadvisor-changemaker\n  privileged: true  # Required for full access\n  volumes:\n    - /:/rootfs:ro\n    - /var/run:/var/run:ro\n    - /sys:/sys:ro\n    - /var/lib/docker/:/var/lib/docker:ro\n    - /dev/disk/:/dev/disk:ro\n  devices:\n    - /dev/kmsg\n

"},{"location":"v2/deployment/monitoring-stack/#node-exporter-host-metrics","title":"Node Exporter (Host Metrics)","text":"

Metrics: - CPU usage (all cores) - Memory usage (total, free, cached) - Disk usage (filesystem, mountpoints) - Network I/O (bytes, packets)

Access: http://localhost:9100/metrics

Configuration:

node-exporter:\n  command:\n    - '--path.rootfs=/host'\n    - '--path.procfs=/host/proc'\n    - '--path.sysfs=/host/sys'\n    - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'\n  volumes:\n    - /proc:/host/proc:ro\n    - /sys:/host/sys:ro\n    - /:/rootfs:ro\n

"},{"location":"v2/deployment/monitoring-stack/#redis-exporter","title":"Redis Exporter","text":"

Metrics: - Memory usage - Commands per second - Connected clients - Keyspace hits/misses - Evicted keys

Access: http://localhost:9121/metrics

Configuration:

redis-exporter:\n  environment:\n    - REDIS_ADDR=redis:6379\n    - REDIS_PASSWORD=${REDIS_PASSWORD}  # Authenticates with Redis\n

"},{"location":"v2/deployment/monitoring-stack/#gotify-push-notifications","title":"Gotify (Push Notifications)","text":"

Setup:

# Access Gotify UI\nopen http://localhost:8889\n\n# Login (default: admin/admin)\n\n# Create app \u2192 Copy token\n\n# Add to Alertmanager config:\nwebhook_configs:\n  - url: 'http://gotify:80/message?token=YOUR_TOKEN'\n

Mobile apps: Available for iOS/Android (receive push notifications).

"},{"location":"v2/deployment/monitoring-stack/#accessing-services","title":"Accessing Services","text":"Service URL Default Credentials Prometheus http://localhost:9090 None Grafana http://localhost:3001 admin / admin Alertmanager http://localhost:9093 None cAdvisor http://localhost:8080 None Node Exporter http://localhost:9100/metrics None Redis Exporter http://localhost:9121/metrics None Gotify http://localhost:8889 admin / admin"},{"location":"v2/deployment/monitoring-stack/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/monitoring-stack/#prometheus-not-scraping","title":"Prometheus Not Scraping","text":"

Symptoms: Missing data in Grafana dashboards

Diagnosis:

# Check Prometheus targets\nopen http://localhost:9090/targets\n\n# Look for errors (red) vs success (green)\n\n# Check API metrics endpoint\ncurl http://localhost:4000/api/metrics\n

Common causes: - API container not running - Wrong port in prometheus.yml - Network connectivity issue

Solution:

# Restart API\ndocker compose restart api\n\n# Reload Prometheus config\ndocker compose exec prometheus kill -HUP 1\n\n# Or restart Prometheus\ndocker compose restart prometheus\n

"},{"location":"v2/deployment/monitoring-stack/#grafana-dashboards-not-loading","title":"Grafana Dashboards Not Loading","text":"

Symptoms: Blank dashboards or \"No data\" errors

Diagnosis:

# Check Grafana logs\ndocker compose logs grafana | tail -50\n\n# Check datasource\nopen http://localhost:3001/datasources\n\n# Test Prometheus query\ncurl http://prometheus:9090/api/v1/query?query=up\n

Solution:

# Verify datasource URL\n# Should be http://prometheus:9090 (container name, not localhost)\n\n# Restart Grafana\ndocker compose restart grafana\n

"},{"location":"v2/deployment/monitoring-stack/#alerts-not-firing","title":"Alerts Not Firing","text":"

Symptoms: No notifications despite issues

Diagnosis:

# Check Prometheus alerts\nopen http://localhost:9090/alerts\n\n# Check Alertmanager\nopen http://localhost:9093\n\n# Verify alert rules loaded\ncurl http://localhost:9090/api/v1/rules\n

Solution:

# Reload Prometheus config\ndocker compose exec prometheus kill -HUP 1\n\n# Check alerts.yml syntax\ndocker compose exec prometheus promtool check rules /etc/prometheus/alerts.yml\n\n# Test notification receiver\ncurl -X POST http://localhost:9093/api/v1/alerts -d '[...]'\n

"},{"location":"v2/deployment/monitoring-stack/#production-best-practices","title":"Production Best Practices","text":""},{"location":"v2/deployment/monitoring-stack/#secure-grafana","title":"Secure Grafana","text":"

Change admin password:

# Via UI: Admin \u2192 Profile \u2192 Change Password\n\n# Via env var (docker-compose.yml):\nenvironment:\n  - GF_SECURITY_ADMIN_PASSWORD=<strong-password>\n

Disable signup:

environment:\n  - GF_USERS_ALLOW_SIGN_UP=false  # Already set\n

"},{"location":"v2/deployment/monitoring-stack/#alert-tuning","title":"Alert Tuning","text":"

Avoid false positives: Increase for duration in critical alerts.

Example (before):

- alert: DiskSpaceLow\n  expr: disk_free_percent < 15\n  for: 1m  # Too aggressive\n

Example (after):

- alert: DiskSpaceLow\n  expr: disk_free_percent < 15\n  for: 10m  # More reasonable\n

"},{"location":"v2/deployment/monitoring-stack/#external-storage-long-term","title":"External Storage (Long-Term)","text":"

Prometheus supports remote write to: - Thanos: Long-term storage (S3/GCS) - Cortex: Multi-tenant Prometheus - VictoriaMetrics: High-performance storage

Example (Thanos):

# prometheus.yml\nremote_write:\n  - url: \"http://thanos-receive:19291/api/v1/receive\"\n

"},{"location":"v2/deployment/monitoring-stack/#related-documentation","title":"Related Documentation","text":"
  • Docker Compose \u2014 Monitoring services configuration
  • Environment Variables \u2014 Monitoring env vars
  • API Reference \u2014 Custom metrics implementation
"},{"location":"v2/deployment/nginx/","title":"Nginx Reverse Proxy Configuration","text":""},{"location":"v2/deployment/nginx/#overview","title":"Overview","text":"

Nginx serves as the central reverse proxy for Changemaker Lite V2, routing traffic to 15+ backend services via subdomain-based routing. It handles SSL termination, security headers, static file serving, and WebSocket upgrades.

Key Responsibilities:

  • Subdomain Routing: api.cmlite.org, app.cmlite.org, db.cmlite.org, etc.
  • SSL/TLS Termination: Handles HTTPS certificates (Let's Encrypt, Cloudflare, or Pangolin)
  • Security Headers: CSP, HSTS, X-Frame-Options, Permissions-Policy
  • Proxy Pass: Forwards requests to backend Docker containers
  • Static File Serving: Serves admin GUI production builds + MkDocs site
  • WebSocket Support: Upgrades connections for n8n, MailHog, MkDocs live reload
  • Iframe Embedding: CSP policies allow admin to embed services (NocoDB, Gitea, etc.)

Architecture:

Internet \u2192 Nginx (:80, :443) \u2192 [Docker Internal Network]\n                                  \u251c\u2500 api:4000 (Express)\n                                  \u251c\u2500 media-api:4100 (Fastify)\n                                  \u251c\u2500 admin:3000 (Vite / static)\n                                  \u251c\u2500 nocodb:8080\n                                  \u251c\u2500 listmonk:9000\n                                  \u251c\u2500 gitea:3000\n                                  \u251c\u2500 n8n:5678\n                                  \u251c\u2500 mkdocs:8000\n                                  \u251c\u2500 code-server:8080\n                                  \u251c\u2500 mailhog:8025\n                                  \u251c\u2500 mini-qr:8080\n                                  \u251c\u2500 homepage:3000\n                                  \u251c\u2500 grafana:3000\n                                  \u2514\u2500 public-media:80\n
"},{"location":"v2/deployment/nginx/#architecture","title":"Architecture","text":"
graph LR\n    subgraph \"External Access\"\n        USER[User Browser]\n        TUNNEL[Pangolin Tunnel]\n    end\n\n    subgraph \"Nginx Proxy :80, :443\"\n        NGINX{Nginx<br/>Subdomain Router}\n    end\n\n    subgraph \"Backend Services (Docker Network)\"\n        API[api:4000<br/>Express]\n        MEDIA[media-api:4100<br/>Fastify]\n        ADMIN[admin:3000<br/>Vite]\n        NOCODB[nocodb:8080]\n        LISTMONK[listmonk:9000]\n        GITEA[gitea:3000]\n        N8N[n8n:5678]\n        MKDOCS[mkdocs:8000]\n        CODE[code-server:8080]\n    end\n\n    USER -->|HTTP/HTTPS| NGINX\n    TUNNEL -->|HTTP| NGINX\n\n    NGINX -->|api.cmlite.org| API\n    NGINX -->|api.cmlite.org/media| MEDIA\n    NGINX -->|app.cmlite.org| ADMIN\n    NGINX -->|db.cmlite.org| NOCODB\n    NGINX -->|listmonk.cmlite.org| LISTMONK\n    NGINX -->|git.cmlite.org| GITEA\n    NGINX -->|n8n.cmlite.org| N8N\n    NGINX -->|docs.cmlite.org| MKDOCS\n    NGINX -->|code.cmlite.org| CODE
"},{"location":"v2/deployment/nginx/#configuration-files","title":"Configuration Files","text":"

Nginx configuration split across multiple files:

File Purpose Type nginx/nginx.conf Global settings, gzip, security headers Main config nginx/conf.d/default.conf Localhost fallback, path-based routing Server block nginx/conf.d/api.conf API subdomain routing (Express + Fastify) Server block nginx/conf.d/services.conf Supporting service subdomains Server blocks (12+)

Configuration hierarchy:

nginx.conf\n\u251c\u2500 Global: worker_processes, events, http\n\u251c\u2500 Security headers (applied to all)\n\u251c\u2500 Gzip compression\n\u251c\u2500 Docker DNS resolver (127.0.0.11)\n\u2514\u2500 Include conf.d/*.conf\n    \u251c\u2500 default.conf (localhost)\n    \u251c\u2500 api.conf (api.cmlite.org)\n    \u2514\u2500 services.conf (all other subdomains)\n

"},{"location":"v2/deployment/nginx/#global-configuration-nginxconf","title":"Global Configuration (nginx.conf)","text":"

File: nginx/nginx.conf

"},{"location":"v2/deployment/nginx/#worker-configuration","title":"Worker Configuration","text":"
worker_processes auto;\nerror_log /var/log/nginx/error.log warn;\npid /var/run/nginx.pid;\n\nevents {\n    worker_connections 1024;\n}\n

Explanation: - worker_processes auto: Detects CPU cores (1 worker per core) - worker_connections 1024: Max 1024 concurrent connections per worker - Total capacity: auto \u00d7 1024 (e.g., 4 cores = 4096 connections)

"},{"location":"v2/deployment/nginx/#http-block","title":"HTTP Block","text":"
http {\n    include /etc/nginx/mime.types;\n    default_type application/octet-stream;\n\n    log_format main '$remote_addr - $remote_user [$time_local] \"$request\" '\n                    '$status $body_bytes_sent \"$http_referer\" '\n                    '\"$http_user_agent\" \"$http_x_forwarded_for\"';\n\n    access_log /var/log/nginx/access.log main;\n\n    sendfile on;\n    tcp_nopush on;\n    tcp_nodelay on;\n    keepalive_timeout 65;\n    types_hash_max_size 2048;\n    client_max_body_size 50m;  # Default max upload size\n\n    # Include server blocks\n    include /etc/nginx/conf.d/*.conf;\n}\n

Key Settings: - sendfile on: Optimized file serving (kernel-level copy) - tcp_nopush on: Sends HTTP headers in single packet - client_max_body_size 50m: Default upload limit (overridden per location)

"},{"location":"v2/deployment/nginx/#gzip-compression","title":"Gzip Compression","text":"
# Gzip compression\ngzip on;\ngzip_vary on;\ngzip_proxied any;\ngzip_comp_level 6;\ngzip_types text/plain text/css application/json application/javascript\n           text/xml application/xml application/xml+rss text/javascript\n           image/svg+xml;\n

Performance Impact: - CPU usage: Level 6 provides 80% compression with moderate CPU cost - Bandwidth savings: ~60-80% reduction for text/JSON responses - Excluded: Images, video (already compressed)

"},{"location":"v2/deployment/nginx/#security-headers","title":"Security Headers","text":"
# Security headers (applied globally)\nadd_header X-Content-Type-Options \"nosniff\" always;\nadd_header X-XSS-Protection \"1; mode=block\" always;\nadd_header Referrer-Policy \"strict-origin-when-cross-origin\" always;\nadd_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\nadd_header Permissions-Policy \"geolocation=(self), microphone=(), camera=()\" always;\n

Header Explanation: - X-Content-Type-Options: Prevents MIME sniffing attacks - X-XSS-Protection: Enables browser XSS filter (legacy browsers) - Referrer-Policy: Controls referer header sent to external sites - HSTS: Forces HTTPS for 1 year (31536000 seconds) - Permissions-Policy: Restricts geolocation/media access

Note: X-Frame-Options set per server block (not global).

"},{"location":"v2/deployment/nginx/#docker-dns-resolver","title":"Docker DNS Resolver","text":"
# Docker internal DNS \u2014 enables runtime resolution\nresolver 127.0.0.11 valid=30s;\n

Purpose: Docker's embedded DNS server at 127.0.0.11 resolves container names.

Why needed: Allows Nginx to start even when optional services are down. Without this, Nginx fails to start if any upstream is missing.

Usage pattern:

location / {\n    set $upstream_api http://changemaker-v2-api:4000;\n    proxy_pass $upstream_api;  # Resolves at request time, not config parse\n}\n

Alternative (fails if container missing):

proxy_pass http://changemaker-v2-api:4000;  # Resolved at config parse \u2014 fails if down\n

"},{"location":"v2/deployment/nginx/#subdomain-routing","title":"Subdomain Routing","text":""},{"location":"v2/deployment/nginx/#default-server-localhost","title":"Default Server (localhost)","text":"

File: nginx/conf.d/default.conf

server {\n    listen 80 default_server;\n    server_name localhost _;\n    add_header X-Frame-Options \"SAMEORIGIN\" always;\n\n    # Admin GUI (default)\n    location / {\n        set $upstream_admin http://changemaker-v2-admin:3000;\n        proxy_pass $upstream_admin;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n    }\n\n    # Media API (must come BEFORE /api/ for longest prefix match)\n    location /api/media/ {\n        set $upstream_media http://changemaker-media-api:4100;\n        proxy_pass $upstream_media;\n        # ... (proxy headers)\n\n        # Large upload support\n        client_max_body_size 10G;\n        proxy_read_timeout 3600s;\n        proxy_connect_timeout 75s;\n        proxy_request_buffering off;\n    }\n\n    # API (Express)\n    location /api/ {\n        set $upstream_api http://changemaker-v2-api:4000;\n        proxy_pass $upstream_api;\n        # ... (proxy headers)\n    }\n\n    # Public Media Gallery\n    location /gallery/ {\n        proxy_pass http://changemaker-public-media:80/;\n        # ... (proxy headers)\n    }\n}\n

Routing Logic: 1. Request to http://localhost/api/media/videos \u2192 media-api:4100 2. Request to http://localhost/api/campaigns \u2192 api:4000 3. Request to http://localhost/ \u2192 admin:3000 4. Request to http://localhost/gallery/ \u2192 public-media:80

Important: /api/media/ location must come before /api/ in config file (longest prefix match).

"},{"location":"v2/deployment/nginx/#api-subdomain-apicmliteorg","title":"API Subdomain (api.cmlite.org)","text":"

File: nginx/conf.d/api.conf

server {\n    listen 80;\n    server_name api.cmlite.org;\n    add_header X-Frame-Options \"SAMEORIGIN\" always;\n\n    # Media API endpoints (must come BEFORE / for longest prefix match)\n    location /media/ {\n        set $upstream_media http://changemaker-media-api:4100/api/;\n        proxy_pass $upstream_media;\n        # ... (proxy headers)\n\n        # Large upload support\n        client_max_body_size 10G;\n        proxy_read_timeout 3600s;\n        proxy_connect_timeout 75s;\n        proxy_request_buffering off;\n\n        # WebSocket support\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n    }\n\n    # Main API (Express)\n    location / {\n        set $upstream_api http://changemaker-v2-api:4000;\n        proxy_pass $upstream_api;\n        # ... (proxy headers)\n        proxy_read_timeout 300s;\n        proxy_connect_timeout 75s;\n    }\n}\n

URL Mapping: - http://api.cmlite.org/media/videos \u2192 http://changemaker-media-api:4100/api/videos - http://api.cmlite.org/auth/login \u2192 http://changemaker-v2-api:4000/auth/login

Critical: Media API location includes /api/ in proxy_pass to rewrite path.

"},{"location":"v2/deployment/nginx/#service-subdomains","title":"Service Subdomains","text":"

File: nginx/conf.d/services.conf

"},{"location":"v2/deployment/nginx/#gitea-gitcmliteorg","title":"Gitea (git.cmlite.org)","text":"
server {\n    listen 80;\n    server_name git.cmlite.org;\n    add_header Content-Security-Policy \"frame-ancestors 'self' app.cmlite.org\" always;\n\n    # Increase max body size for large git pushes (2GB)\n    client_max_body_size 2048M;\n\n    location / {\n        set $upstream_gitea http://gitea-changemaker:3000;\n        proxy_pass $upstream_gitea;\n        proxy_hide_header X-Frame-Options;  # Allow iframe embedding\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n

Key Features: - CSP frame-ancestors: Allows embedding in app.cmlite.org (admin GUI) - proxy_hide_header X-Frame-Options: Strips Gitea's default DENY policy - 2GB upload limit: For large repository pushes

"},{"location":"v2/deployment/nginx/#n8n-n8ncmliteorg","title":"n8n (n8n.cmlite.org)","text":"
server {\n    listen 80;\n    server_name n8n.cmlite.org;\n    add_header Content-Security-Policy \"frame-ancestors 'self' app.cmlite.org\" always;\n\n    location / {\n        set $upstream_n8n http://n8n-changemaker:5678;\n        proxy_pass $upstream_n8n;\n        proxy_hide_header X-Frame-Options;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";  # WebSocket support\n    }\n}\n

WebSocket Headers: - Upgrade: $http_upgrade: Passes WebSocket upgrade header - Connection: \"upgrade\": Indicates protocol upgrade

Required for: n8n workflow editor, MailHog live updates, MkDocs live reload

"},{"location":"v2/deployment/nginx/#nocodb-dbcmliteorg","title":"NocoDB (db.cmlite.org)","text":"
server {\n    listen 80;\n    server_name db.cmlite.org;\n    add_header Content-Security-Policy \"frame-ancestors 'self' app.cmlite.org\" always;\n\n    location / {\n        set $upstream_nocodb http://changemaker-v2-nocodb:8080;\n        proxy_pass $upstream_nocodb;\n        proxy_hide_header X-Frame-Options;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n

Iframe Embedding: - frame-ancestors 'self' app.cmlite.org: Allows admin GUI to embed NocoDB - proxy_hide_header X-Frame-Options: Removes NocoDB's default SAMEORIGIN policy

"},{"location":"v2/deployment/nginx/#mkdocs-docscmliteorg","title":"MkDocs (docs.cmlite.org)","text":"
server {\n    listen 80;\n    server_name docs.cmlite.org;\n    add_header Content-Security-Policy \"frame-ancestors 'self' app.cmlite.org\" always;\n\n    location / {\n        set $upstream_mkdocs http://mkdocs-changemaker:8000;\n        proxy_pass $upstream_mkdocs;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";  # Live reload WebSocket\n    }\n}\n

Live Reload: MkDocs Material theme uses WebSocket for live reload during development.

"},{"location":"v2/deployment/nginx/#code-server-codecmliteorg","title":"Code Server (code.cmlite.org)","text":"
server {\n    listen 80;\n    server_name code.cmlite.org;\n    add_header Content-Security-Policy \"frame-ancestors 'self' app.cmlite.org\" always;\n\n    location / {\n        set $upstream_code http://code-server-changemaker:8080;\n        proxy_pass $upstream_code;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";  # VS Code WebSocket\n    }\n}\n

WebSocket Usage: Code Server uses WebSockets for terminal, file watching, language server.

"},{"location":"v2/deployment/nginx/#mailhog-mailcmliteorg","title":"MailHog (mail.cmlite.org)","text":"
server {\n    listen 80;\n    server_name mail.cmlite.org;\n    add_header Content-Security-Policy \"frame-ancestors 'self' app.cmlite.org\" always;\n\n    location / {\n        set $upstream_mailhog http://mailhog-changemaker:8025;\n        proxy_pass $upstream_mailhog;\n        proxy_hide_header X-Frame-Options;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        # WebSocket support for MailHog live updates\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n    }\n}\n

Live Updates: MailHog uses WebSocket to push new emails to browser without polling.

"},{"location":"v2/deployment/nginx/#listmonk-listmonkcmliteorg","title":"Listmonk (listmonk.cmlite.org)","text":"
server {\n    listen 80;\n    server_name listmonk.cmlite.org;\n    add_header X-Frame-Options \"SAMEORIGIN\" always;\n\n    location / {\n        set $upstream_listmonk http://listmonk-app:9000;\n        proxy_pass $upstream_listmonk;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n

No Iframe: Listmonk not embedded in admin (accessed directly), so SAMEORIGIN policy kept.

"},{"location":"v2/deployment/nginx/#grafana-grafanacmliteorg","title":"Grafana (grafana.cmlite.org)","text":"
server {\n    listen 80;\n    server_name grafana.cmlite.org;\n    add_header X-Frame-Options \"SAMEORIGIN\" always;\n\n    location / {\n        set $upstream_grafana http://grafana-changemaker:3000;\n        proxy_pass $upstream_grafana;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";  # Grafana live updates\n    }\n}\n

WebSocket: Grafana uses WebSocket for live dashboard updates.

"},{"location":"v2/deployment/nginx/#mini-qr-qrcmliteorg","title":"Mini QR (qr.cmlite.org)","text":"
server {\n    listen 80;\n    server_name qr.cmlite.org;\n    add_header Content-Security-Policy \"frame-ancestors 'self' app.cmlite.org\" always;\n\n    location / {\n        set $upstream_miniqr http://mini-qr:8080;\n        proxy_pass $upstream_miniqr;\n        proxy_hide_header X-Frame-Options;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n

Iframe Embedding: Admin GUI embeds Mini QR for walk sheet previews.

"},{"location":"v2/deployment/nginx/#root-domain-cmliteorg","title":"Root Domain (cmlite.org)","text":"
server {\n    listen 80;\n    server_name cmlite.org;\n\n    location / {\n        set $upstream_site http://mkdocs-site-server-changemaker:80;\n        proxy_pass $upstream_site;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n

Purpose: Serves MkDocs static site (production build) on root domain.

"},{"location":"v2/deployment/nginx/#homepage-homecmliteorg","title":"Homepage (home.cmlite.org)","text":"
server {\n    listen 80;\n    server_name home.cmlite.org;\n    add_header X-Frame-Options \"SAMEORIGIN\" always;\n\n    location / {\n        set $upstream_homepage http://homepage-changemaker:3000;\n        proxy_pass $upstream_homepage;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n

Dashboard: Service status dashboard with Docker integration.

"},{"location":"v2/deployment/nginx/#embed-proxy-ports","title":"Embed Proxy Ports","text":"

Purpose: Allow admin GUI to iframe services via localhost ports (bypassing subdomain requirements).

Ports: 8881-8885 (NocoDB, n8n, Gitea, MailHog, Mini QR)

Configuration (in services.conf):

# NocoDB embed proxy (port 8881)\nserver {\n    listen 8881;\n    location / {\n        set $upstream_nocodb http://changemaker-v2-nocodb:8080;\n        proxy_pass $upstream_nocodb;\n        proxy_hide_header X-Frame-Options;\n        proxy_hide_header Content-Security-Policy;  # Strip all frame restrictions\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n\n# n8n embed proxy (port 8882)\nserver {\n    listen 8882;\n    location / {\n        set $upstream_n8n http://n8n-changemaker:5678;\n        proxy_pass $upstream_n8n;\n        proxy_hide_header X-Frame-Options;\n        proxy_hide_header Content-Security-Policy;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n    }\n}\n\n# Gitea embed proxy (port 8883)\nserver {\n    listen 8883;\n    client_max_body_size 2048M;  # Large git pushes\n    location / {\n        set $upstream_gitea http://gitea-changemaker:3000;\n        proxy_pass $upstream_gitea;\n        proxy_hide_header X-Frame-Options;\n        proxy_hide_header Content-Security-Policy;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n\n# MailHog embed proxy (port 8884)\nserver {\n    listen 8884;\n    location / {\n        set $upstream_mailhog http://mailhog-changemaker:8025;\n        proxy_pass $upstream_mailhog;\n        proxy_hide_header X-Frame-Options;\n        proxy_hide_header Content-Security-Policy;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n    }\n}\n\n# Mini QR embed proxy (port 8885)\nserver {\n    listen 8885;\n    location / {\n        set $upstream_miniqr http://mini-qr:8080;\n        proxy_pass $upstream_miniqr;\n        proxy_hide_header X-Frame-Options;\n        proxy_hide_header Content-Security-Policy;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n

Usage in Admin GUI:

<iframe src=\"http://localhost:8881\" />  {/* NocoDB */}\n<iframe src=\"http://localhost:8882\" />  {/* n8n */}\n<iframe src=\"http://localhost:8883\" />  {/* Gitea */}\n<iframe src=\"http://localhost:8884\" />  {/* MailHog */}\n<iframe src=\"http://localhost:8885\" />  {/* Mini QR */}\n

Exposed in docker-compose.yml:

nginx:\n  ports:\n    - \"80:80\"\n    - \"443:443\"\n    - \"8881:8881\"  # NocoDB\n    - \"8882:8882\"  # n8n\n    - \"8883:8883\"  # Gitea\n    - \"8884:8884\"  # MailHog\n    - \"8885:8885\"  # Mini QR\n

"},{"location":"v2/deployment/nginx/#proxy-configuration","title":"Proxy Configuration","text":""},{"location":"v2/deployment/nginx/#standard-proxy-headers","title":"Standard Proxy Headers","text":"

All proxy locations should include:

proxy_set_header Host $host;\nproxy_set_header X-Real-IP $remote_addr;\nproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\nproxy_set_header X-Forwarded-Proto $scheme;\n

Header Explanation: - Host: Preserves original hostname (e.g., api.cmlite.org) - X-Real-IP: Client's IP address - X-Forwarded-For: Chain of proxy IPs (adds to existing list) - X-Forwarded-Proto: HTTP or HTTPS (used by backend for redirect logic)

"},{"location":"v2/deployment/nginx/#websocket-upgrade","title":"WebSocket Upgrade","text":"

Required for: n8n, MailHog, MkDocs, Code Server, Grafana

proxy_set_header Upgrade $http_upgrade;\nproxy_set_header Connection \"upgrade\";\n

Explanation: - Upgrade: websocket: Browser requests protocol upgrade - Connection: upgrade: Indicates connection will persist

Without these headers: WebSocket connections fail with 400 Bad Request.

"},{"location":"v2/deployment/nginx/#timeouts","title":"Timeouts","text":"

Default timeouts:

proxy_read_timeout 300s;     # 5 minutes\nproxy_connect_timeout 75s;   # 75 seconds\n

Media API timeouts (video uploads):

proxy_read_timeout 3600s;    # 1 hour\nproxy_connect_timeout 75s;\n

Why longer: FFprobe video analysis + large file uploads take time.

"},{"location":"v2/deployment/nginx/#upload-size-limits","title":"Upload Size Limits","text":"

Global default (nginx.conf):

client_max_body_size 50m;\n

Per-location overrides: - Media API: client_max_body_size 10G; (video uploads) - Gitea: client_max_body_size 2048M; (large git pushes)

"},{"location":"v2/deployment/nginx/#request-buffering","title":"Request Buffering","text":"

Media API (disable buffering for streaming uploads):

proxy_request_buffering off;\n

Effect: Nginx streams request body directly to backend (no temp file).

Benefits: - Lower disk I/O on Nginx server - Faster upload start time - Reduced memory usage

Trade-off: Backend must handle slow clients (Fastify multipart does this).

"},{"location":"v2/deployment/nginx/#ssltls-configuration","title":"SSL/TLS Configuration","text":""},{"location":"v2/deployment/nginx/#certificate-paths","title":"Certificate Paths","text":"

Recommended structure:

/etc/letsencrypt/live/cmlite.org/\n\u251c\u2500 fullchain.pem  (certificate + intermediate)\n\u251c\u2500 privkey.pem    (private key)\n\u2514\u2500 chain.pem      (intermediate CA)\n

Nginx SSL block:

server {\n    listen 443 ssl http2;\n    server_name api.cmlite.org;\n\n    ssl_certificate /etc/letsencrypt/live/cmlite.org/fullchain.pem;\n    ssl_certificate_key /etc/letsencrypt/live/cmlite.org/privkey.pem;\n\n    # Strong TLS configuration\n    ssl_protocols TLSv1.2 TLSv1.3;\n    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';\n    ssl_prefer_server_ciphers on;\n    ssl_session_cache shared:SSL:10m;\n    ssl_session_timeout 10m;\n\n    # ... location blocks\n}\n

"},{"location":"v2/deployment/nginx/#http-to-https-redirect","title":"HTTP to HTTPS Redirect","text":"
server {\n    listen 80;\n    server_name api.cmlite.org;\n\n    # Redirect all HTTP to HTTPS\n    return 301 https://$host$request_uri;\n}\n\nserver {\n    listen 443 ssl http2;\n    server_name api.cmlite.org;\n    # ... SSL config + locations\n}\n
"},{"location":"v2/deployment/nginx/#hsts-header","title":"HSTS Header","text":"

Already applied globally (in nginx.conf):

add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n

Effect: Browser caches HTTPS requirement for 1 year.

Important: Only enable after verifying HTTPS works (can't easily undo).

"},{"location":"v2/deployment/nginx/#wildcard-certificates","title":"Wildcard Certificates","text":"

For *.cmlite.org (Let's Encrypt DNS challenge):

certbot certonly --dns-cloudflare \\\n  --dns-cloudflare-credentials ~/.secrets/cloudflare.ini \\\n  -d cmlite.org -d \"*.cmlite.org\"\n

Single cert covers all subdomains: - api.cmlite.org - app.cmlite.org - db.cmlite.org - etc.

See SSL/TLS for complete certificate management.

"},{"location":"v2/deployment/nginx/#static-file-serving","title":"Static File Serving","text":""},{"location":"v2/deployment/nginx/#admin-gui-production-build","title":"Admin GUI Production Build","text":"

Dockerfile multi-stage build (admin/Dockerfile):

# Build stage\nFROM node:20-alpine AS builder\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nRUN npm run build\n\n# Production stage\nFROM nginx:alpine\nCOPY --from=builder /app/dist /usr/share/nginx/html\nCOPY nginx.conf /etc/nginx/nginx.conf\nEXPOSE 80\nCMD [\"nginx\", \"-g\", \"daemon off;\"]\n

Nginx serves static files (no Node.js in production):

server {\n    listen 80;\n    server_name app.cmlite.org;\n\n    root /usr/share/nginx/html;\n    index index.html;\n\n    # React Router support (all routes \u2192 index.html)\n    location / {\n        try_files $uri $uri/ /index.html;\n    }\n\n    # API proxy\n    location /api/ {\n        proxy_pass http://changemaker-v2-api:4000;\n        # ... proxy headers\n    }\n\n    # Cache static assets\n    location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {\n        expires 1y;\n        add_header Cache-Control \"public, immutable\";\n    }\n}\n

"},{"location":"v2/deployment/nginx/#mkdocs-static-site","title":"MkDocs Static Site","text":"

Build process (via admin GUI or CLI):

docker compose exec mkdocs mkdocs build\n

Output: mkdocs/site/ directory with static HTML

Served by mkdocs-site-server (Nginx Alpine container):

mkdocs-site-server:\n  image: lscr.io/linuxserver/nginx:latest\n  volumes:\n    - ./mkdocs/site:/config/www\n  ports:\n    - \"4004:80\"\n

Nginx config (in configs/mkdocs-site/default.conf):

server {\n    listen 80;\n    root /config/www;\n    index index.html;\n\n    location / {\n        try_files $uri $uri/ =404;\n    }\n}\n

"},{"location":"v2/deployment/nginx/#performance-optimization","title":"Performance Optimization","text":""},{"location":"v2/deployment/nginx/#gzip-compression_1","title":"Gzip Compression","text":"

Already enabled globally (see nginx.conf above).

Compression ratio: - JSON responses: ~75% reduction - HTML/CSS/JS: ~60-70% reduction - Images/video: No compression (already compressed)

Trade-off: Slight CPU increase (~5-10%) for bandwidth savings.

"},{"location":"v2/deployment/nginx/#caching-static-assets","title":"Caching Static Assets","text":"
location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {\n    expires 1y;\n    add_header Cache-Control \"public, immutable\";\n}\n

Effect: Browsers cache static assets for 1 year.

Caveat: Use content hashing in filenames (Vite does this automatically).

"},{"location":"v2/deployment/nginx/#proxy-caching","title":"Proxy Caching","text":"

Optional (not enabled by default):

# In http block\nproxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m max_size=1g inactive=60m;\n\n# In location block\nlocation /api/campaigns {\n    proxy_cache api_cache;\n    proxy_cache_valid 200 10m;\n    proxy_cache_key \"$scheme$request_method$host$request_uri\";\n    proxy_pass http://changemaker-v2-api:4000;\n}\n

Use cases: - Public campaign listing (10-minute cache) - Public map data (5-minute cache) - Representative lookup (1-hour cache)

Avoid caching: - Authenticated endpoints - POST/PUT/DELETE requests - Real-time data (canvass sessions, email queue)

"},{"location":"v2/deployment/nginx/#connection-pooling","title":"Connection Pooling","text":"

Keep-alive to backends:

upstream api {\n    server changemaker-v2-api:4000;\n    keepalive 32;  # Maintain 32 idle connections\n}\n\nlocation /api/ {\n    proxy_pass http://api;\n    proxy_http_version 1.1;\n    proxy_set_header Connection \"\";  # Clear close header\n}\n

Benefits: - Reduced latency (no TCP handshake) - Lower CPU (fewer connection setups) - Better throughput under load

"},{"location":"v2/deployment/nginx/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/nginx/#502-bad-gateway","title":"502 Bad Gateway","text":"

Symptoms: 502 Bad Gateway error

Causes: 1. Backend container not running 2. Backend healthcheck failing 3. Backend listening on wrong port 4. Network connectivity issue

Diagnosis:

# Check backend status\ndocker compose ps api\n\n# Check backend logs\ndocker compose logs --tail=50 api\n\n# Test backend directly\ndocker compose exec nginx curl http://changemaker-v2-api:4000/api/health\n\n# Check Nginx error log\ndocker compose exec nginx cat /var/log/nginx/error.log\n

Solution:

# Restart backend\ndocker compose restart api\n\n# Check healthcheck\ndocker inspect changemaker-v2-api | jq '.[0].State.Health'\n\n# Verify port in docker-compose.yml\ngrep -A5 \"api:\" docker-compose.yml\n

"},{"location":"v2/deployment/nginx/#504-gateway-timeout","title":"504 Gateway Timeout","text":"

Symptoms: Request times out after 60 seconds

Cause: Backend processing too slow, proxy timeout too short

Solution:

# Increase timeout for slow endpoints\nlocation /api/locations/geocode {\n    proxy_read_timeout 300s;  # 5 minutes\n    proxy_pass http://changemaker-v2-api:4000;\n}\n

"},{"location":"v2/deployment/nginx/#ssl-certificate-errors","title":"SSL Certificate Errors","text":"

Symptoms: SSL_ERROR_RX_RECORD_TOO_LONG or ERR_SSL_PROTOCOL_ERROR

Cause: Accessing HTTPS port via HTTP or vice versa

Diagnosis:

# Test HTTPS\ncurl -I https://api.cmlite.org\n\n# Check certificate\nopenssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org\n\n# Verify Nginx config\ndocker compose exec nginx nginx -t\n

Solution:

# Reload Nginx after cert renewal\ndocker compose exec nginx nginx -s reload\n\n# Check cert paths in config\ngrep ssl_certificate /path/to/nginx/conf.d/*.conf\n

"},{"location":"v2/deployment/nginx/#cors-errors","title":"CORS Errors","text":"

Symptoms: Browser console shows CORS policy: No 'Access-Control-Allow-Origin' header

Cause: Backend not setting CORS headers

Diagnosis:

# Test from browser console\nfetch('http://api.cmlite.org/api/campaigns')\n\n# Check response headers\ncurl -H \"Origin: http://example.com\" -I http://api.cmlite.org/api/campaigns\n

Solution: CORS headers set by backend (not Nginx). Check api/src/server.ts:

app.use(cors({\n  origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'],\n  credentials: true,\n}));\n

Nginx passthrough (don't modify CORS headers):

# DO NOT add these in Nginx (backend handles CORS)\n# add_header Access-Control-Allow-Origin \"*\";  # \u274c WRONG\n

"},{"location":"v2/deployment/nginx/#websocket-connection-failures","title":"WebSocket Connection Failures","text":"

Symptoms: WebSocket upgrade fails with 400 Bad Request

Cause: Missing Upgrade/Connection headers

Diagnosis:

# Check Nginx config\ngrep -A5 \"Upgrade\" nginx/conf.d/services.conf\n\n# Test WebSocket\nwscat -c ws://localhost:5678\n

Solution:

# Add to location block\nproxy_set_header Upgrade $http_upgrade;\nproxy_set_header Connection \"upgrade\";\n

"},{"location":"v2/deployment/nginx/#large-upload-failures","title":"Large Upload Failures","text":"

Symptoms: Upload fails with 413 Request Entity Too Large

Cause: client_max_body_size too small

Solution:

# Increase limit for specific location\nlocation /api/media/videos {\n    client_max_body_size 10G;\n    proxy_pass http://changemaker-media-api:4100;\n}\n

"},{"location":"v2/deployment/nginx/#iframe-not-displaying","title":"Iframe Not Displaying","text":"

Symptoms: Service loads in new tab but not in iframe

Cause: X-Frame-Options: DENY or CSP frame-ancestors blocking

Diagnosis:

# Check response headers\ncurl -I http://db.cmlite.org\n\n# Look for X-Frame-Options or Content-Security-Policy\n

Solution:

# Hide backend's X-Frame-Options\nproxy_hide_header X-Frame-Options;\n\n# Add CSP allowing admin\nadd_header Content-Security-Policy \"frame-ancestors 'self' app.cmlite.org\" always;\n

"},{"location":"v2/deployment/nginx/#nginx-wont-start","title":"Nginx Won't Start","text":"

Symptoms: docker compose up fails with Nginx error

Diagnosis:

# Test config syntax\ndocker compose run --rm nginx nginx -t\n\n# Check for duplicate server_name\ngrep server_name nginx/conf.d/*.conf | sort\n\n# Check for port conflicts\ndocker compose config | grep -A2 \"ports:\"\n

Common mistakes: - Missing semicolon - Duplicate server_name (same subdomain in multiple files) - Invalid regex in location - Unclosed { bracket

"},{"location":"v2/deployment/nginx/#production-best-practices","title":"Production Best Practices","text":""},{"location":"v2/deployment/nginx/#rate-limiting","title":"Rate Limiting","text":"

Limit requests per IP (prevents abuse):

# In http block\nlimit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;\n\n# In location block\nlocation /api/ {\n    limit_req zone=api_limit burst=20 nodelay;\n    proxy_pass http://changemaker-v2-api:4000;\n}\n

Explanation: - rate=10r/s: 10 requests per second average - burst=20: Allow bursts up to 20 requests - nodelay: Process burst immediately (don't queue)

"},{"location":"v2/deployment/nginx/#security-headers-review","title":"Security Headers Review","text":"

Production checklist: - [x] HSTS enabled (max-age=31536000) - [x] X-Content-Type-Options: nosniff - [x] X-XSS-Protection: 1; mode=block - [x] CSP frame-ancestors for embeddable services - [x] X-Frame-Options: SAMEORIGIN for non-embedded services - [x] Referrer-Policy: strict-origin-when-cross-origin - [x] Permissions-Policy restricts sensors

Optional enhancements:

# Stricter CSP\nadd_header Content-Security-Policy \"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';\" always;\n\n# Expect-CT (certificate transparency)\nadd_header Expect-CT \"max-age=86400, enforce\" always;\n

"},{"location":"v2/deployment/nginx/#access-logging","title":"Access Logging","text":"

Production log format (JSON for parsing):

log_format json_combined escape=json\n  '{'\n    '\"time_local\":\"$time_local\",'\n    '\"remote_addr\":\"$remote_addr\",'\n    '\"request\":\"$request\",'\n    '\"status\": $status,'\n    '\"body_bytes_sent\":$body_bytes_sent,'\n    '\"request_time\":$request_time,'\n    '\"http_referrer\":\"$http_referer\",'\n    '\"http_user_agent\":\"$http_user_agent\"'\n  '}';\n\naccess_log /var/log/nginx/access.log json_combined;\n

Benefits: Easy parsing with tools like jq, Logstash, Loki.

"},{"location":"v2/deployment/nginx/#error-page-customization","title":"Error Page Customization","text":"

Custom error pages:

error_page 404 /404.html;\nerror_page 500 502 503 504 /50x.html;\n\nlocation = /404.html {\n    root /usr/share/nginx/html;\n    internal;\n}\n\nlocation = /50x.html {\n    root /usr/share/nginx/html;\n    internal;\n}\n

Create files:

cat > nginx/html/404.html <<EOF\n<!DOCTYPE html>\n<html>\n<head><title>404 Not Found</title></head>\n<body>\n<h1>404 - Page Not Found</h1>\n<p>Return to <a href=\"/\">homepage</a></p>\n</body>\n</html>\nEOF\n

"},{"location":"v2/deployment/nginx/#related-documentation","title":"Related Documentation","text":"
  • Docker Compose \u2014 Container orchestration
  • Environment Variables \u2014 Configuration reference
  • SSL/TLS \u2014 Certificate management
  • Tunneling \u2014 Pangolin tunnel setup
  • Scaling \u2014 Load balancing strategies
"},{"location":"v2/deployment/scaling/","title":"Horizontal Scaling Strategies","text":""},{"location":"v2/deployment/scaling/#overview","title":"Overview","text":"

Changemaker Lite V2 can scale horizontally to handle increased traffic and data volume. This guide covers strategies for scaling each component.

When to Scale: - API response time >500ms (P95) - CPU usage >70% sustained - Memory usage >80% sustained - Database connection pool exhausted - Job queue backing up (>100 jobs waiting)

"},{"location":"v2/deployment/scaling/#database-scaling","title":"Database Scaling","text":""},{"location":"v2/deployment/scaling/#read-replicas","title":"Read Replicas","text":"

PostgreSQL streaming replication for read-heavy workloads.

Setup (docker-compose.yml):

v2-postgres-replica:\n  image: postgres:16-alpine\n  container_name: changemaker-v2-postgres-replica\n  environment:\n    POSTGRES_USER: replicator\n    POSTGRES_PASSWORD: ${REPLICA_PASSWORD}\n  command: |\n    postgres -c wal_level=replica\n             -c hot_standby=on\n             -c max_wal_senders=3\n             -c hot_standby_feedback=on\n  volumes:\n    - v2-postgres-replica-data:/var/lib/postgresql/data\n

Primary config (postgresql.conf):

wal_level = replica\nmax_wal_senders = 3\nwal_keep_size = 64MB\n

Replication user:

CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'replica-password';\n

Prisma read replica (planned feature):

// Future: Prisma read replicas\nconst prisma = new PrismaClient({\n  datasources: {\n    db: {\n      url: process.env.DATABASE_URL,           // Primary (writes)\n      replicaUrl: process.env.REPLICA_URL,     // Replica (reads)\n    },\n  },\n});\n

"},{"location":"v2/deployment/scaling/#connection-pooling","title":"Connection Pooling","text":"

PgBouncer for connection pooling.

docker-compose.yml:

pgbouncer:\n  image: pgbouncer/pgbouncer:latest\n  container_name: pgbouncer-changemaker\n  environment:\n    DATABASES_HOST: changemaker-v2-postgres\n    DATABASES_PORT: 5432\n    DATABASES_USER: changemaker\n    DATABASES_PASSWORD: ${V2_POSTGRES_PASSWORD}\n    DATABASES_DBNAME: changemaker_v2\n    POOL_MODE: transaction\n    MAX_CLIENT_CONN: 1000\n    DEFAULT_POOL_SIZE: 20\n  ports:\n    - \"6432:6432\"\n

Update DATABASE_URL:

# Before (direct)\nDATABASE_URL=postgresql://changemaker:pass@changemaker-v2-postgres:5432/changemaker_v2\n\n# After (pooled)\nDATABASE_URL=postgresql://changemaker:pass@pgbouncer:6432/changemaker_v2\n

Benefits: - Handles 1000+ client connections with only 20 PostgreSQL connections - Reduces connection overhead - Prevents \"too many connections\" errors

"},{"location":"v2/deployment/scaling/#api-scaling","title":"API Scaling","text":""},{"location":"v2/deployment/scaling/#multiple-api-containers","title":"Multiple API Containers","text":"

docker-compose.yml:

api:\n  # ... existing config\n  deploy:\n    replicas: 3  # Run 3 API containers\n

Or manual scaling:

docker compose up -d --scale api=3\n

Load balancer (Nginx upstream):

upstream api_backend {\n    least_conn;  # Load balancing algorithm\n    server changemaker-v2-api-1:4000;\n    server changemaker-v2-api-2:4000;\n    server changemaker-v2-api-3:4000;\n}\n\nserver {\n    location /api/ {\n        proxy_pass http://api_backend;\n    }\n}\n

Session affinity (sticky sessions):

upstream api_backend {\n    ip_hash;  # Route same IP to same backend\n    server changemaker-v2-api-1:4000;\n    server changemaker-v2-api-2:4000;\n}\n

"},{"location":"v2/deployment/scaling/#vertical-scaling-resource-limits","title":"Vertical Scaling (Resource Limits)","text":"

Increase container resources:

api:\n  deploy:\n    resources:\n      limits:\n        cpus: '4'      # 4 CPU cores\n        memory: 4G     # 4GB RAM\n      reservations:\n        cpus: '1'\n        memory: 1G\n

Node.js memory limit:

api:\n  environment:\n    - NODE_OPTIONS=--max-old-space-size=3072  # 3GB heap\n

"},{"location":"v2/deployment/scaling/#redis-scaling","title":"Redis Scaling","text":""},{"location":"v2/deployment/scaling/#redis-cluster-sharding","title":"Redis Cluster (Sharding)","text":"

For >100GB datasets or high throughput.

docker-compose.yml (6-node cluster):

redis-cluster-1:\n  image: redis:7-alpine\n  command: redis-server --cluster-enabled yes --cluster-config-file nodes.conf\n\n# ... repeat for redis-cluster-2 through redis-cluster-6\n

Create cluster:

docker compose exec redis-cluster-1 redis-cli --cluster create \\\n  redis-cluster-1:6379 \\\n  redis-cluster-2:6379 \\\n  redis-cluster-3:6379 \\\n  redis-cluster-4:6379 \\\n  redis-cluster-5:6379 \\\n  redis-cluster-6:6379 \\\n  --cluster-replicas 1\n

"},{"location":"v2/deployment/scaling/#redis-sentinel-high-availability","title":"Redis Sentinel (High Availability)","text":"

Automatic failover for Redis.

docker-compose.yml:

redis-sentinel-1:\n  image: redis:7-alpine\n  command: redis-sentinel /etc/redis/sentinel.conf\n  volumes:\n    - ./configs/redis/sentinel.conf:/etc/redis/sentinel.conf\n\n# ... repeat for sentinel-2, sentinel-3\n

sentinel.conf:

sentinel monitor mymaster redis-primary 6379 2\nsentinel down-after-milliseconds mymaster 5000\nsentinel parallel-syncs mymaster 1\nsentinel failover-timeout mymaster 10000\n

"},{"location":"v2/deployment/scaling/#media-api-scaling","title":"Media API Scaling","text":""},{"location":"v2/deployment/scaling/#separate-media-containers","title":"Separate Media Containers","text":"

docker-compose.yml:

media-api:\n  deploy:\n    replicas: 2  # Run 2 media API containers\n

Nginx load balancer:

upstream media_backend {\n    server changemaker-media-api-1:4100;\n    server changemaker-media-api-2:4100;\n}\n\nlocation /api/media/ {\n    proxy_pass http://media_backend;\n}\n

Shared volume (read-only):

media-api:\n  volumes:\n    - ${MEDIA_ROOT}:/media:ro  # All replicas read same library\n

"},{"location":"v2/deployment/scaling/#cdn-for-static-media","title":"CDN for Static Media","text":"

Cloudflare CDN (or similar):

Setup: 1. Enable Cloudflare proxy (orange cloud) 2. Configure cache rules: - Cache /media/library/*.mp4 for 30 days - Bypass cache for /api/media/ (dynamic)

Benefits: - Offload video bandwidth - Global edge caching - DDoS protection

"},{"location":"v2/deployment/scaling/#frontend-scaling","title":"Frontend Scaling","text":""},{"location":"v2/deployment/scaling/#cdn-for-static-assets","title":"CDN for Static Assets","text":"

Vite production build \u2192 static files \u2192 CDN.

Build:

cd admin && npm run build\n

Upload to CDN (S3 + CloudFront):

aws s3 sync dist/ s3://changemaker-static/ --delete\naws cloudfront create-invalidation --distribution-id XYZ --paths \"/*\"\n

Benefits: - Global edge caching - Reduced origin load - Faster page loads

"},{"location":"v2/deployment/scaling/#nginx-caching","title":"Nginx Caching","text":"

Proxy cache for API responses:

proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m max_size=1g inactive=60m;\n\nlocation /api/campaigns {\n    proxy_cache api_cache;\n    proxy_cache_valid 200 10m;\n    proxy_cache_key \"$scheme$request_method$host$request_uri\";\n    proxy_pass http://changemaker-v2-api:4000;\n}\n

Cacheable endpoints: - /api/campaigns (public listing, 10 minutes) - /api/representatives (lookup cache, 1 hour) - /api/locations/public (map data, 5 minutes)

Never cache: - POST/PUT/DELETE requests - Authenticated endpoints - Real-time data (canvass sessions)

"},{"location":"v2/deployment/scaling/#job-queue-scaling","title":"Job Queue Scaling","text":""},{"location":"v2/deployment/scaling/#multiple-bullmq-workers","title":"Multiple BullMQ Workers","text":"

API container scaling also scales workers (each container runs worker).

Alternative: Dedicated worker containers.

docker-compose.yml:

email-worker:\n  build:\n    context: ./api\n  container_name: email-worker\n  command: node dist/workers/email-worker.js\n  environment:\n    - REDIS_URL=${REDIS_URL}\n    - SMTP_HOST=${SMTP_HOST}\n    # ... other env vars\n  depends_on:\n    - redis\n

Worker script (api/src/workers/email-worker.ts):

import { emailQueue } from '../services/email-queue.service';\n\nemailQueue.process(10, async (job) => {\n  // Process email job\n});\n\nconsole.log('Email worker started');\n

Scale workers:

docker compose up -d --scale email-worker=5\n

"},{"location":"v2/deployment/scaling/#monitoring-under-load","title":"Monitoring Under Load","text":""},{"location":"v2/deployment/scaling/#load-testing","title":"Load Testing","text":"

k6 script (load-test.js):

import http from 'k6/http';\nimport { check } from 'k6';\n\nexport let options = {\n  stages: [\n    { duration: '1m', target: 50 },   // Ramp to 50 users\n    { duration: '3m', target: 50 },   // Stay at 50 users\n    { duration: '1m', target: 100 },  // Ramp to 100 users\n    { duration: '3m', target: 100 },  // Stay at 100 users\n    { duration: '1m', target: 0 },    // Ramp down\n  ],\n};\n\nexport default function () {\n  let res = http.get('http://api.cmlite.org/api/campaigns');\n  check(res, {\n    'status 200': (r) => r.status === 200,\n    'response time < 500ms': (r) => r.timings.duration < 500,\n  });\n}\n

Run test:

k6 run load-test.js\n

"},{"location":"v2/deployment/scaling/#prometheus-metrics","title":"Prometheus Metrics","text":"

Monitor scaling indicators: - rate(http_requests_total[5m]) \u2014 Request rate - histogram_quantile(0.95, http_request_duration_seconds) \u2014 P95 latency - container_cpu_usage_seconds_total \u2014 CPU usage per container - container_memory_usage_bytes \u2014 Memory usage per container

Grafana alert:

- alert: HighAPILatency\n  expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 0.5\n  for: 5m\n  labels:\n    severity: warning\n  annotations:\n    summary: \"P95 latency >500ms, consider scaling\"\n

"},{"location":"v2/deployment/scaling/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/scaling/#high-cpu-usage","title":"High CPU Usage","text":"

Diagnosis:

# Top processes\ndocker stats\n\n# API CPU usage\ndocker stats changemaker-v2-api\n\n# Profile Node.js\ndocker compose exec api node --prof dist/server.js\n

Solutions: - Scale API containers (3-5 replicas) - Increase CPU limit (2-4 cores) - Optimize slow queries (add indexes) - Enable caching (Nginx proxy cache)

"},{"location":"v2/deployment/scaling/#memory-leaks","title":"Memory Leaks","text":"

Diagnosis:

# Memory usage over time\ndocker stats --no-stream changemaker-v2-api\n\n# Heap snapshot (Node.js)\ndocker compose exec api node --inspect dist/server.js\n# Chrome DevTools \u2192 Memory \u2192 Take snapshot\n

Solutions: - Restart containers daily (cron job) - Increase memory limit (4-8GB) - Fix code leaks (event listeners, circular refs)

"},{"location":"v2/deployment/scaling/#database-connection-exhaustion","title":"Database Connection Exhaustion","text":"

Symptoms: Error: too many connections for role \"changemaker\"

Diagnosis:

# Check connection count\ndocker compose exec v2-postgres psql -U changemaker -c \\\n  \"SELECT COUNT(*) FROM pg_stat_activity WHERE usename='changemaker'\"\n\n# Check max connections\ndocker compose exec v2-postgres psql -U changemaker -c \\\n  \"SHOW max_connections\"\n

Solutions: - Add PgBouncer (connection pooling) - Increase max_connections (PostgreSQL config) - Fix connection leaks (always close Prisma clients)

"},{"location":"v2/deployment/scaling/#cost-optimization","title":"Cost Optimization","text":""},{"location":"v2/deployment/scaling/#resource-allocation","title":"Resource Allocation","text":"

Right-sizing (don't over-provision): - Start with 1 CPU, 1GB RAM per container - Monitor actual usage (Prometheus) - Scale based on metrics (not guesses)

Example (production workload): - API: 2 CPUs, 2GB RAM (3 replicas) - PostgreSQL: 2 CPUs, 4GB RAM - Redis: 1 CPU, 512MB RAM - Media API: 2 CPUs, 2GB RAM (2 replicas)

"},{"location":"v2/deployment/scaling/#autoscaling-docker-swarm","title":"Autoscaling (Docker Swarm)","text":"

Docker Swarm mode (alternative to Compose):

# Initialize swarm\ndocker swarm init\n\n# Deploy stack\ndocker stack deploy -c docker-compose.yml changemaker\n\n# Autoscale API\ndocker service scale changemaker_api=3\n\n# Update with zero downtime\ndocker service update --image api:v2.1 changemaker_api\n

Autoscaling:

api:\n  deploy:\n    replicas: 3\n    update_config:\n      parallelism: 1\n      delay: 10s\n    restart_policy:\n      condition: on-failure\n

"},{"location":"v2/deployment/scaling/#related-documentation","title":"Related Documentation","text":"
  • Docker Compose \u2014 Container orchestration
  • Monitoring Stack \u2014 Performance metrics
  • Nginx Configuration \u2014 Load balancing
  • Backup & Restore \u2014 Data protection at scale
"},{"location":"v2/deployment/ssl-tls/","title":"SSL/TLS Certificate Management","text":""},{"location":"v2/deployment/ssl-tls/#overview","title":"Overview","text":"

Changemaker Lite V2 supports multiple SSL/TLS certificate sources for HTTPS deployment:

  • Let's Encrypt: Free automated certificates (recommended for self-hosted)
  • Cloudflare Origin Certificates: Static 15-year certificates (if using Cloudflare)
  • Pangolin Tunnel SSL: Tunnel provider handles SSL termination

Recommendation: Use Pangolin tunnel for simplest setup (SSL handled by tunnel provider).

"},{"location":"v2/deployment/ssl-tls/#certificate-sources","title":"Certificate Sources","text":""},{"location":"v2/deployment/ssl-tls/#lets-encrypt-with-certbot","title":"Let's Encrypt with Certbot","text":"

Best for: Self-hosted deployments with public DNS

Process: 1. Install Certbot 2. Generate certificate (DNS or HTTP challenge) 3. Configure Nginx 4. Auto-renewal via cron

Installation (Ubuntu/Debian):

sudo apt update\nsudo apt install certbot python3-certbot-nginx\n

Generate Certificate (HTTP-01 challenge):

# Stop Nginx temporarily\ndocker compose stop nginx\n\n# Generate cert\nsudo certbot certonly --standalone \\\n  -d cmlite.org \\\n  -d \"*.cmlite.org\" \\\n  --email admin@cmlite.org \\\n  --agree-tos \\\n  --non-interactive\n\n# Start Nginx\ndocker compose start nginx\n

Certificate Location:

/etc/letsencrypt/live/cmlite.org/\n\u251c\u2500 fullchain.pem  (certificate + intermediate)\n\u251c\u2500 privkey.pem    (private key)\n\u2514\u2500 chain.pem      (intermediate only)\n

Nginx Configuration:

server {\n    listen 443 ssl http2;\n    server_name api.cmlite.org;\n\n    ssl_certificate /etc/letsencrypt/live/cmlite.org/fullchain.pem;\n    ssl_certificate_key /etc/letsencrypt/live/cmlite.org/privkey.pem;\n\n    ssl_protocols TLSv1.2 TLSv1.3;\n    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';\n    ssl_prefer_server_ciphers on;\n\n    # ... locations\n}\n

HTTP Redirect:

server {\n    listen 80;\n    server_name api.cmlite.org;\n    return 301 https://$host$request_uri;\n}\n

"},{"location":"v2/deployment/ssl-tls/#cloudflare-origin-certificates","title":"Cloudflare Origin Certificates","text":"

Best for: Sites using Cloudflare DNS + proxy

Process: 1. Generate certificate in Cloudflare dashboard 2. Download certificate + private key 3. Install in Nginx 4. Set SSL mode to \"Full (strict)\"

Generate Certificate: 1. Cloudflare dashboard \u2192 SSL/TLS \u2192 Origin Server 2. Click \"Create Certificate\" 3. Hostnames: cmlite.org, *.cmlite.org 4. Validity: 15 years 5. Download certificate + private key

Install Certificate:

# Create directory\nsudo mkdir -p /etc/ssl/cloudflare\n\n# Save files\nsudo nano /etc/ssl/cloudflare/cmlite.org.pem      # Certificate\nsudo nano /etc/ssl/cloudflare/cmlite.org.key      # Private key\n\n# Set permissions\nsudo chmod 600 /etc/ssl/cloudflare/cmlite.org.key\n

Nginx Configuration:

server {\n    listen 443 ssl http2;\n    server_name api.cmlite.org;\n\n    ssl_certificate /etc/ssl/cloudflare/cmlite.org.pem;\n    ssl_certificate_key /etc/ssl/cloudflare/cmlite.org.key;\n\n    # ... TLS config\n}\n

Cloudflare SSL Mode: Set to \"Full (strict)\" (not \"Flexible\").

"},{"location":"v2/deployment/ssl-tls/#pangolin-tunnel-ssl","title":"Pangolin Tunnel SSL","text":"

Best for: Quick deployment without SSL management

How it works: 1. Pangolin tunnel terminates SSL at tunnel endpoint 2. Traffic forwarded to your Nginx as HTTP 3. No certificate management needed

Setup:

# Configure tunnel (see Tunneling guide)\nPANGOLIN_ENDPOINT=https://pangolin.bnkserve.org\nNEWT_ID=<your-newt-id>\nNEWT_SECRET=<your-newt-secret>\n\n# Start Newt container\ndocker compose up -d newt\n

Nginx Configuration: Keep HTTP-only (tunnel handles HTTPS):

server {\n    listen 80;\n    server_name api.cmlite.org;\n\n    # No SSL config needed \u2014 tunnel terminates HTTPS\n    location / {\n        proxy_pass http://changemaker-v2-api:4000;\n    }\n}\n

DNS Setup: Point domain to tunnel endpoint (provided by Pangolin).

See Tunneling for complete guide.

"},{"location":"v2/deployment/ssl-tls/#nginx-ssl-configuration","title":"Nginx SSL Configuration","text":""},{"location":"v2/deployment/ssl-tls/#strong-tls-settings","title":"Strong TLS Settings","text":"
server {\n    listen 443 ssl http2;\n    server_name api.cmlite.org;\n\n    # Certificates\n    ssl_certificate /etc/letsencrypt/live/cmlite.org/fullchain.pem;\n    ssl_certificate_key /etc/letsencrypt/live/cmlite.org/privkey.pem;\n\n    # Protocols (TLS 1.2+ only)\n    ssl_protocols TLSv1.2 TLSv1.3;\n\n    # Ciphers (secure + fast)\n    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';\n    ssl_prefer_server_ciphers on;\n\n    # Session caching (performance)\n    ssl_session_cache shared:SSL:10m;\n    ssl_session_timeout 10m;\n\n    # OCSP stapling (performance + privacy)\n    ssl_stapling on;\n    ssl_stapling_verify on;\n    ssl_trusted_certificate /etc/letsencrypt/live/cmlite.org/chain.pem;\n\n    # HSTS (already set globally in nginx.conf)\n    # add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n\n    # ... locations\n}\n

Explanation: - TLS 1.2+: Disables insecure SSLv3, TLS 1.0/1.1 - Ciphers: ECDHE for forward secrecy, AES-GCM for speed - Session cache: Reduces TLS handshake overhead - OCSP stapling: Faster certificate validation

"},{"location":"v2/deployment/ssl-tls/#http2","title":"HTTP/2","text":"

Already enabled (:443 ssl http2): - Multiplexes requests over single connection - Server push support (optional) - Faster page loads

No additional config needed \u2014 Nginx handles HTTP/2 automatically.

"},{"location":"v2/deployment/ssl-tls/#hsts-http-strict-transport-security","title":"HSTS (HTTP Strict Transport Security)","text":"

Already set globally (in nginx/nginx.conf):

add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n

Effect: - Browsers cache HTTPS requirement for 1 year - Prevents downgrade attacks - Applies to all subdomains

Warning: Only enable after verifying HTTPS works (can't easily undo).

Test before enabling:

# Test HTTPS works\ncurl -I https://api.cmlite.org\n\n# Check for redirects\ncurl -L https://api.cmlite.org\n

"},{"location":"v2/deployment/ssl-tls/#certificate-renewal","title":"Certificate Renewal","text":""},{"location":"v2/deployment/ssl-tls/#automated-renewal-certbot","title":"Automated Renewal (Certbot)","text":"

Setup cron job:

# Edit crontab\nsudo crontab -e\n\n# Add renewal job (checks twice daily)\n0 0,12 * * * certbot renew --quiet --post-hook \"docker compose -f /path/to/changemaker.lite/docker-compose.yml exec nginx nginx -s reload\"\n

Manual renewal:

# Dry run (test)\nsudo certbot renew --dry-run\n\n# Real renewal\nsudo certbot renew\n\n# Reload Nginx\ndocker compose exec nginx nginx -s reload\n

Renewal conditions: - Certificates expire in <30 days - HTTP-01 challenge succeeds (port 80 must be open)

"},{"location":"v2/deployment/ssl-tls/#manual-renewal-cloudflare","title":"Manual Renewal (Cloudflare)","text":"

Cloudflare origin certificates valid for 15 years \u2014 no renewal needed.

If replacing certificate: 1. Generate new cert in Cloudflare dashboard 2. Download files 3. Replace files in /etc/ssl/cloudflare/ 4. Reload Nginx: docker compose exec nginx nginx -s reload

"},{"location":"v2/deployment/ssl-tls/#monitoring-expiry","title":"Monitoring Expiry","text":"

Check expiry date:

# Via OpenSSL\necho | openssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org 2>/dev/null | openssl x509 -noout -dates\n\n# Output:\n# notBefore=Jan  1 00:00:00 2024 GMT\n# notAfter=Apr  1 23:59:59 2024 GMT\n

Automated monitoring (via Prometheus + Alertmanager):

# In alerts.yml\n- alert: SSLCertificateExpiringSoon\n  expr: probe_ssl_earliest_cert_expiry - time() < 86400 * 30  # 30 days\n  for: 1h\n  labels:\n    severity: warning\n  annotations:\n    summary: \"SSL certificate expiring in <30 days\"\n

"},{"location":"v2/deployment/ssl-tls/#testing-ssl","title":"Testing SSL","text":""},{"location":"v2/deployment/ssl-tls/#ssl-labs","title":"SSL Labs","text":"

Online test: https://www.ssllabs.com/ssltest/

Target grade: A or A+

Common issues: - Missing intermediate certificate (use fullchain.pem not cert.pem) - Weak ciphers (update ssl_ciphers list) - Missing HSTS header (already set globally)

"},{"location":"v2/deployment/ssl-tls/#command-line","title":"Command Line","text":"

Test TLS handshake:

openssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org\n

Check certificate chain:

openssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org -showcerts\n

Test specific protocol:

# TLS 1.2\nopenssl s_client -connect api.cmlite.org:443 -tls1_2\n\n# TLS 1.3\nopenssl s_client -connect api.cmlite.org:443 -tls1_3\n\n# SSLv3 (should fail)\nopenssl s_client -connect api.cmlite.org:443 -ssl3\n

"},{"location":"v2/deployment/ssl-tls/#wildcard-certificates","title":"Wildcard Certificates","text":"

For *.cmlite.org (covers all subdomains):

"},{"location":"v2/deployment/ssl-tls/#lets-encrypt-dns-01-challenge","title":"Let's Encrypt (DNS-01 Challenge)","text":"

Required: API access to DNS provider (Cloudflare, Route53, etc.)

Example (Cloudflare):

# Install Cloudflare plugin\nsudo apt install python3-certbot-dns-cloudflare\n\n# Create credentials file\ncat > ~/.secrets/cloudflare.ini <<EOF\ndns_cloudflare_api_token = YOUR_API_TOKEN\nEOF\nchmod 600 ~/.secrets/cloudflare.ini\n\n# Generate wildcard cert\nsudo certbot certonly \\\n  --dns-cloudflare \\\n  --dns-cloudflare-credentials ~/.secrets/cloudflare.ini \\\n  -d cmlite.org \\\n  -d \"*.cmlite.org\" \\\n  --email admin@cmlite.org \\\n  --agree-tos\n

Advantage: Single certificate covers all subdomains (api, app, db, etc.).

"},{"location":"v2/deployment/ssl-tls/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/ssl-tls/#certificate-not-trusted","title":"Certificate Not Trusted","text":"

Symptoms: Browser shows \"Not Secure\" warning

Causes: 1. Missing intermediate certificate 2. Wrong certificate file 3. Certificate expired

Solution:

# Use fullchain.pem (includes intermediate)\nssl_certificate /etc/letsencrypt/live/cmlite.org/fullchain.pem;\n\n# NOT cert.pem (missing intermediate)\n# ssl_certificate /etc/letsencrypt/live/cmlite.org/cert.pem;  # \u274c WRONG\n\n# Reload Nginx\ndocker compose exec nginx nginx -s reload\n

"},{"location":"v2/deployment/ssl-tls/#mixed-content-warnings","title":"Mixed Content Warnings","text":"

Symptoms: Some assets load via HTTP on HTTPS page

Cause: Hard-coded http:// URLs in HTML/JS

Solution:

// Change absolute URLs to protocol-relative\n// \u274c WRONG\nconst apiUrl = 'http://api.cmlite.org';\n\n// \u2705 CORRECT\nconst apiUrl = 'https://api.cmlite.org';\n\n// \u2705 BEST (protocol-relative)\nconst apiUrl = location.protocol + '//api.cmlite.org';\n

"},{"location":"v2/deployment/ssl-tls/#renewal-failures","title":"Renewal Failures","text":"

Symptoms: Certbot renewal fails

Diagnosis:

# Test renewal\nsudo certbot renew --dry-run\n\n# Check logs\nsudo tail -f /var/log/letsencrypt/letsencrypt.log\n

Common causes: - Port 80 blocked (HTTP-01 challenge fails) - DNS not pointing to server (domain validation fails) - Rate limit hit (5 certs/week per domain)

Solution:

# Check port 80 open\nsudo netstat -tulpn | grep :80\n\n# Test HTTP challenge\ncurl http://cmlite.org/.well-known/acme-challenge/test\n\n# Use DNS-01 challenge instead (no port 80 needed)\nsudo certbot certonly --dns-cloudflare ...\n

"},{"location":"v2/deployment/ssl-tls/#related-documentation","title":"Related Documentation","text":"
  • Docker Compose \u2014 Nginx container setup
  • Nginx Configuration \u2014 Reverse proxy config
  • Tunneling \u2014 Pangolin tunnel SSL
  • Environment Variables \u2014 SSL-related env vars
"},{"location":"v2/deployment/tunneling/","title":"Pangolin Tunnel Deployment","text":""},{"location":"v2/deployment/tunneling/#overview","title":"Overview","text":"

Pangolin is a self-hosted tunnel service (alternative to Cloudflare Tunnel) that provides public HTTPS access to your Changemaker Lite instance without port forwarding or firewall configuration.

Benefits: - No port forwarding needed - SSL/TLS handled by tunnel provider - Static public URLs - Self-hosted tunnel server (privacy/control) - Free/open source

Architecture:

Internet \u2192 Pangolin Tunnel (pangolin.bnkserve.org) \u2192 Newt Container \u2192 Nginx \u2192 Services\n

Changemaker Integration: - Pangolin server: https://api.bnkserve.org/v1 - Tunnel endpoint: https://pangolin.bnkserve.org - Newt container: Tunnel connector (fosrl/newt image) - Admin GUI: PangolinPage.tsx setup wizard

"},{"location":"v2/deployment/tunneling/#setup-workflow","title":"Setup Workflow","text":""},{"location":"v2/deployment/tunneling/#1-prerequisites","title":"1. Prerequisites","text":"

Required: - Pangolin API key (obtain from Pangolin admin) - Docker Compose running - Nginx container accessible from Newt

Environment Variables:

PANGOLIN_API_URL=https://api.bnkserve.org/v1\nPANGOLIN_API_KEY=<your-api-key>\nPANGOLIN_ORG_ID=<set-after-org-creation>\nPANGOLIN_SITE_ID=<set-after-site-creation>\nPANGOLIN_ENDPOINT=https://pangolin.bnkserve.org\nPANGOLIN_NEWT_ID=<set-after-resource-creation>\nPANGOLIN_NEWT_SECRET=<set-after-resource-creation>\n

"},{"location":"v2/deployment/tunneling/#2-setup-via-admin-gui","title":"2. Setup via Admin GUI","text":"

Easiest method: Use /app/pangolin page in admin GUI.

Steps: 1. Navigate to http://localhost:3000/app/pangolin 2. Enter PANGOLIN_API_KEY (click \"Test Connection\") 3. Create Organization (or select existing) 4. Create Site (linked to org) 5. Create Endpoint (tunnel URL) 6. Create Resource (Newt connector credentials) 7. Copy NEWT_ID and NEWT_SECRET to .env 8. Restart Newt container: docker compose restart newt

"},{"location":"v2/deployment/tunneling/#3-manual-setup-cli","title":"3. Manual Setup (CLI)","text":"

Organization:

curl -X POST https://api.bnkserve.org/v1/orgs \\\n  -H \"Authorization: Bearer $PANGOLIN_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"name\": \"My Organization\",\n    \"description\": \"Changemaker Lite Production\"\n  }'\n\n# Returns:\n# {\"id\":\"org_abc123\",\"name\":\"My Organization\",...}\n\nexport PANGOLIN_ORG_ID=org_abc123\n

Site:

curl -X POST https://api.bnkserve.org/v1/sites \\\n  -H \"Authorization: Bearer $PANGOLIN_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"org_id\": \"'\"$PANGOLIN_ORG_ID\"'\",\n    \"name\": \"Production Site\",\n    \"description\": \"Main deployment\"\n  }'\n\n# Returns:\n# {\"id\":\"site_xyz789\",\"name\":\"Production Site\",...}\n\nexport PANGOLIN_SITE_ID=site_xyz789\n

Endpoint:

curl -X POST https://api.bnkserve.org/v1/endpoints \\\n  -H \"Authorization: Bearer $PANGOLIN_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"site_id\": \"'\"$PANGOLIN_SITE_ID\"'\",\n    \"subdomain\": \"changemaker\",\n    \"domain\": \"pangolin.bnkserve.org\"\n  }'\n\n# Returns:\n# {\"id\":\"endpoint_def456\",\"url\":\"https://changemaker.pangolin.bnkserve.org\",...}\n\nexport PANGOLIN_ENDPOINT=https://changemaker.pangolin.bnkserve.org\n

Resource (Newt Connector):

curl -X POST https://api.bnkserve.org/v1/resources \\\n  -H \"Authorization: Bearer $PANGOLIN_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"endpoint_id\": \"<endpoint-id>\",\n    \"target\": \"http://nginx:80\",\n    \"name\": \"Changemaker Services\"\n  }'\n\n# Returns:\n# {\"id\":\"newt_abc123\",\"secret\":\"secret_xyz789\",...}\n\nexport PANGOLIN_NEWT_ID=newt_abc123\nexport PANGOLIN_NEWT_SECRET=secret_xyz789\n

Update .env:

cat >> .env <<EOF\nPANGOLIN_ORG_ID=$PANGOLIN_ORG_ID\nPANGOLIN_SITE_ID=$PANGOLIN_SITE_ID\nPANGOLIN_ENDPOINT=$PANGOLIN_ENDPOINT\nPANGOLIN_NEWT_ID=$PANGOLIN_NEWT_ID\nPANGOLIN_NEWT_SECRET=$PANGOLIN_NEWT_SECRET\nEOF\n

"},{"location":"v2/deployment/tunneling/#4-start-newt-container","title":"4. Start Newt Container","text":"
# Restart to pick up new env vars\ndocker compose up -d --force-recreate newt\n\n# Check logs\ndocker compose logs -f newt\n\n# Should see:\n# [INFO] Connected to Pangolin tunnel\n# [INFO] Tunnel active: https://changemaker.pangolin.bnkserve.org\n
"},{"location":"v2/deployment/tunneling/#5-dns-configuration","title":"5. DNS Configuration","text":"

Option A: Direct Access (use tunnel URL): - https://changemaker.pangolin.bnkserve.org

Option B: Custom Domain (CNAME to tunnel):

app.cmlite.org.  CNAME  changemaker.pangolin.bnkserve.org.\napi.cmlite.org.  CNAME  changemaker.pangolin.bnkserve.org.\n

"},{"location":"v2/deployment/tunneling/#newt-container-configuration","title":"Newt Container Configuration","text":"

docker-compose.yml:

newt:\n  image: fosrl/newt\n  container_name: newt-changemaker\n  restart: unless-stopped\n  environment:\n    - PANGOLIN_ENDPOINT=${PANGOLIN_ENDPOINT}\n    - NEWT_ID=${PANGOLIN_NEWT_ID}\n    - NEWT_SECRET=${PANGOLIN_NEWT_SECRET}\n  depends_on:\n    - nginx\n  networks:\n    - changemaker-lite\n

Key Features: - Restart policy: unless-stopped (auto-reconnects) - Nginx dependency: Ensures Nginx running before Newt starts - No ports exposed: All traffic via tunnel

Target: Newt connects to http://nginx:80 (Docker internal network).

"},{"location":"v2/deployment/tunneling/#tunnel-lifecycle","title":"Tunnel Lifecycle","text":""},{"location":"v2/deployment/tunneling/#start-tunnel","title":"Start Tunnel","text":"
docker compose up -d newt\n
"},{"location":"v2/deployment/tunneling/#stop-tunnel","title":"Stop Tunnel","text":"
docker compose stop newt\n
"},{"location":"v2/deployment/tunneling/#check-status","title":"Check Status","text":"
# Container status\ndocker compose ps newt\n\n# Logs\ndocker compose logs --tail=50 newt\n\n# Test tunnel\ncurl https://changemaker.pangolin.bnkserve.org/api/health\n
"},{"location":"v2/deployment/tunneling/#restart-after-env-changes","title":"Restart (after .env changes)","text":"
docker compose up -d --force-recreate newt\n
"},{"location":"v2/deployment/tunneling/#exit-nodes-resource-routing","title":"Exit Nodes & Resource Routing","text":"

Resource: Defines how tunnel routes traffic to your services.

Target URL: http://nginx:80 (Nginx handles subdomain routing internally).

Example Flow: 1. User visits https://changemaker.pangolin.bnkserve.org/api/health 2. Pangolin tunnel receives HTTPS request 3. Tunnel forwards to Newt container 4. Newt proxies to http://nginx:80/api/health 5. Nginx routes to changemaker-v2-api:4000/api/health 6. Response flows back through tunnel

Multiple Resources: Create separate resources for different backends (advanced).

"},{"location":"v2/deployment/tunneling/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/tunneling/#tunnel-not-connecting","title":"Tunnel Not Connecting","text":"

Symptoms: Newt logs show connection errors

Diagnosis:

docker compose logs newt\n\n# Common errors:\n# - \"Authentication failed\" (wrong NEWT_ID/SECRET)\n# - \"Endpoint not found\" (wrong PANGOLIN_ENDPOINT)\n# - \"Connection refused\" (Nginx not running)\n

Solutions:

# Verify credentials\ndocker compose exec newt printenv | grep PANGOLIN\n\n# Test Nginx from Newt container\ndocker compose exec newt wget -O- http://nginx:80/api/health\n\n# Restart Newt\ndocker compose restart newt\n

"},{"location":"v2/deployment/tunneling/#tunnel-connected-but-site-unreachable","title":"Tunnel Connected But Site Unreachable","text":"

Symptoms: Newt connected, but HTTPS requests timeout/fail

Diagnosis:

# Test tunnel endpoint\ncurl -I https://changemaker.pangolin.bnkserve.org\n\n# Check Nginx logs\ndocker compose logs nginx | tail -50\n\n# Verify resource target\ncurl -X GET https://api.bnkserve.org/v1/resources/<resource-id> \\\n  -H \"Authorization: Bearer $PANGOLIN_API_KEY\"\n

Common Causes: - Resource target points to wrong service - Nginx not listening on port 80 - Firewall blocking Nginx \u2192 backend communication

Solution:

# Verify Nginx config\ndocker compose exec nginx nginx -t\n\n# Check Nginx listening\ndocker compose exec nginx netstat -tulpn | grep :80\n\n# Test backend from Nginx\ndocker compose exec nginx curl http://changemaker-v2-api:4000/api/health\n

"},{"location":"v2/deployment/tunneling/#ssl-certificate-errors","title":"SSL Certificate Errors","text":"

Symptoms: Browser shows \"Certificate invalid\" warning

Cause: Tunnel endpoint SSL certificate not trusted (rare).

Solution: Contact Pangolin support \u2014 tunnel provider manages SSL certificates.

"},{"location":"v2/deployment/tunneling/#frequent-disconnects","title":"Frequent Disconnects","text":"

Symptoms: Newt reconnects every few minutes

Diagnosis:

# Check for network issues\ndocker compose logs newt | grep -i disconnect\n\n# Monitor connection\nwatch -n5 'docker compose logs --tail=1 newt'\n

Possible Causes: - Network instability - Container restarts (check docker compose ps) - Resource limits (check docker stats newt-changemaker)

Solution:

# Increase restart backoff (if needed)\n# Edit docker-compose.yml:\nnewt:\n  restart_policy:\n    condition: on-failure\n    delay: 5s\n    max_attempts: 3\n

"},{"location":"v2/deployment/tunneling/#migration-from-cloudflare-tunnel","title":"Migration from Cloudflare Tunnel","text":"

Retired Scripts (in scripts/legacy/): - start-production.sh - config.sh - tunnel-config.sh

Migration Steps: 1. Stop Cloudflare tunnel: cloudflared service uninstall 2. Remove Cloudflare credentials: rm ~/.cloudflared/*.json 3. Setup Pangolin tunnel (see above) 4. Update DNS: Change CNAME from cloudflared.com to pangolin.bnkserve.org 5. Test new tunnel: curl https://changemaker.pangolin.bnkserve.org/api/health 6. Remove old scripts: rm scripts/legacy/*

Why Pangolin? - Self-hosted (privacy/control) - No Cloudflare dependency - Free/open source - API-driven management

"},{"location":"v2/deployment/tunneling/#advanced-configuration","title":"Advanced Configuration","text":""},{"location":"v2/deployment/tunneling/#custom-tunnel-domain","title":"Custom Tunnel Domain","text":"

Requirement: Own domain with DNS control.

Steps: 1. Create endpoint with custom domain 2. Add DNS record: tunnel.cmlite.org CNAME pangolin.bnkserve.org. 3. Update PANGOLIN_ENDPOINT=https://tunnel.cmlite.org 4. Restart Newt

"},{"location":"v2/deployment/tunneling/#multiple-sites","title":"Multiple Sites","text":"

Use case: Staging + production on same tunnel.

Setup:

# Create second site\ncurl -X POST https://api.bnkserve.org/v1/sites \\\n  -d '{\"org_id\":\"...\",\"name\":\"Staging\"}'\n\n# Create endpoint for staging\ncurl -X POST https://api.bnkserve.org/v1/endpoints \\\n  -d '{\"site_id\":\"...\",\"subdomain\":\"staging-changemaker\"}'\n\n# Create resource pointing to staging Nginx\ncurl -X POST https://api.bnkserve.org/v1/resources \\\n  -d '{\"endpoint_id\":\"...\",\"target\":\"http://nginx-staging:80\"}'\n

"},{"location":"v2/deployment/tunneling/#monitoring","title":"Monitoring","text":""},{"location":"v2/deployment/tunneling/#health-checks","title":"Health Checks","text":"

Tunnel status:

# Container health\ndocker compose ps newt\n\n# Connection logs\ndocker compose logs --tail=50 newt | grep -i connected\n\n# Test public endpoint\ncurl -I https://changemaker.pangolin.bnkserve.org\n

Prometheus metrics (if enabled):

# API uptime through tunnel\ncurl https://changemaker.pangolin.bnkserve.org/api/metrics | grep cm_api_uptime\n

"},{"location":"v2/deployment/tunneling/#related-documentation","title":"Related Documentation","text":"
  • Docker Compose \u2014 Newt container configuration
  • Nginx Configuration \u2014 Reverse proxy setup
  • SSL/TLS \u2014 Certificate management (handled by tunnel)
  • Environment Variables \u2014 Pangolin env vars
"},{"location":"v2/development/","title":"Development Guide","text":"

This section covers development workflows, local setup, coding standards, testing, and best practices for contributing to Changemaker Lite V2.

"},{"location":"v2/development/#development-workflow","title":"Development Workflow","text":""},{"location":"v2/development/#local-setup","title":"Local Setup","text":"

Getting started with local development:

  • Prerequisites (Node.js, Docker, Git)
  • Repository setup
  • Environment configuration
  • Database initialization
  • Running development servers
"},{"location":"v2/development/#docker-workflow","title":"Docker Workflow","text":"

Docker-based development:

  • Starting services with Docker Compose
  • Viewing logs
  • Rebuilding containers
  • Database operations
  • Volume management
"},{"location":"v2/development/#git-workflow","title":"Git Workflow","text":"

Version control best practices:

  • Branch naming conventions
  • Commit message format
  • Pull request process
  • Code review guidelines
  • Merge strategies
"},{"location":"v2/development/#npm-commands","title":"NPM Commands","text":"

Common development commands:

  • Development servers
  • Build commands
  • Testing
  • Type-checking
  • Linting and formatting
"},{"location":"v2/development/#database-migrations","title":"Database Migrations","text":"

Schema changes and migrations:

  • Creating migrations (Prisma)
  • Applying migrations
  • Schema push (Drizzle)
  • Rolling back changes
  • Testing migrations
"},{"location":"v2/development/#typescript","title":"TypeScript","text":"

TypeScript best practices:

  • Type definitions
  • Strict mode
  • Common patterns
  • Zod integration
  • Prisma types
"},{"location":"v2/development/#code-style","title":"Code Style","text":"

Coding standards and conventions:

  • ESLint configuration
  • Prettier formatting
  • Naming conventions
  • File organization
  • Comment standards
"},{"location":"v2/development/#testing","title":"Testing","text":"

Testing strategies:

  • Unit tests
  • Integration tests
  • API testing
  • Component testing
  • E2E testing (future)
"},{"location":"v2/development/#debugging","title":"Debugging","text":"

Debugging techniques:

  • VS Code debugging
  • Browser DevTools
  • API debugging
  • Database queries
  • Log analysis
"},{"location":"v2/development/#quick-start","title":"Quick Start","text":""},{"location":"v2/development/#local-development-no-docker","title":"Local Development (No Docker)","text":"

Terminal 1: API Server

cd api\nnpm install\nnpm run dev\n# Runs on http://localhost:4000\n

Terminal 2: Admin GUI

cd admin\nnpm install\nnpm run dev\n# Runs on http://localhost:3000\n

Terminal 3: Media API (Optional)

cd api\nnpm run dev:media\n# Runs on http://localhost:4100\n

"},{"location":"v2/development/#docker-development","title":"Docker Development","text":"

Start Core Services

docker compose up -d v2-postgres redis\ndocker compose up -d api admin\n

View Logs

docker compose logs -f api\ndocker compose logs -f admin\n

Rebuild After Changes

docker compose build api\ndocker compose up -d api\n

"},{"location":"v2/development/#development-tools","title":"Development Tools","text":""},{"location":"v2/development/#required","title":"Required","text":"
  • Node.js 20+ - JavaScript runtime
  • npm 10+ - Package manager
  • Docker 24+ - Container runtime
  • Docker Compose 2+ - Multi-container orchestration
  • Git 2+ - Version control
"},{"location":"v2/development/#recommended","title":"Recommended","text":"
  • VS Code - IDE with TypeScript support
  • Prisma Studio - Database GUI
  • Postman - API testing
  • Redis Insight - Redis GUI (optional)
"},{"location":"v2/development/#vs-code-extensions","title":"VS Code Extensions","text":"
  • Prisma - Schema syntax highlighting
  • ESLint - JavaScript linting
  • Prettier - Code formatting
  • TypeScript - Language support
  • Docker - Container management
"},{"location":"v2/development/#project-structure","title":"Project Structure","text":"
changemaker.lite/\n\u251c\u2500\u2500 api/                    # Backend (Express + Fastify)\n\u2502   \u251c\u2500\u2500 src/\n\u2502   \u2502   \u251c\u2500\u2500 server.ts       # Express entry point\n\u2502   \u2502   \u251c\u2500\u2500 media-server.ts # Fastify entry point\n\u2502   \u2502   \u251c\u2500\u2500 modules/        # Feature modules\n\u2502   \u2502   \u251c\u2500\u2500 services/       # Shared services\n\u2502   \u2502   \u251c\u2500\u2500 middleware/     # Express middleware\n\u2502   \u2502   \u2514\u2500\u2500 utils/          # Utilities\n\u2502   \u251c\u2500\u2500 prisma/\n\u2502   \u2502   \u251c\u2500\u2500 schema.prisma   # Main schema\n\u2502   \u2502   \u2514\u2500\u2500 migrations/     # Migration history\n\u2502   \u2514\u2500\u2500 package.json\n\u2502\n\u251c\u2500\u2500 admin/                  # Frontend (React + Vite)\n\u2502   \u251c\u2500\u2500 src/\n\u2502   \u2502   \u251c\u2500\u2500 App.tsx         # Main router\n\u2502   \u2502   \u251c\u2500\u2500 components/     # Shared components\n\u2502   \u2502   \u251c\u2500\u2500 pages/          # Page components\n\u2502   \u2502   \u251c\u2500\u2500 lib/            # API clients\n\u2502   \u2502   \u2514\u2500\u2500 stores/         # Zustand stores\n\u2502   \u2514\u2500\u2500 package.json\n\u2502\n\u251c\u2500\u2500 docker-compose.yml      # V2 orchestration\n\u251c\u2500\u2500 .env                    # Environment variables (not committed)\n\u2514\u2500\u2500 .env.example            # Example environment\n
"},{"location":"v2/development/#development-patterns","title":"Development Patterns","text":""},{"location":"v2/development/#backend-module-structure","title":"Backend Module Structure","text":"
api/src/modules/feature/\n\u251c\u2500\u2500 feature.routes.ts       # Express router\n\u251c\u2500\u2500 feature.service.ts      # Business logic\n\u251c\u2500\u2500 feature.schemas.ts      # Zod validation\n\u2514\u2500\u2500 feature-public.routes.ts # Public routes (optional)\n
"},{"location":"v2/development/#frontend-page-structure","title":"Frontend Page Structure","text":"
admin/src/pages/\n\u251c\u2500\u2500 admin/                  # Admin pages (30)\n\u251c\u2500\u2500 public/                 # Public pages (8)\n\u251c\u2500\u2500 volunteer/              # Volunteer pages (4)\n\u2514\u2500\u2500 auth/                   # Auth pages (1)\n
"},{"location":"v2/development/#api-client-pattern","title":"API Client Pattern","text":"
// admin/src/lib/api.ts\nimport axios from 'axios';\n\nexport const api = axios.create({\n  baseURL: import.meta.env.VITE_API_URL,\n});\n\n// Interceptor for auth\napi.interceptors.request.use((config) => {\n  const token = localStorage.getItem('accessToken');\n  if (token) {\n    config.headers.Authorization = `Bearer ${token}`;\n  }\n  return config;\n});\n
"},{"location":"v2/development/#service-pattern","title":"Service Pattern","text":"
// api/src/modules/feature/feature.service.ts\nimport { prisma } from '../../lib/prisma';\n\nclass FeatureService {\n  async create(data: CreateInput) {\n    return await prisma.feature.create({ data });\n  }\n\n  async findAll(filters: Filters) {\n    return await prisma.feature.findMany({ where: filters });\n  }\n}\n\nexport const featureService = new FeatureService();\n
"},{"location":"v2/development/#common-tasks","title":"Common Tasks","text":""},{"location":"v2/development/#add-new-api-endpoint","title":"Add New API Endpoint","text":"
  1. Create schema in *.schemas.ts
  2. Add service method in *.service.ts
  3. Add route handler in *.routes.ts
  4. Register router in server.ts
  5. Test with Postman/curl
"},{"location":"v2/development/#add-new-page","title":"Add New Page","text":"
  1. Create page component in pages/
  2. Add route in App.tsx
  3. Add to sidebar menu (if admin page)
  4. Create API client calls
  5. Test in browser
"},{"location":"v2/development/#add-database-field","title":"Add Database Field","text":"
  1. Update prisma/schema.prisma
  2. Run npx prisma migrate dev --name add_field
  3. Update TypeScript types
  4. Update API endpoints
  5. Update frontend forms
"},{"location":"v2/development/#add-new-service-integration","title":"Add New Service Integration","text":"
  1. Create client in services/
  2. Add environment variables
  3. Create admin routes
  4. Add admin page
  5. Test integration
"},{"location":"v2/development/#testing_1","title":"Testing","text":""},{"location":"v2/development/#api-tests","title":"API Tests","text":"
# Run API tests\ncd api && npm test\n\n# Test specific endpoint\ncurl http://localhost:4000/api/campaigns\n
"},{"location":"v2/development/#frontend-tests","title":"Frontend Tests","text":"
# Run component tests\ncd admin && npm test\n\n# Test build\ncd admin && npm run build\n
"},{"location":"v2/development/#type-checking","title":"Type Checking","text":"
# Check API types\ncd api && npx tsc --noEmit\n\n# Check admin types\ncd admin && npx tsc --noEmit\n
"},{"location":"v2/development/#debugging_1","title":"Debugging","text":""},{"location":"v2/development/#api-debugging","title":"API Debugging","text":"
# View API logs\ndocker compose logs -f api\n\n# Access container shell\ndocker compose exec api sh\n\n# Run Prisma Studio\ncd api && npx prisma studio\n
"},{"location":"v2/development/#frontend-debugging","title":"Frontend Debugging","text":"
# View admin logs\ndocker compose logs -f admin\n\n# Access browser DevTools\n# Open http://localhost:3000\n# F12 for DevTools\n
"},{"location":"v2/development/#code-quality","title":"Code Quality","text":""},{"location":"v2/development/#linting","title":"Linting","text":"
# Lint API\ncd api && npm run lint\n\n# Lint admin\ncd admin && npm run lint\n
"},{"location":"v2/development/#formatting","title":"Formatting","text":"
# Format API\ncd api && npm run format\n\n# Format admin\ncd admin && npm run format\n
"},{"location":"v2/development/#type-safety","title":"Type Safety","text":"

All code uses TypeScript with strict mode:

{\n  \"compilerOptions\": {\n    \"strict\": true,\n    \"noImplicitAny\": true,\n    \"strictNullChecks\": true\n  }\n}\n
"},{"location":"v2/development/#best-practices","title":"Best Practices","text":""},{"location":"v2/development/#backend","title":"Backend","text":"
  • Use Zod for validation
  • Service layer for business logic
  • Middleware for cross-cutting concerns
  • Error handling with try/catch
  • Logging with Winston
"},{"location":"v2/development/#frontend","title":"Frontend","text":"
  • Use React hooks
  • Zustand for state management
  • Ant Design components
  • Type-safe API calls
  • Error boundaries
"},{"location":"v2/development/#database","title":"Database","text":"
  • Use migrations for schema changes
  • Index frequently queried fields
  • Use transactions for multi-step operations
  • Avoid N+1 queries
"},{"location":"v2/development/#related-documentation","title":"Related Documentation","text":"
  • Local Setup
  • Docker Workflow
  • Git Workflow
  • NPM Commands
  • Migrations
  • TypeScript
  • Code Style
  • Testing
  • Debugging
  • Contributing
"},{"location":"v2/development/code-style/","title":"Code Style Guide","text":"

Coding standards and style conventions for Changemaker Lite V2.

"},{"location":"v2/development/code-style/#overview","title":"Overview","text":"

Consistent code style improves: - Readability: Easier to understand code - Maintainability: Easier to modify code - Collaboration: Reduces merge conflicts - Quality: Catches common errors

This guide covers TypeScript, ESLint, Prettier, and naming conventions.

"},{"location":"v2/development/code-style/#tools","title":"Tools","text":""},{"location":"v2/development/code-style/#typescript","title":"TypeScript","text":"

Version: 5.x Config: tsconfig.json (api/ and admin/)

Strict Mode: Enabled

{\n  \"compilerOptions\": {\n    \"strict\": true,\n    \"noImplicitAny\": true,\n    \"strictNullChecks\": true,\n    \"strictFunctionTypes\": true,\n    \"strictPropertyInitialization\": true,\n    \"noImplicitThis\": true,\n    \"alwaysStrict\": true\n  }\n}\n
"},{"location":"v2/development/code-style/#eslint","title":"ESLint","text":"

Version: 8.x Config: .eslintrc.js (api/ and admin/)

Plugins: - @typescript-eslint/eslint-plugin - eslint-plugin-react (admin only) - eslint-plugin-react-hooks (admin only)

"},{"location":"v2/development/code-style/#prettier","title":"Prettier","text":"

Version: 3.x Config: .prettierrc

Format on save: Enabled (VSCode)

"},{"location":"v2/development/code-style/#typescript-configuration","title":"TypeScript Configuration","text":""},{"location":"v2/development/code-style/#api-tsconfigjson","title":"API tsconfig.json","text":"
{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"commonjs\",\n    \"lib\": [\"ES2022\"],\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\",\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"resolveJsonModule\": true,\n    \"moduleResolution\": \"node\",\n    \"types\": [\"node\"]\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\"node_modules\", \"dist\", \"**/*.test.ts\"]\n}\n
"},{"location":"v2/development/code-style/#admin-tsconfigjson","title":"Admin tsconfig.json","text":"
{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"types\": [\"vite/client\"]\n  },\n  \"include\": [\"src\"],\n  \"references\": [{ \"path\": \"./tsconfig.node.json\" }]\n}\n
"},{"location":"v2/development/code-style/#eslint-rules","title":"ESLint Rules","text":""},{"location":"v2/development/code-style/#api-eslintrcjs","title":"API .eslintrc.js","text":"
module.exports = {\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    ecmaVersion: 2022,\n    sourceType: 'module',\n    project: './tsconfig.json'\n  },\n  extends: [\n    'eslint:recommended',\n    'plugin:@typescript-eslint/recommended',\n    'plugin:@typescript-eslint/recommended-requiring-type-checking'\n  ],\n  plugins: ['@typescript-eslint'],\n  root: true,\n  env: {\n    node: true,\n    es2022: true\n  },\n  rules: {\n    // TypeScript\n    '@typescript-eslint/no-explicit-any': 'error',\n    '@typescript-eslint/explicit-function-return-type': 'off',\n    '@typescript-eslint/explicit-module-boundary-types': 'off',\n    '@typescript-eslint/no-unused-vars': [\n      'error',\n      { argsIgnorePattern: '^_' }\n    ],\n    '@typescript-eslint/no-floating-promises': 'error',\n    '@typescript-eslint/await-thenable': 'error',\n\n    // General\n    'no-console': ['warn', { allow: ['warn', 'error'] }],\n    'no-debugger': 'error',\n    'prefer-const': 'error',\n    'no-var': 'error',\n    'eqeqeq': ['error', 'always'],\n    'curly': ['error', 'all']\n  }\n};\n
"},{"location":"v2/development/code-style/#admin-eslintrcjs","title":"Admin .eslintrc.js","text":"
module.exports = {\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    ecmaVersion: 2020,\n    sourceType: 'module',\n    ecmaFeatures: {\n      jsx: true\n    },\n    project: './tsconfig.json'\n  },\n  extends: [\n    'eslint:recommended',\n    'plugin:react/recommended',\n    'plugin:react-hooks/recommended',\n    'plugin:@typescript-eslint/recommended'\n  ],\n  plugins: ['react', 'react-hooks', '@typescript-eslint'],\n  root: true,\n  env: {\n    browser: true,\n    es2020: true\n  },\n  settings: {\n    react: {\n      version: 'detect'\n    }\n  },\n  rules: {\n    // React\n    'react/react-in-jsx-scope': 'off', // React 17+\n    'react/prop-types': 'off', // Use TypeScript\n    'react-hooks/rules-of-hooks': 'error',\n    'react-hooks/exhaustive-deps': 'warn',\n\n    // TypeScript\n    '@typescript-eslint/no-explicit-any': 'error',\n    '@typescript-eslint/explicit-function-return-type': 'off',\n    '@typescript-eslint/no-unused-vars': [\n      'error',\n      { argsIgnorePattern: '^_' }\n    ],\n\n    // General\n    'no-console': ['warn', { allow: ['warn', 'error'] }],\n    'no-debugger': 'error',\n    'prefer-const': 'error',\n    'no-var': 'error',\n    'eqeqeq': ['error', 'always']\n  }\n};\n
"},{"location":"v2/development/code-style/#key-rules-explained","title":"Key Rules Explained","text":"

@typescript-eslint/no-explicit-any - Prevents any type

// \u274c Bad\nfunction foo(data: any) {}\n\n// \u2705 Good\nfunction foo(data: User) {}\nfunction foo(data: unknown) {} // Use unknown instead\n

@typescript-eslint/no-unused-vars - Prevents unused variables

// \u274c Bad\nconst foo = 1; // Never used\n\n// \u2705 Good\nconst _foo = 1; // Prefix with _ to ignore\n

@typescript-eslint/no-floating-promises - Requires await/catch

// \u274c Bad\nasyncFunction(); // Promise not handled\n\n// \u2705 Good\nawait asyncFunction();\nasyncFunction().catch(console.error);\nvoid asyncFunction(); // Explicitly ignore\n

react-hooks/exhaustive-deps - Validates useEffect dependencies

// \u274c Bad\nuseEffect(() => {\n  fetchUser(userId);\n}, []); // Missing userId dependency\n\n// \u2705 Good\nuseEffect(() => {\n  fetchUser(userId);\n}, [userId]);\n

"},{"location":"v2/development/code-style/#prettier-configuration","title":"Prettier Configuration","text":""},{"location":"v2/development/code-style/#prettierrc","title":".prettierrc","text":"
{\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"tabWidth\": 2,\n  \"useTabs\": false,\n  \"trailingComma\": \"es5\",\n  \"printWidth\": 100,\n  \"arrowParens\": \"avoid\",\n  \"endOfLine\": \"lf\"\n}\n
"},{"location":"v2/development/code-style/#prettierignore","title":".prettierignore","text":"
node_modules\ndist\nbuild\ncoverage\n.vite\n.cache\n*.min.js\n*.min.css\npackage-lock.json\n
"},{"location":"v2/development/code-style/#format-commands","title":"Format Commands","text":"
# Format all files\nnpm run format\n\n# Check formatting (CI)\nnpm run format:check\n\n# Format specific file\nnpx prettier --write src/modules/auth/auth.service.ts\n
"},{"location":"v2/development/code-style/#naming-conventions","title":"Naming Conventions","text":""},{"location":"v2/development/code-style/#files-and-directories","title":"Files and Directories","text":"

Files: kebab-case

auth.service.ts\nuser.controller.ts\ncampaign.routes.ts\nlocations-page.tsx\n

Components: PascalCase

UserCard.tsx\nLoginForm.tsx\nMapView.tsx\n

Test files: Match source file with .test or .spec

auth.service.test.ts\nUserCard.test.tsx\n

Directories: kebab-case

src/modules/auth/\nsrc/components/map/\nsrc/pages/public/\n

"},{"location":"v2/development/code-style/#variables-and-functions","title":"Variables and Functions","text":"

Variables: camelCase

const userName = 'John';\nconst isActive = true;\nconst totalCount = 100;\n

Constants: UPPER_SNAKE_CASE

const API_URL = 'http://localhost:4000';\nconst MAX_RETRIES = 3;\nconst DEFAULT_PAGE_SIZE = 50;\n

Functions: camelCase

function getUserById(id: number) {}\nasync function fetchCampaigns() {}\nconst handleClick = () => {};\n

Private methods: Prefix with underscore (optional)

class UserService {\n  async getUser(id: number) {}\n\n  private async _hashPassword(password: string) {}\n}\n

"},{"location":"v2/development/code-style/#types-and-interfaces","title":"Types and Interfaces","text":"

Types/Interfaces: PascalCase

interface User {\n  id: number;\n  email: string;\n}\n\ntype UserRole = 'USER' | 'ADMIN';\n\ninterface CreateUserInput {\n  email: string;\n  password: string;\n}\n

Enums: PascalCase, members UPPER_SNAKE_CASE

enum UserRole {\n  USER = 'USER',\n  ADMIN = 'ADMIN',\n  SUPER_ADMIN = 'SUPER_ADMIN'\n}\n

"},{"location":"v2/development/code-style/#react-components","title":"React Components","text":"

Components: PascalCase

export function UserCard({ user }: { user: User }) {\n  return <div>{user.name}</div>;\n}\n

Props interfaces: ComponentNameProps

interface UserCardProps {\n  user: User;\n  onEdit?: (user: User) => void;\n}\n\nexport function UserCard({ user, onEdit }: UserCardProps) {\n  return <div>{user.name}</div>;\n}\n

Event handlers: handle[Event] or on[Event]

function UserForm() {\n  const handleSubmit = () => {};\n  const onEmailChange = (email: string) => {};\n\n  return <form onSubmit={handleSubmit}>...</form>;\n}\n

"},{"location":"v2/development/code-style/#database-models","title":"Database Models","text":"

Prisma models: PascalCase (singular)

model User {\n  id    Int    @id @default(autoincrement())\n  email String @unique\n}\n\nmodel Campaign {\n  id    Int    @id @default(autoincrement())\n  title String\n}\n

Table names: snake_case (plural)

model User {\n  @@map(\"users\")\n}\n\nmodel Campaign {\n  @@map(\"campaigns\")\n}\n

Fields: camelCase in schema, snake_case in database

model User {\n  createdAt DateTime @default(now()) @map(\"created_at\")\n  updatedAt DateTime @updatedAt @map(\"updated_at\")\n}\n

"},{"location":"v2/development/code-style/#file-organization","title":"File Organization","text":""},{"location":"v2/development/code-style/#module-structure","title":"Module Structure","text":"
src/modules/auth/\n\u251c\u2500\u2500 auth.service.ts        # Business logic\n\u251c\u2500\u2500 auth.routes.ts         # Express routes\n\u251c\u2500\u2500 auth.schemas.ts        # Zod validation schemas\n\u2514\u2500\u2500 auth.service.test.ts   # Tests\n
"},{"location":"v2/development/code-style/#import-order","title":"Import Order","text":"
  1. External libraries
  2. Internal modules (absolute imports)
  3. Relative imports
  4. Types
  5. Styles (frontend)
// 1. External libraries\nimport express from 'express';\nimport { z } from 'zod';\n\n// 2. Internal modules\nimport { authenticate } from '@/middleware/auth';\nimport { UserService } from '@/modules/users/user.service';\n\n// 3. Relative imports\nimport { AuthService } from './auth.service';\nimport { loginSchema } from './auth.schemas';\n\n// 4. Types\nimport type { Request, Response } from 'express';\nimport type { User } from '@prisma/client';\n\n// 5. Styles (frontend only)\nimport './auth.css';\n
"},{"location":"v2/development/code-style/#export-patterns","title":"Export Patterns","text":"

Named exports (preferred)

// auth.service.ts\nexport class AuthService {\n  async login() {}\n}\n\n// usage\nimport { AuthService } from './auth.service';\n

Default exports (React components)

// UserCard.tsx\nexport default function UserCard() {\n  return <div>...</div>;\n}\n\n// usage\nimport UserCard from './UserCard';\n

Re-exports (index files)

// modules/auth/index.ts\nexport { AuthService } from './auth.service';\nexport { authRoutes } from './auth.routes';\nexport * from './auth.schemas';\n

"},{"location":"v2/development/code-style/#code-patterns","title":"Code Patterns","text":""},{"location":"v2/development/code-style/#asyncawait","title":"Async/Await","text":"

Always use async/await (not callbacks or .then()):

Good:

async function getUser(id: number) {\n  const user = await prisma.user.findUnique({ where: { id } });\n  return user;\n}\n

Bad:

function getUser(id: number) {\n  return prisma.user.findUnique({ where: { id } }).then(user => {\n    return user;\n  });\n}\n

"},{"location":"v2/development/code-style/#error-handling","title":"Error Handling","text":"

Use try/catch for error handling:

Good:

async function createUser(data: CreateUserInput) {\n  try {\n    const user = await prisma.user.create({ data });\n    return user;\n  } catch (error) {\n    logger.error('Failed to create user', error);\n    throw new Error('User creation failed');\n  }\n}\n

Bad:

async function createUser(data: CreateUserInput) {\n  const user = await prisma.user.create({ data }); // Unhandled error\n  return user;\n}\n

"},{"location":"v2/development/code-style/#optional-chaining","title":"Optional Chaining","text":"

Use optional chaining for nullable values:

Good:

const email = user?.email;\nconst city = user?.address?.city;\n

Bad:

const email = user && user.email;\nconst city = user && user.address && user.address.city;\n

"},{"location":"v2/development/code-style/#nullish-coalescing","title":"Nullish Coalescing","text":"

Use ?? for default values (not ||):

Good:

const limit = query.limit ?? 50;\nconst name = user.name ?? 'Unknown';\n

Bad:

const limit = query.limit || 50; // Fails for 0\nconst name = user.name || 'Unknown'; // Fails for ''\n

"},{"location":"v2/development/code-style/#array-methods","title":"Array Methods","text":"

Prefer functional array methods:

Good:

const activeUsers = users.filter(u => u.isActive);\nconst emails = users.map(u => u.email);\nconst total = amounts.reduce((sum, amt) => sum + amt, 0);\n

Bad:

const activeUsers = [];\nfor (let i = 0; i < users.length; i++) {\n  if (users[i].isActive) {\n    activeUsers.push(users[i]);\n  }\n}\n

"},{"location":"v2/development/code-style/#object-destructuring","title":"Object Destructuring","text":"

Use destructuring for object properties:

Good:

const { email, name, role } = user;\nconst { limit = 50, page = 1 } = query;\n

Bad:

const email = user.email;\nconst name = user.name;\nconst role = user.role;\n

"},{"location":"v2/development/code-style/#template-literals","title":"Template Literals","text":"

Use template literals for string interpolation:

Good:

const message = `Hello, ${user.name}!`;\nconst url = `/api/users/${userId}`;\n

Bad:

const message = 'Hello, ' + user.name + '!';\nconst url = '/api/users/' + userId;\n

"},{"location":"v2/development/code-style/#comments-and-documentation","title":"Comments and Documentation","text":""},{"location":"v2/development/code-style/#jsdoc-for-functions","title":"JSDoc for Functions","text":"

Document public functions with JSDoc:

/**\n * Creates a new user with the given email and password.\n *\n * @param email - User's email address\n * @param password - User's password (will be hashed)\n * @returns Created user object\n * @throws {Error} If user already exists\n */\nasync function createUser(email: string, password: string): Promise<User> {\n  // ...\n}\n
"},{"location":"v2/development/code-style/#inline-comments","title":"Inline Comments","text":"

Use inline comments for complex logic:

// Calculate pagination offset\nconst offset = (page - 1) * limit;\n\n// Hash password with 10 salt rounds\nconst hashedPassword = await bcrypt.hash(password, 10);\n\n// Point-in-polygon ray-casting algorithm\nlet inside = false;\nfor (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {\n  // ... complex logic\n}\n
"},{"location":"v2/development/code-style/#avoid-obvious-comments","title":"Avoid Obvious Comments","text":"

Don't comment obvious code:

Good:

const isValid = email.includes('@');\n

Bad:

// Check if email is valid\nconst isValid = email.includes('@');\n

"},{"location":"v2/development/code-style/#todo-comments","title":"TODO Comments","text":"

Use TODO for future work:

// TODO: Add pagination support\nasync function getUsers() {\n  return prisma.user.findMany();\n}\n\n// FIXME: This doesn't handle edge case when user is null\nconst userName = user.name;\n
"},{"location":"v2/development/code-style/#git-commit-messages","title":"Git Commit Messages","text":""},{"location":"v2/development/code-style/#conventional-commits","title":"Conventional Commits","text":"

Use conventional commit format:

<type>(<scope>): <subject>\n\n<body>\n\n<footer>\n

Types: - feat: New feature - fix: Bug fix - docs: Documentation - style: Formatting - refactor: Code restructuring - test: Adding tests - chore: Maintenance

Examples:

feat(auth): add JWT refresh token rotation\nfix(map): correct point-in-polygon calculation\ndocs(api): update authentication guide\nrefactor(users): extract service layer\ntest(campaigns): add unit tests for CRUD operations\n

With scope and body:

git commit -m \"feat(campaigns): add email sending\n\nImplements BullMQ queue for async email delivery.\nAdds retry logic and error handling.\n\nCloses #123\"\n

"},{"location":"v2/development/code-style/#co-authoring-with-claude","title":"Co-Authoring with Claude","text":"

When Claude assists with code:

git commit -m \"$(cat <<'EOF'\nfeat(auth): add JWT refresh token rotation\n\nImplemented atomic refresh token rotation to prevent\nrace conditions during concurrent refresh requests.\n\nCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>\nEOF\n)\"\n
"},{"location":"v2/development/code-style/#react-patterns","title":"React Patterns","text":""},{"location":"v2/development/code-style/#functional-components","title":"Functional Components","text":"

Always use functional components (not class components):

Good:

export function UserCard({ user }: UserCardProps) {\n  return <div>{user.name}</div>;\n}\n

Bad:

export class UserCard extends React.Component<UserCardProps> {\n  render() {\n    return <div>{this.props.user.name}</div>;\n  }\n}\n

"},{"location":"v2/development/code-style/#hooks","title":"Hooks","text":"

Use hooks for state and side effects:

function UserList() {\n  const [users, setUsers] = useState<User[]>([]);\n  const [loading, setLoading] = useState(false);\n\n  useEffect(() => {\n    async function fetchUsers() {\n      setLoading(true);\n      const data = await api.get('/users');\n      setUsers(data);\n      setLoading(false);\n    }\n    fetchUsers();\n  }, []);\n\n  if (loading) return <div>Loading...</div>;\n\n  return <div>{users.map(u => <UserCard key={u.id} user={u} />)}</div>;\n}\n
"},{"location":"v2/development/code-style/#props-destructuring","title":"Props Destructuring","text":"

Destructure props in function signature:

Good:

function UserCard({ user, onEdit }: UserCardProps) {\n  return <div onClick={() => onEdit?.(user)}>{user.name}</div>;\n}\n

Bad:

function UserCard(props: UserCardProps) {\n  return <div onClick={() => props.onEdit?.(props.user)}>{props.user.name}</div>;\n}\n

"},{"location":"v2/development/code-style/#key-prop","title":"Key Prop","text":"

Always provide key for list items:

Good:

{users.map(user => (\n  <UserCard key={user.id} user={user} />\n))}\n

Bad:

{users.map((user, index) => (\n  <UserCard key={index} user={user} />\n))}\n

"},{"location":"v2/development/code-style/#editor-integration","title":"Editor Integration","text":""},{"location":"v2/development/code-style/#vscode-settings","title":"VSCode Settings","text":"

Create .vscode/settings.json:

{\n  \"editor.formatOnSave\": true,\n  \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n  \"editor.codeActionsOnSave\": {\n    \"source.fixAll.eslint\": true\n  },\n  \"typescript.tsdk\": \"node_modules/typescript/lib\",\n  \"typescript.enablePromptUseWorkspaceTsdk\": true,\n  \"[typescript]\": {\n    \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n  },\n  \"[typescriptreact]\": {\n    \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n  }\n}\n
"},{"location":"v2/development/code-style/#pre-commit-hook","title":"Pre-commit Hook","text":"

Install husky for pre-commit checks:

npm install --save-dev husky lint-staged\nnpx husky install\n

package.json:

{\n  \"lint-staged\": {\n    \"*.{ts,tsx}\": [\n      \"eslint --fix\",\n      \"prettier --write\"\n    ]\n  },\n  \"scripts\": {\n    \"prepare\": \"husky install\"\n  }\n}\n

.husky/pre-commit:

#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\nnpx lint-staged\n

"},{"location":"v2/development/code-style/#quick-reference","title":"Quick Reference","text":""},{"location":"v2/development/code-style/#run-linting","title":"Run Linting","text":"
# Lint\nnpm run lint\n\n# Auto-fix\nnpm run lint:fix\n\n# Format\nnpm run format\n\n# Type-check\nnpm run type-check\n
"},{"location":"v2/development/code-style/#common-fixes","title":"Common Fixes","text":"
# Fix all auto-fixable issues\nnpm run lint:fix && npm run format\n\n# Type-check both projects\ncd api && npm run type-check && cd ../admin && npm run type-check\n
"},{"location":"v2/development/code-style/#related-documentation","title":"Related Documentation","text":"
  • Setup: Local Development Setup
  • TypeScript: TypeScript Guide
  • Testing: Testing Guide
  • Git: Git Workflow
"},{"location":"v2/development/code-style/#summary","title":"Summary","text":"

You now know: - \u2705 TypeScript configuration (strict mode) - \u2705 ESLint rules and plugins - \u2705 Prettier configuration - \u2705 Naming conventions (files, variables, types) - \u2705 File organization patterns - \u2705 Code patterns (async/await, error handling) - \u2705 Comment and documentation standards - \u2705 Git commit message format - \u2705 React patterns and best practices - \u2705 Editor integration (VSCode, pre-commit hooks)

Quick Start:

# Auto-fix and format\nnpm run lint:fix && npm run format\n\n# Check types\nnpm run type-check\n\n# Pre-commit\nnpm run lint:fix && npm run format && npm run type-check\n

"},{"location":"v2/development/debugging/","title":"Debugging Guide","text":"

Comprehensive guide to debugging Changemaker Lite V2 applications, covering API, frontend, database, and Docker debugging techniques.

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

Effective debugging requires: - Understanding the tools (VSCode, Chrome DevTools, logs) - Systematic approach (reproduce, isolate, fix, verify) - Knowledge of common issues

This guide covers debugging strategies for all parts of V2.

"},{"location":"v2/development/debugging/#api-debugging","title":"API Debugging","text":""},{"location":"v2/development/debugging/#vscode-debugging","title":"VSCode Debugging","text":""},{"location":"v2/development/debugging/#launch-configuration","title":"Launch Configuration","text":"

Create .vscode/launch.json:

{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"name\": \"Debug API\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"runtimeExecutable\": \"npm\",\n      \"runtimeArgs\": [\"run\", \"dev\"],\n      \"cwd\": \"${workspaceFolder}/api\",\n      \"console\": \"integratedTerminal\",\n      \"skipFiles\": [\"<node_internals>/**\"],\n      \"envFile\": \"${workspaceFolder}/.env\",\n      \"sourceMaps\": true,\n      \"restart\": true,\n      \"protocol\": \"inspector\"\n    },\n    {\n      \"name\": \"Debug Media API\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"runtimeExecutable\": \"npm\",\n      \"runtimeArgs\": [\"run\", \"dev:media\"],\n      \"cwd\": \"${workspaceFolder}/api\",\n      \"console\": \"integratedTerminal\",\n      \"skipFiles\": [\"<node_internals>/**\"],\n      \"envFile\": \"${workspaceFolder}/.env\"\n    },\n    {\n      \"name\": \"Attach to API (Docker)\",\n      \"type\": \"node\",\n      \"request\": \"attach\",\n      \"port\": 9229,\n      \"address\": \"localhost\",\n      \"restart\": true,\n      \"sourceMaps\": true,\n      \"localRoot\": \"${workspaceFolder}/api\",\n      \"remoteRoot\": \"/app\",\n      \"skipFiles\": [\"<node_internals>/**\"]\n    }\n  ]\n}\n
"},{"location":"v2/development/debugging/#start-debugging","title":"Start Debugging","text":"
  1. Open VSCode
  2. Open Run and Debug panel (Cmd+Shift+D / Ctrl+Shift+D)
  3. Select \"Debug API\" configuration
  4. Press F5 to start debugging
  5. API starts with debugger attached
"},{"location":"v2/development/debugging/#set-breakpoints","title":"Set Breakpoints","text":"

Click line number gutter to set breakpoint:

// api/src/modules/auth/auth.service.ts\nasync login(email: string, password: string) {\n  const user = await this.prisma.user.findUnique({ // \u2190 Click here\n    where: { email }\n  });\n\n  if (!user) {\n    throw new Error('User not found'); // \u2190 Or here\n  }\n\n  // Breakpoint pauses execution\n  const isValid = await bcrypt.compare(password, user.password);\n\n  return { user, tokens: this.generateTokens(user) };\n}\n
"},{"location":"v2/development/debugging/#debug-features","title":"Debug Features","text":"

Step Controls: - F10: Step over (next line) - F11: Step into (enter function) - Shift+F11: Step out (exit function) - F5: Continue (run to next breakpoint)

Inspect Variables: - Hover over variable to see value - Use \"Variables\" panel to see all local variables - Use \"Watch\" panel to monitor specific expressions

Debug Console: - Evaluate expressions while paused - Call functions with current scope

// In debug console (while paused)\n> user.email\n'john@example.com'\n\n> bcrypt.compare('test', user.password)\nPromise { <pending> }\n\n> await bcrypt.compare('test', user.password)\nfalse\n

Call Stack: - See function call hierarchy - Click stack frame to jump to code - Useful for understanding execution flow

"},{"location":"v2/development/debugging/#logging-winston","title":"Logging (Winston)","text":""},{"location":"v2/development/debugging/#using-logger","title":"Using Logger","text":"
import { logger } from '../../utils/logger';\n\n// Info level\nlogger.info('User logged in', { userId: user.id, email: user.email });\n\n// Error level\nlogger.error('Failed to create user', {\n  error: error.message,\n  stack: error.stack,\n  email\n});\n\n// Warn level\nlogger.warn('Deprecated endpoint accessed', { endpoint: req.path });\n\n// Debug level (only in development)\nlogger.debug('Processing request', {\n  method: req.method,\n  path: req.path,\n  query: req.query\n});\n
"},{"location":"v2/development/debugging/#log-output","title":"Log Output","text":"

Development (console):

[2026-02-13 10:30:45] INFO: User logged in {\"userId\":1,\"email\":\"john@example.com\"}\n[2026-02-13 10:30:46] ERROR: Failed to create user {\"error\":\"Email already exists\",\"email\":\"john@example.com\"}\n

Production (JSON):

{\"level\":\"info\",\"message\":\"User logged in\",\"userId\":1,\"email\":\"john@example.com\",\"timestamp\":\"2026-02-13T10:30:45.123Z\"}\n{\"level\":\"error\",\"message\":\"Failed to create user\",\"error\":\"Email already exists\",\"email\":\"john@example.com\",\"timestamp\":\"2026-02-13T10:30:46.456Z\"}\n

"},{"location":"v2/development/debugging/#log-levels","title":"Log Levels","text":"

Set log level via environment:

# .env\nLOG_LEVEL=debug  # dev: debug, info, warn, error\nLOG_LEVEL=info   # prod: info, warn, error\n
"},{"location":"v2/development/debugging/#database-query-logging","title":"Database Query Logging","text":""},{"location":"v2/development/debugging/#prisma-query-logging","title":"Prisma Query Logging","text":"

Enable in Prisma Client:

// api/src/config/prisma.ts\nconst prisma = new PrismaClient({\n  log: [\n    { emit: 'event', level: 'query' },\n    { emit: 'event', level: 'error' },\n    { emit: 'event', level: 'warn' }\n  ]\n});\n\nprisma.$on('query', (e) => {\n  logger.debug('Prisma query', {\n    query: e.query,\n    params: e.params,\n    duration: e.duration\n  });\n});\n\nprisma.$on('error', (e) => {\n  logger.error('Prisma error', { target: e.target, message: e.message });\n});\n

Output:

[2026-02-13 10:30:45] DEBUG: Prisma query {\n  \"query\": \"SELECT * FROM users WHERE id = $1\",\n  \"params\": \"[1]\",\n  \"duration\": 5\n}\n

"},{"location":"v2/development/debugging/#slow-query-logging","title":"Slow Query Logging","text":"

Log slow queries:

prisma.$on('query', (e) => {\n  if (e.duration > 100) { // > 100ms\n    logger.warn('Slow query detected', {\n      query: e.query,\n      duration: e.duration,\n      params: e.params\n    });\n  }\n});\n
"},{"location":"v2/development/debugging/#network-debugging","title":"Network Debugging","text":""},{"location":"v2/development/debugging/#request-logging","title":"Request Logging","text":"

Log all HTTP requests:

// api/src/middleware/logger.ts\nimport { Request, Response, NextFunction } from 'express';\nimport { logger } from '../utils/logger';\n\nexport function requestLogger(req: Request, res: Response, next: NextFunction) {\n  const start = Date.now();\n\n  res.on('finish', () => {\n    const duration = Date.now() - start;\n\n    logger.info('HTTP request', {\n      method: req.method,\n      path: req.path,\n      status: res.statusCode,\n      duration,\n      ip: req.ip,\n      userAgent: req.get('user-agent')\n    });\n\n    if (duration > 1000) {\n      logger.warn('Slow request', {\n        method: req.method,\n        path: req.path,\n        duration\n      });\n    }\n  });\n\n  next();\n}\n\n// In server.ts\napp.use(requestLogger);\n
"},{"location":"v2/development/debugging/#testing-with-curl","title":"Testing with curl","text":"
# GET request\ncurl http://localhost:4000/api/users\n\n# POST request with JSON\ncurl -X POST http://localhost:4000/api/auth/login \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\":\"admin@example.com\",\"password\":\"Admin123!\"}'\n\n# With authentication\ncurl http://localhost:4000/api/users \\\n  -H \"Authorization: Bearer <token>\"\n\n# Verbose output (see headers)\ncurl -v http://localhost:4000/api/users\n\n# Save response to file\ncurl http://localhost:4000/api/users > users.json\n
"},{"location":"v2/development/debugging/#testing-with-httpie","title":"Testing with HTTPie","text":"
# Install httpie\nbrew install httpie  # macOS\nsudo apt install httpie  # Linux\n\n# GET request\nhttp localhost:4000/api/users\n\n# POST request\nhttp POST localhost:4000/api/auth/login \\\n  email=admin@example.com \\\n  password=Admin123!\n\n# With authentication\nhttp localhost:4000/api/users \\\n  Authorization:\"Bearer <token>\"\n\n# Pretty JSON output\nhttp --pretty=all localhost:4000/api/users\n
"},{"location":"v2/development/debugging/#frontend-debugging","title":"Frontend Debugging","text":""},{"location":"v2/development/debugging/#chrome-devtools","title":"Chrome DevTools","text":""},{"location":"v2/development/debugging/#opening-devtools","title":"Opening DevTools","text":"
  • F12 or Cmd+Option+I (Mac) / Ctrl+Shift+I (Windows/Linux)
"},{"location":"v2/development/debugging/#console-tab","title":"Console Tab","text":"

View console logs and errors:

// admin/src/pages/UsersPage.tsx\nconsole.log('Users loaded', users);\nconsole.error('Failed to fetch users', error);\nconsole.warn('Deprecated API used');\nconsole.table(users); // Display as table\n\n// Conditional logging\nif (import.meta.env.DEV) {\n  console.log('Debug info', { users, loading });\n}\n

Output:

Users loaded [{ id: 1, email: 'john@example.com' }, ...]\n

"},{"location":"v2/development/debugging/#sources-tab","title":"Sources Tab","text":"

Debug JavaScript/TypeScript:

  1. Open Sources tab
  2. Find file in file tree (webpack://./src/)
  3. Click line number to set breakpoint
  4. Interact with UI to trigger breakpoint
  5. Use step controls (same as VSCode)

Conditional Breakpoints: - Right-click line number - Select \"Add conditional breakpoint\" - Enter condition: user.id === 1 - Pauses only when condition is true

"},{"location":"v2/development/debugging/#network-tab","title":"Network Tab","text":"

Debug API calls:

  1. Open Network tab
  2. Filter by \"Fetch/XHR\"
  3. Interact with UI
  4. Click request to see:
  5. Headers (request/response)
  6. Payload (request body)
  7. Preview (formatted response)
  8. Response (raw response)
  9. Timing (request duration)

Common Issues: - 404 Not Found: Check URL path - 401 Unauthorized: Check token/auth header - 500 Server Error: Check API logs - CORS Error: Check CORS_ORIGIN setting

"},{"location":"v2/development/debugging/#application-tab","title":"Application Tab","text":"

Inspect storage:

  • Local Storage: See persisted auth tokens
  • Session Storage: See session data
  • Cookies: See cookies
  • Cache Storage: See cached resources
// View in console\nlocalStorage.getItem('auth-token');\nsessionStorage.getItem('cart');\n
"},{"location":"v2/development/debugging/#react-devtools","title":"React DevTools","text":""},{"location":"v2/development/debugging/#installation","title":"Installation","text":"

Install browser extension: - Chrome - Firefox

"},{"location":"v2/development/debugging/#components-tab","title":"Components Tab","text":"

Inspect React component tree:

  1. Open DevTools
  2. Go to \"Components\" tab
  3. Select component from tree
  4. View:
  5. Props
  6. State (hooks)
  7. Context
  8. Owner (parent component)

Edit Props/State: - Click value to edit - Change takes effect immediately - Useful for testing edge cases

"},{"location":"v2/development/debugging/#profiler-tab","title":"Profiler Tab","text":"

Profile component renders:

  1. Go to \"Profiler\" tab
  2. Click \"Record\"
  3. Interact with UI
  4. Click \"Stop\"
  5. See:
  6. Flame graph (render hierarchy)
  7. Ranked chart (slowest components)
  8. Component details (render duration)

Identify Performance Issues: - Components rendering too often - Slow component renders - Unnecessary re-renders

"},{"location":"v2/development/debugging/#zustand-devtools","title":"Zustand DevTools","text":""},{"location":"v2/development/debugging/#enable-redux-devtools","title":"Enable Redux DevTools","text":"

Already configured in stores:

// admin/src/stores/auth.store.ts\nimport { create } from 'zustand';\nimport { devtools } from 'zustand/middleware';\n\nexport const useAuthStore = create<AuthState>()(\n  devtools(\n    (set, get) => ({\n      user: null,\n      isAuthenticated: false,\n      setUser: (user) => set({ user, isAuthenticated: !!user }),\n      logout: () => set({ user: null, isAuthenticated: false })\n    }),\n    { name: 'AuthStore' } // Name in DevTools\n  )\n);\n
"},{"location":"v2/development/debugging/#using-redux-devtools","title":"Using Redux DevTools","text":"
  1. Install Redux DevTools extension
  2. Open DevTools
  3. Go to \"Redux\" tab
  4. Select store from dropdown (AuthStore, CanvassStore)
  5. View:
  6. State tree
  7. Action history
  8. State diff

Features: - Time-travel debugging (jump to previous state) - Action replay - State export/import

"},{"location":"v2/development/debugging/#vscode-debugging-frontend","title":"VSCode Debugging (Frontend)","text":""},{"location":"v2/development/debugging/#launch-configuration_1","title":"Launch Configuration","text":"
{\n  \"name\": \"Debug Admin (Chrome)\",\n  \"type\": \"chrome\",\n  \"request\": \"launch\",\n  \"url\": \"http://localhost:3000\",\n  \"webRoot\": \"${workspaceFolder}/admin/src\",\n  \"sourceMapPathOverrides\": {\n    \"webpack:///./*\": \"${webRoot}/*\",\n    \"webpack:///src/*\": \"${webRoot}/*\",\n    \"webpack:///*\": \"*\"\n  },\n  \"userDataDir\": false\n}\n

Start Debugging: 1. Start Admin dev server: npm run dev 2. Select \"Debug Admin (Chrome)\" in VSCode 3. Press F5 4. Chrome opens with debugger attached 5. Set breakpoints in VSCode 6. Breakpoints hit when code executes

"},{"location":"v2/development/debugging/#database-debugging","title":"Database Debugging","text":""},{"location":"v2/development/debugging/#prisma-studio","title":"Prisma Studio","text":"

Visual database browser:

# Start Prisma Studio\ncd api\nnpx prisma studio\n

Features: - Browse all tables - Filter and sort data - Edit records directly - Create new records - Delete records

Use Cases: - Inspect database state - Manual data fixes - Verify migrations - Test queries

"},{"location":"v2/development/debugging/#postgresql-shell","title":"PostgreSQL Shell","text":"

Direct database access:

# Connect to database\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db\n\n# List tables\n\\dt\n\n# Describe table\n\\d users\n\n# Run query\nSELECT * FROM users WHERE role = 'SUPER_ADMIN';\n\n# Count records\nSELECT COUNT(*) FROM campaigns;\n\n# Exit\n\\q\n

Common Queries:

-- Find user by email\nSELECT * FROM users WHERE email = 'admin@example.com';\n\n-- Count users by role\nSELECT role, COUNT(*) FROM users GROUP BY role;\n\n-- Recent campaigns\nSELECT * FROM campaigns ORDER BY created_at DESC LIMIT 10;\n\n-- Users without name\nSELECT * FROM users WHERE name IS NULL;\n\n-- Delete test data\nDELETE FROM users WHERE email LIKE '%test%';\n
"},{"location":"v2/development/debugging/#query-analysis","title":"Query Analysis","text":""},{"location":"v2/development/debugging/#explain-query-plan","title":"Explain Query Plan","text":"
EXPLAIN ANALYZE\nSELECT * FROM users WHERE email = 'admin@example.com';\n

Output:

Index Scan using users_email_key on users (cost=0.28..8.29 rows=1 width=...)\n  Index Cond: (email = 'admin@example.com'::text)\n  Planning Time: 0.123 ms\n  Execution Time: 0.045 ms\n

Identify Issues: - Sequential scans (slow on large tables) - Missing indexes - Expensive joins

"},{"location":"v2/development/debugging/#slow-query-log","title":"Slow Query Log","text":"

Enable slow query logging:

-- Set log threshold (100ms)\nALTER DATABASE changemaker_v2_db SET log_min_duration_statement = 100;\n\n-- View slow queries in logs\ndocker compose logs v2-postgres | grep \"duration:\"\n
"},{"location":"v2/development/debugging/#docker-debugging","title":"Docker Debugging","text":""},{"location":"v2/development/debugging/#container-logs","title":"Container Logs","text":"

View container output:

# All services\ndocker compose logs -f\n\n# Specific service\ndocker compose logs -f api\n\n# Last 100 lines\ndocker compose logs --tail=100 api\n\n# With timestamps\ndocker compose logs -t -f api\n\n# Since specific time\ndocker compose logs --since 2024-01-01T10:00:00 api\n
"},{"location":"v2/development/debugging/#execute-commands-in-container","title":"Execute Commands in Container","text":"
# Shell access\ndocker compose exec api sh\n\n# Run command\ndocker compose exec api npm run type-check\n\n# Run as specific user\ndocker compose exec -u root api sh\n\n# Non-interactive command\ndocker compose exec -T api npm run lint\n
"},{"location":"v2/development/debugging/#inspect-container","title":"Inspect Container","text":"
# Container details\ndocker inspect api\n\n# Environment variables\ndocker inspect api | grep -A 20 \"Env\"\n\n# Mounts\ndocker inspect api | grep -A 50 \"Mounts\"\n\n# Network settings\ndocker inspect api | grep -A 20 \"Networks\"\n\n# Resource limits\ndocker inspect api | grep -A 10 \"Memory\"\n
"},{"location":"v2/development/debugging/#container-stats","title":"Container Stats","text":"
# Real-time stats\ndocker stats\n\n# Specific container\ndocker stats api\n\n# Format output\ndocker stats --format \"table {{.Container}}\\t{{.CPUPerc}}\\t{{.MemUsage}}\"\n
"},{"location":"v2/development/debugging/#network-debugging_1","title":"Network Debugging","text":"
# Test connectivity between containers\ndocker compose exec api ping v2-postgres\ndocker compose exec api ping redis\n\n# Check listening ports\ndocker compose exec api netstat -tuln\n\n# Test HTTP endpoint from inside container\ndocker compose exec api wget -O- http://localhost:4000/health\n\n# DNS lookup\ndocker compose exec api nslookup v2-postgres\n
"},{"location":"v2/development/debugging/#common-issues","title":"Common Issues","text":""},{"location":"v2/development/debugging/#401-unauthorized","title":"401 Unauthorized","text":"

Symptoms: API returns 401 for authenticated requests.

Causes: 1. Token expired 2. Invalid token 3. Missing Authorization header 4. Token format incorrect

Debug:

# Check token in browser DevTools\nlocalStorage.getItem('auth-token')\n\n# Test token with curl\ncurl http://localhost:4000/api/users \\\n  -H \"Authorization: Bearer <token>\" \\\n  -v\n\n# Decode JWT (jwt.io)\n# Check expiration (exp claim)\n

Fix: - Refresh token - Re-login - Check token format (Bearer prefix)

"},{"location":"v2/development/debugging/#500-internal-server-error","title":"500 Internal Server Error","text":"

Symptoms: API returns 500 error.

Causes: 1. Unhandled exception 2. Database error 3. External service failure

Debug:

# Check API logs\ndocker compose logs -f api\n\n# Look for error stack trace\ndocker compose logs api | grep -A 20 \"Error:\"\n\n# Check database connection\ndocker compose exec api npx prisma db execute --stdin <<< \"SELECT 1\"\n

Fix: - Check error message in logs - Verify database is running - Check external service (Redis, SMTP, etc.)

"},{"location":"v2/development/debugging/#cors-errors","title":"CORS Errors","text":"

Symptoms: Browser blocks request with CORS error.

Causes: 1. Incorrect CORS_ORIGIN setting 2. Missing CORS headers 3. Preflight OPTIONS request fails

Debug:

# Check CORS_ORIGIN in .env\ngrep CORS_ORIGIN .env\n\n# Test with curl (bypasses CORS)\ncurl http://localhost:4000/api/users\n\n# Check preflight request\ncurl -X OPTIONS http://localhost:4000/api/users \\\n  -H \"Origin: http://localhost:3000\" \\\n  -H \"Access-Control-Request-Method: GET\" \\\n  -v\n

Fix: - Set CORS_ORIGIN=http://localhost:3000 in .env - Restart API: docker compose restart api

"},{"location":"v2/development/debugging/#database-connection-errors","title":"Database Connection Errors","text":"

Symptoms: API fails to connect to database.

Causes: 1. PostgreSQL not running 2. Incorrect DATABASE_URL 3. Network issue

Debug:

# Check PostgreSQL is running\ndocker compose ps v2-postgres\n\n# Check DATABASE_URL\ndocker compose exec api sh -c 'echo $DATABASE_URL'\n\n# Test connection\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"SELECT 1\"\n\n# Check logs\ndocker compose logs v2-postgres\n

Fix: - Start PostgreSQL: docker compose up -d v2-postgres - Verify DATABASE_URL matches docker-compose.yml - Check password in .env

"},{"location":"v2/development/debugging/#redis-connection-errors","title":"Redis Connection Errors","text":"

Symptoms: API fails to connect to Redis.

Causes: 1. Redis not running 2. Incorrect REDIS_URL 3. Missing REDIS_PASSWORD

Debug:

# Check Redis is running\ndocker compose ps redis\n\n# Test connection\ndocker compose exec redis redis-cli -a your_password ping\n\n# Check Redis logs\ndocker compose logs redis\n

Fix: - Start Redis: docker compose up -d redis - Set REDIS_PASSWORD in .env - Update REDIS_URL with password

"},{"location":"v2/development/debugging/#hot-reload-not-working","title":"Hot Reload Not Working","text":"

Symptoms: Code changes don't trigger reload.

Causes: 1. Volume mount missing 2. File watcher not detecting changes 3. Build cache issue

Debug:

# Check volume mounts\ndocker inspect api | grep -A 20 \"Mounts\"\n\n# Test file sync\ndocker compose exec api ls -la /app/src\n\n# Check for .dockerignore blocking sync\ncat api/.dockerignore\n

Fix: - Verify volume mount in docker-compose.yml - Restart container: docker compose restart api - Clear cache: rm -rf api/dist && docker compose restart api

"},{"location":"v2/development/debugging/#debug-checklist","title":"Debug Checklist","text":""},{"location":"v2/development/debugging/#systematic-debugging-approach","title":"Systematic Debugging Approach","text":"
  1. Reproduce:
  2. Can you consistently reproduce the issue?
  3. What are the exact steps?

  4. Isolate:

  5. Does it happen in all environments?
  6. Is it specific to one user/data/scenario?

  7. Gather Information:

  8. Check logs (API, frontend, database)
  9. Check network requests (DevTools)
  10. Check error messages

  11. Form Hypothesis:

  12. What do you think is causing it?
  13. What evidence supports this?

  14. Test Hypothesis:

  15. Set breakpoints
  16. Add logging
  17. Test specific scenario

  18. Fix:

  19. Make minimal change to fix issue
  20. Don't fix multiple issues at once

  21. Verify:

  22. Re-test original scenario
  23. Test related functionality
  24. Check for side effects

  25. Prevent:

  26. Add tests to catch regression
  27. Update documentation
  28. Share learnings with team
"},{"location":"v2/development/debugging/#performance-debugging","title":"Performance Debugging","text":""},{"location":"v2/development/debugging/#api-response-time","title":"API Response Time","text":"
// Measure endpoint performance\napp.get('/users', async (req, res) => {\n  const start = Date.now();\n\n  const users = await prisma.user.findMany();\n\n  const duration = Date.now() - start;\n  logger.info('Users endpoint', { duration, count: users.length });\n\n  res.json({ users });\n});\n
"},{"location":"v2/development/debugging/#database-query-performance","title":"Database Query Performance","text":"
// Log slow queries\nprisma.$on('query', (e) => {\n  if (e.duration > 100) {\n    logger.warn('Slow query', {\n      query: e.query,\n      duration: e.duration,\n      params: e.params\n    });\n  }\n});\n
"},{"location":"v2/development/debugging/#frontend-render-performance","title":"Frontend Render Performance","text":"
// Measure component render time\nfunction UserList() {\n  const renderStart = performance.now();\n\n  useEffect(() => {\n    const renderTime = performance.now() - renderStart;\n    if (renderTime > 16) { // > 1 frame (60fps)\n      console.warn('Slow render', { component: 'UserList', renderTime });\n    }\n  });\n\n  return <div>...</div>;\n}\n
"},{"location":"v2/development/debugging/#related-documentation","title":"Related Documentation","text":"
  • Setup: Local Development Setup
  • Docker: Docker Workflow
  • Testing: Testing Guide
  • Troubleshooting: Troubleshooting Guide
"},{"location":"v2/development/debugging/#summary","title":"Summary","text":"

You now know: - \u2705 How to debug API with VSCode - \u2705 How to use Winston logging effectively - \u2705 How to debug frontend with Chrome DevTools - \u2705 How to use React DevTools and Zustand DevTools - \u2705 How to debug database with Prisma Studio and psql - \u2705 How to debug Docker containers - \u2705 Common issues and their solutions - \u2705 Systematic debugging approach - \u2705 Performance debugging techniques

Quick Start:

# API debugging\ncd api && npm run dev  # Start with debugger\n# Set breakpoints in VSCode, press F5\n\n# Frontend debugging\n# Open Chrome DevTools (F12)\n# Network tab for API calls, Console for logs\n\n# Database debugging\nnpx prisma studio  # Visual browser\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db\n\n# Logs\ndocker compose logs -f api admin\n

"},{"location":"v2/development/docker-workflow/","title":"Docker Development Workflow","text":"

Guide to developing Changemaker Lite V2 using Docker containers for consistent, reproducible development environments.

"},{"location":"v2/development/docker-workflow/#overview","title":"Overview","text":"

Docker-based development provides:

  • Consistency: Same environment across all developer machines
  • Isolation: Services don't interfere with host system
  • Production Parity: Dev environment matches production
  • Easy Reset: Rebuild containers for clean state

This guide covers Docker development workflows, from basic container operations to advanced debugging techniques.

"},{"location":"v2/development/docker-workflow/#docker-vs-local-development","title":"Docker vs Local Development","text":""},{"location":"v2/development/docker-workflow/#when-to-use-docker","title":"When to Use Docker","text":"

Advantages: - Consistent Node.js/PostgreSQL/Redis versions - No need to install services on host machine - Easy onboarding for new developers - Production-like environment - Volume mounts still support hot reload

Disadvantages: - Slightly slower hot reload (especially macOS/Windows) - More complex debugging setup - Volume mount performance overhead - Larger disk space usage

"},{"location":"v2/development/docker-workflow/#when-to-use-local-npm","title":"When to Use Local npm","text":"

Advantages: - Faster hot reload (native file system) - Direct access to Node.js processes - Simpler debugging (VSCode attach) - Better performance on macOS/Windows

Disadvantages: - Must install Node.js, PostgreSQL, Redis locally - Version inconsistencies between developers - Host system configuration required

"},{"location":"v2/development/docker-workflow/#hybrid-approach-recommended","title":"Hybrid Approach (Recommended)","text":"

Run databases in Docker, API/Admin locally:

# Docker: Databases only\ndocker compose up -d v2-postgres redis mailhog\n\n# Local: Development servers\ncd api && npm run dev\ncd admin && npm run dev\n

This combines benefits of both approaches.

"},{"location":"v2/development/docker-workflow/#starting-development-services","title":"Starting Development Services","text":""},{"location":"v2/development/docker-workflow/#full-docker-development","title":"Full Docker Development","text":"

Start all development services:

# Core services (API, Admin, Databases)\ndocker compose up -d api admin v2-postgres redis\n\n# Optional: MailHog for email testing\ndocker compose up -d mailhog\n\n# Optional: Media API\ndocker compose up -d media-api\n

Verify services started:

docker compose ps\n

Expected output:

NAME                  STATUS    PORTS\napi                   running   0.0.0.0:4000->4000/tcp\nadmin                 running   0.0.0.0:3000->3000/tcp\nv2-postgres           running   0.0.0.0:5433->5432/tcp\nredis                 running   0.0.0.0:6379->6379/tcp\nmailhog               running   0.0.0.0:1025->1025/tcp, 0.0.0.0:8025->8025/tcp\n

"},{"location":"v2/development/docker-workflow/#selective-service-start","title":"Selective Service Start","text":"

Start only what you need:

# Just databases (for local npm development)\ndocker compose up -d v2-postgres redis\n\n# Just API (admin running locally)\ndocker compose up -d api v2-postgres redis\n\n# Just Admin (API running locally)\ndocker compose up -d admin\n
"},{"location":"v2/development/docker-workflow/#start-with-monitoring-stack","title":"Start with Monitoring Stack","text":"

Enable monitoring services:

# Start with monitoring profile\ndocker compose --profile monitoring up -d\n\n# Or specific monitoring services\ndocker compose up -d prometheus grafana\n
"},{"location":"v2/development/docker-workflow/#watching-logs","title":"Watching Logs","text":""},{"location":"v2/development/docker-workflow/#view-service-logs","title":"View Service Logs","text":"

Real-time log streaming:

# All services\ndocker compose logs -f\n\n# Specific service\ndocker compose logs -f api\n\n# Multiple services\ndocker compose logs -f api admin\n\n# Last 100 lines, then follow\ndocker compose logs -f --tail=100 api\n

Log output example (API):

api  | Server running on port 4000\napi  | Database connected\napi  | Redis connected\napi  | BullMQ worker started\napi  | GET /api/users 200 45ms\n

Log output example (Admin):

admin  | VITE v5.x.x ready in 500 ms\nadmin  | \u279c  Local:   http://localhost:3000/\nadmin  | \u279c  Network: http://172.18.0.5:3000/\n

"},{"location":"v2/development/docker-workflow/#filter-logs","title":"Filter Logs","text":"

Use grep to filter log output:

# Show only errors\ndocker compose logs -f api | grep ERROR\n\n# Show only database queries\ndocker compose logs -f api | grep \"SELECT\\|INSERT\\|UPDATE\\|DELETE\"\n\n# Show only HTTP requests\ndocker compose logs -f api | grep \"GET\\|POST\\|PUT\\|DELETE\"\n
"},{"location":"v2/development/docker-workflow/#export-logs","title":"Export Logs","text":"

Save logs to file:

# All services\ndocker compose logs > logs.txt\n\n# Specific service with timestamps\ndocker compose logs -t api > api-logs.txt\n\n# Last 24 hours\ndocker compose logs --since 24h > recent-logs.txt\n
"},{"location":"v2/development/docker-workflow/#executing-commands-in-containers","title":"Executing Commands in Containers","text":""},{"location":"v2/development/docker-workflow/#using-docker-compose-exec","title":"Using docker compose exec","text":"

Run commands inside running containers:

# General syntax\ndocker compose exec <service> <command>\n\n# Examples:\ndocker compose exec api npm run type-check\ndocker compose exec admin npm run lint\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db\n
"},{"location":"v2/development/docker-workflow/#common-api-commands","title":"Common API Commands","text":"
# Type-check\ndocker compose exec api npm run type-check\n\n# Prisma migrate\ndocker compose exec api npx prisma migrate dev --name add_field\n\n# Prisma Studio\ndocker compose exec api npx prisma studio\n\n# Seed database\ndocker compose exec api npx prisma db seed\n\n# Drizzle push (Media API)\ndocker compose exec api npx drizzle-kit push\n\n# Node REPL\ndocker compose exec api node\n\n# Shell access\ndocker compose exec api sh\n
"},{"location":"v2/development/docker-workflow/#common-admin-commands","title":"Common Admin Commands","text":"
# Type-check\ndocker compose exec admin npm run type-check\n\n# Build\ndocker compose exec admin npm run build\n\n# Lint\ndocker compose exec admin npm run lint\n\n# Shell access\ndocker compose exec admin sh\n
"},{"location":"v2/development/docker-workflow/#database-commands","title":"Database Commands","text":"
# PostgreSQL shell\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db\n\n# Run SQL query\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"SELECT COUNT(*) FROM users;\"\n\n# Dump database\ndocker compose exec v2-postgres pg_dump -U changemaker_v2 changemaker_v2_db > backup.sql\n\n# Restore database\ncat backup.sql | docker compose exec -T v2-postgres psql -U changemaker_v2 changemaker_v2_db\n
"},{"location":"v2/development/docker-workflow/#redis-commands","title":"Redis Commands","text":"
# Redis CLI\ndocker compose exec redis redis-cli -a your_redis_password\n\n# Ping\ndocker compose exec redis redis-cli -a your_redis_password ping\n\n# Get all keys\ndocker compose exec redis redis-cli -a your_redis_password KEYS '*'\n\n# Monitor commands\ndocker compose exec redis redis-cli -a your_redis_password MONITOR\n
"},{"location":"v2/development/docker-workflow/#hot-reload-in-containers","title":"Hot Reload in Containers","text":""},{"location":"v2/development/docker-workflow/#how-volume-mounts-enable-hot-reload","title":"How Volume Mounts Enable Hot Reload","text":"

Docker Compose volume mounts sync code between host and container:

# docker-compose.yml\napi:\n  volumes:\n    - ./api:/app                # Syncs code changes\n    - /app/node_modules         # Preserves container's node_modules\n    - /app/dist                 # Preserves build output\n

When you edit a file on host: 1. File change detected by host file system 2. Change synced to container via volume mount 3. tsx watch (API) or Vite (Admin) detects change 4. Service restarts (API) or HMR updates (Admin)

"},{"location":"v2/development/docker-workflow/#api-hot-reload","title":"API Hot Reload","text":"

API uses tsx watch for auto-restart:

# Start API in Docker\ndocker compose up -d api\n\n# Watch logs\ndocker compose logs -f api\n\n# Edit file: api/src/modules/auth/auth.service.ts\n# Logs show:\n# api  | File changed: src/modules/auth/auth.service.ts\n# api  | Restarting server...\n# api  | Server running on port 4000\n

What triggers reload: - .ts file changes in src/ - Schema changes (after Prisma migrate)

What does NOT trigger reload: - .env changes (restart container manually) - package.json changes (rebuild container)

"},{"location":"v2/development/docker-workflow/#admin-hot-reload-vite-hmr","title":"Admin Hot Reload (Vite HMR)","text":"

Admin uses Vite Hot Module Replacement:

# Start Admin in Docker\ndocker compose up -d admin\n\n# Watch logs\ndocker compose logs -f admin\n\n# Edit file: admin/src/pages/UsersPage.tsx\n# Logs show:\n# admin  | 10:30:45 AM [vite] hmr update /src/pages/UsersPage.tsx\n# Browser updates WITHOUT full reload\n

HMR behavior: - Component changes: Updates component only - CSS changes: Updates styles instantly - Store changes: May require full reload

"},{"location":"v2/development/docker-workflow/#performance-considerations","title":"Performance Considerations","text":"

Linux: Volume mounts are native, excellent performance.

macOS/Windows: Volume mounts use virtualization layer, slower performance.

Optimization for macOS/Windows:

  1. Use delegated volume mounts (docker-compose.yml):
api:\n  volumes:\n    - ./api:/app:delegated  # Slightly better performance\n
  1. Reduce watched files (.dockerignore):
node_modules\ndist\ncoverage\n.git\n*.log\n
  1. Use local development for intensive work:
# Stop Docker services\ndocker compose stop api admin\n\n# Run locally\ncd api && npm run dev\ncd admin && npm run dev\n
"},{"location":"v2/development/docker-workflow/#database-operations","title":"Database Operations","text":""},{"location":"v2/development/docker-workflow/#running-migrations-in-docker","title":"Running Migrations in Docker","text":"
# Create migration\ndocker compose exec api npx prisma migrate dev --name add_user_field\n\n# Apply migrations (production)\ndocker compose exec api npx prisma migrate deploy\n\n# Check migration status\ndocker compose exec api npx prisma migrate status\n
"},{"location":"v2/development/docker-workflow/#seeding-database","title":"Seeding Database","text":"
# Run seed script\ndocker compose exec api npx prisma db seed\n\n# Or run custom script\ndocker compose exec api npx tsx prisma/custom-seed.ts\n
"},{"location":"v2/development/docker-workflow/#resetting-database","title":"Resetting Database","text":"

WARNING: Deletes all data!

# Reset and re-seed\ndocker compose exec api npx prisma migrate reset\n\n# Confirm when prompted:\n# \u26a0\ufe0f  All data will be lost. Continue? [y/N]: y\n
"},{"location":"v2/development/docker-workflow/#prisma-studio-in-docker","title":"Prisma Studio in Docker","text":"
# Start Prisma Studio\ndocker compose exec api npx prisma studio\n\n# Access at http://localhost:5555\n

Note: Port forwarding must be configured (already set in docker-compose.yml).

"},{"location":"v2/development/docker-workflow/#manual-database-access","title":"Manual Database Access","text":"
# Open PostgreSQL shell\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db\n\n# Run queries\nchangemaker_v2_db=# SELECT * FROM users;\nchangemaker_v2_db=# \\dt  -- List tables\nchangemaker_v2_db=# \\q   -- Exit\n
"},{"location":"v2/development/docker-workflow/#rebuilding-containers","title":"Rebuilding Containers","text":""},{"location":"v2/development/docker-workflow/#when-to-rebuild","title":"When to Rebuild","text":"

Rebuild containers when: - package.json dependencies change - Dockerfile changes - Base image needs update - Container is in corrupted state

"},{"location":"v2/development/docker-workflow/#rebuild-commands","title":"Rebuild Commands","text":"
# Rebuild all services\ndocker compose build\n\n# Rebuild specific service\ndocker compose build api\n\n# Rebuild without cache (clean build)\ndocker compose build --no-cache api\n\n# Rebuild and restart\ndocker compose up -d --build api\n
"},{"location":"v2/development/docker-workflow/#full-rebuild-workflow","title":"Full Rebuild Workflow","text":"
# 1. Stop services\ndocker compose down\n\n# 2. Rebuild (no cache)\ndocker compose build --no-cache\n\n# 3. Start services\ndocker compose up -d\n\n# 4. Verify\ndocker compose ps\ndocker compose logs -f api admin\n
"},{"location":"v2/development/docker-workflow/#after-package-changes","title":"After Package Changes","text":"

When package.json changes (new dependencies):

# Option 1: Rebuild container\ndocker compose build --no-cache api\ndocker compose restart api\n\n# Option 2: Install in running container\ndocker compose exec api npm install\ndocker compose restart api\n\n# Option 3: Remove and recreate\ndocker compose rm -sf api\ndocker compose up -d api\n
"},{"location":"v2/development/docker-workflow/#cleaning-up","title":"Cleaning Up","text":""},{"location":"v2/development/docker-workflow/#stop-services","title":"Stop Services","text":"
# Stop all services\ndocker compose stop\n\n# Stop specific service\ndocker compose stop api\n\n# Stop and remove containers\ndocker compose down\n
"},{"location":"v2/development/docker-workflow/#remove-containers","title":"Remove Containers","text":"
# Remove containers (keeps volumes)\ndocker compose down\n\n# Remove containers and volumes (DELETES DATA)\ndocker compose down -v\n\n# Remove containers, volumes, and images\ndocker compose down -v --rmi all\n
"},{"location":"v2/development/docker-workflow/#clean-docker-system","title":"Clean Docker System","text":"
# Remove stopped containers\ndocker container prune\n\n# Remove unused images\ndocker image prune\n\n# Remove unused volumes\ndocker volume prune\n\n# Remove everything (DANGEROUS)\ndocker system prune -a --volumes\n
"},{"location":"v2/development/docker-workflow/#clean-project-volumes","title":"Clean Project Volumes","text":"
# List project volumes\ndocker volume ls | grep changemaker\n\n# Remove specific volume\ndocker volume rm changemaker-lite_v2-postgres-data\n\n# Remove all project volumes (DELETES DATABASE)\ndocker compose down -v\n
"},{"location":"v2/development/docker-workflow/#reset-development-environment","title":"Reset Development Environment","text":"

Complete reset (deletes all data):

# 1. Stop and remove everything\ndocker compose down -v --rmi all\n\n# 2. Clean Docker system\ndocker system prune -a --volumes -f\n\n# 3. Rebuild from scratch\ndocker compose build --no-cache\n\n# 4. Start services\ndocker compose up -d\n\n# 5. Run migrations\ndocker compose exec api npx prisma migrate deploy\ndocker compose exec api npx prisma db seed\n
"},{"location":"v2/development/docker-workflow/#debugging-in-docker","title":"Debugging in Docker","text":""},{"location":"v2/development/docker-workflow/#attach-to-running-container","title":"Attach to Running Container","text":"
# Get shell in running container\ndocker compose exec api sh\n\n# Or bash (if available)\ndocker compose exec api bash\n\n# Inside container:\n# - Explore file system\n# - Run commands\n# - Check environment variables\n
"},{"location":"v2/development/docker-workflow/#inspect-container","title":"Inspect Container","text":"
# View container details\ndocker inspect api\n\n# View container environment variables\ndocker inspect api | grep -A 20 \"Env\"\n\n# View container mounts\ndocker inspect api | grep -A 50 \"Mounts\"\n
"},{"location":"v2/development/docker-workflow/#vscode-remote-containers","title":"VSCode Remote Containers","text":"

Install \"Remote - Containers\" extension, then:

  1. Open Command Palette (Cmd+Shift+P / Ctrl+Shift+P)
  2. Select \"Remote-Containers: Attach to Running Container\"
  3. Choose api or admin container
  4. VSCode opens new window attached to container
  5. Open /app folder in container
  6. Set breakpoints and debug normally
"},{"location":"v2/development/docker-workflow/#debug-logs","title":"Debug Logs","text":"

Enable verbose logging:

# API with debug logs\ndocker compose exec api npm run dev -- --inspect\n\n# Watch logs with timestamp\ndocker compose logs -f -t api\n\n# Filter errors only\ndocker compose logs -f api 2>&1 | grep -i error\n
"},{"location":"v2/development/docker-workflow/#network-debugging","title":"Network Debugging","text":"
# Test container connectivity\ndocker compose exec api ping v2-postgres\ndocker compose exec api ping redis\n\n# Check listening ports\ndocker compose exec api netstat -tuln\n\n# Test API from inside container\ndocker compose exec api wget -O- http://localhost:4000/health\n
"},{"location":"v2/development/docker-workflow/#performance-debugging","title":"Performance Debugging","text":"
# Container stats\ndocker stats\n\n# Specific service stats\ndocker stats api admin\n\n# Container resource limits\ndocker inspect api | grep -A 10 \"Memory\\|Cpu\"\n
"},{"location":"v2/development/docker-workflow/#advanced-workflows","title":"Advanced Workflows","text":""},{"location":"v2/development/docker-workflow/#multi-stage-development","title":"Multi-Stage Development","text":"

Run different service combinations:

# Frontend development (local Admin, Docker API)\ndocker compose up -d api v2-postgres redis\ncd admin && npm run dev\n\n# Backend development (local API, Docker Admin)\ndocker compose up -d admin v2-postgres redis\ncd api && npm run dev\n\n# Full-stack (everything in Docker)\ndocker compose up -d api admin v2-postgres redis\n
"},{"location":"v2/development/docker-workflow/#custom-docker-compose-files","title":"Custom Docker Compose Files","text":"

Create docker-compose.dev.yml for dev overrides:

# docker-compose.dev.yml\nservices:\n  api:\n    command: npm run dev -- --inspect=0.0.0.0:9229\n    ports:\n      - \"9229:9229\"  # Debug port\n    environment:\n      - LOG_LEVEL=debug\n

Usage:

# Use both files\ndocker compose -f docker-compose.yml -f docker-compose.dev.yml up -d\n\n# Or set COMPOSE_FILE env var\nexport COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml\ndocker compose up -d\n

"},{"location":"v2/development/docker-workflow/#docker-profiles-for-optional-services","title":"Docker Profiles for Optional Services","text":"

Start monitoring stack:

# With monitoring services\ndocker compose --profile monitoring up -d\n\n# Without monitoring (default)\ndocker compose up -d\n

Monitoring services: - Prometheus (port 9090) - Grafana (port 3001) - Alertmanager (port 9093) - cAdvisor (port 8080)

"},{"location":"v2/development/docker-workflow/#build-arguments","title":"Build Arguments","text":"

Pass build-time arguments:

# Build with Node.js version argument\ndocker compose build --build-arg NODE_VERSION=20.11.0 api\n\n# Set in docker-compose.yml\nservices:\n  api:\n    build:\n      context: ./api\n      args:\n        - NODE_VERSION=${NODE_VERSION:-20}\n
"},{"location":"v2/development/docker-workflow/#health-checks","title":"Health Checks","text":"

Check service health:

# View health status\ndocker compose ps\n\n# Inspect health check\ndocker inspect --format='{{json .State.Health}}' api | jq\n\n# Wait for healthy\ndocker compose up -d api\ndocker compose exec api sh -c 'while ! wget -q -O- http://localhost:4000/health; do sleep 1; done'\n
"},{"location":"v2/development/docker-workflow/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/development/docker-workflow/#container-exits-immediately","title":"Container Exits Immediately","text":"

Problem: Container starts then stops.

Solution:

# Check logs for errors\ndocker compose logs api\n\n# Common causes:\n# 1. Missing .env file\n# 2. Database connection failed\n# 3. Syntax error in code\n# 4. Port already in use\n\n# Start with interactive mode to see error\ndocker compose run --rm api npm run dev\n
"},{"location":"v2/development/docker-workflow/#volume-mount-not-working","title":"Volume Mount Not Working","text":"

Problem: Code changes don't appear in container.

Solution:

# Check volume mounts\ndocker inspect api | grep -A 20 \"Mounts\"\n\n# Verify volume path\ndocker compose exec api ls -la /app\n\n# Recreate container\ndocker compose rm -sf api\ndocker compose up -d api\n
"},{"location":"v2/development/docker-workflow/#permission-errors","title":"Permission Errors","text":"

Problem: Permission denied errors in container.

Solution:

# Check file ownership\ndocker compose exec api ls -la /app\n\n# Fix permissions on host\nsudo chown -R $(whoami):$(whoami) ./api\n\n# Or run container as current user (docker-compose.yml)\nservices:\n  api:\n    user: \"${UID}:${GID}\"\n
"},{"location":"v2/development/docker-workflow/#port-conflicts","title":"Port Conflicts","text":"

Problem: Port already in use.

Solution:

# Find process using port\nlsof -ti:4000 | xargs kill -9\n\n# Or change port in docker-compose.yml\nservices:\n  api:\n    ports:\n      - \"4002:4000\"  # Host:Container\n\n# Or use .env\nAPI_PORT=4002\n
"},{"location":"v2/development/docker-workflow/#database-connection-failed","title":"Database Connection Failed","text":"

Problem: API cannot connect to PostgreSQL.

Solution:

# Check database is running\ndocker compose ps v2-postgres\n\n# Check database logs\ndocker compose logs v2-postgres\n\n# Test connection\ndocker compose exec api sh -c 'wget -qO- http://v2-postgres:5432 || echo \"Not reachable\"'\n\n# Verify DATABASE_URL\ndocker compose exec api sh -c 'echo $DATABASE_URL'\n\n# Restart database\ndocker compose restart v2-postgres\n
"},{"location":"v2/development/docker-workflow/#out-of-disk-space","title":"Out of Disk Space","text":"

Problem: No space left on device.

Solution:

# Check Docker disk usage\ndocker system df\n\n# Remove unused images\ndocker image prune -a\n\n# Remove unused volumes\ndocker volume prune\n\n# Remove build cache\ndocker builder prune\n\n# Full cleanup\ndocker system prune -a --volumes\n
"},{"location":"v2/development/docker-workflow/#container-running-out-of-memory","title":"Container Running Out of Memory","text":"

Problem: Container crashes with OOM.

Solution:

# Check container stats\ndocker stats api\n\n# Increase Docker memory limit (Docker Desktop \u2192 Preferences \u2192 Resources)\n\n# Or set memory limit in docker-compose.yml\nservices:\n  api:\n    mem_limit: 2g\n    memswap_limit: 2g\n
"},{"location":"v2/development/docker-workflow/#slow-performance-on-macoswindows","title":"Slow Performance on macOS/Windows","text":"

Problem: Slow hot reload, high CPU usage.

Solution:

  1. Use delegated volume mounts:
services:\n  api:\n    volumes:\n      - ./api:/app:delegated\n
  1. Reduce file watching:
// vite.config.ts\nexport default {\n  server: {\n    watch: {\n      ignored: ['**/node_modules/**', '**/dist/**']\n    }\n  }\n}\n
  1. Switch to local development:
docker compose up -d v2-postgres redis\ncd api && npm run dev\ncd admin && npm run dev\n
"},{"location":"v2/development/docker-workflow/#best-practices","title":"Best Practices","text":""},{"location":"v2/development/docker-workflow/#development-workflow","title":"Development Workflow","text":"
  1. Start services in background:

    docker compose up -d api admin\n

  2. Watch logs in separate terminal:

    docker compose logs -f api admin\n

  3. Make code changes:

  4. Hot reload picks up changes automatically

  5. Type-check before commit:

    docker compose exec api npm run type-check\ndocker compose exec admin npm run type-check\n

  6. Stop services when done:

    docker compose stop\n

"},{"location":"v2/development/docker-workflow/#container-naming","title":"Container Naming","text":"

Use meaningful service names in docker-compose.yml:

services:\n  api:              # Not \"backend\" or \"server\"\n  admin:            # Not \"frontend\" or \"ui\"\n  v2-postgres:      # Not \"db\" (version-specific)\n  redis:            # Standard name\n
"},{"location":"v2/development/docker-workflow/#environment-variables","title":"Environment Variables","text":"
  1. Use .env file (not docker-compose.yml):

    # .env\nAPI_PORT=4000\nADMIN_PORT=3000\n

  2. Reference in docker-compose.yml:

    services:\n  api:\n    environment:\n      - API_PORT=${API_PORT}\n

  3. Don't commit .env (use .env.example).

"},{"location":"v2/development/docker-workflow/#volume-management","title":"Volume Management","text":"
  1. Named volumes for data:

    volumes:\n  v2-postgres-data:  # Persistent database\n

  2. Bind mounts for code:

    volumes:\n  - ./api:/app  # Live code sync\n

  3. Anonymous volumes for dependencies:

    volumes:\n  - /app/node_modules  # Isolate from host\n

"},{"location":"v2/development/docker-workflow/#log-management","title":"Log Management","text":"
  1. Use log rotation:

    services:\n  api:\n    logging:\n      driver: \"json-file\"\n      options:\n        max-size: \"10m\"\n        max-file: \"3\"\n

  2. Filter logs with grep:

    docker compose logs -f api | grep ERROR\n

  3. Export logs for analysis:

    docker compose logs > debug-logs.txt\n

"},{"location":"v2/development/docker-workflow/#quick-reference","title":"Quick Reference","text":""},{"location":"v2/development/docker-workflow/#essential-commands","title":"Essential Commands","text":"
# Start\ndocker compose up -d api admin\n\n# Stop\ndocker compose stop\n\n# Restart\ndocker compose restart api\n\n# Logs\ndocker compose logs -f api\n\n# Execute command\ndocker compose exec api npm run type-check\n\n# Shell access\ndocker compose exec api sh\n\n# Rebuild\ndocker compose build --no-cache api\n\n# Clean up\ndocker compose down -v\n
"},{"location":"v2/development/docker-workflow/#service-health-checks","title":"Service Health Checks","text":"
# Check status\ndocker compose ps\n\n# Test API\ncurl http://localhost:4000/health\n\n# Test Admin\ncurl http://localhost:3000\n\n# Test database\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"SELECT 1\"\n\n# Test Redis\ndocker compose exec redis redis-cli -a password ping\n
"},{"location":"v2/development/docker-workflow/#quick-reset","title":"Quick Reset","text":"
# Full reset (DELETES DATA)\ndocker compose down -v\ndocker compose build --no-cache\ndocker compose up -d\ndocker compose exec api npx prisma migrate deploy\ndocker compose exec api npx prisma db seed\n
"},{"location":"v2/development/docker-workflow/#related-documentation","title":"Related Documentation","text":"
  • Setup: Local Development Setup
  • Commands: NPM Commands Reference
  • Database: Migrations Guide
  • Debugging: Debugging Guide
  • Deployment: Docker Compose Deployment
"},{"location":"v2/development/docker-workflow/#summary","title":"Summary","text":"

You now know: - \u2705 When to use Docker vs local development - \u2705 How to start and stop services - \u2705 How to watch and filter logs - \u2705 How to execute commands in containers - \u2705 How hot reload works with volume mounts - \u2705 How to perform database operations in Docker - \u2705 How to rebuild and clean up containers - \u2705 How to debug containerized services - \u2705 Advanced workflows and best practices

Quick Start:

docker compose up -d api admin v2-postgres redis\ndocker compose logs -f api admin\n# Make changes \u2192 Hot reload!\ndocker compose exec api npm run type-check\ndocker compose stop\n

"},{"location":"v2/development/git-workflow/","title":"Git Workflow","text":"

Git branching strategy, commit conventions, and version control best practices for Changemaker Lite V2.

"},{"location":"v2/development/git-workflow/#overview","title":"Overview","text":"

Changemaker Lite V2 uses Git for version control with a structured branching strategy and conventional commit messages.

Key Principles: - Main branch always deployable - Feature branches for new work - Descriptive commit messages - Code review via pull requests - No direct commits to main

"},{"location":"v2/development/git-workflow/#branch-structure","title":"Branch Structure","text":""},{"location":"v2/development/git-workflow/#main-branches","title":"Main Branches","text":"

main - Production branch - Always deployable - Protected (no direct pushes) - Merges only via pull request - Tagged with version numbers

v2 - Development branch - Active development happens here - Features merge into v2 first - Tested before merging to main - Currently the primary development branch

"},{"location":"v2/development/git-workflow/#feature-branches","title":"Feature Branches","text":"

Naming: feature/<descriptive-name>

# Create feature branch from v2\ngit checkout v2\ngit pull origin v2\ngit checkout -b feature/add-user-avatar\n\n# Make changes\n# ...\n\n# Push to remote\ngit push -u origin feature/add-user-avatar\n

Examples: - feature/add-user-avatar - feature/email-queue-monitoring - feature/map-clustering - feature/campaign-analytics

"},{"location":"v2/development/git-workflow/#bugfix-branches","title":"Bugfix Branches","text":"

Naming: fix/<descriptive-name>

# Create bugfix branch\ngit checkout v2\ngit pull origin v2\ngit checkout -b fix/login-redirect-loop\n\n# Fix bug\n# ...\n\n# Push to remote\ngit push -u origin fix/login-redirect-loop\n

Examples: - fix/login-redirect-loop - fix/map-marker-position - fix/email-template-rendering

"},{"location":"v2/development/git-workflow/#hotfix-branches","title":"Hotfix Branches","text":"

Naming: hotfix/<descriptive-name>

For urgent production fixes:

# Create from main (production)\ngit checkout main\ngit pull origin main\ngit checkout -b hotfix/security-patch\n\n# Fix issue\n# ...\n\n# Merge to main AND v2\ngit checkout main\ngit merge hotfix/security-patch\ngit push origin main\n\ngit checkout v2\ngit merge hotfix/security-patch\ngit push origin v2\n

Examples: - hotfix/security-patch - hotfix/critical-database-error

"},{"location":"v2/development/git-workflow/#release-branches","title":"Release Branches","text":"

Naming: release/vX.Y.Z

For preparing releases:

# Create release branch from v2\ngit checkout v2\ngit pull origin v2\ngit checkout -b release/v2.1.0\n\n# Prepare release (update version, changelog)\n# Test thoroughly\n# ...\n\n# Merge to main (after approval)\ngit checkout main\ngit merge release/v2.1.0\ngit tag v2.1.0\ngit push origin main --tags\n\n# Merge back to v2\ngit checkout v2\ngit merge release/v2.1.0\ngit push origin v2\n
"},{"location":"v2/development/git-workflow/#feature-development-workflow","title":"Feature Development Workflow","text":""},{"location":"v2/development/git-workflow/#step-1-create-branch","title":"Step 1: Create Branch","text":"
# Update v2 branch\ngit checkout v2\ngit pull origin v2\n\n# Create feature branch\ngit checkout -b feature/add-user-avatar\n\n# Verify branch\ngit branch --show-current\n# Output: feature/add-user-avatar\n
"},{"location":"v2/development/git-workflow/#step-2-make-changes","title":"Step 2: Make Changes","text":"

Edit files, test locally:

# Make changes\nvi api/src/modules/users/users.service.ts\nvi admin/src/pages/UsersPage.tsx\n\n# Test locally\nnpm run dev\n\n# Type-check\nnpm run type-check\n\n# Lint\nnpm run lint:fix\n
"},{"location":"v2/development/git-workflow/#step-3-stage-and-commit","title":"Step 3: Stage and Commit","text":"
# Check status\ngit status\n\n# Stage specific files (NOT git add .)\ngit add api/src/modules/users/users.service.ts\ngit add admin/src/pages/UsersPage.tsx\n\n# Commit with conventional message\ngit commit -m \"feat(users): add avatar upload functionality\n\nImplements avatar upload with image validation and S3 storage.\nAdds avatar field to User model and updates UI.\n\nCloses #123\"\n
"},{"location":"v2/development/git-workflow/#step-4-push-to-remote","title":"Step 4: Push to Remote","text":"
# Push branch (first time)\ngit push -u origin feature/add-user-avatar\n\n# Push subsequent commits\ngit push\n
"},{"location":"v2/development/git-workflow/#step-5-create-pull-request","title":"Step 5: Create Pull Request","text":"

On GitHub/GitLab:

  1. Navigate to repository
  2. Click \"New Pull Request\"
  3. Select base: v2, compare: feature/add-user-avatar
  4. Fill in PR template (title, description, testing steps)
  5. Request reviewers
  6. Link related issues
"},{"location":"v2/development/git-workflow/#step-6-address-review-feedback","title":"Step 6: Address Review Feedback","text":"
# Make requested changes\nvi api/src/modules/users/users.service.ts\n\n# Stage and commit\ngit add api/src/modules/users/users.service.ts\ngit commit -m \"fix(users): address review feedback\n\n- Add error handling for upload failures\n- Improve validation messages\n- Add JSDoc comments\"\n\n# Push changes\ngit push\n
"},{"location":"v2/development/git-workflow/#step-7-merge-after-approval","title":"Step 7: Merge (After Approval)","text":"

Squash and Merge (recommended): - Combines all commits into one - Clean history on v2 branch - Preserves individual commits in branch

Merge Commit: - Preserves all commits - More detailed history - Use for large features

Rebase and Merge: - Linear history - No merge commits - Use when v2 has diverged

"},{"location":"v2/development/git-workflow/#step-8-clean-up","title":"Step 8: Clean Up","text":"
# Delete local branch\ngit checkout v2\ngit branch -d feature/add-user-avatar\n\n# Delete remote branch (if not auto-deleted)\ngit push origin --delete feature/add-user-avatar\n\n# Update v2\ngit pull origin v2\n
"},{"location":"v2/development/git-workflow/#commit-messages","title":"Commit Messages","text":""},{"location":"v2/development/git-workflow/#conventional-commits-format","title":"Conventional Commits Format","text":"
<type>(<scope>): <subject>\n\n<body>\n\n<footer>\n
"},{"location":"v2/development/git-workflow/#types","title":"Types","text":"
  • feat: New feature
  • fix: Bug fix
  • docs: Documentation changes
  • style: Code formatting (no logic change)
  • refactor: Code restructuring (no behavior change)
  • perf: Performance improvement
  • test: Adding tests
  • chore: Maintenance (dependencies, config)
  • ci: CI/CD changes
  • build: Build system changes
"},{"location":"v2/development/git-workflow/#scopes","title":"Scopes","text":"

Use module/area name:

  • auth - Authentication
  • users - User management
  • campaigns - Campaign module
  • map - Map features
  • email - Email system
  • db - Database changes
  • ui - UI components
  • api - API changes
"},{"location":"v2/development/git-workflow/#examples","title":"Examples","text":"

Simple commit:

git commit -m \"feat(auth): add JWT refresh token rotation\"\n

With body:

git commit -m \"feat(campaigns): add email queue monitoring\n\nImplements real-time queue stats dashboard with pause/resume controls.\nShows pending, active, completed, and failed jobs.\n\nCloses #45\"\n

Breaking change:

git commit -m \"feat(api)!: change user endpoint response format\n\nBREAKING CHANGE: User endpoint now returns paginated response.\nUpdate client code to handle new format.\n\nMigration guide: docs/migration/v2.1.md\"\n

Multiple changes:

git commit -m \"feat(map): add location clustering and popup improvements\n\n- Implement marker clustering for better performance\n- Add custom popup with location details\n- Improve map controls layout\n\nCloses #67, #68\"\n

Hotfix:

git commit -m \"fix(auth)!: patch critical security vulnerability\n\nFixes CVE-2024-12345 in JWT token validation.\nAll users must update tokens after deploy.\n\nSecurity advisory: docs/security/2024-02-13.md\"\n

"},{"location":"v2/development/git-workflow/#git-safety-protocol","title":"Git Safety Protocol","text":"

From CLAUDE.md - Critical Rules:

"},{"location":"v2/development/git-workflow/#never-do-these-unless-user-explicitly-requests","title":"NEVER Do These (Unless User Explicitly Requests)","text":"
# \u274c NEVER without explicit user approval\ngit push --force\ngit push --force-with-lease\ngit reset --hard\ngit checkout .\ngit restore .\ngit clean -f\ngit clean -fd\ngit branch -D\ngit rebase -i\n\n# \u274c NEVER skip hooks\ngit commit --no-verify\ngit push --no-verify\n\n# \u274c NEVER force push to main/master\ngit push --force origin main  # DANGER!\n
"},{"location":"v2/development/git-workflow/#always-do-these","title":"ALWAYS Do These","text":"
# \u2705 Stage specific files (not git add .)\ngit add api/src/modules/auth/auth.service.ts\ngit add admin/src/pages/LoginPage.tsx\n\n# \u2705 Create NEW commits (not --amend after hook failure)\n# If pre-commit hook fails, commit did NOT happen\n# Fix issue, re-stage, create NEW commit (not amend)\n\n# \u2705 Verify changes before commit\ngit diff --staged\n\n# \u2705 Pull before push\ngit pull origin v2\ngit push origin feature/my-feature\n
"},{"location":"v2/development/git-workflow/#commit-co-authoring-claude-code","title":"Commit Co-Authoring (Claude Code)","text":"

When Claude assists with code, add co-author:

git commit -m \"$(cat <<'EOF'\nfeat(auth): implement refresh token rotation\n\nAdds atomic refresh token rotation to prevent race conditions\nduring concurrent refresh requests.\n\nCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>\nEOF\n)\"\n

Or use heredoc:

git commit -m \"feat(auth): implement refresh token rotation\n\nAdds atomic refresh token rotation to prevent race conditions.\n\nCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>\"\n

"},{"location":"v2/development/git-workflow/#pull-request-process","title":"Pull Request Process","text":""},{"location":"v2/development/git-workflow/#pr-template","title":"PR Template","text":"

Create .github/pull_request_template.md:

## Description\n<!-- Brief description of changes -->\n\n## Type of Change\n- [ ] Bug fix (non-breaking change which fixes an issue)\n- [ ] New feature (non-breaking change which adds functionality)\n- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)\n- [ ] Documentation update\n\n## Related Issues\n<!-- Link to issue(s): Closes #123, Fixes #456 -->\n\n## Changes Made\n<!-- Detailed list of changes -->\n-\n-\n-\n\n## Testing Done\n<!-- How were these changes tested? -->\n- [ ] Unit tests added/updated\n- [ ] Integration tests added/updated\n- [ ] Manual testing performed\n\n## Checklist\n- [ ] Code follows style guidelines\n- [ ] Self-review completed\n- [ ] Comments added for complex logic\n- [ ] Documentation updated\n- [ ] No new warnings generated\n- [ ] Tests pass locally\n- [ ] Database migrations included (if applicable)\n
"},{"location":"v2/development/git-workflow/#code-review-checklist","title":"Code Review Checklist","text":"

Reviewer checks:

  • Code matches description
  • Logic is correct
  • Error handling present
  • Tests included
  • TypeScript types correct
  • No security issues
  • No performance issues
  • Documentation updated
  • Follows code style guide
  • No debugging code left
"},{"location":"v2/development/git-workflow/#review-process","title":"Review Process","text":"
  1. Author submits PR
  2. Fills out template
  3. Self-reviews changes
  4. Requests reviewers

  5. Reviewers review

  6. Read description
  7. Review code changes
  8. Test locally (if needed)
  9. Leave comments/suggestions

  10. Author addresses feedback

  11. Makes requested changes
  12. Responds to comments
  13. Re-requests review

  14. Final approval

  15. Reviewers approve
  16. CI/CD checks pass
  17. Merge to base branch
"},{"location":"v2/development/git-workflow/#merge-strategies","title":"Merge Strategies","text":""},{"location":"v2/development/git-workflow/#squash-and-merge-recommended","title":"Squash and Merge (Recommended)","text":"

When to use: - Feature branches with multiple commits - Want clean history on main/v2 - Individual commits not important

Result:

v2:  A---B---C---D\n                  \\\nfeature:           E---F---G  (squashed into D)\n

How:

# On GitHub: \"Squash and Merge\" button\n\n# Manual:\ngit checkout v2\ngit merge --squash feature/add-avatar\ngit commit -m \"feat(users): add avatar upload functionality\"\ngit push origin v2\n

"},{"location":"v2/development/git-workflow/#merge-commit","title":"Merge Commit","text":"

When to use: - Want to preserve all commits - Large features with meaningful commit history - Release branches

Result:

v2:  A---B-------D\n          \\     /\nfeature:   E---F\n

How:

# On GitHub: \"Create a merge commit\" button\n\n# Manual:\ngit checkout v2\ngit merge feature/add-avatar\ngit push origin v2\n

"},{"location":"v2/development/git-workflow/#rebase-and-merge","title":"Rebase and Merge","text":"

When to use: - Want linear history - Few commits - No merge conflicts

Result:

v2:  A---B---E'---F'\n

How:

# On GitHub: \"Rebase and merge\" button\n\n# Manual:\ngit checkout feature/add-avatar\ngit rebase v2\ngit checkout v2\ngit merge feature/add-avatar\ngit push origin v2\n

"},{"location":"v2/development/git-workflow/#version-tags","title":"Version Tags","text":""},{"location":"v2/development/git-workflow/#semantic-versioning","title":"Semantic Versioning","text":"

Format: vMAJOR.MINOR.PATCH

  • MAJOR: Breaking changes
  • MINOR: New features (backward compatible)
  • PATCH: Bug fixes (backward compatible)

Examples: - v2.0.0 - Major release (V2 launch) - v2.1.0 - New features added - v2.1.1 - Bug fixes - v2.2.0 - More new features

"},{"location":"v2/development/git-workflow/#creating-tags","title":"Creating Tags","text":"
# Create annotated tag\ngit tag -a v2.1.0 -m \"Release v2.1.0: Email queue monitoring\n\nNew Features:\n- Email queue dashboard\n- Pause/resume controls\n- Job statistics\n\nBug Fixes:\n- Fixed map marker positioning\n- Fixed login redirect loop\n\nSee CHANGELOG.md for full details\"\n\n# Push tag to remote\ngit push origin v2.1.0\n\n# Push all tags\ngit push origin --tags\n
"},{"location":"v2/development/git-workflow/#viewing-tags","title":"Viewing Tags","text":"
# List all tags\ngit tag\n\n# List tags matching pattern\ngit tag -l \"v2.1.*\"\n\n# Show tag details\ngit show v2.1.0\n\n# Checkout specific tag\ngit checkout v2.1.0\n
"},{"location":"v2/development/git-workflow/#common-operations","title":"Common Operations","text":""},{"location":"v2/development/git-workflow/#update-branch-with-latest-v2","title":"Update Branch with Latest v2","text":"
# While on feature branch\ngit checkout feature/add-avatar\ngit fetch origin\ngit rebase origin/v2\n\n# Or merge (if rebase has conflicts)\ngit merge origin/v2\n
"},{"location":"v2/development/git-workflow/#resolve-merge-conflicts","title":"Resolve Merge Conflicts","text":"
# Attempt merge/rebase\ngit merge v2\n# CONFLICT (content): Merge conflict in api/src/modules/auth/auth.service.ts\n\n# View conflicted files\ngit status\n\n# Edit conflicted file\nvi api/src/modules/auth/auth.service.ts\n\n# Look for conflict markers:\n# <<<<<<< HEAD\n# Your changes\n# =======\n# Their changes\n# >>>>>>> v2\n\n# Resolve conflict, remove markers\n\n# Stage resolved file\ngit add api/src/modules/auth/auth.service.ts\n\n# Continue merge\ngit commit\n# Or continue rebase\ngit rebase --continue\n
"},{"location":"v2/development/git-workflow/#undo-changes","title":"Undo Changes","text":"
# Unstage file\ngit restore --staged api/src/modules/auth/auth.service.ts\n\n# Discard local changes (CAREFUL!)\ngit restore api/src/modules/auth/auth.service.ts\n\n# Undo last commit (keep changes)\ngit reset --soft HEAD~1\n\n# Undo last commit (discard changes)\ngit reset --hard HEAD~1  # \u26a0\ufe0f DESTRUCTIVE!\n\n# Revert commit (creates new commit)\ngit revert abc123  # Safer than reset\n
"},{"location":"v2/development/git-workflow/#stash-changes","title":"Stash Changes","text":"
# Stash uncommitted changes\ngit stash\n\n# Stash with message\ngit stash save \"WIP: avatar upload\"\n\n# List stashes\ngit stash list\n\n# Apply stash\ngit stash apply\n\n# Apply specific stash\ngit stash apply stash@{1}\n\n# Pop stash (apply and delete)\ngit stash pop\n\n# Delete stash\ngit stash drop stash@{0}\n
"},{"location":"v2/development/git-workflow/#view-history","title":"View History","text":"
# View commit history\ngit log\n\n# One-line format\ngit log --oneline\n\n# Graph view\ngit log --oneline --graph --all\n\n# Filter by author\ngit log --author=\"John Doe\"\n\n# Filter by date\ngit log --since=\"2024-01-01\" --until=\"2024-12-31\"\n\n# File history\ngit log --follow api/src/modules/auth/auth.service.ts\n\n# Search commits\ngit log --grep=\"JWT\"\n
"},{"location":"v2/development/git-workflow/#compare-changes","title":"Compare Changes","text":"
# Compare working directory to staging\ngit diff\n\n# Compare staging to last commit\ngit diff --staged\n\n# Compare two branches\ngit diff v2..feature/add-avatar\n\n# Compare specific file\ngit diff v2 api/src/modules/auth/auth.service.ts\n\n# Compare commits\ngit diff abc123..def456\n
"},{"location":"v2/development/git-workflow/#git-hooks","title":"Git Hooks","text":""},{"location":"v2/development/git-workflow/#pre-commit-hook","title":"Pre-commit Hook","text":"

Install husky:

npm install --save-dev husky lint-staged\nnpx husky install\n

Create pre-commit hook:

# .husky/pre-commit\n#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\n# Run lint-staged\nnpx lint-staged\n

Configure lint-staged (package.json):

{\n  \"lint-staged\": {\n    \"*.{ts,tsx}\": [\n      \"eslint --fix\",\n      \"prettier --write\"\n    ],\n    \"*.{json,md}\": [\n      \"prettier --write\"\n    ]\n  }\n}\n

Hook runs automatically:

git commit -m \"feat: add feature\"\n# Runs ESLint, Prettier on staged files\n# Fails commit if errors found\n
"},{"location":"v2/development/git-workflow/#commit-msg-hook","title":"Commit-msg Hook","text":"

Validate commit message format:

# .husky/commit-msg\n#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\n# Validate conventional commit format\nnpx commitlint --edit $1\n

Install commitlint:

npm install --save-dev @commitlint/cli @commitlint/config-conventional\n

Configure (.commitlintrc.json):

{\n  \"extends\": [\"@commitlint/config-conventional\"],\n  \"rules\": {\n    \"type-enum\": [\n      2,\n      \"always\",\n      [\"feat\", \"fix\", \"docs\", \"style\", \"refactor\", \"perf\", \"test\", \"chore\", \"ci\", \"build\"]\n    ],\n    \"scope-enum\": [\n      2,\n      \"always\",\n      [\"auth\", \"users\", \"campaigns\", \"map\", \"email\", \"db\", \"ui\", \"api\"]\n    ]\n  }\n}\n
"},{"location":"v2/development/git-workflow/#gitignore","title":".gitignore","text":"

Project .gitignore:

# Dependencies\nnode_modules/\n*/node_modules/\n\n# Build outputs\ndist/\nbuild/\n*/dist/\n*/build/\n\n# Environment\n.env\n.env.local\n.env.*.local\n\n# Logs\nlogs/\n*.log\nnpm-debug.log*\n\n# IDE\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n\n# OS\n.DS_Store\nThumbs.db\n\n# Testing\ncoverage/\n.nyc_output/\n\n# Temporary\ntmp/\ntemp/\n*.tmp\n\n# Database\n*.sqlite\n*.db\n\n# Prisma\napi/.prisma/\napi/prisma/.env\n\n# Vite\nadmin/.vite/\nadmin/tsconfig.tsbuildinfo\n\n# Docker volumes\npostgres-data/\nredis-data/\n
"},{"location":"v2/development/git-workflow/#collaboration","title":"Collaboration","text":""},{"location":"v2/development/git-workflow/#forks","title":"Forks","text":"

Fork workflow:

  1. Fork repository on GitHub
  2. Clone your fork:

    git clone https://github.com/your-username/changemaker.lite.git\n

  3. Add upstream remote:

    git remote add upstream https://github.com/original/changemaker.lite.git\n

  4. Create feature branch:

    git checkout -b feature/my-feature\n

  5. Make changes, commit, push to your fork:

    git push origin feature/my-feature\n

  6. Create pull request from your fork to upstream

"},{"location":"v2/development/git-workflow/#sync-fork-with-upstream","title":"Sync Fork with Upstream","text":"
# Fetch upstream changes\ngit fetch upstream\n\n# Merge upstream v2 into your v2\ngit checkout v2\ngit merge upstream/v2\n\n# Push to your fork\ngit push origin v2\n
"},{"location":"v2/development/git-workflow/#best-practices","title":"Best Practices","text":""},{"location":"v2/development/git-workflow/#dos","title":"Do's","text":"
  • \u2705 Pull before push
  • \u2705 Write descriptive commit messages
  • \u2705 Stage specific files (not git add .)
  • \u2705 Review changes before commit (git diff --staged)
  • \u2705 Test locally before pushing
  • \u2705 Keep commits focused (one logical change)
  • \u2705 Use branches for all changes
  • \u2705 Delete merged branches
"},{"location":"v2/development/git-workflow/#donts","title":"Don'ts","text":"
  • \u274c Commit directly to main
  • \u274c Force push without approval
  • \u274c Commit large binary files
  • \u274c Commit secrets (.env, API keys)
  • \u274c Use git add . (stage specific files)
  • \u274c Amend commits after pushing
  • \u274c Rebase public branches
  • \u274c Leave debugging code in commits
"},{"location":"v2/development/git-workflow/#related-documentation","title":"Related Documentation","text":"
  • Setup: Local Development Setup
  • Code Style: Code Style Guide
  • Testing: Testing Guide
  • Contributing: Contributing Guide
"},{"location":"v2/development/git-workflow/#summary","title":"Summary","text":"

You now know: - \u2705 Branch structure (main, v2, feature, fix, hotfix) - \u2705 Feature development workflow - \u2705 Conventional commit message format - \u2705 Git safety protocol (NEVER force push without approval) - \u2705 Pull request process - \u2705 Merge strategies (squash, merge commit, rebase) - \u2705 Version tagging (semantic versioning) - \u2705 Common Git operations - \u2705 Git hooks (pre-commit, commit-msg) - \u2705 Best practices

Quick Reference:

# Create feature branch\ngit checkout -b feature/my-feature\n\n# Make changes, stage, commit\ngit add specific-file.ts\ngit commit -m \"feat(scope): description\"\n\n# Push and create PR\ngit push -u origin feature/my-feature\n\n# After merge, clean up\ngit checkout v2\ngit pull origin v2\ngit branch -d feature/my-feature\n

"},{"location":"v2/development/local-setup/","title":"Local Development Setup","text":"

This guide walks you through setting up Changemaker Lite V2 for local development on your machine.

"},{"location":"v2/development/local-setup/#overview","title":"Overview","text":"

Changemaker Lite V2 supports two development approaches:

  1. Docker-based development - Run API and Admin in containers (recommended for consistency)
  2. Local npm development - Run services directly on your host machine (faster hot reload)

This guide covers both approaches. Choose the one that fits your workflow.

"},{"location":"v2/development/local-setup/#prerequisites","title":"Prerequisites","text":""},{"location":"v2/development/local-setup/#required-software","title":"Required Software","text":""},{"location":"v2/development/local-setup/#nodejs-and-npm","title":"Node.js and npm","text":"
  • Node.js 20.x LTS or higher
  • npm 10.x or higher
# Check versions\nnode --version  # Should be v20.x.x or higher\nnpm --version   # Should be 10.x.x or higher\n

Installation: - Download from nodejs.org - Or use nvm for version management:

nvm install 20\nnvm use 20\n
"},{"location":"v2/development/local-setup/#docker-and-docker-compose","title":"Docker and Docker Compose","text":"
  • Docker Engine 24.x or higher
  • Docker Compose 2.x or higher (included with Docker Desktop)
# Check versions\ndocker --version         # Should be 24.x.x or higher\ndocker compose version   # Should be 2.x.x or higher\n

Installation: - Docker Desktop: docker.com/get-started - Linux: docs.docker.com/engine/install

"},{"location":"v2/development/local-setup/#git","title":"Git","text":"
  • Git 2.30 or higher
# Check version\ngit --version  # Should be 2.30.x or higher\n

Installation: - Download from git-scm.com - Or use package manager (apt, brew, etc.)

"},{"location":"v2/development/local-setup/#optional-tools","title":"Optional Tools","text":""},{"location":"v2/development/local-setup/#postgresql-client-tools","title":"PostgreSQL Client Tools","text":"

Useful for database inspection and debugging:

# Ubuntu/Debian\nsudo apt install postgresql-client\n\n# macOS\nbrew install postgresql@16\n\n# Check installation\npsql --version\n
"},{"location":"v2/development/local-setup/#redis-cli","title":"Redis CLI","text":"

For cache/queue debugging:

# Ubuntu/Debian\nsudo apt install redis-tools\n\n# macOS\nbrew install redis\n\n# Check installation\nredis-cli --version\n
"},{"location":"v2/development/local-setup/#visual-studio-code","title":"Visual Studio Code","text":"

Recommended IDE with excellent TypeScript support:

  • Download from code.visualstudio.com
  • See IDE Setup section for recommended extensions
"},{"location":"v2/development/local-setup/#system-requirements","title":"System Requirements","text":"

Minimum: - 8 GB RAM - 20 GB free disk space - 2 CPU cores

Recommended: - 16 GB RAM - 50 GB free disk space - 4+ CPU cores

"},{"location":"v2/development/local-setup/#repository-setup","title":"Repository Setup","text":""},{"location":"v2/development/local-setup/#clone-repository","title":"Clone Repository","text":"
# Clone the repository\ngit clone <repo-url> changemaker.lite\ncd changemaker.lite\n\n# Checkout v2 branch\ngit checkout v2\n\n# Verify branch\ngit branch --show-current\n# Output: v2\n
"},{"location":"v2/development/local-setup/#repository-structure","title":"Repository Structure","text":"

After cloning, your directory structure should look like:

changemaker.lite/\n\u251c\u2500\u2500 api/                  # Express.js + Fastify backend\n\u251c\u2500\u2500 admin/                # React frontend\n\u251c\u2500\u2500 configs/              # Monitoring configs (Prometheus, Grafana)\n\u251c\u2500\u2500 nginx/                # Reverse proxy configuration\n\u251c\u2500\u2500 scripts/              # Utility scripts\n\u251c\u2500\u2500 docker-compose.yml    # V2 orchestration\n\u251c\u2500\u2500 .env.example          # Environment template\n\u2514\u2500\u2500 V2_PLAN.md           # Development roadmap\n
"},{"location":"v2/development/local-setup/#verify-files","title":"Verify Files","text":"

Check that key files exist:

ls -la api/package.json admin/package.json docker-compose.yml .env.example\n

If any files are missing, ensure you're on the v2 branch.

"},{"location":"v2/development/local-setup/#environment-configuration","title":"Environment Configuration","text":""},{"location":"v2/development/local-setup/#create-env-file","title":"Create .env File","text":"

Copy the example environment file:

cp .env.example .env\n
"},{"location":"v2/development/local-setup/#configure-essential-variables","title":"Configure Essential Variables","text":"

Open .env in your editor and set the following critical variables:

"},{"location":"v2/development/local-setup/#database-passwords","title":"Database Passwords","text":"
# PostgreSQL password (use a strong password)\nV2_POSTGRES_PASSWORD=your_strong_password_here\n\n# Redis password (use a strong password)\nREDIS_PASSWORD=your_redis_password_here\n
"},{"location":"v2/development/local-setup/#jwt-secrets","title":"JWT Secrets","text":"

Generate secure random secrets:

# Generate secrets (run these commands separately)\nopenssl rand -hex 32  # For JWT_ACCESS_SECRET\nopenssl rand -hex 32  # For JWT_REFRESH_SECRET\nopenssl rand -hex 32  # For ENCRYPTION_KEY\n

Add to .env:

# JWT secrets (use different values for each!)\nJWT_ACCESS_SECRET=<output from first command>\nJWT_REFRESH_SECRET=<output from second command>\nENCRYPTION_KEY=<output from third command>\n

IMPORTANT: All three secrets must be different values!

"},{"location":"v2/development/local-setup/#email-configuration-development","title":"Email Configuration (Development)","text":"

For development, use MailHog to capture emails locally:

# Email test mode (sends to MailHog instead of real SMTP)\nEMAIL_TEST_MODE=true\n\n# MailHog SMTP settings\nEMAIL_SMTP_HOST=localhost\nEMAIL_SMTP_PORT=1025\nEMAIL_SMTP_SECURE=false\nEMAIL_FROM_ADDRESS=noreply@cmlite.org\nEMAIL_FROM_NAME=Changemaker Lite\n
"},{"location":"v2/development/local-setup/#optional-features","title":"Optional Features","text":"

Enable optional features as needed:

# Media Manager (video library)\nENABLE_MEDIA_FEATURES=true\n\n# Listmonk newsletter sync\nLISTMONK_SYNC_ENABLED=false  # Enable later if needed\n\n# API ports (defaults work for most setups)\nAPI_PORT=4000\nADMIN_PORT=3000\nMEDIA_API_PORT=4100\n
"},{"location":"v2/development/local-setup/#complete-env-template","title":"Complete .env Template","text":"

Here's a minimal .env for local development:

# Database\nV2_POSTGRES_PASSWORD=your_strong_password\nDATABASE_URL=postgresql://changemaker_v2:your_strong_password@localhost:5433/changemaker_v2_db\n\n# Redis\nREDIS_PASSWORD=your_redis_password\nREDIS_URL=redis://:your_redis_password@localhost:6379\n\n# JWT\nJWT_ACCESS_SECRET=<32-byte hex from openssl>\nJWT_REFRESH_SECRET=<32-byte hex from openssl>\nENCRYPTION_KEY=<32-byte hex from openssl>\n\n# Email (MailHog for dev)\nEMAIL_TEST_MODE=true\nEMAIL_SMTP_HOST=localhost\nEMAIL_SMTP_PORT=1025\nEMAIL_SMTP_SECURE=false\nEMAIL_FROM_ADDRESS=noreply@cmlite.org\nEMAIL_FROM_NAME=Changemaker Lite\n\n# Ports\nAPI_PORT=4000\nADMIN_PORT=3000\nMEDIA_API_PORT=4100\n\n# Features\nENABLE_MEDIA_FEATURES=true\nLISTMONK_SYNC_ENABLED=false\n\n# Node environment\nNODE_ENV=development\n
"},{"location":"v2/development/local-setup/#verify-configuration","title":"Verify Configuration","text":"

Check that required variables are set:

grep -E '^(V2_POSTGRES_PASSWORD|REDIS_PASSWORD|JWT_ACCESS_SECRET|JWT_REFRESH_SECRET|ENCRYPTION_KEY)=' .env\n

You should see 5 lines with non-empty values.

"},{"location":"v2/development/local-setup/#database-setup","title":"Database Setup","text":""},{"location":"v2/development/local-setup/#start-database-services","title":"Start Database Services","text":"

Start PostgreSQL and Redis containers:

docker compose up -d v2-postgres redis\n

Wait for databases to initialize (first run takes 30-60 seconds):

# Watch logs\ndocker compose logs -f v2-postgres redis\n\n# Look for:\n# v2-postgres: \"database system is ready to accept connections\"\n# redis: \"Ready to accept connections\"\n\n# Press Ctrl+C to exit logs\n
"},{"location":"v2/development/local-setup/#verify-database-connection","title":"Verify Database Connection","text":"

Test PostgreSQL connection:

docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"SELECT version();\"\n

You should see PostgreSQL version information.

Test Redis connection:

docker compose exec redis redis-cli -a your_redis_password ping\n# Output: PONG\n
"},{"location":"v2/development/local-setup/#install-api-dependencies","title":"Install API Dependencies","text":"
cd api\nnpm install\n

Expected output: - Installs ~300+ packages - May show peer dependency warnings (safe to ignore) - Should complete without errors

"},{"location":"v2/development/local-setup/#run-database-migrations","title":"Run Database Migrations","text":"

Apply Prisma migrations to create database schema:

# From api/ directory\nnpx prisma migrate deploy\n

Expected output:

Environment variables loaded from .env\nPrisma schema loaded from prisma/schema.prisma\nDatasource \"db\": PostgreSQL database \"changemaker_v2_db\"\n\n20 migrations found in prisma/migrations\n\nApplying migration `20260101000000_init`\nApplying migration `20260105000000_add_campaigns`\n...\nAll migrations have been successfully applied.\n

"},{"location":"v2/development/local-setup/#seed-database","title":"Seed Database","text":"

Populate database with initial data (admin user, settings, etc.):

# From api/ directory\nnpx prisma db seed\n

Expected output:

Running seed command `tsx prisma/seed.ts` ...\nSeeding database...\nCreated default settings\nCreated admin user: admin@example.com\nCreated 10 sample blocks\n...\nSeed completed successfully\n

Default Admin Credentials: - Email: admin@example.com - Password: Admin123! - Change this password immediately after first login!

"},{"location":"v2/development/local-setup/#verify-database-schema","title":"Verify Database Schema","text":"

Open Prisma Studio to browse the database:

# From api/ directory\nnpx prisma studio\n

This opens a browser at http://localhost:5555 showing: - 30+ tables (User, Campaign, Location, Shift, etc.) - Seeded data (1 admin user, settings, blocks)

Press Ctrl+C to close Prisma Studio when done.

"},{"location":"v2/development/local-setup/#return-to-project-root","title":"Return to Project Root","text":"
cd ..  # Back to changemaker.lite/\n
"},{"location":"v2/development/local-setup/#starting-services","title":"Starting Services","text":"

You have two options for running the development servers:

"},{"location":"v2/development/local-setup/#option-1-docker-based-development-recommended","title":"Option 1: Docker-based Development (Recommended)","text":"

Run API and Admin in Docker containers with volume mounts for hot reload:

# Start API and Admin containers\ndocker compose up -d api admin\n\n# Optional: Start MailHog for email testing\ndocker compose up -d mailhog\n\n# Optional: Start Media API\ndocker compose up -d media-api\n

Watch logs:

# All services\ndocker compose logs -f api admin\n\n# Just API\ndocker compose logs -f api\n\n# Just Admin\ndocker compose logs -f admin\n

Verify services started:

docker compose ps\n

You should see: - api - running on port 4000 - admin - running on port 3000 - v2-postgres - running on port 5433 - redis - running on port 6379 - mailhog - running on port 8025 (if started)

Hot Reload in Docker:

Volume mounts automatically sync code changes: - API: tsx watch restarts server on file changes - Admin: Vite HMR updates browser without full reload

"},{"location":"v2/development/local-setup/#option-2-local-npm-development","title":"Option 2: Local npm Development","text":"

Run services directly on your host machine (faster hot reload):

"},{"location":"v2/development/local-setup/#terminal-1-api-server","title":"Terminal 1: API Server","text":"
cd api\nnpm run dev\n

Expected output:

> api@2.0.0 dev\n> tsx watch src/server.ts\n\nServer running on port 4000\nDatabase connected\nRedis connected\nBullMQ worker started\n

"},{"location":"v2/development/local-setup/#terminal-2-admin-server","title":"Terminal 2: Admin Server","text":"
cd admin\nnpm install  # First time only\nnpm run dev\n

Expected output:

> admin@2.0.0 dev\n> vite\n\n  VITE v5.x.x  ready in 500 ms\n\n  \u279c  Local:   http://localhost:3000/\n  \u279c  Network: use --host to expose\n

"},{"location":"v2/development/local-setup/#terminal-3-media-api-optional","title":"Terminal 3: Media API (Optional)","text":"
cd api\nnpm run dev:media\n

Expected output:

> api@2.0.0 dev:media\n> tsx watch src/media-server.ts\n\nMedia API server running on port 4100\nDatabase connected\n

"},{"location":"v2/development/local-setup/#background-services","title":"Background Services","text":"

You still need Docker for PostgreSQL, Redis, and MailHog:

docker compose up -d v2-postgres redis mailhog\n
"},{"location":"v2/development/local-setup/#which-approach-to-use","title":"Which Approach to Use?","text":"

Use Docker-based development if: - You want consistent environment across team - You're new to the project - You prefer simpler setup

Use local npm development if: - You want faster hot reload (especially for frontend) - You're actively developing API changes - You prefer direct access to Node.js processes

You can mix approaches: - Run API in Docker, Admin locally - Run databases in Docker, both API/Admin locally

"},{"location":"v2/development/local-setup/#verifying-setup","title":"Verifying Setup","text":""},{"location":"v2/development/local-setup/#health-check-endpoints","title":"Health Check Endpoints","text":"

Test that services are responding:

# API health check\ncurl http://localhost:4000/health\n# Expected: {\"status\":\"ok\",\"timestamp\":\"2026-02-13T...\"}\n\n# Admin (open in browser)\nopen http://localhost:3000\n# Or visit manually: http://localhost:3000\n\n# Media API health check (if enabled)\ncurl http://localhost:4100/health\n# Expected: {\"status\":\"ok\",\"timestamp\":\"2026-02-13T...\"}\n
"},{"location":"v2/development/local-setup/#test-authentication","title":"Test Authentication","text":"

Test login endpoint:

curl -X POST http://localhost:4000/api/auth/login \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"email\": \"admin@example.com\",\n    \"password\": \"Admin123!\"\n  }'\n

Expected response:

{\n  \"accessToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n  \"refreshToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n  \"user\": {\n    \"id\": 1,\n    \"email\": \"admin@example.com\",\n    \"role\": \"SUPER_ADMIN\"\n  }\n}\n

"},{"location":"v2/development/local-setup/#login-to-admin-gui","title":"Login to Admin GUI","text":"
  1. Open http://localhost:3000 in browser
  2. Login with:
  3. Email: admin@example.com
  4. Password: Admin123!
  5. You should be redirected to /app (admin dashboard)
  6. Change password immediately:
  7. Click user menu (top right)
  8. Settings \u2192 Change Password
  9. Set new password (12+ chars, uppercase, lowercase, digit)
"},{"location":"v2/development/local-setup/#verify-database-connection_1","title":"Verify Database Connection","text":"

Check that API can query database:

curl http://localhost:4000/api/users \\\n  -H \"Authorization: Bearer <access_token_from_login>\"\n

Expected response:

{\n  \"users\": [\n    {\n      \"id\": 1,\n      \"email\": \"admin@example.com\",\n      \"role\": \"SUPER_ADMIN\",\n      ...\n    }\n  ],\n  \"total\": 1,\n  \"page\": 1,\n  \"limit\": 50\n}\n

"},{"location":"v2/development/local-setup/#test-email-capture-mailhog","title":"Test Email Capture (MailHog)","text":"
  1. Open http://localhost:8025 in browser
  2. You should see MailHog web UI
  3. Trigger a test email (e.g., shift signup)
  4. Email appears in MailHog inbox
"},{"location":"v2/development/local-setup/#ide-setup","title":"IDE Setup","text":""},{"location":"v2/development/local-setup/#visual-studio-code_1","title":"Visual Studio Code","text":"

Recommended IDE with excellent TypeScript/React support.

"},{"location":"v2/development/local-setup/#recommended-extensions","title":"Recommended Extensions","text":"

Install these extensions for best developer experience:

Essential: - ESLint (dbaeumer.vscode-eslint) - Linting - Prettier (esbenp.prettier-vscode) - Code formatting - Prisma (Prisma.prisma) - Prisma schema support - TypeScript Vue Plugin (Volar) (Vue.volar) - Vue/JSX support

Highly Recommended: - GitLens (eamodio.gitlens) - Git insights - Docker (ms-azuretools.vscode-docker) - Docker management - Thunder Client (rangav.vscode-thunder-client) - API testing - Error Lens (usernamehw.errorlens) - Inline errors - Auto Rename Tag (formulahendry.auto-rename-tag) - HTML/JSX tag pairs - Path Intellisense (christian-kohler.path-intellisense) - Path autocomplete

Optional: - Tailwind CSS IntelliSense (bradlc.vscode-tailwindcss) - Tailwind support - DotENV (mikestead.dotenv) - .env syntax highlighting - Import Cost (wix.vscode-import-cost) - Bundle size info

"},{"location":"v2/development/local-setup/#workspace-settings","title":"Workspace Settings","text":"

Create .vscode/settings.json in project root:

{\n  // Editor\n  \"editor.formatOnSave\": true,\n  \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n  \"editor.codeActionsOnSave\": {\n    \"source.fixAll.eslint\": true\n  },\n  \"editor.tabSize\": 2,\n  \"editor.insertSpaces\": true,\n\n  // Files\n  \"files.eol\": \"\\n\",\n  \"files.trimTrailingWhitespace\": true,\n  \"files.insertFinalNewline\": true,\n\n  // TypeScript\n  \"typescript.tsdk\": \"node_modules/typescript/lib\",\n  \"typescript.enablePromptUseWorkspaceTsdk\": true,\n  \"typescript.preferences.importModuleSpecifier\": \"relative\",\n\n  // Prisma\n  \"[prisma]\": {\n    \"editor.defaultFormatter\": \"Prisma.prisma\"\n  },\n\n  // ESLint\n  \"eslint.validate\": [\n    \"javascript\",\n    \"javascriptreact\",\n    \"typescript\",\n    \"typescriptreact\"\n  ],\n\n  // Search exclusions (performance)\n  \"search.exclude\": {\n    \"**/node_modules\": true,\n    \"**/dist\": true,\n    \"**/build\": true,\n    \"**/.git\": true,\n    \"**/coverage\": true\n  },\n\n  // File associations\n  \"files.associations\": {\n    \"*.css\": \"css\",\n    \".env*\": \"dotenv\"\n  }\n}\n
"},{"location":"v2/development/local-setup/#launch-configuration","title":"Launch Configuration","text":"

Create .vscode/launch.json for debugging:

{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"name\": \"Debug API\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"runtimeExecutable\": \"npm\",\n      \"runtimeArgs\": [\"run\", \"dev\"],\n      \"cwd\": \"${workspaceFolder}/api\",\n      \"console\": \"integratedTerminal\",\n      \"skipFiles\": [\"<node_internals>/**\"],\n      \"envFile\": \"${workspaceFolder}/.env\"\n    },\n    {\n      \"name\": \"Debug Admin (Chrome)\",\n      \"type\": \"chrome\",\n      \"request\": \"launch\",\n      \"url\": \"http://localhost:3000\",\n      \"webRoot\": \"${workspaceFolder}/admin/src\",\n      \"sourceMapPathOverrides\": {\n        \"webpack:///./src/*\": \"${webRoot}/*\"\n      }\n    },\n    {\n      \"name\": \"Debug Media API\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"runtimeExecutable\": \"npm\",\n      \"runtimeArgs\": [\"run\", \"dev:media\"],\n      \"cwd\": \"${workspaceFolder}/api\",\n      \"console\": \"integratedTerminal\",\n      \"skipFiles\": [\"<node_internals>/**\"],\n      \"envFile\": \"${workspaceFolder}/.env\"\n    }\n  ]\n}\n
"},{"location":"v2/development/local-setup/#workspace-file","title":"Workspace File","text":"

Create changemaker-lite.code-workspace:

{\n  \"folders\": [\n    {\n      \"name\": \"Root\",\n      \"path\": \".\"\n    },\n    {\n      \"name\": \"API\",\n      \"path\": \"api\"\n    },\n    {\n      \"name\": \"Admin\",\n      \"path\": \"admin\"\n    }\n  ],\n  \"settings\": {\n    // Workspace-level settings (inherits from .vscode/settings.json)\n  }\n}\n

Open workspace: code changemaker-lite.code-workspace

"},{"location":"v2/development/local-setup/#other-ides","title":"Other IDEs","text":""},{"location":"v2/development/local-setup/#webstorm-intellij-idea","title":"WebStorm / IntelliJ IDEA","text":"
  • Built-in TypeScript support
  • Built-in Prisma plugin
  • Configure ESLint/Prettier in Preferences \u2192 Languages & Frameworks
"},{"location":"v2/development/local-setup/#neovim-vim","title":"Neovim / Vim","text":"
  • Use LSP with typescript-language-server
  • Prisma LSP: @prisma/language-server
  • ESLint/Prettier via null-ls or ALE
"},{"location":"v2/development/local-setup/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/development/local-setup/#port-conflicts","title":"Port Conflicts","text":"

Problem: Port already in use errors

Error: listen EADDRINUSE: address already in use :::4000\n

Solution 1: Find and kill the process using the port

# Linux/macOS\nlsof -ti:4000 | xargs kill -9\n\n# Or change port in .env\nAPI_PORT=4002\n

Solution 2: Use different ports in .env

API_PORT=4002\nADMIN_PORT=3002\nMEDIA_API_PORT=4102\n
"},{"location":"v2/development/local-setup/#database-connection-errors","title":"Database Connection Errors","text":"

Problem: API cannot connect to PostgreSQL

Error: connect ECONNREFUSED 127.0.0.1:5433\n

Solution 1: Verify PostgreSQL is running

docker compose ps v2-postgres\n# Should show \"running\"\n

Solution 2: Check DATABASE_URL in .env

# Should match your password and port\nDATABASE_URL=postgresql://changemaker_v2:your_password@localhost:5433/changemaker_v2_db\n

Solution 3: Restart PostgreSQL container

docker compose restart v2-postgres\ndocker compose logs -f v2-postgres\n# Wait for \"ready to accept connections\"\n
"},{"location":"v2/development/local-setup/#redis-connection-errors","title":"Redis Connection Errors","text":"

Problem: API cannot connect to Redis

Error: Redis connection refused\n

Solution 1: Verify Redis is running

docker compose ps redis\n# Should show \"running\"\n

Solution 2: Check REDIS_URL and password

# Should match your password\nREDIS_URL=redis://:your_redis_password@localhost:6379\nREDIS_PASSWORD=your_redis_password\n

Solution 3: Test Redis connection directly

docker compose exec redis redis-cli -a your_redis_password ping\n# Should output: PONG\n
"},{"location":"v2/development/local-setup/#migration-errors","title":"Migration Errors","text":"

Problem: Prisma migration fails

Error: P3005 Database schema is not empty\n

Solution 1: Reset database (DEVELOPMENT ONLY)

cd api\nnpx prisma migrate reset\n# WARNING: This deletes all data!\n

Solution 2: Force deploy migrations

cd api\nnpx prisma migrate deploy --force\n

Solution 3: Check migration history

cd api\nnpx prisma migrate status\n
"},{"location":"v2/development/local-setup/#npm-install-failures","title":"npm Install Failures","text":"

Problem: npm install fails with permission errors

Solution 1: Clear npm cache

npm cache clean --force\nrm -rf node_modules package-lock.json\nnpm install\n

Solution 2: Use correct Node.js version

node --version  # Should be v20.x.x\nnvm use 20\n

Solution 3: Check disk space

df -h\n# Ensure sufficient space (10GB+ free)\n
"},{"location":"v2/development/local-setup/#hot-reload-not-working","title":"Hot Reload Not Working","text":"

Problem: Code changes don't trigger reload

Solution 1 (Docker): Verify volume mounts in docker-compose.yml

api:\n  volumes:\n    - ./api:/app  # Must be present\n

Solution 2 (Local): Restart dev server

# Stop (Ctrl+C) and restart\nnpm run dev\n

Solution 3 (Admin/Vite): Clear Vite cache

cd admin\nrm -rf node_modules/.vite\nnpm run dev\n
"},{"location":"v2/development/local-setup/#admin-build-errors","title":"Admin Build Errors","text":"

Problem: TypeScript errors on build

error TS2339: Property 'foo' does not exist on type 'Bar'\n

Solution 1: Type-check without emit

cd admin\nnpx tsc --noEmit\n# Shows all type errors\n

Solution 2: Update type definitions

cd admin\nnpm install --save-dev @types/react@latest @types/react-dom@latest\n

Solution 3: Check tsconfig.json

cd admin\ncat tsconfig.json\n# Ensure \"strict\": true and \"skipLibCheck\": false\n
"},{"location":"v2/development/local-setup/#docker-container-crashes","title":"Docker Container Crashes","text":"

Problem: API/Admin container exits immediately

Solution 1: Check logs

docker compose logs api\n# Look for error messages\n

Solution 2: Verify .env file exists

ls -la .env\n# Should exist in project root\n

Solution 3: Rebuild containers

docker compose down\ndocker compose build --no-cache api admin\ndocker compose up -d api admin\n
"},{"location":"v2/development/local-setup/#browser-cors-errors","title":"Browser CORS Errors","text":"

Problem: Admin cannot call API (CORS errors in browser console)

Solution 1: Check CORS_ORIGIN in .env

# For local development\nCORS_ORIGIN=http://localhost:3000\n

Solution 2: Verify API_URL in admin

For Docker-based API, admin vite.config.ts proxy should work automatically.

For local API, ensure VITE_API_URL is NOT set (defaults to localhost:4000).

Solution 3: Clear browser cache

  • Open DevTools \u2192 Network tab \u2192 Disable cache
  • Hard reload (Cmd+Shift+R / Ctrl+Shift+R)
"},{"location":"v2/development/local-setup/#hot-reload","title":"Hot Reload","text":""},{"location":"v2/development/local-setup/#api-hot-reload-tsx-watch","title":"API Hot Reload (tsx watch)","text":"

API uses tsx watch for automatic restart on file changes:

# Started automatically with npm run dev\ncd api\nnpm run dev\n

What triggers reload: - Changes to .ts files in src/ - Changes to .prisma files (after running migrate)

What does NOT trigger reload: - Changes to .env (restart manually) - Changes to node_modules/ (reinstall packages)

Manual restart:

# If using npm run dev, just Ctrl+C and restart\nnpm run dev\n

"},{"location":"v2/development/local-setup/#admin-hot-reload-vite-hmr","title":"Admin Hot Reload (Vite HMR)","text":"

Admin uses Vite's Hot Module Replacement (HMR):

cd admin\nnpm run dev\n

What triggers HMR: - Changes to .tsx / .ts files - Changes to .css files - Changes to imported assets

HMR Behavior: - Component changes: Updates without full reload - Hook changes: May require full reload - Route changes: Full reload

Force full reload: - Press r in terminal running Vite - Or refresh browser (Cmd+R / Ctrl+R)

"},{"location":"v2/development/local-setup/#docker-hot-reload","title":"Docker Hot Reload","text":"

Docker volume mounts enable hot reload in containers:

# docker-compose.yml\napi:\n  volumes:\n    - ./api:/app      # Syncs code changes\n    - /app/node_modules  # Preserves container's node_modules\n

Same reload behavior as local: - API: tsx watch restarts on .ts changes - Admin: Vite HMR updates browser

Performance note: - macOS/Windows: Volume mounts slightly slower than Linux - For intensive development, consider running locally instead

"},{"location":"v2/development/local-setup/#debugging","title":"Debugging","text":""},{"location":"v2/development/local-setup/#api-debugging-vscode","title":"API Debugging (VSCode)","text":"
  1. Open VSCode
  2. Open Run and Debug panel (Cmd+Shift+D / Ctrl+Shift+D)
  3. Select \"Debug API\" configuration
  4. Press F5 to start debugging
  5. Set breakpoints by clicking line numbers
  6. Trigger API endpoint to hit breakpoint

Debugging features: - Step through code (F10, F11) - Inspect variables - Evaluate expressions in Debug Console - Call stack navigation

"},{"location":"v2/development/local-setup/#frontend-debugging-chrome-devtools","title":"Frontend Debugging (Chrome DevTools)","text":"
  1. Open Admin in Chrome: http://localhost:3000
  2. Open DevTools (F12 / Cmd+Option+I)
  3. Go to Sources tab
  4. Find your component in file tree (webpack://./src/)
  5. Set breakpoints by clicking line numbers
  6. Interact with UI to trigger breakpoint

React DevTools: - Install React DevTools browser extension - Inspect component tree - View/edit props and state - Profile component renders

"},{"location":"v2/development/local-setup/#zustand-devtools","title":"Zustand DevTools","text":"

Enable Redux DevTools for Zustand stores:

// Already configured in auth.store.ts and canvass.store.ts\nimport { devtools } from 'zustand/middleware';\n\nexport const useAuthStore = create<AuthState>()(\n  devtools(\n    (set, get) => ({\n      // ... store implementation\n    }),\n    { name: 'AuthStore' }\n  )\n);\n

Usage: 1. Install Redux DevTools browser extension 2. Open extension 3. Select \"AuthStore\" or \"CanvassStore\" 4. See action history and state changes

"},{"location":"v2/development/local-setup/#common-workflows","title":"Common Workflows","text":""},{"location":"v2/development/local-setup/#starting-fresh-development-day","title":"Starting Fresh Development Day","text":"
# 1. Pull latest changes\ngit pull origin v2\n\n# 2. Check for dependency updates\ncd api && npm install && cd ..\ncd admin && npm install && cd ..\n\n# 3. Apply any new migrations\ncd api && npx prisma migrate deploy && cd ..\n\n# 4. Start services\ndocker compose up -d v2-postgres redis mailhog\n# Either:\ndocker compose up -d api admin  # Docker approach\n# Or:\ncd api && npm run dev  # Terminal 1 (local approach)\ncd admin && npm run dev  # Terminal 2 (local approach)\n\n# 5. Open browser\nopen http://localhost:3000\n
"},{"location":"v2/development/local-setup/#feature-development-workflow","title":"Feature Development Workflow","text":"
# 1. Create feature branch\ngit checkout -b feature/my-new-feature\n\n# 2. Start development servers (see above)\n\n# 3. Make changes\n# - Edit code\n# - Test in browser\n# - Check API responses\n\n# 4. Type-check\ncd api && npx tsc --noEmit && cd ..\ncd admin && npx tsc --noEmit && cd ..\n\n# 5. Run tests (when available)\n# cd api && npm test && cd ..\n# cd admin && npm test && cd ..\n\n# 6. Commit changes\ngit add .\ngit commit -m \"feat: add new feature\"\n\n# 7. Push and create PR\ngit push origin feature/my-new-feature\n# Open PR on GitHub/GitLab\n
"},{"location":"v2/development/local-setup/#database-schema-changes","title":"Database Schema Changes","text":"
# 1. Edit Prisma schema\ncd api\nvi prisma/schema.prisma  # Add/modify models\n\n# 2. Create migration\nnpx prisma migrate dev --name add_new_field\n\n# 3. Migration auto-applies to dev database\n# Check generated SQL in prisma/migrations/\n\n# 4. Update seed if needed\nvi prisma/seed.ts\n\n# 5. Test migration on clean database\nnpx prisma migrate reset  # WARNING: Deletes data\n# Re-run migrations + seed\n\n# 6. Commit migration files\ngit add prisma/migrations/ prisma/schema.prisma\ngit commit -m \"feat(db): add new field to User model\"\n
"},{"location":"v2/development/local-setup/#bug-fixing-workflow","title":"Bug Fixing Workflow","text":"
# 1. Reproduce bug locally\n# - Follow steps from bug report\n# - Check browser console\n# - Check API logs (docker compose logs -f api)\n\n# 2. Add logging to isolate issue\n# api/src/modules/foo/foo.service.ts\nlogger.error('Bug context', { data });\n\n# 3. Set breakpoints (VSCode debug)\n# - Run \"Debug API\" configuration\n# - Trigger bug\n# - Step through code\n\n# 4. Fix bug\n# - Make code changes\n# - Hot reload picks up changes\n# - Test fix\n\n# 5. Verify fix\n# - Re-test original bug steps\n# - Check related functionality\n# - Type-check: npx tsc --noEmit\n\n# 6. Commit fix\ngit add .\ngit commit -m \"fix: resolve issue with user login\"\n
"},{"location":"v2/development/local-setup/#switching-between-docker-and-local","title":"Switching Between Docker and Local","text":"

From Docker to Local:

# 1. Stop Docker services\ndocker compose stop api admin\n\n# 2. Keep databases running\ndocker compose ps v2-postgres redis mailhog\n# Should show running\n\n# 3. Start local dev servers\ncd api && npm run dev  # Terminal 1\ncd admin && npm run dev  # Terminal 2\n

From Local to Docker:

# 1. Stop local dev servers\n# Press Ctrl+C in both terminals\n\n# 2. Start Docker services\ndocker compose up -d api admin\n\n# 3. Watch logs\ndocker compose logs -f api admin\n
"},{"location":"v2/development/local-setup/#next-steps","title":"Next Steps","text":"

After completing local setup:

  1. Read Development Guides:
  2. NPM Commands Reference - All package.json scripts
  3. Docker Workflow - Advanced Docker development
  4. Database Migrations - Schema change workflow

  5. Understand Architecture:

  6. API Architecture - Backend organization
  7. Frontend Architecture - React app structure
  8. Database Schema - Data models

  9. Learn Code Patterns:

  10. TypeScript Guide - TypeScript best practices
  11. Code Style Guide - Coding standards
  12. Testing Guide - Test writing

  13. Start Contributing:

  14. Git Workflow - Branching and commits
  15. Contributing Guide - Contribution process
  16. V2 Development Plan - Roadmap and phases
"},{"location":"v2/development/local-setup/#related-documentation","title":"Related Documentation","text":"
  • Deployment: Docker Compose Deployment
  • Configuration: Environment Variables
  • Database: Migrations Guide
  • Testing: Testing Strategy
  • Debugging: Debugging Guide
"},{"location":"v2/development/local-setup/#getting-help","title":"Getting Help","text":"

Documentation: - This guide for setup issues - Troubleshooting for common problems - FAQ for quick answers

Community: - GitHub Issues for bug reports - GitHub Discussions for questions - Project README for contact info

Logs: - API logs: docker compose logs -f api - Admin logs: docker compose logs -f admin - Database logs: docker compose logs -f v2-postgres

"},{"location":"v2/development/local-setup/#summary","title":"Summary","text":"

You now have: - \u2705 Prerequisites installed (Node.js, Docker, Git) - \u2705 Repository cloned and on v2 branch - \u2705 Environment configured (.env file) - \u2705 Database initialized (migrations + seed) - \u2705 Services running (API + Admin + databases) - \u2705 IDE configured (VSCode with extensions) - \u2705 Admin GUI accessible (http://localhost:3000)

Test your setup: 1. Login to Admin GUI (admin@example.com / Admin123!) 2. Navigate to Users page (/app/users) 3. See yourself in the users table 4. Check MailHog (http://localhost:8025) for welcome email

Ready to develop! Choose a task from V2_PLAN.md Phase 15 or create a feature branch.

"},{"location":"v2/development/migrations/","title":"Database Migrations Guide","text":"

Complete guide to managing database schema changes in Changemaker Lite V2 using Prisma Migrate and Drizzle Kit.

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

Changemaker Lite V2 uses two ORMs for different parts of the application:

  • Prisma (Main API) - Full-featured ORM with migration tracking
  • Drizzle (Media API) - Lightweight ORM with schema push (no migrations)

This guide covers both workflows.

"},{"location":"v2/development/migrations/#prisma-migrations-main-api","title":"Prisma Migrations (Main API)","text":""},{"location":"v2/development/migrations/#migration-workflow-overview","title":"Migration Workflow Overview","text":"
1. Edit schema.prisma\n        \u2193\n2. Create migration (npx prisma migrate dev)\n        \u2193\n3. Review generated SQL\n        \u2193\n4. Test migration locally\n        \u2193\n5. Commit migration files\n        \u2193\n6. Deploy to production (npx prisma migrate deploy)\n
"},{"location":"v2/development/migrations/#understanding-prisma-migrate","title":"Understanding Prisma Migrate","text":"

Prisma Migrate: - Tracks schema changes as SQL migration files - Stores migration history in _prisma_migrations table - Ensures schema consistency across environments - Supports rollback via version control

Migration Files: - Located in api/prisma/migrations/ - Named with timestamp: 20260213123456_description/ - Contains migration.sql (SQL commands)

Migration States: - Pending: Not yet applied - Applied: Successfully executed - Failed: Execution error (requires manual fix)

"},{"location":"v2/development/migrations/#creating-migrations","title":"Creating Migrations","text":""},{"location":"v2/development/migrations/#step-1-edit-prisma-schema","title":"Step 1: Edit Prisma Schema","text":"

Edit api/prisma/schema.prisma:

// Before\nmodel User {\n  id        Int      @id @default(autoincrement())\n  email     String   @unique\n  password  String\n  role      Role     @default(USER)\n  createdAt DateTime @default(now())\n}\n\n// After (add name field)\nmodel User {\n  id        Int      @id @default(autoincrement())\n  email     String   @unique\n  password  String\n  name      String?  // New field (nullable)\n  role      Role     @default(USER)\n  createdAt DateTime @default(now())\n}\n
"},{"location":"v2/development/migrations/#step-2-validate-schema","title":"Step 2: Validate Schema","text":"
cd api\nnpx prisma validate\n

Expected output:

Environment variables loaded from .env\nPrisma schema loaded from prisma/schema.prisma\nThe schema is valid \u2714\n

If errors:

Error validating model \"User\": Field \"foo\" references unknown model \"Bar\"\n

Fix errors before proceeding.

"},{"location":"v2/development/migrations/#step-3-create-migration","title":"Step 3: Create Migration","text":"
cd api\nnpx prisma migrate dev --name add_user_name\n

What happens: 1. Prisma detects schema changes 2. Generates SQL migration file 3. Prompts for migration name (or uses --name argument) 4. Applies migration to development database 5. Regenerates Prisma Client

Expected output:

Environment variables loaded from .env\nPrisma schema loaded from prisma/schema.prisma\nDatasource \"db\": PostgreSQL database \"changemaker_v2_db\"\n\nApplying migration `20260213123456_add_user_name`\nRunning seed command `tsx prisma/seed.ts` ...\n\n\u2714 Generated Prisma Client to ./node_modules/@prisma/client\n

Migration file created:

api/prisma/migrations/\n\u2514\u2500\u2500 20260213123456_add_user_name/\n    \u2514\u2500\u2500 migration.sql\n

"},{"location":"v2/development/migrations/#step-4-review-generated-sql","title":"Step 4: Review Generated SQL","text":"
cd api\ncat prisma/migrations/20260213123456_add_user_name/migration.sql\n

Example SQL:

-- AlterTable\nALTER TABLE \"users\" ADD COLUMN \"name\" TEXT;\n

Verify SQL is correct: - Check table names match expectations - Ensure data types are correct - Look for unexpected DROP commands

"},{"location":"v2/development/migrations/#step-5-test-migration","title":"Step 5: Test Migration","text":"

Migration already applied to development DB. Verify:

# Check schema with Prisma Studio\ncd api\nnpx prisma studio\n

Or query directly:

# PostgreSQL shell\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db\n\n# Describe users table\nchangemaker_v2_db=# \\d users;\n

Expected output:

Column    |  Type   | Nullable | Default\n----------+---------+----------+---------\nid        | integer | not null | nextval(...)\nemail     | text    | not null |\npassword  | text    | not null |\nname      | text    |          |  <-- New field\nrole      | text    | not null | 'USER'\ncreated_at| timestamp| not null | now()\n

"},{"location":"v2/development/migrations/#step-6-commit-migration","title":"Step 6: Commit Migration","text":"
git add prisma/migrations/20260213123456_add_user_name/\ngit add prisma/schema.prisma\ngit commit -m \"feat(db): add name field to User model\"\n

Always commit: - Migration directory (prisma/migrations/*/) - Updated schema.prisma

"},{"location":"v2/development/migrations/#applying-migrations-production","title":"Applying Migrations (Production)","text":""},{"location":"v2/development/migrations/#in-production-environment","title":"In Production Environment","text":"
cd api\nnpx prisma migrate deploy\n

What it does: - Checks _prisma_migrations table for applied migrations - Applies only pending migrations - Does NOT create new migrations - Safe for production

Expected output:

Environment variables loaded from .env\nPrisma schema loaded from prisma/schema.prisma\nDatasource \"db\": PostgreSQL database \"changemaker_v2_prod_db\"\n\n2 migrations found in prisma/migrations\n\nApplying migration `20260213123456_add_user_name`\nApplying migration `20260214000000_add_user_avatar`\n\nAll migrations have been successfully applied.\n

"},{"location":"v2/development/migrations/#in-docker","title":"In Docker","text":"
# Apply migrations in Docker container\ndocker compose exec api npx prisma migrate deploy\n\n# Or during container startup (Dockerfile)\nCMD npx prisma migrate deploy && npm start\n
"},{"location":"v2/development/migrations/#cicd-deployment","title":"CI/CD Deployment","text":"
# GitHub Actions example\n- name: Run migrations\n  run: |\n    cd api\n    npx prisma migrate deploy\n
"},{"location":"v2/development/migrations/#migration-best-practices","title":"Migration Best Practices","text":""},{"location":"v2/development/migrations/#1-incremental-changes","title":"1. Incremental Changes","text":"

Make small, focused migrations:

Good:

# Separate migrations\nnpx prisma migrate dev --name add_user_name\nnpx prisma migrate dev --name add_user_avatar\nnpx prisma migrate dev --name add_user_bio\n

Bad:

# One huge migration\nnpx prisma migrate dev --name update_user_model\n# (adds 10 fields, 3 relations, 5 indexes)\n

"},{"location":"v2/development/migrations/#2-descriptive-names","title":"2. Descriptive Names","text":"

Use clear migration names:

Good:

npx prisma migrate dev --name add_user_name\nnpx prisma migrate dev --name make_email_unique\nnpx prisma migrate dev --name create_posts_table\nnpx prisma migrate dev --name add_user_posts_relation\n

Bad:

npx prisma migrate dev --name update\nnpx prisma migrate dev --name fix\nnpx prisma migrate dev --name changes\n

"},{"location":"v2/development/migrations/#3-review-sql-before-committing","title":"3. Review SQL Before Committing","text":"

Always review generated SQL:

cat prisma/migrations/*/migration.sql\n

Watch for: - Unexpected DROP TABLE or DROP COLUMN - Missing NOT NULL constraints - Incorrect data types - Missing indexes on foreign keys

"},{"location":"v2/development/migrations/#4-backup-before-migration-production","title":"4. Backup Before Migration (Production)","text":"
# Backup database before deploy\ndocker compose exec v2-postgres pg_dump -U changemaker_v2 changemaker_v2_db > backup-$(date +%Y%m%d).sql\n\n# Apply migration\nnpx prisma migrate deploy\n\n# If migration fails, restore:\ncat backup-20260213.sql | docker compose exec -T v2-postgres psql -U changemaker_v2 changemaker_v2_db\n
"},{"location":"v2/development/migrations/#5-test-on-staging-first","title":"5. Test on Staging First","text":"

Never deploy migrations directly to production:

1. Create migration in development\n2. Test locally\n3. Commit to version control\n4. Deploy to staging environment\n5. Test on staging\n6. Deploy to production\n
"},{"location":"v2/development/migrations/#common-migration-scenarios","title":"Common Migration Scenarios","text":""},{"location":"v2/development/migrations/#add-new-field","title":"Add New Field","text":"
// schema.prisma\nmodel User {\n  id    Int    @id @default(autoincrement())\n  email String @unique\n  name  String? // New nullable field\n}\n
npx prisma migrate dev --name add_user_name\n

Generated SQL:

ALTER TABLE \"users\" ADD COLUMN \"name\" TEXT;\n

"},{"location":"v2/development/migrations/#add-required-field-with-default","title":"Add Required Field (with Default)","text":"
model User {\n  id        Int      @id @default(autoincrement())\n  email     String   @unique\n  createdAt DateTime @default(now()) // New required field with default\n}\n
npx prisma migrate dev --name add_created_at\n

Generated SQL:

ALTER TABLE \"users\" ADD COLUMN \"created_at\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;\n

"},{"location":"v2/development/migrations/#add-new-table","title":"Add New Table","text":"
model Post {\n  id        Int      @id @default(autoincrement())\n  title     String\n  content   String?\n  published Boolean  @default(false)\n  authorId  Int\n  author    User     @relation(fields: [authorId], references: [id])\n  createdAt DateTime @default(now())\n}\n\nmodel User {\n  id    Int    @id @default(autoincrement())\n  email String @unique\n  posts Post[]\n}\n
npx prisma migrate dev --name create_posts_table\n

Generated SQL:

CREATE TABLE \"posts\" (\n    \"id\" SERIAL NOT NULL,\n    \"title\" TEXT NOT NULL,\n    \"content\" TEXT,\n    \"published\" BOOLEAN NOT NULL DEFAULT false,\n    \"author_id\" INTEGER NOT NULL,\n    \"created_at\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"posts_pkey\" PRIMARY KEY (\"id\")\n);\n\nCREATE INDEX \"posts_author_id_idx\" ON \"posts\"(\"author_id\");\n\nALTER TABLE \"posts\" ADD CONSTRAINT \"posts_author_id_fkey\"\n    FOREIGN KEY (\"author_id\") REFERENCES \"users\"(\"id\")\n    ON DELETE RESTRICT ON UPDATE CASCADE;\n

"},{"location":"v2/development/migrations/#add-relation","title":"Add Relation","text":"
model Campaign {\n  id           Int    @id @default(autoincrement())\n  title        String\n  createdByUserId Int // New foreign key\n  createdBy    User   @relation(fields: [createdByUserId], references: [id])\n}\n\nmodel User {\n  id        Int        @id @default(autoincrement())\n  email     String     @unique\n  campaigns Campaign[]\n}\n
npx prisma migrate dev --name add_campaign_user_relation\n

Generated SQL:

ALTER TABLE \"campaigns\" ADD COLUMN \"created_by_user_id\" INTEGER NOT NULL;\n\nCREATE INDEX \"campaigns_created_by_user_id_idx\" ON \"campaigns\"(\"created_by_user_id\");\n\nALTER TABLE \"campaigns\" ADD CONSTRAINT \"campaigns_created_by_user_id_fkey\"\n    FOREIGN KEY (\"created_by_user_id\") REFERENCES \"users\"(\"id\")\n    ON DELETE RESTRICT ON UPDATE CASCADE;\n

"},{"location":"v2/development/migrations/#change-field-type","title":"Change Field Type","text":"
// Before\nmodel User {\n  age Int\n}\n\n// After\nmodel User {\n  age String // Changed from Int to String\n}\n
npx prisma migrate dev --name change_user_age_to_string\n

Generated SQL:

ALTER TABLE \"users\" ALTER COLUMN \"age\" SET DATA TYPE TEXT;\n

Warning: This may fail if data cannot be cast. Consider data migration first.

"},{"location":"v2/development/migrations/#add-unique-constraint","title":"Add Unique Constraint","text":"
model User {\n  email String @unique // Add unique constraint\n}\n
npx prisma migrate dev --name make_email_unique\n

Generated SQL:

CREATE UNIQUE INDEX \"users_email_key\" ON \"users\"(\"email\");\n

"},{"location":"v2/development/migrations/#add-index","title":"Add Index","text":"
model User {\n  email String\n\n  @@index([email]) // Add index\n}\n
npx prisma migrate dev --name add_email_index\n

Generated SQL:

CREATE INDEX \"users_email_idx\" ON \"users\"(\"email\");\n

"},{"location":"v2/development/migrations/#migration-history-and-status","title":"Migration History and Status","text":""},{"location":"v2/development/migrations/#check-migration-status","title":"Check Migration Status","text":"
cd api\nnpx prisma migrate status\n

Expected output:

Environment variables loaded from .env\nPrisma schema loaded from prisma/schema.prisma\nDatasource \"db\": PostgreSQL database \"changemaker_v2_db\"\n\nDatabase schema is up to date!\n\nFollowing migrations have been applied:\n\n20260101000000_init\n20260105000000_add_campaigns\n20260110000000_add_locations\n20260213123456_add_user_name\n

"},{"location":"v2/development/migrations/#view-migration-history","title":"View Migration History","text":"
# List migration files\nls -la api/prisma/migrations/\n\n# View specific migration\ncat api/prisma/migrations/20260213123456_add_user_name/migration.sql\n
"},{"location":"v2/development/migrations/#check-database-migration-table","title":"Check Database Migration Table","text":"
docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"SELECT * FROM _prisma_migrations;\"\n

Output:

id | checksum | finished_at | migration_name | logs\n---+----------+-------------+----------------+-----\n1  | abc123   | 2026-01-01  | 20260101000000_init | NULL\n2  | def456   | 2026-01-05  | 20260105000000_add_campaigns | NULL\n

"},{"location":"v2/development/migrations/#rollback-strategies","title":"Rollback Strategies","text":"

Prisma Migrate does NOT have automatic rollback. Use these strategies:

"},{"location":"v2/development/migrations/#1-version-control-rollback","title":"1. Version Control Rollback","text":"
# Revert schema changes\ngit revert <commit-hash>\n\n# Create new migration to undo changes\nnpx prisma migrate dev --name revert_user_name\n\n# This creates a new migration that undoes the previous one\n
"},{"location":"v2/development/migrations/#2-manual-rollback-migration","title":"2. Manual Rollback Migration","text":"

Create a new migration to reverse changes:

// If you added a field, remove it\nmodel User {\n  id    Int    @id @default(autoincrement())\n  email String @unique\n  // name  String? // Remove this\n}\n
npx prisma migrate dev --name remove_user_name\n

Generated SQL:

ALTER TABLE \"users\" DROP COLUMN \"name\";\n

"},{"location":"v2/development/migrations/#3-database-restore-last-resort","title":"3. Database Restore (Last Resort)","text":"
# Restore from backup\ncat backup-20260213.sql | docker compose exec -T v2-postgres psql -U changemaker_v2 changemaker_v2_db\n\n# Mark migrations as rolled back\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"\n  DELETE FROM _prisma_migrations\n  WHERE migration_name = '20260213123456_add_user_name';\n\"\n
"},{"location":"v2/development/migrations/#4-reset-development-database","title":"4. Reset Development Database","text":"

WARNING: Deletes all data!

cd api\nnpx prisma migrate reset\n

This: 1. Drops all tables 2. Re-applies all migrations from scratch 3. Runs seed script

"},{"location":"v2/development/migrations/#handling-migration-conflicts","title":"Handling Migration Conflicts","text":""},{"location":"v2/development/migrations/#schema-drift","title":"Schema Drift","text":"

Problem: Database schema doesn't match Prisma schema.

Symptoms:

Error: Database schema is not in sync with the migration history\n

Solution:

# Check what's different\nnpx prisma migrate diff \\\n  --from-schema-datamodel prisma/schema.prisma \\\n  --to-schema-datasource prisma/schema.prisma\n\n# Create migration to fix drift\nnpx prisma migrate dev --name fix_schema_drift\n
"},{"location":"v2/development/migrations/#failed-migration","title":"Failed Migration","text":"

Problem: Migration fails during apply.

Symptoms:

Error: Migration failed with error:\n  ALTER TABLE \"users\" ADD COLUMN \"age\" INTEGER NOT NULL;\n  ERROR: column \"age\" contains null values\n

Solution:

# 1. Mark migration as rolled back\nnpx prisma migrate resolve --rolled-back 20260213123456_add_user_age\n\n# 2. Fix migration SQL manually\nvi prisma/migrations/20260213123456_add_user_age/migration.sql\n\n# Change to:\nALTER TABLE \"users\" ADD COLUMN \"age\" INTEGER; -- Make nullable first\nUPDATE \"users\" SET \"age\" = 0 WHERE \"age\" IS NULL; -- Set default\nALTER TABLE \"users\" ALTER COLUMN \"age\" SET NOT NULL; -- Then make required\n\n# 3. Apply migration again\nnpx prisma migrate deploy\n
"},{"location":"v2/development/migrations/#conflicting-migrations-team-environment","title":"Conflicting Migrations (Team Environment)","text":"

Problem: Two developers create migrations simultaneously.

Solution:

# 1. Pull latest changes\ngit pull origin v2\n\n# 2. Prisma detects conflict\nnpx prisma migrate dev\n\n# 3. Resolve by creating merge migration\n# Prisma will prompt you to create a migration that includes both changes\n
"},{"location":"v2/development/migrations/#data-migrations","title":"Data Migrations","text":"

Prisma Migrate handles schema changes, not data changes. For data transformations:

"},{"location":"v2/development/migrations/#option-1-custom-sql-in-migration","title":"Option 1: Custom SQL in Migration","text":"

Edit generated migration file:

-- Add column (Prisma-generated)\nALTER TABLE \"users\" ADD COLUMN \"full_name\" TEXT;\n\n-- Populate from existing data (manual addition)\nUPDATE \"users\" SET \"full_name\" = \"first_name\" || ' ' || \"last_name\";\n\n-- Remove old columns (Prisma-generated)\nALTER TABLE \"users\" DROP COLUMN \"first_name\";\nALTER TABLE \"users\" DROP COLUMN \"last_name\";\n
"},{"location":"v2/development/migrations/#option-2-separate-data-migration-script","title":"Option 2: Separate Data Migration Script","text":"
// api/prisma/data-migrations/20260213-populate-full-name.ts\nimport { PrismaClient } from '@prisma/client';\n\nconst prisma = new PrismaClient();\n\nasync function main() {\n  const users = await prisma.user.findMany();\n\n  for (const user of users) {\n    await prisma.user.update({\n      where: { id: user.id },\n      data: {\n        fullName: `${user.firstName} ${user.lastName}`\n      }\n    });\n  }\n\n  console.log(`Updated ${users.length} users`);\n}\n\nmain()\n  .catch(console.error)\n  .finally(() => prisma.$disconnect());\n

Run after migration:

npx tsx prisma/data-migrations/20260213-populate-full-name.ts\n
"},{"location":"v2/development/migrations/#drizzle-push-media-api","title":"Drizzle Push (Media API)","text":""},{"location":"v2/development/migrations/#drizzle-overview","title":"Drizzle Overview","text":"

Drizzle Kit Push: - Syncs schema directly to database - No migration files generated - Fast iteration for prototyping - Used only for Media API tables

Schema Location: - api/src/modules/media/db/schema.ts

When to Use: - Rapid prototyping - Development only - Media API tables (videos, jobs, reactions)

When NOT to Use: - Production deployments - Main API tables (use Prisma) - When migration history is needed

"},{"location":"v2/development/migrations/#drizzle-push-workflow","title":"Drizzle Push Workflow","text":""},{"location":"v2/development/migrations/#step-1-edit-schema","title":"Step 1: Edit Schema","text":"

Edit api/src/modules/media/db/schema.ts:

// Before\nexport const videos = pgTable('videos', {\n  id: serial('id').primaryKey(),\n  filename: text('filename').notNull(),\n  title: text('title'),\n  duration: integer('duration'),\n  createdAt: timestamp('created_at').defaultNow().notNull(),\n});\n\n// After (add description field)\nexport const videos = pgTable('videos', {\n  id: serial('id').primaryKey(),\n  filename: text('filename').notNull(),\n  title: text('title'),\n  description: text('description'), // New field\n  duration: integer('duration'),\n  createdAt: timestamp('created_at').defaultNow().notNull(),\n});\n
"},{"location":"v2/development/migrations/#step-2-push-schema","title":"Step 2: Push Schema","text":"
cd api\nnpm run drizzle:push\n

Or directly:

cd api\nnpx drizzle-kit push\n

What happens: 1. Drizzle compares schema to database 2. Generates SQL for changes 3. Applies changes immediately 4. No migration files created

Expected output:

Reading config from drizzle.config.ts\nUsing 'pg' driver for database querying\n\nPulling schema from database...\n[\u2713] Schema pulled successfully\n\nComparing schemas...\n[!] Changes detected:\n  - ALTER TABLE \"videos\" ADD COLUMN \"description\" TEXT;\n\nDo you want to execute these changes? [y/N]: y\n\nApplying changes...\n[\u2713] Schema pushed successfully\n

"},{"location":"v2/development/migrations/#step-3-verify-changes","title":"Step 3: Verify Changes","text":"
# Check with Drizzle Studio\ncd api\nnpx drizzle-kit studio\n

Or query directly:

docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"\\d videos\"\n
"},{"location":"v2/development/migrations/#drizzle-best-practices","title":"Drizzle Best Practices","text":""},{"location":"v2/development/migrations/#1-development-only","title":"1. Development Only","text":"

Use Drizzle Push only in development:

Good:

# Development\nnpm run drizzle:push\n

Bad:

# Production (use Prisma migrate for production schema changes)\nnpm run drizzle:push\n

"},{"location":"v2/development/migrations/#2-backup-before-push","title":"2. Backup Before Push","text":"

Always backup before pushing schema:

# Backup database\ndocker compose exec v2-postgres pg_dump -U changemaker_v2 changemaker_v2_db > backup.sql\n\n# Push schema\nnpm run drizzle:push\n\n# If something breaks, restore:\ncat backup.sql | docker compose exec -T v2-postgres psql -U changemaker_v2 changemaker_v2_db\n
"},{"location":"v2/development/migrations/#3-test-changes-locally","title":"3. Test Changes Locally","text":"

Never push untested schema changes:

# 1. Edit schema\nvi src/modules/media/db/schema.ts\n\n# 2. Push to dev database\nnpm run drizzle:push\n\n# 3. Test with Drizzle Studio\nnpm run drizzle:studio\n\n# 4. Test API endpoints\ncurl http://localhost:4100/api/media/videos\n
"},{"location":"v2/development/migrations/#drizzle-vs-prisma","title":"Drizzle vs Prisma","text":"Feature Prisma Migrate Drizzle Push Migration files \u2705 Yes \u274c No Migration history \u2705 Tracked \u274c Not tracked Rollback \u2705 Via version control \u274c Manual only Production use \u2705 Recommended \u26a0\ufe0f Not recommended Prototyping \u26a0\ufe0f Slower \u2705 Faster Use case Main API tables Media API tables"},{"location":"v2/development/migrations/#seeding-after-migration","title":"Seeding After Migration","text":""},{"location":"v2/development/migrations/#running-seed-script","title":"Running Seed Script","text":"

After migrations, seed database:

cd api\nnpx prisma db seed\n

What it does: - Runs prisma/seed.ts - Creates admin user - Creates default settings - Creates sample blocks

Expected output:

Running seed command `tsx prisma/seed.ts` ...\nSeeding database...\nCreated default settings\nCreated admin user: admin@example.com\nCreated 10 sample blocks\nSeed completed successfully\n

"},{"location":"v2/development/migrations/#custom-seed-data","title":"Custom Seed Data","text":"

Edit api/prisma/seed.ts:

import { PrismaClient } from '@prisma/client';\n\nconst prisma = new PrismaClient();\n\nasync function main() {\n  // Create admin user\n  await prisma.user.upsert({\n    where: { email: 'admin@example.com' },\n    update: {},\n    create: {\n      email: 'admin@example.com',\n      password: await hashPassword('Admin123!'),\n      role: 'SUPER_ADMIN',\n      name: 'Admin User'\n    }\n  });\n\n  // Create sample campaign\n  await prisma.campaign.create({\n    data: {\n      title: 'Sample Campaign',\n      description: 'This is a sample campaign',\n      active: true,\n      createdByUserId: 1\n    }\n  });\n\n  console.log('Seed completed');\n}\n\nmain()\n  .catch(console.error)\n  .finally(() => prisma.$disconnect());\n
"},{"location":"v2/development/migrations/#cicd-integration","title":"CI/CD Integration","text":""},{"location":"v2/development/migrations/#github-actions-example","title":"GitHub Actions Example","text":"
name: Deploy\n\non:\n  push:\n    branches: [main]\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v3\n        with:\n          node-version: '20'\n\n      - name: Install dependencies\n        working-directory: ./api\n        run: npm ci\n\n      - name: Run migrations\n        working-directory: ./api\n        env:\n          DATABASE_URL: ${{ secrets.DATABASE_URL }}\n        run: npx prisma migrate deploy\n\n      - name: Seed database\n        working-directory: ./api\n        env:\n          DATABASE_URL: ${{ secrets.DATABASE_URL }}\n        run: npx prisma db seed\n
"},{"location":"v2/development/migrations/#docker-deployment","title":"Docker Deployment","text":"
# api/Dockerfile\nFROM node:20-alpine\n\nWORKDIR /app\n\nCOPY package*.json ./\nRUN npm ci --production\n\nCOPY . .\n\n# Generate Prisma Client\nRUN npx prisma generate\n\n# Run migrations on startup\nCMD npx prisma migrate deploy && npm start\n
"},{"location":"v2/development/migrations/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/development/migrations/#migration-fails-with-column-already-exists","title":"Migration Fails with \"Column Already Exists\"","text":"

Problem:

Error: column \"name\" of relation \"users\" already exists\n

Solution:

# Mark migration as applied\nnpx prisma migrate resolve --applied 20260213123456_add_user_name\n\n# Or drop column manually and re-run\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"ALTER TABLE users DROP COLUMN name;\"\nnpx prisma migrate deploy\n
"},{"location":"v2/development/migrations/#migration-fails-with-relation-does-not-exist","title":"Migration Fails with \"Relation Does Not Exist\"","text":"

Problem:

Error: relation \"posts\" does not exist\n

Solution:

# Check migration history\nnpx prisma migrate status\n\n# Apply missing migrations\nnpx prisma migrate deploy\n\n# Or reset (development only)\nnpx prisma migrate reset\n
"},{"location":"v2/development/migrations/#schema-out-of-sync","title":"Schema Out of Sync","text":"

Problem:

Error: Database schema is not in sync\n

Solution:

# Generate migration to fix drift\nnpx prisma migrate dev --name fix_drift\n\n# Or in production, create explicit migration\nnpx prisma migrate diff \\\n  --from-schema-datamodel prisma/schema.prisma \\\n  --to-schema-datasource prisma/schema.prisma \\\n  --script > fix-drift.sql\n\n# Review fix-drift.sql and apply manually\n
"},{"location":"v2/development/migrations/#drizzle-push-fails","title":"Drizzle Push Fails","text":"

Problem:

Error: Could not push schema\n

Solution:

# Check Drizzle config\ncat api/drizzle.config.ts\n\n# Verify DATABASE_URL\necho $DATABASE_URL\n\n# Test database connection\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"SELECT 1\"\n\n# Clear Drizzle cache and retry\nrm -rf api/.drizzle\nnpm run drizzle:push\n
"},{"location":"v2/development/migrations/#related-documentation","title":"Related Documentation","text":"
  • Setup: Local Development Setup
  • Commands: NPM Commands Reference
  • Docker: Docker Workflow
  • Database: Database Schema
  • Deployment: Production Deployment
"},{"location":"v2/development/migrations/#summary","title":"Summary","text":"

You now know: - \u2705 How Prisma Migrate tracks schema changes - \u2705 How to create and apply migrations - \u2705 Common migration scenarios (add field, table, relation) - \u2705 Migration best practices - \u2705 How to handle migration conflicts - \u2705 How to perform data migrations - \u2705 How Drizzle Push works for Media API - \u2705 When to use Prisma vs Drizzle - \u2705 How to seed database after migrations - \u2705 How to integrate migrations in CI/CD

Quick Reference:

# Prisma: Create migration\nnpx prisma migrate dev --name description\n\n# Prisma: Apply migrations (production)\nnpx prisma migrate deploy\n\n# Prisma: Check status\nnpx prisma migrate status\n\n# Drizzle: Push schema (dev only)\nnpx drizzle-kit push\n\n# Seed database\nnpx prisma db seed\n\n# Reset (dev only, DELETES DATA)\nnpx prisma migrate reset\n

"},{"location":"v2/development/npm-commands/","title":"NPM Commands Reference","text":"

Complete reference for all npm scripts in Changemaker Lite V2.

"},{"location":"v2/development/npm-commands/#overview","title":"Overview","text":"

Changemaker Lite V2 uses npm scripts for development, building, testing, and database management. Scripts are defined in package.json files in two main directories:

  • api/package.json - Backend API scripts (Express + Fastify)
  • admin/package.json - Frontend GUI scripts (React + Vite)

This guide documents all available scripts, their usage, and common combinations.

"},{"location":"v2/development/npm-commands/#api-scripts","title":"API Scripts","text":"

Location: api/package.json

"},{"location":"v2/development/npm-commands/#development-scripts","title":"Development Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-dev","title":"npm run dev","text":"

Starts the Express API server in development mode with hot reload.

cd api\nnpm run dev\n

What it does: - Runs tsx watch src/server.ts - Auto-restarts on file changes (.ts files) - Loads environment from .env - Runs on port API_PORT (default: 4000)

Output:

Server running on port 4000\nDatabase connected\nRedis connected\nBullMQ worker started\n

Use when: - Developing API endpoints - Testing backend changes - Debugging server code

"},{"location":"v2/development/npm-commands/#npm-run-devmedia","title":"npm run dev:media","text":"

Starts the Fastify Media API server in development mode.

cd api\nnpm run dev:media\n

What it does: - Runs tsx watch src/media-server.ts - Auto-restarts on file changes - Runs on port MEDIA_API_PORT (default: 4100)

Output:

Media API server running on port 4100\nDatabase connected\n

Use when: - Developing media features (video upload, reactions) - Testing Media API endpoints - Working on FFprobe integration

"},{"location":"v2/development/npm-commands/#build-scripts","title":"Build Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-build","title":"npm run build","text":"

Compiles TypeScript to JavaScript for production.

cd api\nnpm run build\n

What it does: - Runs tsc --build - Outputs to dist/ directory - Type-checks all code - Fails on type errors

Output:

dist/\n\u251c\u2500\u2500 server.js\n\u251c\u2500\u2500 media-server.js\n\u2514\u2500\u2500 modules/\n    \u251c\u2500\u2500 auth/\n    \u251c\u2500\u2500 users/\n    \u2514\u2500\u2500 ...\n

Use when: - Preparing for production deployment - Verifying build succeeds - Creating Docker images

"},{"location":"v2/development/npm-commands/#npm-run-clean","title":"npm run clean","text":"

Removes compiled JavaScript and build artifacts.

cd api\nnpm run clean\n

What it does: - Deletes dist/ directory - Removes *.tsbuildinfo files

Use when: - Starting fresh build - Fixing build cache issues - Cleaning up after development

"},{"location":"v2/development/npm-commands/#production-scripts","title":"Production Scripts","text":""},{"location":"v2/development/npm-commands/#npm-start","title":"npm start","text":"

Runs the compiled API server (production mode).

cd api\nnpm start\n

What it does: - Runs node dist/server.js - Requires npm run build first - Uses production environment (NODE_ENV=production)

Output:

Server running on port 4000\nDatabase connected\nRedis connected\n

Use when: - Running in production (Docker) - Testing production build locally

"},{"location":"v2/development/npm-commands/#npm-run-startmedia","title":"npm run start:media","text":"

Runs the compiled Media API server (production mode).

cd api\nnpm run start:media\n

What it does: - Runs node dist/media-server.js - Requires npm run build first

Use when: - Running Media API in production - Testing production Media API

"},{"location":"v2/development/npm-commands/#code-quality-scripts","title":"Code Quality Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-type-check","title":"npm run type-check","text":"

Type-checks TypeScript without emitting files.

cd api\nnpm run type-check\n

What it does: - Runs tsc --noEmit - Reports type errors - Does NOT generate files

Output:

# Success (no output)\n\n# Errors\nsrc/modules/auth/auth.service.ts:45:12 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.\n

Use when: - Before committing code - In CI/CD pipeline - Debugging type errors

"},{"location":"v2/development/npm-commands/#npm-run-lint","title":"npm run lint","text":"

Runs ESLint to check code style.

cd api\nnpm run lint\n

What it does: - Runs eslint src/ --ext .ts - Reports style violations - Checks for common errors

Output:

# Success\n\u2714 150 files linted, 0 errors, 0 warnings\n\n# Errors\nsrc/modules/auth/auth.service.ts\n  45:12  error  'foo' is assigned a value but never used  @typescript-eslint/no-unused-vars\n

Use when: - Before committing code - Enforcing code style - Finding potential bugs

"},{"location":"v2/development/npm-commands/#npm-run-lintfix","title":"npm run lint:fix","text":"

Automatically fixes ESLint errors where possible.

cd api\nnpm run lint:fix\n

What it does: - Runs eslint src/ --ext .ts --fix - Auto-fixes style issues (formatting, imports, etc.) - Reports unfixable errors

Use when: - After writing new code - Cleaning up formatting - Before commit

"},{"location":"v2/development/npm-commands/#npm-run-format","title":"npm run format","text":"

Formats code with Prettier.

cd api\nnpm run format\n

What it does: - Runs prettier --write \"src/**/*.{ts,js,json}\" - Formats all TypeScript, JavaScript, and JSON files - Overwrites files in place

Use when: - Standardizing code format - After merge conflicts - Team-wide formatting

"},{"location":"v2/development/npm-commands/#npm-run-formatcheck","title":"npm run format:check","text":"

Checks if code is formatted correctly (CI).

cd api\nnpm run format:check\n

What it does: - Runs prettier --check \"src/**/*.{ts,js,json}\" - Reports unformatted files - Does NOT modify files

Use when: - In CI/CD pipeline - Verifying format before commit

"},{"location":"v2/development/npm-commands/#database-scripts","title":"Database Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-prismamigrate","title":"npm run prisma:migrate","text":"

Creates and applies a new Prisma migration.

cd api\nnpm run prisma:migrate\n# Or with name:\nnpx prisma migrate dev --name add_user_field\n

What it does: - Prompts for migration name - Generates SQL migration in prisma/migrations/ - Applies migration to development database - Regenerates Prisma Client

Output:

\u2714 Enter a name for the new migration: \u2026 add_user_field\nApplying migration `20260213000000_add_user_field`\n\u2714 Generated Prisma Client to ./node_modules/@prisma/client\n

Use when: - Changing database schema - Adding new models - Modifying fields

"},{"location":"v2/development/npm-commands/#npm-run-prismadeploy","title":"npm run prisma:deploy","text":"

Applies pending migrations (production).

cd api\nnpm run prisma:deploy\n

What it does: - Runs prisma migrate deploy - Applies unapplied migrations only - Does NOT create new migrations - Safe for production

Output:

Environment variables loaded from .env\nDatasource \"db\": PostgreSQL database \"changemaker_v2_db\"\n\n2 migrations found in prisma/migrations\n\nApplying migration `20260213000000_add_user_field`\nAll migrations have been successfully applied.\n

Use when: - Deploying to production - Applying migrations in Docker - CI/CD deployment

"},{"location":"v2/development/npm-commands/#npm-run-prismaseed","title":"npm run prisma:seed","text":"

Seeds database with initial data.

cd api\nnpm run prisma:seed\n

What it does: - Runs tsx prisma/seed.ts - Creates admin user - Creates default settings - Creates sample blocks

Output:

Running seed command `tsx prisma/seed.ts` ...\nSeeding database...\nCreated default settings\nCreated admin user: admin@example.com\nCreated 10 sample blocks\nSeed completed successfully\n

Use when: - First-time setup - After reset - Populating test data

"},{"location":"v2/development/npm-commands/#npm-run-prismastudio","title":"npm run prisma:studio","text":"

Opens Prisma Studio (database GUI).

cd api\nnpm run prisma:studio\n

What it does: - Runs prisma studio - Opens browser at http://localhost:5555 - Shows all tables and data - Allows CRUD operations

Use when: - Inspecting database - Manual data editing - Debugging data issues

"},{"location":"v2/development/npm-commands/#npm-run-prismareset","title":"npm run prisma:reset","text":"

Resets database (DESTRUCTIVE).

cd api\nnpm run prisma:reset\n

What it does: - Drops all tables - Re-applies all migrations - Runs seed script - DELETES ALL DATA

Output:

\u26a0\ufe0f  You are about to drop the database 'changemaker_v2_db'\n   All data will be lost.\n\nDo you want to continue? [y/N]: y\n\nDatabase reset successful\nMigrations applied\nSeed completed\n

Use when: - Starting fresh in development - Fixing migration conflicts - NEVER in production

"},{"location":"v2/development/npm-commands/#npm-run-prismavalidate","title":"npm run prisma:validate","text":"

Validates Prisma schema.

cd api\nnpm run prisma:validate\n

What it does: - Runs prisma validate - Checks schema syntax - Verifies relations - Does NOT touch database

Output:

# Success\nThe schema is valid \u2714\n\n# Errors\nError validating model \"User\": Field \"foo\" references unknown model \"Bar\"\n

Use when: - After editing schema - Before creating migration - In CI/CD pipeline

"},{"location":"v2/development/npm-commands/#npm-run-drizzlepush","title":"npm run drizzle:push","text":"

Pushes Drizzle schema changes to database (Media API).

cd api\nnpm run drizzle:push\n

What it does: - Runs drizzle-kit push - Syncs src/modules/media/db/schema.ts to database - Does NOT create migration files - Direct schema sync

Output:

Reading config from drizzle.config.ts\nPushing schema to database...\n\u2714 Schema pushed successfully\n

Use when: - Changing Media API tables (videos, jobs, reactions) - Rapid prototyping (no migrations) - Development only

"},{"location":"v2/development/npm-commands/#npm-run-drizzlestudio","title":"npm run drizzle:studio","text":"

Opens Drizzle Studio (database GUI for Media API).

cd api\nnpm run drizzle:studio\n

What it does: - Runs drizzle-kit studio - Opens browser at http://localhost:4983 - Shows Media API tables only

Use when: - Inspecting media tables - Debugging video data - Manual media data editing

"},{"location":"v2/development/npm-commands/#testing-scripts","title":"Testing Scripts","text":""},{"location":"v2/development/npm-commands/#npm-test","title":"npm test","text":"

Runs all tests (when configured).

cd api\nnpm test\n

What it does: - Runs Jest test suite - Executes *.test.ts files - Reports pass/fail

Note: Tests are part of Phase 15 (in progress).

"},{"location":"v2/development/npm-commands/#npm-run-testwatch","title":"npm run test:watch","text":"

Runs tests in watch mode.

cd api\nnpm run test:watch\n

What it does: - Runs jest --watch - Re-runs tests on file changes

"},{"location":"v2/development/npm-commands/#npm-run-testcoverage","title":"npm run test:coverage","text":"

Runs tests with coverage report.

cd api\nnpm run test:coverage\n

What it does: - Runs jest --coverage - Generates coverage report in coverage/

"},{"location":"v2/development/npm-commands/#utility-scripts","title":"Utility Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-envvalidate","title":"npm run env:validate","text":"

Validates required environment variables.

cd api\nnpm run env:validate\n

What it does: - Checks .env has required vars - Uses Zod validation (from config/env.ts) - Fails if vars missing/invalid

Output:

# Success\n\u2714 Environment variables valid\n\n# Errors\nError: Missing required environment variables:\n  - JWT_ACCESS_SECRET\n  - REDIS_PASSWORD\n

Use when: - After editing .env - Before deployment - Debugging config issues

"},{"location":"v2/development/npm-commands/#admin-scripts","title":"Admin Scripts","text":"

Location: admin/package.json

"},{"location":"v2/development/npm-commands/#development-scripts_1","title":"Development Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-dev_1","title":"npm run dev","text":"

Starts Vite development server with HMR.

cd admin\nnpm run dev\n

What it does: - Runs vite - Starts dev server on port ADMIN_PORT (default: 3000) - Enables Hot Module Replacement (HMR) - Proxies API requests to VITE_API_URL

Output:

  VITE v5.x.x  ready in 500 ms\n\n  \u279c  Local:   http://localhost:3000/\n  \u279c  Network: use --host to expose\n

Use when: - Developing frontend components - Testing UI changes - Working on React code

"},{"location":"v2/development/npm-commands/#build-scripts_1","title":"Build Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-build_1","title":"npm run build","text":"

Builds production-optimized bundle.

cd admin\nnpm run build\n

What it does: - Runs tsc --noEmit && vite build - Type-checks TypeScript - Bundles JavaScript/CSS - Optimizes assets (minify, tree-shake) - Outputs to dist/

Output:

vite v5.x.x building for production...\n\u2713 1245 modules transformed.\ndist/index.html                   0.45 kB\ndist/assets/index-a1b2c3d4.js   245.67 kB \u2502 gzip: 78.23 kB\ndist/assets/index-e5f6g7h8.css   12.34 kB \u2502 gzip:  3.45 kB\n\u2713 built in 15.23s\n

Use when: - Preparing for production deployment - Creating Docker image - Verifying build size

"},{"location":"v2/development/npm-commands/#npm-run-preview","title":"npm run preview","text":"

Previews production build locally.

cd admin\nnpm run preview\n

What it does: - Runs vite preview - Serves dist/ directory - Runs on port 4173 (Vite default)

Output:

  \u279c  Local:   http://localhost:4173/\n  \u279c  Network: use --host to expose\n

Use when: - Testing production build - Verifying optimizations - Before deployment

"},{"location":"v2/development/npm-commands/#code-quality-scripts_1","title":"Code Quality Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-type-check_1","title":"npm run type-check","text":"

Type-checks TypeScript without emitting files.

cd admin\nnpm run type-check\n

What it does: - Runs tsc --noEmit - Reports type errors - Checks all .ts and .tsx files

Output:

# Success (no output)\n\n# Errors\nsrc/pages/UsersPage.tsx:123:45 - error TS2339: Property 'foo' does not exist on type 'User'.\n

Use when: - Before committing code - In CI/CD pipeline - Debugging type errors

"},{"location":"v2/development/npm-commands/#npm-run-lint_1","title":"npm run lint","text":"

Runs ESLint to check code style.

cd admin\nnpm run lint\n

What it does: - Runs eslint src/ --ext .ts,.tsx - Reports style violations - Checks React best practices

Output:

# Success\n\u2714 85 files linted, 0 errors, 0 warnings\n\n# Errors\nsrc/pages/UsersPage.tsx\n  123:45  error  'foo' is assigned a value but never used  @typescript-eslint/no-unused-vars\n  200:10  warning  Missing dependency in useEffect  react-hooks/exhaustive-deps\n

Use when: - Before committing code - Enforcing code style - Finding potential bugs

"},{"location":"v2/development/npm-commands/#npm-run-lintfix_1","title":"npm run lint:fix","text":"

Automatically fixes ESLint errors where possible.

cd admin\nnpm run lint:fix\n

What it does: - Runs eslint src/ --ext .ts,.tsx --fix - Auto-fixes style issues - Reports unfixable errors

Use when: - After writing new code - Cleaning up formatting - Before commit

"},{"location":"v2/development/npm-commands/#npm-run-format_1","title":"npm run format","text":"

Formats code with Prettier.

cd admin\nnpm run format\n

What it does: - Runs prettier --write \"src/**/*.{ts,tsx,css,json}\" - Formats all source files - Overwrites files in place

Use when: - Standardizing code format - After merge conflicts - Team-wide formatting

"},{"location":"v2/development/npm-commands/#npm-run-formatcheck_1","title":"npm run format:check","text":"

Checks if code is formatted correctly (CI).

cd admin\nnpm run format:check\n

What it does: - Runs prettier --check \"src/**/*.{ts,tsx,css,json}\" - Reports unformatted files - Does NOT modify files

Use when: - In CI/CD pipeline - Verifying format before commit

"},{"location":"v2/development/npm-commands/#testing-scripts_1","title":"Testing Scripts","text":""},{"location":"v2/development/npm-commands/#npm-test_1","title":"npm test","text":"

Runs all tests (when configured).

cd admin\nnpm test\n

What it does: - Runs Vitest test suite - Executes *.test.tsx and *.spec.tsx files - Reports pass/fail

Note: Tests are part of Phase 15 (in progress).

"},{"location":"v2/development/npm-commands/#npm-run-testwatch_1","title":"npm run test:watch","text":"

Runs tests in watch mode.

cd admin\nnpm run test:watch\n

What it does: - Runs vitest - Re-runs tests on file changes

"},{"location":"v2/development/npm-commands/#npm-run-testui","title":"npm run test:ui","text":"

Runs tests with UI (Vitest UI).

cd admin\nnpm run test:ui\n

What it does: - Runs vitest --ui - Opens browser with test UI - Shows test results visually

"},{"location":"v2/development/npm-commands/#npm-run-testcoverage_1","title":"npm run test:coverage","text":"

Runs tests with coverage report.

cd admin\nnpm run test:coverage\n

What it does: - Runs vitest --coverage - Generates coverage report in coverage/

"},{"location":"v2/development/npm-commands/#utility-scripts_1","title":"Utility Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-clean_1","title":"npm run clean","text":"

Removes build artifacts and cache.

cd admin\nnpm run clean\n

What it does: - Deletes dist/ directory - Removes node_modules/.vite/ cache - Removes tsconfig.tsbuildinfo

Use when: - Starting fresh build - Fixing build cache issues - Cleaning up after development

"},{"location":"v2/development/npm-commands/#docker-commands","title":"Docker Commands","text":"

When running services in Docker, use docker compose exec to run npm scripts:

"},{"location":"v2/development/npm-commands/#api-in-docker","title":"API in Docker","text":"
# Development server (already running via docker compose up)\ndocker compose logs -f api\n\n# Type-check\ndocker compose exec api npm run type-check\n\n# Prisma migrate\ndocker compose exec api npx prisma migrate dev --name add_field\n\n# Prisma Studio\ndocker compose exec api npx prisma studio\n\n# Prisma seed\ndocker compose exec api npx prisma db seed\n\n# Drizzle push (Media API)\ndocker compose exec api npx drizzle-kit push\n\n# Lint\ndocker compose exec api npm run lint\n\n# Format\ndocker compose exec api npm run format\n
"},{"location":"v2/development/npm-commands/#admin-in-docker","title":"Admin in Docker","text":"
# Development server (already running via docker compose up)\ndocker compose logs -f admin\n\n# Type-check\ndocker compose exec admin npm run type-check\n\n# Lint\ndocker compose exec admin npm run lint\n\n# Build\ndocker compose exec admin npm run build\n
"},{"location":"v2/development/npm-commands/#rebuild-containers","title":"Rebuild Containers","text":"
# Rebuild after package.json changes\ndocker compose build --no-cache api admin\n\n# Restart services\ndocker compose restart api admin\n
"},{"location":"v2/development/npm-commands/#script-chaining","title":"Script Chaining","text":""},{"location":"v2/development/npm-commands/#sequential-execution","title":"Sequential Execution (&&)","text":"

Run scripts in sequence, stop on first failure:

# Type-check, then build\ncd api\nnpm run type-check && npm run build\n\n# Lint, format, type-check\ncd admin\nnpm run lint && npm run format && npm run type-check\n\n# Full quality check before commit\ncd api\nnpm run lint:fix && npm run format && npm run type-check && npm test\n
"},{"location":"v2/development/npm-commands/#parallel-execution-npm-run-all","title":"Parallel Execution (npm-run-all)","text":"

Install npm-run-all for parallel script execution:

# Install (project root)\nnpm install --save-dev npm-run-all\n\n# Add to package.json\n{\n  \"scripts\": {\n    \"check\": \"npm-run-all --parallel type-check lint test\"\n  }\n}\n\n# Run all checks in parallel\nnpm run check\n
"},{"location":"v2/development/npm-commands/#prepost-hooks","title":"Pre/Post Hooks","text":"

npm automatically runs pre* and post* scripts:

# package.json\n{\n  \"scripts\": {\n    \"prebuild\": \"npm run clean\",\n    \"build\": \"tsc --build\",\n    \"postbuild\": \"npm run copy-assets\"\n  }\n}\n\n# Running npm run build executes:\n# 1. npm run prebuild (clean)\n# 2. npm run build (tsc)\n# 3. npm run postbuild (copy-assets)\n
"},{"location":"v2/development/npm-commands/#common-script-combinations","title":"Common Script Combinations","text":""},{"location":"v2/development/npm-commands/#full-development-setup","title":"Full Development Setup","text":"
# 1. Install dependencies\ncd api && npm install && cd ..\ncd admin && npm install && cd ..\n\n# 2. Setup database\ncd api\nnpx prisma migrate deploy\nnpx prisma db seed\ncd ..\n\n# 3. Start development servers\n# Option A: Docker\ndocker compose up -d api admin\n\n# Option B: Local\ncd api && npm run dev  # Terminal 1\ncd admin && npm run dev  # Terminal 2\n
"},{"location":"v2/development/npm-commands/#pre-commit-quality-check","title":"Pre-Commit Quality Check","text":"
# API quality check\ncd api\nnpm run lint:fix\nnpm run format\nnpm run type-check\n# npm test  # When tests available\ncd ..\n\n# Admin quality check\ncd admin\nnpm run lint:fix\nnpm run format\nnpm run type-check\n# npm test  # When tests available\ncd ..\n\n# Commit if all pass\ngit add .\ngit commit -m \"feat: add new feature\"\n
"},{"location":"v2/development/npm-commands/#production-build","title":"Production Build","text":"
# Build API\ncd api\nnpm run clean\nnpm run build\ncd ..\n\n# Build Admin\ncd admin\nnpm run clean\nnpm run build\ncd ..\n\n# Build Docker images\ndocker compose build api admin\n\n# Start production services\ndocker compose -f docker-compose.yml up -d api admin\n
"},{"location":"v2/development/npm-commands/#database-migration-workflow","title":"Database Migration Workflow","text":"
# 1. Edit schema\ncd api\nvi prisma/schema.prisma\n\n# 2. Validate schema\nnpx prisma validate\n\n# 3. Create migration\nnpx prisma migrate dev --name add_user_field\n\n# 4. Verify migration SQL\ncat prisma/migrations/20260213000000_add_user_field/migration.sql\n\n# 5. Test on clean database\nnpx prisma migrate reset  # WARNING: Deletes data\nnpx prisma migrate deploy\nnpx prisma db seed\n\n# 6. Commit migration\ngit add prisma/migrations/ prisma/schema.prisma\ngit commit -m \"feat(db): add field to User model\"\n
"},{"location":"v2/development/npm-commands/#database-inspection","title":"Database Inspection","text":"
# Prisma Studio (main API)\ncd api\nnpx prisma studio\n# Open http://localhost:5555\n\n# Drizzle Studio (Media API)\ncd api\nnpx drizzle-kit studio\n# Open http://localhost:4983\n\n# Direct PostgreSQL query\ndocker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db\n# Run SQL queries\n
"},{"location":"v2/development/npm-commands/#full-type-check","title":"Full Type Check","text":"
# Type-check both projects\ncd api && npx tsc --noEmit && cd ..\ncd admin && npx tsc --noEmit && cd ..\n\n# Or create root script (package.json in project root)\n{\n  \"scripts\": {\n    \"type-check\": \"cd api && npm run type-check && cd ../admin && npm run type-check\"\n  }\n}\n\n# Run from root\nnpm run type-check\n
"},{"location":"v2/development/npm-commands/#cicd-integration","title":"CI/CD Integration","text":""},{"location":"v2/development/npm-commands/#github-actions-example","title":"GitHub Actions Example","text":"
name: CI\n\non: [push, pull_request]\n\njobs:\n  api:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-node@v3\n        with:\n          node-version: '20'\n\n      - name: Install dependencies\n        working-directory: ./api\n        run: npm ci\n\n      - name: Type check\n        working-directory: ./api\n        run: npm run type-check\n\n      - name: Lint\n        working-directory: ./api\n        run: npm run lint\n\n      - name: Format check\n        working-directory: ./api\n        run: npm run format:check\n\n      - name: Test\n        working-directory: ./api\n        run: npm test\n\n      - name: Build\n        working-directory: ./api\n        run: npm run build\n\n  admin:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-node@v3\n        with:\n          node-version: '20'\n\n      - name: Install dependencies\n        working-directory: ./admin\n        run: npm ci\n\n      - name: Type check\n        working-directory: ./admin\n        run: npm run type-check\n\n      - name: Lint\n        working-directory: ./admin\n        run: npm run lint\n\n      - name: Format check\n        working-directory: ./admin\n        run: npm run format:check\n\n      - name: Test\n        working-directory: ./admin\n        run: npm test\n\n      - name: Build\n        working-directory: ./admin\n        run: npm run build\n
"},{"location":"v2/development/npm-commands/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/development/npm-commands/#script-not-found","title":"Script Not Found","text":"

Problem:

npm ERR! missing script: dev\n

Solution: - Check package.json has the script defined - Verify you're in correct directory (api/ or admin/) - Run npm install to ensure dependencies installed

"},{"location":"v2/development/npm-commands/#permission-errors","title":"Permission Errors","text":"

Problem:

Error: EACCES: permission denied\n

Solution: - Don't use sudo npm (creates permission issues) - Fix npm permissions: sudo chown -R $(whoami) ~/.npm - Or use nvm for user-level Node.js installation

"},{"location":"v2/development/npm-commands/#port-already-in-use","title":"Port Already in Use","text":"

Problem:

Error: listen EADDRINUSE: address already in use :::4000\n

Solution: - Find and kill process using port: lsof -ti:4000 | xargs kill -9 - Or change port in .env: API_PORT=4002 - Or use Docker (isolated ports)

"},{"location":"v2/development/npm-commands/#typescript-errors-on-build","title":"TypeScript Errors on Build","text":"

Problem:

src/modules/auth/auth.service.ts:45:12 - error TS2339\n

Solution: - Fix type errors in code - Or check tsconfig.json is correct - Or update type definitions: npm install --save-dev @types/node@latest

"},{"location":"v2/development/npm-commands/#prisma-migration-conflicts","title":"Prisma Migration Conflicts","text":"

Problem:

Error: P3005 The database schema is not in sync with the migration history\n

Solution: - Development: npx prisma migrate reset (DELETES DATA) - Production: npx prisma migrate resolve --applied <migration_name> - Or create new migration to fix state

"},{"location":"v2/development/npm-commands/#npm-install-failures","title":"npm install Failures","text":"

Problem:

npm ERR! code ERESOLVE\nnpm ERR! ERESOLVE unable to resolve dependency tree\n

Solution: - Clear cache: npm cache clean --force - Delete and reinstall: rm -rf node_modules package-lock.json && npm install - Use --legacy-peer-deps flag: npm install --legacy-peer-deps

"},{"location":"v2/development/npm-commands/#vite-build-errors","title":"Vite Build Errors","text":"

Problem:

Error: Could not resolve entry module (index.html)\n

Solution: - Ensure index.html exists in admin/ - Check vite.config.ts has correct root - Clear cache: rm -rf node_modules/.vite && npm run dev

"},{"location":"v2/development/npm-commands/#best-practices","title":"Best Practices","text":""},{"location":"v2/development/npm-commands/#script-naming-conventions","title":"Script Naming Conventions","text":"
  • dev - Development mode with hot reload
  • build - Production build
  • start - Run production build
  • test - Run tests
  • lint - Check code style
  • lint:fix - Auto-fix code style
  • format - Format code
  • type-check - TypeScript validation
  • clean - Remove build artifacts
"},{"location":"v2/development/npm-commands/#script-organization","title":"Script Organization","text":"

Group related scripts:

{\n  \"scripts\": {\n    // Development\n    \"dev\": \"tsx watch src/server.ts\",\n    \"dev:media\": \"tsx watch src/media-server.ts\",\n\n    // Build\n    \"build\": \"tsc --build\",\n    \"clean\": \"rm -rf dist\",\n\n    // Quality\n    \"type-check\": \"tsc --noEmit\",\n    \"lint\": \"eslint src/ --ext .ts\",\n    \"lint:fix\": \"eslint src/ --ext .ts --fix\",\n    \"format\": \"prettier --write \\\"src/**/*.ts\\\"\",\n\n    // Database\n    \"prisma:migrate\": \"prisma migrate dev\",\n    \"prisma:deploy\": \"prisma migrate deploy\",\n    \"prisma:seed\": \"tsx prisma/seed.ts\"\n  }\n}\n
"},{"location":"v2/development/npm-commands/#environment-specific-scripts","title":"Environment-Specific Scripts","text":"

Use cross-env for environment variables:

npm install --save-dev cross-env\n
{\n  \"scripts\": {\n    \"dev\": \"cross-env NODE_ENV=development tsx watch src/server.ts\",\n    \"build\": \"cross-env NODE_ENV=production tsc --build\",\n    \"test\": \"cross-env NODE_ENV=test jest\"\n  }\n}\n
"},{"location":"v2/development/npm-commands/#script-documentation","title":"Script Documentation","text":"

Add comments in package.json:

{\n  \"scripts\": {\n    \"// Development\": \"\",\n    \"dev\": \"tsx watch src/server.ts\",\n\n    \"// Build\": \"\",\n    \"build\": \"tsc --build\",\n\n    \"// Quality\": \"\",\n    \"type-check\": \"tsc --noEmit\"\n  }\n}\n
"},{"location":"v2/development/npm-commands/#quick-reference","title":"Quick Reference","text":""},{"location":"v2/development/npm-commands/#api-scripts_1","title":"API Scripts","text":"
npm run dev              # Dev server (port 4000)\nnpm run dev:media        # Media API dev (port 4100)\nnpm run build            # Build for production\nnpm start                # Run production server\nnpm run type-check       # TypeScript validation\nnpm run lint             # ESLint check\nnpm run lint:fix         # ESLint auto-fix\nnpm run format           # Prettier format\nnpx prisma migrate dev   # Create migration\nnpx prisma migrate deploy # Apply migrations\nnpx prisma db seed       # Seed database\nnpx prisma studio        # Database GUI\nnpx drizzle-kit push     # Push Media schema\n
"},{"location":"v2/development/npm-commands/#admin-scripts_1","title":"Admin Scripts","text":"
npm run dev              # Dev server (port 3000)\nnpm run build            # Build for production\nnpm run preview          # Preview production build\nnpm run type-check       # TypeScript validation\nnpm run lint             # ESLint check\nnpm run lint:fix         # ESLint auto-fix\nnpm run format           # Prettier format\nnpm test                 # Run tests\nnpm run test:ui          # Test UI\n
"},{"location":"v2/development/npm-commands/#docker-scripts","title":"Docker Scripts","text":"
docker compose exec api npm run type-check\ndocker compose exec api npx prisma migrate dev\ndocker compose exec admin npm run lint\ndocker compose build --no-cache api admin\n
"},{"location":"v2/development/npm-commands/#related-documentation","title":"Related Documentation","text":"
  • Setup: Local Development Setup
  • Workflow: Docker Workflow
  • Database: Migrations Guide
  • Testing: Testing Guide
  • Code Style: Code Style Guide
  • Debugging: Debugging Guide
"},{"location":"v2/development/npm-commands/#summary","title":"Summary","text":"

You now know: - \u2705 All available npm scripts in API and Admin - \u2705 What each script does and when to use it - \u2705 How to run scripts in Docker containers - \u2705 How to chain scripts together - \u2705 Common script combinations for workflows - \u2705 How to troubleshoot script errors - \u2705 Best practices for script organization

Quick Start:

# Development\ncd api && npm run dev\ncd admin && npm run dev\n\n# Pre-commit\ncd api && npm run lint:fix && npm run type-check\ncd admin && npm run lint:fix && npm run type-check\n\n# Production build\ncd api && npm run build\ncd admin && npm run build\n

"},{"location":"v2/development/testing/","title":"Testing Strategy and Guide","text":"

Comprehensive guide to testing Changemaker Lite V2, covering unit tests, integration tests, and end-to-end testing strategies.

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

Current Status: Phase 15 (Testing + Polish) in progress. Test infrastructure is being implemented.

This guide covers: - Testing philosophy and strategy - Test frameworks (Jest, Vitest, React Testing Library) - Writing tests for API and Frontend - Running tests and generating coverage - Testing best practices

"},{"location":"v2/development/testing/#testing-philosophy","title":"Testing Philosophy","text":""},{"location":"v2/development/testing/#test-pyramid","title":"Test Pyramid","text":"
       /\\\n      /E2E\\         \u2190 Few, high-value end-to-end tests\n     /------\\\n    /Integration\\   \u2190 Moderate integration tests\n   /------------\\\n  /   Unit Tests  \\ \u2190 Many, fast unit tests\n /----------------\\\n

Unit Tests (70%): - Test individual functions/components - Fast execution (milliseconds) - No external dependencies - Easy to write and maintain

Integration Tests (20%): - Test multiple units working together - Test API routes with database - Test user flows in frontend - Moderate execution time

End-to-End Tests (10%): - Test complete user journeys - Test across API and frontend - Slow execution (seconds) - Complex setup

"},{"location":"v2/development/testing/#testing-principles","title":"Testing Principles","text":"
  1. Test Behavior, Not Implementation
  2. Test what the code does, not how it does it
  3. Allows refactoring without breaking tests

  4. Arrange-Act-Assert (AAA) Pattern

  5. Arrange: Set up test data and mocks
  6. Act: Execute the code under test
  7. Assert: Verify expected behavior

  8. Independent Tests

  9. Each test runs in isolation
  10. No shared state between tests
  11. Tests can run in any order

  12. Fast Feedback

  13. Tests run quickly (< 1 second each)
  14. Run tests in watch mode during development
  15. Run full suite in CI/CD

  16. Readable Tests

  17. Clear test names describing what is tested
  18. Simple setup and assertions
  19. Good error messages when tests fail
"},{"location":"v2/development/testing/#test-frameworks","title":"Test Frameworks","text":""},{"location":"v2/development/testing/#api-testing-jest","title":"API Testing (Jest)","text":"

Framework: Jest Location: api/src/**/*.test.ts Config: api/jest.config.js

Installation:

cd api\nnpm install --save-dev jest @types/jest ts-jest\nnpm install --save-dev @types/supertest supertest\n

Configuration (jest.config.js):

module.exports = {\n  preset: 'ts-jest',\n  testEnvironment: 'node',\n  roots: ['<rootDir>/src'],\n  testMatch: ['**/*.test.ts'],\n  collectCoverageFrom: [\n    'src/**/*.{ts,tsx}',\n    '!src/**/*.d.ts',\n    '!src/**/*.test.ts'\n  ],\n  coverageThreshold: {\n    global: {\n      branches: 80,\n      functions: 80,\n      lines: 80,\n      statements: 80\n    }\n  }\n};\n

"},{"location":"v2/development/testing/#frontend-testing-vitest-react-testing-library","title":"Frontend Testing (Vitest + React Testing Library)","text":"

Framework: Vitest (Vite-native test runner) Component Testing: React Testing Library Location: admin/src/**/*.test.tsx, admin/src/**/*.spec.tsx Config: admin/vitest.config.ts

Installation:

cd admin\nnpm install --save-dev vitest @vitest/ui\nnpm install --save-dev @testing-library/react @testing-library/jest-dom\nnpm install --save-dev @testing-library/user-event\n

Configuration (vitest.config.ts):

import { defineConfig } from 'vitest/config';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n  plugins: [react()],\n  test: {\n    environment: 'jsdom',\n    globals: true,\n    setupFiles: './src/test/setup.ts',\n    coverage: {\n      provider: 'v8',\n      reporter: ['text', 'json', 'html'],\n      exclude: [\n        'node_modules/',\n        'src/test/',\n        '**/*.d.ts',\n        '**/*.config.*',\n        '**/mockData'\n      ]\n    }\n  }\n});\n

Setup File (admin/src/test/setup.ts):

import '@testing-library/jest-dom';\nimport { expect, afterEach } from 'vitest';\nimport { cleanup } from '@testing-library/react';\n\n// Cleanup after each test\nafterEach(() => {\n  cleanup();\n});\n

"},{"location":"v2/development/testing/#api-testing","title":"API Testing","text":""},{"location":"v2/development/testing/#unit-tests-service-layer","title":"Unit Tests (Service Layer)","text":"

Test business logic in service files:

Example: api/src/modules/auth/auth.service.test.ts

import { describe, it, expect, beforeEach, vi } from 'vitest';\nimport { AuthService } from './auth.service';\nimport { PrismaClient } from '@prisma/client';\nimport bcrypt from 'bcryptjs';\n\n// Mock Prisma\nvi.mock('@prisma/client');\n\ndescribe('AuthService', () => {\n  let authService: AuthService;\n  let mockPrisma: any;\n\n  beforeEach(() => {\n    mockPrisma = {\n      user: {\n        findUnique: vi.fn(),\n        create: vi.fn()\n      }\n    };\n    authService = new AuthService(mockPrisma);\n  });\n\n  describe('login', () => {\n    it('should return tokens for valid credentials', async () => {\n      // Arrange\n      const email = 'test@example.com';\n      const password = 'Password123!';\n      const hashedPassword = await bcrypt.hash(password, 10);\n\n      mockPrisma.user.findUnique.mockResolvedValue({\n        id: 1,\n        email,\n        password: hashedPassword,\n        role: 'USER'\n      });\n\n      // Act\n      const result = await authService.login(email, password);\n\n      // Assert\n      expect(result).toHaveProperty('accessToken');\n      expect(result).toHaveProperty('refreshToken');\n      expect(result.user.email).toBe(email);\n    });\n\n    it('should throw error for invalid password', async () => {\n      // Arrange\n      const email = 'test@example.com';\n      const hashedPassword = await bcrypt.hash('correctpass', 10);\n\n      mockPrisma.user.findUnique.mockResolvedValue({\n        id: 1,\n        email,\n        password: hashedPassword,\n        role: 'USER'\n      });\n\n      // Act & Assert\n      await expect(\n        authService.login(email, 'wrongpass')\n      ).rejects.toThrow('Invalid credentials');\n    });\n\n    it('should throw error for non-existent user', async () => {\n      // Arrange\n      mockPrisma.user.findUnique.mockResolvedValue(null);\n\n      // Act & Assert\n      await expect(\n        authService.login('nonexistent@example.com', 'password')\n      ).rejects.toThrow('Invalid credentials');\n    });\n  });\n\n  describe('register', () => {\n    it('should create new user with hashed password', async () => {\n      // Arrange\n      const email = 'new@example.com';\n      const password = 'Password123!';\n\n      mockPrisma.user.findUnique.mockResolvedValue(null);\n      mockPrisma.user.create.mockResolvedValue({\n        id: 1,\n        email,\n        role: 'USER'\n      });\n\n      // Act\n      const result = await authService.register(email, password);\n\n      // Assert\n      expect(mockPrisma.user.create).toHaveBeenCalledWith({\n        data: expect.objectContaining({\n          email,\n          password: expect.any(String),\n          role: 'USER'\n        })\n      });\n      expect(result.user.email).toBe(email);\n    });\n\n    it('should throw error if user already exists', async () => {\n      // Arrange\n      mockPrisma.user.findUnique.mockResolvedValue({\n        id: 1,\n        email: 'existing@example.com'\n      });\n\n      // Act & Assert\n      await expect(\n        authService.register('existing@example.com', 'Password123!')\n      ).rejects.toThrow('User already exists');\n    });\n  });\n});\n
"},{"location":"v2/development/testing/#integration-tests-routes","title":"Integration Tests (Routes)","text":"

Test API endpoints with database:

Example: api/src/modules/auth/auth.routes.test.ts

import { describe, it, expect, beforeAll, afterAll } from 'vitest';\nimport request from 'supertest';\nimport { app } from '../../server';\nimport { PrismaClient } from '@prisma/client';\n\nconst prisma = new PrismaClient();\n\ndescribe('Auth Routes', () => {\n  beforeAll(async () => {\n    // Setup test database\n    await prisma.$connect();\n  });\n\n  afterAll(async () => {\n    // Cleanup\n    await prisma.user.deleteMany();\n    await prisma.$disconnect();\n  });\n\n  describe('POST /api/auth/register', () => {\n    it('should register new user', async () => {\n      const response = await request(app)\n        .post('/api/auth/register')\n        .send({\n          email: 'test@example.com',\n          password: 'Password123!'\n        })\n        .expect(201);\n\n      expect(response.body).toHaveProperty('accessToken');\n      expect(response.body).toHaveProperty('refreshToken');\n      expect(response.body.user.email).toBe('test@example.com');\n    });\n\n    it('should return 400 for invalid email', async () => {\n      const response = await request(app)\n        .post('/api/auth/register')\n        .send({\n          email: 'invalid-email',\n          password: 'Password123!'\n        })\n        .expect(400);\n\n      expect(response.body).toHaveProperty('error');\n    });\n\n    it('should return 409 for existing user', async () => {\n      // Create user first\n      await request(app)\n        .post('/api/auth/register')\n        .send({\n          email: 'existing@example.com',\n          password: 'Password123!'\n        });\n\n      // Try to create again\n      const response = await request(app)\n        .post('/api/auth/register')\n        .send({\n          email: 'existing@example.com',\n          password: 'Password123!'\n        })\n        .expect(409);\n\n      expect(response.body.error).toContain('already exists');\n    });\n  });\n\n  describe('POST /api/auth/login', () => {\n    it('should login with valid credentials', async () => {\n      // Register user first\n      await request(app)\n        .post('/api/auth/register')\n        .send({\n          email: 'login@example.com',\n          password: 'Password123!'\n        });\n\n      // Login\n      const response = await request(app)\n        .post('/api/auth/login')\n        .send({\n          email: 'login@example.com',\n          password: 'Password123!'\n        })\n        .expect(200);\n\n      expect(response.body).toHaveProperty('accessToken');\n      expect(response.body).toHaveProperty('refreshToken');\n    });\n\n    it('should return 401 for invalid password', async () => {\n      const response = await request(app)\n        .post('/api/auth/login')\n        .send({\n          email: 'login@example.com',\n          password: 'WrongPassword!'\n        })\n        .expect(401);\n\n      expect(response.body.error).toContain('Invalid credentials');\n    });\n  });\n});\n
"},{"location":"v2/development/testing/#database-testing","title":"Database Testing","text":"

Use separate test database:

Environment Variable (.env.test):

DATABASE_URL=postgresql://changemaker_v2:password@localhost:5433/changemaker_v2_test_db\n

Setup Script (api/src/test/setup.ts):

import { PrismaClient } from '@prisma/client';\nimport { execSync } from 'child_process';\n\nconst prisma = new PrismaClient();\n\nexport async function setupTestDatabase() {\n  // Apply migrations\n  execSync('npx prisma migrate deploy', {\n    env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL }\n  });\n\n  // Clean data\n  await prisma.user.deleteMany();\n  await prisma.campaign.deleteMany();\n  // ... delete all tables\n}\n\nexport async function teardownTestDatabase() {\n  await prisma.$disconnect();\n}\n

"},{"location":"v2/development/testing/#frontend-testing","title":"Frontend Testing","text":""},{"location":"v2/development/testing/#component-unit-tests","title":"Component Unit Tests","text":"

Test individual React components:

Example: admin/src/components/UserCard.test.tsx

import { describe, it, expect } from 'vitest';\nimport { render, screen } from '@testing-library/react';\nimport { UserCard } from './UserCard';\n\ndescribe('UserCard', () => {\n  it('renders user information', () => {\n    const user = {\n      id: 1,\n      email: 'test@example.com',\n      role: 'USER',\n      name: 'Test User'\n    };\n\n    render(<UserCard user={user} />);\n\n    expect(screen.getByText('Test User')).toBeInTheDocument();\n    expect(screen.getByText('test@example.com')).toBeInTheDocument();\n    expect(screen.getByText('USER')).toBeInTheDocument();\n  });\n\n  it('renders \"No name\" when name is null', () => {\n    const user = {\n      id: 1,\n      email: 'test@example.com',\n      role: 'USER',\n      name: null\n    };\n\n    render(<UserCard user={user} />);\n\n    expect(screen.getByText('No name')).toBeInTheDocument();\n  });\n});\n
"},{"location":"v2/development/testing/#component-integration-tests","title":"Component Integration Tests","text":"

Test user interactions:

Example: admin/src/pages/LoginPage.test.tsx

import { describe, it, expect, vi } from 'vitest';\nimport { render, screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { LoginPage } from './LoginPage';\nimport { BrowserRouter } from 'react-router-dom';\nimport * as api from '../lib/api';\n\n// Mock API\nvi.mock('../lib/api');\n\ndescribe('LoginPage', () => {\n  it('submits login form with valid credentials', async () => {\n    const user = userEvent.setup();\n    const mockLogin = vi.spyOn(api, 'login').mockResolvedValue({\n      accessToken: 'token',\n      refreshToken: 'refresh',\n      user: { id: 1, email: 'test@example.com', role: 'USER' }\n    });\n\n    render(\n      <BrowserRouter>\n        <LoginPage />\n      </BrowserRouter>\n    );\n\n    // Fill form\n    await user.type(screen.getByLabelText(/email/i), 'test@example.com');\n    await user.type(screen.getByLabelText(/password/i), 'Password123!');\n\n    // Submit\n    await user.click(screen.getByRole('button', { name: /login/i }));\n\n    // Verify API called\n    await waitFor(() => {\n      expect(mockLogin).toHaveBeenCalledWith({\n        email: 'test@example.com',\n        password: 'Password123!'\n      });\n    });\n  });\n\n  it('shows error for invalid credentials', async () => {\n    const user = userEvent.setup();\n    vi.spyOn(api, 'login').mockRejectedValue(\n      new Error('Invalid credentials')\n    );\n\n    render(\n      <BrowserRouter>\n        <LoginPage />\n      </BrowserRouter>\n    );\n\n    await user.type(screen.getByLabelText(/email/i), 'test@example.com');\n    await user.type(screen.getByLabelText(/password/i), 'wrong');\n    await user.click(screen.getByRole('button', { name: /login/i }));\n\n    await waitFor(() => {\n      expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();\n    });\n  });\n\n  it('disables submit button while loading', async () => {\n    const user = userEvent.setup();\n    vi.spyOn(api, 'login').mockImplementation(\n      () => new Promise(resolve => setTimeout(resolve, 1000))\n    );\n\n    render(\n      <BrowserRouter>\n        <LoginPage />\n      </BrowserRouter>\n    );\n\n    const submitButton = screen.getByRole('button', { name: /login/i });\n\n    await user.type(screen.getByLabelText(/email/i), 'test@example.com');\n    await user.type(screen.getByLabelText(/password/i), 'Password123!');\n    await user.click(submitButton);\n\n    expect(submitButton).toBeDisabled();\n  });\n});\n
"},{"location":"v2/development/testing/#testing-hooks","title":"Testing Hooks","text":"

Test custom React hooks:

Example: admin/src/hooks/useDebounce.test.ts

import { describe, it, expect, vi } from 'vitest';\nimport { renderHook, waitFor } from '@testing-library/react';\nimport { useDebounce } from './useDebounce';\n\ndescribe('useDebounce', () => {\n  it('debounces value changes', async () => {\n    const { result, rerender } = renderHook(\n      ({ value, delay }) => useDebounce(value, delay),\n      { initialProps: { value: 'initial', delay: 500 } }\n    );\n\n    expect(result.current).toBe('initial');\n\n    // Change value\n    rerender({ value: 'updated', delay: 500 });\n\n    // Value should not change immediately\n    expect(result.current).toBe('initial');\n\n    // Wait for debounce\n    await waitFor(() => {\n      expect(result.current).toBe('updated');\n    }, { timeout: 600 });\n  });\n});\n
"},{"location":"v2/development/testing/#testing-zustand-stores","title":"Testing Zustand Stores","text":"

Test state management:

Example: admin/src/stores/auth.store.test.ts

import { describe, it, expect, beforeEach } from 'vitest';\nimport { renderHook, act } from '@testing-library/react';\nimport { useAuthStore } from './auth.store';\n\ndescribe('Auth Store', () => {\n  beforeEach(() => {\n    // Reset store before each test\n    const { result } = renderHook(() => useAuthStore());\n    act(() => {\n      result.current.logout();\n    });\n  });\n\n  it('sets user on login', () => {\n    const { result } = renderHook(() => useAuthStore());\n\n    act(() => {\n      result.current.setUser({\n        id: 1,\n        email: 'test@example.com',\n        role: 'USER'\n      });\n    });\n\n    expect(result.current.user).toEqual({\n      id: 1,\n      email: 'test@example.com',\n      role: 'USER'\n    });\n    expect(result.current.isAuthenticated).toBe(true);\n  });\n\n  it('clears user on logout', () => {\n    const { result } = renderHook(() => useAuthStore());\n\n    act(() => {\n      result.current.setUser({\n        id: 1,\n        email: 'test@example.com',\n        role: 'USER'\n      });\n    });\n\n    expect(result.current.isAuthenticated).toBe(true);\n\n    act(() => {\n      result.current.logout();\n    });\n\n    expect(result.current.user).toBeNull();\n    expect(result.current.isAuthenticated).toBe(false);\n  });\n});\n
"},{"location":"v2/development/testing/#running-tests","title":"Running Tests","text":""},{"location":"v2/development/testing/#run-all-tests","title":"Run All Tests","text":"
# API tests\ncd api\nnpm test\n\n# Frontend tests\ncd admin\nnpm test\n
"},{"location":"v2/development/testing/#watch-mode","title":"Watch Mode","text":"

Run tests automatically on file changes:

# API tests (Jest watch)\ncd api\nnpm run test:watch\n\n# Frontend tests (Vitest watch)\ncd admin\nnpm run test:watch\n
"},{"location":"v2/development/testing/#run-specific-tests","title":"Run Specific Tests","text":"
# Run specific test file\nnpm test -- auth.service.test.ts\n\n# Run tests matching pattern\nnpm test -- --testNamePattern=\"login\"\n\n# Run tests in specific directory\nnpm test -- src/modules/auth/\n
"},{"location":"v2/development/testing/#coverage-reports","title":"Coverage Reports","text":"

Generate test coverage:

# API coverage\ncd api\nnpm run test:coverage\n\n# Frontend coverage\ncd admin\nnpm run test:coverage\n

Coverage output:

File                | % Stmts | % Branch | % Funcs | % Lines |\n--------------------|---------|----------|---------|---------|\nAll files           |   82.45 |    75.33 |   80.12 |   83.21 |\n auth/              |   95.23 |    89.47 |   93.75 |   96.15 |\n  auth.service.ts   |   97.14 |    91.67 |   100   |   98.21 |\n  auth.routes.ts    |   93.33 |    87.50 |   87.50 |   94.12 |\n

HTML report: - Located in coverage/ directory - Open coverage/index.html in browser - Shows line-by-line coverage

"},{"location":"v2/development/testing/#cicd-testing","title":"CI/CD Testing","text":"

GitHub Actions Example:

name: Tests\n\non: [push, pull_request]\n\njobs:\n  api-tests:\n    runs-on: ubuntu-latest\n    services:\n      postgres:\n        image: postgres:16\n        env:\n          POSTGRES_PASSWORD: test\n        options: >-\n          --health-cmd pg_isready\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-node@v3\n        with:\n          node-version: '20'\n\n      - name: Install dependencies\n        working-directory: ./api\n        run: npm ci\n\n      - name: Run migrations\n        working-directory: ./api\n        env:\n          DATABASE_URL: postgresql://postgres:test@localhost:5432/test\n        run: npx prisma migrate deploy\n\n      - name: Run tests\n        working-directory: ./api\n        run: npm test -- --coverage\n\n      - name: Upload coverage\n        uses: codecov/codecov-action@v3\n        with:\n          files: ./api/coverage/coverage-final.json\n\n  frontend-tests:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-node@v3\n        with:\n          node-version: '20'\n\n      - name: Install dependencies\n        working-directory: ./admin\n        run: npm ci\n\n      - name: Run tests\n        working-directory: ./admin\n        run: npm test -- --coverage\n\n      - name: Upload coverage\n        uses: codecov/codecov-action@v3\n        with:\n          files: ./admin/coverage/coverage-final.json\n
"},{"location":"v2/development/testing/#mocking","title":"Mocking","text":""},{"location":"v2/development/testing/#mocking-api-calls-frontend","title":"Mocking API Calls (Frontend)","text":"
// Mock axios\nvi.mock('../lib/api', () => ({\n  api: {\n    get: vi.fn(),\n    post: vi.fn(),\n    put: vi.fn(),\n    delete: vi.fn()\n  }\n}));\n\n// Use in test\nimport { api } from '../lib/api';\n\nvi.mocked(api.get).mockResolvedValue({\n  data: { users: [] }\n});\n
"},{"location":"v2/development/testing/#mocking-database-backend","title":"Mocking Database (Backend)","text":"
// Mock Prisma Client\nvi.mock('@prisma/client', () => ({\n  PrismaClient: vi.fn(() => ({\n    user: {\n      findUnique: vi.fn(),\n      findMany: vi.fn(),\n      create: vi.fn(),\n      update: vi.fn(),\n      delete: vi.fn()\n    }\n  }))\n}));\n
"},{"location":"v2/development/testing/#mocking-external-services","title":"Mocking External Services","text":"
// Mock email service\nvi.mock('../../services/email.service', () => ({\n  EmailService: {\n    sendEmail: vi.fn().mockResolvedValue(true)\n  }\n}));\n
"},{"location":"v2/development/testing/#mocking-environment-variables","title":"Mocking Environment Variables","text":"
// Set env var for test\nprocess.env.JWT_ACCESS_SECRET = 'test-secret';\n\n// Or use vi.stubEnv\nvi.stubEnv('API_URL', 'http://localhost:4000');\n
"},{"location":"v2/development/testing/#best-practices","title":"Best Practices","text":""},{"location":"v2/development/testing/#test-naming","title":"Test Naming","text":"

Use descriptive test names:

Good:

it('should return 401 for expired token', async () => {});\nit('should create user with hashed password', async () => {});\nit('should render error message for invalid email', () => {});\n

Bad:

it('works', async () => {});\nit('test login', async () => {});\nit('should work correctly', () => {});\n

"},{"location":"v2/development/testing/#test-organization","title":"Test Organization","text":"

Group related tests:

describe('AuthService', () => {\n  describe('login', () => {\n    it('should return tokens for valid credentials', () => {});\n    it('should throw error for invalid password', () => {});\n    it('should throw error for non-existent user', () => {});\n  });\n\n  describe('register', () => {\n    it('should create new user', () => {});\n    it('should hash password', () => {});\n    it('should throw error if user exists', () => {});\n  });\n});\n
"},{"location":"v2/development/testing/#setup-and-teardown","title":"Setup and Teardown","text":"

Use beforeEach/afterEach for common setup:

describe('UserService', () => {\n  let userService: UserService;\n  let mockPrisma: any;\n\n  beforeEach(() => {\n    mockPrisma = createMockPrisma();\n    userService = new UserService(mockPrisma);\n  });\n\n  afterEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it('...', () => {});\n});\n
"},{"location":"v2/development/testing/#avoid-test-interdependence","title":"Avoid Test Interdependence","text":"

Each test should be independent:

Good:

it('should create user', async () => {\n  const user = await createUser({ email: 'test@example.com' });\n  expect(user.email).toBe('test@example.com');\n});\n\nit('should update user', async () => {\n  const user = await createUser({ email: 'test@example.com' });\n  const updated = await updateUser(user.id, { name: 'New Name' });\n  expect(updated.name).toBe('New Name');\n});\n

Bad:

let userId;\n\nit('should create user', async () => {\n  const user = await createUser({ email: 'test@example.com' });\n  userId = user.id; // \u274c Shared state\n});\n\nit('should update user', async () => {\n  const updated = await updateUser(userId, { name: 'New Name' });\n  // \u274c Depends on previous test\n});\n

"},{"location":"v2/development/testing/#test-edge-cases","title":"Test Edge Cases","text":"

Test boundary conditions:

describe('pagination', () => {\n  it('should handle page 1', () => {});\n  it('should handle last page', () => {});\n  it('should handle empty results', () => {});\n  it('should handle invalid page number', () => {});\n  it('should handle page exceeding total', () => {});\n});\n
"},{"location":"v2/development/testing/#async-testing","title":"Async Testing","text":"

Always use async/await for async tests:

Good:

it('should fetch users', async () => {\n  const users = await userService.getUsers();\n  expect(users).toHaveLength(10);\n});\n

Bad:

it('should fetch users', () => {\n  userService.getUsers().then(users => {\n    expect(users).toHaveLength(10); // \u274c May not run\n  });\n});\n

"},{"location":"v2/development/testing/#coverage-requirements","title":"Coverage Requirements","text":"

Target coverage thresholds:

// jest.config.js / vitest.config.ts\ncoverageThreshold: {\n  global: {\n    branches: 80,\n    functions: 80,\n    lines: 80,\n    statements: 80\n  }\n}\n

What to test: - \u2705 Business logic (services) - \u2705 API routes - \u2705 UI components - \u2705 Custom hooks - \u2705 Utilities - \u274c Type definitions - \u274c Config files - \u274c Test files themselves

"},{"location":"v2/development/testing/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/development/testing/#tests-timing-out","title":"Tests Timing Out","text":"

Problem: Tests exceed timeout.

Solution:

// Increase timeout for specific test\nit('slow operation', async () => {\n  // ...\n}, 10000); // 10 second timeout\n\n// Or globally (vitest.config.ts)\nexport default defineConfig({\n  test: {\n    testTimeout: 10000\n  }\n});\n
"},{"location":"v2/development/testing/#mocks-not-working","title":"Mocks Not Working","text":"

Problem: Mocks not being used.

Solution:

// Mock must be at top of file, before imports\nvi.mock('../lib/api');\n\nimport { api } from '../lib/api';\n\n// Verify mock is being used\nconsole.log(vi.isMockFunction(api.get)); // Should be true\n
"},{"location":"v2/development/testing/#database-connection-errors","title":"Database Connection Errors","text":"

Problem: Tests fail with DB connection errors.

Solution:

// Use separate test database\nprocess.env.DATABASE_URL = 'postgresql://localhost/test_db';\n\n// Or mock database entirely\nvi.mock('@prisma/client');\n
"},{"location":"v2/development/testing/#react-testing-library-queries-failing","title":"React Testing Library Queries Failing","text":"

Problem: screen.getByText() doesn't find element.

Solution:

// Use findBy for async elements\nconst element = await screen.findByText('Loading...');\n\n// Use queryBy to check non-existence\nexpect(screen.queryByText('Error')).not.toBeInTheDocument();\n\n// Debug rendered output\nscreen.debug();\n
"},{"location":"v2/development/testing/#related-documentation","title":"Related Documentation","text":"
  • Setup: Local Development Setup
  • Code Style: Code Style Guide
  • TypeScript: TypeScript Guide
  • Debugging: Debugging Guide
  • CI/CD: Deployment Guide
"},{"location":"v2/development/testing/#summary","title":"Summary","text":"

You now know: - \u2705 Testing philosophy (test pyramid, AAA pattern) - \u2705 Test frameworks (Jest, Vitest, React Testing Library) - \u2705 How to write unit tests (services, components) - \u2705 How to write integration tests (routes, user flows) - \u2705 How to run tests and generate coverage - \u2705 How to mock dependencies - \u2705 Testing best practices - \u2705 How to integrate tests in CI/CD

Quick Start:

# Install dependencies (when Phase 15 complete)\ncd api && npm install --save-dev jest @types/jest ts-jest\ncd admin && npm install --save-dev vitest @vitest/ui\n\n# Run tests\nnpm test\n\n# Watch mode\nnpm run test:watch\n\n# Coverage\nnpm run test:coverage\n

"},{"location":"v2/development/typescript/","title":"TypeScript Best Practices","text":"

Comprehensive TypeScript guide for Changemaker Lite V2, covering type system fundamentals, common patterns, and V2-specific conventions.

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

Changemaker Lite V2 uses TypeScript 5.x with strict mode enabled for maximum type safety.

Benefits: - Catch errors at compile time - Better IDE autocomplete and refactoring - Self-documenting code - Safer refactoring

This guide covers TypeScript best practices specific to V2 development.

"},{"location":"v2/development/typescript/#type-system-fundamentals","title":"Type System Fundamentals","text":""},{"location":"v2/development/typescript/#primitives","title":"Primitives","text":"
// Basic types\nconst name: string = 'John';\nconst age: number = 30;\nconst isActive: boolean = true;\nconst data: null = null;\nconst value: undefined = undefined;\n\n// Arrays\nconst numbers: number[] = [1, 2, 3];\nconst emails: Array<string> = ['a@example.com', 'b@example.com'];\n\n// Tuples\nconst userTuple: [number, string] = [1, 'John'];\nconst coordinate: [number, number] = [51.5074, -0.1278];\n
"},{"location":"v2/development/typescript/#objects","title":"Objects","text":"
// Object literal\nconst user: { id: number; email: string } = {\n  id: 1,\n  email: 'john@example.com'\n};\n\n// Interface (preferred for reusable types)\ninterface User {\n  id: number;\n  email: string;\n  name?: string; // Optional property\n  readonly role: string; // Read-only property\n}\n\n// Type alias (for unions, intersections, utilities)\ntype UserRole = 'USER' | 'ADMIN' | 'SUPER_ADMIN';\n
"},{"location":"v2/development/typescript/#functions","title":"Functions","text":"
// Function declaration\nfunction greet(name: string): string {\n  return `Hello, ${name}`;\n}\n\n// Arrow function\nconst add = (a: number, b: number): number => a + b;\n\n// Optional parameters\nfunction log(message: string, level?: string): void {\n  console.log(`[${level ?? 'INFO'}] ${message}`);\n}\n\n// Default parameters\nfunction paginate(page: number = 1, limit: number = 50) {\n  return { page, limit };\n}\n\n// Rest parameters\nfunction sum(...numbers: number[]): number {\n  return numbers.reduce((total, n) => total + n, 0);\n}\n\n// Async functions\nasync function fetchUser(id: number): Promise<User> {\n  const response = await fetch(`/api/users/${id}`);\n  return response.json();\n}\n
"},{"location":"v2/development/typescript/#unions-and-intersections","title":"Unions and Intersections","text":"
// Union (OR)\ntype Status = 'pending' | 'active' | 'completed';\ntype ID = number | string;\n\nfunction printId(id: ID) {\n  if (typeof id === 'string') {\n    console.log(id.toUpperCase());\n  } else {\n    console.log(id.toFixed(2));\n  }\n}\n\n// Intersection (AND)\ntype Timestamped = {\n  createdAt: Date;\n  updatedAt: Date;\n};\n\ntype User = {\n  id: number;\n  email: string;\n} & Timestamped;\n\n// User has: id, email, createdAt, updatedAt\n
"},{"location":"v2/development/typescript/#generics","title":"Generics","text":"
// Generic function\nfunction identity<T>(value: T): T {\n  return value;\n}\n\nconst num = identity<number>(42);\nconst str = identity<string>('hello');\n\n// Generic interface\ninterface Response<T> {\n  data: T;\n  error?: string;\n}\n\nconst userResponse: Response<User> = {\n  data: { id: 1, email: 'john@example.com' }\n};\n\n// Generic constraints\nfunction getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {\n  return obj[key];\n}\n\nconst user = { id: 1, email: 'john@example.com' };\nconst email = getProperty(user, 'email'); // Type: string\n
"},{"location":"v2/development/typescript/#utility-types","title":"Utility Types","text":"
// Partial - Makes all properties optional\ntype UpdateUserInput = Partial<User>;\n\n// Pick - Select specific properties\ntype UserPreview = Pick<User, 'id' | 'email'>;\n\n// Omit - Exclude specific properties\ntype UserWithoutPassword = Omit<User, 'password'>;\n\n// Required - Makes all properties required\ntype RequiredUser = Required<User>;\n\n// Readonly - Makes all properties read-only\ntype ImmutableUser = Readonly<User>;\n\n// Record - Object with specific key/value types\ntype UserMap = Record<number, User>;\n\n// ReturnType - Extract return type of function\nfunction getUser() {\n  return { id: 1, email: 'john@example.com' };\n}\ntype User = ReturnType<typeof getUser>;\n\n// Parameters - Extract parameter types\nfunction createUser(email: string, password: string) {}\ntype CreateUserParams = Parameters<typeof createUser>;\n// [string, string]\n
"},{"location":"v2/development/typescript/#common-v2-patterns","title":"Common V2 Patterns","text":""},{"location":"v2/development/typescript/#requestresponse-types","title":"Request/Response Types","text":"

API Request:

// Express Request with typed params, query, body\nimport { Request, Response } from 'express';\n\ninterface GetUserParams {\n  id: string; // Params are always strings\n}\n\ninterface GetUsersQuery {\n  page?: string;\n  limit?: string;\n  search?: string;\n}\n\ninterface CreateUserBody {\n  email: string;\n  password: string;\n  name?: string;\n}\n\n// Route handler\napp.get('/users/:id', (req: Request<GetUserParams>, res: Response) => {\n  const id = parseInt(req.params.id as string); // Cast to string\n  // ...\n});\n\napp.get('/users', (req: Request<{}, {}, {}, GetUsersQuery>, res: Response) => {\n  const page = parseInt(req.query.page ?? '1');\n  const limit = parseInt(req.query.limit ?? '50');\n  // ...\n});\n\napp.post('/users', (req: Request<{}, {}, CreateUserBody>, res: Response) => {\n  const { email, password, name } = req.body;\n  // ...\n});\n

Augmented Request (with user from JWT):

// api/src/types/express.d.ts\nimport { User } from '@prisma/client';\n\ndeclare global {\n  namespace Express {\n    interface Request {\n      user?: {\n        id: number;\n        email: string;\n        role: string;\n      };\n    }\n  }\n}\n\n// Usage in route\napp.get('/me', authenticate, (req: Request, res: Response) => {\n  const userId = req.user!.id; // Non-null assertion (safe after authenticate)\n  // ...\n});\n
"},{"location":"v2/development/typescript/#prisma-types","title":"Prisma Types","text":"

Generated Types:

import { User, Campaign, Location, Prisma } from '@prisma/client';\n\n// Model types\nconst user: User = {\n  id: 1,\n  email: 'john@example.com',\n  password: 'hashed...',\n  role: 'USER',\n  createdAt: new Date(),\n  updatedAt: new Date()\n};\n\n// Create input\nconst createData: Prisma.UserCreateInput = {\n  email: 'john@example.com',\n  password: 'hashed...',\n  role: 'USER'\n};\n\n// Unchecked create (with foreign keys)\nconst createCampaign: Prisma.CampaignUncheckedCreateInput = {\n  title: 'New Campaign',\n  createdByUserId: 1 // Can set FK directly\n};\n\n// Update input\nconst updateData: Prisma.UserUpdateInput = {\n  name: 'John Doe',\n  updatedAt: new Date()\n};\n\n// Where clause\nconst whereClause: Prisma.UserWhereInput = {\n  email: { contains: '@example.com' },\n  role: { in: ['USER', 'ADMIN'] },\n  createdAt: { gte: new Date('2024-01-01') }\n};\n\n// Include relations\nconst userWithCampaigns = await prisma.user.findUnique({\n  where: { id: 1 },\n  include: { campaigns: true }\n});\n// Type: User & { campaigns: Campaign[] }\n

JSON Fields:

// Prisma model with JSON field\nmodel Page {\n  id      Int   @id @default(autoincrement())\n  content Json  // JSON field\n}\n\n// Type-safe JSON usage\nimport { Prisma } from '@prisma/client';\n\ninterface BlockContent {\n  type: string;\n  data: Record<string, unknown>;\n}\n\nconst blocks: BlockContent[] = [\n  { type: 'text', data: { content: 'Hello' } }\n];\n\n// Cast to Prisma.InputJsonValue\nawait prisma.page.create({\n  data: {\n    content: blocks as unknown as Prisma.InputJsonValue\n  }\n});\n\n// Use Prisma.JsonNull for null\nawait prisma.page.update({\n  where: { id: 1 },\n  data: {\n    content: Prisma.JsonNull\n  }\n});\n
"},{"location":"v2/development/typescript/#drizzle-types","title":"Drizzle Types","text":"

Schema Types:

// api/src/modules/media/db/schema.ts\nimport { pgTable, serial, text, integer, timestamp } from 'drizzle-orm/pg-core';\n\nexport const videos = pgTable('videos', {\n  id: serial('id').primaryKey(),\n  filename: text('filename').notNull(),\n  title: text('title'),\n  duration: integer('duration'),\n  createdAt: timestamp('created_at').defaultNow().notNull()\n});\n\n// Infer types from schema\nexport type Video = typeof videos.$inferSelect;\nexport type NewVideo = typeof videos.$inferInsert;\n\n// Usage\nconst video: Video = {\n  id: 1,\n  filename: 'video.mp4',\n  title: 'My Video',\n  duration: 120,\n  createdAt: new Date()\n};\n\nconst newVideo: NewVideo = {\n  filename: 'video.mp4',\n  title: 'My Video',\n  duration: 120\n  // id and createdAt auto-generated\n};\n
"},{"location":"v2/development/typescript/#zod-schemas","title":"Zod Schemas","text":"

Validation Schemas:

import { z } from 'zod';\n\n// Login schema\nexport const loginSchema = z.object({\n  email: z.string().email(),\n  password: z.string().min(12)\n});\n\n// Infer TypeScript type from Zod schema\nexport type LoginInput = z.infer<typeof loginSchema>;\n\n// Usage in route\napp.post('/login', validate(loginSchema), (req: Request, res: Response) => {\n  const { email, password } = req.body as LoginInput;\n  // TypeScript knows email is string and password is string\n});\n\n// Complex schema\nexport const createCampaignSchema = z.object({\n  title: z.string().min(1).max(200),\n  description: z.string().optional(),\n  targetEmails: z.array(z.string().email()),\n  active: z.boolean().default(false),\n  settings: z.object({\n    allowResponses: z.boolean(),\n    moderateResponses: z.boolean()\n  }).optional()\n});\n\nexport type CreateCampaignInput = z.infer<typeof createCampaignSchema>;\n
"},{"location":"v2/development/typescript/#react-component-types","title":"React Component Types","text":"

Component Props:

// Prop types\ninterface UserCardProps {\n  user: User;\n  onEdit?: (user: User) => void;\n  className?: string;\n}\n\nexport function UserCard({ user, onEdit, className }: UserCardProps) {\n  return (\n    <div className={className} onClick={() => onEdit?.(user)}>\n      {user.name}\n    </div>\n  );\n}\n\n// Children prop\ninterface LayoutProps {\n  children: React.ReactNode;\n  title: string;\n}\n\nexport function Layout({ children, title }: LayoutProps) {\n  return (\n    <div>\n      <h1>{title}</h1>\n      {children}\n    </div>\n  );\n}\n\n// Generic component\ninterface ListProps<T> {\n  items: T[];\n  renderItem: (item: T) => React.ReactNode;\n}\n\nexport function List<T>({ items, renderItem }: ListProps<T>) {\n  return <div>{items.map(renderItem)}</div>;\n}\n

Event Handlers:

// Form events\nfunction LoginForm() {\n  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n    // ...\n  };\n\n  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    console.log(e.target.value);\n  };\n\n  return (\n    <form onSubmit={handleSubmit}>\n      <input onChange={handleChange} />\n    </form>\n  );\n}\n\n// Button click\nconst handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {\n  console.log(e.currentTarget);\n};\n

Hooks:

import { useState, useEffect, useRef } from 'react';\n\n// useState\nconst [count, setCount] = useState<number>(0);\nconst [user, setUser] = useState<User | null>(null);\n\n// useEffect\nuseEffect(() => {\n  async function fetchUser() {\n    const data = await api.get<User>('/user');\n    setUser(data);\n  }\n  fetchUser();\n}, []);\n\n// useRef\nconst inputRef = useRef<HTMLInputElement>(null);\nconst timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);\n\nuseEffect(() => {\n  if (inputRef.current) {\n    inputRef.current.focus();\n  }\n}, []);\n\n// useReducer\ntype State = { count: number };\ntype Action = { type: 'increment' } | { type: 'decrement' };\n\nconst reducer = (state: State, action: Action): State => {\n  switch (action.type) {\n    case 'increment':\n      return { count: state.count + 1 };\n    case 'decrement':\n      return { count: state.count - 1 };\n  }\n};\n\nconst [state, dispatch] = useReducer(reducer, { count: 0 });\n

Zustand Store:

import { create } from 'zustand';\n\ninterface AuthState {\n  user: User | null;\n  isAuthenticated: boolean;\n  setUser: (user: User | null) => void;\n  logout: () => void;\n}\n\nexport const useAuthStore = create<AuthState>((set) => ({\n  user: null,\n  isAuthenticated: false,\n\n  setUser: (user) => set({\n    user,\n    isAuthenticated: !!user\n  }),\n\n  logout: () => set({\n    user: null,\n    isAuthenticated: false\n  })\n}));\n\n// Usage\nconst { user, setUser, logout } = useAuthStore();\n
"},{"location":"v2/development/typescript/#type-safety","title":"Type Safety","text":""},{"location":"v2/development/typescript/#avoiding-any","title":"Avoiding any","text":"

Never use any (ESLint rule enforced):

Bad:

function processData(data: any) {\n  return data.foo.bar; // No type safety\n}\n

Good:

// Use unknown if type is truly unknown\nfunction processData(data: unknown) {\n  if (isValidData(data)) {\n    return data.foo.bar; // Safe after type guard\n  }\n  throw new Error('Invalid data');\n}\n\nfunction isValidData(data: unknown): data is { foo: { bar: string } } {\n  return (\n    typeof data === 'object' &&\n    data !== null &&\n    'foo' in data &&\n    typeof data.foo === 'object' &&\n    data.foo !== null &&\n    'bar' in data.foo\n  );\n}\n\n// Or define proper interface\ninterface ValidData {\n  foo: { bar: string };\n}\n\nfunction processData(data: ValidData) {\n  return data.foo.bar;\n}\n

"},{"location":"v2/development/typescript/#type-assertions","title":"Type Assertions","text":"

Use type assertions carefully:

Good:

// When you know more than TypeScript\nconst input = document.getElementById('email') as HTMLInputElement;\n\n// Safer: Use type guard\nif (input instanceof HTMLInputElement) {\n  input.value = 'test@example.com';\n}\n

Bad:

// Dangerous: Could be wrong\nconst data = response.data as User;\n\n// Better: Validate first\nconst data = validateUser(response.data);\n

"},{"location":"v2/development/typescript/#non-null-assertion","title":"Non-null Assertion","text":"

Use ! only when TypeScript can't infer non-null:

Good:

// After authentication middleware\napp.get('/me', authenticate, (req, res) => {\n  const userId = req.user!.id; // Safe: authenticate ensures user exists\n});\n\n// After null check\nconst user = await prisma.user.findUnique({ where: { id: 1 } });\nif (!user) {\n  throw new Error('User not found');\n}\nconsole.log(user!.email); // Safe: null checked above\n

Bad:

// Dangerous: Could be null\nconst user = await prisma.user.findUnique({ where: { id: 1 } });\nconsole.log(user!.email); // Could crash if user is null\n

"},{"location":"v2/development/typescript/#type-guards","title":"Type Guards","text":"

Create type guards for runtime validation:

// Type guard function\nfunction isUser(obj: unknown): obj is User {\n  return (\n    typeof obj === 'object' &&\n    obj !== null &&\n    'id' in obj &&\n    'email' in obj &&\n    typeof obj.id === 'number' &&\n    typeof obj.email === 'string'\n  );\n}\n\n// Usage\nfunction processUser(data: unknown) {\n  if (isUser(data)) {\n    console.log(data.email); // TypeScript knows data is User\n  }\n}\n\n// Discriminated union\ntype Shape =\n  | { kind: 'circle'; radius: number }\n  | { kind: 'square'; size: number };\n\nfunction area(shape: Shape): number {\n  switch (shape.kind) {\n    case 'circle':\n      return Math.PI * shape.radius ** 2; // TS knows: radius exists\n    case 'square':\n      return shape.size ** 2; // TS knows: size exists\n  }\n}\n
"},{"location":"v2/development/typescript/#common-v2-gotchas","title":"Common V2 Gotchas","text":""},{"location":"v2/development/typescript/#express-params-as-string-or-string","title":"Express Params as String or String[]","text":"

Problem: req.params.id type is string | string[] in Express 5.

Solution:

// Cast to string (if you expect single value)\nconst id = parseInt(req.params.id as string);\n\n// Or check type\nconst rawId = req.params.id;\nconst id = typeof rawId === 'string' ? parseInt(rawId) : undefined;\n
"},{"location":"v2/development/typescript/#useref-with-undefined","title":"useRef with Undefined","text":"

Problem: useRef<T>() requires explicit undefined.

Solution:

// Good\nconst ref = useRef<HTMLInputElement>(undefined);\nconst ref = useRef<HTMLInputElement | null>(null);\n\n// Bad\nconst ref = useRef<HTMLInputElement>(); // Type error\n
"},{"location":"v2/development/typescript/#prisma-json-fields","title":"Prisma JSON Fields","text":"

Problem: JSON arrays need cast.

Solution:

import { Prisma } from '@prisma/client';\n\n// Cast array to Prisma.InputJsonValue\nconst blocks: Block[] = [...];\nawait prisma.page.create({\n  data: {\n    content: blocks as unknown as Prisma.InputJsonValue\n  }\n});\n\n// Use Prisma.JsonNull for null\nawait prisma.page.update({\n  where: { id: 1 },\n  data: { content: Prisma.JsonNull }\n});\n
"},{"location":"v2/development/typescript/#mixing-and","title":"Mixing ?? and ||","text":"

Problem: Cannot mix ?? and || without parentheses.

Solution:

// Error\nconst value = a ?? b || c;\n\n// Good\nconst value = (a ?? b) || c;\nconst value = a ?? (b || c);\n
"},{"location":"v2/development/typescript/#record-cast","title":"Record Cast

Problem: Need to cast via unknown first.

Solution:

// Error\nconst obj: Record<string, unknown> = someData;\n\n// Good\nconst obj = someData as unknown as Record<string, unknown>;\n
","text":""},{"location":"v2/development/typescript/#dayjs-via-ant-design","title":"dayjs via Ant Design

Problem: dayjs available transitively.

Solution:

// No need to install dayjs separately\nimport dayjs from 'dayjs'; // Available via antd\n
","text":""},{"location":"v2/development/typescript/#requser-name-field","title":"req.user Name Field

Problem: JWT only has id, email, role (no name).

Solution:

// JWT payload\ninterface JWTPayload {\n  id: number;\n  email: string;\n  role: string;\n}\n\n// Augmented request\ndeclare global {\n  namespace Express {\n    interface Request {\n      user?: JWTPayload; // Not full User\n    }\n  }\n}\n\n// If you need name, fetch from database\nconst user = await prisma.user.findUnique({\n  where: { id: req.user!.id }\n});\nconsole.log(user?.name);\n
","text":""},{"location":"v2/development/typescript/#api-import-pattern","title":"API Import Pattern

Problem: Named export, not default.

Solution:

// Good\nimport { api } from '../lib/api';\n\n// Bad\nimport api from '../lib/api'; // Error\n
","text":""},{"location":"v2/development/typescript/#unchecked-createupdate","title":"Unchecked Create/Update

Problem: Setting foreign keys directly.

Solution:

// Use Unchecked variants\nconst data: Prisma.CampaignUncheckedCreateInput = {\n  title: 'Campaign',\n  createdByUserId: 1 // Can set FK directly\n};\n\n// Regular CreateInput requires nested create\nconst data: Prisma.CampaignCreateInput = {\n  title: 'Campaign',\n  createdBy: {\n    connect: { id: 1 }\n  }\n};\n
","text":""},{"location":"v2/development/typescript/#type-utilities","title":"Type Utilities","text":""},{"location":"v2/development/typescript/#custom-utility-types","title":"Custom Utility Types
// Make specific fields optional\ntype PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;\n\ntype UpdateUserInput = PartialBy<User, 'name' | 'email'>;\n// id required, name and email optional\n\n// Make specific fields required\ntype RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;\n\ntype UserWithEmail = RequiredBy<User, 'email'>;\n// All fields optional except email\n\n// Deep partial\ntype DeepPartial<T> = {\n  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];\n};\n\n// Nullable\ntype Nullable<T> = T | null;\n\ntype NullableUser = Nullable<User>;\n// User | null\n
","text":""},{"location":"v2/development/typescript/#type-extraction","title":"Type Extraction
// Extract specific keys\ntype UserKeys = keyof User;\n// 'id' | 'email' | 'password' | 'role' | ...\n\n// Extract value types\ntype UserEmail = User['email'];\n// string\n\n// Extract function return type\nfunction getUser() {\n  return { id: 1, email: 'john@example.com' };\n}\n\ntype User = ReturnType<typeof getUser>;\n// { id: number; email: string }\n\n// Extract promise result type\nasync function fetchUser(): Promise<User> {\n  // ...\n}\n\ntype FetchUserResult = Awaited<ReturnType<typeof fetchUser>>;\n// User (not Promise<User>)\n
","text":""},{"location":"v2/development/typescript/#performance","title":"Performance","text":""},{"location":"v2/development/typescript/#build-times","title":"Build Times

Optimize tsconfig.json:

{\n  \"compilerOptions\": {\n    \"skipLibCheck\": true, // Skip type checking node_modules\n    \"incremental\": true,  // Enable incremental compilation\n    \"tsBuildInfoFile\": \".tsbuildinfo\" // Cache file\n  }\n}\n

Type-check without emit:

# Faster than full build\nnpx tsc --noEmit\n
","text":""},{"location":"v2/development/typescript/#type-inference","title":"Type Inference

Let TypeScript infer when possible:

Good:

// TypeScript infers string[]\nconst emails = users.map(u => u.email);\n\n// TypeScript infers number\nconst total = amounts.reduce((sum, n) => sum + n, 0);\n

Bad:

// Unnecessary explicit type\nconst emails: string[] = users.map(u => u.email);\n

","text":""},{"location":"v2/development/typescript/#migration-from-javascript","title":"Migration from JavaScript","text":""},{"location":"v2/development/typescript/#gradual-typing","title":"Gradual Typing

Add types incrementally:

Step 1: Allow implicit any

{\n  \"compilerOptions\": {\n    \"noImplicitAny\": false\n  }\n}\n

Step 2: Add types to new code

// New functions with types\nfunction createUser(email: string, password: string): User {\n  // ...\n}\n

Step 3: Add types to existing code

// Old function (before)\nfunction getUser(id) {\n  return prisma.user.findUnique({ where: { id } });\n}\n\n// Add types (after)\nfunction getUser(id: number): Promise<User | null> {\n  return prisma.user.findUnique({ where: { id } });\n}\n

Step 4: Enable strict mode

{\n  \"compilerOptions\": {\n    \"strict\": true\n  }\n}\n
","text":""},{"location":"v2/development/typescript/#related-documentation","title":"Related Documentation","text":"
  • Code Style: Code Style Guide
  • Testing: Testing Guide
  • API: API Architecture
  • Frontend: Frontend Architecture
"},{"location":"v2/development/typescript/#summary","title":"Summary","text":"

You now know: - \u2705 TypeScript type system fundamentals - \u2705 Common V2 patterns (Prisma, Drizzle, Zod, React) - \u2705 Type safety best practices (avoid any, type guards) - \u2705 V2-specific gotchas and solutions - \u2705 Custom utility types - \u2705 Performance optimization - \u2705 Gradual migration from JavaScript

Quick Reference:

// Prisma types\nimport { User, Prisma } from '@prisma/client';\n\n// Zod types\nconst schema = z.object({ email: z.string().email() });\ntype Input = z.infer<typeof schema>;\n\n// React types\ninterface Props {\n  user: User;\n  onEdit?: (user: User) => void;\n}\n\n// Type guards\nfunction isUser(obj: unknown): obj is User {\n  return typeof obj === 'object' && obj !== null && 'id' in obj;\n}\n\n// Utility types\ntype UpdateInput = Partial<User>;\ntype UserPreview = Pick<User, 'id' | 'email'>;\n

"},{"location":"v2/features/","title":"Feature Documentation","text":"

Welcome to the Changemaker Lite V2 feature documentation. This section provides end-to-end guides for complete features, showing how backend APIs, frontend pages, and database models work together to deliver functionality.

"},{"location":"v2/features/#documentation-structure","title":"Documentation Structure","text":"

Each feature guide includes:

  • Architecture diagrams showing data flow
  • Database models with relationships
  • API endpoints for admin and public access
  • Configuration options and environment variables
  • Workflows for admin, public, and volunteer users
  • Code examples from actual source files
  • Troubleshooting common issues
  • Performance optimization tips
"},{"location":"v2/features/#feature-categories","title":"Feature Categories","text":""},{"location":"v2/features/#influence-features","title":"Influence Features","text":"

Email advocacy campaigns and representative outreach:

  • Campaign Management \u2014 Create and manage advocacy campaigns
  • Representative Lookup \u2014 Postal code-based representative search
  • Response Wall \u2014 Public response submission and moderation
  • Email Queue \u2014 BullMQ email processing system
  • Postal Code Cache \u2014 Postal code geocoding cache
"},{"location":"v2/features/#map-features","title":"Map Features","text":"

Geographic location management and canvassing:

  • Location Management \u2014 Building and unit-level address management
  • Geocoding \u2014 Multi-provider geocoding service
  • Geographic Cuts \u2014 Polygon overlays for organizing locations
  • Volunteer Shifts \u2014 Shift scheduling and signup system
  • Canvassing System \u2014 Door-to-door canvassing with GPS
  • GPS Tracking \u2014 Real-time volunteer location tracking
  • Walk Sheets \u2014 Printable canvassing materials
  • Data Quality Dashboard \u2014 Geocoding quality monitoring
  • NAR Import \u2014 Canadian electoral data import
"},{"location":"v2/features/#landing-pages","title":"Landing Pages","text":"

Website page building and management:

  • Page Builder \u2014 GrapesJS visual editor
  • GrapesJS Editor Component \u2014 Editor integration
  • Block Library \u2014 Reusable content blocks
  • MkDocs Export \u2014 Export to documentation site
"},{"location":"v2/features/#email-templates","title":"Email Templates","text":"

Email template system for campaigns:

  • Template System \u2014 Email template engine
  • Template Editor \u2014 HTML template editing
  • Template Variables \u2014 Dynamic variable system
  • Version History \u2014 Template version tracking
"},{"location":"v2/features/#media-features","title":"Media Features","text":"

Video library management:

  • Video Library \u2014 Video organization and metadata
  • Video Upload \u2014 Upload with automatic metadata extraction
  • Media Jobs \u2014 Background job processing
  • Public Gallery \u2014 Public video sharing
"},{"location":"v2/features/#newsletter-integration","title":"Newsletter Integration","text":"

Listmonk newsletter platform integration:

  • Listmonk Integration \u2014 API client setup
  • Data Sync \u2014 Sync contacts to Listmonk
  • List Management \u2014 Newsletter list administration
"},{"location":"v2/features/#tunnel-management","title":"Tunnel Management","text":"

Pangolin tunnel for public access:

  • Pangolin Setup \u2014 Tunnel configuration
  • Newt Container \u2014 Docker integration
  • Exit Nodes \u2014 Exit node management
"},{"location":"v2/features/#observability","title":"Observability","text":"

Monitoring and metrics:

  • Prometheus Metrics \u2014 Custom metrics collection
  • Grafana Dashboards \u2014 Visualization dashboards
  • Alertmanager \u2014 Alert routing
  • Data Quality Monitoring \u2014 Data quality tracking
"},{"location":"v2/features/#related-documentation","title":"Related Documentation","text":"
  • Backend Modules \u2014 API implementation details
  • Frontend Pages \u2014 UI component documentation
  • Database Models \u2014 Schema documentation
  • Architecture \u2014 System architecture guides
  • User Guides \u2014 Step-by-step tutorials
"},{"location":"v2/features/#quick-navigation","title":"Quick Navigation","text":""},{"location":"v2/features/#by-user-role","title":"By User Role","text":"

Administrators: - Campaign creation and management - Response moderation - User management - Location management - Shift scheduling - Email queue monitoring - Landing page editing

Public Users: - Campaign participation - Representative lookup - Email sending - Response submission - Shift signup - Media gallery browsing

Volunteers: - Canvassing with GPS - Visit recording - Shift assignments - Activity tracking - Route history

"},{"location":"v2/features/#by-use-case","title":"By Use Case","text":"

Advocacy Campaigns: 1. Create campaign 2. Configure representatives 3. Monitor email queue 4. Moderate responses

Canvassing Operations: 1. Import locations 2. Create geographic cuts 3. Schedule shifts 4. Track canvassing 5. Print walk sheets

Website Management: 1. Build landing pages 2. Manage content blocks 3. Export to MkDocs

Public Access: 1. Setup Pangolin tunnel 2. Configure Newt container 3. Monitor with observability

"},{"location":"v2/features/COMPLETION_STATUS/","title":"Phase 6 Features Documentation - Completion Status","text":""},{"location":"v2/features/COMPLETION_STATUS/#overview","title":"Overview","text":"

Phase 6 creates comprehensive end-to-end feature documentation showing how complete features work across backend + frontend + database layers.

Target: 26 feature documentation files Created: 6 files (23%) Remaining: 20 files (77%)

"},{"location":"v2/features/COMPLETION_STATUS/#completed-files-626","title":"Completed Files (6/26)","text":""},{"location":"v2/features/COMPLETION_STATUS/#influence-features-56","title":"Influence Features (\u215a)","text":"

\u2705 campaigns.md (1,118 lines) - Campaign management system with lifecycle, feature flags, admin/public workflows \u2705 representatives.md (1,048 lines) - Represent API integration, caching, postal code lookup \u2705 responses.md (1,064 lines) - Response wall submission, moderation, upvoting, email verification \u2705 email-queue.md (994 lines) - BullMQ email processing, queue monitoring, retry logic \u2705 postal-codes.md (151 lines) - Postal code geocoding cache

\u274c call-tracking.md - Phone call tracking (not yet implemented in codebase)

"},{"location":"v2/features/COMPLETION_STATUS/#core-features-11","title":"Core Features (1/1)","text":"

\u2705 index.md (155 lines) - Features documentation index with navigation

"},{"location":"v2/features/COMPLETION_STATUS/#remaining-files-2026","title":"Remaining Files (20/26)","text":""},{"location":"v2/features/COMPLETION_STATUS/#map-features-09","title":"Map Features (0/9)","text":"

\u274c map/locations.md - Location management (building + unit architecture, NAR integration, CSV import/export) \u274c map/geocoding.md - Multi-provider geocoding (6 providers, fallback chain, confidence scoring) \u274c map/cuts.md - Geographic polygon overlays (GeoJSON storage, point-in-polygon, drawing mode) \u274c map/shifts.md - Volunteer shift management (signup workflow, email notifications) \u274c map/canvassing.md - Canvassing session system (visit outcomes, walking routes, GPS tracking) \u274c map/tracking.md - GPS tracking (breadcrumb trails, route visualization, distance calculation) \u274c map/walk-sheets.md - Printable walk sheets (QR codes, browser print API) \u274c map/data-quality.md - Geocoding quality dashboard (confidence metrics, provider success rates) \u274c map/nar-import.md - NAR 2025 electoral data import (province selector, streaming import, EPSG:3347 projection)

"},{"location":"v2/features/COMPLETION_STATUS/#landing-pages-features-04","title":"Landing Pages Features (0/4)","text":"

\u274c pages/page-builder.md - GrapesJS landing page builder (dual-mode editing, block library) \u274c pages/grapes-editor.md - GrapesJS editor component (forwardRef pattern, error boundary) \u274c pages/block-library.md - Reusable page blocks (6 default blocks, JSON schema) \u274c pages/mkdocs-export.md - MkDocs Material theme export (Jinja2 templates, overrides)

"},{"location":"v2/features/COMPLETION_STATUS/#email-templates-features-04","title":"Email Templates Features (0/4)","text":"

\u274c email-templates/template-system.md - Email template engine (categories, variable interpolation, Handlebars) \u274c email-templates/editor.md - Email template editor (HTML editing, variable insertion, preview) \u274c email-templates/variables.md - Template variable system (required/optional, conditional blocks) \u274c email-templates/versioning.md - Template version history (auto-increment, rollback, change notes)

"},{"location":"v2/features/COMPLETION_STATUS/#media-features-04","title":"Media Features (0/4)","text":"

\u274c media/video-library.md - Video library management (9 directory types, FFprobe metadata) \u274c media/upload.md - Video upload system (automatic metadata extraction, 10GB limit, 7 formats) \u274c media/jobs.md - Media job queue (job types, resource categories, status flow) \u274c media/public-gallery.md - Public video gallery (categories, lock/unlock, reactions, comments)

"},{"location":"v2/features/COMPLETION_STATUS/#newsletter-features-03","title":"Newsletter Features (0/3)","text":"

\u274c newsletter/listmonk-integration.md - Listmonk REST API integration (native fetch client, basic auth) \u274c newsletter/sync.md - Data sync to Listmonk (participants/locations/users \u2192 lists) \u274c newsletter/lists.md - Newsletter list management (results pagination, subscriber attributes)

"},{"location":"v2/features/COMPLETION_STATUS/#tunnel-features-03","title":"Tunnel Features (0/3)","text":"

\u274c tunnel/pangolin-setup.md - Pangolin tunnel configuration (self-hosted API, setup wizard) \u274c tunnel/newt-container.md - Newt Docker integration (nginx dependency, tunnel lifecycle) \u274c tunnel/exit-nodes.md - Tunnel exit node management (routing setup, performance monitoring)

"},{"location":"v2/features/COMPLETION_STATUS/#observability-features-04","title":"Observability Features (0/4)","text":"

\u274c observability/prometheus-metrics.md - Custom metrics collection (12 cm_* metrics, HTTP metrics) \u274c observability/grafana-dashboards.md - Grafana visualization (3 pre-configured dashboards) \u274c observability/alertmanager.md - Alert routing (12 alert rules, notification channels) \u274c observability/data-quality.md - Data quality monitoring (geocoding confidence, validation)

"},{"location":"v2/features/COMPLETION_STATUS/#file-structure-template","title":"File Structure Template","text":"

Each feature file should follow this 12-section structure:

  1. Overview \u2014 Feature purpose, use cases, key capabilities
  2. Architecture \u2014 Mermaid diagram showing frontend \u2192 API \u2192 service \u2192 database flow
  3. Database Models \u2014 Related models with links to database docs
  4. API Endpoints \u2014 List of endpoints with links to API reference docs
  5. Configuration \u2014 Environment variables, settings, feature flags (table format)
  6. Admin Workflow \u2014 Step-by-step guide for administrators
  7. Public Workflow \u2014 Step-by-step guide for public users (if applicable)
  8. Volunteer Workflow \u2014 Step-by-step guide for volunteers (if applicable)
  9. Code Examples \u2014 Real code snippets from backend/frontend
  10. Troubleshooting \u2014 Common issues + solutions
  11. Performance Considerations \u2014 Optimization tips, scaling notes
  12. Related Documentation \u2014 Links to backend modules, frontend pages, database models
"},{"location":"v2/features/COMPLETION_STATUS/#source-references","title":"Source References","text":"

Completed Files Reference:

  • api/src/modules/influence/campaigns/ \u2192 campaigns.md
  • api/src/modules/influence/representatives/ \u2192 representatives.md
  • api/src/modules/influence/responses/ \u2192 responses.md
  • api/src/services/email-queue.service.ts \u2192 email-queue.md
  • admin/src/pages/CampaignsPage.tsx \u2192 campaigns.md
  • admin/src/pages/ResponsesPage.tsx \u2192 responses.md

For Remaining Files:

  • api/src/modules/map/locations/ \u2192 locations.md
  • api/src/modules/map/geocoding/ \u2192 geocoding.md
  • api/src/modules/map/cuts/ \u2192 cuts.md
  • api/src/modules/map/shifts/ \u2192 shifts.md
  • api/src/modules/map/canvass/ \u2192 canvassing.md
  • api/src/modules/map/tracking/ \u2192 tracking.md
  • api/src/modules/pages/ \u2192 page-builder.md, block-library.md
  • api/src/modules/email-templates/ \u2192 template-system.md, editor.md, variables.md, versioning.md
  • api/src/modules/media/ \u2192 video-library.md, upload.md, jobs.md, public-gallery.md
  • api/src/services/listmonk.client.ts \u2192 listmonk-integration.md
  • api/src/services/pangolin.client.ts \u2192 pangolin-setup.md
  • api/src/utils/metrics.ts \u2192 prometheus-metrics.md
"},{"location":"v2/features/COMPLETION_STATUS/#statistics","title":"Statistics","text":"

Total Lines Created: ~4,530 lines across 6 files Average File Size: ~755 lines Estimated Remaining: ~15,100 lines (20 files \u00d7 755 avg) Total Target: ~19,630 lines across 26 files

"},{"location":"v2/features/COMPLETION_STATUS/#next-steps","title":"Next Steps","text":"
  1. Create map features (highest priority - core platform functionality)
  2. Create landing pages features (GrapesJS integration)
  3. Create media features (video library + upload)
  4. Create email templates features
  5. Create newsletter features
  6. Create tunnel features
  7. Create observability features
"},{"location":"v2/features/COMPLETION_STATUS/#notes","title":"Notes","text":"
  • All completed files include comprehensive Mermaid architecture diagrams
  • Real code examples extracted from source files (not invented)
  • Cross-references to Phase 3 (backend modules), Phase 4 (frontend pages), Phase 5 (database models)
  • Configuration tables with all environment variables
  • Troubleshooting sections with common errors and solutions
  • Performance considerations with optimization tips
"},{"location":"v2/features/email-templates/","title":"Email Templates","text":"

The Email Templates feature provides a complete email template management system with variable substitution, versioning, and rich text editing. Create reusable email templates for campaigns, notifications, and communications.

"},{"location":"v2/features/email-templates/#overview","title":"Overview","text":"

The Email Templates system consists of four integrated components:

  1. Template System - Template CRUD and management
  2. Editor - Rich text editor with variable insertion
  3. Variables - Dynamic content placeholders
  4. Versioning - Template version history
"},{"location":"v2/features/email-templates/#features","title":"Features","text":""},{"location":"v2/features/email-templates/#template-management","title":"Template Management","text":"
  • Create/edit/delete templates
  • Category organization
  • Template types (campaign, notification, system)
  • Published/draft status
  • Search and filtering
  • Clone templates
"},{"location":"v2/features/email-templates/#rich-text-editor","title":"Rich Text Editor","text":"
  • WYSIWYG HTML editor
  • Variable insertion menu
  • Preview mode
  • HTML source view
  • Image upload (future)
  • Link management
"},{"location":"v2/features/email-templates/#variable-system","title":"Variable System","text":"

Dynamic placeholders:

  • User variables - {{user.name}}, {{user.email}}
  • Campaign variables - {{campaign.name}}, {{campaign.description}}
  • Representative variables - {{rep.name}}, {{rep.title}}, {{rep.email}}
  • Custom variables - Template-specific placeholders
  • System variables - {{site.name}}, {{current.date}}
"},{"location":"v2/features/email-templates/#version-history","title":"Version History","text":"
  • Auto-save on changes
  • Version diff viewer
  • Restore previous versions
  • Change log
"},{"location":"v2/features/email-templates/#user-flow","title":"User Flow","text":""},{"location":"v2/features/email-templates/#admin-experience","title":"Admin Experience","text":"
  1. Create Template (/app/email-templates)
  2. Click \"New Template\"
  3. Enter name and category
  4. Set template type
  5. Save draft

  6. Edit Template (/app/email-templates/:id/edit)

  7. Full-screen rich text editor
  8. Insert variables from dropdown
  9. Preview with sample data
  10. Save changes

  11. Use Template

  12. Select template in campaign form
  13. Variables auto-populated from context
  14. Send email with processed template

  15. Manage Versions (/app/email-templates/:id/versions)

  16. View version history
  17. Compare versions
  18. Restore previous version
"},{"location":"v2/features/email-templates/#architecture","title":"Architecture","text":""},{"location":"v2/features/email-templates/#backend-components","title":"Backend Components","text":"

Module: - api/src/modules/email-templates/email-templates.routes.ts - Template CRUD - api/src/modules/email-templates/email-templates.service.ts - Business logic - api/src/modules/email-templates/email-templates.schemas.ts - Zod validation

Database Models: - EmailTemplate - Template definitions (name, content, variables) - EmailTemplateVersion - Version history (future)

Email Processing: - Variable substitution in email.service.ts - Mustache-style templating: {{variable}} - HTML escaping for security

"},{"location":"v2/features/email-templates/#frontend-components","title":"Frontend Components","text":"

Admin Pages: - admin/src/pages/EmailTemplatesPage.tsx - Template management table - admin/src/pages/EmailTemplateEditorPage.tsx - Full-screen editor

Editor Components: - admin/src/components/email-templates/TemplateEditor.tsx - Rich text editor - admin/src/components/email-templates/VariableInserter.tsx - Variable dropdown

"},{"location":"v2/features/email-templates/#configuration","title":"Configuration","text":""},{"location":"v2/features/email-templates/#template-types","title":"Template Types","text":"
  • campaign - Campaign email templates
  • notification - User notifications
  • system - System emails (verification, password reset)
  • custom - Custom templates
"},{"location":"v2/features/email-templates/#template-categories","title":"Template Categories","text":"
  • Influence - Campaign-related templates
  • Map - Shift/canvass notifications
  • User - User account emails
  • System - Automated system emails
"},{"location":"v2/features/email-templates/#variable-system_1","title":"Variable System","text":""},{"location":"v2/features/email-templates/#available-variables","title":"Available Variables","text":"

User Context:

{{user.id}}           # User ID\n{{user.email}}        # Email address\n{{user.name}}         # Full name\n{{user.role}}         # User role\n

Campaign Context:

{{campaign.id}}       # Campaign ID\n{{campaign.name}}     # Campaign name\n{{campaign.description}} # Description\n{{campaign.emailTemplate}} # Email body\n

Representative Context:

{{rep.name}}          # Representative name\n{{rep.title}}         # Title (MP, MLA, etc.)\n{{rep.email}}         # Email address\n{{rep.phone}}         # Phone number\n{{rep.district}}      # District name\n

System Context:

{{site.name}}         # Site name\n{{site.url}}          # Site URL\n{{current.date}}      # Current date\n{{current.year}}      # Current year\n

"},{"location":"v2/features/email-templates/#variable-insertion","title":"Variable Insertion","text":"
// Insert variable at cursor position\neditor.insertContent('{{user.name}}');\n\n// Variable dropdown menu\n<Select>\n  <Option value=\"{{user.name}}\">User Name</Option>\n  <Option value=\"{{user.email}}\">User Email</Option>\n  <Option value=\"{{rep.name}}\">Representative Name</Option>\n</Select>\n
"},{"location":"v2/features/email-templates/#variable-processing","title":"Variable Processing","text":"

Server-side processing in email.service.ts:

function processTemplate(\n  template: string,\n  variables: Record<string, any>\n): string {\n  let processed = template;\n\n  for (const [key, value] of Object.entries(variables)) {\n    const placeholder = `{{${key}}}`;\n    processed = processed.replace(\n      new RegExp(placeholder, 'g'),\n      escapeHtml(String(value))\n    );\n  }\n\n  return processed;\n}\n
"},{"location":"v2/features/email-templates/#database-schema","title":"Database Schema","text":""},{"location":"v2/features/email-templates/#emailtemplate-model","title":"EmailTemplate Model","text":"
model EmailTemplate {\n  id          Int      @id @default(autoincrement())\n  name        String\n  subject     String\n  body        String   @db.Text\n  category    String?\n  type        String   @default(\"custom\")\n  variables   Json?    # Available variables\n  published   Boolean  @default(false)\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n}\n
"},{"location":"v2/features/email-templates/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/email-templates/#admin-endpoints","title":"Admin Endpoints","text":"
GET    /api/email-templates            # List templates\nPOST   /api/email-templates            # Create template\nGET    /api/email-templates/:id        # Get template\nPATCH  /api/email-templates/:id        # Update template\nDELETE /api/email-templates/:id        # Delete template\nPOST   /api/email-templates/:id/clone  # Clone template\nGET    /api/email-templates/:id/preview # Preview with sample data\n
"},{"location":"v2/features/email-templates/#security","title":"Security","text":""},{"location":"v2/features/email-templates/#html-escaping","title":"HTML Escaping","text":"

All variable values are HTML-escaped to prevent XSS:

import { escapeHtml } from '../utils/sanitize';\n\nconst safe = escapeHtml(userInput);\n// Converts: < > & \" ' to HTML entities\n
"},{"location":"v2/features/email-templates/#template-validation","title":"Template Validation","text":"
  • Subject line: 1-200 characters
  • Body: Required, max 50,000 characters
  • Variables: Valid JSON object
  • Category: Predefined list
"},{"location":"v2/features/email-templates/#best-practices","title":"Best Practices","text":""},{"location":"v2/features/email-templates/#template-design","title":"Template Design","text":"
  • Clear subject lines (50-60 chars)
  • Personalize with variables
  • Mobile-responsive HTML
  • Plain text alternative
  • Unsubscribe link
  • Branding consistency
"},{"location":"v2/features/email-templates/#variable-usage","title":"Variable Usage","text":"
  • Document available variables
  • Provide defaults for missing values
  • Test with sample data
  • Validate variable names
"},{"location":"v2/features/email-templates/#version-management","title":"Version Management","text":"
  • Meaningful version names
  • Document changes
  • Test before publishing
  • Keep version history
"},{"location":"v2/features/email-templates/#desktop-only-editor","title":"Desktop-Only Editor","text":"

Email template editor requires desktop browser:

const screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n\nif (isMobile) {\n  return (\n    <Alert\n      message=\"Desktop Required\"\n      description=\"Email editor requires desktop browser\"\n      type=\"warning\"\n    />\n  );\n}\n
"},{"location":"v2/features/email-templates/#integration-points","title":"Integration Points","text":""},{"location":"v2/features/email-templates/#campaign-emails","title":"Campaign Emails","text":"

Campaign emails use templates:

// Select template in campaign form\n<Select>\n  {templates.map(t => (\n    <Option value={t.id}>{t.name}</Option>\n  ))}\n</Select>\n\n// Process template with campaign data\nconst emailBody = processTemplate(template.body, {\n  'user.name': user.name,\n  'campaign.name': campaign.name,\n  'rep.name': representative.name,\n});\n
"},{"location":"v2/features/email-templates/#system-emails","title":"System Emails","text":"

System emails (verification, password reset):

// Load system template\nconst template = await getTemplateByType('email-verification');\n\n// Process with user data\nconst emailBody = processTemplate(template.body, {\n  'user.name': user.name,\n  'verify.link': verificationUrl,\n});\n\n// Send email\nawait emailService.sendEmail({\n  to: user.email,\n  subject: template.subject,\n  html: emailBody,\n});\n
"},{"location":"v2/features/email-templates/#related-documentation","title":"Related Documentation","text":"
  • Template System
  • Editor
  • Variables
  • Versioning
  • Email Templates Page
  • Email Template Editor Page
  • Email Service
  • Campaign Manager Guide
"},{"location":"v2/features/email-templates/editor/","title":"Email Template Editor","text":""},{"location":"v2/features/email-templates/editor/#overview","title":"Overview","text":"

The Email Template Editor provides a powerful interface for creating and modifying email templates with live preview, variable insertion, and test send functionality. It supports split-pane editing for HTML and plain text versions, visual variable insertion, and real-time rendering with sample data.

Key Features:

  • Split-Pane Editor \u2014 Side-by-side HTML and text editing
  • Variable Insertion Buttons \u2014 Click to insert {{VARIABLES}} at cursor position
  • Live Preview Rendering \u2014 See rendered HTML with sample data in real-time
  • Test Send Functionality \u2014 Send test emails with custom sample data
  • Auto-Save Drafts \u2014 Prevent data loss with automatic draft saving
  • Version Creation \u2014 Every save creates a new version with change notes
  • Responsive Layout \u2014 Desktop-optimized (mobile warning for small screens)
  • Keyboard Shortcuts \u2014 Ctrl+S to save, Ctrl+P to preview, Esc to close

Access Control: - Role Required: SUPER_ADMIN only - Route: /app/email-templates/:id/edit - Layout: Full-screen (no AppLayout sidebar)

"},{"location":"v2/features/email-templates/editor/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph \"Editor UI Components\"\n        Editor[EmailTemplateEditorPage]\n        Toolbar[Editor Toolbar]\n        HtmlEditor[HTML Editor Pane]\n        TextEditor[Text Editor Pane]\n        VarPanel[Variable Insertion Panel]\n        Preview[Live Preview Pane]\n        TestForm[Test Send Form]\n    end\n\n    subgraph \"State Management\"\n        State[Component State]\n        Draft[LocalStorage Draft]\n        AutoSave[Auto-Save Timer]\n    end\n\n    subgraph \"API Layer\"\n        GetTemplate[GET /api/email-templates/:id]\n        UpdateTemplate[PUT /api/email-templates/:id]\n        TestSend[POST /api/email-templates/:id/test]\n    end\n\n    subgraph \"Backend Processing\"\n        Template[(EmailTemplate)]\n        Variables[(EmailTemplateVariable)]\n        Handlebars[Handlebars Compiler]\n        EmailService[Email Service]\n        TestLog[(EmailTemplateTestLog)]\n    end\n\n    Editor --> Toolbar\n    Editor --> HtmlEditor\n    Editor --> TextEditor\n    Editor --> VarPanel\n    Editor --> Preview\n    Editor --> TestForm\n\n    Editor --> State\n    State --> Draft\n    State --> AutoSave\n\n    Editor -->|Load| GetTemplate\n    GetTemplate --> Template\n    GetTemplate --> Variables\n\n    VarPanel -->|Insert| HtmlEditor\n    VarPanel -->|Insert| TextEditor\n\n    HtmlEditor -->|Debounce 300ms| Preview\n    TextEditor --> State\n\n    Preview --> Handlebars\n    Handlebars -->|Render HTML| Preview\n\n    Toolbar -->|Save Click| UpdateTemplate\n    UpdateTemplate --> Template\n    UpdateTemplate -->|Create Version| Versions[(EmailTemplateVersion)]\n\n    TestForm --> TestSend\n    TestSend --> EmailService\n    EmailService -->|Send| SMTP[Nodemailer]\n    SMTP --> TestLog\n\n    AutoSave --> Draft\n\n    style Editor fill:#4a90e2,color:#fff\n    style Template fill:#50c878,color:#fff\n    style Preview fill:#ffb347,color:#333

Data Flow:

  1. Load Template \u2014 Fetch template + variables via GET API
  2. Restore Draft \u2014 Load from localStorage if exists (unsaved changes)
  3. Edit Content \u2014 Type in HTML/text editors, updates component state
  4. Insert Variable \u2014 Click variable button \u2192 inserts {{VAR}} at cursor
  5. Preview Update \u2014 Debounced (300ms) Handlebars compilation + iframe render
  6. Test Send \u2014 Enter recipient + sample data \u2192 POST to test endpoint \u2192 email sent
  7. Save Template \u2014 Click save \u2192 PUT API \u2192 create version \u2192 clear draft \u2192 redirect
  8. Auto-Save Draft \u2014 Blur event \u2192 save to localStorage (not database)
"},{"location":"v2/features/email-templates/editor/#editor-components","title":"Editor Components","text":""},{"location":"v2/features/email-templates/editor/#toolbar","title":"Toolbar","text":"

Location: Top bar (sticky)

Elements: - Template Name \u2014 Read-only display (left) - Save Button \u2014 Saves changes and creates version (right) - Preview Toggle \u2014 Show/hide live preview pane (right) - Test Send Button \u2014 Opens test send modal (right) - Back Button \u2014 Returns to EmailTemplatesPage (left)

Actions:

const handleSave = async () => {\n  setSaving(true);\n  try {\n    await api.put(`/api/email-templates/${id}`, {\n      subjectLine,\n      htmlContent,\n      textContent,\n      changeNotes,\n    });\n\n    message.success('Template saved successfully');\n    localStorage.removeItem(`email-template-draft-${id}`);\n    navigate('/app/email-templates');\n  } catch (error) {\n    message.error('Failed to save template');\n  } finally {\n    setSaving(false);\n  }\n};\n

"},{"location":"v2/features/email-templates/editor/#html-editor-pane","title":"HTML Editor Pane","text":"

Location: Left side (50% width) or full width when preview hidden

Features: - Textarea or Monaco Editor \u2014 Syntax highlighting (Monaco upgrade path) - Line Numbers \u2014 Visual line number gutter - Auto-Resize \u2014 Grows to fit content (max 80vh) - Tab Support \u2014 Tab key inserts 2 spaces (not focus change)

Implementation:

const [htmlContent, setHtmlContent] = useState('');\nconst htmlEditorRef = useRef<HTMLTextAreaElement>(null);\n\nconst handleHtmlChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n  setHtmlContent(e.target.value);\n  debouncedPreview(e.target.value, sampleData);\n};\n\nconst handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n  // Tab key support\n  if (e.key === 'Tab') {\n    e.preventDefault();\n    const textarea = e.currentTarget;\n    const start = textarea.selectionStart;\n    const end = textarea.selectionEnd;\n\n    setHtmlContent(\n      htmlContent.substring(0, start) + '  ' + htmlContent.substring(end)\n    );\n\n    setTimeout(() => {\n      textarea.selectionStart = textarea.selectionEnd = start + 2;\n    }, 0);\n  }\n};\n

"},{"location":"v2/features/email-templates/editor/#text-editor-pane","title":"Text Editor Pane","text":"

Location: Left side (50% width) or full width when preview hidden

Features: - Plain Text Editing \u2014 No syntax highlighting needed - Auto-Resize \u2014 Matches HTML editor height - Variable Insertion \u2014 Same insertion panel as HTML editor

Implementation:

const [textContent, setTextContent] = useState('');\nconst textEditorRef = useRef<HTMLTextAreaElement>(null);\n\nconst handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n  setTextContent(e.target.value);\n};\n

"},{"location":"v2/features/email-templates/editor/#variable-insertion-panel","title":"Variable Insertion Panel","text":"

Location: Right sidebar (collapsible)

Features: - Variable List \u2014 All template variables with labels - Insert Buttons \u2014 Click to insert {{VAR}} at cursor - Required Badge \u2014 Red badge for required variables - Conditional Badge \u2014 Blue badge for conditional variables - Sample Value Display \u2014 Shows example value below each variable - Search/Filter \u2014 Filter variables by name (if many variables)

Implementation:

const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {\n  const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;\n  if (!textarea) return;\n\n  const start = textarea.selectionStart;\n  const end = textarea.selectionEnd;\n  const content = editorType === 'html' ? htmlContent : textContent;\n\n  const before = content.substring(0, start);\n  const after = content.substring(end);\n  const newContent = before + `{{${variableKey}}}` + after;\n\n  if (editorType === 'html') {\n    setHtmlContent(newContent);\n  } else {\n    setTextContent(newContent);\n  }\n\n  // Move cursor after inserted variable\n  setTimeout(() => {\n    const newPos = start + variableKey.length + 4; // 4 = {{ + }}\n    textarea.selectionStart = newPos;\n    textarea.selectionEnd = newPos;\n    textarea.focus();\n  }, 0);\n};\n

Variable List UI:

<Space direction=\"vertical\" style={{ width: '100%' }}>\n  {variables\n    .sort((a, b) => a.sortOrder - b.sortOrder)\n    .map((variable) => (\n      <Card key={variable.id} size=\"small\">\n        <Space direction=\"vertical\" size={0} style={{ width: '100%' }}>\n          <Space>\n            <Text strong>{variable.label}</Text>\n            {variable.isRequired && <Tag color=\"red\">Required</Tag>}\n            {variable.isConditional && <Tag color=\"blue\">Conditional</Tag>}\n          </Space>\n\n          <Text type=\"secondary\" style={{ fontSize: 12 }}>\n            {variable.description}\n          </Text>\n\n          {variable.sampleValue && (\n            <Text code style={{ fontSize: 11 }}>\n              Example: {variable.sampleValue}\n            </Text>\n          )}\n\n          <Space size=\"small\">\n            <Button\n              size=\"small\"\n              onClick={() => handleInsertVariable(variable.key, 'html')}\n            >\n              Insert to HTML\n            </Button>\n            <Button\n              size=\"small\"\n              onClick={() => handleInsertVariable(variable.key, 'text')}\n            >\n              Insert to Text\n            </Button>\n          </Space>\n        </Space>\n      </Card>\n    ))}\n</Space>\n

"},{"location":"v2/features/email-templates/editor/#live-preview-pane","title":"Live Preview Pane","text":"

Location: Right side (50% width) when enabled

Features: - Iframe Rendering \u2014 Isolated HTML preview - Sample Data Form \u2014 Edit sample variable values - Desktop/Mobile Toggle \u2014 Preview in different viewport sizes - Debounced Updates \u2014 Renders 300ms after typing stops - Error Display \u2014 Shows Handlebars compilation errors

Implementation:

import Handlebars from 'handlebars';\n\nconst [previewHtml, setPreviewHtml] = useState('');\nconst [sampleData, setSampleData] = useState<Record<string, unknown>>({});\nconst previewRef = useRef<HTMLIFrameElement>(null);\n\nconst renderPreview = useCallback((html: string, data: Record<string, unknown>) => {\n  try {\n    const compiled = Handlebars.compile(html);\n    const rendered = compiled(data);\n\n    // Inject into iframe\n    if (previewRef.current?.contentDocument) {\n      const doc = previewRef.current.contentDocument;\n      doc.open();\n      doc.write(`\n        <!DOCTYPE html>\n        <html>\n          <head>\n            <meta charset=\"UTF-8\">\n            <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n            <style>\n              body { font-family: Arial, sans-serif; padding: 20px; }\n            </style>\n          </head>\n          <body>${rendered}</body>\n        </html>\n      `);\n      doc.close();\n    }\n\n    setPreviewHtml(rendered);\n  } catch (error) {\n    console.error('Preview render error:', error);\n    setPreviewError(error.message);\n  }\n}, []);\n\nconst debouncedPreview = useMemo(\n  () => debounce(renderPreview, 300),\n  [renderPreview]\n);\n\n// Update preview when HTML or sample data changes\nuseEffect(() => {\n  debouncedPreview(htmlContent, sampleData);\n}, [htmlContent, sampleData, debouncedPreview]);\n

Sample Data Form:

const handleSampleDataChange = (variableKey: string, value: unknown) => {\n  setSampleData((prev) => ({\n    ...prev,\n    [variableKey]: value,\n  }));\n};\n\n// Render form\n<Space direction=\"vertical\" style={{ width: '100%', marginBottom: 16 }}>\n  <Title level={5}>Sample Data</Title>\n  {variables.map((variable) => (\n    <Form.Item key={variable.id} label={variable.label}>\n      <Input\n        value={sampleData[variable.key] as string || ''}\n        onChange={(e) => handleSampleDataChange(variable.key, e.target.value)}\n        placeholder={variable.sampleValue || ''}\n      />\n    </Form.Item>\n  ))}\n</Space>\n

"},{"location":"v2/features/email-templates/editor/#test-send-form","title":"Test Send Form","text":"

Location: Modal dialog

Features: - Recipient Email Input \u2014 Where to send test email - Sample Data Editor \u2014 JSON editor or form fields - Send Button \u2014 Triggers test send API call - Success/Failure Notification \u2014 Shows send result - Test Log Link \u2014 Link to test send history

Implementation:

const [testModalVisible, setTestModalVisible] = useState(false);\nconst [testRecipient, setTestRecipient] = useState('');\nconst [testData, setTestData] = useState<Record<string, unknown>>({});\n\nconst handleTestSend = async () => {\n  if (!testRecipient) {\n    message.error('Please enter recipient email');\n    return;\n  }\n\n  setTestSending(true);\n  try {\n    await api.post(`/api/email-templates/${id}/test`, {\n      recipientEmail: testRecipient,\n      testData,\n    });\n\n    message.success('Test email sent successfully');\n    setTestModalVisible(false);\n  } catch (error) {\n    message.error('Failed to send test email');\n  } finally {\n    setTestSending(false);\n  }\n};\n\n// Modal UI\n<Modal\n  title=\"Send Test Email\"\n  visible={testModalVisible}\n  onOk={handleTestSend}\n  onCancel={() => setTestModalVisible(false)}\n  confirmLoading={testSending}\n  okText=\"Send Test\"\n>\n  <Form layout=\"vertical\">\n    <Form.Item label=\"Recipient Email\" required>\n      <Input\n        type=\"email\"\n        value={testRecipient}\n        onChange={(e) => setTestRecipient(e.target.value)}\n        placeholder=\"your-email@example.com\"\n      />\n    </Form.Item>\n\n    <Form.Item label=\"Sample Data\">\n      <Space direction=\"vertical\" style={{ width: '100%' }}>\n        {variables.map((variable) => (\n          <Input\n            key={variable.id}\n            addonBefore={variable.label}\n            value={testData[variable.key] as string || ''}\n            onChange={(e) =>\n              setTestData((prev) => ({ ...prev, [variable.key]: e.target.value }))\n            }\n            placeholder={variable.sampleValue || ''}\n          />\n        ))}\n      </Space>\n    </Form.Item>\n  </Form>\n</Modal>\n

"},{"location":"v2/features/email-templates/editor/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/email-templates/editor/#opening-editor","title":"Opening Editor","text":"

From EmailTemplatesPage:

  1. Click template row in table
  2. Opens template detail modal
  3. Click \"Edit\" button in modal
  4. Opens EmailTemplateEditorPage in same tab

Direct URL:

/app/email-templates/{id}/edit\n

Route Definition:

// admin/src/App.tsx\n\n<Route\n  path=\"/app/email-templates/:id/edit\"\n  element={\n    <ProtectedRoute allowedRoles={[SUPER_ADMIN]}>\n      <EmailTemplateEditorPage />\n    </ProtectedRoute>\n  }\n/>\n

"},{"location":"v2/features/email-templates/editor/#editing-html-content","title":"Editing HTML Content","text":"

Step 1: Load Template - Template data fetched via API on component mount - HTML/text content populated in editors - Variables loaded in insertion panel

Step 2: Edit HTML - Type HTML with {{VARIABLES}} placeholders - Use variable insertion buttons for convenience - Preview updates automatically (300ms debounce)

Step 3: Insert Variables - Click variable \"Insert to HTML\" button - {{VARIABLE_KEY}} inserted at cursor position - Cursor moves after inserted variable

Step 4: Preview Changes - Live preview pane shows rendered HTML - Edit sample data to test different values - Check for formatting issues

Example Editing Session:

<!-- Initial HTML -->\n<p>Dear {{USER_NAME}},</p>\n<p>Thank you for signing up.</p>\n\n<!-- Add shift details -->\n<p>Dear {{USER_NAME}},</p>\n<p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong>.</p>\n<ul>\n  <li>Date: {{SHIFT_DATE}}</li>\n  <li>Time: {{SHIFT_TIME}}</li>\n</ul>\n\n<!-- Add conditional phone -->\n<p>Dear {{USER_NAME}},</p>\n<p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong>.</p>\n<ul>\n  <li>Date: {{SHIFT_DATE}}</li>\n  <li>Time: {{SHIFT_TIME}}</li>\n</ul>\n\n{{#if HAS_PHONE}}\n<p>We'll call you at {{USER_PHONE}} if there are any changes.</p>\n{{/if}}\n

"},{"location":"v2/features/email-templates/editor/#using-variable-insertion","title":"Using Variable Insertion","text":"

Keyboard Method: 1. Type {{ in HTML editor 2. Type variable name (e.g., USER_NAME) 3. Type }}

Button Method: 1. Place cursor where you want variable 2. Click variable \"Insert to HTML\" button 3. {{VARIABLE_KEY}} inserted at cursor 4. Cursor moves to end of insertion

Insertion Logic:

const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {\n  const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;\n  if (!textarea) return;\n\n  const start = textarea.selectionStart;\n  const end = textarea.selectionEnd;\n  const content = editorType === 'html' ? htmlContent : textContent;\n\n  // Replace selection with variable\n  const before = content.substring(0, start);\n  const after = content.substring(end);\n  const variable = `{{${variableKey}}}`;\n  const newContent = before + variable + after;\n\n  // Update state\n  if (editorType === 'html') {\n    setHtmlContent(newContent);\n  } else {\n    setTextContent(newContent);\n  }\n\n  // Move cursor to end of inserted variable\n  setTimeout(() => {\n    const newPos = start + variable.length;\n    textarea.selectionStart = newPos;\n    textarea.selectionEnd = newPos;\n    textarea.focus();\n  }, 0);\n};\n

"},{"location":"v2/features/email-templates/editor/#live-preview","title":"Live Preview","text":"

Preview Update Flow:

  1. Type in HTML Editor
  2. onChange event fires
  3. Updates htmlContent state
  4. Triggers debounced preview render (300ms)

  5. Debounced Render

  6. Waits 300ms after typing stops
  7. Compiles Handlebars template
  8. Interpolates with sample data
  9. Injects HTML into iframe

  10. Sample Data Changes

  11. Edit sample data form fields
  12. Updates sampleData state
  13. Immediately triggers preview render (no debounce)

Preview Error Handling:

const renderPreview = (html: string, data: Record<string, unknown>) => {\n  try {\n    const compiled = Handlebars.compile(html);\n    const rendered = compiled(data);\n\n    // Inject into iframe...\n    setPreviewError(null);\n  } catch (error) {\n    // Show error in preview pane\n    setPreviewError(error.message);\n\n    if (previewRef.current?.contentDocument) {\n      const doc = previewRef.current.contentDocument;\n      doc.open();\n      doc.write(`\n        <div style=\"color: red; padding: 20px;\">\n          <h3>Preview Error</h3>\n          <pre>${error.message}</pre>\n        </div>\n      `);\n      doc.close();\n    }\n  }\n};\n

"},{"location":"v2/features/email-templates/editor/#testing-template","title":"Testing Template","text":"

Step 1: Click \"Send Test\" Button - Opens test send modal

Step 2: Enter Recipient Email - Your email address (or test account) - Validates email format before sending

Step 3: Edit Sample Data - Pre-filled with variable sample values - Modify to test specific scenarios - Example: Set HAS_PHONE to false to test conditional block

Step 4: Click \"Send Test\" - POST request to /api/email-templates/:id/test - Email sent via SMTP (or MailHog in test mode) - Success notification displayed

Step 5: Check Email - Open email client (or MailHog at http://localhost:8025) - Verify rendering, variables, formatting - Test links, images, layout

Step 6: Review Test Log - Navigate to \"Test Logs\" tab in template detail modal - See test send history (recipient, timestamp, success/failure) - Debug errors if send failed

"},{"location":"v2/features/email-templates/editor/#saving-changes","title":"Saving Changes","text":"

Step 1: Click \"Save\" Button - Toolbar save button (or Ctrl+S keyboard shortcut)

Step 2: Enter Change Notes - Modal prompts for change description - Used for version history audit trail - Optional but recommended

Step 3: Confirm Save - PUT request to /api/email-templates/:id - Creates new version automatically - Clears localStorage draft - Redirects to EmailTemplatesPage

Save Implementation:

const [saveModalVisible, setSaveModalVisible] = useState(false);\nconst [changeNotes, setChangeNotes] = useState('');\n\nconst handleSave = async () => {\n  setSaving(true);\n  try {\n    await api.put(`/api/email-templates/${id}`, {\n      subjectLine,\n      htmlContent,\n      textContent,\n      changeNotes: changeNotes || undefined,\n    });\n\n    message.success('Template saved successfully');\n\n    // Clear draft\n    localStorage.removeItem(`email-template-draft-${id}`);\n\n    // Redirect\n    navigate('/app/email-templates');\n  } catch (error) {\n    message.error('Failed to save template');\n  } finally {\n    setSaving(false);\n    setSaveModalVisible(false);\n  }\n};\n\n// Keyboard shortcut\nuseEffect(() => {\n  const handleKeyDown = (e: KeyboardEvent) => {\n    if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n      e.preventDefault();\n      setSaveModalVisible(true);\n    }\n  };\n\n  window.addEventListener('keydown', handleKeyDown);\n  return () => window.removeEventListener('keydown', handleKeyDown);\n}, []);\n

"},{"location":"v2/features/email-templates/editor/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/email-templates/editor/#emailtemplateeditorpage-component","title":"EmailTemplateEditorPage Component","text":"

Full Component Structure:

// admin/src/pages/EmailTemplateEditorPage.tsx\n\nimport React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';\nimport { useParams, useNavigate } from 'react-router-dom';\nimport { Button, Input, Space, Card, Tag, Typography, Modal, Form, message } from 'antd';\nimport { SaveOutlined, SendOutlined, ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';\nimport Handlebars from 'handlebars';\nimport { debounce } from 'lodash';\nimport { api } from '@/lib/api';\nimport type { EmailTemplate, EmailTemplateVariable } from '@/types/api';\n\nconst { Title, Text } = Typography;\nconst { TextArea } = Input;\n\nexport default function EmailTemplateEditorPage() {\n  const { id } = useParams<{ id: string }>();\n  const navigate = useNavigate();\n\n  // State\n  const [loading, setLoading] = useState(true);\n  const [saving, setSaving] = useState(false);\n  const [template, setTemplate] = useState<EmailTemplate | null>(null);\n  const [variables, setVariables] = useState<EmailTemplateVariable[]>([]);\n\n  const [subjectLine, setSubjectLine] = useState('');\n  const [htmlContent, setHtmlContent] = useState('');\n  const [textContent, setTextContent] = useState('');\n\n  const [showPreview, setShowPreview] = useState(true);\n  const [sampleData, setSampleData] = useState<Record<string, unknown>>({});\n  const [previewError, setPreviewError] = useState<string | null>(null);\n\n  const [testModalVisible, setTestModalVisible] = useState(false);\n  const [testRecipient, setTestRecipient] = useState('');\n  const [testSending, setTestSending] = useState(false);\n\n  const [saveModalVisible, setSaveModalVisible] = useState(false);\n  const [changeNotes, setChangeNotes] = useState('');\n\n  // Refs\n  const htmlEditorRef = useRef<HTMLTextAreaElement>(null);\n  const textEditorRef = useRef<HTMLTextAreaElement>(null);\n  const previewRef = useRef<HTMLIFrameElement>(null);\n\n  // Load template\n  useEffect(() => {\n    const loadTemplate = async () => {\n      try {\n        const response = await api.get(`/api/email-templates/${id}`);\n        const { template: tmpl, variables: vars } = response.data;\n\n        setTemplate(tmpl);\n        setVariables(vars);\n\n        setSubjectLine(tmpl.subjectLine);\n        setHtmlContent(tmpl.htmlContent);\n        setTextContent(tmpl.textContent);\n\n        // Initialize sample data from variable sample values\n        const initialSampleData: Record<string, unknown> = {};\n        vars.forEach((v: EmailTemplateVariable) => {\n          if (v.sampleValue) {\n            initialSampleData[v.key] = v.sampleValue;\n          }\n        });\n        setSampleData(initialSampleData);\n\n        // Restore draft if exists\n        const draft = localStorage.getItem(`email-template-draft-${id}`);\n        if (draft) {\n          const { subjectLine: draftSubject, htmlContent: draftHtml, textContent: draftText } = JSON.parse(draft);\n          setSubjectLine(draftSubject);\n          setHtmlContent(draftHtml);\n          setTextContent(draftText);\n          message.info('Restored unsaved changes from draft');\n        }\n\n        setLoading(false);\n      } catch (error) {\n        message.error('Failed to load template');\n        navigate('/app/email-templates');\n      }\n    };\n\n    loadTemplate();\n  }, [id, navigate]);\n\n  // Auto-save draft to localStorage\n  useEffect(() => {\n    if (!loading && template) {\n      const draft = {\n        subjectLine,\n        htmlContent,\n        textContent,\n      };\n      localStorage.setItem(`email-template-draft-${id}`, JSON.stringify(draft));\n    }\n  }, [subjectLine, htmlContent, textContent, loading, template, id]);\n\n  // Preview rendering\n  const renderPreview = useCallback((html: string, data: Record<string, unknown>) => {\n    try {\n      const compiled = Handlebars.compile(html);\n      const rendered = compiled(data);\n\n      if (previewRef.current?.contentDocument) {\n        const doc = previewRef.current.contentDocument;\n        doc.open();\n        doc.write(`\n          <!DOCTYPE html>\n          <html>\n            <head>\n              <meta charset=\"UTF-8\">\n              <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n              <style>\n                body {\n                  font-family: Arial, sans-serif;\n                  padding: 20px;\n                  line-height: 1.6;\n                }\n              </style>\n            </head>\n            <body>${rendered}</body>\n          </html>\n        `);\n        doc.close();\n      }\n\n      setPreviewError(null);\n    } catch (error: any) {\n      setPreviewError(error.message);\n\n      if (previewRef.current?.contentDocument) {\n        const doc = previewRef.current.contentDocument;\n        doc.open();\n        doc.write(`\n          <div style=\"color: red; padding: 20px;\">\n            <h3>Preview Error</h3>\n            <pre>${error.message}</pre>\n          </div>\n        `);\n        doc.close();\n      }\n    }\n  }, []);\n\n  // Debounced preview\n  const debouncedPreview = useMemo(\n    () => debounce(renderPreview, 300),\n    [renderPreview]\n  );\n\n  // Update preview when HTML or sample data changes\n  useEffect(() => {\n    if (showPreview) {\n      debouncedPreview(htmlContent, sampleData);\n    }\n  }, [htmlContent, sampleData, showPreview, debouncedPreview]);\n\n  // Variable insertion\n  const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {\n    const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;\n    if (!textarea) return;\n\n    const start = textarea.selectionStart;\n    const end = textarea.selectionEnd;\n    const content = editorType === 'html' ? htmlContent : textContent;\n\n    const before = content.substring(0, start);\n    const after = content.substring(end);\n    const variable = `{{${variableKey}}}`;\n    const newContent = before + variable + after;\n\n    if (editorType === 'html') {\n      setHtmlContent(newContent);\n    } else {\n      setTextContent(newContent);\n    }\n\n    setTimeout(() => {\n      const newPos = start + variable.length;\n      textarea.selectionStart = newPos;\n      textarea.selectionEnd = newPos;\n      textarea.focus();\n    }, 0);\n  };\n\n  // Save template\n  const handleSave = async () => {\n    setSaving(true);\n    try {\n      await api.put(`/api/email-templates/${id}`, {\n        subjectLine,\n        htmlContent,\n        textContent,\n        changeNotes: changeNotes || undefined,\n      });\n\n      message.success('Template saved successfully');\n      localStorage.removeItem(`email-template-draft-${id}`);\n      navigate('/app/email-templates');\n    } catch (error) {\n      message.error('Failed to save template');\n    } finally {\n      setSaving(false);\n      setSaveModalVisible(false);\n    }\n  };\n\n  // Test send\n  const handleTestSend = async () => {\n    if (!testRecipient) {\n      message.error('Please enter recipient email');\n      return;\n    }\n\n    setTestSending(true);\n    try {\n      await api.post(`/api/email-templates/${id}/test`, {\n        recipientEmail: testRecipient,\n        testData: sampleData,\n      });\n\n      message.success('Test email sent successfully');\n      setTestModalVisible(false);\n    } catch (error) {\n      message.error('Failed to send test email');\n    } finally {\n      setTestSending(false);\n    }\n  };\n\n  // Keyboard shortcuts\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n        e.preventDefault();\n        setSaveModalVisible(true);\n      }\n      if ((e.ctrlKey || e.metaKey) && e.key === 'p') {\n        e.preventDefault();\n        setShowPreview(!showPreview);\n      }\n    };\n\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [showPreview]);\n\n  if (loading) {\n    return <div style={{ padding: 24 }}>Loading...</div>;\n  }\n\n  return (\n    <div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>\n      {/* Toolbar */}\n      <div\n        style={{\n          padding: '12px 24px',\n          borderBottom: '1px solid #f0f0f0',\n          display: 'flex',\n          justifyContent: 'space-between',\n          alignItems: 'center',\n        }}\n      >\n        <Space>\n          <Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/app/email-templates')}>\n            Back\n          </Button>\n          <Title level={4} style={{ margin: 0 }}>\n            {template?.name}\n          </Title>\n        </Space>\n\n        <Space>\n          <Button icon={<EyeOutlined />} onClick={() => setShowPreview(!showPreview)}>\n            {showPreview ? 'Hide' : 'Show'} Preview\n          </Button>\n          <Button icon={<SendOutlined />} onClick={() => setTestModalVisible(true)}>\n            Send Test\n          </Button>\n          <Button type=\"primary\" icon={<SaveOutlined />} onClick={() => setSaveModalVisible(true)}>\n            Save\n          </Button>\n        </Space>\n      </div>\n\n      {/* Editor Area */}\n      <div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>\n        {/* Left: Editors */}\n        <div\n          style={{\n            flex: showPreview ? 1 : 2,\n            padding: 24,\n            overflowY: 'auto',\n            borderRight: '1px solid #f0f0f0',\n          }}\n        >\n          <Space direction=\"vertical\" style={{ width: '100%' }} size=\"large\">\n            {/* Subject Line */}\n            <div>\n              <Text strong>Subject Line</Text>\n              <Input\n                value={subjectLine}\n                onChange={(e) => setSubjectLine(e.target.value)}\n                placeholder=\"Enter subject line with {{VARIABLES}}\"\n              />\n            </div>\n\n            {/* HTML Editor */}\n            <div>\n              <Text strong>HTML Content</Text>\n              <TextArea\n                ref={htmlEditorRef}\n                value={htmlContent}\n                onChange={(e) => setHtmlContent(e.target.value)}\n                placeholder=\"Enter HTML content with {{VARIABLES}}\"\n                rows={20}\n                style={{ fontFamily: 'monospace', fontSize: 13 }}\n              />\n            </div>\n\n            {/* Text Editor */}\n            <div>\n              <Text strong>Plain Text Content</Text>\n              <TextArea\n                ref={textEditorRef}\n                value={textContent}\n                onChange={(e) => setTextContent(e.target.value)}\n                placeholder=\"Enter plain text version\"\n                rows={15}\n                style={{ fontFamily: 'monospace', fontSize: 13 }}\n              />\n            </div>\n          </Space>\n        </div>\n\n        {/* Right: Preview + Variables */}\n        {showPreview && (\n          <div style={{ flex: 1, padding: 24, overflowY: 'auto' }}>\n            <Space direction=\"vertical\" style={{ width: '100%' }} size=\"large\">\n              {/* Variables Panel */}\n              <Card title=\"Variables\" size=\"small\">\n                <Space direction=\"vertical\" style={{ width: '100%' }} size=\"small\">\n                  {variables\n                    .sort((a, b) => a.sortOrder - b.sortOrder)\n                    .map((variable) => (\n                      <Card key={variable.id} size=\"small\" style={{ marginBottom: 8 }}>\n                        <Space direction=\"vertical\" size={4} style={{ width: '100%' }}>\n                          <Space>\n                            <Text strong>{variable.label}</Text>\n                            {variable.isRequired && <Tag color=\"red\">Required</Tag>}\n                            {variable.isConditional && <Tag color=\"blue\">Conditional</Tag>}\n                          </Space>\n\n                          {variable.description && (\n                            <Text type=\"secondary\" style={{ fontSize: 12 }}>\n                              {variable.description}\n                            </Text>\n                          )}\n\n                          <Space size=\"small\">\n                            <Button size=\"small\" onClick={() => handleInsertVariable(variable.key, 'html')}>\n                              Insert to HTML\n                            </Button>\n                            <Button size=\"small\" onClick={() => handleInsertVariable(variable.key, 'text')}>\n                              Insert to Text\n                            </Button>\n                          </Space>\n                        </Space>\n                      </Card>\n                    ))}\n                </Space>\n              </Card>\n\n              {/* Preview */}\n              <Card title=\"Live Preview\" size=\"small\">\n                {previewError && (\n                  <div style={{ color: 'red', marginBottom: 12 }}>\n                    <Text strong>Error:</Text> {previewError}\n                  </div>\n                )}\n\n                <iframe\n                  ref={previewRef}\n                  style={{\n                    width: '100%',\n                    height: 600,\n                    border: '1px solid #d9d9d9',\n                    borderRadius: 4,\n                  }}\n                  title=\"Email Preview\"\n                />\n              </Card>\n            </Space>\n          </div>\n        )}\n      </div>\n\n      {/* Save Modal */}\n      <Modal\n        title=\"Save Template\"\n        visible={saveModalVisible}\n        onOk={handleSave}\n        onCancel={() => setSaveModalVisible(false)}\n        confirmLoading={saving}\n        okText=\"Save\"\n      >\n        <Form layout=\"vertical\">\n          <Form.Item label=\"Change Notes (optional)\">\n            <TextArea\n              value={changeNotes}\n              onChange={(e) => setChangeNotes(e.target.value)}\n              placeholder=\"Describe what changed in this version\"\n              rows={4}\n            />\n          </Form.Item>\n        </Form>\n      </Modal>\n\n      {/* Test Send Modal */}\n      <Modal\n        title=\"Send Test Email\"\n        visible={testModalVisible}\n        onOk={handleTestSend}\n        onCancel={() => setTestModalVisible(false)}\n        confirmLoading={testSending}\n        okText=\"Send Test\"\n      >\n        <Form layout=\"vertical\">\n          <Form.Item label=\"Recipient Email\" required>\n            <Input\n              type=\"email\"\n              value={testRecipient}\n              onChange={(e) => setTestRecipient(e.target.value)}\n              placeholder=\"your-email@example.com\"\n            />\n          </Form.Item>\n\n          <Form.Item label=\"Sample Data\">\n            <Text type=\"secondary\" style={{ display: 'block', marginBottom: 8 }}>\n              Using sample data from preview. Edit values in the preview panel to change test data.\n            </Text>\n            <pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4 }}>\n              {JSON.stringify(sampleData, null, 2)}\n            </pre>\n          </Form.Item>\n        </Form>\n      </Modal>\n    </div>\n  );\n}\n
"},{"location":"v2/features/email-templates/editor/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/email-templates/editor/#problem-preview-not-updating","title":"Problem: Preview not updating","text":"

Symptoms: - Type in HTML editor but preview doesn't change - Preview shows old content

Causes: 1. Debounce timer still running (300ms delay) 2. Handlebars compilation error (silent failure) 3. Iframe not re-rendering

Solutions:

Wait for debounce: - Wait 300ms after typing stops - Preview should update automatically

Check browser console:

// Look for errors\nHandlebars.compile error: ...\n

Force preview update:

// Add button to manually trigger preview\n<Button onClick={() => renderPreview(htmlContent, sampleData)}>\n  Refresh Preview\n</Button>\n

Check iframe contentDocument:

console.log('Iframe doc:', previewRef.current?.contentDocument);\n// Should not be null\n

"},{"location":"v2/features/email-templates/editor/#problem-test-send-fails","title":"Problem: Test send fails","text":"

Symptoms: - \"Failed to send test email\" error - Email not received in inbox or MailHog

Causes: 1. SMTP configuration incorrect 2. Email test mode disabled (sending to real SMTP) 3. Recipient email invalid 4. Template has compilation errors

Solutions:

Check SMTP settings:

# .env\nEMAIL_TEST_MODE=true  # Use MailHog\n

Verify MailHog running:

docker compose ps mailhog\n# Should show \"Up\"\n

Check test logs:

SELECT * FROM email_template_test_logs\nWHERE template_id = 'xxx'\nORDER BY created_at DESC\nLIMIT 5;\n\n-- Look at error_message column\n

Test with minimal template:

<p>Hello {{USER_NAME}}</p>\n

Validate email address:

import validator from 'validator';\n\nif (!validator.isEmail(testRecipient)) {\n  message.error('Invalid email address');\n  return;\n}\n

"},{"location":"v2/features/email-templates/editor/#problem-variable-insertion-doesnt-work","title":"Problem: Variable insertion doesn't work","text":"

Symptoms: - Click \"Insert to HTML\" button but nothing happens - Variable inserted in wrong location

Causes: 1. Textarea ref not set 2. Cursor position not captured correctly 3. State update timing issue

Solutions:

Check ref exists:

console.log('HTML ref:', htmlEditorRef.current);\n// Should be <textarea> element\n

Debug cursor position:

const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {\n  const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;\n  console.log('Cursor position:', textarea?.selectionStart, textarea?.selectionEnd);\n\n  // Rest of insertion logic...\n};\n

Manual workaround: - Type {{VARIABLE_KEY}} manually instead of using button

"},{"location":"v2/features/email-templates/editor/#problem-draft-not-restored-on-reload","title":"Problem: Draft not restored on reload","text":"

Symptoms: - Unsaved changes lost after browser refresh - No \"Restored draft\" message

Causes: 1. localStorage not available (private browsing) 2. Draft key mismatch 3. localStorage quota exceeded

Solutions:

Check localStorage:

// Browser console\nlocalStorage.getItem('email-template-draft-cuid123');\n// Should return JSON string\n

Verify draft key:

console.log('Draft key:', `email-template-draft-${id}`);\n

Clear old drafts:

// Browser console\nfor (let i = 0; i < localStorage.length; i++) {\n  const key = localStorage.key(i);\n  if (key?.startsWith('email-template-draft-')) {\n    localStorage.removeItem(key);\n  }\n}\n

"},{"location":"v2/features/email-templates/editor/#future-enhancements","title":"Future Enhancements","text":""},{"location":"v2/features/email-templates/editor/#monaco-editor-integration","title":"Monaco Editor Integration","text":"

Current: Basic HTML textarea Future: Monaco Editor with syntax highlighting, IntelliSense, error detection

Benefits: - Syntax highlighting for HTML - Auto-completion for HTML tags and Handlebars syntax - Error squiggles for invalid HTML - Multi-cursor editing - Code folding

Implementation:

import Editor from '@monaco-editor/react';\n\n<Editor\n  height=\"600px\"\n  language=\"html\"\n  value={htmlContent}\n  onChange={(value) => setHtmlContent(value || '')}\n  options={{\n    minimap: { enabled: false },\n    lineNumbers: 'on',\n    wordWrap: 'on',\n  }}\n/>\n

"},{"location":"v2/features/email-templates/editor/#drag-drop-block-builder","title":"Drag-Drop Block Builder","text":"

Current: Manual HTML editing Future: Visual block builder (like GrapesJS)

Benefits: - No HTML knowledge required - Pre-built email blocks (header, footer, CTA button) - Drag-drop interface - Mobile-responsive by default

Implementation: - Use GrapesJS (same as landing page editor) - Custom blocks for email-safe components - Export to HTML for template storage

"},{"location":"v2/features/email-templates/editor/#email-client-previews","title":"Email Client Previews","text":"

Current: Single iframe preview Future: Multi-client previews (Gmail, Outlook, Apple Mail)

Benefits: - Test rendering across email clients - Catch client-specific CSS issues - Preview dark mode rendering

Services: - Litmus API integration - Email on Acid screenshots - Self-hosted preview using email client CSS emulation

"},{"location":"v2/features/email-templates/editor/#ab-testing-support","title":"A/B Testing Support","text":"

Current: Single template version Future: A/B testing with variant templates

Features: - Create template variants (A, B, C) - Split traffic across variants - Track open rates, click rates - Auto-promote winning variant

Implementation: - EmailTemplateVariant model (templateId, variantName, weight, stats) - Random variant selection on send - Tracking pixel in email HTML - Analytics dashboard

"},{"location":"v2/features/email-templates/editor/#performance","title":"Performance","text":""},{"location":"v2/features/email-templates/editor/#auto-save-timing","title":"Auto-Save Timing","text":"

Current Implementation: - Save to localStorage on blur (when focus leaves editor) - No automatic interval-based saves

Performance Impact: - Negligible (localStorage write is < 1ms) - No network requests (local only)

Alternative: Interval-Based Auto-Save:

useEffect(() => {\n  const interval = setInterval(() => {\n    if (htmlContent || textContent) {\n      localStorage.setItem(`email-template-draft-${id}`, JSON.stringify({\n        subjectLine,\n        htmlContent,\n        textContent,\n        savedAt: new Date().toISOString(),\n      }));\n    }\n  }, 10000); // Every 10 seconds\n\n  return () => clearInterval(interval);\n}, [id, subjectLine, htmlContent, textContent]);\n

"},{"location":"v2/features/email-templates/editor/#preview-rendering-performance","title":"Preview Rendering Performance","text":"

Debounce Delay: - Current: 300ms - Too short: Preview updates too frequently (distracting) - Too long: Preview feels laggy

Handlebars Compilation: - Fast (< 1ms for typical templates) - May slow down for very large templates (> 100KB)

Iframe Rendering: - Browser-native rendering (very fast) - No performance concerns

Optimization for Large Templates:

// Skip preview for very large HTML\nconst renderPreview = (html: string, data: Record<string, unknown>) => {\n  if (html.length > 100000) { // 100KB\n    setPreviewError('Template too large for live preview. Use test send instead.');\n    return;\n  }\n\n  // Normal preview rendering...\n};\n

"},{"location":"v2/features/email-templates/editor/#accessibility","title":"Accessibility","text":""},{"location":"v2/features/email-templates/editor/#keyboard-shortcuts","title":"Keyboard Shortcuts","text":"

Implemented: - Ctrl+S (or Cmd+S on Mac) \u2014 Save template - Ctrl+P \u2014 Toggle preview pane - Esc \u2014 Close modal

Implementation:

useEffect(() => {\n  const handleKeyDown = (e: KeyboardEvent) => {\n    // Save\n    if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n      e.preventDefault();\n      setSaveModalVisible(true);\n    }\n\n    // Preview toggle\n    if ((e.ctrlKey || e.metaKey) && e.key === 'p') {\n      e.preventDefault();\n      setShowPreview(!showPreview);\n    }\n\n    // Close modal\n    if (e.key === 'Escape') {\n      setSaveModalVisible(false);\n      setTestModalVisible(false);\n    }\n  };\n\n  window.addEventListener('keydown', handleKeyDown);\n  return () => window.removeEventListener('keydown', handleKeyDown);\n}, [showPreview]);\n

"},{"location":"v2/features/email-templates/editor/#screen-reader-support","title":"Screen Reader Support","text":"

Form Labels:

<Form.Item label=\"Recipient Email\" required>\n  <Input\n    type=\"email\"\n    aria-label=\"Test email recipient address\"\n    aria-required=\"true\"\n    value={testRecipient}\n    onChange={(e) => setTestRecipient(e.target.value)}\n  />\n</Form.Item>\n

Button Descriptions:

<Button\n  icon={<SaveOutlined />}\n  onClick={() => setSaveModalVisible(true)}\n  aria-label=\"Save template and create new version\"\n>\n  Save\n</Button>\n\n<Button\n  size=\"small\"\n  onClick={() => handleInsertVariable(variable.key, 'html')}\n  aria-label={`Insert ${variable.label} variable into HTML editor`}\n>\n  Insert to HTML\n</Button>\n

"},{"location":"v2/features/email-templates/editor/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/email-templates/editor/#frontend-documentation","title":"Frontend Documentation","text":"
  • EmailTemplatesPage.tsx \u2014 Email templates list page
  • App.tsx \u2014 Route definition for editor page
"},{"location":"v2/features/email-templates/editor/#backend-documentation","title":"Backend Documentation","text":"
  • Email Templates Module \u2014 API routes
  • GET /api/email-templates/:id \u2014 Load template + variables
  • PUT /api/email-templates/:id \u2014 Update template (creates version)
  • POST /api/email-templates/:id/test \u2014 Send test email
"},{"location":"v2/features/email-templates/editor/#feature-documentation","title":"Feature Documentation","text":"
  • template-system.md \u2014 Email template engine overview
  • variables.md \u2014 Template variable system
  • versioning.md \u2014 Template version history
"},{"location":"v2/features/email-templates/editor/#related-features","title":"Related Features","text":"
  • Landing Page Editor \u2014 Similar GrapesJS editor for pages
  • Campaign Emails \u2014 Uses email templates for advocacy emails
"},{"location":"v2/features/email-templates/template-system/","title":"Email Template System","text":""},{"location":"v2/features/email-templates/template-system/#overview","title":"Overview","text":"

The Email Template System provides centralized management of all transactional and campaign emails sent by Changemaker Lite. It enables administrators to create, edit, and maintain email templates with variable interpolation, version control, and testing capabilities.

Key Features:

  • Centralized Management \u2014 All email templates stored in database, editable via admin GUI
  • Variable Interpolation \u2014 {{VAR}} syntax powered by Handlebars template engine
  • Three Categories \u2014 INFLUENCE (campaign emails), MAP (shift/canvass emails), SYSTEM (platform emails)
  • Dual Format Support \u2014 HTML + plain text versions for all templates
  • System Templates \u2014 Protected templates with deletion prevention for critical platform emails
  • Version Control \u2014 Automatic version history on every save with rollback capability
  • Test Send \u2014 Preview rendered emails before deploying to production
  • Variable Validation \u2014 Required vs optional variables with runtime validation

Use Cases:

  • Advocacy Campaigns \u2014 Custom email templates for representative outreach
  • Shift Notifications \u2014 Confirmation and reminder emails for volunteer shifts
  • User Onboarding \u2014 Welcome emails, verification emails, password resets
  • Response Moderation \u2014 Notification emails when responses are approved/rejected
  • Canvass Summaries \u2014 End-of-session reports sent to volunteers
"},{"location":"v2/features/email-templates/template-system/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph \"Email Service Layer\"\n        Service[EmailService<br/>email.service.ts]\n        Service --> Load[Load Template by Key]\n        Service --> Validate[Validate Required Variables]\n        Service --> Interpolate[Handlebars Interpolation]\n    end\n\n    subgraph \"Database Models\"\n        Template[(EmailTemplate)]\n        Variables[(EmailTemplateVariable)]\n        Versions[(EmailTemplateVersion)]\n        TestLogs[(EmailTemplateTestLog)]\n\n        Template -->|1:N| Variables\n        Template -->|1:N| Versions\n        Template -->|1:N| TestLogs\n    end\n\n    subgraph \"Output Channels\"\n        HTML[HTML Email]\n        Text[Plain Text Email]\n        Preview[Preview Rendering]\n    end\n\n    Load --> Template\n    Template --> Variables\n    Validate --> Variables\n\n    Interpolate --> HTML\n    Interpolate --> Text\n    Interpolate --> Preview\n\n    HTML --> SMTP[Nodemailer SMTP]\n    Text --> SMTP\n    Preview --> Admin[Admin GUI]\n\n    SMTP --> Sent[Email Sent]\n    Sent --> TestLogs\n\n    Service -.->|Test Mode| MailHog[MailHog<br/>Dev Capture]\n\n    style Service fill:#4a90e2,color:#fff\n    style Template fill:#50c878,color:#fff\n    style SMTP fill:#ff6b6b,color:#fff

Component Responsibilities:

  • EmailService \u2014 Core email sending logic with template loading and interpolation
  • EmailTemplate \u2014 Template metadata (key, name, category, content, active status)
  • EmailTemplateVariable \u2014 Variable definitions (key, label, required/optional, sample values)
  • EmailTemplateVersion \u2014 Version history snapshots with change notes
  • EmailTemplateTestLog \u2014 Test send audit trail with success/failure logging
  • Handlebars Engine \u2014 Template compilation and variable interpolation
  • Nodemailer \u2014 SMTP transport for production email delivery
  • MailHog \u2014 Development email capture (when EMAIL_TEST_MODE=true)
"},{"location":"v2/features/email-templates/template-system/#database-models","title":"Database Models","text":""},{"location":"v2/features/email-templates/template-system/#emailtemplate","title":"EmailTemplate","text":"

Core template storage with metadata and content.

Field Type Description id String (CUID) Primary key key String (unique) Programmatic identifier (e.g., \"shift-signup-confirmation\") name String Display name for admin GUI description String (optional) Template purpose and usage notes category Enum INFLUENCE, MAP, or SYSTEM subjectLine String Email subject (supports {{VARIABLES}}) htmlContent Text HTML email body with Handlebars syntax textContent Text Plain text fallback version isSystem Boolean If true, cannot be deleted (critical platform emails) isActive Boolean If false, template is disabled and won't send createdAt DateTime Creation timestamp updatedAt DateTime Last modification timestamp createdByUserId String (optional) User who created template updatedByUserId String (optional) User who last modified template

Relations: - variables \u2014 EmailTemplateVariable[] (1:N) - versions \u2014 EmailTemplateVersion[] (1:N) - testLogs \u2014 EmailTemplateTestLog[] (1:N)

Indexes: - Unique index on key for fast lookups - Index on category for filtered queries - Index on isActive for production template queries

"},{"location":"v2/features/email-templates/template-system/#emailtemplatevariable","title":"EmailTemplateVariable","text":"

Variable definitions for template interpolation.

Field Type Description id String (CUID) Primary key templateId String Foreign key to EmailTemplate key String Variable name (e.g., \"USER_NAME\") label String Display label for admin GUI description String (optional) Variable purpose and usage notes isRequired Boolean If true, must be provided in data object isConditional Boolean If true, used in {{#if}} blocks (truthy/falsy) sampleValue String (optional) Example value for testing and preview sortOrder Int Display order in editor variable panel createdAt DateTime Creation timestamp

Relations: - template \u2014 EmailTemplate (N:1)

Constraints: - Unique index on (templateId, key) to prevent duplicate variables

"},{"location":"v2/features/email-templates/template-system/#emailtemplateversion","title":"EmailTemplateVersion","text":"

Version history snapshots for audit trail and rollback.

Field Type Description id String (CUID) Primary key templateId String Foreign key to EmailTemplate versionNumber Int Auto-incremented version number (1, 2, 3...) subjectLine String Subject at time of version htmlContent Text HTML content snapshot textContent Text Plain text content snapshot changeNotes String (optional) Admin-provided change description createdByUserId String (optional) User who created this version createdAt DateTime Version creation timestamp

Relations: - template \u2014 EmailTemplate (N:1) - createdBy \u2014 User (N:1)

Constraints: - Unique index on (templateId, versionNumber) for version lookup - Auto-increment logic in service layer (finds max + 1)

"},{"location":"v2/features/email-templates/template-system/#emailtemplatetestlog","title":"EmailTemplateTestLog","text":"

Test send audit trail for debugging and compliance.

Field Type Description id String (CUID) Primary key templateId String Foreign key to EmailTemplate recipientEmail String Email address test was sent to testData JSON Sample variable data used for interpolation success Boolean Whether send succeeded errorMessage String (optional) Error details if send failed messageId String (optional) SMTP message ID if send succeeded sentByUserId String (optional) User who triggered test send createdAt DateTime Test send timestamp

Relations: - template \u2014 EmailTemplate (N:1) - sentBy \u2014 User (N:1)

Indexes: - Index on templateId for template-specific test history - Index on createdAt for chronological queries

"},{"location":"v2/features/email-templates/template-system/#template-categories","title":"Template Categories","text":""},{"location":"v2/features/email-templates/template-system/#influence-category","title":"INFLUENCE Category","text":"

Purpose: Advocacy campaign emails sent to representatives or response notifications to participants.

System Templates:

Key Name Description campaign-email Campaign Email to Representative Main advocacy email template sent on behalf of participants response-verification Response Verification Email Email asking participants to verify their response submission response-approved Response Approval Notification Email notifying participant their response is published on wall response-rejected Response Rejection Notification Email notifying participant their response was rejected (with reason)

Common Variables: - USER_NAME \u2014 Participant's full name - USER_EMAIL \u2014 Participant's email address - CAMPAIGN_TITLE \u2014 Campaign name - CAMPAIGN_SLUG \u2014 URL-safe campaign identifier - REPRESENTATIVE_NAME \u2014 Representative's full name - REPRESENTATIVE_EMAIL \u2014 Representative's email address - REPRESENTATIVE_TITLE \u2014 Representative's title (e.g., \"MP for...\") - CUSTOM_MESSAGE \u2014 Participant's custom message to representative - RESPONSE_TEXT \u2014 Participant's response wall submission - VERIFICATION_LINK \u2014 Unique verification URL - ADMIN_NOTES \u2014 Moderator's rejection reason

"},{"location":"v2/features/email-templates/template-system/#map-category","title":"MAP Category","text":"

Purpose: Location-based emails for volunteer shifts, canvassing sessions, and shift management.

System Templates:

Key Name Description shift-signup-confirmation Shift Signup Confirmation Email confirming volunteer's shift registration shift-reminder Shift Reminder Email sent 24 hours before shift starts shift-cancellation Shift Cancellation Notice Email notifying volunteer of shift cancellation canvass-session-summary Canvass Session Summary End-of-session report with visit statistics

Common Variables: - USER_NAME \u2014 Volunteer's full name - USER_EMAIL \u2014 Volunteer's email address - USER_PHONE \u2014 Volunteer's phone number (optional) - SHIFT_TITLE \u2014 Shift name - SHIFT_DATE \u2014 Shift date (formatted) - SHIFT_TIME \u2014 Shift time range (e.g., \"10:00 AM - 2:00 PM\") - SHIFT_LOCATION \u2014 Shift meeting location - CUT_NAME \u2014 Canvass area name - VISIT_COUNT \u2014 Number of doors knocked - CONTACT_COUNT \u2014 Number of successful contacts - SUPPORT_COUNT \u2014 Number of supporters identified - CANCELLATION_REASON \u2014 Why shift was cancelled

"},{"location":"v2/features/email-templates/template-system/#system-category","title":"SYSTEM Category","text":"

Purpose: Core platform emails for user management, authentication, and system notifications.

System Templates:

Key Name Description user-welcome Welcome Email Email sent to new user registrations password-reset Password Reset Email Email with password reset link email-verification Email Verification Email address verification for new accounts account-locked Account Locked Notice Security notification for locked accounts

Common Variables: - USER_NAME \u2014 User's full name - USER_EMAIL \u2014 User's email address - VERIFICATION_LINK \u2014 Unique verification URL (expires in 24h) - RESET_LINK \u2014 Unique password reset URL (expires in 1h) - SUPPORT_EMAIL \u2014 Platform support email address - SITE_NAME \u2014 Platform name (from SiteSettings) - SITE_URL \u2014 Platform base URL - LOGIN_URL \u2014 Direct link to login page - LOCKOUT_REASON \u2014 Why account was locked

"},{"location":"v2/features/email-templates/template-system/#variable-interpolation","title":"Variable Interpolation","text":"

The template system uses Handlebars for powerful variable interpolation with support for basic variables, conditional blocks, loops, and helpers.

"},{"location":"v2/features/email-templates/template-system/#basic-variables","title":"Basic Variables","text":"

Syntax: {{VARIABLE_NAME}}

Example Template:

<p>Dear {{USER_NAME}},</p>\n\n<p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong> on {{SHIFT_DATE}}.</p>\n\n<p>We'll see you at {{SHIFT_LOCATION}} at {{SHIFT_TIME}}.</p>\n\n<p>If you have any questions, email us at {{SUPPORT_EMAIL}}.</p>\n

Sample Data:

{\n  \"USER_NAME\": \"Jane Smith\",\n  \"SHIFT_TITLE\": \"Door Knocking - Downtown\",\n  \"SHIFT_DATE\": \"Saturday, March 15, 2026\",\n  \"SHIFT_LOCATION\": \"Campaign Office (123 Main St)\",\n  \"SHIFT_TIME\": \"10:00 AM - 2:00 PM\",\n  \"SUPPORT_EMAIL\": \"volunteer@example.org\"\n}\n

Rendered Output:

<p>Dear Jane Smith,</p>\n\n<p>Thank you for signing up for <strong>Door Knocking - Downtown</strong> on Saturday, March 15, 2026.</p>\n\n<p>We'll see you at Campaign Office (123 Main St) at 10:00 AM - 2:00 PM.</p>\n\n<p>If you have any questions, email us at volunteer@example.org.</p>\n

"},{"location":"v2/features/email-templates/template-system/#conditional-blocks","title":"Conditional Blocks","text":"

Syntax: {{#if CONDITION}} ... {{else}} ... {{/if}}

Example Template:

<p>Dear {{USER_NAME}},</p>\n\n<p>Your shift confirmation for {{SHIFT_TITLE}} is below.</p>\n\n{{#if HAS_PHONE}}\n<p><strong>We'll call you at {{USER_PHONE}} if there are any changes.</strong></p>\n{{else}}\n<p>We recommend adding a phone number to your profile for shift updates.</p>\n{{/if}}\n\n{{#if IS_CUT_ASSIGNED}}\n<p>You've been assigned to canvass <strong>{{CUT_NAME}}</strong>.</p>\n{{/if}}\n

Sample Data:

{\n  \"USER_NAME\": \"John Doe\",\n  \"SHIFT_TITLE\": \"Canvassing - North District\",\n  \"HAS_PHONE\": true,\n  \"USER_PHONE\": \"(555) 123-4567\",\n  \"IS_CUT_ASSIGNED\": true,\n  \"CUT_NAME\": \"North District - Zone A\"\n}\n

Rendered Output:

<p>Dear John Doe,</p>\n\n<p>Your shift confirmation for Canvassing - North District is below.</p>\n\n<p><strong>We'll call you at (555) 123-4567 if there are any changes.</strong></p>\n\n<p>You've been assigned to canvass <strong>North District - Zone A</strong>.</p>\n

Truthy/Falsy Values: - true, non-empty strings, non-zero numbers \u2192 truthy - false, null, undefined, 0, \"\" \u2192 falsy

"},{"location":"v2/features/email-templates/template-system/#loops-each-blocks","title":"Loops (Each Blocks)","text":"

Syntax: {{#each ARRAY}} ... {{/each}}

Example Template:

<p>Dear {{USER_NAME}},</p>\n\n<p>Your email will be sent to the following representatives:</p>\n\n<ul>\n{{#each REPRESENTATIVES}}\n  <li>\n    <strong>{{name}}</strong> ({{title}})<br>\n    Email: {{email}}\n  </li>\n{{/each}}\n</ul>\n\n<p>Your custom message:</p>\n<blockquote>{{CUSTOM_MESSAGE}}</blockquote>\n

Sample Data:

{\n  \"USER_NAME\": \"Alice Johnson\",\n  \"REPRESENTATIVES\": [\n    {\n      \"name\": \"Jane Doe\",\n      \"title\": \"MP for Downtown\",\n      \"email\": \"jane.doe@parliament.ca\"\n    },\n    {\n      \"name\": \"John Smith\",\n      \"title\": \"City Councillor Ward 3\",\n      \"email\": \"john.smith@city.ca\"\n    }\n  ],\n  \"CUSTOM_MESSAGE\": \"Please support Bill C-123 to address climate change.\"\n}\n

Rendered Output:

<p>Dear Alice Johnson,</p>\n\n<p>Your email will be sent to the following representatives:</p>\n\n<ul>\n  <li>\n    <strong>Jane Doe</strong> (MP for Downtown)<br>\n    Email: jane.doe@parliament.ca\n  </li>\n  <li>\n    <strong>John Smith</strong> (City Councillor Ward 3)<br>\n    Email: john.smith@city.ca\n  </li>\n</ul>\n\n<p>Your custom message:</p>\n<blockquote>Please support Bill C-123 to address climate change.</blockquote>\n

Loop Variables: - {{@index}} \u2014 0-based index - {{@first}} \u2014 true if first item - {{@last}} \u2014 true if last item

"},{"location":"v2/features/email-templates/template-system/#raw-html-unescaped","title":"Raw HTML (Unescaped)","text":"

Syntax: {{{VARIABLE_NAME}}} (triple braces)

By default, Handlebars escapes HTML to prevent XSS attacks. Use triple braces for trusted HTML content.

Example Template:

<p>Dear {{USER_NAME}},</p>\n\n<div class=\"message-content\">\n  {{{FORMATTED_MESSAGE}}}\n</div>\n

Sample Data:

{\n  \"USER_NAME\": \"Bob Wilson\",\n  \"FORMATTED_MESSAGE\": \"<p>This is <strong>bold</strong> and <em>italic</em> text.</p><ul><li>Item 1</li><li>Item 2</li></ul>\"\n}\n

Rendered Output:

<p>Dear Bob Wilson,</p>\n\n<div class=\"message-content\">\n  <p>This is <strong>bold</strong> and <em>italic</em> text.</p><ul><li>Item 1</li><li>Item 2</li></ul>\n</div>\n

Security Warning: Only use {{{...}}} for content generated by the application, never for user-submitted content without sanitization.

"},{"location":"v2/features/email-templates/template-system/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/email-templates/template-system/#viewing-templates","title":"Viewing Templates","text":"
  1. Navigate to Email Templates Page
  2. Admin sidebar \u2192 Email Templates
  3. Shows table with all templates grouped by category

  4. Filter and Search

  5. Filter by category (INFLUENCE, MAP, SYSTEM)
  6. Search by template name or key
  7. Toggle \"Show Inactive\" to view disabled templates

  8. Template Details

  9. Click template row to view details modal
  10. See subject line, category, active status, system flag
  11. View variable list with required/optional labels
  12. Access version history tab
  13. Access test send tab
"},{"location":"v2/features/email-templates/template-system/#creating-template","title":"Creating Template","text":"
  1. Click \"New Template\" Button
  2. Opens template creation modal

  3. Enter Template Metadata

  4. Key \u2014 Programmatic identifier (lowercase-with-dashes)
  5. Name \u2014 Display name for admin GUI
  6. Description \u2014 Template purpose and usage notes
  7. Category \u2014 Select INFLUENCE, MAP, or SYSTEM
  8. System Flag \u2014 Check if template is critical (prevents deletion)

  9. Define Variables

  10. Click \"Add Variable\" in variables section
  11. Enter variable key (UPPERCASE_WITH_UNDERSCORES)
  12. Enter label and description
  13. Toggle required/conditional flags
  14. Provide sample value for testing
  15. Set sort order (drag to reorder)

  16. Write Template Content

  17. Subject Line \u2014 Enter subject with optional {{VARIABLES}}
  18. HTML Content \u2014 Write HTML body with {{VARIABLES}}
  19. Text Content \u2014 Write plain text fallback

  20. Save Template

  21. Click \"Save\" to create template
  22. Creates version 1 automatically
  23. Template is active by default
"},{"location":"v2/features/email-templates/template-system/#editing-template","title":"Editing Template","text":"
  1. Open Template
  2. Email Templates page \u2192 click template
  3. Opens detail modal

  4. Click \"Edit\" Button

  5. Opens EmailTemplateEditorPage in new tab
  6. Shows split-pane editor (HTML + Text)

  7. Modify Content

  8. Edit subject line, HTML, or text content
  9. Use variable insertion buttons to add {{VARIABLES}}
  10. Preview rendered output with sample data

  11. Add Change Notes

  12. Enter description of changes in \"Change Notes\" field
  13. Used for version history audit trail

  14. Save Changes

  15. Click \"Save\" button
  16. Creates new version automatically
  17. Redirects to Email Templates page
"},{"location":"v2/features/email-templates/template-system/#testing-template","title":"Testing Template","text":"
  1. Open Template Detail Modal
  2. Click template from list

  3. Navigate to \"Test Send\" Tab

  4. Enter Test Parameters

  5. Recipient Email \u2014 Your email address for test
  6. Sample Data \u2014 JSON object with variable values
  7. Pre-filled with variable sample values

  8. Click \"Send Test Email\"

  9. Template is rendered with sample data
  10. Email sent via SMTP (or MailHog in test mode)
  11. Success/failure notification displayed

  12. Check Test Log

  13. View test send history in \"Test Logs\" tab
  14. See timestamp, recipient, success status, error messages
  15. Review sample data used for each test
"},{"location":"v2/features/email-templates/template-system/#activatingdeactivating-template","title":"Activating/Deactivating Template","text":"
  1. Open Template Detail Modal

  2. Toggle \"Active\" Switch

  3. When inactive, template won't send emails
  4. Useful for disabling seasonal templates or broken templates

  5. Confirm Action

  6. System templates require additional confirmation
  7. Deactivating system template may break critical platform functions
"},{"location":"v2/features/email-templates/template-system/#developer-workflow-adding-new-template","title":"Developer Workflow (Adding New Template)","text":""},{"location":"v2/features/email-templates/template-system/#step-1-define-template-key","title":"Step 1: Define Template Key","text":"

Choose a descriptive, unique key using lowercase with dashes:

Good Keys: - shift-signup-confirmation - canvass-session-summary - response-verification

Bad Keys: - template1 (not descriptive) - ShiftSignup (wrong case) - shift_signup (use dashes, not underscores)

"},{"location":"v2/features/email-templates/template-system/#step-2-create-template-via-seed-script","title":"Step 2: Create Template via Seed Script","text":"

Add to api/prisma/seed.ts:

await prisma.emailTemplate.upsert({\n  where: { key: 'shift-signup-confirmation' },\n  update: {},\n  create: {\n    key: 'shift-signup-confirmation',\n    name: 'Shift Signup Confirmation',\n    description: 'Email sent to volunteers when they sign up for a shift',\n    category: 'MAP',\n    isSystem: true,\n    isActive: true,\n    subjectLine: 'Confirmed: {{SHIFT_TITLE}} on {{SHIFT_DATE}}',\n    htmlContent: `\n      <p>Dear {{USER_NAME}},</p>\n\n      <p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong>!</p>\n\n      <p><strong>Details:</strong></p>\n      <ul>\n        <li><strong>Date:</strong> {{SHIFT_DATE}}</li>\n        <li><strong>Time:</strong> {{SHIFT_TIME}}</li>\n        <li><strong>Location:</strong> {{SHIFT_LOCATION}}</li>\n      </ul>\n\n      {{#if HAS_PHONE}}\n      <p>We'll call you at {{USER_PHONE}} if there are any changes.</p>\n      {{/if}}\n\n      <p>See you there!</p>\n    `,\n    textContent: `\nDear {{USER_NAME}},\n\nThank you for signing up for {{SHIFT_TITLE}}!\n\nDetails:\n- Date: {{SHIFT_DATE}}\n- Time: {{SHIFT_TIME}}\n- Location: {{SHIFT_LOCATION}}\n\n{{#if HAS_PHONE}}\nWe'll call you at {{USER_PHONE}} if there are any changes.\n{{/if}}\n\nSee you there!\n    `,\n  },\n});\n

Run Seed:

docker compose exec api npx prisma db seed\n

"},{"location":"v2/features/email-templates/template-system/#step-3-define-variables","title":"Step 3: Define Variables","text":"

Add variables in same seed script:

const template = await prisma.emailTemplate.findUnique({\n  where: { key: 'shift-signup-confirmation' },\n});\n\nconst variables = [\n  {\n    key: 'USER_NAME',\n    label: 'User Name',\n    description: 'Full name of the volunteer',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'John Doe',\n    sortOrder: 1,\n  },\n  {\n    key: 'SHIFT_TITLE',\n    label: 'Shift Title',\n    description: 'Name of the shift',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'Door Knocking - Downtown',\n    sortOrder: 2,\n  },\n  {\n    key: 'SHIFT_DATE',\n    label: 'Shift Date',\n    description: 'Formatted shift date',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'Saturday, March 15, 2026',\n    sortOrder: 3,\n  },\n  {\n    key: 'SHIFT_TIME',\n    label: 'Shift Time',\n    description: 'Shift time range',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: '10:00 AM - 2:00 PM',\n    sortOrder: 4,\n  },\n  {\n    key: 'SHIFT_LOCATION',\n    label: 'Shift Location',\n    description: 'Meeting location for shift',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'Campaign Office (123 Main St)',\n    sortOrder: 5,\n  },\n  {\n    key: 'HAS_PHONE',\n    label: 'Has Phone',\n    description: 'Whether user provided phone number',\n    isRequired: false,\n    isConditional: true,\n    sampleValue: 'true',\n    sortOrder: 6,\n  },\n  {\n    key: 'USER_PHONE',\n    label: 'User Phone',\n    description: 'User phone number (optional)',\n    isRequired: false,\n    isConditional: false,\n    sampleValue: '(555) 123-4567',\n    sortOrder: 7,\n  },\n];\n\nfor (const variable of variables) {\n  await prisma.emailTemplateVariable.upsert({\n    where: {\n      templateId_key: {\n        templateId: template!.id,\n        key: variable.key,\n      },\n    },\n    update: {},\n    create: {\n      templateId: template!.id,\n      ...variable,\n    },\n  });\n}\n
"},{"location":"v2/features/email-templates/template-system/#step-4-use-in-code","title":"Step 4: Use in Code","text":"

Send email from template:

import { emailService } from '@/services/email.service';\n\nawait emailService.sendFromTemplate('shift-signup-confirmation', {\n  recipientEmail: volunteer.email,\n  data: {\n    USER_NAME: volunteer.name,\n    SHIFT_TITLE: shift.title,\n    SHIFT_DATE: dayjs(shift.startTime).format('dddd, MMMM D, YYYY'),\n    SHIFT_TIME: `${dayjs(shift.startTime).format('h:mm A')} - ${dayjs(shift.endTime).format('h:mm A')}`,\n    SHIFT_LOCATION: shift.location,\n    HAS_PHONE: !!volunteer.phone,\n    USER_PHONE: volunteer.phone || '',\n  },\n});\n
"},{"location":"v2/features/email-templates/template-system/#step-5-document-template","title":"Step 5: Document Template","text":"

Add to API documentation:

Create entry in mkdocs/docs/v2/api/email-templates.md:

## shift-signup-confirmation\n\n**Category:** MAP\n**System:** Yes\n\nSent when volunteer signs up for a shift.\n\n**Required Variables:**\n- USER_NAME, SHIFT_TITLE, SHIFT_DATE, SHIFT_TIME, SHIFT_LOCATION\n\n**Optional Variables:**\n- HAS_PHONE (conditional), USER_PHONE\n\n**Usage:**\n\\```typescript\nawait emailService.sendFromTemplate('shift-signup-confirmation', { ... });\n\\```\n
"},{"location":"v2/features/email-templates/template-system/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/email-templates/template-system/#send-email-from-template","title":"Send Email from Template","text":"

Basic Usage:

import { emailService } from '@/services/email.service';\n\nawait emailService.sendFromTemplate('user-welcome', {\n  recipientEmail: 'newuser@example.com',\n  data: {\n    USER_NAME: 'Alice Smith',\n    USER_EMAIL: 'newuser@example.com',\n    VERIFICATION_LINK: 'https://cmlite.org/verify/abc123',\n    SITE_NAME: 'Changemaker Lite',\n    SITE_URL: 'https://cmlite.org',\n  },\n});\n

With Conditional Variables:

await emailService.sendFromTemplate('response-verification', {\n  recipientEmail: participant.email,\n  data: {\n    USER_NAME: participant.name,\n    CAMPAIGN_TITLE: campaign.title,\n    RESPONSE_TEXT: response.content,\n    VERIFICATION_LINK: `https://cmlite.org/responses/verify/${response.verificationToken}`,\n    HAS_CUSTOM_MESSAGE: !!response.customMessage,\n    CUSTOM_MESSAGE: response.customMessage || '',\n  },\n});\n

With Loops (Array Variables):

await emailService.sendFromTemplate('campaign-email', {\n  recipientEmail: 'representative@parliament.ca',\n  data: {\n    USER_NAME: participant.name,\n    USER_EMAIL: participant.email,\n    CAMPAIGN_TITLE: campaign.title,\n    CUSTOM_MESSAGE: emailData.customMessage,\n    REPRESENTATIVES: emailData.representatives.map(rep => ({\n      name: rep.name,\n      title: rep.title,\n      email: rep.email,\n    })),\n  },\n});\n
"},{"location":"v2/features/email-templates/template-system/#template-service-implementation","title":"Template Service Implementation","text":"

Core sendFromTemplate Method:

// api/src/services/email.service.ts\n\nimport Handlebars from 'handlebars';\nimport { prisma } from '@/config/database';\nimport { EmailTemplateNotFoundError, MissingRequiredVariableError } from '@/utils/errors';\n\nclass EmailService {\n  async sendFromTemplate(\n    templateKey: string,\n    options: {\n      recipientEmail: string;\n      data: Record<string, unknown>;\n      attachments?: Array<{ filename: string; path: string }>;\n    }\n  ) {\n    // 1. Load template with variables\n    const template = await prisma.emailTemplate.findUnique({\n      where: { key: templateKey, isActive: true },\n      include: { variables: true },\n    });\n\n    if (!template) {\n      throw new EmailTemplateNotFoundError(`Template not found or inactive: ${templateKey}`);\n    }\n\n    // 2. Validate required variables\n    const requiredVars = template.variables.filter(v => v.isRequired);\n    const missingVars: string[] = [];\n\n    for (const variable of requiredVars) {\n      if (options.data[variable.key] === undefined || options.data[variable.key] === null) {\n        missingVars.push(variable.key);\n      }\n    }\n\n    if (missingVars.length > 0) {\n      throw new MissingRequiredVariableError(\n        `Missing required variables for template ${templateKey}: ${missingVars.join(', ')}`\n      );\n    }\n\n    // 3. Compile Handlebars templates\n    const compiledSubject = Handlebars.compile(template.subjectLine);\n    const compiledHtml = Handlebars.compile(template.htmlContent);\n    const compiledText = Handlebars.compile(template.textContent);\n\n    // 4. Interpolate variables\n    const subject = compiledSubject(options.data);\n    const html = compiledHtml(options.data);\n    const text = compiledText(options.data);\n\n    // 5. Send via Nodemailer\n    const result = await this.send({\n      to: options.recipientEmail,\n      subject,\n      html,\n      text,\n      attachments: options.attachments,\n    });\n\n    return result;\n  }\n\n  private async send(options: {\n    to: string;\n    subject: string;\n    html: string;\n    text: string;\n    attachments?: Array<{ filename: string; path: string }>;\n  }) {\n    // Nodemailer implementation\n    // See api/src/services/email.service.ts for full implementation\n  }\n}\n\nexport const emailService = new EmailService();\n
"},{"location":"v2/features/email-templates/template-system/#handlebars-helper-registration","title":"Handlebars Helper Registration","text":"

Register custom helpers for common formatting:

// api/src/services/email.service.ts\n\nimport Handlebars from 'handlebars';\nimport dayjs from 'dayjs';\n\n// Date formatting helper\nHandlebars.registerHelper('formatDate', (date: string | Date, format: string) => {\n  return dayjs(date).format(format);\n});\n\n// Currency formatting helper\nHandlebars.registerHelper('currency', (amount: number) => {\n  return new Intl.NumberFormat('en-CA', {\n    style: 'currency',\n    currency: 'CAD',\n  }).format(amount);\n});\n\n// Pluralize helper\nHandlebars.registerHelper('pluralize', (count: number, singular: string, plural: string) => {\n  return count === 1 ? singular : plural;\n});\n

Usage in Templates:

<p>Your shift is scheduled for {{formatDate SHIFT_DATE \"MMMM D, YYYY\"}}.</p>\n\n<p>You've knocked on {{DOOR_COUNT}} {{pluralize DOOR_COUNT \"door\" \"doors\"}}.</p>\n\n<p>Campaign budget: {{currency CAMPAIGN_BUDGET}}</p>\n
"},{"location":"v2/features/email-templates/template-system/#error-handling","title":"Error Handling","text":"

Custom Error Classes:

// api/src/utils/errors.ts\n\nexport class EmailTemplateNotFoundError extends Error {\n  constructor(message: string) {\n    super(message);\n    this.name = 'EmailTemplateNotFoundError';\n  }\n}\n\nexport class MissingRequiredVariableError extends Error {\n  constructor(message: string) {\n    super(message);\n    this.name = 'MissingRequiredVariableError';\n  }\n}\n\nexport class TemplateCompilationError extends Error {\n  constructor(message: string) {\n    super(message);\n    this.name = 'TemplateCompilationError';\n  }\n}\n

Service Error Handling:

try {\n  await emailService.sendFromTemplate('shift-reminder', {\n    recipientEmail: volunteer.email,\n    data: { ... },\n  });\n} catch (error) {\n  if (error instanceof EmailTemplateNotFoundError) {\n    logger.error('Template not found', { templateKey: 'shift-reminder' });\n    // Fallback to default email or skip send\n  } else if (error instanceof MissingRequiredVariableError) {\n    logger.error('Missing required variables', { error: error.message });\n    // Log to Sentry, notify admin\n  } else {\n    logger.error('Email send failed', { error });\n    throw error;\n  }\n}\n
"},{"location":"v2/features/email-templates/template-system/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/email-templates/template-system/#problem-template-not-found","title":"Problem: Template not found","text":"

Symptoms: - EmailTemplateNotFoundError: Template not found or inactive: shift-reminder - Email not sent, exception thrown

Causes: 1. Template key typo (case-sensitive) 2. Template is inactive (isActive = false) 3. Template doesn't exist in database

Solutions:

Check template exists:

SELECT * FROM email_templates WHERE key = 'shift-reminder';\n

Check active status:

SELECT key, is_active FROM email_templates WHERE key = 'shift-reminder';\n

Activate template:

UPDATE email_templates SET is_active = true WHERE key = 'shift-reminder';\n

Create template via admin GUI or seed script (see Developer Workflow above)

"},{"location":"v2/features/email-templates/template-system/#problem-variable-not-replaced-shows-var-in-email","title":"Problem: Variable not replaced (shows {{VAR}} in email)","text":"

Symptoms: - Rendered email shows {{USER_NAME}} instead of \"John Doe\" - Variables appear as raw text in subject or body

Causes: 1. Variable key typo in data object (case-sensitive) 2. Variable not provided in data object 3. Handlebars compilation failed silently 4. Using wrong interpolation syntax

Solutions:

Check variable key matches exactly:

// Template uses {{USER_NAME}}\n// Data must have USER_NAME (not userName or user_name)\ndata: {\n  USER_NAME: 'John Doe',  // \u2713 Correct\n  userName: 'John Doe',   // \u2717 Wrong case\n  user_name: 'John Doe',  // \u2717 Wrong format\n}\n

Console log data object:

console.log('Template data:', JSON.stringify(options.data, null, 2));\n

Test Handlebars compilation:

const Handlebars = require('handlebars');\nconst template = Handlebars.compile('Hello {{USER_NAME}}!');\nconsole.log(template({ USER_NAME: 'Test' })); // Should output: \"Hello Test!\"\n

Verify template content:

SELECT subject_line, html_content FROM email_templates WHERE key = 'shift-reminder';\n

"},{"location":"v2/features/email-templates/template-system/#problem-missing-required-variable-error","title":"Problem: Missing required variable error","text":"

Symptoms: - MissingRequiredVariableError: Missing required variables for template shift-reminder: SHIFT_DATE, SHIFT_TIME - Email not sent, exception thrown

Causes: 1. Required variable not provided in data object 2. Variable value is null or undefined

Solutions:

Check EmailTemplateVariable.isRequired:

SELECT key, label, is_required\nFROM email_template_variables\nWHERE template_id = (SELECT id FROM email_templates WHERE key = 'shift-reminder');\n

Provide all required variables:

await emailService.sendFromTemplate('shift-reminder', {\n  recipientEmail: volunteer.email,\n  data: {\n    USER_NAME: volunteer.name,\n    SHIFT_DATE: dayjs(shift.startTime).format('MMMM D, YYYY'),  // \u2713 Required\n    SHIFT_TIME: dayjs(shift.startTime).format('h:mm A'),         // \u2713 Required\n  },\n});\n

Temporary fix (set isRequired = false):

UPDATE email_template_variables\nSET is_required = false\nWHERE template_id = (SELECT id FROM email_templates WHERE key = 'shift-reminder')\n  AND key = 'SHIFT_TIME';\n

Long-term fix: Update code to always provide required variables

"},{"location":"v2/features/email-templates/template-system/#problem-email-sent-to-wrong-recipient","title":"Problem: Email sent to wrong recipient","text":"

Symptoms: - Test email sent to production recipient - User receives email meant for another user

Causes: 1. Wrong recipientEmail parameter 2. Email test mode disabled (EMAIL_TEST_MODE=false) 3. Variable interpolation pulled wrong user data

Solutions:

Enable test mode in development:

# .env\nEMAIL_TEST_MODE=true\n

Check recipient email:

console.log('Sending email to:', options.recipientEmail);\n

Use MailHog in dev: - All emails captured at http://localhost:8025 - Never sent to real recipients

Verify user data query:

const volunteer = await prisma.user.findUnique({ where: { id: volunteerId } });\nconsole.log('Volunteer email:', volunteer.email);\n

"},{"location":"v2/features/email-templates/template-system/#problem-html-rendering-broken-in-email-client","title":"Problem: HTML rendering broken in email client","text":"

Symptoms: - Email looks correct in preview but broken in Gmail/Outlook - Images not loading - Styles not applied

Causes: 1. Email client doesn't support modern CSS 2. External images blocked by email client 3. Invalid HTML structure

Solutions:

Use inline styles (not CSS classes):

<!-- \u2717 Won't work in many email clients -->\n<p class=\"highlight\">Important message</p>\n\n<!-- \u2713 Use inline styles -->\n<p style=\"background-color: #ffeb3b; padding: 10px; font-weight: bold;\">Important message</p>\n

Use tables for layout (not flexbox/grid):

<!-- \u2713 Email-safe layout -->\n<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\">\n  <tr>\n    <td style=\"padding: 20px;\">\n      <p>Content here</p>\n    </td>\n  </tr>\n</table>\n

Embed images as data URIs or use absolute URLs:

<!-- \u2713 Absolute URL -->\n<img src=\"https://cmlite.org/logo.png\" alt=\"Logo\">\n\n<!-- \u2713 Data URI (small images only) -->\n<img src=\"data:image/png;base64,iVBORw0KG...\" alt=\"Icon\">\n

Test in multiple email clients: - Use Litmus or Email on Acid - Test in Gmail, Outlook, Apple Mail, Yahoo Mail

"},{"location":"v2/features/email-templates/template-system/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/email-templates/template-system/#template-loading","title":"Template Loading","text":"

Current Implementation: - Templates fetched from database on every send - Includes variable definitions in same query - No caching layer

Performance Impact: - Single database query per email send (~10ms) - Acceptable for low-volume sends (< 100/min) - May bottleneck for high-volume campaigns (> 1000/min)

Optimization Options:

1. In-Memory Caching:

// api/src/services/email.service.ts\n\nprivate templateCache = new Map<string, EmailTemplate & { variables: EmailTemplateVariable[] }>();\nprivate cacheExpiry = new Map<string, number>();\nprivate readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes\n\nasync loadTemplate(key: string) {\n  const now = Date.now();\n\n  // Check cache\n  if (this.templateCache.has(key) && this.cacheExpiry.get(key)! > now) {\n    return this.templateCache.get(key)!;\n  }\n\n  // Load from database\n  const template = await prisma.emailTemplate.findUnique({\n    where: { key, isActive: true },\n    include: { variables: true },\n  });\n\n  if (!template) throw new EmailTemplateNotFoundError(`Template not found: ${key}`);\n\n  // Cache template\n  this.templateCache.set(key, template);\n  this.cacheExpiry.set(key, now + this.CACHE_TTL);\n\n  return template;\n}\n

2. Redis Caching:

import { redis } from '@/config/redis';\n\nasync loadTemplate(key: string) {\n  // Try Redis cache\n  const cached = await redis.get(`email-template:${key}`);\n  if (cached) {\n    return JSON.parse(cached);\n  }\n\n  // Load from database\n  const template = await prisma.emailTemplate.findUnique({ ... });\n\n  // Cache in Redis (5 min TTL)\n  await redis.setex(`email-template:${key}`, 300, JSON.stringify(template));\n\n  return template;\n}\n

3. Cache Invalidation:

// When template is updated\nawait redis.del(`email-template:${template.key}`);\nthis.templateCache.delete(template.key);\n

"},{"location":"v2/features/email-templates/template-system/#handlebars-compilation","title":"Handlebars Compilation","text":"

Performance: - Handlebars compilation is fast (~1ms per template) - No significant bottleneck for typical templates

Large Templates: - Templates > 100KB may take 5-10ms to compile - Solution: Pre-compile templates and cache compiled functions

Pre-Compilation:

private compiledCache = new Map<string, {\n  subject: HandlebarsTemplateDelegate;\n  html: HandlebarsTemplateDelegate;\n  text: HandlebarsTemplateDelegate;\n}>();\n\nasync sendFromTemplate(templateKey: string, options: { ... }) {\n  const template = await this.loadTemplate(templateKey);\n\n  // Check compiled cache\n  let compiled = this.compiledCache.get(templateKey);\n\n  if (!compiled) {\n    compiled = {\n      subject: Handlebars.compile(template.subjectLine),\n      html: Handlebars.compile(template.htmlContent),\n      text: Handlebars.compile(template.textContent),\n    };\n    this.compiledCache.set(templateKey, compiled);\n  }\n\n  // Interpolate\n  const subject = compiled.subject(options.data);\n  const html = compiled.html(options.data);\n  const text = compiled.text(options.data);\n\n  // Send...\n}\n

"},{"location":"v2/features/email-templates/template-system/#bulk-email-sending","title":"Bulk Email Sending","text":"

Problem: Sending 1000+ emails sequentially is slow (1-2 seconds per email)

Solution: Use BullMQ job queue for async batch processing

Queue Implementation:

// api/src/services/email-queue.service.ts\n\nimport { Queue, Worker } from 'bullmq';\nimport { redis } from '@/config/redis';\n\nconst emailQueue = new Queue('email-queue', {\n  connection: redis,\n});\n\n// Add email job\nexport async function queueEmail(templateKey: string, options: { ... }) {\n  await emailQueue.add('send-template', {\n    templateKey,\n    recipientEmail: options.recipientEmail,\n    data: options.data,\n  });\n}\n\n// Process email jobs\nconst emailWorker = new Worker('email-queue', async (job) => {\n  const { templateKey, recipientEmail, data } = job.data;\n  await emailService.sendFromTemplate(templateKey, { recipientEmail, data });\n}, {\n  connection: redis,\n  concurrency: 10, // Process 10 emails in parallel\n});\n

Usage:

// Queue 1000 emails\nfor (const volunteer of volunteers) {\n  await queueEmail('shift-reminder', {\n    recipientEmail: volunteer.email,\n    data: { ... },\n  });\n}\n

"},{"location":"v2/features/email-templates/template-system/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/features/email-templates/template-system/#xss-cross-site-scripting-in-email-clients","title":"XSS (Cross-Site Scripting) in Email Clients","text":"

Risk: Admin-authored templates may contain malicious JavaScript

Handlebars Auto-Escaping: - By default, {{VAR}} escapes HTML entities - & \u2192 &amp;, < \u2192 &lt;, > \u2192 &gt;

Raw HTML (Unescaped): - {{{VAR}}} (triple braces) renders raw HTML - Use ONLY for trusted, application-generated content - NEVER use for user-submitted content without sanitization

Example:

<!-- Safe: auto-escaped -->\n<p>User message: {{USER_MESSAGE}}</p>\n\n<!-- Unsafe: unescaped (only use for trusted content) -->\n<div>{{{FORMATTED_CONTENT}}}</div>\n

Sanitization:

import DOMPurify from 'isomorphic-dompurify';\n\nconst sanitizedMessage = DOMPurify.sanitize(userInput, {\n  ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li'],\n  ALLOWED_ATTR: [],\n});\n\nawait emailService.sendFromTemplate('response-notification', {\n  recipientEmail: admin.email,\n  data: {\n    USER_MESSAGE: sanitizedMessage, // Safe to use {{{...}}}\n  },\n});\n

"},{"location":"v2/features/email-templates/template-system/#email-address-validation","title":"Email Address Validation","text":"

Risk: Invalid email addresses cause SMTP errors or bounce emails

Validation Before Sending:

import validator from 'validator';\n\nif (!validator.isEmail(options.recipientEmail)) {\n  throw new Error('Invalid recipient email address');\n}\n

Bounce Handling: - Monitor bounce notifications from SMTP provider - Mark bounced emails in database - Disable sending to repeatedly bounced addresses

"},{"location":"v2/features/email-templates/template-system/#rate-limiting-template-test-sends","title":"Rate Limiting Template Test Sends","text":"

Risk: Admin spamming test sends

Rate Limit Implementation:

// api/src/modules/email-templates/email-templates.routes.ts\n\nimport rateLimit from 'express-rate-limit';\n\nconst testSendLimiter = rateLimit({\n  windowMs: 60 * 1000, // 1 minute\n  max: 10, // 10 requests per minute\n  message: 'Too many test sends. Please wait before trying again.',\n  standardHeaders: true,\n  legacyHeaders: false,\n});\n\nrouter.post('/:id/test', testSendLimiter, requireRole(SUPER_ADMIN), async (req, res) => {\n  // Test send implementation...\n});\n

"},{"location":"v2/features/email-templates/template-system/#template-injection-attacks","title":"Template Injection Attacks","text":"

Risk: Admin injects malicious Handlebars helpers or expressions

Handlebars Security: - Handlebars does NOT execute JavaScript (unlike eval) - Helpers are pre-registered by application (admin can't add custom helpers) - No access to Node.js globals or require()

Safe:

{{USER_NAME}}\n{{#if HAS_PHONE}}{{USER_PHONE}}{{/if}}\n{{#each ITEMS}}{{name}}{{/each}}\n

Already Prevented by Handlebars:

<!-- These do NOT execute, render as literal text -->\n{{require('fs').readFileSync('/etc/passwd')}}\n{{process.env.DATABASE_URL}}\n

Best Practice: Still review templates before activating

"},{"location":"v2/features/email-templates/template-system/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/email-templates/template-system/#frontend-documentation","title":"Frontend Documentation","text":"
  • EmailTemplatesPage.tsx \u2014 Email templates list page with CRUD table
  • EmailTemplateEditorPage.tsx \u2014 Split-pane editor with preview
  • Components:
  • Variable insertion panel
  • Live preview renderer
  • Test send form
  • Version comparison modal
"},{"location":"v2/features/email-templates/template-system/#backend-documentation","title":"Backend Documentation","text":"
  • Email Templates Module \u2014 API routes and schemas
  • GET /api/email-templates \u2014 List templates (with filters)
  • POST /api/email-templates \u2014 Create template
  • PUT /api/email-templates/:id \u2014 Update template
  • DELETE /api/email-templates/:id \u2014 Delete template (system templates protected)
  • POST /api/email-templates/:id/test \u2014 Send test email
  • GET /api/email-templates/:id/versions \u2014 Version history
  • POST /api/email-templates/:id/rollback/:versionNumber \u2014 Restore version
  • Email Service \u2014 Core email sending logic
  • sendFromTemplate() \u2014 Load, validate, interpolate, send
  • send() \u2014 Low-level Nodemailer wrapper
  • Handlebars helper registration
"},{"location":"v2/features/email-templates/template-system/#database-documentation","title":"Database Documentation","text":"
  • Email Templates Models \u2014 Schema definitions
  • EmailTemplate model
  • EmailTemplateVariable model
  • EmailTemplateVersion model
  • EmailTemplateTestLog model
  • Indexes and constraints
"},{"location":"v2/features/email-templates/template-system/#feature-documentation","title":"Feature Documentation","text":"
  • editor.md \u2014 Email template editor interface
  • variables.md \u2014 Template variable system
  • versioning.md \u2014 Template version history
"},{"location":"v2/features/email-templates/template-system/#configuration","title":"Configuration","text":"
  • Environment Variables \u2014 Email-related env vars
  • EMAIL_TEST_MODE \u2014 Enable MailHog capture
  • SMTP settings (host, port, user, password)
  • Site Settings \u2014 Site-wide email settings
  • Default from name/email
  • SMTP override settings
"},{"location":"v2/features/email-templates/variables/","title":"Template Variables System","text":""},{"location":"v2/features/email-templates/variables/#overview","title":"Overview","text":"

The Template Variables System defines reusable placeholders for email templates, enabling dynamic content interpolation with validation, documentation, and sample values. Variables are defined per template and provide metadata for variable insertion UI, validation logic, and testing workflows.

Key Features:

  • Per-Template Variables \u2014 Each template has its own variable definitions
  • Required vs Optional \u2014 Enforce required variables at runtime
  • Conditional Variables \u2014 Boolean/truthy flags for {{#if}} blocks
  • Sample Values \u2014 Example data for testing and preview
  • Sort Order \u2014 Control display order in editor UI
  • Documentation \u2014 Labels and descriptions for self-documenting templates
  • Validation \u2014 Runtime checks prevent missing variable errors
  • Reusability \u2014 Common variables (USER_NAME, USER_EMAIL) across templates

Benefits:

  • Type Safety \u2014 Know what data is expected before sending
  • Self-Documentation \u2014 Variables describe their purpose
  • Better Testing \u2014 Sample values pre-fill test send forms
  • Consistency \u2014 Standardized variable naming across templates
  • Error Prevention \u2014 Catch missing variables before SMTP send
"},{"location":"v2/features/email-templates/variables/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph \"Database Layer\"\n        Template[(EmailTemplate)]\n        Variables[(EmailTemplateVariable)]\n\n        Template -->|1:N| Variables\n    end\n\n    subgraph \"Variable Definition\"\n        VarKey[Variable Key<br/>USER_NAME]\n        VarMeta[Metadata<br/>label, description, isRequired]\n        VarSample[Sample Value<br/>'John Doe']\n        VarSort[Sort Order<br/>1, 2, 3...]\n\n        VarKey --> Variables\n        VarMeta --> Variables\n        VarSample --> Variables\n        VarSort --> Variables\n    end\n\n    subgraph \"Template Service\"\n        Load[Load Template + Variables]\n        Validate[Validate Required Variables]\n        Interpolate[Handlebars Interpolation]\n\n        Load --> Template\n        Load --> Variables\n        Validate --> Variables\n        Interpolate -->|{{VAR}}| Data[Data Object]\n    end\n\n    subgraph \"Editor UI\"\n        InsertPanel[Variable Insertion Panel]\n        PreviewForm[Sample Data Form]\n        TestSend[Test Send Form]\n\n        Variables --> InsertPanel\n        Variables --> PreviewForm\n        Variables --> TestSend\n    end\n\n    subgraph \"Runtime Validation\"\n        Send[Send Email]\n        Check{Required<br/>Variables<br/>Present?}\n        Error[Throw MissingVariableError]\n        Success[Send via SMTP]\n\n        Send --> Validate\n        Validate --> Check\n        Check -->|No| Error\n        Check -->|Yes| Interpolate\n        Interpolate --> Success\n    end\n\n    style Template fill:#50c878,color:#fff\n    style Variables fill:#4a90e2,color:#fff\n    style Validate fill:#ffb347,color:#333

Component Responsibilities:

  • EmailTemplateVariable \u2014 Database model storing variable metadata
  • Variable Insertion Panel \u2014 Editor UI for inserting {{VARIABLES}}
  • Sample Data Form \u2014 Preview/test form pre-filled with sample values
  • Validation Service \u2014 Runtime checks before template interpolation
  • Handlebars Engine \u2014 Replaces {{VAR}} with data values
"},{"location":"v2/features/email-templates/variables/#database-model","title":"Database Model","text":""},{"location":"v2/features/email-templates/variables/#emailtemplatevariable-schema","title":"EmailTemplateVariable Schema","text":"

Table: email_template_variables

Field Type Description id String (CUID) Primary key templateId String Foreign key to EmailTemplate key String Variable name (UPPERCASE_WITH_UNDERSCORES) label String Display label for UI (\"User's Full Name\") description String (optional) Variable purpose and usage notes isRequired Boolean If true, must be provided in data object isConditional Boolean If true, used in {{#if}} blocks (truthy/falsy) sampleValue String (optional) Example value for testing/preview sortOrder Int Display order in UI (1, 2, 3...) createdAt DateTime Creation timestamp

Relations: - template \u2014 EmailTemplate (N:1)

Constraints: - Unique index on (templateId, key) \u2014 prevents duplicate variables per template - Index on sortOrder for ordered queries

Prisma Schema:

model EmailTemplateVariable {\n  id            String   @id @default(cuid())\n  templateId    String\n  key           String\n  label         String\n  description   String?\n  isRequired    Boolean  @default(false)\n  isConditional Boolean  @default(false)\n  sampleValue   String?\n  sortOrder     Int      @default(0)\n  createdAt     DateTime @default(now())\n\n  template EmailTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)\n\n  @@unique([templateId, key])\n  @@index([sortOrder])\n  @@map(\"email_template_variables\")\n}\n

"},{"location":"v2/features/email-templates/variables/#variable-types","title":"Variable Types","text":""},{"location":"v2/features/email-templates/variables/#required-variables","title":"Required Variables","text":"

Purpose: Must be provided in data object for template to send.

Behavior: - Validation checks for presence before interpolation - Throws MissingRequiredVariableError if missing - Marked with red \"Required\" badge in editor UI

When to Use: - Variables that appear in ALL template renders - Variables without fallback values - Critical data (e.g., recipient name, event date)

Example:

await prisma.emailTemplateVariable.create({\n  data: {\n    templateId: template.id,\n    key: 'USER_NAME',\n    label: 'User Name',\n    description: 'Full name of the email recipient',\n    isRequired: true,  // \u2190 MUST be provided\n    isConditional: false,\n    sampleValue: 'John Doe',\n    sortOrder: 1,\n  },\n});\n

Template Usage:

<p>Dear {{USER_NAME}},</p>\n<!-- USER_NAME is required, error if missing -->\n

"},{"location":"v2/features/email-templates/variables/#optional-variables","title":"Optional Variables","text":"

Purpose: May be omitted from data object (defaults to empty string).

Behavior: - No validation error if missing - Handlebars renders as empty string if undefined - Useful for conditional content or nice-to-have data

When to Use: - Variables that may not always be available (e.g., phone number) - Variables with fallback text in template - Conditional blocks that check presence

Example:

await prisma.emailTemplateVariable.create({\n  data: {\n    templateId: template.id,\n    key: 'USER_PHONE',\n    label: 'User Phone',\n    description: 'User phone number (optional)',\n    isRequired: false,  // \u2190 Can be omitted\n    isConditional: false,\n    sampleValue: '(555) 123-4567',\n    sortOrder: 5,\n  },\n});\n

Template Usage:

{{#if USER_PHONE}}\n<p>We'll call you at {{USER_PHONE}}.</p>\n{{else}}\n<p>Add a phone number to receive SMS updates.</p>\n{{/if}}\n

"},{"location":"v2/features/email-templates/variables/#conditional-variables","title":"Conditional Variables","text":"

Purpose: Boolean or truthy/falsy values for {{#if}} blocks.

Behavior: - isConditional: true marks variable as boolean-like - Editor UI shows blue \"Conditional\" badge - Used in {{#if VAR}}...{{/if}} blocks - Can also be required or optional

When to Use: - Boolean flags (HAS_PHONE, IS_VERIFIED, IS_ADMIN) - Existence checks (HAS_CUSTOM_MESSAGE, HAS_LOCATION) - Feature flags (SHOW_DISCOUNT, SHOW_MAP_LINK)

Example:

await prisma.emailTemplateVariable.create({\n  data: {\n    templateId: template.id,\n    key: 'HAS_PHONE',\n    label: 'Has Phone Number',\n    description: 'Whether user provided a phone number',\n    isRequired: false,\n    isConditional: true,  // \u2190 Boolean/truthy variable\n    sampleValue: 'true',\n    sortOrder: 4,\n  },\n});\n

Template Usage:

{{#if HAS_PHONE}}\n<p>Contact: {{USER_PHONE}}</p>\n{{/if}}\n

Truthy Values: - true, 'true', 1, non-empty strings, non-empty arrays

Falsy Values: - false, 'false', 0, '', null, undefined, []

"},{"location":"v2/features/email-templates/variables/#array-variables-loops","title":"Array Variables (Loops)","text":"

Purpose: Collections for {{#each}} blocks.

Behavior: - Not explicitly marked (same as other variables) - Sample value should be JSON array string - Used in {{#each VAR}}...{{/each}} loops

When to Use: - Lists of representatives, shift assignments, visit outcomes - Dynamic content length (1-N items)

Example:

await prisma.emailTemplateVariable.create({\n  data: {\n    templateId: template.id,\n    key: 'REPRESENTATIVES',\n    label: 'Representative List',\n    description: 'Array of representative objects',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: JSON.stringify([\n      { name: 'Jane Doe', title: 'MP', email: 'jane@parliament.ca' },\n      { name: 'John Smith', title: 'Councillor', email: 'john@city.ca' },\n    ]),\n    sortOrder: 10,\n  },\n});\n

Template Usage:

<ul>\n{{#each REPRESENTATIVES}}\n  <li>\n    <strong>{{name}}</strong> ({{title}})<br>\n    Email: {{email}}\n  </li>\n{{/each}}\n</ul>\n

Data Object:

{\n  REPRESENTATIVES: [\n    { name: 'Jane Doe', title: 'MP', email: 'jane@parliament.ca' },\n    { name: 'John Smith', title: 'Councillor', email: 'john@city.ca' },\n  ],\n}\n

"},{"location":"v2/features/email-templates/variables/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/email-templates/variables/#viewing-variables","title":"Viewing Variables","text":"

From EmailTemplatesPage:

  1. Click Template Row
  2. Opens template detail modal

  3. Navigate to \"Variables\" Tab

  4. Shows table of all variables
  5. Columns: Key, Label, Required, Conditional, Sample Value, Sort Order

  6. Variable Details

  7. Click variable row for description
  8. See where variable is used in template content
  9. View sample value

From EmailTemplateEditorPage:

  1. Open Template Editor
  2. Variables shown in right sidebar

  3. Variable Insertion Panel

  4. Variables listed with labels, badges, descriptions
  5. Sorted by sortOrder ascending
  6. Click \"Insert to HTML/Text\" buttons
"},{"location":"v2/features/email-templates/variables/#adding-variable","title":"Adding Variable","text":"

Step 1: Open Variables Tab - EmailTemplatesPage \u2192 click template \u2192 \"Variables\" tab

Step 2: Click \"Add Variable\" Button - Opens variable creation modal

Step 3: Enter Variable Metadata

Key (required): - Uppercase with underscores (e.g., USER_NAME) - Must be unique within template - Used in template as {{KEY}}

Label (required): - Display name for UI (e.g., \"User's Full Name\") - Human-readable description

Description (optional): - Detailed explanation of variable purpose - Usage notes (e.g., \"Must be in YYYY-MM-DD format\")

Is Required: - Toggle on if variable must always be provided - Validation will fail if missing

Is Conditional: - Toggle on if variable is used in {{#if}} blocks - UI shows blue \"Conditional\" badge

Sample Value (optional): - Example value for testing/preview - Pre-fills test send form - Shows expected data format

Sort Order: - Numeric order for UI display - Lower numbers appear first (1, 2, 3...) - Auto-assigned if not specified

Step 4: Save Variable - Click \"Save\" button - Variable added to template - Available in editor insertion panel

"},{"location":"v2/features/email-templates/variables/#editing-variable","title":"Editing Variable","text":"

Step 1: Open Variables Tab - EmailTemplatesPage \u2192 click template \u2192 \"Variables\" tab

Step 2: Click Variable Row - Opens variable edit modal - Shows current values

Step 3: Modify Fields - Change label, description, flags, sample value - Cannot change key (would break existing templates)

Step 4: Save Changes - Click \"Save\" button - Variable updated in database

Note: Changing variable key requires creating new variable and updating template content manually.

"},{"location":"v2/features/email-templates/variables/#deleting-variable","title":"Deleting Variable","text":"

Step 1: Check Template Usage - Search template content for {{VAR_KEY}} - Ensure variable is not used in subject/HTML/text

Step 2: Click Delete Button - Variables tab \u2192 click variable row \u2192 \"Delete\" button

Step 3: Confirm Deletion - Warning modal: \"Are you sure? This cannot be undone.\" - Click \"Confirm Delete\"

Step 4: Verify Template Still Valid - Open template editor - Check preview renders without errors - Send test email

Warning: Deleting a variable that's still used in template content will cause rendering errors ({{VAR}} will appear as literal text).

"},{"location":"v2/features/email-templates/variables/#reordering-variables","title":"Reordering Variables","text":"

Step 1: Open Variables Tab - EmailTemplatesPage \u2192 click template \u2192 \"Variables\" tab

Step 2: Drag to Reorder - Drag variable rows up/down - Drop to new position

Step 3: Save Sort Order - Click \"Save Order\" button - Updates sortOrder field for all variables

Alternative: Manual Sort Order - Edit variable \u2192 change sortOrder number - Variables re-sort automatically

"},{"location":"v2/features/email-templates/variables/#developer-workflow","title":"Developer Workflow","text":""},{"location":"v2/features/email-templates/variables/#creating-variables-programmatically","title":"Creating Variables Programmatically","text":"

Seed Script Example:

// api/prisma/seed.ts\n\nconst template = await prisma.emailTemplate.findUnique({\n  where: { key: 'shift-signup-confirmation' },\n});\n\nif (!template) throw new Error('Template not found');\n\nconst variables = [\n  {\n    key: 'USER_NAME',\n    label: 'User Name',\n    description: 'Full name of the volunteer',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'John Doe',\n    sortOrder: 1,\n  },\n  {\n    key: 'USER_EMAIL',\n    label: 'User Email',\n    description: 'Email address of the volunteer',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'john@example.com',\n    sortOrder: 2,\n  },\n  {\n    key: 'SHIFT_TITLE',\n    label: 'Shift Title',\n    description: 'Name of the shift',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'Door Knocking - Downtown',\n    sortOrder: 3,\n  },\n  {\n    key: 'SHIFT_DATE',\n    label: 'Shift Date',\n    description: 'Formatted shift date (e.g., \"Saturday, March 15, 2026\")',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'Saturday, March 15, 2026',\n    sortOrder: 4,\n  },\n  {\n    key: 'SHIFT_TIME',\n    label: 'Shift Time',\n    description: 'Shift time range (e.g., \"10:00 AM - 2:00 PM\")',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: '10:00 AM - 2:00 PM',\n    sortOrder: 5,\n  },\n  {\n    key: 'SHIFT_LOCATION',\n    label: 'Shift Location',\n    description: 'Meeting location for shift',\n    isRequired: true,\n    isConditional: false,\n    sampleValue: 'Campaign Office (123 Main St)',\n    sortOrder: 6,\n  },\n  {\n    key: 'HAS_PHONE',\n    label: 'Has Phone Number',\n    description: 'Whether user provided a phone number',\n    isRequired: false,\n    isConditional: true,\n    sampleValue: 'true',\n    sortOrder: 7,\n  },\n  {\n    key: 'USER_PHONE',\n    label: 'User Phone',\n    description: 'User phone number (optional)',\n    isRequired: false,\n    isConditional: false,\n    sampleValue: '(555) 123-4567',\n    sortOrder: 8,\n  },\n];\n\n// Upsert variables\nfor (const variable of variables) {\n  await prisma.emailTemplateVariable.upsert({\n    where: {\n      templateId_key: {\n        templateId: template.id,\n        key: variable.key,\n      },\n    },\n    update: {\n      label: variable.label,\n      description: variable.description,\n      isRequired: variable.isRequired,\n      isConditional: variable.isConditional,\n      sampleValue: variable.sampleValue,\n      sortOrder: variable.sortOrder,\n    },\n    create: {\n      templateId: template.id,\n      ...variable,\n    },\n  });\n}\n\nconsole.log(`\u2713 Created ${variables.length} variables for shift-signup-confirmation template`);\n
"},{"location":"v2/features/email-templates/variables/#loading-variables-in-code","title":"Loading Variables in Code","text":"

With Template:

const template = await prisma.emailTemplate.findUnique({\n  where: { key: 'shift-signup-confirmation' },\n  include: { variables: true },\n});\n\nconsole.log('Template variables:', template?.variables);\n

Ordered by Sort:

const template = await prisma.emailTemplate.findUnique({\n  where: { key: 'shift-signup-confirmation' },\n  include: {\n    variables: {\n      orderBy: { sortOrder: 'asc' },\n    },\n  },\n});\n

Required Variables Only:

const requiredVars = await prisma.emailTemplateVariable.findMany({\n  where: {\n    templateId: template.id,\n    isRequired: true,\n  },\n});\n\nconsole.log('Required variables:', requiredVars.map(v => v.key));\n

"},{"location":"v2/features/email-templates/variables/#validating-variables","title":"Validating Variables","text":"

Validation Function:

// api/src/services/email.service.ts\n\nfunction validateVariables(\n  template: EmailTemplate & { variables: EmailTemplateVariable[] },\n  data: Record<string, unknown>\n) {\n  const missing: string[] = [];\n\n  for (const variable of template.variables) {\n    if (variable.isRequired && (data[variable.key] === undefined || data[variable.key] === null)) {\n      missing.push(variable.key);\n    }\n  }\n\n  if (missing.length > 0) {\n    throw new MissingRequiredVariableError(\n      `Missing required variables for template ${template.key}: ${missing.join(', ')}`\n    );\n  }\n}\n

Usage:

const template = await prisma.emailTemplate.findUnique({\n  where: { key: 'shift-reminder' },\n  include: { variables: true },\n});\n\ntry {\n  validateVariables(template, {\n    USER_NAME: 'John Doe',\n    SHIFT_DATE: '2026-03-15',\n    // Missing SHIFT_TITLE (required)\n  });\n} catch (error) {\n  console.error('Validation failed:', error.message);\n  // Error: Missing required variables for template shift-reminder: SHIFT_TITLE\n}\n

"},{"location":"v2/features/email-templates/variables/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/email-templates/variables/#creating-variable-via-api","title":"Creating Variable via API","text":"

Endpoint: POST /api/email-templates/:id/variables

Request Body:

{\n  \"key\": \"USER_NAME\",\n  \"label\": \"User Name\",\n  \"description\": \"Full name of the email recipient\",\n  \"isRequired\": true,\n  \"isConditional\": false,\n  \"sampleValue\": \"John Doe\",\n  \"sortOrder\": 1\n}\n

Route Implementation:

// api/src/modules/email-templates/email-templates.routes.ts\n\nrouter.post('/:id/variables', requireRole(SUPER_ADMIN), async (req, res) => {\n  const { id } = req.params;\n  const { key, label, description, isRequired, isConditional, sampleValue, sortOrder } = req.body;\n\n  try {\n    const variable = await prisma.emailTemplateVariable.create({\n      data: {\n        templateId: id,\n        key,\n        label,\n        description,\n        isRequired: isRequired || false,\n        isConditional: isConditional || false,\n        sampleValue,\n        sortOrder: sortOrder || 0,\n      },\n    });\n\n    res.json(variable);\n  } catch (error: any) {\n    if (error.code === 'P2002') {\n      // Unique constraint violation\n      return res.status(400).json({ error: 'Variable key already exists for this template' });\n    }\n    throw error;\n  }\n});\n

"},{"location":"v2/features/email-templates/variables/#auto-generating-sample-data","title":"Auto-Generating Sample Data","text":"

Load Sample Data from Variables:

function generateSampleData(variables: EmailTemplateVariable[]): Record<string, unknown> {\n  const sampleData: Record<string, unknown> = {};\n\n  for (const variable of variables) {\n    if (variable.sampleValue) {\n      // Try to parse as JSON (for arrays/objects)\n      try {\n        sampleData[variable.key] = JSON.parse(variable.sampleValue);\n      } catch {\n        // Use as string\n        sampleData[variable.key] = variable.sampleValue;\n      }\n    } else if (variable.isConditional) {\n      // Default conditional variables to true\n      sampleData[variable.key] = true;\n    } else {\n      // Default to empty string\n      sampleData[variable.key] = '';\n    }\n  }\n\n  return sampleData;\n}\n

Usage in Editor:

const template = await api.get(`/api/email-templates/${id}`);\nconst sampleData = generateSampleData(template.variables);\n\nsetSampleData(sampleData);\n

"},{"location":"v2/features/email-templates/variables/#variable-usage-detection","title":"Variable Usage Detection","text":"

Find Variables Used in Template Content:

function findUsedVariables(content: string): string[] {\n  // Regex: matches {{VAR}} but not {{#if}}, {{/if}}, {{#each}}, etc.\n  const regex = /\\{\\{(?!#|\\/|\\^)([A-Z_]+)\\}\\}/g;\n  const matches = content.matchAll(regex);\n\n  const variables = new Set<string>();\n  for (const match of matches) {\n    variables.add(match[1]);\n  }\n\n  return Array.from(variables);\n}\n

Check for Unused Variables:

const template = await prisma.emailTemplate.findUnique({\n  where: { id: templateId },\n  include: { variables: true },\n});\n\nconst htmlVars = findUsedVariables(template.htmlContent);\nconst textVars = findUsedVariables(template.textContent);\nconst subjectVars = findUsedVariables(template.subjectLine);\n\nconst usedVars = new Set([...htmlVars, ...textVars, ...subjectVars]);\n\nconst unusedVars = template.variables.filter(v => !usedVars.has(v.key));\n\nconsole.log('Unused variables:', unusedVars.map(v => v.key));\n

"},{"location":"v2/features/email-templates/variables/#common-variables-by-category","title":"Common Variables by Category","text":""},{"location":"v2/features/email-templates/variables/#influence-templates","title":"INFLUENCE Templates","text":"

Standard Variables:

Key Label Required Conditional Description USER_NAME User Name Yes No Participant's full name USER_EMAIL User Email Yes No Participant's email address CAMPAIGN_TITLE Campaign Title Yes No Campaign name CAMPAIGN_SLUG Campaign Slug Yes No URL-safe campaign identifier CAMPAIGN_URL Campaign URL No No Full URL to campaign page REPRESENTATIVE_NAME Representative Name Yes No Representative's full name REPRESENTATIVE_TITLE Representative Title Yes No Representative's title (e.g., \"MP for Downtown\") REPRESENTATIVE_EMAIL Representative Email Yes No Representative's email address CUSTOM_MESSAGE Custom Message Yes No Participant's custom message to representative RESPONSE_TEXT Response Text No No Participant's response wall submission VERIFICATION_LINK Verification Link No No Unique verification URL HAS_CUSTOM_MESSAGE Has Custom Message No Yes Whether participant added custom message

Usage Example:

await emailService.sendFromTemplate('campaign-email', {\n  recipientEmail: representative.email,\n  data: {\n    USER_NAME: participant.name,\n    USER_EMAIL: participant.email,\n    CAMPAIGN_TITLE: campaign.title,\n    CAMPAIGN_SLUG: campaign.slug,\n    REPRESENTATIVE_NAME: representative.name,\n    REPRESENTATIVE_TITLE: representative.title,\n    REPRESENTATIVE_EMAIL: representative.email,\n    CUSTOM_MESSAGE: emailData.customMessage,\n  },\n});\n

"},{"location":"v2/features/email-templates/variables/#map-templates","title":"MAP Templates","text":"

Standard Variables:

Key Label Required Conditional Description USER_NAME User Name Yes No Volunteer's full name USER_EMAIL User Email Yes No Volunteer's email address USER_PHONE User Phone No No Volunteer's phone number (optional) HAS_PHONE Has Phone No Yes Whether user provided phone number SHIFT_TITLE Shift Title Yes No Shift name SHIFT_DATE Shift Date Yes No Formatted shift date SHIFT_TIME Shift Time Yes No Shift time range (e.g., \"10:00 AM - 2:00 PM\") SHIFT_LOCATION Shift Location Yes No Meeting location for shift CUT_NAME Cut Name No No Canvass area name IS_CUT_ASSIGNED Is Cut Assigned No Yes Whether volunteer is assigned to a cut VISIT_COUNT Visit Count No No Number of doors knocked (session summary) CONTACT_COUNT Contact Count No No Number of successful contacts SUPPORT_COUNT Support Count No No Number of supporters identified

Usage Example:

await emailService.sendFromTemplate('shift-signup-confirmation', {\n  recipientEmail: volunteer.email,\n  data: {\n    USER_NAME: volunteer.name,\n    USER_EMAIL: volunteer.email,\n    USER_PHONE: volunteer.phone || '',\n    HAS_PHONE: !!volunteer.phone,\n    SHIFT_TITLE: shift.title,\n    SHIFT_DATE: dayjs(shift.startTime).format('dddd, MMMM D, YYYY'),\n    SHIFT_TIME: `${dayjs(shift.startTime).format('h:mm A')} - ${dayjs(shift.endTime).format('h:mm A')}`,\n    SHIFT_LOCATION: shift.location,\n    IS_CUT_ASSIGNED: !!shift.cutId,\n    CUT_NAME: shift.cut?.name || '',\n  },\n});\n

"},{"location":"v2/features/email-templates/variables/#system-templates","title":"SYSTEM Templates","text":"

Standard Variables:

Key Label Required Conditional Description USER_NAME User Name Yes No User's full name USER_EMAIL User Email Yes No User's email address VERIFICATION_LINK Verification Link No No Unique verification URL (expires 24h) RESET_LINK Reset Link No No Unique password reset URL (expires 1h) SUPPORT_EMAIL Support Email Yes No Platform support email address SITE_NAME Site Name Yes No Platform name (from SiteSettings) SITE_URL Site URL Yes No Platform base URL LOGIN_URL Login URL No No Direct link to login page LOCKOUT_REASON Lockout Reason No No Why account was locked (security)

Usage Example:

await emailService.sendFromTemplate('password-reset', {\n  recipientEmail: user.email,\n  data: {\n    USER_NAME: user.name,\n    USER_EMAIL: user.email,\n    RESET_LINK: `https://cmlite.org/reset-password/${token}`,\n    SUPPORT_EMAIL: siteSettings.supportEmail,\n    SITE_NAME: siteSettings.siteName,\n    SITE_URL: siteSettings.siteUrl,\n  },\n});\n

"},{"location":"v2/features/email-templates/variables/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/email-templates/variables/#problem-variable-not-appearing-in-editor","title":"Problem: Variable not appearing in editor","text":"

Symptoms: - Variable exists in database but not shown in editor insertion panel - Variable missing from variables list

Causes: 1. Variable belongs to different template 2. Template not refreshed after adding variable 3. Sort order is null or very high (out of view)

Solutions:

Check variable exists:

SELECT * FROM email_template_variables\nWHERE template_id = 'cuid123' AND key = 'USER_NAME';\n

Verify template ID:

SELECT id, key FROM email_templates WHERE key = 'shift-reminder';\n-- Check ID matches variable.template_id\n

Refresh editor page: - Hard refresh (Ctrl+Shift+R) - Clear browser cache

Check sort order:

SELECT key, sort_order FROM email_template_variables\nWHERE template_id = 'cuid123'\nORDER BY sort_order;\n\n-- Update if needed\nUPDATE email_template_variables\nSET sort_order = 1\nWHERE id = 'variable-id';\n

"},{"location":"v2/features/email-templates/variables/#problem-validation-error-for-optional-variable","title":"Problem: Validation error for optional variable","text":"

Symptoms: - MissingRequiredVariableError thrown for variable marked as optional - Email send fails unexpectedly

Causes: 1. Variable incorrectly marked as required in database 2. Validation logic bug 3. Template uses variable in required context

Solutions:

Check isRequired flag:

SELECT key, is_required FROM email_template_variables\nWHERE key = 'USER_PHONE' AND template_id = 'cuid123';\n

Update to optional:

UPDATE email_template_variables\nSET is_required = false\nWHERE key = 'USER_PHONE' AND template_id = 'cuid123';\n

Provide variable anyway:

// Temporary fix: always provide optional variables\ndata: {\n  USER_PHONE: volunteer.phone || '',  // Empty string if missing\n}\n

Check validation logic:

// Ensure validation checks for undefined AND null\nif (variable.isRequired && (data[variable.key] === undefined || data[variable.key] === null)) {\n  missing.push(variable.key);\n}\n

"},{"location":"v2/features/email-templates/variables/#problem-sample-value-not-used-in-preview","title":"Problem: Sample value not used in preview","text":"

Symptoms: - Preview shows empty values instead of sample values - Test send form doesn't pre-fill

Causes: 1. Sample value is null in database 2. Sample data initialization bug 3. Variable added after editor loaded

Solutions:

Check sample value exists:

SELECT key, sample_value FROM email_template_variables\nWHERE template_id = 'cuid123';\n

Update sample value:

UPDATE email_template_variables\nSET sample_value = 'John Doe'\nWHERE key = 'USER_NAME';\n

Refresh editor: - Close and reopen EmailTemplateEditorPage - Sample data reloads from variables

Manual preview data:

// Editor UI allows manual editing of sample data\nsetSampleData({\n  ...sampleData,\n  USER_NAME: 'Test Name',\n});\n

"},{"location":"v2/features/email-templates/variables/#problem-duplicate-variable-key-error","title":"Problem: Duplicate variable key error","text":"

Symptoms: - P2002: Unique constraint failed error when creating variable - Cannot add variable with same key

Causes: 1. Variable already exists for this template 2. Attempting to create duplicate

Solutions:

Check existing variables:

SELECT * FROM email_template_variables\nWHERE template_id = 'cuid123' AND key = 'USER_NAME';\n

Update existing instead:

await prisma.emailTemplateVariable.upsert({\n  where: {\n    templateId_key: {\n      templateId: template.id,\n      key: 'USER_NAME',\n    },\n  },\n  update: {\n    label: 'User Full Name',  // Updated label\n  },\n  create: {\n    templateId: template.id,\n    key: 'USER_NAME',\n    label: 'User Full Name',\n    // ...\n  },\n});\n

Use different key:

// If truly need separate variable\nkey: 'USER_FULL_NAME',  // Not USER_NAME\n

"},{"location":"v2/features/email-templates/variables/#problem-variables-not-alphabetically-sorted","title":"Problem: Variables not alphabetically sorted","text":"

Symptoms: - Variables appear in random order in editor - Want alphabetical order instead of custom sort

Causes: - Sort order not set alphabetically - Need to update sortOrder values

Solutions:

Sort alphabetically by key:

-- Generate new sort order based on alphabetical order\nWITH sorted AS (\n  SELECT id, ROW_NUMBER() OVER (PARTITION BY template_id ORDER BY key) AS new_order\n  FROM email_template_variables\n  WHERE template_id = 'cuid123'\n)\nUPDATE email_template_variables\nSET sort_order = sorted.new_order\nFROM sorted\nWHERE email_template_variables.id = sorted.id;\n

Sort by label:

WITH sorted AS (\n  SELECT id, ROW_NUMBER() OVER (PARTITION BY template_id ORDER BY label) AS new_order\n  FROM email_template_variables\n  WHERE template_id = 'cuid123'\n)\nUPDATE email_template_variables\nSET sort_order = sorted.new_order\nFROM sorted\nWHERE email_template_variables.id = sorted.id;\n

Manual custom order: - Use admin UI to drag-drop reorder - Saves custom sortOrder values

"},{"location":"v2/features/email-templates/variables/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/email-templates/variables/#variable-loading","title":"Variable Loading","text":"

Current Implementation: - Variables loaded with template via include: { variables: true } - Single database query (JOIN) - Fast (< 10ms for typical templates)

Optimization for Many Variables:

// If template has 100+ variables, consider pagination\nconst variables = await prisma.emailTemplateVariable.findMany({\n  where: { templateId: template.id },\n  orderBy: { sortOrder: 'asc' },\n  take: 50,  // Load first 50\n  skip: 0,   // Offset for pagination\n});\n

"},{"location":"v2/features/email-templates/variables/#validation-performance","title":"Validation Performance","text":"

Required Variable Check: - O(n) where n = number of required variables - Fast for typical templates (< 10 required vars) - No database queries (uses in-memory variable list)

Caching Variables:

// Cache template + variables to avoid DB lookup per send\nconst templateCache = new Map<string, EmailTemplate & { variables: EmailTemplateVariable[] }>();\n\nasync function loadTemplate(key: string) {\n  if (templateCache.has(key)) {\n    return templateCache.get(key)!;\n  }\n\n  const template = await prisma.emailTemplate.findUnique({\n    where: { key, isActive: true },\n    include: { variables: true },\n  });\n\n  if (template) {\n    templateCache.set(key, template);\n  }\n\n  return template;\n}\n

"},{"location":"v2/features/email-templates/variables/#best-practices","title":"Best Practices","text":""},{"location":"v2/features/email-templates/variables/#variable-naming-conventions","title":"Variable Naming Conventions","text":"

Use UPPERCASE_WITH_UNDERSCORES:

// \u2713 Good\nUSER_NAME\nSHIFT_DATE\nHAS_PHONE\nREPRESENTATIVE_EMAIL\n\n// \u2717 Bad\nuserName      // Not uppercase\nuser-name     // Dashes not underscores\nUserName      // PascalCase\n

Be Descriptive:

// \u2713 Good\nSHIFT_START_TIME\nCAMPAIGN_TITLE\nIS_EMAIL_VERIFIED\n\n// \u2717 Bad\nTIME          // Too vague\nTITLE         // Ambiguous\nVERIFIED      // Missing context\n

Prefix Booleans with IS/HAS:

// \u2713 Good\nHAS_PHONE\nIS_VERIFIED\nIS_CUT_ASSIGNED\n\n// \u2717 Bad\nPHONE         // Not clearly boolean\nVERIFIED      // Ambiguous (boolean or timestamp?)\n

"},{"location":"v2/features/email-templates/variables/#documentation","title":"Documentation","text":"

Always Provide Labels:

// \u2713 Good\nlabel: 'User\\'s Full Name',\ndescription: 'Full name of the email recipient',\n\n// \u2717 Bad\nlabel: 'Name',  // Too generic\ndescription: '',\n

Document Expected Format:

// \u2713 Good\ndescription: 'Shift date in format \"Saturday, March 15, 2026\"',\nsampleValue: 'Saturday, March 15, 2026',\n\n// \u2717 Bad\ndescription: 'The date',\nsampleValue: '2026-03-15',  // Doesn't match expected format\n

"},{"location":"v2/features/email-templates/variables/#sample-values","title":"Sample Values","text":"

Provide Realistic Examples:

// \u2713 Good\nsampleValue: 'John Doe',                      // USER_NAME\nsampleValue: 'Saturday, March 15, 2026',      // SHIFT_DATE\nsampleValue: '(555) 123-4567',                // USER_PHONE\n\n// \u2717 Bad\nsampleValue: 'test',                          // Not realistic\nsampleValue: '123',                           // Not realistic phone\n

Use JSON for Arrays/Objects:

// \u2713 Good\nsampleValue: JSON.stringify([\n  { name: 'Jane Doe', email: 'jane@example.com' },\n  { name: 'John Smith', email: 'john@example.com' },\n]),\n\n// \u2717 Bad\nsampleValue: 'array of representatives',  // Not parseable\n

"},{"location":"v2/features/email-templates/variables/#required-vs-optional","title":"Required vs Optional","text":"

Make Variables Required If: - Used in subject line (always visible) - Critical to email meaning (e.g., event date) - No reasonable default value

Make Variables Optional If: - Used in conditional blocks ({{#if}}) - Nice-to-have but not critical - Has fallback text in template

"},{"location":"v2/features/email-templates/variables/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/email-templates/variables/#frontend-documentation","title":"Frontend Documentation","text":"
  • EmailTemplateEditorPage.tsx \u2014 Variable insertion panel
  • EmailTemplatesPage.tsx \u2014 Variables tab
"},{"location":"v2/features/email-templates/variables/#backend-documentation","title":"Backend Documentation","text":"
  • Email Templates Module \u2014 Variable CRUD API
  • GET /api/email-templates/:id/variables \u2014 List variables
  • POST /api/email-templates/:id/variables \u2014 Create variable
  • PUT /api/email-templates/:id/variables/:varId \u2014 Update variable
  • DELETE /api/email-templates/:id/variables/:varId \u2014 Delete variable
"},{"location":"v2/features/email-templates/variables/#database-documentation","title":"Database Documentation","text":"
  • Email Templates Models \u2014 EmailTemplateVariable schema
"},{"location":"v2/features/email-templates/variables/#feature-documentation","title":"Feature Documentation","text":"
  • template-system.md \u2014 Email template engine overview
  • editor.md \u2014 Email template editor interface
  • versioning.md \u2014 Template version history
"},{"location":"v2/features/email-templates/versioning/","title":"Template Version History","text":""},{"location":"v2/features/email-templates/versioning/#overview","title":"Overview","text":"

The Template Version History system provides comprehensive audit trails for email template changes with automatic version creation, rollback capability, and change tracking. Every template save creates a new version snapshot, preserving the complete history of modifications with metadata about who changed what and why.

Key Features:

  • Automatic Version Creation \u2014 Every save creates a new version (no manual versioning)
  • Auto-Incrementing Version Numbers \u2014 Sequential numbering (1, 2, 3...) per template
  • Complete Snapshots \u2014 Stores subject line, HTML content, and text content
  • Change Notes \u2014 Optional admin-provided descriptions of changes
  • User Attribution \u2014 Tracks who created each version
  • Rollback Capability \u2014 Restore any previous version (non-destructive)
  • Version Comparison \u2014 Visual diff between any two versions
  • Audit Trail \u2014 Full history for compliance and debugging

Benefits:

  • Accident Recovery \u2014 Undo mistakes by rolling back to previous version
  • Change Tracking \u2014 See what changed and when
  • Compliance \u2014 Audit trail for regulatory requirements
  • Collaboration \u2014 Multiple admins can see each other's changes
  • Experimentation \u2014 Safely test changes knowing you can rollback
  • Documentation \u2014 Change notes explain why changes were made
"},{"location":"v2/features/email-templates/versioning/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph \"Version Creation Flow\"\n        Save[Admin Saves Template]\n        FindMax[Find Max Version Number]\n        Increment[Increment to Next Version]\n        CreateVersion[Create EmailTemplateVersion]\n        UpdateTemplate[Update EmailTemplate]\n\n        Save --> FindMax\n        FindMax --> Increment\n        Increment --> CreateVersion\n        CreateVersion --> UpdateTemplate\n    end\n\n    subgraph \"Database Models\"\n        Template[(EmailTemplate)]\n        Versions[(EmailTemplateVersion)]\n\n        Template -->|1:N| Versions\n    end\n\n    subgraph \"Version Data\"\n        Snapshot[Content Snapshot<br/>subject, HTML, text]\n        Meta[Metadata<br/>version number, change notes]\n        Attribution[Attribution<br/>created by user, timestamp]\n\n        Snapshot --> Versions\n        Meta --> Versions\n        Attribution --> Versions\n    end\n\n    subgraph \"Version Operations\"\n        List[List Version History]\n        Compare[Compare Two Versions]\n        Rollback[Rollback to Version]\n        View[View Version Details]\n\n        Versions --> List\n        Versions --> Compare\n        Versions --> Rollback\n        Versions --> View\n    end\n\n    subgraph \"Rollback Flow\"\n        SelectVersion[Select Old Version]\n        LoadContent[Load Old Content]\n        UpdateCurrent[Update Current Template]\n        CreateNewVersion[Create New Version<br/>'Rolled back to vX']\n\n        SelectVersion --> LoadContent\n        LoadContent --> UpdateCurrent\n        UpdateCurrent --> CreateNewVersion\n        CreateNewVersion --> Versions\n    end\n\n    Save --> Template\n    CreateVersion --> Versions\n    Rollback --> Template\n\n    style Save fill:#4a90e2,color:#fff\n    style CreateVersion fill:#50c878,color:#fff\n    style Rollback fill:#ff6b6b,color:#fff

Component Responsibilities:

  • EmailTemplateVersion \u2014 Version snapshot storage with metadata
  • Version Service \u2014 Auto-increment logic, version creation
  • Rollback Service \u2014 Restore old version as new version (non-destructive)
  • Comparison Service \u2014 Diff generation between versions
  • Audit Log \u2014 User attribution and change notes
"},{"location":"v2/features/email-templates/versioning/#database-model","title":"Database Model","text":""},{"location":"v2/features/email-templates/versioning/#emailtemplateversion-schema","title":"EmailTemplateVersion Schema","text":"

Table: email_template_versions

Field Type Description id String (CUID) Primary key templateId String Foreign key to EmailTemplate versionNumber Int Auto-incremented version (1, 2, 3...) subjectLine String Subject line snapshot htmlContent Text HTML content snapshot textContent Text Plain text content snapshot changeNotes String (optional) Admin-provided change description createdByUserId String (optional) User who created this version createdAt DateTime Version creation timestamp

Relations: - template \u2014 EmailTemplate (N:1) - createdBy \u2014 User (N:1)

Constraints: - Unique index on (templateId, versionNumber) for version lookup - Auto-increment logic in service layer (finds max + 1) - No ON DELETE CASCADE (preserve versions even if template deleted)

Prisma Schema:

model EmailTemplateVersion {\n  id              String   @id @default(cuid())\n  templateId      String\n  versionNumber   Int\n  subjectLine     String\n  htmlContent     String   @db.Text\n  textContent     String   @db.Text\n  changeNotes     String?\n  createdByUserId String?\n  createdAt       DateTime @default(now())\n\n  template  EmailTemplate @relation(fields: [templateId], references: [id])\n  createdBy User?         @relation(fields: [createdByUserId], references: [id])\n\n  @@unique([templateId, versionNumber])\n  @@index([templateId])\n  @@index([createdAt])\n  @@map(\"email_template_versions\")\n}\n

"},{"location":"v2/features/email-templates/versioning/#version-creation","title":"Version Creation","text":""},{"location":"v2/features/email-templates/versioning/#automatic-versioning-on-save","title":"Automatic Versioning on Save","text":"

When Versions Are Created: - Admin saves template via EmailTemplateEditorPage - API PUT /api/email-templates/:id endpoint called - Version created BEFORE updating template (snapshot current state)

Auto-Increment Logic:

// api/src/modules/email-templates/email-templates.service.ts\n\nasync function createVersion(\n  templateId: string,\n  options: {\n    changeNotes?: string;\n    createdByUserId?: string;\n  }\n) {\n  // 1. Find max version number for this template\n  const maxVersion = await prisma.emailTemplateVersion.findFirst({\n    where: { templateId },\n    orderBy: { versionNumber: 'desc' },\n    select: { versionNumber: true },\n  });\n\n  const nextVersion = (maxVersion?.versionNumber || 0) + 1;\n\n  // 2. Load current template content\n  const template = await prisma.emailTemplate.findUnique({\n    where: { id: templateId },\n  });\n\n  if (!template) {\n    throw new Error('Template not found');\n  }\n\n  // 3. Create version snapshot\n  const version = await prisma.emailTemplateVersion.create({\n    data: {\n      templateId,\n      versionNumber: nextVersion,\n      subjectLine: template.subjectLine,\n      htmlContent: template.htmlContent,\n      textContent: template.textContent,\n      changeNotes: options.changeNotes,\n      createdByUserId: options.createdByUserId,\n    },\n  });\n\n  return version;\n}\n

Save Template with Versioning:

// api/src/modules/email-templates/email-templates.routes.ts\n\nrouter.put('/:id', requireRole(SUPER_ADMIN), async (req, res) => {\n  const { id } = req.params;\n  const { subjectLine, htmlContent, textContent, changeNotes } = req.body;\n\n  try {\n    // 1. Create version BEFORE updating (snapshot current state)\n    await createVersion(id, {\n      changeNotes,\n      createdByUserId: req.user!.id,\n    });\n\n    // 2. Update template with new content\n    const updatedTemplate = await prisma.emailTemplate.update({\n      where: { id },\n      data: {\n        subjectLine,\n        htmlContent,\n        textContent,\n        updatedByUserId: req.user!.id,\n      },\n    });\n\n    res.json(updatedTemplate);\n  } catch (error) {\n    logger.error('Failed to save template', { error, templateId: id });\n    res.status(500).json({ error: 'Failed to save template' });\n  }\n});\n

Important: Version is created BEFORE updating template, so version snapshots the OLD content (not the new content). This preserves the exact state before the change.

"},{"location":"v2/features/email-templates/versioning/#version-number-sequence","title":"Version Number Sequence","text":"

Sequence Rules: - Starts at 1 for first version - Increments by 1 for each save - Per-template sequence (not global) - No gaps in sequence

Example Timeline:

Action Version Subject HTML Change Notes Create template 1 \"Welcome!\" <p>Hello</p> (initial version) Edit subject 2 \"Welcome to Our Platform!\" <p>Hello</p> \"Made subject more descriptive\" Add content 3 \"Welcome to Our Platform!\" <p>Hello {{USER_NAME}}</p> \"Added user name variable\" Rollback to v1 4 \"Welcome!\" <p>Hello</p> \"Rolled back to version 1\"

Note: Rollback creates NEW version (v4 in example), doesn't delete v2 and v3. This preserves complete audit trail.

"},{"location":"v2/features/email-templates/versioning/#change-notes","title":"Change Notes","text":"

Purpose: Describe what changed and why (audit trail documentation)

When Prompted: - EmailTemplateEditorPage shows \"Change Notes\" field on save - Optional but recommended - Stored in changeNotes field

Examples:

Good Change Notes:

- \"Added phone number conditional block\"\n- \"Fixed typo in subject line\"\n- \"Updated shift location variable to include address\"\n- \"Removed deprecated campaign URL variable\"\n- \"Rolled back to version 5 due to rendering issue\"\n

Poor Change Notes:

- \"update\" (not descriptive)\n- \"changes\" (too vague)\n- \"\" (empty, no context)\n

Implementation:

// EmailTemplateEditorPage.tsx\n\nconst [saveModalVisible, setSaveModalVisible] = useState(false);\nconst [changeNotes, setChangeNotes] = useState('');\n\nconst handleSave = async () => {\n  await api.put(`/api/email-templates/${id}`, {\n    subjectLine,\n    htmlContent,\n    textContent,\n    changeNotes: changeNotes || undefined,  // Optional\n  });\n\n  message.success('Template saved successfully');\n  navigate('/app/email-templates');\n};\n\n// Modal UI\n<Modal title=\"Save Template\" visible={saveModalVisible} onOk={handleSave}>\n  <Form.Item label=\"Change Notes (optional)\">\n    <TextArea\n      value={changeNotes}\n      onChange={(e) => setChangeNotes(e.target.value)}\n      placeholder=\"Describe what changed in this version\"\n      rows={4}\n    />\n  </Form.Item>\n</Modal>\n

"},{"location":"v2/features/email-templates/versioning/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/email-templates/versioning/#viewing-version-history","title":"Viewing Version History","text":"

Step 1: Open Template Detail - EmailTemplatesPage \u2192 click template row - Opens template detail modal

Step 2: Navigate to \"Version History\" Tab - Click \"Version History\" tab - Shows table of all versions

Version History Table Columns: - Version \u2014 Version number (e.g., \"v3\") - Created \u2014 Timestamp (e.g., \"2026-03-15 14:23\") - Created By \u2014 User name (e.g., \"John Doe\") - Change Notes \u2014 Description of changes - Actions \u2014 View, Compare, Restore buttons

Sorting: - Default: Descending by version number (newest first) - Can sort by created date or version number

"},{"location":"v2/features/email-templates/versioning/#viewing-version-details","title":"Viewing Version Details","text":"

Step 1: Click \"View\" Button - Version history table \u2192 click version row \u2192 \"View\" button

Step 2: Version Detail Modal - Shows version metadata: - Version number - Created by user - Created timestamp - Change notes - Shows content snapshot: - Subject line - HTML content (scrollable textarea) - Text content (scrollable textarea)

Step 3: Preview Rendered Version - Click \"Preview\" button - Renders HTML with sample data - Shows how email looked at that version

"},{"location":"v2/features/email-templates/versioning/#comparing-versions","title":"Comparing Versions","text":"

Step 1: Select Two Versions - Version history table \u2192 checkbox on two version rows - Click \"Compare Selected\" button

Step 2: Comparison Modal - Side-by-side diff view: - Left: Older version - Right: Newer version - Highlighting: - Green: Added lines - Red: Deleted lines - Yellow: Modified lines

Comparison Sections: - Subject Line Diff \u2014 Shows changes in subject - HTML Content Diff \u2014 Line-by-line HTML diff - Text Content Diff \u2014 Line-by-line text diff

Implementation:

import { diffLines } from 'diff';\n\nfunction renderDiff(oldContent: string, newContent: string) {\n  const diff = diffLines(oldContent, newContent);\n\n  return diff.map((part, index) => {\n    let color = 'black';\n    let backgroundColor = 'transparent';\n\n    if (part.added) {\n      color = 'green';\n      backgroundColor = '#e6ffed';\n    } else if (part.removed) {\n      color = 'red';\n      backgroundColor = '#ffebe9';\n    }\n\n    return (\n      <pre\n        key={index}\n        style={{\n          color,\n          backgroundColor,\n          margin: 0,\n          padding: '2px 4px',\n          fontFamily: 'monospace',\n          fontSize: 12,\n        }}\n      >\n        {part.value}\n      </pre>\n    );\n  });\n}\n

"},{"location":"v2/features/email-templates/versioning/#rolling-back-to-previous-version","title":"Rolling Back to Previous Version","text":"

Step 1: Select Version to Restore - Version history table \u2192 click version row

Step 2: Click \"Restore\" Button - Opens confirmation modal

Step 3: Confirm Rollback - Modal shows: - Version being restored (e.g., \"Version 5\") - Warning: \"This will create a new version with this content\" - Change notes field (pre-filled: \"Rolled back to version 5\")

Step 4: Confirm and Save - Click \"Confirm Restore\" - Creates new version (e.g., v10) with content from v5 - Current template updated to v5 content - Redirects to EmailTemplatesPage

Rollback Process:

// api/src/modules/email-templates/email-templates.routes.ts\n\nrouter.post('/:id/rollback/:versionNumber', requireRole(SUPER_ADMIN), async (req, res) => {\n  const { id } = req.params;\n  const versionNumber = parseInt(req.params.versionNumber);\n\n  try {\n    // 1. Load version to restore\n    const versionToRestore = await prisma.emailTemplateVersion.findUnique({\n      where: {\n        templateId_versionNumber: {\n          templateId: id,\n          versionNumber,\n        },\n      },\n    });\n\n    if (!versionToRestore) {\n      return res.status(404).json({ error: 'Version not found' });\n    }\n\n    // 2. Create version snapshot BEFORE rollback (current state)\n    await createVersion(id, {\n      changeNotes: `Rolled back to version ${versionNumber}`,\n      createdByUserId: req.user!.id,\n    });\n\n    // 3. Update template with old version content\n    const updatedTemplate = await prisma.emailTemplate.update({\n      where: { id },\n      data: {\n        subjectLine: versionToRestore.subjectLine,\n        htmlContent: versionToRestore.htmlContent,\n        textContent: versionToRestore.textContent,\n        updatedByUserId: req.user!.id,\n      },\n    });\n\n    res.json(updatedTemplate);\n  } catch (error) {\n    logger.error('Failed to rollback template', { error, templateId: id, versionNumber });\n    res.status(500).json({ error: 'Failed to rollback template' });\n  }\n});\n

Important: Rollback is non-destructive. It doesn't delete newer versions; it creates a NEW version with old content. This preserves the complete audit trail.

"},{"location":"v2/features/email-templates/versioning/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/email-templates/versioning/#creating-version-manually","title":"Creating Version Manually","text":"

When to Use: - Seed script initialization - Programmatic template updates - Testing version history

Example:

// Create initial version for new template\nconst template = await prisma.emailTemplate.create({\n  data: {\n    key: 'user-welcome',\n    name: 'Welcome Email',\n    category: 'SYSTEM',\n    subjectLine: 'Welcome!',\n    htmlContent: '<p>Hello {{USER_NAME}}</p>',\n    textContent: 'Hello {{USER_NAME}}',\n    isActive: true,\n  },\n});\n\n// Create version 1\nawait prisma.emailTemplateVersion.create({\n  data: {\n    templateId: template.id,\n    versionNumber: 1,\n    subjectLine: template.subjectLine,\n    htmlContent: template.htmlContent,\n    textContent: template.textContent,\n    changeNotes: 'Initial template creation',\n    createdByUserId: adminUser.id,\n  },\n});\n

"},{"location":"v2/features/email-templates/versioning/#loading-version-history","title":"Loading Version History","text":"

Fetch All Versions:

const versions = await prisma.emailTemplateVersion.findMany({\n  where: { templateId },\n  orderBy: { versionNumber: 'desc' },\n  include: {\n    createdBy: {\n      select: { name: true, email: true },\n    },\n  },\n});\n\nconsole.log('Version history:', versions);\n

Fetch Specific Version:

const version = await prisma.emailTemplateVersion.findUnique({\n  where: {\n    templateId_versionNumber: {\n      templateId: 'cuid123',\n      versionNumber: 5,\n    },\n  },\n});\n

Fetch Latest Version:

const latestVersion = await prisma.emailTemplateVersion.findFirst({\n  where: { templateId },\n  orderBy: { versionNumber: 'desc' },\n});\n\nconsole.log('Latest version:', latestVersion.versionNumber);\n

"},{"location":"v2/features/email-templates/versioning/#version-diff-generation","title":"Version Diff Generation","text":"

Line-by-Line Diff:

import { diffLines, Change } from 'diff';\n\ninterface VersionDiff {\n  subject: Change[];\n  html: Change[];\n  text: Change[];\n}\n\nfunction compareVersions(\n  oldVersion: EmailTemplateVersion,\n  newVersion: EmailTemplateVersion\n): VersionDiff {\n  return {\n    subject: diffLines(oldVersion.subjectLine, newVersion.subjectLine),\n    html: diffLines(oldVersion.htmlContent, newVersion.htmlContent),\n    text: diffLines(oldVersion.textContent, newVersion.textContent),\n  };\n}\n

Usage:

const version5 = await prisma.emailTemplateVersion.findUnique({\n  where: { templateId_versionNumber: { templateId, versionNumber: 5 } },\n});\n\nconst version6 = await prisma.emailTemplateVersion.findUnique({\n  where: { templateId_versionNumber: { templateId, versionNumber: 6 } },\n});\n\nconst diff = compareVersions(version5, version6);\n\nconsole.log('Subject changes:', diff.subject);\nconsole.log('HTML changes:', diff.html);\nconsole.log('Text changes:', diff.text);\n

Render Diff in UI:

// admin/src/components/VersionDiff.tsx\n\nimport { diffLines } from 'diff';\n\ninterface VersionDiffProps {\n  oldContent: string;\n  newContent: string;\n  title: string;\n}\n\nexport function VersionDiff({ oldContent, newContent, title }: VersionDiffProps) {\n  const diff = diffLines(oldContent, newContent);\n\n  return (\n    <div>\n      <h4>{title}</h4>\n      <pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 12 }}>\n        {diff.map((part, index) => {\n          let style = {};\n\n          if (part.added) {\n            style = { color: 'green', backgroundColor: '#e6ffed' };\n          } else if (part.removed) {\n            style = { color: 'red', backgroundColor: '#ffebe9' };\n          }\n\n          return (\n            <span key={index} style={style}>\n              {part.value}\n            </span>\n          );\n        })}\n      </pre>\n    </div>\n  );\n}\n

"},{"location":"v2/features/email-templates/versioning/#rollback-api-implementation","title":"Rollback API Implementation","text":"

Full Rollback Route:

// api/src/modules/email-templates/email-templates.routes.ts\n\nimport { Router } from 'express';\nimport { requireRole } from '@/middleware/auth';\nimport { prisma } from '@/config/database';\nimport { logger } from '@/utils/logger';\n\nconst router = Router();\n\nrouter.post('/:id/rollback/:versionNumber', requireRole('SUPER_ADMIN'), async (req, res) => {\n  const { id } = req.params;\n  const versionNumber = parseInt(req.params.versionNumber, 10);\n\n  if (isNaN(versionNumber) || versionNumber < 1) {\n    return res.status(400).json({ error: 'Invalid version number' });\n  }\n\n  try {\n    // 1. Load version to restore\n    const versionToRestore = await prisma.emailTemplateVersion.findUnique({\n      where: {\n        templateId_versionNumber: {\n          templateId: id,\n          versionNumber,\n        },\n      },\n    });\n\n    if (!versionToRestore) {\n      return res.status(404).json({ error: 'Version not found' });\n    }\n\n    // 2. Load current template\n    const currentTemplate = await prisma.emailTemplate.findUnique({\n      where: { id },\n    });\n\n    if (!currentTemplate) {\n      return res.status(404).json({ error: 'Template not found' });\n    }\n\n    // 3. Check if already at this version (no-op)\n    if (\n      currentTemplate.subjectLine === versionToRestore.subjectLine &&\n      currentTemplate.htmlContent === versionToRestore.htmlContent &&\n      currentTemplate.textContent === versionToRestore.textContent\n    ) {\n      return res.status(400).json({ error: 'Template already matches this version' });\n    }\n\n    // 4. Use transaction for atomicity\n    await prisma.$transaction(async (tx) => {\n      // 4a. Create version snapshot of CURRENT state\n      const maxVersion = await tx.emailTemplateVersion.findFirst({\n        where: { templateId: id },\n        orderBy: { versionNumber: 'desc' },\n        select: { versionNumber: true },\n      });\n\n      const nextVersion = (maxVersion?.versionNumber || 0) + 1;\n\n      await tx.emailTemplateVersion.create({\n        data: {\n          templateId: id,\n          versionNumber: nextVersion,\n          subjectLine: currentTemplate.subjectLine,\n          htmlContent: currentTemplate.htmlContent,\n          textContent: currentTemplate.textContent,\n          changeNotes: `Rolled back to version ${versionNumber}`,\n          createdByUserId: req.user!.id,\n        },\n      });\n\n      // 4b. Update template with OLD version content\n      await tx.emailTemplate.update({\n        where: { id },\n        data: {\n          subjectLine: versionToRestore.subjectLine,\n          htmlContent: versionToRestore.htmlContent,\n          textContent: versionToRestore.textContent,\n          updatedByUserId: req.user!.id,\n        },\n      });\n    });\n\n    // 5. Load updated template\n    const updatedTemplate = await prisma.emailTemplate.findUnique({\n      where: { id },\n    });\n\n    logger.info('Template rolled back', {\n      templateId: id,\n      toVersion: versionNumber,\n      userId: req.user!.id,\n    });\n\n    res.json(updatedTemplate);\n  } catch (error: any) {\n    logger.error('Failed to rollback template', {\n      error: error.message,\n      templateId: id,\n      versionNumber,\n    });\n    res.status(500).json({ error: 'Failed to rollback template' });\n  }\n});\n\nexport default router;\n
"},{"location":"v2/features/email-templates/versioning/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/email-templates/versioning/#problem-version-numbers-not-auto-incrementing","title":"Problem: Version numbers not auto-incrementing","text":"

Symptoms: - Duplicate version number error - P2002: Unique constraint failed on templateId_versionNumber

Causes: 1. Race condition (two saves at same time) 2. Max version query returns wrong result 3. Database constraint violated

Solutions:

Check max version:

SELECT MAX(version_number) FROM email_template_versions\nWHERE template_id = 'cuid123';\n

Use transaction for atomicity:

await prisma.$transaction(async (tx) => {\n  // 1. Find max version\n  const maxVersion = await tx.emailTemplateVersion.findFirst({\n    where: { templateId },\n    orderBy: { versionNumber: 'desc' },\n  });\n\n  const nextVersion = (maxVersion?.versionNumber || 0) + 1;\n\n  // 2. Create version (within same transaction)\n  await tx.emailTemplateVersion.create({\n    data: {\n      templateId,\n      versionNumber: nextVersion,\n      // ...\n    },\n  });\n});\n

Reset sequence if needed:

-- Check for gaps\nSELECT version_number FROM email_template_versions\nWHERE template_id = 'cuid123'\nORDER BY version_number;\n\n-- If gaps exist, renumber (DANGEROUS, only in dev)\nUPDATE email_template_versions\nSET version_number = (\n  SELECT COUNT(*) FROM email_template_versions AS v2\n  WHERE v2.template_id = email_template_versions.template_id\n    AND v2.created_at <= email_template_versions.created_at\n)\nWHERE template_id = 'cuid123';\n

"},{"location":"v2/features/email-templates/versioning/#problem-rollback-creates-infinite-versions","title":"Problem: Rollback creates infinite versions","text":"

Symptoms: - Rollback triggers another rollback - Version numbers increment rapidly

Causes: 1. Rollback doesn't use transaction 2. Version creation triggers template update hook

Solutions:

Use atomic transaction:

await prisma.$transaction(async (tx) => {\n  // Create version + update template in same transaction\n  await tx.emailTemplateVersion.create({ ... });\n  await tx.emailTemplate.update({ ... });\n});\n

Disable hooks during rollback:

// If using Prisma middleware, skip version creation during rollback\nprisma.$use(async (params, next) => {\n  if (params.model === 'EmailTemplate' && params.action === 'update') {\n    // Check if this is a rollback operation (via context flag)\n    if (params.args.data._isRollback) {\n      delete params.args.data._isRollback;\n      return next(params);  // Skip version creation\n    }\n\n    // Normal update: create version\n    await createVersion(params.args.where.id);\n  }\n\n  return next(params);\n});\n

"},{"location":"v2/features/email-templates/versioning/#problem-version-history-shows-duplicate-content","title":"Problem: Version history shows duplicate content","text":"

Symptoms: - Multiple versions with identical content - Version numbers increment but content unchanged

Causes: 1. Save triggered multiple times (double-click) 2. No dirty check before saving

Solutions:

Add content comparison before save:

router.put('/:id', requireRole(SUPER_ADMIN), async (req, res) => {\n  const { id } = req.params;\n  const { subjectLine, htmlContent, textContent, changeNotes } = req.body;\n\n  // 1. Load current template\n  const currentTemplate = await prisma.emailTemplate.findUnique({ where: { id } });\n\n  // 2. Check if content changed\n  if (\n    currentTemplate.subjectLine === subjectLine &&\n    currentTemplate.htmlContent === htmlContent &&\n    currentTemplate.textContent === textContent\n  ) {\n    return res.status(400).json({ error: 'No changes detected' });\n  }\n\n  // 3. Create version + update template\n  await createVersion(id, { changeNotes, createdByUserId: req.user!.id });\n  await prisma.emailTemplate.update({ where: { id }, data: { subjectLine, htmlContent, textContent } });\n\n  res.json({ success: true });\n});\n

Debounce save button:

// EmailTemplateEditorPage.tsx\n\nconst [saving, setSaving] = useState(false);\n\nconst handleSave = async () => {\n  if (saving) return;  // Prevent double-click\n\n  setSaving(true);\n  try {\n    await api.put(`/api/email-templates/${id}`, { ... });\n  } finally {\n    setSaving(false);\n  }\n};\n

"},{"location":"v2/features/email-templates/versioning/#problem-version-comparison-shows-no-diff","title":"Problem: Version comparison shows no diff","text":"

Symptoms: - Comparison modal shows identical content - No green/red highlighting

Causes: 1. Comparing version with itself 2. Versions truly identical (duplicate save)

Solutions:

Prevent self-comparison:

function handleCompare(version1: number, version2: number) {\n  if (version1 === version2) {\n    message.error('Cannot compare version with itself');\n    return;\n  }\n\n  // Load and compare versions...\n}\n

Check versions exist:

SELECT version_number, LENGTH(html_content) AS html_length\nFROM email_template_versions\nWHERE template_id = 'cuid123'\n  AND version_number IN (5, 6);\n

"},{"location":"v2/features/email-templates/versioning/#problem-rollback-doesnt-restore-variables","title":"Problem: Rollback doesn't restore variables","text":"

Symptoms: - Template content rolled back - Variables not restored (still showing new variables)

Causes: - Variables stored separately (not in version snapshot)

Current Limitation: - EmailTemplateVersion only stores content (subject, HTML, text) - Does NOT store variable definitions - Rolling back template doesn't affect variables

Workaround: - Manually restore variables via admin UI - Future enhancement: Version variable definitions too

Future Enhancement:

// Add to EmailTemplateVersion model\nvariablesSnapshot: Prisma.JsonValue  // JSON array of variables\n\n// When creating version, snapshot variables\nconst variables = await prisma.emailTemplateVariable.findMany({\n  where: { templateId },\n});\n\nawait prisma.emailTemplateVersion.create({\n  data: {\n    // ...\n    variablesSnapshot: variables as unknown as Prisma.InputJsonValue,\n  },\n});\n\n// When rolling back, restore variables\nconst variablesSnapshot = versionToRestore.variablesSnapshot as EmailTemplateVariable[];\n\nfor (const variable of variablesSnapshot) {\n  await prisma.emailTemplateVariable.upsert({\n    where: { templateId_key: { templateId, key: variable.key } },\n    update: variable,\n    create: { templateId, ...variable },\n  });\n}\n

"},{"location":"v2/features/email-templates/versioning/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/email-templates/versioning/#version-storage-growth","title":"Version Storage Growth","text":"

Storage Impact: - Each version stores 3 text fields (subject, HTML, text) - Typical template: 5-20KB per version - 100 versions = 500KB - 2MB per template

Optimization Options:

1. Compress Old Versions:

import zlib from 'zlib';\n\n// Compress HTML content before storing\nconst compressedHtml = zlib.gzipSync(htmlContent).toString('base64');\n\nawait prisma.emailTemplateVersion.create({\n  data: {\n    // ...\n    htmlContent: compressedHtml,\n    isCompressed: true,  // Add flag\n  },\n});\n\n// Decompress when loading\nif (version.isCompressed) {\n  const buffer = Buffer.from(version.htmlContent, 'base64');\n  const htmlContent = zlib.gunzipSync(buffer).toString('utf-8');\n}\n

2. Archive Old Versions:

-- Move versions > 1 year old to archive table\nINSERT INTO email_template_versions_archive\nSELECT * FROM email_template_versions\nWHERE created_at < NOW() - INTERVAL '1 year';\n\nDELETE FROM email_template_versions\nWHERE created_at < NOW() - INTERVAL '1 year';\n

3. Limit Version History:

// Keep only last 50 versions per template\nconst oldVersions = await prisma.emailTemplateVersion.findMany({\n  where: { templateId },\n  orderBy: { versionNumber: 'desc' },\n  skip: 50,  // Skip first 50 (keep these)\n});\n\n// Delete versions beyond 50\nawait prisma.emailTemplateVersion.deleteMany({\n  where: {\n    id: { in: oldVersions.map(v => v.id) },\n  },\n});\n

"},{"location":"v2/features/email-templates/versioning/#version-diff-performance","title":"Version Diff Performance","text":"

Performance Impact: - Diff generation is CPU-intensive for large templates - diffLines algorithm is O(n*m) where n, m = line counts

Optimization:

1. Cache Diff Results:

const diffCache = new Map<string, Change[]>();\n\nfunction getCachedDiff(oldContent: string, newContent: string): Change[] {\n  const cacheKey = `${hashString(oldContent)}-${hashString(newContent)}`;\n\n  if (diffCache.has(cacheKey)) {\n    return diffCache.get(cacheKey)!;\n  }\n\n  const diff = diffLines(oldContent, newContent);\n  diffCache.set(cacheKey, diff);\n\n  return diff;\n}\n

2. Limit Diff Size:

// For very large templates, show summary instead of full diff\nif (oldContent.length > 100000 || newContent.length > 100000) {\n  return {\n    error: 'Template too large for diff. Use version preview instead.',\n  };\n}\n

"},{"location":"v2/features/email-templates/versioning/#best-practices","title":"Best Practices","text":""},{"location":"v2/features/email-templates/versioning/#change-notes-guidelines","title":"Change Notes Guidelines","text":"

Always Provide Change Notes: - Documents WHY changes were made (not just WHAT) - Helps future admins understand context - Useful for compliance audits

Be Specific:

\u2713 Good:\n  - \"Added USER_PHONE variable with conditional block\"\n  - \"Fixed typo in subject line (Welcome vs Welcom)\"\n  - \"Updated shift location to include full address\"\n\n\u2717 Bad:\n  - \"updates\"\n  - \"changes\"\n  - \"fix\"\n

Reference Issues/Tickets:

\"Fixed rendering issue in Gmail (Ticket #123)\"\n\"Added new variable per Sarah's request\"\n

"},{"location":"v2/features/email-templates/versioning/#rollback-safety","title":"Rollback Safety","text":"

Always Review Before Rollback: - View version content before restoring - Compare with current version - Understand what will change

Use Change Notes:

\"Rolled back to version 5 - version 6 broke email rendering in Outlook\"\n

Test After Rollback: - Send test email after rollback - Verify rendering correct - Check all variables still work

"},{"location":"v2/features/email-templates/versioning/#version-retention","title":"Version Retention","text":"

Keep All Versions (Default): - Complete audit trail - Compliance requirements

Archive Old Versions (Optional): - Templates with 100+ versions - Versions older than 1 year - Move to separate archive table

Never Delete Versions: - Breaks audit trail - May violate compliance requirements - Disk space is cheap

"},{"location":"v2/features/email-templates/versioning/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/email-templates/versioning/#frontend-documentation","title":"Frontend Documentation","text":"
  • EmailTemplatesPage.tsx \u2014 Version history tab
  • EmailTemplateEditorPage.tsx \u2014 Change notes field
"},{"location":"v2/features/email-templates/versioning/#backend-documentation","title":"Backend Documentation","text":"
  • Email Templates Module \u2014 Version API routes
  • GET /api/email-templates/:id/versions \u2014 List versions
  • GET /api/email-templates/:id/versions/:versionNumber \u2014 Get version details
  • POST /api/email-templates/:id/rollback/:versionNumber \u2014 Rollback to version
"},{"location":"v2/features/email-templates/versioning/#database-documentation","title":"Database Documentation","text":"
  • Email Templates Models \u2014 EmailTemplateVersion schema
"},{"location":"v2/features/email-templates/versioning/#feature-documentation","title":"Feature Documentation","text":"
  • template-system.md \u2014 Email template engine overview
  • editor.md \u2014 Email template editor interface
  • variables.md \u2014 Template variable system
"},{"location":"v2/features/influence/","title":"Influence Module","text":"

The Influence module provides a complete advocacy campaign platform for email campaigns, representative lookup, response walls, and engagement tracking. It enables supporters to contact their elected officials on issues that matter.

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

The Influence module consists of five integrated components:

  1. Campaigns - Create and manage advocacy email campaigns
  2. Representatives - Lookup representatives by postal code
  3. Postal Codes - Postal code caching service
  4. Email Queue - Async email sending with BullMQ
  5. Responses - Public response wall with moderation
"},{"location":"v2/features/influence/#features","title":"Features","text":""},{"location":"v2/features/influence/#campaign-management","title":"Campaign Management","text":"
  • Create campaigns with title, description, and email template
  • Target federal, provincial, or municipal representatives
  • Track campaign statistics (emails sent, responses)
  • Public/private campaign visibility
  • Featured campaign highlighting
"},{"location":"v2/features/influence/#representative-lookup","title":"Representative Lookup","text":"
  • Represent API integration (federal/provincial)
  • Postal code \u2192 representative matching
  • Representative information caching
  • Multiple representative levels
  • District boundary support
"},{"location":"v2/features/influence/#email-sending","title":"Email Sending","text":"
  • Async email queue with BullMQ
  • Template processing with variable substitution
  • SMTP delivery with retry logic
  • Email tracking and statistics
  • Test mode support (MailHog)
"},{"location":"v2/features/influence/#response-wall","title":"Response Wall","text":"
  • Public response submissions
  • Email verification flow
  • Moderation dashboard
  • Upvoting system
  • Response filtering and export
"},{"location":"v2/features/influence/#user-flow","title":"User Flow","text":""},{"location":"v2/features/influence/#public-user-experience","title":"Public User Experience","text":"
  1. Browse Campaigns (/campaigns)
  2. View featured campaigns
  3. Search and filter (future)
  4. Click campaign to learn more

  5. Campaign Detail (/campaigns/:id)

  6. Read campaign description
  7. Enter postal code
  8. View matched representatives
  9. Customize email message
  10. Send email

  11. Response Wall (/responses/:campaignId)

  12. Submit public response
  13. Verify email address
  14. View verified responses
  15. Upvote responses
"},{"location":"v2/features/influence/#admin-experience","title":"Admin Experience","text":"
  1. Campaign Management (/app/influence/campaigns)
  2. Create campaigns
  3. Edit templates
  4. Configure targeting
  5. View statistics
  6. Manage visibility

  7. Response Moderation (/app/influence/responses)

  8. Review submissions
  9. Verify/reject responses
  10. Export data
  11. Monitor engagement

  12. Representative Cache (/app/influence/representatives)

  13. View cached representatives
  14. Refresh cache
  15. Monitor lookup statistics

  16. Email Queue (/app/influence/email-queue)

  17. Monitor queue status
  18. View failed jobs
  19. Retry failed emails
  20. Pause/resume queue
"},{"location":"v2/features/influence/#architecture","title":"Architecture","text":""},{"location":"v2/features/influence/#backend-components","title":"Backend Components","text":"

Modules: - api/src/modules/influence/campaigns/ - Campaign CRUD + public routes - api/src/modules/influence/representatives/ - Represent API integration - api/src/modules/influence/postal-codes/ - Postal code cache service - api/src/modules/influence/responses/ - Response CRUD + verification - api/src/modules/influence/campaign-emails/ - Email tracking - api/src/modules/influence/email-queue/ - Queue admin routes

Services: - api/src/services/email.service.ts - Nodemailer wrapper - api/src/services/email-queue.service.ts - BullMQ queue + worker

Database Models: - Campaign - Campaign definitions - CampaignEmail - Sent email tracking - Response - Public response submissions - PostalCodeCache - Cached representative data

"},{"location":"v2/features/influence/#frontend-components","title":"Frontend Components","text":"

Admin Pages: - admin/src/pages/CampaignsPage.tsx - Campaign management - admin/src/pages/ResponsesPage.tsx - Response moderation - admin/src/pages/RepresentativesPage.tsx - Cache admin - admin/src/pages/EmailQueuePage.tsx - Queue monitoring

Public Pages: - admin/src/pages/public/CampaignsListPage.tsx - Campaign listing - admin/src/pages/public/CampaignPage.tsx - Campaign detail + email form - admin/src/pages/public/ResponseWallPage.tsx - Response submissions

"},{"location":"v2/features/influence/#configuration","title":"Configuration","text":""},{"location":"v2/features/influence/#environment-variables","title":"Environment Variables","text":"
# Email\nEMAIL_TEST_MODE=true          # Use MailHog instead of SMTP\nSMTP_HOST=smtp.example.com\nSMTP_PORT=587\nSMTP_USER=user@example.com\nSMTP_PASS=password\n\n# Represent API (optional)\nREPRESENT_API_KEY=your_api_key\n\n# Redis (required for BullMQ)\nREDIS_PASSWORD=your_password\n
"},{"location":"v2/features/influence/#feature-flags","title":"Feature Flags","text":"

Email sending can be toggled via EMAIL_TEST_MODE: - true - Emails sent to MailHog (localhost:8025) - false - Emails sent via SMTP

"},{"location":"v2/features/influence/#integration-points","title":"Integration Points","text":""},{"location":"v2/features/influence/#represent-api","title":"Represent API","text":"

Represent API (https://represent.opennorth.ca/) provides: - Federal MP lookup by postal code - Provincial MLA/MPP lookup - District boundaries - Representative contact info

Rate Limits: 60 requests/minute

Caching Strategy: - Cache postal code \u2192 representative mappings - Refresh cache on 404 (postal code not found) - Cache expiration: 30 days

"},{"location":"v2/features/influence/#listmonk-newsletter-sync","title":"Listmonk Newsletter Sync","text":"

Campaign participants can be synced to Listmonk: - Email submissions \u2192 subscribers - Campaign \u2192 list assignment - Opt-in sync via LISTMONK_SYNC_ENABLED

"},{"location":"v2/features/influence/#email-queue-bullmq","title":"Email Queue (BullMQ)","text":"

BullMQ provides: - Async email processing - Job retry with exponential backoff - Queue monitoring and statistics - Job persistence in Redis

"},{"location":"v2/features/influence/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/#public-endpoints","title":"Public Endpoints","text":"
GET  /api/campaigns/public              # List public campaigns\nGET  /api/campaigns/public/:id          # Get campaign details\nPOST /api/campaigns/:id/send-email      # Send campaign email\nGET  /api/representatives/:postalCode   # Lookup representatives\nPOST /api/responses                     # Submit response\nGET  /api/responses/verify/:token       # Verify email\nGET  /api/responses/campaign/:id        # Get campaign responses\nPOST /api/responses/:id/upvote          # Upvote response\n
"},{"location":"v2/features/influence/#admin-endpoints","title":"Admin Endpoints","text":"
GET    /api/campaigns                   # List all campaigns\nPOST   /api/campaigns                   # Create campaign\nGET    /api/campaigns/:id               # Get campaign\nPATCH  /api/campaigns/:id               # Update campaign\nDELETE /api/campaigns/:id               # Delete campaign\nGET    /api/campaigns/:id/emails        # Get campaign emails\nGET    /api/responses                   # List responses (admin)\nPATCH  /api/responses/:id               # Update response\nDELETE /api/responses/:id               # Delete response\nGET    /api/representatives/cache       # View cache\nPOST   /api/representatives/cache/refresh # Refresh cache\nGET    /api/email-queue/stats           # Queue statistics\nPOST   /api/email-queue/pause           # Pause queue\nPOST   /api/email-queue/resume          # Resume queue\n
"},{"location":"v2/features/influence/#related-documentation","title":"Related Documentation","text":"
  • Campaigns
  • Representatives
  • Email Queue
  • Responses
  • Backend Campaign Module
  • Backend Representatives Module
  • Backend Responses Module
  • Email Service
  • Campaign Manager Guide
"},{"location":"v2/features/influence/campaigns/","title":"Campaign Management System","text":""},{"location":"v2/features/influence/campaigns/#overview","title":"Overview","text":"

The campaign management system is the core of Changemaker Lite's advocacy email platform. It enables organizations to create, configure, and manage advocacy campaigns that allow supporters to contact elected representatives via email. The system supports multiple campaign types, customizable features via feature flags, and a complete lifecycle from draft to archived status.

Key Capabilities:

  • Multi-status lifecycle: Draft \u2192 Active \u2192 Paused \u2192 Archived workflow
  • 12 feature flags: Granular control over campaign behavior
  • Government level filtering: Target specific levels (federal, provincial, municipal)
  • Cover photo uploads: Visual campaign branding
  • Slug-based routing: SEO-friendly public URLs
  • Response wall integration: Public display of campaign responses
  • Email tracking: Monitor sent emails and campaign effectiveness

Use Cases:

  • Advocacy campaigns targeting elected officials
  • Public awareness campaigns with response sharing
  • Email-your-MP initiatives
  • Multi-level government outreach
  • Time-limited advocacy actions
"},{"location":"v2/features/influence/campaigns/#architecture","title":"Architecture","text":"
graph TD\n    A[Admin User] -->|Creates Campaign| B[CampaignsPage]\n    B -->|POST /api/campaigns| C[Campaign Service]\n    C -->|Save| D[(Campaign Model)]\n\n    E[Public User] -->|Browses| F[CampaignsListPage]\n    F -->|GET /api/public/campaigns| C\n\n    E -->|Views Campaign| G[CampaignPage]\n    G -->|GET /api/public/campaigns/:slug| C\n    G -->|Lookup Reps| H[Representatives Service]\n    G -->|Send Email| I[Email Queue Service]\n    I -->|Add Job| J[(BullMQ Redis)]\n\n    K[Email Worker] -->|Process Jobs| J\n    K -->|Send SMTP| L[Email Recipients]\n    K -->|Track| M[(CampaignEmail Model)]\n\n    D -->|1:N| M\n    D -->|1:N| N[(Response Model)]\n\n    style D fill:#e1f5ff\n    style M fill:#e1f5ff\n    style N fill:#e1f5ff\n    style J fill:#fff4e1

Flow Description:

  1. Admin creates campaign \u2192 Campaign service validates and saves to database
  2. Public user browses \u2192 Campaign service returns active campaigns
  3. User views campaign \u2192 Representatives service looks up postal code
  4. User sends email \u2192 Email queue service adds job to BullMQ
  5. Worker processes job \u2192 Email sent via SMTP, tracked in CampaignEmail model
  6. User submits response \u2192 Response service creates response for moderation
"},{"location":"v2/features/influence/campaigns/#database-models","title":"Database Models","text":""},{"location":"v2/features/influence/campaigns/#campaign-model","title":"Campaign Model","text":"

See Campaign Model Documentation for full schema.

Key Fields:

  • status: DRAFT | ACTIVE | PAUSED | ARCHIVED
  • targetGovernmentLevels: Array of government levels (federal, provincial, municipal)
  • emailSubjectTemplate: Subject line with {{VAR}} placeholders
  • emailBodyTemplate: Email body with {{VAR}} placeholders
  • coverPhotoUrl: Campaign hero image URL
  • slug: URL-friendly identifier

Feature Flags (12 total):

Flag Type Default Description allowSmtpEmail boolean true Enable email sending allowCallTracking boolean false Enable phone call logging showResponseWall boolean true Display response wall requireEmailVerification boolean true Verify response emails allowAnonymousResponses boolean false Allow responses without login highlightCampaign boolean false Feature on homepage showProgressBar boolean true Display response count progress allowSharing boolean true Enable social sharing buttons requirePostalCode boolean true Require postal code for lookup allowCustomMessage boolean true Users can edit email text trackEmailOpens boolean false Track email opens (future) notifyOnResponse boolean true Email admin on new responses

Related Models:

  • CampaignEmail \u2014 Tracks sent emails
  • Response \u2014 Public responses to campaign
  • Representative \u2014 Email recipients
"},{"location":"v2/features/influence/campaigns/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/campaigns/#admin-endpoints","title":"Admin Endpoints","text":"

See Campaigns Module API Reference for full details.

Method Endpoint Auth Description GET /api/campaigns SUPER_ADMIN, INFLUENCE_ADMIN List all campaigns (paginated) GET /api/campaigns/:id SUPER_ADMIN, INFLUENCE_ADMIN Get campaign details POST /api/campaigns SUPER_ADMIN, INFLUENCE_ADMIN Create new campaign PUT /api/campaigns/:id SUPER_ADMIN, INFLUENCE_ADMIN Update campaign PATCH /api/campaigns/:id/status SUPER_ADMIN, INFLUENCE_ADMIN Update campaign status DELETE /api/campaigns/:id SUPER_ADMIN Delete campaign"},{"location":"v2/features/influence/campaigns/#public-endpoints","title":"Public Endpoints","text":"

See Campaigns Public API Reference.

Method Endpoint Auth Description GET /api/public/campaigns None List active campaigns GET /api/public/campaigns/:slug None Get campaign by slug"},{"location":"v2/features/influence/campaigns/#configuration","title":"Configuration","text":""},{"location":"v2/features/influence/campaigns/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description EMAIL_TEST_MODE boolean false Send emails to MailHog instead of SMTP SMTP_HOST string - SMTP server hostname SMTP_PORT number 587 SMTP server port SMTP_USER string - SMTP username SMTP_PASS string - SMTP password SMTP_FROM_EMAIL string - Default sender email SMTP_FROM_NAME string - Default sender name"},{"location":"v2/features/influence/campaigns/#site-settings","title":"Site Settings","text":"

SMTP settings can be configured via Site Settings (overrides env vars):

{\n  smtpHost: string | null,\n  smtpPort: number | null,\n  smtpUser: string | null,\n  smtpPass: string | null,\n  smtpFromEmail: string | null,\n  smtpFromName: string | null\n}\n
"},{"location":"v2/features/influence/campaigns/#upload-configuration","title":"Upload Configuration","text":"

Cover photos uploaded to /uploads/campaigns/{campaignId}/{filename}.

Limits: - Max file size: 10MB - Allowed formats: jpg, jpeg, png, gif, webp

"},{"location":"v2/features/influence/campaigns/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/influence/campaigns/#1-create-campaign","title":"1. Create Campaign","text":"

[Screenshot: CampaignsPage with \"Create Campaign\" button]

Steps:

  1. Navigate to Influence > Campaigns
  2. Click Create Campaign button
  3. Fill in campaign details:
  4. Title (required)
  5. Description (required)
  6. Target government levels (select all that apply)
  7. Email subject template (use {{VAR}} for dynamic content)
  8. Email body template (HTML supported)
  9. Upload cover photo (optional)
  10. Click Save (saves as DRAFT)

Code Example (CampaignsPage.tsx):

const handleCreate = async (values: any) => {\n  try {\n    const formData = new FormData();\n    formData.append('title', values.title);\n    formData.append('description', values.description);\n    formData.append('targetGovernmentLevels', JSON.stringify(values.targetGovernmentLevels));\n    formData.append('emailSubjectTemplate', values.emailSubjectTemplate);\n    formData.append('emailBodyTemplate', values.emailBodyTemplate);\n\n    if (values.coverPhoto?.[0]?.originFileObj) {\n      formData.append('coverPhoto', values.coverPhoto[0].originFileObj);\n    }\n\n    await api.post('/campaigns', formData, {\n      headers: { 'Content-Type': 'multipart/form-data' }\n    });\n\n    message.success('Campaign created successfully');\n    fetchCampaigns();\n  } catch (error) {\n    message.error('Failed to create campaign');\n  }\n};\n
"},{"location":"v2/features/influence/campaigns/#2-configure-feature-flags","title":"2. Configure Feature Flags","text":"

[Screenshot: Campaign edit modal with feature flags section]

Steps:

  1. Click Edit on campaign row
  2. Scroll to Feature Flags section
  3. Toggle flags as needed:
  4. allowSmtpEmail: Enable email sending (required for email campaigns)
  5. showResponseWall: Display public response wall
  6. requireEmailVerification: Require email verification for responses
  7. highlightCampaign: Feature on homepage
  8. allowCustomMessage: Let users edit email text before sending
  9. Click Save

Best Practices:

  • Enable requireEmailVerification for public response walls
  • Disable allowCustomMessage if you want consistent messaging
  • Use highlightCampaign sparingly (max 2-3 campaigns)
  • Enable showProgressBar to encourage participation
"},{"location":"v2/features/influence/campaigns/#3-test-campaign","title":"3. Test Campaign","text":"

[Screenshot: Campaign preview with test email form]

Steps:

  1. Set campaign status to ACTIVE
  2. Navigate to public campaign page: /campaigns/{slug}
  3. Enter test postal code
  4. Review representative lookup results
  5. Send test email to your own email address
  6. Verify email content and formatting

Troubleshooting:

  • If no representatives found \u2192 Check Represent API cache
  • If email not received \u2192 Check Email Queue page for job status
  • If email formatting broken \u2192 Review HTML template syntax
"},{"location":"v2/features/influence/campaigns/#4-publish-campaign","title":"4. Publish Campaign","text":"

[Screenshot: Campaign status dropdown]

Steps:

  1. Return to Campaigns page
  2. Click Status dropdown on campaign row
  3. Select ACTIVE
  4. Campaign now visible on public campaigns page

Status Lifecycle:

stateDiagram-v2\n    [*] --> DRAFT: Create\n    DRAFT --> ACTIVE: Publish\n    ACTIVE --> PAUSED: Pause\n    PAUSED --> ACTIVE: Resume\n    ACTIVE --> ARCHIVED: Archive\n    PAUSED --> ARCHIVED: Archive\n    ARCHIVED --> [*]
"},{"location":"v2/features/influence/campaigns/#5-monitor-campaign","title":"5. Monitor Campaign","text":"

[Screenshot: Campaign emails drawer with stats]

Steps:

  1. Click View Emails on campaign row
  2. Review email stats:
  3. Total sent
  4. Success rate
  5. Failed emails
  6. View individual email details (recipient, status, sent date)
  7. Retry failed emails if needed

Metrics to Track:

  • Emails sent per day
  • Response wall submissions
  • Verification rate (if enabled)
  • Geographic distribution (via postal codes)
"},{"location":"v2/features/influence/campaigns/#public-workflow","title":"Public Workflow","text":""},{"location":"v2/features/influence/campaigns/#1-browse-campaigns","title":"1. Browse Campaigns","text":"

[Screenshot: Public campaigns list page with featured campaigns]

User Journey:

  1. User visits /campaigns
  2. Sees featured campaigns (if highlightCampaign enabled)
  3. Browses active campaigns grid
  4. Clicks campaign card to view details

Code Example (CampaignsListPage.tsx):

const CampaignsListPage: React.FC = () => {\n  const [campaigns, setCampaigns] = useState<Campaign[]>([]);\n  const [featured, setFeatured] = useState<Campaign[]>([]);\n\n  useEffect(() => {\n    const fetchCampaigns = async () => {\n      const { data } = await axios.get('/api/public/campaigns');\n\n      const featuredCampaigns = data.filter((c: Campaign) =>\n        c.highlightCampaign && c.status === 'ACTIVE'\n      );\n      const regularCampaigns = data.filter((c: Campaign) =>\n        !c.highlightCampaign && c.status === 'ACTIVE'\n      );\n\n      setFeatured(featuredCampaigns);\n      setCampaigns(regularCampaigns);\n    };\n\n    fetchCampaigns();\n  }, []);\n\n  return (\n    <PublicLayout>\n      {featured.length > 0 && (\n        <FeaturedCampaigns campaigns={featured} />\n      )}\n      <CampaignGrid campaigns={campaigns} />\n    </PublicLayout>\n  );\n};\n
"},{"location":"v2/features/influence/campaigns/#2-view-campaign-details","title":"2. View Campaign Details","text":"

[Screenshot: Campaign detail page with postal code lookup form]

User Journey:

  1. User clicks campaign card
  2. Navigated to /campaigns/{slug}
  3. Reads campaign description
  4. Enters postal code in lookup form
  5. System fetches representatives from Represent API
  6. User selects representatives to email
"},{"location":"v2/features/influence/campaigns/#3-send-email","title":"3. Send Email","text":"

[Screenshot: Email form with representative selection]

User Journey:

  1. User reviews list of representatives
  2. Selects representatives to email (checkboxes)
  3. Reviews email subject and body
  4. Edits message if allowCustomMessage enabled
  5. Adds personal details (name, email)
  6. Clicks Send Email
  7. Email jobs added to BullMQ queue
  8. User sees confirmation message

Code Example (CampaignPage.tsx):

const handleSendEmails = async (values: any) => {\n  try {\n    const payload = {\n      campaignId: campaign.id,\n      senderName: values.senderName,\n      senderEmail: values.senderEmail,\n      postalCode: values.postalCode,\n      representativeIds: values.representativeIds,\n      customMessage: campaign.allowCustomMessage ? values.customMessage : null\n    };\n\n    await axios.post('/api/public/campaigns/send-email', payload);\n\n    message.success('Your emails have been sent!');\n\n    if (campaign.showResponseWall) {\n      message.info('Share your response on the Response Wall!');\n    }\n  } catch (error) {\n    message.error('Failed to send emails');\n  }\n};\n
"},{"location":"v2/features/influence/campaigns/#4-submit-response-optional","title":"4. Submit Response (Optional)","text":"

[Screenshot: Response submission form]

User Journey:

  1. After sending email, user clicks Share Your Response
  2. Navigated to /responses/{campaignId}/submit
  3. Fills in response form:
  4. Type (EMAIL, LETTER, PHONE_CALL, etc.)
  5. Message
  6. Screenshot (optional)
  7. Submits response
  8. If requireEmailVerification enabled \u2192 verification email sent
  9. User clicks verification link in email
  10. Response appears on public response wall (after admin approval if moderation enabled)
"},{"location":"v2/features/influence/campaigns/#volunteer-workflow","title":"Volunteer Workflow","text":"

Not applicable \u2014 campaigns are admin-managed and public-facing.

"},{"location":"v2/features/influence/campaigns/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/influence/campaigns/#backend-create-campaign","title":"Backend: Create Campaign","text":"
// api/src/modules/influence/campaigns/campaigns.service.ts\n\nasync createCampaign(\n  data: Prisma.CampaignUncheckedCreateInput,\n  createdByUserId: string\n): Promise<Campaign> {\n  // Generate slug from title\n  const baseSlug = data.title\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, '-')\n    .replace(/^-|-$/g, '');\n\n  let slug = baseSlug;\n  let counter = 1;\n\n  // Ensure unique slug\n  while (await this.prisma.campaign.findUnique({ where: { slug } })) {\n    slug = `${baseSlug}-${counter}`;\n    counter++;\n  }\n\n  return this.prisma.campaign.create({\n    data: {\n      ...data,\n      slug,\n      createdByUserId,\n      status: 'DRAFT',\n      // Default feature flags\n      allowSmtpEmail: data.allowSmtpEmail ?? true,\n      showResponseWall: data.showResponseWall ?? true,\n      requireEmailVerification: data.requireEmailVerification ?? true,\n      allowCustomMessage: data.allowCustomMessage ?? true,\n      showProgressBar: data.showProgressBar ?? true,\n      allowSharing: data.allowSharing ?? true,\n      requirePostalCode: data.requirePostalCode ?? true,\n      notifyOnResponse: data.notifyOnResponse ?? true\n    }\n  });\n}\n
"},{"location":"v2/features/influence/campaigns/#frontend-campaign-card-component","title":"Frontend: Campaign Card Component","text":"
// admin/src/pages/public/CampaignsListPage.tsx\n\nconst CampaignCard: React.FC<{ campaign: Campaign }> = ({ campaign }) => {\n  const navigate = useNavigate();\n\n  return (\n    <Card\n      hoverable\n      cover={\n        campaign.coverPhotoUrl && (\n          <img\n            alt={campaign.title}\n            src={campaign.coverPhotoUrl}\n            style={{ height: 200, objectFit: 'cover' }}\n          />\n        )\n      }\n      onClick={() => navigate(`/campaigns/${campaign.slug}`)}\n    >\n      <Card.Meta\n        title={campaign.title}\n        description={\n          <Space direction=\"vertical\" size=\"small\">\n            <Typography.Paragraph ellipsis={{ rows: 3 }}>\n              {campaign.description}\n            </Typography.Paragraph>\n\n            {campaign.showProgressBar && (\n              <Progress\n                percent={Math.min(\n                  (campaign._count?.responses || 0) / (campaign.responseGoal || 100) * 100,\n                  100\n                )}\n                status=\"active\"\n              />\n            )}\n\n            <Space>\n              {campaign.targetGovernmentLevels.map(level => (\n                <Tag key={level} color=\"blue\">{level}</Tag>\n              ))}\n            </Space>\n          </Space>\n        }\n      />\n    </Card>\n  );\n};\n
"},{"location":"v2/features/influence/campaigns/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/influence/campaigns/#campaign-not-visible-on-public-page","title":"Campaign Not Visible on Public Page","text":"

Symptoms: - Campaign exists in admin but doesn't appear on /campaigns

Solutions:

  1. Check campaign status \u2192 must be ACTIVE
  2. Verify no draft campaigns leaked \u2192 filter by status in query
  3. Check Nginx caching \u2192 clear cache or disable for /api/public/campaigns

Debugging:

# Check campaign status\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_lite -c \\\n  \"SELECT id, title, status, slug FROM campaigns WHERE slug = 'your-slug';\"\n\n# Check public endpoint response\ncurl http://localhost:4000/api/public/campaigns | jq\n
"},{"location":"v2/features/influence/campaigns/#email-template-variables-not-replaced","title":"Email Template Variables Not Replaced","text":"

Symptoms: - Email sent with {{senderName}} instead of actual name

Solutions:

  1. Verify variable syntax \u2192 must use double curly braces {{VAR}}
  2. Check email service interpolation \u2192 ensure processTemplate() called
  3. Verify variable names match \u2192 senderName, senderEmail, postalCode, recipientName, recipientEmail

Code Fix (email.service.ts):

private processTemplate(template: string, variables: Record<string, string>): string {\n  let processed = template;\n\n  Object.entries(variables).forEach(([key, value]) => {\n    const regex = new RegExp(`{{${key}}}`, 'g');\n    processed = processed.replace(regex, value || '');\n  });\n\n  return processed;\n}\n
"},{"location":"v2/features/influence/campaigns/#cover-photo-upload-fails","title":"Cover Photo Upload Fails","text":"

Symptoms: - Upload spinner never completes - Error: \"File too large\"

Solutions:

  1. Check file size \u2192 max 10MB
  2. Verify file format \u2192 must be jpg/jpeg/png/gif/webp
  3. Check upload directory permissions \u2192 /uploads/campaigns must be writable
  4. Increase Nginx upload limit \u2192 client_max_body_size 20M;

Docker Volume Fix:

# docker-compose.yml\nservices:\n  api:\n    volumes:\n      - ./uploads:/app/uploads:rw  # Ensure :rw (read-write)\n
"},{"location":"v2/features/influence/campaigns/#representatives-not-loading","title":"Representatives Not Loading","text":"

Symptoms: - Postal code lookup returns empty array

Solutions:

  1. Check Represent API status \u2192 visit https://represent.opennorth.ca/health
  2. Verify postal code format \u2192 must be valid Canadian postal code (K1A 0A1)
  3. Check representative cache \u2192 may need refresh
  4. Review API rate limits \u2192 Represent API has rate limits

Manual Cache Refresh:

# Via admin UI\n# Navigate to Influence > Representatives\n# Enter postal code in search box\n# Click \"Lookup\"\n\n# Via API\ncurl -X POST http://localhost:4000/api/representatives/lookup \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"postalCode\": \"K1A0A1\"}'\n
"},{"location":"v2/features/influence/campaigns/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/influence/campaigns/#campaign-listing-optimization","title":"Campaign Listing Optimization","text":"

Query Optimization:

// Include response count for progress bar\nconst campaigns = await prisma.campaign.findMany({\n  where: { status: 'ACTIVE' },\n  include: {\n    _count: {\n      select: { responses: true }\n    }\n  },\n  orderBy: [\n    { highlightCampaign: 'desc' }, // Featured first\n    { createdAt: 'desc' }\n  ]\n});\n

Caching Strategy:

  • Cache active campaigns list for 5 minutes (Redis)
  • Invalidate cache on campaign status change
  • Use ETags for HTTP caching
"},{"location":"v2/features/influence/campaigns/#email-queue-scaling","title":"Email Queue Scaling","text":"

BullMQ Configuration:

// api/src/services/email-queue.service.ts\n\nconst queue = new Queue('campaign-emails', {\n  connection: redisConnection,\n  defaultJobOptions: {\n    attempts: 3,\n    backoff: {\n      type: 'exponential',\n      delay: 5000 // 5s, 25s, 125s\n    },\n    removeOnComplete: {\n      age: 86400, // Keep completed jobs for 24h\n      count: 1000\n    },\n    removeOnFail: {\n      age: 604800 // Keep failed jobs for 7 days\n    }\n  }\n});\n\n// Worker concurrency\nconst worker = new Worker('campaign-emails', processCampaignEmail, {\n  connection: redisConnection,\n  concurrency: 5 // Process 5 emails simultaneously\n});\n

Monitoring:

  • Track queue size with Prometheus cm_email_queue_size metric
  • Alert if queue size > 1000
  • Monitor worker processing rate
"},{"location":"v2/features/influence/campaigns/#cover-photo-optimization","title":"Cover Photo Optimization","text":"

Image Processing:

// api/src/modules/influence/campaigns/campaigns.service.ts\n\nimport sharp from 'sharp';\n\nasync uploadCoverPhoto(file: Express.Multer.File, campaignId: string): Promise<string> {\n  const filename = `${Date.now()}-${file.originalname}`;\n  const uploadPath = `/uploads/campaigns/${campaignId}`;\n\n  // Create directory\n  await fs.mkdir(uploadPath, { recursive: true });\n\n  // Optimize image\n  await sharp(file.buffer)\n    .resize(1200, 630, { // Open Graph ratio\n      fit: 'cover',\n      position: 'center'\n    })\n    .jpeg({ quality: 85 })\n    .toFile(`${uploadPath}/${filename}`);\n\n  return `${uploadPath}/${filename}`;\n}\n

CDN Integration:

  • Serve cover photos via CDN (Cloudflare, CloudFront)
  • Use responsive images with srcset
  • Lazy load images below fold
"},{"location":"v2/features/influence/campaigns/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/influence/campaigns/#backend-modules","title":"Backend Modules","text":"
  • Campaigns Module \u2014 Full API reference
  • Representatives Module \u2014 Represent API integration
  • Responses Module \u2014 Response wall system
  • Email Queue Module \u2014 BullMQ email processing
"},{"location":"v2/features/influence/campaigns/#frontend-pages","title":"Frontend Pages","text":"
  • CampaignsPage \u2014 Admin campaign management
  • CampaignPage \u2014 Public campaign view
  • CampaignsListPage \u2014 Public campaign listing
  • ResponsesPage \u2014 Response moderation
"},{"location":"v2/features/influence/campaigns/#database-models_1","title":"Database Models","text":"
  • Campaign \u2014 Campaign schema
  • CampaignEmail \u2014 Email tracking schema
  • Response \u2014 Response schema
  • Representative \u2014 Representative schema
"},{"location":"v2/features/influence/campaigns/#configuration_1","title":"Configuration","text":"
  • Environment Variables \u2014 SMTP configuration
  • Site Settings \u2014 Global settings API
"},{"location":"v2/features/influence/campaigns/#guides","title":"Guides","text":"
  • Email Sending Guide \u2014 Email queue and BullMQ
  • Response Wall Guide \u2014 Response moderation workflow
  • Representative Lookup Guide \u2014 Represent API integration
"},{"location":"v2/features/influence/email-queue/","title":"Email Queue System","text":""},{"location":"v2/features/influence/email-queue/#overview","title":"Overview","text":"

The email queue system manages asynchronous email sending for advocacy campaigns using BullMQ and Redis. It provides reliable email delivery, retry logic, job monitoring, and comprehensive tracking of email campaign effectiveness.

Key Capabilities:

  • BullMQ integration: Redis-backed job queue for email processing
  • Automatic retry logic: Failed emails retried with exponential backoff
  • Job status tracking: Monitor queued, active, completed, and failed jobs
  • Rate limiting: Prevent SMTP server overload
  • Email tracking: Track sent emails per campaign
  • Admin monitoring: Real-time queue statistics and job management
  • Test mode: Send to MailHog instead of SMTP for testing

Use Cases:

  • Bulk email sending for advocacy campaigns
  • Reliable email delivery with retry
  • Email campaign effectiveness tracking
  • SMTP server load management
  • Development email testing
"},{"location":"v2/features/influence/email-queue/#architecture","title":"Architecture","text":"
graph TD\n    A[Public User] -->|Send Email| B[CampaignPage]\n    B -->|POST /api/public/campaigns/send-email| C[Campaign Service]\n    C -->|Add Job| D[Email Queue Service]\n    D -->|Create Job| E[(BullMQ Redis)]\n\n    F[Email Worker] -->|Poll Jobs| E\n    F -->|Process Job| G{Send Email}\n    G -->|Success| H[Email Service - SMTP]\n    G -->|Failure| I[Retry Logic]\n\n    H -->|Track| J[(CampaignEmail Model)]\n    I -->|Backoff| E\n\n    K[Admin User] -->|Monitor| L[EmailQueuePage]\n    L -->|GET /api/email-queue/stats| D\n    L -->|Pause/Resume| D\n    L -->|Clean Jobs| D\n\n    M[Prometheus] -->|Scrape| N[Metrics Endpoint]\n    N -->|cm_email_queue_size| E\n\n    style E fill:#fff4e1\n    style J fill:#e1f5ff

Flow Description:

  1. User sends email \u2192 Campaign service adds job to BullMQ queue
  2. Worker polls queue \u2192 Picks up job for processing
  3. Email sent via SMTP \u2192 Nodemailer sends email
  4. Success \u2192 Job marked completed, email tracked in database
  5. Failure \u2192 Job retried with exponential backoff (3 attempts)
  6. Admin monitors \u2192 View queue stats, pause/resume, clean old jobs
"},{"location":"v2/features/influence/email-queue/#database-models","title":"Database Models","text":""},{"location":"v2/features/influence/email-queue/#campaignemail-model","title":"CampaignEmail Model","text":"

See CampaignEmail Model Documentation for full schema.

Key Fields:

Field Type Description id String (UUID) Primary key campaignId String Associated campaign recipientEmail String Email recipient recipientName String? Recipient name senderEmail String Sender email address senderName String Sender name subject String Email subject line body String (Text) Email body content status Enum QUEUED, SENT, FAILED jobId String? BullMQ job ID sentAt DateTime? When email was sent failureReason String? Error message if failed

Indexes:

  • campaignId, status \u2014 For campaign email stats
  • jobId \u2014 For job status lookups
  • sentAt \u2014 For time-based queries

Related Models:

  • Campaign \u2014 Campaign association
  • Representative \u2014 Email recipients
"},{"location":"v2/features/influence/email-queue/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/email-queue/#admin-endpoints","title":"Admin Endpoints","text":"

See Email Queue Module API Reference for full details.

Method Endpoint Auth Description GET /api/email-queue/stats SUPER_ADMIN, INFLUENCE_ADMIN Get queue statistics POST /api/email-queue/pause SUPER_ADMIN, INFLUENCE_ADMIN Pause queue processing POST /api/email-queue/resume SUPER_ADMIN, INFLUENCE_ADMIN Resume queue processing POST /api/email-queue/clean SUPER_ADMIN Clean completed/failed jobs POST /api/email-queue/retry/:jobId SUPER_ADMIN, INFLUENCE_ADMIN Retry failed job"},{"location":"v2/features/influence/email-queue/#public-endpoints","title":"Public Endpoints","text":"

Email queue jobs are created via campaign email endpoints (no direct public access).

"},{"location":"v2/features/influence/email-queue/#configuration","title":"Configuration","text":""},{"location":"v2/features/influence/email-queue/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description REDIS_HOST string localhost Redis hostname REDIS_PORT number 6379 Redis port REDIS_PASSWORD string - Redis password (required) SMTP_HOST string - SMTP server hostname SMTP_PORT number 587 SMTP server port SMTP_USER string - SMTP username SMTP_PASS string - SMTP password SMTP_FROM_EMAIL string - Default sender email SMTP_FROM_NAME string - Default sender name EMAIL_TEST_MODE boolean false Send to MailHog instead of SMTP EMAIL_QUEUE_CONCURRENCY number 5 Max concurrent email workers"},{"location":"v2/features/influence/email-queue/#bullmq-configuration","title":"BullMQ Configuration","text":"
// api/src/services/email-queue.service.ts\n\nconst queueOptions = {\n  connection: {\n    host: process.env.REDIS_HOST,\n    port: parseInt(process.env.REDIS_PORT || '6379'),\n    password: process.env.REDIS_PASSWORD\n  },\n  defaultJobOptions: {\n    attempts: 3,\n    backoff: {\n      type: 'exponential',\n      delay: 5000 // 5s, 25s, 125s\n    },\n    removeOnComplete: {\n      age: 86400, // Keep completed jobs for 24h\n      count: 1000\n    },\n    removeOnFail: {\n      age: 604800 // Keep failed jobs for 7 days\n    }\n  }\n};\n
"},{"location":"v2/features/influence/email-queue/#worker-configuration","title":"Worker Configuration","text":"
const workerOptions = {\n  connection: queueOptions.connection,\n  concurrency: parseInt(process.env.EMAIL_QUEUE_CONCURRENCY || '5'),\n  limiter: {\n    max: 60, // Max 60 emails\n    duration: 60000 // per minute\n  }\n};\n
"},{"location":"v2/features/influence/email-queue/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/influence/email-queue/#1-view-queue-statistics","title":"1. View Queue Statistics","text":"

[Screenshot: EmailQueuePage with queue stats cards]

Steps:

  1. Navigate to Influence > Email Queue
  2. View queue statistics:
  3. Waiting: Jobs queued for processing
  4. Active: Jobs currently being processed
  5. Completed: Successfully sent emails
  6. Failed: Failed emails requiring attention
  7. Monitor queue health (green if waiting < 100)

Code Example (EmailQueuePage.tsx):

const [stats, setStats] = useState({\n  waiting: 0,\n  active: 0,\n  completed: 0,\n  failed: 0,\n  paused: false\n});\n\nuseEffect(() => {\n  const fetchStats = async () => {\n    const { data } = await api.get('/email-queue/stats');\n    setStats(data);\n  };\n\n  fetchStats();\n\n  // Refresh every 5 seconds\n  const interval = setInterval(fetchStats, 5000);\n  return () => clearInterval(interval);\n}, []);\n\nreturn (\n  <Row gutter={16}>\n    <Col span={6}>\n      <Card>\n        <Statistic\n          title=\"Waiting\"\n          value={stats.waiting}\n          valueStyle={{ color: stats.waiting > 100 ? '#cf1322' : '#3f8600' }}\n        />\n      </Card>\n    </Col>\n    <Col span={6}>\n      <Card>\n        <Statistic title=\"Active\" value={stats.active} />\n      </Card>\n    </Col>\n    <Col span={6}>\n      <Card>\n        <Statistic title=\"Completed\" value={stats.completed} />\n      </Card>\n    </Col>\n    <Col span={6}>\n      <Card>\n        <Statistic\n          title=\"Failed\"\n          value={stats.failed}\n          valueStyle={{ color: stats.failed > 0 ? '#cf1322' : undefined }}\n        />\n      </Card>\n    </Col>\n  </Row>\n);\n
"},{"location":"v2/features/influence/email-queue/#2-pauseresume-queue","title":"2. Pause/Resume Queue","text":"

[Screenshot: EmailQueuePage with pause/resume buttons]

Steps:

  1. Click Pause Queue button
  2. Queue stops processing new jobs
  3. Active jobs complete normally
  4. Status indicator shows \"Paused\"
  5. Click Resume Queue to restart processing

Use Cases:

  • Temporary SMTP server maintenance
  • Stop email sending during testing
  • Prevent email sending during off-hours

Code Example (email-queue.service.ts):

async pauseQueue(): Promise<void> {\n  await this.queue.pause();\n  logger.info('Email queue paused');\n}\n\nasync resumeQueue(): Promise<void> {\n  await this.queue.resume();\n  logger.info('Email queue resumed');\n}\n\nasync isPaused(): Promise<boolean> {\n  return this.queue.isPaused();\n}\n
"},{"location":"v2/features/influence/email-queue/#3-clean-completed-jobs","title":"3. Clean Completed Jobs","text":"

[Screenshot: EmailQueuePage with clean jobs button]

Steps:

  1. Click Clean Jobs dropdown
  2. Select cleanup type:
  3. Completed (>24h): Remove old successful jobs
  4. Failed (>7d): Remove old failed jobs
  5. All Completed: Remove all successful jobs
  6. Confirm cleanup
  7. Jobs removed from queue, stats updated

Code Example (email-queue.routes.ts):

router.post('/clean', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'), async (req, res) => {\n  try {\n    const { type } = req.body; // 'completed', 'failed', 'all-completed'\n\n    let count = 0;\n\n    if (type === 'completed') {\n      count = await queue.clean(86400000, 1000, 'completed'); // 24h\n    } else if (type === 'failed') {\n      count = await queue.clean(604800000, 1000, 'failed'); // 7d\n    } else if (type === 'all-completed') {\n      count = await queue.clean(0, 0, 'completed'); // All\n    }\n\n    logger.info(`Cleaned ${count} ${type} jobs`);\n\n    res.json({ count });\n  } catch (error) {\n    logger.error('Failed to clean jobs:', error);\n    res.status(500).json({ error: 'Failed to clean jobs' });\n  }\n});\n
"},{"location":"v2/features/influence/email-queue/#4-retry-failed-jobs","title":"4. Retry Failed Jobs","text":"

[Screenshot: Failed jobs table with retry buttons]

Steps:

  1. Scroll to Failed Jobs section
  2. View failed job details (error message, recipient)
  3. Click Retry button on specific job
  4. Job re-queued for processing
  5. Monitor in Active tab

Bulk Retry:

  1. Select multiple failed jobs (checkboxes)
  2. Click Retry Selected button
  3. All selected jobs re-queued

Code Example (email-queue.service.ts):

async retryFailedJob(jobId: string): Promise<void> {\n  const job = await this.queue.getJob(jobId);\n\n  if (!job) {\n    throw new Error('Job not found');\n  }\n\n  if (await job.isFailed()) {\n    await job.retry();\n    logger.info(`Retrying job ${jobId}`);\n  } else {\n    throw new Error('Job is not failed');\n  }\n}\n\nasync retryAllFailed(): Promise<number> {\n  const failed = await this.queue.getFailed();\n  let count = 0;\n\n  for (const job of failed) {\n    await job.retry();\n    count++;\n  }\n\n  logger.info(`Retried ${count} failed jobs`);\n  return count;\n}\n
"},{"location":"v2/features/influence/email-queue/#public-workflow","title":"Public Workflow","text":""},{"location":"v2/features/influence/email-queue/#1-send-campaign-email","title":"1. Send Campaign Email","text":"

[Screenshot: CampaignPage with email sending form]

User Journey:

  1. User selects representatives to email
  2. Fills in sender details (name, email)
  3. Reviews/edits email content (if allowed)
  4. Clicks Send Email button
  5. System creates email jobs (one per recipient)
  6. Jobs added to BullMQ queue
  7. User sees confirmation message

Code Example (campaigns-public.routes.ts):

router.post('/send-email', async (req, res) => {\n  try {\n    const {\n      campaignId,\n      senderName,\n      senderEmail,\n      postalCode,\n      representativeIds,\n      customMessage\n    } = req.body;\n\n    const campaign = await prisma.campaign.findUnique({\n      where: { id: campaignId }\n    });\n\n    if (!campaign || campaign.status !== 'ACTIVE') {\n      return res.status(400).json({ error: 'Campaign not active' });\n    }\n\n    const representatives = await prisma.representative.findMany({\n      where: { id: { in: representativeIds } }\n    });\n\n    // Create email jobs\n    const emailJobs = [];\n\n    for (const rep of representatives) {\n      const emailData = {\n        campaignId,\n        recipientEmail: rep.email,\n        recipientName: rep.name,\n        senderEmail,\n        senderName,\n        subject: processTemplate(campaign.emailSubjectTemplate, {\n          senderName,\n          recipientName: rep.name,\n          postalCode\n        }),\n        body: customMessage || processTemplate(campaign.emailBodyTemplate, {\n          senderName,\n          senderEmail,\n          recipientName: rep.name,\n          recipientEmail: rep.email,\n          postalCode\n        })\n      };\n\n      // Add to queue\n      const job = await emailQueueService.addEmail(emailData);\n\n      emailJobs.push(job);\n    }\n\n    res.json({\n      success: true,\n      emailsQueued: emailJobs.length\n    });\n  } catch (error) {\n    logger.error('Failed to queue campaign emails:', error);\n    res.status(500).json({ error: 'Failed to send emails' });\n  }\n});\n
"},{"location":"v2/features/influence/email-queue/#2-job-processing","title":"2. Job Processing","text":"

Worker Processing Logic:

// api/src/services/email-queue.service.ts\n\nimport { Worker } from 'bullmq';\nimport { emailService } from './email.service';\n\nconst worker = new Worker('campaign-emails', async (job) => {\n  const {\n    campaignId,\n    recipientEmail,\n    recipientName,\n    senderEmail,\n    senderName,\n    subject,\n    body\n  } = job.data;\n\n  try {\n    // Send email via nodemailer\n    await emailService.send({\n      to: recipientEmail,\n      from: {\n        email: process.env.SMTP_FROM_EMAIL!,\n        name: process.env.SMTP_FROM_NAME!\n      },\n      replyTo: {\n        email: senderEmail,\n        name: senderName\n      },\n      subject,\n      html: body\n    });\n\n    // Update database record\n    await prisma.campaignEmail.update({\n      where: { jobId: job.id },\n      data: {\n        status: 'SENT',\n        sentAt: new Date()\n      }\n    });\n\n    logger.info(`Sent campaign email ${job.id} to ${recipientEmail}`);\n\n    // Update Prometheus metric\n    metrics.campaignEmailsSent.inc({ campaign_id: campaignId });\n\n    return { success: true };\n  } catch (error) {\n    logger.error(`Failed to send email ${job.id}:`, error);\n\n    // Update database record\n    await prisma.campaignEmail.update({\n      where: { jobId: job.id },\n      data: {\n        status: 'FAILED',\n        failureReason: error.message\n      }\n    });\n\n    throw error; // Let BullMQ handle retry\n  }\n}, workerOptions);\n\nworker.on('completed', (job) => {\n  logger.info(`Job ${job.id} completed`);\n});\n\nworker.on('failed', (job, err) => {\n  logger.error(`Job ${job?.id} failed:`, err);\n});\n
"},{"location":"v2/features/influence/email-queue/#volunteer-workflow","title":"Volunteer Workflow","text":"

Not applicable \u2014 email queue is system-level.

"},{"location":"v2/features/influence/email-queue/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/influence/email-queue/#backend-email-queue-service","title":"Backend: Email Queue Service","text":"
// api/src/services/email-queue.service.ts\n\nimport { Queue, QueueEvents } from 'bullmq';\nimport { logger } from '../utils/logger';\nimport { prisma } from '../config/database';\n\nexport class EmailQueueService {\n  private queue: Queue;\n  private queueEvents: QueueEvents;\n\n  constructor() {\n    const connection = {\n      host: process.env.REDIS_HOST!,\n      port: parseInt(process.env.REDIS_PORT || '6379'),\n      password: process.env.REDIS_PASSWORD\n    };\n\n    this.queue = new Queue('campaign-emails', {\n      connection,\n      defaultJobOptions: {\n        attempts: 3,\n        backoff: {\n          type: 'exponential',\n          delay: 5000\n        },\n        removeOnComplete: {\n          age: 86400,\n          count: 1000\n        },\n        removeOnFail: {\n          age: 604800\n        }\n      }\n    });\n\n    this.queueEvents = new QueueEvents('campaign-emails', { connection });\n\n    this.setupEventHandlers();\n  }\n\n  private setupEventHandlers(): void {\n    this.queueEvents.on('completed', ({ jobId }) => {\n      logger.info(`Email job ${jobId} completed`);\n    });\n\n    this.queueEvents.on('failed', ({ jobId, failedReason }) => {\n      logger.error(`Email job ${jobId} failed: ${failedReason}`);\n    });\n  }\n\n  async addEmail(data: any): Promise<{ jobId: string }> {\n    // Create database record\n    const emailRecord = await prisma.campaignEmail.create({\n      data: {\n        ...data,\n        status: 'QUEUED'\n      }\n    });\n\n    // Add job to queue\n    const job = await this.queue.add('send-email', data, {\n      jobId: emailRecord.id\n    });\n\n    // Update database with job ID\n    await prisma.campaignEmail.update({\n      where: { id: emailRecord.id },\n      data: { jobId: job.id }\n    });\n\n    logger.info(`Queued email job ${job.id}`);\n\n    return { jobId: job.id! };\n  }\n\n  async getStats(): Promise<any> {\n    const counts = await this.queue.getJobCounts();\n\n    return {\n      waiting: counts.waiting || 0,\n      active: counts.active || 0,\n      completed: counts.completed || 0,\n      failed: counts.failed || 0,\n      paused: await this.queue.isPaused()\n    };\n  }\n\n  async pauseQueue(): Promise<void> {\n    await this.queue.pause();\n  }\n\n  async resumeQueue(): Promise<void> {\n    await this.queue.resume();\n  }\n\n  async clean(grace: number, limit: number, type: string): Promise<number> {\n    return this.queue.clean(grace, limit, type as any);\n  }\n}\n\nexport const emailQueueService = new EmailQueueService();\n
"},{"location":"v2/features/influence/email-queue/#frontend-queue-stats-dashboard","title":"Frontend: Queue Stats Dashboard","text":"
// admin/src/pages/EmailQueuePage.tsx\n\nimport React, { useState, useEffect } from 'react';\nimport { Card, Row, Col, Statistic, Button, Space, message } from 'antd';\nimport { PlayCircleOutlined, PauseCircleOutlined, ClearOutlined } from '@ant-design/icons';\nimport { api } from '../../lib/api';\n\nconst EmailQueuePage: React.FC = () => {\n  const [stats, setStats] = useState<any>(null);\n  const [loading, setLoading] = useState(false);\n\n  const fetchStats = async () => {\n    const { data } = await api.get('/email-queue/stats');\n    setStats(data);\n  };\n\n  useEffect(() => {\n    fetchStats();\n    const interval = setInterval(fetchStats, 5000);\n    return () => clearInterval(interval);\n  }, []);\n\n  const handlePause = async () => {\n    setLoading(true);\n    try {\n      await api.post('/email-queue/pause');\n      message.success('Queue paused');\n      fetchStats();\n    } catch (error) {\n      message.error('Failed to pause queue');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleResume = async () => {\n    setLoading(true);\n    try {\n      await api.post('/email-queue/resume');\n      message.success('Queue resumed');\n      fetchStats();\n    } catch (error) {\n      message.error('Failed to resume queue');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleClean = async (type: string) => {\n    setLoading(true);\n    try {\n      const { data } = await api.post('/email-queue/clean', { type });\n      message.success(`Cleaned ${data.count} jobs`);\n      fetchStats();\n    } catch (error) {\n      message.error('Failed to clean jobs');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  if (!stats) return <Card loading />;\n\n  return (\n    <Space direction=\"vertical\" size=\"large\" style={{ width: '100%' }}>\n      <Card title=\"Queue Statistics\">\n        <Row gutter={16}>\n          <Col span={6}>\n            <Statistic\n              title=\"Waiting\"\n              value={stats.waiting}\n              valueStyle={{ color: stats.waiting > 100 ? '#cf1322' : '#3f8600' }}\n            />\n          </Col>\n          <Col span={6}>\n            <Statistic title=\"Active\" value={stats.active} />\n          </Col>\n          <Col span={6}>\n            <Statistic title=\"Completed\" value={stats.completed} />\n          </Col>\n          <Col span={6}>\n            <Statistic\n              title=\"Failed\"\n              value={stats.failed}\n              valueStyle={{ color: stats.failed > 0 ? '#cf1322' : undefined }}\n            />\n          </Col>\n        </Row>\n      </Card>\n\n      <Card title=\"Queue Controls\">\n        <Space>\n          {stats.paused ? (\n            <Button\n              type=\"primary\"\n              icon={<PlayCircleOutlined />}\n              onClick={handleResume}\n              loading={loading}\n            >\n              Resume Queue\n            </Button>\n          ) : (\n            <Button\n              icon={<PauseCircleOutlined />}\n              onClick={handlePause}\n              loading={loading}\n            >\n              Pause Queue\n            </Button>\n          )}\n\n          <Button\n            icon={<ClearOutlined />}\n            onClick={() => handleClean('completed')}\n            loading={loading}\n          >\n            Clean Completed\n          </Button>\n\n          <Button\n            danger\n            icon={<ClearOutlined />}\n            onClick={() => handleClean('failed')}\n            loading={loading}\n          >\n            Clean Failed\n          </Button>\n        </Space>\n      </Card>\n    </Space>\n  );\n};\n\nexport default EmailQueuePage;\n
"},{"location":"v2/features/influence/email-queue/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/influence/email-queue/#emails-stuck-in-queue","title":"Emails Stuck in Queue","text":"

Symptoms: - Waiting count increases but active/completed don't - Jobs not processing

Solutions:

  1. Check worker status \u2192 docker compose logs api | grep \"Worker\"
  2. Verify Redis connection \u2192 docker compose exec redis redis-cli ping
  3. Check SMTP configuration \u2192 test with /api/auth/test-email
  4. Restart worker \u2192 docker compose restart api

Debugging:

# Check Redis keys\ndocker compose exec redis redis-cli --pass $REDIS_PASSWORD\n> KEYS bull:campaign-emails:*\n\n# Check worker logs\ndocker compose logs -f api | grep \"Email worker\"\n\n# Check queue status\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:4000/api/email-queue/stats\n
"},{"location":"v2/features/influence/email-queue/#high-failure-rate","title":"High Failure Rate","text":"

Symptoms: - Many jobs failing - Failed count increasing rapidly

Solutions:

  1. Check SMTP credentials \u2192 verify username/password
  2. Review failure reasons \u2192 check failureReason field in database
  3. Check SMTP server status \u2192 verify server is reachable
  4. Review rate limits \u2192 may be hitting SMTP server limits

Common Failure Reasons:

  • 535 Authentication failed \u2192 Invalid SMTP credentials
  • 550 Mailbox unavailable \u2192 Recipient email doesn't exist
  • 421 Too many connections \u2192 Reduce concurrency
  • Connection timeout \u2192 SMTP server unreachable

Code Fix (email.service.ts):

// Add better error handling\nasync send(options: EmailOptions): Promise<void> {\n  try {\n    await this.transporter.sendMail(options);\n  } catch (error) {\n    if (error.responseCode === 535) {\n      throw new Error('SMTP authentication failed - check credentials');\n    } else if (error.responseCode === 550) {\n      throw new Error('Recipient mailbox unavailable');\n    } else if (error.code === 'ETIMEDOUT') {\n      throw new Error('SMTP server connection timeout');\n    } else {\n      throw error;\n    }\n  }\n}\n
"},{"location":"v2/features/influence/email-queue/#redis-connection-issues","title":"Redis Connection Issues","text":"

Symptoms: - Error: \"ECONNREFUSED\" or \"NOAUTH\" - Queue operations fail

Solutions:

  1. Verify Redis is running \u2192 docker compose ps redis
  2. Check Redis password \u2192 ensure REDIS_PASSWORD matches docker-compose.yml
  3. Check Redis port \u2192 default 6379
  4. Verify Redis auth \u2192 docker compose exec redis redis-cli --pass $REDIS_PASSWORD ping

Fix Redis Auth:

# docker-compose.yml\nservices:\n  redis:\n    image: redis:7-alpine\n    command: redis-server --requirepass ${REDIS_PASSWORD}\n    ports:\n      - \"6379:6379\"\n
"},{"location":"v2/features/influence/email-queue/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/influence/email-queue/#concurrency-tuning","title":"Concurrency Tuning","text":"

Worker Concurrency:

// Adjust based on SMTP server limits\nconst workerOptions = {\n  concurrency: 5, // Process 5 emails simultaneously\n  limiter: {\n    max: 60, // Max 60 emails per minute\n    duration: 60000\n  }\n};\n

SMTP Server Limits:

  • Gmail: 100 emails/day (consumer), 2000/day (Workspace)
  • SendGrid: Varies by plan (40k/day free tier)
  • AWS SES: 14 emails/second, 200 emails/day (sandbox)
"},{"location":"v2/features/influence/email-queue/#queue-monitoring","title":"Queue Monitoring","text":"

Prometheus Metrics:

import { Counter, Gauge } from 'prom-client';\n\nexport const campaignEmailsQueued = new Counter({\n  name: 'cm_campaign_emails_queued_total',\n  help: 'Total campaign emails queued',\n  labelNames: ['campaign_id']\n});\n\nexport const campaignEmailsSent = new Counter({\n  name: 'cm_campaign_emails_sent_total',\n  help: 'Total campaign emails sent',\n  labelNames: ['campaign_id']\n});\n\nexport const emailQueueSize = new Gauge({\n  name: 'cm_email_queue_size',\n  help: 'Current email queue size',\n  labelNames: ['status']\n});\n\n// Update gauge every 30 seconds\nsetInterval(async () => {\n  const stats = await emailQueueService.getStats();\n\n  emailQueueSize.set({ status: 'waiting' }, stats.waiting);\n  emailQueueSize.set({ status: 'active' }, stats.active);\n  emailQueueSize.set({ status: 'failed' }, stats.failed);\n}, 30000);\n
"},{"location":"v2/features/influence/email-queue/#database-optimization","title":"Database Optimization","text":"

Index Strategy:

CREATE INDEX idx_campaign_email_status ON campaign_emails (status);\nCREATE INDEX idx_campaign_email_campaign_id ON campaign_emails (campaign_id);\nCREATE INDEX idx_campaign_email_sent_at ON campaign_emails (sent_at);\n

Query Optimization:

// Paginated campaign email stats\nconst emails = await prisma.campaignEmail.findMany({\n  where: { campaignId },\n  select: {\n    id: true,\n    recipientEmail: true,\n    status: true,\n    sentAt: true,\n    failureReason: true\n  },\n  orderBy: { createdAt: 'desc' },\n  take: 100,\n  skip: page * 100\n});\n
"},{"location":"v2/features/influence/email-queue/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/influence/email-queue/#backend-modules","title":"Backend Modules","text":"
  • Email Queue Module \u2014 Full API reference
  • Email Service \u2014 SMTP configuration
  • Campaigns Module \u2014 Campaign integration
"},{"location":"v2/features/influence/email-queue/#frontend-pages","title":"Frontend Pages","text":"
  • EmailQueuePage \u2014 Admin queue monitoring
  • CampaignsPage \u2014 Campaign management
"},{"location":"v2/features/influence/email-queue/#database-models_1","title":"Database Models","text":"
  • CampaignEmail \u2014 Email tracking schema
  • Campaign \u2014 Campaign schema
"},{"location":"v2/features/influence/email-queue/#configuration_1","title":"Configuration","text":"
  • Environment Variables \u2014 SMTP/Redis configuration
  • BullMQ Documentation \u2014 Official BullMQ docs
"},{"location":"v2/features/influence/email-queue/#monitoring","title":"Monitoring","text":"
  • Prometheus Metrics \u2014 Email queue metrics
  • Grafana Dashboards \u2014 Queue visualization
"},{"location":"v2/features/influence/postal-codes/","title":"Postal Code Geocoding Cache","text":""},{"location":"v2/features/influence/postal-codes/#overview","title":"Overview","text":"

The postal code geocoding cache system stores geographic coordinates for Canadian postal codes, enabling faster representative lookups and reducing external API calls. It integrates with the multi-provider geocoding service to provide reliable centroid calculations for postal code-based geographic queries.

Key Capabilities:

  • Postal code caching: Store lat/lng centroids for postal codes
  • Geocoding integration: Automatic geocoding via multi-provider service
  • Cache hit optimization: Reduce external API calls
  • Administrative data: City and province extraction
  • Representative lookup: Fast postal code \u2192 representative mapping

Use Cases:

  • Campaign postal code lookups
  • Geographic representative mapping
  • Postal code validation
  • Centroid-based spatial queries
"},{"location":"v2/features/influence/postal-codes/#architecture","title":"Architecture","text":"
graph TD\n    A[Campaign Service] -->|Lookup Postal Code| B[Postal Code Service]\n    B -->|Check Cache| C{Cache Hit?}\n    C -->|Yes| D[Return Cached Centroid]\n    C -->|No| E[Geocoding Service]\n\n    E -->|Geocode| F[Multi-Provider Geocoding]\n    F -->|Parse Result| G[Extract Centroid]\n    G -->|Save| H[(PostalCodeCache Model)]\n    H -->|Return| D\n\n    I[Admin] -->|View Stats| J[RepresentativesPage]\n    J -->|Display| K[Cache Statistics]\n\n    style H fill:#e1f5ff\n    style F fill:#fff4e1
"},{"location":"v2/features/influence/postal-codes/#database-models","title":"Database Models","text":""},{"location":"v2/features/influence/postal-codes/#postalcodecache-model","title":"PostalCodeCache Model","text":"

See PostalCodeCache Model Documentation for full schema.

Key Fields:

Field Type Description postalCode String Normalized postal code (primary key) latitude Float Centroid latitude longitude Float Centroid longitude city String? City name province String? Province abbreviation

Indexes:

  • postalCode \u2014 Primary key, unique constraint

Related Models:

  • Representative \u2014 Uses postal codes for caching
  • Location \u2014 Uses postal codes for geocoding
"},{"location":"v2/features/influence/postal-codes/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/postal-codes/#admin-endpoints","title":"Admin Endpoints","text":"Method Endpoint Auth Description GET /api/postal-codes/stats SUPER_ADMIN, INFLUENCE_ADMIN Get cache statistics POST /api/postal-codes/lookup SUPER_ADMIN, INFLUENCE_ADMIN Manual postal code lookup"},{"location":"v2/features/influence/postal-codes/#public-endpoints","title":"Public Endpoints","text":"

Postal code lookups are performed automatically via representative lookup (no direct public access).

"},{"location":"v2/features/influence/postal-codes/#configuration","title":"Configuration","text":""},{"location":"v2/features/influence/postal-codes/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description GEOCODING_PROVIDER string nominatim Default geocoding provider GEOCODING_FALLBACK_PROVIDERS string - Comma-separated fallback providers"},{"location":"v2/features/influence/postal-codes/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/influence/postal-codes/#1-view-cache-statistics","title":"1. View Cache Statistics","text":"

Steps:

  1. Navigate to Influence > Representatives
  2. View postal code cache statistics
  3. Monitor cache hit rate
"},{"location":"v2/features/influence/postal-codes/#public-workflow","title":"Public Workflow","text":"

Postal code caching is automatic and transparent to public users.

"},{"location":"v2/features/influence/postal-codes/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/influence/postal-codes/#backend-postal-code-caching","title":"Backend: Postal Code Caching","text":"
// api/src/modules/influence/postal-codes/postal-codes.service.ts\n\nexport class PostalCodeService {\n  async getOrCreateCache(postalCode: string): Promise<PostalCodeCache> {\n    const normalized = postalCode.toUpperCase().replace(/\\s/g, '');\n\n    // Check cache\n    const cached = await prisma.postalCodeCache.findUnique({\n      where: { postalCode: normalized }\n    });\n\n    if (cached) {\n      return cached;\n    }\n\n    // Geocode postal code\n    const result = await geocodingService.geocode({\n      query: postalCode,\n      country: 'CA'\n    });\n\n    if (!result) {\n      throw new Error('Failed to geocode postal code');\n    }\n\n    // Create cache entry\n    return prisma.postalCodeCache.create({\n      data: {\n        postalCode: normalized,\n        latitude: result.latitude,\n        longitude: result.longitude,\n        city: result.city,\n        province: result.province\n      }\n    });\n  }\n}\n
"},{"location":"v2/features/influence/postal-codes/#related-documentation","title":"Related Documentation","text":"
  • Representatives Module
  • Geocoding Service
"},{"location":"v2/features/influence/representatives/","title":"Representative Lookup System","text":""},{"location":"v2/features/influence/representatives/#overview","title":"Overview","text":"

The representative lookup system integrates with the Represent API (Open North) to provide real-time postal code-based representative lookups for advocacy campaigns. It includes intelligent caching to minimize API calls, support for all Canadian government levels, and admin tools for cache management.

Key Capabilities:

  • Represent API integration: Real-time lookup of elected officials by postal code
  • Multi-level support: Federal, provincial, and municipal representatives
  • Intelligent caching: Reduce API calls and improve performance
  • Cache invalidation: Manual and automatic cache refresh
  • Admin tools: Cache statistics, manual lookup, bulk operations
  • Error handling: Graceful fallback for API failures

Use Cases:

  • Email-your-MP campaigns
  • Multi-level government outreach
  • Representative contact information lookup
  • Geographic representation analysis
  • Campaign targeting by electoral district
"},{"location":"v2/features/influence/representatives/#architecture","title":"Architecture","text":"
graph TD\n    A[Public User] -->|Enter Postal Code| B[CampaignPage]\n    B -->|POST /api/public/representatives/lookup| C[Representative Service]\n\n    C -->|Check Cache| D{Cache Hit?}\n    D -->|Yes| E[Return Cached Reps]\n    D -->|No| F[Represent API Client]\n\n    F -->|GET /postcodes/:code| G[Represent API]\n    G -->|Return Reps| F\n    F -->|Parse & Save| H[(Representative Model)]\n    H -->|Return| E\n\n    I[Admin User] -->|View Cache| J[RepresentativesPage]\n    J -->|GET /api/representatives| C\n    J -->|Manual Lookup| C\n    J -->|Clear Cache| K[Delete Service]\n    K -->|Delete| H\n\n    L[Cache Invalidation Job] -->|Check lastUpdated| H\n    L -->|Delete Stale| H\n\n    style H fill:#e1f5ff\n    style G fill:#fff4e1

Flow Description:

  1. User enters postal code \u2192 Representative service checks cache
  2. Cache miss \u2192 Represent API client fetches representatives
  3. API response \u2192 Parse representatives, save to cache
  4. Cache hit \u2192 Return cached representatives (skip API call)
  5. Admin management \u2192 View cache stats, manual lookup, clear cache
  6. Cache invalidation \u2192 Automatic cleanup of stale entries (>30 days)
"},{"location":"v2/features/influence/representatives/#database-models","title":"Database Models","text":""},{"location":"v2/features/influence/representatives/#representative-model","title":"Representative Model","text":"

See Representative Model Documentation for full schema.

Key Fields:

Field Type Description id String (UUID) Primary key representId String Represent API unique identifier name String Full name of representative email String Email address districtName String Electoral district name electedOffice String Office held (MP, MPP, Mayor, etc.) partyName String? Political party affiliation photoUrl String? Profile photo URL postalCode String Associated postal code (cache key) level String Government level (federal, provincial, municipal) lastUpdated DateTime Cache timestamp

Indexes:

  • postalCode, level \u2014 Composite index for fast lookups
  • representId \u2014 Unique constraint
  • lastUpdated \u2014 For cache invalidation queries

Related Models:

  • Campaign \u2014 Campaigns target representatives
  • CampaignEmail \u2014 Emails sent to representatives
"},{"location":"v2/features/influence/representatives/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/representatives/#admin-endpoints","title":"Admin Endpoints","text":"

See Representatives Module API Reference for full details.

Method Endpoint Auth Description GET /api/representatives SUPER_ADMIN, INFLUENCE_ADMIN List all cached representatives GET /api/representatives/stats SUPER_ADMIN, INFLUENCE_ADMIN Get cache statistics POST /api/representatives/lookup SUPER_ADMIN, INFLUENCE_ADMIN Manual postal code lookup DELETE /api/representatives/:id SUPER_ADMIN, INFLUENCE_ADMIN Delete cached representative DELETE /api/representatives/postal-code/:postalCode SUPER_ADMIN, INFLUENCE_ADMIN Delete all reps for postal code"},{"location":"v2/features/influence/representatives/#public-endpoints","title":"Public Endpoints","text":"

See Representatives Module API Reference.

Method Endpoint Auth Description POST /api/public/representatives/lookup None Lookup representatives by postal code"},{"location":"v2/features/influence/representatives/#configuration","title":"Configuration","text":""},{"location":"v2/features/influence/representatives/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description REPRESENT_API_URL string https://represent.opennorth.ca Represent API base URL REPRESENT_CACHE_TTL number 2592000 Cache TTL in seconds (30 days) REPRESENT_RATE_LIMIT number 60 Max requests per minute"},{"location":"v2/features/influence/representatives/#represent-api","title":"Represent API","text":"

The Represent API is a public service provided by Open North. No API key required.

API Documentation: https://represent.opennorth.ca/api/

Endpoints Used:

  • GET /postcodes/:postalCode/ \u2014 Lookup representatives by postal code
  • GET /representatives/ \u2014 List representatives (unused, direct lookups only)

Rate Limits:

  • 60 requests per minute per IP address
  • Exceeding limit returns HTTP 429

Postal Code Format:

  • Canadian postal codes only
  • Format: K1A 0A1 or K1A0A1 (space optional)
  • Normalized to uppercase without spaces for API calls
"},{"location":"v2/features/influence/representatives/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/influence/representatives/#1-view-cache-statistics","title":"1. View Cache Statistics","text":"

[Screenshot: RepresentativesPage with cache stats cards]

Steps:

  1. Navigate to Influence > Representatives
  2. View cache statistics:
  3. Total Cached: Total representatives in cache
  4. Unique Postal Codes: Number of postal codes cached
  5. Cache Hit Rate: Percentage of lookups served from cache
  6. Stale Entries: Entries older than 30 days

Code Example (RepresentativesPage.tsx):

const [stats, setStats] = useState({\n  totalCached: 0,\n  uniquePostalCodes: 0,\n  cacheHitRate: 0,\n  staleEntries: 0\n});\n\nuseEffect(() => {\n  const fetchStats = async () => {\n    const { data } = await api.get('/representatives/stats');\n    setStats(data);\n  };\n\n  fetchStats();\n}, []);\n\nreturn (\n  <Row gutter={16}>\n    <Col span={6}>\n      <Card>\n        <Statistic title=\"Total Cached\" value={stats.totalCached} />\n      </Card>\n    </Col>\n    <Col span={6}>\n      <Card>\n        <Statistic title=\"Unique Postal Codes\" value={stats.uniquePostalCodes} />\n      </Card>\n    </Col>\n    <Col span={6}>\n      <Card>\n        <Statistic\n          title=\"Cache Hit Rate\"\n          value={stats.cacheHitRate}\n          suffix=\"%\"\n          precision={1}\n        />\n      </Card>\n    </Col>\n    <Col span={6}>\n      <Card>\n        <Statistic\n          title=\"Stale Entries\"\n          value={stats.staleEntries}\n          valueStyle={{ color: stats.staleEntries > 0 ? '#cf1322' : undefined }}\n        />\n      </Card>\n    </Col>\n  </Row>\n);\n
"},{"location":"v2/features/influence/representatives/#2-manual-postal-code-lookup","title":"2. Manual Postal Code Lookup","text":"

[Screenshot: RepresentativesPage with postal code search form]

Steps:

  1. Enter postal code in search box (e.g., \"K1A 0A1\")
  2. Click Lookup button
  3. View results:
  4. Representative name, office, party
  5. Electoral district
  6. Email address (if available)
  7. Results automatically cached for future lookups

Use Cases:

  • Pre-populate cache for campaign areas
  • Verify representative information
  • Test postal code validation
  • Troubleshoot lookup issues

Code Example (representatives.service.ts):

async lookupByPostalCode(postalCode: string): Promise<Representative[]> {\n  // Normalize postal code\n  const normalized = postalCode.toUpperCase().replace(/\\s/g, '');\n\n  // Check cache first (within last 30 days)\n  const cached = await this.prisma.representative.findMany({\n    where: {\n      postalCode: normalized,\n      lastUpdated: {\n        gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // 30 days\n      }\n    }\n  });\n\n  if (cached.length > 0) {\n    logger.info(`Cache hit for postal code ${normalized}`);\n    return cached;\n  }\n\n  // Cache miss - fetch from Represent API\n  logger.info(`Cache miss for postal code ${normalized}, fetching from API`);\n\n  const representatives = await this.representApiClient.getRepresentativesByPostalCode(\n    normalized\n  );\n\n  // Save to cache\n  const saved = await Promise.all(\n    representatives.map(rep =>\n      this.prisma.representative.upsert({\n        where: { representId: rep.representId },\n        update: {\n          ...rep,\n          postalCode: normalized,\n          lastUpdated: new Date()\n        },\n        create: {\n          ...rep,\n          postalCode: normalized,\n          lastUpdated: new Date()\n        }\n      })\n    )\n  );\n\n  return saved;\n}\n
"},{"location":"v2/features/influence/representatives/#3-clear-stale-cache-entries","title":"3. Clear Stale Cache Entries","text":"

[Screenshot: RepresentativesPage with \"Clear Stale Cache\" button]

Steps:

  1. Click Clear Stale Cache button
  2. Confirm deletion in modal
  3. System deletes all entries older than 30 days
  4. View updated cache statistics

Automatic Cleanup:

Cache invalidation also runs automatically via cron job (daily at 2 AM):

// api/src/server.ts\n\nimport cron from 'node-cron';\n\n// Clean stale representative cache daily at 2 AM\ncron.schedule('0 2 * * *', async () => {\n  try {\n    const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);\n\n    const result = await prisma.representative.deleteMany({\n      where: {\n        lastUpdated: {\n          lt: thirtyDaysAgo\n        }\n      }\n    });\n\n    logger.info(`Deleted ${result.count} stale representative cache entries`);\n  } catch (error) {\n    logger.error('Failed to clean representative cache:', error);\n  }\n});\n
"},{"location":"v2/features/influence/representatives/#4-delete-specific-cache-entries","title":"4. Delete Specific Cache Entries","text":"

[Screenshot: RepresentativesPage table with delete buttons]

Steps:

  1. Browse cached representatives table
  2. Click Delete button on specific row
  3. Confirm deletion
  4. Representative removed from cache (will be re-fetched on next lookup)

Bulk Delete by Postal Code:

  1. Click Delete All button on postal code group
  2. Confirm deletion
  3. All representatives for that postal code removed from cache
"},{"location":"v2/features/influence/representatives/#public-workflow","title":"Public Workflow","text":""},{"location":"v2/features/influence/representatives/#1-enter-postal-code","title":"1. Enter Postal Code","text":"

[Screenshot: CampaignPage with postal code input field]

User Journey:

  1. User visits campaign page (/campaigns/{slug})
  2. Enters postal code in lookup form
  3. Clicks Find My Representatives
  4. System performs lookup (cache or API)
  5. Representatives displayed below form

Code Example (CampaignPage.tsx):

const [representatives, setRepresentatives] = useState<Representative[]>([]);\nconst [loading, setLoading] = useState(false);\n\nconst handleLookup = async (values: { postalCode: string }) => {\n  setLoading(true);\n\n  try {\n    const { data } = await axios.post('/api/public/representatives/lookup', {\n      postalCode: values.postalCode\n    });\n\n    setRepresentatives(data);\n\n    if (data.length === 0) {\n      message.warning('No representatives found for this postal code');\n    }\n  } catch (error) {\n    message.error('Failed to lookup representatives');\n  } finally {\n    setLoading(false);\n  }\n};\n\nreturn (\n  <Form onFinish={handleLookup}>\n    <Form.Item\n      name=\"postalCode\"\n      label=\"Postal Code\"\n      rules={[\n        { required: true, message: 'Please enter your postal code' },\n        {\n          pattern: /^[A-Za-z]\\d[A-Za-z][ -]?\\d[A-Za-z]\\d$/,\n          message: 'Please enter a valid Canadian postal code'\n        }\n      ]}\n    >\n      <Input placeholder=\"K1A 0A1\" maxLength={7} />\n    </Form.Item>\n\n    <Form.Item>\n      <Button type=\"primary\" htmlType=\"submit\" loading={loading}>\n        Find My Representatives\n      </Button>\n    </Form.Item>\n  </Form>\n);\n
"},{"location":"v2/features/influence/representatives/#2-view-representatives","title":"2. View Representatives","text":"

[Screenshot: Representative cards with contact information]

Display Fields:

  • Representative name
  • Elected office (MP, MPP, Mayor, Councillor)
  • Political party (if applicable)
  • Electoral district name
  • Photo (if available)
  • Email button (if email available)

Filtering:

Representatives filtered by campaign's targetGovernmentLevels:

// Filter representatives by campaign levels\nconst filteredRepresentatives = representatives.filter(rep =>\n  campaign.targetGovernmentLevels.includes(rep.level)\n);\n
"},{"location":"v2/features/influence/representatives/#3-select-representatives-to-email","title":"3. Select Representatives to Email","text":"

[Screenshot: Representative list with checkboxes]

User Journey:

  1. User reviews list of representatives
  2. Selects representatives to email (checkboxes)
  3. Clicks Continue to email form
  4. System pre-populates recipient list

Code Example:

const [selectedReps, setSelectedReps] = useState<string[]>([]);\n\nconst handleSelectAll = () => {\n  setSelectedReps(representatives.map(r => r.id));\n};\n\nconst handleSelectNone = () => {\n  setSelectedReps([]);\n};\n\nreturn (\n  <Space direction=\"vertical\" style={{ width: '100%' }}>\n    <Space>\n      <Button onClick={handleSelectAll}>Select All</Button>\n      <Button onClick={handleSelectNone}>Select None</Button>\n    </Space>\n\n    <Checkbox.Group\n      value={selectedReps}\n      onChange={setSelectedReps}\n      style={{ width: '100%' }}\n    >\n      {representatives.map(rep => (\n        <Card key={rep.id} style={{ marginBottom: 16 }}>\n          <Checkbox value={rep.id}>\n            <Space>\n              {rep.photoUrl && (\n                <Avatar src={rep.photoUrl} size={64} />\n              )}\n              <Space direction=\"vertical\" size={0}>\n                <Typography.Text strong>{rep.name}</Typography.Text>\n                <Typography.Text type=\"secondary\">{rep.electedOffice}</Typography.Text>\n                <Typography.Text type=\"secondary\">{rep.districtName}</Typography.Text>\n                {rep.partyName && <Tag>{rep.partyName}</Tag>}\n              </Space>\n            </Space>\n          </Checkbox>\n        </Card>\n      ))}\n    </Checkbox.Group>\n  </Space>\n);\n
"},{"location":"v2/features/influence/representatives/#volunteer-workflow","title":"Volunteer Workflow","text":"

Not applicable \u2014 representative lookup is public-facing and admin-managed.

"},{"location":"v2/features/influence/representatives/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/influence/representatives/#backend-represent-api-client","title":"Backend: Represent API Client","text":"
// api/src/modules/influence/representatives/represent-api.client.ts\n\nimport axios from 'axios';\nimport { logger } from '../../../utils/logger';\n\nconst REPRESENT_API_URL = process.env.REPRESENT_API_URL || 'https://represent.opennorth.ca';\n\ninterface RepresentApiResponse {\n  objects: Array<{\n    name: string;\n    email: string;\n    district_name: string;\n    elected_office: string;\n    party_name?: string;\n    photo_url?: string;\n    url: string;\n    representative_set_name: string;\n  }>;\n}\n\nexport class RepresentApiClient {\n  async getRepresentativesByPostalCode(postalCode: string): Promise<any[]> {\n    try {\n      const { data } = await axios.get<RepresentApiResponse>(\n        `${REPRESENT_API_URL}/postcodes/${postalCode}/`,\n        {\n          headers: {\n            'Accept': 'application/json'\n          },\n          timeout: 10000\n        }\n      );\n\n      return data.objects.map(rep => ({\n        representId: this.extractRepresentId(rep.url),\n        name: rep.name,\n        email: rep.email || null,\n        districtName: rep.district_name,\n        electedOffice: rep.elected_office,\n        partyName: rep.party_name || null,\n        photoUrl: rep.photo_url || null,\n        level: this.mapGovernmentLevel(rep.representative_set_name)\n      }));\n    } catch (error) {\n      if (axios.isAxiosError(error)) {\n        if (error.response?.status === 404) {\n          logger.warn(`No representatives found for postal code: ${postalCode}`);\n          return [];\n        }\n\n        if (error.response?.status === 429) {\n          logger.error('Represent API rate limit exceeded');\n          throw new Error('Rate limit exceeded. Please try again later.');\n        }\n      }\n\n      logger.error('Represent API error:', error);\n      throw new Error('Failed to fetch representatives');\n    }\n  }\n\n  private extractRepresentId(url: string): string {\n    // Extract ID from URL: /representatives/house-of-commons/123/\n    const match = url.match(/\\/representatives\\/[^\\/]+\\/(\\d+)\\//);\n    return match ? match[1] : url;\n  }\n\n  private mapGovernmentLevel(setName: string): string {\n    // Map representative set names to standard levels\n    const lowerSetName = setName.toLowerCase();\n\n    if (lowerSetName.includes('house-of-commons')) return 'federal';\n    if (lowerSetName.includes('legislative-assembly')) return 'provincial';\n    if (lowerSetName.includes('council')) return 'municipal';\n\n    return 'other';\n  }\n}\n
"},{"location":"v2/features/influence/representatives/#frontend-representative-card-component","title":"Frontend: Representative Card Component","text":"
// admin/src/components/influence/RepresentativeCard.tsx\n\nimport React from 'react';\nimport { Card, Avatar, Space, Typography, Tag, Button } from 'antd';\nimport { MailOutlined, UserOutlined } from '@ant-design/icons';\nimport type { Representative } from '../../types/api';\n\ninterface RepresentativeCardProps {\n  representative: Representative;\n  onSelect?: (id: string) => void;\n  selected?: boolean;\n}\n\nconst RepresentativeCard: React.FC<RepresentativeCardProps> = ({\n  representative,\n  onSelect,\n  selected\n}) => {\n  const levelColors: Record<string, string> = {\n    federal: 'blue',\n    provincial: 'green',\n    municipal: 'orange'\n  };\n\n  return (\n    <Card\n      hoverable={!!onSelect}\n      onClick={() => onSelect?.(representative.id)}\n      style={{\n        borderColor: selected ? '#1890ff' : undefined,\n        borderWidth: selected ? 2 : 1\n      }}\n    >\n      <Space align=\"start\" size=\"large\">\n        <Avatar\n          src={representative.photoUrl}\n          icon={<UserOutlined />}\n          size={80}\n        />\n\n        <Space direction=\"vertical\" size={0} style={{ flex: 1 }}>\n          <Typography.Title level={5} style={{ margin: 0 }}>\n            {representative.name}\n          </Typography.Title>\n\n          <Typography.Text type=\"secondary\">\n            {representative.electedOffice}\n          </Typography.Text>\n\n          <Typography.Text type=\"secondary\">\n            {representative.districtName}\n          </Typography.Text>\n\n          <Space size=\"small\" style={{ marginTop: 8 }}>\n            <Tag color={levelColors[representative.level] || 'default'}>\n              {representative.level.toUpperCase()}\n            </Tag>\n\n            {representative.partyName && (\n              <Tag>{representative.partyName}</Tag>\n            )}\n          </Space>\n\n          {representative.email && (\n            <Button\n              type=\"link\"\n              icon={<MailOutlined />}\n              href={`mailto:${representative.email}`}\n              style={{ padding: 0, marginTop: 8 }}\n            >\n              {representative.email}\n            </Button>\n          )}\n        </Space>\n      </Space>\n    </Card>\n  );\n};\n\nexport default RepresentativeCard;\n
"},{"location":"v2/features/influence/representatives/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/influence/representatives/#no-representatives-found","title":"No Representatives Found","text":"

Symptoms: - Lookup returns empty array - Error: \"No representatives found for this postal code\"

Solutions:

  1. Verify postal code format \u2192 Must be valid Canadian postal code
  2. Check Represent API status \u2192 Visit https://represent.opennorth.ca/health
  3. Test postal code manually \u2192 Try https://represent.opennorth.ca/postcodes/K1A0A1/
  4. Review API logs \u2192 Check for rate limit errors

Debugging:

# Test Represent API directly\ncurl https://represent.opennorth.ca/postcodes/K1A0A1/ | jq\n\n# Check representative cache\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_lite -c \\\n  \"SELECT * FROM representatives WHERE postal_code = 'K1A0A1';\"\n\n# Check API logs\ndocker compose logs api | grep \"Represent API\"\n
"},{"location":"v2/features/influence/representatives/#rate-limit-exceeded","title":"Rate Limit Exceeded","text":"

Symptoms: - HTTP 429 error - Error: \"Rate limit exceeded. Please try again later.\"

Solutions:

  1. Implement exponential backoff \u2192 Retry with increasing delays
  2. Use cache more aggressively \u2192 Increase cache TTL to 60 days
  3. Batch lookups \u2192 Avoid rapid repeated lookups
  4. Contact Open North \u2192 Request rate limit increase if needed

Code Fix (represent-api.client.ts):

async getRepresentativesByPostalCodeWithRetry(\n  postalCode: string,\n  maxRetries = 3\n): Promise<any[]> {\n  for (let i = 0; i < maxRetries; i++) {\n    try {\n      return await this.getRepresentativesByPostalCode(postalCode);\n    } catch (error) {\n      if (error.message.includes('Rate limit exceeded')) {\n        const delay = Math.pow(2, i) * 1000; // Exponential backoff\n        logger.warn(`Rate limit hit, retrying in ${delay}ms...`);\n        await new Promise(resolve => setTimeout(resolve, delay));\n        continue;\n      }\n      throw error;\n    }\n  }\n\n  throw new Error('Max retries exceeded');\n}\n
"},{"location":"v2/features/influence/representatives/#stale-representative-information","title":"Stale Representative Information","text":"

Symptoms: - Representative email bounces - Representative no longer in office

Solutions:

  1. Clear cache for postal code \u2192 Delete and re-fetch
  2. Reduce cache TTL \u2192 Set REPRESENT_CACHE_TTL to 7 days (604800)
  3. Manual verification \u2192 Check official government websites
  4. Report to Represent API \u2192 If data is incorrect, report to Open North

Manual Cache Clear:

// Via admin UI\n// Navigate to Influence > Representatives\n// Find postal code in table\n// Click \"Delete All\" for that postal code\n\n// Via API\nawait api.delete(`/representatives/postal-code/${postalCode}`);\n
"},{"location":"v2/features/influence/representatives/#missing-email-addresses","title":"Missing Email Addresses","text":"

Symptoms: - Representative has no email address - Cannot send campaign email

Solutions:

  1. Check Represent API data \u2192 Some reps don't provide email publicly
  2. Use manual email field \u2192 Allow admins to add email addresses
  3. Fallback to constituency office \u2192 Use office email if available
  4. Skip representative \u2192 Don't include in email recipients

Code Fix (representative.service.ts):

async updateRepresentativeEmail(\n  representId: string,\n  email: string\n): Promise<Representative> {\n  return this.prisma.representative.update({\n    where: { representId },\n    data: {\n      email,\n      lastUpdated: new Date() // Reset cache timestamp\n    }\n  });\n}\n
"},{"location":"v2/features/influence/representatives/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/influence/representatives/#cache-strategy","title":"Cache Strategy","text":"

TTL Configuration:

  • Default: 30 days (2,592,000 seconds)
  • Aggressive: 60 days for stable electoral districts
  • Conservative: 7 days during election periods

Cache Warming:

Pre-populate cache for common postal codes:

// api/src/scripts/warm-representative-cache.ts\n\nimport { RepresentativeService } from '../modules/influence/representatives/representatives.service';\nimport { PrismaClient } from '@prisma/client';\n\nconst prisma = new PrismaClient();\nconst representativeService = new RepresentativeService(prisma);\n\n// Common postal codes from campaign participation data\nconst commonPostalCodes = [\n  'K1A0A1', 'M5H2N2', 'V6B1A1', // Federal capitals\n  'T2P2M5', 'H3B1A1', 'S7K0J5'  // Provincial capitals\n];\n\nasync function warmCache() {\n  for (const postalCode of commonPostalCodes) {\n    try {\n      await representativeService.lookupByPostalCode(postalCode);\n      console.log(`Cached representatives for ${postalCode}`);\n    } catch (error) {\n      console.error(`Failed to cache ${postalCode}:`, error);\n    }\n\n    // Rate limit: 1 request per second\n    await new Promise(resolve => setTimeout(resolve, 1000));\n  }\n}\n\nwarmCache();\n
"},{"location":"v2/features/influence/representatives/#query-optimization","title":"Query Optimization","text":"

Index Usage:

-- Composite index for fast lookups\nCREATE INDEX idx_representative_postal_code_level\n  ON representatives (postal_code, level);\n\n-- Index for cache invalidation\nCREATE INDEX idx_representative_last_updated\n  ON representatives (last_updated);\n

Query Pattern:

// Optimized cache lookup with index\nconst cached = await prisma.representative.findMany({\n  where: {\n    postalCode: normalized,\n    level: { in: targetLevels }, // Use index\n    lastUpdated: {\n      gte: new Date(Date.now() - CACHE_TTL * 1000)\n    }\n  }\n});\n
"},{"location":"v2/features/influence/representatives/#api-rate-limiting","title":"API Rate Limiting","text":"

Client-Side Rate Limiter:

import Bottleneck from 'bottleneck';\n\nconst limiter = new Bottleneck({\n  maxConcurrent: 1,\n  minTime: 1000 // 1 request per second\n});\n\nconst getRepresentativesRateLimited = limiter.wrap(\n  representApiClient.getRepresentativesByPostalCode.bind(representApiClient)\n);\n

Redis-Based Distributed Rate Limiting:

import { RateLimiterRedis } from 'rate-limiter-flexible';\n\nconst rateLimiter = new RateLimiterRedis({\n  storeClient: redisClient,\n  keyPrefix: 'represent-api',\n  points: 60, // 60 requests\n  duration: 60 // per minute\n});\n\nawait rateLimiter.consume('represent-api-key');\n
"},{"location":"v2/features/influence/representatives/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/influence/representatives/#backend-modules","title":"Backend Modules","text":"
  • Representatives Module \u2014 Full API reference
  • Campaigns Module \u2014 Campaign integration
  • Postal Codes Module \u2014 Postal code caching
"},{"location":"v2/features/influence/representatives/#frontend-pages","title":"Frontend Pages","text":"
  • RepresentativesPage \u2014 Admin cache management
  • CampaignPage \u2014 Public representative lookup
"},{"location":"v2/features/influence/representatives/#database-models_1","title":"Database Models","text":"
  • Representative \u2014 Representative schema
  • Campaign \u2014 Campaign schema
  • CampaignEmail \u2014 Email tracking schema
"},{"location":"v2/features/influence/representatives/#external-apis","title":"External APIs","text":"
  • Represent API Documentation \u2014 Official API docs
  • Open North \u2014 Represent API provider
"},{"location":"v2/features/influence/representatives/#configuration_1","title":"Configuration","text":"
  • Environment Variables \u2014 Represent API settings
"},{"location":"v2/features/influence/responses/","title":"Response Wall System","text":""},{"location":"v2/features/influence/responses/#overview","title":"Overview","text":"

The response wall system allows campaign participants to share their advocacy actions publicly, creating social proof and encouraging further participation. It includes email verification, admin moderation, upvoting capabilities, and screenshot uploads to showcase genuine participation.

Key Capabilities:

  • Multiple response types: EMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHER
  • Email verification: Prevent spam with email confirmation
  • Admin moderation: PENDING \u2192 APPROVED/REJECTED workflow
  • Upvoting system: Community engagement with IP + user tracking
  • Screenshot uploads: Visual proof of participation
  • Public response wall: SEO-friendly public display
  • Moderation dashboard: Admin tools for reviewing submissions

Use Cases:

  • Public display of campaign participation
  • Social proof for advocacy campaigns
  • Community engagement and sharing
  • Response verification and moderation
  • Campaign effectiveness metrics
"},{"location":"v2/features/influence/responses/#architecture","title":"Architecture","text":"
graph TD\n    A[Public User] -->|Submit Response| B[ResponseWallPage]\n    B -->|POST /api/public/responses| C[Response Service]\n    C -->|Save| D[(Response Model)]\n    C -->|Send| E[Email Service]\n    E -->|Verification Email| F[User Inbox]\n\n    F -->|Click Link| G[Verify Endpoint]\n    G -->|Update| D\n\n    H[Admin User] -->|Review| I[ResponsesPage]\n    I -->|GET /api/responses| C\n    I -->|Approve/Reject| C\n    C -->|Update Status| D\n\n    J[Public User] -->|View Wall| K[ResponseWallPage]\n    K -->|GET /api/public/responses/:campaignId| C\n    C -->|Filter APPROVED| D\n\n    K -->|Upvote| L[Upvote Service]\n    L -->|Track| M[(ResponseUpvote Model)]\n    L -->|Increment| D\n\n    style D fill:#e1f5ff\n    style M fill:#e1f5ff\n    style E fill:#fff4e1

Flow Description:

  1. User submits response \u2192 Response service saves with PENDING status
  2. Verification email sent \u2192 User clicks link to verify email
  3. Email verified \u2192 Response marked as email verified
  4. Admin reviews \u2192 Moderates response (approve/reject)
  5. Response approved \u2192 Appears on public response wall
  6. Users upvote \u2192 Upvote service tracks votes, increments count
  7. Public views wall \u2192 Only approved responses displayed
"},{"location":"v2/features/influence/responses/#database-models","title":"Database Models","text":""},{"location":"v2/features/influence/responses/#response-model","title":"Response Model","text":"

See Response Model Documentation for full schema.

Key Fields:

Field Type Description id String (UUID) Primary key campaignId String Associated campaign responseType Enum EMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHER message String User's response message screenshotUrl String? Uploaded screenshot URL name String Submitter's name email String Submitter's email postalCode String? Submitter's postal code isEmailVerified Boolean Email verification status status Enum PENDING, APPROVED, REJECTED upvotes Int Number of upvotes moderatedByUserId String? Admin who moderated moderationNotes String? Admin notes

Indexes:

  • campaignId, status \u2014 For public wall queries
  • email, campaignId \u2014 Prevent duplicate submissions
  • isEmailVerified \u2014 Filter unverified responses
"},{"location":"v2/features/influence/responses/#responseupvote-model","title":"ResponseUpvote Model","text":"

See ResponseUpvote Model Documentation for full schema.

Key Fields:

Field Type Description id String (UUID) Primary key responseId String Associated response ipAddress String? Voter IP address userId String? Voter user ID (if logged in)

Constraints:

  • Unique constraint on responseId, ipAddress \u2014 Prevent duplicate upvotes by IP
  • Unique constraint on responseId, userId \u2014 Prevent duplicate upvotes by user

Related Models:

  • Campaign \u2014 Campaign association
  • User \u2014 Moderation user
"},{"location":"v2/features/influence/responses/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/responses/#admin-endpoints","title":"Admin Endpoints","text":"

See Responses Module API Reference for full details.

Method Endpoint Auth Description GET /api/responses SUPER_ADMIN, INFLUENCE_ADMIN List all responses (paginated, filterable) GET /api/responses/:id SUPER_ADMIN, INFLUENCE_ADMIN Get response details PATCH /api/responses/:id/moderate SUPER_ADMIN, INFLUENCE_ADMIN Approve/reject response DELETE /api/responses/:id SUPER_ADMIN Delete response"},{"location":"v2/features/influence/responses/#public-endpoints","title":"Public Endpoints","text":"

See Responses Module API Reference.

Method Endpoint Auth Description GET /api/public/responses/:campaignId None List approved responses for campaign POST /api/public/responses None Submit new response GET /api/public/responses/verify/:token None Verify email via token POST /api/public/responses/:id/upvote None Upvote response (IP/user tracked)"},{"location":"v2/features/influence/responses/#configuration","title":"Configuration","text":""},{"location":"v2/features/influence/responses/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description EMAIL_TEST_MODE boolean false Send verification emails to MailHog SMTP_FROM_EMAIL string - Sender email for verification SMTP_FROM_NAME string - Sender name for verification RESPONSE_VERIFICATION_URL string - Base URL for verification links"},{"location":"v2/features/influence/responses/#campaign-feature-flags","title":"Campaign Feature Flags","text":"

Response wall behavior configured per campaign:

Flag Description showResponseWall Enable response wall for campaign requireEmailVerification Require email verification before display allowAnonymousResponses Allow submissions without login"},{"location":"v2/features/influence/responses/#upload-configuration","title":"Upload Configuration","text":"

Screenshots uploaded to /uploads/responses/{responseId}/{filename}.

Limits: - Max file size: 5MB - Allowed formats: jpg, jpeg, png, gif, webp

"},{"location":"v2/features/influence/responses/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/influence/responses/#1-view-pending-responses","title":"1. View Pending Responses","text":"

[Screenshot: ResponsesPage with pending filter active]

Steps:

  1. Navigate to Influence > Responses
  2. Click Pending filter tab
  3. View pending responses requiring moderation
  4. Sort by submission date (newest first)

Code Example (ResponsesPage.tsx):

const [responses, setResponses] = useState<Response[]>([]);\nconst [filter, setFilter] = useState<'all' | 'pending' | 'approved' | 'rejected'>('pending');\n\nuseEffect(() => {\n  const fetchResponses = async () => {\n    const params = new URLSearchParams();\n\n    if (filter !== 'all') {\n      params.set('status', filter.toUpperCase());\n    }\n\n    const { data } = await api.get(`/responses?${params.toString()}`);\n    setResponses(data.responses);\n  };\n\n  fetchResponses();\n}, [filter]);\n\nreturn (\n  <Card>\n    <Tabs activeKey={filter} onChange={setFilter}>\n      <TabPane tab=\"Pending\" key=\"pending\" />\n      <TabPane tab=\"Approved\" key=\"approved\" />\n      <TabPane tab=\"Rejected\" key=\"rejected\" />\n      <TabPane tab=\"All\" key=\"all\" />\n    </Tabs>\n\n    <Table dataSource={responses} columns={columns} />\n  </Card>\n);\n
"},{"location":"v2/features/influence/responses/#2-review-response-details","title":"2. Review Response Details","text":"

[Screenshot: Response detail drawer with full content]

Steps:

  1. Click View on response row
  2. Review response details:
  3. Campaign name
  4. Response type
  5. Submitter name and email
  6. Message content
  7. Screenshot (if uploaded)
  8. Email verification status
  9. Submission date
  10. Check for spam/inappropriate content

Moderation Checklist:

  • \u2713 Message is genuine and relevant
  • \u2713 Screenshot matches claimed action (if provided)
  • \u2713 Email verified (if required by campaign)
  • \u2713 No profanity or inappropriate content
  • \u2713 Not duplicate submission
"},{"location":"v2/features/influence/responses/#3-approve-or-reject-response","title":"3. Approve or Reject Response","text":"

[Screenshot: Response detail drawer with approve/reject buttons]

Steps:

  1. Click Approve or Reject button
  2. Add moderation notes (optional but recommended)
  3. Confirm action
  4. Response status updated
  5. If approved \u2192 appears on public response wall
  6. If rejected \u2192 hidden from public, admin can view

Code Example (responses.service.ts):

async moderateResponse(\n  responseId: string,\n  status: 'APPROVED' | 'REJECTED',\n  moderatorUserId: string,\n  notes?: string\n): Promise<Response> {\n  const response = await this.prisma.response.update({\n    where: { id: responseId },\n    data: {\n      status,\n      moderatedByUserId: moderatorUserId,\n      moderationNotes: notes,\n      moderatedAt: new Date()\n    },\n    include: {\n      campaign: true,\n      moderatedBy: {\n        select: { name: true, email: true }\n      }\n    }\n  });\n\n  // Send notification email if campaign has notifyOnResponse enabled\n  if (status === 'APPROVED' && response.campaign.notifyOnResponse) {\n    await this.emailService.send({\n      to: response.email,\n      subject: `Your response was approved`,\n      template: 'response-approved',\n      variables: {\n        name: response.name,\n        campaignTitle: response.campaign.title,\n        responseWallUrl: `${process.env.FRONTEND_URL}/responses/${response.campaignId}`\n      }\n    });\n  }\n\n  logger.info(`Response ${responseId} ${status} by user ${moderatorUserId}`);\n\n  return response;\n}\n
"},{"location":"v2/features/influence/responses/#4-bulk-moderation-actions","title":"4. Bulk Moderation Actions","text":"

[Screenshot: ResponsesPage with bulk action toolbar]

Steps:

  1. Select multiple responses (checkboxes)
  2. Click Bulk Actions dropdown
  3. Choose action:
  4. Approve selected
  5. Reject selected
  6. Delete selected
  7. Confirm bulk action
  8. All selected responses updated

Code Example (ResponsesPage.tsx):

const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);\n\nconst handleBulkApprove = async () => {\n  try {\n    await Promise.all(\n      selectedRowKeys.map(id =>\n        api.patch(`/responses/${id}/moderate`, {\n          status: 'APPROVED',\n          notes: 'Bulk approved'\n        })\n      )\n    );\n\n    message.success(`Approved ${selectedRowKeys.length} responses`);\n    setSelectedRowKeys([]);\n    fetchResponses();\n  } catch (error) {\n    message.error('Failed to bulk approve responses');\n  }\n};\n\nconst rowSelection = {\n  selectedRowKeys,\n  onChange: setSelectedRowKeys\n};\n\nreturn (\n  <>\n    {selectedRowKeys.length > 0 && (\n      <Space style={{ marginBottom: 16 }}>\n        <Button onClick={handleBulkApprove}>Approve Selected</Button>\n        <Button onClick={handleBulkReject}>Reject Selected</Button>\n        <Button danger onClick={handleBulkDelete}>Delete Selected</Button>\n      </Space>\n    )}\n\n    <Table rowSelection={rowSelection} dataSource={responses} columns={columns} />\n  </>\n);\n
"},{"location":"v2/features/influence/responses/#public-workflow","title":"Public Workflow","text":""},{"location":"v2/features/influence/responses/#1-submit-response","title":"1. Submit Response","text":"

[Screenshot: Response submission form on ResponseWallPage]

User Journey:

  1. User completes campaign action (sends email)
  2. Clicks Share Your Response link
  3. Navigated to /responses/{campaignId}/submit
  4. Fills in response form:
  5. Response type (dropdown)
  6. Name
  7. Email
  8. Postal code (optional)
  9. Message (what they did)
  10. Screenshot (optional upload)
  11. Clicks Submit Response
  12. System saves response as PENDING
  13. Verification email sent (if required)

Code Example (ResponseWallPage.tsx):

const handleSubmit = async (values: any) => {\n  try {\n    const formData = new FormData();\n    formData.append('campaignId', campaignId);\n    formData.append('responseType', values.responseType);\n    formData.append('name', values.name);\n    formData.append('email', values.email);\n    formData.append('message', values.message);\n\n    if (values.postalCode) {\n      formData.append('postalCode', values.postalCode);\n    }\n\n    if (values.screenshot?.[0]?.originFileObj) {\n      formData.append('screenshot', values.screenshot[0].originFileObj);\n    }\n\n    await axios.post('/api/public/responses', formData, {\n      headers: { 'Content-Type': 'multipart/form-data' }\n    });\n\n    if (campaign.requireEmailVerification) {\n      message.success('Response submitted! Please check your email to verify.');\n    } else {\n      message.success('Response submitted! It will appear after admin approval.');\n    }\n\n    form.resetFields();\n  } catch (error) {\n    message.error('Failed to submit response');\n  }\n};\n\nreturn (\n  <Form form={form} onFinish={handleSubmit} layout=\"vertical\">\n    <Form.Item\n      name=\"responseType\"\n      label=\"What did you do?\"\n      rules={[{ required: true }]}\n    >\n      <Select>\n        <Option value=\"EMAIL\">Sent Email</Option>\n        <Option value=\"LETTER\">Sent Letter</Option>\n        <Option value=\"PHONE_CALL\">Made Phone Call</Option>\n        <Option value=\"MEETING\">Attended Meeting</Option>\n        <Option value=\"SOCIAL_MEDIA\">Posted on Social Media</Option>\n        <Option value=\"OTHER\">Other</Option>\n      </Select>\n    </Form.Item>\n\n    <Form.Item\n      name=\"name\"\n      label=\"Your Name\"\n      rules={[{ required: true }]}\n    >\n      <Input />\n    </Form.Item>\n\n    <Form.Item\n      name=\"email\"\n      label=\"Your Email\"\n      rules={[\n        { required: true },\n        { type: 'email' }\n      ]}\n    >\n      <Input />\n    </Form.Item>\n\n    <Form.Item\n      name=\"message\"\n      label=\"Tell us more\"\n      rules={[{ required: true }]}\n    >\n      <Input.TextArea rows={4} placeholder=\"Describe what you did...\" />\n    </Form.Item>\n\n    <Form.Item\n      name=\"screenshot\"\n      label=\"Upload Screenshot (optional)\"\n      valuePropName=\"fileList\"\n      getValueFromEvent={e => Array.isArray(e) ? e : e?.fileList}\n    >\n      <Upload\n        listType=\"picture\"\n        maxCount={1}\n        beforeUpload={() => false}\n      >\n        <Button icon={<UploadOutlined />}>Upload Screenshot</Button>\n      </Upload>\n    </Form.Item>\n\n    <Form.Item>\n      <Button type=\"primary\" htmlType=\"submit\">\n        Submit Response\n      </Button>\n    </Form.Item>\n  </Form>\n);\n
"},{"location":"v2/features/influence/responses/#2-verify-email","title":"2. Verify Email","text":"

[Screenshot: Email verification success page]

User Journey:

  1. User receives verification email
  2. Clicks verification link
  3. Navigated to /api/public/responses/verify/{token}
  4. System verifies email
  5. Response marked as email verified
  6. User redirected to response wall
  7. Message: \"Email verified! Your response will appear after admin approval.\"

Verification Email Template:

<h1>Verify Your Response</h1>\n\n<p>Hi {{name}},</p>\n\n<p>Thanks for sharing your response to <strong>{{campaignTitle}}</strong>!</p>\n\n<p>Please verify your email address by clicking the link below:</p>\n\n<p>\n  <a href=\"{{verificationUrl}}\">Verify Email</a>\n</p>\n\n<p>This link will expire in 24 hours.</p>\n\n<p>If you didn't submit this response, you can safely ignore this email.</p>\n
"},{"location":"v2/features/influence/responses/#3-view-response-wall","title":"3. View Response Wall","text":"

[Screenshot: Public response wall with approved responses]

User Journey:

  1. User visits /responses/{campaignId}
  2. Sees approved responses
  3. Responses sorted by upvotes (most upvoted first)
  4. Can upvote responses
  5. Can filter by response type

Code Example (ResponseWallPage.tsx):

const [responses, setResponses] = useState<Response[]>([]);\nconst [filter, setFilter] = useState<string | null>(null);\n\nuseEffect(() => {\n  const fetchResponses = async () => {\n    const params = new URLSearchParams();\n\n    if (filter) {\n      params.set('responseType', filter);\n    }\n\n    const { data } = await axios.get(\n      `/api/public/responses/${campaignId}?${params.toString()}`\n    );\n\n    setResponses(data);\n  };\n\n  fetchResponses();\n}, [campaignId, filter]);\n\nreturn (\n  <PublicLayout>\n    <Space direction=\"vertical\" size=\"large\" style={{ width: '100%' }}>\n      <Typography.Title level={2}>Response Wall</Typography.Title>\n\n      <Radio.Group value={filter} onChange={e => setFilter(e.target.value)}>\n        <Radio.Button value={null}>All</Radio.Button>\n        <Radio.Button value=\"EMAIL\">Emails</Radio.Button>\n        <Radio.Button value=\"LETTER\">Letters</Radio.Button>\n        <Radio.Button value=\"PHONE_CALL\">Calls</Radio.Button>\n        <Radio.Button value=\"MEETING\">Meetings</Radio.Button>\n        <Radio.Button value=\"SOCIAL_MEDIA\">Social Media</Radio.Button>\n      </Radio.Group>\n\n      <List\n        dataSource={responses}\n        renderItem={response => (\n          <ResponseCard\n            response={response}\n            onUpvote={handleUpvote}\n          />\n        )}\n      />\n    </Space>\n  </PublicLayout>\n);\n
"},{"location":"v2/features/influence/responses/#4-upvote-response","title":"4. Upvote Response","text":"

[Screenshot: Response card with upvote button]

User Journey:

  1. User clicks upvote button on response
  2. System checks for existing upvote (IP + user)
  3. If first upvote \u2192 increment count, save upvote record
  4. If already upvoted \u2192 show message \"You already upvoted this\"
  5. Upvote count updated in real-time

Code Example (responses-public.routes.ts):

router.post('/:id/upvote', async (req, res) => {\n  try {\n    const { id } = req.params;\n    const ipAddress = req.ip;\n    const userId = req.user?.id; // If authenticated\n\n    // Check for existing upvote\n    const existingUpvote = await prisma.responseUpvote.findFirst({\n      where: {\n        responseId: id,\n        OR: [\n          { ipAddress },\n          userId ? { userId } : {}\n        ]\n      }\n    });\n\n    if (existingUpvote) {\n      return res.status(400).json({ error: 'You already upvoted this response' });\n    }\n\n    // Create upvote and increment count (transaction)\n    await prisma.$transaction([\n      prisma.responseUpvote.create({\n        data: {\n          responseId: id,\n          ipAddress,\n          userId\n        }\n      }),\n      prisma.response.update({\n        where: { id },\n        data: {\n          upvotes: { increment: 1 }\n        }\n      })\n    ]);\n\n    res.json({ success: true });\n  } catch (error) {\n    logger.error('Failed to upvote response:', error);\n    res.status(500).json({ error: 'Failed to upvote response' });\n  }\n});\n
"},{"location":"v2/features/influence/responses/#volunteer-workflow","title":"Volunteer Workflow","text":"

Not applicable \u2014 response wall is public-facing.

"},{"location":"v2/features/influence/responses/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/influence/responses/#backend-email-verification-token","title":"Backend: Email Verification Token","text":"
// api/src/modules/influence/responses/responses.service.ts\n\nimport crypto from 'crypto';\n\nasync createResponse(data: any): Promise<Response> {\n  const verificationToken = crypto.randomBytes(32).toString('hex');\n\n  const response = await this.prisma.response.create({\n    data: {\n      ...data,\n      status: 'PENDING',\n      isEmailVerified: false,\n      verificationToken,\n      verificationTokenExpires: new Date(Date.now() + 24 * 60 * 60 * 1000) // 24h\n    }\n  });\n\n  // Send verification email\n  await this.emailService.send({\n    to: response.email,\n    subject: 'Verify your response',\n    template: 'response-verification',\n    variables: {\n      name: response.name,\n      campaignTitle: response.campaign.title,\n      verificationUrl: `${process.env.RESPONSE_VERIFICATION_URL}/api/public/responses/verify/${verificationToken}`\n    }\n  });\n\n  return response;\n}\n\nasync verifyEmail(token: string): Promise<Response> {\n  const response = await this.prisma.response.findFirst({\n    where: {\n      verificationToken: token,\n      verificationTokenExpires: {\n        gt: new Date()\n      }\n    }\n  });\n\n  if (!response) {\n    throw new Error('Invalid or expired verification token');\n  }\n\n  return this.prisma.response.update({\n    where: { id: response.id },\n    data: {\n      isEmailVerified: true,\n      verificationToken: null,\n      verificationTokenExpires: null\n    }\n  });\n}\n
"},{"location":"v2/features/influence/responses/#frontend-response-card-component","title":"Frontend: Response Card Component","text":"
// admin/src/components/influence/ResponseCard.tsx\n\nimport React from 'react';\nimport { Card, Space, Typography, Tag, Button, Avatar } from 'antd';\nimport { LikeOutlined, LikeFilled } from '@ant-design/icons';\nimport type { Response } from '../../types/api';\n\ninterface ResponseCardProps {\n  response: Response;\n  onUpvote: (id: string) => void;\n  hasUpvoted?: boolean;\n}\n\nconst ResponseCard: React.FC<ResponseCardProps> = ({\n  response,\n  onUpvote,\n  hasUpvoted\n}) => {\n  const typeColors: Record<string, string> = {\n    EMAIL: 'blue',\n    LETTER: 'green',\n    PHONE_CALL: 'orange',\n    MEETING: 'purple',\n    SOCIAL_MEDIA: 'cyan',\n    OTHER: 'default'\n  };\n\n  const typeLabels: Record<string, string> = {\n    EMAIL: 'Sent Email',\n    LETTER: 'Sent Letter',\n    PHONE_CALL: 'Made Call',\n    MEETING: 'Attended Meeting',\n    SOCIAL_MEDIA: 'Posted on Social Media',\n    OTHER: 'Other Action'\n  };\n\n  return (\n    <Card>\n      <Space direction=\"vertical\" size=\"small\" style={{ width: '100%' }}>\n        <Space>\n          <Avatar>{response.name[0].toUpperCase()}</Avatar>\n          <Space direction=\"vertical\" size={0}>\n            <Typography.Text strong>{response.name}</Typography.Text>\n            <Typography.Text type=\"secondary\">\n              {new Date(response.createdAt).toLocaleDateString()}\n            </Typography.Text>\n          </Space>\n        </Space>\n\n        <Tag color={typeColors[response.responseType]}>\n          {typeLabels[response.responseType]}\n        </Tag>\n\n        <Typography.Paragraph>{response.message}</Typography.Paragraph>\n\n        {response.screenshotUrl && (\n          <img\n            src={response.screenshotUrl}\n            alt=\"Response screenshot\"\n            style={{ maxWidth: '100%', borderRadius: 4 }}\n          />\n        )}\n\n        <Button\n          type=\"text\"\n          icon={hasUpvoted ? <LikeFilled /> : <LikeOutlined />}\n          onClick={() => onUpvote(response.id)}\n          disabled={hasUpvoted}\n        >\n          {response.upvotes} {response.upvotes === 1 ? 'upvote' : 'upvotes'}\n        </Button>\n      </Space>\n    </Card>\n  );\n};\n\nexport default ResponseCard;\n
"},{"location":"v2/features/influence/responses/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/influence/responses/#verification-email-not-received","title":"Verification Email Not Received","text":"

Symptoms: - User doesn't receive verification email - Email not in spam folder

Solutions:

  1. Check email service logs \u2192 docker compose logs api | grep \"verification\"
  2. Verify SMTP configuration \u2192 test with /api/auth/test-email
  3. Check EMAIL_TEST_MODE \u2192 if true, email sent to MailHog (localhost:8025)
  4. Resend verification email \u2192 manual resend via admin UI

Manual Resend:

// Admin UI: ResponsesPage\nconst handleResendVerification = async (responseId: string) => {\n  await api.post(`/responses/${responseId}/resend-verification`);\n  message.success('Verification email resent');\n};\n
"},{"location":"v2/features/influence/responses/#duplicate-upvotes","title":"Duplicate Upvotes","text":"

Symptoms: - User can upvote same response multiple times - Upvote count inflated

Solutions:

  1. Check database constraints \u2192 should have unique constraint on responseId, ipAddress
  2. Verify transaction \u2192 upvote creation and count increment must be atomic
  3. Check IP address extraction \u2192 ensure req.ip is correct (consider X-Forwarded-For)

Database Fix:

-- Add unique constraint if missing\nALTER TABLE response_upvotes\nADD CONSTRAINT unique_response_ip\nUNIQUE (response_id, ip_address);\n\nALTER TABLE response_upvotes\nADD CONSTRAINT unique_response_user\nUNIQUE (response_id, user_id)\nWHERE user_id IS NOT NULL;\n
"},{"location":"v2/features/influence/responses/#screenshot-upload-fails","title":"Screenshot Upload Fails","text":"

Symptoms: - Upload spinner never completes - Error: \"File too large\"

Solutions:

  1. Check file size \u2192 max 5MB
  2. Verify file format \u2192 must be image (jpg/jpeg/png/gif/webp)
  3. Check upload directory permissions \u2192 /uploads/responses must be writable
  4. Increase Nginx upload limit \u2192 client_max_body_size 10M;

Code Fix (responses.service.ts):

import sharp from 'sharp';\n\nasync uploadScreenshot(\n  file: Express.Multer.File,\n  responseId: string\n): Promise<string> {\n  const uploadDir = `/uploads/responses/${responseId}`;\n  await fs.mkdir(uploadDir, { recursive: true });\n\n  const filename = `${Date.now()}-${file.originalname}`;\n\n  // Optimize image (max 1200px width, 85% quality)\n  await sharp(file.buffer)\n    .resize(1200, null, { withoutEnlargement: true })\n    .jpeg({ quality: 85 })\n    .toFile(`${uploadDir}/${filename}`);\n\n  return `${uploadDir}/${filename}`;\n}\n
"},{"location":"v2/features/influence/responses/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/influence/responses/#query-optimization","title":"Query Optimization","text":"

Index Strategy:

-- Composite index for public wall queries\nCREATE INDEX idx_response_campaign_status\n  ON responses (campaign_id, status)\n  WHERE status = 'APPROVED';\n\n-- Index for sorting by upvotes\nCREATE INDEX idx_response_upvotes\n  ON responses (upvotes DESC);\n\n-- Index for email verification lookups\nCREATE INDEX idx_response_verification_token\n  ON responses (verification_token)\n  WHERE verification_token IS NOT NULL;\n

Optimized Public Query:

const responses = await prisma.response.findMany({\n  where: {\n    campaignId,\n    status: 'APPROVED',\n    isEmailVerified: true\n  },\n  orderBy: [\n    { upvotes: 'desc' },\n    { createdAt: 'desc' }\n  ],\n  take: 50,\n  skip: page * 50\n});\n
"},{"location":"v2/features/influence/responses/#caching-strategy","title":"Caching Strategy","text":"

Redis Caching for Response Wall:

import { redisClient } from '../../../config/redis';\n\nasync getApprovedResponses(campaignId: string): Promise<Response[]> {\n  const cacheKey = `responses:${campaignId}`;\n\n  // Check cache\n  const cached = await redisClient.get(cacheKey);\n  if (cached) {\n    return JSON.parse(cached);\n  }\n\n  // Query database\n  const responses = await prisma.response.findMany({\n    where: {\n      campaignId,\n      status: 'APPROVED',\n      isEmailVerified: true\n    },\n    orderBy: { upvotes: 'desc' }\n  });\n\n  // Cache for 5 minutes\n  await redisClient.setex(cacheKey, 300, JSON.stringify(responses));\n\n  return responses;\n}\n\n// Invalidate cache on moderation\nasync moderateResponse(responseId: string, status: string) {\n  const response = await prisma.response.update({\n    where: { id: responseId },\n    data: { status }\n  });\n\n  // Invalidate cache\n  await redisClient.del(`responses:${response.campaignId}`);\n\n  return response;\n}\n
"},{"location":"v2/features/influence/responses/#screenshot-optimization","title":"Screenshot Optimization","text":"

Image Processing Pipeline:

import sharp from 'sharp';\n\nasync optimizeScreenshot(file: Express.Multer.File): Promise<Buffer> {\n  return sharp(file.buffer)\n    .resize(1200, null, {\n      withoutEnlargement: true,\n      fit: 'inside'\n    })\n    .jpeg({\n      quality: 85,\n      progressive: true\n    })\n    .toBuffer();\n}\n

CDN Integration:

// Upload optimized screenshots to CDN\nimport { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';\n\nconst s3 = new S3Client({ region: 'us-east-1' });\n\nasync uploadToCDN(buffer: Buffer, key: string): Promise<string> {\n  await s3.send(new PutObjectCommand({\n    Bucket: process.env.S3_BUCKET,\n    Key: `responses/${key}`,\n    Body: buffer,\n    ContentType: 'image/jpeg',\n    CacheControl: 'max-age=31536000' // 1 year\n  }));\n\n  return `${process.env.CDN_URL}/responses/${key}`;\n}\n
"},{"location":"v2/features/influence/responses/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/influence/responses/#backend-modules","title":"Backend Modules","text":"
  • Responses Module \u2014 Full API reference
  • Campaigns Module \u2014 Campaign integration
  • Email Service \u2014 Email verification
"},{"location":"v2/features/influence/responses/#frontend-pages","title":"Frontend Pages","text":"
  • ResponsesPage \u2014 Admin moderation
  • ResponseWallPage \u2014 Public response wall
"},{"location":"v2/features/influence/responses/#database-models_1","title":"Database Models","text":"
  • Response \u2014 Response schema
  • ResponseUpvote \u2014 Upvote tracking schema
  • Campaign \u2014 Campaign schema
"},{"location":"v2/features/influence/responses/#guides","title":"Guides","text":"
  • Campaign Management \u2014 Campaign configuration
  • Email Templates \u2014 Verification email templates
"},{"location":"v2/features/landing-pages/","title":"Landing Pages (Page Builder)","text":"

The Landing Pages feature provides a complete page building system with WYSIWYG editing, custom blocks, MkDocs export, and public rendering. Build custom landing pages without code.

"},{"location":"v2/features/landing-pages/#overview","title":"Overview","text":"

The Landing Pages system consists of four integrated components:

  1. Page Builder - Page CRUD and management
  2. GrapesJS Editor - WYSIWYG editor
  3. Block Library - Reusable content blocks
  4. MkDocs Export - Export to Jinja2 templates
"},{"location":"v2/features/landing-pages/#features","title":"Features","text":""},{"location":"v2/features/landing-pages/#wysiwyg-editor","title":"WYSIWYG Editor","text":"
  • GrapesJS integration
  • Drag-and-drop interface
  • Visual editing
  • Component customization
  • CSS styling
  • Responsive preview
  • Desktop-only (mobile warning)
"},{"location":"v2/features/landing-pages/#block-library","title":"Block Library","text":"

Pre-built components:

  • Hero sections - Large header with CTA
  • Feature grids - Multi-column features
  • Call-to-action - Button sections
  • Text blocks - Rich text content
  • Image galleries - Photo grids
  • Forms - Contact forms (future)
  • Custom HTML - Raw HTML blocks
"},{"location":"v2/features/landing-pages/#page-management","title":"Page Management","text":"
  • Create/edit/delete pages
  • Slug management (URL-friendly)
  • Meta tags (title, description)
  • Published/draft status
  • Page settings (layout, scripts)
  • Search and filtering
"},{"location":"v2/features/landing-pages/#mkdocs-export","title":"MkDocs Export","text":"
  • Export to Jinja2 Material theme templates
  • Custom overrides directory
  • Static page generation
  • SEO optimization
  • Template inheritance
"},{"location":"v2/features/landing-pages/#user-flow","title":"User Flow","text":""},{"location":"v2/features/landing-pages/#admin-experience","title":"Admin Experience","text":"
  1. Create Page (/app/pages)
  2. Click \"New Page\"
  3. Enter title and slug
  4. Set meta description
  5. Save draft

  6. Edit Page (/app/pages/:id/edit)

  7. Full-screen GrapesJS editor
  8. Drag blocks from sidebar
  9. Customize components
  10. Ctrl+S to save
  11. Preview changes

  12. Publish Page

  13. Set status to \"Published\"
  14. Page appears at /p/:slug
  15. Listed in page table

  16. Export to MkDocs (/app/services/docs)

  17. Select pages to export
  18. Click \"Export\"
  19. Pages saved to MkDocs overrides
  20. Rebuild MkDocs site
"},{"location":"v2/features/landing-pages/#public-experience","title":"Public Experience","text":"
  1. View Landing Page (/p/:slug)
  2. Rendered HTML/CSS
  3. Custom styling
  4. Responsive design
  5. SEO metadata
"},{"location":"v2/features/landing-pages/#architecture","title":"Architecture","text":""},{"location":"v2/features/landing-pages/#backend-components","title":"Backend Components","text":"

Module: - api/src/modules/pages/pages-admin.routes.ts - Admin CRUD - api/src/modules/pages/pages-public.routes.ts - Public renderer - api/src/modules/pages/blocks.routes.ts - Block library API - api/src/modules/pages/pages.service.ts - Business logic - api/src/modules/pages/pages.schemas.ts - Zod validation

Database Models: - Page - Page definitions (title, slug, html, css, settings) - PageBlock - Reusable block library

"},{"location":"v2/features/landing-pages/#frontend-components","title":"Frontend Components","text":"

Admin Pages: - admin/src/pages/LandingPagesPage.tsx - Page management table - admin/src/pages/PageEditorPage.tsx - Full-screen editor

Public Pages: - admin/src/pages/public/LandingPage.tsx - Page renderer

Editor Component: - admin/src/components/GrapesJSEditor.tsx - GrapesJS wrapper

"},{"location":"v2/features/landing-pages/#configuration","title":"Configuration","text":""},{"location":"v2/features/landing-pages/#environment-variables","title":"Environment Variables","text":"
# MkDocs export directory (inside Docker)\nMKDOCS_EXPORT_DIR=/mkdocs/docs/overrides\n
"},{"location":"v2/features/landing-pages/#page-settings","title":"Page Settings","text":"

Each page can configure:

  • Meta title - Browser title tag
  • Meta description - SEO description
  • Custom CSS - Page-specific styles
  • Custom JS - Page-specific scripts
  • Layout - Template wrapper (future)
"},{"location":"v2/features/landing-pages/#grapesjs-integration","title":"GrapesJS Integration","text":""},{"location":"v2/features/landing-pages/#editor-setup","title":"Editor Setup","text":"
import grapesjs from 'grapesjs';\n\nconst editor = grapesjs.init({\n  container: '#gjs',\n  fromElement: true,\n  height: '100vh',\n  storageManager: false,  // Save via API\n  canvas: {\n    styles: [...],         // Custom styles\n    scripts: [...],        // Custom scripts\n  },\n  blockManager: {\n    blocks: customBlocks,  // Block library\n  },\n});\n
"},{"location":"v2/features/landing-pages/#custom-blocks","title":"Custom Blocks","text":"

Blocks defined in GrapesJSEditor.tsx:

const customBlocks = [\n  {\n    id: 'hero-section',\n    label: 'Hero Section',\n    content: '<div class=\"hero\">...</div>',\n    category: 'Basic',\n  },\n  {\n    id: 'feature-grid',\n    label: 'Feature Grid',\n    content: '<div class=\"features\">...</div>',\n    category: 'Content',\n  },\n  // ... more blocks\n];\n
"},{"location":"v2/features/landing-pages/#save-handler","title":"Save Handler","text":"

Ctrl+S keyboard shortcut:

editor.on('run:core:save', () => {\n  const html = editor.getHtml();\n  const css = editor.getCss();\n  onSave({ html, css });\n});\n
"},{"location":"v2/features/landing-pages/#mkdocs-export_1","title":"MkDocs Export","text":""},{"location":"v2/features/landing-pages/#export-process","title":"Export Process","text":"
  1. Select Pages - Admin selects pages to export
  2. Generate Jinja2 - Wrap HTML in Material theme template
  3. Save to Overrides - Write to mkdocs/docs/overrides/
  4. Configure Front Matter - Set template, title, description
  5. Rebuild Site - MkDocs regenerates static site
"},{"location":"v2/features/landing-pages/#jinja2-template-wrapper","title":"Jinja2 Template Wrapper","text":"
{% extends \"main.html\" %}\n\n{% block content %}\n<style>\n{{ page_css }}\n</style>\n\n{{ page_html }}\n{% endblock %}\n
"},{"location":"v2/features/landing-pages/#front-matter","title":"Front Matter","text":"
---\ntemplate: custom-page.html\ntitle: Page Title\ndescription: Page description for SEO\n---\n
"},{"location":"v2/features/landing-pages/#database-schema","title":"Database Schema","text":""},{"location":"v2/features/landing-pages/#page-model","title":"Page Model","text":"
model Page {\n  id          Int      @id @default(autoincrement())\n  title       String\n  slug        String   @unique\n  html        String   @db.Text\n  css         String?  @db.Text\n  settings    Json?\n  published   Boolean  @default(false)\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n}\n
"},{"location":"v2/features/landing-pages/#pageblock-model","title":"PageBlock Model","text":"
model PageBlock {\n  id          Int      @id @default(autoincrement())\n  name        String\n  category    String\n  html        String   @db.Text\n  css         String?  @db.Text\n  thumbnail   String?\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n}\n
"},{"location":"v2/features/landing-pages/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/landing-pages/#admin-endpoints","title":"Admin Endpoints","text":"
GET    /api/pages                       # List pages\nPOST   /api/pages                       # Create page\nGET    /api/pages/:id                   # Get page\nPATCH  /api/pages/:id                   # Update page\nDELETE /api/pages/:id                   # Delete page\nPOST   /api/pages/export-mkdocs         # Export to MkDocs\nGET    /api/pages/blocks                # Get block library\nPOST   /api/pages/blocks                # Create block\n
"},{"location":"v2/features/landing-pages/#public-endpoints","title":"Public Endpoints","text":"
GET    /api/pages/public/:slug          # Get published page by slug\n
"},{"location":"v2/features/landing-pages/#desktop-only-editor","title":"Desktop-Only Editor","text":"

GrapesJS editor requires desktop browser:

const screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n\nif (isMobile) {\n  return (\n    <Alert\n      message=\"Desktop Required\"\n      description=\"Page editor requires desktop browser\"\n      type=\"warning\"\n    />\n  );\n}\n
"},{"location":"v2/features/landing-pages/#best-practices","title":"Best Practices","text":""},{"location":"v2/features/landing-pages/#slug-management","title":"Slug Management","text":"
  • Auto-generate from title
  • URL-friendly (lowercase, hyphens)
  • Unique constraint
  • Update URL on slug change
"},{"location":"v2/features/landing-pages/#seo-optimization","title":"SEO Optimization","text":"
  • Meta title (50-60 chars)
  • Meta description (150-160 chars)
  • Semantic HTML structure
  • Alt text for images
  • Heading hierarchy
"},{"location":"v2/features/landing-pages/#performance","title":"Performance","text":"
  • Minify CSS
  • Lazy load images
  • Async scripts
  • Cache rendered pages
"},{"location":"v2/features/landing-pages/#responsive-design","title":"Responsive Design","text":"
  • Mobile-first CSS
  • Flexible grids
  • Responsive images
  • Touch-friendly buttons
"},{"location":"v2/features/landing-pages/#related-documentation","title":"Related Documentation","text":"
  • Page Builder
  • GrapesJS Editor
  • Block Library
  • MkDocs Export
  • Backend Pages Module
  • Landing Pages Page
  • Page Editor Page
  • Content Editor Guide
"},{"location":"v2/features/map/","title":"Map Module","text":"

The Map module provides comprehensive location management, geographic organization, volunteer coordination, and door-to-door canvassing capabilities. It combines GIS features with volunteer management for effective ground campaigns.

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

The Map module consists of ten integrated components:

  1. Locations - Location database with geocoding
  2. Geocoding - Multi-provider address \u2192 coordinate conversion
  3. NAR Import - Canadian electoral data import
  4. Cuts - Geographic polygon organization
  5. Shifts - Volunteer shift scheduling
  6. Canvassing - Door-to-door canvassing system
  7. Tracking - GPS tracking sessions
  8. Walk Sheets - Printable canvass materials
  9. Data Quality - Geocoding quality monitoring
  10. Map Features Status - Feature completion tracking
"},{"location":"v2/features/map/#features","title":"Features","text":""},{"location":"v2/features/map/#location-management","title":"Location Management","text":"
  • Location CRUD with address, coordinates, metadata
  • CSV import/export (100,000+ records supported)
  • Multi-provider geocoding (6 providers)
  • Bulk geocoding with queue
  • NAR 2025 server-side import (Canadian electoral data)
  • Address standardization
  • Visit tracking integration
"},{"location":"v2/features/map/#geographic-organization","title":"Geographic Organization","text":"
  • Polygon-based geographic cuts
  • GeoJSON import/export
  • Point-in-polygon queries
  • Cut-based location assignment
  • Spatial bounds calculation
  • Map visualization
"},{"location":"v2/features/map/#volunteer-coordination","title":"Volunteer Coordination","text":"
  • Shift scheduling with cut assignment
  • Volunteer signup (authenticated + anonymous)
  • Email confirmations
  • Temp user creation for walk-ins
  • Shift capacity tracking
"},{"location":"v2/features/map/#canvassing-system","title":"Canvassing System","text":"
  • GPS-enabled mobile interface
  • Walking route algorithm (nearest-neighbor)
  • Visit outcome recording (7 outcomes)
  • Session management (start/end/abandon)
  • Real-time progress tracking
  • Admin monitoring dashboard
  • Printable walk sheets with QR codes
"},{"location":"v2/features/map/#map-display","title":"Map Display","text":"
  • Public interactive Leaflet map
  • Color-coded markers by visit status
  • Polygon overlays for cuts
  • Geolocate button
  • Fullscreen mode
  • Legend and controls
"},{"location":"v2/features/map/#user-flow","title":"User Flow","text":""},{"location":"v2/features/map/#admin-experience","title":"Admin Experience","text":"
  1. Import Locations (/app/map/locations)
  2. Upload CSV or NAR data
  3. Geocode addresses
  4. Review quality metrics
  5. Bulk operations

  6. Create Cuts (/app/map/cuts)

  7. Draw polygons on map
  8. Name and describe cut
  9. Assign locations (automatic)
  10. Export for printing

  11. Schedule Shifts (/app/map/shifts)

  12. Create shift with cut assignment
  13. Set date/time/capacity
  14. Email all volunteers
  15. Monitor signups

  16. Monitor Canvassing (/app/canvass/dashboard)

  17. View active sessions
  18. Track visit progress
  19. Check leaderboard
  20. Review activity feed

  21. Print Materials (/app/canvass/walk-sheet)

  22. Select cut
  23. Generate walk sheet PDF
  24. QR codes for quick access
  25. Browser print
"},{"location":"v2/features/map/#volunteer-experience","title":"Volunteer Experience","text":"
  1. View Assignments (/volunteer/assignments)
  2. See upcoming shifts
  3. Cut information
  4. Start canvass button

  5. Canvass (/volunteer/canvass/:cutId)

  6. Full-screen map with GPS
  7. Follow walking route
  8. Click markers to record visits
  9. Select outcomes + notes
  10. Track progress

  11. Review Activity (/volunteer/activity)

  12. Visit history
  13. Outcome breakdown
  14. Session statistics
"},{"location":"v2/features/map/#public-experience","title":"Public Experience","text":"
  1. View Map (/map)
  2. Browse locations
  3. View cuts
  4. See visit status (color-coded)
  5. Geolocate self

  6. Sign Up for Shifts (/shifts)

  7. Browse available shifts
  8. Signup with email
  9. Receive confirmation
"},{"location":"v2/features/map/#architecture","title":"Architecture","text":""},{"location":"v2/features/map/#backend-components","title":"Backend Components","text":"

Modules: - api/src/modules/map/locations/ - Location CRUD + geocoding + NAR import - api/src/modules/map/geocoding/ - Multi-provider geocoding service - api/src/modules/map/cuts/ - Polygon CRUD + spatial queries - api/src/modules/map/shifts/ - Shift CRUD + signups - api/src/modules/map/canvass/ - Session + visit tracking - api/src/modules/map/tracking/ - GPS tracking (future) - api/src/modules/map/settings/ - Map settings singleton

Services: - api/src/services/geocoding.service.ts - Geocoding abstraction - api/src/services/geocode-queue.service.ts - Async geocoding

Utilities: - api/src/utils/spatial.ts - Point-in-polygon, haversine, bounds, centroid

Database Models: - Location - Address, coordinates, metadata, visit tracking - Cut - Name, GeoJSON polygon - Shift - Date/time, cut, capacity, signups - CanvassSession - Session tracking, start/end times - CanvassVisit - Visit outcomes, notes, GPS - MapSettings - Map center/zoom, walk sheet config

"},{"location":"v2/features/map/#frontend-components","title":"Frontend Components","text":"

Admin Pages: - admin/src/pages/LocationsPage.tsx - Location management - admin/src/pages/CutsPage.tsx - Cut management - admin/src/pages/ShiftsPage.tsx - Shift management - admin/src/pages/CanvassDashboardPage.tsx - Canvass monitoring - admin/src/pages/WalkSheetPage.tsx - Printable materials - admin/src/pages/DataQualityDashboardPage.tsx - Quality metrics

Public Pages: - admin/src/pages/public/MapPage.tsx - Public map - admin/src/pages/public/ShiftsPage.tsx - Shift signup

Volunteer Pages: - admin/src/pages/volunteer/VolunteerMapPage.tsx - GPS canvass map - admin/src/pages/volunteer/VolunteerShiftsPage.tsx - Assignments - admin/src/pages/volunteer/MyActivityPage.tsx - Activity history

Map Components: - admin/src/components/map/MapControls.tsx - Control buttons - admin/src/components/map/AddLocationMode.tsx - Click-to-add - admin/src/components/map/CutDrawingMode.tsx - Polygon drawing - admin/src/components/map/CutOverlays.tsx - GeoJSON rendering

Canvass Components: - admin/src/components/canvass/GPSTracker.tsx - GPS tracking - admin/src/components/canvass/WalkingRouteLine.tsx - Route display - admin/src/components/canvass/VisitRecordingForm.tsx - Outcome form

"},{"location":"v2/features/map/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/#environment-variables","title":"Environment Variables","text":"
# Geocoding Providers\nMAPBOX_ACCESS_TOKEN=pk_...\nGOOGLE_GEOCODE_API_KEY=...\nPELIAS_API_URL=http://pelias:4000\n\n# NAR Import\nNAR_DATA_DIR=/data           # NAR file directory (Docker volume)\n\n# Map Settings\nMAP_DEFAULT_LAT=43.65        # Default map center\nMAP_DEFAULT_LNG=-79.38\nMAP_DEFAULT_ZOOM=12\n
"},{"location":"v2/features/map/#map-settings","title":"Map Settings","text":"

Configurable via admin UI (/app/map/settings): - Default map center (lat/lng) - Default zoom level - Walk sheet header/footer - Display preferences

"},{"location":"v2/features/map/#geocoding","title":"Geocoding","text":""},{"location":"v2/features/map/#supported-providers","title":"Supported Providers","text":"
  1. Nominatim (OpenStreetMap) - Free, rate limited
  2. ArcGIS - Free tier available
  3. Photon - Free, self-hosted option
  4. Mapbox - API key required
  5. Google Geocoding - API key required
  6. Pelias - Self-hosted option
"},{"location":"v2/features/map/#geocoding-strategy","title":"Geocoding Strategy","text":"
  1. Try provider 1 (Nominatim)
  2. If fails, try provider 2 (ArcGIS)
  3. Continue through providers
  4. Cache successful results
  5. Track quality metrics
"},{"location":"v2/features/map/#bulk-geocoding","title":"Bulk Geocoding","text":"
  • BullMQ queue for async processing
  • Batch processing (100 locations/batch)
  • Provider rotation to avoid rate limits
  • Progress tracking
  • Error handling and retry
"},{"location":"v2/features/map/#nar-import","title":"NAR Import","text":"

Canadian electoral data (NAR 2025 format):

  • Address files - Civic addresses with coordinates (EPSG:3347)
  • Location files - Building locations with lat/lng
  • Join on LOC_GUID - Combine address + coordinates
  • Server-side streaming - Memory-efficient for large files
  • Filters - Province, city, postal code, cut, residential-only

Import Flow: 1. Scan NAR data directory 2. List available provinces 3. Stream Address + Location files 4. Join on LOC_GUID 5. Transform coordinates (proj4) 6. Filter and insert locations

"},{"location":"v2/features/map/#spatial-algorithms","title":"Spatial Algorithms","text":""},{"location":"v2/features/map/#point-in-polygon","title":"Point-in-Polygon","text":"

Ray-casting algorithm: - Count ray intersections with polygon edges - Odd count = inside, even count = outside - Supports holes in polygons - Used for cut assignment

"},{"location":"v2/features/map/#walking-route","title":"Walking Route","text":"

Nearest-neighbor algorithm: 1. Start at closest location to shift start point 2. For each location: - Find nearest unvisited location - Add to route - Mark as visited 3. Return ordered list

"},{"location":"v2/features/map/#haversine-distance","title":"Haversine Distance","text":"

Great-circle distance between coordinates: - Returns distance in kilometers - Used for proximity sorting - Route optimization

"},{"location":"v2/features/map/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/map/#locations","title":"Locations","text":"
GET    /api/locations                   # List locations\nPOST   /api/locations                   # Create location\nGET    /api/locations/:id               # Get location\nPATCH  /api/locations/:id               # Update location\nDELETE /api/locations/:id               # Delete location\nPOST   /api/locations/import            # CSV import\nGET    /api/locations/export            # CSV export\nPOST   /api/locations/geocode           # Bulk geocode\n
"},{"location":"v2/features/map/#cuts","title":"Cuts","text":"
GET    /api/cuts                        # List cuts\nPOST   /api/cuts                        # Create cut\nGET    /api/cuts/:id                    # Get cut\nPATCH  /api/cuts/:id                    # Update cut\nDELETE /api/cuts/:id                    # Delete cut\nPOST   /api/cuts/:id/assign-locations   # Assign locations\n
"},{"location":"v2/features/map/#shifts","title":"Shifts","text":"
GET    /api/shifts                      # List shifts\nPOST   /api/shifts                      # Create shift\nGET    /api/shifts/:id                  # Get shift\nPATCH  /api/shifts/:id                  # Update shift\nDELETE /api/shifts/:id                  # Delete shift\nPOST   /api/shifts/:id/signup           # Signup for shift\n
"},{"location":"v2/features/map/#canvassing","title":"Canvassing","text":"
POST   /api/canvass/session/start       # Start session\nPOST   /api/canvass/session/end         # End session\nGET    /api/canvass/session             # Get active session\nPOST   /api/canvass/visit               # Record visit\nGET    /api/canvass/route/:cutId        # Get walking route\nGET    /api/canvass/dashboard           # Dashboard stats\n
"},{"location":"v2/features/map/#related-documentation","title":"Related Documentation","text":"
  • Locations
  • Geocoding
  • NAR Import
  • Cuts
  • Shifts
  • Canvassing
  • Walk Sheets
  • Data Quality
  • Backend Locations Module
  • Backend Canvass Module
  • Spatial Utilities
  • Map Organizer Guide
  • Volunteer Guide
"},{"location":"v2/features/map/MAP_FEATURES_STATUS/","title":"Map Features Documentation Status","text":""},{"location":"v2/features/map/MAP_FEATURES_STATUS/#completion-summary","title":"Completion Summary","text":"

Date: 2026-02-13 Task: Create 9 comprehensive Map feature documentation files Status: 4/9 COMPLETE (in progress)

"},{"location":"v2/features/map/MAP_FEATURES_STATUS/#completed-files-4053-lines","title":"Completed Files (4053 lines)","text":"
  1. \u2705 locations.md (1154 lines) \u2014 Location management system
  2. Building + unit architecture
  3. NAR integration
  4. CSV import/export
  5. Geocoding integration
  6. Multi-provider support

  7. \u2705 geocoding.md (1029 lines) \u2014 Multi-provider geocoding service

  8. 6 provider fallback chain
  9. Confidence scoring
  10. Redis caching
  11. BullMQ bulk processing
  12. Provider health tracking

  13. \u2705 cuts.md (924 lines) \u2014 Geographic polygon overlays

  14. Polygon drawing workflow
  15. GeoJSON storage
  16. Point-in-polygon ray-casting
  17. Cut categories
  18. Completion tracking

  19. \u2705 shifts.md (946 lines) \u2014 Volunteer shift management

  20. Shift scheduling
  21. Capacity management
  22. Public signup
  23. TEMP user creation
  24. Email confirmations
"},{"location":"v2/features/map/MAP_FEATURES_STATUS/#remaining-files-5","title":"Remaining Files (5)","text":"
  1. \ud83d\udea7 canvassing.md \u2014 Canvassing session system
  2. Session lifecycle
  3. Visit recording
  4. Walking route algorithm
  5. GPS integration
  6. Volunteer + admin workflows

  7. \ud83d\udea7 tracking.md \u2014 GPS tracking system

  8. TrackingSession model
  9. TrackPoint recording
  10. Distance calculation
  11. Route visualization
  12. Live volunteer tracking

  13. \ud83d\udea7 walk-sheets.md \u2014 Printable walk sheets + QR codes

  14. MapSettings configuration
  15. QR code generation
  16. Walk sheet layout
  17. Cut export
  18. Browser print API

  19. \ud83d\udea7 data-quality.md \u2014 Geocoding quality dashboard

  20. Confidence metrics
  21. Provider success rate
  22. Ungeocoded locations
  23. Low-confidence alerts
  24. Duplicate detection

  25. \ud83d\udea7 nar-import.md \u2014 NAR 2025 electoral data import

  26. NAR format support
  27. Server-side streaming
  28. Address + Location join
  29. Lambert coordinate conversion
  30. Province code mapping
"},{"location":"v2/features/map/MAP_FEATURES_STATUS/#next-steps","title":"Next Steps","text":"

Continue creating remaining 5 files following the established 12-section structure:

  1. Overview
  2. Architecture (Mermaid diagram)
  3. Database Models
  4. API Endpoints
  5. Configuration
  6. Admin Workflow
  7. Public Workflow (if applicable)
  8. Volunteer Workflow (if applicable)
  9. Code Examples
  10. Troubleshooting
  11. Performance Considerations
  12. Related Documentation

Target: 6,000-9,000 total lines across all 9 files (~670-1000 lines per file) Current: 4,053 lines (4 files) Remaining: ~2,950-4,950 lines (5 files)

"},{"location":"v2/features/map/canvassing/","title":"Canvassing Session System","text":""},{"location":"v2/features/map/canvassing/#overview","title":"Overview","text":"

The canvassing system provides a complete door-to-door organizing workflow with GPS tracking, walking route optimization, visit recording, and progress tracking. It enables volunteers to efficiently canvass assigned territories using mobile devices with real-time location updates.

Key Capabilities:

  • Session Lifecycle: ACTIVE \u2192 COMPLETED \u2192 ABANDONED (auto-close after 12h)
  • Walking Route Algorithm: Nearest-neighbor optimization from volunteer GPS position
  • Visit Recording: 7 outcome types with support level updates
  • GPS Integration: Live tracking via TrackingSession (1:1 relationship)
  • Rate Limiting: 30 visits/min per IP to prevent abuse
  • Progress Tracking: Cut completion percentage auto-calculated
  • Admin Oversight: Active sessions dashboard, activity feed, leaderboard
  • Volunteer Portal: Full-screen map with bottom-sheet visit recording

Use Cases:

  • Door-to-door canvassing for electoral campaigns
  • Voter ID (identifying supporter levels)
  • GOTV (Get Out The Vote) efforts
  • Sign placement tracking
  • Petition signature collection
  • Issue surveys
  • Volunteer coordination
"},{"location":"v2/features/map/canvassing/#architecture","title":"Architecture","text":"
graph TD\n    A[Volunteer] -->|Start Session| B[VolunteerMapPage]\n    B -->|POST /api/map/canvass/sessions| C[Canvass Service]\n    C -->|Create| D[(CanvassSession)]\n    C -->|Start GPS| E[Tracking Service]\n    E -->|Create| F[(TrackingSession)]\n\n    B -->|Load Addresses| C\n    C -->|Filter by Cut| G[Spatial Utils]\n    G -->|Point-in-Polygon| H[(Location Model)]\n\n    B -->|Calculate Route| I[Walking Route Service]\n    I -->|Nearest Neighbor| J[Haversine Distance]\n    J -->|Return Route| B\n\n    K[Volunteer GPS] -->|Submit Points| E\n    E -->|Save| L[(TrackPoint)]\n    E -->|Calculate Distance| J\n\n    B -->|Record Visit| C\n    C -->|Create| M[(CanvassVisit)]\n    C -->|Update Address| N[(Address Model)]\n    C -->|Update Progress| D\n\n    O[Admin] -->|View Dashboard| P[CanvassDashboardPage]\n    P -->|GET /api/map/canvass/admin/activity| C\n    C -->|Aggregate Stats| D\n    C -->|Activity Feed| M\n\n    D -->|1:1| F\n    D -->|1:N| M\n    M -->|N:1| N\n\n    style D fill:#e1f5ff\n    style F fill:#e1f5ff\n    style M fill:#e1f5ff\n    style N fill:#e1f5ff\n    style H fill:#e1f5ff

Flow Description:

  1. Volunteer starts session \u2192 Creates CanvassSession + TrackingSession, loads addresses within cut
  2. Calculate route \u2192 Walking route service uses nearest-neighbor from volunteer GPS position
  3. GPS tracking \u2192 Auto-submit points every 10s, calculate distance with haversine
  4. Record visit \u2192 Create CanvassVisit with outcome, update Address support level, update session progress
  5. End session \u2192 Mark session COMPLETED, end tracking session, calculate final stats
  6. Admin oversight \u2192 View active sessions, activity feed, cut progress, volunteer leaderboard
"},{"location":"v2/features/map/canvassing/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/canvassing/#canvasssession-model","title":"CanvassSession Model","text":"

See CanvassSession Model Documentation for full schema.

Key Fields:

  • userId: Foreign key to volunteer User
  • cutId: Foreign key to Cut (territory)
  • shiftId: Optional foreign key to Shift (if started from shift)
  • status: ACTIVE | COMPLETED | ABANDONED
  • startedAt: Session start timestamp
  • endedAt: Session end timestamp (null while active)
  • totalVisits: Count of CanvassVisit records
  • completionPercentage: Auto-calculated from cut progress

Status Lifecycle:

ACTIVE (session running)\n  \u2193 (volunteer ends session)\nCOMPLETED\n\nOR\n\nACTIVE (session running > 12 hours)\n  \u2193 (auto-cleanup cron)\nABANDONED\n
"},{"location":"v2/features/map/canvassing/#canvassvisit-model","title":"CanvassVisit Model","text":"

See CanvassVisit Model Documentation for full schema.

Key Fields:

  • sessionId: Foreign key to CanvassSession
  • userId: Foreign key to volunteer User
  • addressId: Foreign key to Address (specific unit visited)
  • outcome: Visit result (7 types)
  • supportLevel: Updated support level (LEVEL_1-4 or null)
  • signRequested: Boolean - resident wants lawn/window sign
  • notes: Free-text canvass notes
  • visitedAt: Visit timestamp
  • durationSeconds: Time spent at door (auto-calculated)

Visit Outcome Enum:

enum VisitOutcome {\n  NOT_HOME         // Nobody answered door\n  REFUSED          // Refused to speak\n  MOVED            // Resident moved away\n  ALREADY_VOTED    // Already voted (GOTV)\n  SPOKE_WITH       // Had conversation\n  LEFT_LITERATURE  // Left campaign material\n  COME_BACK_LATER  // Asked to return later\n}\n

Related Models:

  • TrackingSession \u2014 GPS tracking (1:1)
  • Address \u2014 Updated with visit data
  • Cut \u2014 Territory boundary
  • Shift \u2014 Optional shift assignment
"},{"location":"v2/features/map/canvassing/#api-endpoints","title":"API Endpoints","text":"

See Canvass Backend Module Documentation for full API reference.

Volunteer Endpoints:

Method Endpoint Auth Description GET /api/map/canvass/volunteer/assignments Any logged-in user Get shifts with cut assignments GET /api/map/canvass/volunteer/stats Any logged-in user Get volunteer canvass statistics GET /api/map/canvass/volunteer/visits Any logged-in user List own canvass visits with pagination POST /api/map/canvass/sessions Any logged-in user Start new canvass session PATCH /api/map/canvass/sessions/:id Any logged-in user Update session (end session) GET /api/map/canvass/sessions/:id/addresses Any logged-in user Get addresses within session cut POST /api/map/canvass/sessions/:id/route Any logged-in user Calculate walking route POST /api/map/canvass/visits Any logged-in user Record single visit POST /api/map/canvass/visits/bulk Any logged-in user Record multiple visits (batch) PATCH /api/map/canvass/volunteer/locations/:id Any logged-in user Update location from canvass

Admin Endpoints:

Method Endpoint Auth Description GET /api/map/canvass/admin/activity MAP_ADMIN Get recent canvass activity feed GET /api/map/canvass/admin/sessions MAP_ADMIN List active canvass sessions GET /api/map/canvass/admin/visits MAP_ADMIN List all canvass visits with filters GET /api/map/canvass/admin/progress MAP_ADMIN Get cut completion progress GET /api/map/canvass/admin/leaderboard MAP_ADMIN Get volunteer visit leaderboard"},{"location":"v2/features/map/canvassing/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/canvassing/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description CANVASS_SESSION_TIMEOUT_HOURS number 12 Auto-abandon active sessions after N hours CANVASS_VISIT_RATE_LIMIT number 30 Max visits per minute per IP"},{"location":"v2/features/map/canvassing/#rate-limiting","title":"Rate Limiting","text":"

Visit Recording Rate Limit:

  • Limit: 30 visits/min per IP address
  • Window: 60 seconds
  • Redis Prefix: rl:canvass-visit:
  • Purpose: Prevent accidental bulk submissions from GPS auto-submit bugs
"},{"location":"v2/features/map/canvassing/#session-auto-cleanup","title":"Session Auto-Cleanup","text":"

Abandoned Session Detection:

System automatically marks sessions as ABANDONED if:

  • Status: ACTIVE
  • Started: >12 hours ago
  • No Activity: No visits in last hour

Cleanup Schedule:

  • Startup: On API server startup (check all active sessions)
  • Cron: Every hour at :00 (setInterval)
// api/src/server.ts\nsetInterval(async () => {\n  await canvassService.cleanupAbandonedSessions();\n}, 60 * 60 * 1000); // 1 hour\n
"},{"location":"v2/features/map/canvassing/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/canvassing/#viewing-active-sessions","title":"Viewing Active Sessions","text":"

Step 1: Navigate to Canvass Dashboard

Navigate to Map \u2192 Canvass Dashboard in the admin sidebar.

![CanvassDashboardPage Screenshot Placeholder]

Step 2: View Active Sessions

Active Sessions card displays:

  • Volunteer Name: Who is canvassing
  • Cut Name: Territory being canvassed
  • Start Time: When session started
  • Duration: Time elapsed (live updating)
  • Visits: Number of visits recorded
  • Status: ACTIVE badge

Step 3: View Session Details

Click session row to view:

  • Volunteer GPS Location: Last known position on map
  • Route: Walking route polyline
  • Visited Addresses: Green markers
  • Unvisited Addresses: Blue markers
  • Recent Visits: Last 10 visits with outcomes
"},{"location":"v2/features/map/canvassing/#monitoring-canvass-activity","title":"Monitoring Canvass Activity","text":"

Step 1: View Activity Feed

Recent Activity section displays:

  • Volunteer Name: Who recorded visit
  • Address: Location visited
  • Outcome: Visit result (icon + label)
  • Support Level: Updated support level (color-coded)
  • Time Ago: \"5 minutes ago\"

Step 2: Filter Activity

Use filters:

  • Date Range: Last hour / day / week / month
  • Outcome: Filter by specific outcome type
  • Volunteer: Filter by volunteer name
  • Cut: Filter by territory

Step 3: Export Activity

Click Export CSV to download activity feed for reporting.

"},{"location":"v2/features/map/canvassing/#tracking-cut-completion","title":"Tracking Cut Completion","text":"

Step 1: View Cut Progress

Cut Progress card displays:

  • Cut Name: Territory name
  • Total Addresses: Count of addresses in cut
  • Visited: Count of addresses with CanvassVisit records
  • Completion: Percentage (progress bar)
  • Last Activity: Time since last visit

Step 2: View Detailed Progress

Click cut row to view:

  • Address List: All addresses in cut with visit status
  • Visit Heatmap: Map showing visited (green) vs unvisited (blue)
  • Outcome Breakdown: Pie chart of visit outcomes
  • Volunteer Breakdown: Who visited which addresses
"},{"location":"v2/features/map/canvassing/#volunteer-leaderboard","title":"Volunteer Leaderboard","text":"

Step 1: View Leaderboard

Leaderboard card displays:

  • Rank: 1st, 2nd, 3rd place
  • Volunteer Name: Volunteer name
  • Total Visits: Visit count
  • Doors/Hour: Efficiency metric
  • Top Outcome: Most common outcome

Step 2: Filter by Time Period

Toggle time period:

  • Today: Visits since midnight
  • This Week: Visits since Monday
  • This Month: Visits since 1st of month
  • All Time: Total visits
"},{"location":"v2/features/map/canvassing/#volunteer-workflow","title":"Volunteer Workflow","text":""},{"location":"v2/features/map/canvassing/#starting-a-canvass-session","title":"Starting a Canvass Session","text":"

Step 1: Login

Login at /login with volunteer account (or use TEMP account from shift signup).

Step 2: View Assignments

Navigate to Volunteer \u2192 My Assignments.

Step 3: Select Shift

Click Start Canvass on a shift with cut assignment.

Step 4: Grant GPS Permission

Browser requests geolocation permission. Click Allow.

Step 5: Start Session

System redirects to /volunteer/canvass/:cutId (full-screen map).

System will:

  1. Create CanvassSession (status=ACTIVE)
  2. Create TrackingSession (linked 1:1)
  3. Load addresses within cut polygon
  4. Calculate walking route from current GPS position
  5. Start GPS auto-tracking (submit points every 10s)
"},{"location":"v2/features/map/canvassing/#following-walking-route","title":"Following Walking Route","text":"

Step 1: View Route on Map

Map displays:

  • Blue Polyline: Optimized walking route
  • Blue Markers: Unvisited addresses (ordered by route)
  • Green Markers: Visited addresses
  • Red Marker: Current GPS position (live updating)

Step 2: Navigate to First Address

Follow route to nearest unvisited address. Route recalculates when you move.

Step 3: View Address Details

Tap marker to view:

  • Address: Street address + unit number
  • Resident Name: First/Last name (if available)
  • Support Level: Previous support level (if available)
  • Last Visit: Previous visit outcome + date (if applicable)
"},{"location":"v2/features/map/canvassing/#recording-a-visit","title":"Recording a Visit","text":"

Step 1: Knock on Door

Approach address and knock/ring doorbell.

Step 2: Open Visit Recording Form

Tap Record Visit button in bottom toolbar. Bottom sheet slides up.

Step 3: Select Outcome

Choose visit outcome:

  • Not Home: Nobody answered
  • Refused: Refused to speak
  • Moved: Resident moved away
  • Already Voted: Already voted (GOTV campaigns)
  • Spoke With: Had conversation
  • Left Literature: Left campaign material
  • Come Back Later: Asked to return later

Step 4: Update Support Level (if applicable)

For \"Spoke With\" outcome, select support level:

  • Level 1 (Strong): Green badge
  • Level 2 (Leaning): Yellow badge
  • Level 3 (Undecided): Gray badge
  • Level 4 (Opposed): Red badge

Step 5: Sign Request (optional)

Toggle Sign Requested if resident wants lawn/window sign.

Step 6: Add Notes (optional)

Enter free-text notes (e.g., \"Asked about healthcare policy\", \"Concerned about taxes\").

Step 7: Save Visit

Tap Save Visit. System will:

  1. Create CanvassVisit record with outcome + timestamp
  2. Update Address with new support level + sign status + notes
  3. Increment session.totalVisits count
  4. Update cut.completionPercentage
  5. Create LocationHistory audit record
  6. Submit GPS trackpoint with eventType=VISIT_RECORDED
  7. Update marker to green (visited)
  8. Recalculate walking route (exclude visited address)
"},{"location":"v2/features/map/canvassing/#ending-a-canvass-session","title":"Ending a Canvass Session","text":"

Step 1: Finish Route

Complete visits for all addresses (or as many as possible).

Step 2: End Session

Tap End Session button in header.

Step 3: Confirm

Confirmation modal displays session summary:

  • Duration: Total session time
  • Visits: Number of visits recorded
  • Distance: Total distance walked (from GPS tracking)
  • Doors/Hour: Efficiency metric

Step 4: Submit

Tap End Session. System will:

  1. Update CanvassSession (status=COMPLETED, endedAt=now)
  2. End TrackingSession (isActive=false, endedAt=now)
  3. Calculate final stats (totalVisits, totalDistanceM)
  4. Redirect to /volunteer/activity (visit history page)
"},{"location":"v2/features/map/canvassing/#viewing-visit-history","title":"Viewing Visit History","text":"

Step 1: Navigate to My Activity

Navigate to Volunteer \u2192 My Activity.

Step 2: View Visit List

Table displays:

  • Address: Location visited
  • Outcome: Visit result (icon + label)
  • Support Level: Updated support level
  • Visit Date: Formatted date/time
  • Notes: Canvassing notes (truncated)

Step 3: Filter Visits

Use filters:

  • Date Range: Last week / month / all time
  • Outcome: Filter by specific outcome
  • Support Level: Filter by support level

Step 4: View Session History

Navigate to My Routes to view:

  • Session List: Past canvass sessions
  • Session Map: GPS route polyline + visited markers
  • Session Stats: Duration, visits, distance, doors/hour
"},{"location":"v2/features/map/canvassing/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/canvassing/#start-canvass-session-backend","title":"Start Canvass Session (Backend)","text":"
// api/src/modules/map/canvass/canvass.service.ts\nasync startSession(userId: string, data: StartSessionInput) {\n  const { cutId, shiftId, startLat, startLng } = data;\n\n  // Check for existing active session\n  const existing = await prisma.canvassSession.findFirst({\n    where: { userId, status: CanvassSessionStatus.ACTIVE },\n  });\n\n  if (existing) {\n    throw new AppError(400, 'Already have an active session', 'SESSION_ACTIVE');\n  }\n\n  // Create session + tracking session in transaction\n  const session = await prisma.$transaction(async (tx) => {\n    const canvassSession = await tx.canvassSession.create({\n      data: {\n        userId,\n        cutId,\n        shiftId,\n        status: CanvassSessionStatus.ACTIVE,\n      },\n    });\n\n    if (startLat && startLng) {\n      await tx.trackingSession.create({\n        data: {\n          userId,\n          canvassSessionId: canvassSession.id,\n          lastLatitude: new Prisma.Decimal(startLat),\n          lastLongitude: new Prisma.Decimal(startLng),\n          lastRecordedAt: new Date(),\n        },\n      });\n    }\n\n    return canvassSession;\n  });\n\n  setActiveCanvassSessions(\n    await prisma.canvassSession.count({\n      where: { status: CanvassSessionStatus.ACTIVE },\n    })\n  );\n\n  return session;\n}\n
"},{"location":"v2/features/map/canvassing/#calculate-walking-route-backend","title":"Calculate Walking Route (Backend)","text":"
// api/src/modules/map/canvass/canvass-route.service.ts\nexport function calculateWalkingRoute(\n  locations: RouteLocation[],\n  startLat?: number,\n  startLng?: number,\n  cutGeojson?: string,\n): RouteResult {\n  if (locations.length === 0) {\n    return { orderedLocations: [], totalDistanceMeters: 0, estimatedMinutes: 0 };\n  }\n\n  // Determine starting point\n  let currentLat: number;\n  let currentLng: number;\n\n  if (startLat !== undefined && startLng !== undefined) {\n    currentLat = startLat;\n    currentLng = startLng;\n  } else if (cutGeojson) {\n    const polygons = parseGeoJsonPolygon(cutGeojson);\n    const centroid = calculateCentroid(polygons[0]!);\n    currentLat = centroid.lat;\n    currentLng = centroid.lng;\n  } else {\n    // Use first location as starting point\n    currentLat = locations[0]!.latitude;\n    currentLng = locations[0]!.longitude;\n  }\n\n  const remaining = [...locations];\n  const ordered: RouteLocation[] = [];\n  let totalDistance = 0;\n\n  // Nearest-neighbor algorithm\n  while (remaining.length > 0) {\n    let nearestIdx = 0;\n    let nearestDist = Infinity;\n\n    for (let i = 0; i < remaining.length; i++) {\n      const loc = remaining[i]!;\n      const dist = haversineDistance(currentLat, currentLng, loc.latitude, loc.longitude);\n      if (dist < nearestDist) {\n        nearestDist = dist;\n        nearestIdx = i;\n      }\n    }\n\n    const nearest = remaining.splice(nearestIdx, 1)[0]!;\n    ordered.push(nearest);\n    totalDistance += nearestDist;\n    currentLat = nearest.latitude;\n    currentLng = nearest.longitude;\n  }\n\n  const WALKING_SPEED_MPS = 5000 / 60; // 5 km/h in m/min\n  const MINUTES_PER_DOOR = 2;\n  const walkingMinutes = totalDistance / WALKING_SPEED_MPS;\n  const doorMinutes = ordered.length * MINUTES_PER_DOOR;\n  const estimatedMinutes = Math.round(walkingMinutes + doorMinutes);\n\n  return {\n    orderedLocations: ordered,\n    totalDistanceMeters: Math.round(totalDistance),\n    estimatedMinutes,\n  };\n}\n
"},{"location":"v2/features/map/canvassing/#record-visit-backend","title":"Record Visit (Backend)","text":"
// api/src/modules/map/canvass/canvass.service.ts\nasync recordVisit(userId: string, data: RecordVisitInput) {\n  const { sessionId, addressId, outcome, supportLevel, signRequested, notes } = data;\n\n  // Verify session ownership and active status\n  const session = await prisma.canvassSession.findFirst({\n    where: { id: sessionId, userId, status: CanvassSessionStatus.ACTIVE },\n  });\n\n  if (!session) {\n    throw new AppError(404, 'Active session not found', 'SESSION_NOT_FOUND');\n  }\n\n  // Create visit + update address in transaction\n  const visit = await prisma.$transaction(async (tx) => {\n    const canvassVisit = await tx.canvassVisit.create({\n      data: {\n        sessionId,\n        userId,\n        addressId,\n        outcome,\n        supportLevel,\n        signRequested: signRequested ?? false,\n        notes,\n      },\n    });\n\n    // Update address with new data\n    if (supportLevel || signRequested !== undefined || notes) {\n      await tx.address.update({\n        where: { id: addressId },\n        data: {\n          ...(supportLevel && { supportLevel }),\n          ...(signRequested !== undefined && { sign: signRequested }),\n          ...(notes && { notes }),\n        },\n      });\n    }\n\n    // Increment session visit count\n    await tx.canvassSession.update({\n      where: { id: sessionId },\n      data: { totalVisits: { increment: 1 } },\n    });\n\n    // Update cut completion percentage\n    if (session.cutId) {\n      const cutId = session.cutId;\n      const totalAddresses = await tx.address.count({\n        where: {\n          location: {\n            // Point-in-polygon query omitted for brevity\n          },\n        },\n      });\n\n      const visitedAddresses = await tx.canvassVisit.count({\n        where: {\n          session: { cutId },\n        },\n      });\n\n      const completionPercentage = Math.round((visitedAddresses / totalAddresses) * 100);\n\n      await tx.cut.update({\n        where: { id: cutId },\n        data: { completionPercentage },\n      });\n    }\n\n    return canvassVisit;\n  });\n\n  recordCanvassVisit(outcome);\n\n  return visit;\n}\n
"},{"location":"v2/features/map/canvassing/#gps-auto-tracking-frontend","title":"GPS Auto-Tracking (Frontend)","text":"
// admin/src/components/canvass/GPSTracker.tsx\nuseEffect(() => {\n  if (!session || !geolocationEnabled) return;\n\n  const watchId = navigator.geolocation.watchPosition(\n    (position) => {\n      const point = {\n        latitude: position.coords.latitude,\n        longitude: position.coords.longitude,\n        accuracy: position.coords.accuracy,\n        recordedAt: new Date().toISOString(),\n      };\n\n      // Add to local buffer\n      setPointsBuffer((prev) => [...prev, point]);\n\n      // Update current position\n      setCurrentPosition([point.latitude, point.longitude]);\n    },\n    (error) => {\n      console.error('GPS error:', error);\n      message.error('GPS tracking failed');\n    },\n    {\n      enableHighAccuracy: true,\n      maximumAge: 0,\n      timeout: 10000,\n    }\n  );\n\n  // Submit buffered points every 10 seconds\n  const interval = setInterval(async () => {\n    if (pointsBuffer.length === 0) return;\n\n    try {\n      await api.post(`/map/tracking/sessions/${trackingSessionId}/points`, {\n        points: pointsBuffer,\n      });\n\n      setPointsBuffer([]);\n    } catch (error) {\n      console.error('Failed to submit GPS points:', error);\n    }\n  }, 10000);\n\n  return () => {\n    navigator.geolocation.clearWatch(watchId);\n    clearInterval(interval);\n  };\n}, [session, geolocationEnabled, pointsBuffer, trackingSessionId]);\n
"},{"location":"v2/features/map/canvassing/#visit-recording-form-frontend","title":"Visit Recording Form (Frontend)","text":"
// admin/src/components/canvass/VisitRecordingForm.tsx\nconst handleSubmit = async (values: any) => {\n  try {\n    await api.post('/map/canvass/visits', {\n      sessionId: session.id,\n      addressId: selectedAddress.id,\n      outcome: values.outcome,\n      supportLevel: values.supportLevel,\n      signRequested: values.signRequested,\n      notes: values.notes,\n    });\n\n    message.success('Visit recorded');\n    form.resetFields();\n    onVisitRecorded();\n  } catch (error) {\n    message.error('Failed to record visit');\n  }\n};\n
"},{"location":"v2/features/map/canvassing/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/canvassing/#issue-walking-route-not-optimal","title":"Issue: Walking Route Not Optimal","text":"

Symptoms:

  • Route backtracks frequently
  • Total distance much longer than expected
  • Route doesn't start from volunteer GPS position

Causes:

  • Nearest-neighbor algorithm is greedy (not globally optimal)
  • Starting position not provided (defaults to cut centroid)
  • GPS accuracy poor (volunteer position inaccurate)

Solutions:

  1. Use volunteer GPS position as start:
// Always pass volunteer GPS position to route calculation\nconst route = await calculateWalkingRoute(\n  locations,\n  currentLat,\n  currentLng,\n  cut.geojson\n);\n
  1. Consider alternative algorithms:

For better optimization, use 2-opt or genetic algorithms (computationally expensive):

// Install optimization library\nnpm install routing-js\n\n// Use 2-opt algorithm\nimport { twoOpt } from 'routing-js';\nconst optimized = twoOpt(locations, distanceMatrix);\n
  1. Pre-optimize routes for shifts:

Admin can pre-calculate optimal routes and assign to volunteers:

// Calculate route once, store in Shift model\nconst route = await calculateWalkingRoute(locations);\nawait prisma.shift.update({\n  where: { id: shiftId },\n  data: { preCalculatedRoute: JSON.stringify(route) },\n});\n
"},{"location":"v2/features/map/canvassing/#issue-session-auto-abandoned-prematurely","title":"Issue: Session Auto-Abandoned Prematurely","text":"

Symptoms:

  • Active session marked ABANDONED while volunteer still canvassing
  • Session timeout after <12 hours
  • Volunteer can't record visits after timeout

Causes:

  • CANVASS_SESSION_TIMEOUT_HOURS set too low
  • Volunteer paused for lunch/break (no activity for >1 hour)
  • System clock drift

Solutions:

  1. Increase timeout:
# In .env\nCANVASS_SESSION_TIMEOUT_HOURS=24  # Was 12, increase to 24\n
  1. Record \"heartbeat\" visits:

Add periodic \"still active\" ping to prevent timeout:

// Volunteer app sends heartbeat every 30 minutes\nsetInterval(async () => {\n  await api.post(`/map/canvass/sessions/${sessionId}/heartbeat`);\n}, 30 * 60 * 1000);\n
  1. Allow session resumption:

Let volunteers resume ABANDONED sessions:

// Backend: Add resume endpoint\nasync resumeSession(userId: string, sessionId: string) {\n  const session = await prisma.canvassSession.findFirst({\n    where: { id: sessionId, userId, status: CanvassSessionStatus.ABANDONED },\n  });\n\n  if (!session) {\n    throw new AppError(404, 'Abandoned session not found', 'SESSION_NOT_FOUND');\n  }\n\n  return prisma.canvassSession.update({\n    where: { id: sessionId },\n    data: { status: CanvassSessionStatus.ACTIVE },\n  });\n}\n
"},{"location":"v2/features/map/canvassing/#issue-gps-tracking-draining-battery","title":"Issue: GPS Tracking Draining Battery","text":"

Symptoms:

  • Volunteer phone battery drains rapidly
  • Phone overheats during canvassing
  • GPS tracking fails after 2-3 hours

Causes:

  • enableHighAccuracy uses GPS + WiFi + cellular (power-hungry)
  • Watchposition submits too frequently (every second)
  • Screen stays on during entire session

Solutions:

  1. Reduce GPS accuracy:
navigator.geolocation.watchPosition(\n  callback,\n  errorCallback,\n  {\n    enableHighAccuracy: false, // Use WiFi/cellular only (less accurate but lower power)\n    maximumAge: 5000,          // Cache position for 5s\n    timeout: 30000,            // Longer timeout\n  }\n);\n
  1. Reduce submission frequency:
// Submit GPS points every 30s instead of 10s\nconst SUBMIT_INTERVAL_MS = 30000; // Was 10000\n
  1. Pause tracking during breaks:

Add \"Pause Tracking\" button to stop GPS watchPosition:

const pauseTracking = () => {\n  navigator.geolocation.clearWatch(watchId);\n  setTrackingPaused(true);\n};\n\nconst resumeTracking = () => {\n  // Start watchPosition again\n  setTrackingPaused(false);\n};\n
"},{"location":"v2/features/map/canvassing/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/canvassing/#visit-recording-rate-limiting","title":"Visit Recording Rate Limiting","text":"

Prevent Abuse:

Rate limit prevents accidental bulk submissions:

// api/src/middleware/rate-limit.ts\nconst canvassVisitLimiter = new RateLimiterRedis({\n  storeClient: redis,\n  keyPrefix: 'rl:canvass-visit:',\n  points: 30,      // 30 visits\n  duration: 60,    // per 60 seconds\n  blockDuration: 300, // block for 5 minutes if exceeded\n});\n

Legitimate Use Cases:

  • Bulk data entry: Admin can bypass rate limit for importing historical data
  • Offline sync: Mobile app queues visits offline, submits when online (batch endpoint)
"},{"location":"v2/features/map/canvassing/#session-cleanup-performance","title":"Session Cleanup Performance","text":"

Efficient Abandoned Session Query:

-- Index for abandoned session cleanup\nCREATE INDEX idx_canvass_sessions_abandoned ON \"CanvassSession\" (\"status\", \"startedAt\")\nWHERE status = 'ACTIVE';\n\n-- Efficient query\nSELECT id FROM \"CanvassSession\"\nWHERE status = 'ACTIVE'\n  AND \"startedAt\" < NOW() - INTERVAL '12 hours';\n
"},{"location":"v2/features/map/canvassing/#cut-completion-calculation","title":"Cut Completion Calculation","text":"

Avoid N+1 Queries:

// Inefficient: query per cut\nfor (const cut of cuts) {\n  const visited = await prisma.canvassVisit.count({\n    where: { session: { cutId: cut.id } },\n  });\n  const total = await getAddressesInCut(cut.id).length;\n  cut.completionPercentage = (visited / total) * 100;\n}\n\n// Efficient: single aggregation query\nconst completionStats = await prisma.canvassSession.groupBy({\n  by: ['cutId'],\n  where: { status: CanvassSessionStatus.COMPLETED },\n  _count: { visits: true },\n});\n
"},{"location":"v2/features/map/canvassing/#related-documentation","title":"Related Documentation","text":"

Backend Modules:

  • Canvass Backend Module \u2014 API implementation
  • Walking Route Service \u2014 Route optimization
  • Tracking Service \u2014 GPS tracking

Frontend Pages:

  • VolunteerMapPage \u2014 Full-screen canvass map
  • CanvassDashboardPage \u2014 Admin oversight
  • MyActivityPage \u2014 Visit history

Database:

  • CanvassSession Model \u2014 Session schema
  • CanvassVisit Model \u2014 Visit records
  • TrackingSession Model \u2014 GPS tracking

Features:

  • Cuts \u2014 Territory boundaries for canvassing
  • Shifts \u2014 Shift-based canvass scheduling
  • Tracking \u2014 GPS tracking system
  • Locations \u2014 Address management
"},{"location":"v2/features/map/cuts/","title":"Geographic Polygon Overlays (Cuts)","text":""},{"location":"v2/features/map/cuts/#overview","title":"Overview","text":"

The cuts system provides polygon-based geographic organizing using customizable map overlays. Cuts enable campaigns to divide territories into canvassing zones, track completion progress, and assign volunteers to specific areas.

Key Capabilities:

  • Polygon Drawing: Click-to-draw custom polygons on Leaflet maps
  • GeoJSON Storage: Store complex polygons with coordinate precision
  • Spatial Queries: Point-in-polygon filtering using ray-casting algorithm
  • Cut Categories: CUSTOM, WARD, NEIGHBORHOOD, DISTRICT classification
  • Visual Customization: Configurable colors and opacity for map overlays
  • Bounds Calculation: Auto-calculate bounding box from polygon coordinates
  • Completion Tracking: Track canvassing progress by cut
  • Shift Assignment: Link shifts to cuts for volunteer scheduling
  • Export Filtering: Generate walk sheets for specific cuts

Use Cases:

  • Electoral district mapping (wards, polling divisions)
  • Canvassing zone organization
  • Neighborhood targeting
  • Volunteer territory assignment
  • Walk sheet generation by area
  • Progress tracking by geographic zone
  • Multi-volunteer coordination
"},{"location":"v2/features/map/cuts/#architecture","title":"Architecture","text":"
graph TD\n    A[Admin User] -->|Draws Polygon| B[CutDrawingMode]\n    B -->|Click Vertices| C[Leaflet Map]\n    C -->|Auto-Close Detection| D[GeoJSON Polygon]\n    D -->|POST /api/map/cuts| E[Cuts Service]\n    E -->|Calculate Bounds| F[Spatial Utils]\n    F -->|Save| G[(Cut Model)]\n\n    H[Public Map] -->|Load Cuts| I[GET /api/public/map/cuts]\n    I -->|Return GeoJSON| E\n    E -->|Query| G\n    I -->|Render| J[CutOverlays Component]\n\n    K[Canvass Session] -->|Start in Cut| L[Canvass Service]\n    L -->|Load Addresses| M[Locations Service]\n    M -->|Point-in-Polygon| F\n    F -->|Filter| N[(Location Model)]\n\n    O[Shift] -->|Assigned to Cut| G\n    G -->|1:N| O\n\n    P[Export Locations] -->|Filter by Cut| M\n    M -->|Query Polygon| F\n\n    style G fill:#e1f5ff\n    style N fill:#e1f5ff\n    style O fill:#e1f5ff

Flow Description:

  1. Admin draws cut \u2192 Click vertices on map, auto-close detection, generate GeoJSON
  2. Save cut \u2192 Calculate bounds from coordinates, store polygon in database
  3. Public map loads \u2192 Query public cuts, render as colored overlays with opacity
  4. Canvass session starts \u2192 Load addresses within cut polygon using ray-casting
  5. Shift assignment \u2192 Link shift to cut for volunteer scheduling
  6. Export locations \u2192 Filter by cut polygon to generate walk sheet
"},{"location":"v2/features/map/cuts/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/cuts/#cut-model","title":"Cut Model","text":"

See Cut Model Documentation for full schema.

Key Fields:

  • name: Cut display name (e.g., \"Ward 5 - Downtown\")
  • description: Free-text notes about the cut
  • geojson: Polygon coordinates in GeoJSON format (TEXT field)
  • bounds: Auto-calculated bounding box {minLat, maxLat, minLng, maxLng} (JSON)
  • color: Hex color for map overlay (default: #3498db)
  • opacity: Opacity 0.0-1.0 for map rendering (default: 0.3)
  • category: CUSTOM | WARD | NEIGHBORHOOD | DISTRICT
  • isPublic: Show on public map
  • isOfficial: Official electoral boundary (prevents accidental deletion)
  • showLocations: Show location markers within cut on map
  • exportEnabled: Allow walk sheet export for this cut
  • assignedTo: Free-text assigned volunteer/team name
  • completionPercentage: Auto-calculated canvassing progress (0-100)

GeoJSON Format:

{\n  \"type\": \"Polygon\",\n  \"coordinates\": [\n    [\n      [-75.6972, 45.4215],\n      [-75.6980, 45.4220],\n      [-75.6960, 45.4230],\n      [-75.6950, 45.4225],\n      [-75.6972, 45.4215]\n    ]\n  ]\n}\n

Bounds Format:

{\n  \"minLat\": 45.4215,\n  \"maxLat\": 45.4230,\n  \"minLng\": -75.6980,\n  \"maxLng\": -75.6950\n}\n

Related Models:

  • Shift \u2014 Volunteer shifts assigned to cut
  • CanvassSession \u2014 Canvassing within cut
  • Location \u2014 Filtered by cut polygon
"},{"location":"v2/features/map/cuts/#api-endpoints","title":"API Endpoints","text":"

See Cuts Backend Module Documentation for full API reference.

Admin Endpoints:

Method Endpoint Auth Description GET /api/map/cuts MAP_ADMIN List cuts with pagination, search, category filter GET /api/map/cuts/stats MAP_ADMIN Get cut statistics (total, by category) GET /api/map/cuts/:id MAP_ADMIN Get cut details POST /api/map/cuts MAP_ADMIN Create new cut with polygon PATCH /api/map/cuts/:id MAP_ADMIN Update cut DELETE /api/map/cuts/:id MAP_ADMIN Delete cut (blocked if isOfficial=true) GET /api/map/cuts/:id/locations MAP_ADMIN Get locations within cut polygon GET /api/map/cuts/:id/progress MAP_ADMIN Get canvassing progress for cut

Public Endpoints:

Method Endpoint Auth Description GET /api/public/map/cuts None List public cuts (isPublic=true) GET /api/public/map/cuts/:id None Get public cut details"},{"location":"v2/features/map/cuts/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/cuts/#environment-variables","title":"Environment Variables","text":"

No specific environment variables for cuts. Uses standard database and map settings.

"},{"location":"v2/features/map/cuts/#cut-category-enum","title":"Cut Category Enum","text":"
enum CutCategory {\n  CUSTOM       // User-defined boundary\n  WARD         // Municipal ward boundary\n  NEIGHBORHOOD // Neighborhood association boundary\n  DISTRICT     // Electoral district boundary\n}\n
"},{"location":"v2/features/map/cuts/#default-values","title":"Default Values","text":"Field Default Description color #3498db Blue color for overlay opacity 0.3 30% opacity (transparent) isPublic false Hidden from public map isOfficial false Can be deleted by admin showLocations true Show location markers within cut exportEnabled true Allow walk sheet export completionPercentage 0 Auto-updated by canvass service"},{"location":"v2/features/map/cuts/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/cuts/#creating-a-cut","title":"Creating a Cut","text":"

Step 1: Navigate to Cuts Page

Navigate to Map \u2192 Cuts in the admin sidebar.

![CutsPage Screenshot Placeholder]

Step 2: Open Drawing Tab

Click Drawing tab to switch to map drawing mode.

Step 3: Activate Drawing Mode

Click Draw Cut button in the map controls. Map cursor changes to crosshair.

Step 4: Click Vertices

Click on the map to place polygon vertices:

  • First Click: Start polygon
  • Additional Clicks: Add vertices
  • Auto-Close: When cursor near start point (within 10px), polygon auto-closes

Step 5: Configure Cut

Fill in the cut form (right sidebar):

  • Name: \"Ward 5 - Downtown\"
  • Description: \"Central business district and residential blocks\"
  • Category: WARD
  • Color: Choose from color picker (default: blue)
  • Opacity: Slider 0-100 (default: 30%)
  • Is Public: Toggle to show on public map
  • Is Official: Toggle to prevent accidental deletion

Step 6: Save Cut

Click Save Cut. The system will:

  1. Generate GeoJSON from vertices
  2. Calculate bounding box
  3. Save to database
  4. Render polygon on map with configured color/opacity
"},{"location":"v2/features/map/cuts/#editing-a-cut","title":"Editing a Cut","text":"

Step 1: Select Cut

On Table tab, click Edit button for a cut.

Step 2: Update Fields

Modify cut properties:

  • Name/Description: Update text fields
  • Color/Opacity: Adjust visual appearance
  • Category: Change classification
  • Public/Official: Toggle flags

Step 3: Re-Draw Polygon (Optional)

To change polygon shape:

  1. Switch to Drawing tab
  2. Click Edit Cut button
  3. Delete old vertices (click vertices to remove)
  4. Add new vertices
  5. Auto-close polygon

Step 4: Save Changes

Click Update to save changes. Bounds are auto-recalculated if polygon changed.

"},{"location":"v2/features/map/cuts/#viewing-locations-in-cut","title":"Viewing Locations in Cut","text":"

Step 1: Select Cut

Click cut row in table to select.

Step 2: Click \"View Locations\"

Click View Locations button.

Step 3: View Filtered Table

System displays locations within cut polygon:

  • Point-in-Polygon: Uses ray-casting algorithm to filter
  • Count: Number of locations within cut
  • Support Breakdown: Count by support level

Step 4: Export Locations

Click Export CSV to download locations for walk sheet generation.

"},{"location":"v2/features/map/cuts/#assigning-cut-to-shift","title":"Assigning Cut to Shift","text":"

Step 1: Create/Edit Shift

On Map \u2192 Shifts page, create or edit a shift.

Step 2: Select Cut

In shift form, choose cut from Cut dropdown.

Step 3: Save Shift

Shift is now linked to cut. Volunteers will see cut name on shift details.

"},{"location":"v2/features/map/cuts/#tracking-cut-completion","title":"Tracking Cut Completion","text":"

Step 1: View Cut Progress

On CutsPage, click Progress button for a cut.

Step 2: View Metrics

System displays:

  • Completion Percentage: Auto-calculated from canvass visits
  • Total Addresses: Count of addresses within cut
  • Visited: Count of addresses with CanvassVisit records
  • Outstanding: Remaining addresses to visit

Step 3: View Canvass Activity

Table shows recent canvass visits within cut:

  • Volunteer Name: Who visited
  • Visit Date: When visited
  • Outcome: Visit result (SPOKE_WITH, NOT_HOME, etc.)
  • Support Level: Updated support level (if applicable)
"},{"location":"v2/features/map/cuts/#public-workflow","title":"Public Workflow","text":"

Public users can view cut overlays on the interactive map.

Step 1: Navigate to Public Map

Visit /map (no authentication required).

Step 2: Toggle Cut Overlays

Click Cuts button in map controls to open overlay panel.

Step 3: Select Cuts

Check/uncheck cuts to show/hide on map:

  • Color Legend: Shows cut name and color
  • Opacity: Semi-transparent overlays don't obscure markers
  • Multiple Cuts: Show multiple cuts simultaneously

Step 4: View Cut Details

Click on a cut polygon to view:

  • Cut Name: Displayed in popup
  • Category: Ward, Neighborhood, etc.
  • Assigned To: Volunteer/team name (if configured)
"},{"location":"v2/features/map/cuts/#volunteer-workflow","title":"Volunteer Workflow","text":"

Volunteers interact with cuts via shift assignments.

Step 1: View Assigned Shifts

On Volunteer \u2192 My Assignments page, view shifts with cut assignments.

Step 2: Start Canvass Session

Click Start Canvass on a shift. Redirects to /volunteer/canvass/:cutId.

Step 3: View Cut on Map

Full-screen map shows:

  • Cut Polygon: Highlighted boundary
  • Locations Within Cut: Filtered to cut polygon only
  • Walking Route: Optimal route through cut locations

See Canvassing Documentation for full volunteer workflow.

"},{"location":"v2/features/map/cuts/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/cuts/#cut-service-create-backend","title":"Cut Service Create (Backend)","text":"
// api/src/modules/map/cuts/cuts.service.ts\nimport { parseGeoJsonPolygon, calculateBounds } from '../../../utils/spatial';\n\nasync create(data: CreateCutInput, userId: string) {\n  // Auto-calculate bounds from geojson if not provided\n  let boundsStr = data.bounds;\n  if (!boundsStr) {\n    try {\n      const rings = parseGeoJsonPolygon(data.geojson);\n      const allCoords = rings.flat();\n      const bounds = calculateBounds(allCoords);\n      boundsStr = JSON.stringify(bounds);\n    } catch {\n      // Bounds calculation optional\n    }\n  }\n\n  const cut = await prisma.cut.create({\n    data: {\n      name: data.name,\n      description: data.description,\n      color: data.color,\n      opacity: data.opacity,\n      category: data.category,\n      isPublic: data.isPublic,\n      isOfficial: data.isOfficial,\n      geojson: data.geojson,\n      bounds: boundsStr,\n      showLocations: data.showLocations,\n      exportEnabled: data.exportEnabled,\n      assignedTo: data.assignedTo,\n      createdByUserId: userId,\n    },\n  });\n\n  return cut;\n}\n
"},{"location":"v2/features/map/cuts/#bounds-calculation-backend","title":"Bounds Calculation (Backend)","text":"
// api/src/utils/spatial.ts\nexport function calculateBounds(coordinates: number[][]): {\n  minLat: number;\n  maxLat: number;\n  minLng: number;\n  maxLng: number;\n} {\n  let minLat = Infinity;\n  let maxLat = -Infinity;\n  let minLng = Infinity;\n  let maxLng = -Infinity;\n\n  for (const coord of coordinates) {\n    const lng = coord[0]!;\n    const lat = coord[1]!;\n    if (lat < minLat) minLat = lat;\n    if (lat > maxLat) maxLat = lat;\n    if (lng < minLng) minLng = lng;\n    if (lng > maxLng) maxLng = lng;\n  }\n\n  return { minLat, maxLat, minLng, maxLng };\n}\n
"},{"location":"v2/features/map/cuts/#point-in-polygon-filter-backend","title":"Point-in-Polygon Filter (Backend)","text":"
// api/src/modules/map/cuts/cuts.service.ts\nimport { isPointInPolygon, parseGeoJsonPolygon } from '../../../utils/spatial';\n\nasync getLocationsInCut(cutId: string) {\n  const cut = await prisma.cut.findUnique({\n    where: { id: cutId },\n    select: { geojson: true },\n  });\n\n  if (!cut?.geojson) {\n    throw new AppError(404, 'Cut not found', 'CUT_NOT_FOUND');\n  }\n\n  // Get all locations (or use bounds for optimization)\n  const locations = await prisma.location.findMany({\n    select: {\n      id: true,\n      latitude: true,\n      longitude: true,\n      address: true,\n    },\n  });\n\n  // Parse polygon coordinates\n  const polygons = parseGeoJsonPolygon(cut.geojson);\n\n  // Filter locations using ray-casting algorithm\n  const filtered = locations.filter((loc) => {\n    const lat = Number(loc.latitude);\n    const lng = Number(loc.longitude);\n    return polygons.some((poly) => isPointInPolygon(lat, lng, poly));\n  });\n\n  return filtered;\n}\n
"},{"location":"v2/features/map/cuts/#ray-casting-algorithm-backend","title":"Ray-Casting Algorithm (Backend)","text":"
// api/src/utils/spatial.ts\nexport function isPointInPolygon(\n  lat: number,\n  lng: number,\n  polygonCoords: number[][]\n): boolean {\n  let inside = false;\n  for (let i = 0, j = polygonCoords.length - 1; i < polygonCoords.length; j = i++) {\n    const xi = polygonCoords[i]![1]!; // lat\n    const yi = polygonCoords[i]![0]!; // lng\n    const xj = polygonCoords[j]![1]!;\n    const yj = polygonCoords[j]![0]!;\n\n    const intersect = ((yi > lng) !== (yj > lng)) &&\n      (lat < (xj - xi) * (lng - yi) / (yj - yi) + xi);\n    if (intersect) inside = !inside;\n  }\n  return inside;\n}\n
"},{"location":"v2/features/map/cuts/#cut-drawing-mode-frontend","title":"Cut Drawing Mode (Frontend)","text":"
// admin/src/components/map/CutDrawingMode.tsx\nimport { useState, useEffect } from 'react';\nimport { useMapEvents } from 'react-leaflet';\nimport type { LatLng } from 'leaflet';\n\ninterface CutDrawingModeProps {\n  onPolygonComplete: (vertices: LatLng[]) => void;\n}\n\nexport default function CutDrawingMode({ onPolygonComplete }: CutDrawingModeProps) {\n  const [vertices, setVertices] = useState<LatLng[]>([]);\n  const [isDrawing, setIsDrawing] = useState(true);\n\n  useMapEvents({\n    click(e) {\n      if (!isDrawing) return;\n\n      const newVertex = e.latlng;\n\n      // Auto-close detection: if click near first vertex (within 10px)\n      if (vertices.length >= 3) {\n        const firstVertex = vertices[0]!;\n        const map = e.target;\n        const firstPoint = map.latLngToContainerPoint(firstVertex);\n        const newPoint = map.latLngToContainerPoint(newVertex);\n        const distance = Math.sqrt(\n          Math.pow(firstPoint.x - newPoint.x, 2) +\n          Math.pow(firstPoint.y - newPoint.y, 2)\n        );\n\n        if (distance < 10) {\n          // Auto-close polygon\n          setIsDrawing(false);\n          onPolygonComplete(vertices);\n          return;\n        }\n      }\n\n      // Add vertex\n      setVertices([...vertices, newVertex]);\n    },\n  });\n\n  return (\n    <>\n      {/* Render temporary polygon while drawing */}\n      {vertices.length >= 2 && (\n        <Polygon positions={vertices} pathOptions={{ color: '#3498db', opacity: 0.5 }} />\n      )}\n      {/* Render vertex markers */}\n      {vertices.map((v, i) => (\n        <CircleMarker\n          key={i}\n          center={v}\n          radius={5}\n          pathOptions={{ color: '#e74c3c', fillColor: '#e74c3c', fillOpacity: 1 }}\n        />\n      ))}\n    </>\n  );\n}\n
"},{"location":"v2/features/map/cuts/#cut-overlays-rendering-frontend","title":"Cut Overlays Rendering (Frontend)","text":"
// admin/src/components/map/CutOverlays.tsx\nimport { Polygon, Popup } from 'react-leaflet';\nimport type { Cut } from '@/types/api';\n\ninterface CutOverlaysProps {\n  cuts: Cut[];\n  visibleCutIds: string[];\n}\n\nexport default function CutOverlays({ cuts, visibleCutIds }: CutOverlaysProps) {\n  return (\n    <>\n      {cuts\n        .filter((cut) => visibleCutIds.includes(cut.id))\n        .map((cut) => {\n          const geojson = JSON.parse(cut.geojson);\n          // GeoJSON uses [lng, lat], Leaflet uses [lat, lng]\n          const positions = geojson.coordinates[0].map(([lng, lat]: number[]) => [lat, lng]);\n\n          return (\n            <Polygon\n              key={cut.id}\n              positions={positions}\n              pathOptions={{\n                color: cut.color,\n                fillColor: cut.color,\n                fillOpacity: cut.opacity,\n                weight: 2,\n              }}\n            >\n              <Popup>\n                <div>\n                  <strong>{cut.name}</strong>\n                  <br />\n                  {cut.category}\n                  {cut.assignedTo && (\n                    <>\n                      <br />\n                      Assigned to: {cut.assignedTo}\n                    </>\n                  )}\n                </div>\n              </Popup>\n            </Polygon>\n          );\n        })}\n    </>\n  );\n}\n
"},{"location":"v2/features/map/cuts/#convert-leaflet-polygon-to-geojson-frontend","title":"Convert Leaflet Polygon to GeoJSON (Frontend)","text":"
// admin/src/pages/CutsPage.tsx\nconst handleSaveCut = async (vertices: LatLng[]) => {\n  // Convert Leaflet [lat, lng] to GeoJSON [lng, lat]\n  const coordinates = vertices.map((v) => [v.lng, v.lat]);\n\n  // Close polygon (first vertex === last vertex)\n  coordinates.push(coordinates[0]!);\n\n  const geojson = {\n    type: 'Polygon',\n    coordinates: [coordinates],\n  };\n\n  try {\n    const { data } = await api.post<Cut>('/map/cuts', {\n      name: cutName,\n      description: cutDescription,\n      geojson: JSON.stringify(geojson),\n      color: cutColor,\n      opacity: cutOpacity,\n      category: cutCategory,\n      isPublic: isPublic,\n      isOfficial: isOfficial,\n    });\n\n    message.success('Cut created');\n    fetchCuts();\n  } catch (error) {\n    message.error('Failed to create cut');\n  }\n};\n
"},{"location":"v2/features/map/cuts/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/cuts/#issue-polygon-not-closing","title":"Issue: Polygon Not Closing","text":"

Symptoms:

  • Clicking near start point doesn't auto-close polygon
  • Polygon remains open after many vertices
  • \"Save Cut\" button disabled

Causes:

  • Auto-close distance threshold too small
  • Mouse click precision issues on mobile
  • Map zoom level affecting pixel distance calculation

Solutions:

  1. Increase auto-close threshold:
// admin/src/components/map/CutDrawingMode.tsx\nconst AUTO_CLOSE_DISTANCE_PX = 15; // Was 10, increase to 15\n\nif (distance < AUTO_CLOSE_DISTANCE_PX) {\n  // Auto-close polygon\n}\n
  1. Manual close button:

Add explicit \"Close Polygon\" button for mobile users:

<Button onClick={() => {\n  if (vertices.length >= 3) {\n    onPolygonComplete(vertices);\n  }\n}}>\n  Close Polygon\n</Button>\n
"},{"location":"v2/features/map/cuts/#issue-point-in-polygon-returns-wrong-results","title":"Issue: Point-in-Polygon Returns Wrong Results","text":"

Symptoms:

  • Locations outside cut polygon included in canvass session
  • Locations inside cut polygon excluded
  • Export CSV missing locations

Causes:

  • Coordinate order mismatch (GeoJSON [lng, lat] vs Leaflet [lat, lng])
  • Polygon not properly closed (first vertex !== last vertex)
  • Ray-casting algorithm bug with edge cases

Solutions:

  1. Verify coordinate order:
// GeoJSON uses [lng, lat]\nconst geojson = {\n  type: 'Polygon',\n  coordinates: [\n    [\n      [-75.6972, 45.4215], // [lng, lat]\n      [-75.6980, 45.4220],\n      // ...\n    ]\n  ]\n};\n\n// Leaflet uses [lat, lng]\n<Polygon positions={[[45.4215, -75.6972], [45.4220, -75.6980]]} />\n
  1. Verify polygon closure:
-- Check if polygon is properly closed\nSELECT id, name,\n  geojson::json->'coordinates'->0->0 as first_vertex,\n  geojson::json->'coordinates'->0->-1 as last_vertex\nFROM \"Cut\"\nWHERE id = 'YOUR_CUT_ID';\n\n-- First and last should be identical\n
  1. Test with known points:
# Test point-in-polygon directly\ncurl -X POST http://localhost:4000/api/map/cuts/YOUR_CUT_ID/test-point \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"latitude\":45.4220,\"longitude\":-75.6975}'\n
"},{"location":"v2/features/map/cuts/#issue-cut-rendering-performance-slow","title":"Issue: Cut Rendering Performance Slow","text":"

Symptoms:

  • Map lags when rendering multiple cuts
  • Browser freezes with >10 cuts visible
  • Polygon rendering takes >2 seconds

Causes:

  • Too many polygon vertices (complex boundaries)
  • Multiple cut overlays rendered simultaneously
  • No polygon simplification

Solutions:

  1. Simplify complex polygons:

Use Turf.js simplify algorithm to reduce vertices:

import * as turf from '@turf/turf';\n\nconst simplified = turf.simplify(polygon, {\n  tolerance: 0.0001, // Adjust based on zoom level\n  highQuality: true\n});\n
  1. Lazy render cuts:

Only render cuts within current map bounds:

const visibleCuts = cuts.filter((cut) => {\n  const bounds = JSON.parse(cut.bounds);\n  const mapBounds = map.getBounds();\n  return mapBounds.intersects([\n    [bounds.minLat, bounds.minLng],\n    [bounds.maxLat, bounds.maxLng]\n  ]);\n});\n
  1. Use Canvas renderer:

For large polygons, use Leaflet Canvas renderer instead of SVG:

<Polygon\n  positions={positions}\n  renderer={L.canvas()}\n  pathOptions={{ color: cut.color }}\n/>\n
"},{"location":"v2/features/map/cuts/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/cuts/#spatial-query-optimization","title":"Spatial Query Optimization","text":"

Bounds Pre-Filter:

Always pre-filter by bounding box before point-in-polygon:

async getLocationsInCut(cutId: string) {\n  const cut = await prisma.cut.findUnique({ where: { id: cutId } });\n  const bounds = JSON.parse(cut.bounds);\n\n  // Pre-filter by bounds (fast, uses index)\n  const candidates = await prisma.location.findMany({\n    where: {\n      latitude: {\n        gte: new Prisma.Decimal(bounds.minLat),\n        lte: new Prisma.Decimal(bounds.maxLat),\n      },\n      longitude: {\n        gte: new Prisma.Decimal(bounds.minLng),\n        lte: new Prisma.Decimal(bounds.maxLng),\n      },\n    },\n  });\n\n  // Then apply point-in-polygon (slower, but fewer candidates)\n  const polygons = parseGeoJsonPolygon(cut.geojson);\n  return candidates.filter((loc) => {\n    const lat = Number(loc.latitude);\n    const lng = Number(loc.longitude);\n    return polygons.some((poly) => isPointInPolygon(lat, lng, poly));\n  });\n}\n

Performance Impact:

  • Without bounds pre-filter: 10,000 locations \u2192 10,000 point-in-polygon checks
  • With bounds pre-filter: 10,000 locations \u2192 500 candidates \u2192 500 point-in-polygon checks (20x faster)
"},{"location":"v2/features/map/cuts/#polygon-simplification","title":"Polygon Simplification","text":"

Reduce Vertices for Large Cuts:

Use Douglas-Peucker algorithm to simplify polygons while preserving shape:

import * as turf from '@turf/turf';\n\nfunction simplifyPolygon(geojson: string, tolerance: number = 0.0001): string {\n  const polygon = JSON.parse(geojson);\n  const simplified = turf.simplify(polygon, { tolerance, highQuality: true });\n  return JSON.stringify(simplified);\n}\n\n// Usage: simplify when importing official boundaries (e.g., electoral districts)\nconst simplifiedGeojson = simplifyPolygon(officialBoundary, 0.0005);\n

Tolerance Guidelines:

  • 0.00001: High precision (\u00b11m), use for small neighborhoods
  • 0.0001: Medium precision (\u00b110m), use for wards
  • 0.001: Low precision (\u00b1100m), use for large districts
"},{"location":"v2/features/map/cuts/#caching-cut-queries","title":"Caching Cut Queries","text":"

Cache Frequently Used Cuts:

// Cache cut polygons in Redis for fast repeated queries\nconst CACHE_KEY = `CUT_POLYGON:${cutId}`;\nconst cached = await redis.get(CACHE_KEY);\n\nif (cached) {\n  return JSON.parse(cached);\n}\n\nconst cut = await prisma.cut.findUnique({ where: { id: cutId } });\nawait redis.setex(CACHE_KEY, 3600, JSON.stringify(cut)); // 1 hour TTL\n\nreturn cut;\n
"},{"location":"v2/features/map/cuts/#related-documentation","title":"Related Documentation","text":"

Backend Modules:

  • Cuts Backend Module \u2014 API implementation
  • Spatial Utils \u2014 Point-in-polygon algorithms
  • Locations Service \u2014 Spatial filtering

Frontend Pages:

  • CutsPage \u2014 Admin CRUD interface
  • CutDrawingMode \u2014 Polygon drawing
  • CutOverlays \u2014 Map rendering

Database:

  • Cut Model \u2014 Cut schema
  • Spatial Queries \u2014 Optimization tips

Features:

  • Locations \u2014 Location filtering by cut
  • Shifts \u2014 Shift assignment to cuts
  • Canvassing \u2014 Canvassing within cut boundaries
  • Walk Sheets \u2014 Export locations by cut
"},{"location":"v2/features/map/data-quality/","title":"Data Quality Dashboard","text":""},{"location":"v2/features/map/data-quality/#overview","title":"Overview","text":"

The Data Quality Dashboard provides comprehensive monitoring and management of geocoding accuracy and location data integrity. This feature enables campaign administrators to identify and resolve data quality issues, track geocoding provider performance, and ensure reliable map data for canvassing operations.

Key Features:

  • Real-time geocoding quality metrics
  • Provider success rate tracking
  • Low-confidence location detection
  • Duplicate location identification
  • Bulk re-geocoding operations
  • Address validation reporting
  • Interactive quality charts
  • Export quality reports

Use Cases:

  • Monthly data quality audits
  • NAR import validation
  • Geocoding provider evaluation
  • Pre-canvass data verification
  • Address database cleanup
  • Campaign planning accuracy checks

Architecture Highlights:

  • Aggregate statistics via database queries
  • Confidence threshold filtering (0-100 scale)
  • Provider performance comparison
  • Duplicate detection via coordinate matching
  • Manual review workflows
  • Prometheus metrics integration
"},{"location":"v2/features/map/data-quality/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph Admin Interface\n        Admin[Admin User]\n        Dashboard[DataQualityDashboardPage]\n        LocationsPage[LocationsPage]\n    end\n\n    subgraph API Layer\n        StatsAPI[\"/api/locations/geocode-stats\"]\n        LocationsAPI[\"/api/locations\"]\n        DuplicatesAPI[\"/api/locations/duplicates\"]\n        RegeocodeAPI[\"/api/locations/:id/regeocode\"]\n        BulkGeocodeAPI[\"/api/locations/bulk-geocode\"]\n    end\n\n    subgraph Database\n        LocationsDB[(Locations)]\n        Indexes[(Indexes)]\n    end\n\n    subgraph Geocoding Service\n        GeocodingService[GeocodingService]\n        Providers[6 Providers]\n        Cache[Redis Cache]\n    end\n\n    subgraph Monitoring\n        Prometheus[Prometheus]\n        Metrics[cm_locations_low_confidence_count]\n    end\n\n    Admin --> Dashboard\n    Admin --> LocationsPage\n\n    Dashboard --> StatsAPI\n    Dashboard --> LocationsAPI\n    Dashboard --> DuplicatesAPI\n    LocationsPage --> RegeocodeAPI\n    LocationsPage --> BulkGeocodeAPI\n\n    StatsAPI --> LocationsDB\n    LocationsAPI --> LocationsDB\n    DuplicatesAPI --> LocationsDB\n    RegeocodeAPI --> GeocodingService\n    BulkGeocodeAPI --> GeocodingService\n\n    LocationsDB --> Indexes\n    GeocodingService --> Providers\n    GeocodingService --> Cache\n\n    StatsAPI --> Prometheus\n    Prometheus --> Metrics

Data Flow:

  1. Statistics Aggregation:
  2. Query all locations with geocoding metadata
  3. Calculate aggregate metrics (total, geocoded %, avg confidence)
  4. Group by provider for success rate comparison
  5. Identify low-confidence locations (< 50)
  6. Detect duplicates via coordinate matching

  7. Quality Review:

  8. Admin views dashboard statistics
  9. Filters low-confidence locations
  10. Reviews individual location details
  11. Identifies patterns (provider failures, address format issues)

  12. Remediation:

  13. Manual address correction
  14. Single location re-geocoding
  15. Bulk re-geocoding with different provider
  16. Duplicate merging or marking

  17. Monitoring:

  18. Prometheus metrics track quality trends
  19. Alert rules trigger for quality degradation
  20. Grafana dashboards visualize provider performance
"},{"location":"v2/features/map/data-quality/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/data-quality/#location-model","title":"Location Model","text":"
model Location {\n  id          Int      @id @default(autoincrement())\n  address     String\n  latitude    Float?\n  longitude   Float?\n  postalCode  String?\n  province    String?\n\n  // Geocoding metadata\n  geocodeConfidence Int?        // 0-100 quality score\n  geocodeProvider   String?     // Provider used for geocoding\n  geocodedAt        DateTime?   // Timestamp of last geocode\n\n  // NAR import fields\n  locGuid           String?  @unique\n  federalDistrict   String?\n  buildingUse       Int?     // 1 = Residential\n\n  addresses   Address[]\n\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n\n  @@index([geocodeConfidence])\n  @@index([geocodeProvider])\n  @@index([latitude, longitude])\n  @@index([latitude, longitude], where: latitude IS NOT NULL AND longitude IS NOT NULL)\n}\n

Geocode Confidence Scale: - 0-20: Very Low (manual review required) - 21-40: Low (likely incorrect, re-geocode recommended) - 41-60: Medium (acceptable but consider verification) - 61-80: Good (likely accurate) - 81-100: Excellent (high confidence)

Geocode Provider Enum:

enum GeocodeProvider {\n  GOOGLE = 'GOOGLE',\n  MAPBOX = 'MAPBOX',\n  NOMINATIM = 'NOMINATIM',\n  PHOTON = 'PHOTON',\n  LOCATIONIQ = 'LOCATIONIQ',\n  ARCGIS = 'ARCGIS',\n  UNKNOWN = 'UNKNOWN'\n}\n

"},{"location":"v2/features/map/data-quality/#address-model","title":"Address Model","text":"
model Address {\n  id         Int      @id @default(autoincrement())\n  locationId Int\n  location   Location @relation(fields: [locationId], references: [id], onDelete: Cascade)\n\n  unitNumber   String?\n  firstName    String?\n  lastName     String?\n  supportLevel Int?\n  notes        String?\n\n  // Address validation\n  isValidated  Boolean  @default(false)\n  validatedAt  DateTime?\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([locationId])\n}\n
"},{"location":"v2/features/map/data-quality/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/map/data-quality/#get-apilocationsgeocode-stats","title":"GET /api/locations/geocode-stats","text":"

Fetch aggregate geocoding quality statistics.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Response:

{\n  \"total\": 1500,\n  \"geocoded\": 1450,\n  \"geocodedPercent\": 96.67,\n  \"avgConfidence\": 78.5,\n  \"providerBreakdown\": {\n    \"GOOGLE\": 800,\n    \"MAPBOX\": 350,\n    \"NOMINATIM\": 200,\n    \"PHOTON\": 100,\n    \"ARCGIS\": 0,\n    \"LOCATIONIQ\": 0,\n    \"UNKNOWN\": 50\n  },\n  \"confidenceDistribution\": {\n    \"0-20\": 15,\n    \"21-40\": 35,\n    \"41-60\": 150,\n    \"61-80\": 450,\n    \"81-100\": 800\n  },\n  \"lowConfidenceCount\": 50,\n  \"missingCoordinates\": 50,\n  \"duplicatesCount\": 12\n}\n

Implementation:

// locations.service.ts\nasync getGeocodeStats() {\n  const locations = await prisma.location.findMany({\n    select: {\n      latitude: true,\n      longitude: true,\n      geocodeConfidence: true,\n      geocodeProvider: true\n    }\n  });\n\n  const total = locations.length;\n  const geocoded = locations.filter(l => l.latitude && l.longitude).length;\n  const avgConfidence = locations.reduce((sum, l) =>\n    sum + (l.geocodeConfidence || 0), 0) / total;\n\n  const providerBreakdown = locations.reduce((acc, l) => {\n    const provider = l.geocodeProvider || 'UNKNOWN';\n    acc[provider] = (acc[provider] || 0) + 1;\n    return acc;\n  }, {} as Record<string, number>);\n\n  const confidenceDistribution = {\n    '0-20': 0,\n    '21-40': 0,\n    '41-60': 0,\n    '61-80': 0,\n    '81-100': 0\n  };\n\n  locations.forEach(l => {\n    const conf = l.geocodeConfidence || 0;\n    if (conf <= 20) confidenceDistribution['0-20']++;\n    else if (conf <= 40) confidenceDistribution['21-40']++;\n    else if (conf <= 60) confidenceDistribution['41-60']++;\n    else if (conf <= 80) confidenceDistribution['61-80']++;\n    else confidenceDistribution['81-100']++;\n  });\n\n  const lowConfidenceCount = locations.filter(l =>\n    (l.geocodeConfidence || 0) < 50).length;\n\n  return {\n    total,\n    geocoded,\n    geocodedPercent: (geocoded / total) * 100,\n    avgConfidence,\n    providerBreakdown,\n    confidenceDistribution,\n    lowConfidenceCount,\n    missingCoordinates: total - geocoded,\n    duplicatesCount: await this.countDuplicates()\n  };\n}\n
"},{"location":"v2/features/map/data-quality/#get-apilocationsgeocodeconfidencelt50","title":"GET /api/locations?geocodeConfidence=lt:50","text":"

Fetch locations filtered by geocode confidence.

Authentication: Required

Query Parameters: - geocodeConfidence (filter): lt:X, gt:X, eq:X, null - geocodeProvider (filter): Provider name (GOOGLE, MAPBOX, etc.) - page (optional): Page number (default: 1) - limit (optional): Results per page (default: 50) - sortBy (optional): Field to sort by (default: \"geocodeConfidence\") - order (optional): \"asc\" or \"desc\" (default: \"asc\")

Examples:

GET /api/locations?geocodeConfidence=lt:50\nGET /api/locations?geocodeConfidence=null\nGET /api/locations?geocodeProvider=NOMINATIM&geocodeConfidence=lt:70\nGET /api/locations?geocodeConfidence=gt:80&sortBy=address\n

Response:

{\n  \"data\": [\n    {\n      \"id\": 1001,\n      \"address\": \"123 Main St\",\n      \"latitude\": 43.6532,\n      \"longitude\": -79.3832,\n      \"postalCode\": \"M5H 2N2\",\n      \"geocodeConfidence\": 45,\n      \"geocodeProvider\": \"NOMINATIM\",\n      \"geocodedAt\": \"2025-02-10T10:00:00Z\",\n      \"addresses\": [...]\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 50,\n    \"total\": 150,\n    \"pages\": 3\n  }\n}\n

"},{"location":"v2/features/map/data-quality/#get-apilocationsduplicates","title":"GET /api/locations/duplicates","text":"

Identify locations with identical coordinates.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Query Parameters: - threshold (optional): Distance threshold in meters (default: 1, matches exact duplicates)

Response:

{\n  \"duplicates\": [\n    {\n      \"coordinates\": {\n        \"latitude\": 43.6532,\n        \"longitude\": -79.3832\n      },\n      \"count\": 3,\n      \"locations\": [\n        {\n          \"id\": 1001,\n          \"address\": \"123 Main St\",\n          \"postalCode\": \"M5H 2N2\"\n        },\n        {\n          \"id\": 1002,\n          \"address\": \"123 Main Street\",\n          \"postalCode\": \"M5H 2N2\"\n        },\n        {\n          \"id\": 1003,\n          \"address\": \"123 Main St, Unit 1\",\n          \"postalCode\": \"M5H 2N2\"\n        }\n      ]\n    }\n  ],\n  \"total\": 12\n}\n

Implementation:

// locations.service.ts\nasync findDuplicates(thresholdMeters: number = 1) {\n  const locations = await prisma.location.findMany({\n    where: {\n      AND: [\n        { latitude: { not: null } },\n        { longitude: { not: null } }\n      ]\n    },\n    select: {\n      id: true,\n      address: true,\n      latitude: true,\n      longitude: true,\n      postalCode: true\n    }\n  });\n\n  const coordMap = new Map<string, typeof locations>();\n\n  locations.forEach(loc => {\n    // Round to 6 decimal places (~0.1m precision)\n    const key = `${loc.latitude!.toFixed(6)},${loc.longitude!.toFixed(6)}`;\n    if (!coordMap.has(key)) {\n      coordMap.set(key, []);\n    }\n    coordMap.get(key)!.push(loc);\n  });\n\n  const duplicates = Array.from(coordMap.entries())\n    .filter(([_, locs]) => locs.length > 1)\n    .map(([coords, locs]) => {\n      const [lat, lng] = coords.split(',').map(Number);\n      return {\n        coordinates: { latitude: lat, longitude: lng },\n        count: locs.length,\n        locations: locs\n      };\n    });\n\n  return {\n    duplicates,\n    total: duplicates.reduce((sum, dup) => sum + dup.count, 0)\n  };\n}\n
"},{"location":"v2/features/map/data-quality/#post-apilocationsidregeocode","title":"POST /api/locations/:id/regeocode","text":"

Re-geocode a single location with specified provider.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Request Body:

{\n  \"provider\": \"GOOGLE\",\n  \"address\": \"123 Main St, Toronto ON M5H 2N2\"\n}\n

Parameters: - provider (optional): Specific provider to use (default: fallback chain) - address (optional): Override address string (default: use existing)

Response:

{\n  \"id\": 1001,\n  \"address\": \"123 Main St\",\n  \"latitude\": 43.6532,\n  \"longitude\": -79.3832,\n  \"geocodeConfidence\": 95,\n  \"geocodeProvider\": \"GOOGLE\",\n  \"geocodedAt\": \"2025-02-13T10:30:00Z\"\n}\n

"},{"location":"v2/features/map/data-quality/#post-apilocationsbulk-geocode","title":"POST /api/locations/bulk-geocode","text":"

Bulk re-geocode multiple locations.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Request Body:

{\n  \"locationIds\": [1001, 1002, 1003],\n  \"provider\": \"GOOGLE\",\n  \"confidenceThreshold\": 50\n}\n

Parameters: - locationIds (optional): Specific location IDs (default: all with confidence < threshold) - provider (optional): Specific provider to use (default: fallback chain) - confidenceThreshold (optional): Only re-geocode locations below this confidence (default: 50)

Response:

{\n  \"jobId\": \"bulk-geocode-20250213-103000\",\n  \"status\": \"queued\",\n  \"total\": 150,\n  \"message\": \"Bulk geocoding job started\"\n}\n

Job Progress Endpoint:

GET /api/locations/bulk-geocode/:jobId\n

Job Status Response:

{\n  \"jobId\": \"bulk-geocode-20250213-103000\",\n  \"status\": \"processing\",\n  \"progress\": {\n    \"total\": 150,\n    \"processed\": 75,\n    \"successful\": 70,\n    \"failed\": 5,\n    \"percent\": 50\n  },\n  \"startedAt\": \"2025-02-13T10:30:00Z\",\n  \"estimatedCompletion\": \"2025-02-13T10:35:00Z\"\n}\n

"},{"location":"v2/features/map/data-quality/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/data-quality/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description GEOCODE_CONFIDENCE_THRESHOLD number 50 Minimum confidence for acceptable geocoding GEOCODE_PRIMARY_PROVIDER string GOOGLE Primary geocoding provider GEOCODE_FALLBACK_PROVIDERS string MAPBOX,NOMINATIM Comma-separated fallback providers GEOCODE_CACHE_TTL number 2592000 Cache TTL in seconds (30 days)"},{"location":"v2/features/map/data-quality/#quality-thresholds","title":"Quality Thresholds","text":"Metric Warning Critical Description Geocoded % < 95% < 90% Percentage of locations with coordinates Avg Confidence < 70 < 60 Average geocode confidence score Low Confidence Count > 50 > 100 Locations with confidence < 50 Duplicates > 20 > 50 Locations with identical coordinates Missing Coordinates > 5% > 10% Locations without lat/lng"},{"location":"v2/features/map/data-quality/#prometheus-metrics","title":"Prometheus Metrics","text":"

Custom Metrics:

// api/src/utils/metrics.ts\n\nexport const geocodingQualityGauge = new Gauge({\n  name: 'cm_geocoding_avg_confidence',\n  help: 'Average geocoding confidence score (0-100)',\n  async collect() {\n    const stats = await locationsService.getGeocodeStats();\n    this.set(stats.avgConfidence);\n  }\n});\n\nexport const lowConfidenceLocationsGauge = new Gauge({\n  name: 'cm_locations_low_confidence_count',\n  help: 'Number of locations with geocode confidence < 50',\n  async collect() {\n    const stats = await locationsService.getGeocodeStats();\n    this.set(stats.lowConfidenceCount);\n  }\n});\n\nexport const geocodedPercentGauge = new Gauge({\n  name: 'cm_locations_geocoded_percent',\n  help: 'Percentage of locations with coordinates',\n  async collect() {\n    const stats = await locationsService.getGeocodeStats();\n    this.set(stats.geocodedPercent);\n  }\n});\n\nexport const duplicateLocationsGauge = new Gauge({\n  name: 'cm_locations_duplicates_count',\n  help: 'Number of duplicate location entries',\n  async collect() {\n    const duplicates = await locationsService.findDuplicates();\n    this.set(duplicates.total);\n  }\n});\n

Alert Rules:

# configs/prometheus/alerts.yml\n\ngroups:\n  - name: data_quality\n    interval: 5m\n    rules:\n      - alert: LowGeocodingConfidence\n        expr: cm_geocoding_avg_confidence < 60\n        for: 10m\n        labels:\n          severity: warning\n        annotations:\n          summary: Low average geocoding confidence\n          description: \"Average geocoding confidence is {{ $value }}, below threshold of 60\"\n\n      - alert: HighLowConfidenceLocations\n        expr: cm_locations_low_confidence_count > 100\n        for: 5m\n        labels:\n          severity: critical\n        annotations:\n          summary: High number of low-confidence locations\n          description: \"{{ $value }} locations have geocoding confidence < 50\"\n\n      - alert: LowGeocodedPercent\n        expr: cm_locations_geocoded_percent < 90\n        for: 10m\n        labels:\n          severity: warning\n        annotations:\n          summary: Low percentage of geocoded locations\n          description: \"Only {{ $value }}% of locations have coordinates\"\n\n      - alert: HighDuplicateLocations\n        expr: cm_locations_duplicates_count > 50\n        for: 15m\n        labels:\n          severity: warning\n        annotations:\n          summary: High number of duplicate locations\n          description: \"{{ $value }} duplicate location entries detected\"\n
"},{"location":"v2/features/map/data-quality/#quality-metrics","title":"Quality Metrics","text":""},{"location":"v2/features/map/data-quality/#geocoding-confidence","title":"Geocoding Confidence","text":"

Calculation:

Geocoding confidence is calculated based on multiple factors:

interface GeocodeResult {\n  latitude: number;\n  longitude: number;\n  matchType: 'exact' | 'interpolated' | 'approximate' | 'fallback';\n  addressComponents: {\n    streetNumber?: string;\n    street?: string;\n    city?: string;\n    postalCode?: string;\n    province?: string;\n  };\n  providerConfidence?: number; // Provider-specific score\n}\n\nfunction calculateConfidence(result: GeocodeResult, inputAddress: string): number {\n  let confidence = 0;\n\n  // Match type (0-40 points)\n  switch (result.matchType) {\n    case 'exact': confidence += 40; break;\n    case 'interpolated': confidence += 30; break;\n    case 'approximate': confidence += 20; break;\n    case 'fallback': confidence += 10; break;\n  }\n\n  // Address component completeness (0-30 points)\n  const components = result.addressComponents;\n  if (components.streetNumber) confidence += 10;\n  if (components.street) confidence += 10;\n  if (components.postalCode) confidence += 10;\n\n  // Provider-specific confidence (0-30 points)\n  if (result.providerConfidence) {\n    confidence += (result.providerConfidence / 100) * 30;\n  }\n\n  return Math.min(Math.round(confidence), 100);\n}\n

Confidence Levels:

  • 81-100 (Excellent): Exact match with full address components
  • 61-80 (Good): Interpolated match with most components
  • 41-60 (Medium): Approximate match, missing some components
  • 21-40 (Low): Fallback geocoding, significant uncertainty
  • 0-20 (Very Low): Minimal match, likely incorrect
"},{"location":"v2/features/map/data-quality/#provider-success-rates","title":"Provider Success Rates","text":"

Metrics Tracked:

interface ProviderMetrics {\n  provider: GeocodeProvider;\n  totalAttempts: number;\n  successfulGeocodes: number;\n  successRate: number; // 0-100%\n  avgConfidence: number; // 0-100\n  avgResponseTime: number; // milliseconds\n  errorCount: number;\n  lastError?: string;\n}\n

Success Rate Calculation:

const calculateProviderMetrics = async (): Promise<ProviderMetrics[]> => {\n  const locations = await prisma.location.findMany({\n    select: {\n      geocodeProvider: true,\n      geocodeConfidence: true,\n      latitude: true,\n      longitude: true\n    }\n  });\n\n  const providerGroups = groupBy(locations, 'geocodeProvider');\n\n  return Object.entries(providerGroups).map(([provider, locs]) => {\n    const total = locs.length;\n    const successful = locs.filter(l => l.latitude && l.longitude).length;\n    const avgConf = locs.reduce((sum, l) => sum + (l.geocodeConfidence || 0), 0) / total;\n\n    return {\n      provider: provider as GeocodeProvider,\n      totalAttempts: total,\n      successfulGeocodes: successful,\n      successRate: (successful / total) * 100,\n      avgConfidence: avgConf,\n      avgResponseTime: 0, // Would need separate tracking\n      errorCount: total - successful\n    };\n  });\n};\n
"},{"location":"v2/features/map/data-quality/#duplicate-detection","title":"Duplicate Detection","text":"

Detection Methods:

  1. Exact Coordinate Match:

    // Round to 6 decimal places (~0.1m precision)\nconst isDuplicateExact = (loc1: Location, loc2: Location): boolean => {\n  return loc1.latitude!.toFixed(6) === loc2.latitude!.toFixed(6) &&\n         loc1.longitude!.toFixed(6) === loc2.longitude!.toFixed(6);\n};\n

  2. Proximity Threshold:

    // Haversine distance check\nconst isDuplicateProximity = (loc1: Location, loc2: Location, thresholdM: number): boolean => {\n  const distance = haversineDistance(\n    [loc1.latitude!, loc1.longitude!],\n    [loc2.latitude!, loc2.longitude!]\n  );\n  return distance < thresholdM;\n};\n

  3. Address Similarity:

    import { distance as levenshteinDistance } from 'fastest-levenshtein';\n\nconst isDuplicateAddress = (addr1: string, addr2: string): boolean => {\n  const normalized1 = normalizeAddress(addr1);\n  const normalized2 = normalizeAddress(addr2);\n  const dist = levenshteinDistance(normalized1, normalized2);\n  const similarity = 1 - (dist / Math.max(normalized1.length, normalized2.length));\n  return similarity > 0.9; // 90% similar\n};\n\nconst normalizeAddress = (address: string): string => {\n  return address\n    .toLowerCase()\n    .replace(/\\bstreet\\b/g, 'st')\n    .replace(/\\bavenue\\b/g, 'ave')\n    .replace(/\\broad\\b/g, 'rd')\n    .replace(/\\bdrive\\b/g, 'dr')\n    .replace(/[^a-z0-9]/g, '');\n};\n

"},{"location":"v2/features/map/data-quality/#address-validation","title":"Address Validation","text":"

Validation Checks:

interface AddressValidationResult {\n  isValid: boolean;\n  issues: string[];\n  suggestions?: string[];\n}\n\nconst validateAddress = (address: string): AddressValidationResult => {\n  const issues: string[] = [];\n\n  // Check minimum length\n  if (address.length < 5) {\n    issues.push('Address too short');\n  }\n\n  // Check for street number\n  if (!/^\\d+/.test(address)) {\n    issues.push('Missing street number');\n  }\n\n  // Check for street name\n  if (!/\\d+\\s+([A-Za-z]+\\s*)+/.test(address)) {\n    issues.push('Missing street name');\n  }\n\n  // Check for postal code (Canadian format)\n  if (!/[A-Z]\\d[A-Z]\\s?\\d[A-Z]\\d/.test(address)) {\n    issues.push('Missing or invalid postal code');\n  }\n\n  // Check for unusual characters\n  if (/[^A-Za-z0-9\\s,.-]/.test(address)) {\n    issues.push('Contains unusual characters');\n  }\n\n  return {\n    isValid: issues.length === 0,\n    issues\n  };\n};\n
"},{"location":"v2/features/map/data-quality/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/data-quality/#navigate-to-data-quality-dashboard","title":"Navigate to Data Quality Dashboard","text":"

Step 1: Access Dashboard

  1. Log in as SUPER_ADMIN or MAP_ADMIN
  2. Click Map in sidebar
  3. Click Data Quality submenu
  4. Dashboard loads with statistics

Step 2: Review Overall Statistics

Dashboard displays 4 main statistic cards:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Total Locations  \u2502 Geocoded         \u2502 Avg Confidence   \u2502 Low Confidence   \u2502\n\u2502 1,500            \u2502 1,450 (96.7%)    \u2502 78.5             \u2502 50               \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n

Step 3: Analyze Provider Performance

Provider breakdown table shows:

Provider Count Success Rate Avg Confidence GOOGLE 800 99.2% 85.3 MAPBOX 350 97.1% 82.1 NOMINATIM 200 94.5% 75.8 PHOTON 100 91.0% 68.2 UNKNOWN 50 N/A 0

Step 4: Review Confidence Distribution

Bar chart displays confidence distribution:

Confidence Distribution\n100 |              \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n 80 |              \u2502      \u2502\n 60 |        \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2524      \u2502\n 40 |  \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2524      \u2502      \u2502\n 20 |  \u2502      \u2502      \u2502      \u2502\n  0 \u2514\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n    0-20  21-40  41-60  61-80 81-100\n     15     35    150    450    800\n
"},{"location":"v2/features/map/data-quality/#identify-and-review-low-confidence-locations","title":"Identify and Review Low-Confidence Locations","text":"

Step 1: Filter Low-Confidence Locations

  1. Click Low Confidence tab on dashboard
  2. Table loads with locations where confidence < 50
  3. Sort by confidence (ascending) to prioritize worst

Step 2: Review Location Details

Click row to open detail drawer:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Location Details                        \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 Address: 123 Main St                    \u2502\n\u2502 Postal Code: M5H 2N2                    \u2502\n\u2502 Coordinates: 43.6532, -79.3832          \u2502\n\u2502                                         \u2502\n\u2502 Geocoding Info:                         \u2502\n\u2502   Confidence: 45 (Low)                  \u2502\n\u2502   Provider: NOMINATIM                   \u2502\n\u2502   Geocoded: Feb 10, 2025 10:00 AM      \u2502\n\u2502                                         \u2502\n\u2502 Issues:                                 \u2502\n\u2502   \u2022 Missing street number in response   \u2502\n\u2502   \u2022 Approximate match only              \u2502\n\u2502                                         \u2502\n\u2502 [Re-geocode] [Edit Address] [View Map] \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n

Step 3: Take Action

Options for remediation:

  1. Re-geocode with different provider:
  2. Click Re-geocode button
  3. Select provider (GOOGLE recommended for low confidence)
  4. Click Geocode Now
  5. New confidence displayed

  6. Edit address:

  7. Click Edit Address
  8. Correct typos or formatting issues
  9. Save changes
  10. Auto-triggers re-geocoding

  11. View on map:

  12. Click View Map
  13. Verify location accuracy visually
  14. Drag marker to correct position if needed
"},{"location":"v2/features/map/data-quality/#bulk-re-geocoding","title":"Bulk Re-geocoding","text":"

Step 1: Select Locations

  1. In Low Confidence tab, use table checkboxes to select locations
  2. Or click Select All to select all visible
  3. Selected count displays: \"50 selected\"

Step 2: Choose Provider

  1. Click Bulk Re-geocode button
  2. Modal opens with provider selection:
    \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Bulk Re-geocode                     \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 Re-geocode 50 locations             \u2502\n\u2502                                     \u2502\n\u2502 Provider: [GOOGLE \u25bc]                \u2502\n\u2502                                     \u2502\n\u2502 Options:                            \u2502\n\u2502 \u2611 Only if confidence < 50           \u2502\n\u2502 \u2611 Cache results                     \u2502\n\u2502 \u2610 Overwrite existing coordinates    \u2502\n\u2502                                     \u2502\n\u2502 Estimated time: ~2 minutes          \u2502\n\u2502                                     \u2502\n\u2502 [Cancel] [Start Re-geocoding]       \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n

Step 3: Monitor Progress

  1. Job starts, progress bar appears:

    Re-geocoding in progress... 25/50 (50%)\n[\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591] 50%\n

  2. Real-time updates:

  3. Total processed
  4. Successful geocodes
  5. Failed geocodes
  6. Average new confidence

Step 4: Review Results

Job completion summary:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Bulk Re-geocode Complete            \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 Processed: 50                       \u2502\n\u2502 Successful: 47 (94%)                \u2502\n\u2502 Failed: 3 (6%)                      \u2502\n\u2502                                     \u2502\n\u2502 Quality Improvement:                \u2502\n\u2502   Avg Confidence Before: 42.5       \u2502\n\u2502   Avg Confidence After: 81.3        \u2502\n\u2502   Improvement: +38.8                \u2502\n\u2502                                     \u2502\n\u2502 [View Failed] [Close]               \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
"},{"location":"v2/features/map/data-quality/#handle-duplicates","title":"Handle Duplicates","text":"

Step 1: View Duplicates Tab

  1. Click Duplicates tab on dashboard
  2. Table groups locations by coordinates

Step 2: Review Duplicate Groups

Table displays:

Coordinates Count Addresses Action 43.6532, -79.3832 3 123 Main St, 123 Main Street, 123 Main St Unit 1 [Review] 43.6540, -79.3825 2 456 Bay St, 456 Bay Street [Review]

Step 3: Resolve Duplicates

Click Review to open resolution modal:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Resolve Duplicates                  \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 3 locations at 43.6532, -79.3832    \u2502\n\u2502                                     \u2502\n\u2502 \u25cb Merge into single location        \u2502\n\u2502   Primary: 123 Main St              \u2502\n\u2502   Merge units from duplicates       \u2502\n\u2502                                     \u2502\n\u2502 \u25cb Keep as separate multi-unit       \u2502\n\u2502   Mark as validated multi-unit      \u2502\n\u2502                                     \u2502\n\u2502 \u25cb Re-geocode individually           \u2502\n\u2502   Try to get unique coordinates     \u2502\n\u2502                                     \u2502\n\u2502 [Cancel] [Resolve]                  \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n

Resolution Options:

  1. Merge: Combine into single Location with multiple Address records
  2. Multi-unit: Mark as legitimate multi-unit building
  3. Re-geocode: Attempt to get unique coordinates for each
"},{"location":"v2/features/map/data-quality/#quality-improvement-strategies","title":"Quality Improvement Strategies","text":""},{"location":"v2/features/map/data-quality/#multi-provider-geocoding","title":"Multi-Provider Geocoding","text":"

Fallback Chain:

// geocoding.service.ts\n\nconst PROVIDER_CHAIN: GeocodeProvider[] = [\n  'GOOGLE',    // Primary: Best accuracy, paid\n  'MAPBOX',    // Fallback 1: Good accuracy, paid\n  'NOMINATIM', // Fallback 2: Free, decent accuracy\n  'PHOTON',    // Fallback 3: Free, lower accuracy\n  'ARCGIS'     // Fallback 4: Free, basic accuracy\n];\n\nasync geocode(address: string): Promise<GeocodeResult | null> {\n  for (const provider of PROVIDER_CHAIN) {\n    try {\n      const result = await this.geocodeWithProvider(address, provider);\n      if (result && result.confidence >= 50) {\n        return result; // Success, confidence acceptable\n      }\n    } catch (error) {\n      logger.warn(`Geocoding failed with ${provider}:`, error);\n      // Try next provider\n    }\n  }\n  return null; // All providers failed\n}\n

Benefits: - Increases success rate (90% \u2192 96%+) - Reduces dependency on single provider - Cost optimization (use free providers as fallback) - Provider outage resilience

"},{"location":"v2/features/map/data-quality/#address-normalization","title":"Address Normalization","text":"

Pre-Geocoding Normalization:

const normalizeAddressForGeocoding = (address: string): string => {\n  let normalized = address;\n\n  // Remove extra whitespace\n  normalized = normalized.replace(/\\s+/g, ' ').trim();\n\n  // Standardize abbreviations\n  const replacements: Record<string, string> = {\n    'Street': 'St',\n    'Avenue': 'Ave',\n    'Road': 'Rd',\n    'Drive': 'Dr',\n    'Boulevard': 'Blvd',\n    'Apartment': 'Apt',\n    'Unit': 'Unit',\n    'Suite': 'Ste'\n  };\n\n  Object.entries(replacements).forEach(([long, short]) => {\n    const regex = new RegExp(`\\\\b${long}\\\\b`, 'gi');\n    normalized = normalized.replace(regex, short);\n  });\n\n  // Ensure postal code spacing (Canadian format)\n  normalized = normalized.replace(/([A-Z]\\d[A-Z])(\\d[A-Z]\\d)/, '$1 $2');\n\n  // Remove periods from abbreviations\n  normalized = normalized.replace(/\\./g, '');\n\n  return normalized;\n};\n

Improvements: - Reduces geocoding errors by 10-15% - Increases confidence scores - Better cache hit rate

"},{"location":"v2/features/map/data-quality/#geocoding-cache","title":"Geocoding Cache","text":"

Redis Cache Implementation:

// geocoding.service.ts\n\nprivate async geocodeWithCache(address: string): Promise<GeocodeResult | null> {\n  const cacheKey = `geocode:${normalizeAddress(address)}`;\n\n  // Check cache\n  const cached = await redis.get(cacheKey);\n  if (cached) {\n    logger.debug('Geocoding cache hit:', address);\n    return JSON.parse(cached);\n  }\n\n  // Cache miss, geocode\n  const result = await this.geocode(address);\n  if (result) {\n    // Cache for 30 days\n    await redis.setex(cacheKey, 2592000, JSON.stringify(result));\n  }\n\n  return result;\n}\n

Benefits: - Reduces API costs (90% cache hit rate) - Faster response times (Redis: <5ms vs API: 200-500ms) - Consistent results for same address - Provider API rate limit avoidance

"},{"location":"v2/features/map/data-quality/#manual-verification","title":"Manual Verification","text":"

Critical Location Verification:

Manually verify high-priority locations:

  1. Campaign offices: Ensure exact coordinates
  2. Shift start points: Verify accessibility
  3. Event venues: Confirm entrance location
  4. Polling stations: Critical for voter info

Verification Process:

// Mark location as manually verified\nawait prisma.location.update({\n  where: { id: locationId },\n  data: {\n    geocodeConfidence: 100,\n    geocodeProvider: 'MANUAL',\n    geocodedAt: new Date()\n  }\n});\n
"},{"location":"v2/features/map/data-quality/#regular-audits","title":"Regular Audits","text":"

Monthly Quality Audit Checklist:

  1. Run quality report:

    curl http://localhost:4000/api/locations/geocode-stats\n

  2. Check metrics against thresholds:

  3. Geocoded % > 95%
  4. Avg confidence > 70
  5. Low confidence count < 50
  6. Duplicates < 20

  7. Review low-confidence locations:

  8. Filter locations with confidence < 50
  9. Review top 20 by address
  10. Identify patterns (specific streets, providers)

  11. Bulk re-geocode low confidence:

  12. Use GOOGLE provider for accuracy
  13. Monitor improvement in avg confidence

  14. Resolve duplicates:

  15. Review all duplicate groups
  16. Merge or mark as multi-unit
  17. Update addresses as needed

  18. Export quality report:

    const report = await generateQualityReport();\nfs.writeFileSync(`quality-report-${date}.json`, JSON.stringify(report, null, 2));\n

"},{"location":"v2/features/map/data-quality/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/data-quality/#dataqualitydashboardpagetsx","title":"DataQualityDashboardPage.tsx","text":"
import React, { useEffect, useState } from 'react';\nimport { Card, Row, Col, Statistic, Table, Tabs, Button, message } from 'antd';\nimport { WarningOutlined, CheckCircleOutlined } from '@ant-design/icons';\nimport { api } from '@/lib/api';\nimport { Bar } from 'react-chartjs-2';\n\ninterface GeocodeStats {\n  total: number;\n  geocoded: number;\n  geocodedPercent: number;\n  avgConfidence: number;\n  providerBreakdown: Record<string, number>;\n  confidenceDistribution: Record<string, number>;\n  lowConfidenceCount: number;\n  missingCoordinates: number;\n  duplicatesCount: number;\n}\n\nconst DataQualityDashboardPage: React.FC = () => {\n  const [stats, setStats] = useState<GeocodeStats | null>(null);\n  const [lowConfLocations, setLowConfLocations] = useState<any[]>([]);\n  const [duplicates, setDuplicates] = useState<any[]>([]);\n  const [loading, setLoading] = useState(false);\n\n  useEffect(() => {\n    fetchStats();\n    fetchLowConfidenceLocations();\n    fetchDuplicates();\n  }, []);\n\n  const fetchStats = async () => {\n    setLoading(true);\n    try {\n      const { data } = await api.get<GeocodeStats>('/locations/geocode-stats');\n      setStats(data);\n    } catch (error) {\n      message.error('Failed to load statistics');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const fetchLowConfidenceLocations = async () => {\n    try {\n      const { data } = await api.get('/locations?geocodeConfidence=lt:50&limit=100');\n      setLowConfLocations(data.data);\n    } catch (error) {\n      message.error('Failed to load low-confidence locations');\n    }\n  };\n\n  const fetchDuplicates = async () => {\n    try {\n      const { data } = await api.get('/locations/duplicates');\n      setDuplicates(data.duplicates);\n    } catch (error) {\n      message.error('Failed to load duplicates');\n    }\n  };\n\n  const handleRegeocodeLocation = async (locationId: number) => {\n    try {\n      await api.post(`/locations/${locationId}/regeocode`, { provider: 'GOOGLE' });\n      message.success('Location re-geocoded successfully');\n      fetchStats();\n      fetchLowConfidenceLocations();\n    } catch (error) {\n      message.error('Failed to re-geocode location');\n    }\n  };\n\n  const confidenceChartData = stats ? {\n    labels: Object.keys(stats.confidenceDistribution),\n    datasets: [{\n      label: 'Locations',\n      data: Object.values(stats.confidenceDistribution),\n      backgroundColor: [\n        '#e74c3c', // 0-20: Red\n        '#f39c12', // 21-40: Orange\n        '#f1c40f', // 41-60: Yellow\n        '#3498db', // 61-80: Blue\n        '#27ae60'  // 81-100: Green\n      ]\n    }]\n  } : null;\n\n  const lowConfColumns = [\n    { title: 'Address', dataIndex: 'address', key: 'address' },\n    { title: 'Confidence', dataIndex: 'geocodeConfidence', key: 'confidence', render: (val: number) => (\n      <span style={{ color: val < 30 ? '#e74c3c' : '#f39c12' }}>{val}</span>\n    )},\n    { title: 'Provider', dataIndex: 'geocodeProvider', key: 'provider' },\n    { title: 'Action', key: 'action', render: (_: any, record: any) => (\n      <Button size=\"small\" onClick={() => handleRegeocodeLocation(record.id)}>\n        Re-geocode\n      </Button>\n    )}\n  ];\n\n  return (\n    <div>\n      <h1>Data Quality Dashboard</h1>\n\n      {/* Statistics Cards */}\n      <Row gutter={16} style={{ marginBottom: 24 }}>\n        <Col span={6}>\n          <Card>\n            <Statistic\n              title=\"Total Locations\"\n              value={stats?.total || 0}\n              prefix={<CheckCircleOutlined />}\n            />\n          </Card>\n        </Col>\n        <Col span={6}>\n          <Card>\n            <Statistic\n              title=\"Geocoded\"\n              value={stats?.geocoded || 0}\n              suffix={`(${stats?.geocodedPercent.toFixed(1) || 0}%)`}\n              valueStyle={{ color: (stats?.geocodedPercent || 0) > 95 ? '#27ae60' : '#f39c12' }}\n            />\n          </Card>\n        </Col>\n        <Col span={6}>\n          <Card>\n            <Statistic\n              title=\"Avg Confidence\"\n              value={stats?.avgConfidence.toFixed(1) || 0}\n              valueStyle={{ color: (stats?.avgConfidence || 0) > 70 ? '#27ae60' : '#f39c12' }}\n            />\n          </Card>\n        </Col>\n        <Col span={6}>\n          <Card>\n            <Statistic\n              title=\"Low Confidence\"\n              value={stats?.lowConfidenceCount || 0}\n              prefix={<WarningOutlined />}\n              valueStyle={{ color: (stats?.lowConfidenceCount || 0) > 50 ? '#e74c3c' : '#f39c12' }}\n            />\n          </Card>\n        </Col>\n      </Row>\n\n      {/* Charts and Tables */}\n      <Tabs\n        items={[\n          {\n            key: 'overview',\n            label: 'Overview',\n            children: (\n              <div>\n                <Card title=\"Confidence Distribution\" style={{ marginBottom: 24 }}>\n                  {confidenceChartData && <Bar data={confidenceChartData} />}\n                </Card>\n                <Card title=\"Provider Performance\">\n                  <Table\n                    dataSource={stats ? Object.entries(stats.providerBreakdown).map(([provider, count]) => ({\n                      provider,\n                      count\n                    })) : []}\n                    columns={[\n                      { title: 'Provider', dataIndex: 'provider', key: 'provider' },\n                      { title: 'Count', dataIndex: 'count', key: 'count' }\n                    ]}\n                    pagination={false}\n                  />\n                </Card>\n              </div>\n            )\n          },\n          {\n            key: 'low-confidence',\n            label: `Low Confidence (${lowConfLocations.length})`,\n            children: (\n              <Table\n                dataSource={lowConfLocations}\n                columns={lowConfColumns}\n                rowKey=\"id\"\n                loading={loading}\n              />\n            )\n          },\n          {\n            key: 'duplicates',\n            label: `Duplicates (${duplicates.length})`,\n            children: (\n              <Table\n                dataSource={duplicates}\n                columns={[\n                  { title: 'Coordinates', key: 'coords', render: (_, record: any) =>\n                    `${record.coordinates.latitude.toFixed(6)}, ${record.coordinates.longitude.toFixed(6)}`\n                  },\n                  { title: 'Count', dataIndex: 'count', key: 'count' },\n                  { title: 'Addresses', key: 'addresses', render: (_, record: any) =>\n                    record.locations.map((l: any) => l.address).join(', ')\n                  }\n                ]}\n                rowKey={(record) => `${record.coordinates.latitude}-${record.coordinates.longitude}`}\n              />\n            )\n          }\n        ]}\n      />\n    </div>\n  );\n};\n\nexport default DataQualityDashboardPage;\n
"},{"location":"v2/features/map/data-quality/#geocode-statistics-service","title":"Geocode Statistics Service","text":"
// locations.service.ts\n\nimport { prisma } from '@/config/database';\nimport type { GeocodeProvider } from '@prisma/client';\n\nexport class LocationsService {\n  async getGeocodeStats() {\n    const locations = await prisma.location.findMany({\n      select: {\n        id: true,\n        latitude: true,\n        longitude: true,\n        geocodeConfidence: true,\n        geocodeProvider: true\n      }\n    });\n\n    const total = locations.length;\n    const geocoded = locations.filter(l => l.latitude && l.longitude).length;\n\n    const sumConfidence = locations.reduce((sum, l) => sum + (l.geocodeConfidence || 0), 0);\n    const avgConfidence = total > 0 ? sumConfidence / total : 0;\n\n    // Provider breakdown\n    const providerBreakdown: Record<string, number> = {};\n    locations.forEach(l => {\n      const provider = l.geocodeProvider || 'UNKNOWN';\n      providerBreakdown[provider] = (providerBreakdown[provider] || 0) + 1;\n    });\n\n    // Confidence distribution\n    const confidenceDistribution = {\n      '0-20': 0,\n      '21-40': 0,\n      '41-60': 0,\n      '61-80': 0,\n      '81-100': 0\n    };\n\n    locations.forEach(l => {\n      const conf = l.geocodeConfidence || 0;\n      if (conf <= 20) confidenceDistribution['0-20']++;\n      else if (conf <= 40) confidenceDistribution['21-40']++;\n      else if (conf <= 60) confidenceDistribution['41-60']++;\n      else if (conf <= 80) confidenceDistribution['61-80']++;\n      else confidenceDistribution['81-100']++;\n    });\n\n    const lowConfidenceCount = locations.filter(l => (l.geocodeConfidence || 0) < 50).length;\n    const duplicatesCount = await this.countDuplicates();\n\n    return {\n      total,\n      geocoded,\n      geocodedPercent: total > 0 ? (geocoded / total) * 100 : 0,\n      avgConfidence,\n      providerBreakdown,\n      confidenceDistribution,\n      lowConfidenceCount,\n      missingCoordinates: total - geocoded,\n      duplicatesCount\n    };\n  }\n\n  async countDuplicates(): Promise<number> {\n    const locations = await prisma.location.findMany({\n      where: {\n        AND: [\n          { latitude: { not: null } },\n          { longitude: { not: null } }\n        ]\n      },\n      select: { latitude: true, longitude: true }\n    });\n\n    const coordMap = new Map<string, number>();\n    locations.forEach(l => {\n      const key = `${l.latitude!.toFixed(6)},${l.longitude!.toFixed(6)}`;\n      coordMap.set(key, (coordMap.get(key) || 0) + 1);\n    });\n\n    return Array.from(coordMap.values()).filter(count => count > 1).reduce((sum, count) => sum + count, 0);\n  }\n\n  async regeocode(locationId: number, provider?: GeocodeProvider) {\n    const location = await prisma.location.findUnique({\n      where: { id: locationId }\n    });\n\n    if (!location) {\n      throw new Error('Location not found');\n    }\n\n    const result = await geocodingService.geocode(location.address, provider);\n\n    if (!result) {\n      throw new Error('Geocoding failed');\n    }\n\n    return await prisma.location.update({\n      where: { id: locationId },\n      data: {\n        latitude: result.latitude,\n        longitude: result.longitude,\n        geocodeConfidence: result.confidence,\n        geocodeProvider: result.provider,\n        geocodedAt: new Date()\n      }\n    });\n  }\n}\n
"},{"location":"v2/features/map/data-quality/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/data-quality/#problem-many-low-confidence-locations","title":"Problem: Many low-confidence locations","text":"

Symptoms: - > 100 locations with confidence < 50 - Avg confidence < 60 - Prometheus alert firing

Solutions:

  1. Check provider API keys:

    # Test Google Geocoding API\ncurl \"https://maps.googleapis.com/maps/api/geocode/json?address=123+Main+St+Toronto&key=YOUR_KEY\"\n\n# Verify key in .env\necho $GEOCODE_GOOGLE_API_KEY\n

  2. Try different primary provider:

    # In .env, change primary provider\nGEOCODE_PRIMARY_PROVIDER=GOOGLE  # Most accurate\n# Or try:\nGEOCODE_PRIMARY_PROVIDER=MAPBOX  # Good alternative\n

  3. Verify address format:

    // Bad: Missing city/postal\n\"123 Main St\"\n\n// Good: Full address\n\"123 Main St, Toronto ON M5H 2N2\"\n

  4. Use postal code for better accuracy:

    // Append postal code if available\nconst fullAddress = location.postalCode\n  ? `${location.address}, ${location.postalCode}`\n  : location.address;\n

  5. Bulk re-geocode with Google:

    # Via API\ncurl -X POST http://localhost:4000/api/locations/bulk-geocode \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -d '{\"provider\":\"GOOGLE\",\"confidenceThreshold\":50}'\n

"},{"location":"v2/features/map/data-quality/#problem-duplicate-locations-detected","title":"Problem: Duplicate locations detected","text":"

Symptoms: - Multiple locations at same coordinates - Duplicates tab shows many groups - Inflated location counts in cuts

Solutions:

  1. Check if legitimately multi-unit:

    -- Find buildings with multiple addresses\nSELECT l.id, l.address, COUNT(a.id) as unit_count\nFROM \"Location\" l\nJOIN \"Address\" a ON a.\"locationId\" = l.id\nGROUP BY l.id\nHAVING COUNT(a.id) > 1;\n

  2. Verify geocoding precision:

    // Check if rounding issue\nconst isDuplicateRounding = (loc1, loc2) => {\n  // Use 4 decimal places (~11m precision) instead of 6 (~0.1m)\n  return loc1.latitude.toFixed(4) === loc2.latitude.toFixed(4) &&\n         loc1.longitude.toFixed(4) === loc2.longitude.toFixed(4);\n};\n

  3. Review NAR import process:

    // Ensure LOC_GUID unique constraint\nconst location = await prisma.location.upsert({\n  where: { locGuid: narRecord.LOC_GUID },\n  update: { /* update fields */ },\n  create: { /* create fields */ }\n});\n

  4. Merge duplicates:

    // Merge function\nconst mergeDuplicates = async (primaryId: number, duplicateIds: number[]) => {\n  // Move addresses to primary location\n  await prisma.address.updateMany({\n    where: { locationId: { in: duplicateIds } },\n    data: { locationId: primaryId }\n  });\n\n  // Delete duplicates\n  await prisma.location.deleteMany({\n    where: { id: { in: duplicateIds } }\n  });\n};\n

"},{"location":"v2/features/map/data-quality/#problem-geocoding-stats-slow-to-load","title":"Problem: Geocoding stats slow to load","text":"

Symptoms: - GET /api/locations/geocode-stats takes > 5 seconds - Dashboard timeout errors - High database CPU

Solutions:

  1. Add database indexes:

    CREATE INDEX CONCURRENTLY idx_locations_geocode_confidence\n  ON \"Location\"(geocodeConfidence);\n\nCREATE INDEX CONCURRENTLY idx_locations_geocode_provider\n  ON \"Location\"(geocodeProvider);\n\nCREATE INDEX CONCURRENTLY idx_locations_coords\n  ON \"Location\"(latitude, longitude)\n  WHERE latitude IS NOT NULL AND longitude IS NOT NULL;\n

  2. Cache stats in Redis:

    // Cache for 5 minutes\nconst getCachedStats = async () => {\n  const cached = await redis.get('geocode:stats');\n  if (cached) return JSON.parse(cached);\n\n  const stats = await locationsService.getGeocodeStats();\n  await redis.setex('geocode:stats', 300, JSON.stringify(stats));\n  return stats;\n};\n

  3. Use aggregation pipeline:

    // Raw SQL for better performance\nconst stats = await prisma.$queryRaw`\n  SELECT\n    COUNT(*) as total,\n    COUNT(latitude) as geocoded,\n    AVG(COALESCE(\"geocodeConfidence\", 0)) as avg_confidence,\n    \"geocodeProvider\",\n    COUNT(*) FILTER (WHERE \"geocodeConfidence\" < 50) as low_confidence\n  FROM \"Location\"\n  GROUP BY \"geocodeProvider\"\n`;\n

  4. Materialize stats view:

    -- Create materialized view\nCREATE MATERIALIZED VIEW geocode_stats_mv AS\nSELECT\n  COUNT(*) as total,\n  COUNT(latitude) FILTER (WHERE latitude IS NOT NULL) as geocoded,\n  AVG(COALESCE(\"geocodeConfidence\", 0)) as avg_confidence,\n  COUNT(*) FILTER (WHERE \"geocodeConfidence\" < 50) as low_confidence\nFROM \"Location\";\n\n-- Refresh hourly\nREFRESH MATERIALIZED VIEW geocode_stats_mv;\n

"},{"location":"v2/features/map/data-quality/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/data-quality/#database-query-optimization","title":"Database Query Optimization","text":"

Indexes: - geocodeConfidence (filtering) - geocodeProvider (grouping) - (latitude, longitude) composite (duplicate detection) - Partial index on non-null coordinates

Query Performance: - geocode-stats: ~500ms (1500 locations) - Low confidence filter: ~100ms (with index) - Duplicate detection: ~200ms (coordinate grouping) - Bulk re-geocode: ~2-5 min (150 locations, depends on provider)

"},{"location":"v2/features/map/data-quality/#api-rate-limits","title":"API Rate Limits","text":"

Provider Limits: - Google: 50 QPS, $5/1000 requests - Mapbox: 100,000/month free, then $0.50/1000 - Nominatim: 1 QPS (public), no commercial use - Photon: No official limit, self-hosted recommended - ArcGIS: 100,000/month free

Optimization: - Use Redis cache (30-day TTL) - Batch geocoding jobs (avoid rate limits) - Fallback to free providers for non-critical - Monitor usage via provider dashboards

"},{"location":"v2/features/map/data-quality/#caching-strategy","title":"Caching Strategy","text":"

Cache Layers:

  1. Application Cache (Redis):

    // 30-day TTL for geocode results\nconst cacheKey = `geocode:${normalizeAddress(address)}`;\nawait redis.setex(cacheKey, 2592000, JSON.stringify(result));\n

  2. Statistics Cache:

    // 5-minute TTL for stats\nawait redis.setex('geocode:stats', 300, JSON.stringify(stats));\n

  3. Provider Response Cache:

    // Cache raw provider responses separately\nawait redis.setex(`provider:${provider}:${address}`, 604800, JSON.stringify(rawResponse));\n

Cache Hit Rates: - Geocoding: 90%+ (repeated addresses) - Statistics: 95%+ (frequent dashboard views) - Provider responses: 85%+ (re-geocoding attempts)

"},{"location":"v2/features/map/data-quality/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/map/data-quality/#backend-documentation","title":"Backend Documentation","text":"
  • Locations Service: api/src/modules/map/locations/locations.service.ts
  • Geocode stats aggregation
  • Duplicate detection
  • Re-geocoding operations

  • Geocoding Service: api/src/modules/map/geocoding/geocoding.service.ts

  • Multi-provider fallback
  • Confidence calculation
  • Cache integration

  • Bulk Geocoding: api/src/modules/map/locations/bulk-geocode.routes.ts

  • Job queue integration
  • Progress tracking
  • Error handling
"},{"location":"v2/features/map/data-quality/#frontend-documentation","title":"Frontend Documentation","text":"
  • Data Quality Dashboard: admin/src/pages/DataQualityDashboardPage.tsx
  • Statistics display
  • Charts and tables
  • Bulk actions

  • Locations Page: admin/src/pages/LocationsPage.tsx

  • CSV import/export
  • Inline geocoding
  • Address editing
"},{"location":"v2/features/map/data-quality/#database-documentation","title":"Database Documentation","text":"
  • Location Model: api/prisma/schema.prisma
  • Geocoding metadata fields
  • Indexes for performance
  • Relations to Address
"},{"location":"v2/features/map/data-quality/#monitoring-documentation","title":"Monitoring Documentation","text":"
  • Prometheus Metrics: api/src/utils/metrics.ts
  • Custom geocoding metrics
  • Quality gauges
  • Alert integration

  • Grafana Dashboard: configs/grafana/dashboards/data-quality.json

  • Quality trend charts
  • Provider comparison
  • Alert visualization
"},{"location":"v2/features/map/data-quality/#external-resources","title":"External Resources","text":"
  • Google Geocoding API: https://developers.google.com/maps/documentation/geocoding
  • Mapbox Geocoding API: https://docs.mapbox.com/api/search/geocoding
  • Nominatim API: https://nominatim.org/release-docs/latest/api/Search
  • Photon API: https://photon.komoot.io
"},{"location":"v2/features/map/geocoding/","title":"Multi-Provider Geocoding Service","text":""},{"location":"v2/features/map/geocoding/#overview","title":"Overview","text":"

The geocoding service provides automated address-to-coordinate conversion using a six-provider fallback chain. It enables campaigns to quickly convert voter addresses to map coordinates, with confidence scoring, Redis caching, and BullMQ queue integration for bulk operations.

Key Capabilities:

  • 6 Geocoding Providers: Google, Mapbox, Nominatim, Photon, LocationIQ, ArcGIS
  • Provider Fallback Chain: Try providers in order until success
  • Confidence Scoring: 0-100 score based on match quality
  • Redis Caching: 7-day TTL to avoid redundant API calls
  • Bulk Queue Processing: BullMQ integration for large geocoding jobs
  • Address Normalization: Expand abbreviations, normalize postal codes
  • Reverse Geocoding: Convert coordinates to human-readable address
  • Provider Health Tracking: Prometheus metrics for success rates

Use Cases:

  • Bulk geocoding of voter files
  • Real-time address validation during data entry
  • Map marker placement for locations
  • Address autocomplete (future)
  • Spatial filtering by coordinates
  • Walk sheet generation with accurate maps
"},{"location":"v2/features/map/geocoding/#architecture","title":"Architecture","text":"
graph TD\n    A[Location Service] -->|Geocode Request| B[Geocoding Service]\n    B -->|Check Cache| C[(Redis Cache)]\n    C -->|Cache Hit| A\n    C -->|Cache Miss| D[Provider Chain]\n\n    D -->|Try Provider 1| E[Google Geocoding API]\n    E -->|Success| F[Confidence Scorer]\n    E -->|Fail| G[Try Provider 2]\n    G -->|Mapbox| H[Mapbox Geocoding API]\n    H -->|Success| F\n    H -->|Fail| I[Try Provider 3]\n    I -->|Nominatim| J[Nominatim API]\n    J -->|Success| F\n    J -->|Fail| K[Try Provider 4]\n    K -->|Photon| L[Photon API]\n    L -->|Success| F\n    L -->|Fail| M[Try Provider 5]\n    M -->|LocationIQ| N[LocationIQ API]\n    N -->|Success| F\n    N -->|Fail| O[Try Provider 6]\n    O -->|ArcGIS| P[ArcGIS API]\n    P -->|Success| F\n    P -->|Fail| Q[Geocoding Failed]\n\n    F -->|Store Result| C\n    F -->|Return| A\n\n    R[Bulk Geocode Job] -->|Queue| S[(BullMQ)]\n    S -->|Process Batch| B\n    B -->|Rate Limit| T[Rate Limiter]\n    T -->|Allow| D\n\n    style C fill:#fff4e1\n    style S fill:#fff4e1\n    style E fill:#e8f5e9\n    style H fill:#e8f5e9\n    style J fill:#e8f5e9\n    style L fill:#e8f5e9\n    style N fill:#e8f5e9\n    style P fill:#e8f5e9

Flow Description:

  1. Location service requests geocode \u2192 Geocoding service checks Redis cache
  2. Cache miss \u2192 Try providers in configured order (Google \u2192 Mapbox \u2192 Nominatim \u2192 Photon \u2192 LocationIQ \u2192 ArcGIS)
  3. Provider success \u2192 Calculate confidence score (0-100) based on match type
  4. Cache result \u2192 Store in Redis with 7-day TTL
  5. Bulk geocoding \u2192 BullMQ worker processes batches with rate limiting
  6. Metrics tracking \u2192 Prometheus gauges for provider health and cache hit rate
"},{"location":"v2/features/map/geocoding/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/geocoding/#geocodeprovider-enum","title":"GeocodeProvider Enum","text":"

See Location Model Documentation for full schema.

Provider Enum Values:

enum GeocodeProvider {\n  GOOGLE\n  MAPBOX\n  NOMINATIM\n  PHOTON\n  LOCATIONIQ\n  ARCGIS\n  UNKNOWN\n}\n

Location Model Geocoding Fields:

  • latitude / longitude: Decimal coordinates from geocoding
  • geocodeConfidence: Integer 0-100 (>90=high, 70-90=medium, <70=low)
  • geocodeProvider: Which provider successfully geocoded
  • geocodeAttempts: Number of failed attempts (for retry logic)
  • lastGeocodeAttempt: Timestamp of last geocoding attempt

Related Models:

  • Location \u2014 Stores geocoded coordinates
  • LocationHistory \u2014 Audit trail for geocoding changes
"},{"location":"v2/features/map/geocoding/#api-endpoints","title":"API Endpoints","text":"

See Geocoding Backend Module Documentation for full API reference.

Geocoding Endpoints:

Method Endpoint Auth Description POST /api/map/locations/geocode MAP_ADMIN Geocode single address POST /api/map/locations/reverse-geocode MAP_ADMIN Reverse geocode lat/lng to address POST /api/map/locations/bulk-geocode/start MAP_ADMIN Start bulk geocoding job (BullMQ) GET /api/map/locations/bulk-geocode/status MAP_ADMIN Check bulk geocoding job status POST /api/map/locations/bulk-geocode/cancel MAP_ADMIN Cancel running bulk geocoding job

Request/Response Examples:

Single Geocode Request:

POST /api/map/locations/geocode\n{\n  \"address\": \"123 Main Street, Ottawa, ON K1A 0B1\"\n}\n\n// Response\n{\n  \"latitude\": 45.4215,\n  \"longitude\": -75.6972,\n  \"confidence\": 95,\n  \"provider\": \"GOOGLE\",\n  \"formattedAddress\": \"123 Main St, Ottawa, ON K1A 0B1, Canada\"\n}\n

Bulk Geocode Job:

POST /api/map/locations/bulk-geocode/start\n{\n  \"confidenceThreshold\": 70,\n  \"provider\": \"GOOGLE\",\n  \"batchSize\": 50\n}\n\n// Response\n{\n  \"jobId\": \"bulk-geocode-uuid\",\n  \"status\": \"queued\",\n  \"totalLocations\": 1234\n}\n
"},{"location":"v2/features/map/geocoding/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/geocoding/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description GEOCODING_ENABLED boolean true Enable geocoding services GEOCODING_CACHE_ENABLED boolean true Cache results in Redis GEOCODING_CACHE_TTL_HOURS number 168 Cache TTL (7 days) GEOCODING_PROVIDERS string GOOGLE,MAPBOX,NOMINATIM,PHOTON,LOCATIONIQ,ARCGIS Provider order (comma-separated) GOOGLE_MAPS_API_KEY string - Google Geocoding API key (required if Google enabled) MAPBOX_ACCESS_TOKEN string - Mapbox API token (required if Mapbox enabled) LOCATIONIQ_API_KEY string - LocationIQ API key (required if LocationIQ enabled) NOMINATIM_BASE_URL string https://nominatim.openstreetmap.org Nominatim API URL PHOTON_BASE_URL string https://photon.komoot.io Photon API URL ARCGIS_BASE_URL string https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer ArcGIS API URL"},{"location":"v2/features/map/geocoding/#provider-configuration","title":"Provider Configuration","text":"

Provider Selection Strategy:

  1. Free tier exhausted? Remove provider from chain
  2. Rate limit hit? Skip provider temporarily (5min cooldown)
  3. Service down? Skip provider (exponential backoff)
  4. Low confidence? Try next provider

Provider Priority (Default):

  1. Google \u2014 Best accuracy, paid API (free $200/month credit)
  2. Mapbox \u2014 Good accuracy, generous free tier (100k/month)
  3. Nominatim \u2014 Free, moderate accuracy, 1 req/sec limit
  4. Photon \u2014 Free, fast, good for European addresses
  5. LocationIQ \u2014 Free tier (5k/day), good international coverage
  6. ArcGIS \u2014 Free tier (20k/month), good US coverage
"},{"location":"v2/features/map/geocoding/#confidence-scoring-rules","title":"Confidence Scoring Rules","text":"

Confidence Score Calculation:

Match Type Google Mapbox Nominatim Photon LocationIQ ArcGIS Rooftop (exact address) 95-100 95-100 90-95 90-95 90-95 95-100 Interpolated 85-94 85-94 80-89 80-89 80-89 85-94 Street-level 70-84 70-84 65-79 65-79 65-79 70-84 Postal code 50-69 50-69 45-64 45-64 45-64 50-69 City 30-49 30-49 25-44 25-44 25-44 30-49 Province/State 10-29 10-29 5-24 5-24 5-24 10-29 Country 0-9 0-9 0-4 0-4 0-4 0-9

Confidence Thresholds:

  • High (90-100): Exact address match, suitable for door-knocking
  • Medium (70-89): Street-level or interpolated, suitable for mapping
  • Low (50-69): Postal code or city-level, needs manual verification
  • None (<50): Unreliable, should re-geocode or manually enter coordinates
"},{"location":"v2/features/map/geocoding/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/geocoding/#single-address-geocoding","title":"Single Address Geocoding","text":"

Step 1: Enter Address

On LocationsPage create/edit form, enter address:

Address: 123 Main Street\nPostal Code: K1A 0B1\n

Step 2: Click Geocode Button

Click Geocode button below address field.

Step 3: View Results

System displays:

  • Latitude/Longitude: Auto-populated
  • Confidence Score: 95% (High)
  • Provider: Google
  • Formatted Address: 123 Main St, Ottawa, ON K1A 0B1, Canada

Step 4: Save Location

Click Save to create/update location with geocoded coordinates.

"},{"location":"v2/features/map/geocoding/#bulk-re-geocoding","title":"Bulk Re-Geocoding","text":"

Use Case: Re-geocode locations with missing or low-confidence coordinates.

Step 1: Open Bulk Geocode Modal

On LocationsPage, click Bulk Re-Geocode button.

Step 2: Configure Job

Set parameters:

  • Confidence Threshold: Only geocode locations below this score (e.g., 70)
  • Missing Only: Only geocode locations without coordinates
  • Provider: Choose preferred provider (or use default chain)
  • Batch Size: Locations per batch (default: 50)

Step 3: Start Job

Click Start Job to queue job in BullMQ.

Step 4: Monitor Progress

View real-time progress:

  • Completed: 234 / 1000 locations
  • Failed: 12 locations
  • Progress: 23.4%
  • ETA: 8 minutes

Step 5: Review Results

After job completes:

  • Success Rate: 98.8%
  • Average Confidence: 87.3
  • Failed Addresses: Download CSV of failures

Step 6: Retry Failures (Optional)

For failed addresses:

  1. Download failure CSV
  2. Manually verify addresses
  3. Fix typos/formatting issues
  4. Re-import CSV
  5. Run bulk geocode again
"},{"location":"v2/features/map/geocoding/#reverse-geocoding","title":"Reverse Geocoding","text":"

Use Case: Convert map click coordinates to address.

Step 1: Click Map

On AdminMapView, click location to get lat/lng.

Step 2: Reverse Geocode

Click Reverse Geocode button in popup.

Step 3: View Address

System displays:

Address: 123 Main St\nCity: Ottawa\nProvince: ON\nCountry: Canada\n

Step 4: Create Location

Click Create Location to auto-fill address form.

"},{"location":"v2/features/map/geocoding/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/geocoding/#geocoding-service-backend","title":"Geocoding Service (Backend)","text":"
// api/src/modules/map/geocoding/geocoding.service.ts\nexport interface GeocodeResult {\n  latitude: number;\n  longitude: number;\n  confidence: number;\n  provider: GeocodeProvider;\n  formattedAddress?: string;\n}\n\nasync function geocode(address: string): Promise<GeocodeResult> {\n  // Check Redis cache first\n  const cached = await getCachedResult(address);\n  if (cached) {\n    logger.debug('Geocode cache hit', { address });\n    return cached;\n  }\n\n  // Normalize address (expand abbreviations, fix postal code)\n  const normalized = normalizeAddress(address);\n\n  // Try providers in order\n  const providers = env.GEOCODING_PROVIDERS.split(',');\n  let lastError: Error | null = null;\n\n  for (const providerName of providers) {\n    try {\n      const result = await tryProvider(providerName, normalized);\n\n      if (result.confidence >= 50) {\n        // Cache successful result\n        await setCachedResult(address, result);\n        logger.info('Geocoded address', {\n          address,\n          provider: result.provider,\n          confidence: result.confidence,\n        });\n        return result;\n      }\n    } catch (err) {\n      lastError = err as Error;\n      logger.warn(`Provider ${providerName} failed`, { address, error: err });\n      continue;\n    }\n  }\n\n  throw new AppError(\n    500,\n    'All geocoding providers failed',\n    'GEOCODING_FAILED',\n    { address, lastError: lastError?.message }\n  );\n}\n
"},{"location":"v2/features/map/geocoding/#provider-chain-implementation","title":"Provider Chain Implementation","text":"
// api/src/modules/map/geocoding/geocoding.service.ts\nasync function tryProvider(\n  providerName: string,\n  address: string\n): Promise<GeocodeResult> {\n  switch (providerName.toUpperCase()) {\n    case 'GOOGLE':\n      return await geocodeWithGoogle(address);\n    case 'MAPBOX':\n      return await geocodeWithMapbox(address);\n    case 'NOMINATIM':\n      return await geocodeWithNominatim(address);\n    case 'PHOTON':\n      return await geocodeWithPhoton(address);\n    case 'LOCATIONIQ':\n      return await geocodeWithLocationIQ(address);\n    case 'ARCGIS':\n      return await geocodeWithArcGIS(address);\n    default:\n      throw new Error(`Unknown provider: ${providerName}`);\n  }\n}\n
"},{"location":"v2/features/map/geocoding/#google-geocoding-provider","title":"Google Geocoding Provider","text":"
// api/src/modules/map/geocoding/geocoding.service.ts\nasync function geocodeWithGoogle(address: string): Promise<GeocodeResult> {\n  if (!env.GOOGLE_MAPS_API_KEY) {\n    throw new Error('Google Maps API key not configured');\n  }\n\n  const url = new URL('https://maps.googleapis.com/maps/api/geocode/json');\n  url.searchParams.set('address', address);\n  url.searchParams.set('key', env.GOOGLE_MAPS_API_KEY);\n\n  const response = await fetch(url.toString());\n  const data = await response.json();\n\n  if (data.status !== 'OK' || !data.results?.[0]) {\n    throw new Error(`Google geocoding failed: ${data.status}`);\n  }\n\n  const result = data.results[0];\n  const location = result.geometry.location;\n\n  // Calculate confidence based on location_type\n  let confidence = 50;\n  if (result.geometry.location_type === 'ROOFTOP') {\n    confidence = 95;\n  } else if (result.geometry.location_type === 'RANGE_INTERPOLATED') {\n    confidence = 85;\n  } else if (result.geometry.location_type === 'GEOMETRIC_CENTER') {\n    confidence = 70;\n  }\n\n  return {\n    latitude: location.lat,\n    longitude: location.lng,\n    confidence,\n    provider: GeocodeProvider.GOOGLE,\n    formattedAddress: result.formatted_address,\n  };\n}\n
"},{"location":"v2/features/map/geocoding/#mapbox-geocoding-provider","title":"Mapbox Geocoding Provider","text":"
// api/src/modules/map/geocoding/geocoding.service.ts\nasync function geocodeWithMapbox(address: string): Promise<GeocodeResult> {\n  if (!env.MAPBOX_ACCESS_TOKEN) {\n    throw new Error('Mapbox access token not configured');\n  }\n\n  const encodedAddress = encodeURIComponent(address);\n  const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedAddress}.json?access_token=${env.MAPBOX_ACCESS_TOKEN}`;\n\n  const response = await fetch(url);\n  const data = await response.json();\n\n  if (!data.features?.[0]) {\n    throw new Error('Mapbox geocoding failed: no results');\n  }\n\n  const feature = data.features[0];\n  const [lng, lat] = feature.center;\n\n  // Calculate confidence based on place_type\n  let confidence = 50;\n  if (feature.place_type.includes('address')) {\n    confidence = 95;\n  } else if (feature.place_type.includes('place')) {\n    confidence = 60;\n  } else if (feature.place_type.includes('postcode')) {\n    confidence = 55;\n  }\n\n  // Boost confidence for exact match\n  if (feature.relevance >= 0.9) {\n    confidence = Math.min(100, confidence + 10);\n  }\n\n  return {\n    latitude: lat,\n    longitude: lng,\n    confidence,\n    provider: GeocodeProvider.MAPBOX,\n    formattedAddress: feature.place_name,\n  };\n}\n
"},{"location":"v2/features/map/geocoding/#nominatim-geocoding-provider","title":"Nominatim Geocoding Provider","text":"
// api/src/modules/map/geocoding/geocoding.service.ts\nasync function geocodeWithNominatim(address: string): Promise<GeocodeResult> {\n  const baseUrl = env.NOMINATIM_BASE_URL || 'https://nominatim.openstreetmap.org';\n  const url = new URL(`${baseUrl}/search`);\n  url.searchParams.set('q', address);\n  url.searchParams.set('format', 'json');\n  url.searchParams.set('limit', '1');\n\n  const response = await fetch(url.toString(), {\n    headers: { 'User-Agent': 'Changemaker Lite/2.0' }, // Required by Nominatim\n  });\n\n  const data = await response.json();\n\n  if (!data?.[0]) {\n    throw new Error('Nominatim geocoding failed: no results');\n  }\n\n  const result = data[0];\n  const lat = parseFloat(result.lat);\n  const lng = parseFloat(result.lon);\n\n  // Calculate confidence based on osm_type and importance\n  let confidence = 50;\n  if (result.osm_type === 'node' && result.importance > 0.5) {\n    confidence = 90;\n  } else if (result.osm_type === 'way' && result.importance > 0.4) {\n    confidence = 80;\n  } else if (result.importance > 0.3) {\n    confidence = 70;\n  }\n\n  return {\n    latitude: lat,\n    longitude: lng,\n    confidence,\n    provider: GeocodeProvider.NOMINATIM,\n    formattedAddress: result.display_name,\n  };\n}\n
"},{"location":"v2/features/map/geocoding/#address-normalization","title":"Address Normalization","text":"
// api/src/modules/map/geocoding/geocoding.service.ts\nconst abbreviations: Record<string, string> = {\n  // Street types\n  'st': 'street',\n  'ave': 'avenue',\n  'blvd': 'boulevard',\n  'dr': 'drive',\n  'rd': 'road',\n  'ln': 'lane',\n  'ct': 'court',\n  // Directional suffixes\n  'n': 'north',\n  'ne': 'northeast',\n  'e': 'east',\n  'se': 'southeast',\n  's': 'south',\n  'sw': 'southwest',\n  'w': 'west',\n  'nw': 'northwest',\n};\n\nfunction normalizeAddress(address: string): string {\n  let normalized = address.trim().toLowerCase();\n\n  // Expand abbreviations\n  for (const [abbr, full] of Object.entries(abbreviations)) {\n    const regex = new RegExp(`\\\\b${abbr}\\\\b`, 'gi');\n    normalized = normalized.replace(regex, full);\n  }\n\n  // Normalize postal code (K1A0B1 \u2192 K1A 0B1)\n  normalized = normalized.replace(\n    /\\b([A-Za-z]\\d[A-Za-z])\\s*(\\d[A-Za-z]\\d)\\b/g,\n    (match, p1, p2) => `${p1.toUpperCase()} ${p2.toUpperCase()}`\n  );\n\n  // Remove extra whitespace\n  normalized = normalized.replace(/\\s+/g, ' ').trim();\n\n  return normalized;\n}\n
"},{"location":"v2/features/map/geocoding/#redis-caching","title":"Redis Caching","text":"
// api/src/modules/map/geocoding/geocoding.service.ts\nimport crypto from 'crypto';\n\nconst CACHE_KEY_PREFIX = 'GEOCODE_CACHE:';\n\nfunction hashAddress(address: string): string {\n  return crypto.createHash('sha256').update(address).digest('hex').substring(0, 16);\n}\n\nasync function getCachedResult(address: string): Promise<GeocodeResult | null> {\n  if (env.GEOCODING_CACHE_ENABLED !== 'true') return null;\n\n  try {\n    const key = `${CACHE_KEY_PREFIX}${hashAddress(address)}`;\n    const cached = await redis.get(key);\n\n    if (!cached) {\n      cm_geocode_cache_misses.inc();\n      return null;\n    }\n\n    const parsed = JSON.parse(cached);\n    cm_geocode_cache_hits.inc();\n    return parsed;\n  } catch (err) {\n    logger.warn('Failed to get cached geocode result:', err);\n    return null;\n  }\n}\n\nasync function setCachedResult(address: string, result: GeocodeResult): Promise<void> {\n  if (env.GEOCODING_CACHE_ENABLED !== 'true') return;\n\n  try {\n    const key = `${CACHE_KEY_PREFIX}${hashAddress(address)}`;\n    const ttlSeconds = env.GEOCODING_CACHE_TTL_HOURS * 60 * 60;\n\n    await redis.setex(key, ttlSeconds, JSON.stringify(result));\n  } catch (err) {\n    logger.warn('Failed to cache geocode result:', err);\n  }\n}\n
"},{"location":"v2/features/map/geocoding/#bulk-geocoding-job-bullmq","title":"Bulk Geocoding Job (BullMQ)","text":"
// api/src/services/geocode-queue.service.ts\nimport Bull from 'bull';\n\nexport const geocodeQueue = new Bull('geocode-queue', env.REDIS_URL, {\n  defaultJobOptions: {\n    attempts: 3,\n    backoff: { type: 'exponential', delay: 5000 },\n    removeOnComplete: 100,\n    removeOnFail: false,\n  },\n});\n\n// Bulk geocode job processor\ngeocodeQueue.process(async (job) => {\n  const { locationIds, provider, batchSize } = job.data;\n\n  logger.info('Processing bulk geocode job', {\n    jobId: job.id,\n    totalLocations: locationIds.length,\n  });\n\n  let completed = 0;\n  let failed = 0;\n\n  for (let i = 0; i < locationIds.length; i += batchSize) {\n    const batch = locationIds.slice(i, i + batchSize);\n\n    for (const locationId of batch) {\n      try {\n        const location = await prisma.location.findUnique({\n          where: { id: locationId },\n        });\n\n        if (!location?.address) {\n          failed++;\n          continue;\n        }\n\n        const result = await geocodingService.geocode(location.address);\n\n        await prisma.location.update({\n          where: { id: locationId },\n          data: {\n            latitude: result.latitude,\n            longitude: result.longitude,\n            geocodeConfidence: result.confidence,\n            geocodeProvider: result.provider,\n            lastGeocodeAttempt: new Date(),\n          },\n        });\n\n        completed++;\n      } catch (err) {\n        logger.warn('Failed to geocode location', { locationId, error: err });\n        failed++;\n      }\n    }\n\n    // Update job progress\n    const progress = ((i + batch.length) / locationIds.length) * 100;\n    await job.progress(progress);\n\n    // Rate limiting: wait 1s between batches\n    await new Promise((resolve) => setTimeout(resolve, 1000));\n  }\n\n  return { completed, failed, total: locationIds.length };\n});\n
"},{"location":"v2/features/map/geocoding/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/geocoding/#issue-all-providers-failing","title":"Issue: All Providers Failing","text":"

Symptoms:

  • \"All geocoding providers failed\" error
  • Geocode confidence always 0
  • No results from any provider

Causes:

  • All API keys invalid or missing
  • Network connectivity issues
  • Rate limits exceeded on all providers
  • Address format not recognized

Solutions:

  1. Verify API keys:
# Check .env file\ngrep \"GOOGLE_MAPS_API_KEY\\|MAPBOX_ACCESS_TOKEN\\|LOCATIONIQ_API_KEY\" .env\n\n# Test Google API key directly\ncurl \"https://maps.googleapis.com/maps/api/geocode/json?address=123+Main+St&key=YOUR_KEY\"\n
  1. Check provider health:
# View Prometheus metrics\ncurl http://localhost:4000/metrics | grep cm_geocode\n\n# View API logs\ndocker compose logs -f api | grep geocode\n
  1. Test with free provider (Nominatim):
# Temporarily use only Nominatim\nGEOCODING_PROVIDERS=NOMINATIM\n\n# Test endpoint\ncurl -X POST http://localhost:4000/api/map/locations/geocode \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"address\":\"123 Main Street, Ottawa, ON\"}'\n
"},{"location":"v2/features/map/geocoding/#issue-low-confidence-scores","title":"Issue: Low Confidence Scores","text":"

Symptoms:

  • Geocode confidence consistently <70
  • Coordinates appear incorrect on map
  • Addresses geocoded to city-level instead of street-level

Causes:

  • Address format ambiguous (missing street type, postal code)
  • Provider using city centroid instead of exact address
  • International address format not recognized
  • Address doesn't exist in provider database

Solutions:

  1. Improve address format:
// Bad: missing postal code, street type\n\"123 Main, Ottawa\"\n\n// Good: full Canadian address\n\"123 Main Street, Ottawa, ON K1A 0B1\"\n
  1. Try different providers:
# Google/Mapbox best for North American addresses\nGEOCODING_PROVIDERS=GOOGLE,MAPBOX,NOMINATIM\n\n# Nominatim/Photon better for European addresses\nGEOCODING_PROVIDERS=NOMINATIM,PHOTON,MAPBOX\n
  1. Manual verification:

For critical addresses, manually verify coordinates:

# Reverse geocode to check accuracy\ncurl -X POST http://localhost:4000/api/map/locations/reverse-geocode \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"latitude\":45.4215,\"longitude\":-75.6972}'\n
"},{"location":"v2/features/map/geocoding/#issue-bulk-geocoding-job-stuck","title":"Issue: Bulk Geocoding Job Stuck","text":"

Symptoms:

  • Bulk geocode progress stuck at X%
  • Job running for hours without completing
  • BullMQ job marked as \"active\" but not processing

Causes:

  • Worker crashed mid-job
  • Rate limit hit (paused for cooldown)
  • Redis connection lost
  • Job timeout (default: 30min)

Solutions:

  1. Check job status:
# View BullMQ jobs in Redis\ndocker compose exec redis redis-cli KEYS \"bull:geocode-queue:*\"\n\n# Get job details\ndocker compose exec redis redis-cli GET \"bull:geocode-queue:JOB_ID\"\n
  1. Restart worker:
# Restart API service (worker runs in API container)\ndocker compose restart api\n
  1. Cancel stuck job:
# Via API endpoint\ncurl -X POST http://localhost:4000/api/map/locations/bulk-geocode/cancel \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Or manually in Redis\ndocker compose exec redis redis-cli DEL \"bull:geocode-queue:ACTIVE_JOB_ID\"\n
  1. Increase timeout:
// api/src/services/geocode-queue.service.ts\ndefaultJobOptions: {\n  timeout: 3600000, // 1 hour (was 30min)\n}\n
"},{"location":"v2/features/map/geocoding/#issue-cache-not-working","title":"Issue: Cache Not Working","text":"

Symptoms:

  • cm_geocode_cache_hits metric always 0
  • Same address geocoded multiple times
  • High API usage for repeated addresses

Causes:

  • Redis not running
  • GEOCODING_CACHE_ENABLED=false
  • Cache keys expiring too quickly
  • Address normalization inconsistent (cache miss due to formatting)

Solutions:

  1. Verify Redis connection:
# Check Redis is running\ndocker compose ps redis\n\n# Test Redis connection from API\ndocker compose exec api node -e \"const redis = require('./src/config/redis').redis; redis.ping().then(console.log);\"\n
  1. Check cache keys:
# View cached geocode results\ndocker compose exec redis redis-cli KEYS \"GEOCODE_CACHE:*\"\n\n# Get sample cached result\ndocker compose exec redis redis-cli GET \"GEOCODE_CACHE:abc123def456\"\n
  1. Enable caching:
# Verify in .env\nGEOCODING_CACHE_ENABLED=true\nGEOCODING_CACHE_TTL_HOURS=168  # 7 days\n
  1. Clear cache to test:
# Delete all geocode cache keys\ndocker compose exec redis redis-cli --scan --pattern \"GEOCODE_CACHE:*\" | xargs docker compose exec redis redis-cli DEL\n
"},{"location":"v2/features/map/geocoding/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/geocoding/#provider-rate-limits","title":"Provider Rate Limits","text":"

Free Tier Limits:

Provider Free Tier Rate Limit Best For Google $200/month credit (~28k reqs) 50 req/sec North American addresses Mapbox 100,000/month 600 req/min Global coverage Nominatim Unlimited 1 req/sec Europe, low-volume Photon Unlimited No limit* Europe, high-volume LocationIQ 5,000/day 2 req/sec Testing, low-volume ArcGIS 20,000/month 50 req/sec US addresses

*Self-hosted Photon recommended for production high-volume use.

Best Practices:

  1. Enable Redis caching (7-day TTL reduces API calls by ~80%)
  2. Use bulk geocoding jobs (BullMQ queue with 1s delay between batches)
  3. Prefer NAR imports (coordinates included, no geocoding needed)
  4. Set up Photon self-hosted (for high-volume European campaigns)
"},{"location":"v2/features/map/geocoding/#caching-strategy","title":"Caching Strategy","text":"

Cache Hit Rate Optimization:

// Normalize address before hashing to improve cache hits\nfunction hashAddress(address: string): string {\n  // Remove punctuation, lowercase, trim\n  const normalized = address\n    .toLowerCase()\n    .replace(/[.,]/g, '')\n    .replace(/\\s+/g, ' ')\n    .trim();\n\n  return crypto.createHash('sha256').update(normalized).digest('hex').substring(0, 16);\n}\n

TTL Configuration:

  • Development: 24 hours (test address changes)
  • Production: 7 days (balance freshness vs API quota)
  • NAR imports: 30 days (addresses rarely change)
"},{"location":"v2/features/map/geocoding/#bulk-geocoding-performance","title":"Bulk Geocoding Performance","text":"

Batch Size Tuning:

// Small batches: better for rate limits, slower overall\nbatchSize: 10, // 1 req/sec = 10 locations per 10s batch\n\n// Large batches: faster, but may hit rate limits\nbatchSize: 100, // 50 req/sec = 100 locations per 2s batch\n

Optimal Settings:

Provider Batch Size Delay Between Batches Google 50 1s Mapbox 100 10s Nominatim 1 1s (strict rate limit) Photon 50 0s (self-hosted)

Prometheus Metrics:

# Cache hit rate (target: >80%)\nrate(cm_geocode_cache_hits_total[5m]) /\n  (rate(cm_geocode_cache_hits_total[5m]) + rate(cm_geocode_cache_misses_total[5m]))\n\n# Provider success rate (target: >95%)\nsum by (provider) (rate(cm_geocode_success_total[5m]))\n
"},{"location":"v2/features/map/geocoding/#related-documentation","title":"Related Documentation","text":"

Backend Modules:

  • Geocoding Backend Module \u2014 Full service implementation
  • Locations Service \u2014 Geocoding integration
  • Geocode Queue Service \u2014 BullMQ worker

Frontend Pages:

  • LocationsPage \u2014 Geocoding UI
  • Data Quality Dashboard \u2014 Confidence metrics

Database:

  • Location Model \u2014 Geocoding fields
  • GeocodeProvider Enum \u2014 Provider types

Features:

  • Locations \u2014 Location management system
  • Data Quality Dashboard \u2014 Geocoding quality metrics
  • NAR Import \u2014 Canadian electoral data (pre-geocoded)

Configuration:

  • Environment Variables \u2014 Provider setup
  • Redis Configuration \u2014 Cache setup
"},{"location":"v2/features/map/locations/","title":"Location Management System","text":""},{"location":"v2/features/map/locations/#overview","title":"Overview","text":"

The location management system is the foundation of Changemaker Lite's field organizing capabilities. It provides building-level and unit-level voter/supporter tracking with comprehensive address management, geocoding integration, and Canadian electoral data (NAR) import support.

Key Capabilities:

  • Building + Unit Architecture: Location (building) has 1:N Address (units) for multi-unit buildings
  • NAR Integration: Import Canadian electoral data (LOC_GUID, ADDR_GUID from Elections Canada)
  • Multi-Provider Geocoding: Automatically geocode addresses with confidence scoring
  • CSV Import/Export: Bulk operations for campaign data management
  • Support Level Tracking: LEVEL_1 (Strong) \u2192 LEVEL_4 (Opposed) classification
  • Spatial Filtering: Filter locations by polygon cuts or bounding box
  • History Tracking: Complete audit trail of location changes
  • Field Data: Sign tracking, building notes, federal district assignment

Use Cases:

  • Voter file management for electoral campaigns
  • Door-to-door canvassing organization
  • Sign placement tracking (lawn signs, window signs)
  • Multi-unit building canvassing (apartments, condos)
  • Federal electoral district mapping
  • NAR 2025 import for Canadian campaigns
  • Walk sheet generation for field teams
"},{"location":"v2/features/map/locations/#architecture","title":"Architecture","text":"
graph TD\n    A[Admin User] -->|Manages Locations| B[LocationsPage]\n    B -->|CRUD Operations| C[Locations API]\n    C -->|Save/Query| D[(Location Model)]\n    C -->|Geocode Address| E[Geocoding Service]\n    E -->|Try Providers| F[Multi-Provider Chain]\n    F -->|Cache Result| G[(Redis Cache)]\n\n    H[CSV Import] -->|Parse File| C\n    C -->|Validate| I[Location Service]\n    I -->|Auto-Geocode| E\n    I -->|Create Records| D\n\n    J[NAR Import] -->|Server Stream| K[NAR Import Service]\n    K -->|Join Address+Location| L[Location Files]\n    K -->|Convert Coords| M[proj4 Lambert\u2192WGS84]\n    K -->|Filter| N[Cut/City/Postal]\n    K -->|Bulk Insert| D\n\n    D -->|1:N| O[(Address Model)]\n    D -->|Assigned To| P[(Cut Model)]\n\n    Q[Public Map] -->|GET /api/public/map/locations| C\n    C -->|Filter by Bounds| D\n\n    R[Canvass Session] -->|Load Addresses| C\n    C -->|Point-in-Polygon| S[Spatial Utils]\n\n    style D fill:#e1f5ff\n    style O fill:#e1f5ff\n    style P fill:#e1f5ff\n    style G fill:#fff4e1

Flow Description:

  1. Admin creates location \u2192 Location service validates address and optionally geocodes
  2. CSV import \u2192 Service parses file, detects format (standard/NAR), geocodes if needed, creates records
  3. NAR server import \u2192 Streams large files, joins Address+Location CSVs, converts Lambert coords, filters, bulk inserts
  4. Public map loads \u2192 Location service queries by bounds, returns color-coded markers
  5. Canvass session starts \u2192 Service loads addresses within cut polygon using ray-casting algorithm
  6. Geocoding \u2192 Multi-provider chain tries providers in order, caches successful results
"},{"location":"v2/features/map/locations/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/locations/#location-model","title":"Location Model","text":"

See Location Model Documentation for full schema.

Key Fields:

  • latitude / longitude: WGS84 coordinates (Decimal type for precision)
  • address: Street address (building level, not including unit numbers)
  • postalCode: Canadian postal code (A1A 1A1 format)
  • province: Province code (ON, QC, AB, etc.)
  • federalDistrict: Federal electoral district name
  • buildingType: SINGLE_FAMILY | MULTI_UNIT | MIXED_USE | COMMERCIAL
  • totalUnits: Number of units in building (for multi-unit buildings)
  • geocodeConfidence: Confidence score 0-100 from geocoding service
  • geocodeProvider: Google, Mapbox, Nominatim, Photon, LocationIQ, ArcGIS
  • narLocGuid: NAR LOC_GUID identifier (Canadian electoral data)
  • buildingNotes: Free-text notes about building access, parking, etc.

NAR-Specific Fields:

  • narLocGuid: Location GUID from NAR dataset
  • buildingUse: Building use code (1=Residential, 2=Commercial, etc.)
  • postalCode: Extracted from NAR MAIL_POSTAL_CODE
  • province: Extracted from NAR PROV_CODE
  • federalDistrict: Extracted from NAR FED_ENG_NAME

Geocoding Fields:

  • geocodeConfidence: 0-100 score (>90=high, 70-90=medium, <70=low)
  • geocodeProvider: Which provider successfully geocoded the address
  • geocodeAttempts: Number of failed geocoding attempts
  • lastGeocodeAttempt: Timestamp of last geocoding attempt
"},{"location":"v2/features/map/locations/#address-model","title":"Address Model","text":"

See Address Model Documentation for full schema.

Key Fields:

  • locationId: Foreign key to Location (building)
  • unitNumber: Unit/apartment/suite number (optional for single-family)
  • firstName / lastName: Resident name
  • email / phone: Contact information
  • supportLevel: LEVEL_1 (Strong) | LEVEL_2 (Leaning) | LEVEL_3 (Undecided) | LEVEL_4 (Opposed)
  • sign: Boolean - has lawn/window sign
  • signSize: Sign size description (e.g., \"24x18 lawn\", \"window\")
  • notes: Free-text notes from canvassing
  • narAddrGuid: NAR ADDR_GUID identifier

NAR-Specific Fields:

  • narAddrGuid: Address GUID from NAR dataset
  • unitNumber: Extracted from NAR APT_NO_LABEL

Related Models:

  • Cut \u2014 Polygon overlays for organizing
  • CanvassVisit \u2014 Door-knock records
  • LocationHistory \u2014 Audit trail
"},{"location":"v2/features/map/locations/#api-endpoints","title":"API Endpoints","text":"

See Locations Backend Module Documentation for full API reference.

Admin Endpoints:

Method Endpoint Auth Description GET /api/map/locations MAP_ADMIN List locations with pagination, search, filters GET /api/map/locations/stats MAP_ADMIN Get location statistics (total, geocoded, by confidence) GET /api/map/locations/:id MAP_ADMIN Get location details with addresses POST /api/map/locations MAP_ADMIN Create new location PATCH /api/map/locations/:id MAP_ADMIN Update location DELETE /api/map/locations/:id MAP_ADMIN Delete location (and cascade addresses) POST /api/map/locations/geocode MAP_ADMIN Geocode single address POST /api/map/locations/reverse-geocode MAP_ADMIN Reverse geocode lat/lng to address POST /api/map/locations/import MAP_ADMIN Import CSV file (standard or NAR format) GET /api/map/locations/export MAP_ADMIN Export locations to CSV GET /api/map/locations/:id/history MAP_ADMIN Get location change history

Bulk Operations:

Method Endpoint Auth Description POST /api/map/locations/bulk-geocode/start MAP_ADMIN Start bulk geocoding job (BullMQ) GET /api/map/locations/bulk-geocode/status MAP_ADMIN Check bulk geocoding job status POST /api/map/locations/bulk-geocode/cancel MAP_ADMIN Cancel running bulk geocoding job

NAR Import Endpoints:

Method Endpoint Auth Description GET /api/map/locations/nar/datasets MAP_ADMIN List available NAR datasets from /data directory POST /api/map/locations/nar/import MAP_ADMIN Server-side streaming NAR import with filters GET /api/map/locations/nar/import/progress MAP_ADMIN Get NAR import progress (polling endpoint)

Public Endpoints:

Method Endpoint Auth Description GET /api/public/map/locations None List locations by bounds (for public map)

Volunteer Endpoints:

Method Endpoint Auth Description PATCH /api/map/canvass/volunteer/locations/:id Any logged-in user Update location from canvass session"},{"location":"v2/features/map/locations/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/locations/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description GEOCODING_ENABLED boolean true Enable geocoding services GEOCODING_CACHE_ENABLED boolean true Cache geocoding results in Redis GEOCODING_CACHE_TTL_HOURS number 168 Cache TTL (7 days) GEOCODING_PROVIDERS string[] See geocoding.md Comma-separated provider list GOOGLE_MAPS_API_KEY string - Google Geocoding API key MAPBOX_ACCESS_TOKEN string - Mapbox API token LOCATIONIQ_API_KEY string - LocationIQ API key NAR_DATA_DIR string /data Directory containing NAR CSV files"},{"location":"v2/features/map/locations/#database-indexes","title":"Database Indexes","text":"

Key indexes for performance:

-- Location queries\nCREATE INDEX idx_locations_lat_lng ON \"Location\" (latitude, longitude);\nCREATE INDEX idx_locations_postal_code ON \"Location\" (\"postalCode\");\nCREATE INDEX idx_locations_province ON \"Location\" (province);\nCREATE INDEX idx_locations_federal_district ON \"Location\" (\"federalDistrict\");\nCREATE INDEX idx_locations_geocode_confidence ON \"Location\" (\"geocodeConfidence\");\nCREATE INDEX idx_locations_nar_loc_guid ON \"Location\" (\"narLocGuid\");\n\n-- Address queries\nCREATE INDEX idx_addresses_location_id ON \"Address\" (\"locationId\");\nCREATE INDEX idx_addresses_support_level ON \"Address\" (\"supportLevel\");\nCREATE INDEX idx_addresses_nar_addr_guid ON \"Address\" (\"narAddrGuid\");\n\n-- Spatial queries (cut assignment)\nCREATE INDEX idx_locations_lat ON \"Location\" (latitude);\nCREATE INDEX idx_locations_lng ON \"Location\" (longitude);\n
"},{"location":"v2/features/map/locations/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/locations/#creating-a-location","title":"Creating a Location","text":"

Step 1: Navigate to Locations Page

Navigate to Map \u2192 Locations in the admin sidebar.

![LocationsPage Screenshot Placeholder]

Step 2: Click \"Add Location\"

Click the + Add Location button in the top-right corner.

Step 3: Enter Address Information

Fill in the location form:

  • Address: Street address (e.g., \"123 Main Street\")
  • Postal Code: Canadian postal code (e.g., \"K1A 0B1\")
  • Building Type: Single Family / Multi-Unit / Mixed Use / Commercial
  • Total Units: Number of units (for multi-unit buildings)
  • Building Notes: Access codes, parking info, etc.

Step 4: Auto-Geocode (Optional)

Click Geocode button to automatically fetch latitude/longitude coordinates. The system will:

  1. Try geocoding providers in order (Google \u2192 Mapbox \u2192 Nominatim \u2192 Photon \u2192 LocationIQ \u2192 ArcGIS)
  2. Return confidence score (0-100)
  3. Display formatted address from provider
  4. Cache result in Redis for 7 days

Step 5: Add Addresses (Units)

For multi-unit buildings, click Add Address to create unit records:

  • Unit Number: Apartment/suite number
  • First Name / Last Name: Resident name
  • Support Level: LEVEL_1 (Strong) \u2192 LEVEL_4 (Opposed)
  • Sign: Check if resident has lawn/window sign
  • Notes: Canvassing notes

Step 6: Save Location

Click Create to save the location and addresses.

"},{"location":"v2/features/map/locations/#csv-import-workflow","title":"CSV Import Workflow","text":"

Step 1: Prepare CSV File

Prepare a CSV file with the following columns (flexible header names):

Standard Format:

address,firstName,lastName,email,phone,unitNumber,supportLevel,sign,notes,latitude,longitude\n123 Main St,John,Doe,john@example.com,555-1234,101,LEVEL_1,true,Friendly contact,,\n124 Main St,Jane,Smith,jane@example.com,555-5678,,LEVEL_2,false,Ask about lawn sign,45.4215,-75.6972\n

NAR Format (auto-detected if 3+ NAR columns present):

CIVIC_NO,OFFICIAL_STREET_NAME,OFFICIAL_STREET_TYPE,APT_NO_LABEL,MAIL_POSTAL_CODE,BG_LATITUDE,BG_LONGITUDE,FED_ENG_NAME\n123,Main,Street,101,K1A 0B1,45.4215,-75.6972,Ottawa Centre\n124,Main,Street,,K1A 0B2,45.4220,-75.6975,Ottawa Centre\n

Step 2: Open Import Modal

Click Import CSV button on LocationsPage.

Step 3: Select Import Format

Choose format:

  • Standard: General campaign CSV (address, firstName, lastName, supportLevel, etc.)
  • NAR: National Address Register format (auto-detected)
  • Server: Server-side NAR streaming import (for large files >100MB)

Step 4: Configure Filters (Optional)

Filter imported locations:

  • Cut: Import only locations within a polygon
  • Map Area: Import only locations within current map bounds
  • City: Filter by city name
  • Province: Filter by province code (ON, QC, AB, etc.)
  • Residential Only: Exclude commercial buildings (BU_USE = 1)

Step 5: Upload File

Drag-and-drop or click to select CSV file.

Step 6: Configure Geocoding

Toggle Geocode Missing Coordinates:

  • Enabled: Automatically geocode addresses without lat/lng (slower, uses geocoding API quota)
  • Disabled: Import only records with coordinates (faster, for NAR imports)

Step 7: Review Import Results

After import completes, view results:

  • Created: Number of new locations created
  • Skipped: Number of duplicate addresses skipped
  • Failed: Number of errors (invalid addresses, geocoding failures)
  • Geocoded: Number of addresses successfully geocoded
"},{"location":"v2/features/map/locations/#nar-server-import-workflow","title":"NAR Server Import Workflow","text":"

For large NAR datasets (>100MB), use server-side streaming import:

Step 1: Upload NAR Files to Server

Copy NAR CSV files to server's /data directory:

# Example NAR files for Ontario (province code 35)\n/data/Address_35_part_1.csv\n/data/Address_35_part_2.csv\n/data/Location_35.csv\n

Step 2: Open NAR Import Tab

Click NAR Import tab on LocationsPage.

Step 3: Scan for Datasets

Click Scan NAR Directory to detect available datasets. The system will:

  • Scan /data directory for Address_.csv and Location_.csv files
  • Group files by province code (10=NL, 24=QC, 35=ON, 48=AB, etc.)
  • Display file sizes and counts

Step 4: Select Province

Choose province from dropdown (e.g., \"35 - Ontario (10.5 GB, 45 files)\").

Step 5: Configure Filters

Apply optional filters:

  • City: Filter by MAIL_MUN_NAME or CSD_ENG_NAME
  • Postal Code Prefix: Filter by first 3 characters (e.g., \"K1A\")
  • Cut: Import only addresses within polygon
  • Residential Only: Exclude commercial buildings (BU_USE != 1)

Step 6: Start Import

Click Start Import. The system will:

  1. Stream Address CSV files (multi-part files processed sequentially)
  2. Join with Location CSV on LOC_GUID
  3. Convert BG_X/BG_Y (Lambert projection) to lat/lng (WGS84) using proj4
  4. Apply filters (city, postal, cut, residential)
  5. Bulk insert locations + addresses (transaction batches of 500)
  6. Update progress every 5 seconds

Step 7: Monitor Progress

View real-time progress:

  • Records Processed: Current/total count
  • Progress Percentage: Visual progress bar
  • ETA: Estimated time remaining
  • Current File: Which multi-part file is being processed

Step 8: Review Results

After import completes:

  • Total Created: Number of locations + addresses created
  • Duration: Total import time
  • Skipped: Duplicate or filtered records
"},{"location":"v2/features/map/locations/#bulk-re-geocoding","title":"Bulk Re-Geocoding","text":"

For locations with missing or low-confidence coordinates:

Step 1: Open Bulk Geocode Modal

Click Bulk Re-Geocode button on LocationsPage.

Step 2: Configure Job Parameters

Set parameters:

  • Confidence Filter: Re-geocode locations below threshold (e.g., <70)
  • Missing Only: Only geocode locations without coordinates
  • Provider: Choose preferred geocoding provider
  • Batch Size: Number of locations per batch (default: 50)

Step 3: Start Job

Click Start Job to queue bulk geocoding job in BullMQ.

Step 4: Monitor Progress

Poll job status:

  • Completed: Number of successfully geocoded locations
  • Failed: Number of geocoding failures
  • Progress: Percentage complete
  • ETA: Estimated time remaining

Step 5: Cancel Job (Optional)

Click Cancel Job to stop bulk geocoding.

"},{"location":"v2/features/map/locations/#exporting-locations","title":"Exporting Locations","text":"

Step 1: Configure Export Filters

Apply filters on LocationsPage:

  • Search: Filter by address or notes
  • Confidence Level: High / Medium / Low / None
  • Cut: Export locations within specific polygon

Step 2: Click Export CSV

Click Export CSV button. The system will:

  1. Export locations matching current filters
  2. Include all address records (one row per address)
  3. Download CSV file with timestamp

Export Format:

locationId,address,latitude,longitude,postalCode,province,federalDistrict,buildingType,totalUnits,geocodeConfidence,geocodeProvider,unitNumber,firstName,lastName,email,phone,supportLevel,sign,signSize,notes\nuuid-1,123 Main St,45.4215,-75.6972,K1A 0B1,ON,Ottawa Centre,MULTI_UNIT,12,95,GOOGLE,101,John,Doe,john@example.com,555-1234,LEVEL_1,true,24x18 lawn,Friendly contact\n
"},{"location":"v2/features/map/locations/#public-workflow","title":"Public Workflow","text":"

Public users can view locations on the interactive map.

Step 1: Navigate to Public Map

Visit /map (public route, no authentication required).

Step 2: Browse Map

Interact with Leaflet map:

  • Zoom/Pan: Use mouse or touch gestures
  • Markers: Locations displayed as color-coded circle markers:
  • Green: LEVEL_1 (Strong support)
  • Yellow: LEVEL_2 (Leaning support)
  • Gray: LEVEL_3 (Undecided)
  • Red: LEVEL_4 (Opposed)
  • Blue: No support level assigned

Step 3: View Cut Overlays

Toggle cut overlays using Cuts control panel:

  • Show/Hide: Toggle cut visibility
  • Opacity: Adjust polygon transparency
  • Legend: View cut color legend

Step 4: Geolocate

Click Geolocate button to center map on current location (requires browser geolocation permission).

Step 5: Fullscreen Mode

Click Fullscreen button to expand map to full screen.

"},{"location":"v2/features/map/locations/#volunteer-workflow","title":"Volunteer Workflow","text":"

Volunteers can update location data during canvassing sessions.

Step 1: Start Canvass Session

See Canvassing Documentation for full workflow.

Step 2: Record Visit

When visiting a location, update fields:

  • Support Level: Update based on conversation
  • Sign: Check if resident wants lawn/window sign
  • Notes: Add canvassing notes

Step 3: Update Location

Click Save Visit to record changes. The system will:

  1. Create CanvassVisit record with outcome
  2. Update Address with new supportLevel/sign/notes
  3. Update Location.lastUpdated timestamp
  4. Create LocationHistory audit record
"},{"location":"v2/features/map/locations/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/locations/#creating-a-location-frontend","title":"Creating a Location (Frontend)","text":"
// admin/src/pages/LocationsPage.tsx\nconst handleCreate = async (values: any) => {\n  try {\n    const { data } = await api.post<Location>('/map/locations', {\n      address: values.address,\n      postalCode: values.postalCode,\n      buildingType: values.buildingType,\n      totalUnits: values.totalUnits,\n      buildingNotes: values.buildingNotes,\n      latitude: values.latitude,\n      longitude: values.longitude,\n      geocodeConfidence: values.geocodeConfidence,\n      geocodeProvider: values.geocodeProvider,\n    });\n\n    message.success('Location created');\n    setCreateModalOpen(false);\n    createForm.resetFields();\n    fetchLocations();\n  } catch (error) {\n    message.error('Failed to create location');\n  }\n};\n
"},{"location":"v2/features/map/locations/#geocoding-an-address-frontend","title":"Geocoding an Address (Frontend)","text":"
// admin/src/pages/LocationsPage.tsx\nconst handleGeocode = async () => {\n  const address = createForm.getFieldValue('address');\n  const postalCode = createForm.getFieldValue('postalCode');\n\n  if (!address) {\n    message.warning('Please enter an address first');\n    return;\n  }\n\n  setGeocoding(true);\n  try {\n    const fullAddress = postalCode ? `${address}, ${postalCode}` : address;\n    const { data } = await api.post<GeocodeResult>('/map/locations/geocode', {\n      address: fullAddress,\n    });\n\n    createForm.setFieldsValue({\n      latitude: data.latitude,\n      longitude: data.longitude,\n      geocodeConfidence: data.confidence,\n      geocodeProvider: data.provider,\n    });\n\n    message.success(\n      `Geocoded with ${data.provider} (confidence: ${data.confidence}%)`\n    );\n  } catch (error) {\n    message.error('Geocoding failed');\n  } finally {\n    setGeocoding(false);\n  }\n};\n
"},{"location":"v2/features/map/locations/#location-service-create-backend","title":"Location Service Create (Backend)","text":"
// api/src/modules/map/locations/locations.service.ts\nasync create(data: CreateLocationInput, userId: string) {\n  // Auto-geocode if address provided but no coordinates\n  if (data.address && !data.latitude && !data.longitude) {\n    try {\n      const fullAddress = data.postalCode\n        ? `${data.address}, ${data.postalCode}`\n        : data.address;\n      const geocodeResult = await geocodingService.geocode(fullAddress);\n\n      data.latitude = geocodeResult.latitude;\n      data.longitude = geocodeResult.longitude;\n      data.geocodeConfidence = geocodeResult.confidence;\n      data.geocodeProvider = geocodeResult.provider;\n\n      logger.info('Auto-geocoded location', {\n        address: fullAddress,\n        provider: geocodeResult.provider,\n        confidence: geocodeResult.confidence,\n      });\n    } catch (err) {\n      logger.warn('Auto-geocoding failed, creating location without coordinates', err);\n    }\n  }\n\n  const location = await prisma.location.create({\n    data: {\n      address: data.address,\n      latitude: data.latitude,\n      longitude: data.longitude,\n      postalCode: data.postalCode,\n      province: data.province,\n      federalDistrict: data.federalDistrict,\n      buildingType: data.buildingType,\n      totalUnits: data.totalUnits,\n      buildingNotes: data.buildingNotes,\n      geocodeConfidence: data.geocodeConfidence,\n      geocodeProvider: data.geocodeProvider,\n      createdByUserId: userId,\n    },\n  });\n\n  // Create history record\n  await prisma.locationHistory.create({\n    data: {\n      locationId: location.id,\n      action: LocationHistoryAction.CREATED,\n      changedByUserId: userId,\n      changes: JSON.stringify({ created: true }),\n    },\n  });\n\n  recordLocationQuery('create');\n  return location;\n}\n
"},{"location":"v2/features/map/locations/#csv-import-detection-backend","title":"CSV Import Detection (Backend)","text":"
// api/src/modules/map/locations/locations.service.ts\nfunction detectNarFormat(headers: string[]): boolean {\n  const normalizedHeaders = headers.map((h) => h.trim().toUpperCase());\n  let matchCount = 0;\n  const matched = new Set<string>();\n\n  // NAR columns to detect (need 3+ matches)\n  const NAR_DETECT_COLUMNS = [\n    'CIVIC_NO', 'OFFICIAL_STREET_NAME', 'OFFICIAL_STREET_TYPE',\n    'BG_X', 'BG_Y', 'MAIL_POSTAL_CODE', 'MAIL_PROV_ABVN',\n    'BG_LATITUDE', 'BG_LONGITUDE',\n  ];\n\n  for (const col of NAR_DETECT_COLUMNS) {\n    if (normalizedHeaders.includes(col) && !matched.has(col)) {\n      matched.add(col);\n      matchCount++;\n    }\n  }\n\n  return matchCount >= 3;\n}\n
"},{"location":"v2/features/map/locations/#nar-lambert-coordinate-conversion-backend","title":"NAR Lambert Coordinate Conversion (Backend)","text":"
// api/src/modules/map/locations/locations.service.ts\nimport proj4 from 'proj4';\n\n// Statistics Canada Lambert Conformal Conic (EPSG:3347) \u2192 WGS84 (EPSG:4326)\nproj4.defs(\n  'EPSG:3347',\n  '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=63.390675 +lon_0=-91.86666666666666 ' +\n  '+x_0=6200000 +y_0=3000000 +ellps=GRS80 +units=m +no_defs'\n);\n\n/** Convert BG_X/BG_Y (EPSG:3347 Lambert) to [lat, lng] (WGS84) */\nfunction lambertToLatLng(bgX: number, bgY: number): [number, number] {\n  const [lng, lat] = proj4('EPSG:3347', 'EPSG:4326', [bgX, bgY]);\n  return [lat, lng];\n}\n\n// Usage in NAR import\nconst [lat, lng] = lambertToLatLng(row.BG_X, row.BG_Y);\n
"},{"location":"v2/features/map/locations/#spatial-filtering-by-cut-backend","title":"Spatial Filtering by Cut (Backend)","text":"
// api/src/modules/map/locations/locations.service.ts\nasync findByBounds(filters: BoundsQuery) {\n  const where: Prisma.LocationWhereInput = {\n    latitude: {\n      gte: new Prisma.Decimal(filters.minLat),\n      lte: new Prisma.Decimal(filters.maxLat),\n    },\n    longitude: {\n      gte: new Prisma.Decimal(filters.minLng),\n      lte: new Prisma.Decimal(filters.maxLng),\n    },\n  };\n\n  const locations = await prisma.location.findMany({\n    where,\n    select: {\n      id: true,\n      latitude: true,\n      longitude: true,\n      address: true,\n      addresses: {\n        select: {\n          supportLevel: true,\n        },\n      },\n    },\n  });\n\n  // If cut filter provided, apply point-in-polygon\n  if (filters.cutId) {\n    const cut = await prisma.cut.findUnique({\n      where: { id: filters.cutId },\n      select: { geojson: true },\n    });\n\n    if (cut?.geojson) {\n      const polygons = parseGeoJsonPolygon(cut.geojson);\n      return locations.filter((loc) => {\n        const lat = Number(loc.latitude);\n        const lng = Number(loc.longitude);\n        return polygons.some((poly) => isPointInPolygon(lat, lng, poly));\n      });\n    }\n  }\n\n  return locations;\n}\n
"},{"location":"v2/features/map/locations/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/locations/#issue-geocoding-fails-for-valid-address","title":"Issue: Geocoding Fails for Valid Address","text":"

Symptoms:

  • \"Geocoding failed\" error message
  • Location created without coordinates
  • Low geocode confidence score (<50)

Causes:

  • Invalid API key for geocoding provider
  • Provider quota exceeded
  • Address format not recognized by provider
  • Provider service down

Solutions:

  1. Check API keys:
# Verify API keys are set in .env\ngrep \"GOOGLE_MAPS_API_KEY\\|MAPBOX_ACCESS_TOKEN\\|LOCATIONIQ_API_KEY\" .env\n
  1. Test geocoding endpoint directly:
curl -X POST http://localhost:4000/api/map/locations/geocode \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"address\":\"123 Main Street, Ottawa, ON K1A 0B1\"}'\n
  1. Check provider order in env:
# Try different provider order\nGEOCODING_PROVIDERS=GOOGLE,NOMINATIM,PHOTON,MAPBOX,LOCATIONIQ,ARCGIS\n
  1. View API logs:
docker compose logs -f api | grep geocode\n
"},{"location":"v2/features/map/locations/#issue-nar-import-fails-or-hangs","title":"Issue: NAR Import Fails or Hangs","text":"

Symptoms:

  • NAR import progress stuck at 0%
  • Import fails with \"File not found\" error
  • Import fails with \"Invalid coordinates\" error
  • Memory errors during large imports

Causes:

  • NAR files not in /data directory
  • Multi-part files missing (e.g., Address_35_part_2.csv)
  • Incorrect province code
  • Invalid BG_X/BG_Y coordinates
  • Cut polygon filter too complex

Solutions:

  1. Verify NAR files exist:
# Check /data directory in container\ndocker compose exec api ls -lh /data\n\n# Verify file naming matches NAR format\n# Address_{PROV_CODE}_part_{N}.csv\n# Location_{PROV_CODE}.csv\n
  1. Check province code mapping:
10 = Newfoundland and Labrador\n24 = Quebec\n35 = Ontario\n48 = Alberta\n59 = British Columbia\n62 = Nunavut\n
  1. Test coordinate conversion:
# Verify proj4 is installed\ndocker compose exec api node -e \"const proj4 = require('proj4'); console.log(proj4.version);\"\n
  1. Monitor import progress:
# Watch API logs during import\ndocker compose logs -f api | grep \"NAR import\"\n\n# Check Redis for progress key\ndocker compose exec redis redis-cli GET \"NAR_IMPORT_PROGRESS\"\n
  1. Use smaller filters for testing:

  2. Start with single postal code prefix (e.g., \"K1A\")

  3. Use small cut polygon
  4. Enable residential-only filter (reduces records by ~50%)
"},{"location":"v2/features/map/locations/#issue-duplicate-locations-created-on-import","title":"Issue: Duplicate Locations Created on Import","text":"

Symptoms:

  • Same address appears multiple times in table
  • Export CSV has duplicate rows
  • Location count doesn't match expected NAR count

Causes:

  • Re-importing same CSV file without checking for duplicates
  • NAR Address multi-part files have overlapping records
  • Different LOC_GUID for same physical address (NAR data issue)

Solutions:

  1. Use NAR GUID fields for deduplication:

The system deduplicates by narLocGuid and narAddrGuid:

// Check for existing location before creating\nconst existing = await prisma.location.findFirst({\n  where: { narLocGuid: row.LOC_GUID },\n});\n\nif (existing) {\n  skipped++;\n  continue;\n}\n
  1. Delete duplicates manually:
-- Find duplicate locations by address\nSELECT address, COUNT(*) as count\nFROM \"Location\"\nGROUP BY address\nHAVING COUNT(*) > 1;\n\n-- Keep first, delete rest\nDELETE FROM \"Location\"\nWHERE id NOT IN (\n  SELECT MIN(id)\n  FROM \"Location\"\n  GROUP BY address\n);\n
  1. Use server-side NAR import (better deduplication):

Server-side import joins Address + Location files on LOC_GUID before inserting, preventing duplicates.

"},{"location":"v2/features/map/locations/#issue-low-geocode-confidence-for-nar-data","title":"Issue: Low Geocode Confidence for NAR Data","text":"

Symptoms:

  • NAR locations have geocodeConfidence < 70
  • Locations appear in wrong place on map
  • \"Low confidence\" warnings in admin

Causes:

  • BG_X/BG_Y coordinates missing in NAR Location file
  • BG_LATITUDE/BG_LONGITUDE used instead of converted Lambert coords
  • proj4 conversion error

Solutions:

  1. Verify coordinate source:

NAR Location files have TWO coordinate fields:

  • BG_LATITUDE / BG_LONGITUDE: Direct WGS84 (use these if available)
  • BG_X / BG_Y: Lambert Conformal Conic EPSG:3347 (requires conversion)

  • Use BG_LATITUDE/BG_LONGITUDE if available:

// Priority: use direct WGS84 coords if available\nconst lat = row.BG_LATITUDE\n  ? parseFloat(row.BG_LATITUDE)\n  : (row.BG_X && row.BG_Y ? lambertToLatLng(row.BG_X, row.BG_Y)[0] : null);\n
  1. Re-geocode low-confidence locations:

Use bulk re-geocoding feature with confidence filter <70.

"},{"location":"v2/features/map/locations/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/locations/#query-optimization","title":"Query Optimization","text":"

Bounding Box Queries:

Always use indexed lat/lng queries for map bounds:

-- Efficient: uses idx_locations_lat_lng index\nSELECT * FROM \"Location\"\nWHERE latitude BETWEEN 45.0 AND 46.0\n  AND longitude BETWEEN -76.0 AND -75.0;\n\n-- Inefficient: no index\nSELECT * FROM \"Location\"\nWHERE ST_Contains(polygon, point); -- PostGIS not used\n

Point-in-Polygon:

For small result sets (<1000 locations), use application-level ray-casting:

// api/src/utils/spatial.ts\nexport function isPointInPolygon(\n  lat: number,\n  lng: number,\n  polygonCoords: number[][]\n): boolean {\n  let inside = false;\n  for (let i = 0, j = polygonCoords.length - 1; i < polygonCoords.length; j = i++) {\n    const xi = polygonCoords[i]![1]!; // lat\n    const yi = polygonCoords[i]![0]!; // lng\n    const xj = polygonCoords[j]![1]!;\n    const yj = polygonCoords[j]![0]!;\n\n    const intersect = ((yi > lng) !== (yj > lng)) &&\n      (lat < (xj - xi) * (lng - yi) / (yj - yi) + xi);\n    if (intersect) inside = !inside;\n  }\n  return inside;\n}\n

For large result sets (>10,000 locations), consider PostGIS extension.

"},{"location":"v2/features/map/locations/#geocoding-rate-limits","title":"Geocoding Rate Limits","text":"

Provider Limits:

Provider Free Tier Rate Limit Google $200/month credit 50 req/sec Mapbox 100,000/month 600 req/min Nominatim Unlimited 1 req/sec Photon Unlimited No limit (self-hosted recommended) LocationIQ 5,000/day 2 req/sec ArcGIS 20,000/month 50 req/sec

Best Practices:

  1. Enable Redis caching (default: 7 days TTL)
  2. Use bulk geocoding jobs (BullMQ queue with rate limiting)
  3. Prefer NAR imports (coordinates included, no geocoding needed)
  4. Batch geocoding requests (50 locations per batch)
"},{"location":"v2/features/map/locations/#nar-import-performance","title":"NAR Import Performance","text":"

Large File Streaming:

NAR Address files can be 10+ GB. Use server-side streaming to avoid memory issues:

// api/src/modules/map/locations/nar-import.service.ts\nimport { createReadStream } from 'fs';\nimport { parse } from 'csv-parse';\n\nasync function streamNarFile(filePath: string) {\n  return new Promise((resolve, reject) => {\n    const stream = createReadStream(filePath)\n      .pipe(parse({ columns: true, skip_empty_lines: true }));\n\n    const batch: any[] = [];\n    const BATCH_SIZE = 500;\n\n    stream.on('data', async (row) => {\n      batch.push(row);\n\n      if (batch.length >= BATCH_SIZE) {\n        stream.pause(); // Backpressure\n        await insertBatch(batch);\n        batch.length = 0;\n        stream.resume();\n      }\n    });\n\n    stream.on('end', async () => {\n      if (batch.length > 0) await insertBatch(batch);\n      resolve(true);\n    });\n\n    stream.on('error', reject);\n  });\n}\n

Transaction Batching:

Insert locations in transaction batches to improve performance:

async function insertBatch(rows: any[]) {\n  await prisma.$transaction(\n    rows.map((row) =>\n      prisma.location.create({\n        data: {\n          address: row.address,\n          latitude: row.latitude,\n          longitude: row.longitude,\n          // ... other fields\n        },\n      })\n    ),\n    { timeout: 30000 } // 30s timeout for large batches\n  );\n}\n
"},{"location":"v2/features/map/locations/#map-rendering-performance","title":"Map Rendering Performance","text":"

Marker Clustering:

For maps with >1000 locations, use marker clustering to improve render performance:

// admin/src/components/map/AdminMapView.tsx\nimport MarkerClusterGroup from 'react-leaflet-cluster';\n\n<MarkerClusterGroup>\n  {locations.map((loc) => (\n    <CircleMarker\n      key={loc.id}\n      center={[loc.latitude, loc.longitude]}\n      radius={8}\n      pathOptions={{ color: getSupportLevelColor(loc.supportLevel) }}\n    />\n  ))}\n</MarkerClusterGroup>\n

Viewport Filtering:

Only load locations within map bounds + buffer:

// admin/src/pages/public/MapPage.tsx\nconst handleMapMove = useCallback(\n  debounce(() => {\n    if (!mapRef.current) return;\n\n    const bounds = mapRef.current.getBounds();\n    const buffer = 0.1; // 10% buffer\n\n    fetchLocations({\n      minLat: bounds.getSouth() - buffer,\n      maxLat: bounds.getNorth() + buffer,\n      minLng: bounds.getWest() - buffer,\n      maxLng: bounds.getEast() + buffer,\n    });\n  }, 500),\n  []\n);\n
"},{"location":"v2/features/map/locations/#related-documentation","title":"Related Documentation","text":"

Backend Modules:

  • Locations Backend Module \u2014 API implementation
  • Geocoding Service \u2014 Multi-provider geocoding
  • Spatial Utils \u2014 Point-in-polygon algorithms

Frontend Pages:

  • LocationsPage \u2014 Admin CRUD interface
  • AdminMapView \u2014 Interactive map component
  • Public MapPage \u2014 Public map view

Database:

  • Map Models \u2014 Location, Address, Cut schemas
  • Location History \u2014 Audit trail
  • Spatial Queries \u2014 Optimization tips

Features:

  • Geocoding \u2014 Multi-provider geocoding system
  • Cuts \u2014 Geographic polygon overlays
  • Canvassing \u2014 Field organizing workflow
  • NAR Import \u2014 Canadian electoral data import
  • Data Quality Dashboard \u2014 Geocoding quality metrics
"},{"location":"v2/features/map/nar-import/","title":"NAR Import System","text":""},{"location":"v2/features/map/nar-import/#overview","title":"Overview","text":"

The National Address Register (NAR) import system enables bulk import of Canadian electoral data from Elections Canada. The system supports the 2025 NAR format with server-side streaming import, coordinate projection conversion, and comprehensive filtering options.

Key Features:

  • Server-side streaming import (handles large datasets)
  • NAR 2025 format support (BG_X/BG_Y Lambert projection)
  • Address + Location file joining on LOC_GUID
  • Proj4 coordinate conversion (EPSG:3347 \u2192 WGS84)
  • Province selector (13 provinces/territories)
  • Filtering: city, postal code, cut boundary, residential-only
  • Multi-part file handling (large provinces)
  • Progress tracking and error reporting
  • Import statistics and validation

Use Cases:

  • Initial campaign database setup
  • Electoral district targeting
  • NAR data updates (new redistribution)
  • Multi-region campaign expansion
  • Address database verification

Architecture Highlights:

  • Streaming CSV parser (avoids memory limits)
  • File-based LOC_GUID join
  • Real-time coordinate projection
  • Point-in-polygon cut filtering
  • Transaction batching (500 records/commit)
  • Duplicate prevention via UPSERT
"},{"location":"v2/features/map/nar-import/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph Admin Interface\n        Admin[Admin User]\n        LocationsPage[LocationsPage - NAR Tab]\n    end\n\n    subgraph API Layer\n        DatasetsAPI[\"/api/locations/nar/datasets\"]\n        ImportAPI[\"/api/locations/nar/import\"]\n    end\n\n    subgraph NAR Import Service\n        Scanner[File Scanner]\n        Reader[CSV Stream Reader]\n        Joiner[Address+Location Joiner]\n        Converter[Coordinate Converter]\n        Filter[Filter Pipeline]\n        Importer[Bulk Importer]\n    end\n\n    subgraph File System\n        DataDir[/data/NAR Files]\n        AddressFiles[Address_XX_part_*.csv]\n        LocationFiles[Location_XX.csv]\n    end\n\n    subgraph Database\n        LocationsDB[(Locations)]\n        AddressesDB[(Addresses)]\n    end\n\n    subgraph External Services\n        Proj4[Proj4 Library]\n        EPSG3347[EPSG:3347 Definition]\n    end\n\n    Admin --> LocationsPage\n    LocationsPage --> DatasetsAPI\n    LocationsPage --> ImportAPI\n\n    DatasetsAPI --> Scanner\n    Scanner --> DataDir\n\n    ImportAPI --> Reader\n    Reader --> AddressFiles\n    Reader --> LocationFiles\n\n    Reader --> Joiner\n    Joiner --> Converter\n    Converter --> Proj4\n    Proj4 --> EPSG3347\n\n    Converter --> Filter\n    Filter --> Importer\n    Importer --> LocationsDB\n    Importer --> AddressesDB

Data Flow:

  1. Dataset Discovery:
  2. Scan /data directory for NAR CSV files
  3. Group by province code (10-62)
  4. Identify multi-part Address files
  5. Return available datasets

  6. Import Initiation:

  7. Admin selects province + filters
  8. API creates import job
  9. Begins streaming CSV files

  10. File Processing:

  11. Read Address files (all parts sequentially)
  12. Read Location file (parallel)
  13. Join on LOC_GUID (in-memory map)

  14. Coordinate Conversion:

  15. Extract BG_X/BG_Y from Location file
  16. Convert EPSG:3347 \u2192 WGS84 using Proj4
  17. Fallback to BG_LATITUDE/BG_LONGITUDE if conversion fails

  18. Filtering:

  19. City filter (exact match on MUNICIPALITY)
  20. Postal code filter (prefix match)
  21. Cut filter (point-in-polygon)
  22. Residential filter (BU_USE = 1)

  23. Database Import:

  24. UPSERT Locations by locGuid (prevent duplicates)
  25. INSERT Addresses with foreign key
  26. Batch commits (500 records)
  27. Track progress and errors
"},{"location":"v2/features/map/nar-import/#nar-file-format","title":"NAR File Format","text":""},{"location":"v2/features/map/nar-import/#file-structure","title":"File Structure","text":"

Directory Layout:

/data/\n\u251c\u2500\u2500 Address_10.csv                  # Newfoundland\n\u251c\u2500\u2500 Address_11.csv                  # PEI\n\u251c\u2500\u2500 Address_12.csv                  # Nova Scotia\n\u251c\u2500\u2500 Address_13.csv                  # New Brunswick\n\u251c\u2500\u2500 Address_24_part_1.csv           # Quebec (multi-part)\n\u251c\u2500\u2500 Address_24_part_2.csv\n\u251c\u2500\u2500 Address_24_part_3.csv\n\u251c\u2500\u2500 Address_24_part_4.csv\n\u251c\u2500\u2500 Address_24_part_5.csv\n\u251c\u2500\u2500 Address_24_part_6.csv\n\u251c\u2500\u2500 Address_35_part_1.csv           # Ontario (multi-part)\n\u251c\u2500\u2500 Address_35_part_2.csv\n\u251c\u2500\u2500 ...\n\u251c\u2500\u2500 Location_10.csv\n\u251c\u2500\u2500 Location_11.csv\n\u251c\u2500\u2500 Location_12.csv\n\u251c\u2500\u2500 Location_13.csv\n\u251c\u2500\u2500 Location_24.csv\n\u251c\u2500\u2500 Location_35.csv\n\u2514\u2500\u2500 ...\n

"},{"location":"v2/features/map/nar-import/#address-file-schema","title":"Address File Schema","text":"

File: Address_XX_part_Y.csv

ADDR_GUID,LOC_GUID,CIVIC_NO,OFFICIAL_STREET_NAME,POSTAL_CODE,MUNICIPALITY,PROVINCE_CODE\n{uuid},{uuid},123,MAIN ST,M5H2N2,TORONTO,35\n{uuid},{uuid},125,MAIN ST,M5H2N2,TORONTO,35\n{uuid},{uuid},127,MAIN ST,M5H2N2,TORONTO,35\n

Key Fields:

Field Type Description Example ADDR_GUID UUID Unique address identifier {12345678-...} LOC_GUID UUID Location identifier (FK) {87654321-...} CIVIC_NO String Street number 123, 123A, 123-125 OFFICIAL_STREET_NAME String Street name (uppercase) MAIN ST, YONGE ST POSTAL_CODE String Canadian postal code (no space) M5H2N2, K1A0B1 MUNICIPALITY String City/town name TORONTO, OTTAWA PROVINCE_CODE Integer Province code (10-62) 35 (Ontario)

Record Count: - Small provinces: 10k-50k addresses - Medium provinces: 50k-200k addresses - Large provinces: 200k-1M+ addresses (multi-part files)

"},{"location":"v2/features/map/nar-import/#location-file-schema","title":"Location File Schema","text":"

File: Location_XX.csv

LOC_GUID,BG_LATITUDE,BG_LONGITUDE,BG_X,BG_Y,FED_NUM,BU_USE,MUNICIPALITY\n{uuid},43.6532,-79.3832,1234567.89,234567.89,35001,1,TORONTO\n{uuid},43.6540,-79.3825,1234600.00,234600.00,35001,1,TORONTO\n

Key Fields:

Field Type Description Example LOC_GUID UUID Unique location identifier {87654321-...} BG_LATITUDE Float Latitude (WGS84) 43.6532 BG_LONGITUDE Float Longitude (WGS84) -79.3832 BG_X Float X coord (EPSG:3347 Lambert) 1234567.89 BG_Y Float Y coord (EPSG:3347 Lambert) 234567.89 FED_NUM String Federal electoral district 35001, 24050 BU_USE Integer Building use code 1 = Residential MUNICIPALITY String City/town name TORONTO

Coordinate Systems:

  • BG_LATITUDE/BG_LONGITUDE: WGS84 decimal degrees (EPSG:4326)
  • BG_X/BG_Y: Statistics Canada Lambert Conformal Conic (EPSG:3347)
  • 2025 NAR Change: Primary coordinates shifted from lat/lng to BG_X/BG_Y

Building Use Codes:

Code Description 1 Residential 2 Commercial 3 Industrial 4 Institutional 5 Parks/Recreation 9 Other"},{"location":"v2/features/map/nar-import/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/nar-import/#location-model-extensions","title":"Location Model Extensions","text":"
model Location {\n  id          Int      @id @default(autoincrement())\n  address     String\n  latitude    Float?\n  longitude   Float?\n  postalCode  String?\n  province    String?\n\n  // NAR-specific fields\n  locGuid           String?  @unique  // NAR LOC_GUID (UUID)\n  federalDistrict   String?           // NAR FED_NUM\n  buildingUse       Int?              // NAR BU_USE code\n  municipality      String?           // NAR MUNICIPALITY\n\n  // Geocoding metadata (populated during import)\n  geocodeConfidence Int?     @default(100)  // NAR = high confidence\n  geocodeProvider   String?  @default(\"NAR\")\n  geocodedAt        DateTime?\n\n  addresses   Address[]\n\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n\n  @@index([locGuid])\n  @@index([federalDistrict])\n  @@index([buildingUse])\n  @@index([postalCode])\n}\n
"},{"location":"v2/features/map/nar-import/#address-model-extensions","title":"Address Model Extensions","text":"
model Address {\n  id         Int      @id @default(autoincrement())\n  locationId Int\n  location   Location @relation(fields: [locationId], references: [id], onDelete: Cascade)\n\n  // NAR-specific fields\n  addrGuid    String?  @unique  // NAR ADDR_GUID (UUID)\n  unitNumber  String?           // NAR CIVIC_NO (if multi-unit)\n\n  // Voter data (future)\n  firstName    String?\n  lastName     String?\n  supportLevel Int?\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([locationId])\n  @@index([addrGuid])\n}\n

UPSERT Strategy:

// Prevent duplicates on re-import\nconst location = await prisma.location.upsert({\n  where: { locGuid: narRecord.LOC_GUID },\n  update: {\n    address: narRecord.addressString,\n    latitude: coords.latitude,\n    longitude: coords.longitude,\n    postalCode: narRecord.POSTAL_CODE,\n    province: provinceMap[narRecord.PROVINCE_CODE],\n    federalDistrict: narRecord.FED_NUM,\n    buildingUse: narRecord.BU_USE,\n    municipality: narRecord.MUNICIPALITY,\n    geocodeProvider: 'NAR',\n    geocodedAt: new Date()\n  },\n  create: {\n    locGuid: narRecord.LOC_GUID,\n    address: narRecord.addressString,\n    latitude: coords.latitude,\n    longitude: coords.longitude,\n    postalCode: narRecord.POSTAL_CODE,\n    province: provinceMap[narRecord.PROVINCE_CODE],\n    federalDistrict: narRecord.FED_NUM,\n    buildingUse: narRecord.BU_USE,\n    municipality: narRecord.MUNICIPALITY,\n    geocodeConfidence: 100,\n    geocodeProvider: 'NAR',\n    geocodedAt: new Date()\n  }\n});\n
"},{"location":"v2/features/map/nar-import/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/map/nar-import/#get-apilocationsnardatasets","title":"GET /api/locations/nar/datasets","text":"

Scan NAR data directory and return available province datasets.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Response:

{\n  \"datasets\": [\n    {\n      \"provinceCode\": \"10\",\n      \"provinceName\": \"Newfoundland and Labrador\",\n      \"addressFiles\": [\"Address_10.csv\"],\n      \"locationFile\": \"Location_10.csv\",\n      \"addressFileCount\": 1,\n      \"estimatedRecords\": 15000,\n      \"lastModified\": \"2025-01-15T00:00:00Z\"\n    },\n    {\n      \"provinceCode\": \"24\",\n      \"provinceName\": \"Quebec\",\n      \"addressFiles\": [\n        \"Address_24_part_1.csv\",\n        \"Address_24_part_2.csv\",\n        \"Address_24_part_3.csv\",\n        \"Address_24_part_4.csv\",\n        \"Address_24_part_5.csv\",\n        \"Address_24_part_6.csv\"\n      ],\n      \"locationFile\": \"Location_24.csv\",\n      \"addressFileCount\": 6,\n      \"estimatedRecords\": 850000,\n      \"lastModified\": \"2025-01-20T00:00:00Z\"\n    },\n    {\n      \"provinceCode\": \"35\",\n      \"provinceName\": \"Ontario\",\n      \"addressFiles\": [\n        \"Address_35_part_1.csv\",\n        \"Address_35_part_2.csv\",\n        \"Address_35_part_3.csv\"\n      ],\n      \"locationFile\": \"Location_35.csv\",\n      \"addressFileCount\": 3,\n      \"estimatedRecords\": 1200000,\n      \"lastModified\": \"2025-01-22T00:00:00Z\"\n    }\n  ],\n  \"dataDir\": \"/data\",\n  \"totalDatasets\": 13\n}\n

Implementation:

// nar-import.service.ts\n\nasync scanDatasets(): Promise<NARDataset[]> {\n  const files = await fs.readdir(NAR_DATA_DIR);\n\n  // Group files by province code\n  const provinceGroups: Record<string, { address: string[], location: string }> = {};\n\n  files.forEach(file => {\n    const addressMatch = file.match(/^Address_(\\d+)(?:_part_\\d+)?\\.csv$/);\n    const locationMatch = file.match(/^Location_(\\d+)\\.csv$/);\n\n    if (addressMatch) {\n      const code = addressMatch[1];\n      if (!provinceGroups[code]) provinceGroups[code] = { address: [], location: '' };\n      provinceGroups[code].address.push(file);\n    } else if (locationMatch) {\n      const code = locationMatch[1];\n      if (!provinceGroups[code]) provinceGroups[code] = { address: [], location: '' };\n      provinceGroups[code].location = file;\n    }\n  });\n\n  // Build dataset objects\n  const datasets: NARDataset[] = [];\n\n  for (const [code, group] of Object.entries(provinceGroups)) {\n    if (group.address.length === 0 || !group.location) continue;\n\n    const stats = await fs.stat(path.join(NAR_DATA_DIR, group.location));\n\n    datasets.push({\n      provinceCode: code,\n      provinceName: PROVINCE_NAMES[code],\n      addressFiles: group.address.sort(),\n      locationFile: group.location,\n      addressFileCount: group.address.length,\n      estimatedRecords: await this.estimateRecordCount(group.address),\n      lastModified: stats.mtime.toISOString()\n    });\n  }\n\n  return datasets.sort((a, b) => a.provinceCode.localeCompare(b.provinceCode));\n}\n
"},{"location":"v2/features/map/nar-import/#post-apilocationsnarimport","title":"POST /api/locations/nar/import","text":"

Start NAR import job with filters.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Request Body:

{\n  \"provinceCode\": \"35\",\n  \"city\": \"TORONTO\",\n  \"postalCodePrefix\": \"M5\",\n  \"cutId\": 42,\n  \"residentialOnly\": true\n}\n

Parameters:

Parameter Type Required Description provinceCode string Yes Province code (10-62) city string No Filter by MUNICIPALITY (exact match, uppercase) postalCodePrefix string No Filter by postal code prefix (e.g., \"M5\", \"K1A\") cutId number No Filter by cut boundary (point-in-polygon) residentialOnly boolean No Only import BU_USE = 1 (default: false)

Response:

{\n  \"jobId\": \"nar-import-35-20250213-103000\",\n  \"status\": \"processing\",\n  \"provinceCode\": \"35\",\n  \"provinceName\": \"Ontario\",\n  \"filters\": {\n    \"city\": \"TORONTO\",\n    \"postalCodePrefix\": \"M5\",\n    \"cutId\": 42,\n    \"residentialOnly\": true\n  },\n  \"startedAt\": \"2025-02-13T10:30:00Z\",\n  \"estimatedCompletion\": \"2025-02-13T10:45:00Z\"\n}\n

"},{"location":"v2/features/map/nar-import/#get-apilocationsnarimportjobid","title":"GET /api/locations/nar/import/:jobId","text":"

Check import job progress.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Response (In Progress):

{\n  \"jobId\": \"nar-import-35-20250213-103000\",\n  \"status\": \"processing\",\n  \"progress\": {\n    \"total\": 1200000,\n    \"processed\": 600000,\n    \"imported\": 580000,\n    \"skipped\": 15000,\n    \"errors\": 5000,\n    \"percent\": 50.0\n  },\n  \"currentFile\": \"Address_35_part_2.csv\",\n  \"startedAt\": \"2025-02-13T10:30:00Z\",\n  \"estimatedCompletion\": \"2025-02-13T10:45:00Z\"\n}\n

Response (Complete):

{\n  \"jobId\": \"nar-import-35-20250213-103000\",\n  \"status\": \"completed\",\n  \"result\": {\n    \"total\": 1200000,\n    \"processed\": 1200000,\n    \"imported\": 1150000,\n    \"skipped\": 45000,\n    \"errors\": 5000,\n    \"percent\": 100.0\n  },\n  \"statistics\": {\n    \"locationsCreated\": 800000,\n    \"locationsUpdated\": 350000,\n    \"addressesCreated\": 1150000,\n    \"avgConfidence\": 100,\n    \"processingTime\": \"14m 32s\"\n  },\n  \"startedAt\": \"2025-02-13T10:30:00Z\",\n  \"completedAt\": \"2025-02-13T10:44:32Z\"\n}\n

Status Values: - queued: Job created, waiting to start - processing: Import in progress - completed: Import finished successfully - failed: Import failed with errors - cancelled: Import cancelled by user

"},{"location":"v2/features/map/nar-import/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/nar-import/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description NAR_DATA_DIR string /data Directory containing NAR CSV files NAR_BATCH_SIZE number 500 Records per database transaction NAR_IMPORT_TIMEOUT number 3600000 Import timeout in ms (1 hour)"},{"location":"v2/features/map/nar-import/#province-codes","title":"Province Codes","text":"

Complete mapping of NAR province codes:

// nar-import.service.ts\n\nconst PROVINCE_NAMES: Record<string, string> = {\n  '10': 'Newfoundland and Labrador',\n  '11': 'Prince Edward Island',\n  '12': 'Nova Scotia',\n  '13': 'New Brunswick',\n  '24': 'Quebec',\n  '35': 'Ontario',\n  '46': 'Manitoba',\n  '47': 'Saskatchewan',\n  '48': 'Alberta',\n  '59': 'British Columbia',\n  '60': 'Yukon',\n  '61': 'Northwest Territories',\n  '62': 'Nunavut'\n};\n\nconst PROVINCE_ABBREVIATIONS: Record<string, string> = {\n  '10': 'NL',\n  '11': 'PE',\n  '12': 'NS',\n  '13': 'NB',\n  '24': 'QC',\n  '35': 'ON',\n  '46': 'MB',\n  '47': 'SK',\n  '48': 'AB',\n  '59': 'BC',\n  '60': 'YT',\n  '61': 'NT',\n  '62': 'NU'\n};\n
"},{"location":"v2/features/map/nar-import/#coordinate-projection","title":"Coordinate Projection","text":"

EPSG:3347 Definition (Statistics Canada Lambert Conformal Conic):

import proj4 from 'proj4';\n\n// Define EPSG:3347 projection\nproj4.defs('EPSG:3347', '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=63.390675 +lon_0=-91.86666666666666 +x_0=6200000 +y_0=3000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs');\n\n// Convert function\nconst convertCoordinates = (bgX: number, bgY: number): [number, number] => {\n  // Input: [X, Y] in EPSG:3347 (meters)\n  // Output: [longitude, latitude] in WGS84 (degrees)\n  return proj4('EPSG:3347', 'WGS84', [bgX, bgY]);\n};\n

Projection Parameters:

  • Type: Lambert Conformal Conic
  • Standard Parallels: 49\u00b0N, 77\u00b0N
  • Central Meridian: -91.866667\u00b0
  • Origin: 63.390675\u00b0N, -91.866667\u00b0W
  • False Easting: 6,200,000 m
  • False Northing: 3,000,000 m
  • Ellipsoid: GRS80
  • Units: Meters

Example Conversion:

// Toronto City Hall coordinates\nconst bgX = 609091.8;  // EPSG:3347 X\nconst bgY = 4834610.7; // EPSG:3347 Y\n\nconst [lng, lat] = proj4('EPSG:3347', 'WGS84', [bgX, bgY]);\n// Result: lng = -79.3832, lat = 43.6532\n
"},{"location":"v2/features/map/nar-import/#import-workflow","title":"Import Workflow","text":""},{"location":"v2/features/map/nar-import/#prepare-nar-files","title":"Prepare NAR Files","text":"

Step 1: Download NAR Data

  1. Visit Elections Canada NAR portal: https://www.elections.ca/NAR
  2. Select \"2025 National Address Register\"
  3. Download province-specific CSV files
  4. Extract ZIP archives

Step 2: Upload Files to Server

# Create data directory if not exists\nmkdir -p /path/to/data\n\n# Upload files via SCP\nscp Address_35_*.csv user@server:/path/to/data/\nscp Location_35.csv user@server:/path/to/data/\n\n# Or mount volume in Docker\n# docker-compose.yml:\nvolumes:\n  - ./data:/data:ro\n

Step 3: Verify File Integrity

# Check file count\nls -l /path/to/data/Address_35_*.csv | wc -l\n\n# Check Location file exists\nls -l /path/to/data/Location_35.csv\n\n# Sample first few rows\nhead -5 /path/to/data/Address_35_part_1.csv\nhead -5 /path/to/data/Location_35.csv\n
"},{"location":"v2/features/map/nar-import/#run-import-via-admin-ui","title":"Run Import via Admin UI","text":"

Step 1: Navigate to NAR Import Tab

  1. Log in as SUPER_ADMIN or MAP_ADMIN
  2. Click Map \u2192 Locations in sidebar
  3. Click NAR Import tab
  4. Available datasets load automatically

Step 2: Select Province

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Available NAR Datasets                  \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 Province         \u2502 Files \u2502 Records      \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 Ontario (35)     \u2502   3   \u2502 1,200,000    \u2502\n\u2502 Quebec (24)      \u2502   6   \u2502   850,000    \u2502\n\u2502 Alberta (48)     \u2502   2   \u2502   450,000    \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n[Select Province: Ontario \u25bc]\n

Step 3: Configure Filters (Optional)

Filters (Optional):\n\nCity:                [TORONTO          ]\n  Filter by exact municipality name (uppercase)\n\nPostal Code Prefix:  [M5               ]\n  Filter by postal code prefix (2-3 chars)\n\nCut Boundary:        [Downtown Core \u25bc  ]\n  Only import locations within cut polygon\n\n\u2611 Residential Only\n  Only import buildings with BU_USE = 1\n

Step 4: Review Import Summary

Import Summary:\n\nProvince:      Ontario (35)\nFiles:         Address_35_part_1.csv\n               Address_35_part_2.csv\n               Address_35_part_3.csv\n               Location_35.csv\n\nFilters:\n  City:               TORONTO\n  Postal Code:        M5\n  Cut:                Downtown Core\n  Residential Only:   Yes\n\nEstimated Records:  ~50,000 (after filters)\nEstimated Time:     ~3 minutes\n\n[Cancel] [Start Import]\n

Step 5: Monitor Progress

Import in Progress...\n\nCurrent File: Address_35_part_2.csv\nProgress: 600,000 / 1,200,000 (50%)\n\n[\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591] 50%\n\nStatistics:\n  Processed:  600,000\n  Imported:   580,000\n  Skipped:    15,000\n  Errors:     5,000\n\n[Cancel Import]\n

Step 6: Review Results

Import Complete!\n\nFinal Statistics:\n  Total Processed:     1,200,000\n  Successfully Imported: 1,150,000\n  Skipped (Filters):      45,000\n  Errors:                  5,000\n\nDetails:\n  Locations Created:    800,000\n  Locations Updated:    350,000\n  Addresses Created:  1,150,000\n\n  Processing Time:      14m 32s\n  Avg Records/Second:   1,375\n\n[View Import Log] [Import Another Province] [Close]\n
"},{"location":"v2/features/map/nar-import/#import-via-api","title":"Import via API","text":"

Step 1: Get Available Datasets

curl -X GET http://localhost:4000/api/locations/nar/datasets \\\n  -H \"Authorization: Bearer $TOKEN\"\n

Step 2: Start Import

curl -X POST http://localhost:4000/api/locations/nar/import \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"provinceCode\": \"35\",\n    \"city\": \"TORONTO\",\n    \"postalCodePrefix\": \"M5\",\n    \"residentialOnly\": true\n  }'\n

Step 3: Poll Job Status

JOB_ID=\"nar-import-35-20250213-103000\"\n\nwhile true; do\n  STATUS=$(curl -s -X GET \\\n    http://localhost:4000/api/locations/nar/import/$JOB_ID \\\n    -H \"Authorization: Bearer $TOKEN\" \\\n    | jq -r '.status')\n\n  if [ \"$STATUS\" = \"completed\" ] || [ \"$STATUS\" = \"failed\" ]; then\n    break\n  fi\n\n  sleep 5\ndone\n\n# Get final result\ncurl -X GET http://localhost:4000/api/locations/nar/import/$JOB_ID \\\n  -H \"Authorization: Bearer $TOKEN\" | jq\n
"},{"location":"v2/features/map/nar-import/#coordinate-conversion","title":"Coordinate Conversion","text":""},{"location":"v2/features/map/nar-import/#proj4-integration","title":"Proj4 Integration","text":"

Installation:

npm install proj4\n# TypeScript types included in package\n

Service Implementation:

// nar-import.service.ts\n\nimport proj4 from 'proj4';\n\n// Define EPSG:3347 (Statistics Canada Lambert)\nproj4.defs('EPSG:3347',\n  '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=63.390675 ' +\n  '+lon_0=-91.86666666666666 +x_0=6200000 +y_0=3000000 ' +\n  '+ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs'\n);\n\ninterface Coordinates {\n  latitude: number;\n  longitude: number;\n}\n\nclass NARImportService {\n  /**\n   * Convert NAR BG_X/BG_Y (EPSG:3347) to WGS84 lat/lng\n   */\n  convertCoordinates(bgX: number, bgY: number): Coordinates | null {\n    try {\n      // Validate inputs\n      if (!bgX || !bgY || bgX < 0 || bgY < 0) {\n        logger.warn('Invalid BG_X/BG_Y coordinates:', { bgX, bgY });\n        return null;\n      }\n\n      // Convert: EPSG:3347 \u2192 WGS84\n      const [longitude, latitude] = proj4('EPSG:3347', 'WGS84', [bgX, bgY]);\n\n      // Validate output (Canada bounds)\n      if (\n        latitude < 41.0 || latitude > 84.0 ||   // Canada latitude range\n        longitude < -141.0 || longitude > -52.0 // Canada longitude range\n      ) {\n        logger.warn('Converted coordinates outside Canada:', { latitude, longitude });\n        return null;\n      }\n\n      return { latitude, longitude };\n    } catch (error) {\n      logger.error('Coordinate conversion failed:', error);\n      return null;\n    }\n  }\n\n  /**\n   * Get coordinates from NAR record (try BG_X/BG_Y, fallback to lat/lng)\n   */\n  getCoordinates(narLocation: NARLocationRecord): Coordinates | null {\n    // Primary: Convert BG_X/BG_Y\n    if (narLocation.BG_X && narLocation.BG_Y) {\n      const coords = this.convertCoordinates(narLocation.BG_X, narLocation.BG_Y);\n      if (coords) return coords;\n    }\n\n    // Fallback: Use BG_LATITUDE/BG_LONGITUDE directly\n    if (narLocation.BG_LATITUDE && narLocation.BG_LONGITUDE) {\n      return {\n        latitude: narLocation.BG_LATITUDE,\n        longitude: narLocation.BG_LONGITUDE\n      };\n    }\n\n    return null;\n  }\n}\n
"},{"location":"v2/features/map/nar-import/#conversion-examples","title":"Conversion Examples","text":"

Example 1: Toronto City Hall

const bgX = 609091.8;\nconst bgY = 4834610.7;\n\nconst coords = convertCoordinates(bgX, bgY);\n// Result: { latitude: 43.6532, longitude: -79.3832 }\n

Example 2: Parliament Hill, Ottawa

const bgX = 447384.4;\nconst bgY = 5030660.5;\n\nconst coords = convertCoordinates(bgX, bgY);\n// Result: { latitude: 45.4236, longitude: -75.7009 }\n

Example 3: Invalid Coordinates

const bgX = -1000;  // Negative (invalid)\nconst bgY = 0;      // Zero (invalid)\n\nconst coords = convertCoordinates(bgX, bgY);\n// Result: null\n
"},{"location":"v2/features/map/nar-import/#validation","title":"Validation","text":"

Canada Bounds Check:

const isWithinCanada = (lat: number, lng: number): boolean => {\n  return (\n    lat >= 41.0 && lat <= 84.0 &&     // Latitude: Pelee Island to Alert\n    lng >= -141.0 && lng <= -52.0     // Longitude: Yukon to Newfoundland\n  );\n};\n

Precision Check:

// NAR coordinates should have 2-6 decimal places\nconst hasValidPrecision = (value: number): boolean => {\n  const str = value.toString();\n  const decimals = str.split('.')[1]?.length || 0;\n  return decimals >= 2 && decimals <= 6;\n};\n
"},{"location":"v2/features/map/nar-import/#multi-part-file-handling","title":"Multi-Part File Handling","text":""},{"location":"v2/features/map/nar-import/#large-province-processing","title":"Large Province Processing","text":"

Quebec (Province Code 24): - 6 Address files: Address_24_part_1.csv through Address_24_part_6.csv - 1 Location file: Location_24.csv - Total records: ~850,000

Ontario (Province Code 35): - 3 Address files: Address_35_part_1.csv through Address_35_part_3.csv - 1 Location file: Location_35.csv - Total records: ~1,200,000

"},{"location":"v2/features/map/nar-import/#sequential-file-reading","title":"Sequential File Reading","text":"
// nar-import.service.ts\n\nasync processAddressFiles(provinceCode: string): Promise<Map<string, AddressRecord[]>> {\n  const addressMap = new Map<string, AddressRecord[]>();\n\n  // Find all Address files for province\n  const files = await fs.readdir(NAR_DATA_DIR);\n  const addressFiles = files\n    .filter(f => f.match(new RegExp(`^Address_${provinceCode}(?:_part_\\\\d+)?\\\\.csv$`)))\n    .sort(); // Ensure part_1, part_2, ... order\n\n  logger.info(`Processing ${addressFiles.length} address files for province ${provinceCode}`);\n\n  // Process each file sequentially\n  for (const file of addressFiles) {\n    logger.info(`Reading ${file}...`);\n\n    const filePath = path.join(NAR_DATA_DIR, file);\n    const stream = fs.createReadStream(filePath);\n    const parser = stream.pipe(csvParser());\n\n    let rowCount = 0;\n\n    for await (const row of parser) {\n      const locGuid = row.LOC_GUID;\n\n      if (!addressMap.has(locGuid)) {\n        addressMap.set(locGuid, []);\n      }\n\n      addressMap.get(locGuid)!.push({\n        addrGuid: row.ADDR_GUID,\n        civicNo: row.CIVIC_NO,\n        streetName: row.OFFICIAL_STREET_NAME,\n        postalCode: row.POSTAL_CODE,\n        municipality: row.MUNICIPALITY\n      });\n\n      rowCount++;\n\n      if (rowCount % 10000 === 0) {\n        logger.debug(`Processed ${rowCount} addresses from ${file}`);\n      }\n    }\n\n    logger.info(`Completed ${file}: ${rowCount} addresses`);\n  }\n\n  logger.info(`Total unique locations: ${addressMap.size}`);\n  return addressMap;\n}\n
"},{"location":"v2/features/map/nar-import/#memory-management","title":"Memory Management","text":"

Streaming Strategy:

// Process files in chunks to avoid memory overflow\nasync processInChunks(\n  addressMap: Map<string, AddressRecord[]>,\n  locationFile: string,\n  batchSize: number = 500\n): Promise<ImportResult> {\n  const locationPath = path.join(NAR_DATA_DIR, locationFile);\n  const stream = fs.createReadStream(locationPath);\n  const parser = stream.pipe(csvParser());\n\n  let batch: LocationImport[] = [];\n  let stats = { imported: 0, skipped: 0, errors: 0 };\n\n  for await (const row of parser) {\n    const locGuid = row.LOC_GUID;\n    const addresses = addressMap.get(locGuid);\n\n    if (!addresses || addresses.length === 0) {\n      stats.skipped++;\n      continue;\n    }\n\n    // Apply filters\n    if (!this.passesFilters(row, addresses)) {\n      stats.skipped++;\n      continue;\n    }\n\n    // Convert coordinates\n    const coords = this.getCoordinates(row);\n    if (!coords) {\n      stats.errors++;\n      continue;\n    }\n\n    batch.push({ location: row, addresses, coords });\n\n    // Import batch when full\n    if (batch.length >= batchSize) {\n      await this.importBatch(batch);\n      stats.imported += batch.length;\n      batch = [];\n    }\n  }\n\n  // Import remaining\n  if (batch.length > 0) {\n    await this.importBatch(batch);\n    stats.imported += batch.length;\n  }\n\n  return stats;\n}\n

Batch Transaction:

async importBatch(batch: LocationImport[]): Promise<void> {\n  await prisma.$transaction(async (tx) => {\n    for (const item of batch) {\n      // Upsert location\n      const location = await tx.location.upsert({\n        where: { locGuid: item.location.LOC_GUID },\n        update: {\n          address: this.formatAddress(item.addresses[0]),\n          latitude: item.coords.latitude,\n          longitude: item.coords.longitude,\n          postalCode: item.addresses[0].postalCode,\n          federalDistrict: item.location.FED_NUM,\n          buildingUse: parseInt(item.location.BU_USE),\n          municipality: item.location.MUNICIPALITY,\n          geocodedAt: new Date()\n        },\n        create: {\n          locGuid: item.location.LOC_GUID,\n          address: this.formatAddress(item.addresses[0]),\n          latitude: item.coords.latitude,\n          longitude: item.coords.longitude,\n          postalCode: item.addresses[0].postalCode,\n          federalDistrict: item.location.FED_NUM,\n          buildingUse: parseInt(item.location.BU_USE),\n          municipality: item.location.MUNICIPALITY,\n          geocodeConfidence: 100,\n          geocodeProvider: 'NAR',\n          geocodedAt: new Date()\n        }\n      });\n\n      // Insert addresses\n      for (const addr of item.addresses) {\n        await tx.address.upsert({\n          where: { addrGuid: addr.addrGuid },\n          update: { locationId: location.id },\n          create: {\n            addrGuid: addr.addrGuid,\n            locationId: location.id,\n            unitNumber: addr.civicNo\n          }\n        });\n      }\n    }\n  });\n}\n
"},{"location":"v2/features/map/nar-import/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/nar-import/#locationspage-nar-import-tab","title":"LocationsPage - NAR Import Tab","text":"
// LocationsPage.tsx\n\nimport React, { useEffect, useState } from 'react';\nimport { Tabs, Table, Button, Select, Input, Checkbox, Card, Progress, message } from 'antd';\nimport { UploadOutlined } from '@ant-design/icons';\nimport { api } from '@/lib/api';\n\nconst NARImportTab: React.FC = () => {\n  const [datasets, setDatasets] = useState<NARDataset[]>([]);\n  const [selectedProvince, setSelectedProvince] = useState<string | null>(null);\n  const [filters, setFilters] = useState({\n    city: '',\n    postalCodePrefix: '',\n    cutId: null as number | null,\n    residentialOnly: true\n  });\n  const [importing, setImporting] = useState(false);\n  const [progress, setProgress] = useState<ImportProgress | null>(null);\n  const [jobId, setJobId] = useState<string | null>(null);\n\n  useEffect(() => {\n    fetchDatasets();\n  }, []);\n\n  useEffect(() => {\n    if (jobId && importing) {\n      const interval = setInterval(pollProgress, 2000);\n      return () => clearInterval(interval);\n    }\n  }, [jobId, importing]);\n\n  const fetchDatasets = async () => {\n    try {\n      const { data } = await api.get<{ datasets: NARDataset[] }>('/locations/nar/datasets');\n      setDatasets(data.datasets);\n    } catch (error) {\n      message.error('Failed to load NAR datasets');\n    }\n  };\n\n  const pollProgress = async () => {\n    if (!jobId) return;\n\n    try {\n      const { data } = await api.get(`/locations/nar/import/${jobId}`);\n\n      if (data.status === 'completed') {\n        setImporting(false);\n        setProgress(null);\n        message.success(`Import complete! Imported ${data.result.imported} locations.`);\n      } else if (data.status === 'failed') {\n        setImporting(false);\n        setProgress(null);\n        message.error('Import failed. Check logs for details.');\n      } else {\n        setProgress(data.progress);\n      }\n    } catch (error) {\n      message.error('Failed to fetch import progress');\n    }\n  };\n\n  const startImport = async () => {\n    if (!selectedProvince) {\n      message.warning('Please select a province');\n      return;\n    }\n\n    try {\n      const { data } = await api.post('/locations/nar/import', {\n        provinceCode: selectedProvince,\n        ...filters\n      });\n\n      setJobId(data.jobId);\n      setImporting(true);\n      message.info('Import started...');\n    } catch (error) {\n      message.error('Failed to start import');\n    }\n  };\n\n  const datasetColumns = [\n    { title: 'Province', dataIndex: 'provinceName', key: 'name' },\n    { title: 'Files', dataIndex: 'addressFileCount', key: 'files' },\n    { title: 'Estimated Records', dataIndex: 'estimatedRecords', key: 'records',\n      render: (val: number) => val.toLocaleString() },\n    { title: 'Last Modified', dataIndex: 'lastModified', key: 'modified',\n      render: (val: string) => new Date(val).toLocaleDateString() }\n  ];\n\n  return (\n    <div>\n      <Card title=\"Available NAR Datasets\" style={{ marginBottom: 24 }}>\n        <Table\n          dataSource={datasets}\n          columns={datasetColumns}\n          rowKey=\"provinceCode\"\n          pagination={false}\n          onRow={(record) => ({\n            onClick: () => setSelectedProvince(record.provinceCode),\n            style: {\n              cursor: 'pointer',\n              backgroundColor: selectedProvince === record.provinceCode ? '#e6f7ff' : undefined\n            }\n          })}\n        />\n      </Card>\n\n      {selectedProvince && (\n        <Card title=\"Import Configuration\">\n          <div style={{ marginBottom: 16 }}>\n            <label>Province: </label>\n            <strong>{datasets.find(d => d.provinceCode === selectedProvince)?.provinceName}</strong>\n          </div>\n\n          <div style={{ marginBottom: 16 }}>\n            <label>City (Optional): </label>\n            <Input\n              style={{ width: 300 }}\n              placeholder=\"TORONTO\"\n              value={filters.city}\n              onChange={e => setFilters({ ...filters, city: e.target.value.toUpperCase() })}\n            />\n          </div>\n\n          <div style={{ marginBottom: 16 }}>\n            <label>Postal Code Prefix (Optional): </label>\n            <Input\n              style={{ width: 200 }}\n              placeholder=\"M5\"\n              value={filters.postalCodePrefix}\n              onChange={e => setFilters({ ...filters, postalCodePrefix: e.target.value.toUpperCase() })}\n            />\n          </div>\n\n          <div style={{ marginBottom: 16 }}>\n            <Checkbox\n              checked={filters.residentialOnly}\n              onChange={e => setFilters({ ...filters, residentialOnly: e.target.checked })}\n            >\n              Residential Only\n            </Checkbox>\n          </div>\n\n          <Button\n            type=\"primary\"\n            icon={<UploadOutlined />}\n            onClick={startImport}\n            loading={importing}\n            disabled={importing}\n          >\n            Start Import\n          </Button>\n        </Card>\n      )}\n\n      {importing && progress && (\n        <Card title=\"Import Progress\" style={{ marginTop: 24 }}>\n          <Progress percent={progress.percent} status=\"active\" />\n          <div style={{ marginTop: 16 }}>\n            <p>Processed: {progress.processed.toLocaleString()} / {progress.total.toLocaleString()}</p>\n            <p>Imported: {progress.imported.toLocaleString()}</p>\n            <p>Skipped: {progress.skipped.toLocaleString()}</p>\n            <p>Errors: {progress.errors.toLocaleString()}</p>\n          </div>\n        </Card>\n      )}\n    </div>\n  );\n};\n
"},{"location":"v2/features/map/nar-import/#nar-import-service-full-implementation","title":"NAR Import Service - Full Implementation","text":"
// nar-import.service.ts\n\nimport fs from 'fs/promises';\nimport path from 'path';\nimport csvParser from 'csv-parser';\nimport proj4 from 'proj4';\nimport { prisma } from '@/config/database';\nimport { logger } from '@/utils/logger';\n\n// Define EPSG:3347\nproj4.defs('EPSG:3347',\n  '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=63.390675 ' +\n  '+lon_0=-91.86666666666666 +x_0=6200000 +y_0=3000000 ' +\n  '+ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs'\n);\n\nconst NAR_DATA_DIR = process.env.NAR_DATA_DIR || '/data';\nconst BATCH_SIZE = parseInt(process.env.NAR_BATCH_SIZE || '500');\n\ninterface NARAddressRecord {\n  ADDR_GUID: string;\n  LOC_GUID: string;\n  CIVIC_NO: string;\n  OFFICIAL_STREET_NAME: string;\n  POSTAL_CODE: string;\n  MUNICIPALITY: string;\n}\n\ninterface NARLocationRecord {\n  LOC_GUID: string;\n  BG_LATITUDE?: number;\n  BG_LONGITUDE?: number;\n  BG_X?: number;\n  BG_Y?: number;\n  FED_NUM: string;\n  BU_USE: string;\n  MUNICIPALITY: string;\n}\n\nexport class NARImportService {\n  async importProvince(\n    provinceCode: string,\n    filters: {\n      city?: string;\n      postalCodePrefix?: string;\n      cutId?: number;\n      residentialOnly?: boolean;\n    }\n  ): Promise<ImportResult> {\n    logger.info(`Starting NAR import for province ${provinceCode}`, { filters });\n\n    // Load address files into memory map\n    const addressMap = await this.loadAddressFiles(provinceCode, filters);\n\n    // Process location file and import\n    const result = await this.processLocationFile(provinceCode, addressMap, filters);\n\n    logger.info(`NAR import complete for province ${provinceCode}`, result);\n    return result;\n  }\n\n  private async loadAddressFiles(\n    provinceCode: string,\n    filters: { city?: string; postalCodePrefix?: string }\n  ): Promise<Map<string, NARAddressRecord[]>> {\n    const addressMap = new Map<string, NARAddressRecord[]>();\n\n    const files = await fs.readdir(NAR_DATA_DIR);\n    const addressFiles = files\n      .filter(f => f.match(new RegExp(`^Address_${provinceCode}(?:_part_\\\\d+)?\\\\.csv$`)))\n      .sort();\n\n    for (const file of addressFiles) {\n      logger.info(`Reading ${file}...`);\n      const filePath = path.join(NAR_DATA_DIR, file);\n      const stream = require('fs').createReadStream(filePath);\n      const parser = stream.pipe(csvParser());\n\n      for await (const row of parser) {\n        // Apply filters\n        if (filters.city && row.MUNICIPALITY !== filters.city) continue;\n        if (filters.postalCodePrefix && !row.POSTAL_CODE.startsWith(filters.postalCodePrefix)) continue;\n\n        const locGuid = row.LOC_GUID;\n        if (!addressMap.has(locGuid)) {\n          addressMap.set(locGuid, []);\n        }\n        addressMap.get(locGuid)!.push(row);\n      }\n    }\n\n    logger.info(`Loaded ${addressMap.size} unique locations`);\n    return addressMap;\n  }\n\n  private async processLocationFile(\n    provinceCode: string,\n    addressMap: Map<string, NARAddressRecord[]>,\n    filters: { cutId?: number; residentialOnly?: boolean }\n  ): Promise<ImportResult> {\n    const locationFile = `Location_${provinceCode}.csv`;\n    const filePath = path.join(NAR_DATA_DIR, locationFile);\n    const stream = require('fs').createReadStream(filePath);\n    const parser = stream.pipe(csvParser());\n\n    let batch: any[] = [];\n    const stats = { imported: 0, skipped: 0, errors: 0, total: 0 };\n\n    for await (const row of parser) {\n      stats.total++;\n\n      const locGuid = row.LOC_GUID;\n      const addresses = addressMap.get(locGuid);\n\n      if (!addresses || addresses.length === 0) {\n        stats.skipped++;\n        continue;\n      }\n\n      // Residential filter\n      if (filters.residentialOnly && parseInt(row.BU_USE) !== 1) {\n        stats.skipped++;\n        continue;\n      }\n\n      // Convert coordinates\n      const coords = this.getCoordinates(row);\n      if (!coords) {\n        stats.errors++;\n        continue;\n      }\n\n      // Cut filter (if specified)\n      if (filters.cutId) {\n        const cut = await prisma.cut.findUnique({ where: { id: filters.cutId } });\n        if (cut && !this.isPointInPolygon([coords.longitude, coords.latitude], cut.geojson)) {\n          stats.skipped++;\n          continue;\n        }\n      }\n\n      batch.push({ location: row, addresses, coords });\n\n      if (batch.length >= BATCH_SIZE) {\n        await this.importBatch(batch);\n        stats.imported += batch.length;\n        batch = [];\n      }\n    }\n\n    if (batch.length > 0) {\n      await this.importBatch(batch);\n      stats.imported += batch.length;\n    }\n\n    return stats;\n  }\n\n  private getCoordinates(row: NARLocationRecord): { latitude: number; longitude: number } | null {\n    // Try BG_X/BG_Y conversion\n    if (row.BG_X && row.BG_Y) {\n      try {\n        const [lng, lat] = proj4('EPSG:3347', 'WGS84', [row.BG_X, row.BG_Y]);\n        if (lat >= 41 && lat <= 84 && lng >= -141 && lng <= -52) {\n          return { latitude: lat, longitude: lng };\n        }\n      } catch (error) {\n        logger.warn('Coordinate conversion failed:', error);\n      }\n    }\n\n    // Fallback to BG_LATITUDE/BG_LONGITUDE\n    if (row.BG_LATITUDE && row.BG_LONGITUDE) {\n      return { latitude: row.BG_LATITUDE, longitude: row.BG_LONGITUDE };\n    }\n\n    return null;\n  }\n\n  private async importBatch(batch: any[]): Promise<void> {\n    await prisma.$transaction(async (tx) => {\n      for (const item of batch) {\n        const location = await tx.location.upsert({\n          where: { locGuid: item.location.LOC_GUID },\n          update: {\n            address: this.formatAddress(item.addresses[0]),\n            latitude: item.coords.latitude,\n            longitude: item.coords.longitude,\n            postalCode: item.addresses[0].POSTAL_CODE,\n            federalDistrict: item.location.FED_NUM,\n            buildingUse: parseInt(item.location.BU_USE),\n            municipality: item.location.MUNICIPALITY\n          },\n          create: {\n            locGuid: item.location.LOC_GUID,\n            address: this.formatAddress(item.addresses[0]),\n            latitude: item.coords.latitude,\n            longitude: item.coords.longitude,\n            postalCode: item.addresses[0].POSTAL_CODE,\n            federalDistrict: item.location.FED_NUM,\n            buildingUse: parseInt(item.location.BU_USE),\n            municipality: item.location.MUNICIPALITY,\n            geocodeConfidence: 100,\n            geocodeProvider: 'NAR'\n          }\n        });\n\n        for (const addr of item.addresses) {\n          await tx.address.upsert({\n            where: { addrGuid: addr.ADDR_GUID },\n            update: {},\n            create: {\n              addrGuid: addr.ADDR_GUID,\n              locationId: location.id,\n              unitNumber: addr.CIVIC_NO\n            }\n          });\n        }\n      }\n    });\n  }\n\n  private formatAddress(addr: NARAddressRecord): string {\n    return `${addr.CIVIC_NO} ${addr.OFFICIAL_STREET_NAME}`.trim();\n  }\n\n  private isPointInPolygon(point: [number, number], geojson: any): boolean {\n    // Point-in-polygon implementation\n    // (Same as in spatial.ts)\n    return true; // Placeholder\n  }\n}\n
"},{"location":"v2/features/map/nar-import/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/nar-import/#problem-no-datasets-found","title":"Problem: No datasets found","text":"

Symptoms: - GET /api/locations/nar/datasets returns empty array - \"No datasets available\" message in admin

Solutions:

  1. Verify NAR_DATA_DIR path:

    echo $NAR_DATA_DIR\nls -la /data\n

  2. Check Docker volume mount:

    # docker-compose.yml\nservices:\n  api:\n    volumes:\n      - ./data:/data:ro\n

  3. Verify file naming convention:

    # Correct:\nAddress_35_part_1.csv\nLocation_35.csv\n\n# Incorrect:\naddress_35.csv  # Lowercase\nAddresses_35.csv  # Plural\nAddress35.csv  # No underscore\n

  4. Check file permissions:

    chmod 644 /data/Address_*.csv\nchmod 644 /data/Location_*.csv\n

"},{"location":"v2/features/map/nar-import/#problem-coordinate-conversion-errors","title":"Problem: Coordinate conversion errors","text":"

Symptoms: - Many locations skipped during import - \"Converted coordinates outside Canada\" warnings - Null latitude/longitude in database

Solutions:

  1. Verify BG_X/BG_Y values:

    // Valid range for Canada (EPSG:3347):\n// BG_X: ~400,000 to 3,000,000\n// BG_Y: ~4,600,000 to 9,000,000\n\nconsole.log('BG_X:', narRecord.BG_X);  // Should be 6-7 digits\nconsole.log('BG_Y:', narRecord.BG_Y);  // Should be 7 digits\n

  2. Test with known coordinates:

    // Toronto City Hall\nconst [lng, lat] = proj4('EPSG:3347', 'WGS84', [609091.8, 4834610.7]);\nconsole.log('Expected: 43.6532, -79.3832');\nconsole.log('Got:', lat, lng);\n

  3. Fallback to BG_LATITUDE/BG_LONGITUDE:

    // If BG_X/BG_Y missing or invalid, use lat/lng directly\nif (!coords && narRecord.BG_LATITUDE && narRecord.BG_LONGITUDE) {\n  coords = {\n    latitude: narRecord.BG_LATITUDE,\n    longitude: narRecord.BG_LONGITUDE\n  };\n}\n

  4. Check proj4 definition:

    npm list proj4\n# Ensure version 2.8.0+\n

"},{"location":"v2/features/map/nar-import/#problem-import-very-slow-30min-for-100k-records","title":"Problem: Import very slow (> 30min for 100k records)","text":"

Symptoms: - Import hangs on large provinces - Memory usage grows over time - Database connection timeouts

Solutions:

  1. Increase batch size:

    NAR_BATCH_SIZE=1000  # Default: 500\n

  2. Use streaming instead of loading all addresses:

    // DON'T do this (loads all into memory):\nconst allAddresses = await readAllAddressFiles();\n\n// DO this (stream and process incrementally):\nfor await (const addressBatch of streamAddressFiles()) {\n  processBatch(addressBatch);\n}\n

  3. Optimize database indexes:

    CREATE INDEX CONCURRENTLY idx_locations_loc_guid ON \"Location\"(locGuid);\nCREATE INDEX CONCURRENTLY idx_addresses_addr_guid ON \"Address\"(addrGuid);\n

  4. Disable geocoding during import:

    // Skip geocoding service since NAR already has coordinates\ngeocodeConfidence: 100,\ngeocodeProvider: 'NAR'\n// No call to geocodingService.geocode()\n

  5. Use worker threads for parallel processing:

    import { Worker } from 'worker_threads';\n\nconst workers = [];\nfor (let i = 0; i < 4; i++) {\n  const worker = new Worker('./nar-import-worker.js');\n  workers.push(worker);\n}\n

"},{"location":"v2/features/map/nar-import/#problem-duplicate-loc_guid-errors","title":"Problem: Duplicate LOC_GUID errors","text":"

Symptoms: - Unique constraint violation on locGuid - Import fails mid-process - \"Duplicate key value violates unique constraint\" error

Solutions:

  1. Use UPSERT instead of INSERT:

    await prisma.location.upsert({\n  where: { locGuid: narRecord.LOC_GUID },\n  update: { /* update fields */ },\n  create: { /* create fields */ }\n});\n

  2. Check for corrupt NAR files:

    # Count unique LOC_GUIDs\ncut -d, -f2 Address_35_part_1.csv | sort | uniq | wc -l\n\n# Check for duplicates\ncut -d, -f2 Address_35_part_1.csv | sort | uniq -d\n

  3. Clean up partial imports:

    -- Delete locations from failed import\nDELETE FROM \"Location\" WHERE \"geocodeProvider\" = 'NAR' AND \"createdAt\" > '2025-02-13';\n

  4. Implement transaction rollback on error:

    try {\n  await prisma.$transaction(async (tx) => {\n    // Import batch\n  });\n} catch (error) {\n  logger.error('Batch failed, rolling back:', error);\n  // Transaction automatically rolled back\n}\n

"},{"location":"v2/features/map/nar-import/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/nar-import/#import-speed","title":"Import Speed","text":"

Benchmarks:

Province Records Files Time Records/Second PEI (11) 15,000 1 12s 1,250 Nova Scotia (12) 85,000 1 1m 10s 1,214 Quebec (24) 850,000 6 11m 20s 1,250 Ontario (35) 1,200,000 3 14m 30s 1,379

Factors: - Batch size: 500 (optimal for most systems) - Coordinate conversion: ~0.1ms per record - Database write: ~0.5ms per location (depends on disk speed) - Total overhead: ~0.7ms per record

"},{"location":"v2/features/map/nar-import/#memory-usage","title":"Memory Usage","text":"

Peak Memory: - Address map (in-memory): ~200MB per 100k records - CSV parser buffer: ~10MB - Batch buffer: ~5MB (500 records) - Total: ~220MB per 100k records

Optimization: - Stream address files instead of loading all - Process location file in chunks - Clear batch after each commit - Limit concurrent transactions

"},{"location":"v2/features/map/nar-import/#database-load","title":"Database Load","text":"

Transaction Rate: - 1 transaction per batch (500 records) - ~2-3 transactions/second - Low database CPU (~10-20%) - Moderate disk I/O (sequential writes)

Connection Pool:

// prisma/schema.prisma\ndatasource db {\n  url = env(\"DATABASE_URL\")\n  connection_limit = 10\n}\n

"},{"location":"v2/features/map/nar-import/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/map/nar-import/#backend-documentation","title":"Backend Documentation","text":"
  • NAR Import Service: api/src/modules/map/locations/nar-import.service.ts
  • File scanning
  • Streaming CSV parser
  • Coordinate conversion
  • Batch import

  • NAR Import Routes: api/src/modules/map/locations/nar-import.routes.ts

  • Dataset discovery
  • Import job creation
  • Progress tracking

  • Locations Service: api/src/modules/map/locations/locations.service.ts

  • Location CRUD
  • Geocoding integration
"},{"location":"v2/features/map/nar-import/#frontend-documentation","title":"Frontend Documentation","text":"
  • Locations Page: admin/src/pages/LocationsPage.tsx
  • NAR Import tab
  • Dataset selection
  • Filter configuration
  • Progress monitoring
"},{"location":"v2/features/map/nar-import/#database-documentation","title":"Database Documentation","text":"
  • Location Model: api/prisma/schema.prisma
  • NAR-specific fields
  • locGuid unique constraint
  • Federal district index

  • Address Model: api/prisma/schema.prisma

  • addrGuid unique constraint
  • Location foreign key
"},{"location":"v2/features/map/nar-import/#external-resources","title":"External Resources","text":"
  • Elections Canada NAR: https://www.elections.ca/content.aspx?section=res&dir=cir/tech/nar&document=index&lang=e
  • EPSG:3347 Definition: https://epsg.io/3347
  • Proj4 Documentation: https://github.com/proj4js/proj4js
  • NAR Data Dictionary: Elections Canada NAR Technical Documentation (PDF)
"},{"location":"v2/features/map/shifts/","title":"Volunteer Shift Management","text":""},{"location":"v2/features/map/shifts/#overview","title":"Overview","text":"

The shifts system enables campaigns to organize volunteer activities with time-based scheduling, capacity management, and cut assignment. It supports public shift signup with automatic TEMP user creation for unauthenticated volunteers.

Key Capabilities:

  • Shift Scheduling: Date, start/end times (HH:MM format), location
  • Capacity Management: Max volunteers, auto-status updates (OPEN \u2192 FULL)
  • Cut Assignment: Link shifts to geographic cuts for territory-based organizing
  • Public Signup: Unauthenticated users can signup (creates TEMP user)
  • Email Confirmations: Auto-send confirmation emails on signup
  • Signup Tracking: Source tracking (AUTHENTICATED, PUBLIC, ADMIN)
  • Status Lifecycle: OPEN, FULL, CANCELLED workflow
  • Bulk Operations: Email all volunteers, export signups CSV

Use Cases:

  • Canvassing shift scheduling
  • Phone bank volunteer coordination
  • Event volunteer management
  • Door-knocking territory assignment
  • Get-out-the-vote (GOTV) shifts
  • Public volunteer recruitment
  • Volunteer confirmation emails
"},{"location":"v2/features/map/shifts/#architecture","title":"Architecture","text":"
graph TD\n    A[Admin] -->|Creates Shift| B[ShiftsPage]\n    B -->|POST /api/map/shifts| C[Shifts Service]\n    C -->|Validate| D[Shift Model]\n    D -->|Linked To| E[Cut Model]\n\n    F[Public User] -->|Browse Shifts| G[Public ShiftsPage]\n    G -->|GET /api/public/map/shifts| C\n    C -->|Filter upcoming=true| D\n\n    F -->|Signup| H[Signup Modal]\n    H -->|POST /api/public/map/shifts/:id/signup| C\n    C -->|Check Capacity| D\n    C -->|Create TEMP User| I[User Service]\n    C -->|Create Signup| J[ShiftSignup Model]\n    C -->|Send Email| K[Email Service]\n\n    L[Volunteer] -->|View Assignments| M[VolunteerShiftsPage]\n    M -->|GET /api/map/canvass/volunteer/assignments| N[Canvass Service]\n    N -->|Filter by userId| J\n    N -->|Include Cut| E\n\n    D -->|1:N| J\n    D -->|N:1| E\n\n    style D fill:#e1f5ff\n    style J fill:#e1f5ff\n    style E fill:#e1f5ff\n    style I fill:#e8f5e9

Flow Description:

  1. Admin creates shift \u2192 Validates date/time, assigns cut (optional), saves to database
  2. Public user browses \u2192 Query upcoming shifts (isPublic=true, date >=today), display cards
  3. Public signup \u2192 Check capacity, create TEMP user if unauthenticated, create signup record, send confirmation email
  4. Volunteer views assignments \u2192 Query signups for current user, include shift + cut details
  5. Shift capacity check \u2192 Auto-update status to FULL when currentVolunteers >= maxVolunteers
"},{"location":"v2/features/map/shifts/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/shifts/#shift-model","title":"Shift Model","text":"

See Shift Model Documentation for full schema.

Key Fields:

  • title: Shift name (e.g., \"Saturday Canvassing - Downtown\")
  • description: Free-text shift details
  • date: Shift date (Date type, not DateTime)
  • startTime: Start time in HH:MM format (24-hour)
  • endTime: End time in HH:MM format (24-hour)
  • location: Meeting point address/description
  • maxVolunteers: Maximum volunteer capacity
  • currentVolunteers: Current signup count (auto-updated)
  • status: OPEN | FULL | CANCELLED
  • isPublic: Show on public shifts page
  • cutId: Optional foreign key to Cut (territory assignment)
  • createdBy: User ID who created shift

Status Enum:

enum ShiftStatus {\n  OPEN       // Accepting signups\n  FULL       // At capacity\n  CANCELLED  // Cancelled by admin\n}\n
"},{"location":"v2/features/map/shifts/#shiftsignup-model","title":"ShiftSignup Model","text":"

See ShiftSignup Model Documentation for full schema.

Key Fields:

  • shiftId: Foreign key to Shift
  • userId: Foreign key to User (optional for TEMP users)
  • userEmail: Email address (required, used for confirmations)
  • userName: Display name
  • userPhone: Phone number (optional)
  • status: CONFIRMED | CANCELLED | NO_SHOW
  • signupDate: When signup occurred
  • signupSource: AUTHENTICATED | PUBLIC | ADMIN
  • notes: Admin notes about signup

Signup Source Enum:

enum SignupSource {\n  AUTHENTICATED  // Logged-in user signup\n  PUBLIC         // Public signup (creates TEMP user)\n  ADMIN          // Admin created signup\n}\n

Signup Status Enum:

enum SignupStatus {\n  CONFIRMED  // Signup active\n  CANCELLED  // Volunteer cancelled\n  NO_SHOW    // Marked as no-show by admin\n}\n

Related Models:

  • Shift \u2014 Parent shift
  • User \u2014 Volunteer account (TEMP role for public signups)
  • Cut \u2014 Geographic territory assignment
  • CanvassSession \u2014 Linked to shift for canvassing
"},{"location":"v2/features/map/shifts/#api-endpoints","title":"API Endpoints","text":"

See Shifts Backend Module Documentation for full API reference.

Admin Endpoints:

Method Endpoint Auth Description GET /api/map/shifts MAP_ADMIN List shifts with pagination, search, filters GET /api/map/shifts/stats MAP_ADMIN Get shift statistics (total, upcoming, by status) GET /api/map/shifts/:id MAP_ADMIN Get shift details with signups POST /api/map/shifts MAP_ADMIN Create new shift PATCH /api/map/shifts/:id MAP_ADMIN Update shift DELETE /api/map/shifts/:id MAP_ADMIN Delete shift (cascade signups) POST /api/map/shifts/:id/signups MAP_ADMIN Manually add signup PATCH /api/map/shifts/:id/signups/:signupId MAP_ADMIN Update signup (change status, notes) DELETE /api/map/shifts/:id/signups/:signupId MAP_ADMIN Delete signup POST /api/map/shifts/:id/email-volunteers MAP_ADMIN Send email to all shift volunteers

Public Endpoints:

Method Endpoint Auth Description GET /api/public/map/shifts None List upcoming public shifts (isPublic=true, date >=today) GET /api/public/map/shifts/:id None Get public shift details POST /api/public/map/shifts/:id/signup None Public signup (creates TEMP user if unauthenticated)

Volunteer Endpoints:

Method Endpoint Auth Description GET /api/map/canvass/volunteer/assignments Any logged-in user Get shifts user signed up for DELETE /api/map/shifts/:id/signups/cancel Any logged-in user Cancel own signup"},{"location":"v2/features/map/shifts/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/shifts/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description EMAIL_TEST_MODE boolean false Send confirmation emails to MailHog (dev) SMTP_HOST string - SMTP server for confirmation emails SMTP_PORT number 587 SMTP port SMTP_USER string - SMTP username SMTP_PASSWORD string - SMTP password"},{"location":"v2/features/map/shifts/#email-templates","title":"Email Templates","text":"

Shift Confirmation Email:

Subject: Shift Confirmation - {{shift.title}}

Body:

Hi {{userName}},\n\nYou're confirmed for:\n{{shift.title}}\n\nDate: {{shift.date}}\nTime: {{shift.startTime}} - {{shift.endTime}}\nLocation: {{shift.location}}\n\n{{#if shift.cut}}\nTerritory: {{shift.cut.name}}\n{{/if}}\n\n{{#if shift.description}}\nDetails:\n{{shift.description}}\n{{/if}}\n\nTo cancel your signup, reply to this email.\n\nThank you!\n

Admin Email All Volunteers:

Subject: Configurable by admin

Body: Configurable by admin (supports {{name}}, {{email}}, {{phone}} placeholders)

"},{"location":"v2/features/map/shifts/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/shifts/#creating-a-shift","title":"Creating a Shift","text":"

Step 1: Navigate to Shifts Page

Navigate to Map \u2192 Shifts in the admin sidebar.

![ShiftsPage Screenshot Placeholder]

Step 2: Click \"Add Shift\"

Click + Add Shift button in the top-right corner.

Step 3: Fill Shift Form

Complete shift details:

  • Title: \"Saturday Canvassing - Ward 5\"
  • Description: \"Door-knocking downtown, meet at campaign office\"
  • Date: Select date from calendar
  • Start Time: \"09:00\" (24-hour format)
  • End Time: \"12:00\"
  • Location: \"123 Campaign Office, Main St\"
  • Max Volunteers: 20
  • Cut: Select from dropdown (optional)
  • Is Public: Toggle to show on public shifts page

Step 4: Save Shift

Click Create to save shift. Status is automatically set to OPEN.

"},{"location":"v2/features/map/shifts/#managing-signups","title":"Managing Signups","text":"

Step 1: View Shift

Click Signups button on a shift row to open signups drawer.

Step 2: View Signup List

Drawer displays:

  • Volunteer Name: From signup or user account
  • Email: Contact email
  • Phone: Contact phone (if provided)
  • Signup Date: When volunteer signed up
  • Source: AUTHENTICATED | PUBLIC | ADMIN
  • Status: CONFIRMED | CANCELLED | NO_SHOW

Step 3: Manually Add Signup (Admin)

Click Add Signup button in drawer:

  • Email: Required (validates format)
  • Name: Required
  • Phone: Optional
  • Notes: Admin notes

System will:

  1. Check capacity (reject if FULL)
  2. Create TEMP user if email not in database
  3. Create signup with source=ADMIN
  4. Send confirmation email
  5. Update shift.currentVolunteers count

Step 4: Mark No-Show

Click Mark No-Show on signup row to update status. Useful for tracking volunteer reliability.

Step 5: Delete Signup

Click Delete to remove signup. Decrements shift.currentVolunteers count.

"},{"location":"v2/features/map/shifts/#emailing-all-volunteers","title":"Emailing All Volunteers","text":"

Step 1: Click \"Email All\"

On shift row, click Email All button.

Step 2: Compose Email

Modal opens with:

  • Subject: Pre-filled with shift title
  • Message: Rich text editor with placeholders
  • Placeholders: {{name}}, {{email}}, {{phone}}, {{shift.title}}, {{shift.date}}, {{shift.startTime}}, {{shift.endTime}}

Step 3: Preview

Click Preview to see sample email with placeholders replaced.

Step 4: Send

Click Send Email to queue emails to all CONFIRMED volunteers. Uses BullMQ email queue for async processing.

"},{"location":"v2/features/map/shifts/#updating-shift-status","title":"Updating Shift Status","text":"

Step 1: Edit Shift

Click Edit on shift row.

Step 2: Change Status

Update status dropdown:

  • OPEN: Accepting signups
  • FULL: At capacity (auto-set when currentVolunteers >= maxVolunteers)
  • CANCELLED: Cancelled by admin

Step 3: Save

Click Update. If status changed to CANCELLED, optionally send cancellation email to all volunteers.

"},{"location":"v2/features/map/shifts/#public-workflow","title":"Public Workflow","text":"

Public users can browse and signup for shifts without authentication.

Step 1: Navigate to Public Shifts Page

Visit /shifts (public route, no auth required).

Step 2: Browse Shifts

View upcoming shifts as cards:

  • Shift Title: Large heading
  • Date/Time: Formatted date + time range
  • Location: Meeting point
  • Volunteers: \"5 / 20 spots filled\" progress bar
  • Cut: Territory name (if assigned)
  • Status Badge: OPEN (green), FULL (red), CANCELLED (gray)

Step 3: Filter Shifts

Use filters:

  • Date: Show only shifts on specific date
  • Status: OPEN only (hide FULL/CANCELLED)

Step 4: Click Signup

Click Signup button on shift card. Modal opens.

Step 5: Fill Signup Form

Complete form:

  • Name: Required
  • Email: Required (validates format)
  • Phone: Optional

Step 6: Submit

Click Sign Up. System will:

  1. Check capacity (reject if FULL)
  2. Create TEMP user with email (if not exists)
  3. Create shift signup with source=PUBLIC
  4. Send confirmation email
  5. Update shift.currentVolunteers count
  6. Auto-update status to FULL if at capacity

Step 7: Receive Confirmation

Check email for confirmation with shift details.

"},{"location":"v2/features/map/shifts/#volunteer-workflow","title":"Volunteer Workflow","text":"

Authenticated volunteers can view assigned shifts and cancel signups.

Step 1: Login

Login at /login with volunteer account.

Step 2: Navigate to Assignments

Navigate to Volunteer \u2192 My Assignments.

Step 3: View Assigned Shifts

Table displays:

  • Shift Title: Linked to shift details
  • Date/Time: Formatted
  • Location: Meeting point
  • Cut: Territory name (if assigned)
  • Status: Signup status

Step 4: View Shift Details

Click shift title to view:

  • Description: Full shift details
  • Volunteers: List of other volunteers (names only, privacy protected)
  • Map: If cut assigned, show cut polygon on map

Step 5: Cancel Signup

Click Cancel Signup button. Confirmation modal appears.

Step 6: Confirm Cancellation

Click Confirm. System will:

  1. Update signup status to CANCELLED
  2. Decrement shift.currentVolunteers count
  3. Update shift status to OPEN if was FULL
  4. Send cancellation confirmation email
"},{"location":"v2/features/map/shifts/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/shifts/#shift-service-create-backend","title":"Shift Service Create (Backend)","text":"
// api/src/modules/map/shifts/shifts.service.ts\nasync create(data: CreateShiftInput, userId: string) {\n  const shift = await prisma.shift.create({\n    data: {\n      title: data.title,\n      description: data.description,\n      date: new Date(data.date),\n      startTime: data.startTime,\n      endTime: data.endTime,\n      location: data.location,\n      maxVolunteers: data.maxVolunteers,\n      isPublic: data.isPublic,\n      cutId: data.cutId,\n      createdBy: userId,\n    },\n  });\n\n  return shift;\n}\n
"},{"location":"v2/features/map/shifts/#public-signup-backend","title":"Public Signup (Backend)","text":"
// api/src/modules/map/shifts/shifts.service.ts\nimport bcrypt from 'bcryptjs';\n\nasync publicSignup(shiftId: string, data: PublicSignupInput) {\n  const shift = await prisma.shift.findUnique({ where: { id: shiftId } });\n\n  if (!shift) {\n    throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');\n  }\n\n  // Check capacity\n  if (shift.currentVolunteers >= shift.maxVolunteers) {\n    throw new AppError(400, 'Shift is full', 'SHIFT_FULL');\n  }\n\n  // Find or create TEMP user\n  let user = await prisma.user.findUnique({ where: { email: data.email } });\n\n  if (!user) {\n    const password = generateReadablePassword(); // e.g., \"BlueEagle42\"\n    const hashedPassword = await bcrypt.hash(password, 10);\n\n    user = await prisma.user.create({\n      data: {\n        email: data.email,\n        name: data.name,\n        phone: data.phone,\n        password: hashedPassword,\n        role: 'TEMP',\n      },\n    });\n\n    logger.info('Created TEMP user for shift signup', {\n      email: data.email,\n      shiftId,\n    });\n  }\n\n  // Create signup\n  const signup = await prisma.shiftSignup.create({\n    data: {\n      shiftId,\n      userId: user.id,\n      userEmail: user.email,\n      userName: user.name ?? data.name,\n      userPhone: user.phone ?? data.phone,\n      signupSource: SignupSource.PUBLIC,\n      status: SignupStatus.CONFIRMED,\n    },\n  });\n\n  // Increment volunteer count\n  await prisma.shift.update({\n    where: { id: shiftId },\n    data: {\n      currentVolunteers: { increment: 1 },\n      status: shift.currentVolunteers + 1 >= shift.maxVolunteers\n        ? ShiftStatus.FULL\n        : shift.status,\n    },\n  });\n\n  // Send confirmation email\n  await emailService.sendShiftConfirmation(user.email, shift, user.name ?? data.name);\n\n  recordShiftSignup('public');\n\n  return signup;\n}\n
"},{"location":"v2/features/map/shifts/#generate-readable-password-backend","title":"Generate Readable Password (Backend)","text":"
// api/src/modules/map/shifts/shifts.service.ts\nconst adjectives = ['Blue', 'Red', 'Green', 'Swift', 'Bright', 'Bold', 'Calm', 'Fair'];\nconst nouns = ['Eagle', 'River', 'Mountain', 'Star', 'Forest', 'Lake', 'Wolf', 'Hawk'];\n\nfunction generateReadablePassword(): string {\n  const adj = adjectives[Math.floor(Math.random() * adjectives.length)];\n  const noun = nouns[Math.floor(Math.random() * nouns.length)];\n  const num = Math.floor(Math.random() * 90) + 10;\n  return `${adj}${noun}${num}`;\n}\n\n// Example output: \"BoldWolf72\", \"SwiftStar45\"\n
"},{"location":"v2/features/map/shifts/#shift-confirmation-email-backend","title":"Shift Confirmation Email (Backend)","text":"
// api/src/services/email.service.ts\nasync sendShiftConfirmation(\n  to: string,\n  shift: Shift,\n  userName: string\n): Promise<void> {\n  const subject = `Shift Confirmation - ${shift.title}`;\n\n  const body = `\nHi ${userName},\n\nYou're confirmed for:\n${shift.title}\n\nDate: ${dayjs(shift.date).format('MMMM D, YYYY')}\nTime: ${shift.startTime} - ${shift.endTime}\nLocation: ${shift.location}\n\n${shift.description ? `\\nDetails:\\n${shift.description}\\n` : ''}\n\nTo cancel your signup, reply to this email.\n\nThank you!\n`;\n\n  await this.sendEmail({ to, subject, text: body });\n}\n
"},{"location":"v2/features/map/shifts/#public-shifts-list-frontend","title":"Public Shifts List (Frontend)","text":"
// admin/src/pages/public/ShiftsPage.tsx\nconst fetchShifts = async () => {\n  try {\n    const { data } = await axios.get('/api/public/map/shifts', {\n      params: {\n        upcoming: true, // Only show future shifts\n      },\n    });\n\n    setShifts(data.shifts);\n  } catch (error) {\n    message.error('Failed to load shifts');\n  }\n};\n\nuseEffect(() => {\n  fetchShifts();\n}, []);\n
"},{"location":"v2/features/map/shifts/#signup-modal-frontend","title":"Signup Modal (Frontend)","text":"
// admin/src/pages/public/ShiftsPage.tsx\nconst handleSignup = async (values: any) => {\n  try {\n    await axios.post(`/api/public/map/shifts/${selectedShift.id}/signup`, {\n      name: values.name,\n      email: values.email,\n      phone: values.phone,\n    });\n\n    message.success('Signup successful! Check your email for confirmation.');\n    setSignupModalOpen(false);\n    signupForm.resetFields();\n    fetchShifts(); // Refresh to update volunteer count\n  } catch (error: any) {\n    if (error.response?.data?.code === 'SHIFT_FULL') {\n      message.error('This shift is now full. Please choose another shift.');\n    } else {\n      message.error('Signup failed. Please try again.');\n    }\n  }\n};\n
"},{"location":"v2/features/map/shifts/#volunteer-assignments-frontend","title":"Volunteer Assignments (Frontend)","text":"
// admin/src/pages/volunteer/VolunteerShiftsPage.tsx\nconst fetchAssignments = async () => {\n  try {\n    const { data } = await api.get('/map/canvass/volunteer/assignments');\n\n    setAssignments(data);\n  } catch (error) {\n    message.error('Failed to load assignments');\n  }\n};\n
"},{"location":"v2/features/map/shifts/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/shifts/#issue-shift-status-not-auto-updating-to-full","title":"Issue: Shift Status Not Auto-Updating to FULL","text":"

Symptoms:

  • Shift accepts signups beyond maxVolunteers
  • Status remains OPEN even when at capacity
  • currentVolunteers count incorrect

Causes:

  • currentVolunteers not incremented on signup
  • Signup deletion not decrementing count
  • Race condition on concurrent signups

Solutions:

  1. Use database transaction for capacity check + signup creation:
await prisma.$transaction(async (tx) => {\n  const shift = await tx.shift.findUnique({\n    where: { id: shiftId },\n    select: { currentVolunteers: true, maxVolunteers: true },\n  });\n\n  if (shift.currentVolunteers >= shift.maxVolunteers) {\n    throw new AppError(400, 'Shift is full', 'SHIFT_FULL');\n  }\n\n  await tx.shiftSignup.create({ data: signupData });\n\n  await tx.shift.update({\n    where: { id: shiftId },\n    data: {\n      currentVolunteers: { increment: 1 },\n      status: shift.currentVolunteers + 1 >= shift.maxVolunteers\n        ? ShiftStatus.FULL\n        : shift.status,\n    },\n  });\n});\n
  1. Verify count matches reality:
-- Check if currentVolunteers matches actual signup count\nSELECT s.id, s.title, s.currentVolunteers,\n  COUNT(ss.id) as actual_signups\nFROM \"Shift\" s\nLEFT JOIN \"ShiftSignup\" ss ON s.id = ss.\"shiftId\"\n  AND ss.status = 'CONFIRMED'\nGROUP BY s.id\nHAVING s.\"currentVolunteers\" != COUNT(ss.id);\n
  1. Recalculate counts:
// Admin utility to fix counts\nasync function recalculateShiftCounts() {\n  const shifts = await prisma.shift.findMany();\n\n  for (const shift of shifts) {\n    const count = await prisma.shiftSignup.count({\n      where: {\n        shiftId: shift.id,\n        status: SignupStatus.CONFIRMED,\n      },\n    });\n\n    await prisma.shift.update({\n      where: { id: shift.id },\n      data: {\n        currentVolunteers: count,\n        status: count >= shift.maxVolunteers ? ShiftStatus.FULL : ShiftStatus.OPEN,\n      },\n    });\n  }\n}\n
"},{"location":"v2/features/map/shifts/#issue-confirmation-emails-not-sending","title":"Issue: Confirmation Emails Not Sending","text":"

Symptoms:

  • Users signup successfully but no email received
  • MailHog shows no emails in dev
  • SMTP errors in API logs

Causes:

  • EMAIL_TEST_MODE not set in dev
  • SMTP credentials invalid
  • Email service not configured
  • Email in spam folder

Solutions:

  1. Check email service config:
# Verify SMTP settings in .env\ngrep \"SMTP_\\|EMAIL_TEST_MODE\" .env\n\n# In development, use MailHog\nEMAIL_TEST_MODE=true\n\n# In production, configure SMTP\nEMAIL_TEST_MODE=false\nSMTP_HOST=smtp.gmail.com\nSMTP_PORT=587\nSMTP_USER=your-email@gmail.com\nSMTP_PASSWORD=your-app-password\n
  1. Test email service:
# Send test email via API\ncurl -X POST http://localhost:4000/api/test-email \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"to\":\"test@example.com\",\"subject\":\"Test\",\"text\":\"Test email\"}'\n
  1. Check MailHog in dev:
# Access MailHog UI\nopen http://localhost:8025\n\n# View email queue in BullMQ\ndocker compose exec redis redis-cli KEYS \"bull:email-queue:*\"\n
  1. Check spam folder (production):

Add SPF/DKIM/DMARC records to domain to improve deliverability.

"},{"location":"v2/features/map/shifts/#issue-temp-user-password-security","title":"Issue: TEMP User Password Security","text":"

Symptoms:

  • TEMP users can't login with generated password
  • Password doesn't meet complexity requirements
  • Account locked after signup

Causes:

  • Generated password doesn't meet 12-char minimum
  • Password missing uppercase/lowercase/digit
  • Password not sent to user (they can't login)

Solutions:

  1. Ensure generated password meets policy:
function generateReadablePassword(): string {\n  // Must meet: 12+ chars, uppercase, lowercase, digit\n  const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; // Uppercase\n  const noun = nouns[Math.floor(Math.random() * nouns.length)]; // Uppercase\n  const num = Math.floor(Math.random() * 90) + 10; // 2 digits\n  const lower = 'abc'; // Lowercase\n\n  return `${adj}${noun}${num}${lower}`; // E.g., \"BoldWolf72abc\" (14 chars)\n}\n
  1. Send password to user (security risk, consider alternative):

Include password in confirmation email (only for TEMP users, one-time):

Your temporary account has been created.\n\nEmail: {{email}}\nPassword: {{password}}\n\nPlease change your password after logging in.\n

Better Alternative: Use passwordless login link:

Click here to confirm your shift and access your account:\nhttps://app.cmlite.org/confirm-shift/{{signupToken}}\n
"},{"location":"v2/features/map/shifts/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/shifts/#shift-query-optimization","title":"Shift Query Optimization","text":"

Index Upcoming Shifts:

Create composite index for common query:

CREATE INDEX idx_shifts_upcoming ON \"Shift\" (date, \"isPublic\", status)\nWHERE date >= CURRENT_DATE;\n

Efficient Public Query:

// Only query future public shifts\nconst shifts = await prisma.shift.findMany({\n  where: {\n    isPublic: true,\n    date: { gte: new Date() },\n    status: { not: ShiftStatus.CANCELLED },\n  },\n  orderBy: { date: 'asc' },\n  include: {\n    cut: { select: { id: true, name: true } },\n    _count: {\n      select: { signups: { where: { status: SignupStatus.CONFIRMED } } },\n    },\n  },\n});\n
"},{"location":"v2/features/map/shifts/#email-queue-performance","title":"Email Queue Performance","text":"

Batch Email Sending:

Use BullMQ queue to avoid blocking API requests:

// Add email jobs to queue\nfor (const volunteer of volunteers) {\n  await emailQueue.add('send-email', {\n    to: volunteer.email,\n    subject: 'Shift Update',\n    text: message,\n  });\n}\n\n// Worker processes jobs asynchronously\nemailQueue.process('send-email', async (job) => {\n  await emailService.sendEmail(job.data);\n});\n
"},{"location":"v2/features/map/shifts/#concurrent-signup-handling","title":"Concurrent Signup Handling","text":"

Prevent Race Conditions:

Use database transactions with SELECT FOR UPDATE:

await prisma.$transaction(async (tx) => {\n  const shift = await tx.shift.findUnique({\n    where: { id: shiftId },\n    // Lock row to prevent concurrent updates\n  });\n\n  if (shift.currentVolunteers >= shift.maxVolunteers) {\n    throw new AppError(400, 'Shift is full', 'SHIFT_FULL');\n  }\n\n  // Create signup and update count atomically\n  await tx.shiftSignup.create({ data: signupData });\n  await tx.shift.update({\n    where: { id: shiftId },\n    data: { currentVolunteers: { increment: 1 } },\n  });\n});\n
"},{"location":"v2/features/map/shifts/#related-documentation","title":"Related Documentation","text":"

Backend Modules:

  • Shifts Backend Module \u2014 API implementation
  • Email Service \u2014 Confirmation emails

Frontend Pages:

  • ShiftsPage \u2014 Admin CRUD interface
  • Public ShiftsPage \u2014 Public signup
  • VolunteerShiftsPage \u2014 Volunteer assignments

Database:

  • Shift Model \u2014 Shift schema
  • ShiftSignup Model \u2014 Signup records
  • User Model \u2014 TEMP user accounts

Features:

  • Cuts \u2014 Territory assignment for shifts
  • Canvassing \u2014 Shift-based canvassing sessions
  • Users \u2014 TEMP user management
"},{"location":"v2/features/map/tracking/","title":"GPS Tracking System","text":""},{"location":"v2/features/map/tracking/#overview","title":"Overview","text":"

The GPS tracking system provides real-time volunteer location monitoring with breadcrumb trail recording, distance calculation, and route visualization. It integrates with canvassing sessions for field organizing oversight and volunteer safety.

Key Capabilities:

  • Live Tracking: Real-time volunteer GPS positions
  • Breadcrumb Trails: Auto-record GPS points every 10 seconds
  • Distance Calculation: Haversine formula for accurate walking distance
  • Event Markers: Mark key events (session start, visits, session end)
  • Route Visualization: Leaflet polyline with color-coded event markers
  • 1:1 Canvass Link: Each TrackingSession linked to one CanvassSession
  • Admin Oversight: View live volunteer positions on map
  • Privacy Controls: Tracking only during active canvass sessions
"},{"location":"v2/features/map/tracking/#architecture","title":"Architecture","text":"
graph TD\n    A[Volunteer GPS] -->|watchPosition| B[GPSTracker Component]\n    B -->|Buffer Points| C[Local Storage]\n    C -->|Submit Every 10s| D[POST /api/map/tracking/sessions/:id/points]\n    D -->|Batch Insert| E[Tracking Service]\n    E -->|Save Points| F[(TrackPoint Model)]\n    E -->|Calculate Distance| G[Haversine Formula]\n    G -->|Update Session| H[(TrackingSession Model)]\n\n    I[Canvass Session] -->|Start| J[Canvass Service]\n    J -->|Create 1:1| E\n    E -->|Create| H\n\n    K[Admin] -->|View Live Map| L[CanvassDashboardPage]\n    L -->|GET /api/map/tracking/admin/live| E\n    E -->|Query Active| H\n    E -->|Return Positions| L\n\n    M[Volunteer] -->|View Route History| N[MyRoutesPage]\n    N -->|GET /api/map/tracking/sessions/:id/route| E\n    E -->|Query Points| F\n    E -->|Generate Polyline| N\n\n    H -->|1:1| I\n    H -->|1:N| F\n\n    style H fill:#e1f5ff\n    style F fill:#e1f5ff

Flow Description:

  1. Canvass session starts \u2192 Create TrackingSession linked 1:1
  2. GPS auto-tracking \u2192 watchPosition submits points every 10s
  3. Distance calculation \u2192 Haversine formula calculates incremental distance
  4. Event markers \u2192 Mark visits, session start/end with eventType
  5. Admin oversight \u2192 View live volunteer positions on dashboard
  6. Route history \u2192 Generate polyline from saved TrackPoints
"},{"location":"v2/features/map/tracking/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/tracking/#trackingsession-model","title":"TrackingSession Model","text":"

See TrackingSession Model Documentation.

Key Fields:

  • userId: Foreign key to volunteer User
  • canvassSessionId: 1:1 foreign key to CanvassSession
  • startedAt: Tracking start timestamp
  • endedAt: Tracking end timestamp (null while active)
  • isActive: Boolean - tracking currently running
  • totalPoints: Count of TrackPoint records
  • totalDistanceM: Total distance walked in meters
  • lastLatitude / lastLongitude: Most recent GPS position
  • lastRecordedAt: Timestamp of last GPS point
"},{"location":"v2/features/map/tracking/#trackpoint-model","title":"TrackPoint Model","text":"

See TrackPoint Model Documentation.

Key Fields:

  • trackingSessionId: Foreign key to TrackingSession
  • latitude / longitude: GPS coordinates (Decimal type)
  • accuracy: GPS accuracy in meters (lower = better)
  • recordedAt: When point was recorded (client timestamp)
  • eventType: Optional event marker (LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED)

Event Type Enum:

enum TrackPointEventType {\n  LOCATION_ADDED   // Regular GPS breadcrumb\n  VISIT_RECORDED   // Canvass visit recorded\n  SESSION_STARTED  // Canvass session started\n  SESSION_ENDED    // Canvass session ended\n}\n
"},{"location":"v2/features/map/tracking/#api-endpoints","title":"API Endpoints","text":"

See Tracking Backend Module Documentation.

Volunteer Endpoints:

Method Endpoint Auth Description POST /api/map/tracking/sessions Any logged-in user Start tracking session PATCH /api/map/tracking/sessions/:id/end Any logged-in user End tracking session POST /api/map/tracking/sessions/:id/points Any logged-in user Submit batch of GPS points GET /api/map/tracking/sessions/:id Any logged-in user Get tracking session details GET /api/map/tracking/sessions/:id/route Any logged-in user Get route polyline (all points)

Admin Endpoints:

Method Endpoint Auth Description GET /api/map/tracking/admin/live MAP_ADMIN Get live volunteer positions GET /api/map/tracking/admin/sessions/:id MAP_ADMIN Get volunteer tracking session GET /api/map/tracking/admin/sessions/:id/route MAP_ADMIN Get volunteer route"},{"location":"v2/features/map/tracking/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/tracking/#gps-tracking-settings","title":"GPS Tracking Settings","text":"Setting Default Description SUBMIT_INTERVAL_MS 10000 Submit GPS points every 10 seconds MAX_DISTANCE_JUMP_M 1000 Ignore GPS glitches >1km distance HIGH_ACCURACY true Use GPS + WiFi + cellular (vs WiFi only) MAX_AGE_MS 0 Don't use cached GPS position TIMEOUT_MS 10000 GPS position timeout (10s)"},{"location":"v2/features/map/tracking/#privacy-security","title":"Privacy & Security","text":"
  • Opt-In Only: Tracking only enabled when volunteer starts canvass session
  • Session-Based: Tracking ends when session ends (not continuous)
  • Admin-Only: Only MAP_ADMIN can view live positions
  • Data Retention: TrackPoints retained for analytics (consider GDPR compliance for EU campaigns)
"},{"location":"v2/features/map/tracking/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/tracking/#start-tracking-session-backend","title":"Start Tracking Session (Backend)","text":"
// api/src/modules/map/tracking/tracking.service.ts\nasync startSession(userId: string, data: StartTrackingInput) {\n  const { canvassSessionId, latitude, longitude } = data;\n\n  // Check for existing active session\n  const existing = await prisma.trackingSession.findFirst({\n    where: { userId, isActive: true },\n  });\n\n  if (existing) return existing; // Reuse existing session\n\n  return prisma.trackingSession.create({\n    data: {\n      userId,\n      canvassSessionId: canvassSessionId ?? null,\n      lastLatitude: latitude != null ? new Prisma.Decimal(latitude) : null,\n      lastLongitude: longitude != null ? new Prisma.Decimal(longitude) : null,\n      lastRecordedAt: latitude != null ? new Date() : null,\n    },\n  });\n}\n
"},{"location":"v2/features/map/tracking/#submit-gps-points-backend","title":"Submit GPS Points (Backend)","text":"
// api/src/modules/map/tracking/tracking.service.ts\nconst MAX_DISTANCE_JUMP_M = 1000;\n\nasync submitPoints(sessionId: string, userId: string, data: SubmitPointsInput) {\n  const session = await prisma.trackingSession.findFirst({\n    where: { id: sessionId, userId, isActive: true },\n  });\n\n  if (!session) {\n    throw new AppError(404, 'Active tracking session not found', 'SESSION_NOT_FOUND');\n  }\n\n  const { points } = data;\n\n  // Batch insert all points\n  await prisma.trackPoint.createMany({\n    data: points.map((p) => ({\n      trackingSessionId: sessionId,\n      latitude: new Prisma.Decimal(p.latitude),\n      longitude: new Prisma.Decimal(p.longitude),\n      accuracy: p.accuracy ?? null,\n      recordedAt: new Date(p.recordedAt),\n      eventType: p.eventType ?? null,\n    })),\n  });\n\n  // Calculate incremental distance\n  let addedDistance = 0;\n  let prevLat = session.lastLatitude ? Number(session.lastLatitude) : null;\n  let prevLng = session.lastLongitude ? Number(session.lastLongitude) : null;\n\n  const sorted = [...points].sort(\n    (a, b) => new Date(a.recordedAt).getTime() - new Date(b.recordedAt).getTime()\n  );\n\n  for (const p of sorted) {\n    if (prevLat != null && prevLng != null) {\n      const d = haversineDistance(prevLat, prevLng, p.latitude, p.longitude);\n      if (d <= MAX_DISTANCE_JUMP_M) {\n        addedDistance += d;\n      }\n    }\n    prevLat = p.latitude;\n    prevLng = p.longitude;\n  }\n\n  const lastPoint = sorted[sorted.length - 1]!;\n\n  // Update session summary\n  await prisma.trackingSession.update({\n    where: { id: sessionId },\n    data: {\n      totalPoints: { increment: points.length },\n      totalDistanceM: { increment: addedDistance },\n      lastLatitude: new Prisma.Decimal(lastPoint.latitude),\n      lastLongitude: new Prisma.Decimal(lastPoint.longitude),\n      lastRecordedAt: new Date(lastPoint.recordedAt),\n    },\n  });\n\n  return { accepted: points.length, distance: addedDistance };\n}\n
"},{"location":"v2/features/map/tracking/#gps-auto-tracking-frontend","title":"GPS Auto-Tracking (Frontend)","text":"
// admin/src/components/canvass/GPSTracker.tsx\nuseEffect(() => {\n  if (!trackingSessionId || !enabled) return;\n\n  const pointsBuffer: TrackPoint[] = [];\n\n  const watchId = navigator.geolocation.watchPosition(\n    (position) => {\n      const point = {\n        latitude: position.coords.latitude,\n        longitude: position.coords.longitude,\n        accuracy: position.coords.accuracy,\n        recordedAt: new Date().toISOString(),\n      };\n\n      pointsBuffer.push(point);\n      setCurrentPosition([point.latitude, point.longitude]);\n    },\n    (error) => {\n      console.error('GPS error:', error);\n      message.error('GPS tracking failed');\n    },\n    {\n      enableHighAccuracy: true,\n      maximumAge: 0,\n      timeout: 10000,\n    }\n  );\n\n  // Submit buffered points every 10 seconds\n  const interval = setInterval(async () => {\n    if (pointsBuffer.length === 0) return;\n\n    try {\n      await api.post(`/map/tracking/sessions/${trackingSessionId}/points`, {\n        points: pointsBuffer.splice(0), // Drain buffer\n      });\n    } catch (error) {\n      console.error('Failed to submit GPS points:', error);\n    }\n  }, 10000);\n\n  return () => {\n    navigator.geolocation.clearWatch(watchId);\n    clearInterval(interval);\n  };\n}, [trackingSessionId, enabled]);\n
"},{"location":"v2/features/map/tracking/#route-visualization-frontend","title":"Route Visualization (Frontend)","text":"
// admin/src/pages/volunteer/MyRoutesPage.tsx\nconst fetchRoute = async (sessionId: string) => {\n  const { data } = await api.get(`/map/tracking/sessions/${sessionId}/route`);\n\n  // Convert TrackPoints to polyline coordinates\n  const polyline = data.points.map((p: TrackPoint) => [p.latitude, p.longitude]);\n\n  // Extract event markers\n  const events = data.points\n    .filter((p: TrackPoint) => p.eventType)\n    .map((p: TrackPoint) => ({\n      position: [p.latitude, p.longitude],\n      eventType: p.eventType,\n      recordedAt: p.recordedAt,\n    }));\n\n  setRoute({ polyline, events, distance: data.totalDistanceM });\n};\n\n// Render route\n<Polyline positions={route.polyline} pathOptions={{ color: '#3498db', weight: 3 }} />\n{route.events.map((event, i) => (\n  <Marker\n    key={i}\n    position={event.position}\n    icon={getEventIcon(event.eventType)}\n  >\n    <Popup>{event.eventType} - {dayjs(event.recordedAt).format('HH:mm')}</Popup>\n  </Marker>\n))}\n
"},{"location":"v2/features/map/tracking/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/tracking/#issue-gps-tracking-draining-battery","title":"Issue: GPS Tracking Draining Battery","text":"

Solutions:

  1. Reduce accuracy: enableHighAccuracy: false
  2. Increase submit interval: SUBMIT_INTERVAL_MS = 30000 (30s)
  3. Add pause/resume tracking buttons
"},{"location":"v2/features/map/tracking/#issue-distance-calculation-incorrect","title":"Issue: Distance Calculation Incorrect","text":"

Symptoms: Total distance much higher than expected

Causes: GPS glitches causing large jumps

Solutions:

Increase MAX_DISTANCE_JUMP_M threshold to ignore outliers:

const MAX_DISTANCE_JUMP_M = 2000; // Was 1000, increase to 2000\n
"},{"location":"v2/features/map/tracking/#issue-route-polyline-jagged","title":"Issue: Route Polyline Jagged","text":"

Symptoms: Route looks zigzag instead of smooth

Causes: GPS accuracy poor (\u00b120m)

Solutions:

Apply smoothing algorithm to polyline:

import { simplify } from '@turf/turf';\n\nconst smoothed = simplify(polyline, { tolerance: 0.0001, highQuality: true });\n
"},{"location":"v2/features/map/tracking/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/tracking/#batch-point-insertion","title":"Batch Point Insertion","text":"

Efficient Bulk Insert:

// Insert all points in single transaction\nawait prisma.trackPoint.createMany({\n  data: points.map((p) => ({ ... })),\n});\n\n// Avoid N+1: single UPDATE instead of N UPDATEs\nawait prisma.trackingSession.update({\n  where: { id: sessionId },\n  data: {\n    totalPoints: { increment: points.length },\n    totalDistanceM: { increment: totalDistance },\n  },\n});\n
"},{"location":"v2/features/map/tracking/#query-optimization","title":"Query Optimization","text":"

Index for Route Queries:

CREATE INDEX idx_track_points_session_time ON \"TrackPoint\" (\"trackingSessionId\", \"recordedAt\");\n

Efficient Route Query:

const points = await prisma.trackPoint.findMany({\n  where: { trackingSessionId: sessionId },\n  orderBy: { recordedAt: 'asc' },\n  select: { latitude: true, longitude: true, recordedAt: true, eventType: true },\n});\n
"},{"location":"v2/features/map/tracking/#related-documentation","title":"Related Documentation","text":"
  • Canvassing \u2014 Canvass session integration
  • Tracking Backend Module
  • MyRoutesPage
  • TrackingSession Model
"},{"location":"v2/features/map/walk-sheets/","title":"Walk Sheets & QR Codes","text":""},{"location":"v2/features/map/walk-sheets/#overview","title":"Overview","text":"

The Walk Sheets system provides printable door-to-door canvassing materials with integrated QR code support. This feature enables campaign organizers to generate professional walk sheets for volunteers, complete with address lists, cut boundaries, and quick-access QR codes to campaign resources.

Key Features:

  • Browser-based printing (no server-side PDF generation)
  • Customizable headers, footers, and QR codes
  • Cut-based address filtering
  • Point-in-polygon location selection
  • Print-optimized layout (A4/Letter)
  • Cut export reports with statistics
  • Multi-unit building support
  • Support level indicators

Use Cases:

  • Door-to-door canvassing
  • Volunteer shift materials
  • Cut logistics planning
  • Campaign resource distribution
  • Field data collection

Architecture Highlights:

  • Frontend-only printing (window.print())
  • QR code generation via public API
  • MapSettings singleton for configuration
  • Point-in-polygon filtering for cut locations
  • CSS @media print rules for layout
"},{"location":"v2/features/map/walk-sheets/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph Admin Interface\n        Admin[Admin User]\n        Settings[MapSettingsPage]\n        WalkSheet[WalkSheetPage]\n        CutExport[CutExportPage]\n    end\n\n    subgraph API Layer\n        MapSettingsAPI[\"/api/map-settings\"]\n        CutsAPI[\"/api/cuts/:id\"]\n        LocationsAPI[\"/api/locations?cutId=\"]\n        QRAPI[\"/api/qr/generate\"]\n    end\n\n    subgraph Database\n        MapSettingsDB[(MapSettings)]\n        CutsDB[(Cuts)]\n        LocationsDB[(Locations)]\n    end\n\n    subgraph Print System\n        Preview[Print Preview]\n        Browser[Browser Print Dialog]\n        PDF[PDF Output]\n    end\n\n    Admin --> Settings\n    Admin --> WalkSheet\n    Admin --> CutExport\n\n    Settings --> MapSettingsAPI\n    WalkSheet --> MapSettingsAPI\n    WalkSheet --> CutsAPI\n    WalkSheet --> LocationsAPI\n    WalkSheet --> QRAPI\n    CutExport --> CutsAPI\n    CutExport --> LocationsAPI\n\n    MapSettingsAPI --> MapSettingsDB\n    CutsAPI --> CutsDB\n    LocationsAPI --> LocationsDB\n\n    WalkSheet --> Preview\n    CutExport --> Preview\n    Preview --> Browser\n    Browser --> PDF\n\n    QRAPI --> QRGen[QR Code PNG Generator]\n    QRGen --> Base64[Base64 Data URL]\n    Base64 --> WalkSheet

Data Flow:

  1. Configuration Phase:
  2. Admin configures walk sheet settings (title, subtitle, footer, QR codes)
  3. Settings stored in MapSettings singleton
  4. QR code URLs and labels defined (up to 3)

  5. Generation Phase:

  6. Admin selects cut from dropdown
  7. Frontend fetches cut details and settings
  8. Point-in-polygon filter retrieves locations within cut
  9. QR codes generated via POST /api/qr/generate
  10. Walk sheet rendered with all components

  11. Print Phase:

  12. window.print() triggered
  13. Browser print dialog opens
  14. Print CSS rules applied (hide nav, adjust layout)
  15. User selects printer or \"Save as PDF\"
"},{"location":"v2/features/map/walk-sheets/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/walk-sheets/#mapsettings-model","title":"MapSettings Model","text":"
model MapSettings {\n  id        Int      @id @default(1) // Singleton\n\n  // Walk Sheet Configuration\n  walkSheetTitle    String  @default(\"Walk Sheet\")\n  walkSheetSubtitle String  @default(\"\")\n  walkSheetFooter   String  @default(\"\")\n\n  // QR Code 1\n  qrCode1Url   String?\n  qrCode1Label String?\n\n  // QR Code 2\n  qrCode2Url   String?\n  qrCode2Label String?\n\n  // QR Code 3\n  qrCode3Url   String?\n  qrCode3Label String?\n\n  // Other map settings\n  defaultCenterLat Float   @default(43.6532)\n  defaultCenterLng Float   @default(-79.3832)\n  defaultZoom      Int     @default(12)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n}\n

Singleton Pattern: - Always ID = 1 - Created during seed if not exists - Single source of truth for walk sheet config

"},{"location":"v2/features/map/walk-sheets/#cut-model","title":"Cut Model","text":"
model Cut {\n  id          Int      @id @default(autoincrement())\n  name        String\n  description String?\n  geojson     Json     // GeoJSON Polygon or MultiPolygon\n  color       String   @default(\"#3498db\")\n  visible     Boolean  @default(true)\n\n  shifts      Shift[]\n\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n}\n

GeoJSON Structure:

{\n  \"type\": \"Polygon\",\n  \"coordinates\": [\n    [\n      [-79.38, 43.65],\n      [-79.37, 43.65],\n      [-79.37, 43.66],\n      [-79.38, 43.66],\n      [-79.38, 43.65]\n    ]\n  ]\n}\n

"},{"location":"v2/features/map/walk-sheets/#location-model","title":"Location Model","text":"
model Location {\n  id          Int      @id @default(autoincrement())\n  address     String\n  latitude    Float?\n  longitude   Float?\n  postalCode  String?\n  province    String?\n\n  // Geocoding metadata\n  geocodeConfidence Int?        // 0-100\n  geocodeProvider   String?     // GOOGLE, MAPBOX, etc.\n\n  // NAR import fields\n  locGuid           String?  @unique\n  federalDistrict   String?\n  buildingUse       Int?     // 1 = Residential\n\n  addresses   Address[]\n\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n}\n
"},{"location":"v2/features/map/walk-sheets/#address-model","title":"Address Model","text":"
model Address {\n  id         Int      @id @default(autoincrement())\n  locationId Int\n  location   Location @relation(fields: [locationId], references: [id], onDelete: Cascade)\n\n  unitNumber   String?\n  firstName    String?\n  lastName     String?\n  supportLevel Int?     // 1-5 scale\n  notes        String?\n\n  // NAR import\n  addrGuid String? @unique\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([locationId])\n}\n

Support Level Scale: - 1 = Strong Opposition - 2 = Lean Opposition - 3 = Undecided - 4 = Lean Support - 5 = Strong Support

"},{"location":"v2/features/map/walk-sheets/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/map/walk-sheets/#get-apimap-settings","title":"GET /api/map-settings","text":"

Fetch walk sheet configuration.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Response:

{\n  \"id\": 1,\n  \"walkSheetTitle\": \"Toronto Canvass Walk Sheet\",\n  \"walkSheetSubtitle\": \"Ward 10 - November 2025\",\n  \"walkSheetFooter\": \"Questions? Call HQ at 416-555-1234\",\n  \"qrCode1Url\": \"https://example.com/campaign\",\n  \"qrCode1Label\": \"Campaign Page\",\n  \"qrCode2Url\": \"https://example.com/volunteer\",\n  \"qrCode2Label\": \"Volunteer Portal\",\n  \"qrCode3Url\": \"https://example.com/donate\",\n  \"qrCode3Label\": \"Donate Now\",\n  \"defaultCenterLat\": 43.6532,\n  \"defaultCenterLng\": -79.3832,\n  \"defaultZoom\": 12,\n  \"createdAt\": \"2025-01-15T10:00:00Z\",\n  \"updatedAt\": \"2025-02-10T14:30:00Z\"\n}\n

"},{"location":"v2/features/map/walk-sheets/#put-apimap-settings","title":"PUT /api/map-settings","text":"

Update walk sheet configuration.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Request Body:

{\n  \"walkSheetTitle\": \"Updated Title\",\n  \"walkSheetSubtitle\": \"Updated Subtitle\",\n  \"walkSheetFooter\": \"Updated footer text with contact info\",\n  \"qrCode1Url\": \"https://newurl.com\",\n  \"qrCode1Label\": \"New Label\"\n}\n

Response: Updated MapSettings object

Validation: - walkSheetTitle: 1-100 characters - walkSheetSubtitle: 0-200 characters - walkSheetFooter: 0-500 characters - qrCode URLs: valid HTTP/HTTPS URLs - qrCode labels: 0-50 characters

"},{"location":"v2/features/map/walk-sheets/#get-apicutsid","title":"GET /api/cuts/:id","text":"

Fetch cut details for walk sheet.

Authentication: Required

Response:

{\n  \"id\": 42,\n  \"name\": \"Downtown Core\",\n  \"description\": \"High-density residential area\",\n  \"geojson\": {\n    \"type\": \"Polygon\",\n    \"coordinates\": [[...]]\n  },\n  \"color\": \"#3498db\",\n  \"visible\": true,\n  \"createdAt\": \"2025-01-20T09:00:00Z\",\n  \"updatedAt\": \"2025-02-01T11:00:00Z\"\n}\n

"},{"location":"v2/features/map/walk-sheets/#get-apilocationscutidid","title":"GET /api/locations?cutId=:id","text":"

Fetch locations within cut boundary.

Authentication: Required

Query Parameters: - cutId (required): Cut ID for filtering - sortBy (optional): Field to sort by (default: \"address\") - order (optional): \"asc\" or \"desc\" (default: \"asc\")

Response:

{\n  \"data\": [\n    {\n      \"id\": 1001,\n      \"address\": \"123 Main St\",\n      \"latitude\": 43.6532,\n      \"longitude\": -79.3832,\n      \"postalCode\": \"M5H 2N2\",\n      \"addresses\": [\n        {\n          \"id\": 5001,\n          \"unitNumber\": \"101\",\n          \"firstName\": \"John\",\n          \"lastName\": \"Smith\",\n          \"supportLevel\": 4,\n          \"notes\": \"Lawn sign requested\"\n        },\n        {\n          \"id\": 5002,\n          \"unitNumber\": \"102\",\n          \"firstName\": \"Jane\",\n          \"lastName\": \"Doe\",\n          \"supportLevel\": 5,\n          \"notes\": null\n        }\n      ]\n    }\n  ],\n  \"total\": 150\n}\n

Filtering Logic:

// Point-in-polygon filter\nconst locations = await prisma.location.findMany({\n  where: {\n    AND: [\n      { latitude: { not: null } },\n      { longitude: { not: null } }\n    ]\n  },\n  include: {\n    addresses: {\n      orderBy: { unitNumber: 'asc' }\n    }\n  },\n  orderBy: { address: 'asc' }\n});\n\n// Filter using point-in-polygon\nconst filtered = locations.filter(loc =>\n  isPointInPolygon([loc.longitude!, loc.latitude!], cut.geojson)\n);\n

"},{"location":"v2/features/map/walk-sheets/#post-apiqrgenerate","title":"POST /api/qr/generate","text":"

Generate QR code PNG from URL.

Authentication: None (public endpoint)

Request Body:

{\n  \"url\": \"https://example.com/campaign\",\n  \"size\": 200\n}\n

Parameters: - url (required): Target URL for QR code - size (optional): QR code dimension in pixels (default: 200, max: 500)

Response:

{\n  \"png\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...\"\n}\n

Error Responses: - 400: Invalid URL format - 400: Size must be between 50-500 - 500: QR code generation failed

Rate Limiting: 100 requests per 15 minutes per IP

"},{"location":"v2/features/map/walk-sheets/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/walk-sheets/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description N/A Walk sheet settings stored in database"},{"location":"v2/features/map/walk-sheets/#mapsettings-configuration","title":"MapSettings Configuration","text":"

Access via: Admin \u2192 Settings \u2192 Map Settings

Setting Type Default Max Length Description walkSheetTitle string \"Walk Sheet\" 100 Header title for walk sheets walkSheetSubtitle string \"\" 200 Subtitle below title (ward, date, etc.) walkSheetFooter string \"\" 500 Footer text (contact info, instructions) qrCode1Url string null 2048 First QR code target URL qrCode1Label string null 50 First QR code label qrCode2Url string null 2048 Second QR code target URL qrCode2Label string null 50 Second QR code label qrCode3Url string null 2048 Third QR code target URL qrCode3Label string null 50 Third QR code label

QR Code URL Examples: - Campaign page: https://example.com/campaigns/123 - Volunteer portal: https://example.com/volunteer - Donation page: https://example.com/donate - Social media: https://facebook.com/campaignpage - Google Form: https://forms.google.com/...

QR Code Label Best Practices: - Keep short (2-4 words) - Action-oriented (\"Donate Now\", \"Get Updates\") - Mobile-friendly (scanned on phones) - Clear purpose (\"Campaign Details\", \"Volunteer Info\")

"},{"location":"v2/features/map/walk-sheets/#print-configuration","title":"Print Configuration","text":"

CSS Variables:

@media print {\n  --print-margin: 0.5in;\n  --print-font-size: 10pt;\n  --print-header-size: 16pt;\n  --print-qr-size: 150px;\n  --print-table-border: 1px solid #000;\n}\n

Page Setup: - Size: A4 (210mm \u00d7 297mm) or Letter (8.5\" \u00d7 11\") - Orientation: Portrait - Margins: 0.5 inches (12.7mm) - Print background: Enabled (for borders) - Scale: 100% (no auto-fit)

"},{"location":"v2/features/map/walk-sheets/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/walk-sheets/#configure-walk-sheet-settings","title":"Configure Walk Sheet Settings","text":"

Step 1: Navigate to Map Settings

  1. Log in as SUPER_ADMIN or MAP_ADMIN
  2. Click Settings in sidebar
  3. Click Map Settings submenu
  4. Scroll to \"Walk Sheet Configuration\" section

Step 2: Set Title and Subtitle

Walk Sheet Title: \"Toronto Canvass Walk Sheet\"\nWalk Sheet Subtitle: \"Ward 10 - November 2025 Campaign\"\n

Step 3: Configure QR Codes

QR Code 1:\n  URL: https://example.com/campaign/123\n  Label: Campaign Page\n\nQR Code 2:\n  URL: https://example.com/volunteer\n  Label: Volunteer Sign-Up\n\nQR Code 3:\n  URL: https://example.com/donate\n  Label: Donate Now\n

Step 4: Set Footer Text

Walk Sheet Footer:\n  Questions? Call HQ at 416-555-1234\n  Emergency? Text volunteer coordinator at 416-555-5678\n  Return completed sheets to campaign office by 8 PM\n

Step 5: Save Settings

  • Click Save button
  • Success notification appears
  • Settings applied to all future walk sheets
"},{"location":"v2/features/map/walk-sheets/#generate-walk-sheet","title":"Generate Walk Sheet","text":"

Step 1: Navigate to Walk Sheet Page

  1. Click Map in sidebar
  2. Click Walk Sheet submenu
  3. Walk sheet generator page loads

Step 2: Select Cut

  1. Click Select Cut dropdown
  2. Choose cut from list (e.g., \"Downtown Core\")
  3. Loading indicator shows while fetching locations
  4. Location count displayed (e.g., \"150 locations\")

Step 3: Preview Walk Sheet

Walk sheet displays:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502  Toronto Canvass Walk Sheet                 \u2502\n\u2502  Ward 10 - November 2025 Campaign           \u2502\n\u2502  Cut: Downtown Core                         \u2502\n\u2502  Date: February 13, 2026                    \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Address           \u2502 Unit \u2502 Notes  \u2502 Visited \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 100 Adelaide St E \u2502 101  \u2502 Lawn   \u2502    \u25a1    \u2502\n\u2502 100 Adelaide St E \u2502 102  \u2502        \u2502    \u25a1    \u2502\n\u2502 102 Adelaide St E \u2502      \u2502        \u2502    \u25a1    \u2502\n\u2502 105 Bay St        \u2502 1A   \u2502 Strong \u2502    \u25a1    \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n[QR Code]        [QR Code]        [QR Code]\nCampaign Page    Volunteer Info    Donate Now\n\nQuestions? Call HQ at 416-555-1234\nEmergency? Text volunteer coordinator at 416-555-5678\nReturn completed sheets to campaign office by 8 PM\n

Step 4: Print Walk Sheet

  1. Click Print button (top-right corner)
  2. Browser print dialog opens
  3. Configure print settings:
  4. Destination: Printer or \"Save as PDF\"
  5. Pages: All
  6. Layout: Portrait
  7. Margins: Default
  8. Background graphics: Enabled
  9. Click Print or Save

Step 5: Distribute to Volunteers

  • Print multiple copies for shift volunteers
  • Include shift assignment sheet
  • Provide pens for checkboxes and notes
  • Brief volunteers on walk sheet usage
"},{"location":"v2/features/map/walk-sheets/#generate-cut-export-report","title":"Generate Cut Export Report","text":"

Step 1: Navigate to Cuts Page

  1. Click Map \u2192 Cuts in sidebar
  2. Cuts table loads with list of all cuts

Step 2: Open Cut Export

  1. Find cut row (e.g., \"Downtown Core\")
  2. Click Export button in Actions column
  3. New tab opens with export report

Step 3: Review Statistics

Export report shows:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502  Cut Export Report                          \u2502\n\u2502  Cut: Downtown Core                         \u2502\n\u2502  Generated: February 13, 2026 10:30 AM      \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\nStatistics:\n  Total Locations: 150\n  Total Units: 287\n  Residential: 280 (97.6%)\n  Commercial: 7 (2.4%)\n  Geocoded: 148 (98.7%)\n  Missing Coordinates: 2 (1.3%)\n\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Address         \u2502 Lat  \u2502 Lng   \u2502 Units    \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 100 Adelaide E  \u2502 43.6 \u2502 -79.3 \u2502 2        \u2502\n\u2502 102 Adelaide E  \u2502 43.6 \u2502 -79.3 \u2502 1        \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n[Export CSV Button]  [Print Button]\n

Step 4: Export to CSV

  1. Click Export CSV button
  2. File downloads: cut-42-downtown-core-2026-02-13.csv
  3. Open in spreadsheet for further analysis

CSV Format:

Address,Latitude,Longitude,Postal Code,Units,Residential\n\"100 Adelaide St E\",43.6532,-79.3832,\"M5H 2N2\",2,true\n\"102 Adelaide St E\",43.6540,-79.3825,\"M5H 2N3\",1,true\n

"},{"location":"v2/features/map/walk-sheets/#print-layout","title":"Print Layout","text":""},{"location":"v2/features/map/walk-sheets/#page-structure","title":"Page Structure","text":"
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 [HEADER SECTION]                            \u2502\n\u2502   - Walk Sheet Title                        \u2502\n\u2502   - Subtitle                                \u2502\n\u2502   - Cut Name                                \u2502\n\u2502   - Generated Date                          \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 [ADDRESS TABLE]                             \u2502\n\u2502   - Sortable by street name                \u2502\n\u2502   - Multi-unit grouped                      \u2502\n\u2502   - Support level indicators               \u2502\n\u2502   - Notes column                            \u2502\n\u2502   - Visited checkbox                        \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 [QR CODE SECTION]                           \u2502\n\u2502   - Up to 3 QR codes                        \u2502\n\u2502   - Labels below each code                  \u2502\n\u2502   - Horizontal layout                       \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 [FOOTER SECTION]                            \u2502\n\u2502   - Custom footer text                      \u2502\n\u2502   - Contact information                     \u2502\n\u2502   - Instructions                            \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
"},{"location":"v2/features/map/walk-sheets/#css-print-rules","title":"CSS Print Rules","text":"

Component: WalkSheetPage.tsx

@media print {\n  /* Hide non-printable elements */\n  .no-print,\n  .ant-layout-header,\n  .ant-layout-sider,\n  button,\n  .ant-select,\n  .ant-form,\n  nav {\n    display: none !important;\n  }\n\n  /* Page setup */\n  @page {\n    size: A4 portrait;\n    margin: 0.5in;\n  }\n\n  body {\n    font-size: 10pt;\n    line-height: 1.4;\n    color: #000;\n    background: #fff;\n  }\n\n  /* Header styling */\n  .walk-sheet-header {\n    text-align: center;\n    margin-bottom: 20px;\n    border-bottom: 2px solid #000;\n    padding-bottom: 10px;\n  }\n\n  .walk-sheet-title {\n    font-size: 16pt;\n    font-weight: bold;\n    margin-bottom: 5px;\n  }\n\n  .walk-sheet-subtitle {\n    font-size: 12pt;\n    color: #333;\n  }\n\n  /* Table styling */\n  table {\n    width: 100%;\n    border-collapse: collapse;\n    page-break-inside: avoid;\n    margin-bottom: 20px;\n  }\n\n  th, td {\n    border: 1px solid #000;\n    padding: 6px;\n    text-align: left;\n  }\n\n  th {\n    background-color: #f0f0f0;\n    font-weight: bold;\n    font-size: 9pt;\n  }\n\n  td {\n    font-size: 9pt;\n  }\n\n  /* Prevent row breaks */\n  tr {\n    page-break-inside: avoid;\n  }\n\n  /* QR code section */\n  .qr-code-section {\n    display: flex;\n    justify-content: space-around;\n    margin: 20px 0;\n    page-break-inside: avoid;\n  }\n\n  .qr-code-item {\n    text-align: center;\n    width: 150px;\n  }\n\n  .qr-code-item img {\n    width: 150px;\n    height: 150px;\n    margin-bottom: 5px;\n  }\n\n  .qr-code-label {\n    font-size: 9pt;\n    font-weight: bold;\n  }\n\n  /* Footer styling */\n  .walk-sheet-footer {\n    margin-top: 20px;\n    padding-top: 10px;\n    border-top: 1px solid #000;\n    font-size: 9pt;\n    white-space: pre-wrap;\n  }\n\n  /* Checkbox styling */\n  .visited-checkbox {\n    width: 15px;\n    height: 15px;\n    border: 1px solid #000;\n    display: inline-block;\n  }\n\n  /* Support level indicators */\n  .support-level-1 { color: #e74c3c; } /* Strong Opposition */\n  .support-level-2 { color: #f39c12; } /* Lean Opposition */\n  .support-level-3 { color: #95a5a6; } /* Undecided */\n  .support-level-4 { color: #3498db; } /* Lean Support */\n  .support-level-5 { color: #27ae60; } /* Strong Support */\n}\n
"},{"location":"v2/features/map/walk-sheets/#address-table-layout","title":"Address Table Layout","text":"

Column Structure:

Column Width Content Sort Address 40% Street address Alphabetical Unit 10% Unit/apartment number Alphanumeric Name 20% First + Last name Alphabetical Support 10% Support level (1-5) Color-coded Notes 15% Canvasser notes N/A Visited 5% Checkbox N/A

Multi-Unit Grouping:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Address           \u2502 Unit \u2502 Name       \u2502 Support \u2502 Notes  \u2502 Visited \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 100 Adelaide St E \u2502 101  \u2502 John Smith \u2502    4    \u2502 Lawn   \u2502    \u25a1    \u2502\n\u2502 100 Adelaide St E \u2502 102  \u2502 Jane Doe   \u2502    5    \u2502        \u2502    \u25a1    \u2502\n\u2502 100 Adelaide St E \u2502 103  \u2502            \u2502         \u2502        \u2502    \u25a1    \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 102 Adelaide St E \u2502      \u2502            \u2502         \u2502        \u2502    \u25a1    \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n

Support Level Colors: - 1 (Strong Opposition): Red (#e74c3c) - 2 (Lean Opposition): Orange (#f39c12) - 3 (Undecided): Gray (#95a5a6) - 4 (Lean Support): Blue (#3498db) - 5 (Strong Support): Green (#27ae60)

"},{"location":"v2/features/map/walk-sheets/#qr-code-layout","title":"QR Code Layout","text":"

Horizontal Layout:

    [QR 150\u00d7150]         [QR 150\u00d7150]         [QR 150\u00d7150]\n    Campaign Page        Volunteer Info        Donate Now\n

QR Code Generation: - Size: 150\u00d7150 pixels - Error correction: Medium (M) - Format: PNG with transparent background - Encoding: UTF-8 - Margin: 4 modules

Spacing: - Between codes: 30px - Above section: 20px - Below section: 20px - Label margin: 5px

"},{"location":"v2/features/map/walk-sheets/#cut-export-page","title":"Cut Export Page","text":""},{"location":"v2/features/map/walk-sheets/#export-report-structure","title":"Export Report Structure","text":"

Component: CutExportPage.tsx

Route: /app/map/cuts/:id/export

Layout:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502  Cut Export Report                          \u2502\n\u2502  [Cut Name]                                 \u2502\n\u2502  Generated: [Date Time]                     \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502  STATISTICS PANEL                           \u2502\n\u2502  \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510            \u2502\n\u2502  \u2502 Total Locs   \u2502 Geocoded     \u2502            \u2502\n\u2502  \u2502 150          \u2502 148 (98.7%)  \u2502            \u2502\n\u2502  \u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524            \u2502\n\u2502  \u2502 Total Units  \u2502 Residential  \u2502            \u2502\n\u2502  \u2502 287          \u2502 280 (97.6%)  \u2502            \u2502\n\u2502  \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518            \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502  LOCATION TABLE                             \u2502\n\u2502  [Sortable, filterable table]              \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502  ACTIONS                                    \u2502\n\u2502  [Export CSV] [Print]                       \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
"},{"location":"v2/features/map/walk-sheets/#statistics-panel","title":"Statistics Panel","text":"

Metrics Displayed:

  1. Total Locations: Count of locations within cut
  2. Total Units: Sum of addresses across all locations
  3. Geocoded Locations: Locations with lat/lng (% of total)
  4. Missing Coordinates: Locations without lat/lng
  5. Residential Units: Units with buildingUse = 1
  6. Commercial Units: Units with buildingUse != 1
  7. Support Level Breakdown: Count by level (1-5)
  8. Cut Area: Approximate area in square kilometers

Statistics Calculation:

interface CutStatistics {\n  totalLocations: number;\n  totalUnits: number;\n  geocodedCount: number;\n  geocodedPercent: number;\n  missingCoordinates: number;\n  residentialCount: number;\n  residentialPercent: number;\n  commercialCount: number;\n  supportLevelBreakdown: Record<number, number>;\n  cutAreaKm2: number;\n}\n\nconst calculateStats = (locations: Location[]): CutStatistics => {\n  const totalLocations = locations.length;\n  const totalUnits = locations.reduce((sum, loc) =>\n    sum + loc.addresses.length, 0);\n  const geocodedCount = locations.filter(loc =>\n    loc.latitude && loc.longitude).length;\n  const residentialCount = locations.filter(loc =>\n    loc.buildingUse === 1).length;\n\n  const supportLevelBreakdown = {};\n  locations.forEach(loc => {\n    loc.addresses.forEach(addr => {\n      if (addr.supportLevel) {\n        supportLevelBreakdown[addr.supportLevel] =\n          (supportLevelBreakdown[addr.supportLevel] || 0) + 1;\n      }\n    });\n  });\n\n  return {\n    totalLocations,\n    totalUnits,\n    geocodedCount,\n    geocodedPercent: (geocodedCount / totalLocations) * 100,\n    missingCoordinates: totalLocations - geocodedCount,\n    residentialCount,\n    residentialPercent: (residentialCount / totalLocations) * 100,\n    commercialCount: totalLocations - residentialCount,\n    supportLevelBreakdown,\n    cutAreaKm2: calculatePolygonArea(cut.geojson)\n  };\n};\n
"},{"location":"v2/features/map/walk-sheets/#location-table","title":"Location Table","text":"

Columns:

Column Data Format Address location.address String Latitude location.latitude 6 decimals Longitude location.longitude 6 decimals Postal Code location.postalCode Uppercase Units addresses.length Integer Residential buildingUse === 1 Boolean Support Avg avg(addresses.supportLevel) 1 decimal

Table Features:

  • Sortable by all columns
  • Filterable by postal code prefix
  • Pagination (50 per page)
  • Export selected rows to CSV
  • Highlight locations with missing coordinates
  • Color-code by average support level
"},{"location":"v2/features/map/walk-sheets/#csv-export","title":"CSV Export","text":"

Export Button Handler:

const exportToCSV = () => {\n  const headers = [\n    'Address',\n    'Latitude',\n    'Longitude',\n    'Postal Code',\n    'Units',\n    'Residential',\n    'Support Average',\n    'Federal District'\n  ];\n\n  const rows = locations.map(loc => [\n    loc.address,\n    loc.latitude?.toFixed(6) || '',\n    loc.longitude?.toFixed(6) || '',\n    loc.postalCode || '',\n    loc.addresses.length,\n    loc.buildingUse === 1 ? 'Yes' : 'No',\n    calculateAverageSupportLevel(loc.addresses).toFixed(1),\n    loc.federalDistrict || ''\n  ]);\n\n  const csv = [headers, ...rows]\n    .map(row => row.map(cell => `\"${cell}\"`).join(','))\n    .join('\\n');\n\n  const blob = new Blob([csv], { type: 'text/csv' });\n  const url = URL.createObjectURL(blob);\n  const link = document.createElement('a');\n  link.href = url;\n  link.download = `cut-${cutId}-${cutName}-${new Date().toISOString().split('T')[0]}.csv`;\n  link.click();\n  URL.revokeObjectURL(url);\n};\n

CSV Output Example:

\"Address\",\"Latitude\",\"Longitude\",\"Postal Code\",\"Units\",\"Residential\",\"Support Average\",\"Federal District\"\n\"100 Adelaide St E\",\"43.653200\",\"-79.383200\",\"M5H 2N2\",\"2\",\"Yes\",\"4.5\",\"Toronto Centre\"\n\"102 Adelaide St E\",\"43.654000\",\"-79.382500\",\"M5H 2N3\",\"1\",\"Yes\",\"3.0\",\"Toronto Centre\"\n\"105 Bay St\",\"43.650000\",\"-79.380000\",\"M5J 2R8\",\"12\",\"Yes\",\"4.2\",\"Toronto Centre\"\n
"},{"location":"v2/features/map/walk-sheets/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/walk-sheets/#walksheetpagetsx-component-structure","title":"WalkSheetPage.tsx - Component Structure","text":"
import React, { useEffect, useState } from 'react';\nimport { Select, Button, Table, Space, Spin, Typography, Row, Col } from 'antd';\nimport { PrinterOutlined } from '@ant-design/icons';\nimport { api } from '@/lib/api';\nimport type { Cut, Location, MapSettings } from '@/types/api';\n\nconst { Title, Text } = Typography;\n\nconst WalkSheetPage: React.FC = () => {\n  const [cuts, setCuts] = useState<Cut[]>([]);\n  const [selectedCutId, setSelectedCutId] = useState<number | null>(null);\n  const [locations, setLocations] = useState<Location[]>([]);\n  const [settings, setSettings] = useState<MapSettings | null>(null);\n  const [qrCodes, setQrCodes] = useState<Record<number, string>>({});\n  const [loading, setLoading] = useState(false);\n\n  useEffect(() => {\n    fetchCuts();\n    fetchSettings();\n  }, []);\n\n  useEffect(() => {\n    if (selectedCutId) {\n      fetchLocations(selectedCutId);\n    }\n  }, [selectedCutId]);\n\n  useEffect(() => {\n    if (settings) {\n      generateQRCodes();\n    }\n  }, [settings]);\n\n  const fetchCuts = async () => {\n    const { data } = await api.get<Cut[]>('/cuts');\n    setCuts(data);\n  };\n\n  const fetchSettings = async () => {\n    const { data } = await api.get<MapSettings>('/map-settings');\n    setSettings(data);\n  };\n\n  const fetchLocations = async (cutId: number) => {\n    setLoading(true);\n    try {\n      const { data } = await api.get<{ data: Location[] }>(\n        `/locations?cutId=${cutId}&sortBy=address&order=asc`\n      );\n      setLocations(data.data);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const generateQRCodes = async () => {\n    if (!settings) return;\n\n    const codes: Record<number, string> = {};\n    const qrUrls = [\n      { url: settings.qrCode1Url, index: 1 },\n      { url: settings.qrCode2Url, index: 2 },\n      { url: settings.qrCode3Url, index: 3 }\n    ].filter(item => item.url);\n\n    for (const { url, index } of qrUrls) {\n      try {\n        const { data } = await api.post('/qr/generate', { url, size: 150 });\n        codes[index] = data.png;\n      } catch (error) {\n        console.error(`Failed to generate QR code ${index}:`, error);\n      }\n    }\n\n    setQrCodes(codes);\n  };\n\n  const handlePrint = () => {\n    window.print();\n  };\n\n  const columns = [\n    {\n      title: 'Address',\n      dataIndex: 'address',\n      key: 'address',\n      width: '40%'\n    },\n    {\n      title: 'Unit',\n      key: 'unit',\n      width: '10%',\n      render: (_: any, record: Location) => (\n        <Space direction=\"vertical\" size={0}>\n          {record.addresses.map(addr => (\n            <Text key={addr.id}>{addr.unitNumber || '-'}</Text>\n          ))}\n        </Space>\n      )\n    },\n    {\n      title: 'Name',\n      key: 'name',\n      width: '20%',\n      render: (_: any, record: Location) => (\n        <Space direction=\"vertical\" size={0}>\n          {record.addresses.map(addr => (\n            <Text key={addr.id}>\n              {addr.firstName && addr.lastName\n                ? `${addr.firstName} ${addr.lastName}`\n                : '-'}\n            </Text>\n          ))}\n        </Space>\n      )\n    },\n    {\n      title: 'Support',\n      key: 'support',\n      width: '10%',\n      render: (_: any, record: Location) => (\n        <Space direction=\"vertical\" size={0}>\n          {record.addresses.map(addr => (\n            <Text\n              key={addr.id}\n              className={addr.supportLevel ? `support-level-${addr.supportLevel}` : ''}\n            >\n              {addr.supportLevel || '-'}\n            </Text>\n          ))}\n        </Space>\n      )\n    },\n    {\n      title: 'Notes',\n      key: 'notes',\n      width: '15%',\n      render: (_: any, record: Location) => (\n        <Space direction=\"vertical\" size={0}>\n          {record.addresses.map(addr => (\n            <Text key={addr.id} ellipsis={{ tooltip: addr.notes }}>\n              {addr.notes || '-'}\n            </Text>\n          ))}\n        </Space>\n      )\n    },\n    {\n      title: 'Visited',\n      key: 'visited',\n      width: '5%',\n      render: (_: any, record: Location) => (\n        <Space direction=\"vertical\" size={0}>\n          {record.addresses.map(addr => (\n            <div key={addr.id} className=\"visited-checkbox\" />\n          ))}\n        </Space>\n      )\n    }\n  ];\n\n  const selectedCut = cuts.find(c => c.id === selectedCutId);\n\n  return (\n    <div className=\"walk-sheet-page\">\n      {/* Controls - hidden when printing */}\n      <div className=\"no-print\" style={{ marginBottom: 24 }}>\n        <Space>\n          <Select\n            style={{ width: 300 }}\n            placeholder=\"Select a cut\"\n            value={selectedCutId}\n            onChange={setSelectedCutId}\n            options={cuts.map(cut => ({\n              label: cut.name,\n              value: cut.id\n            }))}\n          />\n          <Button\n            type=\"primary\"\n            icon={<PrinterOutlined />}\n            onClick={handlePrint}\n            disabled={!selectedCutId || loading}\n          >\n            Print\n          </Button>\n        </Space>\n      </div>\n\n      {/* Walk Sheet Content - printed */}\n      {selectedCutId && settings && (\n        <>\n          {/* Header */}\n          <div className=\"walk-sheet-header\">\n            <Title level={2} className=\"walk-sheet-title\">\n              {settings.walkSheetTitle}\n            </Title>\n            {settings.walkSheetSubtitle && (\n              <Text className=\"walk-sheet-subtitle\">\n                {settings.walkSheetSubtitle}\n              </Text>\n            )}\n            <div style={{ marginTop: 8 }}>\n              <Text strong>Cut: </Text>\n              <Text>{selectedCut?.name}</Text>\n              <br />\n              <Text strong>Date: </Text>\n              <Text>{new Date().toLocaleDateString()}</Text>\n            </div>\n          </div>\n\n          {/* Address Table */}\n          {loading ? (\n            <div style={{ textAlign: 'center', padding: 40 }}>\n              <Spin size=\"large\" />\n            </div>\n          ) : (\n            <Table\n              dataSource={locations}\n              columns={columns}\n              pagination={false}\n              rowKey=\"id\"\n              bordered\n            />\n          )}\n\n          {/* QR Codes */}\n          {Object.keys(qrCodes).length > 0 && (\n            <Row gutter={16} className=\"qr-code-section\">\n              {[1, 2, 3].map(index => {\n                const qrUrl = settings[`qrCode${index}Url` as keyof MapSettings];\n                const qrLabel = settings[`qrCode${index}Label` as keyof MapSettings];\n                if (!qrUrl || !qrCodes[index]) return null;\n\n                return (\n                  <Col key={index} span={8} className=\"qr-code-item\">\n                    <img src={qrCodes[index]} alt={`QR Code ${index}`} />\n                    <div className=\"qr-code-label\">{qrLabel}</div>\n                  </Col>\n                );\n              })}\n            </Row>\n          )}\n\n          {/* Footer */}\n          {settings.walkSheetFooter && (\n            <div className=\"walk-sheet-footer\">\n              {settings.walkSheetFooter}\n            </div>\n          )}\n        </>\n      )}\n\n      {/* Print Styles */}\n      <style>{`\n        @media print {\n          .no-print {\n            display: none !important;\n          }\n\n          @page {\n            size: A4 portrait;\n            margin: 0.5in;\n          }\n\n          body {\n            font-size: 10pt;\n            line-height: 1.4;\n          }\n\n          .walk-sheet-header {\n            text-align: center;\n            margin-bottom: 20px;\n            border-bottom: 2px solid #000;\n            padding-bottom: 10px;\n          }\n\n          .walk-sheet-title {\n            font-size: 16pt !important;\n            margin-bottom: 5px !important;\n          }\n\n          .walk-sheet-subtitle {\n            font-size: 12pt;\n          }\n\n          table {\n            page-break-inside: avoid;\n          }\n\n          th, td {\n            font-size: 9pt !important;\n            padding: 6px !important;\n          }\n\n          .visited-checkbox {\n            width: 15px;\n            height: 15px;\n            border: 1px solid #000;\n            display: inline-block;\n          }\n\n          .support-level-1 { color: #e74c3c; }\n          .support-level-2 { color: #f39c12; }\n          .support-level-3 { color: #95a5a6; }\n          .support-level-4 { color: #3498db; }\n          .support-level-5 { color: #27ae60; }\n\n          .qr-code-section {\n            display: flex;\n            justify-content: space-around;\n            margin: 20px 0;\n            page-break-inside: avoid;\n          }\n\n          .qr-code-item {\n            text-align: center;\n          }\n\n          .qr-code-item img {\n            width: 150px;\n            height: 150px;\n          }\n\n          .qr-code-label {\n            font-size: 9pt;\n            font-weight: bold;\n            margin-top: 5px;\n          }\n\n          .walk-sheet-footer {\n            margin-top: 20px;\n            padding-top: 10px;\n            border-top: 1px solid #000;\n            font-size: 9pt;\n            white-space: pre-wrap;\n          }\n        }\n      `}</style>\n    </div>\n  );\n};\n\nexport default WalkSheetPage;\n
"},{"location":"v2/features/map/walk-sheets/#qr-code-api-qrroutests","title":"QR Code API - qr.routes.ts","text":"
import { Router } from 'express';\nimport QRCode from 'qrcode';\nimport { z } from 'zod';\nimport { validate } from '@/middleware/validate';\nimport rateLimit from 'express-rate-limit';\n\nconst router = Router();\n\n// Rate limiter: 100 requests per 15 minutes\nconst qrLimiter = rateLimit({\n  windowMs: 15 * 60 * 1000,\n  max: 100,\n  message: 'Too many QR code requests, please try again later'\n});\n\nconst generateQRSchema = z.object({\n  body: z.object({\n    url: z.string().url('Must be a valid URL'),\n    size: z.number().int().min(50).max(500).optional().default(200)\n  })\n});\n\n/**\n * POST /api/qr/generate\n * Generate QR code PNG from URL\n * Public endpoint (no authentication)\n */\nrouter.post(\n  '/generate',\n  qrLimiter,\n  validate(generateQRSchema),\n  async (req, res, next) => {\n    try {\n      const { url, size } = req.body;\n\n      // Generate QR code as data URL\n      const png = await QRCode.toDataURL(url, {\n        width: size,\n        margin: 4,\n        errorCorrectionLevel: 'M',\n        type: 'image/png'\n      });\n\n      res.json({ png });\n    } catch (error) {\n      next(error);\n    }\n  }\n);\n\nexport default router;\n
"},{"location":"v2/features/map/walk-sheets/#mapsettingspagetsx-qr-code-configuration","title":"MapSettingsPage.tsx - QR Code Configuration","text":"
import React, { useEffect } from 'react';\nimport { Form, Input, Button, message, Divider, Space, Typography } from 'antd';\nimport { api } from '@/lib/api';\nimport type { MapSettings } from '@/types/api';\n\nconst { Title, Text } = Typography;\n\nconst MapSettingsPage: React.FC = () => {\n  const [form] = Form.useForm();\n  const [loading, setLoading] = useState(false);\n\n  useEffect(() => {\n    fetchSettings();\n  }, []);\n\n  const fetchSettings = async () => {\n    setLoading(true);\n    try {\n      const { data } = await api.get<MapSettings>('/map-settings');\n      form.setFieldsValue(data);\n    } catch (error) {\n      message.error('Failed to load map settings');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleSubmit = async (values: Partial<MapSettings>) => {\n    setLoading(true);\n    try {\n      await api.put('/map-settings', values);\n      message.success('Settings saved successfully');\n    } catch (error) {\n      message.error('Failed to save settings');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  return (\n    <div>\n      <Title level={2}>Map Settings</Title>\n\n      <Form\n        form={form}\n        layout=\"vertical\"\n        onFinish={handleSubmit}\n        disabled={loading}\n      >\n        <Divider orientation=\"left\">Walk Sheet Configuration</Divider>\n\n        <Form.Item\n          label=\"Walk Sheet Title\"\n          name=\"walkSheetTitle\"\n          rules={[\n            { required: true, message: 'Title is required' },\n            { max: 100, message: 'Maximum 100 characters' }\n          ]}\n        >\n          <Input placeholder=\"Walk Sheet\" />\n        </Form.Item>\n\n        <Form.Item\n          label=\"Walk Sheet Subtitle\"\n          name=\"walkSheetSubtitle\"\n          rules={[{ max: 200, message: 'Maximum 200 characters' }]}\n        >\n          <Input placeholder=\"Ward 10 - November 2025\" />\n        </Form.Item>\n\n        <Form.Item\n          label=\"Walk Sheet Footer\"\n          name=\"walkSheetFooter\"\n          rules={[{ max: 500, message: 'Maximum 500 characters' }]}\n        >\n          <Input.TextArea\n            rows={4}\n            placeholder=\"Contact information, instructions, etc.\"\n          />\n        </Form.Item>\n\n        <Divider orientation=\"left\">QR Code 1</Divider>\n\n        <Form.Item\n          label=\"QR Code 1 URL\"\n          name=\"qrCode1Url\"\n          rules={[{ type: 'url', message: 'Must be a valid URL' }]}\n        >\n          <Input placeholder=\"https://example.com/campaign\" />\n        </Form.Item>\n\n        <Form.Item\n          label=\"QR Code 1 Label\"\n          name=\"qrCode1Label\"\n          rules={[{ max: 50, message: 'Maximum 50 characters' }]}\n        >\n          <Input placeholder=\"Campaign Page\" />\n        </Form.Item>\n\n        <Divider orientation=\"left\">QR Code 2</Divider>\n\n        <Form.Item\n          label=\"QR Code 2 URL\"\n          name=\"qrCode2Url\"\n          rules={[{ type: 'url', message: 'Must be a valid URL' }]}\n        >\n          <Input placeholder=\"https://example.com/volunteer\" />\n        </Form.Item>\n\n        <Form.Item\n          label=\"QR Code 2 Label\"\n          name=\"qrCode2Label\"\n          rules={[{ max: 50, message: 'Maximum 50 characters' }]}\n        >\n          <Input placeholder=\"Volunteer Info\" />\n        </Form.Item>\n\n        <Divider orientation=\"left\">QR Code 3</Divider>\n\n        <Form.Item\n          label=\"QR Code 3 URL\"\n          name=\"qrCode3Url\"\n          rules={[{ type: 'url', message: 'Must be a valid URL' }]}\n        >\n          <Input placeholder=\"https://example.com/donate\" />\n        </Form.Item>\n\n        <Form.Item\n          label=\"QR Code 3 Label\"\n          name=\"qrCode3Label\"\n          rules={[{ max: 50, message: 'Maximum 50 characters' }]}\n        >\n          <Input placeholder=\"Donate Now\" />\n        </Form.Item>\n\n        <Form.Item>\n          <Space>\n            <Button type=\"primary\" htmlType=\"submit\" loading={loading}>\n              Save Settings\n            </Button>\n            <Button onClick={fetchSettings}>Reset</Button>\n          </Space>\n        </Form.Item>\n      </Form>\n    </div>\n  );\n};\n\nexport default MapSettingsPage;\n
"},{"location":"v2/features/map/walk-sheets/#cutexportpagetsx-statistics-and-csv-export","title":"CutExportPage.tsx - Statistics and CSV Export","text":"
import React, { useEffect, useState } from 'react';\nimport { useParams } from 'react-router-dom';\nimport { Button, Table, Card, Row, Col, Statistic, Space, message } from 'antd';\nimport { PrinterOutlined, DownloadOutlined } from '@ant-design/icons';\nimport { api } from '@/lib/api';\nimport type { Cut, Location } from '@/types/api';\n\nconst CutExportPage: React.FC = () => {\n  const { id } = useParams<{ id: string }>();\n  const cutId = parseInt(id);\n\n  const [cut, setCut] = useState<Cut | null>(null);\n  const [locations, setLocations] = useState<Location[]>([]);\n  const [loading, setLoading] = useState(false);\n\n  useEffect(() => {\n    fetchData();\n  }, [cutId]);\n\n  const fetchData = async () => {\n    setLoading(true);\n    try {\n      const [cutRes, locsRes] = await Promise.all([\n        api.get<Cut>(`/cuts/${cutId}`),\n        api.get<{ data: Location[] }>(`/locations?cutId=${cutId}`)\n      ]);\n      setCut(cutRes.data);\n      setLocations(locsRes.data.data);\n    } catch (error) {\n      message.error('Failed to load cut data');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const calculateStats = () => {\n    const totalLocations = locations.length;\n    const totalUnits = locations.reduce((sum, loc) => sum + loc.addresses.length, 0);\n    const geocoded = locations.filter(loc => loc.latitude && loc.longitude).length;\n    const residential = locations.filter(loc => loc.buildingUse === 1).length;\n\n    return {\n      totalLocations,\n      totalUnits,\n      geocoded,\n      geocodedPercent: totalLocations > 0 ? (geocoded / totalLocations) * 100 : 0,\n      residential,\n      residentialPercent: totalLocations > 0 ? (residential / totalLocations) * 100 : 0\n    };\n  };\n\n  const exportToCSV = () => {\n    const headers = [\n      'Address',\n      'Latitude',\n      'Longitude',\n      'Postal Code',\n      'Units',\n      'Residential'\n    ];\n\n    const rows = locations.map(loc => [\n      loc.address,\n      loc.latitude?.toFixed(6) || '',\n      loc.longitude?.toFixed(6) || '',\n      loc.postalCode || '',\n      loc.addresses.length,\n      loc.buildingUse === 1 ? 'Yes' : 'No'\n    ]);\n\n    const csv = [headers, ...rows]\n      .map(row => row.map(cell => `\"${cell}\"`).join(','))\n      .join('\\n');\n\n    const blob = new Blob([csv], { type: 'text/csv' });\n    const url = URL.createObjectURL(blob);\n    const link = document.createElement('a');\n    link.href = url;\n    link.download = `cut-${cutId}-${cut?.name.replace(/\\s+/g, '-').toLowerCase()}-${new Date().toISOString().split('T')[0]}.csv`;\n    link.click();\n    URL.revokeObjectURL(url);\n  };\n\n  const handlePrint = () => {\n    window.print();\n  };\n\n  const stats = calculateStats();\n\n  const columns = [\n    { title: 'Address', dataIndex: 'address', key: 'address' },\n    {\n      title: 'Latitude',\n      dataIndex: 'latitude',\n      key: 'latitude',\n      render: (val: number) => val?.toFixed(6) || 'N/A'\n    },\n    {\n      title: 'Longitude',\n      dataIndex: 'longitude',\n      key: 'longitude',\n      render: (val: number) => val?.toFixed(6) || 'N/A'\n    },\n    { title: 'Postal Code', dataIndex: 'postalCode', key: 'postalCode' },\n    {\n      title: 'Units',\n      key: 'units',\n      render: (_: any, record: Location) => record.addresses.length\n    },\n    {\n      title: 'Residential',\n      dataIndex: 'buildingUse',\n      key: 'residential',\n      render: (val: number) => val === 1 ? 'Yes' : 'No'\n    }\n  ];\n\n  return (\n    <div className=\"cut-export-page\">\n      <div className=\"no-print\" style={{ marginBottom: 24 }}>\n        <Space>\n          <Button icon={<PrinterOutlined />} onClick={handlePrint}>\n            Print\n          </Button>\n          <Button icon={<DownloadOutlined />} onClick={exportToCSV}>\n            Export CSV\n          </Button>\n        </Space>\n      </div>\n\n      <div className=\"cut-export-header\">\n        <h1>Cut Export Report</h1>\n        <h2>{cut?.name}</h2>\n        <p>Generated: {new Date().toLocaleString()}</p>\n      </div>\n\n      <Card title=\"Statistics\" style={{ marginBottom: 24 }}>\n        <Row gutter={16}>\n          <Col span={6}>\n            <Statistic title=\"Total Locations\" value={stats.totalLocations} />\n          </Col>\n          <Col span={6}>\n            <Statistic title=\"Total Units\" value={stats.totalUnits} />\n          </Col>\n          <Col span={6}>\n            <Statistic\n              title=\"Geocoded\"\n              value={stats.geocoded}\n              suffix={`(${stats.geocodedPercent.toFixed(1)}%)`}\n            />\n          </Col>\n          <Col span={6}>\n            <Statistic\n              title=\"Residential\"\n              value={stats.residential}\n              suffix={`(${stats.residentialPercent.toFixed(1)}%)`}\n            />\n          </Col>\n        </Row>\n      </Card>\n\n      <Table\n        dataSource={locations}\n        columns={columns}\n        rowKey=\"id\"\n        loading={loading}\n        pagination={{ pageSize: 50 }}\n      />\n\n      <style>{`\n        @media print {\n          .no-print { display: none !important; }\n          @page { size: A4 landscape; margin: 0.5in; }\n          body { font-size: 10pt; }\n        }\n      `}</style>\n    </div>\n  );\n};\n\nexport default CutExportPage;\n
"},{"location":"v2/features/map/walk-sheets/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/walk-sheets/#problem-qr-codes-not-generating","title":"Problem: QR codes not generating","text":"

Symptoms: - Empty QR code section on walk sheet - Console errors about /api/qr/generate - Network 404 or 500 errors

Solutions:

  1. Verify endpoint accessibility:

    curl -X POST http://localhost:4000/api/qr/generate \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"url\":\"https://example.com\",\"size\":200}'\n

  2. Check qrcode package installed:

    cd api\nnpm list qrcode\n# If not installed:\nnpm install qrcode\nnpm install --save-dev @types/qrcode\n

  3. Verify route registration in server.ts:

    import qrRoutes from './modules/qr/qr.routes';\napp.use('/api/qr', qrRoutes);\n

  4. Check URL validation:

    // URL must start with http:// or https://\nconst validUrls = [\n  'https://example.com',  // \u2713 Valid\n  'http://example.com',   // \u2713 Valid\n  'example.com',          // \u2717 Invalid (missing protocol)\n  'ftp://example.com'     // \u2717 Invalid (wrong protocol)\n];\n

  5. Test with simple URL:

    // Test with minimal payload\nconst testQR = async () => {\n  const { data } = await api.post('/qr/generate', {\n    url: 'https://google.com'\n    // size omitted (uses default 200)\n  });\n  console.log('QR generated:', data.png.substring(0, 50));\n};\n

"},{"location":"v2/features/map/walk-sheets/#problem-print-layout-broken","title":"Problem: Print layout broken","text":"

Symptoms: - Elements overlap when printing - Missing borders or backgrounds - Incorrect page breaks - Cut-off content

Solutions:

  1. Enable background graphics in browser:
  2. Chrome: Print \u2192 More settings \u2192 Background graphics (checked)
  3. Firefox: Print \u2192 Options \u2192 Print backgrounds (checked)
  4. Safari: Print \u2192 Show Details \u2192 Print backgrounds (checked)

  5. Test print preview first:

    // Add print preview button for debugging\nconst handlePrintPreview = () => {\n  const printWindow = window.open('', '_blank');\n  printWindow?.document.write(document.documentElement.outerHTML);\n  printWindow?.print();\n};\n

  6. Check @page margins:

    @media print {\n  @page {\n    size: A4 portrait;\n    margin: 0.5in; /* Adjust if content cut off */\n  }\n}\n

  7. Prevent table row breaks:

    @media print {\n  tr {\n    page-break-inside: avoid;\n    page-break-after: auto;\n  }\n\n  thead {\n    display: table-header-group; /* Repeat on each page */\n  }\n}\n

  8. Test in different browsers:

  9. Chrome/Edge: Best print CSS support
  10. Firefox: Good, but some layout differences
  11. Safari: May require webkit prefixes

  12. Adjust font sizes if content overflows:

    @media print {\n  body { font-size: 9pt; } /* Reduce from 10pt */\n  th, td { font-size: 8pt; } /* Reduce from 9pt */\n}\n

"},{"location":"v2/features/map/walk-sheets/#problem-walk-sheet-showing-wrong-cut","title":"Problem: Walk sheet showing wrong cut","text":"

Symptoms: - Selected cut shows different locations - Location count doesn't match cut - Locations outside cut boundary visible

Solutions:

  1. Verify cutId in API request:

    console.log('Fetching locations for cut:', selectedCutId);\nconst { data } = await api.get(`/locations?cutId=${selectedCutId}`);\nconsole.log('Received locations:', data.data.length);\n

  2. Check point-in-polygon filter:

    // In locations.service.ts\nconst locations = await prisma.location.findMany({\n  where: {\n    AND: [\n      { latitude: { not: null } },\n      { longitude: { not: null } }\n    ]\n  }\n});\n\n// Filter by cut boundary\nconst cut = await prisma.cut.findUnique({ where: { id: cutId } });\nconst filtered = locations.filter(loc =>\n  isPointInPolygon([loc.longitude!, loc.latitude!], cut.geojson)\n);\n\nconsole.log('Total locations:', locations.length);\nconsole.log('Within cut:', filtered.length);\n

  3. Test with simple rectangular cut:

    {\n  \"type\": \"Polygon\",\n  \"coordinates\": [\n    [\n      [-79.40, 43.64],\n      [-79.36, 43.64],\n      [-79.36, 43.66],\n      [-79.40, 43.66],\n      [-79.40, 43.64]\n    ]\n  ]\n}\n

  4. Verify GeoJSON coordinate order:

    // Correct: [longitude, latitude]\nconst point = [loc.longitude, loc.latitude]; // \u2713\n\n// Incorrect: [latitude, longitude]\nconst point = [loc.latitude, loc.longitude]; // \u2717\n

  5. Check cut geojson validity:

  6. First and last coordinates must be identical (closed polygon)
  7. Coordinates must be [lng, lat] order
  8. Use http://geojson.io to visualize
"},{"location":"v2/features/map/walk-sheets/#problem-large-cuts-slow-to-load","title":"Problem: Large cuts slow to load","text":"

Symptoms: - Walk sheet takes > 10 seconds to load - Browser freezes during render - Print preview crashes

Solutions:

  1. Implement pagination:

    const LOCATIONS_PER_PAGE = 50;\n\nconst [currentPage, setCurrentPage] = useState(1);\nconst paginatedLocations = locations.slice(\n  (currentPage - 1) * LOCATIONS_PER_PAGE,\n  currentPage * LOCATIONS_PER_PAGE\n);\n

  2. Add location count warning:

    {locations.length > 200 && (\n  <Alert\n    message=\"Large Cut\"\n    description={`This cut has ${locations.length} locations. Consider splitting into smaller sheets.`}\n    type=\"warning\"\n    showIcon\n  />\n)}\n

  3. Use virtual scrolling for preview:

    import { List } from 'react-virtualized';\n\n// Render only visible rows during preview\n<List\n  height={600}\n  rowCount={locations.length}\n  rowHeight={40}\n  rowRenderer={({ index, style }) => (\n    <div style={style}>{renderLocationRow(locations[index])}</div>\n  )}\n/>\n

  4. Optimize QR code generation:

    // Generate QR codes only when print button clicked\nconst [qrCodesGenerated, setQrCodesGenerated] = useState(false);\n\nconst handlePrint = async () => {\n  if (!qrCodesGenerated) {\n    await generateQRCodes();\n    setQrCodesGenerated(true);\n  }\n  window.print();\n};\n

  5. Split large cuts into multiple sheets:

    // Group by postal code prefix\nconst groupedByPostal = locations.reduce((acc, loc) => {\n  const prefix = loc.postalCode?.substring(0, 3) || 'Unknown';\n  if (!acc[prefix]) acc[prefix] = [];\n  acc[prefix].push(loc);\n  return acc;\n}, {} as Record<string, Location[]>);\n\n// Generate separate sheet per group\nObject.entries(groupedByPostal).forEach(([prefix, locs]) => {\n  console.log(`${prefix}: ${locs.length} locations`);\n});\n

"},{"location":"v2/features/map/walk-sheets/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/walk-sheets/#client-side-rendering","title":"Client-Side Rendering","text":"

Walk Sheet Page Load: - Initial load: ~500ms (fetch cuts + settings) - Cut selection: ~1-2 seconds (fetch locations + generate QR codes) - Large cuts (500+ locations): ~3-5 seconds - QR code generation: ~100ms per code (parallel)

Optimization Strategies:

  1. Lazy load QR codes:

    // Only generate when visible\nconst [qrCodesVisible, setQrCodesVisible] = useState(false);\n\nuseEffect(() => {\n  const observer = new IntersectionObserver(entries => {\n    if (entries[0].isIntersecting && !qrCodesVisible) {\n      generateQRCodes();\n      setQrCodesVisible(true);\n    }\n  });\n  observer.observe(qrSectionRef.current);\n  return () => observer.disconnect();\n}, []);\n

  2. Cache QR codes in localStorage:

    const getCachedQR = (url: string): string | null => {\n  const cached = localStorage.getItem(`qr:${url}`);\n  if (cached) {\n    const { png, timestamp } = JSON.parse(cached);\n    // Cache valid for 24 hours\n    if (Date.now() - timestamp < 24 * 60 * 60 * 1000) {\n      return png;\n    }\n  }\n  return null;\n};\n\nconst cacheQR = (url: string, png: string) => {\n  localStorage.setItem(`qr:${url}`, JSON.stringify({\n    png,\n    timestamp: Date.now()\n  }));\n};\n

  3. Debounce cut selection:

    import { debounce } from 'lodash';\n\nconst debouncedFetchLocations = debounce((cutId: number) => {\n  fetchLocations(cutId);\n}, 300);\n\n<Select onChange={debouncedFetchLocations} />\n

"},{"location":"v2/features/map/walk-sheets/#server-side-performance","title":"Server-Side Performance","text":"

API Response Times: - GET /api/map-settings: ~50ms (singleton query) - GET /api/cuts/ ~100ms (single record + geojson) - GET /api/locations?cutId=X: ~500ms-2s (depends on cut size) - POST /api/qr/generate: ~50ms (QRCode.toDataURL is fast)

Database Optimization:

-- Index for cut location queries\nCREATE INDEX idx_locations_coords ON \"Location\"(latitude, longitude);\n\n-- Index for address sorting\nCREATE INDEX idx_locations_address ON \"Location\"(address);\n\n-- Composite index for geocoded locations\nCREATE INDEX idx_locations_geocoded ON \"Location\"(latitude, longitude)\n  WHERE latitude IS NOT NULL AND longitude IS NOT NULL;\n

Query Optimization:

// Use select to limit fields\nconst locations = await prisma.location.findMany({\n  where: { /* filters */ },\n  select: {\n    id: true,\n    address: true,\n    latitude: true,\n    longitude: true,\n    postalCode: true,\n    addresses: {\n      select: {\n        id: true,\n        unitNumber: true,\n        firstName: true,\n        lastName: true,\n        supportLevel: true,\n        notes: true\n      },\n      orderBy: { unitNumber: 'asc' }\n    }\n  }\n});\n
"},{"location":"v2/features/map/walk-sheets/#print-performance","title":"Print Performance","text":"

Print Dialog Load Time: - Small walk sheets (<50 locations): Instant - Medium (50-200 locations): 1-2 seconds - Large (200-500 locations): 3-5 seconds - Very large (500+ locations): Consider pagination

Browser Print Limits: - Chrome: ~1000 table rows before slowdown - Firefox: ~800 table rows - Safari: ~600 table rows

Optimization: - Use page-break-inside: avoid sparingly - Minimize complex CSS in print rules - Avoid large images (QR codes already optimized at 150px) - Split very large cuts into multiple PDFs

"},{"location":"v2/features/map/walk-sheets/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/map/walk-sheets/#backend-documentation","title":"Backend Documentation","text":"
  • QR Code Generation: api/src/modules/qr/qr.routes.ts
  • QRCode.toDataURL() wrapper
  • Rate limiting (100/15min)
  • Size validation (50-500px)

  • Map Settings: api/src/modules/map/settings/

  • MapSettings singleton CRUD
  • Walk sheet configuration
  • QR code URL storage

  • Cuts API: api/src/modules/map/cuts/

  • Cut CRUD operations
  • GeoJSON polygon storage
  • Point-in-polygon filtering

  • Locations API: api/src/modules/map/locations/

  • Location CRUD with cut filtering
  • Address relations
  • Support level tracking
"},{"location":"v2/features/map/walk-sheets/#frontend-documentation","title":"Frontend Documentation","text":"
  • Walk Sheet Page: admin/src/pages/WalkSheetPage.tsx
  • Cut selection dropdown
  • Location table rendering
  • QR code display
  • Print functionality

  • Cut Export Page: admin/src/pages/CutExportPage.tsx

  • Statistics calculation
  • CSV export
  • Print layout

  • Map Settings Page: admin/src/pages/MapSettingsPage.tsx

  • Walk sheet configuration form
  • QR code URL/label inputs
  • Settings persistence
"},{"location":"v2/features/map/walk-sheets/#database-documentation","title":"Database Documentation","text":"
  • Models: api/prisma/schema.prisma
  • MapSettings (singleton)
  • Cut (geojson polygon)
  • Location (geocoded addresses)
  • Address (unit-level data)
"},{"location":"v2/features/map/walk-sheets/#external-resources","title":"External Resources","text":"
  • QRCode.js: https://github.com/soldair/node-qrcode
  • PNG generation API
  • Error correction levels
  • Size/margin options

  • CSS Print Media: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/print

  • @media print rules
  • @page configuration
  • page-break properties

  • GeoJSON Specification: https://geojson.org/

  • Polygon format
  • Coordinate order ([lng, lat])
  • MultiPolygon support
"},{"location":"v2/features/media/","title":"Media Manager","text":"

The Media Manager provides a complete video library system with upload, metadata extraction, public gallery, reaction system, and job queue monitoring. Built as a separate Fastify microservice with Drizzle ORM.

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

The Media Manager consists of four integrated components:

  1. Video Library - Admin video management
  2. Upload System - Video upload with metadata extraction
  3. Public Gallery - Public video sharing
  4. Job Queue - Background job monitoring
"},{"location":"v2/features/media/#features","title":"Features","text":""},{"location":"v2/features/media/#video-library","title":"Video Library","text":"
  • Video CRUD operations
  • Metadata editing (title, description, tags)
  • Lock/unlock videos
  • Bulk operations (delete, lock, share)
  • Search and filtering
  • Thumbnail generation (future)
"},{"location":"v2/features/media/#upload-system","title":"Upload System","text":"
  • Drag-and-drop upload
  • Single and batch upload
  • Progress tracking
  • Automatic metadata extraction (FFprobe)
  • Duration
  • Dimensions (width x height)
  • Orientation (landscape/portrait/square)
  • Quality (resolution-based: 4K, 1080p, 720p, etc.)
  • Audio detection
  • File validation (type, size)
  • Supported formats: MP4, MOV, AVI, MKV, WebM, M4V, FLV
  • Max file size: 10GB
"},{"location":"v2/features/media/#public-gallery","title":"Public Gallery","text":"
  • Shared videos display
  • Category filtering
  • Reaction system (6 emojis)
  • Video cards with thumbnails
  • Lock/unlock control
  • Visitor reactions
"},{"location":"v2/features/media/#reaction-system","title":"Reaction System","text":"

Six emoji reactions:

  • Like (\ud83d\udc4d)
  • Love (\u2764\ufe0f)
  • Laugh (\ud83d\ude02)
  • Wow (\ud83d\ude2e)
  • Sad (\ud83d\ude22)
  • Angry (\ud83d\ude21)
"},{"location":"v2/features/media/#architecture","title":"Architecture","text":""},{"location":"v2/features/media/#dual-api-design","title":"Dual API Design","text":"

Express API (Port 4000) - Main V2 features - Prisma ORM - PostgreSQL

Fastify Media API (Port 4100) - Media-specific operations - Drizzle ORM - Same PostgreSQL database - Optimized for file uploads

"},{"location":"v2/features/media/#backend-components","title":"Backend Components","text":"

Media API: - api/src/media-server.ts - Fastify entry point - api/src/modules/media/routes/ - Video, upload, shared, reactions, jobs - api/src/modules/media/services/ - FFprobe, video service - api/src/modules/media/db/schema.ts - Drizzle schema

Database Tables: - videos - Video metadata (Drizzle) - shared_media - Public gallery (Drizzle) - media_reactions - Reaction tracking (Drizzle) - media_jobs - Job queue (Drizzle)

"},{"location":"v2/features/media/#frontend-components","title":"Frontend Components","text":"

Admin Pages: - admin/src/pages/media/LibraryPage.tsx - Video library management - admin/src/pages/media/SharedMediaPage.tsx - Public gallery admin - admin/src/pages/media/MediaJobsPage.tsx - Job queue monitoring

Public Pages: - admin/src/pages/public/MediaGalleryPage.tsx - Public video gallery - admin/src/pages/public/MediaViewerPage.tsx - Video detail page

Components: - admin/src/components/media/VideoCard.tsx - Video display card - admin/src/components/media/BulkActions.tsx - Batch operations - admin/src/components/media/UploadVideoModal.tsx - Upload interface

API Clients: - admin/src/lib/media-api.ts - Authenticated media API client - admin/src/lib/media-public-api.ts - Public media API client

"},{"location":"v2/features/media/#configuration","title":"Configuration","text":""},{"location":"v2/features/media/#environment-variables","title":"Environment Variables","text":"
# Enable media features\nENABLE_MEDIA_FEATURES=true\n\n# Media API port\nMEDIA_API_PORT=4100\n\n# Media storage\nMEDIA_LIBRARY_PATH=/media/local/library    # Read-only library\nMEDIA_INBOX_PATH=/media/local/inbox        # Read-write inbox\n
"},{"location":"v2/features/media/#docker-volumes","title":"Docker Volumes","text":"
volumes:\n  # Library (read-only)\n  - /path/to/library:/media/local/library:ro\n\n  # Inbox (read-write for uploads)\n  - /path/to/inbox:/media/local/inbox:rw\n
"},{"location":"v2/features/media/#upload-system_1","title":"Upload System","text":""},{"location":"v2/features/media/#upload-flow","title":"Upload Flow","text":"
  1. Select Files
  2. Drag-and-drop or file picker
  3. Multiple file selection
  4. File type validation

  5. Upload to Inbox

  6. Stream to /media/local/inbox
  7. UUID filename (prevents conflicts)
  8. Progress tracking

  9. Extract Metadata

  10. FFprobe analysis (30s timeout)
  11. Duration, dimensions, orientation
  12. Quality calculation
  13. Audio detection

  14. Create Database Record

  15. Store metadata
  16. Set initial status
  17. Link to user

  18. Process Video (Future)

  19. Generate thumbnail
  20. Transcode formats
  21. Move to library
"},{"location":"v2/features/media/#ffprobe-metadata-extraction","title":"FFprobe Metadata Extraction","text":"

Automatically extracts:

interface VideoMetadata {\n  duration: number;        // Seconds (e.g., 125.5)\n  width: number;           // Pixels (e.g., 1920)\n  height: number;          // Pixels (e.g., 1080)\n  orientation: string;     // 'landscape' | 'portrait' | 'square'\n  quality: string;         // '4K' | '1080p' | '720p' | 'SD'\n  hasAudio: boolean;       // Audio stream detected\n}\n

Quality Calculation: - 4K: \u22652160p (3840x2160) - 1080p: \u22651080p (1920x1080) - 720p: \u2265720p (1280x720) - SD: <720p

Orientation: - Landscape: width > height - Portrait: height > width - Square: width \u2248 height (within 10%)

"},{"location":"v2/features/media/#supported-formats","title":"Supported Formats","text":"
  • MP4 - H.264/H.265 video
  • MOV - QuickTime
  • AVI - Audio Video Interleave
  • MKV - Matroska
  • WebM - Web-optimized
  • M4V - iTunes video
  • FLV - Flash video
"},{"location":"v2/features/media/#public-gallery_1","title":"Public Gallery","text":""},{"location":"v2/features/media/#sharing-system","title":"Sharing System","text":"

Videos can be shared publicly:

  1. Lock/Unlock - Control public visibility
  2. Category Assignment - Organize by category
  3. Public Access - View at /media
  4. Reactions - Emoji reactions from visitors
"},{"location":"v2/features/media/#categories","title":"Categories","text":"

Predefined categories:

  • Events
  • Testimonials
  • Tutorials
  • Announcements
  • Behind the Scenes
  • Custom categories
"},{"location":"v2/features/media/#reaction-system_1","title":"Reaction System","text":""},{"location":"v2/features/media/#reaction-tracking","title":"Reaction Tracking","text":"
interface MediaReaction {\n  id: number;\n  videoId: number;\n  reactionType: string;  // 'like' | 'love' | 'laugh' | 'wow' | 'sad' | 'angry'\n  sessionId: string;      // Unique session identifier\n  createdAt: Date;\n}\n
"},{"location":"v2/features/media/#session-based-reactions","title":"Session-Based Reactions","text":"
  • One reaction per video per session
  • Session ID in localStorage
  • Update reaction (change type)
  • Remove reaction (click again)
"},{"location":"v2/features/media/#job-queue","title":"Job Queue","text":"

Background jobs for:

  • Video processing
  • Thumbnail generation
  • Format transcoding
  • Metadata extraction
  • File cleanup
"},{"location":"v2/features/media/#job-monitoring","title":"Job Monitoring","text":"

Admin can:

  • View job status
  • Monitor progress
  • Retry failed jobs
  • View error logs
"},{"location":"v2/features/media/#database-schema-drizzle","title":"Database Schema (Drizzle)","text":""},{"location":"v2/features/media/#videos-table","title":"Videos Table","text":"
export const videos = pgTable('videos', {\n  id: serial('id').primaryKey(),\n  title: varchar('title', { length: 255 }).notNull(),\n  description: text('description'),\n  filename: varchar('filename', { length: 255 }).notNull(),\n  filepath: varchar('filepath', { length: 500 }).notNull(),\n  duration: integer('duration'),        // Seconds\n  width: integer('width'),              // Pixels\n  height: integer('height'),            // Pixels\n  orientation: varchar('orientation', { length: 20 }), // 'landscape' | 'portrait' | 'square'\n  quality: varchar('quality', { length: 20 }),         // '4K' | '1080p' | '720p' | 'SD'\n  hasAudio: boolean('has_audio'),\n  tags: json('tags').$type<string[]>(),\n  locked: boolean('locked').default(false),\n  uploadedBy: integer('uploaded_by'),\n  createdAt: timestamp('created_at').defaultNow(),\n  updatedAt: timestamp('updated_at').defaultNow(),\n});\n
"},{"location":"v2/features/media/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/media/#admin-endpoints-media-api-port-4100","title":"Admin Endpoints (Media API - Port 4100)","text":"
GET    /media-api/videos               # List videos\nPOST   /media-api/videos               # Create video (manual)\nGET    /media-api/videos/:id           # Get video\nPATCH  /media-api/videos/:id           # Update video\nDELETE /media-api/videos/:id           # Delete video\nPOST   /media-api/upload               # Upload single video\nPOST   /media-api/upload/batch         # Upload multiple videos\nGET    /media-api/shared               # List shared videos\nPOST   /media-api/shared               # Share video\nDELETE /media-api/shared/:id           # Unshare video\nGET    /media-api/jobs                 # List jobs\n
"},{"location":"v2/features/media/#public-endpoints","title":"Public Endpoints","text":"
GET    /media-api/public/videos        # List public videos\nGET    /media-api/public/videos/:id    # Get public video\nPOST   /media-api/reactions            # Add/update reaction\nDELETE /media-api/reactions/:id        # Remove reaction\n
"},{"location":"v2/features/media/#security","title":"Security","text":""},{"location":"v2/features/media/#file-validation","title":"File Validation","text":"
  • MIME type checking
  • File extension validation
  • File size limits (10GB max)
  • Path traversal prevention
  • Null byte detection
"},{"location":"v2/features/media/#access-control","title":"Access Control","text":"
  • Admin-only uploads
  • Lock/unlock controls
  • Public visibility flags
  • Session-based reactions
"},{"location":"v2/features/media/#performance","title":"Performance","text":""},{"location":"v2/features/media/#upload-optimization","title":"Upload Optimization","text":"
  • Streaming uploads (no memory buffering)
  • UUID filenames (no collisions)
  • Async metadata extraction
  • Progress tracking
"},{"location":"v2/features/media/#gallery-optimization","title":"Gallery Optimization","text":"
  • Lazy loading
  • Thumbnail caching (future)
  • Paginated results
  • Infinite scroll
"},{"location":"v2/features/media/#related-documentation","title":"Related Documentation","text":"
  • Video Library
  • Upload System
  • Public Gallery
  • Job Queue
  • Backend Media Module
  • Library Page
  • Upload Modal Component
  • FFprobe Service
"},{"location":"v2/features/media/jobs/","title":"Media Job Queue System","text":""},{"location":"v2/features/media/jobs/#overview","title":"Overview","text":"

The Media Job Queue System provides asynchronous background processing for CPU and GPU-intensive video operations. Built on a custom job queue with resource-aware scheduling, it handles everything from directory scanning to AI-powered video analysis while maintaining system stability through resource category management.

Key Features:

  • Resource Categories \u2014 Jobs classified by resource needs (CPU, GPU encode, GPU AI)
  • Priority Scheduling \u2014 High-priority jobs processed first within same category
  • Job Types \u2014 15+ job types (compilation, encoding, digest generation, scene extraction, etc.)
  • Progress Tracking \u2014 Real-time progress updates (0-100%)
  • Status Management \u2014 Pending \u2192 Queued \u2192 Running \u2192 Completed/Failed lifecycle
  • Retry Logic \u2014 Failed jobs can be retried with exponential backoff
  • Detailed Logging \u2014 Execution logs for debugging and audit trail
  • Queue Management \u2014 Pause, resume, cancel, and prioritize jobs
  • VRAM Awareness \u2014 Prevents GPU memory exhaustion by tracking VRAM requirements

Access Control:

  • Job viewing/management requires SUPER_ADMIN role
  • Job creation can be triggered by admins or automated workflows

Technology Stack:

  • Database Queue \u2014 PostgreSQL-backed job queue (no BullMQ for media)
  • Worker Process \u2014 Node.js worker polling queue every 5 seconds
  • FFmpeg \u2014 Video encoding and compilation
  • AI Integration \u2014 Future support for scene detection and auto-tagging
"},{"location":"v2/features/media/jobs/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph \"Job Creation\"\n        A1[Admin Action]\n        A2[Automated Trigger]\n        A3[Scheduled Task]\n    end\n\n    subgraph \"Job Queue (PostgreSQL)\"\n        Q1[Pending Jobs]\n        Q2[Queued Jobs]\n        Q3[Running Jobs]\n        Q4[Completed/Failed Jobs]\n    end\n\n    subgraph \"Worker Process\"\n        W1[Job Poller<br/>Every 5s]\n        W2[Resource Checker]\n        W3[Job Executor]\n        W4[Progress Updater]\n    end\n\n    subgraph \"Processors\"\n        P1[CPU Jobs<br/>scan, validate]\n        P2[GPU Encode<br/>reencode, compile]\n        P3[GPU AI<br/>digest, tag, scene]\n    end\n\n    subgraph \"Results\"\n        R1[Video Records Updated]\n        R2[New Files Created]\n        R3[Logs Written]\n    end\n\n    A1 --> Q1\n    A2 --> Q1\n    A3 --> Q1\n\n    Q1 --> W1\n    W1 --> W2\n    W2 -->|Check Resources| Q2\n    Q2 --> W3\n\n    W3 --> P1\n    W3 --> P2\n    W3 --> P3\n\n    W3 --> W4\n    W4 --> Q3\n\n    P1 --> R1\n    P2 --> R2\n    P3 --> R3\n\n    Q3 --> Q4\n\n    style Q1 fill:#f9f\n    style Q3 fill:#ff9\n    style Q4 fill:#9f9

Workflow:

  1. Job Creation \u2014 Admin clicks \"Re-encode\" button, API creates job record
  2. Queue Polling \u2014 Worker checks for pending jobs every 5 seconds
  3. Resource Check \u2014 Worker verifies sufficient VRAM/CPU available
  4. Job Execution \u2014 Worker runs appropriate processor (FFmpeg, AI script, etc.)
  5. Progress Updates \u2014 Worker updates job progress every ~5% completion
  6. Completion \u2014 Worker marks job complete and logs results
  7. Retry on Failure \u2014 Failed jobs can be retried with exponential backoff
"},{"location":"v2/features/media/jobs/#database-model","title":"Database Model","text":""},{"location":"v2/features/media/jobs/#jobs-table-schema","title":"Jobs Table Schema","text":"
// api/src/modules/media/db/schema.ts\nexport const jobs = pgTable('jobs', {\n  id: uuid('id').primaryKey().defaultRandom(),\n\n  // Job Definition\n  type: text('type').notNull(), // JobType enum: compilation, scan, reencode, etc.\n  status: text('status').notNull().default('pending'), // JobStatus enum\n  params: jsonb('params').$type<Record<string, any>>().notNull(), // Job-specific parameters\n\n  // Progress Tracking\n  progress: integer('progress').default(0), // 0-100\n  log: text('log').default(''), // Execution log (append-only)\n\n  // Scheduling\n  priority: integer('priority').default(5), // 1 (highest) - 10 (lowest)\n  queuePosition: integer('queue_position'), // Position in queue\n  waitingReason: text('waiting_reason'), // Why job is waiting (e.g., \"Insufficient VRAM\")\n\n  // Resource Management\n  resourceCategory: text('resource_category').notNull(), // cpu|gpu_encode|gpu_ai\n  vramRequired: integer('vram_required').default(0), // MB of VRAM needed\n\n  // Timing\n  createdAt: timestamp('created_at').defaultNow(),\n  startedAt: timestamp('started_at'),\n  completedAt: timestamp('completed_at'),\n\n  // Retry Logic\n  retryCount: integer('retry_count').default(0),\n  maxRetries: integer('max_retries').default(3),\n  retryAfter: timestamp('retry_after'), // Don't retry before this time\n});\n
"},{"location":"v2/features/media/jobs/#job-types-enum","title":"Job Types Enum","text":"Type Resource Category VRAM (MB) Description scan cpu 0 Scan directory for new videos public_scan cpu 0 Scan public gallery directory validate cpu 0 Validate video metadata (FFprobe) reencode_streaming gpu_encode 4000 Re-encode for web playback (H.264) compile_random gpu_encode 2000 Random video compilation compile_quad gpu_encode 4000 4-up grid compilation compile_mega gpu_encode 6000 Large multi-video compilation compile_gif cpu 0 Create GIF from video digest_generate gpu_ai 8000 AI-powered video digest clip_generate gpu_ai 6000 Extract clips from digest highlight_generate gpu_ai 8000 Create highlight reel tag_generation gpu_ai 6000 AI auto-tagging scene_extract gpu_ai 8000 Scene detection and extraction thumbnail_generate cpu 0 Generate thumbnail from video move_to_library cpu 0 Move video from inbox to target directory"},{"location":"v2/features/media/jobs/#job-status-enum","title":"Job Status Enum","text":"Status Description Final State pending Waiting to be picked up by worker No queued Selected by worker, waiting for resources No running Currently executing No completed Finished successfully Yes failed Execution failed (see log for details) Yes cancelled Manually cancelled by admin Yes paused Temporarily paused (can be resumed) No"},{"location":"v2/features/media/jobs/#resource-categories","title":"Resource Categories","text":"Category Typical VRAM Concurrent Limit Use Cases cpu 0 MB 5 Scanning, validation, simple encodes, GIF creation gpu_encode 2-6 GB 2 Video re-encoding, compilation, format conversion gpu_ai 6-12 GB 1 AI tagging, scene detection, digest generation, highlight extraction

VRAM Management:

Worker tracks total VRAM usage across running jobs:

const runningJobs = await db.select().from(jobs).where(eq(jobs.status, 'running'));\nconst totalVramUsed = runningJobs.reduce((sum, job) => sum + (job.vramRequired || 0), 0);\n\n// Only start new job if VRAM available\nconst TOTAL_VRAM = 16000; // 16GB GPU\nif (totalVramUsed + newJob.vramRequired <= TOTAL_VRAM) {\n  startJob(newJob);\n}\n
"},{"location":"v2/features/media/jobs/#api-endpoints","title":"API Endpoints","text":"

All endpoints require SUPER_ADMIN role.

"},{"location":"v2/features/media/jobs/#list-jobs","title":"List Jobs","text":"
GET /api/media/jobs\n

Query Parameters:

Parameter Type Default Description page number 1 Page number limit number 20 Results per page status string - Filter by status (pending, running, completed, failed) type string - Filter by job type resourceCategory string - Filter by resource category

Response:

{\n  \"data\": [\n    {\n      \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n      \"type\": \"reencode_streaming\",\n      \"status\": \"running\",\n      \"progress\": 45,\n      \"resourceCategory\": \"gpu_encode\",\n      \"vramRequired\": 4000,\n      \"priority\": 5,\n      \"params\": {\n        \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\",\n        \"targetBitrate\": 2000\n      },\n      \"startedAt\": \"2026-02-13T10:30:00Z\",\n      \"createdAt\": \"2026-02-13T10:25:00Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 156,\n    \"totalPages\": 8\n  }\n}\n
"},{"location":"v2/features/media/jobs/#get-job-details","title":"Get Job Details","text":"
GET /api/media/jobs/:id\n

Response:

{\n  \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"type\": \"reencode_streaming\",\n  \"status\": \"completed\",\n  \"progress\": 100,\n  \"log\": \"Starting re-encode...\\nFFmpeg command: ffmpeg -i input.mp4 -c:v h264 -preset medium -crf 23 output.mp4\\nProgress: 25%\\nProgress: 50%\\nProgress: 75%\\nProgress: 100%\\nCompleted successfully\",\n  \"params\": {\n    \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\",\n    \"inputPath\": \"inbox/original.mp4\",\n    \"outputPath\": \"playback/encoded.mp4\",\n    \"targetBitrate\": 2000\n  },\n  \"resourceCategory\": \"gpu_encode\",\n  \"vramRequired\": 4000,\n  \"priority\": 5,\n  \"retryCount\": 0,\n  \"maxRetries\": 3,\n  \"createdAt\": \"2026-02-13T10:25:00Z\",\n  \"startedAt\": \"2026-02-13T10:30:00Z\",\n  \"completedAt\": \"2026-02-13T10:45:00Z\"\n}\n
"},{"location":"v2/features/media/jobs/#create-job","title":"Create Job","text":"
POST /api/media/jobs\n

Request Body:

{\n  \"type\": \"reencode_streaming\",\n  \"params\": {\n    \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\",\n    \"targetBitrate\": 2000\n  },\n  \"priority\": 5,\n  \"resourceCategory\": \"gpu_encode\",\n  \"vramRequired\": 4000\n}\n

Response:

{\n  \"id\": \"770e8400-e29b-41d4-a716-446655440002\",\n  \"type\": \"reencode_streaming\",\n  \"status\": \"pending\",\n  \"progress\": 0,\n  \"createdAt\": \"2026-02-13T11:00:00Z\"\n}\n
"},{"location":"v2/features/media/jobs/#retry-failed-job","title":"Retry Failed Job","text":"
POST /api/media/jobs/:id/retry\n

Response:

{\n  \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"status\": \"pending\",\n  \"retryCount\": 1,\n  \"retryAfter\": null,\n  \"log\": \"Starting re-encode...\\n[Previous logs...]\\n--- RETRY ATTEMPT 1 ---\\n\"\n}\n

Retry Logic:

  • Failed jobs can be retried up to maxRetries times (default: 3)
  • Exponential backoff: wait 2^retryCount minutes before retry
  • Retry resets status to pending and appends retry marker to log
"},{"location":"v2/features/media/jobs/#cancel-job","title":"Cancel Job","text":"
POST /api/media/jobs/:id/cancel\n

Response:

{\n  \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"status\": \"cancelled\",\n  \"log\": \"Starting re-encode...\\nProgress: 25%\\n--- JOB CANCELLED BY ADMIN ---\"\n}\n

Notes:

  • Running jobs cannot be cancelled immediately (worker must finish current chunk)
  • Pending/queued jobs cancelled instantly
"},{"location":"v2/features/media/jobs/#pauseresume-job","title":"Pause/Resume Job","text":"
POST /api/media/jobs/:id/pause\nPOST /api/media/jobs/:id/resume\n

Pause Response:

{\n  \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"status\": \"paused\"\n}\n

Resume Response:

{\n  \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"status\": \"pending\"\n}\n
"},{"location":"v2/features/media/jobs/#queue-statistics","title":"Queue Statistics","text":"
GET /api/media/jobs/stats\n

Response:

{\n  \"pending\": 12,\n  \"queued\": 2,\n  \"running\": 3,\n  \"completed\": 1458,\n  \"failed\": 23,\n  \"paused\": 1,\n  \"totalVramUsed\": 12000,\n  \"totalVramAvailable\": 16000,\n  \"averageProcessingTime\": 245,\n  \"jobsByType\": {\n    \"reencode_streaming\": 45,\n    \"scan\": 8,\n    \"compile_random\": 12\n  }\n}\n
"},{"location":"v2/features/media/jobs/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/media/jobs/#viewing-job-queue","title":"Viewing Job Queue","text":"
  1. Navigate to Media \u2192 Jobs in admin sidebar
  2. Table displays all jobs with:
  3. Job type icon
  4. Status badge (color-coded)
  5. Progress bar
  6. Priority indicator
  7. Resource category
  8. Created/started/completed times
  9. Use filters at top:
  10. Status dropdown (All / Pending / Running / Completed / Failed)
  11. Type dropdown (job type)
  12. Resource dropdown (CPU / GPU Encode / GPU AI)
"},{"location":"v2/features/media/jobs/#creating-jobs-manually","title":"Creating Jobs Manually","text":"

Option 1: From Library Page

  1. Select video in library table
  2. Click \"Actions\" dropdown
  3. Select action:
  4. \"Re-encode for Streaming\"
  5. \"Generate Thumbnail\"
  6. \"Validate Metadata\"
  7. \"Move to Directory\"
  8. Confirm job creation
  9. Redirected to Jobs page showing new job

Option 2: From Jobs Page

  1. Click \"Create Job\" button
  2. Modal opens with form:
  3. Type dropdown (15+ job types)
  4. Video selector (search by title/filename)
  5. Priority slider (1-10)
  6. Parameters JSON editor (advanced)
  7. Click \"Create\"
  8. Job appears in pending queue
"},{"location":"v2/features/media/jobs/#monitoring-job-progress","title":"Monitoring Job Progress","text":"

Real-Time Updates:

  1. Jobs page polls API every 2 seconds for running jobs
  2. Progress bars update smoothly (0-100%)
  3. Status badges change color:
  4. Grey: Pending
  5. Blue: Queued
  6. Yellow: Running
  7. Green: Completed
  8. Red: Failed

Detailed Logs:

  1. Click job row to expand details panel
  2. View execution log in monospace text area
  3. Log updates in real-time while job running
  4. Example log output:
[2026-02-13 10:30:15] Starting re-encode job\n[2026-02-13 10:30:16] Input: /media/local/inbox/original.mp4\n[2026-02-13 10:30:16] Output: /media/local/playback/encoded.mp4\n[2026-02-13 10:30:17] FFmpeg command: ffmpeg -i /media/local/inbox/original.mp4 -c:v libx264 -preset medium -crf 23 -c:a aac -b:a 128k /media/local/playback/encoded.mp4\n[2026-02-13 10:30:20] Progress: 5%\n[2026-02-13 10:30:25] Progress: 15%\n[2026-02-13 10:30:30] Progress: 25%\n...\n[2026-02-13 10:45:00] Progress: 100%\n[2026-02-13 10:45:01] Re-encode completed successfully\n[2026-02-13 10:45:02] Output file size: 25.3 MB\n
"},{"location":"v2/features/media/jobs/#retrying-failed-jobs","title":"Retrying Failed Jobs","text":"
  1. Filter for Failed jobs
  2. Click job row to view error log
  3. Identify failure reason (e.g., \"FFmpeg error: codec not supported\")
  4. Fix underlying issue (install codec, fix file path, etc.)
  5. Click \"Retry\" button
  6. Job resets to pending status
  7. Worker picks up job again

Auto-Retry:

Jobs automatically retry up to 3 times with exponential backoff:

  • 1st retry: after 2 minutes
  • 2nd retry: after 4 minutes
  • 3rd retry: after 8 minutes
"},{"location":"v2/features/media/jobs/#cancelling-jobs","title":"Cancelling Jobs","text":"
  1. Find job in pending/queued/running state
  2. Click \"Cancel\" button
  3. Confirm cancellation dialog
  4. Job marked as cancelled
  5. If running, worker stops after current chunk completes
"},{"location":"v2/features/media/jobs/#pausingresuming-jobs","title":"Pausing/Resuming Jobs","text":"

Use Case: Temporarily stop low-priority jobs to free resources for urgent tasks

  1. Select low-priority pending job
  2. Click \"Pause\" button
  3. Job status changes to paused (greyed out)
  4. Worker skips paused jobs
  5. When ready, click \"Resume\"
  6. Job returns to pending queue
"},{"location":"v2/features/media/jobs/#job-type-details","title":"Job Type Details","text":""},{"location":"v2/features/media/jobs/#scan-jobs-scan-public_scan","title":"Scan Jobs (scan, public_scan)","text":"

Purpose: Scan filesystem directory for new videos and create database records

Parameters:

{\n  \"directoryType\": \"videos\",\n  \"skipExisting\": true\n}\n

Process:

  1. Read directory /media/local/library/{directoryType}/
  2. Filter for video extensions (.mp4, .mov, etc.)
  3. Check each file against database (by path)
  4. Create records for new files
  5. Run FFprobe on new files
  6. Update progress: files processed / total files

Typical Duration: 2-30 seconds (depends on file count)

"},{"location":"v2/features/media/jobs/#validation-jobs-validate","title":"Validation Jobs (validate)","text":"

Purpose: Re-run FFprobe to refresh video metadata

Parameters:

{\n  \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\"\n}\n

Process:

  1. Fetch video record from database
  2. Build full file path
  3. Run FFprobe extraction
  4. Update database with fresh metadata
  5. Mark video as valid/invalid based on result

Typical Duration: 100-500ms per video

"},{"location":"v2/features/media/jobs/#re-encode-jobs-reencode_streaming","title":"Re-encode Jobs (reencode_streaming)","text":"

Purpose: Convert video to web-optimized format (H.264, web-friendly profile)

Parameters:

{\n  \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\",\n  \"targetBitrate\": 2000,\n  \"preset\": \"medium\",\n  \"crf\": 23\n}\n

FFmpeg Command:

ffmpeg -i /media/local/inbox/original.mp4 \\\n  -c:v libx264 \\\n  -preset medium \\\n  -crf 23 \\\n  -maxrate 2000k \\\n  -bufsize 4000k \\\n  -c:a aac \\\n  -b:a 128k \\\n  -movflags +faststart \\\n  /media/local/playback/encoded.mp4\n

Process:

  1. Validate input file exists
  2. Build FFmpeg command
  3. Start encoding process
  4. Parse FFmpeg progress output
  5. Update job progress every ~5%
  6. Create new video record for encoded file
  7. Update original video reencodeJobId reference

Typical Duration: 5-30 minutes (depends on video length and resolution)

"},{"location":"v2/features/media/jobs/#compilation-jobs-compile_random-compile_quad-compile_mega","title":"Compilation Jobs (compile_random, compile_quad, compile_mega)","text":"

Purpose: Merge multiple videos into single compilation

Parameters (Random):

{\n  \"count\": 10,\n  \"minDuration\": 30,\n  \"maxDuration\": 120,\n  \"orientation\": \"landscape\",\n  \"outputPath\": \"compilations/random-001.mp4\"\n}\n

Process:

  1. Query database for videos matching criteria (orientation, duration range)
  2. Randomly select count videos
  3. Build FFmpeg concat demuxer file list
  4. Run FFmpeg compilation
  5. Create new video record for compilation
  6. Update progress based on FFmpeg output

Quad Compilation (4-up grid):

ffmpeg -i video1.mp4 -i video2.mp4 -i video3.mp4 -i video4.mp4 \\\n  -filter_complex \"[0:v][1:v][2:v][3:v]xstack=inputs=4:layout=0_0|w0_0|0_h0|w0_h0[v]\" \\\n  -map \"[v]\" \\\n  output.mp4\n

Typical Duration: 10-60 minutes

"},{"location":"v2/features/media/jobs/#digest-generation-digest_generate","title":"Digest Generation (digest_generate)","text":"

Purpose: AI-powered video digest creation (future feature)

Parameters:

{\n  \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\",\n  \"targetLength\": 60,\n  \"includeHighlights\": true\n}\n

Process (Planned):

  1. Extract frames at 1 FPS
  2. Run AI scene detection
  3. Identify highlights (action, faces, motion)
  4. Select best segments totaling target length
  5. Compile segments into digest video

GPU AI Required: 8GB VRAM

"},{"location":"v2/features/media/jobs/#thumbnail-generation-thumbnail_generate","title":"Thumbnail Generation (thumbnail_generate)","text":"

Purpose: Extract thumbnail image from video

Parameters:

{\n  \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\",\n  \"timestamp\": 5,\n  \"width\": 640\n}\n

FFmpeg Command:

ffmpeg -i /media/local/library/videos/sample.mp4 \\\n  -ss 00:00:05 \\\n  -vframes 1 \\\n  -vf scale=640:-1 \\\n  /media/local/thumbnails/sample.jpg\n

Process:

  1. Seek to timestamp (default: 25% into video)
  2. Extract single frame
  3. Scale to width (preserve aspect ratio)
  4. Save as JPEG
  5. Update video record with thumbnailPath

Typical Duration: 1-5 seconds

"},{"location":"v2/features/media/jobs/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/media/jobs/#create-re-encode-job","title":"Create Re-encode Job","text":"
// api/src/modules/media/routes/jobs.routes.ts\nimport { db } from '@/modules/media/db';\nimport { jobs, videos } from '@/modules/media/db/schema';\n\napp.post('/api/media/jobs/reencode', async (req, reply) => {\n  const { videoId, targetBitrate = 2000, preset = 'medium', crf = 23 } = req.body;\n\n  // Fetch video\n  const [video] = await db\n    .select()\n    .from(videos)\n    .where(eq(videos.id, videoId))\n    .limit(1);\n\n  if (!video) {\n    return reply.code(404).send({ error: 'Video not found' });\n  }\n\n  // Create job\n  const [job] = await db\n    .insert(jobs)\n    .values({\n      type: 'reencode_streaming',\n      status: 'pending',\n      params: {\n        videoId,\n        inputPath: video.path,\n        outputPath: `playback/${video.filename}`,\n        targetBitrate,\n        preset,\n        crf,\n      },\n      resourceCategory: 'gpu_encode',\n      vramRequired: 4000,\n      priority: 5,\n    })\n    .returning();\n\n  reply.send(job);\n});\n
"},{"location":"v2/features/media/jobs/#job-worker-polling-loop","title":"Job Worker (Polling Loop)","text":"
// api/src/modules/media/services/job-worker.service.ts\nimport { db } from '@/modules/media/db';\nimport { jobs } from '@/modules/media/db/schema';\nimport { eq, and, lte } from 'drizzle-orm';\n\nexport class JobWorkerService {\n  private polling = false;\n\n  async start() {\n    this.polling = true;\n    console.log('Job worker started');\n\n    while (this.polling) {\n      try {\n        await this.processNextJob();\n      } catch (error) {\n        console.error('Job worker error:', error);\n      }\n\n      // Wait 5 seconds before next poll\n      await new Promise((resolve) => setTimeout(resolve, 5000));\n    }\n  }\n\n  async stop() {\n    this.polling = false;\n    console.log('Job worker stopped');\n  }\n\n  private async processNextJob() {\n    // Find next pending job (highest priority first)\n    const [job] = await db\n      .select()\n      .from(jobs)\n      .where(eq(jobs.status, 'pending'))\n      .orderBy(jobs.priority, jobs.createdAt)\n      .limit(1);\n\n    if (!job) {\n      return; // No jobs in queue\n    }\n\n    // Check resource availability\n    const canRun = await this.checkResources(job);\n    if (!canRun) {\n      // Update waiting reason\n      await db\n        .update(jobs)\n        .set({ waitingReason: 'Insufficient resources' })\n        .where(eq(jobs.id, job.id));\n      return;\n    }\n\n    // Start job\n    await this.executeJob(job);\n  }\n\n  private async checkResources(job: any): Promise<boolean> {\n    // Get running jobs\n    const runningJobs = await db\n      .select()\n      .from(jobs)\n      .where(eq(jobs.status, 'running'));\n\n    // Calculate total VRAM used\n    const totalVramUsed = runningJobs.reduce(\n      (sum, j) => sum + (j.vramRequired || 0),\n      0\n    );\n\n    const TOTAL_VRAM = 16000; // 16GB GPU\n    const available = TOTAL_VRAM - totalVramUsed;\n\n    if (job.vramRequired && job.vramRequired > available) {\n      return false; // Not enough VRAM\n    }\n\n    // Check concurrent job limits by category\n    const categoryCount = runningJobs.filter(\n      (j) => j.resourceCategory === job.resourceCategory\n    ).length;\n\n    const limits = {\n      cpu: 5,\n      gpu_encode: 2,\n      gpu_ai: 1,\n    };\n\n    if (categoryCount >= limits[job.resourceCategory as keyof typeof limits]) {\n      return false; // Category limit reached\n    }\n\n    return true; // Resources available\n  }\n\n  private async executeJob(job: any) {\n    // Mark as running\n    await db\n      .update(jobs)\n      .set({\n        status: 'running',\n        startedAt: new Date(),\n        waitingReason: null,\n      })\n      .where(eq(jobs.id, job.id));\n\n    try {\n      // Execute job based on type\n      switch (job.type) {\n        case 'reencode_streaming':\n          await this.executeReencode(job);\n          break;\n        case 'scan':\n          await this.executeScan(job);\n          break;\n        case 'thumbnail_generate':\n          await this.executeThumbnail(job);\n          break;\n        // ... other job types\n      }\n\n      // Mark as completed\n      await db\n        .update(jobs)\n        .set({\n          status: 'completed',\n          progress: 100,\n          completedAt: new Date(),\n        })\n        .where(eq(jobs.id, job.id));\n    } catch (error: any) {\n      // Mark as failed\n      await db\n        .update(jobs)\n        .set({\n          status: 'failed',\n          log: (job.log || '') + `\\n\\n--- ERROR ---\\n${error.message}`,\n        })\n        .where(eq(jobs.id, job.id));\n\n      // Schedule retry if under max retries\n      if (job.retryCount < job.maxRetries) {\n        const retryDelay = Math.pow(2, job.retryCount) * 60 * 1000; // Exponential backoff\n        await db\n          .update(jobs)\n          .set({\n            status: 'pending',\n            retryCount: job.retryCount + 1,\n            retryAfter: new Date(Date.now() + retryDelay),\n          })\n          .where(eq(jobs.id, job.id));\n      }\n    }\n  }\n\n  private async executeReencode(job: any) {\n    const { inputPath, outputPath, targetBitrate, preset, crf } = job.params;\n\n    const inputFull = path.join(process.env.MEDIA_LIBRARY_PATH!, inputPath);\n    const outputFull = path.join(process.env.MEDIA_LIBRARY_PATH!, outputPath);\n\n    const command = `ffmpeg -i \"${inputFull}\" -c:v libx264 -preset ${preset} -crf ${crf} -maxrate ${targetBitrate}k -bufsize ${targetBitrate * 2}k -c:a aac -b:a 128k -movflags +faststart \"${outputFull}\"`;\n\n    await this.appendLog(job.id, `Starting re-encode\\nCommand: ${command}`);\n\n    // Execute FFmpeg (simplified - real implementation uses spawn for progress parsing)\n    await execAsync(command);\n\n    await this.appendLog(job.id, 'Re-encode completed successfully');\n  }\n\n  private async appendLog(jobId: string, message: string) {\n    const timestamp = new Date().toISOString();\n    const logEntry = `[${timestamp}] ${message}`;\n\n    await db\n      .update(jobs)\n      .set({\n        log: sql`${jobs.log} || E'\\n' || ${logEntry}`,\n      })\n      .where(eq(jobs.id, jobId));\n  }\n}\n\n// Start worker\nexport const jobWorker = new JobWorkerService();\njobWorker.start();\n
"},{"location":"v2/features/media/jobs/#frontend-jobs-page","title":"Frontend: Jobs Page","text":"
// admin/src/pages/media/MediaJobsPage.tsx\nimport { Table, Tag, Progress, Button, Space, Select, message } from 'antd';\nimport { useEffect, useState } from 'react';\nimport { mediaApi } from '@/lib/media-api';\n\nexport default function MediaJobsPage() {\n  const [jobs, setJobs] = useState([]);\n  const [loading, setLoading] = useState(false);\n  const [filter, setFilter] = useState({ status: undefined, type: undefined });\n  const [polling, setPolling] = useState(true);\n\n  const fetchJobs = async () => {\n    setLoading(true);\n    try {\n      const { data } = await mediaApi.get('/api/media/jobs', {\n        params: filter,\n      });\n      setJobs(data.data);\n    } catch (error) {\n      console.error('Failed to fetch jobs:', error);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  useEffect(() => {\n    fetchJobs();\n  }, [filter]);\n\n  // Poll for running jobs every 2 seconds\n  useEffect(() => {\n    if (!polling) return;\n\n    const interval = setInterval(() => {\n      const hasRunning = jobs.some((j: any) => j.status === 'running');\n      if (hasRunning) {\n        fetchJobs();\n      }\n    }, 2000);\n\n    return () => clearInterval(interval);\n  }, [polling, jobs]);\n\n  const handleRetry = async (id: string) => {\n    try {\n      await mediaApi.post(`/api/media/jobs/${id}/retry`);\n      message.success('Job queued for retry');\n      fetchJobs();\n    } catch (error) {\n      message.error('Retry failed');\n    }\n  };\n\n  const handleCancel = async (id: string) => {\n    try {\n      await mediaApi.post(`/api/media/jobs/${id}/cancel`);\n      message.success('Job cancelled');\n      fetchJobs();\n    } catch (error) {\n      message.error('Cancel failed');\n    }\n  };\n\n  const statusColors: Record<string, string> = {\n    pending: 'default',\n    queued: 'blue',\n    running: 'processing',\n    completed: 'success',\n    failed: 'error',\n    cancelled: 'default',\n    paused: 'warning',\n  };\n\n  const columns = [\n    {\n      title: 'Type',\n      dataIndex: 'type',\n      width: 150,\n      render: (type: string) => <span style={{ fontFamily: 'monospace' }}>{type}</span>,\n    },\n    {\n      title: 'Status',\n      dataIndex: 'status',\n      width: 100,\n      render: (status: string) => <Tag color={statusColors[status]}>{status.toUpperCase()}</Tag>,\n    },\n    {\n      title: 'Progress',\n      dataIndex: 'progress',\n      width: 150,\n      render: (progress: number, record: any) => (\n        record.status === 'running' ? (\n          <Progress percent={progress} size=\"small\" status=\"active\" />\n        ) : record.status === 'completed' ? (\n          <Progress percent={100} size=\"small\" status=\"success\" />\n        ) : record.status === 'failed' ? (\n          <Progress percent={progress} size=\"small\" status=\"exception\" />\n        ) : (\n          <Progress percent={progress} size=\"small\" />\n        )\n      ),\n    },\n    {\n      title: 'Resource',\n      dataIndex: 'resourceCategory',\n      width: 120,\n    },\n    {\n      title: 'Priority',\n      dataIndex: 'priority',\n      width: 80,\n      render: (priority: number) => (\n        <Tag color={priority <= 3 ? 'red' : priority <= 6 ? 'orange' : 'default'}>\n          {priority}\n        </Tag>\n      ),\n    },\n    {\n      title: 'Created',\n      dataIndex: 'createdAt',\n      width: 150,\n      render: (date: string) => new Date(date).toLocaleString(),\n    },\n    {\n      title: 'Actions',\n      width: 200,\n      render: (_: any, record: any) => (\n        <Space>\n          {record.status === 'failed' && (\n            <Button size=\"small\" onClick={() => handleRetry(record.id)}>\n              Retry\n            </Button>\n          )}\n          {['pending', 'queued', 'running'].includes(record.status) && (\n            <Button size=\"small\" danger onClick={() => handleCancel(record.id)}>\n              Cancel\n            </Button>\n          )}\n          <Button size=\"small\" onClick={() => window.open(`/app/media/jobs/${record.id}`, '_blank')}>\n            View Log\n          </Button>\n        </Space>\n      ),\n    },\n  ];\n\n  return (\n    <div>\n      <Space style={{ marginBottom: 16 }}>\n        <Select\n          placeholder=\"Filter by status\"\n          style={{ width: 150 }}\n          onChange={(value) => setFilter({ ...filter, status: value })}\n          allowClear\n        >\n          <Select.Option value=\"pending\">Pending</Select.Option>\n          <Select.Option value=\"running\">Running</Select.Option>\n          <Select.Option value=\"completed\">Completed</Select.Option>\n          <Select.Option value=\"failed\">Failed</Select.Option>\n        </Select>\n\n        <Select\n          placeholder=\"Filter by type\"\n          style={{ width: 200 }}\n          onChange={(value) => setFilter({ ...filter, type: value })}\n          allowClear\n        >\n          <Select.Option value=\"scan\">Scan</Select.Option>\n          <Select.Option value=\"reencode_streaming\">Re-encode</Select.Option>\n          <Select.Option value=\"compile_random\">Compilation</Select.Option>\n        </Select>\n\n        <Button onClick={() => setPolling(!polling)}>\n          {polling ? 'Stop Auto-Refresh' : 'Start Auto-Refresh'}\n        </Button>\n      </Space>\n\n      <Table\n        columns={columns}\n        dataSource={jobs}\n        loading={loading}\n        rowKey=\"id\"\n        pagination={{ pageSize: 20 }}\n      />\n    </div>\n  );\n}\n
"},{"location":"v2/features/media/jobs/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/media/jobs/#problem-jobs-stuck-in-pending","title":"Problem: Jobs Stuck in Pending","text":"

Symptoms:

  • Jobs created but never start
  • Status remains \"pending\" for hours
  • No \"running\" jobs visible

Solutions:

  1. Check worker process running:
docker compose ps media-api\n# Should show \"Up\" status\n\ndocker compose logs media-api | grep \"Job worker\"\n# Should show \"Job worker started\"\n
  1. Manually trigger worker:
# Restart media-api container\ndocker compose restart media-api\n\n# Worker starts automatically on container boot\n
  1. Check worker logs for errors:
docker compose logs -f media-api | grep ERROR\n# Look for database connection errors, permission issues\n
  1. Verify database connection:
# Test database accessible from container\ndocker compose exec media-api psql $DATABASE_URL -c \"SELECT COUNT(*) FROM jobs WHERE status='pending';\"\n
"},{"location":"v2/features/media/jobs/#problem-job-fails-immediately","title":"Problem: Job Fails Immediately","text":"

Symptoms:

  • Job status changes from pending \u2192 running \u2192 failed within seconds
  • No meaningful progress
  • Error in log: \"Command not found\" or \"Permission denied\"

Solutions:

  1. Check job log in database:
SELECT log FROM jobs WHERE id = 'JOB_ID';\n
  1. Verify FFmpeg installed:
docker compose exec media-api which ffmpeg\n# Should output: /usr/bin/ffmpeg\n\ndocker compose exec media-api ffmpeg -version\n
  1. Check file paths valid:
# Verify input file exists\ndocker compose exec media-api ls -la /media/local/library/inbox/original.mp4\n\n# Check output directory writable\ndocker compose exec media-api touch /media/local/playback/test.txt\n
  1. Test FFmpeg command manually:
# Copy command from job log, run manually\ndocker compose exec media-api ffmpeg -i /media/local/inbox/test.mp4 -c:v libx264 /media/local/playback/test-output.mp4\n
"},{"location":"v2/features/media/jobs/#problem-re-encode-job-hangs-at-same-progress","title":"Problem: Re-encode Job Hangs at Same Progress","text":"

Symptoms:

  • Job progress reaches 25%, 50%, or 75% then stops updating
  • Status remains \"running\" for hours
  • No CPU/GPU activity visible

Solutions:

  1. Check FFmpeg process still running:
docker compose exec media-api ps aux | grep ffmpeg\n# Should show ffmpeg process\n\n# If not running, worker crashed\ndocker compose logs media-api --tail 100\n
  1. Kill hung FFmpeg process:
docker compose exec media-api pkill -9 ffmpeg\n\n# Job will fail and can be retried\n
  1. Check disk space:
df -h /media/local/playback\n# If 100% full, encoding fails\n\n# Free space\ndocker compose exec media-api rm /media/local/playback/*.partial\n
  1. Increase FFmpeg timeout (if very large file):
// api/src/modules/media/services/job-worker.service.ts\nconst FFMPEG_TIMEOUT = 3600000; // 1 hour (from 30 minutes)\n
"},{"location":"v2/features/media/jobs/#problem-gpu-out-of-memory-errors","title":"Problem: GPU Out of Memory Errors","text":"

Symptoms:

  • Multiple GPU jobs running simultaneously
  • Error in log: \"CUDA out of memory\" or \"Cannot allocate memory\"
  • System becomes unresponsive

Solutions:

  1. Check total VRAM available:
nvidia-smi\n# Shows GPU memory usage\n\n# Should show < 16GB used (adjust based on your GPU)\n
  1. Reduce concurrent GPU job limit:
// api/src/modules/media/services/job-worker.service.ts\nconst limits = {\n  cpu: 5,\n  gpu_encode: 1,  // Reduced from 2\n  gpu_ai: 1,\n};\n
  1. Increase VRAM requirements for jobs:
// Jobs require more VRAM than specified\n// Update job creation to use higher vramRequired values\n{\n  type: 'reencode_streaming',\n  vramRequired: 6000,  // Increased from 4000\n}\n
  1. Kill running GPU jobs:
# Stop all media jobs\ndocker compose exec media-api pkill -9 ffmpeg\n\n# Update stuck jobs to failed status\ndocker compose exec v2-postgres psql -U changemaker -d v2_changemaker \\\n  -c \"UPDATE jobs SET status='failed' WHERE status='running';\"\n
"},{"location":"v2/features/media/jobs/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/media/jobs/#job-queue-throughput","title":"Job Queue Throughput","text":"

Scaling Factors:

  • CPU jobs: 5 concurrent = ~10-20 jobs/minute (scans, validations)
  • GPU encode: 2 concurrent = ~4-8 videos/hour (depends on length)
  • GPU AI: 1 concurrent = ~2-6 videos/hour (depends on complexity)

Bottlenecks:

  1. GPU Memory \u2014 Limits concurrent GPU jobs
  2. Disk I/O \u2014 Reading/writing large video files
  3. CPU \u2014 FFmpeg encoding uses all available cores

Optimization:

  • Distribute workers across multiple machines \u2014 Each machine runs separate worker process
  • Use job priority \u2014 Urgent jobs (priority 1-3) run first
  • Batch similar jobs \u2014 Group scan jobs, re-encode jobs, etc. for efficiency
"},{"location":"v2/features/media/jobs/#database-performance","title":"Database Performance","text":"

Job Queue Index:

CREATE INDEX idx_jobs_status_priority ON jobs(status, priority, created_at);\n

Query Performance:

  • Find next pending job: ~1-5ms (with index)
  • Update job status: ~2-10ms
  • Fetch job logs: ~5-20ms

Optimization:

  • Partition jobs table by date \u2014 Move old completed/failed jobs to archive table
  • Limit log size \u2014 Truncate logs > 10KB to prevent bloat
"},{"location":"v2/features/media/jobs/#monitoring-observability","title":"Monitoring & Observability","text":""},{"location":"v2/features/media/jobs/#prometheus-metrics","title":"Prometheus Metrics","text":"
// api/src/utils/metrics.ts\nimport { Counter, Gauge } from 'prom-client';\n\nexport const mediaJobsTotal = new Counter({\n  name: 'media_jobs_total',\n  help: 'Total media jobs created',\n  labelNames: ['type', 'status'],\n});\n\nexport const mediaJobsPending = new Gauge({\n  name: 'media_jobs_pending',\n  help: 'Number of pending media jobs',\n});\n\nexport const mediaJobsRunning = new Gauge({\n  name: 'media_jobs_running',\n  help: 'Number of running media jobs',\n  labelNames: ['resourceCategory'],\n});\n\nexport const mediaVramUsed = new Gauge({\n  name: 'media_vram_used_mb',\n  help: 'Total VRAM used by running jobs (MB)',\n});\n\n// Update metrics in worker\nmediaJobsPending.set(pendingCount);\nmediaJobsRunning.set({ resourceCategory: 'gpu_encode' }, gpuEncodeCount);\nmediaVramUsed.set(totalVramUsed);\n
"},{"location":"v2/features/media/jobs/#grafana-dashboard-panel","title":"Grafana Dashboard Panel","text":"

Job Queue Status:

# Pending jobs count\nmedia_jobs_pending\n\n# Running jobs by category\nsum(media_jobs_running) by (resourceCategory)\n\n# VRAM usage percentage\n(media_vram_used_mb / 16000) * 100\n

Alert Rules:

# configs/prometheus/alerts.yml\ngroups:\n  - name: media_jobs\n    rules:\n      - alert: MediaJobQueueBacklog\n        expr: media_jobs_pending > 50\n        for: 30m\n        labels:\n          severity: warning\n        annotations:\n          summary: \"Media job queue backlog\"\n          description: \"{{ $value }} jobs pending for 30+ minutes\"\n\n      - alert: MediaJobsStuckRunning\n        expr: sum(media_jobs_running) == 0 AND media_jobs_pending > 0\n        for: 10m\n        labels:\n          severity: critical\n        annotations:\n          summary: \"Media jobs stuck\"\n          description: \"Jobs pending but worker not processing\"\n
"},{"location":"v2/features/media/jobs/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/media/jobs/#backend-documentation","title":"Backend Documentation","text":"
  • Job Worker: backend/modules/media/job-worker.md \u2014 Worker process implementation
  • Job Processors: backend/modules/media/processors/ \u2014 Individual job type processors (reencode, scan, etc.)
  • Jobs Routes: backend/modules/media/jobs.md \u2014 API endpoints for job management
"},{"location":"v2/features/media/jobs/#frontend-documentation","title":"Frontend Documentation","text":"
  • Jobs Page: frontend/pages/media/jobs.md \u2014 Job queue monitoring UI
  • Job Detail Modal: frontend/components/media/job-detail.md \u2014 Log viewer component
"},{"location":"v2/features/media/jobs/#feature-documentation","title":"Feature Documentation","text":"
  • Video Library: features/media/video-library.md \u2014 Triggering jobs from library actions
  • Upload System: features/media/upload.md \u2014 Post-upload job creation
"},{"location":"v2/features/media/jobs/#next-steps","title":"Next Steps","text":"

After mastering the job queue:

  1. Create Custom Jobs \u2014 Implement new job types for domain-specific processing
  2. Optimize Scheduling \u2014 Tune resource limits and priority settings for your workload
  3. Monitor Performance \u2014 Set up Grafana dashboards and alerts for job queue health
  4. Distributed Workers \u2014 Scale horizontally by running workers on multiple machines

Hands-On Practice:

# 1. Create re-encode job\ncurl -X POST http://localhost:4100/api/media/jobs \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"type\": \"reencode_streaming\",\n    \"params\": { \"videoId\": \"VIDEO_ID\", \"targetBitrate\": 2000 },\n    \"priority\": 5\n  }'\n\n# 2. Monitor job progress\nwatch -n 2 'curl -s http://localhost:4100/api/media/jobs/JOB_ID | jq \".progress\"'\n\n# 3. View job logs\ncurl http://localhost:4100/api/media/jobs/JOB_ID | jq -r \".log\"\n\n# 4. Check queue stats\ncurl http://localhost:4100/api/media/jobs/stats | jq\n

Last Updated: 2026-02-13 Version: V2.0 Maintainer: Changemaker Lite Team

"},{"location":"v2/features/media/public-gallery/","title":"Public Video Gallery","text":""},{"location":"v2/features/media/public-gallery/#overview","title":"Overview","text":"

The Public Video Gallery provides a visitor-friendly interface for browsing and watching shared videos without requiring authentication. Built with category-based organization, reaction systems, and view tracking, it transforms the admin video library into a public-facing media platform similar to YouTube or Vimeo.

Key Features:

  • Public Access \u2014 No login required, SEO-friendly URLs
  • Category Organization \u2014 Browse by Entertainment, Education, Sports, News, etc.
  • Lock/Unlock System \u2014 Admins control which videos are public via Shared Media page
  • Reaction System \u2014 6 emoji reactions (Like, Love, Laugh, Surprise, Sad, Angry)
  • Comment System \u2014 Visitor comments with name/email (moderation pending)
  • View Tracking \u2014 Track total views + watch time per video
  • Upvote System \u2014 Visitors upvote favorite videos (ranking algorithm)
  • Related Videos \u2014 Show 3 similar videos below player
  • Responsive Design \u2014 Mobile-friendly grid layout
  • Video Player \u2014 HTML5 player with controls, fullscreen, playback speed
  • Social Sharing \u2014 Share video URLs on social media

Access Control:

  • Public Routes \u2014 No authentication required
  • Admin Control \u2014 Shared Media page (SUPER_ADMIN only) controls which videos are public
  • Unlocking Videos \u2014 Removes from public gallery (not deleted, just hidden)

Technology Stack:

  • Frontend: React + Ant Design + react-player
  • Backend: Fastify media API public routes (no auth)
  • Caching: Redis for public video lists (5 min TTL)
  • SEO: Server-side meta tags, sitemap generation
"},{"location":"v2/features/media/public-gallery/#architecture","title":"Architecture","text":"
flowchart TB\n    subgraph \"Public Users\"\n        U1[Desktop Browser]\n        U2[Mobile Browser]\n        U3[Social Media Bot]\n    end\n\n    subgraph \"Admin Control\"\n        A1[Admin User]\n        A2[SharedMediaPage]\n    end\n\n    subgraph \"Public Routes (No Auth)\"\n        P1[GET /api/public/media]\n        P2[GET /api/public/media/:id]\n        P3[POST /api/public/media/:id/view]\n        P4[POST /api/public/media/:id/reaction]\n        P5[POST /api/public/media/:id/comment]\n    end\n\n    subgraph \"Admin Routes (Auth)\"\n        A3[PUT /api/media/videos/:id/share]\n        A4[PUT /api/media/videos/:id/unshare]\n    end\n\n    subgraph \"Database\"\n        D1[(videos table)]\n        D2[(reactions table)]\n        D3[(comments table)]\n        D4[(view_logs table)]\n    end\n\n    subgraph \"Cache\"\n        C1[Redis<br/>Public Videos<br/>5 min TTL]\n    end\n\n    U1 --> P1\n    U2 --> P1\n    U3 --> P1\n\n    U1 --> P2\n    U2 --> P2\n\n    U1 --> P3\n    U1 --> P4\n    U1 --> P5\n\n    A1 --> A2\n    A2 --> A3\n    A2 --> A4\n\n    P1 --> C1\n    C1 --> D1\n\n    P2 --> D1\n    P3 --> D4\n    P4 --> D2\n    P5 --> D3\n\n    A3 --> D1\n    A4 --> D1\n\n    style P1 fill:#2ecc71\n    style P2 fill:#2ecc71\n    style C1 fill:#e74c3c\n    style A2 fill:#3498db

Workflow:

  1. Admin Shares Video \u2014 Admin clicks \"Share\" button on SharedMediaPage \u2192 video marked public
  2. Public Browse \u2014 Visitor navigates to /media \u2192 sees grid of public videos
  3. Video Player \u2014 Visitor clicks video card \u2192 opens /media/:id \u2192 player page
  4. Engagement \u2014 Visitor reacts, comments, or shares video
  5. View Tracking \u2014 Frontend tracks watch time, sends to API on pause/end
  6. Related Videos \u2014 API suggests 3 similar videos (same category/creator)
"},{"location":"v2/features/media/public-gallery/#database-models","title":"Database Models","text":""},{"location":"v2/features/media/public-gallery/#videos-table-public-fields","title":"Videos Table (Public Fields)","text":"
// Only expose public-safe fields\ninterface PublicVideo {\n  id: string;\n  title: string;\n  producer: string;\n  creator: string;\n  durationSeconds: number;\n  quality: string;\n  orientation: string;\n  thumbnailPath: string;\n  publicViewCount: number;\n  publicUpvoteCount: number;\n  createdAt: Date;\n\n  // Derived fields\n  category: string; // From tags or directoryType\n  isPublic: boolean; // Computed: movedFromPublicAt === null\n}\n

Privacy: Never expose path, filename, fileHash, or internal metadata publicly.

"},{"location":"v2/features/media/public-gallery/#reactions-table","title":"Reactions Table","text":"
CREATE TABLE video_reactions (\n  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n  video_id UUID NOT NULL REFERENCES videos(id),\n  reaction_type TEXT NOT NULL, -- like|love|laugh|surprise|sad|angry\n  session_id TEXT NOT NULL, -- IP hash or session cookie\n  created_at TIMESTAMP DEFAULT NOW(),\n  UNIQUE(video_id, session_id) -- One reaction per user per video\n);\n\nCREATE INDEX idx_reactions_video ON video_reactions(video_id);\nCREATE INDEX idx_reactions_session ON video_reactions(session_id);\n

Reaction Types:

  • \ud83d\udc4d like \u2014 General approval
  • \u2764\ufe0f love \u2014 Strong positive emotion
  • \ud83d\ude02 laugh \u2014 Funny/amusing
  • \ud83d\ude2e surprise \u2014 Surprising/shocking
  • \ud83d\ude22 sad \u2014 Sad/emotional
  • \ud83d\ude20 angry \u2014 Frustrating/angering

Session Tracking:

// Use IP hash for anonymous users\nconst sessionId = crypto.createHash('sha256').update(req.ip).digest('hex');\n\n// Or use cookie for persistent tracking\nconst sessionId = req.cookies.sessionId || randomUUID();\nres.cookie('sessionId', sessionId, { maxAge: 365 * 24 * 60 * 60 * 1000 }); // 1 year\n
"},{"location":"v2/features/media/public-gallery/#comments-table","title":"Comments Table","text":"
CREATE TABLE video_comments (\n  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n  video_id UUID NOT NULL REFERENCES videos(id),\n  name TEXT NOT NULL,\n  email TEXT, -- Optional, for moderation notifications\n  comment TEXT NOT NULL,\n  approved BOOLEAN DEFAULT FALSE, -- Moderation flag\n  session_id TEXT, -- For tracking duplicate comments\n  created_at TIMESTAMP DEFAULT NOW()\n);\n\nCREATE INDEX idx_comments_video ON video_comments(video_id);\nCREATE INDEX idx_comments_approved ON video_comments(approved);\n

Moderation Workflow:

  1. User submits comment \u2192 stored with approved = false
  2. Admin reviews comment in moderation dashboard
  3. Admin clicks \"Approve\" \u2192 approved = true, comment visible
  4. Admin clicks \"Reject\" \u2192 comment remains hidden or deleted
"},{"location":"v2/features/media/public-gallery/#view-logs-table","title":"View Logs Table","text":"
CREATE TABLE video_view_logs (\n  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n  video_id UUID NOT NULL REFERENCES videos(id),\n  session_id TEXT NOT NULL,\n  watch_time_seconds INTEGER DEFAULT 0, -- Actual watch time (not video duration)\n  completed BOOLEAN DEFAULT FALSE, -- Watched > 90%\n  created_at TIMESTAMP DEFAULT NOW()\n);\n\nCREATE INDEX idx_view_logs_video ON video_view_logs(video_id);\nCREATE INDEX idx_view_logs_session ON video_view_logs(session_id, video_id);\n

Watch Time Tracking:

// Frontend sends watch time on pause/end\nlet watchTime = 0;\nconst interval = setInterval(() => {\n  if (!player.paused) {\n    watchTime++;\n  }\n}, 1000);\n\n// On pause or end\nconst handlePause = async () => {\n  await axios.post(`/api/public/media/${videoId}/view`, {\n    watchTimeSeconds: watchTime,\n    completed: watchTime >= video.durationSeconds * 0.9,\n  });\n};\n
"},{"location":"v2/features/media/public-gallery/#api-endpoints-public","title":"API Endpoints (Public)","text":"

All endpoints are public (no authentication required).

"},{"location":"v2/features/media/public-gallery/#list-public-videos","title":"List Public Videos","text":"
GET /api/public/media\n

Query Parameters:

Parameter Type Default Description page number 1 Page number limit number 24 Results per page category string - Filter by category orientation string - Filter by orientation (portrait/landscape/square) quality string - Filter by quality (SD/HD/FHD/UHD) sort string recent Sort by: recent, popular, trending

Response:

{\n  \"data\": [\n    {\n      \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n      \"title\": \"Amazing Sports Highlight\",\n      \"producer\": \"Studio A\",\n      \"creator\": \"Director B\",\n      \"durationSeconds\": 125,\n      \"quality\": \"FHD\",\n      \"orientation\": \"landscape\",\n      \"thumbnailPath\": \"/media/thumbnails/550e8400.jpg\",\n      \"publicViewCount\": 1250,\n      \"publicUpvoteCount\": 85,\n      \"category\": \"Sports\",\n      \"createdAt\": \"2026-02-10T12:00:00Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 24,\n    \"total\": 156,\n    \"totalPages\": 7\n  }\n}\n

Caching:

// Cache public video lists for 5 minutes\nconst cacheKey = `public:videos:${JSON.stringify(query)}`;\nconst cached = await redisClient.get(cacheKey);\nif (cached) {\n  return reply.send(JSON.parse(cached));\n}\n\n// Fetch from database\nconst videos = await db.select()...;\n\n// Cache for 5 minutes\nawait redisClient.setex(cacheKey, 300, JSON.stringify(videos));\n
"},{"location":"v2/features/media/public-gallery/#get-video-details","title":"Get Video Details","text":"
GET /api/public/media/:id\n

Response:

{\n  \"video\": {\n    \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n    \"title\": \"Amazing Sports Highlight\",\n    \"producer\": \"Studio A\",\n    \"creator\": \"Director B\",\n    \"durationSeconds\": 125,\n    \"quality\": \"FHD\",\n    \"orientation\": \"landscape\",\n    \"width\": 1920,\n    \"height\": 1080,\n    \"thumbnailPath\": \"/media/thumbnails/550e8400.jpg\",\n    \"publicViewCount\": 1251,\n    \"publicUpvoteCount\": 85,\n    \"category\": \"Sports\",\n    \"createdAt\": \"2026-02-10T12:00:00Z\",\n    \"reactions\": {\n      \"like\": 45,\n      \"love\": 20,\n      \"laugh\": 10,\n      \"surprise\": 5,\n      \"sad\": 3,\n      \"angry\": 2\n    }\n  },\n  \"relatedVideos\": [\n    {\n      \"id\": \"660e8400-e29b-41d4-a716-446655440001\",\n      \"title\": \"Another Sports Video\",\n      \"thumbnailPath\": \"/media/thumbnails/660e8400.jpg\",\n      \"durationSeconds\": 90\n    },\n    {\n      \"id\": \"770e8400-e29b-41d4-a716-446655440002\",\n      \"title\": \"Top Plays Compilation\",\n      \"thumbnailPath\": \"/media/thumbnails/770e8400.jpg\",\n      \"durationSeconds\": 180\n    }\n  ],\n  \"comments\": [\n    {\n      \"id\": \"880e8400-e29b-41d4-a716-446655440003\",\n      \"name\": \"John Doe\",\n      \"comment\": \"Amazing video!\",\n      \"createdAt\": \"2026-02-12T14:30:00Z\"\n    }\n  ]\n}\n

Related Videos Algorithm:

// Find 3 similar videos\nconst relatedVideos = await db.select()\n  .from(videos)\n  .where(\n    and(\n      eq(videos.isPublic, true),\n      eq(videos.category, video.category), // Same category\n      not(eq(videos.id, video.id)) // Not current video\n    )\n  )\n  .orderBy(desc(videos.publicViewCount)) // Most popular first\n  .limit(3);\n
"},{"location":"v2/features/media/public-gallery/#track-video-view","title":"Track Video View","text":"
POST /api/public/media/:id/view\n

Request Body:

{\n  \"watchTimeSeconds\": 120,\n  \"completed\": true\n}\n

Response:

{\n  \"success\": true,\n  \"newViewCount\": 1252\n}\n

Process:

  1. Get session ID (IP hash or cookie)
  2. Check if already viewed in last 24 hours (prevent duplicate counting)
  3. Create view log record
  4. Increment video publicViewCount
  5. Return new view count
"},{"location":"v2/features/media/public-gallery/#addupdate-reaction","title":"Add/Update Reaction","text":"
POST /api/public/media/:id/reaction\n

Request Body:

{\n  \"reactionType\": \"like\"\n}\n

Response:

{\n  \"success\": true,\n  \"reactions\": {\n    \"like\": 46,\n    \"love\": 20,\n    \"laugh\": 10,\n    \"surprise\": 5,\n    \"sad\": 3,\n    \"angry\": 2\n  }\n}\n

Process:

  1. Get session ID
  2. Check if user already reacted
  3. If same reaction, remove it (toggle off)
  4. If different reaction, update it
  5. If no reaction, insert new one
  6. Return updated reaction counts
"},{"location":"v2/features/media/public-gallery/#submit-comment","title":"Submit Comment","text":"
POST /api/public/media/:id/comment\n

Request Body:

{\n  \"name\": \"John Doe\",\n  \"email\": \"john@example.com\",\n  \"comment\": \"This video is amazing! Thanks for sharing.\"\n}\n

Response:

{\n  \"success\": true,\n  \"message\": \"Comment submitted for moderation\"\n}\n

Validation:

  • Name: 1-100 characters
  • Email: Optional, valid email format
  • Comment: 1-1000 characters, no HTML allowed

Anti-Spam:

  • Rate limit: 5 comments per hour per session
  • Duplicate detection: reject if same comment in last 24 hours
"},{"location":"v2/features/media/public-gallery/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/media/public-gallery/#sharing-videos-making-public","title":"Sharing Videos (Making Public)","text":"
  1. Navigate to Media \u2192 Shared Media page
  2. Table shows all videos with \"Public\" toggle switch
  3. To share video:
  4. Click toggle switch to ON (blue)
  5. Video immediately appears in public gallery
  6. Modal prompts for category selection (optional)
  7. To unshare video:
  8. Click toggle switch to OFF (grey)
  9. Video removed from public gallery
  10. movedFromPublicAt timestamp set (preserves history)

Shared Media Page Features:

  • Category Management \u2014 Assign videos to categories (Entertainment, Education, Sports, etc.)
  • Bulk Actions \u2014 Select multiple videos, share/unshare all at once
  • Preview \u2014 Click \"Preview\" button to see public view
  • Stats \u2014 View count, upvote count, reaction breakdown
  • Lock Indicator \u2014 Icon shows which videos are currently public
"},{"location":"v2/features/media/public-gallery/#setting-categories","title":"Setting Categories","text":"

Option 1: Tag-Based Categories

Use video tags to auto-assign categories:

// If video has \"sports\" tag \u2192 Sports category\n// If video has \"education\" or \"tutorial\" tag \u2192 Education category\nconst detectCategory = (tags: string[]): string => {\n  if (tags.some(t => ['sports', 'game', 'play'].includes(t.toLowerCase()))) {\n    return 'Sports';\n  }\n  if (tags.some(t => ['education', 'tutorial', 'learn'].includes(t.toLowerCase()))) {\n    return 'Education';\n  }\n  if (tags.some(t => ['entertainment', 'comedy', 'music'].includes(t.toLowerCase()))) {\n    return 'Entertainment';\n  }\n  return 'Other';\n};\n

Option 2: Manual Assignment

  1. Select video in Shared Media page
  2. Click \"Edit Category\" button
  3. Modal opens with category dropdown:
  4. Entertainment
  5. Education
  6. Sports
  7. News
  8. Music
  9. Gaming
  10. Science & Tech
  11. Travel
  12. Other
  13. Click \"Save\"
  14. Category updated immediately
"},{"location":"v2/features/media/public-gallery/#viewing-statistics","title":"Viewing Statistics","text":"

Per-Video Stats:

  1. Click video row in Shared Media page
  2. Stats drawer slides in from right showing:
  3. Total Views \u2014 All-time view count
  4. Average Watch Time \u2014 Mean watch time (seconds)
  5. Completion Rate \u2014 % of viewers who watched > 90%
  6. Upvotes \u2014 Total upvote count
  7. Reactions Breakdown \u2014 Chart showing reaction distribution
  8. Top Referrers \u2014 Where views came from (direct, social, etc.)
  9. View Trend \u2014 Line chart of views over last 30 days

Gallery-Wide Stats:

Dashboard widget showing:

  • Total public videos
  • Total views across all videos
  • Most popular video (by views)
  • Trending video (highest growth rate)
  • Total reactions
  • Total comments (pending + approved)
"},{"location":"v2/features/media/public-gallery/#moderating-comments","title":"Moderating Comments","text":"
  1. Navigate to Media \u2192 Comments page (or notification badge in sidebar)
  2. Table shows all comments with filters:
  3. Pending \u2014 Awaiting moderation
  4. Approved \u2014 Visible on public gallery
  5. Rejected \u2014 Hidden from public
  6. To approve comment:
  7. Click \"Approve\" button
  8. Comment appears on video page immediately
  9. To reject comment:
  10. Click \"Reject\" button
  11. Comment hidden (or deleted)
  12. Optional: Send email to commenter explaining why

Bulk Moderation:

  • Select multiple comments via checkboxes
  • Click \"Approve All\" or \"Reject All\"
  • Batch updates applied instantly
"},{"location":"v2/features/media/public-gallery/#public-user-workflow","title":"Public User Workflow","text":""},{"location":"v2/features/media/public-gallery/#browsing-gallery","title":"Browsing Gallery","text":"
  1. Navigate to https://cmlite.org/media
  2. Hero section shows featured video (most popular or admin-selected)
  3. Category tabs below hero:
  4. All
  5. Entertainment
  6. Education
  7. Sports
  8. News
  9. Music
  10. Gaming
  11. Science & Tech
  12. Grid of video cards (4 per row on desktop, 2 on tablet, 1 on mobile)
  13. Each card shows:
  14. Thumbnail image
  15. Title
  16. Producer/creator
  17. Duration badge
  18. View count
  19. Quality badge (HD, FHD, UHD)

Infinite Scroll:

  • As user scrolls to bottom, next page loads automatically
  • Loading spinner shows while fetching
  • No \"Load More\" button needed
"},{"location":"v2/features/media/public-gallery/#watching-video","title":"Watching Video","text":"
  1. Click video card \u2192 navigates to https://cmlite.org/media/:id
  2. Video player page layout:
  3. Video Player \u2014 Full-width HTML5 player with controls
  4. Video Title & Metadata \u2014 Title, producer, creator, view count
  5. Reaction Bar \u2014 6 emoji buttons with counts
  6. Description \u2014 Auto-generated or admin-provided
  7. Comments Section \u2014 Approved comments + submit form
  8. Related Videos \u2014 3 similar videos in sidebar
  9. User clicks play \u2192 video starts, watch time tracked
  10. User clicks reaction \u2192 emoji highlighted, count increments
  11. User scrolls to comments \u2192 reads existing, submits new

Video Player Features:

  • Play/pause button
  • Volume slider
  • Playback speed (0.5x, 1x, 1.25x, 1.5x, 2x)
  • Fullscreen button
  • Current time / total duration
  • Scrub bar (seek to any position)
  • Auto-play next related video (optional)
"},{"location":"v2/features/media/public-gallery/#reacting-to-video","title":"Reacting to Video","text":"
  1. Click reaction emoji button (e.g., \ud83d\udc4d Like)
  2. Button highlights in color
  3. Count increments by 1
  4. Toggle behavior:
  5. Click again \u2192 removes reaction, count decrements
  6. Click different emoji \u2192 switches reaction
  7. Session tracked via cookie (reactions persist across page refreshes)

Reaction Colors:

  • Like \ud83d\udc4d \u2014 Blue
  • Love \u2764\ufe0f \u2014 Red
  • Laugh \ud83d\ude02 \u2014 Yellow
  • Surprise \ud83d\ude2e \u2014 Purple
  • Sad \ud83d\ude22 \u2014 Grey
  • Angry \ud83d\ude20 \u2014 Orange
"},{"location":"v2/features/media/public-gallery/#commenting","title":"Commenting","text":"
  1. Scroll to comments section below video
  2. Fill out form:
  3. Name \u2014 Required, displayed publicly
  4. Email \u2014 Optional, for moderation notifications
  5. Comment \u2014 Required, 1-1000 characters
  6. Click \"Submit Comment\"
  7. Success message: \"Comment submitted for moderation\"
  8. Comment appears in list with \"Pending approval\" badge
  9. After admin approval, comment visible to all

Comment Formatting:

  • Plain text only (no HTML)
  • URLs auto-linked
  • Line breaks preserved
  • Profanity filter applied (optional)
"},{"location":"v2/features/media/public-gallery/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/media/public-gallery/#backend-list-public-videos","title":"Backend: List Public Videos","text":"
// api/src/modules/media/routes/public.routes.ts\nimport { FastifyInstance } from 'fastify';\nimport { eq, and, isNull, desc } from 'drizzle-orm';\nimport { videos } from '@/modules/media/db/schema';\nimport { redisClient } from '@/config/redis';\n\nexport default async function (app: FastifyInstance) {\n  app.get('/api/public/media', async (req, reply) => {\n    const {\n      page = 1,\n      limit = 24,\n      category,\n      orientation,\n      quality,\n      sort = 'recent',\n    } = req.query as any;\n\n    // Check cache\n    const cacheKey = `public:videos:${JSON.stringify(req.query)}`;\n    const cached = await redisClient.get(cacheKey);\n    if (cached) {\n      return reply.send(JSON.parse(cached));\n    }\n\n    // Build filters\n    const filters = [\n      isNull(videos.movedFromPublicAt), // Only public videos\n      eq(videos.isValid, true),\n    ];\n\n    if (category) {\n      filters.push(eq(videos.category, category));\n    }\n\n    if (orientation) {\n      filters.push(eq(videos.orientation, orientation));\n    }\n\n    if (quality) {\n      filters.push(eq(videos.quality, quality));\n    }\n\n    // Build order by\n    let orderBy;\n    if (sort === 'popular') {\n      orderBy = desc(videos.publicViewCount);\n    } else if (sort === 'trending') {\n      // Trending = highest view count in last 7 days\n      // (requires separate view_logs aggregation query)\n      orderBy = desc(videos.publicViewCount);\n    } else {\n      orderBy = desc(videos.createdAt);\n    }\n\n    // Fetch videos\n    const results = await db\n      .select({\n        id: videos.id,\n        title: videos.title,\n        producer: videos.producer,\n        creator: videos.creator,\n        durationSeconds: videos.durationSeconds,\n        quality: videos.quality,\n        orientation: videos.orientation,\n        thumbnailPath: videos.thumbnailPath,\n        publicViewCount: videos.publicViewCount,\n        publicUpvoteCount: videos.publicUpvoteCount,\n        category: videos.category,\n        createdAt: videos.createdAt,\n      })\n      .from(videos)\n      .where(and(...filters))\n      .orderBy(orderBy)\n      .limit(Number(limit))\n      .offset((Number(page) - 1) * Number(limit));\n\n    // Count total\n    const [{ count }] = await db\n      .select({ count: sql<number>`count(*)` })\n      .from(videos)\n      .where(and(...filters));\n\n    const response = {\n      data: results,\n      pagination: {\n        page: Number(page),\n        limit: Number(limit),\n        total: Number(count),\n        totalPages: Math.ceil(Number(count) / Number(limit)),\n      },\n    };\n\n    // Cache for 5 minutes\n    await redisClient.setex(cacheKey, 300, JSON.stringify(response));\n\n    reply.send(response);\n  });\n}\n
"},{"location":"v2/features/media/public-gallery/#backend-track-view","title":"Backend: Track View","text":"
// api/src/modules/media/routes/public.routes.ts\nimport { videoViewLogs, videos } from '@/modules/media/db/schema';\nimport crypto from 'crypto';\n\napp.post('/api/public/media/:id/view', async (req, reply) => {\n  const { id } = req.params as { id: string };\n  const { watchTimeSeconds, completed } = req.body as any;\n\n  // Get session ID from IP hash\n  const sessionId = crypto.createHash('sha256').update(req.ip).digest('hex');\n\n  // Check if already viewed in last 24 hours\n  const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);\n  const existingView = await db\n    .select()\n    .from(videoViewLogs)\n    .where(\n      and(\n        eq(videoViewLogs.videoId, id),\n        eq(videoViewLogs.sessionId, sessionId),\n        gte(videoViewLogs.createdAt, yesterday)\n      )\n    )\n    .limit(1);\n\n  if (existingView.length > 0) {\n    // Update watch time if longer than previous\n    if (watchTimeSeconds > existingView[0].watchTimeSeconds) {\n      await db\n        .update(videoViewLogs)\n        .set({\n          watchTimeSeconds,\n          completed: completed || existingView[0].completed,\n        })\n        .where(eq(videoViewLogs.id, existingView[0].id));\n    }\n\n    return reply.send({ success: true, newViewCount: null });\n  }\n\n  // Create new view log\n  await db.insert(videoViewLogs).values({\n    videoId: id,\n    sessionId,\n    watchTimeSeconds,\n    completed,\n  });\n\n  // Increment view count\n  const [updated] = await db\n    .update(videos)\n    .set({\n      publicViewCount: sql`${videos.publicViewCount} + 1`,\n    })\n    .where(eq(videos.id, id))\n    .returning({ newViewCount: videos.publicViewCount });\n\n  reply.send({ success: true, newViewCount: updated.newViewCount });\n});\n
"},{"location":"v2/features/media/public-gallery/#backend-add-reaction","title":"Backend: Add Reaction","text":"
// api/src/modules/media/routes/public.routes.ts\nimport { videoReactions } from '@/modules/media/db/schema';\n\napp.post('/api/public/media/:id/reaction', async (req, reply) => {\n  const { id } = req.params as { id: string };\n  const { reactionType } = req.body as { reactionType: string };\n\n  const validReactions = ['like', 'love', 'laugh', 'surprise', 'sad', 'angry'];\n  if (!validReactions.includes(reactionType)) {\n    return reply.code(400).send({ error: 'Invalid reaction type' });\n  }\n\n  const sessionId = crypto.createHash('sha256').update(req.ip).digest('hex');\n\n  // Check existing reaction\n  const [existing] = await db\n    .select()\n    .from(videoReactions)\n    .where(\n      and(\n        eq(videoReactions.videoId, id),\n        eq(videoReactions.sessionId, sessionId)\n      )\n    )\n    .limit(1);\n\n  if (existing) {\n    if (existing.reactionType === reactionType) {\n      // Toggle off (remove reaction)\n      await db\n        .delete(videoReactions)\n        .where(eq(videoReactions.id, existing.id));\n    } else {\n      // Update to new reaction\n      await db\n        .update(videoReactions)\n        .set({ reactionType })\n        .where(eq(videoReactions.id, existing.id));\n    }\n  } else {\n    // Insert new reaction\n    await db.insert(videoReactions).values({\n      videoId: id,\n      sessionId,\n      reactionType,\n    });\n  }\n\n  // Get updated reaction counts\n  const reactions = await db\n    .select({\n      reactionType: videoReactions.reactionType,\n      count: sql<number>`count(*)`,\n    })\n    .from(videoReactions)\n    .where(eq(videoReactions.videoId, id))\n    .groupBy(videoReactions.reactionType);\n\n  const reactionCounts = validReactions.reduce((acc, type) => {\n    acc[type] = reactions.find((r) => r.reactionType === type)?.count || 0;\n    return acc;\n  }, {} as Record<string, number>);\n\n  reply.send({ success: true, reactions: reactionCounts });\n});\n
"},{"location":"v2/features/media/public-gallery/#frontend-video-gallery-page","title":"Frontend: Video Gallery Page","text":"
// admin/src/pages/public/MediaGalleryPage.tsx\nimport { Row, Col, Card, Tag, Tabs, Empty } from 'antd';\nimport { PlayCircleOutlined, EyeOutlined } from '@ant-design/icons';\nimport { useEffect, useState } from 'react';\nimport axios from 'axios';\nimport InfiniteScroll from 'react-infinite-scroll-component';\n\nexport default function MediaGalleryPage() {\n  const [videos, setVideos] = useState<any[]>([]);\n  const [category, setCategory] = useState<string>('');\n  const [page, setPage] = useState(1);\n  const [hasMore, setHasMore] = useState(true);\n\n  const fetchVideos = async () => {\n    try {\n      const { data } = await axios.get('http://api.cmlite.org/api/public/media', {\n        params: {\n          page,\n          limit: 24,\n          category: category || undefined,\n        },\n      });\n\n      setVideos((prev) => [...prev, ...data.data]);\n      setHasMore(page < data.pagination.totalPages);\n    } catch (error) {\n      console.error('Failed to fetch videos:', error);\n    }\n  };\n\n  useEffect(() => {\n    setVideos([]);\n    setPage(1);\n    setHasMore(true);\n  }, [category]);\n\n  useEffect(() => {\n    fetchVideos();\n  }, [page, category]);\n\n  const categories = [\n    { key: '', label: 'All' },\n    { key: 'Entertainment', label: 'Entertainment' },\n    { key: 'Education', label: 'Education' },\n    { key: 'Sports', label: 'Sports' },\n    { key: 'News', label: 'News' },\n    { key: 'Music', label: 'Music' },\n    { key: 'Gaming', label: 'Gaming' },\n    { key: 'Science & Tech', label: 'Science & Tech' },\n  ];\n\n  return (\n    <div style={{ padding: 24 }}>\n      <h1 style={{ fontSize: 32, marginBottom: 24 }}>Video Gallery</h1>\n\n      <Tabs\n        activeKey={category}\n        onChange={setCategory}\n        items={categories.map((cat) => ({\n          key: cat.key,\n          label: cat.label,\n        }))}\n        style={{ marginBottom: 24 }}\n      />\n\n      <InfiniteScroll\n        dataLength={videos.length}\n        next={() => setPage((p) => p + 1)}\n        hasMore={hasMore}\n        loader={<div style={{ textAlign: 'center', padding: 24 }}>Loading...</div>}\n        endMessage={\n          <Empty description=\"No more videos\" style={{ marginTop: 48 }} />\n        }\n      >\n        <Row gutter={[16, 16]}>\n          {videos.map((video) => (\n            <Col key={video.id} xs={24} sm={12} md={8} lg={6}>\n              <Card\n                hoverable\n                cover={\n                  <div\n                    style={{\n                      position: 'relative',\n                      paddingTop: '56.25%',\n                      background: '#000',\n                    }}\n                  >\n                    <img\n                      src={video.thumbnailPath || '/placeholder.jpg'}\n                      alt={video.title}\n                      style={{\n                        position: 'absolute',\n                        top: 0,\n                        left: 0,\n                        width: '100%',\n                        height: '100%',\n                        objectFit: 'cover',\n                      }}\n                    />\n                    <div\n                      style={{\n                        position: 'absolute',\n                        top: 8,\n                        right: 8,\n                        background: 'rgba(0,0,0,0.7)',\n                        color: '#fff',\n                        padding: '4px 8px',\n                        borderRadius: 4,\n                        fontSize: 12,\n                      }}\n                    >\n                      {Math.floor(video.durationSeconds / 60)}:\n                      {(video.durationSeconds % 60).toString().padStart(2, '0')}\n                    </div>\n                    <PlayCircleOutlined\n                      style={{\n                        position: 'absolute',\n                        top: '50%',\n                        left: '50%',\n                        transform: 'translate(-50%, -50%)',\n                        fontSize: 48,\n                        color: '#fff',\n                        opacity: 0.8,\n                      }}\n                    />\n                  </div>\n                }\n                onClick={() => (window.location.href = `/media/${video.id}`)}\n              >\n                <Card.Meta\n                  title={\n                    <div style={{ fontSize: 14, height: 40, overflow: 'hidden' }}>\n                      {video.title}\n                    </div>\n                  }\n                  description={\n                    <div>\n                      <div style={{ fontSize: 12, color: '#888', marginBottom: 8 }}>\n                        {video.producer}\n                      </div>\n                      <div style={{ display: 'flex', justifyContent: 'space-between' }}>\n                        <span style={{ fontSize: 12 }}>\n                          <EyeOutlined /> {video.publicViewCount.toLocaleString()}\n                        </span>\n                        <Tag color={video.quality === 'UHD' ? 'purple' : 'blue'}>\n                          {video.quality}\n                        </Tag>\n                      </div>\n                    </div>\n                  }\n                />\n              </Card>\n            </Col>\n          ))}\n        </Row>\n      </InfiniteScroll>\n    </div>\n  );\n}\n
"},{"location":"v2/features/media/public-gallery/#frontend-video-player-page","title":"Frontend: Video Player Page","text":"
// admin/src/pages/public/MediaViewerPage.tsx\nimport { useParams } from 'react-router-dom';\nimport { useEffect, useState } from 'react';\nimport axios from 'axios';\nimport ReactPlayer from 'react-player';\nimport { Button, Row, Col, Card, Divider, Form, Input, message } from 'antd';\n\nexport default function MediaViewerPage() {\n  const { id } = useParams<{ id: string }>();\n  const [video, setVideo] = useState<any>(null);\n  const [watchTime, setWatchTime] = useState(0);\n  const [userReaction, setUserReaction] = useState<string | null>(null);\n\n  useEffect(() => {\n    fetchVideo();\n  }, [id]);\n\n  const fetchVideo = async () => {\n    const { data } = await axios.get(`http://api.cmlite.org/api/public/media/${id}`);\n    setVideo(data.video);\n  };\n\n  const trackView = async () => {\n    await axios.post(`http://api.cmlite.org/api/public/media/${id}/view`, {\n      watchTimeSeconds: watchTime,\n      completed: watchTime >= video.durationSeconds * 0.9,\n    });\n  };\n\n  const handleReaction = async (reactionType: string) => {\n    const { data } = await axios.post(`http://api.cmlite.org/api/public/media/${id}/reaction`, {\n      reactionType,\n    });\n\n    setUserReaction(userReaction === reactionType ? null : reactionType);\n    setVideo({ ...video, reactions: data.reactions });\n  };\n\n  const handleSubmitComment = async (values: any) => {\n    await axios.post(`http://api.cmlite.org/api/public/media/${id}/comment`, values);\n    message.success('Comment submitted for moderation');\n  };\n\n  if (!video) return <div>Loading...</div>;\n\n  const reactions = [\n    { type: 'like', emoji: '\ud83d\udc4d', label: 'Like' },\n    { type: 'love', emoji: '\u2764\ufe0f', label: 'Love' },\n    { type: 'laugh', emoji: '\ud83d\ude02', label: 'Laugh' },\n    { type: 'surprise', emoji: '\ud83d\ude2e', label: 'Surprise' },\n    { type: 'sad', emoji: '\ud83d\ude22', label: 'Sad' },\n    { type: 'angry', emoji: '\ud83d\ude20', label: 'Angry' },\n  ];\n\n  return (\n    <div style={{ maxWidth: 1200, margin: '0 auto', padding: 24 }}>\n      <Row gutter={24}>\n        <Col span={16}>\n          <ReactPlayer\n            url={`/media/videos/${video.id}.mp4`}\n            controls\n            width=\"100%\"\n            height=\"auto\"\n            onProgress={(state) => setWatchTime(Math.floor(state.playedSeconds))}\n            onPause={trackView}\n            onEnded={trackView}\n          />\n\n          <h1 style={{ marginTop: 16 }}>{video.title}</h1>\n          <div style={{ color: '#888', marginBottom: 16 }}>\n            {video.producer} \u2022 {video.publicViewCount.toLocaleString()} views\n          </div>\n\n          <div style={{ display: 'flex', gap: 8, marginBottom: 24 }}>\n            {reactions.map((r) => (\n              <Button\n                key={r.type}\n                type={userReaction === r.type ? 'primary' : 'default'}\n                onClick={() => handleReaction(r.type)}\n              >\n                <span style={{ fontSize: 20, marginRight: 4 }}>{r.emoji}</span>\n                {video.reactions[r.type] || 0}\n              </Button>\n            ))}\n          </div>\n\n          <Divider />\n\n          <h3>Comments</h3>\n          {video.comments.map((comment: any) => (\n            <Card key={comment.id} style={{ marginBottom: 16 }}>\n              <Card.Meta\n                title={comment.name}\n                description={comment.comment}\n              />\n              <div style={{ fontSize: 12, color: '#888', marginTop: 8 }}>\n                {new Date(comment.createdAt).toLocaleDateString()}\n              </div>\n            </Card>\n          ))}\n\n          <Form onFinish={handleSubmitComment} layout=\"vertical\">\n            <Form.Item label=\"Name\" name=\"name\" rules={[{ required: true }]}>\n              <Input />\n            </Form.Item>\n            <Form.Item label=\"Email\" name=\"email\" rules={[{ type: 'email' }]}>\n              <Input />\n            </Form.Item>\n            <Form.Item label=\"Comment\" name=\"comment\" rules={[{ required: true }]}>\n              <Input.TextArea rows={4} />\n            </Form.Item>\n            <Button type=\"primary\" htmlType=\"submit\">\n              Submit Comment\n            </Button>\n          </Form>\n        </Col>\n\n        <Col span={8}>\n          <h3>Related Videos</h3>\n          {video.relatedVideos.map((related: any) => (\n            <Card\n              key={related.id}\n              hoverable\n              cover={<img src={related.thumbnailPath} alt={related.title} />}\n              onClick={() => (window.location.href = `/media/${related.id}`)}\n              style={{ marginBottom: 16 }}\n            >\n              <Card.Meta title={related.title} />\n            </Card>\n          ))}\n        </Col>\n      </Row>\n    </div>\n  );\n}\n
"},{"location":"v2/features/media/public-gallery/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/media/public-gallery/#problem-videos-not-appearing-in-gallery","title":"Problem: Videos Not Appearing in Gallery","text":"

Symptoms:

  • SharedMediaPage shows videos marked as public
  • Public gallery shows \"No videos found\"
  • API returns empty array

Solutions:

  1. Check movedFromPublicAt field:
SELECT id, title, moved_from_public_at FROM videos WHERE moved_from_public_at IS NULL;\n-- Should show public videos\n\n-- If all have timestamps, videos were unlocked\n-- Fix: Set to NULL for videos that should be public\nUPDATE videos SET moved_from_public_at = NULL WHERE id = 'VIDEO_ID';\n
  1. Verify isValid = true:
SELECT id, title, is_valid FROM videos WHERE is_valid = false;\n-- Invalid videos hidden from public\n\n-- Fix: Validate videos to mark as valid\n
  1. Check Redis cache:
# Clear public video cache\ndocker compose exec redis redis-cli\n> KEYS public:videos:*\n> DEL public:videos:*\n\n# Refresh gallery page\n
  1. Test API directly:
curl http://localhost:4100/api/public/media\n# Should return JSON with videos array\n
"},{"location":"v2/features/media/public-gallery/#problem-reactions-not-saving","title":"Problem: Reactions Not Saving","text":"

Symptoms:

  • Click reaction button, count doesn't increment
  • Refresh page, reaction disappears
  • No errors in console

Solutions:

  1. Check session ID generation:
// Backend should use consistent session ID\nconst sessionId = crypto.createHash('sha256').update(req.ip).digest('hex');\n\n// Or use cookie for persistence\nconst sessionId = req.cookies.sessionId || randomUUID();\nres.cookie('sessionId', sessionId, { maxAge: 365 * 24 * 60 * 60 * 1000 });\n
  1. Verify database insert:
SELECT * FROM video_reactions WHERE video_id = 'VIDEO_ID';\n-- Should show reaction records\n\n-- If empty, insert is failing\n-- Check unique constraint: (video_id, session_id)\n
  1. Test reaction endpoint:
curl -X POST http://localhost:4100/api/public/media/VIDEO_ID/reaction \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"reactionType\": \"like\"}'\n\n# Should return updated reaction counts\n
"},{"location":"v2/features/media/public-gallery/#problem-comments-not-showing-after-approval","title":"Problem: Comments Not Showing After Approval","text":"

Symptoms:

  • Admin approves comment
  • Comment still doesn't appear on video page
  • Database shows approved = true

Solutions:

  1. Check query filter:
// Backend should filter for approved comments\nconst comments = await db\n  .select()\n  .from(videoComments)\n  .where(\n    and(\n      eq(videoComments.videoId, videoId),\n      eq(videoComments.approved, true) // MUST include this\n    )\n  )\n  .orderBy(desc(videoComments.createdAt));\n
  1. Clear cache:
# Video details may be cached\ndocker compose exec redis redis-cli DEL \"public:video:VIDEO_ID\"\n
  1. Verify approval:
SELECT id, comment, approved FROM video_comments WHERE video_id = 'VIDEO_ID';\n-- Should show approved = true\n
"},{"location":"v2/features/media/public-gallery/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/media/public-gallery/#redis-caching-strategy","title":"Redis Caching Strategy","text":"

Cache Keys:

  • public:videos:{query} \u2014 List of videos (5 min TTL)
  • public:video:{id} \u2014 Video details (10 min TTL)
  • public:stats \u2014 Gallery-wide stats (15 min TTL)

Cache Invalidation:

// When admin shares/unshares video\nawait redisClient.del(`public:videos:*`); // Clear all list caches\nawait redisClient.del(`public:video:${videoId}`); // Clear detail cache\n\n// When comment approved\nawait redisClient.del(`public:video:${videoId}`); // Refresh comments\n
"},{"location":"v2/features/media/public-gallery/#database-indexes","title":"Database Indexes","text":"
-- Public video queries\nCREATE INDEX idx_videos_public ON videos(moved_from_public_at) WHERE moved_from_public_at IS NULL;\nCREATE INDEX idx_videos_category ON videos(category, created_at DESC);\nCREATE INDEX idx_videos_popular ON videos(public_view_count DESC);\n\n-- Reactions\nCREATE INDEX idx_reactions_video ON video_reactions(video_id);\nCREATE INDEX idx_reactions_session ON video_reactions(session_id);\n\n-- Comments\nCREATE INDEX idx_comments_video_approved ON video_comments(video_id, approved);\n\n-- View logs\nCREATE INDEX idx_view_logs_video ON video_view_logs(video_id);\nCREATE INDEX idx_view_logs_recent ON video_view_logs(created_at DESC);\n
"},{"location":"v2/features/media/public-gallery/#seo-optimization","title":"SEO Optimization","text":"

Server-Side Rendering (Future):

// Next.js or similar for SSR\nexport async function getServerSideProps({ params }: { params: { id: string } }) {\n  const video = await fetchVideo(params.id);\n\n  return {\n    props: {\n      video,\n      meta: {\n        title: video.title,\n        description: `Watch ${video.title} by ${video.producer}`,\n        image: video.thumbnailPath,\n        url: `https://cmlite.org/media/${video.id}`,\n      },\n    },\n  };\n}\n

Meta Tags:

<head>\n  <title>Amazing Sports Highlight | CMLite Gallery</title>\n  <meta name=\"description\" content=\"Watch Amazing Sports Highlight by Studio A. 1,250 views.\">\n  <meta property=\"og:title\" content=\"Amazing Sports Highlight\">\n  <meta property=\"og:description\" content=\"Watch Amazing Sports Highlight by Studio A\">\n  <meta property=\"og:image\" content=\"https://cmlite.org/media/thumbnails/550e8400.jpg\">\n  <meta property=\"og:url\" content=\"https://cmlite.org/media/550e8400\">\n  <meta property=\"og:type\" content=\"video.other\">\n  <meta name=\"twitter:card\" content=\"player\">\n  <meta name=\"twitter:title\" content=\"Amazing Sports Highlight\">\n  <meta name=\"twitter:image\" content=\"https://cmlite.org/media/thumbnails/550e8400.jpg\">\n</head>\n

Sitemap Generation:

<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n  <url>\n    <loc>https://cmlite.org/media</loc>\n    <changefreq>daily</changefreq>\n    <priority>1.0</priority>\n  </url>\n  <url>\n    <loc>https://cmlite.org/media/550e8400-e29b-41d4-a716-446655440000</loc>\n    <lastmod>2026-02-10</lastmod>\n    <changefreq>weekly</changefreq>\n    <priority>0.8</priority>\n  </url>\n  <!-- ... more video URLs -->\n</urlset>\n
"},{"location":"v2/features/media/public-gallery/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/features/media/public-gallery/#rate-limiting","title":"Rate Limiting","text":"
// Public endpoints more restrictive than admin\nimport rateLimit from '@fastify/rate-limit';\n\napp.register(rateLimit, {\n  max: 100,          // 100 requests\n  timeWindow: '1 minute',\n  allowList: [],     // No whitelist for public\n});\n

Per-Endpoint Limits:

  • List videos: 100/min
  • Video details: 100/min
  • Track view: 10/min (prevent view count manipulation)
  • Add reaction: 20/min
  • Submit comment: 5/hour (anti-spam)
"},{"location":"v2/features/media/public-gallery/#content-moderation","title":"Content Moderation","text":"

Comment Filtering:

import Filter from 'bad-words';\n\nconst filter = new Filter();\n\nconst sanitizeComment = (comment: string): string => {\n  // Remove HTML tags\n  const cleaned = comment.replace(/<[^>]*>/g, '');\n\n  // Filter profanity\n  return filter.clean(cleaned);\n};\n

Spam Detection:

// Reject duplicate comments\nconst existingComment = await db.select()\n  .from(videoComments)\n  .where(\n    and(\n      eq(videoComments.sessionId, sessionId),\n      eq(videoComments.comment, comment),\n      gte(videoComments.createdAt, new Date(Date.now() - 24 * 60 * 60 * 1000))\n    )\n  )\n  .limit(1);\n\nif (existingComment.length > 0) {\n  return reply.code(429).send({ error: 'Duplicate comment detected' });\n}\n
"},{"location":"v2/features/media/public-gallery/#privacy-protection","title":"Privacy Protection","text":"

Never Expose:

  • Internal file paths (/media/local/library/...)
  • Original filenames (use video ID for playback URL)
  • Admin user information
  • Email addresses from comments (unless user explicitly made public)

Session Tracking:

// Use IP hash (not raw IP) for session ID\nconst sessionId = crypto.createHash('sha256').update(req.ip + 'SECRET_SALT').digest('hex');\n\n// Store minimal data in session\n// NO: { userId: 123, name: 'John', email: 'john@example.com' }\n// YES: { sessionId: 'abc123' }\n
"},{"location":"v2/features/media/public-gallery/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/media/public-gallery/#backend-documentation","title":"Backend Documentation","text":"
  • Public Routes: backend/modules/media/public.md \u2014 Public API endpoints
  • Reactions Service: backend/modules/media/reactions.md \u2014 Reaction system implementation
  • Comments Service: backend/modules/media/comments.md \u2014 Comment moderation system
"},{"location":"v2/features/media/public-gallery/#frontend-documentation","title":"Frontend Documentation","text":"
  • Media Gallery Page: frontend/pages/public/media-gallery.md \u2014 Gallery UI implementation
  • Video Player Page: frontend/pages/public/media-viewer.md \u2014 Player component
"},{"location":"v2/features/media/public-gallery/#feature-documentation","title":"Feature Documentation","text":"
  • Video Library: features/media/video-library.md \u2014 Admin video management
  • Shared Media: features/media/shared-media.md \u2014 Sharing controls (admin)
"},{"location":"v2/features/media/public-gallery/#next-steps","title":"Next Steps","text":"

After mastering the public gallery:

  1. Analytics Dashboard \u2014 Build admin dashboard showing view trends, popular videos, engagement metrics
  2. Playlist System \u2014 Allow users to create and share playlists
  3. Video Embedding \u2014 Generate embed codes for external websites
  4. Advanced Search \u2014 Full-text search across titles, producers, creators, tags

Hands-On Practice:

# 1. Share video via API\ncurl -X PUT http://localhost:4100/api/media/videos/VIDEO_ID/share \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"category\": \"Sports\"}'\n\n# 2. Browse public gallery\ncurl http://localhost:4100/api/public/media?category=Sports\n\n# 3. Track view\ncurl -X POST http://localhost:4100/api/public/media/VIDEO_ID/view \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"watchTimeSeconds\": 120, \"completed\": true}'\n\n# 4. Add reaction\ncurl -X POST http://localhost:4100/api/public/media/VIDEO_ID/reaction \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"reactionType\": \"like\"}'\n

Last Updated: 2026-02-13 Version: V2.0 Maintainer: Changemaker Lite Team

"},{"location":"v2/features/media/upload/","title":"Video Upload System","text":""},{"location":"v2/features/media/upload/#overview","title":"Overview","text":"

The Video Upload System provides a modern drag-and-drop interface for uploading video files with automatic metadata extraction, progress tracking, and batch processing capabilities. Built on Fastify's multipart plugin with FFprobe integration, it supports large files up to 10GB while maintaining server stability through streaming.

Key Features:

  • Drag-and-Drop Interface \u2014 Intuitive file selection with visual drop zone
  • Automatic Metadata Extraction \u2014 FFprobe extracts duration, dimensions, orientation, quality, and audio detection
  • Single & Batch Upload \u2014 Upload one video or queue multiple files
  • Large File Support \u2014 Handles files up to 10GB via streaming (no memory buffering)
  • Progress Tracking \u2014 Real-time upload progress with percentage and speed
  • Format Validation \u2014 Supports MP4, MOV, AVI, MKV, WebM, M4V, FLV
  • UUID Filenames \u2014 Prevents conflicts and path traversal attacks
  • Inbox Staging \u2014 Videos uploaded to /inbox directory before processing
  • Manual Metadata \u2014 Admin can override auto-detected fields (producer, creator, title, tags)

Technology Stack:

  • Frontend: Ant Design Upload component with custom drag-drop styling
  • Backend: Fastify @fastify/multipart plugin for streaming uploads
  • Metadata: FFprobe for video analysis (duration, dimensions, codec, bitrate)
  • Storage: Direct filesystem writes to /media/local/inbox directory
"},{"location":"v2/features/media/upload/#architecture","title":"Architecture","text":"
sequenceDiagram\n    participant U as User\n    participant UI as UploadVideoModal\n    participant API as Fastify Media API\n    participant FS as Filesystem\n    participant FFP as FFprobe Service\n    participant DB as PostgreSQL\n\n    U->>UI: Drag video file(s)\n    UI->>UI: Validate file type/size\n    UI->>U: Show file in queue\n\n    U->>UI: Click \"Upload\"\n    UI->>API: POST /api/media/upload/single<br/>(multipart/form-data)\n\n    API->>API: Generate UUID filename\n    API->>FS: Stream to /inbox/{uuid}.mp4\n    FS-->>API: Write complete\n\n    API->>FFP: Extract metadata\n    FFP->>FS: Analyze video file\n    FFP-->>API: Return metadata JSON\n\n    API->>DB: INSERT video record\n    DB-->>API: Return video ID\n\n    API-->>UI: Upload success + metadata\n    UI-->>U: Show success message\n    UI->>UI: Refresh library table\n\n    Note over API,FS: File remains in /inbox<br/>until moved by admin

Upload Flow:

  1. Client Validation \u2014 Browser checks file extension and size before upload
  2. Streaming Upload \u2014 File streamed to disk in chunks (no memory buffer)
  3. Metadata Extraction \u2014 FFprobe analyzes video (30s timeout)
  4. Database Record \u2014 Video record created with auto-detected metadata
  5. Response \u2014 Frontend receives video ID and metadata
  6. Library Update \u2014 Table refreshes to show new video

Key Design Decisions:

  • Streaming vs Buffering \u2014 Streaming prevents memory exhaustion on large files (10GB would require 10GB RAM if buffered)
  • Inbox Staging \u2014 New uploads go to /inbox directory instead of final location, allowing admin review before publishing
  • UUID Filenames \u2014 Prevents filename conflicts and path traversal attacks (../../etc/passwd.mp4)
  • Synchronous FFprobe \u2014 Metadata extracted immediately (not deferred to job queue) for instant feedback
"},{"location":"v2/features/media/upload/#upload-workflow","title":"Upload Workflow","text":""},{"location":"v2/features/media/upload/#user-workflow-admin","title":"User Workflow (Admin)","text":"
  1. Open Upload Modal
  2. Navigate to Media \u2192 Library page
  3. Click \"Upload Video\" button in top toolbar
  4. Modal opens with drag-drop zone

  5. Select Files

  6. Drag files from desktop into blue dashed zone
  7. OR click \"Click to browse\" link to open file picker
  8. Multiple files can be selected for batch upload

  9. Review Queue

  10. Selected files appear in list with:
    • Filename and size
    • File type icon
    • Remove button (X)
  11. Invalid files (wrong extension, too large) highlighted in red

  12. Enter Metadata (Optional)

  13. Producer \u2014 Studio or production company name
  14. Creator \u2014 Director or primary creator
  15. Title \u2014 Display title (defaults to filename if blank)
  16. Tags \u2014 Comma-separated tags (e.g., \"action, sports, highlight\")

  17. Upload

  18. Click \"Upload\" button
  19. Files upload sequentially (not parallel)
  20. Progress bar shows:

    • Current file name
    • Upload percentage (0-100%)
    • Upload speed (MB/s)
    • Estimated time remaining
  21. Metadata Extraction

  22. After upload completes, FFprobe runs automatically
  23. Spinner shows \"Extracting metadata...\"
  24. Auto-fills: duration, dimensions, orientation, quality, audio

  25. Success

  26. Green checkmark appears
  27. Success message: \"Uploaded: {filename}\"
  28. Modal can be closed or kept open for more uploads
  29. Library table refreshes showing new video
"},{"location":"v2/features/media/upload/#error-handling","title":"Error Handling","text":"

Invalid File Type:

Error: File type not supported\nAllowed: MP4, MOV, AVI, MKV, WebM, M4V, FLV\n

File Too Large:

Error: File exceeds 10GB limit\nSelected file: 12.5 GB\n

Upload Failed:

Error: Upload failed\nNetwork error or server unavailable\n

FFprobe Extraction Failed:

Warning: Metadata extraction failed\nVideo uploaded but metadata incomplete\nYou can manually enter duration and dimensions\n
"},{"location":"v2/features/media/upload/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/media/upload/#upload-single-video","title":"Upload Single Video","text":"
POST /api/media/upload/single\nContent-Type: multipart/form-data\nAuthorization: Bearer <admin_token>\n

Request (Multipart Form Data):

--boundary\nContent-Disposition: form-data; name=\"video\"; filename=\"my-video.mp4\"\nContent-Type: video/mp4\n\n<binary video data>\n--boundary\nContent-Disposition: form-data; name=\"producer\"\n\nStudio A\n--boundary\nContent-Disposition: form-data; name=\"creator\"\n\nDirector B\n--boundary\nContent-Disposition: form-data; name=\"title\"\n\nMy Awesome Video\n--boundary\nContent-Disposition: form-data; name=\"tags\"\n\naction,sports,highlight\n--boundary--\n

Response (Success):

{\n  \"id\": \"660e8400-e29b-41d4-a716-446655440000\",\n  \"path\": \"inbox/660e8400-e29b-41d4-a716-446655440000.mp4\",\n  \"filename\": \"660e8400-e29b-41d4-a716-446655440000.mp4\",\n  \"originalFilename\": \"my-video.mp4\",\n  \"directoryType\": \"inbox\",\n  \"producer\": \"Studio A\",\n  \"creator\": \"Director B\",\n  \"title\": \"My Awesome Video\",\n  \"tags\": [\"action\", \"sports\", \"highlight\"],\n  \"durationSeconds\": 125,\n  \"width\": 1920,\n  \"height\": 1080,\n  \"quality\": \"FHD\",\n  \"orientation\": \"landscape\",\n  \"hasAudio\": true,\n  \"fileSize\": 45678912,\n  \"isValid\": true,\n  \"createdAt\": \"2026-02-13T14:30:00Z\"\n}\n

Response (Error):

{\n  \"statusCode\": 400,\n  \"error\": \"Bad Request\",\n  \"message\": \"Invalid file type. Allowed: mp4, mov, avi, mkv, webm, m4v, flv\"\n}\n
"},{"location":"v2/features/media/upload/#upload-batch-multiple-videos","title":"Upload Batch (Multiple Videos)","text":"
POST /api/media/upload/batch\nContent-Type: multipart/form-data\nAuthorization: Bearer <admin_token>\n

Request:

--boundary\nContent-Disposition: form-data; name=\"videos\"; filename=\"video1.mp4\"\nContent-Type: video/mp4\n\n<binary data>\n--boundary\nContent-Disposition: form-data; name=\"videos\"; filename=\"video2.mp4\"\nContent-Type: video/mp4\n\n<binary data>\n--boundary\nContent-Disposition: form-data; name=\"producer\"\n\nStudio A\n--boundary--\n

Response:

{\n  \"uploaded\": 2,\n  \"failed\": 0,\n  \"results\": [\n    {\n      \"id\": \"660e8400-e29b-41d4-a716-446655440000\",\n      \"filename\": \"video1.mp4\",\n      \"status\": \"success\"\n    },\n    {\n      \"id\": \"770e8400-e29b-41d4-a716-446655440001\",\n      \"filename\": \"video2.mp4\",\n      \"status\": \"success\"\n    }\n  ]\n}\n
"},{"location":"v2/features/media/upload/#configuration","title":"Configuration","text":""},{"location":"v2/features/media/upload/#environment-variables","title":"Environment Variables","text":"
# Upload Limits\nMEDIA_MAX_FILE_SIZE=10737418240  # 10GB in bytes\nMEDIA_MAX_FILES_BATCH=10         # Max files per batch upload\n\n# Upload Paths\nMEDIA_INBOX_PATH=/media/local/inbox\nMEDIA_LIBRARY_PATH=/media/local/library\n\n# FFprobe\nFFPROBE_TIMEOUT=30000  # 30 seconds\nFFPROBE_PATH=/usr/bin/ffprobe  # Auto-detected if not set\n\n# Allowed Extensions (comma-separated)\nMEDIA_ALLOWED_EXTENSIONS=mp4,mov,avi,mkv,webm,m4v,flv\n
"},{"location":"v2/features/media/upload/#fastify-multipart-configuration","title":"Fastify Multipart Configuration","text":"
// api/src/media-server.ts\nimport multipart from '@fastify/multipart';\n\napp.register(multipart, {\n  limits: {\n    fieldNameSize: 100,      // Max field name size (bytes)\n    fieldSize: 1000000,      // Max field value size (bytes) - for text fields\n    fields: 10,              // Max number of non-file fields\n    fileSize: 10 * 1024 * 1024 * 1024, // 10GB max file size\n    files: 10,               // Max number of files per request\n    headerPairs: 2000,       // Max header key-value pairs\n  },\n  attachFieldsToBody: false, // Don't parse all fields into body (use req.file())\n});\n
"},{"location":"v2/features/media/upload/#docker-volume-mounts","title":"Docker Volume Mounts","text":"

Critical: Inbox directory must be mounted as read-write (:rw):

# docker-compose.yml\nservices:\n  media-api:\n    volumes:\n      - /media/local/library:/media/local/library:ro  # Read-only library\n      - /media/local/inbox:/media/local/inbox:rw      # READ-WRITE inbox\n

Without :rw suffix, uploads fail with permission errors.

"},{"location":"v2/features/media/upload/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/media/upload/#frontend-upload-modal-component","title":"Frontend: Upload Modal Component","text":"
// admin/src/components/media/UploadVideoModal.tsx\nimport { Modal, Upload, Form, Input, Button, Progress, message } from 'antd';\nimport { InboxOutlined } from '@ant-design/icons';\nimport { useState } from 'react';\nimport { mediaApi } from '@/lib/media-api';\n\ninterface UploadVideoModalProps {\n  visible: boolean;\n  onClose: () => void;\n  onSuccess: () => void;\n}\n\nexport default function UploadVideoModal({ visible, onClose, onSuccess }: UploadVideoModalProps) {\n  const [form] = Form.useForm();\n  const [fileList, setFileList] = useState<any[]>([]);\n  const [uploading, setUploading] = useState(false);\n  const [uploadProgress, setUploadProgress] = useState(0);\n\n  const handleUpload = async () => {\n    if (fileList.length === 0) {\n      message.error('Please select at least one video file');\n      return;\n    }\n\n    setUploading(true);\n\n    try {\n      const values = await form.validateFields();\n\n      for (const fileItem of fileList) {\n        const formData = new FormData();\n        formData.append('video', fileItem.originFileObj);\n        formData.append('producer', values.producer || '');\n        formData.append('creator', values.creator || '');\n        formData.append('title', values.title || fileItem.name);\n        formData.append('tags', values.tags || '');\n\n        const { data } = await mediaApi.post('/api/media/upload/single', formData, {\n          headers: {\n            'Content-Type': 'multipart/form-data',\n          },\n          onUploadProgress: (progressEvent) => {\n            const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total!);\n            setUploadProgress(percent);\n          },\n        });\n\n        message.success(`Uploaded: ${fileItem.name}`);\n      }\n\n      onSuccess();\n      handleClose();\n    } catch (error: any) {\n      message.error(error.response?.data?.message || 'Upload failed');\n    } finally {\n      setUploading(false);\n      setUploadProgress(0);\n    }\n  };\n\n  const handleClose = () => {\n    form.resetFields();\n    setFileList([]);\n    setUploadProgress(0);\n    onClose();\n  };\n\n  return (\n    <Modal\n      title=\"Upload Video\"\n      open={visible}\n      onCancel={handleClose}\n      footer={[\n        <Button key=\"cancel\" onClick={handleClose} disabled={uploading}>\n          Cancel\n        </Button>,\n        <Button key=\"upload\" type=\"primary\" onClick={handleUpload} loading={uploading}>\n          Upload\n        </Button>,\n      ]}\n      width={600}\n      destroyOnClose\n    >\n      <Upload.Dragger\n        multiple\n        fileList={fileList}\n        onChange={({ fileList }) => setFileList(fileList)}\n        beforeUpload={(file) => {\n          const isVideo = [\n            'video/mp4',\n            'video/quicktime',\n            'video/x-msvideo',\n            'video/x-matroska',\n            'video/webm',\n            'video/x-m4v',\n            'video/x-flv',\n          ].includes(file.type);\n\n          if (!isVideo) {\n            message.error(`${file.name} is not a supported video format`);\n            return Upload.LIST_IGNORE;\n          }\n\n          const isLt10GB = file.size / 1024 / 1024 / 1024 < 10;\n          if (!isLt10GB) {\n            message.error(`${file.name} exceeds 10GB limit`);\n            return Upload.LIST_IGNORE;\n          }\n\n          return false; // Prevent auto-upload\n        }}\n        disabled={uploading}\n      >\n        <p className=\"ant-upload-drag-icon\">\n          <InboxOutlined />\n        </p>\n        <p className=\"ant-upload-text\">Click or drag video files to this area</p>\n        <p className=\"ant-upload-hint\">\n          Supports MP4, MOV, AVI, MKV, WebM, M4V, FLV. Max 10GB per file.\n        </p>\n      </Upload.Dragger>\n\n      {uploading && (\n        <div style={{ marginTop: 16 }}>\n          <Progress percent={uploadProgress} status=\"active\" />\n        </div>\n      )}\n\n      <Form form={form} layout=\"vertical\" style={{ marginTop: 24 }}>\n        <Form.Item label=\"Producer\" name=\"producer\">\n          <Input placeholder=\"Studio or production company\" />\n        </Form.Item>\n\n        <Form.Item label=\"Creator\" name=\"creator\">\n          <Input placeholder=\"Director or creator name\" />\n        </Form.Item>\n\n        <Form.Item label=\"Title\" name=\"title\">\n          <Input placeholder=\"Display title (defaults to filename)\" />\n        </Form.Item>\n\n        <Form.Item label=\"Tags\" name=\"tags\">\n          <Input placeholder=\"Comma-separated tags (e.g., action, sports)\" />\n        </Form.Item>\n      </Form>\n    </Modal>\n  );\n}\n
"},{"location":"v2/features/media/upload/#backend-single-upload-route","title":"Backend: Single Upload Route","text":"
// api/src/modules/media/routes/upload.routes.ts\nimport { FastifyInstance } from 'fastify';\nimport path from 'path';\nimport fs from 'fs/promises';\nimport { randomUUID } from 'crypto';\nimport { db } from '@/modules/media/db';\nimport { videos } from '@/modules/media/db/schema';\nimport { ffprobeService } from '@/modules/media/services/ffprobe.service';\nimport { requireRole } from '@/middleware/auth';\n\nexport default async function (app: FastifyInstance) {\n  app.post(\n    '/api/media/upload/single',\n    {\n      preHandler: [requireRole('SUPER_ADMIN')],\n    },\n    async (req, reply) => {\n      try {\n        // Get uploaded file\n        const data = await req.file();\n\n        if (!data) {\n          return reply.code(400).send({ error: 'No file uploaded' });\n        }\n\n        // Validate file extension\n        const ext = path.extname(data.filename).toLowerCase();\n        const allowedExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv'];\n\n        if (!allowedExtensions.includes(ext)) {\n          return reply.code(400).send({\n            error: 'Invalid file type',\n            message: `Allowed extensions: ${allowedExtensions.join(', ')}`,\n          });\n        }\n\n        // Generate UUID filename\n        const uuid = randomUUID();\n        const filename = `${uuid}${ext}`;\n        const relativePath = `inbox/${filename}`;\n        const absolutePath = path.join(process.env.MEDIA_INBOX_PATH!, filename);\n\n        // Stream to disk\n        const writeStream = fs.createWriteStream(absolutePath);\n        await data.file.pipe(writeStream);\n\n        app.log.info(`File uploaded to ${absolutePath}`);\n\n        // Extract metadata\n        let metadata;\n        try {\n          metadata = await ffprobeService.extract(absolutePath);\n          app.log.info('FFprobe metadata extracted', metadata);\n        } catch (error: any) {\n          app.log.warn('FFprobe extraction failed', error);\n          // Continue without metadata (can be validated later)\n          metadata = {\n            duration: null,\n            width: null,\n            height: null,\n            orientation: null,\n            quality: null,\n            hasAudio: false,\n          };\n        }\n\n        // Get file size\n        const stats = await fs.stat(absolutePath);\n\n        // Parse metadata from request body\n        const body = data.fields as any;\n        const producer = body.producer?.value || null;\n        const creator = body.creator?.value || null;\n        const title = body.title?.value || data.filename;\n        const tagsString = body.tags?.value || '';\n        const tags = tagsString\n          ? tagsString.split(',').map((t: string) => t.trim())\n          : [];\n\n        // Create database record\n        const [video] = await db\n          .insert(videos)\n          .values({\n            path: relativePath,\n            filename,\n            originalFilename: data.filename,\n            directoryType: 'inbox',\n            producer,\n            creator,\n            title,\n            tags,\n            durationSeconds: metadata.duration,\n            width: metadata.width,\n            height: metadata.height,\n            orientation: metadata.orientation,\n            quality: metadata.quality,\n            hasAudio: metadata.hasAudio,\n            fileSize: stats.size,\n            isValid: true,\n          })\n          .returning();\n\n        reply.send(video);\n      } catch (error: any) {\n        app.log.error('Upload failed', error);\n        reply.code(500).send({\n          error: 'Upload failed',\n          message: error.message,\n        });\n      }\n    }\n  );\n}\n
"},{"location":"v2/features/media/upload/#ffprobe-metadata-extraction","title":"FFprobe Metadata Extraction","text":"
// api/src/modules/media/services/ffprobe.service.ts\nimport { exec } from 'child_process';\nimport { promisify } from 'util';\n\nconst execAsync = promisify(exec);\n\ninterface VideoMetadata {\n  duration: number | null;\n  width: number | null;\n  height: number | null;\n  orientation: string | null;\n  quality: string | null;\n  hasAudio: boolean;\n  fileSize: number | null;\n  fileHash: string | null;\n}\n\nexport class FFprobeService {\n  private timeout = parseInt(process.env.FFPROBE_TIMEOUT || '30000', 10);\n  private ffprobePath = process.env.FFPROBE_PATH || 'ffprobe';\n\n  async extract(filePath: string): Promise<VideoMetadata> {\n    try {\n      const command = `${this.ffprobePath} -v quiet -print_format json -show_streams -show_format \"${filePath}\"`;\n\n      const { stdout } = await execAsync(command, {\n        timeout: this.timeout,\n        maxBuffer: 1024 * 1024 * 10, // 10MB buffer\n      });\n\n      const data = JSON.parse(stdout);\n\n      // Find video stream\n      const videoStream = data.streams.find((s: any) => s.codec_type === 'video');\n      if (!videoStream) {\n        throw new Error('No video stream found');\n      }\n\n      // Find audio stream\n      const audioStream = data.streams.find((s: any) => s.codec_type === 'audio');\n\n      // Extract metadata\n      const width = parseInt(videoStream.width, 10);\n      const height = parseInt(videoStream.height, 10);\n      const duration = parseFloat(data.format.duration);\n      const fileSize = parseInt(data.format.size, 10);\n\n      // Detect orientation\n      const orientation = this.detectOrientation(width, height);\n\n      // Detect quality\n      const quality = this.detectQuality(height);\n\n      return {\n        duration: isNaN(duration) ? null : Math.round(duration),\n        width: isNaN(width) ? null : width,\n        height: isNaN(height) ? null : height,\n        orientation,\n        quality,\n        hasAudio: !!audioStream,\n        fileSize: isNaN(fileSize) ? null : fileSize,\n        fileHash: null, // Can be computed separately if needed\n      };\n    } catch (error: any) {\n      throw new Error(`FFprobe extraction failed: ${error.message}`);\n    }\n  }\n\n  private detectOrientation(width: number, height: number): string {\n    if (isNaN(width) || isNaN(height)) return 'unknown';\n\n    const ratio = width / height;\n    if (ratio > 1.1) return 'landscape';\n    if (ratio < 0.9) return 'portrait';\n    return 'square';\n  }\n\n  private detectQuality(height: number): string {\n    if (isNaN(height)) return 'unknown';\n\n    if (height < 720) return 'SD';\n    if (height < 1080) return 'HD';\n    if (height < 2160) return 'FHD';\n    return 'UHD';\n  }\n}\n\nexport const ffprobeService = new FFprobeService();\n
"},{"location":"v2/features/media/upload/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/media/upload/#problem-upload-fails-with-file-too-large","title":"Problem: Upload Fails with \"File Too Large\"","text":"

Symptoms:

  • Upload progress reaches 100% then fails
  • Error message: \"File exceeds maximum size\"
  • Browser console shows 413 Payload Too Large

Solutions:

  1. Check file size:
# On macOS/Linux\nls -lh video.mp4\n# Should show size < 10GB\n\n# If larger, compress video first:\nffmpeg -i large-video.mp4 -vcodec h264 -acodec aac compressed.mp4\n
  1. Verify Fastify limit:
// api/src/media-server.ts\napp.register(multipart, {\n  limits: {\n    fileSize: 10 * 1024 * 1024 * 1024, // 10GB\n  },\n});\n
  1. Check nginx client_max_body_size:
# nginx/nginx.conf or nginx/conf.d/api.conf\nclient_max_body_size 10G;\n
  1. Increase timeout for large files:
# nginx/conf.d/api.conf\nserver {\n    location / {\n        proxy_pass http://localhost:4100;\n        proxy_read_timeout 600s;  # 10 minutes\n        proxy_send_timeout 600s;\n    }\n}\n
"},{"location":"v2/features/media/upload/#problem-ffprobe-metadata-extraction-fails","title":"Problem: FFprobe Metadata Extraction Fails","text":"

Symptoms:

  • Upload succeeds but metadata fields null
  • Warning: \"Metadata extraction failed\"
  • Duration, dimensions missing in library

Solutions:

  1. Check FFmpeg installed:
docker compose exec media-api which ffprobe\n# Should output: /usr/bin/ffprobe\n\ndocker compose exec media-api ffprobe -version\n# Should show FFmpeg version\n
  1. Install FFmpeg if missing:
# api/Dockerfile.media\nFROM node:20-alpine\n\n# Install FFmpeg\nRUN apk add --no-cache ffmpeg\n\n# ... rest of Dockerfile\n
# Rebuild container\ndocker compose build media-api\ndocker compose up -d media-api\n
  1. Test FFprobe manually:
# Run FFprobe on uploaded file\ndocker compose exec media-api ffprobe \\\n  -v quiet \\\n  -print_format json \\\n  -show_streams \\\n  -show_format \\\n  /media/local/inbox/test.mp4\n\n# Should output JSON with streams and format info\n
  1. Check video file not corrupt:
# Try playing video\ndocker compose exec media-api ffplay /media/local/inbox/test.mp4\n\n# Or copy to host and test\ndocker cp $(docker compose ps -q media-api):/media/local/inbox/test.mp4 ./\nvlc test.mp4\n
  1. Increase timeout for large files:
# .env\nFFPROBE_TIMEOUT=60000  # 60 seconds (from 30)\n
"},{"location":"v2/features/media/upload/#problem-upload-hangs-at-100","title":"Problem: Upload Hangs at 100%","text":"

Symptoms:

  • Progress bar reaches 100% but never completes
  • No success or error message
  • Browser tab freezes

Solutions:

  1. Check nginx proxy timeout:
# nginx/conf.d/api.conf\nserver {\n    location / {\n        proxy_pass http://localhost:4100;\n        proxy_read_timeout 600s;  # 10 minutes for large uploads\n    }\n}\n
  1. Verify disk space available:
df -h /media/local/inbox\n# Should show available space > file size\n\n# Clear space if needed\ndocker compose exec media-api rm /media/local/inbox/*.mp4\n
  1. Check backend logs:
docker compose logs -f media-api | grep upload\n# Look for errors or timeouts\n
  1. Test with smaller file:
# Create 100MB test video\nffmpeg -f lavfi -i testsrc=duration=10:size=1920x1080:rate=30 -pix_fmt yuv420p test-100mb.mp4\n\n# Upload test file\n# If succeeds, issue likely large file timeout\n
"},{"location":"v2/features/media/upload/#problem-inbox-directory-not-writable","title":"Problem: Inbox Directory Not Writable","text":"

Symptoms:

  • Upload fails with \"Permission denied\"
  • Error: \"EACCES: permission denied, open '/media/local/inbox/...'\"
  • Upload never starts

Solutions:

  1. Check Docker volume mount:
# docker-compose.yml\nservices:\n  media-api:\n    volumes:\n      - /media/local/inbox:/media/local/inbox:rw  # MUST have :rw suffix\n
  1. Verify mount in running container:
docker compose exec media-api mount | grep inbox\n# Should show /media/local/inbox mounted as rw (read-write)\n
  1. Check directory permissions:
# On host machine\nls -la /media/local/inbox\n# Should show drwxrwxrwx or drwxr-xr-x\n\n# Fix permissions if needed\nsudo chmod 777 /media/local/inbox\n\n# Or set ownership to container user (usually node:node)\nsudo chown -R 1000:1000 /media/local/inbox\n
  1. Create directory if missing:
# On host\nsudo mkdir -p /media/local/inbox\nsudo chmod 777 /media/local/inbox\n\n# Restart container\ndocker compose restart media-api\n
  1. Test write access:
# Try writing test file from container\ndocker compose exec media-api sh -c 'echo \"test\" > /media/local/inbox/test.txt'\n\n# If fails, permissions issue\n# If succeeds, issue elsewhere\n
"},{"location":"v2/features/media/upload/#problem-invalid-file-type-error","title":"Problem: Invalid File Type Error","text":"

Symptoms:

  • Upload rejected immediately
  • Error: \"File type not supported\"
  • File is valid MP4/MOV/etc

Solutions:

  1. Check MIME type:
// Browser console\nconst file = document.querySelector('input[type=file]').files[0];\nconsole.log(file.type);\n// Should be video/mp4, video/quicktime, etc.\n
  1. Verify file extension:
# Rename file to ensure correct extension\nmv video.MP4 video.mp4  # Case-sensitive on Linux\n
  1. Add MIME type to allowed list:
// admin/src/components/media/UploadVideoModal.tsx\nconst isVideo = [\n  'video/mp4',\n  'video/quicktime',\n  'video/x-msvideo',\n  'video/x-matroska',\n  'video/webm',\n  'video/x-m4v',\n  'video/x-flv',\n  'video/mpeg',  // Add MPEG\n  'video/ogg',   // Add OGG\n].includes(file.type);\n
  1. Bypass frontend validation (testing only):
// Temporarily comment out beforeUpload validation\nbeforeUpload={() => false}\n
  1. Check backend extension validation:
// api/src/modules/media/routes/upload.routes.ts\nconst allowedExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv'];\n// Add more if needed\n
"},{"location":"v2/features/media/upload/#problem-batch-upload-only-uploads-first-file","title":"Problem: Batch Upload Only Uploads First File","text":"

Symptoms:

  • Multiple files selected
  • Only first file uploads
  • Others disappear from queue

Solutions:

  1. Check sequential upload logic:
// admin/src/components/media/UploadVideoModal.tsx\n// Should use for loop, not forEach with async\nfor (const fileItem of fileList) {\n  await mediaApi.post(...);  // Await each upload\n}\n
  1. Verify batch endpoint:
# Use /api/media/upload/batch for multiple files\n# Not multiple calls to /api/media/upload/single\n
  1. Check Fastify file limit:
// api/src/media-server.ts\napp.register(multipart, {\n  limits: {\n    files: 10,  // Max 10 files per request\n  },\n});\n
  1. Frontend: prevent early unmount:
// Don't close modal while uploading\n<Modal\n  closable={!uploading}\n  maskClosable={!uploading}\n  ...\n/>\n
"},{"location":"v2/features/media/upload/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/media/upload/#upload-speed","title":"Upload Speed","text":"

Factors:

  • Network bandwidth \u2014 100 Mbps = ~12 MB/s theoretical max
  • Disk write speed \u2014 SSD: 500+ MB/s, HDD: 100-150 MB/s
  • Nginx buffering \u2014 Can slow large uploads if enabled
  • Docker overlay network \u2014 ~10% overhead vs host networking

Typical Speeds:

File Size Upload Time (100 Mbps) Upload Time (1 Gbps) 100 MB ~10 seconds ~1 second 1 GB ~1.5 minutes ~10 seconds 5 GB ~7 minutes ~50 seconds 10 GB ~14 minutes ~1.5 minutes

Optimization:

  1. Disable nginx buffering:
# nginx/conf.d/api.conf\nlocation /api/media/upload {\n    proxy_pass http://localhost:4100;\n    proxy_request_buffering off;  # Stream directly to backend\n    client_max_body_size 10G;\n}\n
  1. Use faster disk:

Mount /media/local/inbox on SSD instead of HDD.

  1. Increase network MTU:
# Increase Docker network MTU\ndocker network create --opt com.docker.network.driver.mtu=9000 changemaker-lite\n
"},{"location":"v2/features/media/upload/#ffprobe-extraction-time","title":"FFprobe Extraction Time","text":"

Benchmarks:

Video Size Resolution Extraction Time 50 MB 720p ~50-100ms 200 MB 1080p ~100-200ms 1 GB 1080p ~200-400ms 5 GB 4K ~500ms-1s

Optimization:

FFprobe only reads video metadata (not entire file), so extraction time scales sub-linearly with file size.

For very large files (10GB+), consider deferring extraction to job queue:

// Upload endpoint returns immediately\nconst video = await db.insert(videos).values({ ... }).returning();\n\n// Queue FFprobe job\nawait jobQueue.add('extract-metadata', { videoId: video.id });\n\nreply.send({ id: video.id, status: 'pending-metadata' });\n
"},{"location":"v2/features/media/upload/#streaming-vs-buffering","title":"Streaming vs Buffering","text":"

Memory Usage Comparison:

Upload Method Memory Usage (10GB file) Streaming (current) ~10 MB Buffering (alternative) ~10 GB

Why Streaming:

  • Constant memory \u2014 Uses fixed ~10 MB buffer regardless of file size
  • Server stability \u2014 10 concurrent uploads = ~100 MB RAM vs 100 GB if buffered
  • No 32-bit limit \u2014 Buffering fails on Node.js for files > 2GB on 32-bit systems

Tradeoff:

Streaming writes directly to disk, so failed uploads leave partial files in /inbox. Cleanup script required:

# Cron job to clean incomplete uploads (files with 0 size)\nfind /media/local/inbox -type f -size 0 -mtime +1 -delete\n
"},{"location":"v2/features/media/upload/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/features/media/upload/#admin-only-access","title":"Admin-Only Access","text":"

All upload endpoints require SUPER_ADMIN role:

// api/src/modules/media/routes/upload.routes.ts\napp.post('/api/media/upload/single', {\n  preHandler: [requireRole('SUPER_ADMIN')],\n}, async (req, reply) => {\n  // ...\n});\n

Regular users, volunteers, and public cannot upload videos.

"},{"location":"v2/features/media/upload/#file-extension-validation","title":"File Extension Validation","text":"

Backend enforces strict whitelist:

const allowedExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv'];\n\nif (!allowedExtensions.includes(ext)) {\n  return reply.code(400).send({ error: 'Invalid file type' });\n}\n

No executable extensions allowed:

  • \u274c .exe
  • \u274c .sh
  • \u274c .bat
  • \u274c .php
  • \u274c .js (only video extensions)
"},{"location":"v2/features/media/upload/#path-traversal-prevention","title":"Path Traversal Prevention","text":"

UUID filenames prevent directory traversal:

// User-supplied filename: ../../etc/passwd.mp4\n// Actual filename: 660e8400-e29b-41d4-a716-446655440000.mp4\n\nconst uuid = randomUUID();\nconst filename = `${uuid}${ext}`;  // No user input in filename\n

Original filename preserved in database:

originalFilename: data.filename,  // Stored for reference, not used for filepath\n
"},{"location":"v2/features/media/upload/#virus-scanning-future","title":"Virus Scanning (Future)","text":"

Recommended Integration:

// api/src/modules/media/services/virus-scan.service.ts\nimport { exec } from 'child_process';\n\nclass VirusScanService {\n  async scan(filePath: string): Promise<{ clean: boolean; threat?: string }> {\n    // Use ClamAV\n    const { stdout } = await execAsync(`clamscan --no-summary ${filePath}`);\n\n    if (stdout.includes('FOUND')) {\n      return { clean: false, threat: stdout };\n    }\n\n    return { clean: true };\n  }\n}\n\n// In upload route:\nconst scanResult = await virusScanService.scan(absolutePath);\nif (!scanResult.clean) {\n  await fs.unlink(absolutePath);  // Delete infected file\n  return reply.code(400).send({ error: 'File contains malware' });\n}\n
"},{"location":"v2/features/media/upload/#rate-limiting","title":"Rate Limiting","text":"

Upload endpoint has stricter rate limits:

// api/src/modules/media/routes/upload.routes.ts\nimport rateLimit from '@fastify/rate-limit';\n\napp.register(rateLimit, {\n  max: 10,        // 10 uploads\n  timeWindow: '1 hour',\n});\n

Prevents abuse (uploading hundreds of large files).

"},{"location":"v2/features/media/upload/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/media/upload/#backend-documentation","title":"Backend Documentation","text":"
  • Upload Routes: backend/modules/media/upload.md \u2014 Upload endpoint implementation
  • FFprobe Service: backend/modules/media/ffprobe.md \u2014 Metadata extraction service
  • Fastify Multipart: backend/api/media-server.md \u2014 Multipart plugin configuration
"},{"location":"v2/features/media/upload/#frontend-documentation","title":"Frontend Documentation","text":"
  • Upload Modal: frontend/components/media/upload-modal.md \u2014 Upload UI component
  • Library Page: frontend/pages/media/library.md \u2014 Integration with library table
"},{"location":"v2/features/media/upload/#feature-documentation","title":"Feature Documentation","text":"
  • Video Library: features/media/video-library.md \u2014 Video management system overview
  • Media Jobs: features/media/jobs.md \u2014 Background processing for uploads
"},{"location":"v2/features/media/upload/#deployment-documentation","title":"Deployment Documentation","text":"
  • Docker Volumes: deployment/docker.md \u2014 Volume mount configuration for inbox
  • Nginx: deployment/nginx.md \u2014 Reverse proxy upload timeout settings
"},{"location":"v2/features/media/upload/#next-steps","title":"Next Steps","text":"

After mastering video upload:

  1. Move Videos \u2014 Learn how to move uploaded videos from /inbox to target directories
  2. Thumbnail Generation \u2014 Create thumbnails for video previews
  3. Encoding Jobs \u2014 Queue re-encoding jobs for web-optimized playback
  4. Public Sharing \u2014 Share videos in public gallery (see public-gallery.md)

Hands-On Practice:

# 1. Create test video (FFmpeg)\nffmpeg -f lavfi -i testsrc=duration=30:size=1920x1080:rate=30 -pix_fmt yuv420p test-video.mp4\n\n# 2. Upload via curl\ncurl -X POST http://localhost:4100/api/media/upload/single \\\n  -H \"Authorization: Bearer YOUR_ADMIN_TOKEN\" \\\n  -F \"video=@test-video.mp4\" \\\n  -F \"producer=Test Studio\" \\\n  -F \"title=Test Video\"\n\n# 3. Verify in database\ndocker compose exec v2-postgres psql -U changemaker -d v2_changemaker \\\n  -c \"SELECT id, filename, duration_seconds, quality FROM videos ORDER BY created_at DESC LIMIT 1;\"\n\n# 4. Check file on disk\ndocker compose exec media-api ls -lh /media/local/inbox/\n

Last Updated: 2026-02-13 Version: V2.0 Maintainer: Changemaker Lite Team

"},{"location":"v2/features/media/video-library/","title":"Video Library Management","text":""},{"location":"v2/features/media/video-library/#overview","title":"Overview","text":"

The Video Library system provides comprehensive video asset management through a dedicated Fastify microservice running on port 4100, separate from the main Express API. This dual API architecture allows the media system to operate independently while sharing the same PostgreSQL database.

Key Features:

  • Dual API Architecture \u2014 Fastify media API (port 4100) separate from Express API (port 4000)
  • Drizzle ORM \u2014 Media tables use Drizzle ORM instead of Prisma for schema flexibility
  • 9 Directory Types \u2014 Organized library structure (studios, gifs, private, inbox, curated, playback, compilations, videos, highlights)
  • FFprobe Integration \u2014 Automatic metadata extraction (duration, dimensions, orientation, quality, audio detection)
  • Video CRUD \u2014 Full create, read, update, delete operations (admin-only)
  • Directory Scanning \u2014 Bulk import videos from filesystem with automatic record creation
  • Validation System \u2014 Re-validate videos to refresh metadata and check file integrity
  • File Hashing \u2014 Duplicate detection via SHA-256 file hashing
  • Soft Delete \u2014 Videos marked invalid instead of hard deletion (preserves history)
  • Thumbnail Support \u2014 Custom thumbnail paths for video previews

Access Control:

  • All video library operations require SUPER_ADMIN role
  • Public video viewing handled separately via Shared Media system (see public-gallery.md)

Technology Stack:

  • Fastify 4.x \u2014 High-performance Node.js web framework
  • Drizzle ORM \u2014 TypeScript-first ORM with zero-runtime overhead
  • FFprobe \u2014 FFmpeg's media file analyzer for metadata extraction
  • PostgreSQL 16 \u2014 Shared database with main API
"},{"location":"v2/features/media/video-library/#architecture","title":"Architecture","text":"

The Media API operates as an independent microservice while maintaining data consistency through shared database access:

flowchart TB\n    subgraph \"Client Layer\"\n        Admin[Admin GUI :3000]\n        Public[Public Users]\n    end\n\n    subgraph \"API Layer\"\n        Express[Express API :4000<br/>Prisma ORM]\n        Fastify[Fastify Media API :4100<br/>Drizzle ORM]\n    end\n\n    subgraph \"Data Layer\"\n        DB[(PostgreSQL 16<br/>v2_changemaker)]\n        FS[/media/local/library/<br/>Video Files]\n    end\n\n    subgraph \"Processing\"\n        FFprobe[FFprobe Service<br/>Metadata Extraction]\n    end\n\n    Admin -->|Media Requests| Fastify\n    Admin -->|Other Requests| Express\n    Public -->|View Videos| Fastify\n\n    Fastify -->|Drizzle Queries| DB\n    Express -->|Prisma Queries| DB\n\n    Fastify -->|Read/Write| FS\n    Fastify -->|Extract Metadata| FFprobe\n    FFprobe -->|Analyze| FS\n\n    style Fastify fill:#e74c3c\n    style Express fill:#3498db\n    style DB fill:#2ecc71\n    style FS fill:#f39c12

Architecture Highlights:

  1. Port Separation \u2014 Media API on 4100, Main API on 4000
  2. ORM Independence \u2014 Drizzle for media, Prisma for everything else
  3. Shared Database \u2014 Both APIs access same PostgreSQL instance
  4. File System Access \u2014 Media API has direct volume mount to /media/local/library
  5. Nginx Routing \u2014 media.cmlite.org routes to port 4100

Why Dual API?

The media system was added after V2 launch as a self-contained enhancement. Keeping it as a separate Fastify microservice:

  • Avoids disrupting the stable Express API
  • Allows independent scaling and deployment
  • Provides testing ground for Drizzle ORM migration
  • Isolates video processing workloads from core application logic
"},{"location":"v2/features/media/video-library/#database-model-drizzle","title":"Database Model (Drizzle)","text":""},{"location":"v2/features/media/video-library/#videos-table-schema","title":"Videos Table Schema","text":"
// api/src/modules/media/db/schema.ts\nimport { pgTable, uuid, text, integer, timestamp, boolean, jsonb } from 'drizzle-orm/pg-core';\n\nexport const videos = pgTable('videos', {\n  id: uuid('id').primaryKey().defaultRandom(),\n\n  // File Information\n  path: text('path').notNull().unique(), // Relative path from library root\n  filename: text('filename').notNull(),\n  originalFilename: text('original_filename'), // User-uploaded filename\n  directoryType: text('directory_type').notNull(), // studios|gifs|private|inbox|curated|playback|compilations|videos|highlights\n\n  // Metadata\n  producer: text('producer'),\n  creator: text('creator'),\n  title: text('title'),\n  tags: jsonb('tags').$type<string[]>().default([]),\n\n  // Video Properties\n  durationSeconds: integer('duration_seconds'),\n  quality: text('quality'), // SD|HD|FHD|UHD\n  orientation: text('orientation'), // portrait|landscape|square\n  hasAudio: boolean('has_audio').default(false),\n  width: integer('width'),\n  height: integer('height'),\n\n  // File Details\n  fileSize: integer('file_size'), // Bytes\n  fileHash: text('file_hash'), // SHA-256 for duplicate detection\n\n  // Validation\n  isValid: boolean('is_valid').default(true),\n  lastValidated: timestamp('last_validated'),\n  standardizedAt: timestamp('standardized_at'), // When file was moved to standard location\n\n  // Thumbnail\n  thumbnailPath: text('thumbnail_path'),\n\n  // Public Sharing\n  publicViewCount: integer('public_view_count').default(0),\n  publicUpvoteCount: integer('public_upvote_count').default(0),\n  movedFromPublicAt: timestamp('moved_from_public_at'), // When video was unlocked from public\n\n  // Timestamps\n  createdAt: timestamp('created_at').defaultNow(),\n  updatedAt: timestamp('updated_at').defaultNow(),\n});\n
"},{"location":"v2/features/media/video-library/#directory-types-enum","title":"Directory Types Enum","text":"Directory Type Purpose Public Eligible studios Studio-organized content \u2705 gifs Short looping videos \u2705 private Private/unreleased content \u274c inbox Upload staging area \u274c curated Hand-picked highlights \u2705 playback Playback-optimized encodes \u2705 compilations Multi-video compilations \u2705 videos General video library \u2705 highlights Auto-generated highlights \u2705"},{"location":"v2/features/media/video-library/#quality-classifications","title":"Quality Classifications","text":"Quality Height Range Typical Resolution SD < 720px 480p, 576p HD 720px - 1079px 720p FHD 1080px - 2159px 1080p UHD \u2265 2160px 4K, 8K"},{"location":"v2/features/media/video-library/#orientation-detection","title":"Orientation Detection","text":"
const detectOrientation = (width: number, height: number): string => {\n  const ratio = width / height;\n  if (ratio > 1.1) return 'landscape';\n  if (ratio < 0.9) return 'portrait';\n  return 'square';\n};\n
"},{"location":"v2/features/media/video-library/#api-endpoints","title":"API Endpoints","text":"

All endpoints require authentication with SUPER_ADMIN role unless marked as public.

"},{"location":"v2/features/media/video-library/#list-videos","title":"List Videos","text":"
GET /api/media/videos\n

Query Parameters:

Parameter Type Default Description page number 1 Page number for pagination limit number 20 Results per page (max 100) directoryType string - Filter by directory (studios, gifs, etc.) orientation string - Filter by orientation (portrait, landscape, square) producer string - Filter by producer (partial match) creator string - Filter by creator (partial match) quality string - Filter by quality (SD, HD, FHD, UHD) hasAudio boolean - Filter by audio presence isValid boolean true Filter by validation status search string - Search in title, producer, creator

Response:

{\n  \"data\": [\n    {\n      \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n      \"path\": \"videos/sample.mp4\",\n      \"filename\": \"sample.mp4\",\n      \"directoryType\": \"videos\",\n      \"producer\": \"Studio A\",\n      \"creator\": \"Director B\",\n      \"title\": \"Sample Video\",\n      \"durationSeconds\": 180,\n      \"quality\": \"FHD\",\n      \"orientation\": \"landscape\",\n      \"hasAudio\": true,\n      \"width\": 1920,\n      \"height\": 1080,\n      \"fileSize\": 52428800,\n      \"isValid\": true,\n      \"createdAt\": \"2026-02-10T12:00:00Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 156,\n    \"totalPages\": 8\n  }\n}\n
"},{"location":"v2/features/media/video-library/#get-video-details","title":"Get Video Details","text":"
GET /api/media/videos/:id\n

Response:

{\n  \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"path\": \"videos/sample.mp4\",\n  \"filename\": \"sample.mp4\",\n  \"originalFilename\": \"my-video.mp4\",\n  \"directoryType\": \"videos\",\n  \"producer\": \"Studio A\",\n  \"creator\": \"Director B\",\n  \"title\": \"Sample Video\",\n  \"tags\": [\"action\", \"sports\", \"highlight\"],\n  \"durationSeconds\": 180,\n  \"quality\": \"FHD\",\n  \"orientation\": \"landscape\",\n  \"hasAudio\": true,\n  \"width\": 1920,\n  \"height\": 1080,\n  \"fileSize\": 52428800,\n  \"fileHash\": \"a3d2f1e8b9c7...\",\n  \"isValid\": true,\n  \"lastValidated\": \"2026-02-10T12:00:00Z\",\n  \"thumbnailPath\": \"thumbnails/550e8400.jpg\",\n  \"publicViewCount\": 1250,\n  \"publicUpvoteCount\": 85,\n  \"createdAt\": \"2026-02-10T12:00:00Z\",\n  \"updatedAt\": \"2026-02-10T12:00:00Z\"\n}\n
"},{"location":"v2/features/media/video-library/#create-video-record","title":"Create Video Record","text":"
POST /api/media/videos\n

Request Body:

{\n  \"path\": \"videos/new-video.mp4\",\n  \"filename\": \"new-video.mp4\",\n  \"directoryType\": \"videos\",\n  \"producer\": \"Studio A\",\n  \"creator\": \"Director B\",\n  \"title\": \"New Video\",\n  \"tags\": [\"action\", \"sports\"]\n}\n

Notes:

  • File must already exist at specified path on filesystem
  • FFprobe metadata extraction runs automatically after creation
  • Use /api/media/upload/single for file upload + record creation

Response:

{\n  \"id\": \"660e8400-e29b-41d4-a716-446655440000\",\n  \"path\": \"videos/new-video.mp4\",\n  \"filename\": \"new-video.mp4\",\n  \"directoryType\": \"videos\",\n  \"isValid\": true,\n  \"createdAt\": \"2026-02-13T10:30:00Z\"\n}\n
"},{"location":"v2/features/media/video-library/#update-video-metadata","title":"Update Video Metadata","text":"
PUT /api/media/videos/:id\n

Request Body:

{\n  \"producer\": \"Updated Studio\",\n  \"creator\": \"New Director\",\n  \"title\": \"Updated Title\",\n  \"tags\": [\"updated\", \"tags\"]\n}\n

Updatable Fields:

  • producer \u2014 Video producer/studio
  • creator \u2014 Director/creator name
  • title \u2014 Display title
  • tags \u2014 Array of tag strings
  • thumbnailPath \u2014 Custom thumbnail path

Response:

{\n  \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"producer\": \"Updated Studio\",\n  \"creator\": \"New Director\",\n  \"title\": \"Updated Title\",\n  \"tags\": [\"updated\", \"tags\"],\n  \"updatedAt\": \"2026-02-13T10:35:00Z\"\n}\n
"},{"location":"v2/features/media/video-library/#delete-video","title":"Delete Video","text":"
DELETE /api/media/videos/:id\n

Behavior:

  • Soft Delete \u2014 Sets isValid = false instead of removing record
  • File remains on filesystem (manual cleanup required)
  • Video no longer appears in default listings
  • Can be restored by setting isValid = true via database

Response:

{\n  \"success\": true,\n  \"message\": \"Video marked as invalid\"\n}\n
"},{"location":"v2/features/media/video-library/#scan-directory","title":"Scan Directory","text":"
POST /api/media/videos/scan\n

Request Body:

{\n  \"directoryType\": \"videos\",\n  \"skipExisting\": true\n}\n

Parameters:

Field Type Required Description directoryType string \u2705 Directory to scan (videos, studios, etc.) skipExisting boolean - Skip files already in database (default: true)

Process:

  1. Reads filesystem directory /media/local/library/{directoryType}/
  2. Filters for video extensions (.mp4, .mov, .avi, .mkv, .webm, .m4v, .flv)
  3. Checks each file against database (by path)
  4. Creates records for new files
  5. Runs FFprobe metadata extraction on new records

Response:

{\n  \"scanned\": 45,\n  \"created\": 12,\n  \"skipped\": 33,\n  \"failed\": 0,\n  \"errors\": []\n}\n
"},{"location":"v2/features/media/video-library/#validate-video","title":"Validate Video","text":"
POST /api/media/videos/:id/validate\n

Purpose:

  • Re-run FFprobe metadata extraction
  • Update video properties (duration, dimensions, etc.)
  • Verify file still exists and is readable
  • Refresh file size and hash

Response:

{\n  \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"isValid\": true,\n  \"lastValidated\": \"2026-02-13T10:40:00Z\",\n  \"metadata\": {\n    \"durationSeconds\": 180,\n    \"width\": 1920,\n    \"height\": 1080,\n    \"quality\": \"FHD\",\n    \"orientation\": \"landscape\",\n    \"hasAudio\": true\n  }\n}\n
"},{"location":"v2/features/media/video-library/#configuration","title":"Configuration","text":""},{"location":"v2/features/media/video-library/#environment-variables","title":"Environment Variables","text":"
# Media API Server\nMEDIA_API_PORT=4100\nMEDIA_API_HOST=0.0.0.0\n\n# File Paths\nMEDIA_LIBRARY_PATH=/media/local/library\nMEDIA_INBOX_PATH=/media/local/inbox\n\n# Feature Flags\nENABLE_MEDIA_FEATURES=true\n\n# Database (shared with main API)\nDATABASE_URL=postgresql://user:pass@v2-postgres:5432/v2_changemaker\n\n# FFprobe\nFFPROBE_TIMEOUT=30000  # milliseconds\nFFPROBE_PATH=/usr/bin/ffprobe  # Auto-detected if not set\n
"},{"location":"v2/features/media/video-library/#docker-volume-mounts","title":"Docker Volume Mounts","text":"
# docker-compose.yml\nservices:\n  media-api:\n    volumes:\n      - /media/local/library:/media/local/library:ro  # Read-only library\n      - /media/local/inbox:/media/local/inbox:rw      # Read-write inbox\n

Important: Inbox requires :rw (read-write) for uploads. Library can be :ro (read-only) for security.

"},{"location":"v2/features/media/video-library/#site-settings","title":"Site Settings","text":"

The media system respects the global ENABLE_MEDIA_FEATURES flag in Site Settings:

SELECT * FROM settings WHERE key = 'ENABLE_MEDIA_FEATURES';\n

When disabled:

  • Media API still runs but returns 503 Service Unavailable
  • Admin GUI hides Media menu items
  • Public gallery shows maintenance message
"},{"location":"v2/features/media/video-library/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/media/video-library/#viewing-the-video-library","title":"Viewing the Video Library","text":"
  1. Navigate to Media \u2192 Library in admin sidebar
  2. Table displays all videos with:
  3. Thumbnail preview
  4. Title, producer, creator
  5. Duration, quality, orientation
  6. Directory type
  7. File size
  8. Created date
  9. Use filters at top:
  10. Directory Type dropdown
  11. Orientation radio buttons (All / Portrait / Landscape / Square)
  12. Quality checkboxes (SD, HD, FHD, UHD)
  13. Search input (searches title, producer, creator)
"},{"location":"v2/features/media/video-library/#scanning-a-directory","title":"Scanning a Directory","text":"

When to Use:

  • After manually copying videos to library directory
  • After video processing jobs complete
  • When videos exist on filesystem but not in database

Steps:

  1. Click \"Scan Directory\" button in Library page toolbar
  2. Select directory type from dropdown
  3. Toggle \"Skip Existing\" (recommended for large libraries)
  4. Click \"Start Scan\"
  5. Progress modal shows:
  6. Files scanned
  7. New records created
  8. Skipped (already in DB)
  9. Failed (with error messages)
  10. Click \"Close\" when complete
  11. Table refreshes with new videos

Example Output:

Scanning /media/local/library/videos...\nFound 45 video files\n- Created 12 new records\n- Skipped 33 existing records\n- Failed 0 files\nScan complete in 8.3 seconds\n
"},{"location":"v2/features/media/video-library/#editing-video-metadata","title":"Editing Video Metadata","text":"
  1. Click pencil icon in video row
  2. Edit modal opens with fields:
  3. Producer \u2014 Studio or production company
  4. Creator \u2014 Director or primary creator
  5. Title \u2014 Display title
  6. Tags \u2014 Comma-separated tags (auto-suggests existing tags)
  7. Click \"Save\" to update
  8. Metadata changes immediately visible in table

Bulk Editing:

  1. Select multiple videos using checkboxes
  2. Click \"Bulk Edit\" button
  3. Set common fields (producer, tags, etc.)
  4. Click \"Apply to Selected\"
"},{"location":"v2/features/media/video-library/#validating-videos","title":"Validating Videos","text":"

Purpose: Refresh metadata and verify file integrity

Steps:

  1. Click \"Validate\" button in video row (or Actions dropdown)
  2. FFprobe re-analyzes video file
  3. Database updates with fresh metadata:
  4. Duration (may have changed if file was re-encoded)
  5. Dimensions
  6. Audio detection
  7. File size and hash
  8. lastValidated timestamp updates
  9. If file missing or corrupt, isValid set to false

Bulk Validation:

  1. Select multiple videos
  2. Click \"Validate Selected\"
  3. Progress modal shows validation results
  4. Failed validations highlighted in red
"},{"location":"v2/features/media/video-library/#deleting-videos","title":"Deleting Videos","text":"

Soft Delete (Default):

  1. Click trash icon in video row
  2. Confirm deletion dialog
  3. Video marked isValid = false
  4. Video disappears from default view
  5. File remains on filesystem
  6. Record preserved in database

Viewing Deleted Videos:

  1. Toggle \"Show Invalid\" filter
  2. Deleted videos appear with strikethrough
  3. Can restore by clicking \"Restore\" button

Hard Delete (Database Only):

  1. Filter for invalid videos
  2. Select video(s)
  3. Click \"Permanently Delete\"
  4. Removes database record
  5. File still on filesystem (manual cleanup required)

File System Cleanup:

Deleted video files must be manually removed from filesystem:

# SSH into media-api container\ndocker compose exec media-api sh\n\n# Navigate to library\ncd /media/local/library/videos\n\n# Remove specific file\nrm deleted-video.mp4\n\n# Or find and remove all invalid videos (BE CAREFUL)\n# (requires database query to get invalid file paths)\n
"},{"location":"v2/features/media/video-library/#directory-structure","title":"Directory Structure","text":"
/media/local/library/\n\u251c\u2500\u2500 studios/              # Studio-organized content\n\u2502   \u251c\u2500\u2500 studio-a/\n\u2502   \u2502   \u251c\u2500\u2500 video-001.mp4\n\u2502   \u2502   \u2514\u2500\u2500 video-002.mp4\n\u2502   \u2514\u2500\u2500 studio-b/\n\u2502       \u2514\u2500\u2500 video-003.mp4\n\u2502\n\u251c\u2500\u2500 gifs/                 # Short looping videos\n\u2502   \u251c\u2500\u2500 loop-001.mp4\n\u2502   \u2514\u2500\u2500 loop-002.webm\n\u2502\n\u251c\u2500\u2500 private/              # Private/unreleased content\n\u2502   \u2514\u2500\u2500 unreleased.mp4\n\u2502\n\u251c\u2500\u2500 inbox/                # Upload staging area (READ-WRITE)\n\u2502   \u251c\u2500\u2500 uuid-123.mp4      # Temp uploads\n\u2502   \u2514\u2500\u2500 uuid-456.mov\n\u2502\n\u251c\u2500\u2500 curated/              # Hand-picked highlights\n\u2502   \u251c\u2500\u2500 best-of-2025.mp4\n\u2502   \u2514\u2500\u2500 top-plays.mp4\n\u2502\n\u251c\u2500\u2500 playback/             # Playback-optimized encodes\n\u2502   \u251c\u2500\u2500 streaming-001.mp4\n\u2502   \u2514\u2500\u2500 streaming-002.mp4\n\u2502\n\u251c\u2500\u2500 compilations/         # Multi-video compilations\n\u2502   \u251c\u2500\u2500 compilation-001.mp4\n\u2502   \u2514\u2500\u2500 mega-compilation.mp4\n\u2502\n\u251c\u2500\u2500 videos/               # General video library\n\u2502   \u251c\u2500\u2500 video-001.mp4\n\u2502   \u251c\u2500\u2500 video-002.mp4\n\u2502   \u2514\u2500\u2500 ... (thousands of videos)\n\u2502\n\u2514\u2500\u2500 highlights/           # Auto-generated highlights\n    \u251c\u2500\u2500 highlight-001.mp4\n    \u2514\u2500\u2500 highlight-002.mp4\n

Directory Guidelines:

  • studios/ \u2014 Organize by producer/studio name (subfolder structure allowed)
  • gifs/ \u2014 Short videos under 15 seconds, suitable for looping
  • private/ \u2014 Never shared publicly, admin-only access
  • inbox/ \u2014 Temporary upload location, files moved after processing
  • curated/ \u2014 High-quality selections for public gallery homepage
  • playback/ \u2014 Web-optimized encodes (H.264, web-friendly profiles)
  • compilations/ \u2014 Merged videos created by compilation jobs
  • videos/ \u2014 Main library, all-purpose storage
  • highlights/ \u2014 AI-generated or manually created highlight reels
"},{"location":"v2/features/media/video-library/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/media/video-library/#list-videos-with-filters-fastify-route","title":"List Videos with Filters (Fastify Route)","text":"
// api/src/modules/media/routes/videos.routes.ts\nimport { FastifyInstance } from 'fastify';\nimport { eq, and, like, desc, sql } from 'drizzle-orm';\nimport { videos } from '@/modules/media/db/schema';\nimport { db } from '@/modules/media/db';\n\nexport default async function (app: FastifyInstance) {\n  app.get('/api/media/videos', async (req, reply) => {\n    const {\n      page = 1,\n      limit = 20,\n      directoryType,\n      orientation,\n      producer,\n      creator,\n      quality,\n      hasAudio,\n      isValid = true,\n      search,\n    } = req.query as any;\n\n    // Build filters\n    const filters = [];\n\n    if (directoryType) {\n      filters.push(eq(videos.directoryType, directoryType));\n    }\n\n    if (orientation) {\n      filters.push(eq(videos.orientation, orientation));\n    }\n\n    if (producer) {\n      filters.push(like(videos.producer, `%${producer}%`));\n    }\n\n    if (creator) {\n      filters.push(like(videos.creator, `%${creator}%`));\n    }\n\n    if (quality) {\n      filters.push(eq(videos.quality, quality));\n    }\n\n    if (typeof hasAudio === 'boolean') {\n      filters.push(eq(videos.hasAudio, hasAudio));\n    }\n\n    if (typeof isValid === 'boolean') {\n      filters.push(eq(videos.isValid, isValid));\n    }\n\n    if (search) {\n      filters.push(\n        sql`(\n          ${videos.title} ILIKE ${'%' + search + '%'} OR\n          ${videos.producer} ILIKE ${'%' + search + '%'} OR\n          ${videos.creator} ILIKE ${'%' + search + '%'}\n        )`\n      );\n    }\n\n    // Count total\n    const [{ count }] = await db\n      .select({ count: sql<number>`count(*)` })\n      .from(videos)\n      .where(and(...filters));\n\n    // Fetch paginated results\n    const results = await db\n      .select()\n      .from(videos)\n      .where(and(...filters))\n      .limit(Number(limit))\n      .offset((Number(page) - 1) * Number(limit))\n      .orderBy(desc(videos.createdAt));\n\n    reply.send({\n      data: results,\n      pagination: {\n        page: Number(page),\n        limit: Number(limit),\n        total: Number(count),\n        totalPages: Math.ceil(Number(count) / Number(limit)),\n      },\n    });\n  });\n}\n
"},{"location":"v2/features/media/video-library/#scan-directory-for-videos","title":"Scan Directory for Videos","text":"
// api/src/modules/media/routes/videos.routes.ts\nimport fs from 'fs/promises';\nimport path from 'path';\nimport { eq } from 'drizzle-orm';\nimport { videos } from '@/modules/media/db/schema';\nimport { ffprobeService } from '@/modules/media/services/ffprobe.service';\n\napp.post('/api/media/videos/scan', async (req, reply) => {\n  const { directoryType, skipExisting = true } = req.body as any;\n\n  if (!directoryType) {\n    return reply.code(400).send({ error: 'directoryType required' });\n  }\n\n  const dirPath = path.join(process.env.MEDIA_LIBRARY_PATH!, directoryType);\n\n  try {\n    // Check directory exists\n    await fs.access(dirPath);\n  } catch {\n    return reply.code(400).send({ error: `Directory not found: ${directoryType}` });\n  }\n\n  // Read directory\n  const files = await fs.readdir(dirPath, { recursive: true });\n\n  // Filter for video files\n  const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv'];\n  const videoFiles = files.filter((f) =>\n    videoExtensions.some((ext) => f.toLowerCase().endsWith(ext))\n  );\n\n  const results = {\n    scanned: videoFiles.length,\n    created: 0,\n    skipped: 0,\n    failed: 0,\n    errors: [] as string[],\n  };\n\n  for (const filename of videoFiles) {\n    try {\n      const relativePath = path.join(directoryType, filename);\n\n      // Check if already exists\n      if (skipExisting) {\n        const existing = await db\n          .select()\n          .from(videos)\n          .where(eq(videos.path, relativePath))\n          .limit(1);\n\n        if (existing.length > 0) {\n          results.skipped++;\n          continue;\n        }\n      }\n\n      // Extract metadata\n      const fullPath = path.join(dirPath, filename);\n      const metadata = await ffprobeService.extract(fullPath);\n\n      // Create record\n      await db.insert(videos).values({\n        path: relativePath,\n        filename: path.basename(filename),\n        directoryType,\n        durationSeconds: metadata.duration,\n        width: metadata.width,\n        height: metadata.height,\n        orientation: metadata.orientation,\n        quality: metadata.quality,\n        hasAudio: metadata.hasAudio,\n        fileSize: metadata.fileSize,\n        isValid: true,\n      });\n\n      results.created++;\n    } catch (error: any) {\n      results.failed++;\n      results.errors.push(`${filename}: ${error.message}`);\n    }\n  }\n\n  reply.send(results);\n});\n
"},{"location":"v2/features/media/video-library/#validate-video-metadata","title":"Validate Video Metadata","text":"
// api/src/modules/media/routes/videos.routes.ts\nimport { eq } from 'drizzle-orm';\nimport { videos } from '@/modules/media/db/schema';\nimport { ffprobeService } from '@/modules/media/services/ffprobe.service';\n\napp.post('/api/media/videos/:id/validate', async (req, reply) => {\n  const { id } = req.params as { id: string };\n\n  // Fetch video record\n  const [video] = await db\n    .select()\n    .from(videos)\n    .where(eq(videos.id, id))\n    .limit(1);\n\n  if (!video) {\n    return reply.code(404).send({ error: 'Video not found' });\n  }\n\n  try {\n    // Build full file path\n    const fullPath = path.join(process.env.MEDIA_LIBRARY_PATH!, video.path);\n\n    // Extract fresh metadata\n    const metadata = await ffprobeService.extract(fullPath);\n\n    // Update database\n    const [updated] = await db\n      .update(videos)\n      .set({\n        durationSeconds: metadata.duration,\n        width: metadata.width,\n        height: metadata.height,\n        orientation: metadata.orientation,\n        quality: metadata.quality,\n        hasAudio: metadata.hasAudio,\n        fileSize: metadata.fileSize,\n        fileHash: metadata.fileHash,\n        isValid: true,\n        lastValidated: new Date(),\n        updatedAt: new Date(),\n      })\n      .where(eq(videos.id, id))\n      .returning();\n\n    reply.send({\n      id: updated.id,\n      isValid: updated.isValid,\n      lastValidated: updated.lastValidated,\n      metadata: {\n        durationSeconds: updated.durationSeconds,\n        width: updated.width,\n        height: updated.height,\n        quality: updated.quality,\n        orientation: updated.orientation,\n        hasAudio: updated.hasAudio,\n      },\n    });\n  } catch (error: any) {\n    // Mark as invalid if validation fails\n    await db\n      .update(videos)\n      .set({\n        isValid: false,\n        lastValidated: new Date(),\n        updatedAt: new Date(),\n      })\n      .where(eq(videos.id, id));\n\n    reply.code(500).send({\n      error: 'Validation failed',\n      message: error.message,\n      isValid: false,\n    });\n  }\n});\n
"},{"location":"v2/features/media/video-library/#frontend-library-page-table","title":"Frontend: Library Page Table","text":"
// admin/src/pages/media/LibraryPage.tsx\nimport { Table, Button, Select, Input, Tag, Space } from 'antd';\nimport { useEffect, useState } from 'react';\nimport { mediaApi } from '@/lib/media-api';\n\nexport default function LibraryPage() {\n  const [videos, setVideos] = useState([]);\n  const [loading, setLoading] = useState(false);\n  const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });\n  const [filters, setFilters] = useState({\n    directoryType: undefined,\n    orientation: undefined,\n    search: '',\n  });\n\n  const fetchVideos = async () => {\n    setLoading(true);\n    try {\n      const { data } = await mediaApi.get('/api/media/videos', {\n        params: {\n          page: pagination.page,\n          limit: pagination.limit,\n          ...filters,\n        },\n      });\n      setVideos(data.data);\n      setPagination((prev) => ({ ...prev, total: data.pagination.total }));\n    } catch (error) {\n      console.error('Failed to fetch videos:', error);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  useEffect(() => {\n    fetchVideos();\n  }, [pagination.page, filters]);\n\n  const columns = [\n    {\n      title: 'Preview',\n      dataIndex: 'thumbnailPath',\n      width: 100,\n      render: (path: string) => (\n        <img\n          src={path || '/placeholder.jpg'}\n          alt=\"Thumbnail\"\n          style={{ width: 80, height: 60, objectFit: 'cover' }}\n        />\n      ),\n    },\n    {\n      title: 'Title',\n      dataIndex: 'title',\n      render: (text: string, record: any) => (\n        <div>\n          <div style={{ fontWeight: 600 }}>{text || record.filename}</div>\n          <div style={{ fontSize: 12, color: '#888' }}>\n            {record.producer} \u2022 {record.creator}\n          </div>\n        </div>\n      ),\n    },\n    {\n      title: 'Duration',\n      dataIndex: 'durationSeconds',\n      width: 100,\n      render: (seconds: number) => {\n        const mins = Math.floor(seconds / 60);\n        const secs = seconds % 60;\n        return `${mins}:${secs.toString().padStart(2, '0')}`;\n      },\n    },\n    {\n      title: 'Quality',\n      dataIndex: 'quality',\n      width: 80,\n      render: (quality: string) => {\n        const colors: Record<string, string> = {\n          SD: 'default',\n          HD: 'blue',\n          FHD: 'green',\n          UHD: 'purple',\n        };\n        return <Tag color={colors[quality]}>{quality}</Tag>;\n      },\n    },\n    {\n      title: 'Orientation',\n      dataIndex: 'orientation',\n      width: 100,\n    },\n    {\n      title: 'Directory',\n      dataIndex: 'directoryType',\n      width: 120,\n    },\n    {\n      title: 'Actions',\n      width: 150,\n      render: (_: any, record: any) => (\n        <Space>\n          <Button size=\"small\" onClick={() => handleEdit(record.id)}>\n            Edit\n          </Button>\n          <Button size=\"small\" onClick={() => handleValidate(record.id)}>\n            Validate\n          </Button>\n          <Button size=\"small\" danger onClick={() => handleDelete(record.id)}>\n            Delete\n          </Button>\n        </Space>\n      ),\n    },\n  ];\n\n  return (\n    <div>\n      <Space style={{ marginBottom: 16 }}>\n        <Select\n          placeholder=\"Directory Type\"\n          style={{ width: 200 }}\n          onChange={(value) => setFilters({ ...filters, directoryType: value })}\n          allowClear\n        >\n          <Select.Option value=\"videos\">Videos</Select.Option>\n          <Select.Option value=\"studios\">Studios</Select.Option>\n          <Select.Option value=\"gifs\">GIFs</Select.Option>\n          <Select.Option value=\"curated\">Curated</Select.Option>\n        </Select>\n\n        <Input.Search\n          placeholder=\"Search title, producer, creator\"\n          style={{ width: 300 }}\n          onSearch={(value) => setFilters({ ...filters, search: value })}\n          allowClear\n        />\n\n        <Button type=\"primary\" onClick={handleScanDirectory}>\n          Scan Directory\n        </Button>\n      </Space>\n\n      <Table\n        columns={columns}\n        dataSource={videos}\n        loading={loading}\n        rowKey=\"id\"\n        pagination={{\n          current: pagination.page,\n          pageSize: pagination.limit,\n          total: pagination.total,\n          onChange: (page) => setPagination({ ...pagination, page }),\n        }}\n      />\n    </div>\n  );\n}\n
"},{"location":"v2/features/media/video-library/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/media/video-library/#problem-media-api-not-accessible","title":"Problem: Media API Not Accessible","text":"

Symptoms:

  • Admin GUI shows \"Cannot connect to media API\"
  • Browser console shows CORS errors or network failures
  • Public gallery doesn't load

Solutions:

  1. Check Fastify server running:
docker compose ps media-api\n# Should show \"Up\" status\n\ndocker compose logs media-api\n# Look for \"Fastify server listening on port 4100\"\n
  1. Verify port 4100 not in use:
lsof -i :4100\n# Should show only media-api container\n\n# If another process using port, stop it or change MEDIA_API_PORT in .env\n
  1. Check nginx proxy configuration:
# nginx/conf.d/api.conf\n# Media API block must come BEFORE general API block\n\nserver {\n    listen 80;\n    server_name media.cmlite.org;\n\n    location / {\n        proxy_pass http://localhost:4100;\n        proxy_http_version 1.1;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection 'upgrade';\n        proxy_set_header Host $host;\n        proxy_cache_bypass $http_upgrade;\n    }\n}\n
  1. Test direct API access:
# From host machine\ncurl http://localhost:4100/api/media/videos\n\n# From inside container\ndocker compose exec media-api curl http://localhost:4100/api/media/videos\n
  1. Check Docker networking:
docker network inspect changemaker-lite\n# Verify media-api container connected\n
"},{"location":"v2/features/media/video-library/#problem-scan-finds-no-videos","title":"Problem: Scan Finds No Videos","text":"

Symptoms:

  • Scan completes with \"Created 0 new records\"
  • Directory known to contain video files
  • Scan reports 0 files scanned

Solutions:

  1. Verify MEDIA_LIBRARY_PATH correct:
# Check environment variable\ndocker compose exec media-api printenv MEDIA_LIBRARY_PATH\n# Should output: /media/local/library\n\n# List directory contents\ndocker compose exec media-api ls -la /media/local/library/videos\n# Should show video files\n
  1. Check directory exists:
# Create missing directory\ndocker compose exec media-api mkdir -p /media/local/library/videos\n\n# Copy test videos\ndocker cp test.mp4 $(docker compose ps -q media-api):/media/local/library/videos/\n
  1. Verify Docker volume mounted:
# docker-compose.yml\nservices:\n  media-api:\n    volumes:\n      - /media/local/library:/media/local/library:ro  # Check path correct\n
# Inspect volume mounts\ndocker compose config | grep -A 5 media-api\n
  1. Check file extensions supported:

Only these extensions scanned:

  • .mp4
  • .mov
  • .avi
  • .mkv
  • .webm
  • .m4v
  • .flv

Rename files if using other extensions:

# Rename .MP4 to .mp4 (case-sensitive)\ndocker compose exec media-api sh -c 'cd /media/local/library/videos && rename \"s/.MP4$/.mp4/\" *.MP4'\n
  1. Check file permissions:
# Verify readable by container user\ndocker compose exec media-api ls -la /media/local/library/videos\n\n# Fix permissions if needed (on host)\nsudo chmod -R 755 /media/local/library\n
"},{"location":"v2/features/media/video-library/#problem-ffprobe-validation-fails","title":"Problem: FFprobe Validation Fails","text":"

Symptoms:

  • Validation returns error \"FFprobe command failed\"
  • Videos marked isValid = false
  • Timeout errors after 30 seconds

Solutions:

  1. Check FFmpeg installed in container:
# Verify FFprobe available\ndocker compose exec media-api which ffprobe\n# Should output: /usr/bin/ffprobe\n\ndocker compose exec media-api ffprobe -version\n# Should show FFmpeg version info\n
  1. Install FFmpeg if missing:
# api/Dockerfile.media\nFROM node:20-alpine\n\n# Install FFmpeg (both dev and production stages)\nRUN apk add --no-cache ffmpeg\n\n# ... rest of Dockerfile\n
# Rebuild container\ndocker compose build media-api\ndocker compose up -d media-api\n
  1. Test FFprobe directly on video:
# Run FFprobe manually\ndocker compose exec media-api ffprobe -v quiet -print_format json -show_streams -show_format /media/local/library/videos/test.mp4\n\n# If this fails, video file corrupt or unsupported\n
  1. Check timeout not exceeded:

Default timeout: 30 seconds

# For very large files (>5GB), increase timeout\n# api/src/modules/media/services/ffprobe.service.ts\nconst FFPROBE_TIMEOUT = 60000; // 60 seconds\n
  1. Verify video file not corrupt:
# Test playback\ndocker compose exec media-api ffplay /media/local/library/videos/test.mp4\n\n# Or copy to host and test in VLC\ndocker cp $(docker compose ps -q media-api):/media/local/library/videos/test.mp4 ./test.mp4\nvlc test.mp4\n
  1. Check for special characters in filename:
# Rename files with spaces or special chars\ndocker compose exec media-api sh -c 'cd /media/local/library/videos && rename \"s/ /_/g\" *.mp4'\n
"},{"location":"v2/features/media/video-library/#problem-drizzle-schema-changes-not-applied","title":"Problem: Drizzle Schema Changes Not Applied","text":"

Symptoms:

  • Code references new column but database doesn't have it
  • Error: \"column does not exist\"
  • Schema changes made but not reflected

Solutions:

  1. Push schema changes:
# Drizzle uses push (not migrations)\ncd api\nnpx drizzle-kit push\n\n# Confirm changes\n
  1. Verify connection:
# Check DATABASE_URL correct\ndocker compose exec media-api printenv DATABASE_URL\n\n# Test connection\ndocker compose exec media-api npx drizzle-kit studio\n# Opens DB browser on http://localhost:4983\n
  1. Compare with Prisma migrations:

Media tables exist in same database as Prisma tables. If conflict:

# Check both schemas\nnpx prisma db pull  # Prisma introspection\nnpx drizzle-kit introspect  # Drizzle introspection\n\n# Resolve conflicts manually\n
"},{"location":"v2/features/media/video-library/#problem-large-library-performance","title":"Problem: Large Library Performance","text":"

Symptoms:

  • Library page loads slowly (5+ seconds)
  • Pagination sluggish
  • Scan operations timeout

Solutions:

  1. Add database indexes:
-- Index for common filters\nCREATE INDEX idx_videos_directory_type ON videos(directory_type);\nCREATE INDEX idx_videos_orientation ON videos(orientation);\nCREATE INDEX idx_videos_quality ON videos(quality);\nCREATE INDEX idx_videos_is_valid ON videos(is_valid);\nCREATE INDEX idx_videos_created_at ON videos(created_at DESC);\n\n-- Composite index for filtered queries\nCREATE INDEX idx_videos_filters ON videos(directory_type, is_valid, created_at DESC);\n\n-- Full-text search index\nCREATE INDEX idx_videos_search ON videos USING gin(to_tsvector('english', coalesce(title, '') || ' ' || coalesce(producer, '') || ' ' || coalesce(creator, '')));\n
  1. Reduce page size:
// admin/src/pages/media/LibraryPage.tsx\nconst [pagination, setPagination] = useState({ page: 1, limit: 10, total: 0 });\n// Reduced from 20 to 10\n
  1. Enable query caching:
// api/src/modules/media/routes/videos.routes.ts\nimport { redisClient } from '@/config/redis';\n\napp.get('/api/media/videos', async (req, reply) => {\n  const cacheKey = `videos:list:${JSON.stringify(req.query)}`;\n\n  // Check cache\n  const cached = await redisClient.get(cacheKey);\n  if (cached) {\n    return reply.send(JSON.parse(cached));\n  }\n\n  // Fetch from database\n  const results = await db.select()...;\n\n  // Cache for 5 minutes\n  await redisClient.setex(cacheKey, 300, JSON.stringify(results));\n\n  reply.send(results);\n});\n
  1. Use virtual scrolling:
// Replace Ant Design Table with react-window for large datasets\nimport { FixedSizeList } from 'react-window';\n
"},{"location":"v2/features/media/video-library/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/media/video-library/#directory-scans","title":"Directory Scans","text":"

Scaling Factors:

  • 100 files: ~2 seconds
  • 1,000 files: ~15 seconds
  • 10,000 files: ~2.5 minutes

Optimization Strategies:

  1. Incremental Scans \u2014 Use skipExisting: true to only process new files
  2. Parallel Processing \u2014 Scan multiple directories simultaneously
  3. Background Jobs \u2014 Queue scans as async jobs instead of synchronous requests
  4. Caching \u2014 Cache directory listings in Redis
"},{"location":"v2/features/media/video-library/#ffprobe-extraction","title":"FFprobe Extraction","text":"

Timing:

  • Small video (<100MB): ~50-100ms
  • Medium video (500MB): ~150-250ms
  • Large video (2GB+): ~500ms-1s

Batch Processing:

For 100 videos: ~10-20 seconds total

Optimization:

// Parallel extraction (limit concurrency)\nimport pLimit from 'p-limit';\n\nconst limit = pLimit(5); // Max 5 concurrent FFprobe calls\n\nconst results = await Promise.all(\n  videoFiles.map((file) =>\n    limit(() => ffprobeService.extract(file))\n  )\n);\n
"},{"location":"v2/features/media/video-library/#database-queries","title":"Database Queries","text":"

Query Performance:

  • List 20 videos (no filters): ~5-10ms
  • List 20 videos (with filters): ~10-20ms
  • Full-text search: ~20-50ms
  • Count total videos: ~5ms (with index)

Optimization:

  1. Always use pagination \u2014 Never fetch all records
  2. Index heavily filtered columns \u2014 directoryType, orientation, quality, isValid
  3. Use SELECT only needed columns \u2014 Avoid SELECT * for large tables
  4. Cache counts \u2014 Total video count changes infrequently, cache in Redis
"},{"location":"v2/features/media/video-library/#thumbnail-generation","title":"Thumbnail Generation","text":"

Deferred Loading:

Don't generate thumbnails during scan. Instead:

  1. Create video record without thumbnail
  2. Queue thumbnail generation job
  3. Worker processes job asynchronously
  4. Update record with thumbnailPath

Lazy Loading:

Frontend requests thumbnails only when visible (IntersectionObserver).

"},{"location":"v2/features/media/video-library/#dual-api-architecture","title":"Dual API Architecture","text":""},{"location":"v2/features/media/video-library/#why-separate-fastify-api","title":"Why Separate Fastify API?","text":"

The media system was introduced as a Phase 14 enhancement after V2 core functionality stabilized. A separate Fastify microservice was chosen to:

  1. Avoid Disrupting Stable Express API \u2014 V2 Express API battle-tested with 30+ models, introducing media directly risked regressions
  2. Test Drizzle ORM Migration \u2014 Fastify+Drizzle serves as proof-of-concept for potential future Prisma\u2192Drizzle migration
  3. Isolate Video Processing \u2014 CPU/GPU-intensive FFprobe, encoding jobs isolated from main API request handling
  4. Independent Scaling \u2014 Media API can be horizontally scaled separately based on video processing load
  5. Technology Experimentation \u2014 Fastify's performance benefits evaluated for potential broader adoption
"},{"location":"v2/features/media/video-library/#database-sharing-strategy","title":"Database Sharing Strategy","text":"

Same PostgreSQL, Different ORMs:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502  PostgreSQL 16  \u2502\n\u2502 v2_changemaker  \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n         \u2191\n    \u250c\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2510\n    \u2502         \u2502\n\u250c\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2510\n\u2502Prisma \u2502 \u2502Drizzle\u2502\n\u2502 ORM   \u2502 \u2502  ORM  \u2502\n\u2514\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2518\n    \u2502        \u2502\n\u250c\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502Express \u2502 \u2502Fastify\u2502\n\u2502  API   \u2502 \u2502 Media \u2502\n\u2502 :4000  \u2502 \u2502  API  \u2502\n\u2502        \u2502 \u2502 :4100 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n

Benefits:

  • Single Source of Truth \u2014 All data in one database
  • Cross-API Queries \u2014 Main API can query media tables via Prisma raw queries
  • Unified Backups \u2014 One PostgreSQL dump includes both APIs
  • Shared Connections \u2014 Connection pooling optimizations benefit both

Challenges:

  • Schema Coordination \u2014 Must manually sync schema changes between Prisma migrations and Drizzle pushes
  • Type Conflicts \u2014 Same table, different type definitions (Prisma vs Drizzle types)
  • Migration Complexity \u2014 Prisma generates migrations, Drizzle uses push (no migration files)
"},{"location":"v2/features/media/video-library/#migration-strategy-roadmap","title":"Migration Strategy Roadmap","text":"

Short Term (Current):

  • Keep dual API architecture
  • Synchronize schemas manually
  • Document shared tables in both ORMs

Medium Term (6-12 months):

  • Evaluate Fastify+Drizzle performance vs Express+Prisma
  • If Fastify superior, migrate select Express routes to Fastify
  • If no significant benefit, consolidate media into Express+Prisma

Long Term (12+ months):

  • Unified API (either all Express or all Fastify)
  • Single ORM (either all Prisma or all Drizzle)
  • Deprecate less performant stack

Migration Effort Estimate:

  • Media to Express+Prisma: 3-5 days (convert Drizzle queries to Prisma, merge Fastify routes into Express)
  • All to Fastify+Drizzle: 2-3 weeks (convert 30+ Prisma models to Drizzle, rewrite Express routes for Fastify)
"},{"location":"v2/features/media/video-library/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/media/video-library/#backend-documentation","title":"Backend Documentation","text":"
  • API Server: backend/api/media-server.md \u2014 Fastify server setup, middleware, error handling
  • Videos Module: backend/modules/media/videos.md \u2014 Video routes, service layer, business logic
  • FFprobe Service: backend/modules/media/ffprobe.md \u2014 Metadata extraction implementation
  • Jobs System: backend/modules/media/jobs.md \u2014 Job queue architecture, worker processes
"},{"location":"v2/features/media/video-library/#frontend-documentation","title":"Frontend Documentation","text":"
  • Library Page: frontend/pages/media/library.md \u2014 Video library management UI
  • Shared Media Page: frontend/pages/media/shared.md \u2014 Public gallery admin UI
  • Media Components: frontend/components/media.md \u2014 Reusable video components
"},{"location":"v2/features/media/video-library/#database-documentation","title":"Database Documentation","text":"
  • Media Models: database/models/media.md \u2014 Drizzle schema definitions for videos, compilations, jobs
  • Drizzle Setup: database/drizzle.md \u2014 Drizzle ORM configuration, connection management
"},{"location":"v2/features/media/video-library/#feature-documentation","title":"Feature Documentation","text":"
  • Video Upload: features/media/upload.md \u2014 Upload system workflow, FFprobe integration
  • Media Jobs: features/media/jobs.md \u2014 Job queue system, processing pipeline
  • Public Gallery: features/media/public-gallery.md \u2014 Public video sharing system
"},{"location":"v2/features/media/video-library/#integration-documentation","title":"Integration Documentation","text":"
  • Dual API Architecture: architecture/dual-api.md \u2014 Express+Prisma vs Fastify+Drizzle comparison
  • Nginx Routing: deployment/nginx.md \u2014 Reverse proxy configuration for media.cmlite.org
  • Docker Setup: deployment/docker.md \u2014 Media API container, volume mounts, healthchecks
"},{"location":"v2/features/media/video-library/#next-steps","title":"Next Steps","text":"

After mastering video library management:

  1. Upload System \u2014 Read features/media/upload.md to understand video upload workflow
  2. Jobs Queue \u2014 Review features/media/jobs.md for video processing automation
  3. Public Gallery \u2014 Explore features/media/public-gallery.md for sharing videos publicly
  4. Custom Integrations \u2014 Use Media API endpoints to build custom video features

For hands-on practice, try:

# 1. Upload test videos\ncurl -X POST http://localhost:4100/api/media/upload/single \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -F \"video=@test.mp4\" \\\n  -F \"producer=Test Studio\" \\\n  -F \"title=Test Video\"\n\n# 2. Scan directory\ncurl -X POST http://localhost:4100/api/media/videos/scan \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"directoryType\": \"videos\"}'\n\n# 3. List videos\ncurl http://localhost:4100/api/media/videos?page=1&limit=10\n\n# 4. Validate video\ncurl -X POST http://localhost:4100/api/media/videos/VIDEO_ID/validate \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n

Last Updated: 2026-02-13 Version: V2.0 Maintainer: Changemaker Lite Team

"},{"location":"v2/features/newsletter/","title":"Newsletter Integration (Listmonk)","text":"

The Newsletter Integration provides automated synchronization between Changemaker Lite and Listmonk newsletter platform. Campaign participants, volunteers, and locations can be automatically synced to Listmonk lists for targeted email campaigns.

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

The Listmonk integration provides:

  • Opt-in Sync - Controlled by LISTMONK_SYNC_ENABLED flag
  • Automatic Subscriber Creation - Campaign participants \u2192 subscribers
  • List Management - Campaigns \u2192 lists, Locations \u2192 lists
  • User Role Sync - User roles \u2192 list assignment
  • Bi-directional Updates - Keep data synchronized
  • Admin Interface - Manual sync controls and monitoring
"},{"location":"v2/features/newsletter/#features","title":"Features","text":""},{"location":"v2/features/newsletter/#subscriber-sync","title":"Subscriber Sync","text":"

Automatically sync users to Listmonk:

  • Campaign Participants - Email senders become subscribers
  • Shift Signups - Volunteers added to lists
  • Response Submitters - Response wall participants
  • Manual Users - User role-based list assignment
"},{"location":"v2/features/newsletter/#list-management","title":"List Management","text":"

Auto-create and manage lists:

  • Campaign Lists - One list per campaign
  • Location Lists - One list per geographic area
  • Role Lists - Lists for each user role
  • Custom Lists - Admin-defined lists
"},{"location":"v2/features/newsletter/#sync-triggers","title":"Sync Triggers","text":"

Automatic sync on:

  • Campaign email sent
  • Shift signup
  • Response submission
  • User registration
  • Manual admin trigger
"},{"location":"v2/features/newsletter/#admin-controls","title":"Admin Controls","text":"
  • View sync status
  • Manual sync buttons
  • Test connection
  • List statistics
  • Reinitialize lists
"},{"location":"v2/features/newsletter/#architecture","title":"Architecture","text":""},{"location":"v2/features/newsletter/#backend-components","title":"Backend Components","text":"

Listmonk Client: - api/src/services/listmonk.client.ts - Typed HTTP client (native fetch) - Basic auth integration - Full REST API coverage

Listmonk Sync Service: - api/src/services/listmonk-sync.service.ts - Sync orchestration - Participant \u2192 subscriber mapping - List creation and management - Error handling and logging

Admin Module: - api/src/modules/listmonk/listmonk.routes.ts - Admin endpoints - Status, stats, sync controls

Database: - No new tables (uses existing User, Campaign, Location) - Listmonk IDs stored in Prisma models (future)

"},{"location":"v2/features/newsletter/#frontend-components","title":"Frontend Components","text":"

Admin Page: - admin/src/pages/ListmonkPage.tsx - Newsletter management - Connection status display - Sync controls - List statistics table

"},{"location":"v2/features/newsletter/#configuration","title":"Configuration","text":""},{"location":"v2/features/newsletter/#environment-variables","title":"Environment Variables","text":"
# Enable Listmonk sync (opt-in)\nLISTMONK_SYNC_ENABLED=true\n\n# Listmonk connection\nLISTMONK_API_URL=http://listmonk:9000\nLISTMONK_API_USER=api_user\nLISTMONK_API_TOKEN=your_api_token\n\n# Web admin credentials (for setup)\nLISTMONK_WEB_ADMIN_USER=admin\nLISTMONK_WEB_ADMIN_PASSWORD=password\n
"},{"location":"v2/features/newsletter/#docker-setup","title":"Docker Setup","text":"

Listmonk runs as a service in docker-compose.yml:

listmonk:\n  image: listmonk/listmonk:latest\n  ports:\n    - \"9001:9000\"\n  depends_on:\n    - listmonk-db\n  environment:\n    LISTMONK_app__admin_username: ${LISTMONK_WEB_ADMIN_USER}\n    LISTMONK_app__admin_password: ${LISTMONK_WEB_ADMIN_PASSWORD}\n
"},{"location":"v2/features/newsletter/#initialization","title":"Initialization","text":"

Auto-create API user via listmonk-init container:

INSERT INTO users (email, name, password, type, status, created_at, updated_at)\nVALUES (\n  '${LISTMONK_API_USER}',\n  'API User',\n  '${LISTMONK_API_TOKEN}',  -- Plaintext (Listmonk API tokens)\n  'api',\n  'enabled',\n  NOW(),\n  NOW()\n);\n
"},{"location":"v2/features/newsletter/#sync-process","title":"Sync Process","text":""},{"location":"v2/features/newsletter/#campaign-participant-sync","title":"Campaign Participant Sync","text":"
  1. Email Sent - Campaign email sent via API
  2. Create Subscriber - POST /api/subscribers
  3. Email, name from user
  4. Status: enabled
  5. Get/Create List - GET/POST /api/lists
  6. List name: Campaign name
  7. Type: public or private
  8. Subscribe to List - PUT /api/subscribers/:id/lists
  9. Add subscriber to campaign list
"},{"location":"v2/features/newsletter/#location-sync","title":"Location Sync","text":"
  1. Location Created - New location added
  2. Get/Create List - List name: Location name/city
  3. Sync Users - All users in location \u2192 list
"},{"location":"v2/features/newsletter/#user-role-sync","title":"User Role Sync","text":"
  1. User Registration - New user account
  2. Get Role List - SUPER_ADMIN, INFLUENCE_ADMIN, etc.
  3. Subscribe User - Add to role-based list
"},{"location":"v2/features/newsletter/#api-integration","title":"API Integration","text":""},{"location":"v2/features/newsletter/#listmonk-client-usage","title":"Listmonk Client Usage","text":"
import { listmonkClient } from '../services/listmonk.client';\n\n// Create subscriber\nconst subscriber = await listmonkClient.createSubscriber({\n  email: 'user@example.com',\n  name: 'User Name',\n  status: 'enabled',\n  lists: [listId],\n});\n\n// Get/Create list\nlet list = await listmonkClient.getListByName('Campaign Name');\nif (!list) {\n  list = await listmonkClient.createList({\n    name: 'Campaign Name',\n    type: 'public',\n    optin: 'double',\n  });\n}\n\n// Subscribe to list\nawait listmonkClient.subscribeToList(subscriberId, [listId]);\n
"},{"location":"v2/features/newsletter/#sync-service-usage","title":"Sync Service Usage","text":"
import { listmonkSyncService } from '../services/listmonk-sync.service';\n\n// Sync campaign participant\nawait listmonkSyncService.syncCampaignParticipant(\n  campaign.id,\n  user.email,\n  user.name\n);\n\n// Sync all participants\nawait listmonkSyncService.syncAllParticipants(campaign.id);\n\n// Sync location members\nawait listmonkSyncService.syncLocationMembers(location.id);\n
"},{"location":"v2/features/newsletter/#admin-interface","title":"Admin Interface","text":""},{"location":"v2/features/newsletter/#connection-status","title":"Connection Status","text":"

Display: - Connected/disconnected status - Listmonk version - API endpoint - Last sync time

"},{"location":"v2/features/newsletter/#sync-controls","title":"Sync Controls","text":"

Buttons: - Sync All Participants - Sync all campaign participants - Sync All Locations - Sync all location members - Test Connection - Verify API access - Reinitialize - Reset lists and subscribers

"},{"location":"v2/features/newsletter/#list-statistics","title":"List Statistics","text":"

Table showing: - List name - Subscriber count - Campaign/location association - Last updated time

"},{"location":"v2/features/newsletter/#security","title":"Security","text":""},{"location":"v2/features/newsletter/#api-authentication","title":"API Authentication","text":"

Listmonk v6+ requires auth on all endpoints:

const headers = {\n  'Authorization': `Basic ${btoa(`${apiUser}:${apiToken}`)}`,\n  'Content-Type': 'application/json',\n};\n
"},{"location":"v2/features/newsletter/#token-storage","title":"Token Storage","text":"

API tokens stored as plaintext in Listmonk DB: - Not bcrypt hashed - Direct upsert possible - Secure via Redis authentication

"},{"location":"v2/features/newsletter/#data-privacy","title":"Data Privacy","text":"
  • Opt-in sync only
  • User consent required (future)
  • Unsubscribe support
  • Data deletion on request
"},{"location":"v2/features/newsletter/#error-handling","title":"Error Handling","text":""},{"location":"v2/features/newsletter/#sync-failures","title":"Sync Failures","text":"

Handled gracefully: - Network errors logged - Failed syncs retried - Admin notifications - Error statistics

"},{"location":"v2/features/newsletter/#rate-limiting","title":"Rate Limiting","text":"

Respect Listmonk limits: - Batch operations - Delay between requests - Queue large syncs

"},{"location":"v2/features/newsletter/#listmonk-features","title":"Listmonk Features","text":""},{"location":"v2/features/newsletter/#campaign-management","title":"Campaign Management","text":"

Listmonk provides: - Email campaign creation - Template management - Scheduling - A/B testing - Analytics

"},{"location":"v2/features/newsletter/#subscriber-management","title":"Subscriber Management","text":"
  • Import/export subscribers
  • List segmentation
  • Tags and attributes
  • Bounce handling
  • Unsubscribe management
"},{"location":"v2/features/newsletter/#analytics","title":"Analytics","text":"
  • Open rates
  • Click rates
  • Bounce rates
  • Unsubscribe rates
  • Campaign reports
"},{"location":"v2/features/newsletter/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/newsletter/#admin-endpoints","title":"Admin Endpoints","text":"
GET    /api/listmonk/status            # Connection status\nGET    /api/listmonk/stats             # Sync statistics\nPOST   /api/listmonk/sync-participants # Sync campaign participants\nPOST   /api/listmonk/sync-locations    # Sync location members\nPOST   /api/listmonk/test-connection   # Test API connection\nPOST   /api/listmonk/reinitialize      # Reset and reinitialize\n
"},{"location":"v2/features/newsletter/#limitations","title":"Limitations","text":""},{"location":"v2/features/newsletter/#current-limitations","title":"Current Limitations","text":"
  • Listmonk v6+ only (auth required on all endpoints)
  • No webhook support (future)
  • Manual sync triggers
  • No bi-directional sync (Listmonk \u2192 CM Lite)
"},{"location":"v2/features/newsletter/#future-enhancements","title":"Future Enhancements","text":"
  • Webhook integration
  • Real-time sync
  • Custom field mapping
  • Advanced segmentation
  • Campaign stats in CM Lite
"},{"location":"v2/features/newsletter/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/newsletter/#connection-issues","title":"Connection Issues","text":"
  1. Check LISTMONK_SYNC_ENABLED=true
  2. Verify LISTMONK_API_URL reachable
  3. Confirm API user created
  4. Test credentials with curl
"},{"location":"v2/features/newsletter/#sync-failures_1","title":"Sync Failures","text":"
  1. Check logs for errors
  2. Verify Listmonk database
  3. Test API connection
  4. Reinitialize if needed
"},{"location":"v2/features/newsletter/#related-documentation","title":"Related Documentation","text":"
  • Listmonk Page
  • Listmonk Client
  • Campaign Module
  • Environment Variables
  • Docker Compose
"},{"location":"v2/features/observability/","title":"Observability & Monitoring","text":"

The Observability feature provides comprehensive monitoring, metrics collection, and alerting for the Changemaker Lite platform. Built on the Prometheus ecosystem with Grafana dashboards and Alertmanager integration.

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

The Observability stack consists of:

  1. Prometheus - Metrics collection and storage
  2. Grafana - Visualization dashboards
  3. Alertmanager - Alert routing and notifications
  4. Custom Metrics - 12 domain-specific cm_* metrics
  5. HTTP Metrics - Request tracking and performance
  6. Service Health - External service monitoring
"},{"location":"v2/features/observability/#features","title":"Features","text":""},{"location":"v2/features/observability/#metrics-collection","title":"Metrics Collection","text":"

Custom Domain Metrics (12 total):

Counters: - cm_api_uptime_seconds - API uptime counter - cm_canvass_visits_total - Total canvass visits - cm_campaign_emails_sent_total - Total campaign emails sent - cm_geocode_requests_total - Total geocode requests

Gauges: - cm_canvass_sessions_active - Active canvass sessions - cm_email_queue_size - Email queue depth - cm_geocode_queue_size - Geocode queue depth - cm_external_service_health - Service health (0/1)

Histograms: - cm_geocode_duration_seconds - Geocoding latency - http_request_duration_ms - HTTP request duration

HTTP Metrics: - Request count by method/route/status - Request duration percentiles (p50, p95, p99) - Active requests gauge - Error rate tracking

"},{"location":"v2/features/observability/#grafana-dashboards","title":"Grafana Dashboards","text":"

Three pre-configured dashboards:

  1. Changemaker Lite Overview - System-wide metrics
  2. API uptime and request rates
  3. Queue sizes and health
  4. Active sessions
  5. Error rates

  6. Canvassing Metrics - Canvass-specific metrics

  7. Active sessions over time
  8. Visits by outcome
  9. Session duration
  10. Volunteer leaderboard

  11. External Services - Integration health

  12. Redis health
  13. PostgreSQL health
  14. Listmonk status
  15. Geocoding providers
"},{"location":"v2/features/observability/#alert-rules","title":"Alert Rules","text":"

12 predefined alert rules:

Critical Alerts: - API down (>5 min) - Database unreachable - Redis connection lost

Warning Alerts: - High error rate (>5%) - Queue backup (>1000 jobs) - Slow requests (p95 >2s) - Service degradation

Info Alerts: - New deployment - Service restart - Configuration change

"},{"location":"v2/features/observability/#admin-interface","title":"Admin Interface","text":"

Observability page (/app/observability) with:

  • Metrics Tab - Live metrics display
  • Dashboards Tab - Embedded Grafana
  • Alerts Tab - Active alerts and rules
"},{"location":"v2/features/observability/#architecture","title":"Architecture","text":""},{"location":"v2/features/observability/#backend-components","title":"Backend Components","text":"

Metrics Module: - api/src/utils/metrics.ts - Prometheus metrics definitions - api/src/modules/observability/observability.routes.ts - Admin API

Instrumentation: - Express middleware for HTTP metrics - Service-level metric updates - Queue size tracking - External service health checks

Configuration: - configs/prometheus/prometheus.yml - Scrape config - configs/prometheus/alerts.yml - Alert rules - configs/grafana/dashboards/ - Dashboard JSON

"},{"location":"v2/features/observability/#frontend-components","title":"Frontend Components","text":"

Admin Page: - admin/src/pages/ObservabilityPage.tsx - Monitoring dashboard - Three tabs: Metrics, Dashboards, Alerts - Embedded Grafana iframes - Live metric cards

Observability Components: - admin/src/components/observability/MetricsChart.tsx - Chart component - admin/src/components/observability/ServiceHealthCard.tsx - Health display

"},{"location":"v2/features/observability/#docker-services","title":"Docker Services","text":"

Monitoring Profile:

Services run with --profile monitoring:

profiles: [monitoring]\n  prometheus:\n    image: prom/prometheus:latest\n    ports: [\"9090:9090\"]\n\n  grafana:\n    image: grafana/grafana:latest\n    ports: [\"3001:3000\"]\n\n  alertmanager:\n    image: prom/alertmanager:latest\n    ports: [\"9093:9093\"]\n\n  cadvisor:\n    image: gcr.io/cadvisor/cadvisor:latest\n    ports: [\"8080:8080\"]\n\n  node-exporter:\n    image: prom/node-exporter:latest\n    ports: [\"9100:9100\"]\n\n  redis-exporter:\n    image: oliver006/redis_exporter:latest\n    ports: [\"9121:9121\"]\n
"},{"location":"v2/features/observability/#configuration","title":"Configuration","text":""},{"location":"v2/features/observability/#environment-variables","title":"Environment Variables","text":"
# Enable metrics\nMETRICS_ENABLED=true\n\n# Prometheus\nPROMETHEUS_PORT=9090\n\n# Grafana\nGRAFANA_PORT=3001\nGRAFANA_ADMIN_USER=admin\nGRAFANA_ADMIN_PASSWORD=admin\n\n# Alertmanager\nALERTMANAGER_PORT=9093\n
"},{"location":"v2/features/observability/#prometheus-scrape-targets","title":"Prometheus Scrape Targets","text":"
scrape_configs:\n  - job_name: 'changemaker-api'\n    static_configs:\n      - targets: ['api:4000']\n\n  - job_name: 'media-api'\n    static_configs:\n      - targets: ['media-api:4100']\n\n  - job_name: 'redis'\n    static_configs:\n      - targets: ['redis-exporter:9121']\n\n  - job_name: 'node'\n    static_configs:\n      - targets: ['node-exporter:9100']\n\n  - job_name: 'cadvisor'\n    static_configs:\n      - targets: ['cadvisor:8080']\n
"},{"location":"v2/features/observability/#alert-rules_1","title":"Alert Rules","text":"

Example alert rule:

groups:\n  - name: api_alerts\n    rules:\n      - alert: APIDown\n        expr: up{job=\"changemaker-api\"} == 0\n        for: 5m\n        labels:\n          severity: critical\n        annotations:\n          summary: \"API is down\"\n          description: \"API has been down for 5 minutes\"\n\n      - alert: HighErrorRate\n        expr: rate(http_request_duration_ms_count{status=~\"5..\"}[5m]) > 0.05\n        for: 10m\n        labels:\n          severity: warning\n        annotations:\n          summary: \"High error rate detected\"\n
"},{"location":"v2/features/observability/#metrics-usage","title":"Metrics Usage","text":""},{"location":"v2/features/observability/#increment-counter","title":"Increment Counter","text":"
import { metrics } from '../utils/metrics';\n\n// Campaign email sent\nmetrics.campaignEmailsSent.inc();\n\n// Geocode request\nmetrics.geocodeRequests.inc({ provider: 'nominatim' });\n
"},{"location":"v2/features/observability/#set-gauge","title":"Set Gauge","text":"
// Update queue size\nmetrics.emailQueueSize.set(queueSize);\n\n// Update active sessions\nmetrics.canvassSessionsActive.set(activeSessions);\n\n// Set service health (1 = healthy, 0 = unhealthy)\nmetrics.externalServiceHealth.set({ service: 'redis' }, 1);\n
"},{"location":"v2/features/observability/#observe-histogram","title":"Observe Histogram","text":"
// Time geocoding request\nconst end = metrics.geocodeDuration.startTimer();\ntry {\n  await geocode(address);\n  end({ success: 'true' });\n} catch (error) {\n  end({ success: 'false' });\n}\n
"},{"location":"v2/features/observability/#grafana-dashboards_1","title":"Grafana Dashboards","text":""},{"location":"v2/features/observability/#dashboard-setup","title":"Dashboard Setup","text":"

Dashboards auto-provisioned from configs/grafana/dashboards/:

{\n  \"dashboard\": {\n    \"title\": \"Changemaker Lite Overview\",\n    \"panels\": [\n      {\n        \"title\": \"API Request Rate\",\n        \"targets\": [\n          {\n            \"expr\": \"rate(http_request_duration_ms_count[5m])\"\n          }\n        ]\n      }\n    ]\n  }\n}\n
"},{"location":"v2/features/observability/#accessing-dashboards","title":"Accessing Dashboards","text":"
  • Direct: http://localhost:3001 (admin/admin)
  • Embedded: /app/observability \u2192 Dashboards tab
  • Subdomain: http://grafana.cmlite.org (production)
"},{"location":"v2/features/observability/#alertmanager","title":"Alertmanager","text":""},{"location":"v2/features/observability/#alert-routing","title":"Alert Routing","text":"

Configure in configs/alertmanager/alertmanager.yml:

route:\n  receiver: 'default'\n  group_by: ['alertname', 'severity']\n  routes:\n    - match:\n        severity: critical\n      receiver: 'critical-alerts'\n\nreceivers:\n  - name: 'default'\n    webhook_configs:\n      - url: 'http://gotify:8889/message'\n\n  - name: 'critical-alerts'\n    email_configs:\n      - to: 'admin@example.com'\n
"},{"location":"v2/features/observability/#notification-channels","title":"Notification Channels","text":"

Supported receivers:

  • Webhook - Gotify, Slack, Discord
  • Email - SMTP notifications
  • PagerDuty - Incident management
  • Opsgenie - Alert management
"},{"location":"v2/features/observability/#service-health-monitoring","title":"Service Health Monitoring","text":""},{"location":"v2/features/observability/#external-service-checks","title":"External Service Checks","text":"

Monitor services via health gauges:

// Check Redis\ntry {\n  await redisClient.ping();\n  metrics.externalServiceHealth.set({ service: 'redis' }, 1);\n} catch (error) {\n  metrics.externalServiceHealth.set({ service: 'redis' }, 0);\n}\n\n// Check PostgreSQL\ntry {\n  await prisma.$queryRaw`SELECT 1`;\n  metrics.externalServiceHealth.set({ service: 'postgres' }, 1);\n} catch (error) {\n  metrics.externalServiceHealth.set({ service: 'postgres' }, 0);\n}\n
"},{"location":"v2/features/observability/#docker-healthchecks","title":"Docker Healthchecks","text":"

Services with healthchecks:

  • API - wget --spider http://localhost:4000/health
  • Media API - wget --spider http://localhost:4100/health
  • PostgreSQL - pg_isready
  • Redis - redis-cli ping
  • Listmonk - wget --spider http://localhost:9000/health
"},{"location":"v2/features/observability/#performance-monitoring","title":"Performance Monitoring","text":""},{"location":"v2/features/observability/#http-request-tracking","title":"HTTP Request Tracking","text":"

Automatic tracking of:

  • Request count by route
  • Request duration percentiles
  • Status code distribution
  • Error rates
"},{"location":"v2/features/observability/#queue-monitoring","title":"Queue Monitoring","text":"

Track queue depths:

  • Email queue size
  • Geocode queue size
  • Failed job count
  • Processing rate
"},{"location":"v2/features/observability/#resource-monitoring","title":"Resource Monitoring","text":"

Via cAdvisor and Node Exporter:

  • CPU usage
  • Memory usage
  • Disk I/O
  • Network traffic
"},{"location":"v2/features/observability/#admin-interface_1","title":"Admin Interface","text":""},{"location":"v2/features/observability/#metrics-tab","title":"Metrics Tab","text":"

Display cards:

  • API uptime
  • Request rate (req/sec)
  • Error rate (%)
  • Queue sizes
  • Active sessions
  • Service health
"},{"location":"v2/features/observability/#dashboards-tab","title":"Dashboards Tab","text":"

Embedded Grafana:

  • Overview dashboard
  • Canvassing metrics
  • External services
  • Custom queries
"},{"location":"v2/features/observability/#alerts-tab","title":"Alerts Tab","text":"

Active alerts list:

  • Alert name
  • Severity
  • Status (firing/pending/resolved)
  • Duration
  • Quick actions (silence, resolve)
"},{"location":"v2/features/observability/#starting-monitoring-stack","title":"Starting Monitoring Stack","text":"
# Start with monitoring profile\ndocker compose --profile monitoring up -d\n\n# Access services\n# Prometheus: http://localhost:9090\n# Grafana: http://localhost:3001 (admin/admin)\n# Alertmanager: http://localhost:9093\n
"},{"location":"v2/features/observability/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/observability/#observability-endpoints","title":"Observability Endpoints","text":"
GET    /api/observability/prometheus   # Prometheus status\nGET    /api/observability/grafana      # Grafana status\nGET    /api/observability/alertmanager # Alertmanager status\nGET    /api/observability/metrics      # Current metrics values\n
"},{"location":"v2/features/observability/#metrics-endpoint","title":"Metrics Endpoint","text":"
GET    /metrics                         # Prometheus scrape endpoint\n
"},{"location":"v2/features/observability/#related-documentation","title":"Related Documentation","text":"
  • Observability Page
  • Metrics Utilities
  • Docker Compose
  • Monitoring Stack
  • Healthchecks
  • Performance Optimization
"},{"location":"v2/features/pages/block-library/","title":"Block Library","text":"

Reusable page component system with JSON schema definitions, default values, and campaign-specific customization.

"},{"location":"v2/features/pages/block-library/#overview","title":"Overview","text":"

The Block Library provides a database-driven system for managing reusable page components (blocks) in the GrapesJS editor. Administrators can use pre-configured blocks or create custom ones tailored to their campaign needs.

"},{"location":"v2/features/pages/block-library/#key-features","title":"Key Features","text":"
  • Database-Driven: Blocks stored in PostgreSQL (PageBlock model)
  • JSON Schema: Define configurable properties for each block type
  • Default Values: Pre-populate blocks with campaign-specific content
  • Category Organization: Group blocks (Headers, Content, Actions, etc.)
  • Sort Order: Control block position in editor panel
  • 6 Default Blocks: Hero, Text, Features, CTA, Testimonials, Contact Form
  • Custom Blocks: Create campaign-specific blocks via admin API
"},{"location":"v2/features/pages/block-library/#architecture","title":"Architecture","text":"
graph LR\n    A[(PageBlock Table)] -->|GET /api/page-blocks| B[API Service]\n    B --> C[LandingPageEditor]\n    C --> D[GrapesJSEditor]\n    D --> E[BlockManager]\n    E --> F[Left Panel]\n\n    G[Admin] -->|POST /api/page-blocks| B\n    G -->|Define Schema| H[JSON Schema]\n    G -->|Set Defaults| I[Default Values]\n    H --> A\n    I --> A\n\n    style A fill:#3498db\n    style E fill:#9d4edd\n    style F fill:#2ecc71

Flow:

  1. Seed: Default blocks created in api/prisma/seed.ts
  2. Fetch: Editor loads all blocks via GET /api/page-blocks
  3. Register: GrapesJSEditor registers each block with BlockManager
  4. Render: Blocks appear in left panel (grouped by category)
  5. Customize: Admin creates custom blocks via API (future enhancement)
"},{"location":"v2/features/pages/block-library/#database-model","title":"Database Model","text":""},{"location":"v2/features/pages/block-library/#pageblock-table","title":"PageBlock Table","text":"

Schema:

model PageBlock {\n  id         String   @id @default(uuid())\n  type       String   @unique // Block type identifier (e.g., 'hero', 'text')\n  label      String   // Display name in editor (\"Hero Section\")\n  category   String?  // Group blocks (\"Headers\", \"Content\", \"Actions\")\n  sortOrder  Int      @default(0) // Position in left panel\n  schema     Json     // JSON schema for configurable properties\n  defaults   Json     // Default values for schema fields\n  thumbnail  String?  // Preview image URL (future enhancement)\n  createdAt  DateTime @default(now())\n  updatedAt  DateTime @updatedAt\n\n  @@index([category, sortOrder])\n}\n

Fields:

Field Type Description id String (UUID) Primary key type String Unique identifier (e.g., \"hero\", \"features\") label String Human-readable name shown in editor category String? Group blocks in collapsible sections sortOrder Int Order within category (lower = higher in list) schema JSON Property definitions (field name, type, label) defaults JSON Default values for each schema field thumbnail String? Preview image URL (not implemented)

Indexes:

  • type (unique)
  • category + sortOrder (composite, for sorted listing)
"},{"location":"v2/features/pages/block-library/#default-blocks","title":"Default Blocks","text":""},{"location":"v2/features/pages/block-library/#1-hero-section","title":"1. Hero Section","text":"

Type: hero

Category: Headers

Schema:

{\n  \"title\": { \"type\": \"string\", \"label\": \"Title\" },\n  \"subtitle\": { \"type\": \"string\", \"label\": \"Subtitle\" },\n  \"backgroundImage\": { \"type\": \"string\", \"label\": \"Background Image URL\" },\n  \"ctaText\": { \"type\": \"string\", \"label\": \"Button Text\" },\n  \"ctaUrl\": { \"type\": \"string\", \"label\": \"Button URL\" }\n}\n

Defaults:

{\n  \"title\": \"Welcome to Our Campaign\",\n  \"subtitle\": \"Join us in making a difference in your community.\",\n  \"backgroundImage\": \"\",\n  \"ctaText\": \"Get Involved\",\n  \"ctaUrl\": \"#\"\n}\n

Rendered HTML:

<section style=\"padding: 80px 40px; text-align: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #fff;\">\n  <h1 style=\"font-size: 2.5rem; margin-bottom: 16px;\">Welcome to Our Campaign</h1>\n  <p style=\"font-size: 1.25rem; opacity: 0.85; margin-bottom: 32px;\">Join us in making a difference in your community.</p>\n  <a href=\"#\" style=\"display: inline-block; padding: 12px 32px; background: #9d4edd; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600;\">Get Involved</a>\n</section>\n
"},{"location":"v2/features/pages/block-library/#2-text-block","title":"2. Text Block","text":"

Type: text

Category: Content

Schema:

{\n  \"heading\": { \"type\": \"string\", \"label\": \"Heading\" },\n  \"body\": { \"type\": \"text\", \"label\": \"Body Text\" }\n}\n

Defaults:

{\n  \"heading\": \"About Us\",\n  \"body\": \"Tell your story here. Explain your mission, values, and what drives your campaign forward.\"\n}\n

Rendered HTML:

<section style=\"padding: 60px 40px; max-width: 800px; margin: 0 auto;\">\n  <h2 style=\"font-size: 1.75rem; margin-bottom: 16px;\">About Us</h2>\n  <p style=\"font-size: 1rem; line-height: 1.7; opacity: 0.85;\">Tell your story here. Explain your mission, values, and what drives your campaign forward.</p>\n</section>\n
"},{"location":"v2/features/pages/block-library/#3-features-grid","title":"3. Features Grid","text":"

Type: features

Category: Content

Schema:

{\n  \"features\": {\n    \"type\": \"array\",\n    \"label\": \"Features\",\n    \"items\": {\n      \"title\": \"string\",\n      \"description\": \"string\",\n      \"icon\": \"string\"\n    }\n  }\n}\n

Defaults:

{\n  \"features\": [\n    { \"title\": \"Community Action\", \"description\": \"Organize local events and initiatives.\", \"icon\": \"\" },\n    { \"title\": \"Advocacy\", \"description\": \"Email your representatives directly.\", \"icon\": \"\" },\n    { \"title\": \"Volunteer\", \"description\": \"Sign up for shifts and make a difference.\", \"icon\": \"\" }\n  ]\n}\n

Rendered HTML:

<section style=\"padding: 60px 40px;\">\n  <div style=\"display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;\">\n    <div style=\"flex: 1; min-width: 250px; padding: 24px; text-align: center;\">\n      <h3 style=\"font-size: 1.25rem; margin-bottom: 8px;\">Community Action</h3>\n      <p style=\"opacity: 0.8;\">Organize local events and initiatives.</p>\n    </div>\n    <div style=\"flex: 1; min-width: 250px; padding: 24px; text-align: center;\">\n      <h3 style=\"font-size: 1.25rem; margin-bottom: 8px;\">Advocacy</h3>\n      <p style=\"opacity: 0.8;\">Email your representatives directly.</p>\n    </div>\n    <div style=\"flex: 1; min-width: 250px; padding: 24px; text-align: center;\">\n      <h3 style=\"font-size: 1.25rem; margin-bottom: 8px;\">Volunteer</h3>\n      <p style=\"opacity: 0.8;\">Sign up for shifts and make a difference.</p>\n    </div>\n  </div>\n</section>\n
"},{"location":"v2/features/pages/block-library/#4-call-to-action","title":"4. Call to Action","text":"

Type: cta

Category: Actions

Schema:

{\n  \"heading\": { \"type\": \"string\", \"label\": \"Heading\" },\n  \"description\": { \"type\": \"string\", \"label\": \"Description\" },\n  \"buttonText\": { \"type\": \"string\", \"label\": \"Button Text\" },\n  \"buttonUrl\": { \"type\": \"string\", \"label\": \"Button URL\" }\n}\n

Defaults:

{\n  \"heading\": \"Ready to Take Action?\",\n  \"description\": \"Join thousands of community members making their voices heard.\",\n  \"buttonText\": \"Join Now\",\n  \"buttonUrl\": \"#\"\n}\n

Rendered HTML:

<section style=\"padding: 60px 40px; text-align: center; background: linear-gradient(135deg, #9d4edd 0%, #7b2cbf 100%); color: #fff;\">\n  <h2 style=\"font-size: 2rem; margin-bottom: 12px;\">Ready to Take Action?</h2>\n  <p style=\"font-size: 1.1rem; margin-bottom: 24px; opacity: 0.9;\">Join thousands of community members making their voices heard.</p>\n  <a href=\"#\" style=\"display: inline-block; padding: 12px 32px; background: #fff; color: #9d4edd; text-decoration: none; border-radius: 6px; font-weight: 600;\">Join Now</a>\n</section>\n
"},{"location":"v2/features/pages/block-library/#5-testimonials","title":"5. Testimonials","text":"

Type: testimonials

Category: Content

Schema:

{\n  \"quotes\": {\n    \"type\": \"array\",\n    \"label\": \"Quotes\",\n    \"items\": {\n      \"text\": \"string\",\n      \"author\": \"string\",\n      \"role\": \"string\"\n    }\n  }\n}\n

Defaults:

{\n  \"quotes\": [\n    { \"text\": \"This platform made it so easy to contact my representatives.\", \"author\": \"Jane D.\", \"role\": \"Community Member\" },\n    { \"text\": \"I signed up for a volunteer shift and it changed my perspective.\", \"author\": \"Mark S.\", \"role\": \"Volunteer\" }\n  ]\n}\n

Rendered HTML:

<section style=\"padding: 60px 40px;\">\n  <div style=\"display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;\">\n    <div style=\"flex: 1; min-width: 280px; padding: 24px; background: rgba(255,255,255,0.05); border-radius: 8px;\">\n      <p style=\"font-style: italic; margin-bottom: 12px;\">\"This platform made it so easy to contact my representatives.\"</p>\n      <p style=\"font-weight: 600; margin-bottom: 2px;\">Jane D.</p>\n      <p style=\"font-size: 0.85rem; opacity: 0.7;\">Community Member</p>\n    </div>\n    <div style=\"flex: 1; min-width: 280px; padding: 24px; background: rgba(255,255,255,0.05); border-radius: 8px;\">\n      <p style=\"font-style: italic; margin-bottom: 12px;\">\"I signed up for a volunteer shift and it changed my perspective.\"</p>\n      <p style=\"font-weight: 600; margin-bottom: 2px;\">Mark S.</p>\n      <p style=\"font-size: 0.85rem; opacity: 0.7;\">Volunteer</p>\n    </div>\n  </div>\n</section>\n
"},{"location":"v2/features/pages/block-library/#6-contact-form","title":"6. Contact Form","text":"

Type: contact-form

Category: Actions

Schema:

{\n  \"heading\": { \"type\": \"string\", \"label\": \"Heading\" },\n  \"fields\": {\n    \"type\": \"array\",\n    \"label\": \"Fields\",\n    \"items\": {\n      \"name\": \"string\",\n      \"type\": \"string\",\n      \"required\": \"boolean\"\n    }\n  }\n}\n

Defaults:

{\n  \"heading\": \"Get in Touch\",\n  \"fields\": [\n    { \"name\": \"name\", \"type\": \"text\", \"required\": true },\n    { \"name\": \"email\", \"type\": \"email\", \"required\": true },\n    { \"name\": \"message\", \"type\": \"textarea\", \"required\": true }\n  ]\n}\n

Rendered HTML:

<section style=\"padding: 60px 40px; max-width: 600px; margin: 0 auto;\">\n  <h2 style=\"text-align: center; margin-bottom: 24px;\">Get in Touch</h2>\n  <form style=\"display: flex; flex-direction: column; gap: 16px;\">\n    <input type=\"text\" placeholder=\"Name\" style=\"padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit;\" />\n    <input type=\"email\" placeholder=\"Email\" style=\"padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit;\" />\n    <textarea placeholder=\"Message\" rows=\"4\" style=\"padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit; resize: vertical;\"></textarea>\n    <button type=\"submit\" style=\"padding: 12px 24px; background: #9d4edd; color: #fff; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;\">Send Message</button>\n  </form>\n</section>\n

Note: Form submission not wired (static HTML). Use grapesjs-plugin-forms for backend integration.

"},{"location":"v2/features/pages/block-library/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/pages/block-library/#admin-routes","title":"Admin Routes","text":"

Prefix: /api/page-blocks

Authentication: Requires admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN)

"},{"location":"v2/features/pages/block-library/#list-blocks","title":"List Blocks","text":"
GET /api/page-blocks?category=Headers\n

Query Parameters:

  • category (string?) \u2014 Filter by category

Response:

[\n  {\n    \"id\": \"default-hero\",\n    \"type\": \"hero\",\n    \"label\": \"Hero Section\",\n    \"category\": \"Headers\",\n    \"sortOrder\": 1,\n    \"schema\": {\n      \"title\": { \"type\": \"string\", \"label\": \"Title\" },\n      \"subtitle\": { \"type\": \"string\", \"label\": \"Subtitle\" }\n    },\n    \"defaults\": {\n      \"title\": \"Welcome to Our Campaign\",\n      \"subtitle\": \"Join us in making a difference.\"\n    },\n    \"thumbnail\": null,\n    \"createdAt\": \"2026-01-10T00:00:00Z\",\n    \"updatedAt\": \"2026-01-10T00:00:00Z\"\n  }\n]\n

Sorting:

  • Results ordered by category ASC, sortOrder ASC
  • Blocks in same category appear in sortOrder sequence
"},{"location":"v2/features/pages/block-library/#get-block","title":"Get Block","text":"
GET /api/page-blocks/:id\n

Response: Single PageBlock object

Errors:

  • 404 BLOCK_NOT_FOUND \u2014 Block doesn't exist
"},{"location":"v2/features/pages/block-library/#create-block","title":"Create Block","text":"
POST /api/page-blocks\nContent-Type: application/json\n\n{\n  \"type\": \"campaign-stats\",\n  \"label\": \"Campaign Stats\",\n  \"category\": \"Campaign\",\n  \"sortOrder\": 10,\n  \"schema\": {\n    \"volunteers\": { \"type\": \"number\", \"label\": \"Volunteers\" },\n    \"emails\": { \"type\": \"number\", \"label\": \"Emails Sent\" }\n  },\n  \"defaults\": {\n    \"volunteers\": 1250,\n    \"emails\": 5400\n  }\n}\n

Request Body:

  • type (string, required) \u2014 Unique type identifier (alphanumeric + hyphens)
  • label (string, required) \u2014 Display name
  • category (string?) \u2014 Group name (default: null)
  • sortOrder (number?, default: 0) \u2014 Position in list
  • schema (JSON, required) \u2014 Property definitions
  • defaults (JSON, required) \u2014 Default values matching schema
  • thumbnail (string?) \u2014 Preview image URL

Response: Created PageBlock object (201 status)

Errors:

  • 400 VALIDATION_ERROR \u2014 Invalid schema or type collision
"},{"location":"v2/features/pages/block-library/#update-block","title":"Update Block","text":"
PUT /api/page-blocks/:id\nContent-Type: application/json\n\n{\n  \"label\": \"Updated Label\",\n  \"defaults\": {\n    \"volunteers\": 2000\n  }\n}\n

Request Body: (all fields optional except constraints)

  • type (string?) \u2014 Cannot change after creation (immutable)
  • label (string?)
  • category (string?)
  • sortOrder (number?)
  • schema (JSON?)
  • defaults (JSON?)

Response: Updated PageBlock object

Errors:

  • 404 BLOCK_NOT_FOUND \u2014 Block doesn't exist
  • 400 VALIDATION_ERROR \u2014 Invalid schema or defaults
"},{"location":"v2/features/pages/block-library/#delete-block","title":"Delete Block","text":"
DELETE /api/page-blocks/:id\n

Response: 204 No Content

Errors:

  • 404 BLOCK_NOT_FOUND \u2014 Block doesn't exist

Side Effects:

  • Pages using this block will still render (HTML is cached)
  • Block removed from editor panel for new pages
"},{"location":"v2/features/pages/block-library/#schema-format","title":"Schema Format","text":""},{"location":"v2/features/pages/block-library/#property-types","title":"Property Types","text":"

Supported Types:

Type Description Example string Short text field Title, subtitle, URL text Multi-line text Body paragraph number Numeric value Volunteer count, price boolean True/false toggle Show/hide element array List of items Features, testimonials"},{"location":"v2/features/pages/block-library/#simple-property","title":"Simple Property","text":"
{\n  \"title\": {\n    \"type\": \"string\",\n    \"label\": \"Title\"\n  }\n}\n

Rendered in GrapesJS: Text input labeled \"Title\"

"},{"location":"v2/features/pages/block-library/#array-property","title":"Array Property","text":"
{\n  \"features\": {\n    \"type\": \"array\",\n    \"label\": \"Features\",\n    \"items\": {\n      \"title\": \"string\",\n      \"description\": \"string\",\n      \"icon\": \"string\"\n    }\n  }\n}\n

Rendered in GrapesJS:

  • Repeatable item group
  • Add/remove buttons
  • Each item has 3 fields (title, description, icon)
"},{"location":"v2/features/pages/block-library/#defaults-matching","title":"Defaults Matching","text":"

Schema:

{\n  \"heading\": { \"type\": \"string\", \"label\": \"Heading\" },\n  \"count\": { \"type\": \"number\", \"label\": \"Count\" }\n}\n

Valid Defaults:

{\n  \"heading\": \"Our Impact\",\n  \"count\": 42\n}\n

Invalid Defaults:

{\n  \"heading\": 123,  // Type mismatch (should be string)\n  \"count\": \"foo\"   // Type mismatch (should be number)\n}\n
"},{"location":"v2/features/pages/block-library/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/pages/block-library/#using-default-blocks","title":"Using Default Blocks","text":"
  1. Open Editor: Admin \u2192 Pages \u2192 Click \"Edit\" on any page
  2. Locate Block: Left panel \u2192 Expand \"Headers\" category
  3. Drag Block: Drag \"Hero Section\" to canvas
  4. Configure: Click block \u2192 Right panel shows properties
  5. Title: \"Join the Movement\"
  6. Subtitle: \"Together we can make a difference.\"
  7. CTA Text: \"Sign Up\"
  8. CTA URL: \"/shifts\"
  9. Save: Press Ctrl+S \u2192 Block HTML stored in database
"},{"location":"v2/features/pages/block-library/#creating-custom-blocks","title":"Creating Custom Blocks","text":"

Note: Custom block creation UI not implemented. Use API directly.

Example: Campaign Stats Block

curl -X POST http://localhost:4000/api/page-blocks \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"type\": \"campaign-stats\",\n    \"label\": \"Campaign Stats\",\n    \"category\": \"Campaign\",\n    \"sortOrder\": 10,\n    \"schema\": {\n      \"volunteers\": { \"type\": \"number\", \"label\": \"Volunteers\" },\n      \"emails\": { \"type\": \"number\", \"label\": \"Emails Sent\" },\n      \"events\": { \"type\": \"number\", \"label\": \"Events\" }\n    },\n    \"defaults\": {\n      \"volunteers\": 1250,\n      \"emails\": 5400,\n      \"events\": 32\n    }\n  }'\n

Result:

  • New block appears in left panel under \"Campaign\" category
  • Dragging block inserts HTML (requires generateBlockHtml update)
"},{"location":"v2/features/pages/block-library/#updating-block-defaults","title":"Updating Block Defaults","text":"

Use Case: Update hero CTA text for all new pages

curl -X PUT http://localhost:4000/api/page-blocks/default-hero \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"defaults\": {\n      \"title\": \"Welcome to Our 2026 Campaign\",\n      \"subtitle\": \"Join us in making a difference.\",\n      \"ctaText\": \"Get Started Today\",\n      \"ctaUrl\": \"/shifts\"\n    }\n  }'\n

Effect:

  • New pages using hero block get updated defaults
  • Existing pages unchanged (HTML already rendered)
"},{"location":"v2/features/pages/block-library/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/pages/block-library/#fetching-blocks-for-editor","title":"Fetching Blocks for Editor","text":"
import { api } from '@/lib/api';\nimport type { PageBlock } from '@/types/api';\n\nasync function loadBlocks(): Promise<PageBlock[]> {\n  const { data } = await api.get<PageBlock[]>('/page-blocks');\n  return data.sort((a, b) => {\n    // Sort by category, then sortOrder\n    const catCompare = (a.category || '').localeCompare(b.category || '');\n    return catCompare !== 0 ? catCompare : a.sortOrder - b.sortOrder;\n  });\n}\n
"},{"location":"v2/features/pages/block-library/#creating-custom-block","title":"Creating Custom Block","text":"
async function createCampaignStatsBlock() {\n  const { data } = await api.post<PageBlock>('/page-blocks', {\n    type: 'campaign-stats',\n    label: 'Campaign Stats',\n    category: 'Campaign',\n    sortOrder: 10,\n    schema: {\n      volunteers: { type: 'number', label: 'Volunteers' },\n      emails: { type: 'number', label: 'Emails Sent' },\n      events: { type: 'number', label: 'Events' },\n    },\n    defaults: {\n      volunteers: 1250,\n      emails: 5400,\n      events: 32,\n    },\n  });\n\n  console.log('Created block:', data.id);\n  return data;\n}\n
"},{"location":"v2/features/pages/block-library/#extending-generateblockhtml","title":"Extending generateBlockHtml()","text":"
// In admin/src/components/GrapesJSEditor.tsx\n\nfunction generateBlockHtml(type: string, defaults: Record<string, unknown>): string {\n  switch (type) {\n    // ... existing cases ...\n\n    case 'campaign-stats': {\n      const volunteers = defaults.volunteers || 0;\n      const emails = defaults.emails || 0;\n      const events = defaults.events || 0;\n\n      return `\n        <section style=\"padding: 60px 40px; background: #f8f9fa; text-align: center;\">\n          <h2 style=\"margin-bottom: 32px; font-size: 2rem;\">Our Impact</h2>\n          <div style=\"display: flex; gap: 48px; justify-content: center; flex-wrap: wrap;\">\n            <div>\n              <div style=\"font-size: 3rem; font-weight: 700; color: #9d4edd;\">${(volunteers as number).toLocaleString()}</div>\n              <div style=\"font-size: 1rem; color: #666;\">Volunteers</div>\n            </div>\n            <div>\n              <div style=\"font-size: 3rem; font-weight: 700; color: #9d4edd;\">${(emails as number).toLocaleString()}</div>\n              <div style=\"font-size: 1rem; color: #666;\">Emails Sent</div>\n            </div>\n            <div>\n              <div style=\"font-size: 3rem; font-weight: 700; color: #9d4edd;\">${events}</div>\n              <div style=\"font-size: 1rem; color: #666;\">Events</div>\n            </div>\n          </div>\n        </section>`;\n    }\n\n    default:\n      return `<section style=\"padding: 40px; text-align: center;\"><p>Custom block: ${type}</p></section>`;\n  }\n}\n
"},{"location":"v2/features/pages/block-library/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/pages/block-library/#problem-block-not-appearing-in-editor","title":"Problem: Block Not Appearing in Editor","text":"

Symptoms:

  • Created block via API
  • Not visible in left panel
  • Other blocks show correctly

Causes:

  1. GrapesJSEditor not re-fetching blocks
  2. generateBlockHtml() missing case
  3. Category name mismatch

Solutions:

  1. Reload editor:
  2. Close page editor \u2192 Re-open
  3. Blocks fetched on mount

  4. Add HTML generation case:

    case 'my-new-block':\n  return `<section>My block HTML</section>`;\n

  5. Check category:

    SELECT category FROM page_blocks WHERE type = 'my-new-block';\n-- Category should match GrapesJS panel (case-sensitive)\n

  6. Verify API response:

    curl -H \"Authorization: Bearer $TOKEN\" http://localhost:4000/api/page-blocks\n# Should include new block in response\n

"},{"location":"v2/features/pages/block-library/#problem-default-values-not-applying","title":"Problem: Default Values Not Applying","text":"

Symptoms:

  • Drag block to canvas \u2192 Fields are empty
  • Expected pre-filled title/subtitle

Causes:

  1. Defaults not matching schema keys
  2. HTML template ignores defaults
  3. Type mismatch (string vs number)

Solutions:

  1. Verify defaults match schema:

    // Schema\n{ \"title\": { \"type\": \"string\" } }\n\n// Defaults (good)\n{ \"title\": \"Welcome\" }\n\n// Defaults (bad - key mismatch)\n{ \"heading\": \"Welcome\" }\n

  2. Check HTML template:

    // Good - uses defaults\nreturn `<h1>${defaults.title || 'Fallback'}</h1>`;\n\n// Bad - ignores defaults\nreturn `<h1>Hardcoded Title</h1>`;\n

  3. Fix type mismatch:

    // If schema says \"number\", defaults must be number\n{ \"count\": { \"type\": \"number\" } }\n{ \"count\": 42 }  // Good\n{ \"count\": \"42\" } // Bad\n

"},{"location":"v2/features/pages/block-library/#problem-block-html-not-rendering","title":"Problem: Block HTML Not Rendering","text":"

Symptoms:

  • Block appears in panel
  • Dragging to canvas shows nothing or error

Causes:

  1. generateBlockHtml() returns invalid HTML
  2. Inline styles have syntax errors
  3. Missing closing tags

Solutions:

  1. Validate HTML:

    const html = generateBlockHtml('my-block', defaults);\nconsole.log(html); // Check for malformed tags\n

  2. Test inline styles:

    <!-- Bad - missing quotes -->\n<div style=padding: 20px>\n\n<!-- Good - quoted attribute -->\n<div style=\"padding: 20px;\">\n

  3. Use template literals carefully:

    // Ensure all ${} expressions return strings\nreturn `<div>${defaults.title || ''}</div>`;\n

"},{"location":"v2/features/pages/block-library/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/pages/block-library/#block-count-impact","title":"Block Count Impact","text":"

Threshold: 50+ blocks in library

Symptoms:

  • Slow editor initialization (~1s+)
  • Left panel laggy on scroll

Mitigations:

  1. Category filtering:
  2. Only fetch blocks for specific category
  3. Lazy-load categories on expand

  4. Pagination:

  5. Load first 20 blocks, fetch more on scroll
  6. Not implemented in current version

  7. Caching:

  8. Store blocks in localStorage
  9. Refresh only when version changes
"},{"location":"v2/features/pages/block-library/#schema-complexity","title":"Schema Complexity","text":"

Issue: Deeply nested array schemas (3+ levels) slow GrapesJS rendering

Example:

{\n  \"sections\": {\n    \"type\": \"array\",\n    \"items\": {\n      \"features\": {\n        \"type\": \"array\",\n        \"items\": {\n          \"details\": {\n            \"type\": \"array\"\n          }\n        }\n      }\n    }\n  }\n}\n

Alternative: Flatten structure or use CODE mode

"},{"location":"v2/features/pages/block-library/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/features/pages/block-library/#admin-only-access","title":"Admin-Only Access","text":"

Protection: All /api/page-blocks endpoints require admin role

router.use(authenticate);\nrouter.use(requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN));\n

Risk: Malicious admin creates XSS block with <script> tags

Mitigation:

  • Accepted risk: Admins are trusted users
  • Blocks only render on admin-authored pages (not user-submitted)
  • Public pages use admin-created HTML (already trusted)
"},{"location":"v2/features/pages/block-library/#type-validation","title":"Type Validation","text":"

Attack: Submit block with type containing SQL injection

Protection:

// Zod schema in pages.schemas.ts\ntype: z.string()\n  .min(1)\n  .max(50)\n  .regex(/^[a-z0-9-]+$/, 'Type must be lowercase alphanumeric with hyphens'),\n

Safe types: hero, text-block, campaign-stats-2026

Rejected: '; DROP TABLE--, <script>alert(1)</script>

"},{"location":"v2/features/pages/block-library/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/pages/block-library/#frontend-components","title":"Frontend Components","text":"
  • GrapesJSEditor \u2014 Block registration logic
  • LandingPageEditor \u2014 Fetches blocks for editor
"},{"location":"v2/features/pages/block-library/#backend-modules","title":"Backend Modules","text":"
  • blocks.routes \u2014 CRUD endpoints
  • blocks.service \u2014 Business logic
  • pages.schemas \u2014 Zod schemas
"},{"location":"v2/features/pages/block-library/#database","title":"Database","text":"
  • PageBlock Model \u2014 Schema + indexes
"},{"location":"v2/features/pages/block-library/#features","title":"Features","text":"
  • Page Builder \u2014 Landing page system
  • GrapesJS Editor \u2014 Editor integration
"},{"location":"v2/features/pages/block-library/#seed-data","title":"Seed Data","text":"
  • api/prisma/seed.ts \u2014 Default blocks definition
"},{"location":"v2/features/pages/grapes-editor/","title":"GrapesJS Editor Integration","text":"

React wrapper component for GrapesJS WYSIWYG editor with forwardRef pattern, custom block registration, and keyboard shortcuts.

"},{"location":"v2/features/pages/grapes-editor/#overview","title":"Overview","text":"

The GrapesJS Editor component provides a production-ready integration of the GrapesJS page builder library into the Changemaker Lite admin interface. It handles initialization, plugin configuration, custom block registration, and save orchestration.

"},{"location":"v2/features/pages/grapes-editor/#key-features","title":"Key Features","text":"
  • forwardRef Pattern: Parent components trigger save via ref handle
  • Custom Block Library: Register campaign-specific blocks from database
  • Plugin Ecosystem: 10+ GrapesJS plugins pre-configured
  • Keyboard Shortcuts: Ctrl+S (Cmd+S on Mac) to save
  • Error Boundary: Graceful fallback on initialization failure
  • Mobile Detection: Desktop-only warning for small screens
  • Video Block Support: Placeholder generation for media library videos
"},{"location":"v2/features/pages/grapes-editor/#architecture","title":"Architecture","text":"
graph TD\n    A[LandingPageEditor] -->|ref| B[GrapesJSEditor]\n    B -->|useImperativeHandle| C[triggerSave handle]\n    B --> D[grapesjs.init]\n    D --> E[Load Plugins]\n    E --> F[Register Custom Blocks]\n    F --> G[Load Initial Data]\n    G --> H[Canvas Ready]\n\n    A -->|handleSave| I[editorRef.current.triggerSave]\n    I --> J[Commands.run save-page]\n    J --> K[getProjectData + getHtml + getCss]\n    K --> L[onSave callback]\n    L --> M[API PUT /pages/:id]\n\n    style B fill:#9d4edd\n    style D fill:#3498db\n    style M fill:#2ecc71

Flow:

  1. Mount: LandingPageEditor creates ref, renders GrapesJSEditor
  2. Init: GrapesJSEditor calls grapesjs.init() \u2192 Loads plugins
  3. Blocks: Registers custom blocks from PageBlock library
  4. Data: Loads initialData (GrapesJS projectData JSON)
  5. Expose: useImperativeHandle exposes triggerSave() method
  6. Save: Parent calls editorRef.current.triggerSave() \u2192 Runs save-page command
  7. Callback: GrapesJS extracts HTML/CSS \u2192 Calls onSave() \u2192 Parent saves to API
"},{"location":"v2/features/pages/grapes-editor/#component-api","title":"Component API","text":""},{"location":"v2/features/pages/grapes-editor/#props","title":"Props","text":"
interface GrapesJSEditorProps {\n  initialData?: Record<string, unknown>;\n  onSave: (data: { projectData: Record<string, unknown>; html: string; css: string }) => void;\n  customBlocks?: PageBlock[];\n}\n

Fields:

  • initialData (optional): GrapesJS projectData JSON from previous save
  • Contains components tree, styles, assets
  • Empty object {} for new pages
  • onSave (required): Callback when save triggered
  • Receives { projectData, html, css }
  • Parent responsibility: Send to API
  • customBlocks (optional): Array of PageBlock records from database
  • Registered as draggable blocks in left panel
  • See Block Library for schema
"},{"location":"v2/features/pages/grapes-editor/#ref-handle","title":"Ref Handle","text":"
interface GrapesJSEditorHandle {\n  triggerSave: () => void;\n}\n

Method:

  • triggerSave(): Programmatically trigger save command
  • Extracts current editor state
  • Calls onSave callback
  • Used by parent's \"Save\" button or keyboard shortcut
"},{"location":"v2/features/pages/grapes-editor/#usage-example","title":"Usage Example","text":"
import { useRef } from 'react';\nimport GrapesJSEditor, { GrapesJSEditorHandle } from '@/components/GrapesJSEditor';\n\nfunction MyEditor() {\n  const editorRef = useRef<GrapesJSEditorHandle>(null);\n\n  const handleSave = async (data) => {\n    await api.put('/pages/123', {\n      blocks: data.projectData,\n      htmlOutput: data.html,\n      cssOutput: data.css,\n    });\n  };\n\n  const handleManualSave = () => {\n    editorRef.current?.triggerSave();\n  };\n\n  return (\n    <div>\n      <button onClick={handleManualSave}>Save</button>\n      <GrapesJSEditor\n        ref={editorRef}\n        initialData={page.blocks}\n        onSave={handleSave}\n        customBlocks={blocks}\n      />\n    </div>\n  );\n}\n
"},{"location":"v2/features/pages/grapes-editor/#grapesjs-configuration","title":"GrapesJS Configuration","text":""},{"location":"v2/features/pages/grapes-editor/#initialization-options","title":"Initialization Options","text":"
const editor = grapesjs.init({\n  container: containerRef.current,\n  height: '100%',\n  width: 'auto',\n  storageManager: false, // No localStorage persistence (managed by API)\n  plugins: [\n    blocksBasicPlugin,\n    presetWebpagePlugin,\n    formsPlugin,\n    navbarPlugin,\n    countdownPlugin,\n    tabsPlugin,\n    typedPlugin,\n    customCodePlugin,\n    exportPlugin,\n    styleGradientPlugin,\n    touchPlugin,\n  ],\n  pluginsOpts: {\n    [blocksBasicPlugin]: { flexGrid: true },\n    // ... other plugin options\n  },\n  canvas: {\n    styles: [\n      'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',\n    ],\n  },\n});\n

Key Settings:

  • storageManager: false: Disables auto-save to localStorage (we use API persistence)
  • height: '100%': Fills parent container (full-screen editor)
  • canvas.styles: Injects Google Fonts into preview iframe
"},{"location":"v2/features/pages/grapes-editor/#plugins-ecosystem","title":"Plugins Ecosystem","text":"Plugin Purpose Features grapesjs-blocks-basic Basic blocks Section, text, image, video, map, link, flexGrid grapesjs-preset-webpage Full page presets Header, footer, hero templates grapesjs-plugin-forms Form components Input, textarea, select, button, checkbox, radio grapesjs-navbar Navigation bars Responsive navbar with dropdowns grapesjs-component-countdown Countdown timers Event countdown with custom styling grapesjs-tabs Tab panels Horizontal/vertical tab containers grapesjs-typed Typing animation Typewriter text effect grapesjs-custom-code Embed raw HTML/JS Custom code blocks (advanced users) grapesjs-plugin-export Export templates ZIP download of HTML/CSS/assets grapesjs-style-gradient Gradient editor Visual gradient picker for backgrounds grapesjs-touch Touch support Mobile/tablet drag-and-drop (experimental)

Installation:

cd admin && npm install \\\n  grapesjs \\\n  grapesjs-blocks-basic \\\n  grapesjs-preset-webpage \\\n  grapesjs-plugin-forms \\\n  grapesjs-navbar \\\n  grapesjs-component-countdown \\\n  grapesjs-tabs \\\n  grapesjs-typed \\\n  grapesjs-custom-code \\\n  grapesjs-plugin-export \\\n  grapesjs-style-gradient \\\n  grapesjs-touch\n
"},{"location":"v2/features/pages/grapes-editor/#custom-blocks-registration","title":"Custom Blocks Registration","text":""},{"location":"v2/features/pages/grapes-editor/#block-registration-flow","title":"Block Registration Flow","text":"
sequenceDiagram\n    participant API as API Database\n    participant Parent as LandingPageEditor\n    participant Editor as GrapesJSEditor\n    participant GJS as GrapesJS\n\n    Parent->>API: GET /api/page-blocks\n    API-->>Parent: PageBlock[]\n    Parent->>Editor: <GrapesJSEditor customBlocks={blocks} />\n    Editor->>Editor: useEffect(() => init)\n    Editor->>GJS: grapesjs.init()\n    GJS-->>Editor: editor instance\n    Editor->>Editor: Register custom blocks loop\n    loop For each block\n        Editor->>Editor: generateBlockHtml(type, defaults)\n        Editor->>GJS: BlockManager.add(id, config)\n    end\n    GJS-->>Editor: Blocks ready
"},{"location":"v2/features/pages/grapes-editor/#block-generation-logic","title":"Block Generation Logic","text":"
// From GrapesJSEditor.tsx\nconst blockManager = editor.Blocks;\nfor (const block of customBlocks) {\n  const defaults = block.defaults as Record<string, unknown>;\n  const html = generateBlockHtml(block.type, defaults);\n\n  blockManager.add(`custom-${block.type}`, {\n    label: block.label,\n    category: block.category || 'Campaign',\n    content: html,\n  });\n}\n

Example Block:

// From seed.ts\n{\n  id: 'default-hero',\n  type: 'hero',\n  label: 'Hero Section',\n  category: 'Headers',\n  defaults: {\n    title: 'Welcome to Our Campaign',\n    subtitle: 'Join us in making a difference.',\n    ctaText: 'Get Involved',\n    ctaUrl: '#',\n  },\n}\n

Generated HTML:

<section style=\"padding: 80px 40px; text-align: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #fff;\">\n  <h1 style=\"font-size: 2.5rem; margin-bottom: 16px;\">Welcome to Our Campaign</h1>\n  <p style=\"font-size: 1.25rem; opacity: 0.85; margin-bottom: 32px;\">Join us in making a difference.</p>\n  <a href=\"#\" style=\"display: inline-block; padding: 12px 32px; background: #9d4edd; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600;\">Get Involved</a>\n</section>\n
"},{"location":"v2/features/pages/grapes-editor/#built-in-block-templates","title":"Built-In Block Templates","text":"

1. Hero Section

case 'hero':\n  return `\n    <section style=\"padding: 80px 40px; text-align: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #fff;\">\n      <h1 style=\"font-size: 2.5rem; margin-bottom: 16px;\">${defaults.title || 'Hero Title'}</h1>\n      <p style=\"font-size: 1.25rem; opacity: 0.85; margin-bottom: 32px;\">${defaults.subtitle || 'Subtitle text here'}</p>\n      <a href=\"${defaults.ctaUrl || '#'}\" style=\"display: inline-block; padding: 12px 32px; background: #9d4edd; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600;\">${defaults.ctaText || 'Get Started'}</a>\n    </section>`;\n

2. Text Block

case 'text':\n  return `\n    <section style=\"padding: 60px 40px; max-width: 800px; margin: 0 auto;\">\n      <h2 style=\"font-size: 1.75rem; margin-bottom: 16px;\">${defaults.heading || 'Heading'}</h2>\n      <p style=\"font-size: 1rem; line-height: 1.7; opacity: 0.85;\">${defaults.body || 'Body text goes here.'}</p>\n    </section>`;\n

3. Features Grid

case 'features': {\n  const features = (defaults.features as Array<{ title: string; description: string }>) || [];\n  const featureHtml = features.map(f => `\n    <div style=\"flex: 1; min-width: 250px; padding: 24px; text-align: center;\">\n      <h3 style=\"font-size: 1.25rem; margin-bottom: 8px;\">${f.title}</h3>\n      <p style=\"opacity: 0.8;\">${f.description}</p>\n    </div>`).join('');\n\n  return `\n    <section style=\"padding: 60px 40px;\">\n      <div style=\"display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;\">\n        ${featureHtml}\n      </div>\n    </section>`;\n}\n

4. Call to Action

case 'cta':\n  return `\n    <section style=\"padding: 60px 40px; text-align: center; background: linear-gradient(135deg, #9d4edd 0%, #7b2cbf 100%); color: #fff;\">\n      <h2 style=\"font-size: 2rem; margin-bottom: 12px;\">${defaults.heading || 'Call to Action'}</h2>\n      <p style=\"font-size: 1.1rem; margin-bottom: 24px; opacity: 0.9;\">${defaults.description || 'Description here'}</p>\n      <a href=\"${defaults.buttonUrl || '#'}\" style=\"display: inline-block; padding: 12px 32px; background: #fff; color: #9d4edd; text-decoration: none; border-radius: 6px; font-weight: 600;\">${defaults.buttonText || 'Click Here'}</a>\n    </section>`;\n

5. Video Block

case 'video': {\n  const videoId = defaults.videoId || 'PLACEHOLDER';\n  const playerType = defaults.playerType || 'standard';\n\n  return `\n    <section style=\"padding: 60px 40px;\">\n      <div class=\"video-block\"\n           data-video-id=\"${videoId}\"\n           data-player-type=\"${playerType}\"\n           data-autoplay=\"${defaults.autoplay || false}\"\n           data-controls=\"${defaults.controls !== false}\"\n           data-show-reactions=\"${defaults.showReactions !== false}\"\n           style=\"max-width: 100%; margin: 0 auto;\">\n        <div class=\"video-placeholder\" style=\"aspect-ratio: 16/9; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; display: flex; align-items: center; justify-content: center;\">\n          <div style=\"text-align: center; color: #fff; padding: 24px;\">\n            <svg style=\"width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.9;\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n              <path d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z\" />\n            </svg>\n            <p style=\"margin: 0; font-size: 1.1rem; font-weight: 600;\">Video Player</p>\n            <p style=\"margin: 8px 0 0; font-size: 0.9rem; opacity: 0.8;\">ID: ${videoId}</p>\n            <p style=\"margin: 4px 0 0; font-size: 0.85rem; opacity: 0.7;\">${playerType === 'advanced' ? 'Advanced Player (with reactions)' : 'Standard HTML5 Player'}</p>\n            <p style=\"margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;\">Video will render on published page</p>\n          </div>\n        </div>\n      </div>\n    </section>`;\n}\n
"},{"location":"v2/features/pages/grapes-editor/#save-command-integration","title":"Save Command Integration","text":""},{"location":"v2/features/pages/grapes-editor/#command-registration","title":"Command Registration","text":"
// In useEffect() after editor init\neditor.Commands.add('save-page', {\n  run(ed: Editor) {\n    const projectData = ed.getProjectData() as Record<string, unknown>;\n    const html = ed.getHtml();\n    const css = ed.getCss() || '';\n    onSaveRef.current({ projectData, html, css });\n  },\n});\n

Why onSaveRef?

  • Avoids stale closure over onSave prop
  • Parent can update callback without re-initializing editor
  • Pattern: const onSaveRef = useRef(onSave); onSaveRef.current = onSave;
"},{"location":"v2/features/pages/grapes-editor/#keyboard-shortcut","title":"Keyboard Shortcut","text":"
const handleKeyDown = (e: KeyboardEvent) => {\n  if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n    e.preventDefault();\n    editor.runCommand('save-page');\n  }\n};\n\ndocument.addEventListener('keydown', handleKeyDown);\n\n// Cleanup\nreturn () => {\n  document.removeEventListener('keydown', handleKeyDown);\n  editor.destroy();\n};\n

Shortcuts:

  • Windows/Linux: Ctrl+S
  • macOS: Cmd+S

Behavior:

  • Prevents browser's default \"Save Page As...\" dialog
  • Triggers GrapesJS save command
  • Calls onSave callback with current state
"},{"location":"v2/features/pages/grapes-editor/#forwardref-pattern","title":"forwardRef Pattern","text":""},{"location":"v2/features/pages/grapes-editor/#implementation","title":"Implementation","text":"
const GrapesJSEditor = forwardRef<GrapesJSEditorHandle, GrapesJSEditorProps>(\n  function GrapesJSEditor({ initialData, onSave, customBlocks }, ref) {\n    const editorRef = useRef<Editor | null>(null);\n\n    useImperativeHandle(ref, () => ({\n      triggerSave() {\n        editorRef.current?.runCommand('save-page');\n      },\n    }));\n\n    // ... rest of component\n  }\n);\n
"},{"location":"v2/features/pages/grapes-editor/#parent-usage","title":"Parent Usage","text":"
// In LandingPageEditor.tsx\nimport { useRef } from 'react';\n\nconst editorRef = useRef<GrapesJSEditorHandle>(null);\n\nconst handleManualSave = () => {\n  editorRef.current?.triggerSave(); // Programmatic save\n};\n\nreturn (\n  <div>\n    <button onClick={handleManualSave}>Save</button>\n    <GrapesJSEditor ref={editorRef} onSave={handleSave} />\n  </div>\n);\n

Why forwardRef?

  • Decouples save trigger from GrapesJS internals
  • Parent controls when to save (toolbar button, auto-save timer, etc.)
  • Cleaner API than prop drilling onManualSave callback
"},{"location":"v2/features/pages/grapes-editor/#error-handling","title":"Error Handling","text":""},{"location":"v2/features/pages/grapes-editor/#error-boundary-state","title":"Error Boundary State","text":"
const [error, setError] = useState<string | null>(null);\n\ntry {\n  editor = grapesjs.init({ /* ... */ });\n} catch (err) {\n  console.error('GrapesJS init error:', err);\n  setError('Failed to initialize the page editor. Please refresh the page.');\n  return;\n}\n\nif (error) {\n  return (\n    <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ff4d4f' }}>\n      {error}\n    </div>\n  );\n}\n

Failure Modes:

  1. Missing plugin: GrapesJS throws error during init()
  2. Browser incompatibility: Old browser doesn't support ES6 modules
  3. Memory exhaustion: Very large initialData crashes tab

Recovery:

  • Error state shows user-friendly message
  • No infinite re-render (error doesn't trigger re-init)
  • User can refresh page or report issue
"},{"location":"v2/features/pages/grapes-editor/#parent-level-fallback","title":"Parent-Level Fallback","text":"
// In LandingPageEditor.tsx\nimport { ErrorBoundary } from 'react-error-boundary';\n\n<ErrorBoundary\n  fallback={<div>Editor failed to load. Please try CODE mode.</div>}\n  onReset={() => navigate('/app/pages')}\n>\n  <GrapesJSEditor ref={editorRef} onSave={handleSave} />\n</ErrorBoundary>\n

Cascade:

  1. GrapesJS init error \u2192 Internal error state
  2. React render error \u2192 ErrorBoundary catches
  3. User sees fallback \u2192 Can switch to CODE mode
"},{"location":"v2/features/pages/grapes-editor/#mobile-detection","title":"Mobile Detection","text":""},{"location":"v2/features/pages/grapes-editor/#desktop-only-warning","title":"Desktop-Only Warning","text":"

Location: LandingPageEditor.tsx (parent component)

import { Grid } from 'antd';\n\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md; // md = 768px\n\nif (isMobile) {\n  return (\n    <Result\n      status=\"warning\"\n      title=\"Desktop Required\"\n      subTitle=\"The page editor requires a desktop or tablet device (minimum 768px width).\"\n      extra={<Button onClick={() => navigate('/app/pages')}>Back to Pages</Button>}\n    />\n  );\n}\n

Why desktop-only?

  • GrapesJS drag-and-drop requires precise mouse interactions
  • Small screens can't fit 3-panel layout (blocks, canvas, properties)
  • Touch support experimental (grapesjs-touch plugin unstable)

Alternative for mobile admins:

  • Use CODE mode (Monaco editor works on mobile)
  • Edit on desktop, preview on mobile
  • Use responsive design testing tools
"},{"location":"v2/features/pages/grapes-editor/#data-flow-patterns","title":"Data Flow Patterns","text":""},{"location":"v2/features/pages/grapes-editor/#initial-load","title":"Initial Load","text":"
sequenceDiagram\n    participant DB as Database\n    participant API as API Service\n    participant Parent as LandingPageEditor\n    participant Editor as GrapesJSEditor\n    participant GJS as GrapesJS\n\n    Parent->>API: GET /api/pages/:id\n    API->>DB: SELECT blocks FROM landing_pages\n    DB-->>API: { blocks: {...} }\n    API-->>Parent: LandingPage JSON\n    Parent->>Editor: <GrapesJSEditor initialData={page.blocks} />\n    Editor->>GJS: editor.loadProjectData(initialData)\n    GJS-->>Editor: Canvas rendered

Key Points:

  • blocks field contains full GrapesJS projectData (components tree, styles, assets)
  • Empty object {} for new pages (GrapesJS shows blank canvas)
  • Large JSON (50KB+) loads in ~200ms
"},{"location":"v2/features/pages/grapes-editor/#save-flow","title":"Save Flow","text":"
sequenceDiagram\n    participant User as User\n    participant Parent as LandingPageEditor\n    participant Editor as GrapesJSEditor\n    participant GJS as GrapesJS\n    participant API as API\n\n    User->>User: Press Ctrl+S\n    User->>Editor: KeyboardEvent\n    Editor->>GJS: runCommand('save-page')\n    GJS->>GJS: getProjectData()\n    GJS->>GJS: getHtml()\n    GJS->>GJS: getCss()\n    GJS-->>Editor: { projectData, html, css }\n    Editor->>Parent: onSave(data)\n    Parent->>API: PUT /api/pages/:id\n    API-->>Parent: 200 OK\n    Parent->>User: \"Page saved\" notification

Critical Detail:

  • getProjectData() returns full editor state (for future edits)
  • getHtml() returns rendered HTML (for public display)
  • getCss() returns compiled CSS (for public display)
  • All three saved to database (different use cases)
"},{"location":"v2/features/pages/grapes-editor/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/pages/grapes-editor/#complete-integration-example","title":"Complete Integration Example","text":"
// admin/src/pages/LandingPageEditor.tsx\nimport { useState, useEffect, useRef } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { Button, message, Spin } from 'antd';\nimport { api } from '@/lib/api';\nimport GrapesJSEditor, { GrapesJSEditorHandle } from '@/components/GrapesJSEditor';\nimport type { LandingPage, PageBlock } from '@/types/api';\n\ninterface LandingPageEditorProps {\n  pageId: string;\n  onClose: () => void;\n}\n\nexport default function LandingPageEditor({ pageId, onClose }: LandingPageEditorProps) {\n  const navigate = useNavigate();\n  const editorRef = useRef<GrapesJSEditorHandle>(null);\n  const [page, setPage] = useState<LandingPage | null>(null);\n  const [blocks, setBlocks] = useState<PageBlock[]>([]);\n  const [loading, setLoading] = useState(true);\n\n  useEffect(() => {\n    const fetchData = async () => {\n      try {\n        const [pageRes, blocksRes] = await Promise.all([\n          api.get<LandingPage>(`/pages/${pageId}`),\n          api.get<PageBlock[]>('/page-blocks'),\n        ]);\n        setPage(pageRes.data);\n        setBlocks(blocksRes.data);\n      } catch {\n        message.error('Failed to load page');\n        onClose();\n      } finally {\n        setLoading(false);\n      }\n    };\n    fetchData();\n  }, [pageId, onClose]);\n\n  const handleSave = async (data: { projectData: any; html: string; css: string }) => {\n    try {\n      await api.put(`/pages/${pageId}`, {\n        blocks: data.projectData,\n        htmlOutput: data.html,\n        cssOutput: data.css,\n      });\n      message.success('Page saved');\n    } catch {\n      message.error('Failed to save page');\n    }\n  };\n\n  const handleManualSave = () => {\n    editorRef.current?.triggerSave();\n  };\n\n  if (loading) return <Spin size=\"large\" />;\n  if (!page) return null;\n\n  return (\n    <div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>\n      <div style={{ padding: '12px 16px', borderBottom: '1px solid #d9d9d9' }}>\n        <Button onClick={onClose}>Back</Button>\n        <Button type=\"primary\" onClick={handleManualSave} style={{ marginLeft: 8 }}>\n          Save (Ctrl+S)\n        </Button>\n      </div>\n      <GrapesJSEditor\n        ref={editorRef}\n        initialData={page.blocks as Record<string, unknown>}\n        onSave={handleSave}\n        customBlocks={blocks}\n      />\n    </div>\n  );\n}\n
"},{"location":"v2/features/pages/grapes-editor/#custom-block-registration","title":"Custom Block Registration","text":"
// Add a custom \"Campaign Stats\" block\nconst campaignStatsBlock: PageBlock = {\n  id: 'custom-campaign-stats',\n  type: 'campaign-stats',\n  label: 'Campaign Stats',\n  category: 'Campaign',\n  sortOrder: 10,\n  schema: {\n    volunteers: { type: 'number', label: 'Volunteers' },\n    emails: { type: 'number', label: 'Emails Sent' },\n    events: { type: 'number', label: 'Events' },\n  },\n  defaults: {\n    volunteers: 1250,\n    emails: 5400,\n    events: 32,\n  },\n};\n\n// GrapesJSEditor will auto-register via generateBlockHtml()\n<GrapesJSEditor customBlocks={[campaignStatsBlock, ...otherBlocks]} />\n
"},{"location":"v2/features/pages/grapes-editor/#adding-custom-html-generation","title":"Adding Custom HTML Generation","text":"
// In GrapesJSEditor.tsx generateBlockHtml() function\ncase 'campaign-stats': {\n  const volunteers = defaults.volunteers || 0;\n  const emails = defaults.emails || 0;\n  const events = defaults.events || 0;\n\n  return `\n    <section style=\"padding: 60px 40px; background: #f8f9fa; text-align: center;\">\n      <h2 style=\"margin-bottom: 32px; font-size: 2rem;\">Our Impact</h2>\n      <div style=\"display: flex; gap: 48px; justify-content: center; flex-wrap: wrap;\">\n        <div>\n          <div style=\"font-size: 3rem; font-weight: 700; color: #9d4edd;\">${volunteers.toLocaleString()}</div>\n          <div style=\"font-size: 1rem; color: #666;\">Volunteers</div>\n        </div>\n        <div>\n          <div style=\"font-size: 3rem; font-weight: 700; color: #9d4edd;\">${emails.toLocaleString()}</div>\n          <div style=\"font-size: 1rem; color: #666;\">Emails Sent</div>\n        </div>\n        <div>\n          <div style=\"font-size: 3rem; font-weight: 700; color: #9d4edd;\">${events}</div>\n          <div style=\"font-size: 1rem; color: #666;\">Events</div>\n        </div>\n      </div>\n    </section>`;\n}\n
"},{"location":"v2/features/pages/grapes-editor/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/pages/grapes-editor/#problem-blocks-not-appearing-in-left-panel","title":"Problem: Blocks Not Appearing in Left Panel","text":"

Symptoms:

  • Custom blocks array passed to GrapesJSEditor
  • Left panel shows default blocks only
  • No campaign-specific blocks

Causes:

  1. generateBlockHtml() missing case for block type
  2. Category name mismatch
  3. Block registration timing issue

Solutions:

  1. Add case to generateBlockHtml():

    case 'my-custom-block':\n  return `<section>My custom block HTML</section>`;\n

  2. Check category:

    // Block category: \"Campaign\"\n// GrapesJS shows blocks in collapsible \"Campaign\" section\n// Case-sensitive match\n

  3. Verify registration timing:

    // Registration happens in useEffect after init\nconsole.log('Registering blocks:', customBlocks.length);\n

  4. Inspect BlockManager:

    // In browser console (after editor loads)\nwindow.editor.BlockManager.getAll().forEach(b => console.log(b.id));\n// Should include 'custom-hero', 'custom-text', etc.\n

"},{"location":"v2/features/pages/grapes-editor/#problem-save-not-triggering","title":"Problem: Save Not Triggering","text":"

Symptoms:

  • Press Ctrl+S \u2192 Nothing happens
  • Manual save button doesn't work
  • onSave callback never called

Causes:

  1. Keyboard event listener not registered
  2. forwardRef not working
  3. save-page command not registered

Solutions:

  1. Check keyboard listener:

    // In GrapesJSEditor useEffect\nconst handleKeyDown = (e: KeyboardEvent) => {\n  console.log('Key pressed:', e.key, 'Ctrl:', e.ctrlKey);\n  if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n    console.log('Save shortcut triggered');\n    e.preventDefault();\n    editor.runCommand('save-page');\n  }\n};\n

  2. Verify ref handle:

    // In parent component\nconsole.log('Editor ref:', editorRef.current); // Should be { triggerSave: fn }\n

  3. Test command directly:

    // In browser console (after editor loads)\nwindow.editor.runCommand('save-page');\n// Should trigger onSave callback\n

  4. Check onSaveRef pattern:

    const onSaveRef = useRef(onSave);\nonSaveRef.current = onSave; // Update on every render\n

"},{"location":"v2/features/pages/grapes-editor/#problem-editor-crashes-on-large-pages","title":"Problem: Editor Crashes on Large Pages","text":"

Symptoms:

  • Loading page with 100+ components \u2192 Tab freezes
  • GrapesJS UI unresponsive
  • Save takes 10+ seconds

Causes:

  • Too many components in single page
  • Deep nesting (10+ levels)
  • Heavy images without lazy loading

Solutions:

  1. Split into multiple pages:
  2. Separate hero, features, testimonials into 3 pages
  3. Link pages via navigation

  4. Use CODE mode for complex layouts:

  5. Write HTML directly \u2192 Faster than GrapesJS rendering
  6. Import via \"Sync Overrides\"

  7. Optimize images:

  8. Use external CDN (not base64-encoded)
  9. Compress before upload
  10. Lazy load below fold

  11. Increase browser memory:

  12. Chrome \u2192 --max-old-space-size=4096
  13. Edge \u2192 Similar flag
"},{"location":"v2/features/pages/grapes-editor/#problem-initial-data-not-loading","title":"Problem: Initial Data Not Loading","text":"

Symptoms:

  • Editor opens with blank canvas
  • initialData prop has data
  • Console shows no errors

Causes:

  1. loadProjectData() called before editor ready
  2. Invalid JSON structure
  3. Async timing issue

Solutions:

  1. Check editor ready state:

    useEffect(() => {\n  if (!containerRef.current) return;\n\n  const editor = grapesjs.init({ /* ... */ });\n\n  // Wait for editor load event\n  editor.on('load', () => {\n    if (initialData && Object.keys(initialData).length > 0) {\n      editor.loadProjectData(initialData);\n    }\n  });\n}, []);\n

  2. Validate JSON:

    console.log('Loading data:', JSON.stringify(initialData, null, 2));\n// Should have keys: assets, styles, pages\n

  3. Handle empty data:

    if (initialData && Object.keys(initialData).length > 0) {\n  editor.loadProjectData(initialData);\n} else {\n  console.log('Starting with blank canvas');\n}\n

"},{"location":"v2/features/pages/grapes-editor/#problem-styles-not-applying-in-canvas","title":"Problem: Styles Not Applying in Canvas","text":"

Symptoms:

  • Drag block to canvas \u2192 No background color
  • Text has wrong font
  • Layout broken

Causes:

  1. Inline styles not supported
  2. External stylesheet missing
  3. Canvas iframe CSP issue

Solutions:

  1. Use inline styles in generateBlockHtml():

    // Good\nreturn `<section style=\"padding: 40px; background: #f00;\">...</section>`;\n\n// Bad (requires CSS injection)\nreturn `<section class=\"hero\">...</section>`;\n

  2. Inject fonts into canvas:

    canvas: {\n  styles: [\n    'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',\n  ],\n}\n

  3. Check iframe sandbox:

    // GrapesJS canvas uses <iframe> \u2014 ensure no sandbox restrictions\n// Default config works, but custom CSP may block\n

"},{"location":"v2/features/pages/grapes-editor/#performance-optimization","title":"Performance Optimization","text":""},{"location":"v2/features/pages/grapes-editor/#lazy-loading","title":"Lazy Loading","text":"
// In LandingPageEditor.tsx\nimport { lazy, Suspense } from 'react';\n\nconst GrapesJSEditor = lazy(() => import('@/components/GrapesJSEditor'));\n\nreturn (\n  <Suspense fallback={<Spin size=\"large\" />}>\n    <GrapesJSEditor ref={editorRef} onSave={handleSave} />\n  </Suspense>\n);\n

Benefit: Reduces initial bundle size by ~800KB (GrapesJS + plugins)

"},{"location":"v2/features/pages/grapes-editor/#debounced-auto-save","title":"Debounced Auto-Save","text":"
import { useRef, useEffect } from 'react';\n\nconst autoSaveTimerRef = useRef<ReturnType<typeof setTimeout>>();\n\nconst handleEditorChange = () => {\n  clearTimeout(autoSaveTimerRef.current);\n  autoSaveTimerRef.current = setTimeout(() => {\n    editorRef.current?.triggerSave();\n  }, 5000); // Auto-save after 5s of inactivity\n};\n\nuseEffect(() => {\n  // Listen to editor change events\n  const editor = window.editor; // Access via global (not recommended for prod)\n  editor?.on('component:update', handleEditorChange);\n  editor?.on('style:update', handleEditorChange);\n\n  return () => {\n    clearTimeout(autoSaveTimerRef.current);\n    editor?.off('component:update', handleEditorChange);\n    editor?.off('style:update', handleEditorChange);\n  };\n}, []);\n

Trade-off: More API calls vs. reduced data loss risk

"},{"location":"v2/features/pages/grapes-editor/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/pages/grapes-editor/#components","title":"Components","text":"
  • LandingPageEditor \u2014 Full-screen editor wrapper
  • LandingPagesPage \u2014 Table view with edit links
"},{"location":"v2/features/pages/grapes-editor/#features","title":"Features","text":"
  • Page Builder \u2014 Complete page builder system
  • Block Library \u2014 Custom blocks database
  • MkDocs Export \u2014 Export to documentation site
"},{"location":"v2/features/pages/grapes-editor/#external","title":"External","text":"
  • GrapesJS Docs \u2014 Official documentation
  • GrapesJS API \u2014 JavaScript API reference
  • GrapesJS Plugins \u2014 Plugin ecosystem
"},{"location":"v2/features/pages/mkdocs-export/","title":"MkDocs Export Integration","text":"

Export landing pages to MkDocs Material theme with Jinja2 template wrapping, front matter configuration, and synchronized stub files.

"},{"location":"v2/features/pages/mkdocs-export/#overview","title":"Overview","text":"

The MkDocs Export system bridges the Page Builder and static documentation site. Administrators can publish landing pages to the main MkDocs site, where they benefit from Material theme styling, navigation, and SEO features.

"},{"location":"v2/features/pages/mkdocs-export/#key-features","title":"Key Features","text":"
  • Two Export Modes: THEMED (extends Material theme) vs STANDALONE (full HTML document)
  • Jinja2 Template Wrapping: Integrates with MkDocs Material theme inheritance
  • Front Matter Configuration: Control navigation, table of contents visibility
  • Dual-File Output: HTML override + Markdown stub
  • Automatic Sync: Export triggered on publish, cleanup on unpublish
  • Path Validation: Prevents directory traversal attacks
  • Stub Backfill: Repair missing files via \"Validate Exports\"
"},{"location":"v2/features/pages/mkdocs-export/#architecture","title":"Architecture","text":"
graph TD\n    A[Admin] -->|Publish Page| B[PUT /api/pages/:id]\n    B --> C[pages.service.update]\n    C --> D{published && !skipExport?}\n    D -->|Yes| E[exportToMkDocs]\n    E --> F[wrapInMaterialOverride]\n    E --> G[generateMdStub]\n    F --> H[Write .html to overrides/]\n    G --> I[Write .md to docs/]\n\n    J[MkDocs Build] --> K[Read .md stub]\n    K --> L[Front matter: template]\n    L --> H\n    H --> M[Render with Material theme]\n    M --> N[Public site]\n\n    style E fill:#9d4edd\n    style H fill:#3498db\n    style N fill:#2ecc71

Flow:

  1. Trigger: Admin publishes page (or updates published page)
  2. Service: pages.service.update() checks publish status
  3. Export: Calls exportToMkDocs() with page data
  4. Wrap: HTML wrapped in Jinja2 {% extends \"main.html\" %}
  5. Write: Two files created:
  6. mkdocs/docs/overrides/{slug}.html \u2014 HTML override
  7. mkdocs/docs/{slug}.md \u2014 Markdown stub
  8. Build: MkDocs rebuild (mkdocs build)
  9. Render: Stub references override, Material theme applies
  10. Serve: Page accessible at https://cmlite.org/pages/{slug}/
"},{"location":"v2/features/pages/mkdocs-export/#export-modes","title":"Export Modes","text":""},{"location":"v2/features/pages/mkdocs-export/#themed-mode-default","title":"THEMED Mode (Default)","text":"

Purpose: Integrate page with MkDocs Material theme (header, footer, navigation)

Jinja2 Template:

{% extends \"main.html\" %}\n{% block content %}\n<style>\nsection { padding: 40px; }\n</style>\n<section>\n  <h1>Welcome</h1>\n  <p>Page content here.</p>\n</section>\n{% endblock %}\n

Features:

  • Uses Material theme header/footer
  • Respects site navigation
  • Table of contents auto-generated
  • Search integration works
  • Responsive design inherited

Use Cases:

  • Documentation pages
  • Campaign info pages
  • Community guidelines
"},{"location":"v2/features/pages/mkdocs-export/#standalone-mode","title":"STANDALONE Mode","text":"

Purpose: Full control over HTML (no MkDocs chrome)

HTML Document:

<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>About Us | Campaign 2026</title>\n    <meta name=\"description\" content=\"Join our movement for change.\">\n    <style>\n    section { padding: 40px; }\n    </style>\n</head>\n<body>\n<section>\n  <h1>Welcome</h1>\n  <p>Page content here.</p>\n</section>\n</body>\n</html>\n

Features:

  • No Material theme elements
  • Custom head section (meta tags, styles)
  • Independent from site navigation
  • Full design freedom

Use Cases:

  • Marketing landing pages (like lander.html)
  • Event registration pages
  • Embedded pages (iframes)
"},{"location":"v2/features/pages/mkdocs-export/#file-outputs","title":"File Outputs","text":""},{"location":"v2/features/pages/mkdocs-export/#override-file-html","title":"Override File (.html)","text":"

Location: mkdocs/docs/overrides/{slug}.html

Example: mkdocs/docs/overrides/about-us.html

Content (THEMED mode):

{% extends \"main.html\" %}\n{% block content %}\n<style>\n/* Page CSS */\n</style>\n<!-- Page HTML -->\n{% endblock %}\n

Content (STANDALONE mode):

<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <title>About Us</title>\n  <style>/* Page CSS */</style>\n</head>\n<body>\n  <!-- Page HTML -->\n</body>\n</html>\n

Access Control:

  • Readable by MkDocs build process
  • Not directly served (accessed via stub)
"},{"location":"v2/features/pages/mkdocs-export/#stub-file-md","title":"Stub File (.md)","text":"

Location: mkdocs/docs/{slug}.md

Example: mkdocs/docs/about-us.md

Content:

---\ntemplate: about-us.html\ntitle: \"About Us | Campaign 2026\"\ndescription: \"Join our movement for change.\"\nhide:\n  - navigation\n  - toc\n---\n

Front Matter Fields:

Field Type Description template string Override filename (relative to custom_dir) title string Page title (from seoTitle or title) description string Meta description (from seoDescription) hide array Hide navigation/toc elements

Important: Template path is relative to custom_dir (mkdocs/overrides/). Use about-us.html, NOT overrides/about-us.html (causes TemplateNotFound error).

"},{"location":"v2/features/pages/mkdocs-export/#database-fields","title":"Database Fields","text":""},{"location":"v2/features/pages/mkdocs-export/#landingpage-export-configuration","title":"LandingPage Export Configuration","text":"

Fields:

Field Type Default Description mkdocsPath String? {slug}.html Override filename (auto-generated from slug) mkdocsStubPath String? {slug}.md Stub filename (derived from mkdocsPath) mkdocsExportMode Enum THEMED THEMED or STANDALONE mkdocsHideNav Boolean false Hide navigation sidebar (THEMED only) mkdocsHideToc Boolean false Hide table of contents (THEMED only) mkdocsSkipExport Boolean false Skip MkDocs export entirely

Behavior:

  • On publish (published=true):
  • If mkdocsSkipExport=false: Export files
  • If mkdocsSkipExport=true: No export (page only at /p/:slug)
  • On unpublish (published=false): Remove export files
  • On title change: Regenerate slug, update mkdocsPath, clean up old files
"},{"location":"v2/features/pages/mkdocs-export/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/pages/mkdocs-export/#exporting-a-page","title":"Exporting a Page","text":"

Automatic Export (on publish):

  1. Admin \u2192 Pages \u2192 Click \"Publish\" button
  2. API updates published=true
  3. Service checks mkdocsSkipExport
  4. If false: Calls exportToMkDocs()
  5. Files written to disk
  6. Database updated with mkdocsStubPath

Manual Export Trigger:

  1. Edit page settings
  2. Change mkdocsExportMode or mkdocsHideNav
  3. Save settings
  4. If published: Auto re-exports
"},{"location":"v2/features/pages/mkdocs-export/#configuring-export-options","title":"Configuring Export Options","text":"

Location: Page Settings modal \u2192 MkDocs Integration section

Steps:

  1. Admin \u2192 Pages \u2192 Click gear icon (Settings)
  2. Scroll to \"MkDocs Integration\"
  3. Configure options:
  4. Skip MkDocs Export: \u2610 (unchecked)
  5. Override Path: about.html (auto-filled)
  6. Full page MkDocs: \u2610 (THEMED mode)
  7. Hide navigation sidebar: \u2611 (checked)
  8. Hide table of contents: \u2611 (checked)
  9. Click \"Save\"
  10. If published: Files re-exported immediately
"},{"location":"v2/features/pages/mkdocs-export/#rebuilding-mkdocs-site","title":"Rebuilding MkDocs Site","text":"

Trigger: After exporting pages

Methods:

Option 1: Admin UI

  1. Admin \u2192 Pages \u2192 \"Build Site\" button (SUPER_ADMIN only)
  2. Confirmation modal appears
  3. Click \"Confirm\"
  4. API executes docker compose exec mkdocs mkdocs build
  5. Success notification

Option 2: Command Line

docker compose exec mkdocs mkdocs build\n# Rebuilds site from mkdocs/docs/ directory\n# Output: mkdocs/site/ (static HTML)\n

Auto-rebuild: Not implemented (manual trigger required)

"},{"location":"v2/features/pages/mkdocs-export/#syncing-overrides","title":"Syncing Overrides","text":"

Purpose: Import hand-coded .html files from overrides/ directory

Workflow:

  1. Place .html file in mkdocs/docs/overrides/custom.html
  2. Admin \u2192 Pages \u2192 \"Sync Overrides\" button
  3. API scans directory:
  4. Untracked files \u2192 Create CODE-mode page
  5. Tracked CODE-mode pages \u2192 Update htmlOutput from disk
  6. VISUAL pages \u2192 Skip (managed by GrapesJS)
  7. Backfills missing .md stubs
  8. Shows result: Synced: 2 imported, 1 updated, 3 stubs created

Use Cases:

  • Migrate legacy templates
  • Import designer-created HTML
  • Restore after file system corruption
"},{"location":"v2/features/pages/mkdocs-export/#validating-exports","title":"Validating Exports","text":"

Purpose: Verify files exist on disk, repair if missing

Workflow:

  1. Admin \u2192 Pages \u2192 \"Validate Exports\" button
  2. API queries all published, non-skipped pages
  3. For each page:
  4. Check .html override exists
  5. Check .md stub exists
  6. If either missing: Re-export
  7. Shows result: Validated 10 pages: 2 repaired, 0 errors

Use Cases:

  • Recover from accidental deletion
  • Fix state after container restarts
  • Audit before production deploy
"},{"location":"v2/features/pages/mkdocs-export/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/pages/mkdocs-export/#themed-mode-export","title":"Themed Mode Export","text":"
// From pages.service.ts\n\nfunction wrapInMaterialOverride(html: string, css: string | null): string {\n  const styleBlock = css ? `<style>\\n${css}\\n</style>` : '';\n  return `{% extends \"main.html\" %}\n{% block content %}\n${styleBlock}\n${html}\n{% endblock %}\n`;\n}\n\n// Usage\nconst content = wrapInMaterialOverride(\n  '<section><h1>About Us</h1></section>',\n  'section { padding: 40px; }'\n);\n\n// Result:\n// {% extends \"main.html\" %}\n// {% block content %}\n// <style>\n// section { padding: 40px; }\n// </style>\n// <section><h1>About Us</h1></section>\n// {% endblock %}\n
"},{"location":"v2/features/pages/mkdocs-export/#standalone-mode-export","title":"Standalone Mode Export","text":"
function wrapInStandaloneDocument(\n  html: string,\n  css: string | null,\n  title: string,\n  description: string | null\n): string {\n  const metaDesc = description\n    ? `\\n    <meta name=\"description\" content=\"${description.replace(/\"/g, '&quot;')}\">`\n    : '';\n  const styleBlock = css ? `\\n    <style>\\n${css}\\n    </style>` : '';\n\n  return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>${title.replace(/</g, '&lt;')}</title>${metaDesc}${styleBlock}\n</head>\n<body>\n${html}\n</body>\n</html>\n`;\n}\n\n// Usage\nconst content = wrapInStandaloneDocument(\n  '<section><h1>About Us</h1></section>',\n  'section { padding: 40px; }',\n  'About Us | Campaign 2026',\n  'Join our movement for change.'\n);\n
"},{"location":"v2/features/pages/mkdocs-export/#markdown-stub-generation","title":"Markdown Stub Generation","text":"
interface StubOptions {\n  overrideFilename: string;\n  title: string;\n  description: string | null;\n  hideNav: boolean;\n  hideToc: boolean;\n}\n\nfunction generateMdStub(opts: StubOptions): string {\n  const hideItems: string[] = [];\n  if (opts.hideNav) hideItems.push('  - navigation');\n  if (opts.hideToc) hideItems.push('  - toc');\n\n  const hideBlock = hideItems.length > 0\n    ? `hide:\\n${hideItems.join('\\n')}\\n`\n    : '';\n\n  const descLine = opts.description\n    ? `description: \"${opts.description.replace(/\"/g, '\\\\\"')}\"\\n`\n    : '';\n\n  return `---\ntemplate: ${opts.overrideFilename}\n${hideBlock}title: \"${opts.title.replace(/\"/g, '\\\\\"')}\"\n${descLine}---\n`;\n}\n\n// Usage\nconst stub = generateMdStub({\n  overrideFilename: 'about-us.html',\n  title: 'About Us | Campaign 2026',\n  description: 'Join our movement.',\n  hideNav: true,\n  hideToc: true,\n});\n\n// Result:\n// ---\n// template: about-us.html\n// hide:\n//   - navigation\n//   - toc\n// title: \"About Us | Campaign 2026\"\n// description: \"Join our movement.\"\n// ---\n
"},{"location":"v2/features/pages/mkdocs-export/#export-orchestration","title":"Export Orchestration","text":"
// From pages.service.update()\n\n// After updating page in database\nif (page.published && !page.mkdocsSkipExport && page.mkdocsPath && page.htmlOutput) {\n  const stubPath = await exportToMkDocs({\n    mkdocsPath: page.mkdocsPath,\n    html: page.htmlOutput,\n    css: page.cssOutput,\n    editorMode: page.editorMode,\n    exportMode: page.mkdocsExportMode,\n    title: page.title,\n    seoTitle: page.seoTitle,\n    seoDescription: page.seoDescription,\n    hideNav: page.mkdocsHideNav,\n    hideToc: page.mkdocsHideToc,\n  });\n\n  // Store stubPath if changed\n  if (stubPath !== page.mkdocsStubPath) {\n    await prisma.landingPage.update({\n      where: { id },\n      data: { mkdocsStubPath: stubPath },\n    });\n  }\n} else if ((!page.published || page.mkdocsSkipExport) && existing.mkdocsPath) {\n  // Clean up exports on unpublish or skip\n  await removeFromMkDocs(existing.mkdocsPath, existing.mkdocsStubPath);\n\n  if (existing.mkdocsStubPath) {\n    await prisma.landingPage.update({\n      where: { id },\n      data: { mkdocsStubPath: null },\n    });\n  }\n}\n
"},{"location":"v2/features/pages/mkdocs-export/#path-validation","title":"Path Validation","text":"
function validateMkdocsPath(mkdocsPath: string): void {\n  // Check for null bytes\n  if (mkdocsPath.includes('\\0')) {\n    throw new AppError(400, 'Invalid path: null byte detected', 'INVALID_MKDOCS_PATH');\n  }\n\n  // Normalize and check for traversal\n  const normalized = path.normalize(mkdocsPath);\n  if (normalized.includes('..') || path.isAbsolute(normalized)) {\n    throw new AppError(400, 'Path traversal not allowed', 'INVALID_MKDOCS_PATH');\n  }\n\n  // Check for encoded traversal sequences\n  if (mkdocsPath.includes('%2e') || mkdocsPath.includes('%2E')) {\n    throw new AppError(400, 'Encoded path traversal not allowed', 'INVALID_MKDOCS_PATH');\n  }\n\n  if (!mkdocsPath.endsWith('.html')) {\n    throw new AppError(400, 'Path must end with .html', 'INVALID_MKDOCS_PATH');\n  }\n}\n\n// Safe paths\nvalidateMkdocsPath('about.html'); // \u2713\nvalidateMkdocsPath('pages/contact.html'); // \u2713\n\n// Rejected paths\nvalidateMkdocsPath('../etc/passwd.html'); // \u2717 Path traversal\nvalidateMkdocsPath('/etc/shadow.html'); // \u2717 Absolute path\nvalidateMkdocsPath('admin%2e%2e/config.html'); // \u2717 Encoded traversal\nvalidateMkdocsPath('about.md'); // \u2717 Missing .html extension\n
"},{"location":"v2/features/pages/mkdocs-export/#mkdocs-configuration","title":"MkDocs Configuration","text":""},{"location":"v2/features/pages/mkdocs-export/#mkdocsyml-settings","title":"mkdocs.yml Settings","text":"

Required Configuration:

site_name: Changemaker Lite\ntheme:\n  name: material\n  custom_dir: overrides  # Points to mkdocs/docs/overrides/\n\nnav:\n  - Home: index.md\n  - Pages:\n    - About: about-us.md\n    - Contact: contact.md\n

Key Points:

  • custom_dir: overrides \u2014 Enables template overrides
  • Stub files must be listed in nav to appear in navigation
  • Unlisted stubs still accessible via direct URL
"},{"location":"v2/features/pages/mkdocs-export/#template-search-paths","title":"Template Search Paths","text":"

MkDocs Material searches:

  1. mkdocs/overrides/ (custom_dir)
  2. Material theme templates
  3. MkDocs core templates

Resolution:

# In stub front matter\ntemplate: about-us.html\n\n# MkDocs searches:\n# 1. mkdocs/overrides/about-us.html \u2713 (found here)\n# 2. material/templates/about-us.html\n# 3. mkdocs/templates/about-us.html\n

Common Mistake:

# WRONG - causes TemplateNotFound\ntemplate: overrides/about-us.html\n\n# MkDocs searches:\n# 1. mkdocs/overrides/overrides/about-us.html \u2717 (not found)\n

Solution: Use filename only, not path with overrides/.

"},{"location":"v2/features/pages/mkdocs-export/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/pages/mkdocs-export/#problem-template-not-found-error","title":"Problem: Template Not Found Error","text":"

Symptoms:

  • MkDocs build fails
  • Error: jinja2.exceptions.TemplateNotFound: overrides/about-us.html

Causes:

  1. Stub uses template: overrides/about-us.html (incorrect path)
  2. custom_dir not configured in mkdocs.yml
  3. Override file doesn't exist

Solutions:

  1. Fix stub front matter:

    # Before (wrong)\ntemplate: overrides/about-us.html\n\n# After (correct)\ntemplate: about-us.html\n

  2. Verify custom_dir:

    # In mkdocs.yml\ntheme:\n  name: material\n  custom_dir: overrides\n

  3. Check file exists:

    ls -la mkdocs/docs/overrides/about-us.html\n# Should exist if page published\n

  4. Validate exports:

  5. Admin \u2192 Pages \u2192 \"Validate Exports\"
  6. Repairs missing files
"},{"location":"v2/features/pages/mkdocs-export/#problem-export-files-missing-after-restart","title":"Problem: Export Files Missing After Restart","text":"

Symptoms:

  • Pages were published before restart
  • After docker compose restart: Files gone
  • MkDocs build fails

Causes:

  1. Volume mount not configured
  2. Files written to container filesystem (not host)
  3. Container recreated (ephemeral storage lost)

Solutions:

  1. Check volume mount:

    # In docker-compose.yml\nservices:\n  api:\n    volumes:\n      - ./mkdocs:/mkdocs:rw  # Must have :rw for write access\n

  2. Verify host files:

    ls -la mkdocs/docs/overrides/\n# Files should persist on host filesystem\n

  3. Re-export all pages:

  4. Admin \u2192 Pages \u2192 \"Validate Exports\"
  5. Regenerates all missing files
"},{"location":"v2/features/pages/mkdocs-export/#problem-page-not-appearing-in-mkdocs-site","title":"Problem: Page Not Appearing in MkDocs Site","text":"

Symptoms:

  • Page published, files exist
  • MkDocs builds successfully
  • Page shows 404 on site

Causes:

  1. Stub not listed in mkdocs.yml nav
  2. MkDocs not rebuilt after export
  3. Nginx cache serving old version

Solutions:

  1. Add to nav (optional):

    nav:\n  - Pages:\n    - About: about-us.md  # Stub filename\n

  2. Rebuild MkDocs:

    docker compose exec mkdocs mkdocs build\n# Or Admin \u2192 Pages \u2192 \"Build Site\"\n

  3. Clear Nginx cache:

    docker compose exec nginx nginx -s reload\n

  4. Test direct access:

    curl http://localhost:4001/pages/about-us/\n# Should return HTML, not 404\n

"},{"location":"v2/features/pages/mkdocs-export/#problem-styles-not-applying-in-mkdocs","title":"Problem: Styles Not Applying in MkDocs","text":"

Symptoms:

  • Page renders in GrapesJS editor
  • MkDocs site shows unstyled content

Causes:

  1. CSS not exported (CODE mode without cssOutput)
  2. Material theme CSS conflicts
  3. Inline styles overridden

Solutions:

  1. Check cssOutput field:

    SELECT css_output FROM landing_pages WHERE slug = 'about-us';\n-- Should contain CSS, not NULL\n

  2. Inspect rendered HTML:

    curl http://localhost:4001/pages/about-us/ | grep '<style>'\n# Should include page CSS\n

  3. Use !important for overrides:

    /* In page CSS */\nsection {\n  padding: 40px !important;\n}\n

  4. Test STANDALONE mode:

  5. Settings \u2192 Full page MkDocs (checked)
  6. Bypasses Material theme CSS
"},{"location":"v2/features/pages/mkdocs-export/#problem-hide-navigation-not-working","title":"Problem: Hide Navigation Not Working","text":"

Symptoms:

  • Page settings: mkdocsHideNav=true
  • Navigation sidebar still shows

Causes:

  1. Stub front matter not updated
  2. MkDocs cache not cleared
  3. STANDALONE mode enabled (hide options ignored)

Solutions:

  1. Check stub front matter:

    cat mkdocs/docs/about-us.md\n# Should have:\n# hide:\n#   - navigation\n

  2. Re-export:

  3. Edit page settings \u2192 Save
  4. Triggers stub regeneration

  5. Clear MkDocs cache:

    rm -rf mkdocs/site/\ndocker compose exec mkdocs mkdocs build\n

  6. Verify not STANDALONE:

  7. Settings \u2192 Full page MkDocs (unchecked)
  8. STANDALONE ignores hide options
"},{"location":"v2/features/pages/mkdocs-export/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/pages/mkdocs-export/#file-system-io","title":"File System I/O","text":"

Export operation: Writes 2 files per page (~1ms each)

Bottleneck: Synchronous file writes in API request handler

Impact:

  • Publish operation: +2ms overhead
  • Batch operations: Linear scaling (10 pages = +20ms)

Optimization (future):

// Current: Synchronous writes in request\nawait fs.writeFile(path, content);\n\n// Future: Background job queue\nawait queue.add('export-page', { pageId });\n
"},{"location":"v2/features/pages/mkdocs-export/#mkdocs-build-time","title":"MkDocs Build Time","text":"

Build duration: Proportional to page count

  • 10 pages: ~2 seconds
  • 100 pages: ~10 seconds
  • 1000 pages: ~90 seconds

Optimization:

  • Use mkdocs serve --dirtyreload in dev (incremental builds)
  • Production builds: Full rebuild recommended
"},{"location":"v2/features/pages/mkdocs-export/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/features/pages/mkdocs-export/#path-traversal-protection","title":"Path Traversal Protection","text":"

Validation:

  1. Null byte check: Prevents about\\0.html attacks
  2. Normalization: path.normalize() resolves ../
  3. Absolute path check: Rejects /etc/passwd.html
  4. Encoded traversal: Blocks %2e%2e/admin.html
  5. Extension validation: Must end with .html

Rejected Paths:

  • ../../../etc/passwd.html
  • /var/www/config.html
  • admin%2e%2e%2fconfig.html
  • about.md (wrong extension)
"},{"location":"v2/features/pages/mkdocs-export/#file-permission-isolation","title":"File Permission Isolation","text":"

Docker Volume Mount:

volumes:\n  - ./mkdocs:/mkdocs:rw\n

Permissions:

  • API container writes as node user (UID 1000)
  • Host user must have write access to mkdocs/docs/
  • MkDocs container reads as mkdocs user (UID 1001)

Risk: Container escape could write arbitrary files

Mitigation:

  • API container runs as non-root user
  • Volume mount scoped to /mkdocs only (no host root access)
"},{"location":"v2/features/pages/mkdocs-export/#template-injection","title":"Template Injection","text":"

Risk: Malicious admin injects Jinja2 code

Example:

<!-- Malicious HTML in editor -->\n<h1>{{ config.site_name }}</h1>\n

Rendering:

  • THEMED mode: Jinja2 processes {{ }} expressions
  • Could expose MkDocs config or Material theme internals

Mitigation:

  • Accepted risk: Admins are trusted users
  • Template code only renders in MkDocs (isolated from main app)
  • Public users cannot edit landing pages
"},{"location":"v2/features/pages/mkdocs-export/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/pages/mkdocs-export/#frontend-components","title":"Frontend Components","text":"
  • LandingPagesPage \u2014 Export buttons + validation
  • PageEditorPage \u2014 Auto-export on publish
"},{"location":"v2/features/pages/mkdocs-export/#backend-modules","title":"Backend Modules","text":"
  • pages.service \u2014 Export logic (exportToMkDocs, validateExports, syncOverrides)
  • pages-admin.routes \u2014 /sync and /validate endpoints
"},{"location":"v2/features/pages/mkdocs-export/#features","title":"Features","text":"
  • Page Builder \u2014 Landing page system overview
  • GrapesJS Editor \u2014 Editor integration
  • Block Library \u2014 Reusable blocks
"},{"location":"v2/features/pages/mkdocs-export/#mkdocs-resources","title":"MkDocs Resources","text":"
  • MkDocs Material Templates \u2014 Theme customization
  • Jinja2 Documentation \u2014 Template syntax
  • MkDocs Configuration \u2014 mkdocs.yml reference
"},{"location":"v2/features/pages/mkdocs-export/#deployment","title":"Deployment","text":"
  • Docker Setup \u2014 Volume mounts + permissions
  • MkDocs Service \u2014 Container configuration
"},{"location":"v2/features/pages/page-builder/","title":"Page Builder","text":"

Complete WYSIWYG landing page builder with GrapesJS editor, slug-based public routing, and MkDocs Material theme integration.

"},{"location":"v2/features/pages/page-builder/#overview","title":"Overview","text":"

The Page Builder system provides a comprehensive solution for creating custom landing pages without coding. Administrators can use a visual drag-and-drop interface or write raw HTML/CSS directly.

"},{"location":"v2/features/pages/page-builder/#key-features","title":"Key Features","text":"
  • Dual-Mode Editing: Switch between VISUAL (GrapesJS drag-and-drop) and CODE (raw HTML editor)
  • Slug-Based Routing: Public pages accessible at /p/:slug (e.g., /p/about-us)
  • MkDocs Export: Publish pages to MkDocs documentation site with Material theme integration
  • SEO Meta Tags: Configure title, description, and Open Graph images
  • Custom Blocks: Reusable components (hero, features, CTA, testimonials, contact forms)
  • Video Integration: Embed media library videos with standard or advanced players
  • Mobile Detection: Editor warns users on small screens (desktop-only editing)
"},{"location":"v2/features/pages/page-builder/#architecture-overview","title":"Architecture Overview","text":"
graph LR\n    A[Admin] --> B[LandingPagesPage]\n    B --> C[Create Page Modal]\n    C --> D[LandingPageEditor]\n    D --> E[GrapesJS Editor]\n    E --> F[Save API]\n    F --> G[(LandingPage Model)]\n    G --> H[Public Route]\n    H --> I[/p/:slug]\n\n    D --> J[Publish Toggle]\n    J --> K[MkDocs Export]\n    K --> L[overrides/*.html]\n    K --> M[docs/*.md stub]\n\n    style E fill:#9d4edd\n    style G fill:#3498db\n    style K fill:#2ecc71

Flow:

  1. Admin creates page via LandingPagesPage
  2. Editor loads with GrapesJS (VISUAL mode) or Monaco (CODE mode)
  3. Admin drags blocks, configures properties, saves (Ctrl+S)
  4. API stores projectData (GrapesJS JSON), htmlOutput, cssOutput
  5. On publish: API exports .html override + .md stub to MkDocs
  6. Public users access page at /p/:slug (React route renders HTML)
"},{"location":"v2/features/pages/page-builder/#database-models","title":"Database Models","text":""},{"location":"v2/features/pages/page-builder/#landingpage","title":"LandingPage","text":"

Table: landing_pages

Key Fields:

Field Type Description id String (UUID) Primary key slug String Unique URL-safe identifier (auto-generated from title) title String Page title (internal + fallback SEO) description String? Page description (internal) editorMode Enum VISUAL (GrapesJS) or CODE (raw HTML) blocks JSON GrapesJS projectData (components tree) htmlOutput String? Rendered HTML (cached output from editor) cssOutput String? Rendered CSS (cached output from editor) mkdocsPath String? Override file path (e.g., about.html) mkdocsStubPath String? Stub Markdown path (e.g., about.md) mkdocsExportMode Enum THEMED (extends main.html) or STANDALONE (full HTML) mkdocsHideNav Boolean Hide navigation sidebar in MkDocs mkdocsHideToc Boolean Hide table of contents in MkDocs mkdocsSkipExport Boolean Don't export to MkDocs (only accessible via /p/:slug) published Boolean Public visibility (false = draft) seoTitle String? Custom SEO title (overrides title) seoDescription String? Meta description for search engines seoImage String? Open Graph image URL createdAt DateTime Creation timestamp updatedAt DateTime Last modification timestamp

Indexes:

  • slug (unique)
  • published (filter index)

Relationships:

  • None (standalone model)
"},{"location":"v2/features/pages/page-builder/#pageblock","title":"PageBlock","text":"

See Block Library documentation.

"},{"location":"v2/features/pages/page-builder/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/pages/page-builder/#admin-routes","title":"Admin Routes","text":"

Prefix: /api/pages

Authentication: Requires admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN)

"},{"location":"v2/features/pages/page-builder/#list-pages","title":"List Pages","text":"
GET /api/pages?page=1&limit=20&search=campaign&published=true\n

Query Parameters:

  • page (number, default: 1) \u2014 Page number
  • limit (number, default: 20, max: 100) \u2014 Results per page
  • search (string?) \u2014 Search title, description, or slug (case-insensitive)
  • published (string?) \u2014 Filter by status: \"true\", \"false\", or omit for all

Response:

{\n  \"pages\": [\n    {\n      \"id\": \"abc123\",\n      \"slug\": \"about-us\",\n      \"title\": \"About Our Campaign\",\n      \"description\": \"Learn more about our mission.\",\n      \"editorMode\": \"VISUAL\",\n      \"blocks\": { /* GrapesJS JSON */ },\n      \"htmlOutput\": \"<section>...</section>\",\n      \"cssOutput\": \"section { padding: 40px; }\",\n      \"mkdocsPath\": \"about.html\",\n      \"mkdocsStubPath\": \"about.md\",\n      \"mkdocsExportMode\": \"THEMED\",\n      \"mkdocsHideNav\": false,\n      \"mkdocsHideToc\": true,\n      \"mkdocsSkipExport\": false,\n      \"published\": true,\n      \"seoTitle\": \"About Us | Campaign 2026\",\n      \"seoDescription\": \"Join our movement for change.\",\n      \"seoImage\": \"https://example.com/og-image.jpg\",\n      \"createdAt\": \"2026-01-15T10:00:00Z\",\n      \"updatedAt\": \"2026-02-13T14:30:00Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 5,\n    \"totalPages\": 1\n  }\n}\n
"},{"location":"v2/features/pages/page-builder/#get-page","title":"Get Page","text":"
GET /api/pages/:id\n

Response: Single LandingPage object (same structure as list item above)

Errors:

  • 404 PAGE_NOT_FOUND \u2014 Page doesn't exist
"},{"location":"v2/features/pages/page-builder/#create-page","title":"Create Page","text":"
POST /api/pages\nContent-Type: application/json\n\n{\n  \"title\": \"New Landing Page\",\n  \"description\": \"Page description\",\n  \"editorMode\": \"VISUAL\"\n}\n

Request Body:

  • title (string, required) \u2014 Page title (slug auto-generated)
  • description (string?) \u2014 Internal description
  • editorMode (enum?, default: VISUAL) \u2014 VISUAL or CODE
  • mkdocsPath (string?) \u2014 Custom override path (defaults to {slug}.html)

Response: Created LandingPage object (201 status)

Errors:

  • 400 INVALID_MKDOCS_PATH \u2014 Invalid path (traversal attempt, missing .html extension)

Behavior:

  • Slug auto-generated from title (lowercased, spaces\u2192hyphens, alphanumeric only)
  • Slug collision handling (appends -2, -3, etc.)
  • blocks initialized as empty JSON object
  • published defaults to false
"},{"location":"v2/features/pages/page-builder/#update-page","title":"Update Page","text":"
PUT /api/pages/:id\nContent-Type: application/json\n\n{\n  \"blocks\": { /* GrapesJS projectData */ },\n  \"htmlOutput\": \"<section>...</section>\",\n  \"cssOutput\": \"section { padding: 40px; }\",\n  \"published\": true\n}\n

Request Body: (all fields optional)

  • title (string?) \u2014 New title (regenerates slug if changed)
  • description (string?)
  • blocks (JSON?) \u2014 GrapesJS projectData
  • htmlOutput (string?) \u2014 Rendered HTML
  • cssOutput (string?) \u2014 Rendered CSS
  • published (boolean?) \u2014 Publish status
  • mkdocsPath (string?) \u2014 Custom override path
  • mkdocsExportMode (enum?) \u2014 THEMED or STANDALONE
  • mkdocsHideNav (boolean?)
  • mkdocsHideToc (boolean?)
  • mkdocsSkipExport (boolean?)
  • seoTitle (string?)
  • seoDescription (string?)
  • seoImage (string?)

Response: Updated LandingPage object

Errors:

  • 404 PAGE_NOT_FOUND \u2014 Page doesn't exist
  • 400 INVALID_MKDOCS_PATH \u2014 Invalid path

Side Effects:

  • On publish (published=true, mkdocsSkipExport=false): Exports to MkDocs (writes .html + .md stub)
  • On unpublish or mkdocsSkipExport=true: Removes MkDocs files
  • On title change: Regenerates slug, updates mkdocsPath if it was auto-generated, cleans up old exports
"},{"location":"v2/features/pages/page-builder/#delete-page","title":"Delete Page","text":"
DELETE /api/pages/:id\n

Response: 204 No Content

Errors:

  • 404 PAGE_NOT_FOUND \u2014 Page doesn't exist

Side Effects:

  • Removes MkDocs exports (.html override + .md stub) if they exist
"},{"location":"v2/features/pages/page-builder/#sync-overrides","title":"Sync Overrides","text":"
POST /api/pages/sync\n

Purpose: Import untracked .html files from mkdocs/docs/overrides/ as CODE-mode pages. Useful for migrating hand-crafted HTML templates.

Response:

{\n  \"imported\": 2,\n  \"updated\": 1,\n  \"stubs\": 3\n}\n

Behavior:

  1. Scans mkdocs/docs/overrides/ recursively for .html files
  2. For untracked files: Creates new CODE-mode page (published=true)
  3. For tracked CODE-mode pages: Updates htmlOutput from disk (disk wins)
  4. For tracked VISUAL-mode pages: Skips (managed by GrapesJS)
  5. Backfills missing .md stubs for published pages

Use Cases:

  • Migrate legacy hand-coded landing pages
  • Import templates from designers
  • Sync after manual file system edits
"},{"location":"v2/features/pages/page-builder/#validate-exports","title":"Validate Exports","text":"
POST /api/pages/validate\n

Purpose: Verify MkDocs exports exist on disk, repair if missing.

Response:

{\n  \"validated\": 10,\n  \"repaired\": 2,\n  \"errors\": [\n    {\n      \"pageId\": \"xyz789\",\n      \"slug\": \"broken-page\",\n      \"error\": \"EACCES: permission denied\"\n    }\n  ]\n}\n

Behavior:

  1. Queries all published, non-skipped pages with mkdocsPath
  2. Checks if .html override and .md stub exist
  3. Re-exports if either missing
  4. Updates mkdocsStubPath if changed
  5. Returns error list for manual intervention

Use Cases:

  • Recover from accidental file deletion
  • Fix export state after container restarts
  • Audit before MkDocs rebuild
"},{"location":"v2/features/pages/page-builder/#public-routes","title":"Public Routes","text":"

Prefix: /api/pages

Authentication: None (public access)

"},{"location":"v2/features/pages/page-builder/#view-published-page","title":"View Published Page","text":"
GET /api/pages/:slug/view\n

Example:

GET /api/pages/about-us/view\n

Response:

{\n  \"id\": \"abc123\",\n  \"slug\": \"about-us\",\n  \"title\": \"About Our Campaign\",\n  \"htmlOutput\": \"<section>...</section>\",\n  \"cssOutput\": \"section { padding: 40px; }\",\n  \"seoTitle\": \"About Us | Campaign 2026\",\n  \"seoDescription\": \"Join our movement for change.\",\n  \"seoImage\": \"https://example.com/og-image.jpg\",\n  \"createdAt\": \"2026-01-15T10:00:00Z\",\n  \"updatedAt\": \"2026-02-13T14:30:00Z\"\n}\n

Errors:

  • 404 PAGE_NOT_FOUND \u2014 Page doesn't exist or is unpublished

Security:

  • Only returns published pages (published=true)
  • Omits editor-only fields (blocks, mkdocsPath, etc.)
"},{"location":"v2/features/pages/page-builder/#configuration","title":"Configuration","text":""},{"location":"v2/features/pages/page-builder/#environment-variables","title":"Environment Variables","text":"
# MkDocs integration\nMKDOCS_DOCS_PATH=/mkdocs/docs\n# Override path: ${MKDOCS_DOCS_PATH}/overrides/\n# Stub path: ${MKDOCS_DOCS_PATH}/ (root of docs)\n

Docker Volume:

volumes:\n  - ./mkdocs:/mkdocs:rw\n

Note: API container needs write access to export files.

"},{"location":"v2/features/pages/page-builder/#site-settings","title":"Site Settings","text":"

Feature Flag: ENABLE_LANDING_PAGES

Location: Admin \u2192 Settings \u2192 Features \u2192 Landing Pages

Default: true

Effect: Shows/hides \"Pages\" menu item in admin sidebar

"},{"location":"v2/features/pages/page-builder/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/pages/page-builder/#creating-a-page","title":"Creating a Page","text":"
  1. Navigate: Admin sidebar \u2192 Pages
  2. Click: \"Create Page\" button
  3. Fill form:
  4. Title: \"About Us\" (slug auto-generated: about-us)
  5. Description: \"Learn about our campaign\" (optional)
  6. Editor Mode: VISUAL (default) or CODE
  7. Submit: \"Create & Edit\" button
  8. Result: Redirected to full-screen editor
"},{"location":"v2/features/pages/page-builder/#visual-editing-visual-mode","title":"Visual Editing (VISUAL Mode)","text":"
  1. Editor opens: GrapesJS interface with 3 panels:
  2. Left: Block library (drag-and-drop components)
  3. Center: Canvas (preview + inline editing)
  4. Right: Properties panel (configure selected component)
  5. Add blocks: Drag \"Hero Section\" from left panel to canvas
  6. Configure: Click hero \u2192 Edit title/subtitle/CTA in right panel
  7. Save: Press Ctrl+S (or Cmd+S on Mac) \u2192 API saves projectData, htmlOutput, cssOutput
  8. Close: Click \"X\" or \"Back to Pages\" \u2192 Returns to table
"},{"location":"v2/features/pages/page-builder/#code-editing-code-mode","title":"Code Editing (CODE Mode)","text":"
  1. Editor opens: Split-view Monaco editors:
  2. Left: HTML editor
  3. Right: CSS editor (optional)
  4. Edit HTML: Write raw HTML with Jinja2 template syntax (for MkDocs)
  5. Save: Press Ctrl+S \u2192 API saves htmlOutput, cssOutput
  6. Close: Click \"Back to Pages\"
"},{"location":"v2/features/pages/page-builder/#publishing-a-page","title":"Publishing a Page","text":"

Option 1: From Table

  1. Locate page in table
  2. Click \"Publish\" button in Actions column
  3. Status tag changes: Draft \u2192 Published
  4. Page accessible at /p/{slug}

Option 2: From Settings Modal

  1. Click gear icon (Settings) in Actions column
  2. Settings modal opens
  3. (Field not shown in modal \u2014 use table toggle)

Side Effects (on publish):

  • If mkdocsSkipExport=false: Exports .html + .md to MkDocs
  • If mkdocsSkipExport=true: Only accessible via /p/:slug (no MkDocs export)
"},{"location":"v2/features/pages/page-builder/#configuring-seo","title":"Configuring SEO","text":"
  1. Click gear icon (Settings) in Actions column
  2. Fill SEO section:
  3. SEO Title: Custom title for <title> and Open Graph (defaults to title)
  4. SEO Description: Meta description for search engines
  5. SEO Image: Full URL to Open Graph image (e.g., https://cdn.example.com/og.jpg)
  6. Click \"Save\"
  7. Re-export to MkDocs if already published
"},{"location":"v2/features/pages/page-builder/#mkdocs-integration-settings","title":"MkDocs Integration Settings","text":"

Access: Page Settings modal \u2192 MkDocs Integration section

Fields:

  1. Skip MkDocs Export (checkbox)
  2. When enabled: Page NOT exported to MkDocs site
  3. Use case: Pages meant only for /p/:slug (not documentation)
  4. Default: false (export enabled)

  5. Override Path (text input)

  6. Custom filename for override (e.g., custom-about.html)
  7. Default: Auto-generated from slug ({slug}.html)
  8. Validation: Must end with .html, no path traversal

  9. Full page MkDocs (checkbox)

  10. When enabled: Exports as STANDALONE (full <!DOCTYPE html> document)
  11. When disabled: Exports as THEMED (wraps in {% extends \"main.html\" %})
  12. Default: false (THEMED)
  13. Use case: Standalone pages with no MkDocs chrome (like lander.html)

  14. Hide navigation sidebar (checkbox, only for THEMED mode)

  15. Adds hide: [navigation] to .md stub front matter
  16. Hides left sidebar on page
  17. Default: false

  18. Hide table of contents (checkbox, only for THEMED mode)

  19. Adds hide: [toc] to .md stub front matter
  20. Hides right sidebar on page
  21. Default: false

Workflow:

  1. Edit page settings
  2. Configure MkDocs options
  3. Save settings
  4. If published: API auto-exports with new settings
  5. Rebuild MkDocs: Admin \u2192 Pages \u2192 \"Build Site\" button
"},{"location":"v2/features/pages/page-builder/#syncing-overrides","title":"Syncing Overrides","text":"

Purpose: Import hand-coded .html files from disk

Workflow:

  1. Place .html files in mkdocs/docs/overrides/ (on Docker host)
  2. Admin \u2192 Pages \u2192 \"Sync Overrides\" button
  3. API scans directory, imports new files as CODE-mode pages
  4. Table refreshes, new pages appear
  5. Edit pages normally, publish as needed

Example:

# On Docker host\necho '<h1>Custom Page</h1>' > mkdocs/docs/overrides/custom.html\n\n# In admin panel\n# Click \"Sync Overrides\" \u2192 1 imported\n
"},{"location":"v2/features/pages/page-builder/#validating-exports","title":"Validating Exports","text":"

Purpose: Verify MkDocs files exist, repair if missing

Workflow:

  1. Admin \u2192 Pages \u2192 \"Validate Exports\" button
  2. API checks all published pages:
  3. .html override exists?
  4. .md stub exists?
  5. Re-exports if either missing
  6. Shows result: Validated 10 pages: 2 repaired

Use Cases:

  • After container restart (volume mount issues)
  • After manual file deletion
  • Before rebuilding MkDocs site
"},{"location":"v2/features/pages/page-builder/#public-workflow","title":"Public Workflow","text":""},{"location":"v2/features/pages/page-builder/#viewing-a-published-page","title":"Viewing a Published Page","text":"
  1. User navigates: https://yoursite.com/p/about-us
  2. React router: Matches /p/:slug route \u2192 Loads LandingPage.tsx
  3. API call: GET /api/pages/about-us/view
  4. Response: Returns htmlOutput, cssOutput, SEO fields
  5. Render:
  6. Sets document.title = seoTitle || title
  7. Updates meta description, Open Graph image
  8. Injects cssOutput as <style> tag
  9. Renders htmlOutput via dangerouslySetInnerHTML
  10. Video hydration: Scans for .video-block divs, replaces placeholders with React VideoPlayer components
"},{"location":"v2/features/pages/page-builder/#seo-meta-tags","title":"SEO Meta Tags","text":"

Applied automatically on page load:

<html>\n<head>\n  <title>About Us | Campaign 2026</title>\n  <meta name=\"description\" content=\"Join our movement for change.\">\n  <meta property=\"og:image\" content=\"https://example.com/og-image.jpg\">\n</head>\n<body>\n  <style>section { padding: 40px; }</style>\n  <section>...</section>\n</body>\n</html>\n
"},{"location":"v2/features/pages/page-builder/#video-embedding","title":"Video Embedding","text":"

Editor Placeholder:

<div class=\"video-block\"\n     data-video-id=\"123\"\n     data-player-type=\"advanced\"\n     data-width=\"100%\"\n     data-autoplay=\"false\"\n     data-controls=\"true\"\n     data-show-reactions=\"true\">\n  <div class=\"video-placeholder\">\n    <!-- SVG play icon + metadata -->\n  </div>\n</div>\n

Runtime Hydration:

  1. LandingPage.tsx mounts \u2192 Scans for .video-block elements
  2. Reads data-* attributes
  3. Creates React root for each block
  4. Renders AdvancedVideoPlayer or VideoPlayer component
  5. Replaces placeholder with live player

Supported Attributes:

  • data-video-id (required) \u2014 Media library video ID
  • data-player-type (\"standard\" or \"advanced\", default: \"standard\")
  • data-width (CSS value, default: \"100%\")
  • data-height (CSS value, default: \"auto\")
  • data-autoplay (\"true\" or \"false\", default: \"false\")
  • data-controls (\"true\" or \"false\", default: \"true\")
  • data-show-reactions (\"true\" or \"false\", default: \"true\", advanced player only)
"},{"location":"v2/features/pages/page-builder/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/pages/page-builder/#creating-a-page-typescript","title":"Creating a Page (TypeScript)","text":"
import { api } from '@/lib/api';\n\nasync function createAboutPage() {\n  const { data } = await api.post('/pages', {\n    title: 'About Us',\n    description: 'Learn about our campaign',\n    editorMode: 'VISUAL',\n  });\n\n  console.log('Created page:', data.slug); // \"about-us\"\n  return data.id;\n}\n
"},{"location":"v2/features/pages/page-builder/#saving-editor-state-grapesjs","title":"Saving Editor State (GrapesJS)","text":"
// In LandingPageEditor component\nimport { useRef } from 'react';\nimport GrapesJSEditor, { GrapesJSEditorHandle } from '@/components/GrapesJSEditor';\n\nconst editorRef = useRef<GrapesJSEditorHandle>(null);\n\nconst handleSave = () => {\n  editorRef.current?.triggerSave(); // Calls registered save command\n};\n\nconst handleEditorSave = async (data: { projectData: any; html: string; css: string }) => {\n  await api.put(`/pages/${pageId}`, {\n    blocks: data.projectData,\n    htmlOutput: data.html,\n    cssOutput: data.css,\n  });\n  message.success('Page saved');\n};\n\nreturn (\n  <GrapesJSEditor\n    ref={editorRef}\n    initialData={page.blocks}\n    onSave={handleEditorSave}\n  />\n);\n
"},{"location":"v2/features/pages/page-builder/#fetching-published-page-public-route","title":"Fetching Published Page (Public Route)","text":"
import axios from 'axios';\n\nasync function loadLandingPage(slug: string) {\n  try {\n    const { data } = await axios.get(`/api/pages/${slug}/view`);\n\n    // Set SEO\n    document.title = data.seoTitle || data.title;\n\n    // Inject CSS\n    const style = document.createElement('style');\n    style.textContent = data.cssOutput || '';\n    document.head.appendChild(style);\n\n    // Render HTML\n    return data.htmlOutput;\n  } catch (error) {\n    if (axios.isAxiosError(error) && error.response?.status === 404) {\n      throw new Error('Page not found or unpublished');\n    }\n    throw error;\n  }\n}\n
"},{"location":"v2/features/pages/page-builder/#mkdocs-export-logic-backend","title":"MkDocs Export Logic (Backend)","text":"
// From pages.service.ts\n\nfunction wrapInMaterialOverride(html: string, css: string | null): string {\n  const styleBlock = css ? `<style>\\n${css}\\n</style>` : '';\n  return `{% extends \"main.html\" %}\n{% block content %}\n${styleBlock}\n${html}\n{% endblock %}\n`;\n}\n\nasync function exportToMkDocs(opts: ExportOptions): Promise<string> {\n  const { mkdocsPath, html, css, exportMode, title, seoTitle, seoDescription } = opts;\n\n  // Write override template\n  const filePath = path.join(MKDOCS_OVERRIDES, mkdocsPath);\n  const content = exportMode === 'STANDALONE'\n    ? wrapInStandaloneDocument(html, css, seoTitle || title, seoDescription)\n    : wrapInMaterialOverride(html, css);\n\n  await fs.writeFile(filePath, content, 'utf-8');\n\n  // Write .md stub\n  const stubPath = mkdocsPath.replace(/\\.html$/, '.md');\n  const stubContent = `---\ntemplate: ${mkdocsPath}\ntitle: \"${seoTitle || title}\"\n---\n`;\n  await fs.writeFile(path.join(MKDOCS_DOCS_ROOT, stubPath), stubContent, 'utf-8');\n\n  return stubPath;\n}\n
"},{"location":"v2/features/pages/page-builder/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/pages/page-builder/#problem-grapesjs-editor-not-loading","title":"Problem: GrapesJS Editor Not Loading","text":"

Symptoms:

  • Blank screen in editor
  • Console error: Cannot read property 'init' of undefined

Causes:

  • GrapesJS package not installed
  • CSS import missing
  • Plugin incompatibility

Solutions:

  1. Verify installation:

    cd admin && npm list grapesjs\n# Should show: grapesjs@0.21.x\n

  2. Check CSS import:

    // In GrapesJSEditor.tsx\nimport 'grapesjs/dist/css/grapes.min.css';\n

  3. Check browser console:

  4. Look for grapesjs variable in global scope
  5. Verify all plugins loaded successfully

  6. Clear cache:

    # In browser DevTools\n# Right-click Reload \u2192 Empty Cache and Hard Reload\n

"},{"location":"v2/features/pages/page-builder/#problem-published-page-not-rendering","title":"Problem: Published Page Not Rendering","text":"

Symptoms:

  • 404 error at /p/my-page
  • Page exists in database, published=true

Causes:

  • React route not registered
  • Slug mismatch
  • Public route mounted incorrectly

Solutions:

  1. Verify route registration:

    // In admin/src/App.tsx\n<Route path=\"/p/:slug\" element={<LandingPage />} />\n

  2. Check slug in URL:

  3. Slug is case-sensitive: /p/About-Us \u2260 /p/about-us
  4. Use lowercase, hyphenated: /p/about-us

  5. Test API directly:

    curl http://localhost:4000/api/pages/about-us/view\n# Should return JSON, not 404\n

  6. Check published status:

    SELECT slug, published FROM landing_pages WHERE slug = 'about-us';\n-- published should be true\n

"},{"location":"v2/features/pages/page-builder/#problem-mobile-warning-shows-on-desktop","title":"Problem: Mobile Warning Shows on Desktop","text":"

Symptoms:

  • \"Desktop Required\" warning displays on 1920px screen
  • Editor won't load

Causes:

  • Browser window width < 768px
  • Breakpoint detection failure
  • DevTools docked (reduces viewport width)

Solutions:

  1. Check actual viewport width:

    // In browser console\nconsole.log(window.innerWidth);\n// Should be > 768 for desktop\n

  2. Undock DevTools:

  3. Press F12 \u2192 Click \u22ee (three dots) \u2192 Dock to right/bottom \u2192 Undock
  4. Increases available viewport width

  5. Verify breakpoint hook:

    // In PageEditorPage.tsx\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md; // md = 768px\n

  6. Test responsive mode:

  7. F12 \u2192 Toggle device toolbar (Ctrl+Shift+M)
  8. Select \"Responsive\" \u2192 Set width to 1024px
"},{"location":"v2/features/pages/page-builder/#problem-mkdocs-export-not-found","title":"Problem: MkDocs Export Not Found","text":"

Symptoms:

  • MkDocs site shows 404 for /pages/about-us/
  • Override file missing from mkdocs/docs/overrides/

Causes:

  • Page not published
  • mkdocsSkipExport=true
  • Export path incorrect
  • MkDocs not rebuilt

Solutions:

  1. Verify publish status:

    SELECT slug, published, mkdocs_skip_export FROM landing_pages WHERE slug = 'about-us';\n-- Both should be true/false appropriately\n

  2. Check export path:

    ls -la mkdocs/docs/overrides/about.html\n# Should exist if published and not skipped\n

  3. Validate exports:

  4. Admin \u2192 Pages \u2192 \"Validate Exports\" button
  5. Check repair count

  6. Rebuild MkDocs:

    docker compose exec mkdocs mkdocs build\n# Or in admin: Pages \u2192 \"Build Site\"\n

  7. Check template path in stub:

    cat mkdocs/docs/about.md\n# Should show: template: about.html (NOT overrides/about.html)\n

"},{"location":"v2/features/pages/page-builder/#problem-slug-collision-on-create","title":"Problem: Slug Collision on Create","text":"

Symptoms:

  • Create page with title \"About Us\" \u2192 slug becomes about-us-2
  • Expected about-us but already taken

Causes:

  • Existing page with same slug (possibly unpublished)
  • Soft-deleted page (if soft delete implemented)

Solutions:

  1. Check existing pages:

    SELECT id, title, slug, published FROM landing_pages WHERE slug LIKE 'about-us%';\n

  2. Delete duplicate:

  3. If old page is unwanted: Admin \u2192 Pages \u2192 Delete
  4. New page can reuse slug

  5. Use unique title:

  6. Rename new page: \"About Us 2026\" \u2192 slug about-us-2026

  7. Manual slug override:

  8. After create: Edit page \u2192 Settings \u2192 Override Path \u2192 about-us-custom.html
"},{"location":"v2/features/pages/page-builder/#problem-video-block-not-hydrating","title":"Problem: Video Block Not Hydrating","text":"

Symptoms:

  • Video placeholder shows on published page
  • No player renders
  • Console error: Invalid video ID: PLACEHOLDER

Causes:

  • data-video-id=\"PLACEHOLDER\" not replaced
  • Video ID not numeric
  • Hydration script not running

Solutions:

  1. Check video ID in editor:
  2. Open GrapesJS editor \u2192 Select video block
  3. Properties panel \u2192 Video ID field should be numeric (e.g., 123)
  4. Not PLACEHOLDER

  5. Verify HTML output:

    <!-- Bad -->\n<div class=\"video-block\" data-video-id=\"PLACEHOLDER\">...</div>\n\n<!-- Good -->\n<div class=\"video-block\" data-video-id=\"42\">...</div>\n

  6. Check hydration script:

    // In LandingPage.tsx\nuseEffect(() => {\n  // Should scan for .video-block elements\n  const videoBlocks = contentRef.current?.querySelectorAll('.video-block');\n  console.log('Found video blocks:', videoBlocks?.length);\n}, [page]);\n

  7. Test video ID validity:

    curl http://localhost:4100/api/media/videos/42\n# Should return video metadata, not 404\n

"},{"location":"v2/features/pages/page-builder/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/pages/page-builder/#editor-initialization","title":"Editor Initialization","text":"

GrapesJS startup: ~500ms on modern desktop

Optimization strategies:

  • Lazy load GrapesJS: const GrapesJS = lazy(() => import('./GrapesJSEditor'))
  • Show loading spinner during init
  • Preload on hover over \"Edit\" button
"},{"location":"v2/features/pages/page-builder/#large-pages","title":"Large Pages","text":"

Complexity threshold: 100+ components

Symptoms:

  • Laggy drag-and-drop
  • Slow save operations
  • Canvas rendering delay

Mitigations:

  • Break into multiple pages (split hero + sections)
  • Use CODE mode for complex layouts
  • Minimize nested components
"},{"location":"v2/features/pages/page-builder/#htmloutput-storage","title":"htmlOutput Storage","text":"

Database overhead: htmlOutput can be 50KB+ for complex pages

Considerations:

  • Indexed by published for public queries (fast)
  • Not indexed by content (no full-text search on HTML)
  • Consider external storage for very large pages (future enhancement)
"},{"location":"v2/features/pages/page-builder/#public-page-rendering","title":"Public Page Rendering","text":"

React hydration: Video blocks hydrate after initial render (~100ms delay)

Performance tips:

  • Use dangerouslySetInnerHTML for immediate HTML paint
  • Defer video hydration to setTimeout(..., 100)
  • Preload video metadata for above-fold players
"},{"location":"v2/features/pages/page-builder/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/features/pages/page-builder/#admin-authored-html","title":"Admin-Authored HTML","text":"

Risk: XSS via malicious HTML in editor

Mitigation:

  • Accepted risk: Only admins can create/edit pages (trusted users)
  • No user-supplied content: Public users cannot edit landing pages
  • Authentication required: All write endpoints require admin role

Comment in code:

// HTML/CSS is admin-authored via GrapesJS editor (not user-submitted content).\n// Only authenticated admins can create/edit pages, so XSS risk is accepted.\nreturn <div dangerouslySetInnerHTML={{ __html: page.htmlOutput }} />;\n
"},{"location":"v2/features/pages/page-builder/#slug-validation","title":"Slug Validation","text":"

Attack vector: Path traversal via slug injection

Protection:

function generateSlug(title: string): string {\n  return title\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, '-')  // Alphanumeric + hyphens only\n    .replace(/^-+|-+$/g, '')       // Trim leading/trailing hyphens\n    .slice(0, 80);                 // Max 80 chars\n}\n

Safe slugs: about-us, campaign-2026, contact

Rejected: ../etc/passwd, <script>alert(1)</script>, ../../admin

"},{"location":"v2/features/pages/page-builder/#mkdocs-path-validation","title":"MkDocs Path Validation","text":"

Attack vector: Write arbitrary files via path traversal in mkdocsPath

Protection:

function validateMkdocsPath(mkdocsPath: string): void {\n  if (mkdocsPath.includes('\\0')) throw new Error('Null byte detected');\n\n  const normalized = path.normalize(mkdocsPath);\n  if (normalized.includes('..') || path.isAbsolute(normalized)) {\n    throw new Error('Path traversal not allowed');\n  }\n\n  if (mkdocsPath.includes('%2e') || mkdocsPath.includes('%2E')) {\n    throw new Error('Encoded path traversal not allowed');\n  }\n\n  if (!mkdocsPath.endsWith('.html')) {\n    throw new Error('Path must end with .html');\n  }\n}\n

Safe paths: about.html, pages/contact.html

Rejected: ../../../etc/passwd.html, /etc/shadow.html, %2e%2e/admin.html

"},{"location":"v2/features/pages/page-builder/#published-flag-enforcement","title":"Published Flag Enforcement","text":"

Attack vector: Access draft pages via public route

Protection:

// In pagesService.findBySlugPublic()\nif (!page || !page.published) {\n  throw new AppError(404, 'Page not found', 'PAGE_NOT_FOUND');\n}\n

Behavior:

  • Unpublished pages return 404 on public route
  • Admin routes bypass check (can view drafts)
"},{"location":"v2/features/pages/page-builder/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/pages/page-builder/#frontend-components","title":"Frontend Components","text":"
  • LandingPageEditor \u2014 Full-screen editor wrapper
  • LandingPagesPage \u2014 Table view + CRUD
  • GrapesJSEditor \u2014 GrapesJS wrapper with forwardRef
  • PublicLandingPage \u2014 Public page renderer
"},{"location":"v2/features/pages/page-builder/#backend-modules","title":"Backend Modules","text":"
  • pages-admin.routes \u2014 Admin CRUD endpoints
  • pages-public.routes \u2014 Public view endpoint
  • pages.service \u2014 Business logic + MkDocs export
  • pages.schemas \u2014 Zod validation schemas
"},{"location":"v2/features/pages/page-builder/#database","title":"Database","text":"
  • LandingPage Model \u2014 Schema + relationships
  • PageBlock Model \u2014 Block library schema
"},{"location":"v2/features/pages/page-builder/#feature-documentation","title":"Feature Documentation","text":"
  • GrapesJS Editor Integration \u2014 forwardRef pattern + custom blocks
  • Block Library \u2014 Reusable components system
  • MkDocs Export \u2014 Material theme integration
"},{"location":"v2/features/pages/page-builder/#external-resources","title":"External Resources","text":"
  • GrapesJS Documentation \u2014 Official editor docs
  • GrapesJS Plugins \u2014 Available plugins
  • MkDocs Material \u2014 Theme docs
  • Jinja2 Templates \u2014 Template syntax
"},{"location":"v2/features/tunnel/","title":"Tunnel Management (Pangolin)","text":"

The Tunnel Management feature provides secure public access to your self-hosted Changemaker Lite instance via Pangolin tunnel service with Newt container integration. An alternative to Cloudflare Tunnel for exposing your application to the internet.

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

Pangolin integration provides:

  • Secure Tunneling - Expose localhost to public internet
  • Newt Container - Self-hosted exit node
  • Setup Wizard - Guided configuration
  • Resource Management - Subdomain and route configuration
  • Status Monitoring - Tunnel health and uptime
  • No DNS Configuration - Automatic subdomain setup
"},{"location":"v2/features/tunnel/#features","title":"Features","text":""},{"location":"v2/features/tunnel/#tunnel-setup","title":"Tunnel Setup","text":"
  • Create Pangolin organization and site
  • Generate Newt container credentials
  • Configure resources (subdomains)
  • Deploy Newt container
  • Start tunnel automatically
"},{"location":"v2/features/tunnel/#resource-configuration","title":"Resource Configuration","text":"

Map internal services to public subdomains:

  • app.yoursite.com \u2192 Admin GUI (port 3000)
  • api.yoursite.com \u2192 Express API (port 4000)
  • media.yoursite.com \u2192 Media API (port 4100)
  • docs.yoursite.com \u2192 MkDocs (port 4003)
  • grafana.yoursite.com \u2192 Grafana (port 3001)
  • Custom subdomains for other services
"},{"location":"v2/features/tunnel/#admin-interface","title":"Admin Interface","text":"

Setup wizard (/app/services/pangolin):

  1. Connection - Enter Pangolin API credentials
  2. Organization - Create/select organization
  3. Site - Create/configure site
  4. Resources - Map services to subdomains
  5. Deploy - Start Newt container
  6. Verify - Test tunnel connectivity
"},{"location":"v2/features/tunnel/#status-monitoring","title":"Status Monitoring","text":"
  • Tunnel status (active/inactive)
  • Resource health checks
  • Traffic statistics
  • Error logs
  • Quick actions (restart, update config)
"},{"location":"v2/features/tunnel/#architecture","title":"Architecture","text":""},{"location":"v2/features/tunnel/#backend-components","title":"Backend Components","text":"

Pangolin Client: - api/src/services/pangolin.client.ts - Typed HTTP client - API key authentication - Full Integration API coverage

Pangolin Module: - api/src/modules/pangolin/pangolin.routes.ts - Admin endpoints - Setup, config, status routes

Newt Container: - Docker service in docker-compose.yml - Self-hosted exit node - Routes through nginx - Automatic startup

"},{"location":"v2/features/tunnel/#frontend-components","title":"Frontend Components","text":"

Admin Page: - admin/src/pages/PangolinPage.tsx - Setup wizard - Step-by-step configuration - Status dashboard - Resource table

"},{"location":"v2/features/tunnel/#docker-integration","title":"Docker Integration","text":"

Newt container in docker-compose.yml:

newt:\n  image: bnkserve/newt:latest\n  container_name: newt\n  restart: unless-stopped\n  depends_on:\n    - nginx\n  environment:\n    NEWT_ID: ${PANGOLIN_NEWT_ID}\n    NEWT_SECRET: ${PANGOLIN_NEWT_SECRET}\n    PANGOLIN_ENDPOINT: ${PANGOLIN_ENDPOINT}\n  networks:\n    - changemaker-lite\n
"},{"location":"v2/features/tunnel/#configuration","title":"Configuration","text":""},{"location":"v2/features/tunnel/#environment-variables","title":"Environment Variables","text":"
# Pangolin API\nPANGOLIN_API_URL=https://api.bnkserve.org/v1\nPANGOLIN_API_KEY=your_api_key\n\n# Organization & Site\nPANGOLIN_ORG_ID=your_org_id\nPANGOLIN_SITE_ID=your_site_id\n\n# Newt Container\nPANGOLIN_NEWT_ID=your_newt_id\nPANGOLIN_NEWT_SECRET=your_newt_secret\nPANGOLIN_ENDPOINT=your_endpoint_url\n
"},{"location":"v2/features/tunnel/#setup-process","title":"Setup Process","text":"
  1. Create Account - Sign up at pangolin.bnkserve.org
  2. Get API Key - Generate API key in dashboard
  3. Add to .env - Set PANGOLIN_API_KEY
  4. Run Wizard - Complete setup wizard in admin
  5. Deploy Newt - Start Newt container
  6. Test Tunnel - Verify public access
"},{"location":"v2/features/tunnel/#pangolin-api-integration","title":"Pangolin API Integration","text":""},{"location":"v2/features/tunnel/#api-client-usage","title":"API Client Usage","text":"
import { pangolinClient } from '../services/pangolin.client';\n\n// Get organization\nconst org = await pangolinClient.getOrganization(orgId);\n\n// Create site\nconst site = await pangolinClient.createSite(orgId, {\n  name: 'My Campaign Site',\n  domain: 'campaign.example.com',\n});\n\n// Create resource (subdomain)\nconst resource = await pangolinClient.createResource(siteId, {\n  subdomain: 'app',\n  targetUrl: 'http://nginx:80',\n  port: 80,\n});\n\n// Get tunnel status\nconst status = await pangolinClient.getTunnelStatus(siteId);\n
"},{"location":"v2/features/tunnel/#authentication","title":"Authentication","text":"

Pangolin uses Bearer token authentication:

const headers = {\n  'Authorization': `Bearer ${apiKey}`,\n  'Content-Type': 'application/json',\n};\n
"},{"location":"v2/features/tunnel/#setup-wizard","title":"Setup Wizard","text":""},{"location":"v2/features/tunnel/#step-1-connection","title":"Step 1: Connection","text":"
  • Enter Pangolin API key
  • Validate credentials
  • Test connection
"},{"location":"v2/features/tunnel/#step-2-organization","title":"Step 2: Organization","text":"
  • List existing organizations
  • Create new organization
  • Select organization
"},{"location":"v2/features/tunnel/#step-3-site","title":"Step 3: Site","text":"
  • List existing sites
  • Create new site
  • Configure domain
  • Select site
"},{"location":"v2/features/tunnel/#step-4-resources","title":"Step 4: Resources","text":"
  • Add resources (subdomains)
  • Map to internal services
  • Configure routing

Example resources:

app.yoursite.com \u2192 http://nginx:80 (proxies to admin:3000)\napi.yoursite.com \u2192 http://nginx:80 (proxies to api:4000)\n
"},{"location":"v2/features/tunnel/#step-5-deploy","title":"Step 5: Deploy","text":"
  • Generate Newt credentials
  • Update .env with credentials
  • Restart Newt container
  • Verify tunnel
"},{"location":"v2/features/tunnel/#step-6-verify","title":"Step 6: Verify","text":"
  • Test public URLs
  • Check resource health
  • View status dashboard
"},{"location":"v2/features/tunnel/#newt-container","title":"Newt Container","text":""},{"location":"v2/features/tunnel/#purpose","title":"Purpose","text":"

Newt is the exit node that:

  • Establishes tunnel to Pangolin
  • Receives public traffic
  • Forwards to internal nginx
  • Handles SSL/TLS termination
"},{"location":"v2/features/tunnel/#routing","title":"Routing","text":"

All traffic flows through nginx:

Public Request\n  \u2193\nPangolin Tunnel\n  \u2193\nNewt Container\n  \u2193\nNginx (port 80)\n  \u2193\nInternal Service (admin/api/etc.)\n
"},{"location":"v2/features/tunnel/#configuration_1","title":"Configuration","text":"

Newt configured via environment variables:

  • NEWT_ID - Unique container identifier
  • NEWT_SECRET - Authentication secret
  • PANGOLIN_ENDPOINT - Tunnel endpoint URL
"},{"location":"v2/features/tunnel/#resource-management","title":"Resource Management","text":""},{"location":"v2/features/tunnel/#resource-types","title":"Resource Types","text":"
  • Web Apps - Admin, public pages
  • APIs - Express API, Media API
  • Services - Docs, Grafana, etc.
"},{"location":"v2/features/tunnel/#subdomain-mapping","title":"Subdomain Mapping","text":"
interface Resource {\n  subdomain: string;      // 'app', 'api', 'docs'\n  targetUrl: string;      // 'http://nginx:80'\n  port: number;           // 80\n  protocol: string;       // 'http' or 'https'\n}\n
"},{"location":"v2/features/tunnel/#internal-routing","title":"Internal Routing","text":"

Nginx routes by Host header:

server {\n  listen 80;\n  server_name app.yoursite.com;\n\n  location / {\n    proxy_pass http://admin:3000;\n  }\n}\n\nserver {\n  listen 80;\n  server_name api.yoursite.com;\n\n  location / {\n    proxy_pass http://api:4000;\n  }\n}\n
"},{"location":"v2/features/tunnel/#status-dashboard","title":"Status Dashboard","text":""},{"location":"v2/features/tunnel/#tunnel-status","title":"Tunnel Status","text":"

Display: - Active/inactive status - Uptime duration - Last connected time - Connection errors

"},{"location":"v2/features/tunnel/#resource-health","title":"Resource Health","text":"

For each resource: - Subdomain - Target service - Health status (online/offline) - Response time - Error count

"},{"location":"v2/features/tunnel/#actions","title":"Actions","text":"

Quick actions: - Restart tunnel - Update configuration - Add/remove resources - Test connectivity - View logs

"},{"location":"v2/features/tunnel/#security","title":"Security","text":""},{"location":"v2/features/tunnel/#ssltls","title":"SSL/TLS","text":"
  • Pangolin handles SSL termination
  • Automatic certificate management
  • HTTPS enforced on public URLs
  • HTTP \u2192 HTTPS redirect
"},{"location":"v2/features/tunnel/#authentication_1","title":"Authentication","text":"
  • API key authentication
  • Newt secret for container auth
  • No public credentials exposure
"},{"location":"v2/features/tunnel/#access-control","title":"Access Control","text":"
  • Firewall rules (optional)
  • IP whitelisting (optional)
  • Rate limiting via Pangolin
  • DDoS protection
"},{"location":"v2/features/tunnel/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/tunnel/#connection-issues","title":"Connection Issues","text":"
  1. Verify API key
  2. Check organization/site IDs
  3. Confirm Newt credentials
  4. Test internal nginx routing
  5. Check container logs
"},{"location":"v2/features/tunnel/#resource-not-accessible","title":"Resource Not Accessible","text":"
  1. Verify resource configuration
  2. Test internal service
  3. Check nginx config
  4. Review Pangolin logs
  5. Confirm DNS propagation
"},{"location":"v2/features/tunnel/#newt-container-errors","title":"Newt Container Errors","text":"
  1. Check environment variables
  2. Verify network connectivity
  3. Review container logs
  4. Restart container
  5. Update Newt image
"},{"location":"v2/features/tunnel/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/tunnel/#admin-endpoints","title":"Admin Endpoints","text":"
GET    /api/pangolin/status            # Tunnel status\nGET    /api/pangolin/config            # Current configuration\nGET    /api/pangolin/organizations     # List organizations\nGET    /api/pangolin/sites             # List sites\nGET    /api/pangolin/resources         # List resources\nPOST   /api/pangolin/setup             # Complete setup wizard\nPOST   /api/pangolin/sync              # Sync configuration\n
"},{"location":"v2/features/tunnel/#comparison-to-cloudflare-tunnel","title":"Comparison to Cloudflare Tunnel","text":""},{"location":"v2/features/tunnel/#advantages","title":"Advantages","text":"
  • Self-hosted exit node (Newt)
  • No vendor lock-in
  • Full control over routing
  • Open-source alternative
  • No DNS changes required
"},{"location":"v2/features/tunnel/#considerations","title":"Considerations","text":"
  • Requires Pangolin account
  • Newt container overhead
  • Manual setup process
  • Smaller ecosystem
"},{"location":"v2/features/tunnel/#related-documentation","title":"Related Documentation","text":"
  • Pangolin Page
  • Pangolin Client
  • Tunneling Deployment
  • Nginx Configuration
  • SSL/TLS Setup
  • Docker Compose
"},{"location":"v2/frontend/","title":"Frontend Overview","text":"

The Changemaker Lite V2 frontend is a React-based admin interface built with modern web technologies, providing a comprehensive management system for campaigns, locations, media, and more.

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

The frontend is a single-page application (SPA) built with:

  • React 19 - UI framework
  • Vite - Build tool and dev server
  • Ant Design 5 - Component library
  • Zustand - State management
  • React Router 6 - Client-side routing
  • Leaflet - Interactive maps
  • Axios - HTTP client with auth interceptors
"},{"location":"v2/frontend/#application-structure","title":"Application Structure","text":"
admin/\n\u251c\u2500\u2500 src/\n\u2502   \u251c\u2500\u2500 App.tsx                # Main router + route definitions\n\u2502   \u251c\u2500\u2500 components/            # Shared components\n\u2502   \u251c\u2500\u2500 pages/                 # Page components\n\u2502   \u2502   \u251c\u2500\u2500 admin/             # Admin pages (30+)\n\u2502   \u2502   \u251c\u2500\u2500 public/            # Public pages (8)\n\u2502   \u2502   \u2514\u2500\u2500 volunteer/         # Volunteer portal (4)\n\u2502   \u251c\u2500\u2500 lib/                   # API clients\n\u2502   \u251c\u2500\u2500 stores/                # Zustand state stores\n\u2502   \u251c\u2500\u2500 types/                 # TypeScript definitions\n\u2502   \u251c\u2500\u2500 hooks/                 # Custom React hooks\n\u2502   \u2514\u2500\u2500 utils/                 # Helper utilities\n\u2514\u2500\u2500 public/                    # Static assets\n
"},{"location":"v2/frontend/#key-components","title":"Key Components","text":""},{"location":"v2/frontend/#layouts","title":"Layouts","text":"

Three distinct layout components for different user contexts:

  • AppLayout - Admin sidebar with role-based navigation
  • PublicLayout - Dark theme public pages
  • VolunteerLayout - Volunteer portal with top navigation
"},{"location":"v2/frontend/#components","title":"Components","text":"

Reusable UI components organized by feature:

  • Map components (Leaflet, drawing tools, markers)
  • Canvass components (GPS tracking, visit recording)
  • Media components (video cards, upload, gallery)
  • Email template components
  • Form components and utilities
"},{"location":"v2/frontend/#pages","title":"Pages","text":"

42 page components across three sections:

  • Admin Pages (30) - Campaign management, location management, settings, analytics
  • Public Pages (8) - Campaign views, map, shifts, media gallery
  • Volunteer Pages (4) - Canvass map, assignments, activity tracking
"},{"location":"v2/frontend/#state-management","title":"State Management","text":""},{"location":"v2/frontend/#zustand-stores","title":"Zustand Stores","text":"

Auth Store (stores/auth.store.ts)

  • User authentication state
  • Token persistence (localStorage)
  • Login/logout actions
  • Role-based access

Canvass Store (stores/canvass.store.ts)

  • Active canvass session
  • GPS tracking state
  • Walking route
  • Visit recording
"},{"location":"v2/frontend/#api-integration","title":"API Integration","text":"

Main API Client (lib/api.ts)

  • Axios instance with auth interceptors
  • Automatic token refresh on 401
  • Base URL configuration
  • Error handling

Media API Client (lib/media-api.ts)

  • Dedicated client for Fastify media API
  • Separate base URL (port 4100)
  • File upload support

Public API Client (lib/media-public-api.ts)

  • Unauthenticated client for public media
  • No auth interceptors
"},{"location":"v2/frontend/#routing","title":"Routing","text":"

Routes are organized by user role and access level:

"},{"location":"v2/frontend/#admin-routes-app","title":"Admin Routes (/app/*)","text":"

Require authentication and admin role:

<Route path=\"/app\" element={<AppLayout />}>\n  <Route path=\"dashboard\" element={<DashboardPage />} />\n  <Route path=\"users\" element={<UsersPage />} />\n  <Route path=\"influence/campaigns\" element={<CampaignsPage />} />\n  // ... 30+ admin routes\n</Route>\n
"},{"location":"v2/frontend/#public-routes","title":"Public Routes","text":"

No authentication required:

<Route path=\"/campaigns\" element={<PublicLayout />}>\n  <Route index element={<CampaignsListPage />} />\n  <Route path=\":id\" element={<CampaignPage />} />\n</Route>\n
"},{"location":"v2/frontend/#volunteer-routes-volunteer","title":"Volunteer Routes (/volunteer/*)","text":"

Require authentication, any role:

<Route path=\"/volunteer\" element={<VolunteerLayout />}>\n  <Route path=\"assignments\" element={<VolunteerShiftsPage />} />\n  <Route path=\"canvass/:cutId\" element={<VolunteerMapPage />} />\n</Route>\n
"},{"location":"v2/frontend/#theming","title":"Theming","text":""},{"location":"v2/frontend/#admin-theme","title":"Admin Theme","text":"

Light theme with primary blue colors:

colorPrimary: '#1677ff'\ncolorBgBase: '#ffffff'\n
"},{"location":"v2/frontend/#public-theme","title":"Public Theme","text":"

Dark theme with blue/teal accents:

colorBgBase: '#0d1b2a'\ncolorBgContainer: '#1b2838'\ncolorPrimary: '#3498db'\n
"},{"location":"v2/frontend/#build-development","title":"Build & Development","text":""},{"location":"v2/frontend/#development-server","title":"Development Server","text":"
cd admin && npm run dev\n# Runs on http://localhost:3000\n
"},{"location":"v2/frontend/#production-build","title":"Production Build","text":"
cd admin && npm run build\n# Output: admin/dist/\n
"},{"location":"v2/frontend/#type-checking","title":"Type Checking","text":"
cd admin && npx tsc --noEmit\n
"},{"location":"v2/frontend/#environment-variables","title":"Environment Variables","text":"

Frontend uses Vite environment variables:

VITE_API_URL=http://localhost:4000      # Main API\nVITE_MEDIA_API_URL=http://localhost:4100 # Media API\nVITE_MKDOCS_URL=http://localhost:4003   # MkDocs\n

Docker deployments override these in docker-compose.yml to use container hostnames.

"},{"location":"v2/frontend/#key-features","title":"Key Features","text":""},{"location":"v2/frontend/#responsive-design","title":"Responsive Design","text":"
  • Grid breakpoints with Ant Design
  • Mobile-aware components
  • Desktop-only editors (GrapesJS, Email Templates)
"},{"location":"v2/frontend/#form-handling","title":"Form Handling","text":"
  • Ant Design Form integration
  • Zod schema validation (client-side)
  • Error display and validation feedback
"},{"location":"v2/frontend/#data-tables","title":"Data Tables","text":"
  • Pagination support
  • Search and filtering
  • Sorting and column configuration
  • Bulk actions
"},{"location":"v2/frontend/#map-integration","title":"Map Integration","text":"
  • Leaflet for interactive maps
  • Custom markers and overlays
  • Drawing tools for polygons
  • GPS tracking for canvassing
"},{"location":"v2/frontend/#file-uploads","title":"File Uploads","text":"
  • Drag-and-drop support
  • Progress tracking
  • File type validation
  • Preview generation
"},{"location":"v2/frontend/#related-documentation","title":"Related Documentation","text":"
  • Backend Overview
  • Architecture
  • Development Guide
  • User Guides
"},{"location":"v2/frontend/#quick-links","title":"Quick Links","text":"
  • App Layout
  • Dashboard Page
  • Campaigns Page
  • Volunteer Map
  • API Client Setup
"},{"location":"v2/frontend/components/","title":"Frontend Components","text":"

Reusable UI components provide common functionality across the Changemaker Lite admin interface. Components are organized by feature area and follow React best practices.

"},{"location":"v2/frontend/components/#component-organization","title":"Component Organization","text":"
admin/src/components/\n\u251c\u2500\u2500 map/                    # Leaflet map components\n\u251c\u2500\u2500 canvass/                # GPS tracking and visit recording\n\u251c\u2500\u2500 media/                  # Video library components\n\u251c\u2500\u2500 email-templates/        # Email template editor components\n\u251c\u2500\u2500 observability/          # Monitoring components\n\u251c\u2500\u2500 AppLayout.tsx           # Admin sidebar layout\n\u251c\u2500\u2500 PublicLayout.tsx        # Public dark theme layout\n\u251c\u2500\u2500 VolunteerLayout.tsx     # Volunteer portal layout\n\u251c\u2500\u2500 MediaPublicLayout.tsx   # Public media gallery layout\n\u2514\u2500\u2500 GrapesJSEditor.tsx      # Landing page WYSIWYG editor\n
"},{"location":"v2/frontend/components/#layout-components","title":"Layout Components","text":""},{"location":"v2/frontend/components/#applayout","title":"AppLayout","text":"

Admin sidebar layout with role-based navigation:

  • Location: components/AppLayout.tsx
  • Features:
  • Collapsible sidebar
  • Role-based menu items
  • User dropdown menu
  • Breadcrumb navigation
  • Responsive mobile drawer
"},{"location":"v2/frontend/components/#publiclayout","title":"PublicLayout","text":"

Dark theme layout for public pages:

  • Location: components/PublicLayout.tsx
  • Features:
  • Dark blue/teal color scheme
  • Header with logo and navigation
  • Footer with links
  • Responsive grid breakpoints
"},{"location":"v2/frontend/components/#volunteerlayout","title":"VolunteerLayout","text":"

Top navigation layout for volunteer portal:

  • Location: components/VolunteerLayout.tsx
  • Features:
  • Horizontal navigation bar
  • Mobile hamburger menu
  • User status display
  • Active route highlighting
"},{"location":"v2/frontend/components/#mediapubliclayout","title":"MediaPublicLayout","text":"

Minimal layout for public media gallery:

  • Location: components/MediaPublicLayout.tsx
  • Features:
  • Clean header with branding
  • Full-width content area
  • Dark theme consistency
"},{"location":"v2/frontend/components/#map-components","title":"Map Components","text":""},{"location":"v2/frontend/components/#mapcontrols","title":"MapControls","text":"

Floating control buttons for map interactions:

  • Location: components/map/MapControls.tsx
  • Features:
  • Add location mode toggle
  • Move location mode toggle
  • Geolocate current position
  • Fullscreen toggle
  • Auto-refresh toggle
"},{"location":"v2/frontend/components/#addlocationmode","title":"AddLocationMode","text":"

Click-to-add location drawing mode:

  • Location: components/map/AddLocationMode.tsx
  • Features:
  • Click map to add location
  • Reverse geocoding
  • Form modal for details
  • Marker placement
"},{"location":"v2/frontend/components/#movelocationmode","title":"MoveLocationMode","text":"

Click-to-move existing locations:

  • Location: components/map/MoveLocationMode.tsx
  • Features:
  • Drag markers to new position
  • Update coordinates
  • Cancel/confirm actions
"},{"location":"v2/frontend/components/#cutdrawingmode","title":"CutDrawingMode","text":"

Polygon drawing tool for geographic cuts:

  • Location: components/map/CutDrawingMode.tsx
  • Features:
  • Click vertices to draw polygon
  • Close polygon detection
  • Vertex editing
  • Save/cancel actions
"},{"location":"v2/frontend/components/#cutoverlays","title":"CutOverlays","text":"

GeoJSON polygon rendering:

  • Location: components/map/CutOverlays.tsx
  • Features:
  • Multi-polygon support
  • Color-coded cuts
  • Click to select
  • Popup information
"},{"location":"v2/frontend/components/#cutoverlaycontrols","title":"CutOverlayControls","text":"

Cut visibility toggle panel:

  • Location: components/map/CutOverlayControls.tsx
  • Features:
  • Show/hide individual cuts
  • Bulk toggle all
  • Color legend
"},{"location":"v2/frontend/components/#cuteditormap","title":"CutEditorMap","text":"

Specialized map for cut editing:

  • Location: components/map/CutEditorMap.tsx
  • Features:
  • Drawing mode integration
  • Vertex editing
  • Polygon validation
  • Save to database
"},{"location":"v2/frontend/components/#maplegend","title":"MapLegend","text":"

Floating legend overlay:

  • Location: components/map/MapLegend.tsx
  • Features:
  • Color-coded markers
  • Cut legend
  • Collapsible panel
"},{"location":"v2/frontend/components/#canvass-components","title":"Canvass Components","text":""},{"location":"v2/frontend/components/#canvassheader","title":"CanvassHeader","text":"

Session header with timer and status:

  • Location: components/canvass/CanvassHeader.tsx
  • Features:
  • Session timer
  • Start/end session
  • Cut information
  • Visit counter
"},{"location":"v2/frontend/components/#sessiontimer","title":"SessionTimer","text":"

Elapsed time display:

  • Location: components/canvass/SessionTimer.tsx
  • Features:
  • Real-time countdown
  • Hours:minutes:seconds format
  • Auto-update
"},{"location":"v2/frontend/components/#canvassmarker","title":"CanvassMarker","text":"

Location marker with visit status:

  • Location: components/canvass/CanvassMarker.tsx
  • Features:
  • Color-coded by visit status
  • Click to record visit
  • Popup with details
  • Next location highlighting
"},{"location":"v2/frontend/components/#canvassmarkergroup","title":"CanvassMarkerGroup","text":"

Optimized marker clustering:

  • Location: components/canvass/CanvassMarkerGroup.tsx
  • Features:
  • Performance optimization
  • Batch rendering
  • Click handlers
"},{"location":"v2/frontend/components/#walkingrouteline","title":"WalkingRouteLine","text":"

Polyline for walking route:

  • Location: components/canvass/WalkingRouteLine.tsx
  • Features:
  • Blue dashed line
  • Location-to-location path
  • Auto-update on visit
"},{"location":"v2/frontend/components/#gpstracker","title":"GPSTracker","text":"

GPS position tracking:

  • Location: components/canvass/GPSTracker.tsx
  • Features:
  • Watch position API
  • Blue GPS marker
  • Accuracy circle
  • Auto-center map
"},{"location":"v2/frontend/components/#canvassbottomtoolbar","title":"CanvassBottomToolbar","text":"

Bottom sheet with actions:

  • Location: components/canvass/CanvassBottomToolbar.tsx
  • Features:
  • Expandable drawer
  • Quick actions
  • Visit recording
  • Session controls
"},{"location":"v2/frontend/components/#visitrecordingform","title":"VisitRecordingForm","text":"

Visit outcome form:

  • Location: components/canvass/VisitRecordingForm.tsx
  • Features:
  • Outcome selection
  • Notes input
  • GPS coordinates
  • Submit with validation
"},{"location":"v2/frontend/components/#canvasslegend","title":"CanvassLegend","text":"

Map legend for canvass status:

  • Location: components/canvass/CanvassLegend.tsx
  • Features:
  • Status color codes
  • Visit outcome legend
  • Collapsible panel
"},{"location":"v2/frontend/components/#media-components","title":"Media Components","text":""},{"location":"v2/frontend/components/#videocard","title":"VideoCard","text":"

Video item display card:

  • Location: components/media/VideoCard.tsx
  • Features:
  • Thumbnail preview
  • Title and description
  • Action buttons
  • Selection checkbox
"},{"location":"v2/frontend/components/#bulkactions","title":"BulkActions","text":"

Batch operation toolbar:

  • Location: components/media/BulkActions.tsx
  • Features:
  • Select all toggle
  • Delete selected
  • Lock/unlock selected
  • Share selected
"},{"location":"v2/frontend/components/#uploadvideomodal","title":"UploadVideoModal","text":"

Video upload interface:

  • Location: components/media/UploadVideoModal.tsx
  • Features:
  • Drag-and-drop upload
  • Progress tracking
  • Metadata form
  • Single/batch upload
"},{"location":"v2/frontend/components/#mediagallerygrid","title":"MediaGalleryGrid","text":"

Responsive video grid:

  • Location: components/media/MediaGalleryGrid.tsx
  • Features:
  • Masonry layout
  • Lazy loading
  • Infinite scroll
  • Filter/sort
"},{"location":"v2/frontend/components/#email-template-components","title":"Email Template Components","text":""},{"location":"v2/frontend/components/#templateeditor","title":"TemplateEditor","text":"

Email template WYSIWYG editor:

  • Location: components/email-templates/TemplateEditor.tsx
  • Features:
  • Rich text editing
  • Variable insertion
  • Preview mode
  • HTML source view
"},{"location":"v2/frontend/components/#variableinserter","title":"VariableInserter","text":"

Template variable selector:

  • Location: components/email-templates/VariableInserter.tsx
  • Features:
  • Variable dropdown
  • Click to insert
  • Variable documentation
  • Preview rendering
"},{"location":"v2/frontend/components/#observability-components","title":"Observability Components","text":""},{"location":"v2/frontend/components/#metricschart","title":"MetricsChart","text":"

Prometheus metrics visualization:

  • Location: components/observability/MetricsChart.tsx
  • Features:
  • Time-series charts
  • Multiple metrics
  • Auto-refresh
  • Zoom controls
"},{"location":"v2/frontend/components/#servicehealthcard","title":"ServiceHealthCard","text":"

Service status display:

  • Location: components/observability/ServiceHealthCard.tsx
  • Features:
  • Health indicator
  • Uptime display
  • Quick actions
  • Error messages
"},{"location":"v2/frontend/components/#editor-components","title":"Editor Components","text":""},{"location":"v2/frontend/components/#grapesjseditor","title":"GrapesJSEditor","text":"

Landing page WYSIWYG editor:

  • Location: components/GrapesJSEditor.tsx
  • Features:
  • GrapesJS integration
  • Custom block library
  • Ctrl+S save handler
  • Error boundary
  • Forward ref support
  • Desktop-only warning
"},{"location":"v2/frontend/components/#related-documentation","title":"Related Documentation","text":"
  • Frontend Overview
  • Layouts
  • Pages
  • Map Features
  • Media Features
"},{"location":"v2/frontend/layouts/","title":"Frontend Layouts","text":"

Layout components provide consistent page structure and navigation across different sections of the Changemaker Lite application. Each layout serves a specific user context with appropriate theming and navigation.

"},{"location":"v2/frontend/layouts/#layout-components","title":"Layout Components","text":""},{"location":"v2/frontend/layouts/#applayout","title":"AppLayout","text":"

Admin sidebar layout for authenticated admin users.

Location: admin/src/components/AppLayout.tsx

Features:

  • Collapsible sidebar navigation
  • Role-based menu items (SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN)
  • User dropdown menu with logout
  • Breadcrumb navigation
  • Mobile responsive drawer
  • Light theme

Route Context: /app/*

Used By: - Dashboard - User management - Campaign management - Location management - Settings pages - All admin features

Navigation Sections:

  1. Dashboard - Overview and quick actions
  2. Influence - Campaign management, responses, email queue
  3. Map - Locations, cuts, shifts, canvassing
  4. Content - Landing pages, email templates
  5. Media - Video library, public gallery, jobs
  6. Services - Integrations (Listmonk, Pangolin, MkDocs, etc.)
  7. System - Users, settings, observability

Sidebar Behavior:

  • Default collapsed state for better content space
  • Expand on hover (desktop)
  • Drawer for mobile devices
  • Persistent state in localStorage
  • Active menu item highlighting
"},{"location":"v2/frontend/layouts/#publiclayout","title":"PublicLayout","text":"

Dark theme layout for public-facing pages.

Location: admin/src/components/PublicLayout.tsx

Features:

  • Dark blue/teal color scheme (#0d1b2a background)
  • Header with logo and navigation links
  • Footer with contact/about links
  • Full-width content area
  • Responsive grid breakpoints
  • No authentication required

Route Context: /campaigns, /map, /shifts, /p/:slug, /media

Used By: - Public campaign listing - Campaign detail pages - Response wall - Public map view - Public shift signup - Landing pages - Public media gallery

Theme Colors:

colorBgBase: '#0d1b2a'       // Dark navy background\ncolorBgContainer: '#1b2838'  // Container background\ncolorPrimary: '#3498db'      // Bright blue\ncolorLink: '#3498db'         // Link color\ncolorText: '#e0e0e0'         // Light text\n

Header Navigation:

  • Home link
  • Campaigns
  • Map
  • Shifts
  • Media Gallery
  • Login button (when not authenticated)
"},{"location":"v2/frontend/layouts/#volunteerlayout","title":"VolunteerLayout","text":"

Top navigation layout for volunteer portal.

Location: admin/src/components/VolunteerLayout.tsx

Features:

  • Horizontal navigation bar
  • Mobile hamburger menu
  • User status display (name, role)
  • Active route highlighting
  • Dark theme (consistent with public)
  • Logout button

Route Context: /volunteer/*

Used By: - Volunteer dashboard - Shift assignments - Canvass map (linked from assignments) - Activity history - Route history

Navigation Items:

  1. Dashboard - Overview and stats
  2. Assignments - Assigned shifts
  3. Activity - Visit history
  4. Routes - Walking route history

Mobile Behavior:

  • Hamburger menu for small screens
  • Drawer navigation
  • Full-width content
  • Touch-friendly controls
"},{"location":"v2/frontend/layouts/#mediapubliclayout","title":"MediaPublicLayout","text":"

Minimal layout for public media gallery.

Location: admin/src/components/MediaPublicLayout.tsx

Features:

  • Clean header with branding
  • Full-width content area
  • Dark theme consistency
  • No footer clutter
  • Focus on media content

Route Context: /media, /media/:id

Used By: - Public media gallery page - Video viewer page

"},{"location":"v2/frontend/layouts/#layout-selection-pattern","title":"Layout Selection Pattern","text":"

Layouts are selected based on route context:

// Admin routes use AppLayout\n<Route path=\"/app\" element={<AppLayout />}>\n  <Route path=\"dashboard\" element={<DashboardPage />} />\n  <Route path=\"users\" element={<UsersPage />} />\n  // ... more admin routes\n</Route>\n\n// Public routes use PublicLayout\n<Route element={<PublicLayout />}>\n  <Route path=\"/campaigns\" element={<CampaignsListPage />} />\n  <Route path=\"/campaigns/:id\" element={<CampaignPage />} />\n  <Route path=\"/map\" element={<MapPage />} />\n</Route>\n\n// Volunteer routes use VolunteerLayout\n<Route path=\"/volunteer\" element={<VolunteerLayout />}>\n  <Route path=\"dashboard\" element={<VolunteerDashboardPage />} />\n  <Route path=\"assignments\" element={<VolunteerShiftsPage />} />\n</Route>\n\n// Some pages are full-screen (no layout)\n<Route path=\"/volunteer/canvass/:cutId\" element={<VolunteerMapPage />} />\n<Route path=\"/app/pages/:id/edit\" element={<PageEditorPage />} />\n
"},{"location":"v2/frontend/layouts/#full-screen-pages","title":"Full-Screen Pages","text":"

Some pages render without any layout wrapper:

  • VolunteerMapPage - Full-screen canvass map with GPS
  • PageEditorPage - GrapesJS editor (desktop-only)
  • EmailTemplateEditorPage - Email template editor

These pages handle their own navigation and controls.

"},{"location":"v2/frontend/layouts/#layout-customization","title":"Layout Customization","text":""},{"location":"v2/frontend/layouts/#theme-overrides","title":"Theme Overrides","text":"

Layouts use Ant Design ConfigProvider for theming:

<ConfigProvider\n  theme={{\n    token: {\n      colorPrimary: '#3498db',\n      colorBgBase: '#0d1b2a',\n      // ... more tokens\n    },\n  }}\n>\n  {children}\n</ConfigProvider>\n
"},{"location":"v2/frontend/layouts/#role-based-navigation","title":"Role-Based Navigation","text":"

AppLayout filters menu items based on user role:

const menuItems = [\n  { key: 'dashboard', label: 'Dashboard', icon: <DashboardOutlined /> },\n\n  // Influence section - only for SUPER_ADMIN, INFLUENCE_ADMIN\n  user.role === 'SUPER_ADMIN' || user.role === 'INFLUENCE_ADMIN' ? {\n    key: 'influence',\n    label: 'Influence',\n    children: [...]\n  } : null,\n\n  // Map section - only for SUPER_ADMIN, MAP_ADMIN\n  user.role === 'SUPER_ADMIN' || user.role === 'MAP_ADMIN' ? {\n    key: 'map',\n    label: 'Map',\n    children: [...]\n  } : null,\n].filter(Boolean);\n
"},{"location":"v2/frontend/layouts/#responsive-breakpoints","title":"Responsive Breakpoints","text":"

Layouts use Ant Design grid breakpoints:

  • xs - < 576px (mobile)
  • sm - \u2265 576px (tablet)
  • md - \u2265 768px (small desktop)
  • lg - \u2265 992px (desktop)
  • xl - \u2265 1200px (large desktop)
  • xxl - \u2265 1600px (extra large)

Access via Grid.useBreakpoint():

const screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n
"},{"location":"v2/frontend/layouts/#related-documentation","title":"Related Documentation","text":"
  • Frontend Overview
  • Components
  • Admin Pages
  • Public Pages
  • Volunteer Pages
  • Theming Guide
"},{"location":"v2/frontend/pages/","title":"Frontend Pages","text":"

Page components provide the main user interface screens for Changemaker Lite. Pages are organized into three categories based on user access and context.

"},{"location":"v2/frontend/pages/#page-categories","title":"Page Categories","text":""},{"location":"v2/frontend/pages/#admin-pages-30-pages","title":"Admin Pages (30 pages)","text":"

Authenticated admin interface for campaign management, location management, settings, and system administration. Requires admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN).

Route Prefix: /app/*

Layout: AppLayout (sidebar navigation)

Key Pages: - Dashboard - User management - Campaign management - Location and mapping - Settings and configuration - Media library - Service integrations

"},{"location":"v2/frontend/pages/#public-pages-8-pages","title":"Public Pages (8 pages)","text":"

Public-facing pages accessible without authentication. Used by campaign supporters and volunteers to view campaigns, sign up for shifts, and interact with content.

Route Prefix: Various (/campaigns, /map, /shifts, /p/:slug, /media)

Layout: PublicLayout (dark theme)

Key Pages: - Campaign listing and details - Response wall - Public map view - Shift signup - Landing pages - Media gallery

"},{"location":"v2/frontend/pages/#volunteer-pages-4-pages","title":"Volunteer Pages (4 pages)","text":"

Volunteer portal for canvassing activities. Requires authentication (any role) and provides tools for door-to-door canvassing, GPS tracking, and activity tracking.

Route Prefix: /volunteer/*

Layout: VolunteerLayout (top navigation)

Key Pages: - Volunteer dashboard - Shift assignments - Full-screen canvass map - Activity history - Route history

"},{"location":"v2/frontend/pages/#page-overview-by-feature","title":"Page Overview by Feature","text":""},{"location":"v2/frontend/pages/#authentication","title":"Authentication","text":"
  • LoginPage - User login with JWT authentication
"},{"location":"v2/frontend/pages/#dashboard-analytics","title":"Dashboard & Analytics","text":"
  • DashboardPage - Admin overview with stats and recent activity
  • CanvassDashboardPage - Canvass monitoring and leaderboard
  • DataQualityDashboardPage - Geocoding quality metrics
  • ObservabilityPage - Prometheus/Grafana monitoring
"},{"location":"v2/frontend/pages/#campaign-management","title":"Campaign Management","text":"
  • CampaignsPage - Campaign CRUD table
  • CampaignPage (Public) - Campaign detail with email form
  • CampaignsListPage (Public) - Featured campaign listing
  • ResponsesPage - Response moderation
  • ResponseWallPage (Public) - Public response submissions
  • RepresentativesPage - Representative cache management
  • EmailQueuePage - Email queue monitoring
"},{"location":"v2/frontend/pages/#location-mapping","title":"Location & Mapping","text":"
  • LocationsPage - Location CRUD, CSV import/export, geocoding
  • MapPage (Public) - Public Leaflet map with locations and cuts
  • CutsPage - Geographic cut management with polygon drawing
  • MapSettingsPage - Map configuration
  • ShiftsPage - Volunteer shift management
  • ShiftsPage (Public) - Public shift signup
"},{"location":"v2/frontend/pages/#canvassing","title":"Canvassing","text":"
  • VolunteerMapPage - Full-screen GPS canvass map
  • VolunteerShiftsPage - Assigned shifts for volunteers
  • MyActivityPage - Visit history and outcomes
  • MyRoutesPage - Walking route history
  • WalkSheetPage - Printable walk sheet with QR codes
  • CutExportPage - Printable location report
"},{"location":"v2/frontend/pages/#content-management","title":"Content Management","text":"
  • LandingPagesPage - Landing page CRUD
  • PageEditorPage - GrapesJS WYSIWYG editor
  • LandingPage (Public) - Rendered landing page
  • EmailTemplatesPage - Email template CRUD
  • EmailTemplateEditorPage - Email template editor
"},{"location":"v2/frontend/pages/#media-management","title":"Media Management","text":"
  • LibraryPage - Video library management
  • SharedMediaPage - Public gallery administration
  • MediaJobsPage - Job queue monitoring
  • MediaGalleryPage (Public) - Public video gallery
  • MediaViewerPage (Public) - Video detail page
"},{"location":"v2/frontend/pages/#system-settings","title":"System & Settings","text":"
  • UsersPage - User CRUD with role management
  • SettingsPage - Global site settings
"},{"location":"v2/frontend/pages/#service-integrations","title":"Service Integrations","text":"
  • ListmonkPage - Newsletter sync management
  • PangolinPage - Tunnel setup wizard
  • DocsPage - MkDocs export management
  • MkDocsSettingsPage - Documentation configuration
  • MiniQRPage - QR code service iframe
  • MailHogPage - Email capture UI
  • CodeEditorPage - Code Server management
  • N8nPage - Workflow automation
  • GiteaPage - Git repository hosting
  • NocoDBPage - Data browser management
"},{"location":"v2/frontend/pages/#page-count-summary","title":"Page Count Summary","text":"Category Count Description Admin 30 Admin interface pages Public 8 Public-facing pages Volunteer 4 Volunteer portal pages Total 42 All page components"},{"location":"v2/frontend/pages/#common-page-patterns","title":"Common Page Patterns","text":""},{"location":"v2/frontend/pages/#data-tables","title":"Data Tables","text":"

Most CRUD pages use Ant Design Table with:

  • Pagination (server-side)
  • Search and filtering
  • Sorting
  • Action buttons (edit, delete)
  • Bulk operations
  • Export options
"},{"location":"v2/frontend/pages/#forms","title":"Forms","text":"

Form pages use Ant Design Form with:

  • Zod schema validation
  • Error display
  • Submit handlers
  • Cancel/reset actions
  • Auto-save (where applicable)
"},{"location":"v2/frontend/pages/#maps","title":"Maps","text":"

Map pages use React Leaflet with:

  • Tile layers (OpenStreetMap)
  • Markers and overlays
  • Drawing tools
  • Geolocate controls
  • Fullscreen mode
"},{"location":"v2/frontend/pages/#mobile-responsiveness","title":"Mobile Responsiveness","text":"

Pages use responsive design patterns:

  • Grid breakpoints with Grid.useBreakpoint()
  • Mobile-specific layouts
  • Touch-friendly controls
  • Responsive tables
  • Desktop-only warnings (for editors)
"},{"location":"v2/frontend/pages/#route-protection","title":"Route Protection","text":"

Pages are protected based on authentication and role:

// Public routes - no auth required\n<Route path=\"/campaigns\" element={<CampaignsListPage />} />\n\n// Authenticated routes - any role\n<Route path=\"/volunteer/assignments\" element={<VolunteerShiftsPage />} />\n\n// Admin routes - specific roles\n<Route path=\"/app/campaigns\" element={<CampaignsPage />} />\n// Middleware: requireRole(SUPER_ADMIN, INFLUENCE_ADMIN)\n
"},{"location":"v2/frontend/pages/#related-documentation","title":"Related Documentation","text":"
  • Frontend Overview
  • Layouts
  • Components
  • Backend Modules
  • User Guides
"},{"location":"v2/frontend/pages/admin/","title":"Admin Pages","text":"

Admin pages provide the main administrative interface for managing campaigns, locations, content, media, and system settings. All admin pages require authentication and appropriate role permissions.

"},{"location":"v2/frontend/pages/admin/#route-context","title":"Route Context","text":"
  • Prefix: /app/*
  • Layout: AppLayout (sidebar navigation)
  • Auth Required: Yes
  • Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN (feature-specific)
"},{"location":"v2/frontend/pages/admin/#dashboard-overview","title":"Dashboard & Overview","text":""},{"location":"v2/frontend/pages/admin/#dashboard-page","title":"Dashboard Page","text":"

Route: /app/dashboard

Main admin landing page with:

  • Recent activity feed
  • Quick statistics (users, campaigns, locations)
  • Action shortcuts
  • System status overview

Role: Any admin role

"},{"location":"v2/frontend/pages/admin/#user-management","title":"User Management","text":""},{"location":"v2/frontend/pages/admin/#users-page","title":"Users Page","text":"

Route: /app/users

User CRUD interface with:

  • Paginated user table
  • Search and filter
  • Role assignment
  • User creation/editing
  • Bulk operations

Role: SUPER_ADMIN only

"},{"location":"v2/frontend/pages/admin/#settings-page","title":"Settings Page","text":"

Route: /app/settings

Global site settings:

  • Site name and branding
  • Contact information
  • Feature flags
  • API configuration

Role: SUPER_ADMIN only

"},{"location":"v2/frontend/pages/admin/#influence-module","title":"Influence Module","text":""},{"location":"v2/frontend/pages/admin/#campaigns-page","title":"Campaigns Page","text":"

Route: /app/influence/campaigns

Campaign management:

  • Campaign CRUD table
  • Status filtering
  • Email stats drawer
  • Target audience configuration

Role: SUPER_ADMIN, INFLUENCE_ADMIN

"},{"location":"v2/frontend/pages/admin/#responses-page","title":"Responses Page","text":"

Route: /app/influence/responses

Response moderation:

  • Response table with filters
  • Verification status
  • Detail drawer
  • Bulk moderation
  • Export options

Role: SUPER_ADMIN, INFLUENCE_ADMIN

"},{"location":"v2/frontend/pages/admin/#representatives-page","title":"Representatives Page","text":"

Route: /app/influence/representatives

Representative cache:

  • Lookup by postal code
  • Cache statistics
  • Manual cache refresh
  • Representative details

Role: SUPER_ADMIN, INFLUENCE_ADMIN

"},{"location":"v2/frontend/pages/admin/#email-queue-page","title":"Email Queue Page","text":"

Route: /app/influence/email-queue

BullMQ queue monitoring:

  • Queue statistics
  • Job status (active, completed, failed)
  • Pause/resume controls
  • Failed job retry
  • Queue cleanup

Role: SUPER_ADMIN, INFLUENCE_ADMIN

"},{"location":"v2/frontend/pages/admin/#map-module","title":"Map Module","text":""},{"location":"v2/frontend/pages/admin/#locations-page","title":"Locations Page","text":"

Route: /app/map/locations

Location database management:

  • Location CRUD table
  • CSV import/export
  • Geocoding controls
  • Map integration
  • NAR import
  • Bulk operations

Role: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/frontend/pages/admin/#cuts-page","title":"Cuts Page","text":"

Route: /app/map/cuts

Geographic cut management:

  • Cut CRUD table
  • Map drawing interface
  • Polygon editing
  • Point-in-polygon queries
  • Export options

Role: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/frontend/pages/admin/#shifts-page","title":"Shifts Page","text":"

Route: /app/map/shifts

Volunteer shift management:

  • Shift CRUD table
  • Signups drawer
  • Email all volunteers
  • Cut assignment
  • Status tracking

Role: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/frontend/pages/admin/#map-settings-page","title":"Map Settings Page","text":"

Route: /app/map/settings

Map configuration:

  • Default center coordinates
  • Default zoom level
  • Walk sheet settings
  • Display preferences

Role: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/frontend/pages/admin/#data-quality-dashboard-page","title":"Data Quality Dashboard Page","text":"

Route: /app/map/data-quality

Geocoding quality metrics:

  • Geocode success rates by provider
  • Failed geocode list
  • Provider statistics
  • Retry controls

Role: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/frontend/pages/admin/#canvassing","title":"Canvassing","text":""},{"location":"v2/frontend/pages/admin/#canvass-dashboard-page","title":"Canvass Dashboard Page","text":"

Route: /app/canvass/dashboard

Canvass monitoring:

  • Active session tracking
  • Visit statistics
  • Cut progress
  • Volunteer leaderboard
  • Activity feed

Role: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/frontend/pages/admin/#walk-sheet-page","title":"Walk Sheet Page","text":"

Route: /app/canvass/walk-sheet

Printable walk sheet:

  • Location list by cut
  • QR codes for quick access
  • Walking route order
  • Browser print integration

Role: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/frontend/pages/admin/#cut-export-page","title":"Cut Export Page","text":"

Route: /app/canvass/cut-export

Printable location report:

  • Cut statistics
  • Location table
  • Map snapshot
  • Browser print integration

Role: SUPER_ADMIN, MAP_ADMIN

"},{"location":"v2/frontend/pages/admin/#content-management","title":"Content Management","text":""},{"location":"v2/frontend/pages/admin/#landing-pages-page","title":"Landing Pages Page","text":"

Route: /app/pages

Landing page CRUD:

  • Page table with search
  • Create/edit/delete
  • Slug management
  • Settings modal
  • MkDocs export

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#page-editor-page","title":"Page Editor Page","text":"

Route: /app/pages/:id/edit

GrapesJS WYSIWYG editor:

  • Full-screen editor
  • Custom block library
  • Ctrl+S save
  • Desktop-only
  • Preview mode

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#email-templates-page","title":"Email Templates Page","text":"

Route: /app/email-templates

Email template CRUD:

  • Template table
  • Variable documentation
  • Preview rendering
  • Version history

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#email-template-editor-page","title":"Email Template Editor Page","text":"

Route: /app/email-templates/:id/edit

Email template editor:

  • Rich text editing
  • Variable insertion
  • HTML source view
  • Preview mode
  • Desktop-only

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#media-management","title":"Media Management","text":""},{"location":"v2/frontend/pages/admin/#library-page","title":"Library Page","text":"

Route: /app/media/library

Video library management:

  • Video CRUD table
  • Upload modal
  • Metadata editing
  • Lock/unlock
  • Bulk operations

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#shared-media-page","title":"Shared Media Page","text":"

Route: /app/media/shared

Public gallery administration:

  • Shared video management
  • Category assignment
  • Visibility controls
  • Reaction moderation

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#media-jobs-page","title":"Media Jobs Page","text":"

Route: /app/media/jobs

Job queue monitoring:

  • Job status tracking
  • Progress indicators
  • Error logs
  • Retry controls

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#service-integrations","title":"Service Integrations","text":""},{"location":"v2/frontend/pages/admin/#listmonk-page","title":"Listmonk Page","text":"

Route: /app/services/listmonk

Newsletter sync management:

  • Connection status
  • Sync controls
  • List statistics
  • Test connection

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#pangolin-page","title":"Pangolin Page","text":"

Route: /app/services/pangolin

Tunnel setup wizard:

  • Tunnel status
  • Configuration wizard
  • Site management
  • Resource configuration

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#docs-page","title":"Docs Page","text":"

Route: /app/services/docs

MkDocs management:

  • Service status
  • Export table
  • Configuration
  • Health checks

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#mkdocs-settings-page","title":"MkDocs Settings Page","text":"

Route: /app/services/mkdocs-settings

Documentation configuration:

  • Site settings
  • Export options
  • Template configuration

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#mini-qr-page","title":"Mini QR Page","text":"

Route: /app/services/qr

QR code service iframe:

  • QR generation interface
  • Download options

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#mailhog-page","title":"MailHog Page","text":"

Route: /app/services/mailhog

Email capture UI:

  • Test email viewer
  • Email list
  • Search/filter

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#code-editor-page","title":"Code Editor Page","text":"

Route: /app/services/code

Code Server management:

  • Code editor iframe
  • File browser
  • Terminal access

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#n8n-page","title":"N8n Page","text":"

Route: /app/services/n8n

Workflow automation:

  • n8n interface iframe
  • Workflow management

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#gitea-page","title":"Gitea Page","text":"

Route: /app/services/gitea

Git repository hosting:

  • Gitea interface iframe
  • Repository browser

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#nocodb-page","title":"NocoDB Page","text":"

Route: /app/services/nocodb

Data browser management:

  • NocoDB interface iframe
  • Database browser

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#monitoring","title":"Monitoring","text":""},{"location":"v2/frontend/pages/admin/#observability-page","title":"Observability Page","text":"

Route: /app/observability

Monitoring dashboard:

  • Prometheus metrics
  • Grafana dashboards
  • Alertmanager alerts
  • Service health

Role: SUPER_ADMIN

"},{"location":"v2/frontend/pages/admin/#admin-page-count","title":"Admin Page Count","text":"

Total: 30 admin pages

"},{"location":"v2/frontend/pages/admin/#common-features","title":"Common Features","text":"

Most admin pages include:

  • Data Tables - Pagination, search, sort, filters
  • Forms - Validation, error display, submit handlers
  • Modals - Create/edit forms, detail views
  • Drawers - Side panels for related data
  • Action Buttons - CRUD operations, exports, bulk actions
  • Loading States - Spinners, skeletons
  • Error Handling - User-friendly error messages
"},{"location":"v2/frontend/pages/admin/#related-documentation","title":"Related Documentation","text":"
  • Frontend Pages Overview
  • Public Pages
  • Volunteer Pages
  • Backend Modules
  • Admin User Guide
"},{"location":"v2/frontend/pages/admin/campaigns-page/","title":"CampaignsPage","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#overview","title":"Overview","text":"

The CampaignsPage provides complete CRUD management for advocacy email campaigns in the Influence module. It displays campaigns in a paginated table with search, status filtering, and quick actions for viewing, editing, deleting, and accessing email statistics. Features include campaign highlighting, government level targeting, and comprehensive feature flags for customizing campaign behavior.

Route: /app/influence/campaigns Component: admin/src/pages/CampaignsPage.tsx (507 lines) Auth Required: Yes (SUPER_ADMIN, INFLUENCE_ADMIN roles) Layout: AppLayout

"},{"location":"v2/frontend/pages/admin/campaigns-page/#screenshot","title":"Screenshot","text":"

[Screenshot: Campaigns page with search bar at top left, status filter dropdown at top right, and \"Create Campaign\" button in page header. Main table shows columns: Title (with highlighted star icon for featured campaigns + public slug), Status (colored tags), Gov. Levels (multiple colored tags), Emails (count), Responses (count), Created (date), and Actions (5 icon buttons: view public page, copy link, view emails, edit, delete). Below table is pagination showing \"X campaigns\" total.]

"},{"location":"v2/frontend/pages/admin/campaigns-page/#features","title":"Features","text":"
  • Full CRUD operations \u2014 Create, read, update, delete campaigns
  • Advanced search \u2014 300ms debounced search by title or description
  • Status filtering \u2014 Filter by DRAFT, ACTIVE, PAUSED, ARCHIVED
  • Campaign highlighting \u2014 Star icon indicates featured campaigns (highlightCampaign flag)
  • Government level tags \u2014 Visual tags for FEDERAL, PROVINCIAL, MUNICIPAL, SCHOOL_BOARD
  • Email statistics \u2014 Click MailOutlined icon to open emails drawer with campaign email stats
  • Public link management \u2014 Copy campaign public link, view public page (ACTIVE only)
  • Comprehensive feature flags \u2014 9 boolean toggles for campaign behavior:
  • Allow SMTP Email (send via queue)
  • Allow Mailto Link (browser email client)
  • Collect User Info (name, email, postal code)
  • Show Email Count (display total emails sent)
  • Show Call Count (display total calls made)
  • Allow Email Editing (user can edit template)
  • Allow Custom Recipients (user can add custom reps)
  • Show Response Wall (public response submission + display)
  • Highlight Campaign (featured on public campaigns list)
  • Color-coded statuses \u2014 Visual distinction between draft, active, paused, archived
  • Responsive table \u2014 Columns hide on smaller screens (Gov. Levels: md+, Responses: lg+, Created: md+)
  • Delete confirmation \u2014 Warns that associated emails and responses will also be deleted
"},{"location":"v2/frontend/pages/admin/campaigns-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#viewing-campaigns-list","title":"Viewing Campaigns List","text":"
  1. Navigate to /app/influence/campaigns
  2. Page loads first 20 campaigns (pagination)
  3. View campaign stats: Emails count, Responses count
  4. See campaign status with colored tags
  5. Identify featured campaigns by star icon (highlightCampaign)
  6. Note public URL slug below campaign title
"},{"location":"v2/frontend/pages/admin/campaigns-page/#creating-a-new-campaign","title":"Creating a New Campaign","text":"
  1. Click \"Create Campaign\" button in page header
  2. Modal opens (640px width) with vertical form
  3. Fill required fields:
  4. Title (auto-generates slug from title)
  5. Email Subject
  6. Email Body (template shown to users)
  7. Fill optional fields:
  8. Description (internal note, not shown to public)
  9. Call to Action (additional instructions for users)
  10. Government Levels (multi-select: Federal, Provincial, Municipal, School Board)
  11. Cover Photo URL (hero image on public campaign page)
  12. Status (default: DRAFT)
  13. Configure feature flags (9 switches in 2-column grid):
  14. Default ON: allowSmtpEmail, allowMailtoLink, collectUserInfo, showEmailCount, showCallCount
  15. Default OFF: allowEmailEditing, allowCustomRecipients, showResponseWall, highlightCampaign
  16. Click \"Create\" button
  17. Success message: \"Campaign created\"
  18. Modal closes, table refreshes to page 1
  19. New campaign appears at top (most recent first)
"},{"location":"v2/frontend/pages/admin/campaigns-page/#editing-an-existing-campaign","title":"Editing an Existing Campaign","text":"
  1. Locate campaign in table
  2. Click Edit icon button (EditOutlined) in Actions column
  3. Edit modal opens (640px width) with pre-filled values
  4. Modify any fields (same form as create)
  5. Click \"Save\" button
  6. Success message: \"Campaign updated\"
  7. Modal closes, table refreshes with updated data
  8. If title changed, slug auto-updates
"},{"location":"v2/frontend/pages/admin/campaigns-page/#viewing-campaign-emails","title":"Viewing Campaign Emails","text":"
  1. Locate campaign in table
  2. Click Mail icon button (MailOutlined) in Actions column
  3. CampaignEmailsDrawer opens on right side (see CampaignEmailsDrawer)
  4. View email statistics:
  5. Total emails sent
  6. Delivered, failed, pending counts
  7. Email list with recipient, status, timestamp
  8. Click \"X\" to close drawer
"},{"location":"v2/frontend/pages/admin/campaigns-page/#publishing-a-campaign","title":"Publishing a Campaign","text":"
  1. Open campaign in edit modal
  2. Change Status dropdown from DRAFT to ACTIVE
  3. Click \"Save\"
  4. Campaign now visible on public /campaigns page
  5. View icon button (EyeOutlined) now enabled
  6. Click View to open public campaign page in new tab
"},{"location":"v2/frontend/pages/admin/campaigns-page/#copying-public-campaign-link","title":"Copying Public Campaign Link","text":"
  1. Locate ACTIVE campaign in table
  2. Click Link icon button (LinkOutlined) in Actions column
  3. URL copied to clipboard: http://app.cmlite.org/campaign/{slug}
  4. Success message: \"Campaign link copied\"
  5. Share link with supporters
"},{"location":"v2/frontend/pages/admin/campaigns-page/#searching-and-filtering","title":"Searching and Filtering","text":"
  1. Use search bar at top left:
  2. Type title or description keywords
  3. 300ms debounce (waits for typing to stop)
  4. Search resets pagination to page 1
  5. Use status filter dropdown at top right:
  6. Select DRAFT, ACTIVE, PAUSED, or ARCHIVED
  7. Filter resets pagination to page 1
  8. Clear filter to show all campaigns
  9. Filters persist during pagination
"},{"location":"v2/frontend/pages/admin/campaigns-page/#deleting-a-campaign","title":"Deleting a Campaign","text":"
  1. Locate campaign in table
  2. Click Delete icon button (DeleteOutlined) in Actions column
  3. Popconfirm appears: \"Delete this campaign?\"
  4. Description: \"All associated emails and responses will also be deleted.\"
  5. Click \"OK\" to confirm
  6. Success message: \"Campaign deleted\"
  7. Table refreshes
  8. Associated CampaignEmail and Response records also deleted (cascade)
"},{"location":"v2/frontend/pages/admin/campaigns-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  • Table \u2014 Main campaigns list with columns, pagination, responsive breakpoints
  • Input \u2014 Search text input with SearchOutlined prefix icon
  • Select \u2014 Status filter dropdown with 4 options
  • Button \u2014 Create (primary), view, copy link, email stats, edit, delete actions
  • Modal \u2014 Create and edit campaign forms (destroyOnHidden)
  • Form \u2014 Vertical layout with all campaign fields
  • Form.Item \u2014 Individual field wrappers with labels, rules, help text
  • Input.TextArea \u2014 Multi-line fields (description, email body, call to action)
  • Row, Col \u2014 Responsive grid for status + gov levels (2 columns), feature flags (2 columns, 9 switches)
  • Switch \u2014 Boolean feature flag toggles with valuePropName=\"checked\"
  • Tag \u2014 Status tags (color-coded), government level tags (color-coded)
  • Space \u2014 Action button grouping
  • Popconfirm \u2014 Delete confirmation with warning message
  • Divider \u2014 Feature flags section separator
"},{"location":"v2/frontend/pages/admin/campaigns-page/#table-columns","title":"Table Columns","text":"
const columns: ColumnsType<Campaign> = [\n  {\n    title: 'Title',\n    dataIndex: 'title',\n    key: 'title',\n    render: (title, record) => (\n      <div>\n        <Space>\n          <span style={{ fontWeight: 500 }}>{title}</span>\n          {record.highlightCampaign && <StarFilled style={{ color: '#faad14' }} />}\n        </Space>\n        <div style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)' }}>/campaign/{record.slug}</div>\n      </div>\n    ),\n  },\n  {\n    title: 'Status',\n    dataIndex: 'status',\n    render: (status) => <Tag color={statusColors[status]}>{status}</Tag>,\n  },\n  {\n    title: 'Gov. Levels',\n    dataIndex: 'targetGovernmentLevels',\n    render: (levels: GovernmentLevel[]) =>\n      levels.map((l) => <Tag key={l} color={govLevelColors[l]}>{l.replace('_', ' ')}</Tag>),\n    responsive: ['md'],\n  },\n  {\n    title: 'Emails',\n    render: (_, record) => record._count.emails,\n    responsive: ['md'],\n  },\n  {\n    title: 'Responses',\n    render: (_, record) => record._count.responses,\n    responsive: ['lg'],\n  },\n  {\n    title: 'Created',\n    dataIndex: 'createdAt',\n    render: (date) => dayjs(date).format('YYYY-MM-DD'),\n    responsive: ['md'],\n  },\n  {\n    title: 'Actions',\n    render: (_, record) => (\n      <Space>\n        {/* View public page (ACTIVE only) */}\n        {/* Copy link */}\n        {/* View emails drawer */}\n        {/* Edit modal */}\n        {/* Delete popconfirm */}\n      </Space>\n    ),\n  },\n];\n

Key patterns: - _count aggregation fields from Prisma (emails, responses) - Responsive column visibility with responsive: ['md'] - Conditional rendering: View button only for ACTIVE campaigns

"},{"location":"v2/frontend/pages/admin/campaigns-page/#status-colors","title":"Status Colors","text":"
const statusColors: Record<CampaignStatus, string> = {\n  DRAFT: 'default',    // Gray\n  ACTIVE: 'green',     // Green\n  PAUSED: 'orange',    // Orange\n  ARCHIVED: 'gray',    // Gray\n};\n
"},{"location":"v2/frontend/pages/admin/campaigns-page/#government-level-colors","title":"Government Level Colors","text":"
const govLevelColors: Record<GovernmentLevel, string> = {\n  FEDERAL: 'blue',\n  PROVINCIAL: 'purple',\n  MUNICIPAL: 'cyan',\n  SCHOOL_BOARD: 'magenta',\n};\n
"},{"location":"v2/frontend/pages/admin/campaigns-page/#feature-flags-form-section","title":"Feature Flags Form Section","text":"
<Divider orientation=\"left\" plain>Feature Flags</Divider>\n<Row gutter={[16, 8]}>\n  <Col xs={24} sm={12}>\n    <Form.Item name=\"allowSmtpEmail\" label=\"Allow SMTP Email\" valuePropName=\"checked\" initialValue={true}>\n      <Switch />\n    </Form.Item>\n  </Col>\n  <Col xs={24} sm={12}>\n    <Form.Item name=\"allowMailtoLink\" label=\"Allow Mailto Link\" valuePropName=\"checked\" initialValue={true}>\n      <Switch />\n    </Form.Item>\n  </Col>\n  {/* 7 more switches */}\n</Row>\n

Pattern: 9 switches in 2-column responsive grid (xs: 1 column, sm+: 2 columns)

"},{"location":"v2/frontend/pages/admin/campaigns-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#zustand-stores-used","title":"Zustand Stores Used","text":"

None \u2014 Campaigns are fetched from API on each page load. No global state required.

"},{"location":"v2/frontend/pages/admin/campaigns-page/#local-state","title":"Local State","text":"
const [campaigns, setCampaigns] = useState<Campaign[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });\nconst [loading, setLoading] = useState(false);\nconst [search, setSearch] = useState('');\nconst [debouncedSearch, setDebouncedSearch] = useState('');\nconst searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\nconst [statusFilter, setStatusFilter] = useState<CampaignStatus | undefined>();\nconst [createModalOpen, setCreateModalOpen] = useState(false);\nconst [editModalOpen, setEditModalOpen] = useState(false);\nconst [editingCampaign, setEditingCampaign] = useState<Campaign | null>(null);\nconst [emailsDrawerOpen, setEmailsDrawerOpen] = useState(false);\nconst [emailsCampaign, setEmailsCampaign] = useState<Campaign | null>(null);\nconst [createForm] = Form.useForm();\nconst [editForm] = Form.useForm();\n

Debounced search pattern:

const handleSearchChange = (value: string) => {\n  setSearch(value);               // Update input immediately\n  clearTimeout(searchTimerRef.current);\n  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);  // Debounce API call\n};\n\nuseEffect(() => {\n  fetchCampaigns({ page: 1 });\n}, [debouncedSearch, statusFilter]);  // Re-fetch when debounced search or filter changes\n\nuseEffect(() => {\n  return () => clearTimeout(searchTimerRef.current);  // Cleanup on unmount\n}, []);\n

Why 300ms debounce? Prevents API spam while typing. Only fetches when user pauses.

"},{"location":"v2/frontend/pages/admin/campaigns-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose GET /api/campaigns List campaigns (paginated, filtered) POST /api/campaigns Create campaign PUT /api/campaigns/:id Update campaign DELETE /api/campaigns/:id Delete campaign (cascade emails + responses)"},{"location":"v2/frontend/pages/admin/campaigns-page/#list-campaigns","title":"List Campaigns","text":"

Request:

const { data } = await api.get<CampaignsListResponse>('/campaigns', {\n  params: {\n    page: 1,\n    limit: 20,\n    search: 'climate',       // Optional: search title/description\n    status: 'ACTIVE',        // Optional: filter by status\n  },\n});\n

Response:

{\n  \"campaigns\": [\n    {\n      \"id\": \"cm-123\",\n      \"title\": \"Contact Your MP About Climate Action\",\n      \"slug\": \"contact-your-mp-about-climate-action\",\n      \"description\": \"Urge federal representatives to support renewable energy legislation\",\n      \"emailSubject\": \"Support Climate Action Now\",\n      \"emailBody\": \"Dear [Representative Name],\\n\\nI am writing to urge you to support...\",\n      \"callToAction\": \"Remember to follow up with a phone call next week!\",\n      \"status\": \"ACTIVE\",\n      \"targetGovernmentLevels\": [\"FEDERAL\"],\n      \"allowSmtpEmail\": true,\n      \"allowMailtoLink\": true,\n      \"collectUserInfo\": true,\n      \"showEmailCount\": true,\n      \"showCallCount\": false,\n      \"allowEmailEditing\": false,\n      \"allowCustomRecipients\": false,\n      \"showResponseWall\": true,\n      \"highlightCampaign\": true,\n      \"coverPhoto\": \"https://example.com/climate.jpg\",\n      \"createdAt\": \"2026-01-15T10:30:00.000Z\",\n      \"updatedAt\": \"2026-01-20T14:45:00.000Z\",\n      \"_count\": {\n        \"emails\": 847,\n        \"responses\": 23\n      }\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 12,\n    \"totalPages\": 1\n  }\n}\n

Key fields: - slug \u2014 URL-friendly identifier (auto-generated from title) - targetGovernmentLevels \u2014 Array of government levels (empty array = all) - _count \u2014 Prisma aggregation with email and response counts - Feature flags \u2014 9 boolean fields controlling campaign behavior

"},{"location":"v2/frontend/pages/admin/campaigns-page/#create-campaign","title":"Create Campaign","text":"

Request:

const payload: CreateCampaignPayload = {\n  title: \"Stop Deforestation in Northern Ontario\",\n  description: \"Internal campaign note\",\n  emailSubject: \"Protect Our Forests\",\n  emailBody: \"Dear [Representative Name],\\n\\nI urge you to...\",\n  callToAction: \"Share this campaign on social media!\",\n  status: \"DRAFT\",\n  targetGovernmentLevels: [\"PROVINCIAL\"],\n  allowSmtpEmail: true,\n  allowMailtoLink: true,\n  collectUserInfo: true,\n  showEmailCount: true,\n  showCallCount: false,\n  allowEmailEditing: false,\n  allowCustomRecipients: false,\n  showResponseWall: false,\n  highlightCampaign: false,\n  coverPhoto: \"https://example.com/forest.jpg\",\n};\n\nawait api.post('/campaigns', payload);\n

Response:

{\n  \"id\": \"cm-456\",\n  \"title\": \"Stop Deforestation in Northern Ontario\",\n  \"slug\": \"stop-deforestation-in-northern-ontario\",\n  \"status\": \"DRAFT\",\n  \"createdAt\": \"2026-02-11T09:00:00.000Z\",\n  // ... all other fields\n}\n

Slug generation: Backend auto-generates slug from title (lowercase, hyphens replace spaces/punctuation)

"},{"location":"v2/frontend/pages/admin/campaigns-page/#update-campaign","title":"Update Campaign","text":"

Request:

const payload: UpdateCampaignPayload = {\n  status: \"ACTIVE\",              // Publish campaign\n  highlightCampaign: true,       // Feature on campaigns list\n  showResponseWall: true,        // Enable response submissions\n};\n\nawait api.put(`/campaigns/${campaignId}`, payload);\n

Response:

{\n  \"id\": \"cm-456\",\n  \"status\": \"ACTIVE\",\n  \"highlightCampaign\": true,\n  \"showResponseWall\": true,\n  \"updatedAt\": \"2026-02-11T10:15:00.000Z\",\n  // ... all other fields\n}\n

Partial updates: Only send changed fields, backend merges with existing record.

"},{"location":"v2/frontend/pages/admin/campaigns-page/#delete-campaign","title":"Delete Campaign","text":"

Request:

await api.delete(`/campaigns/${campaignId}`);\n

Response: 204 No Content

Cascade behavior: Prisma cascade deletes: - All CampaignEmail records (sent emails) - All Response records (public responses) - All PostalCodeCache entries referencing this campaign

Warning: Shown in Popconfirm: \"All associated emails and responses will also be deleted.\"

"},{"location":"v2/frontend/pages/admin/campaigns-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#debounced-search-implementation","title":"Debounced Search Implementation","text":"
const [search, setSearch] = useState('');\nconst [debouncedSearch, setDebouncedSearch] = useState('');\nconst searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\n\nconst handleSearchChange = (value: string) => {\n  setSearch(value);                             // Update input immediately (controlled component)\n  clearTimeout(searchTimerRef.current);         // Cancel previous timer\n  searchTimerRef.current = setTimeout(() => {\n    setDebouncedSearch(value);                  // Update debounced value after 300ms\n  }, 300);\n};\n\nuseEffect(() => {\n  return () => clearTimeout(searchTimerRef.current);  // Cleanup timer on unmount\n}, []);\n\nuseEffect(() => {\n  fetchCampaigns({ page: 1 });                  // Re-fetch when debounced search changes\n}, [debouncedSearch, statusFilter]);            // Also re-fetch when filter changes\n

Benefits: - User sees immediate feedback in input (controlled) - API only called once per 300ms (prevents spam) - Timer cleared on unmount (no memory leaks)

"},{"location":"v2/frontend/pages/admin/campaigns-page/#usecallback-optimization","title":"useCallback Optimization","text":"
const fetchCampaigns = useCallback(async (params?: CampaignsListParams) => {\n  setLoading(true);\n  try {\n    const { data } = await api.get<CampaignsListResponse>('/campaigns', {\n      params: {\n        page: params?.page ?? pagination.page,\n        limit: params?.limit ?? pagination.limit,\n        search: params?.search ?? (debouncedSearch || undefined),\n        status: params?.status ?? statusFilter,\n      },\n    });\n    setCampaigns(data.campaigns);\n    setPagination(data.pagination);\n  } catch {\n    message.error('Failed to load campaigns');\n  } finally {\n    setLoading(false);\n  }\n}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);\n

Why useCallback? Memoizes function, prevents re-creating on every render. Dependencies array ensures function updates when pagination, search, or filter changes.

"},{"location":"v2/frontend/pages/admin/campaigns-page/#color-coded-government-level-tags","title":"Color-Coded Government Level Tags","text":"
const govLevelColors: Record<GovernmentLevel, string> = {\n  FEDERAL: 'blue',\n  PROVINCIAL: 'purple',\n  MUNICIPAL: 'cyan',\n  SCHOOL_BOARD: 'magenta',\n};\n\n// In table column render:\n{\n  title: 'Gov. Levels',\n  dataIndex: 'targetGovernmentLevels',\n  render: (levels: GovernmentLevel[]) =>\n    levels.length > 0\n      ? levels.map((l) => (\n          <Tag key={l} color={govLevelColors[l]} style={{ fontSize: 11 }}>\n            {l.replace('_', ' ')}  // \"SCHOOL_BOARD\" \u2192 \"SCHOOL BOARD\"\n          </Tag>\n        ))\n      : '--',\n  responsive: ['md'],\n}\n

Pattern: Map each government level to a colored tag, replace underscores with spaces for readability.

"},{"location":"v2/frontend/pages/admin/campaigns-page/#reusable-form-fields-component","title":"Reusable Form Fields Component","text":"
const campaignFormFields = (\n  <>\n    <Form.Item name=\"title\" label=\"Title\" rules={[{ required: true }]}>\n      <Input />\n    </Form.Item>\n    <Form.Item name=\"description\" label=\"Description\">\n      <TextArea rows={2} />\n    </Form.Item>\n    {/* ... all other fields */}\n    <Divider orientation=\"left\" plain>Feature Flags</Divider>\n    <Row gutter={[16, 8]}>\n      {/* 9 switches in 2-column grid */}\n    </Row>\n  </>\n);\n\n// Used in both create and edit modals:\n<Form form={createForm} onFinish={handleCreate} layout=\"vertical\">\n  {campaignFormFields}\n</Form>\n\n<Form form={editForm} onFinish={handleEdit} layout=\"vertical\">\n  {campaignFormFields}\n</Form>\n

Benefits: - DRY principle (Don't Repeat Yourself) - Single source of truth for form structure - Easy to add/modify fields in one place

"},{"location":"v2/frontend/pages/admin/campaigns-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#debounced-search","title":"Debounced Search","text":"

300ms debounce prevents API spam: - User typing \"climate action\" fires 1 API call (not 14) - Reduces server load, improves responsiveness - Uses clearTimeout to cancel pending calls

"},{"location":"v2/frontend/pages/admin/campaigns-page/#responsive-column-hiding","title":"Responsive Column Hiding","text":"
{\n  title: 'Gov. Levels',\n  responsive: ['md'],  // Hide on screens < 768px\n}\n

Benefits: - Mobile users see only essential columns (Title, Status, Actions) - Desktop users see full details - No horizontal scrolling on mobile

"},{"location":"v2/frontend/pages/admin/campaigns-page/#usecallback-memoization","title":"useCallback Memoization","text":"
const fetchCampaigns = useCallback(async (params) => {\n  // ... fetch logic\n}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);\n

Benefits: - Function reference stable unless dependencies change - Prevents unnecessary re-renders in child components - Avoids infinite re-render loops

"},{"location":"v2/frontend/pages/admin/campaigns-page/#pagination","title":"Pagination","text":"

Default 20 items per page: - Keeps initial load fast - User can change page size (10, 20, 50, 100) - Server-side pagination (not loading all campaigns at once)

"},{"location":"v2/frontend/pages/admin/campaigns-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#mobile-576px","title":"Mobile (< 576px)","text":"
  • Table: Single column layout
  • Title + star icon (if highlighted)
  • Status tag
  • Actions column (all 5 buttons visible)
  • Gov. Levels, Emails, Responses, Created columns hidden
  • Search bar: Full width
  • Status filter: Full width below search
  • Feature flags: Single column (xs={24})
"},{"location":"v2/frontend/pages/admin/campaigns-page/#tablet-576px-992px","title":"Tablet (576px - 992px)","text":"
  • Table: Gov. Levels, Emails, Created columns visible
  • Responses column still hidden (lg+)
  • Search bar: Half width (sm={12})
  • Status filter: Quarter width (sm={6})
  • Feature flags: 2 columns (sm={12})
"},{"location":"v2/frontend/pages/admin/campaigns-page/#desktop-992px","title":"Desktop (\u2265 992px)","text":"
  • Table: All columns visible
  • Filters: Compact layout (search \u2153 width, filter \u2159 width)
  • Feature flags: 2 columns with comfortable spacing
"},{"location":"v2/frontend/pages/admin/campaigns-page/#accessibility","title":"Accessibility","text":"
  • Keyboard navigation: All buttons, inputs, selects focusable via Tab
  • ARIA labels: Icon buttons have title attribute for tooltips
  • Form validation: Required fields marked with red asterisk, inline error messages
  • Color contrast: Status tags use Ant Design default colors (WCAG AA compliant)
  • Screen reader support: Form.Item labels properly associated with inputs
  • Focus management: Modal auto-focuses first input on open
"},{"location":"v2/frontend/pages/admin/campaigns-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#campaign-not-appearing-on-public-page","title":"Campaign Not Appearing on Public Page","text":"

Problem: Created campaign, set status to ACTIVE, but /campaigns page doesn't show it.

Diagnosis:

Check status in campaigns table:

campaigns.find((c) => c.slug === 'my-campaign')?.status  // Should be \"ACTIVE\"\n

Common Issues:

  1. Status still DRAFT:
  2. Edit campaign
  3. Change Status dropdown from DRAFT to ACTIVE
  4. Click Save

  5. Browser cache:

  6. Hard refresh public page (Ctrl+Shift+R)
  7. Or clear browser cache

  8. Campaign created but not saved:

  9. Check for error message after clicking Create
  10. Verify required fields filled (Title, Email Subject, Email Body)

Solution: Always verify status is ACTIVE after creating campaign. Status defaults to DRAFT.

"},{"location":"v2/frontend/pages/admin/campaigns-page/#emails-drawer-shows-0-emails","title":"Emails Drawer Shows 0 Emails","text":"

Problem: Click Mail icon for ACTIVE campaign, drawer shows 0 emails.

Diagnosis:

Campaign might be active but no one has sent emails yet:

campaign._count.emails === 0  // No emails sent via this campaign\n

Common Issues:

  1. Campaign just published:
  2. No users have accessed public page yet
  3. Share campaign link to supporters

  4. SMTP not configured:

  5. Check Settings \u2192 Email tab
  6. Verify Production SMTP credentials
  7. Test connection

  8. BullMQ queue not running:

  9. Check docker-compose logs: docker compose logs email-worker
  10. Verify redis container running

Solution: Emails drawer shows historical data. If campaign is new, wait for users to send emails via public page.

"},{"location":"v2/frontend/pages/admin/campaigns-page/#copy-link-button-not-working","title":"Copy Link Button Not Working","text":"

Problem: Click Link icon, no success message, clipboard empty.

Diagnosis:

Check browser console for errors:

DOMException: Document is not focused\n

Common Issue:

Browser security blocks clipboard access if page not focused.

Solution:

  1. Click anywhere on page to focus
  2. Retry Copy Link button
  3. Or manually copy slug from table: /campaign/{slug}
"},{"location":"v2/frontend/pages/admin/campaigns-page/#duplicate-campaign-titles","title":"Duplicate Campaign Titles","text":"

Problem: Create campaign with same title as existing, backend allows it.

Diagnosis:

Backend auto-generates unique slug by appending numbers:

\"Climate Action\" \u2192 \"climate-action\"\n\"Climate Action\" (duplicate) \u2192 \"climate-action-1\"\n\"Climate Action\" (duplicate 2) \u2192 \"climate-action-2\"\n

Not an error: Duplicate titles allowed, slugs remain unique.

Best Practice: Use unique, descriptive titles to avoid confusion: - \u274c \"Climate Action\" (generic) - \u2705 \"Climate Action: Support Bill C-12\" (specific)

"},{"location":"v2/frontend/pages/admin/campaigns-page/#delete-confirmation-not-showing","title":"Delete Confirmation Not Showing","text":"

Problem: Click Delete icon, campaign deletes immediately without confirmation.

Diagnosis:

Check Popconfirm placement in table Actions column:

<Popconfirm\n  title=\"Delete this campaign?\"\n  description=\"All associated emails and responses will also be deleted.\"\n  onConfirm={() => handleDelete(record.id)}\n>\n  <Button type=\"link\" size=\"small\" danger icon={<DeleteOutlined />} title=\"Delete\" />\n</Popconfirm>\n

Solution: Popconfirm wraps the Button. If Popconfirm missing, delete happens immediately. Always use Popconfirm for destructive actions.

"},{"location":"v2/frontend/pages/admin/campaigns-page/#related-documentation","title":"Related Documentation","text":"
  • Campaigns Module (Backend) \u2014 API implementation, schemas, service functions
  • Campaign Emails Module \u2014 Email tracking, stats, sent emails
  • Responses Module \u2014 Response wall, moderation, upvoting
  • CampaignEmailsDrawer Component \u2014 Email statistics drawer
  • Public Campaign Page \u2014 Public-facing campaign detail page
  • Campaigns API Reference \u2014 Complete endpoint documentation
  • Influence Feature Guide \u2014 End-to-end campaign workflow
  • User Guide: Campaign Management \u2014 Step-by-step campaign creation guide
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/","title":"CanvassDashboardPage","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#overview","title":"Overview","text":"

The CanvassDashboardPage provides a real-time administrative overview of all volunteer canvassing activities across all cuts. It displays live statistics (total visits, active volunteers, active sessions), a chronological activity feed showing recent visit outcomes, cut-by-cut progress tracking with completion percentages, a volunteer leaderboard ranked by visit count, and an interactive map showing active volunteers' current GPS positions. The dashboard auto-refreshes every 30 seconds to maintain real-time accuracy.

Route: /app/canvass/dashboard Component: admin/src/pages/CanvassDashboardPage.tsx (316 lines) Auth Required: Yes (SUPER_ADMIN or MAP_ADMIN role recommended) Layout: AppLayout Backend Module: api/src/modules/map/canvass/

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#screenshot","title":"Screenshot","text":"

[Screenshot: CanvassDashboardPage with \"Canvass Dashboard\" title and refresh icon button. Below are four statistics cards in a row: \"Total Visits: 1,247\", \"Active Volunteers: 8\", \"Active Sessions: 5\", \"Avg Visits per Session: 23.7\". Below that is a two-column layout: left side has \"Recent Activity\" card with scrollable feed showing timestamped visit entries like \"John Doe - NOT_HOME (123 Main St) - 2 mins ago\"; right side has two stacked cards: \"Cut Progress\" showing progress bars for each cut with percentages, and \"Top Volunteers\" showing ranked list with visit counts. At bottom is full-width \"Live Volunteer Map\" card with Leaflet map showing blue circle markers for active volunteer positions, colored polygon overlays for cuts, and legend in bottom-right corner.]

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#features","title":"Features","text":"
  • Real-time statistics \u2014 Total visits, active volunteers, active sessions, average visits per session
  • Auto-refresh \u2014 Updates every 30 seconds automatically (configurable interval)
  • Activity feed \u2014 Chronological list of recent visits with outcome, address, timestamp
  • Cut progress tracking \u2014 Progress bars showing visit completion percentage per cut
  • Volunteer leaderboard \u2014 Top 10 volunteers ranked by total visit count
  • Live volunteer map \u2014 Interactive Leaflet map showing active volunteers' GPS positions
  • Manual refresh \u2014 Refresh button to update data immediately
  • Responsive design \u2014 Two-column layout on desktop, stacked on mobile
  • Color-coded outcomes \u2014 Visit outcomes highlighted with semantic colors (green, red, orange, blue)
  • Relative timestamps \u2014 Human-readable time since visit (e.g., \"2 mins ago\", \"1 hour ago\")
  • Empty states \u2014 Friendly messages when no data available
  • Cut filtering \u2014 Click cut name in progress list to view cut details
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#monitoring-active-canvassing","title":"Monitoring Active Canvassing","text":"
  1. Navigate to /app/canvass/dashboard
  2. Page loads with initial data fetch
  3. View statistics cards (top row):
  4. Total Visits: All-time visit count across all cuts
  5. Active Volunteers: Currently signed in with active sessions
  6. Active Sessions: Currently ACTIVE sessions (not COMPLETED or ABANDONED)
  7. Avg Visits per Session: Total visits / total sessions
  8. Observe auto-refresh indicator:
  9. Page refreshes every 30 seconds
  10. No loading spinner (silent refresh)
  11. Data updates smoothly without UI flicker
  12. Monitor activity feed (left column):
  13. See most recent 20 visits
  14. Each entry shows: volunteer name, outcome, address, relative time
  15. Color-coded by outcome (Answered=green, Not Home=red, etc.)
  16. Auto-scrolls to top when new visits appear
  17. Track cut progress (right column, top):
  18. See all cuts with visit counts
  19. Progress bars show completion percentage
  20. Percentage calculated as (visits / locations) \u00d7 100%
  21. View volunteer leaderboard (right column, bottom):
  22. Top 10 volunteers by visit count
  23. Shows total visits per volunteer
  24. Ranked 1st to 10th place
  25. Use live map (bottom):
  26. See active volunteers as blue circle markers
  27. View cut polygons as colored overlays
  28. Zoom and pan to explore territory
  29. Hover over markers for volunteer name and current location
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#responding-to-activity","title":"Responding to Activity","text":"
  1. Notice new visit in activity feed (e.g., \"Jane Doe - ANSWERED - 456 Oak Ave - Just now\")
  2. Click cut name in progress section to view cut details:
  3. Navigates to /app/map/cuts?id={cutId}
  4. Opens CutsPage filtered to that cut
  5. Can view all locations, edit cut, or export data
  6. Click volunteer name in leaderboard to view volunteer details:
  7. Navigates to /app/users?id={userId}
  8. Opens UsersPage filtered to that user
  9. Can edit user info, assign roles, or view all visits
  10. Click refresh button (top-right, next to title) to force immediate data update:
  11. Fetches latest data from API
  12. Updates all sections simultaneously
  13. Useful when expecting urgent update (e.g., shift just ended)
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#identifying-issues","title":"Identifying Issues","text":"
  1. No Active Volunteers:
  2. Statistics show \"Active Volunteers: 0\"
  3. Activity feed is empty or stale
  4. Action: Check if any shifts are scheduled, contact volunteers to start shifts

  5. High \"Not Home\" Rate:

  6. Activity feed shows many red \"NOT_HOME\" entries
  7. Action: Consider rescheduling shifts to evening hours when residents more likely home

  8. Stalled Sessions:

  9. Active Sessions count doesn't decrease over time
  10. Action: Check for abandoned sessions (volunteers forgot to end session), manually close via backend

  11. Volunteers Off Course:

  12. Live map shows volunteer marker far from assigned cut polygon
  13. Action: Contact volunteer to redirect back to assigned territory

  14. Low Visits per Session:

  15. Average visits per session below expected rate (e.g., < 10)
  16. Action: Investigate if locations are too far apart, provide walking route optimization
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#using-live-map","title":"Using Live Map","text":"
  1. Scroll to \"Live Volunteer Map\" card at bottom
  2. Map loads with:
  3. Cut polygons as colored overlays (semi-transparent fill)
  4. Active volunteer markers as blue circles
  5. Legend in bottom-right corner
  6. Zoom controls:
  7. Plus (+) button: Zoom in
  8. Minus (\u2212) button: Zoom out
  9. Scroll wheel: Zoom in/out
  10. Pan:
  11. Click and drag map to move view
  12. Double-click to zoom in on point
  13. Marker interaction:
  14. Hover over blue marker: Tooltip shows volunteer name
  15. Click marker: Opens popup with volunteer details (name, current session, visit count)
  16. Polygon interaction:
  17. Hover over cut polygon: Tooltip shows cut name and visit count
  18. Click polygon: Navigates to cut details page
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  • Typography.Title \u2014 Page heading (\"Canvass Dashboard\")
  • Typography.Text \u2014 Labels, descriptions, empty state text
  • Row / Col \u2014 Grid layout for statistics cards and two-column layout
  • Card \u2014 Container for all sections (stats, activity, progress, leaderboard, map)
  • Statistic \u2014 Formatted numeric statistics display
  • Button \u2014 Refresh button (top-right)
  • List \u2014 Activity feed list
  • List.Item \u2014 Individual activity entries
  • Progress \u2014 Cut progress bars
  • Empty \u2014 Empty state when no data available
  • Tooltip \u2014 Hover tooltips on map markers
  • Spin \u2014 Loading spinner during initial data fetch
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#custom-components","title":"Custom Components","text":"
  • AdminMapView \u2014 Leaflet map wrapper with volunteer markers and cut overlays
  • Renders active volunteer positions as blue circle markers
  • Renders cut polygons as colored overlays
  • Provides zoom/pan controls
  • Auto-centers on volunteer cluster
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#statistics-cards","title":"Statistics Cards","text":"
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>\n  <Col xs={24} sm={12} md={6}>\n    <Card>\n      <Statistic\n        title=\"Total Visits\"\n        value={stats.totalVisits}\n        prefix={<CheckCircleOutlined />}\n        valueStyle={{ color: '#3f8600' }}\n      />\n    </Card>\n  </Col>\n  <Col xs={24} sm={12} md={6}>\n    <Card>\n      <Statistic\n        title=\"Active Volunteers\"\n        value={stats.activeVolunteers}\n        prefix={<TeamOutlined />}\n        valueStyle={{ color: '#1890ff' }}\n      />\n    </Card>\n  </Col>\n  <Col xs={24} sm={12} md={6}>\n    <Card>\n      <Statistic\n        title=\"Active Sessions\"\n        value={stats.activeSessions}\n        prefix={<FieldTimeOutlined />}\n        valueStyle={{ color: '#faad14' }}\n      />\n    </Card>\n  </Col>\n  <Col xs={24} sm={12} md={6}>\n    <Card>\n      <Statistic\n        title=\"Avg Visits per Session\"\n        value={stats.avgVisitsPerSession}\n        precision={1}\n        prefix={<LineChartOutlined />}\n        valueStyle={{ color: '#722ed1' }}\n      />\n    </Card>\n  </Col>\n</Row>\n

Responsive Grid: - Mobile (xs, <576px): Stacked cards (24 columns = full width) - Tablet (sm, \u2265576px): 2 columns (12 columns each = 50% width) - Desktop (md, \u2265768px): 4 columns (6 columns each = 25% width)

Color-Coded Values: - Total Visits: Green (#3f8600) \u2014 success metric - Active Volunteers: Blue (#1890ff) \u2014 informational - Active Sessions: Orange (#faad14) \u2014 warning/attention - Avg Visits per Session: Purple (#722ed1) \u2014 analytical

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#activity-feed","title":"Activity Feed","text":"
<Card\n  title=\"Recent Activity\"\n  style={{ height: 400, overflow: 'auto' }}\n>\n  <List\n    dataSource={recentActivity}\n    renderItem={(activity) => (\n      <List.Item>\n        <Space direction=\"vertical\" size={0} style={{ width: '100%' }}>\n          <Space>\n            <Text strong>{activity.volunteerName}</Text>\n            <Tag color={getOutcomeColor(activity.outcome)}>\n              {formatOutcome(activity.outcome)}\n            </Tag>\n          </Space>\n          <Text type=\"secondary\" style={{ fontSize: 13 }}>\n            {activity.address}\n          </Text>\n          <Text type=\"secondary\" style={{ fontSize: 12 }}>\n            {formatRelativeTime(activity.timestamp)}\n          </Text>\n        </Space>\n      </List.Item>\n    )}\n  />\n</Card>\n

Activity Entry Structure: - Line 1: Volunteer name (bold) + Outcome tag (color-coded) - Line 2: Location address (secondary gray text) - Line 3: Relative timestamp (smaller secondary text)

Outcome Color Mapping:

function getOutcomeColor(outcome: string): string {\n  const colorMap: Record<string, string> = {\n    ANSWERED: 'green',\n    NOT_HOME: 'red',\n    MOVED: 'orange',\n    REFUSED: 'volcano',\n    INACCESSIBLE: 'default',\n    OTHER: 'blue',\n  };\n  return colorMap[outcome] || 'default';\n}\n

Relative Time Formatting:

function formatRelativeTime(timestamp: string): string {\n  const now = dayjs();\n  const visitTime = dayjs(timestamp);\n  const diffMinutes = now.diff(visitTime, 'minute');\n\n  if (diffMinutes < 1) return 'Just now';\n  if (diffMinutes < 60) return `${diffMinutes} min${diffMinutes > 1 ? 's' : ''} ago`;\n\n  const diffHours = now.diff(visitTime, 'hour');\n  if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;\n\n  const diffDays = now.diff(visitTime, 'day');\n  return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;\n}\n
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#cut-progress-section","title":"Cut Progress Section","text":"
<Card title=\"Cut Progress\" style={{ marginBottom: 16 }}>\n  <Space direction=\"vertical\" size=\"middle\" style={{ width: '100%' }}>\n    {cutProgress.map((cut) => (\n      <div key={cut.id}>\n        <Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 4 }}>\n          <Text strong>{cut.name}</Text>\n          <Text type=\"secondary\">\n            {cut.visitCount} / {cut.locationCount} locations\n          </Text>\n        </Space>\n        <Progress\n          percent={cut.percentage}\n          status={cut.percentage === 100 ? 'success' : 'active'}\n          strokeColor={cut.percentage === 100 ? '#52c41a' : '#1890ff'}\n        />\n      </div>\n    ))}\n  </Space>\n</Card>\n

Progress Calculation:

const percentage = Math.round((cut.visitCount / cut.locationCount) * 100);\n

Progress Bar States: - Active (< 100%): Blue bar, animated stripes - Success (100%): Green bar, checkmark icon - Empty (0%): Gray bar, no progress

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#volunteer-leaderboard","title":"Volunteer Leaderboard","text":"
<Card title=\"Top Volunteers\">\n  <List\n    dataSource={topVolunteers.slice(0, 10)}  // Top 10 only\n    renderItem={(volunteer, index) => (\n      <List.Item>\n        <Space>\n          <Text strong style={{ fontSize: 16 }}>\n            #{index + 1}\n          </Text>\n          <Text>{volunteer.name}</Text>\n        </Space>\n        <Tag color=\"blue\">{volunteer.visitCount} visits</Tag>\n      </List.Item>\n    )}\n  />\n</Card>\n

Ranking Display: - #1-3: Bold, larger font (emphasize top performers) - #4-10: Standard font - Visit count: Blue badge on right side

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#live-volunteer-map","title":"Live Volunteer Map","text":"
<Card title=\"Live Volunteer Map\" style={{ marginTop: 24 }}>\n  <div style={{ height: 500 }}>\n    <AdminMapView\n      cuts={cuts}\n      volunteers={activeVolunteers}\n      showCutOverlays={true}\n      showVolunteerMarkers={true}\n      autoCenter={true}\n    />\n  </div>\n</Card>\n

Map Features: - Height: Fixed 500px (provides adequate viewing area) - Auto-center: Automatically zooms to show all active volunteers - Cut overlays: Semi-transparent polygons with cut colors - Volunteer markers: Blue circles at current GPS position - Legend: Bottom-right corner explaining marker types

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#local-state-no-zustand-store","title":"Local State (No Zustand Store)","text":"
// Data state\nconst [stats, setStats] = useState<CanvassStats | null>(null);\nconst [recentActivity, setRecentActivity] = useState<CanvassVisit[]>([]);\nconst [cutProgress, setCutProgress] = useState<CutProgress[]>([]);\nconst [topVolunteers, setTopVolunteers] = useState<VolunteerStats[]>([]);\nconst [activeVolunteers, setActiveVolunteers] = useState<ActiveVolunteer[]>([]);\nconst [cuts, setCuts] = useState<Cut[]>([]);\nconst [loading, setLoading] = useState(true);\n

No Global State:

This page does NOT use Zustand stores. All data is fetched directly from the API and stored in local state. This is appropriate because: - Dashboard data is admin-only (not shared with other pages) - Data is highly dynamic (changes every 30 seconds) - No need to persist data between page visits - Simpler architecture without store overhead

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#auto-refresh-with-useeffect","title":"Auto-Refresh with useEffect","text":"
const loadData = useCallback(async () => {\n  try {\n    // Fetch all data in parallel\n    const [statsRes, activityRes, progressRes, volunteersRes, cutsRes, activePosRes] = await Promise.all([\n      api.get<CanvassStats>('/canvass/admin/stats'),\n      api.get<CanvassVisit[]>('/canvass/admin/recent-activity?limit=20'),\n      api.get<CutProgress[]>('/canvass/admin/cut-progress'),\n      api.get<VolunteerStats[]>('/canvass/admin/top-volunteers?limit=10'),\n      api.get<Cut[]>('/cuts'),\n      api.get<ActiveVolunteer[]>('/canvass/admin/active-volunteers'),\n    ]);\n\n    setStats(statsRes.data);\n    setRecentActivity(activityRes.data);\n    setCutProgress(progressRes.data);\n    setTopVolunteers(volunteersRes.data);\n    setCuts(cutsRes.data);\n    setActiveVolunteers(activePosRes.data);\n  } catch (error) {\n    message.error('Failed to load dashboard data');\n  } finally {\n    setLoading(false);\n  }\n}, []);\n\nuseEffect(() => {\n  // Initial load\n  loadData();\n\n  // Set up auto-refresh interval\n  const interval = setInterval(loadData, 30000);  // Refresh every 30 seconds\n\n  // Cleanup on unmount\n  return () => clearInterval(interval);\n}, [loadData]);\n

Auto-Refresh Strategy:

  • Initial load: Immediate fetch on mount
  • Interval: 30 seconds (30,000 milliseconds)
  • Parallel fetching: 6 API calls executed simultaneously (Promise.all)
  • Silent refresh: No loading spinner on auto-refresh (only on initial load)
  • Cleanup: Clear interval on unmount to prevent memory leak

Why 30 Seconds?

  • Balance: Frequent enough to feel real-time, infrequent enough to avoid API overload
  • Network efficiency: 6 API calls every 30 seconds = 12 requests/minute (manageable)
  • Battery-friendly: 30-second interval doesn't drain mobile devices excessively
  • Configurable: Can be adjusted via environment variable if needed
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#usecallback-optimization","title":"useCallback Optimization","text":"
const loadData = useCallback(async () => {\n  // ... fetch logic\n}, []);\n\nuseEffect(() => {\n  loadData();\n  const interval = setInterval(loadData, 30000);\n  return () => clearInterval(interval);\n}, [loadData]);\n

Why useCallback?

  • Prevents infinite re-renders: Without useCallback, useEffect would create new function reference on every render, triggering effect again
  • Stable interval: Ensures setInterval always references same function instance
  • No dependencies: Empty dependency array means function never re-created
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose Auth GET /api/canvass/admin/stats Overall statistics Required (ADMIN) GET /api/canvass/admin/recent-activity Recent 20 visits Required (ADMIN) GET /api/canvass/admin/cut-progress Cut-by-cut progress Required (ADMIN) GET /api/canvass/admin/top-volunteers Volunteer leaderboard Required (ADMIN) GET /api/canvass/admin/active-volunteers Live volunteer positions Required (ADMIN) GET /api/cuts All cuts for map Required"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#load-overall-statistics","title":"Load Overall Statistics","text":"

Request:

const { data } = await api.get<CanvassStats>('/canvass/admin/stats');\n

Response (200 OK):

{\n  \"totalVisits\": 1247,\n  \"activeVolunteers\": 8,\n  \"activeSessions\": 5,\n  \"avgVisitsPerSession\": 23.7,\n  \"breakdown\": {\n    \"ANSWERED\": 523,\n    \"NOT_HOME\": 412,\n    \"MOVED\": 89,\n    \"REFUSED\": 156,\n    \"INACCESSIBLE\": 45,\n    \"OTHER\": 22\n  }\n}\n

Response Fields: - totalVisits (number): All-time visit count across all sessions - activeVolunteers (number): Currently signed in with ACTIVE sessions - activeSessions (number): Sessions with status = ACTIVE (not COMPLETED or ABANDONED) - avgVisitsPerSession (number): Total visits / total sessions (decimal) - breakdown (object): Visit count by outcome type

Backend Calculation:

const totalVisits = await prisma.canvassVisit.count();\n\nconst activeSessions = await prisma.canvassSession.count({\n  where: { status: 'ACTIVE' },\n});\n\nconst activeVolunteers = await prisma.canvassSession.findMany({\n  where: { status: 'ACTIVE' },\n  distinct: ['userId'],\n});\n\nconst allSessions = await prisma.canvassSession.count();\nconst avgVisitsPerSession = allSessions > 0 ? totalVisits / allSessions : 0;\n\nconst breakdown = await prisma.canvassVisit.groupBy({\n  by: ['outcome'],\n  _count: { id: true },\n});\n
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#load-recent-activity","title":"Load Recent Activity","text":"

Request:

const { data } = await api.get<CanvassVisit[]>('/canvass/admin/recent-activity', {\n  params: { limit: 20 },\n});\n

Query Parameters: - limit (number, optional): Maximum number of visits to return (default: 20)

Response (200 OK):

[\n  {\n    \"id\": \"visit_abc123\",\n    \"outcome\": \"ANSWERED\",\n    \"address\": \"456 Oak Avenue\",\n    \"timestamp\": \"2026-02-11T14:23:15.000Z\",\n    \"volunteerName\": \"Jane Doe\",\n    \"locationId\": \"loc_def456\",\n    \"sessionId\": \"session_ghi789\"\n  },\n  {\n    \"id\": \"visit_jkl012\",\n    \"outcome\": \"NOT_HOME\",\n    \"address\": \"123 Main Street\",\n    \"timestamp\": \"2026-02-11T14:18:42.000Z\",\n    \"volunteerName\": \"John Smith\",\n    \"locationId\": \"loc_mno345\",\n    \"sessionId\": \"session_pqr678\"\n  }\n]\n

Response Fields: - id (string): Unique visit identifier - outcome (string): Visit outcome (ANSWERED, NOT_HOME, MOVED, REFUSED, INACCESSIBLE, OTHER) - address (string): Location address - timestamp (ISO 8601): Visit timestamp - volunteerName (string): Name of volunteer who recorded visit - locationId (string): Associated location ID - sessionId (string): Associated canvass session ID

Sorting: - Ordered by timestamp DESC (most recent first)

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#load-cut-progress","title":"Load Cut Progress","text":"

Request:

const { data } = await api.get<CutProgress[]>('/canvass/admin/cut-progress');\n

Response (200 OK):

[\n  {\n    \"id\": \"cut_abc123\",\n    \"name\": \"Downtown Core\",\n    \"locationCount\": 157,\n    \"visitCount\": 89,\n    \"percentage\": 57\n  },\n  {\n    \"id\": \"cut_def456\",\n    \"name\": \"Riverside District\",\n    \"locationCount\": 203,\n    \"visitCount\": 203,\n    \"percentage\": 100\n  },\n  {\n    \"id\": \"cut_ghi789\",\n    \"name\": \"Suburban Area\",\n    \"locationCount\": 312,\n    \"visitCount\": 0,\n    \"percentage\": 0\n  }\n]\n

Response Fields: - id (string): Cut identifier - name (string): Cut name - locationCount (number): Total locations in cut - visitCount (number): Number of locations with at least one visit - percentage (number): Completion percentage (rounded to integer)

Percentage Calculation:

// Backend calculation\nconst percentage = Math.round((visitCount / locationCount) * 100);\n

Important: A location is counted as \"visited\" if it has at least one CanvassVisit record, regardless of outcome. Multiple visits to same location don't increase count.

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#load-top-volunteers","title":"Load Top Volunteers","text":"

Request:

const { data } = await api.get<VolunteerStats[]>('/canvass/admin/top-volunteers', {\n  params: { limit: 10 },\n});\n

Query Parameters: - limit (number, optional): Maximum number of volunteers to return (default: 10)

Response (200 OK):

[\n  {\n    \"id\": \"user_abc123\",\n    \"name\": \"Jane Doe\",\n    \"email\": \"jane.doe@example.com\",\n    \"visitCount\": 347,\n    \"sessionCount\": 15,\n    \"avgVisitsPerSession\": 23.1\n  },\n  {\n    \"id\": \"user_def456\",\n    \"name\": \"John Smith\",\n    \"email\": \"john.smith@example.com\",\n    \"visitCount\": 289,\n    \"sessionCount\": 12,\n    \"avgVisitsPerSession\": 24.1\n  },\n  {\n    \"id\": \"user_ghi789\",\n    \"name\": \"Bob Johnson\",\n    \"email\": \"bob.johnson@example.com\",\n    \"visitCount\": 201,\n    \"sessionCount\": 8,\n    \"avgVisitsPerSession\": 25.1\n  }\n]\n

Response Fields: - id (string): User identifier - name (string): Volunteer full name - email (string): Volunteer email - visitCount (number): Total visits recorded by volunteer (all-time) - sessionCount (number): Total sessions completed by volunteer - avgVisitsPerSession (number): Visits / sessions (decimal)

Sorting: - Ordered by visitCount DESC (highest visit count first) - Limited to top N volunteers (default 10)

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#load-active-volunteers","title":"Load Active Volunteers","text":"

Request:

const { data } = await api.get<ActiveVolunteer[]>('/canvass/admin/active-volunteers');\n

Response (200 OK):

[\n  {\n    \"id\": \"user_abc123\",\n    \"name\": \"Jane Doe\",\n    \"sessionId\": \"session_ghi789\",\n    \"cutId\": \"cut_jkl012\",\n    \"cutName\": \"Downtown Core\",\n    \"latitude\": 45.42153,\n    \"longitude\": -75.69602,\n    \"lastUpdate\": \"2026-02-11T14:25:30.000Z\",\n    \"visitCount\": 12\n  },\n  {\n    \"id\": \"user_def456\",\n    \"name\": \"John Smith\",\n    \"sessionId\": \"session_mno345\",\n    \"cutId\": \"cut_pqr678\",\n    \"cutName\": \"Riverside District\",\n    \"latitude\": 45.43264,\n    \"longitude\": -75.70813,\n    \"lastUpdate\": \"2026-02-11T14:24:15.000Z\",\n    \"visitCount\": 8\n  }\n]\n

Response Fields: - id (string): User identifier - name (string): Volunteer full name - sessionId (string): Active session identifier - cutId (string): Assigned cut identifier - cutName (string): Assigned cut name - latitude (number): Current GPS latitude - longitude (number): Current GPS longitude - lastUpdate (ISO 8601): Last GPS position update timestamp - visitCount (number): Visits recorded in current session

Filtering: - Only includes volunteers with status = ACTIVE sessions - GPS position from most recent TrackPoint record - Excludes volunteers with null GPS coordinates

Backend Query:

const activeSessions = await prisma.canvassSession.findMany({\n  where: { status: 'ACTIVE' },\n  include: {\n    user: true,\n    cut: true,\n    visits: true,\n    trackPoints: {\n      orderBy: { timestamp: 'desc' },\n      take: 1,  // Most recent track point\n    },\n  },\n});\n\nconst activeVolunteers = activeSessions\n  .filter((session) => session.trackPoints.length > 0)\n  .map((session) => ({\n    id: session.userId,\n    name: session.user.name,\n    sessionId: session.id,\n    cutId: session.cutId,\n    cutName: session.cut?.name || 'Unknown',\n    latitude: session.trackPoints[0].latitude,\n    longitude: session.trackPoints[0].longitude,\n    lastUpdate: session.trackPoints[0].timestamp,\n    visitCount: session.visits.length,\n  }));\n
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#complete-data-loading-flow","title":"Complete Data Loading Flow","text":"
const loadData = useCallback(async () => {\n  try {\n    // Fetch all dashboard data in parallel (6 API calls)\n    const [statsRes, activityRes, progressRes, volunteersRes, cutsRes, activePosRes] = await Promise.all([\n      api.get<CanvassStats>('/canvass/admin/stats'),\n      api.get<CanvassVisit[]>('/canvass/admin/recent-activity', { params: { limit: 20 } }),\n      api.get<CutProgress[]>('/canvass/admin/cut-progress'),\n      api.get<VolunteerStats[]>('/canvass/admin/top-volunteers', { params: { limit: 10 } }),\n      api.get<Cut[]>('/cuts'),\n      api.get<ActiveVolunteer[]>('/canvass/admin/active-volunteers'),\n    ]);\n\n    // Update all state simultaneously\n    setStats(statsRes.data);\n    setRecentActivity(activityRes.data);\n    setCutProgress(progressRes.data);\n    setTopVolunteers(volunteersRes.data);\n    setCuts(cutsRes.data);\n    setActiveVolunteers(activePosRes.data);\n  } catch (error) {\n    if (axios.isAxiosError(error) && error.response?.status === 401) {\n      message.error('Authentication expired. Please log in again.');\n    } else {\n      message.error('Failed to load dashboard data');\n    }\n  } finally {\n    setLoading(false);\n  }\n}, []);\n

Parallel Fetching Benefits: - Faster load time: 6 requests execute simultaneously, not sequentially - Without parallel: 6 \u00d7 200ms average = 1,200ms total load time - With parallel: max(200ms) = 200ms total load time (6\u00d7 faster) - Atomic updates: All state updates happen together (no partial UI updates)

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#auto-refresh-setup","title":"Auto-Refresh Setup","text":"
useEffect(() => {\n  // Initial load on mount\n  loadData();\n\n  // Set up 30-second auto-refresh interval\n  const interval = setInterval(loadData, 30000);\n\n  // Cleanup interval on unmount (prevents memory leak)\n  return () => {\n    clearInterval(interval);\n    console.log('Dashboard auto-refresh stopped');\n  };\n}, [loadData]);\n

Cleanup Importance:

If interval is not cleared on unmount: - Memory leak (interval continues running in background) - API calls continue even after user navigates away - Multiple overlapping intervals if user returns to page

Testing Auto-Refresh:

// Mock API responses changing over time\nconst mockStats = {\n  totalVisits: 1247 + Math.floor(Math.random() * 10),  // Increases by 0-10 each refresh\n  activeVolunteers: 8,\n  activeSessions: 5,\n  avgVisitsPerSession: 23.7,\n};\n
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#manual-refresh-handler","title":"Manual Refresh Handler","text":"
const handleRefresh = async () => {\n  message.loading('Refreshing dashboard data...', 0);  // Indefinite loading message\n  try {\n    await loadData();\n    message.destroy();  // Clear loading message\n    message.success('Dashboard refreshed');\n  } catch (error) {\n    message.destroy();\n    message.error('Failed to refresh dashboard');\n  }\n};\n\n// Refresh button in header\n<Button\n  type=\"text\"\n  icon={<ReloadOutlined />}\n  onClick={handleRefresh}\n  style={{ marginLeft: 8 }}\n>\n  Refresh\n</Button>\n

Manual Refresh Use Cases: - User expects immediate update after completing action (e.g., ending shift) - 30-second auto-refresh feels too slow for urgent update - User wants to verify data accuracy

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#relative-time-formatting","title":"Relative Time Formatting","text":"
function formatRelativeTime(timestamp: string): string {\n  const now = dayjs();\n  const visitTime = dayjs(timestamp);\n\n  const diffMinutes = now.diff(visitTime, 'minute');\n  if (diffMinutes < 1) return 'Just now';\n  if (diffMinutes === 1) return '1 min ago';\n  if (diffMinutes < 60) return `${diffMinutes} mins ago`;\n\n  const diffHours = now.diff(visitTime, 'hour');\n  if (diffHours === 1) return '1 hour ago';\n  if (diffHours < 24) return `${diffHours} hours ago`;\n\n  const diffDays = now.diff(visitTime, 'day');\n  if (diffDays === 1) return '1 day ago';\n  if (diffDays < 7) return `${diffDays} days ago`;\n\n  // For older visits, show absolute date\n  return visitTime.format('MMM D, h:mm A');\n}\n

Examples: - \"Just now\" (< 1 minute) - \"1 min ago\" (exactly 1 minute) - \"5 mins ago\" (5 minutes) - \"1 hour ago\" (exactly 1 hour) - \"3 hours ago\" (3 hours) - \"1 day ago\" (exactly 1 day) - \"5 days ago\" (5 days) - \"Jan 25, 2:30 PM\" (> 7 days)

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#outcome-color-mapping","title":"Outcome Color Mapping","text":"
function getOutcomeColor(outcome: string): string {\n  const colorMap: Record<string, string> = {\n    ANSWERED: 'green',       // Success: resident answered door\n    NOT_HOME: 'red',         // Fail: no answer\n    MOVED: 'orange',         // Warning: resident moved away\n    REFUSED: 'volcano',      // Fail: resident refused to engage\n    INACCESSIBLE: 'default', // Neutral: location inaccessible\n    OTHER: 'blue',           // Info: other outcome\n  };\n  return colorMap[outcome] || 'default';\n}\n\nfunction formatOutcome(outcome: string): string {\n  const labelMap: Record<string, string> = {\n    ANSWERED: 'Answered',\n    NOT_HOME: 'Not Home',\n    MOVED: 'Moved',\n    REFUSED: 'Refused',\n    INACCESSIBLE: 'Inaccessible',\n    OTHER: 'Other',\n  };\n  return labelMap[outcome] || outcome;\n}\n

Semantic Colors: - Green (Answered): Positive outcome, resident engaged - Red (Not Home): Negative outcome, wasted visit - Orange (Moved): Warning, location needs update - Volcano/Red (Refused): Negative, hostile interaction - Gray (Inaccessible): Neutral, infrastructure issue - Blue (Other): Informational, miscellaneous

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#parallel-api-requests","title":"Parallel API Requests","text":"

Dashboard loads 6 API endpoints simultaneously:

const [statsRes, activityRes, progressRes, volunteersRes, cutsRes, activePosRes] = await Promise.all([\n  api.get('/canvass/admin/stats'),\n  api.get('/canvass/admin/recent-activity'),\n  api.get('/canvass/admin/cut-progress'),\n  api.get('/canvass/admin/top-volunteers'),\n  api.get('/cuts'),\n  api.get('/canvass/admin/active-volunteers'),\n]);\n

Performance Comparison:

Sequential Fetching (bad):

const statsRes = await api.get('/stats');           // 200ms\nconst activityRes = await api.get('/activity');     // 200ms\nconst progressRes = await api.get('/progress');     // 200ms\nconst volunteersRes = await api.get('/volunteers'); // 200ms\nconst cutsRes = await api.get('/cuts');             // 200ms\nconst activePosRes = await api.get('/positions');   // 200ms\n// Total: 1,200ms\n

Parallel Fetching (good):

const allResults = await Promise.all([...]);  // max(200ms) = 200ms\n// Total: 200ms (6\u00d7 faster)\n

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#silent-auto-refresh","title":"Silent Auto-Refresh","text":"

Auto-refresh doesn't show loading spinner:

const loadData = useCallback(async () => {\n  // No setLoading(true) here for silent refresh\n  try {\n    const results = await Promise.all([...]);\n    // Update state without UI flicker\n  } catch (error) {\n    // Error handling without disrupting UX\n  }\n  // No finally setLoading(false)\n}, []);\n

Benefits: - No UI flicker: Dashboard doesn't flash every 30 seconds - Better UX: User can continue reading data during refresh - Smooth updates: Data changes appear naturally without distraction

Trade-off:

User doesn't see loading indicator, so may not know if data is stale. Mitigation: - Show \"Last updated: 15 seconds ago\" timestamp - Add refresh icon that spins during update - Use Ant Design Skeleton for shimmer effect

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#limited-data-sets","title":"Limited Data Sets","text":"

API endpoints return limited data:

  • Recent Activity: 20 most recent visits (not all 1,247 visits)
  • Top Volunteers: 10 highest-ranked volunteers (not all 50 volunteers)
  • Active Volunteers: Only currently active (not all users)
  • Cut Progress: All cuts (typically < 50)

Benefits: - Reduced payload: Smaller API responses = faster load times - Faster rendering: React renders 20 list items faster than 1,247 - Manageable UI: User can process 20 recent visits; 1,247 would be overwhelming

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#usecallback-memoization","title":"useCallback Memoization","text":"

Fetch function is memoized to prevent re-creation:

const loadData = useCallback(async () => {\n  // ... fetch logic\n}, []);  // Empty dependency array = function never re-created\n

Without useCallback:

const loadData = async () => { /* ... */ };\n\nuseEffect(() => {\n  loadData();\n  const interval = setInterval(loadData, 30000);\n  return () => clearInterval(interval);\n}, [loadData]);  // loadData changes every render \u2192 infinite loop\n

With useCallback:

const loadData = useCallback(async () => { /* ... */ }, []);\n\nuseEffect(() => {\n  loadData();\n  const interval = setInterval(loadData, 30000);\n  return () => clearInterval(interval);\n}, [loadData]);  // loadData stable \u2192 effect runs once\n

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#mobile-layout","title":"Mobile Layout","text":"

Dashboard adapts to mobile viewports:

Statistics Cards:

<Row gutter={[16, 16]}>\n  <Col xs={24} sm={12} md={6}>  {/* Full width mobile, half tablet, quarter desktop */}\n    <Card><Statistic title=\"Total Visits\" value={1247} /></Card>\n  </Col>\n  {/* Repeat for other cards */}\n</Row>\n

Two-Column Layout:

<Row gutter={[16, 16]}>\n  <Col xs={24} lg={12}>  {/* Full width mobile, half desktop */}\n    <Card title=\"Recent Activity\">{/* ... */}</Card>\n  </Col>\n  <Col xs={24} lg={12}>  {/* Full width mobile, half desktop */}\n    <Space direction=\"vertical\" style={{ width: '100%' }}>\n      <Card title=\"Cut Progress\">{/* ... */}</Card>\n      <Card title=\"Top Volunteers\">{/* ... */}</Card>\n    </Space>\n  </Col>\n</Row>\n

Responsive Breakpoints: - xs (mobile, <576px): Stacked layout (all cards full-width) - sm (tablet, \u2265576px): 2-column statistics cards - md (small desktop, \u2265768px): 4-column statistics cards - lg (large desktop, \u2265992px): 2-column main layout (activity left, progress/leaderboard right)

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#map-height","title":"Map Height","text":"

Map adapts to viewport height:

<Card title=\"Live Volunteer Map\">\n  <div style={{ height: 500, minHeight: 300 }}>\n    <AdminMapView {...props} />\n  </div>\n</Card>\n

Responsive Heights: - Desktop: 500px fixed height - Tablet: 400px (less vertical space) - Mobile: 300px minimum height (prevent squishing)

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

All interactive elements are keyboard-accessible:

Refresh Button: - Tab: Focus on refresh button - Enter/Space: Trigger refresh

Activity Feed: - Tab: Focus on list items - Enter: Activate clickable items (volunteer name, location) - Arrow Keys: Scroll list (native browser behavior)

Map: - Tab: Focus on map container - Arrow Keys: Pan map - +/\u2212: Zoom in/out - Enter: Activate focused marker

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#screen-reader-support","title":"Screen Reader Support","text":"

All elements have proper ARIA labels:

Statistics Cards:

<Statistic\n  title=\"Total Visits\"\n  value={stats.totalVisits}\n  aria-label={`Total visits: ${stats.totalVisits}`}\n/>\n

Activity Feed:

<List\n  aria-label=\"Recent canvassing activity\"\n  dataSource={recentActivity}\n  renderItem={(activity) => (\n    <List.Item aria-label={`${activity.volunteerName} recorded ${activity.outcome} at ${activity.address}`}>\n      {/* ... */}\n    </List.Item>\n  )}\n/>\n

Progress Bars:

<Progress\n  percent={cut.percentage}\n  aria-label={`${cut.name} progress: ${cut.percentage}% complete`}\n/>\n

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#color-contrast","title":"Color Contrast","text":"

All color-coded elements meet WCAG AA standards:

Outcome Tags: - Green (ANSWERED): #52c41a on white = 3.0:1 contrast (AA for large text) - Red (NOT_HOME): #f5222d on white = 4.5:1 contrast (AA) - Orange (MOVED): #fa8c16 on white = 3.3:1 contrast (AA for large text)

Statistics Values: - Green: #3f8600 on white = 4.8:1 contrast (AA) - Blue: #1890ff on white = 4.5:1 contrast (AA) - Orange: #faad14 on white = 3.2:1 contrast (AA for large text)

"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#dashboard-not-auto-refreshing","title":"Dashboard Not Auto-Refreshing","text":"

Problem: Dashboard loads initially, but data doesn't update after 30 seconds.

Diagnosis:

Check if interval is set up correctly:

useEffect(() => {\n  loadData();\n  const interval = setInterval(loadData, 30000);\n  return () => clearInterval(interval);\n}, [loadData]);\n

Open browser console and check for errors:

// Expected: No errors every 30 seconds\n// If errors appear every 30 seconds, auto-refresh is running but failing\n

Possible Causes:

  1. Interval not set up:
  2. Missing setInterval call
  3. Interval not returned from useEffect

  4. Interval cleared prematurely:

  5. Component unmounted and remounted (React Strict Mode in development)
  6. Cleanup function called too early

  7. API errors silently failing:

  8. Backend API down, but error not shown to user
  9. JWT token expired, 401 errors swallowed by try/catch

Solution:

  1. Verify interval exists:
  2. Add console.log in loadData: console.log('Dashboard refresh:', new Date())
  3. Check console every 30 seconds for log message

  4. Handle React Strict Mode:

  5. Accept that development mode unmounts/remounts components
  6. Ensure production build works correctly (no double mounting)

  7. Show API errors:

  8. Remove generic try/catch error handling
  9. Let errors bubble up to user as message.error()
  10. Add retry logic for transient failures
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#active-volunteers-0-despite-active-sessions","title":"\"Active Volunteers: 0\" Despite Active Sessions","text":"

Problem: Statistics show \"Active Volunteers: 0\" but shifts are scheduled and volunteers are canvassing.

Diagnosis:

Check active sessions in database:

SELECT COUNT(*) FROM \"CanvassSession\" WHERE status = 'ACTIVE';\n-- Expected: > 0 if volunteers are active\n-- Actual: 0 (sessions not marked as ACTIVE)\n

Possible Causes:

  1. Sessions not started:
  2. Volunteers signed up for shifts but didn't start canvassing
  3. No sessions with status = ACTIVE

  4. Sessions abandoned:

  5. Volunteers forgot to end sessions, sessions auto-closed by backend
  6. Sessions marked as ABANDONED instead of ACTIVE

  7. Sessions completed:

  8. Volunteers ended sessions, now showing as COMPLETED
  9. Active count only includes ACTIVE status

Solution:

  1. Contact volunteers:
  2. Ask them to start canvassing session from volunteer portal
  3. Navigate to /volunteer/assignments, click \"Start Canvassing\"

  4. Check abandoned sessions:

  5. Navigate to Canvass Dashboard
  6. Look for sessions with \"ABANDONED\" status
  7. Manually reopen if volunteer is still active

  8. Adjust status query:

  9. If volunteers frequently forget to end sessions, consider showing ACTIVE + recently updated sessions (< 1 hour ago)
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#map-not-showing-volunteer-markers","title":"Map Not Showing Volunteer Markers","text":"

Problem: Live Volunteer Map loads but shows no blue markers, even though \"Active Volunteers: 8\".

Diagnosis:

Check active volunteers API response:

const { data } = await api.get('/canvass/admin/active-volunteers');\nconsole.log('Active volunteers:', data);\n// Expected: Array with 8 volunteers\n// Actual: Empty array or volunteers without GPS coordinates\n

Possible Causes:

  1. No GPS tracking enabled:
  2. Volunteers have active sessions but GPS tracking not enabled
  3. No TrackPoint records exist for sessions

  4. Null GPS coordinates:

  5. TrackPoint records exist but latitude/longitude are null
  6. Backend filters out volunteers without valid coordinates

  7. Map zoom level:

  8. Volunteers outside current map viewport
  9. Auto-center not working correctly

Solution:

  1. Enable GPS tracking:
  2. Ensure volunteers grant location permissions in browser
  3. Check volunteer portal GPS tracker is running
  4. Navigate to /volunteer/canvass/:cutId, verify \"GPS Active\" indicator

  5. Check GPS permissions:

  6. Ask volunteers to enable location services in browser settings
  7. Chrome: Settings \u2192 Privacy \u2192 Site Settings \u2192 Location \u2192 Allow
  8. Safari: Preferences \u2192 Websites \u2192 Location \u2192 Allow

  9. Zoom out on map:

  10. Click zoom out (\u2212) button several times
  11. See if markers appear outside initial viewport
  12. If yes, auto-center logic is broken (should zoom to fit all markers)
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#progress-percentages-over-100","title":"Progress Percentages Over 100%","text":"

Problem: Cut Progress section shows \"Downtown Core: 157 / 150 locations (105%)\".

Diagnosis:

Check location count vs. visit count:

-- Count locations in cut\nSELECT COUNT(*) FROM \"Location\" WHERE \"cutId\" = 'cut_abc123';\n-- Result: 150\n\n-- Count unique locations with visits\nSELECT COUNT(DISTINCT \"locationId\") FROM \"CanvassVisit\"\nWHERE \"locationId\" IN (\n  SELECT id FROM \"Location\" WHERE \"cutId\" = 'cut_abc123'\n);\n-- Result: 157 (more than location count!)\n

Possible Causes:

  1. Locations moved out of cut:
  2. Locations visited while in cut, then unassigned from cut
  3. Visit records still reference old cutId, inflating count

  4. Duplicate visits counted:

  5. Multiple visits to same location counted separately
  6. Should count unique locations, not total visits

  7. Backend calculation bug:

  8. Visit count not filtered by current cut membership
  9. Includes visits to locations now in different cuts

Solution:

  1. Fix backend query:
  2. Only count visits to locations currently in cut:

    const visitCount = await prisma.canvassVisit.count({\n  where: {\n    location: {\n      cutId: cut.id,\n      deletedAt: null,\n    },\n  },\n  distinct: ['locationId'],  // Count unique locations only\n});\n

  3. Cap percentage at 100%:

  4. Frontend safety check:

    const percentage = Math.min(100, Math.round((visitCount / locationCount) * 100));\n

  5. Investigate data integrity:

  6. Find orphaned visits:
    SELECT * FROM \"CanvassVisit\"\nWHERE \"locationId\" NOT IN (SELECT id FROM \"Location\");\n
  7. Delete orphaned visits or reassociate with correct locations
"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#related-documentation","title":"Related Documentation","text":"
  • Canvassing Overview \u2014 Volunteer canvassing feature set
  • Canvass Backend Module \u2014 Backend canvass service and API
  • Canvass API Reference \u2014 Admin canvass endpoints
  • Volunteer Map Page \u2014 Volunteer canvassing interface
  • Volunteer Activity Page \u2014 Volunteer visit history
  • CutsPage \u2014 Cut management
  • ShiftsPage \u2014 Shift scheduling
  • LocationsPage \u2014 Location management
  • AdminMapView Component \u2014 Map component with overlays
  • User Guide: Map Organizer \u2014 Canvass management workflow
  • Troubleshooting: GPS Issues \u2014 GPS tracking troubleshooting
"},{"location":"v2/frontend/pages/admin/code-editor-page/","title":"CodeEditorPage","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#overview","title":"Overview","text":"

File: admin/src/pages/CodeEditorPage.tsx

Route: /app/services/code-editor

Role Requirements: Any authenticated user (uses authenticate middleware)

Purpose: Provides an embedded interface to the Code Server (VS Code in browser) via iframe. Code Server is a web-based IDE that runs Visual Studio Code in the browser, allowing developers to edit code, manage files, and run terminal commands directly from the admin interface. This page serves as a wrapper that embeds Code Server with online/offline status monitoring and mobile device detection.

Key Features: - Full-page iframe embed of Code Server service - Service online/offline status monitoring with Badge - Mobile device detection with warning screen - \"Refresh\" button to re-check service status - \"Open in New Tab\" button for external access - Fullbleed layout (no padding in AppLayout) - Automatic service health checks via API

Layout: AppLayout with fullbleed (no content padding)

Dependencies: - Ant Design v5 (Button, Space, Badge, Spin, Grid, Result) - react-router-dom (useOutletContext)

"},{"location":"v2/frontend/pages/admin/code-editor-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#1-service-status-monitoring","title":"1. Service Status Monitoring","text":"

Status Display: - Green \"Online\" badge when Code Server is accessible - Red \"Offline\" badge when Code Server is not accessible - Blue \"Checking...\" badge during status check - Badge displayed in page header

"},{"location":"v2/frontend/pages/admin/code-editor-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"

Mobile Warning Screen: - Detects mobile devices using Grid.useBreakpoint() - Shows warning Result component on mobile - Recommends using desktop for code editing - Icon: CodeOutlined (48px)

Breakpoint: !screens.md (screen width < 768px = mobile)

"},{"location":"v2/frontend/pages/admin/code-editor-page/#3-code-server-url-construction","title":"3. Code Server URL Construction","text":"

URL Building: - Fetches docs config from API (/api/docs/config) - Builds URL using codeServerPort configuration - Uses hostname + port pattern - Example: http://localhost:8888 or http://code.cmlite.org

"},{"location":"v2/frontend/pages/admin/code-editor-page/#4-iframe-embedding","title":"4. Iframe Embedding","text":"

Fullbleed Layout: - No padding around iframe - Height: calc(100vh - 64px) (full viewport height minus header) - Width: 100% - No border for seamless VS Code integration

"},{"location":"v2/frontend/pages/admin/code-editor-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#accessing-code-server","title":"Accessing Code Server","text":"
  1. Navigate to Code Editor:
  2. Click \"Services\" \u2192 \"Code Editor\" in sidebar
  3. Page loads with status check

  4. Check Service Status:

  5. Status badge appears in page header:

    • \u2705 \"Online\" (green) - Service available
    • \u274c \"Offline\" (red) - Service unavailable
    • \ud83d\udd35 \"Checking...\" (blue) - Status check in progress
  6. View on Desktop:

  7. If on desktop (screen width \u2265 768px):

    • Iframe loads automatically
    • Full VS Code interface embedded
    • Can edit files, run terminal commands, use extensions
  8. View on Mobile:

  9. If on mobile (screen width < 768px):

    • Warning message appears
    • Message: \"The code editor requires a desktop browser\"
    • \"Open in New Tab\" button provided
  10. Using Code Server:

  11. File Explorer: Browse project files in sidebar
  12. Editor: Edit code with syntax highlighting, IntelliSense
  13. Terminal: Run bash commands (npm, git, docker)
  14. Extensions: Install VS Code extensions
  15. Search: Global file search (Ctrl+P)
  16. Git: Source control integration

  17. Common Tasks:

  18. Edit API routes: /api/src/modules/
  19. Edit admin pages: /admin/src/pages/
  20. Run migrations: Terminal \u2192 cd api && npx prisma migrate dev
  21. Start dev servers: Terminal \u2192 npm run dev
  22. View logs: Terminal \u2192 docker compose logs -f api
"},{"location":"v2/frontend/pages/admin/code-editor-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#main-component-structure","title":"Main Component Structure","text":"
export default function CodeEditorPage() {\n  const { setPageHeader } = useOutletContext<AppOutletContext>();\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n\n  const [online, setOnline] = useState<boolean | null>(null);\n  const [codeServerPort, setCodeServerPort] = useState<number | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  // Fetch service status and config\n  const fetchStatus = useCallback(async () => {\n    try {\n      const [statusRes, configRes] = await Promise.all([\n        api.get<DocsStatus>('/docs/status'),\n        api.get<DocsConfig>('/docs/config'),\n      ]);\n      setOnline(statusRes.data.codeServer.online);\n      setCodeServerPort(configRes.data.codeServerPort);\n    } catch {\n      setOnline(false);\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    fetchStatus();\n  }, [fetchStatus]);\n\n  // Build service URL\n  const codeServerUrl = codeServerPort\n    ? `//${window.location.hostname}:${codeServerPort}`\n    : null;\n\n  // Page header with status badge and actions\n  const headerActions = useMemo(() => (\n    <Space>\n      <Badge\n        status={online === null ? 'processing' : online ? 'success' : 'error'}\n        text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}\n      />\n      <Button icon={<ReloadOutlined />} onClick={fetchStatus} size=\"small\">\n        Refresh\n      </Button>\n      {codeServerUrl && (\n        <Button icon={<LinkOutlined />} href={codeServerUrl} target=\"_blank\" size=\"small\">\n          Open in New Tab\n        </Button>\n      )}\n    </Space>\n  ), [online, fetchStatus, codeServerUrl]);\n\n  useEffect(() => {\n    setPageHeader({ title: 'Code Editor', actions: headerActions, fullBleed: true });\n    return () => setPageHeader(null);\n  }, [setPageHeader, headerActions]);\n\n  // Mobile warning\n  if (isMobile) {\n    return (\n      <Result\n        status=\"info\"\n        title=\"Desktop Required\"\n        subTitle=\"The code editor requires a desktop browser with a larger screen.\"\n        icon={<CodeOutlined style={{ fontSize: 48 }} />}\n      />\n    );\n  }\n\n  // Loading state\n  if (loading) {\n    return (\n      <div style={{ textAlign: 'center', padding: 80 }}>\n        <Spin size=\"large\" />\n      </div>\n    );\n  }\n\n  // Offline state\n  if (!online || !codeServerUrl) {\n    return (\n      <Result\n        status=\"error\"\n        title=\"Code Server Unavailable\"\n        subTitle=\"Code Server is not running or could not be reached. Check that the code-server container is started.\"\n        extra={\n          <Button type=\"primary\" onClick={fetchStatus}>\n            Retry\n          </Button>\n        }\n      />\n    );\n  }\n\n  // Iframe embed\n  return (\n    <iframe\n      src={codeServerUrl}\n      style={{\n        width: '100%',\n        height: 'calc(100vh - 64px)',\n        border: 'none',\n        display: 'block',\n      }}\n      title=\"Code Server\"\n    />\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/code-editor-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#local-component-state","title":"Local Component State","text":"
// Service online/offline state\nconst [online, setOnline] = useState<boolean | null>(null);\n\n// Code Server port configuration\nconst [codeServerPort, setCodeServerPort] = useState<number | null>(null);\n\n// Loading state\nconst [loading, setLoading] = useState(true);\n\n// Responsive breakpoint detection\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n
"},{"location":"v2/frontend/pages/admin/code-editor-page/#state-flow","title":"State Flow","text":"
  1. Component Mounts:
  2. fetchStatus() called
  3. Parallel API calls:
    • GET /api/docs/status - Check Code Server online status
    • GET /api/docs/config - Fetch port configuration
  4. Sets online and codeServerPort
  5. Constructs URL: //${hostname}:${port}

  6. URL Construction:

  7. Uses current hostname (from window.location.hostname)
  8. Appends Code Server port (default: 8888)
  9. Example: //localhost:8888 or //app.cmlite.org:8888
"},{"location":"v2/frontend/pages/admin/code-editor-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/docs/status - Check MkDocs and Code Server health
  2. GET /api/docs/config - Fetch Code Server port configuration
"},{"location":"v2/frontend/pages/admin/code-editor-page/#example-api-calls","title":"Example API Calls","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#1-fetch-service-status","title":"1. Fetch Service Status","text":"
const statusRes = await api.get<DocsStatus>('/docs/status');\nsetOnline(statusRes.data.codeServer.online);\n

Response Format:

{\n  \"mkdocs\": { \"online\": true },\n  \"codeServer\": { \"online\": true }\n}\n

"},{"location":"v2/frontend/pages/admin/code-editor-page/#2-fetch-config","title":"2. Fetch Config","text":"
const configRes = await api.get<DocsConfig>('/docs/config');\nsetCodeServerPort(configRes.data.codeServerPort);\n

Response Format:

{\n  \"mkdocsPort\": 4003,\n  \"codeServerPort\": 8888\n}\n

"},{"location":"v2/frontend/pages/admin/code-editor-page/#3-build-url","title":"3. Build URL","text":"
const codeServerUrl = codeServerPort\n  ? `//${window.location.hostname}:${codeServerPort}`\n  : null;\n\n// Example results:\n// localhost \u2192 \"//localhost:8888\"\n// app.cmlite.org \u2192 \"//app.cmlite.org:8888\"\n
"},{"location":"v2/frontend/pages/admin/code-editor-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#1-parallel-api-requests","title":"1. Parallel API Requests","text":"
const [statusRes, configRes] = await Promise.all([\n  api.get<DocsStatus>('/docs/status'),\n  api.get<DocsConfig>('/docs/config'),\n]);\n

Benefit: Reduces total loading time by ~50%.

"},{"location":"v2/frontend/pages/admin/code-editor-page/#2-early-mobile-detection","title":"2. Early Mobile Detection","text":"
if (isMobile) {\n  return <Result />;  // No API calls, no iframe\n}\n

Benefit: Saves bandwidth and API requests on mobile devices.

"},{"location":"v2/frontend/pages/admin/code-editor-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#mobile-warning","title":"Mobile Warning","text":"
if (isMobile) {\n  return (\n    <Result\n      status=\"info\"\n      title=\"Desktop Required\"\n      subTitle=\"The code editor requires a desktop browser with a larger screen.\"\n      icon={<CodeOutlined style={{ fontSize: 48 }} />}\n    />\n  );\n}\n

Why Mobile Warning? - VS Code UI requires large screen (file explorer + editor + terminal) - Keyboard shortcuts essential (Ctrl+P, Ctrl+S, etc.) - Terminal commands difficult on mobile keyboards - Better UX to SSH into server directly from mobile

"},{"location":"v2/frontend/pages/admin/code-editor-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#problem-service-shows-offline","title":"Problem: Service Shows \"Offline\"","text":"

Solutions:

  1. Check Docker container:

    docker compose ps code-server\n

  2. Check logs:

    docker compose logs code-server\n

  3. Test direct access:

  4. Open http://localhost:8888 in browser

  5. Restart service:

    docker compose restart code-server\n

"},{"location":"v2/frontend/pages/admin/code-editor-page/#problem-iframe-not-loading","title":"Problem: Iframe Not Loading","text":"

Solutions:

  1. Check password:
  2. Code Server requires password authentication
  3. Check CODE_SERVER_PASSWORD env var in .env

  4. Check CSP headers:

  5. Open DevTools Console
  6. Look for Content Security Policy errors

  7. Try \"Open in New Tab\":

  8. Click button to test service directly
"},{"location":"v2/frontend/pages/admin/code-editor-page/#related-documentation","title":"Related Documentation","text":"
  • Code Server Setup - Docker container configuration
  • Development Workflow - Using Code Server for development
  • Docs API - Status and config endpoints
"},{"location":"v2/frontend/pages/admin/cut-export-page/","title":"CutExportPage","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#overview","title":"Overview","text":"

File: admin/src/pages/CutExportPage.tsx

Route: /app/map/cuts/:id/export

Role Requirements: Any authenticated admin user (uses authenticate middleware + admin role check)

Purpose: Generates a printable location report for a specific cut (geographic boundary). The report includes cut statistics, support level breakdown, and a detailed table of all addresses within the cut. Campaign organizers use this report for planning canvassing efforts, analyzing voter support distribution, and exporting contact lists for targeted outreach.

Key Features: - Printable cut location report optimized for landscape printing - Cut metadata (name, category, assigned person) - Statistics cards (total addresses, support levels, signs, contact info) - Paginated address table with support levels, contact info, and notes - Color-coded support level tags - Print-optimized styling with CSS @media print rules - Landscape orientation for wide table layout

Layout: Full AppLayout with \"Back to Cuts\" and \"Print\" buttons in header

Dependencies: - Ant Design v5 (Button, Typography, Spin, Space, Table, Tag, Row, Col, Card, Statistic, message) - react-router-dom (useParams, useNavigate, useOutletContext) - dayjs for date formatting

"},{"location":"v2/frontend/pages/admin/cut-export-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#1-cut-metadata-header","title":"1. Cut Metadata Header","text":"

Displayed Information: - Cut Name: Title of the cut (e.g., \"Downtown District - Block 5\") - Cut Category: Visual tag (e.g., \"Priority\", \"Target\", \"Base\") - Assigned To: Person responsible for canvassing this cut - Generation Timestamp: Date and time report was generated

Purpose: Provides context for the location report

"},{"location":"v2/frontend/pages/admin/cut-export-page/#2-statistics-grid","title":"2. Statistics Grid","text":"

9 Statistics Cards:

  1. Total: Total number of addresses in cut
  2. Strong: Count of LEVEL_1 support (strong supporters)
  3. Likely: Count of LEVEL_2 support (likely supporters)
  4. Unsure: Count of LEVEL_3 support (undecided voters)
  5. Oppose: Count of LEVEL_4 support (opponents)
  6. None: Count of addresses with no support level assigned
  7. Signs: Count of addresses requesting lawn signs
  8. Email: Count of addresses with email addresses
  9. Phone: Count of addresses with phone numbers

Color-Coded Values: - Strong Support: Green (#52c41a) - Likely Support: Cyan (#13c2c2) - Unsure: Orange (#faad14) - Oppose: Red (#ff4d4f) - None/Other: Default gray

Layout: Responsive grid (9 cards, 3 per row on desktop, 2 per row on mobile)

"},{"location":"v2/frontend/pages/admin/cut-export-page/#3-address-table","title":"3. Address Table","text":"

8 Columns:

  1. Name: First name + last name (combined)
  2. Address: Building street address + unit number (if multi-unit)
  3. Support: Support level tag (Strong/Likely/Unsure/Oppose/None)
  4. Phone: Phone number or \"--\"
  5. Email: Email address or \"--\"
  6. Sign: Sign interest (\"Yes\" with size, or \"No\")
  7. Notes: Additional notes (ellipsis if long)

Table Features: - Bordered table for clear gridlines - Small size (compact rows) - No pagination (print all addresses on multiple pages if needed) - Sortable columns (default Ant Design behavior)

"},{"location":"v2/frontend/pages/admin/cut-export-page/#4-footer","title":"4. Footer","text":"

Footer Text: \"Generated by Changemaker Lite \u2014 {timestamp}\"

Purpose: Attribution and timestamp for report archiving

"},{"location":"v2/frontend/pages/admin/cut-export-page/#5-print-optimization","title":"5. Print Optimization","text":"

CSS @media print Rules: - Hides everything except .cut-export-print container - Positions report at absolute top-left with fixed position - Uses landscape orientation (@page { size: letter landscape; }) - Reduces font size to 9-10px for compact printing - Optimizes table padding and borders for clarity - Forces exact color printing with print-color-adjust: exact

Print Trigger: \"Print\" button in page header (calls window.print())

"},{"location":"v2/frontend/pages/admin/cut-export-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#exporting-a-cut-report","title":"Exporting a Cut Report","text":"
  1. Navigate to Cuts:
  2. Click \"Map\" \u2192 \"Cuts\" in sidebar
  3. Cuts table loads

  4. Select Cut:

  5. Find cut to export in table
  6. Click \"Export\" action button (or similar)
  7. Route navigates to /app/map/cuts/:id/export

  8. Review Report Preview:

  9. Page loads with cut metadata header
  10. Statistics cards show support level distribution
  11. Address table lists all locations in cut

  12. Print Report:

  13. Click \"Print\" button in page header
  14. OR press Ctrl+P (Windows/Linux) or Cmd+P (Mac)
  15. Browser print dialog opens

  16. Configure Print Settings:

  17. Orientation: Landscape (automatically set by CSS)
  18. Paper Size: Letter (8.5\" \u00d7 11\")
  19. Margins: Minimal (0.25\")
  20. Background graphics: ON (to print color tags and borders)

  21. Print or Save PDF:

  22. Click \"Print\" to send to printer
  23. OR select \"Save as PDF\" to create digital copy
  24. Report saved/printed for field use
"},{"location":"v2/frontend/pages/admin/cut-export-page/#analyzing-cut-statistics","title":"Analyzing Cut Statistics","text":"
  1. Review Statistics Cards:
  2. Total: Understand cut size (e.g., 150 addresses)
  3. Strong + Likely: Identify supporter base (e.g., 80 strong + 30 likely = 110 supporters)
  4. Unsure: Target for persuasion (e.g., 40 undecided)
  5. Oppose: Avoid during canvassing (e.g., 10 opponents)
  6. None: Not yet contacted (e.g., 10 addresses)

  7. Calculate Support Percentage:

  8. Strong Support % = (Strong / Total) \u00d7 100
  9. Example: (80 / 150) \u00d7 100 = 53.3% strong support

  10. Assess Contact Coverage:

  11. Email: Contact via email campaigns (e.g., 90 emails = 60% coverage)
  12. Phone: Contact via phone banking (e.g., 100 phones = 67% coverage)
  13. Signs: Distribute lawn signs (e.g., 50 sign requests)

  14. Plan Canvassing Strategy:

  15. High support areas: Focus on turnout (ensure supporters vote)
  16. High unsure areas: Focus on persuasion (door-to-door conversations)
  17. High oppose areas: Skip or minimal contact (avoid antagonism)
"},{"location":"v2/frontend/pages/admin/cut-export-page/#using-report-for-canvassing","title":"Using Report for Canvassing","text":"
  1. Print Report Before Canvassing:
  2. Export cut report
  3. Print landscape orientation
  4. Bring printed report to field

  5. Review Addresses During Canvass:

  6. Check support level before knocking
  7. Note contact info (phone/email) for follow-up
  8. See sign requests (bring signs to those addresses)

  9. Update Notes During Canvass:

  10. Handwrite additional notes on printed report (e.g., \"Not home\", \"Call back after 6pm\")
  11. Mark addresses as \"Visited\" with checkmarks

  12. Data Entry After Canvass:

  13. Return to office with updated report
  14. Enter new data into LocationsPage or AddressPage
  15. Update support levels, contact info, notes
"},{"location":"v2/frontend/pages/admin/cut-export-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#main-component-structure","title":"Main Component Structure","text":"
export default function CutExportPage() {\n  const { id } = useParams<{ id: string }>();\n  const navigate = useNavigate();\n  const [cut, setCut] = useState<Cut | null>(null);\n  const [addresses, setAddresses] = useState<AddressWithLocation[]>([]);\n  const [stats, setStats] = useState<CutStatistics | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  // Load cut data on mount\n  useEffect(() => {\n    if (!id) return;\n    (async () => {\n      try {\n        // Parallel fetch: cut metadata, locations, statistics\n        const [cutRes, locsRes, statsRes] = await Promise.all([\n          api.get<Cut>(`/map/cuts/${id}`),\n          api.get<Location[]>(`/map/cuts/${id}/locations`),\n          api.get<CutStatistics>(`/map/cuts/${id}/statistics`),\n        ]);\n\n        setCut(cutRes.data);\n\n        // Flatten locations with their addresses\n        const flatAddresses: AddressWithLocation[] = [];\n        for (const loc of locsRes.data) {\n          if (loc.addresses && loc.addresses.length > 0) {\n            for (const addr of loc.addresses) {\n              flatAddresses.push({\n                ...addr,\n                locationAddress: loc.address, // Building street address\n              });\n            }\n          }\n        }\n        setAddresses(flatAddresses);\n\n        setStats(statsRes.data);\n      } catch {\n        message.error('Failed to load cut data');\n      } finally {\n        setLoading(false);\n      }\n    })();\n  }, [id]);\n\n  // Set page header with actions\n  const headerActions = useMemo(() => (\n    <Space>\n      <Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/app/map/cuts')}>\n        Back to Cuts\n      </Button>\n      <Button type=\"primary\" icon={<PrinterOutlined />} onClick={() => window.print()}>\n        Print\n      </Button>\n    </Space>\n  ), [navigate]);\n\n  useEffect(() => {\n    setPageHeader({ title: cut?.name || 'Cut Export', actions: headerActions });\n    return () => setPageHeader(null);\n  }, [setPageHeader, headerActions, cut?.name]);\n\n  if (loading) {\n    return <Spin size=\"large\" />;\n  }\n\n  if (!cut) {\n    return <Text type=\"danger\">Cut not found</Text>;\n  }\n\n  const now = dayjs().format('YYYY-MM-DD HH:mm');\n\n  return (\n    <>\n      <style>{/* Print CSS rules */}</style>\n\n      <div className=\"cut-export-print\">\n        {/* Report header */}\n        <Row justify=\"space-between\" align=\"middle\">\n          <Col>\n            <Title level={4}>{cut.name}</Title>\n            <Space>\n              <Tag color={CUT_CATEGORY_COLORS[cut.category]}>\n                {CUT_CATEGORY_LABELS[cut.category]}\n              </Tag>\n              {cut.assignedTo && <Text type=\"secondary\">Assigned to: {cut.assignedTo}</Text>}\n            </Space>\n          </Col>\n          <Col>\n            <Text type=\"secondary\">Generated: {now}</Text>\n          </Col>\n        </Row>\n\n        {/* Stats grid */}\n        <Row gutter={[12, 12]}>\n          <Col xs={8} sm={4}><Card size=\"small\"><Statistic title=\"Total\" value={stats.total} /></Card></Col>\n          <Col xs={8} sm={4}><Card size=\"small\"><Statistic title=\"Strong\" value={stats.byLevel.LEVEL_1} valueStyle={{ color: '#52c41a' }} /></Card></Col>\n          {/* ... more stats cards ... */}\n        </Row>\n\n        {/* Address table */}\n        <Table\n          columns={columns}\n          dataSource={addresses}\n          rowKey=\"id\"\n          pagination={false}\n          size=\"small\"\n          bordered\n        />\n\n        {/* Footer */}\n        <div style={{ marginTop: 16, textAlign: 'center' }}>\n          <Text type=\"secondary\">Generated by Changemaker Lite \u2014 {now}</Text>\n        </div>\n      </div>\n    </>\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/cut-export-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  1. Button - \"Back to Cuts\" and \"Print\" buttons
  2. Typography.Title - Cut name heading
  3. Typography.Text - Labels, timestamps, footer
  4. Spin - Loading indicator during data fetch
  5. Space - Button grouping, tag grouping
  6. Table - Address data grid
  7. Tag - Cut category, support levels
  8. Row / Col - Statistics grid layout
  9. Card - Statistics card containers
  10. Statistic - Numerical statistics display
  11. message - Toast notifications for errors
"},{"location":"v2/frontend/pages/admin/cut-export-page/#table-column-definition","title":"Table Column Definition","text":"
const columns: ColumnsType<AddressWithLocation> = [\n  {\n    title: 'Name',\n    key: 'name',\n    render: (_: unknown, record: AddressWithLocation) =>\n      [record.firstName, record.lastName].filter(Boolean).join(' ') || '--',\n  },\n  {\n    title: 'Address',\n    key: 'address',\n    render: (_: unknown, record: AddressWithLocation) =>\n      [record.locationAddress, record.unitNumber && `#${record.unitNumber}`].filter(Boolean).join(' ') || '--',\n  },\n  {\n    title: 'Support',\n    dataIndex: 'supportLevel',\n    key: 'supportLevel',\n    width: 120,\n    render: (level: SupportLevel | null) =>\n      level ? (\n        <Tag color={SUPPORT_LEVEL_COLORS[level]}>{SUPPORT_LEVEL_LABELS[level]}</Tag>\n      ) : (\n        <Tag>None</Tag>\n      ),\n  },\n  {\n    title: 'Phone',\n    dataIndex: 'phone',\n    key: 'phone',\n    width: 120,\n    render: (val: string | null) => val || '--',\n  },\n  {\n    title: 'Email',\n    dataIndex: 'email',\n    key: 'email',\n    width: 180,\n    render: (val: string | null) => val || '--',\n  },\n  {\n    title: 'Sign',\n    key: 'sign',\n    width: 80,\n    render: (_: unknown, record: AddressWithLocation) =>\n      record.sign\n        ? `Yes${record.signSize ? ` (${record.signSize})` : ''}`\n        : 'No',\n  },\n  {\n    title: 'Notes',\n    dataIndex: 'notes',\n    key: 'notes',\n    width: 150,\n    ellipsis: true,\n    render: (val: string | null) => val || '--',\n  },\n];\n
"},{"location":"v2/frontend/pages/admin/cut-export-page/#print-css-styling","title":"Print CSS Styling","text":"
<style>{`\n  @media print {\n    /* Hide everything except report */\n    body * { visibility: hidden !important; }\n    .cut-export-print, .cut-export-print * { visibility: visible !important; }\n\n    /* Position report at top-left */\n    .cut-export-print {\n      position: fixed !important;\n      left: 0 !important;\n      top: 0 !important;\n      width: 100% !important;\n      padding: 0.4in !important;\n      background: white !important;\n      color: black !important;\n      font-size: 10px !important;\n    }\n\n    /* Compact table styling */\n    .cut-export-print .ant-table { font-size: 9px !important; }\n    .cut-export-print .ant-table-thead > tr > th {\n      background: #f0f0f0 !important;\n      print-color-adjust: exact;\n      -webkit-print-color-adjust: exact;\n      padding: 4px 6px !important;\n    }\n    .cut-export-print .ant-table-tbody > tr > td { padding: 3px 6px !important; }\n\n    /* Force exact color printing for tags */\n    .cut-export-print .ant-tag {\n      print-color-adjust: exact;\n      -webkit-print-color-adjust: exact;\n    }\n\n    /* Remove card shadows for print */\n    .cut-export-print .ant-card { box-shadow: none !important; border: 1px solid #ddd !important; }\n\n    /* Landscape orientation */\n    @page { size: letter landscape; margin: 0.25in; }\n  }\n`}</style>\n

Key Print Rules: - visibility: hidden !important on all elements except .cut-export-print - Fixed positioning at top-left (0, 0) with 0.4in padding - 9-10px font sizes for compact printing - print-color-adjust: exact forces exact color printing (tags, statistics) - Landscape orientation via @page { size: letter landscape; } - Minimal margins (0.25in) to maximize table width

"},{"location":"v2/frontend/pages/admin/cut-export-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#local-component-state-usestate","title":"Local Component State (useState)","text":"

No Zustand stores used - All state managed locally with React hooks.

// Cut metadata state\nconst [cut, setCut] = useState<Cut | null>(null);\n\n// Flattened addresses state (Location + Address combined)\nconst [addresses, setAddresses] = useState<AddressWithLocation[]>([]);\n\n// Cut statistics state\nconst [stats, setStats] = useState<CutStatistics | null>(null);\n\n// Loading state\nconst [loading, setLoading] = useState(true);\n
"},{"location":"v2/frontend/pages/admin/cut-export-page/#state-flow","title":"State Flow","text":"
  1. Component Mounts:
  2. Extracts id from URL params (:id in /app/map/cuts/:id/export)
  3. Calls 3 parallel API requests:
    • GET /api/map/cuts/:id (cut metadata)
    • GET /api/map/cuts/:id/locations (locations with addresses)
    • GET /api/map/cuts/:id/statistics (aggregated statistics)
  4. Sets cut, addresses, stats states
  5. Sets loading to false

  6. Address Flattening:

  7. API returns locations with nested addresses array
  8. Component flattens to single AddressWithLocation[] array:
    const flatAddresses: AddressWithLocation[] = [];\nfor (const loc of locations) {\n  if (loc.addresses && loc.addresses.length > 0) {\n    for (const addr of loc.addresses) {\n      flatAddresses.push({\n        ...addr,\n        locationAddress: loc.address, // Add parent location address\n      });\n    }\n  }\n}\n
  9. Result: One row per address (not per location)

  10. Statistics Rendering:

  11. stats.total \u2192 Total card
  12. stats.byLevel.LEVEL_1 \u2192 Strong card
  13. stats.byLevel.LEVEL_2 \u2192 Likely card
  14. stats.byLevel.LEVEL_3 \u2192 Unsure card
  15. stats.byLevel.LEVEL_4 \u2192 Oppose card
  16. stats.byLevel.NONE \u2192 None card
  17. stats.withSign \u2192 Signs card
  18. Count emails/phones from addresses array

  19. User Clicks Print:

  20. window.print() called
  21. Browser opens print dialog
  22. Print CSS rules activate
  23. Report rendered in landscape layout
"},{"location":"v2/frontend/pages/admin/cut-export-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/map/cuts/:id - Fetch cut metadata (name, category, assignedTo)
  2. GET /api/map/cuts/:id/locations - Fetch all locations within cut (with nested addresses)
  3. GET /api/map/cuts/:id/statistics - Fetch aggregated cut statistics (support levels, signs)
"},{"location":"v2/frontend/pages/admin/cut-export-page/#api-client","title":"API Client","text":"
import { api } from '@/lib/api';\n\n// All requests use authenticated API client with automatic token refresh\n
"},{"location":"v2/frontend/pages/admin/cut-export-page/#example-api-calls","title":"Example API Calls","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#1-fetch-cut-metadata","title":"1. Fetch Cut Metadata","text":"
const cutRes = await api.get<Cut>(`/map/cuts/${id}`);\nsetCut(cutRes.data);\n

Response Format:

{\n  \"id\": 5,\n  \"name\": \"Downtown District - Block 5\",\n  \"category\": \"PRIORITY\",\n  \"assignedTo\": \"Jane Smith\",\n  \"geometry\": {...},\n  \"createdAt\": \"2025-01-15T10:00:00Z\",\n  \"updatedAt\": \"2025-02-11T12:00:00Z\"\n}\n

"},{"location":"v2/frontend/pages/admin/cut-export-page/#2-fetch-locations-with-addresses","title":"2. Fetch Locations with Addresses","text":"
const locsRes = await api.get<Location[]>(`/map/cuts/${id}/locations`);\nconst locations = locsRes.data;\n

Response Format:

[\n  {\n    \"id\": 101,\n    \"address\": \"123 Main St\",\n    \"lat\": 45.5017,\n    \"lng\": -73.5673,\n    \"addresses\": [\n      {\n        \"id\": 1001,\n        \"firstName\": \"John\",\n        \"lastName\": \"Doe\",\n        \"unitNumber\": \"101\",\n        \"supportLevel\": \"LEVEL_1\",\n        \"phone\": \"555-1234\",\n        \"email\": \"john@example.com\",\n        \"sign\": true,\n        \"signSize\": \"18x24\",\n        \"notes\": \"Strong supporter, wants yard sign\"\n      },\n      {\n        \"id\": 1002,\n        \"firstName\": \"Jane\",\n        \"lastName\": \"Smith\",\n        \"unitNumber\": \"102\",\n        \"supportLevel\": \"LEVEL_2\",\n        \"phone\": \"555-5678\",\n        \"email\": \"jane@example.com\",\n        \"sign\": false,\n        \"notes\": \"Likely supporter, call after 6pm\"\n      }\n    ]\n  },\n  {\n    \"id\": 102,\n    \"address\": \"125 Main St\",\n    \"lat\": 45.5018,\n    \"lng\": -73.5674,\n    \"addresses\": [\n      {\n        \"id\": 1003,\n        \"firstName\": \"Bob\",\n        \"lastName\": \"Johnson\",\n        \"unitNumber\": null,\n        \"supportLevel\": \"LEVEL_3\",\n        \"phone\": null,\n        \"email\": null,\n        \"sign\": false,\n        \"notes\": \"Undecided, needs more info\"\n      }\n    ]\n  }\n]\n

"},{"location":"v2/frontend/pages/admin/cut-export-page/#3-fetch-cut-statistics","title":"3. Fetch Cut Statistics","text":"
const statsRes = await api.get<CutStatistics>(`/map/cuts/${id}/statistics`);\nsetStats(statsRes.data);\n

Response Format:

{\n  \"total\": 150,\n  \"byLevel\": {\n    \"LEVEL_1\": 80,\n    \"LEVEL_2\": 30,\n    \"LEVEL_3\": 25,\n    \"LEVEL_4\": 10,\n    \"NONE\": 5\n  },\n  \"withSign\": 50,\n  \"withPhone\": 100,\n  \"withEmail\": 90\n}\n

"},{"location":"v2/frontend/pages/admin/cut-export-page/#4-parallel-api-calls-pattern","title":"4. Parallel API Calls Pattern","text":"
useEffect(() => {\n  if (!id) return;\n  (async () => {\n    try {\n      // Fetch all 3 endpoints in parallel\n      const [cutRes, locsRes, statsRes] = await Promise.all([\n        api.get<Cut>(`/map/cuts/${id}`),\n        api.get<Location[]>(`/map/cuts/${id}/locations`),\n        api.get<CutStatistics>(`/map/cuts/${id}/statistics`),\n      ]);\n\n      setCut(cutRes.data);\n\n      // Flatten locations with addresses\n      const flatAddresses: AddressWithLocation[] = [];\n      for (const loc of locsRes.data) {\n        if (loc.addresses && loc.addresses.length > 0) {\n          for (const addr of loc.addresses) {\n            flatAddresses.push({\n              ...addr,\n              locationAddress: loc.address,\n            });\n          }\n        }\n      }\n      setAddresses(flatAddresses);\n\n      setStats(statsRes.data);\n    } catch {\n      message.error('Failed to load cut data');\n    } finally {\n      setLoading(false);\n    }\n  })();\n}, [id]);\n

Benefit: Parallel requests reduce total loading time (3 requests in ~200ms instead of ~600ms sequential).

"},{"location":"v2/frontend/pages/admin/cut-export-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#complete-address-flattening-logic","title":"Complete Address Flattening Logic","text":"
interface AddressWithLocation extends Address {\n  locationAddress: string; // Building street address from parent Location\n}\n\n// Flatten locations with their addresses\nconst flatAddresses: AddressWithLocation[] = [];\nfor (const loc of locations) {\n  if (loc.addresses && loc.addresses.length > 0) {\n    for (const addr of loc.addresses) {\n      flatAddresses.push({\n        ...addr,\n        locationAddress: loc.address, // Add parent location address\n      });\n    }\n  }\n}\n\nsetAddresses(flatAddresses);\n

Result: - Input: 100 locations with 2-10 addresses each - Output: 500 flat addresses (one row per address in table)

"},{"location":"v2/frontend/pages/admin/cut-export-page/#statistics-grid-rendering","title":"Statistics Grid Rendering","text":"
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic title=\"Total\" value={stats.total} />\n    </Card>\n  </Col>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Strong\"\n        value={stats.byLevel.LEVEL_1 || 0}\n        valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_1 }}\n      />\n    </Card>\n  </Col>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Likely\"\n        value={stats.byLevel.LEVEL_2 || 0}\n        valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_2 }}\n      />\n    </Card>\n  </Col>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Unsure\"\n        value={stats.byLevel.LEVEL_3 || 0}\n        valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_3 }}\n      />\n    </Card>\n  </Col>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Oppose\"\n        value={stats.byLevel.LEVEL_4 || 0}\n        valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_4 }}\n      />\n    </Card>\n  </Col>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic title=\"None\" value={stats.byLevel.NONE || 0} />\n    </Card>\n  </Col>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic title=\"Signs\" value={stats.withSign} />\n    </Card>\n  </Col>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic title=\"Email\" value={addresses.filter(a => a.email).length} />\n    </Card>\n  </Col>\n  <Col xs={8} sm={4}>\n    <Card size=\"small\">\n      <Statistic title=\"Phone\" value={addresses.filter(a => a.phone).length} />\n    </Card>\n  </Col>\n</Row>\n
"},{"location":"v2/frontend/pages/admin/cut-export-page/#support-level-tag-rendering","title":"Support Level Tag Rendering","text":"
{\n  title: 'Support',\n  dataIndex: 'supportLevel',\n  key: 'supportLevel',\n  width: 120,\n  render: (level: SupportLevel | null) =>\n    level ? (\n      <Tag color={SUPPORT_LEVEL_COLORS[level]}>\n        {SUPPORT_LEVEL_LABELS[level]}\n      </Tag>\n    ) : (\n      <Tag>None</Tag>\n    ),\n}\n\n// Constants (from types/api.ts)\nexport const SUPPORT_LEVEL_LABELS = {\n  LEVEL_1: 'Strong',\n  LEVEL_2: 'Likely',\n  LEVEL_3: 'Unsure',\n  LEVEL_4: 'Oppose',\n  NONE: 'None',\n};\n\nexport const SUPPORT_LEVEL_COLORS = {\n  LEVEL_1: 'green',\n  LEVEL_2: 'cyan',\n  LEVEL_3: 'orange',\n  LEVEL_4: 'red',\n  NONE: 'default',\n};\n
"},{"location":"v2/frontend/pages/admin/cut-export-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#1-parallel-api-requests","title":"1. Parallel API Requests","text":"

Three API calls made in parallel with Promise.all():

const [cutRes, locsRes, statsRes] = await Promise.all([\n  api.get<Cut>(`/map/cuts/${id}`),\n  api.get<Location[]>(`/map/cuts/${id}/locations`),\n  api.get<CutStatistics>(`/map/cuts/${id}/statistics`),\n]);\n

Benefit: Total loading time ~200ms (slowest request) instead of ~600ms (sum of all requests).

"},{"location":"v2/frontend/pages/admin/cut-export-page/#2-address-flattening-onm","title":"2. Address Flattening (O(n*m))","text":"

Flattening addresses is O(n\u00d7m) where n = locations, m = addresses per location:

for (const loc of locations) {        // O(n)\n  for (const addr of loc.addresses) {  // O(m)\n    flatAddresses.push({...addr, locationAddress: loc.address});\n  }\n}\n

Complexity: O(n\u00d7m), typically O(100\u00d75) = O(500) operations

Benefit: Simple nested loop, fast for typical cut sizes (< 1000 addresses).

"},{"location":"v2/frontend/pages/admin/cut-export-page/#3-no-pagination-print-all","title":"3. No Pagination (Print All)","text":"

Table has pagination={false}:

<Table\n  dataSource={addresses}\n  pagination={false}  // Print all addresses\n/>\n

Trade-off: - Benefit: All addresses visible in one print job (no manual page-turning) - Cost: Large cuts (> 500 addresses) may slow page load slightly

Rationale: Printable reports typically exported for offline use, so full dataset preferred over pagination.

"},{"location":"v2/frontend/pages/admin/cut-export-page/#4-usememo-for-header-actions","title":"4. useMemo for Header Actions","text":"

Header actions memoized with useMemo to prevent re-renders:

const headerActions = useMemo(() => (\n  <Space>\n    <Button onClick={() => navigate('/app/map/cuts')}>Back</Button>\n    <Button onClick={() => window.print()}>Print</Button>\n  </Space>\n), [navigate]);\n

Benefit: Header actions only recreated if navigate changes (never changes), preventing unnecessary re-renders.

"},{"location":"v2/frontend/pages/admin/cut-export-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#desktop-first-layout","title":"Desktop-First Layout","text":"

Report optimized for desktop printing, not mobile viewing: - Landscape orientation (@page { size: letter landscape; }) - 9 statistics cards in responsive grid (3 per row on desktop, 2 per row on mobile) - Wide table with 8 columns (requires landscape for clarity)

"},{"location":"v2/frontend/pages/admin/cut-export-page/#responsive-statistics-grid","title":"Responsive Statistics Grid","text":"
<Row gutter={[12, 12]}>\n  <Col xs={8} sm={4}>  {/* 3 per row mobile, 6 per row desktop */}\n    <Card size=\"small\">\n      <Statistic title=\"Total\" value={stats.total} />\n    </Card>\n  </Col>\n  {/* ... 8 more cards ... */}\n</Row>\n

Breakpoints: - xs={8}: 3 cards per row on mobile (8+8+8 = 24 columns) - sm={4}: 6 cards per row on tablet (4\u00d76 = 24 columns) - md+: 6-9 cards per row on desktop (depends on screen width)

"},{"location":"v2/frontend/pages/admin/cut-export-page/#print-layout-landscape","title":"Print Layout (Landscape)","text":"
@page {\n  size: letter landscape;\n  margin: 0.25in;\n}\n

Landscape Orientation: - Paper: 11\" wide \u00d7 8.5\" tall (instead of 8.5\" \u00d7 11\") - Allows 8-column table to fit without horizontal scroll - Critical for readability of address table

"},{"location":"v2/frontend/pages/admin/cut-export-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#print-only-page","title":"Print-Only Page","text":"

Cut export report is primarily for printing, not interactive use. Accessibility considerations minimal:

  1. Semantic HTML:
  2. <table> for address grid
  3. <th> for column headers
  4. <td> for data cells
  5. Proper heading hierarchy (<h4> for cut name)

  6. Keyboard Navigation:

  7. \"Back to Cuts\" button accessible via Tab + Enter
  8. \"Print\" button accessible via Tab + Enter

  9. Screen Reader Support:

  10. Table headers announced for each column
  11. Statistics titles read before values
  12. Tag labels announced (e.g., \"Strong Support\", \"Priority Cut\")

  13. Color Contrast:

  14. Support level tags meet WCAG AA standards
  15. Statistics value colors have sufficient contrast on white background

Note: Once printed, report relies on visual layout (table, colors, spacing) for interpretation.

"},{"location":"v2/frontend/pages/admin/cut-export-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#problem-report-shows-cut-not-found","title":"Problem: Report Shows \"Cut Not Found\"","text":"

Symptoms: - Navigate to /app/map/cuts/:id/export - Page shows error: \"Cut not found\" - No data displayed

Causes: 1. Invalid cut ID in URL (cut doesn't exist) 2. API returned 404 for cut 3. Cut deleted after URL generated

Solutions:

  1. Verify cut ID:
  2. Check URL bar: /app/map/cuts/5/export
  3. Note the ID number (5)
  4. Navigate to \"Map\" \u2192 \"Cuts\"
  5. Verify cut with ID 5 exists in table

  6. Check API response:

  7. Open browser DevTools (F12)
  8. Go to Network tab
  9. Look for GET /api/map/cuts/5 request
  10. Check response:

    • 200 OK: Cut exists, check response body
    • 404 Not Found: Cut doesn't exist
    • 500 Server Error: API error
  11. Navigate from Cuts page:

  12. Instead of typing URL manually, click \"Export\" button from CutsPage
  13. This ensures valid cut ID used
"},{"location":"v2/frontend/pages/admin/cut-export-page/#problem-address-table-is-empty","title":"Problem: Address Table is Empty","text":"

Symptoms: - Cut metadata loads correctly (name, category, assigned person) - Statistics cards show zeros - Address table has no rows

Causes: 1. Cut has no locations assigned (empty polygon or no location assignment) 2. Locations have no addresses (only building locations, no unit/contact data) 3. API error fetching locations

Solutions:

  1. Check cut has locations:
  2. Navigate to \"Map\" \u2192 \"Cuts\"
  3. Click on cut name to view details
  4. Check \"Locations\" count in details modal
  5. If 0 locations, cut is empty (no addresses to export)

  6. Assign locations to cut:

  7. Navigate to \"Map\" \u2192 \"Locations\"
  8. Draw cut polygon on map
  9. Use point-in-polygon to assign locations
  10. Re-export cut

  11. Check locations have addresses:

  12. Navigate to \"Map\" \u2192 \"Locations\"
  13. Click on location in cut
  14. Check \"Addresses\" tab in location details
  15. If no addresses, add address records via CSV import or manual entry

  16. Check API response:

  17. Open browser DevTools (F12)
  18. Go to Network tab
  19. Look for GET /api/map/cuts/:id/locations request
  20. Check response:
    • 200 OK with empty array []: No locations in cut
    • 200 OK with locations but no addresses field: Locations exist but no address data
    • 500 Server Error: API error
"},{"location":"v2/frontend/pages/admin/cut-export-page/#problem-statistics-dont-match-address-table","title":"Problem: Statistics Don't Match Address Table","text":"

Symptoms: - Statistics cards show different counts than visible in address table - Example: \"Strong\" card shows 80, but table has 60 \"Strong\" tags

Causes: 1. Statistics API aggregates all addresses (including those without contact info) 2. Table filters out addresses with missing data 3. Race condition between API calls

Solutions:

  1. Verify statistics API:
  2. Statistics endpoint (/api/map/cuts/:id/statistics) counts ALL addresses
  3. Table may filter addresses (e.g., only show addresses with names)
  4. This is expected behavior

  5. Check table filters:

  6. No default filters applied in CutExportPage
  7. If custom filters added, they may hide addresses from table

  8. Refresh page:

  9. Hard refresh (Ctrl+Shift+R or Cmd+Shift+R)
  10. Clears cached data and re-fetches from API

  11. Check API responses match:

  12. Open browser DevTools (F12)
  13. Go to Network tab
  14. Compare responses:
    • GET /api/map/cuts/:id/statistics \u2192 total: 150
    • GET /api/map/cuts/:id/locations \u2192 count addresses in response
    • Counts should match
"},{"location":"v2/frontend/pages/admin/cut-export-page/#problem-print-preview-is-blank","title":"Problem: Print Preview is Blank","text":"

Symptoms: - Click \"Print\" button - Print preview shows blank page - No content visible

Causes: 1. Print CSS not applying 2. Browser print settings incorrect 3. Content outside printable area

Solutions:

  1. Check print CSS:
  2. View page source (Ctrl+U or Cmd+U)
  3. Verify <style> tag with @media print rules exists
  4. If missing, print CSS not loaded

  5. Enable background graphics:

  6. In print dialog, check \"Background graphics\" option
  7. This ensures table borders and colors print

  8. Try different browser:

  9. Chrome, Firefox, and Edge have different print engines
  10. If one fails, try another

  11. Check browser console:

  12. Open DevTools (F12)
  13. Go to Console tab
  14. Look for CSS errors (e.g., invalid print rules)

  15. Use Ctrl+P instead of button:

  16. Press Ctrl+P (Windows/Linux) or Cmd+P (Mac)
  17. This bypasses custom print button and uses browser default
"},{"location":"v2/frontend/pages/admin/cut-export-page/#problem-table-columns-cut-off-when-printed","title":"Problem: Table Columns Cut Off When Printed","text":"

Symptoms: - Print preview shows table, but rightmost columns missing - Horizontal scrollbar visible in print preview

Causes: 1. Portrait orientation used instead of landscape 2. Table too wide for paper 3. Print scaling set to \"Fit to page\" (shrinks content)

Solutions:

  1. Verify landscape orientation:
  2. In print dialog, check \"Orientation: Landscape\"
  3. Landscape gives 11\" width instead of 8.5\"
  4. Critical for 8-column table

  5. Check print scaling:

  6. In print dialog, set scale to \"100%\" (not \"Fit to page\")
  7. \"Fit to page\" shrinks content, making text too small

  8. Reduce font sizes:

  9. If table still too wide, edit print CSS:

    @media print {\n  .cut-export-print { font-size: 8px !important; } /* Was 10px */\n  .cut-export-print .ant-table { font-size: 7px !important; } /* Was 9px */\n}\n

  10. Remove less important columns:

  11. Temporarily hide \"Notes\" column (least critical for field use):
    const columns = [\n  // ... other columns ...\n  // Comment out Notes column\n  // { title: 'Notes', dataIndex: 'notes', ... },\n];\n
"},{"location":"v2/frontend/pages/admin/cut-export-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#backend-documentation","title":"Backend Documentation","text":"
  • Cuts Module - Cut CRUD API + spatial queries
  • Locations Module - Location + Address CRUD
  • Cut Statistics Service - Aggregation logic
"},{"location":"v2/frontend/pages/admin/cut-export-page/#frontend-documentation","title":"Frontend Documentation","text":"
  • CutsPage - Cut management table with export button
  • WalkSheetPage - Printable walk sheet form (complementary printable page)
  • LocationsPage - Location + Address CRUD
"},{"location":"v2/frontend/pages/admin/cut-export-page/#feature-documentation","title":"Feature Documentation","text":"
  • Canvassing System - Complete volunteer canvassing workflow
  • Cut Management - Creating and managing geographic boundaries
  • Report Generation - Printable reports for campaigns
"},{"location":"v2/frontend/pages/admin/cut-export-page/#api-documentation","title":"API Documentation","text":"
  • GET /api/map/cuts/:id - Fetch cut metadata
  • GET /api/map/cuts/:id/locations - Fetch locations in cut
  • GET /api/map/cuts/:id/statistics - Fetch cut statistics
"},{"location":"v2/frontend/pages/admin/cut-export-page/#user-guides","title":"User Guides","text":"
  • Campaign Organizer Guide - Using cut reports for planning
  • Field Organizer Guide - Printing and using reports during canvassing
"},{"location":"v2/frontend/pages/admin/cut-export-page/#deployment-documentation","title":"Deployment Documentation","text":"
  • Printing Best Practices - Print server configuration for campaign offices
"},{"location":"v2/frontend/pages/admin/cuts-page/","title":"CutsPage","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#overview","title":"Overview","text":"

The CutsPage provides administrative management of geographic polygon boundaries (\"cuts\") used to organize canvassing territories for volunteer door-knocking campaigns. It offers a dual-view interface: a table view for CRUD operations on cut metadata, and an interactive map view for drawing new polygons, editing existing boundaries, and visualizing all cuts simultaneously. The page integrates with the Location system and Shift system to enable territory-based volunteer assignments.

Route: /app/map/cuts Component: admin/src/pages/CutsPage.tsx (561 lines) Auth Required: Yes (SUPER_ADMIN or MAP_ADMIN role recommended) Layout: AppLayout Backend Module: api/src/modules/map/cuts/

"},{"location":"v2/frontend/pages/admin/cuts-page/#screenshot","title":"Screenshot","text":"

[Screenshot: CutsPage with large Segmented control at top showing \"Table\" (selected with TableOutlined icon) and \"Map\" (EnvironmentOutlined icon). Below in table view: search bar, \"Create Cut\" primary button, and \"Import GeoJSON\" secondary button aligned right. Table has columns: Name (sortable), Description, Color (colored circle preview), Location Count (number badge), Created At (date), Actions. Each row shows Edit, View Locations, Export GeoJSON, and Delete buttons. Pagination at bottom. When \"Map\" tab selected: full-screen Leaflet map with CutEditorMap component showing existing cuts as colored polygons, drawing controls in top-left corner, and \"Save Cut\" button when polygon drawing complete.]

"},{"location":"v2/frontend/pages/admin/cuts-page/#features","title":"Features","text":"
  • Dual-view interface \u2014 Segmented control to switch between table and map views
  • Cut CRUD operations \u2014 Create, read, update, delete cut metadata (name, description, color)
  • Interactive polygon drawing \u2014 Click vertices on map to draw custom boundaries
  • Cut visualization \u2014 View all cuts as colored polygon overlays on map
  • GeoJSON import/export \u2014 Import cuts from GeoJSON files, export to GeoJSON format
  • Location assignment \u2014 Automatically assign locations within polygon boundaries
  • Color-coded polygons \u2014 Each cut has customizable color for visual distinction
  • Location count badges \u2014 See number of locations within each cut at a glance
  • Search and filter \u2014 Search cuts by name or description (300ms debounce)
  • Sortable table \u2014 Sort by name, location count, or creation date
  • Polygon editing \u2014 Edit existing cut boundaries on map (drag vertices, add/remove points)
  • Validation \u2014 Prevent self-intersecting polygons, ensure minimum 3 vertices
  • Responsive design \u2014 Mobile-friendly table, full-screen map view
  • Pagination \u2014 Configurable page size (10, 25, 50, 100 per page)
"},{"location":"v2/frontend/pages/admin/cuts-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#creating-a-new-cut-table-view","title":"Creating a New Cut (Table View)","text":"
  1. Navigate to /app/map/cuts
  2. Ensure \"Table\" tab is selected (default)
  3. Click \"Create Cut\" button (top right)
  4. Modal appears: \"Create Cut\"
  5. Fill in fields:
  6. Name: (required) e.g., \"Downtown Core\"
  7. Description: (optional) e.g., \"High-density residential area with apartment buildings\"
  8. Color: (required) Click color picker to select polygon color (default: #3498db blue)
  9. Click \"Create\" button
  10. Success message: \"Cut created successfully\"
  11. Modal closes, table refreshes to show new cut (with 0 locations initially)
  12. New cut appears in table with selected color preview circle
"},{"location":"v2/frontend/pages/admin/cuts-page/#drawing-a-cut-polygon-map-view","title":"Drawing a Cut Polygon (Map View)","text":"
  1. Click \"Map\" tab in Segmented control
  2. Map view appears with CutEditorMap component
  3. Existing cuts render as colored polygon overlays
  4. Click \"Draw New Cut\" button (top-left map controls)
  5. Drawing mode activates:
  6. Cursor changes to crosshair
  7. Instructional text: \"Click to place vertices. Double-click or click first vertex to close polygon.\"
  8. Click map to place first vertex (blue circle marker appears)
  9. Click again to place second vertex (line drawn between vertices)
  10. Continue clicking to place vertices (polygon outline forms)
  11. Close polygon by:
  12. Double-clicking final vertex, OR
  13. Clicking first vertex again (close detection radius: 10 pixels)
  14. Polygon closes automatically, fills with semi-transparent color
  15. Modal appears: \"Save Cut\"
  16. Fill in fields:
    • Name: (required) e.g., \"Riverside District\"
    • Description: (optional) e.g., \"Area bounded by river and highway\"
    • Color: (required, pre-filled with default blue)
  17. Click \"Save\" button
  18. Backend calculates locations within polygon (point-in-polygon algorithm)
  19. Success message: \"Cut created successfully with 47 locations\"
  20. Polygon remains on map, now saved to database
  21. Switch to \"Table\" tab to see new cut with location count: 47
"},{"location":"v2/frontend/pages/admin/cuts-page/#editing-a-cut","title":"Editing a Cut","text":"
  1. In Table view, locate cut to edit
  2. Click \"Edit\" button in Actions column
  3. Modal appears: \"Edit Cut\"
  4. Modify fields:
  5. Name: Update cut name
  6. Description: Update or add description
  7. Color: Change polygon color (affects map visualization)
  8. Click \"Save\" button
  9. Success message: \"Cut updated successfully\"
  10. Table refreshes to show updated values
  11. Color preview circle updates to new color

Note: Editing cut metadata does NOT modify polygon boundary. To change boundary, must delete cut and redraw.

"},{"location":"v2/frontend/pages/admin/cuts-page/#importing-cuts-from-geojson","title":"Importing Cuts from GeoJSON","text":"
  1. In Table view, click \"Import GeoJSON\" button (top right, next to Create Cut)
  2. File picker opens
  3. Select GeoJSON file from local filesystem (e.g., cuts-export-2026-01-15.geojson)
  4. File uploads to backend
  5. Backend parses GeoJSON:
  6. Validates FeatureCollection format
  7. Extracts polygons from features
  8. Creates Cut records with properties (name, description, color)
  9. Calculates locations within each polygon
  10. Success message: \"Imported 5 cuts with 234 total locations\"
  11. Table refreshes to show imported cuts
  12. Switch to Map view to see imported polygons

GeoJSON Format Expected:

{\n  \"type\": \"FeatureCollection\",\n  \"features\": [\n    {\n      \"type\": \"Feature\",\n      \"geometry\": {\n        \"type\": \"Polygon\",\n        \"coordinates\": [\n          [\n            [-75.69602, 45.42153],\n            [-75.69102, 45.42153],\n            [-75.69102, 45.41653],\n            [-75.69602, 45.41653],\n            [-75.69602, 45.42153]\n          ]\n        ]\n      },\n      \"properties\": {\n        \"name\": \"Downtown Core\",\n        \"description\": \"High-density residential area\",\n        \"color\": \"#3498db\"\n      }\n    }\n  ]\n}\n
"},{"location":"v2/frontend/pages/admin/cuts-page/#exporting-a-cut-to-geojson","title":"Exporting a Cut to GeoJSON","text":"
  1. In Table view, locate cut to export
  2. Click \"Export GeoJSON\" button in Actions column
  3. Backend generates GeoJSON with:
  4. Polygon geometry (coordinates array)
  5. Cut properties (name, description, color)
  6. Location count metadata
  7. Browser downloads file: cut-{name}-{id}.geojson
  8. File can be opened in GIS software (QGIS, ArcGIS) or re-imported later

Example Export:

{\n  \"type\": \"Feature\",\n  \"geometry\": {\n    \"type\": \"Polygon\",\n    \"coordinates\": [\n      [\n        [-75.69602, 45.42153],\n        [-75.69102, 45.42153],\n        [-75.69102, 45.41653],\n        [-75.69602, 45.41653],\n        [-75.69602, 45.42153]\n      ]\n    ]\n  },\n  \"properties\": {\n    \"id\": \"cut_abc123\",\n    \"name\": \"Downtown Core\",\n    \"description\": \"High-density residential area\",\n    \"color\": \"#3498db\",\n    \"locationCount\": 47,\n    \"createdAt\": \"2026-01-15T10:30:00.000Z\"\n  }\n}\n
"},{"location":"v2/frontend/pages/admin/cuts-page/#viewing-locations-in-a-cut","title":"Viewing Locations in a Cut","text":"
  1. In Table view, locate cut
  2. Note location count badge (e.g., \"47 locations\")
  3. Click \"View Locations\" button in Actions column
  4. Navigates to /app/map/locations?cutId={cutId}
  5. LocationsPage opens with cut filter pre-applied
  6. Table shows only locations within that cut's polygon
  7. Can edit locations, view on map, or export to CSV
"},{"location":"v2/frontend/pages/admin/cuts-page/#deleting-a-cut","title":"Deleting a Cut","text":"
  1. In Table view, locate cut to delete
  2. Click \"Delete\" button in Actions column (red text)
  3. Confirmation modal appears: \"Are you sure you want to delete the cut 'Downtown Core'? This will unassign all locations from this cut but will not delete the locations themselves.\"
  4. Click \"Delete\" to confirm (or \"Cancel\" to abort)
  5. Backend:
  6. Deletes Cut record from database
  7. Sets cutId = null on all Location records within polygon (unassigns)
  8. Deletes associated Shift records (shifts are cut-specific)
  9. Success message: \"Cut deleted successfully. 47 locations unassigned.\"
  10. Table refreshes, deleted cut removed
  11. Switch to Map view: polygon no longer visible
"},{"location":"v2/frontend/pages/admin/cuts-page/#searching-cuts","title":"Searching Cuts","text":"
  1. Locate search bar at top of Table view (below Segmented control)
  2. Start typing search query (e.g., \"Downtown\")
  3. Search automatically triggers after 300ms pause (debounce)
  4. Table filters to show matching cuts
  5. Matches on: cut name, description
  6. Clear search by clicking X icon or deleting text
"},{"location":"v2/frontend/pages/admin/cuts-page/#sorting-the-table","title":"Sorting the Table","text":"
  1. Identify sortable columns (Name, Location Count, Created At)
  2. Click Name column header to sort alphabetically (A\u2192Z)
  3. Click again to reverse sort (Z\u2192A)
  4. Click Location Count to sort by number of locations (ascending)
  5. Click again to reverse sort (descending, highest first)
  6. Click Created At to sort by creation date (newest first)
  7. Can combine with search filter (sorted results only)
"},{"location":"v2/frontend/pages/admin/cuts-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  • Typography.Title \u2014 Page heading (\"Cuts\")
  • Typography.Text \u2014 Labels, descriptions, empty state text
  • Segmented \u2014 Tab control for table/map view switching (large button style)
  • Space \u2014 Button grouping (Create, Import)
  • Button \u2014 Primary actions (Create, Import, Draw), row actions (Edit, View, Export, Delete)
  • Input.Search \u2014 Cut search field with debounce
  • Table \u2014 Main data table with sortable columns, pagination
  • Tag \u2014 Location count badges
  • Modal \u2014 Create/edit cut form, confirmation dialogs
  • Form \u2014 Cut metadata form (name, description, color)
  • Form.Item \u2014 Form field wrapper with validation
  • Input \u2014 Text fields (name, description)
  • Input.TextArea \u2014 Description field (multi-line)
  • ColorPicker \u2014 Cut color selection
  • Upload \u2014 GeoJSON file upload
  • message \u2014 Toast notifications for success/error feedback
  • Empty \u2014 Empty state when no cuts exist
"},{"location":"v2/frontend/pages/admin/cuts-page/#map-components-custom","title":"Map Components (Custom)","text":"
  • CutEditorMap \u2014 Specialized Leaflet map wrapper for cut drawing/editing
  • Renders existing cuts as polygon overlays
  • Provides drawing mode for new polygons
  • Handles vertex placement, line drawing, polygon closing
  • Validates polygon geometry (minimum 3 vertices, no self-intersections)
  • Provides save callback after polygon closes

  • CutOverlays \u2014 Component for rendering cut polygons on map

  • Renders Leaflet Polygon layers for each cut
  • Applies cut color to fill and stroke
  • Adds tooltip on hover (cut name + location count)
  • Handles click events for cut selection
"},{"location":"v2/frontend/pages/admin/cuts-page/#segmented-tab-control","title":"Segmented Tab Control","text":"
<Segmented\n  value={activeTab}\n  onChange={(val) => setActiveTab(val as string)}\n  options={[\n    {\n      value: 'table',\n      label: 'Table',\n      icon: <TableOutlined />,\n    },\n    {\n      value: 'map',\n      label: 'Map',\n      icon: <EnvironmentOutlined />,\n    },\n  ]}\n  size=\"large\"\n  block\n  style={{ marginBottom: 16 }}\n/>\n

Segmented Control Features: - Large size: Prominent button-style tabs - Block layout: Full-width tabs (50% each) - Icons: Visual indicators (TableOutlined, EnvironmentOutlined) - Value state: Controlled component with activeTab state - Smooth transition: Instant view switching (no loading)

"},{"location":"v2/frontend/pages/admin/cuts-page/#table-structure","title":"Table Structure","text":"
const columns: ColumnsType<Cut> = [\n  {\n    title: 'Name',\n    dataIndex: 'name',\n    key: 'name',\n    sorter: (a, b) => a.name.localeCompare(b.name),\n    width: 200,\n  },\n  {\n    title: 'Description',\n    dataIndex: 'description',\n    key: 'description',\n    width: 300,\n    ellipsis: true,\n    render: (text: string | null) => text || <Text type=\"secondary\">\u2014</Text>,\n  },\n  {\n    title: 'Color',\n    dataIndex: 'color',\n    key: 'color',\n    width: 80,\n    render: (color: string) => (\n      <div\n        style={{\n          width: 24,\n          height: 24,\n          borderRadius: '50%',\n          backgroundColor: color,\n          border: '2px solid rgba(0,0,0,0.1)',\n        }}\n      />\n    ),\n  },\n  {\n    title: 'Location Count',\n    dataIndex: 'locationCount',\n    key: 'locationCount',\n    width: 150,\n    sorter: (a, b) => (a.locationCount || 0) - (b.locationCount || 0),\n    render: (count: number) => (\n      <Tag color={count > 0 ? 'blue' : 'default'}>{count} locations</Tag>\n    ),\n  },\n  {\n    title: 'Created At',\n    dataIndex: 'createdAt',\n    key: 'createdAt',\n    width: 180,\n    sorter: (a, b) => dayjs(a.createdAt).unix() - dayjs(b.createdAt).unix(),\n    render: (date: string) => dayjs(date).format('MMM D, YYYY h:mm A'),\n  },\n  {\n    title: 'Actions',\n    key: 'actions',\n    width: 300,\n    fixed: 'right',\n    render: (_: unknown, record: Cut) => (\n      <Space size=\"small\" wrap>\n        <Button size=\"small\" type=\"link\" icon={<EditOutlined />} onClick={() => handleEdit(record)}>\n          Edit\n        </Button>\n        <Button\n          size=\"small\"\n          type=\"link\"\n          icon={<EnvironmentOutlined />}\n          onClick={() => handleViewLocations(record)}\n        >\n          View Locations\n        </Button>\n        <Button\n          size=\"small\"\n          type=\"link\"\n          icon={<DownloadOutlined />}\n          onClick={() => handleExportGeoJSON(record)}\n        >\n          Export GeoJSON\n        </Button>\n        <Button\n          size=\"small\"\n          type=\"link\"\n          danger\n          icon={<DeleteOutlined />}\n          onClick={() => handleDeleteConfirm(record)}\n        >\n          Delete\n        </Button>\n      </Space>\n    ),\n  },\n];\n

Column Features: - Name: Primary identifier, sortable, 200px width - Description: Optional text, ellipsis for overflow, nullable (shows \"\u2014\" if null) - Color: Visual circle preview (24px diameter, rounded, bordered), 80px width - Location Count: Number of locations within polygon, color-coded tag (blue if > 0, gray if 0), sortable - Created At: Formatted timestamp (e.g., \"Jan 15, 2026 10:30 AM\"), sortable - Actions: 4 buttons (Edit, View Locations, Export, Delete), 300px width, fixed right

"},{"location":"v2/frontend/pages/admin/cuts-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#local-state-no-zustand-store","title":"Local State (No Zustand Store)","text":"
// View state\nconst [activeTab, setActiveTab] = useState<string>('table');\n\n// Data state\nconst [cuts, setCuts] = useState<Cut[]>([]);\nconst [loading, setLoading] = useState(false);\n\n// Filter state\nconst [search, setSearch] = useState('');\n\n// Pagination state\nconst [pagination, setPagination] = useState({\n  current: 1,\n  pageSize: 10,\n  total: 0,\n});\n\n// Modal state\nconst [createModalOpen, setCreateModalOpen] = useState(false);\nconst [editModalOpen, setEditModalOpen] = useState(false);\nconst [selectedCut, setSelectedCut] = useState<Cut | null>(null);\n\n// Import state\nconst [importing, setImporting] = useState(false);\n\n// Debounce timer\nconst searchTimerRef = useRef<NodeJS.Timeout | null>(null);\n

No Global State:

This page does NOT use Zustand stores. Cut data is fetched directly from the API on mount and after mutations. This is appropriate because: - Cut data is admin-only (not needed globally) - Data changes infrequently (only on manual create/edit/delete) - No need to share state between pages (LocationsPage fetches cuts independently) - Simpler architecture without store overhead

"},{"location":"v2/frontend/pages/admin/cuts-page/#debounced-search-pattern","title":"Debounced Search Pattern","text":"
const handleSearch = (value: string) => {\n  // Clear existing timer\n  if (searchTimerRef.current) {\n    clearTimeout(searchTimerRef.current);\n  }\n\n  // Set new timer\n  searchTimerRef.current = setTimeout(() => {\n    setSearch(value);\n    setPagination((prev) => ({ ...prev, current: 1 })); // Reset to page 1\n  }, 300);\n};\n\n// Cleanup on unmount\nuseEffect(() => {\n  return () => {\n    if (searchTimerRef.current) clearTimeout(searchTimerRef.current);\n  };\n}, []);\n

Why 300ms Debounce?

  • Performance: Prevents API call on every keystroke
  • User Experience: Long enough to avoid lag, short enough to feel responsive
  • API Load: Reduces backend database queries
  • Reset pagination: Search resets to page 1 (user expects to see first results)
"},{"location":"v2/frontend/pages/admin/cuts-page/#usecallback-optimization","title":"useCallback Optimization","text":"
const loadCuts = useCallback(async () => {\n  setLoading(true);\n  try {\n    const params: Record<string, unknown> = {\n      page: pagination.current,\n      limit: pagination.pageSize,\n    };\n\n    if (search) params.search = search;\n\n    const { data } = await api.get<{\n      data: Cut[];\n      pagination: { total: number };\n    }>('/cuts', { params });\n\n    setCuts(data.data);\n    setPagination((prev) => ({\n      ...prev,\n      total: data.pagination.total,\n    }));\n  } catch (error) {\n    message.error('Failed to load cuts');\n  } finally {\n    setLoading(false);\n  }\n}, [pagination.current, pagination.pageSize, search]);\n\nuseEffect(() => {\n  if (activeTab === 'table') {\n    loadCuts();\n  }\n}, [activeTab, loadCuts]);\n

Conditional Loading:

Cuts only load when Table tab is active. Map view uses separate data fetching in CutEditorMap component.

"},{"location":"v2/frontend/pages/admin/cuts-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose Auth GET /api/cuts List cuts (paginated, filtered) Required GET /api/cuts/:id Get single cut with geometry Required POST /api/cuts Create new cut Required PUT /api/cuts/:id Update cut metadata Required DELETE /api/cuts/:id Delete cut Required POST /api/cuts/import Import cuts from GeoJSON Required GET /api/cuts/:id/export Export cut to GeoJSON Required"},{"location":"v2/frontend/pages/admin/cuts-page/#load-cuts-paginated-with-search","title":"Load Cuts (Paginated with Search)","text":"

Request:

const params: Record<string, unknown> = {\n  page: 1,\n  limit: 10,\n  search: 'Downtown',  // Optional: search query\n};\n\nconst { data } = await api.get<{\n  data: Cut[];\n  pagination: { total: number; page: number; limit: number };\n}>('/cuts', { params });\n

Query Parameters: - page (number, required): Page number (1-indexed) - limit (number, required): Items per page (10, 25, 50, or 100) - search (string, optional): Search query (matches name, description)

Response (200 OK):

{\n  \"data\": [\n    {\n      \"id\": \"cut_abc123\",\n      \"name\": \"Downtown Core\",\n      \"description\": \"High-density residential area with apartment buildings\",\n      \"color\": \"#3498db\",\n      \"geometry\": {\n        \"type\": \"Polygon\",\n        \"coordinates\": [\n          [\n            [-75.69602, 45.42153],\n            [-75.69102, 45.42153],\n            [-75.69102, 45.41653],\n            [-75.69602, 45.41653],\n            [-75.69602, 45.42153]\n          ]\n        ]\n      },\n      \"locationCount\": 47,\n      \"createdAt\": \"2026-01-15T10:30:00.000Z\",\n      \"updatedAt\": \"2026-01-15T10:30:00.000Z\"\n    },\n    {\n      \"id\": \"cut_def456\",\n      \"name\": \"Riverside District\",\n      \"description\": null,\n      \"color\": \"#e74c3c\",\n      \"geometry\": {\n        \"type\": \"Polygon\",\n        \"coordinates\": [\n          [\n            [-75.70602, 45.43153],\n            [-75.70102, 45.43153],\n            [-75.70102, 45.42653],\n            [-75.70602, 45.42653],\n            [-75.70602, 45.43153]\n          ]\n        ]\n      },\n      \"locationCount\": 32,\n      \"createdAt\": \"2026-01-16T14:20:00.000Z\",\n      \"updatedAt\": \"2026-01-16T14:20:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 10,\n    \"total\": 12\n  }\n}\n

Response Fields:

  • id (string): Unique cut identifier (prefixed with \"cut_\")
  • name (string): Cut name
  • description (string | null): Optional description
  • color (string): Hex color code (e.g., \"#3498db\")
  • geometry (GeoJSON Polygon): Polygon boundary (GeoJSON format)
  • locationCount (number): Number of locations within polygon (calculated field, not stored)
  • createdAt (ISO 8601): Creation timestamp
  • updatedAt (ISO 8601): Last update timestamp
"},{"location":"v2/frontend/pages/admin/cuts-page/#create-cut","title":"Create Cut","text":"

Request:

const cutData = {\n  name: 'Downtown Core',\n  description: 'High-density residential area',\n  color: '#3498db',\n  geometry: {\n    type: 'Polygon',\n    coordinates: [\n      [\n        [-75.69602, 45.42153],\n        [-75.69102, 45.42153],\n        [-75.69102, 45.41653],\n        [-75.69602, 45.41653],\n        [-75.69602, 45.42153]  // Closed polygon (first point = last point)\n      ]\n    ]\n  }\n};\n\nconst { data } = await api.post<{\n  message: string;\n  cut: Cut;\n  locationCount: number;\n}>('/cuts', cutData);\n

Request Body Schema:

{\n  name: string;           // Required, min 1 char, max 255 chars\n  description?: string;   // Optional, max 1000 chars\n  color: string;          // Required, hex color code (e.g., \"#3498db\")\n  geometry: {             // Required, GeoJSON Polygon\n    type: 'Polygon';\n    coordinates: number[][][];  // [[[lng, lat], [lng, lat], ...]]\n  };\n}\n

Validation Rules:

  • Name: Required, 1-255 characters
  • Description: Optional, max 1000 characters
  • Color: Required, must be valid hex color (#RRGGBB format)
  • Geometry: Required, must be valid GeoJSON Polygon
  • Minimum 3 vertices (4 coordinates including closing point)
  • Closing point must match first point (polygon must be closed)
  • No self-intersections (validated by backend)
  • Coordinates in [longitude, latitude] order (GeoJSON standard)

Response (201 Created):

{\n  \"message\": \"Cut created successfully with 47 locations\",\n  \"cut\": {\n    \"id\": \"cut_abc123\",\n    \"name\": \"Downtown Core\",\n    \"description\": \"High-density residential area\",\n    \"color\": \"#3498db\",\n    \"geometry\": {\n      \"type\": \"Polygon\",\n      \"coordinates\": [[[...]]\n    },\n    \"locationCount\": 47,\n    \"createdAt\": \"2026-01-15T10:30:00.000Z\",\n    \"updatedAt\": \"2026-01-15T10:30:00.000Z\"\n  },\n  \"locationCount\": 47\n}\n

Backend Workflow:

// 1. Validate polygon geometry\nconst isValidPolygon = validatePolygonGeometry(geometry);\nif (!isValidPolygon) {\n  throw new Error('Invalid polygon geometry (self-intersecting or unclosed)');\n}\n\n// 2. Create Cut record\nconst cut = await prisma.cut.create({\n  data: {\n    name,\n    description,\n    color,\n    geometry: geometry as unknown as Prisma.InputJsonValue,\n  },\n});\n\n// 3. Find locations within polygon (point-in-polygon algorithm)\nconst allLocations = await prisma.location.findMany({\n  where: { deletedAt: null },\n});\n\nconst locationsInCut = allLocations.filter((location) => {\n  if (!location.latitude || !location.longitude) return false;\n  return isPointInPolygon(\n    [location.longitude, location.latitude],\n    geometry.coordinates[0]\n  );\n});\n\n// 4. Assign locations to cut\nawait prisma.location.updateMany({\n  where: {\n    id: { in: locationsInCut.map((l) => l.id) },\n  },\n  data: { cutId: cut.id },\n});\n\n// 5. Return cut with location count\nreturn {\n  message: `Cut created successfully with ${locationsInCut.length} locations`,\n  cut,\n  locationCount: locationsInCut.length,\n};\n
"},{"location":"v2/frontend/pages/admin/cuts-page/#update-cut-metadata","title":"Update Cut Metadata","text":"

Request:

const cutId = 'cut_abc123';\nconst updates = {\n  name: 'Downtown Core (Updated)',\n  description: 'Updated description',\n  color: '#2ecc71',  // New color\n};\n\nconst { data } = await api.put<Cut>(`/cuts/${cutId}`, updates);\n

Request Body Schema:

{\n  name?: string;           // Optional, min 1 char, max 255 chars\n  description?: string;    // Optional, max 1000 chars\n  color?: string;          // Optional, hex color code\n  // Note: geometry cannot be updated (must delete and recreate)\n}\n

Response (200 OK):

{\n  \"id\": \"cut_abc123\",\n  \"name\": \"Downtown Core (Updated)\",\n  \"description\": \"Updated description\",\n  \"color\": \"#2ecc71\",\n  \"geometry\": {\n    \"type\": \"Polygon\",\n    \"coordinates\": [[[...]]]\n  },\n  \"locationCount\": 47,\n  \"createdAt\": \"2026-01-15T10:30:00.000Z\",\n  \"updatedAt\": \"2026-01-15T12:45:00.000Z\"\n}\n

Important: Updating cut metadata does NOT recalculate locations within polygon. Geometry cannot be updated via this endpoint (must delete cut and create new one with new geometry).

"},{"location":"v2/frontend/pages/admin/cuts-page/#delete-cut","title":"Delete Cut","text":"

Request:

const cutId = 'cut_abc123';\nawait api.delete(`/cuts/${cutId}`);\n

URL Parameter: - id (string): Cut ID to delete

Response (200 OK):

{\n  \"message\": \"Cut deleted successfully\",\n  \"unassignedLocations\": 47\n}\n

Response Fields: - message (string): Confirmation message - unassignedLocations (number): Number of locations that were unassigned from cut

Backend Workflow:

// 1. Delete Cut record\nawait prisma.cut.delete({\n  where: { id: cutId },\n});\n\n// 2. Unassign locations (set cutId = null)\nconst unassignedCount = await prisma.location.updateMany({\n  where: { cutId },\n  data: { cutId: null },\n});\n\n// 3. Delete associated shifts (cascade delete)\nawait prisma.shift.deleteMany({\n  where: { cutId },\n});\n\nreturn {\n  message: 'Cut deleted successfully',\n  unassignedLocations: unassignedCount.count,\n};\n

Cascade Effects: - Locations: Unassigned (cutId set to null), NOT deleted - Shifts: Deleted (shifts are cut-specific, meaningless without cut) - Canvass Sessions: Closed/abandoned (sessions reference cutId)

"},{"location":"v2/frontend/pages/admin/cuts-page/#import-cuts-from-geojson","title":"Import Cuts from GeoJSON","text":"

Request:

const formData = new FormData();\nformData.append('file', geoJsonFile);  // File object from <input type=\"file\">\n\nconst { data } = await api.post<{\n  message: string;\n  importedCuts: number;\n  totalLocations: number;\n}>('/cuts/import', formData, {\n  headers: { 'Content-Type': 'multipart/form-data' },\n});\n

Request Body: - file (File): GeoJSON file (FeatureCollection with Polygon features)

Expected GeoJSON Format:

{\n  \"type\": \"FeatureCollection\",\n  \"features\": [\n    {\n      \"type\": \"Feature\",\n      \"geometry\": {\n        \"type\": \"Polygon\",\n        \"coordinates\": [\n          [\n            [-75.69602, 45.42153],\n            [-75.69102, 45.42153],\n            [-75.69102, 45.41653],\n            [-75.69602, 45.41653],\n            [-75.69602, 45.42153]\n          ]\n        ]\n      },\n      \"properties\": {\n        \"name\": \"Downtown Core\",\n        \"description\": \"High-density residential area\",\n        \"color\": \"#3498db\"\n      }\n    },\n    {\n      \"type\": \"Feature\",\n      \"geometry\": {\n        \"type\": \"Polygon\",\n        \"coordinates\": [[...]]\n      },\n      \"properties\": {\n        \"name\": \"Riverside District\",\n        \"description\": null,\n        \"color\": \"#e74c3c\"\n      }\n    }\n  ]\n}\n

Response (200 OK):

{\n  \"message\": \"Imported 2 cuts with 79 total locations\",\n  \"importedCuts\": 2,\n  \"totalLocations\": 79,\n  \"details\": [\n    {\n      \"cutId\": \"cut_abc123\",\n      \"name\": \"Downtown Core\",\n      \"locationCount\": 47\n    },\n    {\n      \"cutId\": \"cut_def456\",\n      \"name\": \"Riverside District\",\n      \"locationCount\": 32\n    }\n  ]\n}\n

Error Response (400 Bad Request) - Invalid GeoJSON:

{\n  \"error\": \"Validation Error\",\n  \"message\": \"Invalid GeoJSON format. Expected FeatureCollection with Polygon features.\"\n}\n

Backend Workflow:

// 1. Parse GeoJSON file\nconst geoJson = JSON.parse(fileContent);\nif (geoJson.type !== 'FeatureCollection') {\n  throw new Error('Expected GeoJSON FeatureCollection');\n}\n\n// 2. Validate features\nconst polygonFeatures = geoJson.features.filter(\n  (f) => f.geometry.type === 'Polygon'\n);\nif (polygonFeatures.length === 0) {\n  throw new Error('No Polygon features found in GeoJSON');\n}\n\n// 3. Import each feature as a Cut\nconst importResults = [];\nfor (const feature of polygonFeatures) {\n  const cut = await prisma.cut.create({\n    data: {\n      name: feature.properties.name || 'Untitled Cut',\n      description: feature.properties.description || null,\n      color: feature.properties.color || '#3498db',\n      geometry: feature.geometry as unknown as Prisma.InputJsonValue,\n    },\n  });\n\n  // 4. Assign locations to cut\n  const locationCount = await assignLocationsToCut(cut.id, feature.geometry);\n\n  importResults.push({\n    cutId: cut.id,\n    name: cut.name,\n    locationCount,\n  });\n}\n\n// 5. Return summary\nreturn {\n  message: `Imported ${importResults.length} cuts with ${totalLocations} total locations`,\n  importedCuts: importResults.length,\n  totalLocations,\n  details: importResults,\n};\n
"},{"location":"v2/frontend/pages/admin/cuts-page/#export-cut-to-geojson","title":"Export Cut to GeoJSON","text":"

Request:

const cutId = 'cut_abc123';\nconst { data } = await api.get<GeoJSON.Feature>(`/cuts/${cutId}/export`);\n\n// Convert to JSON string and download\nconst blob = new Blob([JSON.stringify(data, null, 2)], {\n  type: 'application/geo+json',\n});\nconst url = URL.createObjectURL(blob);\nconst a = document.createElement('a');\na.href = url;\na.download = `cut-${data.properties.name}-${cutId}.geojson`;\na.click();\nURL.revokeObjectURL(url);\n

Response (200 OK):

{\n  \"type\": \"Feature\",\n  \"geometry\": {\n    \"type\": \"Polygon\",\n    \"coordinates\": [\n      [\n        [-75.69602, 45.42153],\n        [-75.69102, 45.42153],\n        [-75.69102, 45.41653],\n        [-75.69602, 45.41653],\n        [-75.69602, 45.42153]\n      ]\n    ]\n  },\n  \"properties\": {\n    \"id\": \"cut_abc123\",\n    \"name\": \"Downtown Core\",\n    \"description\": \"High-density residential area\",\n    \"color\": \"#3498db\",\n    \"locationCount\": 47,\n    \"createdAt\": \"2026-01-15T10:30:00.000Z\",\n    \"updatedAt\": \"2026-01-15T10:30:00.000Z\"\n  }\n}\n

GeoJSON Feature Structure: - type: \"Feature\" (GeoJSON standard) - geometry: Polygon geometry with coordinates - properties: Cut metadata including location count, timestamps

"},{"location":"v2/frontend/pages/admin/cuts-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#complete-cut-creation-flow-map-view","title":"Complete Cut Creation Flow (Map View)","text":"
const handleSaveCut = async (polygon: LatLng[], formValues: { name: string; description?: string; color: string }) => {\n  try {\n    // 1. Convert Leaflet LatLng array to GeoJSON coordinates\n    const coordinates = polygon.map((point) => [point.lng, point.lat]);\n\n    // 2. Close polygon (first point = last point)\n    if (\n      coordinates[0][0] !== coordinates[coordinates.length - 1][0] ||\n      coordinates[0][1] !== coordinates[coordinates.length - 1][1]\n    ) {\n      coordinates.push(coordinates[0]);\n    }\n\n    // 3. Create GeoJSON geometry\n    const geometry: GeoJSON.Polygon = {\n      type: 'Polygon',\n      coordinates: [coordinates],\n    };\n\n    // 4. Validate polygon (minimum 3 vertices)\n    if (coordinates.length < 4) {  // 4 including closing point\n      message.error('Polygon must have at least 3 vertices');\n      return;\n    }\n\n    // 5. Create cut via API\n    const { data } = await api.post<{\n      message: string;\n      cut: Cut;\n      locationCount: number;\n    }>('/cuts', {\n      name: formValues.name,\n      description: formValues.description || null,\n      color: formValues.color,\n      geometry,\n    });\n\n    message.success(data.message);\n\n    // 6. Refresh cuts on map\n    await loadCuts();\n\n    // 7. Reset drawing mode\n    setDrawingMode(false);\n  } catch (error) {\n    if (axios.isAxiosError(error) && error.response?.status === 400) {\n      message.error('Invalid polygon geometry. Ensure polygon is closed and does not self-intersect.');\n    } else {\n      message.error('Failed to create cut');\n    }\n  }\n};\n

Key Steps: 1. Convert Leaflet LatLng objects to [lng, lat] arrays (GeoJSON format) 2. Ensure polygon is closed (first point = last point) 3. Wrap coordinates in GeoJSON Polygon structure 4. Validate minimum vertex count (3 vertices + 1 closing point = 4 coordinates) 5. Send POST request with geometry + metadata 6. Refresh map to show new polygon overlay 7. Exit drawing mode to allow normal map interaction

"},{"location":"v2/frontend/pages/admin/cuts-page/#geojson-import-flow","title":"GeoJSON Import Flow","text":"
const handleImportGeoJSON = async (file: File) => {\n  setImporting(true);\n  try {\n    // 1. Create FormData\n    const formData = new FormData();\n    formData.append('file', file);\n\n    // 2. Upload to backend\n    const { data } = await api.post<{\n      message: string;\n      importedCuts: number;\n      totalLocations: number;\n      details: Array<{ cutId: string; name: string; locationCount: number }>;\n    }>('/cuts/import', formData, {\n      headers: { 'Content-Type': 'multipart/form-data' },\n    });\n\n    // 3. Show detailed success message\n    message.success(data.message);\n\n    // 4. Log import details\n    console.log('Import details:', data.details);\n\n    // 5. Refresh table\n    await loadCuts();\n  } catch (error) {\n    if (axios.isAxiosError(error) && error.response?.status === 400) {\n      message.error('Invalid GeoJSON format. Expected FeatureCollection with Polygon features.');\n    } else {\n      message.error('Failed to import GeoJSON');\n    }\n  } finally {\n    setImporting(false);\n  }\n};\n

Error Handling: - 400 Bad Request: Invalid GeoJSON format (not FeatureCollection, no Polygon features) - 500 Internal Server Error: Server error during import (e.g., database error, polygon validation failure)

"},{"location":"v2/frontend/pages/admin/cuts-page/#geojson-export-flow","title":"GeoJSON Export Flow","text":"
const handleExportGeoJSON = async (cut: Cut) => {\n  try {\n    // 1. Fetch cut with geometry\n    const { data } = await api.get<GeoJSON.Feature>(`/cuts/${cut.id}/export`);\n\n    // 2. Convert to JSON string (pretty-printed)\n    const geoJsonString = JSON.stringify(data, null, 2);\n\n    // 3. Create Blob\n    const blob = new Blob([geoJsonString], {\n      type: 'application/geo+json',\n    });\n\n    // 4. Create download link\n    const url = URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = `cut-${cut.name.replace(/\\s+/g, '-').toLowerCase()}-${cut.id}.geojson`;\n\n    // 5. Trigger download\n    document.body.appendChild(a);\n    a.click();\n\n    // 6. Cleanup\n    document.body.removeChild(a);\n    URL.revokeObjectURL(url);\n\n    message.success(`Exported \"${cut.name}\" to GeoJSON`);\n  } catch (error) {\n    message.error('Failed to export GeoJSON');\n  }\n};\n

File Naming: - Pattern: cut-{name}-{id}.geojson - Example: cut-downtown-core-cut_abc123.geojson - Spaces in name replaced with hyphens - Lowercase for consistency

"},{"location":"v2/frontend/pages/admin/cuts-page/#delete-with-cascade-warning","title":"Delete with Cascade Warning","text":"
const handleDeleteConfirm = (cut: Cut) => {\n  Modal.confirm({\n    title: 'Delete Cut',\n    content: (\n      <div>\n        <p>Are you sure you want to delete the cut <strong>\"{cut.name}\"</strong>?</p>\n        <p style={{ marginTop: 8, color: '#ff4d4f' }}>\n          <WarningOutlined /> This will:\n        </p>\n        <ul style={{ marginTop: 4, paddingLeft: 20 }}>\n          <li>Unassign all {cut.locationCount} locations from this cut</li>\n          <li>Delete all associated shifts</li>\n          <li>Close any active canvass sessions in this cut</li>\n        </ul>\n        <p style={{ marginTop: 8 }}>Locations will NOT be deleted (only unassigned).</p>\n      </div>\n    ),\n    okText: 'Delete',\n    okType: 'danger',\n    cancelText: 'Cancel',\n    width: 520,\n    onOk: async () => {\n      try {\n        const { data } = await api.delete<{\n          message: string;\n          unassignedLocations: number;\n        }>(`/cuts/${cut.id}`);\n\n        message.success(`Cut deleted successfully. ${data.unassignedLocations} locations unassigned.`);\n\n        // Refresh both table and map views\n        await loadCuts();\n      } catch (error) {\n        message.error('Failed to delete cut');\n      }\n    },\n  });\n};\n

Enhanced Confirmation: - Shows cut name for clarity - Lists all cascade effects (unassign locations, delete shifts, close sessions) - Clarifies that locations are NOT deleted (only unassigned) - Uses danger button styling - Wider modal (520px) to accommodate detailed content

"},{"location":"v2/frontend/pages/admin/cuts-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#lazy-map-loading","title":"Lazy Map Loading","text":"

Map view only loads when tab is active:

useEffect(() => {\n  if (activeTab === 'map') {\n    // Load cuts for map visualization\n    loadCutsForMap();\n  }\n}, [activeTab]);\n

Benefits: - Faster initial page load: Leaflet map library not loaded until needed - Reduced memory: Map tiles not downloaded until Map tab clicked - Better UX: Table view loads instantly without map overhead

"},{"location":"v2/frontend/pages/admin/cuts-page/#server-side-pagination","title":"Server-Side Pagination","text":"

Table uses server-side pagination to handle large cut datasets:

const { data } = await api.get('/cuts', {\n  params: {\n    page: pagination.current,\n    limit: pagination.pageSize,\n    search,\n  },\n});\n

Scalability: - Works efficiently with 10 to 1,000+ cuts - Only fetches current page (10-100 items) - Backend applies search filter before pagination

"},{"location":"v2/frontend/pages/admin/cuts-page/#debounced-search-300ms","title":"Debounced Search (300ms)","text":"

Prevents API spam during typing:

searchTimerRef.current = setTimeout(() => {\n  setSearch(value);\n}, 300);\n

Performance Impact: - Without debounce: Typing \"Downtown Core\" (13 chars) = 13 API calls - With 300ms debounce: Typing \"Downtown Core\" = 1 API call - 92% reduction in API requests

"},{"location":"v2/frontend/pages/admin/cuts-page/#polygon-simplification-future-enhancement","title":"Polygon Simplification (Future Enhancement)","text":"

For cuts with 1,000+ vertices (very detailed polygons), consider simplifying geometry:

// Using Turf.js library\nimport { simplify } from '@turf/simplify';\n\nconst simplifiedPolygon = simplify(polygon, {\n  tolerance: 0.0001,  // Degrees (~10 meters)\n  highQuality: false,\n});\n

Benefits: - Reduces GeoJSON payload size - Faster map rendering (fewer vertices to draw) - Maintains visual accuracy for canvassing purposes

"},{"location":"v2/frontend/pages/admin/cuts-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#mobile-table-layout","title":"Mobile Table Layout","text":"

Table adapts to mobile viewports:

{\n  title: 'Description',\n  dataIndex: 'description',\n  responsive: ['md'],  // Hidden on mobile\n  ellipsis: true,\n  render: (text) => text || '\u2014',\n},\n{\n  title: 'Location Count',\n  dataIndex: 'locationCount',\n  responsive: ['sm'],  // Visible on tablet+\n  render: (count) => <Tag>{count} locations</Tag>,\n}\n

Mobile Columns (xs): - Name (visible) - Color (visible) - Actions (visible, wrapped)

Tablet Columns (sm+): - Name + Color + Location Count + Actions

Desktop Columns (md+): - Name + Description + Color + Location Count + Created At + Actions

"},{"location":"v2/frontend/pages/admin/cuts-page/#full-screen-map-view","title":"Full-Screen Map View","text":"

Map view uses full available height:

<div style={{ height: 'calc(100vh - 200px)', width: '100%' }}>\n  <CutEditorMap\n    cuts={cuts}\n    onSaveCut={handleSaveCut}\n  />\n</div>\n

Calculation: - 100vh: Full viewport height - -200px: Subtract header (64px) + page title (48px) + segmented control (48px) + margins (40px) - Result: Map fills remaining vertical space

"},{"location":"v2/frontend/pages/admin/cuts-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

All interactive elements are keyboard-accessible:

Segmented Control: - Tab: Focus on segmented control - Arrow Keys: Switch between Table and Map tabs - Enter/Space: Activate selected tab

Table Navigation: - Tab: Move between action buttons (Edit, View, Export, Delete) - Enter/Space: Activate focused button - Arrow Keys: Navigate table rows

Map Drawing: - Escape: Cancel drawing mode - Enter: Complete polygon (after placing 3+ vertices) - Backspace: Remove last vertex

"},{"location":"v2/frontend/pages/admin/cuts-page/#screen-reader-support","title":"Screen Reader Support","text":"

All elements have proper ARIA labels:

Action Buttons:

<Button\n  icon={<EditOutlined />}\n  onClick={() => handleEdit(cut)}\n  aria-label={`Edit cut ${cut.name}`}\n>\n  Edit\n</Button>\n\n<Button\n  icon={<EnvironmentOutlined />}\n  onClick={() => handleViewLocations(cut)}\n  aria-label={`View ${cut.locationCount} locations in cut ${cut.name}`}\n>\n  View Locations\n</Button>\n

Color Preview:

<div\n  style={{ backgroundColor: cut.color }}\n  aria-label={`Cut color: ${cut.color}`}\n  role=\"img\"\n/>\n

"},{"location":"v2/frontend/pages/admin/cuts-page/#focus-indicators","title":"Focus Indicators","text":"

All interactive elements have visible focus states:

Buttons:

.ant-btn:focus {\n  outline: 2px solid #1890ff;\n  outline-offset: 2px;\n}\n

Segmented Control:

.ant-segmented-item:focus {\n  outline: 2px solid #1890ff;\n  outline-offset: 2px;\n}\n

"},{"location":"v2/frontend/pages/admin/cuts-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#polygon-wont-close","title":"Polygon Won't Close","text":"

Problem: Drawing polygon on map, clicked 5+ vertices, but polygon won't close automatically.

Diagnosis:

Check if first vertex is being clicked:

// Close detection radius: 10 pixels\nconst distanceToFirst = Math.sqrt(\n  Math.pow(clickX - firstVertexX, 2) + Math.pow(clickY - firstVertexY, 2)\n);\n\nif (distanceToFirst <= 10) {\n  // Close polygon\n}\n

Possible Causes:

  1. Click not close enough to first vertex:
  2. Must click within 10 pixels of first vertex marker
  3. First vertex marker may be small or obscured

  4. Double-click required:

  5. Some users expect double-click to close polygon
  6. Single-click on first vertex should work but may feel unintuitive

  7. Drawing mode not active:

  8. Forgot to click \"Draw New Cut\" button first
  9. Drawing mode indicator not visible

Solution:

  1. For close detection:
  2. Click directly on the blue circle marker (first vertex)
  3. Or double-click anywhere to force close polygon
  4. Ensure at least 3 vertices placed before closing

  5. Alternative closing methods:

  6. Press Enter key to close polygon (keyboard shortcut)
  7. Right-click and select \"Close Polygon\" from context menu (if implemented)

  8. Visual feedback:

  9. First vertex marker should pulse or highlight when hovering nearby (indicates close detection active)
  10. Drawing mode indicator should show \"Click first vertex to close\" text
"},{"location":"v2/frontend/pages/admin/cuts-page/#import-geojson-fails","title":"Import GeoJSON Fails","text":"

Problem: Click \"Import GeoJSON\", select file, get error: \"Invalid GeoJSON format\".

Diagnosis:

Check GeoJSON structure:

// Valid GeoJSON FeatureCollection\n{\n  \"type\": \"FeatureCollection\",\n  \"features\": [...]\n}\n\n// Invalid: Single Feature (missing FeatureCollection wrapper)\n{\n  \"type\": \"Feature\",\n  \"geometry\": {...}\n}\n

Possible Causes:

  1. Single Feature instead of FeatureCollection:
  2. GeoJSON file contains single Feature, not FeatureCollection
  3. Backend expects FeatureCollection with multiple features

  4. Non-Polygon geometries:

  5. GeoJSON contains Point, LineString, or MultiPolygon features
  6. Backend only supports Polygon geometry type

  7. Missing required properties:

  8. Feature properties don't include \"name\" field
  9. Backend requires name to create cut

  10. Invalid JSON syntax:

  11. Trailing commas, missing quotes, incorrect brackets
  12. JSON parser cannot read file

Solution:

  1. For single Feature:
  2. Wrap in FeatureCollection:

    {\n  \"type\": \"FeatureCollection\",\n  \"features\": [\n    {\n      \"type\": \"Feature\",\n      \"geometry\": {...},\n      \"properties\": {...}\n    }\n  ]\n}\n

  3. For non-Polygon geometries:

  4. Convert Point/LineString to Polygon using GIS software (QGIS, ArcGIS)
  5. Or manually edit GeoJSON to create Polygon boundaries

  6. For missing properties:

  7. Add \"name\" property to each Feature:

    \"properties\": {\n  \"name\": \"Untitled Cut\",\n  \"description\": \"\",\n  \"color\": \"#3498db\"\n}\n

  8. For invalid JSON:

  9. Validate JSON syntax using online tool (jsonlint.com)
  10. Fix any syntax errors before importing
"},{"location":"v2/frontend/pages/admin/cuts-page/#locations-not-appearing-in-cut","title":"Locations Not Appearing in Cut","text":"

Problem: Create cut polygon, success message says \"Cut created successfully with 0 locations\", but there should be locations within boundary.

Diagnosis:

Check location coordinates vs. polygon coordinates:

// Example: Location at [45.42, -75.69] (lat, lng)\n// Polygon coordinates: [[-75.69, 45.42], ...] (lng, lat)\n\n// Are coordinates in correct order?\n// Is location actually within polygon boundary?\n

Possible Causes:

  1. Coordinate order confusion:
  2. Location stored as [lat, lng] but polygon uses [lng, lat] (GeoJSON standard)
  3. Point-in-polygon algorithm receives wrong coordinate order

  4. Locations not geocoded:

  5. Locations have null latitude/longitude values
  6. Cannot check if point is in polygon without coordinates

  7. Polygon too small:

  8. Drew very small polygon that doesn't actually contain any location markers
  9. Zoom in on map to verify polygon size vs. location density

  10. Precision issues:

  11. Location coordinates have low precision (e.g., rounded to 2 decimal places)
  12. Polygon boundary is at edge of location, but point-in-polygon check fails due to rounding

Solution:

  1. For coordinate order:
  2. Verify backend point-in-polygon function uses correct order:

    isPointInPolygon(\n  [location.longitude, location.latitude],  // [lng, lat] order\n  polygon.coordinates[0]\n);\n

  3. For missing coordinates:

  4. Run geocoding on locations before assigning to cut
  5. Navigate to LocationsPage, bulk geocode locations, then create cut

  6. For small polygons:

  7. Zoom in on map to see location markers
  8. Draw larger polygon that clearly encompasses location clusters
  9. Use Location Count filter on LocationsPage to verify locations exist in area

  10. For precision issues:

  11. Use higher precision coordinates (6+ decimal places = ~0.1 meter accuracy)
  12. Slightly expand polygon boundary to account for rounding errors
"},{"location":"v2/frontend/pages/admin/cuts-page/#delete-cut-fails-with-constraint-error","title":"Delete Cut Fails with Constraint Error","text":"

Problem: Click \"Delete\" button, confirm deletion, get error: \"Failed to delete cut. Constraint violation.\"

Diagnosis:

Check database foreign key constraints:

-- Check for references to cut\nSELECT COUNT(*) FROM \"Shift\" WHERE \"cutId\" = 'cut_abc123';\nSELECT COUNT(*) FROM \"CanvassSession\" WHERE \"cutId\" = 'cut_abc123';\n

Possible Causes:

  1. Active shifts:
  2. Shift records reference this cutId
  3. Foreign key constraint prevents deletion

  4. Active canvass sessions:

  5. CanvassSession records reference this cutId
  6. Sessions must be closed/deleted before cut can be deleted

  7. Database migration issue:

  8. Foreign key constraints not set to CASCADE
  9. Deletion of parent record (Cut) should cascade to child records (Shift, CanvassSession)

Solution:

  1. For active shifts:
  2. Navigate to /app/map/shifts
  3. Filter by cut name
  4. Delete all shifts in this cut
  5. Return to CutsPage and retry delete

  6. For active sessions:

  7. Navigate to /app/canvass/dashboard
  8. Find active sessions in this cut
  9. Close or abandon sessions
  10. Return to CutsPage and retry delete

  11. For migration issue (developer fix):

  12. Update Prisma schema to add cascade delete:
    model Shift {\n  cutId String?\n  cut   Cut?    @relation(fields: [cutId], references: [id], onDelete: Cascade)\n}\n\nmodel CanvassSession {\n  cutId String?\n  cut   Cut?    @relation(fields: [cutId], references: [id], onDelete: Cascade)\n}\n
  13. Run migration: npx prisma migrate dev
"},{"location":"v2/frontend/pages/admin/cuts-page/#related-documentation","title":"Related Documentation","text":"
  • Map Module Overview \u2014 Geographic mapping feature set
  • Cuts Backend Module \u2014 Backend cut service and API
  • Cuts API Reference \u2014 API endpoint documentation
  • Spatial Utils \u2014 Point-in-polygon algorithm
  • LocationsPage \u2014 Location management (references cuts)
  • ShiftsPage \u2014 Shift scheduling (references cuts)
  • Canvass Dashboard \u2014 Canvassing overview (uses cuts)
  • CutEditorMap Component \u2014 Map drawing component
  • User Guide: Map Organizer \u2014 Cut management workflow
  • GeoJSON Specification \u2014 GeoJSON format reference
  • Troubleshooting: GIS Issues \u2014 Geographic data troubleshooting
"},{"location":"v2/frontend/pages/admin/dashboard-page/","title":"DashboardPage","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#overview","title":"Overview","text":"

The DashboardPage serves as the landing page for authenticated admin users after login. It provides a high-level overview of key metrics across all modules (Users, Campaigns, Locations, Emails) using statistic cards. Currently displays placeholder values with a notice that full analytics are coming soon.

Route: /app Component: admin/src/pages/DashboardPage.tsx (67 lines) Auth Required: Yes (any authenticated admin role) Layout: AppLayout

"},{"location":"v2/frontend/pages/admin/dashboard-page/#screenshot","title":"Screenshot","text":"

[Screenshot: Dashboard with 4 statistic cards in a responsive grid showing Total Users, Active Campaigns, Map Locations, and Emails Sent. Below the cards is an info alert explaining that analytics are coming soon. The page shows a personalized welcome message \"Welcome, [User Name]\" at the top.]

"},{"location":"v2/frontend/pages/admin/dashboard-page/#features","title":"Features","text":"
  • Personalized greeting \u2014 Shows \"Welcome, [User Name]\" if user has a name set
  • Quick metrics overview \u2014 4 statistic cards with icons:
  • Total Users (TeamOutlined icon)
  • Active Campaigns (SendOutlined icon)
  • Map Locations (EnvironmentOutlined icon)
  • Emails Sent (MailOutlined icon)
  • Responsive grid layout \u2014 Cards adapt to screen size (xs: full width, sm: 2 columns, lg: 4 columns)
  • Placeholder state \u2014 Currently shows \"--\" for all metrics with info alert
  • Future-ready \u2014 Structure prepared for real-time statistics integration
"},{"location":"v2/frontend/pages/admin/dashboard-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#viewing-dashboard-current-state","title":"Viewing Dashboard (Current State)","text":"
  1. User logs in and is redirected to /app
  2. Dashboard loads with personalized greeting
  3. Four metric cards display with placeholder values (\"--\")
  4. Info alert explains that analytics are coming soon
"},{"location":"v2/frontend/pages/admin/dashboard-page/#planned-workflow-future-enhancement","title":"Planned Workflow (Future Enhancement)","text":"
  1. User logs in and is redirected to /app
  2. Dashboard fetches real-time statistics from API
  3. Metric cards populate with actual values
  4. Charts and graphs display below cards (planned)
  5. Recent activity feed shows latest actions (planned)
"},{"location":"v2/frontend/pages/admin/dashboard-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  • Typography.Title \u2014 Page title with greeting
  • Row, Col \u2014 Responsive grid layout
  • Row with gutter: [16, 16] (horizontal, vertical)
  • Col breakpoints: xs={24} sm={12} lg={6} (responsive card sizing)
  • Card \u2014 Container for each statistic
  • Statistic \u2014 Numeric display with title, value, prefix icon
  • Alert \u2014 Info message about future analytics
  • Icons \u2014 Ant Design icons for visual clarity
  • TeamOutlined (users)
  • SendOutlined (campaigns)
  • EnvironmentOutlined (locations)
  • MailOutlined (emails)
"},{"location":"v2/frontend/pages/admin/dashboard-page/#component-structure","title":"Component Structure","text":"
<>\n  <Title level={4}>Welcome{user?.name ? `, ${user.name}` : ''}</Title>\n\n  <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>\n    <Col xs={24} sm={12} lg={6}>\n      <Card>\n        <Statistic title=\"Total Users\" value=\"--\" prefix={<TeamOutlined />} />\n      </Card>\n    </Col>\n    {/* 3 more similar cards */}\n  </Row>\n\n  <Alert\n    message=\"Dashboard analytics coming soon\"\n    description=\"Statistics and charts will be populated as additional modules are implemented.\"\n    type=\"info\"\n    showIcon\n  />\n</>\n
"},{"location":"v2/frontend/pages/admin/dashboard-page/#layout-breakpoints","title":"Layout Breakpoints","text":"Screen Size Columns Cards Per Row xs (< 576px) 24/24 1 sm (\u2265 576px) 12/24 2 lg (\u2265 992px) 6/24 4"},{"location":"v2/frontend/pages/admin/dashboard-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#zustand-stores-used","title":"Zustand Stores Used","text":"
  • auth.store \u2014 Accesses current user data
  • user \u2014 User object with name field for personalized greeting
import { useAuthStore } from '@/stores/auth.store';\n\nconst { user } = useAuthStore();\n
"},{"location":"v2/frontend/pages/admin/dashboard-page/#local-state","title":"Local State","text":"

None \u2014 Component is stateless, reads user from auth store only.

"},{"location":"v2/frontend/pages/admin/dashboard-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#current-implementation","title":"Current Implementation","text":"

No API calls \u2014 displays placeholder values.

"},{"location":"v2/frontend/pages/admin/dashboard-page/#planned-api-integration","title":"Planned API Integration","text":"

GET /api/dashboard/stats \u2014 Fetch dashboard statistics

Planned request:

const { data } = await api.get('/api/dashboard/stats');\n\n// Expected response:\n{\n  totalUsers: 45,\n  activeUsers: 32,\n  activeCampaigns: 8,\n  totalCampaigns: 12,\n  mapLocations: 1250,\n  emailsSent: 3420,\n  emailsQueued: 15,\n  queuedJobs: 3,\n  recentActivity: [...]\n}\n

"},{"location":"v2/frontend/pages/admin/dashboard-page/#code-example","title":"Code Example","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#adding-real-statistics-future-enhancement","title":"Adding Real Statistics (Future Enhancement)","text":"
import { useState, useEffect } from 'react';\nimport { api } from '@/lib/api';\n\nexport default function DashboardPage() {\n  const { user } = useAuthStore();\n  const [stats, setStats] = useState({\n    totalUsers: 0,\n    activeCampaigns: 0,\n    mapLocations: 0,\n    emailsSent: 0,\n  });\n  const [loading, setLoading] = useState(true);\n\n  useEffect(() => {\n    const fetchStats = async () => {\n      try {\n        const { data } = await api.get('/api/dashboard/stats');\n        setStats(data);\n      } catch (err) {\n        message.error('Failed to load dashboard statistics');\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    fetchStats();\n  }, []);\n\n  return (\n    <>\n      <Title level={4}>Welcome{user?.name ? `, ${user.name}` : ''}</Title>\n\n      <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>\n        <Col xs={24} sm={12} lg={6}>\n          <Card>\n            <Statistic\n              title=\"Total Users\"\n              value={stats.totalUsers}\n              loading={loading}\n              prefix={<TeamOutlined />}\n            />\n          </Card>\n        </Col>\n        {/* ... other cards */}\n      </Row>\n    </>\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/dashboard-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#mobile-576px","title":"Mobile (< 576px)","text":"
  • Cards stack vertically (full width)
  • Greeting text wraps naturally
  • Alert description wraps
"},{"location":"v2/frontend/pages/admin/dashboard-page/#tablet-576px-992px","title":"Tablet (576px - 992px)","text":"
  • Cards display 2 per row
  • Even spacing maintained
"},{"location":"v2/frontend/pages/admin/dashboard-page/#desktop-992px","title":"Desktop (\u2265 992px)","text":"
  • Cards display 4 per row
  • Optimal for wide screens
  • All content visible without scrolling
"},{"location":"v2/frontend/pages/admin/dashboard-page/#accessibility","title":"Accessibility","text":"
  • Semantic HTML \u2014 Proper heading hierarchy (h4 for title)
  • Icon labels \u2014 Statistic titles provide text alternative to icons
  • Color contrast \u2014 Default Ant Design theme ensures WCAG AA compliance
  • Keyboard navigation \u2014 All interactive elements focusable
"},{"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":"
  • Fast initial render \u2014 No API calls, minimal DOM
  • Small bundle \u2014 Only imports necessary Ant Design components
  • No re-renders \u2014 Stateless, no local state changes
"},{"location":"v2/frontend/pages/admin/dashboard-page/#planned-optimizations","title":"Planned Optimizations","text":"
  • Memoization \u2014 Use useMemo for derived stats
  • Caching \u2014 Cache dashboard stats with 5-minute expiry
  • Skeleton loading \u2014 Show loading skeleton during fetch
import { Skeleton } from 'antd';\n\n{loading ? (\n  <Card>\n    <Skeleton.Input active size=\"small\" style={{ width: 100 }} />\n    <Skeleton.Input active size=\"large\" style={{ width: 60, marginTop: 8 }} />\n  </Card>\n) : (\n  <Card>\n    <Statistic title=\"Total Users\" value={stats.totalUsers} />\n  </Card>\n)}\n
"},{"location":"v2/frontend/pages/admin/dashboard-page/#future-enhancements","title":"Future Enhancements","text":"
  1. Real-time statistics \u2014 WebSocket updates for live metrics
  2. Charts and graphs \u2014 Trend visualizations (Chart.js or Recharts)
  3. Recent activity feed \u2014 List of latest actions across all modules
  4. Quick actions \u2014 Buttons for common tasks (Create Campaign, Add User, etc.)
  5. Module-specific widgets \u2014 Expandable cards with detailed stats
  6. Date range filter \u2014 View metrics for custom time periods
  7. Export dashboard \u2014 PDF report generation
"},{"location":"v2/frontend/pages/admin/dashboard-page/#related-documentation","title":"Related Documentation","text":"
  • Auth Module \u2014 Authentication system
  • Users Module \u2014 User statistics
  • Campaigns Module \u2014 Campaign statistics
  • Locations Module \u2014 Location statistics
  • AppLayout Component \u2014 Layout wrapper
  • Auth Store \u2014 Authentication state
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/","title":"DataQualityDashboardPage","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#overview","title":"Overview","text":"

File: admin/src/pages/DataQualityDashboardPage.tsx Route: /app/map/data-quality Role Requirements: SUPER_ADMIN, MAP_ADMIN

DataQualityDashboardPage is a specialized dashboard for monitoring geocoding data quality across the location database. It displays comprehensive statistics about total locations, geocoding success rates, confidence levels, provider distribution, and building type breakdown. The page features auto-refresh every 30 seconds, responsive grid layout, and color-coded statistics cards for quick visual assessment of data quality.

The page provides insights into: - Total locations in database - Geocoding status (geocoded vs. ungeocoded) - Confidence levels (high/medium/low confidence, manual/none) - Provider distribution (Nominatim, ArcGIS, Photon, Google, Manual, etc.) - Building types (Single Family, Multi-Unit, Mixed Use, Commercial)

Key Features: - Auto-refresh every 30 seconds (no manual intervention needed) - Color-coded statistics (green=good, red=warning, gray=neutral) - Responsive grid layout (1-4 columns depending on screen size) - Refresh button for manual updates - Geocoded percentage calculation - Average confidence score with color thresholds

Key Components: - Ant Design Statistic cards for all metrics - Row/Col grid layout for responsive design - Typography for section headers - Auto-refresh interval with cleanup

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#screenshot","title":"Screenshot","text":"

[Screenshot: DataQualityDashboardPage showing four rows of statistics cards: 1) Overview row with Total Locations (blue), Geocoded with percentage (green), Ungeocoded (red if > 0), Average Confidence with color (green \u226585%, yellow 60-84%, red <60%); 2) Geocoding Confidence row with High Confidence (green, \u226585%), Medium Confidence (yellow, 60-84%), Low Confidence (red, <60%), Manual/None (gray); 3) Provider Distribution row showing counts for Nominatim, ArcGIS, Photon, Google, Manual; 4) Building Types row with Single Family (blue), Multi-Unit (green), Mixed Use (yellow), Commercial (purple). Refresh button in header.]

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#core-features","title":"Core Features","text":"
  1. Overview Statistics (4 cards)
  2. Total Locations: Count of all locations in database (blue)
  3. Geocoded: Count + percentage of geocoded locations (green)
  4. Ungeocoded: Count of locations without coordinates (red if > 0, gray if 0)
  5. Average Confidence: Percentage score with color-coded threshold (green \u226585%, yellow 60-84%, red <60%)

  6. Geocoding Confidence Breakdown (4 cards)

  7. High Confidence: Count of locations with \u226585% confidence (green)
  8. Medium Confidence: Count of locations with 60-84% confidence (yellow)
  9. Low Confidence: Count of locations with <60% confidence (red)
  10. Manual/None: Count of locations with no confidence score (gray)

  11. Provider Distribution (Dynamic cards)

  12. One card per geocoding provider (Nominatim, ArcGIS, Photon, Google, Manual, etc.)
  13. Capitalized provider names
  14. Count of locations geocoded by each provider
  15. Dynamic grid layout (adapts to number of providers)

  16. Building Type Distribution (4 cards)

  17. Single Family: Count of single-family residences (blue)
  18. Multi-Unit: Count of multi-unit residential buildings (green)
  19. Mixed Use: Count of mixed-use properties (yellow)
  20. Commercial: Count of commercial properties (purple)

  21. Auto-Refresh

  22. Refreshes every 30 seconds automatically
  23. Interval set with setInterval, cleaned up on unmount
  24. No loading spinner on auto-refresh (seamless updates)

  25. Manual Refresh

  26. Refresh button in page header
  27. Loading state during refresh
  28. Fetches latest statistics from API

  29. Responsive Grid Layout

  30. Desktop (\u2265768px): 4 columns per row
  31. Tablet (\u2265576px): 2 columns per row
  32. Mobile (<576px): 1 column per row
  33. Consistent gap (16px horizontal, 16px vertical)

  34. Color-Coded Statistics

  35. Green (#52c41a): Good/high confidence/geocoded
  36. Red (#ff4d4f): Warning/low confidence/ungeocoded
  37. Yellow (#faad14): Medium confidence
  38. Blue (#1890ff): Neutral/informational
  39. Gray (#8c8c8c): Neutral/none
  40. Purple (#722ed1): Commercial building type
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#viewing-data-quality-overview","title":"Viewing Data Quality Overview","text":"
  1. Navigate to page: Admin sidebar \u2192 Map \u2192 Data Quality
  2. Page loads: Initial statistics fetched and displayed
  3. Review overview cards:
  4. Check total locations count
  5. Verify geocoded percentage (aim for > 90%)
  6. Check if any ungeocoded locations (red warning)
  7. Review average confidence score (aim for \u226585%)
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#interpreting-confidence-levels","title":"Interpreting Confidence Levels","text":"

High Confidence (\u226585%): - Indicates accurate geocoding with precise coordinates - Green color = good data quality - Goal: Most locations should be in this category

Medium Confidence (60-84%): - Indicates acceptable geocoding but less precise - Yellow color = acceptable but could improve - Consider manual review or re-geocoding

Low Confidence (<60%): - Indicates poor geocoding accuracy - Red color = data quality issue - Action: Re-geocode with different provider or manually verify

Manual/None: - Manually entered coordinates or no confidence score - Gray color = neutral (may be accurate if manually verified)

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#monitoring-provider-performance","title":"Monitoring Provider Performance","text":"
  1. Check provider distribution cards:
  2. See which providers are most used
  3. Identify dominant provider (e.g., Nominatim: 8000, Google: 2000)
  4. Correlate with confidence levels:
  5. If high confidence count matches dominant provider, good sign
  6. If low confidence count high, may indicate provider issues
  7. Consider provider switching:
  8. Use LocationsPage to re-geocode low-confidence locations with different provider
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#reviewing-building-types","title":"Reviewing Building Types","text":"
  1. Check building type distribution:
  2. Single Family: Residential detached homes
  3. Multi-Unit: Apartments, condos, duplexes
  4. Mixed Use: Residential + commercial combo
  5. Commercial: Stores, offices, warehouses
  6. Verify data accuracy:
  7. Ensure building types match expected distribution for your area
  8. Flag anomalies (e.g., 0 single-family homes in suburban area)
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#using-auto-refresh","title":"Using Auto-Refresh","text":"
  1. Leave page open: Auto-refresh updates data every 30 seconds
  2. Monitor changes: Watch for new locations being added/geocoded
  3. No action needed: Data updates seamlessly without loading spinners
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#manual-refresh","title":"Manual Refresh","text":"
  1. Click Refresh button: In page header
  2. Loading state: Brief spinner or loading indicator
  3. Data updates: Latest statistics fetched from API
  4. Use case: Immediate update after bulk geocoding operation
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#overview-statistics-cards","title":"Overview Statistics Cards","text":"
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>\n  <Col xs={24} sm={12} md={6}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Total Locations\"\n        value={stats.total}\n        prefix={<EnvironmentOutlined />}\n        valueStyle={{ color: '#1890ff' }}\n      />\n    </Card>\n  </Col>\n  <Col xs={24} sm={12} md={6}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Geocoded\"\n        value={stats.geocoded}\n        suffix={\n          <Text type=\"secondary\" style={{ fontSize: 12 }}>\n            ({stats.total > 0 ? Math.round((stats.geocoded / stats.total) * 100) : 0}%)\n          </Text>\n        }\n        valueStyle={{ color: '#52c41a' }}\n      />\n    </Card>\n  </Col>\n  <Col xs={24} sm={12} md={6}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Ungeocoded\"\n        value={stats.ungeocoded}\n        valueStyle={{ color: stats.ungeocoded > 0 ? '#ff4d4f' : '#8c8c8c' }}\n        prefix={stats.ungeocoded > 0 ? <WarningOutlined /> : undefined}\n      />\n    </Card>\n  </Col>\n  <Col xs={24} sm={12} md={6}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Average Confidence\"\n        value={stats.confidence.average ?? 0}\n        suffix=\"%\"\n        valueStyle={{\n          color:\n            !stats.confidence.average ? '#8c8c8c'\n            : stats.confidence.average >= 85 ? '#52c41a'\n            : stats.confidence.average >= 60 ? '#faad14'\n            : '#ff4d4f',\n        }}\n      />\n    </Card>\n  </Col>\n</Row>\n

Responsive Grid: - xs={24}: Mobile (full width) - sm={12}: Tablet (2 columns, 50% width each) - md={6}: Desktop (4 columns, 25% width each)

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#confidence-level-cards","title":"Confidence Level Cards","text":"
<Row gutter={[12, 12]} style={{ marginBottom: 24 }}>\n  <Col xs={24} sm={12} md={6}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"High Confidence\"\n        value={stats.confidence.high}\n        prefix={<CheckCircleOutlined />}\n        suffix={<Text type=\"secondary\" style={{ fontSize: 12 }}>\u226585%</Text>}\n        valueStyle={{ color: '#52c41a' }}\n      />\n    </Card>\n  </Col>\n  <Col xs={24} sm={12} md={6}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Medium Confidence\"\n        value={stats.confidence.medium}\n        prefix={<InfoCircleOutlined />}\n        suffix={<Text type=\"secondary\" style={{ fontSize: 12 }}>60-84%</Text>}\n        valueStyle={{ color: '#faad14' }}\n      />\n    </Card>\n  </Col>\n  {/* Low confidence and Manual/None cards... */}\n</Row>\n
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#provider-distribution-cards","title":"Provider Distribution Cards","text":"
<Row gutter={[12, 12]} style={{ marginBottom: 24 }}>\n  {Object.entries(stats.providers).map(([provider, count]) => (\n    <Col xs={24} sm={12} md={6} key={provider}>\n      <Card size=\"small\">\n        <Statistic\n          title={provider.charAt(0).toUpperCase() + provider.slice(1)}\n          value={count}\n          valueStyle={{ fontSize: 18 }}\n        />\n      </Card>\n    </Col>\n  ))}\n</Row>\n

Dynamic Grid: Number of cards adapts to number of providers in response.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#building-type-cards","title":"Building Type Cards","text":"
<Row gutter={[12, 12]}>\n  <Col xs={24} sm={12} md={6}>\n    <Card size=\"small\">\n      <Statistic\n        title=\"Single Family\"\n        value={stats.buildingTypes.SINGLE_FAMILY}\n        valueStyle={{ color: '#1890ff' }}\n      />\n    </Card>\n  </Col>\n  {/* Multi-Unit, Mixed Use, Commercial cards... */}\n</Row>\n
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#local-state","title":"Local State","text":"
const [stats, setStats] = useState<LocationStats | null>(null);\nconst [loading, setLoading] = useState(true);\n
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#data-fetching","title":"Data Fetching","text":"
const loadStats = useCallback(async () => {\n  try {\n    const { data } = await api.get<LocationStats>('/map/locations/stats');\n    setStats(data);\n  } catch {\n    message.error('Failed to load data quality stats');\n  } finally {\n    setLoading(false);\n  }\n}, [message]);\n
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#auto-refresh-setup","title":"Auto-Refresh Setup","text":"
useEffect(() => {\n  loadStats();  // Initial load\n  const interval = setInterval(loadStats, 30000);  // Refresh every 30s\n  return () => clearInterval(interval);  // Cleanup on unmount\n}, [loadStats]);\n

Pattern: 1. Load data immediately on mount 2. Set up interval for 30-second refreshes 3. Clean up interval when component unmounts (prevents memory leaks)

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#endpoint-used","title":"Endpoint Used","text":"

GET /map/locations/stats - Fetch location statistics

const { data } = await api.get<LocationStats>('/map/locations/stats');\n

Response:

{\n  \"total\": 15234,\n  \"geocoded\": 14980,\n  \"ungeocoded\": 254,\n  \"confidence\": {\n    \"average\": 87.3,\n    \"high\": 13500,\n    \"medium\": 1200,\n    \"low\": 280,\n    \"none\": 254\n  },\n  \"providers\": {\n    \"nominatim\": 8500,\n    \"arcgis\": 3200,\n    \"photon\": 1800,\n    \"google\": 980,\n    \"manual\": 500\n  },\n  \"buildingTypes\": {\n    \"SINGLE_FAMILY\": 10500,\n    \"MULTI_UNIT\": 3200,\n    \"MIXED_USE\": 1000,\n    \"COMMERCIAL\": 534\n  }\n}\n

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#percentage-calculation","title":"Percentage Calculation","text":"
<Text type=\"secondary\" style={{ fontSize: 12 }}>\n  ({stats.total > 0 ? Math.round((stats.geocoded / stats.total) * 100) : 0}%)\n</Text>\n

Pattern: Round percentage to nearest integer, handle division by zero.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#conditional-color","title":"Conditional Color","text":"
valueStyle={{\n  color:\n    !stats.confidence.average ? '#8c8c8c'\n    : stats.confidence.average >= 85 ? '#52c41a'\n    : stats.confidence.average >= 60 ? '#faad14'\n    : '#ff4d4f',\n}}\n

Color Logic: - No average \u2192 Gray - \u226585% \u2192 Green - 60-84% \u2192 Yellow - <60% \u2192 Red

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#conditional-icon","title":"Conditional Icon","text":"
prefix={stats.ungeocoded > 0 ? <WarningOutlined /> : undefined}\n

Pattern: Show warning icon only if ungeocoded count > 0.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#auto-refresh-pattern","title":"Auto-Refresh Pattern","text":"
useEffect(() => {\n  loadStats();\n  const interval = setInterval(loadStats, 30000);\n  return () => clearInterval(interval);\n}, [loadStats]);\n

Best Practice: Always clean up intervals to prevent memory leaks.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#auto-refresh-cleanup","title":"Auto-Refresh Cleanup","text":"
return () => clearInterval(interval);\n

Why Important: Without cleanup, interval continues running after component unmounts, causing memory leaks and unnecessary API calls.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#usecallback-for-load-function","title":"useCallback for Load Function","text":"
const loadStats = useCallback(async () => { /* ... */ }, [message]);\n

Why: Prevents recreation of load function on every render, essential for stable useEffect dependency.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#efficient-percentage-calculation","title":"Efficient Percentage Calculation","text":"
Math.round((stats.geocoded / stats.total) * 100)\n

Math.round() is more efficient than .toFixed() for integer percentages.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#responsive-grid","title":"Responsive Grid","text":"
<Col xs={24} sm={12} md={6}>\n

Breakpoints: - Mobile (xs, < 576px): 24/24 = 100% width (1 column) - Tablet (sm, \u2265 576px): 12/24 = 50% width (2 columns) - Desktop (md, \u2265 768px): 6/24 = 25% width (4 columns)

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#padding-adjustment","title":"Padding Adjustment","text":"
<div style={{ padding: screens.md ? 24 : 16 }}>\n

Reduces padding on mobile to maximize screen space.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#statistic-titles","title":"Statistic Titles","text":"
<Statistic title=\"Total Locations\" />\n

Clear, descriptive titles for each metric.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#icon-text-combination","title":"Icon + Text Combination","text":"
<Statistic\n  prefix={<EnvironmentOutlined />}\n  value={stats.total}\n/>\n

Icons enhance visual communication but text labels provide meaning.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#suffix-explanations","title":"Suffix Explanations","text":"
<Statistic\n  suffix={<Text type=\"secondary\">\u226585%</Text>}\n/>\n

Explains threshold for confidence levels.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#statistics-not-loading","title":"Statistics Not Loading","text":"

Symptoms: - Loading spinner forever - Error message \"Failed to load data quality stats\"

Causes: 1. API server down 2. Database connection issue 3. Permission denied

Solutions:

# Check API logs\ndocker compose logs -f api | grep locations\n\n# Test endpoint\ncurl -H \"Authorization: Bearer <token>\" \\\n  http://localhost:4000/map/locations/stats\n\n# Check database\ndocker compose exec api npx prisma studio\n# Navigate to Location model, verify records exist\n

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#percentage-shows-0-but-locations-exist","title":"Percentage Shows 0% (but locations exist)","text":"

Cause: All locations ungeocoded (stats.geocoded === 0)

Expected Behavior: Percentage correctly shows 0% (not a bug)

Solution: Geocode locations using LocationsPage bulk geocoding feature.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#average-confidence-shows-0","title":"Average Confidence Shows 0%","text":"

Cause: No geocoded locations have confidence scores

Expected Behavior: Shows 0% and gray color

Solution: Confidence scores only populated during geocoding. Re-geocode locations to populate confidence.

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#auto-refresh-not-working","title":"Auto-Refresh Not Working","text":"

Symptoms: - Statistics never update automatically - Must manually click Refresh button

Causes: 1. Component unmounting and remounting (React Strict Mode in dev) 2. Interval cleared prematurely 3. Browser tab inactive (browser throttles timers)

Debug:

useEffect(() => {\n  console.log('Setting up auto-refresh');\n  loadStats();\n  const interval = setInterval(() => {\n    console.log('Auto-refreshing...');\n    loadStats();\n  }, 30000);\n  return () => {\n    console.log('Cleaning up interval');\n    clearInterval(interval);\n  };\n}, [loadStats]);\n

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#building-types-show-unexpected-zeros","title":"Building Types Show Unexpected Zeros","text":"

Cause: Building type not set for locations (optional field)

Expected Behavior: buildingTypes counts only locations with buildingType set

Solution: Update locations to set buildingType field (use LocationsPage).

"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#backend-integration","title":"Backend Integration","text":"
  • Locations Module - Service, schemas, routes
  • Locations API Reference - Full endpoint documentation
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#frontend-pages","title":"Frontend Pages","text":"
  • LocationsPage - Location CRUD and geocoding operations
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#features_1","title":"Features","text":"
  • Geocoding System - Multi-provider geocoding
  • Location Management - Location data management
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#user-guides","title":"User Guides","text":"
  • Admin Guide - Data Quality - Data quality workflows
  • Map Organizer Guide - Location management
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#troubleshooting_1","title":"Troubleshooting","text":"
  • Geocoding Issues - Geocoding troubleshooting
  • Database Issues - Database troubleshooting
"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#related-pages","title":"Related Pages","text":"
  • LocationsPage - Bulk geocoding operations
  • MapSettingsPage - Map configuration
"},{"location":"v2/frontend/pages/admin/docs-page/","title":"DocsPage","text":""},{"location":"v2/frontend/pages/admin/docs-page/#overview","title":"Overview","text":"

File: admin/src/pages/DocsPage.tsx Route: /app/docs Role Requirements: SUPER_ADMIN

DocsPage is a comprehensive documentation editor for Changemaker Lite's MkDocs documentation system. It provides a full-featured IDE-like experience with a file tree browser, Monaco code editor with syntax highlighting, live MkDocs preview, and an extensive MkDocs snippet system with 60+ predefined templates for formatting, headings, admonitions, code blocks, and content insertion.

The page offers three layout modes (split, editor-only, preview-only), collapsible file tree with search/filter, drag-to-resize panels, keyboard shortcuts (Ctrl+S to save), CRUD operations for files/folders, and a rich formatting toolbar with dropdown menus for quick content insertion.

Key Features: - Obsidian-style tight file tree with smooth hover effects - Monaco Editor with Markdown syntax highlighting - Split-pane layout with draggable dividers (tree, editor, preview) - Live MkDocs preview in iframe with auto-refresh on save - Custom right-click context menu with hierarchical snippet groups - Formatting toolbar with Bold, Italic, Strikethrough, Highlight, etc. - 60+ MkDocs snippets (formatting, headings, admonitions, code, insert elements) - File/folder creation, renaming, deletion via tree context menu - Filter/search across file tree with auto-expand - URL preview bar (production + localhost links above iframe) - MkDocs site building (SUPER_ADMIN only)

Key Components: - Ant Design Tree for file browser - Monaco Editor (@monaco-editor/react) for code editing - Custom snippet system with wrap/block/insert types - Keyboard bindings (Ctrl+B for bold, Ctrl+I for italic, Ctrl+S for save) - Three-panel resizable layout with localStorage persistence

"},{"location":"v2/frontend/pages/admin/docs-page/#screenshot","title":"Screenshot","text":"

[Screenshot: DocsPage showing three-panel layout: left sidebar with file tree (Obsidian-style, collapsible, filterable), center Monaco editor with dark theme and formatting toolbar above, right iframe showing live MkDocs preview with URL bar at top displaying production/localhost links. Top toolbar has layout mode buttons (Split/Editor/Preview), Save button, Refresh Preview, Open MkDocs, and Build buttons.]

"},{"location":"v2/frontend/pages/admin/docs-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/docs-page/#core-features","title":"Core Features","text":"
  1. Three-Panel Resizable Layout
  2. Left: File tree (160px - 400px width, draggable divider)
  3. Center: Monaco editor (40% width default, draggable)
  4. Right: MkDocs preview iframe (60% width default, draggable)
  5. Layout mode switcher: Split / Editor-only / Preview-only
  6. Collapsible tree panel (click hamburger or thin bar to toggle)
  7. Persists layout preferences in localStorage

  8. File Tree Browser

  9. Hierarchical file/folder display (Ant Design Tree)
  10. Shows .md files without extension (e.g., \"index\" not \"index.md\")
  11. Tight spacing (28px row height) for compact view
  12. Smooth hover effects (rgba background transitions)
  13. Selected file highlighted
  14. Expand/collapse folders with arrow icons
  15. Context menu (right-click) for New File, New Folder, Rename, Delete
  16. Search/filter with auto-expand matching nodes

  17. Monaco Code Editor

  18. Markdown syntax highlighting with VS Dark theme
  19. Line numbers, word wrap, no minimap (clean editing)
  20. Real-time change detection (dirty state tracking)
  21. Ctrl+S keyboard shortcut for saving
  22. Custom right-click context menu (replaces Monaco's default)
  23. Detects file type (markdown, yaml, json, css, html, javascript)

  24. MkDocs Snippet System (60+ snippets)

  25. Formatting: Bold (**), Italic (*), Strikethrough (~~), Highlight (==), Inline Code (`), Keyboard Key (++)
  26. Headings: H1-H4 with # syntax
  27. Admonitions: Note, Warning, Tip, Danger, Info, Success, Question, Abstract, Example, Bug, Quote (+ collapsible variants)
  28. Code: Code block (```), Annotated code, Mermaid diagrams
  29. Insert: Link, Image, Button, Primary button, Material icon, Table, Task list, Tabs, Math block, Footnote, Definition list, Horizontal rule
  30. Snippet types: wrap (surround selection), block (insert template), insert (paste content)

  31. Formatting Toolbar

  32. Always visible for .md files (28px height, compact)
  33. Direct buttons: Bold, Italic, Strikethrough, Highlight, Inline Code, Keyboard Key
  34. Dropdown menus: Headings (H1-H4), Admonitions (11 types + collapsible), Code (3 types), Insert (12 elements)
  35. Keyboard shortcuts shown in menus (Ctrl+B, Ctrl+I)

  36. Live Preview

  37. MkDocs server iframe (proxied via /mkdocs-proxy/)
  38. Auto-reload on save (500ms delay)
  39. Manual refresh button in toolbar
  40. URL preview bar above iframe showing production + localhost URLs
  41. Click URL buttons to open in new tab

  42. File Operations

  43. New File: Right-click folder \u2192 New File (auto-appends .md)
  44. New Folder: Right-click folder \u2192 New Folder
  45. Rename: Right-click file/folder \u2192 Rename
  46. Delete: Right-click file/folder \u2192 Delete (with confirmation modal)
  47. Root-level creation: Toolbar buttons (+ File, + Folder icons)

  48. File Tree Actions

  49. Filter: Search button in tree toolbar \u2192 input field \u2192 auto-expand matches
  50. Expand All: Button in tree toolbar
  51. Collapse All: Button in tree toolbar
  52. Hide Panel: Fold icon collapses tree to thin bar
  53. Show Panel: Click thin bar or unfold icon to restore tree

  54. Save Operations

  55. Save button in top toolbar (blue primary, shows when dirty)
  56. Ctrl+S keyboard shortcut (global)
  57. Loading state during save
  58. Success message on save
  59. Modified indicator in editor status bar

  60. Layout Modes

    • Split: Editor + Preview side-by-side (default)
    • Editor: Editor only (full width)
    • Preview: Preview only (full width)
    • Toggle buttons in top toolbar
    • Persists preference in localStorage
  61. MkDocs Site Building (SUPER_ADMIN)

    • Build button in toolbar (hammer icon)
    • Confirmation modal before build
    • Triggers static site generation
    • Success/error messages
  62. Mobile Detection

    • Screens < 768px show \"Desktop Required\" message
    • No editor on mobile (unusable)
"},{"location":"v2/frontend/pages/admin/docs-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/docs-page/#opening-editor","title":"Opening Editor","text":"
  1. Navigate to page: Admin sidebar \u2192 System \u2192 Documentation
  2. Page loads: File tree appears on left, empty editor in center, MkDocs homepage in preview
  3. Select file: Click on file in tree (e.g., index.md)
  4. Editor loads: Monaco editor shows file content, preview updates to matching page
"},{"location":"v2/frontend/pages/admin/docs-page/#editing-documentation","title":"Editing Documentation","text":"
  1. Modify content: Type in Monaco editor (Markdown syntax)
  2. Use formatting toolbar: Click Bold/Italic/etc. buttons or dropdown menus
  3. Insert snippets: Click Insert dropdown \u2192 select Link/Image/Table/etc.
  4. Check preview: Right pane shows live rendering
  5. Save changes: Click Save button or press Ctrl+S
  6. Auto-refresh: Preview reloads after 500ms delay
"},{"location":"v2/frontend/pages/admin/docs-page/#using-formatting-toolbar","title":"Using Formatting Toolbar","text":"

Direct Buttons (wrap selected text): 1. Bold: Select text, click B button (or Ctrl+B) \u2192 **text** 2. Italic: Select text, click I button (or Ctrl+I) \u2192 *text* 3. Strikethrough: Select text, click S\u0336 button \u2192 ~~text~~ 4. Highlight: Select text, click highlight button \u2192 ==text== 5. Inline Code: Select text, click <> button \u2192 `text` 6. Keyboard Key: Select text, click K button \u2192 ++text++

Dropdown Menus (insert templates): 1. Headings: Click \"H \u25bc\" \u2192 select H1/H2/H3/H4 \u2192 inserts ## at cursor 2. Admonitions: Click \"Admonitions \u25bc\" \u2192 select Note/Warning/etc. \u2192 inserts block:

!!! note \"Title\"\n    Content here\n
3. Code: Click \"Code \u25bc\" \u2192 select Code Block/Annotated/Mermaid \u2192 inserts template 4. Insert: Click \"Insert \u25bc\" \u2192 select Link/Image/Table/etc. \u2192 pastes element

"},{"location":"v2/frontend/pages/admin/docs-page/#using-right-click-context-menu","title":"Using Right-Click Context Menu","text":"
  1. Right-click in editor: Custom context menu appears (not Monaco's default)
  2. Select category: Formatting, Headings, Admonitions, Code, or Insert submenu
  3. Click snippet: Snippet applied to cursor/selection
  4. Context menu closes: Focus returns to editor

Menu Structure: - Formatting (submenu) \u2192 Bold (Ctrl+B), Italic (Ctrl+I), Strikethrough, etc. - Headings (submenu) \u2192 H1, H2, H3, H4 - Admonitions (submenu) \u2192 Note, Warning, Tip, etc. (13 types) - Code (submenu) \u2192 Code Block, Annotated Code, Mermaid Diagram - Insert (submenu) \u2192 Link, Image, Button, Icon, Table, etc. (12 elements)

"},{"location":"v2/frontend/pages/admin/docs-page/#managing-files","title":"Managing Files","text":"

Creating New File: 1. Right-click folder in tree: Context menu appears 2. Click \"New File\": Modal opens with input 3. Enter name: Type filename (e.g., my-page) 4. Submit: Modal closes, new file appears in tree (auto-appends .md) 5. File auto-opens: Editor loads with template content (# {filename})

Creating New Folder: 1. Right-click folder in tree: Context menu appears 2. Click \"New Folder\": Modal opens 3. Enter name: Type folder name 4. Submit: Modal closes, new folder appears in tree

Renaming File/Folder: 1. Right-click file/folder: Context menu appears 2. Click \"Rename\": Modal opens with current name 3. Edit name: Modify name 4. Submit: Modal closes, tree updates

Deleting File/Folder: 1. Right-click file/folder: Context menu appears 2. Click \"Delete\": Confirmation modal appears 3. Confirm: Click OK 4. File removed: Tree refreshes, if currently open file deleted, editor clears

Root-Level Creation: 1. Click \"+ File\" or \"+ Folder\" icons in tree toolbar 2. Follow same modal flow as folder context menu

"},{"location":"v2/frontend/pages/admin/docs-page/#filtering-file-tree","title":"Filtering File Tree","text":"
  1. Click search icon in tree toolbar: Filter input appears below toolbar
  2. Type query: Enter filename or partial match (e.g., \"api\")
  3. Tree filters: Only matching files/folders shown
  4. Matching folders auto-expand: See nested matches
  5. Clear filter: Click X in input or search icon to hide input
"},{"location":"v2/frontend/pages/admin/docs-page/#resizing-panels","title":"Resizing Panels","text":"

Tree Panel Resize: 1. Hover over tree divider: Vertical bar (1px) between tree and editor 2. Divider highlights: Changes color to primary 3. Drag left/right: Tree width adjusts (160px - 400px range) 4. Release: Width persists in localStorage

Editor/Preview Split: 1. Hover over editor/preview divider: Vertical bar (4px) between panes 2. Divider highlights: Changes color to primary 3. Drag left/right: Adjust split percentage (15% - 85% range) 4. Release: Split persists in localStorage

"},{"location":"v2/frontend/pages/admin/docs-page/#switching-layout-modes","title":"Switching Layout Modes","text":"
  1. Click layout mode button in toolbar:
  2. Split icon: Editor + Preview side-by-side
  3. Code icon: Editor only (full width)
  4. Eye icon: Preview only (full width)
  5. Layout changes immediately
  6. Active mode highlighted: Primary blue color
  7. Preference saved: Persists in localStorage
"},{"location":"v2/frontend/pages/admin/docs-page/#opening-preview-urls","title":"Opening Preview URLs","text":"
  1. Check URL bar above preview iframe (only for .md files)
  2. Click \"Production\" button: Opens https://docs.cmlite.org/{path} in new tab
  3. Click \"Localhost\" button: Opens http://localhost:4003/{path} in new tab
"},{"location":"v2/frontend/pages/admin/docs-page/#building-mkdocs-site-super_admin","title":"Building MkDocs Site (SUPER_ADMIN)","text":"
  1. Click Build button in toolbar (hammer icon)
  2. Confirmation modal: \"Build static site? This may take a few minutes.\"
  3. Confirm: Click OK
  4. Build starts: Button shows loading spinner
  5. Wait: ~30-60 seconds for build to complete
  6. Success message: \"Site built successfully\"
  7. Check output: Navigate to MkDocs site URL to verify
"},{"location":"v2/frontend/pages/admin/docs-page/#saving-and-preview-refresh","title":"Saving and Preview Refresh","text":"
  1. Make changes in editor
  2. Status bar shows \"Modified\" in yellow
  3. Save with Ctrl+S or Save button
  4. Success message: \"Saved\"
  5. Preview auto-refreshes: After 500ms delay
  6. Status bar clears \"Modified\"
"},{"location":"v2/frontend/pages/admin/docs-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/docs-page/#top-toolbar","title":"Top Toolbar","text":"
<Space size={8}>\n  <Tooltip title=\"Editor + Preview\">\n    <Button\n      type={layout === 'split' ? 'primary' : 'text'}\n      icon={<ColumnWidthOutlined />}\n      onClick={() => setLayout('split')}\n    />\n  </Tooltip>\n  <Tooltip title=\"Editor Only\">\n    <Button\n      type={layout === 'editor' ? 'primary' : 'text'}\n      icon={<CodeOutlined />}\n      onClick={() => setLayout('editor')}\n    />\n  </Tooltip>\n  <Tooltip title=\"Preview Only\">\n    <Button\n      type={layout === 'preview' ? 'primary' : 'text'}\n      icon={<EyeOutlined />}\n      onClick={() => setLayout('preview')}\n    />\n  </Tooltip>\n\n  {dirty && (\n    <Button type=\"primary\" icon={<SaveOutlined />} onClick={saveFile} loading={saving}>\n      Save\n    </Button>\n  )}\n\n  <Button type=\"text\" icon={<ReloadOutlined />} onClick={refreshPreview} />\n  <Button type=\"text\" icon={<ExportOutlined />} onClick={() => window.open(mkdocsDirectUrl, '_blank')} />\n\n  {isSuperAdmin && (\n    <Button type=\"text\" icon={<BuildOutlined />} onClick={confirmAndBuild} loading={building} />\n  )}\n</Space>\n
"},{"location":"v2/frontend/pages/admin/docs-page/#file-tree-component","title":"File Tree Component","text":"
<Tree\n  treeData={treeData}\n  showIcon={false}\n  showLine={false}\n  selectedKeys={selectedFile ? [selectedFile] : []}\n  expandedKeys={expandedKeys}\n  onExpand={(keys) => setExpandedKeys(keys)}\n  onSelect={(keys) => {\n    if (keys.length === 0) return;\n    const path = keys[0] as string;\n    if (isDirectoryPath(path)) return;\n    onTreeSelect(keys);\n  }}\n  blockNode\n  titleRender={(nodeData) => {\n    const nodePath = nodeData.key as string;\n    const isDir = isDirectoryPath(nodePath);\n    return (\n      <Dropdown\n        menu={{ items: getContextMenuItems(nodePath, isDir) }}\n        trigger={['contextMenu']}\n      >\n        <span>{nodeData.title as string}</span>\n      </Dropdown>\n    );\n  }}\n/>\n

Tree Styling (Obsidian-style):

.docs-tree .ant-tree-treenode {\n  padding: 0 !important;\n  min-height: 28px !important;\n  line-height: 28px !important;\n  border-radius: 0 !important;\n  transition: background 0.15s !important;\n}\n.docs-tree .ant-tree-treenode:hover {\n  background: rgba(255,255,255,0.06) !important;\n}\n.docs-tree .ant-tree-treenode-selected {\n  background: rgba(255,255,255,0.10) !important;\n}\n

"},{"location":"v2/frontend/pages/admin/docs-page/#monaco-editor","title":"Monaco Editor","text":"
<Editor\n  language={selectedFile.endsWith('.md') ? 'markdown' : 'yaml'}\n  theme=\"vs-dark\"\n  value={fileContent}\n  onChange={onEditorChange}\n  onMount={handleEditorMount}\n  options={{\n    minimap: { enabled: false },\n    contextmenu: false,  // Disable default context menu\n    wordWrap: 'on',\n    lineNumbers: 'on',\n    fontSize: 14,\n    scrollBeyondLastLine: false,\n    automaticLayout: true,\n    tabSize: 2,\n  }}\n/>\n
"},{"location":"v2/frontend/pages/admin/docs-page/#formatting-toolbar","title":"Formatting Toolbar","text":"
<div style={{ height: 28, display: 'flex', alignItems: 'center', gap: 2 }}>\n  {/* Direct buttons */}\n  <Tooltip title=\"Bold (Ctrl+B)\">\n    <Button type=\"text\" size=\"small\" icon={<BoldOutlined />} onClick={() => handleToolbarSnippet('bold')} />\n  </Tooltip>\n  <Tooltip title=\"Italic (Ctrl+I)\">\n    <Button type=\"text\" size=\"small\" icon={<ItalicOutlined />} onClick={() => handleToolbarSnippet('italic')} />\n  </Tooltip>\n  {/* ... more buttons ... */}\n\n  {/* Dropdown menus */}\n  <Dropdown menu={{ items: headingItems }} trigger={['click']}>\n    <Button type=\"text\" size=\"small\">\n      <FontSizeOutlined /> H <DownOutlined />\n    </Button>\n  </Dropdown>\n\n  <Dropdown menu={{ items: admonitionItems }} trigger={['click']}>\n    <Button type=\"text\" size=\"small\">\n      <AlertOutlined /> Admonitions <DownOutlined />\n    </Button>\n  </Dropdown>\n  {/* ... more dropdowns ... */}\n</div>\n
"},{"location":"v2/frontend/pages/admin/docs-page/#url-preview-bar","title":"URL Preview Bar","text":"
const URLPreviewBar = ({ filePath }: { filePath: string | null }) => {\n  if (!filePath || !filePath.endsWith('.md')) return null;\n\n  const productionUrl = `https://docs.cmlite.org/${urlPath}/`;\n  const localhostUrl = `http://localhost:4003/${urlPath}/`;\n\n  return (\n    <div style={{ height: 32, display: 'flex', alignItems: 'center', gap: 8 }}>\n      <Typography.Text>Preview:</Typography.Text>\n      <Button size=\"small\" icon={<ExportOutlined />} onClick={() => openUrl(productionUrl)}>\n        Production\n      </Button>\n      <Button size=\"small\" icon={<ExportOutlined />} onClick={() => openUrl(localhostUrl)}>\n        Localhost\n      </Button>\n    </div>\n  );\n};\n
"},{"location":"v2/frontend/pages/admin/docs-page/#snippet-system","title":"Snippet System","text":"

Snippet Definition:

interface MkDocsSnippet {\n  id: string;\n  label: string;\n  group: 'formatting' | 'heading' | 'admonition' | 'code' | 'insert';\n  type: 'wrap' | 'block' | 'insert';\n  prefix?: string;\n  suffix?: string;\n  template?: string;\n  keybinding?: 'ctrl+b' | 'ctrl+i';\n}\n\nconst SNIPPETS: MkDocsSnippet[] = [\n  { id: 'bold', label: 'Bold', group: 'formatting', type: 'wrap', prefix: '**', suffix: '**', keybinding: 'ctrl+b' },\n  { id: 'italic', label: 'Italic', group: 'formatting', type: 'wrap', prefix: '*', suffix: '*', keybinding: 'ctrl+i' },\n  { id: 'h2', label: 'Heading 2', group: 'heading', type: 'block', template: '## $CURSOR' },\n  { id: 'admonition-note', label: 'Note', group: 'admonition', type: 'block', template: '!!! note \"Title\"\\n    Content here' },\n  { id: 'code-block', label: 'Code Block', group: 'code', type: 'block', template: '```python\\n$CURSOR\\n```' },\n  { id: 'link', label: 'Link', group: 'insert', type: 'wrap', prefix: '[', suffix: '](url)' },\n  { id: 'image', label: 'Image', group: 'insert', type: 'insert', template: '![Alt text](image.png)' },\n  // ... 60+ total snippets\n];\n

Apply Snippet Function:

function applySnippet(\n  ed: monacoEditor.IStandaloneCodeEditor,\n  snippet: MkDocsSnippet,\n  monaco: typeof import('monaco-editor'),\n) {\n  const sel = ed.getSelection();\n  const model = ed.getModel();\n  if (!sel || !model) return;\n\n  const selectedText = model.getValueInRange(sel);\n\n  if (snippet.type === 'wrap' && snippet.prefix != null && snippet.suffix != null) {\n    if (selectedText) {\n      ed.executeEdits('mkdocs-snippet', [{\n        range: sel,\n        text: snippet.prefix + selectedText + snippet.suffix,\n      }]);\n    } else {\n      const placeholder = 'text';\n      ed.executeEdits('mkdocs-snippet', [{\n        range: sel,\n        text: snippet.prefix + placeholder + snippet.suffix,\n      }]);\n      // Select placeholder\n      const pos = sel.getStartPosition();\n      const startCol = pos.column + snippet.prefix.length;\n      ed.setSelection(new monaco.Selection(pos.lineNumber, startCol, pos.lineNumber, startCol + placeholder.length));\n    }\n  } else if (snippet.type === 'block' && snippet.template) {\n    let text = snippet.template.replace('$CURSOR', selectedText);\n    ed.executeEdits('mkdocs-snippet', [{ range: sel, text }]);\n  } else if (snippet.type === 'insert' && snippet.template) {\n    ed.executeEdits('mkdocs-snippet', [{ range: sel, text: snippet.template }]);\n  }\n\n  ed.focus();\n}\n

"},{"location":"v2/frontend/pages/admin/docs-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/docs-page/#local-state","title":"Local State","text":"

File Tree & Content:

const [fileTree, setFileTree] = useState<FileNode[]>([]);\nconst [selectedFile, setSelectedFile] = useState<string | null>(null);\nconst [fileContent, setFileContent] = useState<string>('');\nconst [originalContent, setOriginalContent] = useState<string>('');\nconst [dirty, setDirty] = useState(false);\n

UI State:

const [loading, setLoading] = useState(true);\nconst [saving, setSaving] = useState(false);\nconst [fileLoading, setFileLoading] = useState(false);\nconst [layout, setLayout] = useState<LayoutMode>(() => (localStorage.getItem(LAYOUT_STORAGE_KEY) as LayoutMode) || 'split');\nconst [splitPercent, setSplitPercent] = useState<number>(() => Number(localStorage.getItem(DIVIDER_STORAGE_KEY)) || 50);\nconst [treeCollapsed, setTreeCollapsed] = useState<boolean>(() => localStorage.getItem(TREE_COLLAPSED_KEY) === 'true');\nconst [treeWidth, setTreeWidth] = useState<number>(() => Number(localStorage.getItem(TREE_WIDTH_KEY)) || 200);\n

Filter & Modal State:

const [filterQuery, setFilterQuery] = useState('');\nconst [filterVisible, setFilterVisible] = useState(false);\nconst [modalType, setModalType] = useState<'newFile' | 'newFolder' | 'rename' | null>(null);\nconst [modalInput, setModalInput] = useState('');\nconst [contextPath, setContextPath] = useState<string>('');\n

Monaco Refs:

const monacoEditorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null);\nconst monacoRef = useRef<typeof import('monaco-editor') | null>(null);\nconst previewIframeRef = useRef<HTMLIFrameElement>(null);\n

"},{"location":"v2/frontend/pages/admin/docs-page/#localstorage-persistence","title":"localStorage Persistence","text":"
useEffect(() => { localStorage.setItem(LAYOUT_STORAGE_KEY, layout); }, [layout]);\nuseEffect(() => { localStorage.setItem(DIVIDER_STORAGE_KEY, String(splitPercent)); }, [splitPercent]);\nuseEffect(() => { localStorage.setItem(TREE_COLLAPSED_KEY, String(treeCollapsed)); }, [treeCollapsed]);\nuseEffect(() => { localStorage.setItem(TREE_WIDTH_KEY, String(treeWidth)); }, [treeWidth]);\n
"},{"location":"v2/frontend/pages/admin/docs-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/docs-page/#endpoints-used","title":"Endpoints Used","text":"

GET /docs/files - Fetch file tree

const { data } = await api.get<FileNode[]>('/docs/files');\n

Response:

[\n  {\n    \"name\": \"index.md\",\n    \"path\": \"index.md\",\n    \"isDirectory\": false\n  },\n  {\n    \"name\": \"v2\",\n    \"path\": \"v2\",\n    \"isDirectory\": true,\n    \"children\": [\n      {\n        \"name\": \"index.md\",\n        \"path\": \"v2/index.md\",\n        \"isDirectory\": false\n      }\n    ]\n  }\n]\n

GET /docs/files/:filePath - Read file content

const { data } = await api.get<{ path: string; content: string }>(`/docs/files/${filePath}`);\n

Response:

{\n  \"path\": \"v2/index.md\",\n  \"content\": \"# V2 Documentation\\n\\nWelcome to V2 docs...\"\n}\n

PUT /docs/files/:filePath - Update file

await api.put(`/docs/files/${filePath}`, { content: fileContent });\n

POST /docs/files/:filePath - Create file/folder

// Create file\nawait api.post(`/docs/files/${path}`, { content: '# New File\\n' });\n\n// Create folder\nawait api.post(`/docs/files/${path}`, { isDirectory: true });\n

POST /docs/files/rename - Rename file/folder

await api.post('/docs/files/rename', { from: 'old-path.md', to: 'new-path.md' });\n

DELETE /docs/files/:filePath - Delete file/folder

await api.delete(`/docs/files/${filePath}`);\n

"},{"location":"v2/frontend/pages/admin/docs-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/docs-page/#keyboard-shortcut-registration","title":"Keyboard Shortcut Registration","text":"
const handleEditorMount: OnMount = useCallback((ed, monaco) => {\n  monacoEditorRef.current = ed;\n  monacoRef.current = monaco;\n\n  // Register Ctrl+B and Ctrl+I\n  SNIPPETS.filter(s => s.keybinding).forEach(snippet => {\n    const kb = snippet.keybinding === 'ctrl+b'\n      ? monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyB\n      : monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyI;\n    ed.addAction({\n      id: `mkdocs.${snippet.id}`,\n      label: snippet.label,\n      keybindings: [kb],\n      run: (editor) => applySnippet(editor as monacoEditor.IStandaloneCodeEditor, snippet, monaco),\n    });\n  });\n}, []);\n
"},{"location":"v2/frontend/pages/admin/docs-page/#drag-to-resize-logic","title":"Drag-to-Resize Logic","text":"
const onTreeDividerDown = useCallback(() => {\n  dragging.current = 'tree';\n  document.body.style.cursor = 'col-resize';\n  document.body.style.userSelect = 'none';\n}, []);\n\nuseEffect(() => {\n  const onMouseMove = (e: MouseEvent) => {\n    if (!dragging.current || !containerRef.current) return;\n    const rect = containerRef.current.getBoundingClientRect();\n\n    if (dragging.current === 'tree') {\n      const w = e.clientX - rect.left;\n      setTreeWidth(Math.min(MAX_TREE_WIDTH, Math.max(MIN_TREE_WIDTH, w)));\n    }\n  };\n\n  const onMouseUp = () => {\n    if (dragging.current) {\n      dragging.current = false;\n      document.body.style.cursor = '';\n      document.body.style.userSelect = '';\n    }\n  };\n\n  window.addEventListener('mousemove', onMouseMove);\n  window.addEventListener('mouseup', onMouseUp);\n  return () => {\n    window.removeEventListener('mousemove', onMouseMove);\n    window.removeEventListener('mouseup', onMouseUp);\n  };\n}, []);\n
"},{"location":"v2/frontend/pages/admin/docs-page/#file-tree-filtering","title":"File Tree Filtering","text":"
function filterTree(nodes: FileNode[], query: string): FileNode[] {\n  const q = query.toLowerCase();\n  const filtered: FileNode[] = [];\n\n  for (const node of nodes) {\n    if (node.isDirectory) {\n      const childMatches = node.children ? filterTree(node.children, query) : [];\n      if (childMatches.length > 0 || node.name.toLowerCase().includes(q)) {\n        filtered.push({ ...node, children: childMatches.length > 0 ? childMatches : node.children });\n      }\n    } else {\n      if (node.name.toLowerCase().includes(q)) {\n        filtered.push(node);\n      }\n    }\n  }\n\n  return filtered;\n}\n\n// Auto-expand when filtering\nconst expandedKeysForFilter = useMemo(() => {\n  if (filterQuery.trim()) return collectAllDirKeys(filteredTree);\n  return [];\n}, [filterQuery, filteredTree]);\n
"},{"location":"v2/frontend/pages/admin/docs-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/docs-page/#monaco-editor-lazy-load","title":"Monaco Editor Lazy Load","text":"

Monaco loads from CDN when component mounts (not in main bundle).

"},{"location":"v2/frontend/pages/admin/docs-page/#usecallback-for-event-handlers","title":"useCallback for Event Handlers","text":"

All drag handlers, save handler, and snippet handler use useCallback to prevent recreation.

"},{"location":"v2/frontend/pages/admin/docs-page/#conditional-toolbar-rendering","title":"Conditional Toolbar Rendering","text":"
{selectedFile?.endsWith('.md') && !fileLoading && (\n  <div>{/* Formatting toolbar */}</div>\n)}\n

Only renders toolbar for Markdown files.

"},{"location":"v2/frontend/pages/admin/docs-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/docs-page/#mobile-warning","title":"Mobile Warning","text":"
if (isMobile) {\n  return <Result status=\"info\" title=\"Desktop Required\" />;\n}\n

Screens < 768px: Show warning, don't render editor.

"},{"location":"v2/frontend/pages/admin/docs-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/docs-page/#keyboard-shortcuts","title":"Keyboard Shortcuts","text":"
  • Ctrl+S: Save file
  • Ctrl+B: Bold
  • Ctrl+I: Italic
  • Tab: Navigate through toolbar buttons, tree nodes
"},{"location":"v2/frontend/pages/admin/docs-page/#button-labels","title":"Button Labels","text":"

All toolbar buttons have tooltips with keyboard shortcuts shown.

"},{"location":"v2/frontend/pages/admin/docs-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/docs-page/#monaco-editor-blank","title":"Monaco Editor Blank","text":"

Cause: CDN load failed or height not set

Solution:

<Editor height=\"100%\" /> // Parent must have defined height\n

"},{"location":"v2/frontend/pages/admin/docs-page/#preview-not-updating","title":"Preview Not Updating","text":"

Cause: Iframe src not changing or MkDocs server down

Debug:

# Check MkDocs container\ndocker compose logs mkdocs\n\n# Restart MkDocs\ndocker compose restart mkdocs\n

"},{"location":"v2/frontend/pages/admin/docs-page/#file-tree-not-loading","title":"File Tree Not Loading","text":"

Cause: API endpoint failing

Debug:

# Check API logs\ndocker compose logs -f api | grep docs\n\n# Test endpoint\ncurl -H \"Authorization: Bearer <token>\" \\\n  http://localhost:4000/docs/files\n

"},{"location":"v2/frontend/pages/admin/docs-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/docs-page/#backend-integration","title":"Backend Integration","text":"
  • Docs Module - Docs routes and file operations
  • MkDocs Configuration - MkDocs setup
"},{"location":"v2/frontend/pages/admin/docs-page/#features_1","title":"Features","text":"
  • Documentation System - Feature overview
  • MkDocs Integration - MkDocs setup
"},{"location":"v2/frontend/pages/admin/docs-page/#user-guides","title":"User Guides","text":"
  • Admin Guide - Documentation - Editing workflows
"},{"location":"v2/frontend/pages/admin/docs-page/#external-resources","title":"External Resources","text":"
  • Monaco Editor Documentation - Monaco API
  • MkDocs Documentation - MkDocs reference
  • Material for MkDocs - Theme documentation
"},{"location":"v2/frontend/pages/admin/email-queue-page/","title":"EmailQueuePage","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#overview","title":"Overview","text":"

The EmailQueuePage provides real-time monitoring of the BullMQ email job queue that handles asynchronous advocacy email sending for the Influence module. It displays four key statistics (waiting, active, completed, failed jobs) with auto-refresh functionality and provides administrative controls to pause/resume the queue and clean up old completed jobs. The page is designed for monitoring email delivery health and troubleshooting stuck jobs.

Route: /app/influence/email-queue Component: admin/src/pages/EmailQueuePage.tsx (140 lines) Auth Required: Yes (SUPER_ADMIN or INFLUENCE_ADMIN role recommended) Layout: AppLayout Backend Module: api/src/modules/influence/email-queue/

"},{"location":"v2/frontend/pages/admin/email-queue-page/#screenshot","title":"Screenshot","text":"

[Screenshot: EmailQueuePage with \"Email Queue\" header showing \"RUNNING\" green tag. Right side has three buttons: \"Refresh\", \"Pause\", and \"Clean Old Jobs\". Below are four statistics cards in a row: \"Waiting: 23\" (blue text), \"Active: 2\" (green text), \"Completed: 1,487\" (default gray), and \"Failed: 12\" (red text). The page has minimal UI, focusing on the statistics cards.]

"},{"location":"v2/frontend/pages/admin/email-queue-page/#features","title":"Features","text":"
  • Real-time statistics \u2014 Waiting, active, completed, and failed job counts
  • Auto-refresh \u2014 Updates every 10 seconds automatically
  • Queue status indicator \u2014 Visual tag showing RUNNING (green) or PAUSED (orange)
  • Pause/Resume control \u2014 Stop/start email processing with single button
  • Clean old jobs \u2014 Remove completed jobs from queue to free memory
  • Manual refresh \u2014 Force immediate statistics update
  • Color-coded metrics \u2014 Semantic colors for each job state
  • Minimal UI \u2014 Focus on essential monitoring data without clutter
  • Header-integrated actions \u2014 Controls in page header for quick access
"},{"location":"v2/frontend/pages/admin/email-queue-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#monitoring-email-queue-health","title":"Monitoring Email Queue Health","text":"
  1. Navigate to /app/influence/email-queue
  2. Page loads with initial statistics fetch
  3. Observe statistics cards (displayed in single row):
  4. Waiting: Jobs queued but not yet processing (blue text)
  5. Active: Jobs currently being processed (green text)
  6. Completed: Successfully sent emails (gray text)
  7. Failed: Jobs that encountered errors (red text)
  8. Check queue status tag in header:
  9. RUNNING (green): Queue is processing jobs normally
  10. PAUSED (orange): Queue is stopped, no jobs being processed
  11. Auto-refresh occurs every 10 seconds:
  12. Statistics update silently (no loading spinner)
  13. Numbers increment/decrement based on queue activity
  14. Status tag updates if queue state changes
"},{"location":"v2/frontend/pages/admin/email-queue-page/#pausing-the-email-queue","title":"Pausing the Email Queue","text":"

When to Pause: - Troubleshooting SMTP connection issues - Performing backend maintenance - Preventing emails from sending during off-hours - Testing email configuration changes

Steps:

  1. Click \"Pause\" button in header (next to Refresh button)
  2. API request sent to /api/email-queue/pause
  3. Success message: \"Queue paused\"
  4. Queue status tag changes from \"RUNNING\" (green) to \"PAUSED\" (orange)
  5. Active count drops to 0 (currently processing jobs complete)
  6. Waiting count remains (jobs queued but not processing)
  7. Button label changes to \"Resume\"

Effect: - No new jobs will be picked up from queue - Currently active jobs will complete (cannot interrupt mid-send) - New campaign emails will still be added to queue (just not processed)

"},{"location":"v2/frontend/pages/admin/email-queue-page/#resuming-the-email-queue","title":"Resuming the Email Queue","text":"
  1. Verify SMTP configuration is correct (Settings page)
  2. Click \"Resume\" button in header
  3. API request sent to /api/email-queue/resume
  4. Success message: \"Queue resumed\"
  5. Queue status tag changes from \"PAUSED\" (orange) to \"RUNNING\" (green)
  6. Waiting count begins decreasing as jobs are picked up
  7. Active count increases (workers processing jobs)
  8. Button label changes back to \"Pause\"
"},{"location":"v2/frontend/pages/admin/email-queue-page/#cleaning-old-completed-jobs","title":"Cleaning Old Completed Jobs","text":"

When to Clean: - Completed job count exceeds 10,000 (memory usage) - Queue dashboard feels sluggish - Regular maintenance (weekly/monthly)

Steps:

  1. Click \"Clean Old Jobs\" button in header
  2. Confirmation: No confirmation dialog (immediate action)
  3. API request sent to /api/email-queue/clean
  4. Backend deletes all jobs with status = COMPLETED
  5. Success message: \"Cleaned 1,487 completed jobs\"
  6. Completed count resets to 0
  7. Statistics automatically refresh

Important: This only removes completed jobs. Waiting, active, and failed jobs are preserved.

"},{"location":"v2/frontend/pages/admin/email-queue-page/#refreshing-statistics-manually","title":"Refreshing Statistics Manually","text":"
  1. Click \"Refresh\" button in header (circular arrow icon)
  2. Loading spinner appears on button
  3. API request sent to /api/email-queue/stats
  4. All four statistics update simultaneously
  5. Loading spinner disappears
  6. Use case: Immediate update without waiting for 10-second auto-refresh
"},{"location":"v2/frontend/pages/admin/email-queue-page/#investigating-failed-jobs","title":"Investigating Failed Jobs","text":"

Problem: \"Failed\" count increases (e.g., from 5 to 12)

Diagnosis Steps:

  1. Note current failed count (e.g., 12)
  2. Navigate to backend logs: docker compose logs -f api | grep \"Email job failed\"
  3. Look for error messages:
    Email job failed for campaign abc123: SMTP connection timeout\n
  4. Identify root cause:
  5. SMTP server down
  6. Invalid credentials
  7. Rate limiting
  8. Network connectivity issues

Resolution:

  1. Fix underlying issue (e.g., update SMTP credentials in Settings)
  2. Return to Email Queue page
  3. Consider options:
  4. Retry failed jobs: Currently no UI button (requires backend job retry API)
  5. Clean failed jobs: Click \"Clean Old Jobs\" to remove (also removes completed)
  6. Wait for auto-retry: BullMQ will retry failed jobs automatically (3 attempts)
"},{"location":"v2/frontend/pages/admin/email-queue-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  • Card \u2014 Container for each statistic
  • Statistic \u2014 Formatted numeric display with title
  • Row / Col \u2014 Grid layout for statistics cards
  • Button \u2014 Header action buttons (Refresh, Pause/Resume, Clean)
  • Tag \u2014 Queue status indicator (RUNNING/PAUSED)
  • Space \u2014 Button grouping in header
  • message \u2014 Toast notifications for success/error feedback
"},{"location":"v2/frontend/pages/admin/email-queue-page/#statistics-card-grid","title":"Statistics Card Grid","text":"
<Row gutter={[16, 16]}>\n  <Col xs={12} sm={6}>\n    <Card>\n      <Statistic\n        title=\"Waiting\"\n        value={stats?.waiting ?? 0}\n        valueStyle={{ color: '#1890ff' }}\n      />\n    </Card>\n  </Col>\n  <Col xs={12} sm={6}>\n    <Card>\n      <Statistic\n        title=\"Active\"\n        value={stats?.active ?? 0}\n        valueStyle={{ color: '#52c41a' }}\n      />\n    </Card>\n  </Col>\n  <Col xs={12} sm={6}>\n    <Card>\n      <Statistic\n        title=\"Completed\"\n        value={stats?.completed ?? 0}\n      />\n    </Card>\n  </Col>\n  <Col xs={12} sm={6}>\n    <Card>\n      <Statistic\n        title=\"Failed\"\n        value={stats?.failed ?? 0}\n        valueStyle={{ color: '#ff4d4f' }}\n      />\n    </Card>\n  </Col>\n</Row>\n

Responsive Grid: - Mobile (xs, <576px): 2 columns (Waiting/Active on top row, Completed/Failed on bottom) - Tablet/Desktop (sm+, \u2265576px): 4 columns (all cards in single row)

Color-Coded Values: - Waiting: Blue (#1890ff) \u2014 informational, jobs pending - Active: Green (#52c41a) \u2014 success, jobs processing - Completed: Gray (default) \u2014 neutral, jobs done - Failed: Red (#ff4d4f) \u2014 error, jobs failed

"},{"location":"v2/frontend/pages/admin/email-queue-page/#header-actions","title":"Header Actions","text":"
const headerActions = useMemo(() => (\n  <Space>\n    {stats && (\n      <Tag color={stats.paused ? 'orange' : 'green'}>\n        {stats.paused ? 'PAUSED' : 'RUNNING'}\n      </Tag>\n    )}\n    <Button icon={<ReloadOutlined />} onClick={fetchStats} loading={loading}>\n      Refresh\n    </Button>\n    <Button\n      icon={stats?.paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}\n      onClick={handlePauseResume}\n      loading={actionLoading}\n    >\n      {stats?.paused ? 'Resume' : 'Pause'}\n    </Button>\n    <Button\n      icon={<DeleteOutlined />}\n      onClick={handleClean}\n      loading={actionLoading}\n    >\n      Clean Old Jobs\n    </Button>\n  </Space>\n), [stats, loading, actionLoading, fetchStats, handlePauseResume, handleClean]);\n

Dynamic Elements: - Status Tag: Color and text change based on stats.paused boolean - Pause/Resume Button: Icon and label toggle based on current state - Loading States: Separate loading states for refresh (loading) and actions (actionLoading)

"},{"location":"v2/frontend/pages/admin/email-queue-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#local-state-no-zustand-store","title":"Local State (No Zustand Store)","text":"
const [stats, setStats] = useState<QueueStats | null>(null);\nconst [loading, setLoading] = useState(false);\nconst [actionLoading, setActionLoading] = useState(false);\n

State Variables: - stats (QueueStats | null): Current queue statistics (waiting, active, completed, failed, paused) - loading (boolean): Refresh button loading state - actionLoading (boolean): Pause/Resume/Clean buttons loading state (shared)

No Global State:

This page does NOT use Zustand stores. Queue statistics are fetched directly from the API and stored in local state. This is appropriate because: - Queue stats are admin-only monitoring data - Data changes frequently (auto-refresh every 10 seconds) - No need to share state between pages - Simpler architecture without store overhead

"},{"location":"v2/frontend/pages/admin/email-queue-page/#auto-refresh-with-useeffect","title":"Auto-Refresh with useEffect","text":"
const fetchStats = useCallback(async () => {\n  setLoading(true);\n  try {\n    const { data } = await api.get<QueueStats>('/email-queue/stats');\n    setStats(data);\n  } catch {\n    message.error('Failed to load queue stats');\n  } finally {\n    setLoading(false);\n  }\n}, []);\n\nuseEffect(() => {\n  fetchStats();\n  const interval = setInterval(fetchStats, 10_000);  // Refresh every 10 seconds\n  return () => clearInterval(interval);\n}, [fetchStats]);\n

Auto-Refresh Strategy:

  • Initial load: Immediate fetch on mount
  • Interval: 10 seconds (10,000 milliseconds)
  • Silent refresh: Loading state updates, but no UI disruption
  • Cleanup: Clear interval on unmount to prevent memory leak

Why 10 Seconds?

  • Faster than dashboard: Email queue needs more frequent updates than canvass dashboard (30s)
  • Balance: Fast enough to catch stuck jobs quickly, slow enough to avoid API overload
  • Email context: Emails send in 1-5 seconds each, so 10s interval catches status changes promptly
"},{"location":"v2/frontend/pages/admin/email-queue-page/#usecallback-optimization","title":"useCallback Optimization","text":"
const fetchStats = useCallback(async () => {\n  // ... fetch logic\n}, []);\n\nconst handlePauseResume = useCallback(async () => {\n  // ... pause/resume logic\n}, [stats, fetchStats]);\n\nconst handleClean = useCallback(async () => {\n  // ... clean logic\n}, [fetchStats]);\n

Why useCallback?

  • Prevents infinite re-renders: Without useCallback, useEffect would create new function reference on every render
  • useMemo dependency: Header actions use fetchStats in dependency array, so must be stable
  • No unnecessary re-renders: Functions only re-created when dependencies change
"},{"location":"v2/frontend/pages/admin/email-queue-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose Auth GET /api/email-queue/stats Get queue statistics Required POST /api/email-queue/pause Pause queue processing Required POST /api/email-queue/resume Resume queue processing Required POST /api/email-queue/clean Clean completed jobs Required"},{"location":"v2/frontend/pages/admin/email-queue-page/#load-queue-statistics","title":"Load Queue Statistics","text":"

Request:

const { data } = await api.get<QueueStats>('/email-queue/stats');\n

Response (200 OK):

{\n  \"waiting\": 23,\n  \"active\": 2,\n  \"completed\": 1487,\n  \"failed\": 12,\n  \"paused\": false\n}\n

Response Fields: - waiting (number): Jobs queued but not yet picked up by worker - active (number): Jobs currently being processed by worker - completed (number): Successfully completed jobs (still in Redis) - failed (number): Jobs that failed after all retry attempts - paused (boolean): Whether queue is paused (true) or running (false)

Backend Calculation:

// api/src/modules/influence/email-queue/email-queue.routes.ts\nimport { emailQueueService } from '@/services/email-queue.service';\n\nconst queue = emailQueueService.getQueue();\nconst counts = await queue.getJobCounts();\nconst isPaused = await queue.isPaused();\n\nreturn {\n  waiting: counts.waiting,\n  active: counts.active,\n  completed: counts.completed,\n  failed: counts.failed,\n  paused: isPaused,\n};\n

Job Count Breakdown:

  • Waiting: await queue.getWaitingCount() \u2014 jobs in \"wait\" state
  • Active: await queue.getActiveCount() \u2014 jobs in \"active\" state
  • Completed: await queue.getCompletedCount() \u2014 jobs in \"completed\" state
  • Failed: await queue.getFailedCount() \u2014 jobs in \"failed\" state
"},{"location":"v2/frontend/pages/admin/email-queue-page/#pause-queue","title":"Pause Queue","text":"

Request:

await api.post('/email-queue/pause');\n

Response (200 OK):

{\n  \"message\": \"Queue paused\"\n}\n

Backend Implementation:

await queue.pause();\nreturn { message: 'Queue paused' };\n

Effect: - Queue stops picking up new jobs from \"waiting\" state - Currently active jobs continue to completion - New jobs can still be added to queue (will wait until resumed)

"},{"location":"v2/frontend/pages/admin/email-queue-page/#resume-queue","title":"Resume Queue","text":"

Request:

await api.post('/email-queue/resume');\n

Response (200 OK):

{\n  \"message\": \"Queue resumed\"\n}\n

Backend Implementation:

await queue.resume();\nreturn { message: 'Queue resumed' };\n

Effect: - Queue starts picking up jobs from \"waiting\" state - Workers process jobs according to concurrency setting (default: 1 job at a time)

"},{"location":"v2/frontend/pages/admin/email-queue-page/#clean-completed-jobs","title":"Clean Completed Jobs","text":"

Request:

const { data } = await api.post<{ cleaned: number }>('/email-queue/clean');\n

Response (200 OK):

{\n  \"cleaned\": 1487,\n  \"message\": \"Cleaned 1487 completed jobs\"\n}\n

Response Fields: - cleaned (number): Number of jobs removed from Redis - message (string): Confirmation message

Backend Implementation:

const completedJobs = await queue.getCompleted();\nawait queue.clean(0, 'completed');  // Remove all completed jobs\nreturn {\n  cleaned: completedJobs.length,\n  message: `Cleaned ${completedJobs.length} completed jobs`,\n};\n

Important: This only removes jobs in \"completed\" state. Failed jobs are preserved for troubleshooting.

"},{"location":"v2/frontend/pages/admin/email-queue-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#complete-pauseresume-flow","title":"Complete Pause/Resume Flow","text":"
const handlePauseResume = useCallback(async () => {\n  if (!stats) return;  // Guard: stats must be loaded\n  setActionLoading(true);\n  try {\n    // Determine action based on current state\n    const action = stats.paused ? 'resume' : 'pause';\n\n    // Send POST request\n    await api.post(`/email-queue/${action}`);\n\n    // Show success message\n    message.success(`Queue ${action}d`);\n\n    // Refresh statistics to reflect new state\n    fetchStats();\n  } catch {\n    message.error('Action failed');\n  } finally {\n    setActionLoading(false);\n  }\n}, [stats, fetchStats]);\n

Key Steps: 1. Guard clause: Ensure stats are loaded before determining action 2. Conditional action: Pause if running, resume if paused 3. Dynamic message: Show \"Queue paused\" or \"Queue resumed\" 4. Refresh stats: Update UI to reflect new queue state 5. Error handling: Generic error message (no sensitive details) 6. Always clear loading state in finally block

"},{"location":"v2/frontend/pages/admin/email-queue-page/#clean-old-jobs-flow","title":"Clean Old Jobs Flow","text":"
const handleClean = useCallback(async () => {\n  setActionLoading(true);\n  try {\n    // Send clean request\n    const { data } = await api.post<{ cleaned: number }>('/email-queue/clean');\n\n    // Show detailed success message\n    message.success(`Cleaned ${data.cleaned} completed jobs`);\n\n    // Refresh statistics (completed count should be 0 now)\n    fetchStats();\n  } catch {\n    message.error('Clean failed');\n  } finally {\n    setActionLoading(false);\n  }\n}, [fetchStats]);\n

Key Steps: 1. Set loading state before API call 2. Extract cleaned count from response 3. Show specific count in success message (confirms action worked) 4. Refresh stats to update completed count (should drop to 0) 5. Generic error message on failure

"},{"location":"v2/frontend/pages/admin/email-queue-page/#auto-refresh-setup","title":"Auto-Refresh Setup","text":"
useEffect(() => {\n  // Initial load on mount\n  fetchStats();\n\n  // Set up 10-second auto-refresh interval\n  const interval = setInterval(fetchStats, 10_000);\n\n  // Cleanup interval on unmount (prevents memory leak)\n  return () => {\n    clearInterval(interval);\n    console.log('Email queue auto-refresh stopped');\n  };\n}, [fetchStats]);\n

Cleanup Importance:

If interval is not cleared on unmount: - Memory leak (interval continues running in background) - API calls continue even after user navigates away - Multiple overlapping intervals if user returns to page

"},{"location":"v2/frontend/pages/admin/email-queue-page/#header-actions-with-usememo","title":"Header Actions with useMemo","text":"
const headerActions = useMemo(() => (\n  <Space>\n    {stats && (\n      <Tag color={stats.paused ? 'orange' : 'green'}>\n        {stats.paused ? 'PAUSED' : 'RUNNING'}\n      </Tag>\n    )}\n    <Button icon={<ReloadOutlined />} onClick={fetchStats} loading={loading}>\n      Refresh\n    </Button>\n    <Button\n      icon={stats?.paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}\n      onClick={handlePauseResume}\n      loading={actionLoading}\n    >\n      {stats?.paused ? 'Resume' : 'Pause'}\n    </Button>\n    <Button\n      icon={<DeleteOutlined />}\n      onClick={handleClean}\n      loading={actionLoading}\n    >\n      Clean Old Jobs\n    </Button>\n  </Space>\n), [stats, loading, actionLoading, fetchStats, handlePauseResume, handleClean]);\n\nuseEffect(() => {\n  setPageHeader({ title: 'Email Queue', actions: headerActions });\n  return () => setPageHeader(null);\n}, [setPageHeader, headerActions]);\n

Why useMemo?

  • Prevents infinite loop: Header actions passed to setPageHeader, which triggers useEffect if reference changes
  • Optimized re-renders: Only re-creates actions when dependencies change (stats, loading states, handler functions)
  • Stable reference: Ensures AppLayout doesn't unnecessarily re-render header
"},{"location":"v2/frontend/pages/admin/email-queue-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#10-second-auto-refresh","title":"10-Second Auto-Refresh","text":"

Queue statistics update every 10 seconds:

const interval = setInterval(fetchStats, 10_000);\n

Performance Impact: - API Load: 1 request per 10 seconds = 6 requests/minute (very manageable) - Redis Queries: Each stats request queries Redis (fast, <10ms) - Network: Minimal payload (~100 bytes JSON response)

Comparison to Dashboard: - Dashboard: 30-second refresh, 6 API calls in parallel - Email Queue: 10-second refresh, 1 API call - Email Queue is more frequent but lighter weight

"},{"location":"v2/frontend/pages/admin/email-queue-page/#shared-action-loading-state","title":"Shared Action Loading State","text":"

All action buttons share single actionLoading state:

const [actionLoading, setActionLoading] = useState(false);\n\n<Button loading={actionLoading} onClick={handlePauseResume}>Pause</Button>\n<Button loading={actionLoading} onClick={handleClean}>Clean Old Jobs</Button>\n

Why Shared State?

  • Simplicity: Fewer state variables to manage
  • Prevent concurrent actions: User cannot pause and clean simultaneously
  • UI clarity: All action buttons disabled during any action

Trade-off:

User cannot trigger multiple actions at once (e.g., pause + clean). This is acceptable because: - Actions are fast (< 1 second) - Concurrent actions could cause conflicts (pausing while cleaning) - Simpler mental model for user

"},{"location":"v2/frontend/pages/admin/email-queue-page/#silent-refresh","title":"Silent Refresh","text":"

Auto-refresh doesn't show loading spinner:

const fetchStats = useCallback(async () => {\n  setLoading(true);  // But this doesn't affect UI during auto-refresh\n  try {\n    const { data } = await api.get<QueueStats>('/email-queue/stats');\n    setStats(data);\n  } catch {\n    message.error('Failed to load queue stats');\n  } finally {\n    setLoading(false);\n  }\n}, []);\n

Why Silent?

  • No UI flicker: Statistics update smoothly without visual distraction
  • Better UX: User can read numbers without interruption
  • Manual refresh shows loading: Clicking \"Refresh\" button shows loading spinner on button
"},{"location":"v2/frontend/pages/admin/email-queue-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#mobile-layout","title":"Mobile Layout","text":"

Statistics cards adapt to mobile viewports:

<Row gutter={[16, 16]}>\n  <Col xs={12} sm={6}>  {/* Half width mobile, quarter width desktop */}\n    <Card><Statistic title=\"Waiting\" value={23} /></Card>\n  </Col>\n  {/* Repeat for other cards */}\n</Row>\n

Responsive Grid: - Mobile (xs, <576px): 2\u00d72 grid (Waiting/Active on row 1, Completed/Failed on row 2) - Tablet/Desktop (sm+, \u2265576px): 1\u00d74 grid (all cards in single row)

"},{"location":"v2/frontend/pages/admin/email-queue-page/#header-actions_1","title":"Header Actions","text":"

Header actions are part of AppLayout's page header:

useEffect(() => {\n  setPageHeader({ title: 'Email Queue', actions: headerActions });\n  return () => setPageHeader(null);\n}, [setPageHeader, headerActions]);\n

Mobile Behavior:

AppLayout automatically collapses header actions into hamburger menu on mobile: - Desktop: Actions visible in header - Mobile: Actions in dropdown menu (hamburger icon)

"},{"location":"v2/frontend/pages/admin/email-queue-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

All interactive elements are keyboard-accessible:

Buttons: - Tab: Focus on next button (Refresh \u2192 Pause \u2192 Clean) - Enter/Space: Activate focused button - Escape: Blur focused button

Auto-refresh: - No keyboard interaction needed (automatic updates)

"},{"location":"v2/frontend/pages/admin/email-queue-page/#screen-reader-support","title":"Screen Reader Support","text":"

All elements have proper ARIA labels:

Statistics Cards:

<Statistic\n  title=\"Waiting\"\n  value={stats?.waiting ?? 0}\n  aria-label={`${stats?.waiting ?? 0} waiting jobs`}\n/>\n

Status Tag:

<Tag\n  color={stats.paused ? 'orange' : 'green'}\n  aria-label={`Queue status: ${stats.paused ? 'paused' : 'running'}`}\n>\n  {stats.paused ? 'PAUSED' : 'RUNNING'}\n</Tag>\n

Action Buttons:

<Button\n  icon={<ReloadOutlined />}\n  onClick={fetchStats}\n  aria-label=\"Refresh queue statistics\"\n>\n  Refresh\n</Button>\n

"},{"location":"v2/frontend/pages/admin/email-queue-page/#color-contrast","title":"Color Contrast","text":"

All color-coded elements meet WCAG AA standards:

Statistic Values: - Waiting (blue): #1890ff on white = 4.5:1 contrast (AA) - Active (green): #52c41a on white = 3.0:1 contrast (AA for large text) - Completed (gray): rgba(0,0,0,0.85) on white = 13.6:1 contrast (AAA) - Failed (red): #ff4d4f on white = 4.5:1 contrast (AA)

Status Tags: - RUNNING (green): #52c41a background with white text = 3.5:1 contrast (AA for large text) - PAUSED (orange): #fa8c16 background with white text = 3.2:1 contrast (AA for large text)

"},{"location":"v2/frontend/pages/admin/email-queue-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#statistics-not-updating","title":"Statistics Not Updating","text":"

Problem: Navigate to Email Queue page, statistics load initially, but don't update after 10 seconds.

Diagnosis:

Check browser console for errors:

// Expected: No errors every 10 seconds\n// If errors appear every 10 seconds, auto-refresh is running but failing\nGET /api/email-queue/stats 401 Unauthorized\n

Possible Causes:

  1. JWT token expired:
  2. Access token expired, refresh token not working
  3. User needs to log out and log back in

  4. Interval cleared prematurely:

  5. Component unmounted and remounted (React Strict Mode in development)
  6. useEffect cleanup called too early

  7. Backend API down:

  8. API container not running
  9. Email queue service crashed

Solution:

  1. For token issues:
  2. Refresh page to trigger token refresh
  3. If that fails, log out and log back in
  4. Check JWT_ACCESS_SECRET and JWT_REFRESH_SECRET env vars

  5. For interval issues:

  6. Accept that development mode unmounts/remounts components
  7. Ensure production build works correctly (no double mounting)

  8. For backend issues:

  9. Check API container: docker compose ps api
  10. Check API logs: docker compose logs -f api | grep email-queue
  11. Restart API: docker compose restart api
"},{"location":"v2/frontend/pages/admin/email-queue-page/#pause-button-not-working","title":"Pause Button Not Working","text":"

Problem: Click \"Pause\" button, success message appears, but queue status remains \"RUNNING\" (green).

Diagnosis:

Check API logs:

docker compose logs -f api | grep \"Queue pause\"\n

Expected output:

API: Queue paused successfully\n

Actual output:

API: Error pausing queue: Queue not initialized\n

Possible Causes:

  1. BullMQ queue not initialized:
  2. Email queue service failed to start
  3. Redis connection error during queue initialization

  4. Redis connection lost:

  5. Redis container down
  6. Network connectivity issue between API and Redis

  7. Multiple workers:

  8. Multiple API containers running, only one paused
  9. Other workers continue processing jobs

Solution:

  1. For queue initialization:
  2. Check email queue service: docker compose logs api | grep \"Email queue service\"
  3. Expected: \"Email queue service started successfully\"
  4. If missing, check Redis connection

  5. For Redis issues:

  6. Check Redis container: docker compose ps redis
  7. Test Redis connection: docker compose exec redis redis-cli PING
  8. Expected: \"PONG\"
  9. If down, restart: docker compose restart redis

  10. For multiple workers:

  11. Check running API containers: docker compose ps api
  12. Scale down to single instance: docker compose up -d --scale api=1
"},{"location":"v2/frontend/pages/admin/email-queue-page/#failed-count-increasing","title":"\"Failed\" Count Increasing","text":"

Problem: \"Failed\" count increases from 5 to 50 over time.

Diagnosis:

Check failed jobs in Redis:

docker compose exec redis redis-cli\n> LRANGE bull:email-queue:failed 0 -1\n

Check API logs for failure reasons:

docker compose logs -f api | grep \"Email job failed\"\n

Common error messages:

Email job failed: SMTP connection timeout\nEmail job failed: Authentication failed (535)\nEmail job failed: Recipient address rejected\n

Possible Causes:

  1. SMTP server issues:
  2. SMTP server down (connection timeout)
  3. Invalid credentials (authentication failed)
  4. Rate limiting (too many emails sent)

  5. Invalid recipient addresses:

  6. Email addresses with typos
  7. Non-existent domains
  8. Blocked by recipient server

  9. Network connectivity:

  10. Firewall blocking SMTP ports (25, 587, 465)
  11. DNS resolution failure

Solution:

  1. For SMTP server issues:
  2. Test SMTP connection: Navigate to /app/settings, click \"Test Connection\"
  3. Update SMTP credentials if authentication failed
  4. Wait 5 minutes if rate limited, then resume queue

  5. For invalid addresses:

  6. Review campaign email list: Navigate to /app/influence/campaigns
  7. Check representative email addresses: Navigate to /app/influence/representatives
  8. Delete invalid addresses or update to correct ones

  9. For network issues:

  10. Check firewall rules: sudo iptables -L | grep 587
  11. Test DNS: nslookup smtp.protonmail.ch
  12. Test SMTP port: telnet smtp.protonmail.ch 587
"},{"location":"v2/frontend/pages/admin/email-queue-page/#clean-button-removes-all-jobs","title":"Clean Button Removes All Jobs","text":"

Problem: Click \"Clean Old Jobs\" expecting to remove only completed jobs, but all jobs disappear (waiting + completed).

Diagnosis:

Check API logs:

docker compose logs api | grep \"clean\"\n

Expected:

Cleaned 1487 completed jobs\n

Actual:

Cleaned 1500 jobs (completed + waiting + failed)\n

Possible Causes:

  1. Backend bug:
  2. Clean endpoint removing all job types, not just completed
  3. BullMQ clean() called with wrong parameters

  4. User misunderstanding:

  5. Clean button label unclear (should say \"Clean Completed Jobs\")
  6. User expected to remove failed jobs too

Solution:

  1. For backend bug (developer fix):
  2. Update clean endpoint to only remove completed jobs:
    await queue.clean(0, 'completed');  // Only completed, not 'failed' or 'waiting'\n
  3. Test: Add jobs to queue, click clean, verify waiting/failed jobs remain

  4. For unclear UI:

  5. Update button label: \"Clean Old Jobs\" \u2192 \"Clean Completed Jobs\"
  6. Add tooltip: \"Removes completed jobs from queue. Failed and waiting jobs are preserved.\"
"},{"location":"v2/frontend/pages/admin/email-queue-page/#statistics-show-wrong-counts","title":"Statistics Show Wrong Counts","text":"

Problem: \"Completed\" count shows 1,487, but only 500 emails were sent.

Diagnosis:

Check actual job counts in Redis:

docker compose exec redis redis-cli\n> LLEN bull:email-queue:completed\n

Expected: 1487 (matches UI)

Check campaign email records in database:

SELECT COUNT(*) FROM \"CampaignEmail\" WHERE status = 'SENT';\n

Result: 500 (mismatch with Redis count)

Possible Causes:

  1. Duplicate jobs:
  2. Multiple jobs created for same email
  3. Job retry logic creating duplicates

  4. Test jobs:

  5. Developer testing created many jobs
  6. Test mode emails counting toward total

  7. Redis not cleaned:

  8. Completed jobs from previous campaigns still in Redis
  9. Clean operation not run in months

Solution:

  1. For duplicates:
  2. Investigate job creation logic in CampaignsPage
  3. Ensure single job created per campaign email
  4. Add deduplication: Check if job already exists before creating

  5. For test jobs:

  6. Clean queue after testing: Click \"Clean Old Jobs\"
  7. Use separate test queue (not production queue)

  8. For stale jobs:

  9. Run clean operation regularly (weekly)
  10. Consider auto-clean after 30 days (backend cron job)
"},{"location":"v2/frontend/pages/admin/email-queue-page/#related-documentation","title":"Related Documentation","text":"
  • Email Queue Backend Module \u2014 Backend email queue service
  • Email Queue API Reference \u2014 Queue API endpoints
  • Email Service \u2014 Email sending service (Nodemailer)
  • Email Queue Service \u2014 BullMQ queue + worker
  • CampaignsPage \u2014 Campaign management (triggers email jobs)
  • Campaign Emails Drawer \u2014 Email stats drawer (links to queue page)
  • SettingsPage \u2014 SMTP configuration
  • BullMQ Documentation \u2014 Official BullMQ docs
  • Troubleshooting: Email Issues \u2014 Email troubleshooting guide
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/","title":"EmailTemplateEditorPage","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#overview","title":"Overview","text":"

File: admin/src/pages/EmailTemplateEditorPage.tsx Route: /app/email-templates/:id/edit Role Requirements: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

EmailTemplateEditorPage is a full-screen Monaco code editor for editing email templates. It provides a split-pane interface with separate editors for HTML and plain text content, real-time preview with sample data, and a variables reference panel. The editor supports Ctrl+S keyboard shortcuts, test email sending, and mobile device detection.

The page displays: - Top toolbar with template metadata (name, category, system status) and action buttons - Subject line input with variable support - Dual Monaco editors (HTML + text) side-by-side - Right sidebar with tabs: Variables, HTML Preview, Text Preview - Sample data inputs for preview rendering - Mobile warning screen (desktop required)

Key Components: - Monaco Editor (@monaco-editor/react) for syntax-highlighted code editing - Ant Design theme tokens for consistent styling - Three-tab right panel (Variables table, HTML iframe preview, Text pre block) - TestEmailModal for sending test emails - Full-screen layout (no AppLayout wrapper)

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#screenshot","title":"Screenshot","text":"

[Screenshot: EmailTemplateEditorPage showing full-screen layout with top toolbar (template name, category tag, Test Email and Save buttons), subject line input, two Monaco editors side-by-side (HTML content on left, plain text on right), and right sidebar with Variables/HTML Preview/Text Preview tabs. Desktop-only interface with dark theme editors.]

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#core-features","title":"Core Features","text":"
  1. Dual Editor Layout
  2. Left pane (40% width): HTML content editor with syntax highlighting
  3. Center pane (40% width): Plain text content editor
  4. Right pane (20% width): Variables reference + previews
  5. VS Dark theme for all Monaco editors
  6. Line numbers, word wrap, no minimap for clean editing

  7. Subject Line Editor

  8. Input field with envelope icon
  9. Supports variable interpolation (e.g., {{CAMPAIGN_NAME}})
  10. Large size input for visibility
  11. Saved together with HTML/text content

  12. Variables Reference Panel

  13. Table showing all template variables with columns:
    • Variable: Code format (e.g., {{FIRST_NAME}})
    • Label: Human-readable name
    • Description: Usage explanation
    • Required: Red \"Required\" or gray \"Optional\" tag
  14. Sample data input fields for preview
  15. Persists sample values during editing session

  16. Real-Time Previews

  17. HTML Preview Tab: Sandboxed iframe rendering processed HTML
  18. Text Preview Tab: Pre-formatted text block with styling
  19. Live updates when sample data changes
  20. Variable interpolation uses simple string replacement

  21. Save Operations

  22. Save button in toolbar (primary, blue)
  23. Ctrl+S (or Cmd+S on Mac) keyboard shortcut
  24. Creates new version in database
  25. Success message on save
  26. Updates template timestamp

  27. Test Email Functionality

  28. Test Email button opens TestEmailModal
  29. Fill in variable values and recipient
  30. Sends email with current editor content (not saved)
  31. Success message on send

  32. Template Metadata Display

  33. Template name in toolbar
  34. Category tag (color-coded: blue=Influence, green=Map, purple=System)
  35. System template indicator (blue SYSTEM tag)
  36. Back button to return to templates list

  37. Mobile Detection

  38. Detects screens < 768px (md breakpoint)
  39. Shows warning Result component
  40. \"Desktop Required\" message
  41. Back button to return to templates list

  42. Dark Theme Editor

  43. VS Dark Monaco theme
  44. Consistent with code editor expectations
  45. High contrast for readability
  46. Token colors from Ant Design theme
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#opening-editor","title":"Opening Editor","text":"
  1. Navigate from templates list: Click Edit button on EmailTemplatesPage
  2. Route loads: /app/email-templates/:id/edit
  3. Template fetches: Loading spinner while fetching template data
  4. Editor displays: Full-screen layout with template content
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#editing-template","title":"Editing Template","text":"
  1. Modify subject line: Type in top input field, use {{VARIABLES}} as needed
  2. Edit HTML content: Click in left Monaco editor, write HTML markup
  3. Edit text content: Click in center Monaco editor, write plain text
  4. Check syntax: Monaco provides HTML syntax highlighting and error detection
  5. Save changes: Click Save button or press Ctrl+S
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#using-variables","title":"Using Variables","text":"
  1. View variables table: Click Variables tab in right sidebar
  2. Check variable syntax: Copy {{VARIABLE_NAME}} from table
  3. Insert in content: Paste into subject line, HTML, or text editor
  4. Mark required variables: Red \"Required\" tag indicates mandatory variables
  5. Reference descriptions: Read description column for usage guidance
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#previewing-changes","title":"Previewing Changes","text":"
  1. Enter sample data: In Variables tab, fill in input fields below table
  2. Switch to preview: Click \"HTML Preview\" or \"Text Preview\" tab
  3. View rendered output: Iframe shows HTML with variables replaced
  4. Update sample data: Change input values to see different renderings
  5. Verify output: Check that variables interpolate correctly
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#testing-email","title":"Testing Email","text":"
  1. Click Test Email button: Opens TestEmailModal
  2. Fill in variables: Enter values for each template variable
  3. Enter recipient email: Provide test email address
  4. Send test: Click Send button
  5. Check inbox: Verify email received (or MailHog in dev mode)
  6. Review formatting: Check HTML rendering in email client
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#saving-template","title":"Saving Template","text":"
  1. Make changes: Edit subject, HTML, or text content
  2. Save with Ctrl+S: Keyboard shortcut (or click Save button)
  3. Loading state: Save button shows spinner
  4. Success message: \"Template saved successfully\" notification
  5. New version created: Template version history incremented
  6. Continue editing: Can continue making changes and saving again
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#returning-to-list","title":"Returning to List","text":"
  1. Click back button: Arrow icon in top-left of toolbar
  2. Navigate back: Browser back button also works
  3. Unsaved changes: No confirmation prompt (consider implementing)
  4. Route change: Returns to /app/email-templates
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#top-toolbar","title":"Top Toolbar","text":"
<div\n  style={{\n    display: 'flex',\n    alignItems: 'center',\n    justifyContent: 'space-between',\n    padding: '8px 16px',\n    borderBottom: `1px solid ${token.colorBorderSecondary}`,\n    flexShrink: 0,\n  }}\n>\n  <Space>\n    <Button\n      type=\"text\"\n      icon={<ArrowLeftOutlined />}\n      onClick={() => navigate('/app/email-templates')}\n    />\n    <Text strong>{template.name}</Text>\n    <Tag color={getCategoryColor(template.category)}>{template.category}</Tag>\n    {template.isSystem && <Tag color=\"blue\">SYSTEM</Tag>}\n  </Space>\n  <Space>\n    <Button onClick={() => setTestModalOpen(true)} icon={<SendOutlined />}>\n      Test Email\n    </Button>\n    <Button type=\"primary\" loading={saving} onClick={handleSave} icon={<SaveOutlined />}>\n      Save\n    </Button>\n  </Space>\n</div>\n

Layout: - Left: Back button + template metadata (name, category, system status) - Right: Test Email + Save buttons - Height: ~40px (shrinks to fit content) - Border bottom for visual separation

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#subject-line-input","title":"Subject Line Input","text":"
<div style={{ padding: '12px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>\n  <Input\n    value={subjectLine}\n    onChange={(e) => setSubjectLine(e.target.value)}\n    placeholder=\"Email Subject Line (use {{VARIABLES}})\"\n    prefix={<MailOutlined />}\n    size=\"large\"\n  />\n</div>\n

Props: - value: Controlled input with subjectLine state - placeholder: Explains variable syntax - prefix: Envelope icon for visual context - size=\"large\": 40px height for prominence

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#html-editor-monaco","title":"HTML Editor (Monaco)","text":"
<Editor\n  height=\"100%\"\n  language=\"html\"\n  theme=\"vs-dark\"\n  value={htmlContent}\n  onChange={(value) => setHtmlContent(value || '')}\n  options={{\n    minimap: { enabled: false },\n    fontSize: 14,\n    wordWrap: 'on',\n    lineNumbers: 'on',\n    scrollBeyondLastLine: false,\n  }}\n/>\n

Options: - minimap: false - No code minimap (saves space) - fontSize: 14 - Readable code size - wordWrap: 'on' - Wrap long lines instead of horizontal scroll - lineNumbers: 'on' - Show line numbers for reference - scrollBeyondLastLine: false - Don't scroll past last line

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#text-editor-monaco","title":"Text Editor (Monaco)","text":"
<Editor\n  height=\"100%\"\n  language=\"plaintext\"\n  theme=\"vs-dark\"\n  value={textContent}\n  onChange={(value) => setTextContent(value || '')}\n  options={{\n    minimap: { enabled: false },\n    fontSize: 14,\n    wordWrap: 'on',\n    lineNumbers: 'on',\n    scrollBeyondLastLine: false,\n  }}\n/>\n

Same options as HTML editor but language is plaintext (no syntax highlighting).

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#variables-table","title":"Variables Table","text":"
<Table\n  dataSource={template.variables}\n  columns={variableColumns}\n  rowKey=\"id\"\n  size=\"small\"\n  pagination={false}\n/>\n

Columns:

const variableColumns = [\n  {\n    title: 'Variable',\n    dataIndex: 'key',\n    render: (key: string) => <Text code>{'{{' + key + '}}'}</Text>,\n  },\n  {\n    title: 'Label',\n    dataIndex: 'label',\n  },\n  {\n    title: 'Description',\n    dataIndex: 'description',\n    render: (desc: string | null) => <Text type=\"secondary\">{desc || '\u2014'}</Text>,\n  },\n  {\n    title: 'Required',\n    dataIndex: 'isRequired',\n    render: (isRequired: boolean) => (\n      isRequired ? <Tag color=\"red\">Required</Tag> : <Tag>Optional</Tag>\n    ),\n  },\n];\n

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#sample-data-inputs","title":"Sample Data Inputs","text":"
<div style={{ marginTop: 16 }}>\n  <Text strong>Sample Data (for preview):</Text>\n  {template.variables.map((v) => (\n    <div key={v.key} style={{ marginTop: 8 }}>\n      <Text type=\"secondary\" style={{ fontSize: 12 }}>\n        {v.label}\n      </Text>\n      <Input\n        size=\"small\"\n        value={sampleData[v.key] || ''}\n        onChange={(e) => setSampleData({ ...sampleData, [v.key]: e.target.value })}\n        placeholder={v.sampleValue || ''}\n      />\n    </div>\n  ))}\n</div>\n

Pattern: One input per variable, labeled with variable label, placeholder shows default sample value.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#html-preview-iframe","title":"HTML Preview Iframe","text":"
<iframe\n  srcDoc={processedHtml}\n  style={{\n    width: '100%',\n    height: '100%',\n    border: `1px solid ${token.colorBorder}`,\n    borderRadius: 4,\n  }}\n  sandbox=\"allow-same-origin\"\n  title=\"HTML Preview\"\n/>\n

Security: sandbox=\"allow-same-origin\" restricts iframe capabilities (no scripts, no forms).

srcDoc prop: Renders inline HTML without external URL.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#text-preview-block","title":"Text Preview Block","text":"
<pre\n  style={{\n    whiteSpace: 'pre-wrap',\n    fontFamily: 'monospace',\n    fontSize: 12,\n    lineHeight: 1.5,\n    padding: 12,\n    backgroundColor: token.colorBgLayout,\n    borderRadius: 4,\n    border: `1px solid ${token.colorBorder}`,\n  }}\n>\n  {processedText}\n</pre>\n

Styling: - whiteSpace: 'pre-wrap' - Preserve whitespace but wrap long lines - fontFamily: 'monospace' - Fixed-width font like email clients use - Background color for contrast

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#template-processing-function","title":"Template Processing Function","text":"
const processTemplate = (content: string, data: Record<string, string>): string => {\n  let processed = content;\n  Object.entries(data).forEach(([key, value]) => {\n    processed = processed.replace(new RegExp(`{{${key}}}`, 'g'), value);\n  });\n  return processed;\n};\n

Usage:

const processedHtml = processTemplate(htmlContent, sampleData);\nconst processedText = processTemplate(textContent, sampleData);\n

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#local-state","title":"Local State","text":"

Template Data:

const [template, setTemplate] = useState<EmailTemplate | null>(null);\nconst [loading, setLoading] = useState(true);\n

Editor Content:

const [subjectLine, setSubjectLine] = useState('');\nconst [htmlContent, setHtmlContent] = useState('');\nconst [textContent, setTextContent] = useState('');\n

Sample Data for Preview:

const [sampleData, setSampleData] = useState<Record<string, string>>({});\n

UI State:

const [saving, setSaving] = useState(false);\nconst [activeTab, setActiveTab] = useState('variables');\nconst [testModalOpen, setTestModalOpen] = useState(false);\n

Responsive State:

const screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#data-fetching","title":"Data Fetching","text":"

Fetch Template on Mount:

useEffect(() => {\n  const fetchTemplate = async () => {\n    try {\n      const { data } = await api.get<EmailTemplate>(`/email-templates/${id}`);\n      setTemplate(data);\n      setSubjectLine(data.subjectLine);\n      setHtmlContent(data.htmlContent);\n      setTextContent(data.textContent);\n\n      // Initialize sample data from variables\n      const initialSampleData: Record<string, string> = {};\n      data.variables.forEach((v) => {\n        initialSampleData[v.key] = v.sampleValue || '';\n      });\n      setSampleData(initialSampleData);\n    } catch {\n      message.error('Failed to load template');\n      navigate('/app/email-templates');\n    } finally {\n      setLoading(false);\n    }\n  };\n  fetchTemplate();\n}, [id, navigate]);\n

Error Handling: Redirect to templates list if template not found.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#save-handler","title":"Save Handler","text":"
const handleSave = useCallback(async () => {\n  if (!template) return;\n  setSaving(true);\n  try {\n    const { data: updated } = await api.put<EmailTemplate>(`/email-templates/${id}`, {\n      subjectLine,\n      htmlContent,\n      textContent,\n    });\n    setTemplate(updated);\n    message.success('Template saved successfully');\n  } catch (err: unknown) {\n    const msg =\n      (err as { response?: { data?: { error?: string } } })?.response?.data?.error ||\n      'Failed to save template';\n    message.error(msg);\n  } finally {\n    setSaving(false);\n  }\n}, [template, id, subjectLine, htmlContent, textContent]);\n
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#keyboard-shortcut","title":"Keyboard Shortcut","text":"
useEffect(() => {\n  const handler = (e: KeyboardEvent) => {\n    if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n      e.preventDefault();\n      handleSave();\n    }\n  };\n  window.addEventListener('keydown', handler);\n  return () => window.removeEventListener('keydown', handler);\n}, [handleSave]);\n

Why e.preventDefault()? Prevents browser's default \"Save Page\" dialog.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#endpoints-used","title":"Endpoints Used","text":"

GET /email-templates/:id - Fetch template

const { data } = await api.get<EmailTemplate>(`/email-templates/${id}`);\n

Response:

{\n  \"id\": \"tmpl_123\",\n  \"key\": \"campaign_email\",\n  \"name\": \"Campaign Email\",\n  \"category\": \"INFLUENCE\",\n  \"subjectLine\": \"Take action on {{CAMPAIGN_NAME}}\",\n  \"htmlContent\": \"<html><body><h1>{{CAMPAIGN_NAME}}</h1><p>{{MESSAGE}}</p></body></html>\",\n  \"textContent\": \"{{CAMPAIGN_NAME}}\\n\\n{{MESSAGE}}\",\n  \"isActive\": true,\n  \"isSystem\": false,\n  \"variables\": [\n    {\n      \"id\": \"var_1\",\n      \"key\": \"CAMPAIGN_NAME\",\n      \"label\": \"Campaign Name\",\n      \"description\": \"Name of the campaign\",\n      \"isRequired\": true,\n      \"sampleValue\": \"Stop Deforestation\"\n    },\n    {\n      \"id\": \"var_2\",\n      \"key\": \"MESSAGE\",\n      \"label\": \"Message\",\n      \"description\": \"Main message content\",\n      \"isRequired\": true,\n      \"sampleValue\": \"Join us in protecting our forests.\"\n    }\n  ],\n  \"createdAt\": \"2026-01-15T10:00:00Z\",\n  \"updatedAt\": \"2026-02-10T14:30:00Z\"\n}\n

PUT /email-templates/:id - Update template

const { data: updated } = await api.put<EmailTemplate>(`/email-templates/${id}`, {\n  subjectLine: \"Updated subject with {{VARIABLE}}\",\n  htmlContent: \"<html>...</html>\",\n  textContent: \"Plain text...\",\n});\n

Response: Returns updated EmailTemplate object with new updatedAt timestamp.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#keyboard-shortcut-pattern","title":"Keyboard Shortcut Pattern","text":"
const handleSave = useCallback(async () => {\n  // Save logic\n}, [/* dependencies */]);\n\nuseEffect(() => {\n  const handler = (e: KeyboardEvent) => {\n    if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n      e.preventDefault();\n      handleSave();\n    }\n  };\n  window.addEventListener('keydown', handler);\n  return () => window.removeEventListener('keydown', handler);\n}, [handleSave]);\n

Pattern: 1. Use useCallback for save handler with dependencies 2. Add keyboard event listener in useEffect 3. Check Ctrl/Cmd + S key combination 4. Call preventDefault to stop browser save dialog 5. Clean up listener on unmount

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#variable-interpolation","title":"Variable Interpolation","text":"
const processTemplate = (content: string, data: Record<string, string>): string => {\n  let processed = content;\n  Object.entries(data).forEach(([key, value]) => {\n    processed = processed.replace(new RegExp(`{{${key}}}`, 'g'), value);\n  });\n  return processed;\n};\n\n// Usage\nconst processedHtml = processTemplate(htmlContent, sampleData);\n// Input: \"<h1>{{CAMPAIGN_NAME}}</h1>\"\n// Sample data: { CAMPAIGN_NAME: \"Save the Planet\" }\n// Output: \"<h1>Save the Planet</h1>\"\n

Note: This is a simple string replacement for preview. Production email sending uses server-side template engine with proper escaping.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#mobile-detection","title":"Mobile Detection","text":"
const screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n\nif (isMobile) {\n  return (\n    <Result\n      status=\"warning\"\n      title=\"Desktop Required\"\n      subTitle=\"The email template editor requires a desktop browser.\"\n      extra={\n        <Button type=\"primary\" onClick={() => navigate('/app/email-templates')}>\n          Back to Templates\n        </Button>\n      }\n    />\n  );\n}\n

Breakpoint: md = 768px (Ant Design standard)

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#sample-data-initialization","title":"Sample Data Initialization","text":"
// Initialize sample data from template variables\nconst initialSampleData: Record<string, string> = {};\ndata.variables.forEach((v) => {\n  initialSampleData[v.key] = v.sampleValue || '';\n});\nsetSampleData(initialSampleData);\n

Pattern: Pre-fill sample data inputs with default sample values from variable definitions.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#category-color-helper","title":"Category Color Helper","text":"
const getCategoryColor = (category: EmailTemplateCategory): string => {\n  const colors: Record<EmailTemplateCategory, string> = {\n    INFLUENCE: 'blue',\n    MAP: 'green',\n    SYSTEM: 'purple',\n  };\n  return colors[category];\n};\n

Consistent with EmailTemplatesPage color scheme.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#monaco-editor-lazy-loading","title":"Monaco Editor Lazy Loading","text":"

Monaco Editor is loaded via CDN when component mounts:

import Editor from '@monaco-editor/react';\n

Bundle size: Monaco not included in main bundle (reduces initial load).

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#usecallback-for-save-handler","title":"useCallback for Save Handler","text":"
const handleSave = useCallback(async () => { /* ... */ }, [template, id, subjectLine, htmlContent, textContent]);\n

Why: Prevents recreation on every render, essential for keyboard shortcut listener dependency.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#controlled-inputs","title":"Controlled Inputs","text":"

All three editors (subject, HTML, text) use controlled state:

<Input value={subjectLine} onChange={(e) => setSubjectLine(e.target.value)} />\n<Editor value={htmlContent} onChange={(value) => setHtmlContent(value || '')} />\n

Tradeoff: Controlled inputs = React re-renders on every keystroke, but ensures state consistency.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#iframe-preview-updates","title":"Iframe Preview Updates","text":"

Preview iframe updates only when: 1. Sample data changes 2. Editor content changes (via processedHtml dependency)

No automatic refresh timer needed.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#mobile-warning","title":"Mobile Warning","text":"
const isMobile = !screens.md;\n\nif (isMobile) {\n  return <Result status=\"warning\" title=\"Desktop Required\" />;\n}\n

Screens < 768px: Show warning, don't render editor (unusable on small screens).

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#full-screen-layout","title":"Full-Screen Layout","text":"
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>\n  {/* Toolbar */}\n  <div style={{ flexShrink: 0 }}>...</div>\n\n  {/* Editors */}\n  <div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>...</div>\n</div>\n

height: 100vh ensures full viewport height, no scrolling.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#flex-layout","title":"Flex Layout","text":"
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>\n  <div style={{ flex: '0 0 40%' }}>HTML Editor</div>\n  <div style={{ flex: '0 0 40%' }}>Text Editor</div>\n  <div style={{ flex: '0 0 20%' }}>Sidebar</div>\n</div>\n

Flex basis percentages: Fixed width columns, no shrinking/growing.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#keyboard-navigation","title":"Keyboard Navigation","text":"
  • Tab: Navigate between subject input, editors, buttons
  • Ctrl+S / Cmd+S: Save template
  • Monaco shortcuts: Ctrl+F (find), Ctrl+H (replace), Ctrl+/ (comment)
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#button-labels","title":"Button Labels","text":"
<Button icon={<SaveOutlined />}>Save</Button>\n<Button icon={<SendOutlined />}>Test Email</Button>\n

Not icon-only buttons \u2013 text labels for clarity.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#input-placeholders","title":"Input Placeholders","text":"
<Input placeholder=\"Email Subject Line (use {{VARIABLES}})\" />\n

Descriptive placeholder explains variable syntax.

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#preview-iframe-sandbox","title":"Preview Iframe Sandbox","text":"
<iframe sandbox=\"allow-same-origin\" />\n

Security: Restricts iframe capabilities (no JavaScript execution from injected HTML).

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#template-not-loading","title":"Template Not Loading","text":"

Symptoms: - Loading spinner forever - Error message \"Failed to load template\" - Redirect to templates list

Causes: 1. Invalid template ID in URL 2. API server down 3. Template deleted 4. Permission denied

Solutions:

# Check API logs\ndocker compose logs -f api\n\n# Test API endpoint\ncurl -H \"Authorization: Bearer <token>\" \\\n  http://localhost:4000/email-templates/tmpl_123\n\n# Verify template exists in database\ndocker compose exec api npx prisma studio\n# Navigate to EmailTemplate model, search by ID\n

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#save-not-working","title":"Save Not Working","text":"

Symptoms: - Clicking Save does nothing - Ctrl+S has no effect - No success/error message

Causes: 1. handleSave callback not defined 2. Keyboard listener not registered 3. Network error

Debug:

const handleSave = useCallback(async () => {\n  console.log('Save triggered');\n  console.log('Template ID:', id);\n  console.log('Content:', { subjectLine, htmlContent, textContent });\n  // ... rest of save logic\n}, [template, id, subjectLine, htmlContent, textContent]);\n

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#preview-not-updating","title":"Preview Not Updating","text":"

Symptoms: - Changing sample data doesn't update preview - Preview shows old content

Causes: 1. processTemplate function not called 2. Sample data state not updating 3. Iframe not re-rendering

Debug:

const processedHtml = processTemplate(htmlContent, sampleData);\nconsole.log('Sample data:', sampleData);\nconsole.log('Processed HTML:', processedHtml);\n

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#variables-not-showing","title":"Variables Not Showing","text":"

Symptoms: - Variables table empty - Sample data inputs not rendering

Cause: - Template has no variables defined

Expected Behavior: - If template.variables is empty array, table shows no rows - This is valid (template may not use variables)

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#mobile-warning-not-showing","title":"Mobile Warning Not Showing","text":"

Symptoms: - Editor renders on mobile (broken layout)

Cause: - Breakpoint detection not working

Debug:

const screens = Grid.useBreakpoint();\nconsole.log('Breakpoints:', screens);\nconsole.log('Is mobile:', !screens.md);\n

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#monaco-editor-blank","title":"Monaco Editor Blank","text":"

Symptoms: - Editor pane shows nothing (white/black) - No code visible

Causes: 1. Monaco CDN failed to load 2. Content is empty string 3. Height not set correctly

Solutions:

// Check if content loaded\nconsole.log('HTML content length:', htmlContent.length);\n\n// Verify Monaco loaded\nimport Editor from '@monaco-editor/react';\nconsole.log('Monaco Editor component:', Editor);\n\n// Check editor height\n<Editor height=\"100%\" /> // Ensure parent has defined height\n

"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#backend-integration","title":"Backend Integration","text":"
  • Email Templates Module - Service, schemas, routes
  • Email Templates API Reference - Full endpoint documentation
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#frontend-pages","title":"Frontend Pages","text":"
  • Email Templates Page - Template list and management
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#frontend-components","title":"Frontend Components","text":"
  • TestEmailModal Component - Test email modal
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#features_1","title":"Features","text":"
  • Email Template System - Feature overview
  • Template Variables - Variable system documentation
  • Template Versioning - Version control system
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#user-guides","title":"User Guides","text":"
  • Admin Guide - Email Templates - Template editing workflows
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#external-resources","title":"External Resources","text":"
  • Monaco Editor Documentation - Monaco API reference
  • Monaco React Documentation - React wrapper docs
"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#related-technologies","title":"Related Technologies","text":"
  • GrapesJS Editor Page - Similar full-screen editor for landing pages
  • DocsPage - Similar Monaco editor for documentation files
"},{"location":"v2/frontend/pages/admin/email-templates-page/","title":"EmailTemplatesPage","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#overview","title":"Overview","text":"

File: admin/src/pages/EmailTemplatesPage.tsx Route: /app/email-templates Role Requirements: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

EmailTemplatesPage is the email template management interface for the V2 system. It provides CRUD operations for email templates used throughout the platform (campaigns, shifts, notifications), with versioning support, test email functionality, and categorization. The page manages HTML + text content templates with variable substitution for dynamic content.

The page integrates with the email template system to provide: - Template library with search and filtering - Category organization (Influence, Map, System) - Active/inactive status management - Test email sending with sample data - Version history with rollback capability - System template protection (cannot delete)

Key Components: - Ant Design Table for template list with pagination - Input with 300ms debounced search - Select filters for category and active status - TestEmailModal for sending test emails - VersionHistoryDrawer for viewing and restoring previous versions - Action buttons (Edit, Test, Versions, Delete)

"},{"location":"v2/frontend/pages/admin/email-templates-page/#screenshot","title":"Screenshot","text":"

[Screenshot: EmailTemplatesPage showing template list with search bar, category/active filters, and table with columns for Name, Category, Subject, Active status, Updated timestamp, and Actions (Edit, Test, Versions, Delete buttons). System templates show SYSTEM tag and no delete button.]

"},{"location":"v2/frontend/pages/admin/email-templates-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#core-features","title":"Core Features","text":"
  1. Template List Management
  2. Paginated table showing all email templates (default 20 per page)
  3. Name column shows template name + key, system templates marked with blue SYSTEM tag
  4. Category column color-coded (blue=Influence, green=Map, purple=System)
  5. Subject line preview (truncated to 50 chars)
  6. Active/Inactive badge status
  7. Relative timestamp (e.g., \"2 hours ago\")
  8. Four action buttons per row (Edit, Test, Versions, Delete)

  9. Search & Filtering

  10. Real-time search by name or key (300ms debounce)
  11. Category filter dropdown (All, Influence, Map, System)
  12. Active status filter (All, Active, Inactive)
  13. Filters trigger automatic refetch with page reset to 1

  14. Template Actions

  15. Edit: Navigate to full-screen Monaco editor (/app/email-templates/:id/edit)
  16. Test: Open modal to send test email with sample data
  17. Versions: Open drawer showing version history with rollback options
  18. Delete: Popconfirm with warning (only for non-system templates)

  19. Pagination Controls

  20. Page size options: 10, 20, 50, 100
  21. Show total count (e.g., \"Total 15 templates\")
  22. Current page and page size preserved during search/filter operations

  23. System Template Protection

  24. System templates (isSystem: true) cannot be deleted
  25. Delete button hidden for system templates
  26. Blue SYSTEM tag displayed in name column

  27. Test Email Modal

  28. Fill in variable values with form inputs
  29. Send test email to specified recipient
  30. Uses template's current HTML + text content
  31. Success message on send

  32. Version History

  33. Drawer showing all historical versions
  34. View previous subject lines, HTML, and text content
  35. Rollback to any previous version
  36. Refetches template list after rollback
"},{"location":"v2/frontend/pages/admin/email-templates-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#viewing-templates","title":"Viewing Templates","text":"
  1. Navigate to page: Admin sidebar \u2192 Email \u2192 Templates
  2. Browse templates: Table shows all templates with pagination
  3. View details: Click on template name or use filters to narrow list
  4. Check status: Green \"Active\" badge = enabled, gray \"Inactive\" badge = disabled
"},{"location":"v2/frontend/pages/admin/email-templates-page/#searching-templates","title":"Searching Templates","text":"
  1. Enter search query: Type in search bar (name or key)
  2. Debounced search: 300ms delay prevents excessive API calls
  3. Clear search: Click X icon or clear input
  4. Results update: Table refreshes with matching templates, page resets to 1
"},{"location":"v2/frontend/pages/admin/email-templates-page/#filtering-templates","title":"Filtering Templates","text":"
  1. Select category: Choose from All, Influence, Map, System dropdown
  2. Select active status: Choose from All, Active, Inactive dropdown
  3. Combined filters: Search + category + active status work together
  4. Reset filters: Change dropdowns back to \"All\" or clear search
"},{"location":"v2/frontend/pages/admin/email-templates-page/#editing-template","title":"Editing Template","text":"
  1. Click Edit button: Opens full-screen Monaco editor in new route
  2. Modify content: Edit subject line, HTML, and text content
  3. Save changes: Ctrl+S or click Save button (creates new version)
  4. Return to list: Browser back button or navigate away
"},{"location":"v2/frontend/pages/admin/email-templates-page/#testing-email","title":"Testing Email","text":"
  1. Click Test button: Opens TestEmailModal
  2. Fill in variables: Enter sample data for template variables
  3. Enter recipient: Provide email address for test
  4. Send test: Click Send button
  5. Check result: Success message or error message
  6. Verify email: Check recipient inbox (or MailHog in dev mode)
"},{"location":"v2/frontend/pages/admin/email-templates-page/#viewing-version-history","title":"Viewing Version History","text":"
  1. Click Versions button: Opens VersionHistoryDrawer on right side
  2. Browse versions: See all historical versions with timestamps
  3. View version details: Expand version to see full content
  4. Rollback (if needed): Click Rollback button to restore previous version
  5. Close drawer: Click X or click outside drawer
"},{"location":"v2/frontend/pages/admin/email-templates-page/#deleting-template","title":"Deleting Template","text":"
  1. Verify not system template: Check for absence of SYSTEM tag
  2. Click Delete button: Opens Popconfirm dialog
  3. Read warning: \"This action cannot be undone\"
  4. Confirm deletion: Click OK in popconfirm
  5. Template removed: Table refreshes, template no longer shown
"},{"location":"v2/frontend/pages/admin/email-templates-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#table-component","title":"Table Component","text":"
<Table\n  columns={columns}\n  dataSource={templates}\n  rowKey=\"id\"\n  loading={loading}\n  onChange={handleTableChange}\n  pagination={{\n    current: pagination.page,\n    pageSize: pagination.limit,\n    total: pagination.total,\n    showSizeChanger: true,\n    showTotal: (total) => `Total ${total} templates`,\n    pageSizeOptions: ['10', '20', '50', '100'],\n  }}\n  scroll={{ x: 'max-content' }}\n/>\n

Column Configuration:

Column Dataindex Responsive Render Logic Name name Always visible Shows name + key, SYSTEM tag for system templates Category category Hidden on mobile (['md']) Color-coded tag (blue/green/purple) Subject subjectLine Hidden on small tablets (['lg']) Truncated to 50 chars with ellipsis Active isActive Hidden on mobile Badge (success or default) Updated updatedAt Hidden on mobile Relative time with dayjs fromNow() Actions - Fixed right, 280px Edit, Test, Versions, Delete buttons"},{"location":"v2/frontend/pages/admin/email-templates-page/#search-input","title":"Search Input","text":"
<Input\n  placeholder=\"Search by name or key...\"\n  prefix={<SearchOutlined />}\n  value={search}\n  onChange={(e) => handleSearchChange(e.target.value)}\n  style={{ width: 300 }}\n  allowClear\n/>\n

Debounce Logic:

const handleSearchChange = (value: string) => {\n  setSearch(value);  // Update input immediately\n  clearTimeout(searchTimerRef.current);\n  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);\n};\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#category-filter","title":"Category Filter","text":"
<Select\n  value={categoryFilter}\n  onChange={setCategoryFilter}\n  options={categoryOptions}\n  style={{ width: 180 }}\n/>\n

Options:

const categoryOptions = [\n  { value: 'ALL', label: 'All Categories' },\n  { value: 'INFLUENCE', label: 'Influence' },\n  { value: 'MAP', label: 'Map' },\n  { value: 'SYSTEM', label: 'System' },\n];\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#active-status-filter","title":"Active Status Filter","text":"
<Select\n  value={activeFilter}\n  onChange={setActiveFilter}\n  options={activeOptions}\n  style={{ width: 150 }}\n/>\n

Options:

const activeOptions = [\n  { value: 'ALL', label: 'All Status' },\n  { value: 'ACTIVE', label: 'Active' },\n  { value: 'INACTIVE', label: 'Inactive' },\n];\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#action-buttons","title":"Action Buttons","text":"
<Space wrap>\n  <Button\n    type=\"link\"\n    size=\"small\"\n    icon={<EditOutlined />}\n    onClick={() => navigate(`/app/email-templates/${record.id}/edit`)}\n  >\n    Edit\n  </Button>\n  <Button\n    type=\"link\"\n    size=\"small\"\n    icon={<MailOutlined />}\n    onClick={() => openTestEmailModal(record)}\n  >\n    Test\n  </Button>\n  <Button\n    type=\"link\"\n    size=\"small\"\n    icon={<HistoryOutlined />}\n    onClick={() => openVersionDrawer(record)}\n  >\n    Versions\n  </Button>\n  {!record.isSystem && (\n    <Popconfirm\n      title=\"Delete template?\"\n      description=\"This action cannot be undone.\"\n      onConfirm={() => handleDelete(record.id)}\n    >\n      <Button type=\"link\" size=\"small\" danger icon={<DeleteOutlined />}>\n        Delete\n      </Button>\n    </Popconfirm>\n  )}\n</Space>\n
"},{"location":"v2/frontend/pages/admin/email-templates-page/#testemailmodal","title":"TestEmailModal","text":"

Props: - open: boolean - Modal visibility - template: EmailTemplate - Template to test - onClose: () => void - Close callback - onSuccess: () => void - Success callback

Usage:

<TestEmailModal\n  open={testModalOpen}\n  template={selectedTemplate}\n  onClose={() => {\n    setTestModalOpen(false);\n    setSelectedTemplate(null);\n  }}\n  onSuccess={() => {\n    message.success('Test email sent successfully');\n    setTestModalOpen(false);\n    setSelectedTemplate(null);\n  }}\n/>\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#versionhistorydrawer","title":"VersionHistoryDrawer","text":"

Props: - open: boolean - Drawer visibility - templateId: string - Template ID - templateName: string - Template name for header - onClose: () => void - Close callback - onRollbackSuccess: () => void - Rollback success callback

Usage:

<VersionHistoryDrawer\n  open={versionDrawerOpen}\n  templateId={selectedTemplate.id}\n  templateName={selectedTemplate.name}\n  onClose={() => {\n    setVersionDrawerOpen(false);\n    setSelectedTemplate(null);\n  }}\n  onRollbackSuccess={() => {\n    fetchTemplates();  // Refresh list\n    setVersionDrawerOpen(false);\n    setSelectedTemplate(null);\n  }}\n/>\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#local-state","title":"Local State","text":"

Template List State:

const [templates, setTemplates] = useState<EmailTemplate[]>([]);\nconst [pagination, setPagination] = useState<PaginationMeta>({\n  page: 1,\n  limit: 20,\n  total: 0,\n  totalPages: 0\n});\nconst [loading, setLoading] = useState(false);\n

Search & Filter State:

const [search, setSearch] = useState('');  // Input value (immediate)\nconst [debouncedSearch, setDebouncedSearch] = useState('');  // API query (300ms delay)\nconst searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\nconst [categoryFilter, setCategoryFilter] = useState<EmailTemplateCategory | 'ALL'>('ALL');\nconst [activeFilter, setActiveFilter] = useState<'ALL' | 'ACTIVE' | 'INACTIVE'>('ALL');\n

Modal State:

const [testModalOpen, setTestModalOpen] = useState(false);\nconst [selectedTemplate, setSelectedTemplate] = useState<EmailTemplate | null>(null);\nconst [versionDrawerOpen, setVersionDrawerOpen] = useState(false);\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#data-fetching","title":"Data Fetching","text":"

Fetch Templates Function:

const fetchTemplates = useCallback(\n  async (params?: EmailTemplatesListParams) => {\n    setLoading(true);\n    try {\n      const { data } = await api.get<EmailTemplatesListResponse>('/email-templates', {\n        params: {\n          page: params?.page ?? 1,\n          limit: params?.limit ?? 20,\n          search: params?.search ?? (debouncedSearch || undefined),\n          category: categoryFilter !== 'ALL' ? categoryFilter : undefined,\n          isActive: activeFilter !== 'ALL' ? activeFilter === 'ACTIVE' : undefined,\n        },\n      });\n      setTemplates(data.templates);\n      setPagination(data.pagination);\n    } catch {\n      message.error('Failed to load templates');\n    } finally {\n      setLoading(false);\n    }\n  },\n  [debouncedSearch, categoryFilter, activeFilter]\n);\n

Auto-Refetch on Filter Changes:

useEffect(() => {\n  fetchTemplates({ page: 1 });\n}, [debouncedSearch, categoryFilter, activeFilter]); // Reset to page 1 on filter change\n

Debounce Cleanup:

useEffect(() => {\n  return () => clearTimeout(searchTimerRef.current);\n}, []);\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#helper-functions","title":"Helper Functions","text":"

Category Color Mapping:

const getCategoryColor = (category: EmailTemplateCategory): string => {\n  const colors: Record<EmailTemplateCategory, string> = {\n    INFLUENCE: 'blue',\n    MAP: 'green',\n    SYSTEM: 'purple',\n  };\n  return colors[category];\n};\n

Open Modal/Drawer:

const openTestEmailModal = (template: EmailTemplate) => {\n  setSelectedTemplate(template);\n  setTestModalOpen(true);\n};\n\nconst openVersionDrawer = (template: EmailTemplate) => {\n  setSelectedTemplate(template);\n  setVersionDrawerOpen(true);\n};\n

Delete Handler:

const handleDelete = async (id: string) => {\n  try {\n    await api.delete(`/email-templates/${id}`);\n    message.success('Template deleted');\n    fetchTemplates();  // Refresh list\n  } catch (err: unknown) {\n    const msg =\n      (err as { response?: { data?: { error?: string } } })?.response?.data?.error ||\n      'Failed to delete template';\n    message.error(msg);\n  }\n};\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#endpoints-used","title":"Endpoints Used","text":"

GET /email-templates - List templates with filters

const { data } = await api.get<EmailTemplatesListResponse>('/email-templates', {\n  params: {\n    page: 1,\n    limit: 20,\n    search: 'campaign',\n    category: 'INFLUENCE',\n    isActive: true,\n  },\n});\n

Response:

{\n  \"templates\": [\n    {\n      \"id\": \"tmpl_123\",\n      \"key\": \"campaign_email\",\n      \"name\": \"Campaign Email\",\n      \"category\": \"INFLUENCE\",\n      \"subjectLine\": \"Take action on {{CAMPAIGN_NAME}}\",\n      \"htmlContent\": \"<html>...</html>\",\n      \"textContent\": \"Plain text...\",\n      \"isActive\": true,\n      \"isSystem\": false,\n      \"variables\": [\n        {\n          \"id\": \"var_1\",\n          \"key\": \"CAMPAIGN_NAME\",\n          \"label\": \"Campaign Name\",\n          \"description\": \"Name of the campaign\",\n          \"isRequired\": true,\n          \"sampleValue\": \"Stop Deforestation\"\n        }\n      ],\n      \"createdAt\": \"2026-01-15T10:00:00Z\",\n      \"updatedAt\": \"2026-02-10T14:30:00Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 15,\n    \"totalPages\": 1\n  }\n}\n

DELETE /email-templates/:id - Delete template

await api.delete(`/email-templates/${id}`);\n

Response: 204 No Content on success

"},{"location":"v2/frontend/pages/admin/email-templates-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#debounced-search-implementation","title":"Debounced Search Implementation","text":"
const [search, setSearch] = useState('');\nconst [debouncedSearch, setDebouncedSearch] = useState('');\nconst searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\n\nconst handleSearchChange = (value: string) => {\n  setSearch(value);  // Update input immediately for responsive UI\n  clearTimeout(searchTimerRef.current);  // Cancel previous timer\n  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);\n};\n\n// Cleanup on unmount\nuseEffect(() => {\n  return () => clearTimeout(searchTimerRef.current);\n}, []);\n\n// Trigger API fetch when debouncedSearch changes\nuseEffect(() => {\n  fetchTemplates({ page: 1 });\n}, [debouncedSearch]);\n

Why 300ms? Standard debounce for search inputs balances responsiveness with API efficiency.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#conditional-delete-button","title":"Conditional Delete Button","text":"
{!record.isSystem && (\n  <Popconfirm\n    title=\"Delete template?\"\n    description=\"This action cannot be undone.\"\n    onConfirm={() => handleDelete(record.id)}\n  >\n    <Button type=\"link\" size=\"small\" danger icon={<DeleteOutlined />}>\n      Delete\n    </Button>\n  </Popconfirm>\n)}\n

Pattern: Hide delete button entirely for system templates rather than showing disabled button (clearer UI).

"},{"location":"v2/frontend/pages/admin/email-templates-page/#modal-openclose-pattern","title":"Modal Open/Close Pattern","text":"
// Open modal with selected template\nconst openTestEmailModal = (template: EmailTemplate) => {\n  setSelectedTemplate(template);\n  setTestModalOpen(true);\n};\n\n// Close modal and clear selection\nconst closeTestEmailModal = () => {\n  setTestModalOpen(false);\n  setSelectedTemplate(null);\n};\n\n// Render modal (only when template selected)\n{selectedTemplate && (\n  <TestEmailModal\n    open={testModalOpen}\n    template={selectedTemplate}\n    onClose={closeTestEmailModal}\n    onSuccess={() => {\n      message.success('Test email sent successfully');\n      closeTestEmailModal();\n    }}\n  />\n)}\n

Pattern: Conditional rendering with selectedTemplate && prevents rendering modal with null template.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#category-color-function","title":"Category Color Function","text":"
const getCategoryColor = (category: EmailTemplateCategory): string => {\n  const colors: Record<EmailTemplateCategory, string> = {\n    INFLUENCE: 'blue',\n    MAP: 'green',\n    SYSTEM: 'purple',\n  };\n  return colors[category];\n};\n\n// Usage in column render\nrender: (category: EmailTemplateCategory) => (\n  <Tag color={getCategoryColor(category)}>{category}</Tag>\n)\n
"},{"location":"v2/frontend/pages/admin/email-templates-page/#relative-time-with-dayjs","title":"Relative Time with dayjs","text":"
import dayjs from 'dayjs';\nimport relativeTime from 'dayjs/plugin/relativeTime';\n\ndayjs.extend(relativeTime);\n\n// In column render\nrender: (date: string) => dayjs(date).fromNow()\n// Output: \"2 hours ago\", \"3 days ago\", \"a month ago\"\n
"},{"location":"v2/frontend/pages/admin/email-templates-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#debounced-search","title":"Debounced Search","text":"

Problem: Typing in search input triggers API call on every keystroke (excessive network traffic).

Solution: 300ms debounce timer delays API call until user stops typing.

Implementation:

const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\n\nconst handleSearchChange = (value: string) => {\n  setSearch(value);  // Immediate UI update\n  clearTimeout(searchTimerRef.current);\n  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);\n};\n

Benefit: Reduces API calls by ~80% for typical search behavior.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#usecallback-for-fetch-function","title":"useCallback for Fetch Function","text":"
const fetchTemplates = useCallback(\n  async (params?: EmailTemplatesListParams) => { /* ... */ },\n  [debouncedSearch, categoryFilter, activeFilter]\n);\n

Why: Prevents infinite re-render loop when fetchTemplates is used in useEffect dependency array.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#table-pagination","title":"Table Pagination","text":"

Server-side pagination (not client-side) means only current page data loaded: - Page 1: Load 20 templates - Page 2: Load next 20 templates - Total: 1000 templates \u2192 Only 20 in memory at a time

Benefit: Handles large template libraries without performance degradation.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#component-conditional-rendering","title":"Component Conditional Rendering","text":"
{selectedTemplate && (\n  <TestEmailModal\n    open={testModalOpen}\n    template={selectedTemplate}\n    onClose={closeTestEmailModal}\n  />\n)}\n

Why: Modal only mounted when needed, saves memory and avoids rendering with null props.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#responsive-columns","title":"Responsive Columns","text":"

Column Configuration:

{\n  title: 'Category',\n  dataIndex: 'category',\n  responsive: ['md'],  // Hide on mobile (< 768px)\n},\n{\n  title: 'Subject',\n  dataIndex: 'subjectLine',\n  responsive: ['lg'],  // Hide on tablets (< 992px)\n},\n{\n  title: 'Active',\n  dataIndex: 'isActive',\n  responsive: ['md'],  // Hide on mobile\n},\n{\n  title: 'Updated',\n  dataIndex: 'updatedAt',\n  responsive: ['md'],  // Hide on mobile\n},\n

Mobile View (< 768px): - Visible: Name, Actions - Hidden: Category, Subject, Active, Updated

Tablet View (768px - 991px): - Visible: Name, Category, Active, Updated, Actions - Hidden: Subject

Desktop View (\u2265 992px): - All columns visible

"},{"location":"v2/frontend/pages/admin/email-templates-page/#filter-layout","title":"Filter Layout","text":"
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>\n  <Input style={{ width: 300 }} />\n  <Select style={{ width: 180 }} />\n  <Select style={{ width: 150 }} />\n</div>\n

flexWrap: 'wrap' ensures filters stack vertically on narrow screens.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#table-scroll","title":"Table Scroll","text":"
<Table\n  scroll={{ x: 'max-content' }}\n/>\n

Horizontal scroll on mobile prevents column squishing.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#keyboard-navigation","title":"Keyboard Navigation","text":"
  • Tab: Navigate through search input, filter selects, action buttons
  • Enter: Activate focused button (Edit, Test, Versions, Delete)
  • Escape: Close open modals/drawers
  • Arrow keys: Navigate table rows (native Ant Design behavior)
"},{"location":"v2/frontend/pages/admin/email-templates-page/#icon-labels","title":"Icon Labels","text":"

All icon-only buttons have text labels:

<Button icon={<EditOutlined />}>Edit</Button>\n<Button icon={<MailOutlined />}>Test</Button>\n<Button icon={<HistoryOutlined />}>Versions</Button>\n

Not icon-only buttons \u2013 clear action labels improve accessibility.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#search-input_1","title":"Search Input","text":"
<Input\n  placeholder=\"Search by name or key...\"\n  prefix={<SearchOutlined />}\n  allowClear\n/>\n
  • Placeholder text: Describes what to search for
  • Prefix icon: Visual search indicator
  • allowClear: X button to clear input (keyboard accessible)
"},{"location":"v2/frontend/pages/admin/email-templates-page/#popconfirm-for-destructive-actions","title":"Popconfirm for Destructive Actions","text":"
<Popconfirm\n  title=\"Delete template?\"\n  description=\"This action cannot be undone.\"\n  onConfirm={() => handleDelete(record.id)}\n>\n  <Button danger>Delete</Button>\n</Popconfirm>\n

Two-step confirmation prevents accidental deletion (important for accessibility and safety).

"},{"location":"v2/frontend/pages/admin/email-templates-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#templates-not-loading","title":"Templates Not Loading","text":"

Symptoms: - Empty table - Loading spinner never stops - Error message \"Failed to load templates\"

Causes: 1. API server not running (port 4000) 2. Network error 3. Missing authentication token 4. Database connection issue

Solutions:

# Check API server logs\ndocker compose logs -f api\n\n# Verify API is accessible\ncurl -H \"Authorization: Bearer <token>\" http://localhost:4000/email-templates\n\n# Restart API container\ndocker compose restart api\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#search-not-working","title":"Search Not Working","text":"

Symptoms: - Typing in search input doesn't filter results - Search triggers on every keystroke (should debounce)

Causes: 1. Debounce timer not clearing properly 2. debouncedSearch state not updating 3. API not receiving search param

Debug:

// Add console log to verify debounce\nconst handleSearchChange = (value: string) => {\n  console.log('Input value:', value);\n  setSearch(value);\n  clearTimeout(searchTimerRef.current);\n  searchTimerRef.current = setTimeout(() => {\n    console.log('Debounced search:', value);\n    setDebouncedSearch(value);\n  }, 300);\n};\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#delete-button-missing","title":"Delete Button Missing","text":"

Symptoms: - Delete button not visible for some templates

Cause: - Template is a system template (isSystem: true)

Expected Behavior: - System templates cannot be deleted (protected) - Delete button intentionally hidden for system templates

Verification:

// Check template data\nconsole.log('Template:', record.isSystem);\n// If true, delete button correctly hidden\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#modaldrawer-not-opening","title":"Modal/Drawer Not Opening","text":"

Symptoms: - Clicking Test or Versions button does nothing - Modal/drawer remains closed

Causes: 1. selectedTemplate is null 2. State update not triggering 3. Modal/drawer component not rendered

Debug:

const openTestEmailModal = (template: EmailTemplate) => {\n  console.log('Opening test modal for:', template.id);\n  setSelectedTemplate(template);\n  setTestModalOpen(true);\n};\n\n// Check render condition\nconsole.log('Selected template:', selectedTemplate);\nconsole.log('Modal open:', testModalOpen);\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#pagination-not-working","title":"Pagination Not Working","text":"

Symptoms: - Clicking page numbers doesn't load new data - Page stays on 1

Cause: - handleTableChange not wired correctly - Pagination params not passed to API

Debug:

const handleTableChange = (pag: TablePaginationConfig) => {\n  console.log('Page change:', pag.current, pag.pageSize);\n  fetchTemplates({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });\n};\n

"},{"location":"v2/frontend/pages/admin/email-templates-page/#subject-line-truncation","title":"Subject Line Truncation","text":"

Symptoms: - Long subject lines cut off without ellipsis

Cause: - CSS ellipsis not applied

Fix:

render: (subject: string) => (\n  <Text ellipsis style={{ maxWidth: 300 }}>\n    {subject.length > 50 ? `${subject.slice(0, 50)}...` : subject}\n  </Text>\n)\n

Alternative: Use Ant Design Typography.Text with ellipsis prop for automatic truncation.

"},{"location":"v2/frontend/pages/admin/email-templates-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#backend-integration","title":"Backend Integration","text":"
  • Email Templates Module - Service, schemas, routes
  • Email Templates API Reference - Full endpoint documentation
"},{"location":"v2/frontend/pages/admin/email-templates-page/#frontend-components","title":"Frontend Components","text":"
  • TestEmailModal Component - Test email modal
  • VersionHistoryDrawer Component - Version history drawer
"},{"location":"v2/frontend/pages/admin/email-templates-page/#editor-page","title":"Editor Page","text":"
  • Email Template Editor Page - Monaco editor for templates
"},{"location":"v2/frontend/pages/admin/email-templates-page/#features_1","title":"Features","text":"
  • Email Template System - Feature overview
  • Template Variables - Variable system documentation
  • Template Versioning - Version control system
"},{"location":"v2/frontend/pages/admin/email-templates-page/#user-guides","title":"User Guides","text":"
  • Admin Guide - Email Templates - Template management workflows
  • Campaign Manager Guide - Using templates in campaigns
"},{"location":"v2/frontend/pages/admin/email-templates-page/#troubleshooting_1","title":"Troubleshooting","text":"
  • Email Issues - Email delivery troubleshooting
  • Common Errors - General error resolution
"},{"location":"v2/frontend/pages/admin/email-templates-page/#related-pages","title":"Related Pages","text":"
  • CampaignsPage - Campaign management (uses email templates)
  • ShiftsPage - Shift management (uses email templates)
  • SettingsPage - Global settings including email configuration
"},{"location":"v2/frontend/pages/admin/gitea-page/","title":"GiteaPage","text":""},{"location":"v2/frontend/pages/admin/gitea-page/#overview","title":"Overview","text":"

File: admin/src/pages/GiteaPage.tsx

Route: /app/services/gitea

Role Requirements: Any authenticated user

Purpose: Provides an embedded interface to the Gitea Git repository hosting service via iframe. Gitea is a self-hosted Git service (similar to GitHub/GitLab) that allows developers to manage source code repositories, issues, pull requests, and collaboration. This page embeds Gitea with status monitoring and mobile device detection.

Key Features: - Full-page iframe embed of Gitea service - Service online/offline status monitoring - Mobile device detection with warning screen - \"Refresh\" and \"Open in New Tab\" buttons - Fullbleed layout for maximum repository browser space - Git repository management, issue tracking, pull requests

Layout: AppLayout with fullbleed

"},{"location":"v2/frontend/pages/admin/gitea-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/gitea-page/#1-service-status-monitoring","title":"1. Service Status Monitoring","text":"

Status Display: - Green \"Online\" badge when Gitea is accessible - Red \"Offline\" badge when unavailable - Blue \"Checking...\" badge during status check

"},{"location":"v2/frontend/pages/admin/gitea-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"

Mobile Warning: - BranchesOutlined icon (48px) - Message: \"The Git repository browser requires a desktop browser\" - \"Open in New Tab\" button for external access

"},{"location":"v2/frontend/pages/admin/gitea-page/#3-git-repository-management","title":"3. Git Repository Management","text":"

Gitea Features (within iframe): - Repositories: Create, clone, browse Git repositories - Code Browser: View files, commits, branches, tags - Issues: Bug tracking and feature requests - Pull Requests: Code review and merging workflow - Wiki: Project documentation - Organizations: Team collaboration - Access Control: Public/private repos, user permissions

"},{"location":"v2/frontend/pages/admin/gitea-page/#component-structure","title":"Component Structure","text":"
export default function GiteaPage() {\n  const { setPageHeader } = useOutletContext<AppOutletContext>();\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n\n  const [online, setOnline] = useState<boolean | null>(null);\n  const [config, setConfig] = useState<ServicesConfig | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  const fetchStatus = useCallback(async () => {\n    try {\n      const [statusRes, configRes] = await Promise.all([\n        api.get<ServicesStatus>('/services/status'),\n        api.get<ServicesConfig>('/services/config'),\n      ]);\n      setOnline(statusRes.data.gitea.online);\n      setConfig(configRes.data);\n    } catch {\n      setOnline(false);\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    fetchStatus();\n  }, [fetchStatus]);\n\n  const serviceUrl = config\n    ? buildServiceUrl(config.giteaSubdomain, config.domain, config.giteaPort)\n    : null;\n\n  // Header actions, mobile warning, loading, offline states...\n  // Iframe embed\n\n  return (\n    <iframe\n      src={serviceUrl}\n      style={{\n        width: '100%',\n        height: 'calc(100vh - 64px)',\n        border: 'none',\n        display: 'block',\n      }}\n      title=\"Gitea\"\n    />\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/gitea-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/gitea-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/services/status - Check Gitea health
  2. GET /api/services/config - Fetch subdomain/port config
"},{"location":"v2/frontend/pages/admin/gitea-page/#example-responses","title":"Example Responses","text":"

Status:

{\n  \"gitea\": { \"online\": true }\n}\n

Config:

{\n  \"domain\": \"cmlite.org\",\n  \"giteaSubdomain\": \"git\",\n  \"giteaPort\": 3030\n}\n

Service URL: - Production: http://git.cmlite.org - Development: http://localhost:3030

"},{"location":"v2/frontend/pages/admin/gitea-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/gitea-page/#accessing-gitea","title":"Accessing Gitea","text":"
  1. Navigate to \"Services\" \u2192 \"Git Repository\" in sidebar
  2. Check status badge (Online/Offline)
  3. View Gitea interface in iframe
  4. Or click \"Open in New Tab\" for full window
"},{"location":"v2/frontend/pages/admin/gitea-page/#common-use-cases","title":"Common Use Cases","text":"

Repository Management: - Create new repository for project - Clone repository URL for local development - Browse code, commits, branches

Collaboration: - Create issues for bugs/features - Submit pull requests for code review - Comment on code changes - Merge approved pull requests

Documentation: - Edit project wiki - Update README files - Maintain changelog

"},{"location":"v2/frontend/pages/admin/gitea-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/gitea-page/#problem-gitea-shows-offline","title":"Problem: Gitea Shows \"Offline\"","text":"

Solutions:

  1. Check Docker container:

    docker compose ps gitea\n

  2. Check logs:

    docker compose logs gitea\n

  3. Restart service:

    docker compose restart gitea\n

"},{"location":"v2/frontend/pages/admin/gitea-page/#problem-login-required","title":"Problem: Login Required","text":"

Symptoms: Iframe shows Gitea login screen

Solutions:

  1. Check Gitea credentials in .env:
  2. GITEA_ADMIN_USER
  3. GITEA_ADMIN_PASSWORD

  4. Login manually with admin credentials

  5. Create user account if needed

"},{"location":"v2/frontend/pages/admin/gitea-page/#related-documentation","title":"Related Documentation","text":"
  • Gitea Setup - Docker configuration
  • Git Workflow - Repository management
  • Services API - Status endpoints
"},{"location":"v2/frontend/pages/admin/landing-pages-page/","title":"LandingPagesPage","text":""},{"location":"v2/frontend/pages/admin/landing-pages-page/#overview","title":"Overview","text":"

The LandingPagesPage provides administrative management of landing pages built with GrapesJS visual editor or raw HTML/CSS code editor. It offers CRUD operations on pages with pagination, search/filter capabilities, and dual publishing modes: standalone React renderer (/p/:slug) and MkDocs integration for Material theme embedding. The page includes advanced features like MkDocs synchronization to import existing override files, validation to repair missing export files, and site building integration for SUPER_ADMIN users. Pages can be configured with SEO metadata, custom MkDocs paths, and theme customization options (hide navigation, hide TOC, full-page standalone mode).

Route: /app/pages Component: admin/src/pages/LandingPagesPage.tsx (510 lines) Auth Required: Yes (All authenticated users can view; editing requires appropriate role) Layout: AppLayout Backend Module: api/src/modules/pages/

"},{"location":"v2/frontend/pages/admin/landing-pages-page/#screenshot","title":"Screenshot","text":"

[Screenshot: LandingPagesPage with \"Landing Pages\" title on left. Right side has four buttons: \"Build Site\" (visible to SUPER_ADMIN only), \"Sync Overrides\", \"Validate Exports\", and \"Create Page\" (primary blue button). Below are two filter inputs: search bar \"Search by title or description\" and status dropdown \"Published/Draft\". Table shows columns: Title (with /p/:slug below), Editor (tag: Visual/Code), Status (tag: Published/Draft), MkDocs (path + stub path in gray), Created (date), Updated (date), Actions (Edit icon, Settings icon, Eye icon for published pages, Publish/Unpublish button, Delete icon). Pagination at bottom: \"24 pages\" with page selector.]

"},{"location":"v2/frontend/pages/admin/landing-pages-page/#features","title":"Features","text":"
  • Paginated table \u2014 Browse all landing pages with configurable page size (20, 50, 100)
  • Search functionality \u2014 Search by title or description (300ms debounce)
  • Status filtering \u2014 Filter by published/draft status
  • Editor mode selection \u2014 Choose between Visual (GrapesJS) or Code editor
  • CRUD operations \u2014 Create, edit, delete landing pages
  • Publish/unpublish toggle \u2014 Control page visibility without deleting
  • MkDocs integration \u2014 Export pages to MkDocs site with Material theme
  • MkDocs synchronization \u2014 Import existing override files as page stubs
  • Export validation \u2014 Detect and repair missing MkDocs export files
  • Site building \u2014 Build MkDocs site directly from page list (SUPER_ADMIN only)
  • SEO metadata \u2014 Configure title, description, and image for each page
  • Custom MkDocs paths \u2014 Override default path (e.g., \"about.html\" instead of \"/p/about\")
  • Standalone mode \u2014 Publish full HTML page without MkDocs theme wrapper
  • Theme customization \u2014 Hide navigation sidebar, hide table of contents
  • Skip export option \u2014 Keep page only accessible via /p/:slug (not in MkDocs)
  • Settings modal \u2014 Comprehensive page settings (metadata, MkDocs config, SEO)
"},{"location":"v2/frontend/pages/admin/landing-pages-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/landing-pages-page/#creating-a-new-landing-page","title":"Creating a New Landing Page","text":"
  1. Navigate to /app/pages
  2. Click \"Create Page\" button (top-right, primary blue)
  3. Modal appears: \"Create Landing Page\"
  4. Fill in fields:
  5. Title: (required) e.g., \"About Our Campaign\"
  6. Description: (optional) e.g., \"Learn about our mission and values\"
  7. Editor Mode: (required) Choose \"Visual Editor\" or \"Code Editor\"
  8. Click \"Create & Edit\" button
  9. Page created in database (slug auto-generated from title)
  10. Navigates to /app/pages/:id/edit (page editor)
  11. Begin editing page content in chosen editor

Editor Mode Selection:

  • Visual Editor (default):
  • GrapesJS drag-and-drop builder
  • No coding required
  • Pre-built components (text, image, button, form, etc.)
  • Best for non-technical users

  • Code Editor:

  • Raw HTML/CSS/JS editor with Monaco
  • Full control over markup
  • Syntax highlighting
  • Best for technical users

Slug Generation:

Title \"About Our Campaign\" \u2192 Slug \"about-our-campaign\"

  • Lowercase
  • Spaces \u2192 hyphens
  • Special characters removed
  • Unique (appends -2, -3 if duplicate)
"},{"location":"v2/frontend/pages/admin/landing-pages-page/#editing-an-existing-page","title":"Editing an Existing Page","text":"
  1. Locate page in table
  2. Click Edit icon (pencil) in Actions column
  3. Navigates to /app/pages/:id/edit
  4. Opens GrapesJS editor (visual) or Monaco editor (code)
  5. Make changes to page content
  6. Press Ctrl+S (or click Save button in editor)
  7. Changes auto-saved to database
  8. Return to page list: Click browser back button or navigate to /app/pages
"},{"location":"v2/frontend/pages/admin/landing-pages-page/#publishing-a-page","title":"Publishing a Page","text":"
  1. Locate draft page in table (Status: \"Draft\" gray tag)
  2. Click \"Publish\" button in Actions column
  3. API request updates published: true
  4. Success message: \"Page published\"
  5. Table refreshes to show Status: \"Published\" (green tag)
  6. Effects of publishing:
  7. Page becomes visible at /p/:slug (public access)
  8. If not skipped, page exported to MkDocs site as override file
  9. Page appears in MkDocs navigation (if configured)
  10. SEO metadata becomes active
"},{"location":"v2/frontend/pages/admin/landing-pages-page/#unpublishing-a-page","title":"Unpublishing a Page","text":"
  1. Locate published page in table (Status: \"Published\" green tag)
  2. Click \"Unpublish\" button in Actions column
  3. Confirmation: No confirmation dialog (immediate action)
  4. API request updates published: false
  5. Success message: \"Page unpublished\"
  6. Table refreshes to show Status: \"Draft\" (gray tag)
  7. Effects of unpublishing:
  8. Page no longer accessible at /p/:slug (404 error)
  9. MkDocs export file remains (but page not linked)
  10. Page removed from MkDocs navigation
  11. SEO metadata inactive

Use Cases for Unpublishing: - Temporarily hide page (e.g., event page after event ends) - Work on major revisions without affecting live site - Test page changes in staging before re-publishing

"},{"location":"v2/frontend/pages/admin/landing-pages-page/#viewing-a-published-page","title":"Viewing a Published Page","text":"
  1. Locate published page in table
  2. Click Eye icon in Actions column
  3. Opens page in new browser tab: /p/:slug
  4. View page as public user sees it
  5. Close tab to return to admin

Note: Eye icon only visible for published pages (unpublished pages return 404).

"},{"location":"v2/frontend/pages/admin/landing-pages-page/#configuring-page-settings","title":"Configuring Page Settings","text":"
  1. Locate page in table
  2. Click Settings icon (gear) in Actions column
  3. Modal appears: \"Page Settings\"
  4. Configure settings (see settings modal sections below)
  5. Click \"Save\" button
  6. API request updates page metadata
  7. Success message: \"Page settings updated\"
  8. Table refreshes to show updated values

Settings Modal Sections:

Basic Settings: - Title (required) - Description (optional)

SEO Settings: - SEO Title (overrides page title in tag) - SEO Description (meta description tag) - SEO Image URL (og:image for social media)</p> <p><strong>MkDocs Integration:</strong> - Skip MkDocs Export (checkbox) - Override Path (custom MkDocs path, e.g., \"about.html\") - Full page MkDocs (checkbox for standalone mode) - Hide navigation sidebar (checkbox, only if not standalone) - Hide table of contents (checkbox, only if not standalone)</p> <h3 id=\"searching-for-pages\">Searching for Pages<a class=\"headerlink\" href=\"#searching-for-pages\" title=\"Permanent link\">\u00b6</a></h3> <ol> <li>Locate <strong>search bar</strong> (below page header, left side)</li> <li>Start typing search query (e.g., \"campaign\")</li> <li>Search automatically triggers after 300ms pause (debounce)</li> <li>Table filters to show matching pages</li> <li>Matches on: page title, description</li> <li>Clear search by clicking X icon or deleting text</li> <li>Pagination resets to page 1 when search changes</li> </ol> <h3 id=\"filtering-by-status\">Filtering by Status<a class=\"headerlink\" href=\"#filtering-by-status\" title=\"Permanent link\">\u00b6</a></h3> <ol> <li>Locate <strong>Status dropdown</strong> (below page header, right of search bar)</li> <li>Click dropdown to open options:</li> <li>Published</li> <li>Draft</li> <li>Select desired status</li> <li>Table filters to show only pages with that status</li> <li>Clear filter by clicking X icon in dropdown</li> <li>Pagination resets to page 1 when filter changes</li> </ol> <h3 id=\"syncing-mkdocs-overrides\">Syncing MkDocs Overrides<a class=\"headerlink\" href=\"#syncing-mkdocs-overrides\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>What is \"Sync Overrides\"?</strong></p> <p>MkDocs sites can have custom HTML override files in <code>mkdocs/docs/overrides/</code> directory. The sync operation: - Scans <code>mkdocs/docs/overrides/</code> for <code>.html</code> files - Creates stub page records in database for files not yet tracked - Updates existing pages if override file content changed - Imports new pages that were manually added to overrides folder</p> <p><strong>When to Sync:</strong> - After manually adding HTML files to <code>mkdocs/docs/overrides/</code> - After upgrading MkDocs theme (new override templates available) - During migration from old system - Periodically to ensure database matches filesystem</p> <p><strong>Steps:</strong></p> <ol> <li>Click <strong>\"Sync Overrides\"</strong> button (top-right, next to \"Create Page\")</li> <li>Loading spinner appears on button</li> <li>Backend scans <code>mkdocs/docs/overrides/</code> directory</li> <li>Success message shows counts:</li> <li>\"Synced: 3 imported, 2 updated, 1 stubs created\"</li> <li>OR \"No new overrides to sync\" (if no changes)</li> <li>Table refreshes to show newly imported/updated pages</li> </ol> <p><strong>Result:</strong> - New pages appear in table with editor mode = CODE - Existing pages updated with latest override content - Stub pages created for overrides without page records</p> <h3 id=\"validating-mkdocs-exports\">Validating MkDocs Exports<a class=\"headerlink\" href=\"#validating-mkdocs-exports\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>What is \"Validate Exports\"?</strong></p> <p>Published pages should have corresponding override files in <code>mkdocs/docs/overrides/</code>. The validation operation: - Checks all published pages for existence of export file - Repairs missing files by re-exporting page content - Detects and reports errors (e.g., invalid HTML, write permissions)</p> <p><strong>When to Validate:</strong> - After deleting override files manually (cleanup) - After deployment (ensure all exports present) - Before building MkDocs site (catch missing files early) - Troubleshooting page display issues</p> <p><strong>Steps:</strong></p> <ol> <li>Click <strong>\"Validate Exports\"</strong> button (top-right, next to \"Sync Overrides\")</li> <li>Loading spinner appears on button</li> <li>Backend checks all published pages</li> <li>Success message shows results:</li> <li>\"Validated 24 pages: 2 repaired\" (some files missing, now fixed)</li> <li>OR \"Validated 24 pages - all OK\" (no issues found)</li> <li>OR \"Validated 24 pages: 2 repaired, 1 errors\" (some pages have unfixable errors)</li> <li>Table refreshes if any pages updated</li> </ol> <p><strong>Common Errors:</strong> - <strong>Missing export file:</strong> Page published but no override file (now repaired) - <strong>Invalid HTML:</strong> Page content has syntax errors (cannot export) - <strong>Write permissions:</strong> Cannot write to <code>mkdocs/docs/overrides/</code> (filesystem issue)</p> <h3 id=\"building-mkdocs-site-super_admin-only\">Building MkDocs Site (SUPER_ADMIN Only)<a class=\"headerlink\" href=\"#building-mkdocs-site-super_admin-only\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>What is \"Build Site\"?</strong></p> <p>MkDocs site must be built to apply changes (new pages, updated content, theme config). The build operation: - Runs <code>mkdocs build</code> command - Regenerates all HTML pages from Markdown + overrides - Updates navigation structure - Copies static assets (images, CSS, JS)</p> <p><strong>When to Build:</strong> - After publishing new pages - After updating page content - After changing MkDocs configuration - Before deploying to production</p> <p><strong>Steps:</strong></p> <ol> <li>Ensure you are SUPER_ADMIN role (button not visible to other roles)</li> <li>Click <strong>\"Build Site\"</strong> button (top-right, next to \"Sync Overrides\")</li> <li>Confirmation modal appears: \"Build MkDocs site? This will regenerate all pages and may take up to 2 minutes.\"</li> <li>Click <strong>\"Build\"</strong> to confirm (or <strong>\"Cancel\"</strong> to abort)</li> <li>Loading spinner appears on button (build in progress)</li> <li>Wait for build to complete (typically 10-30 seconds)</li> <li>Success message: \"Site built successfully\" (or error message if build failed)</li> </ol> <p><strong>Build Errors:</strong> - <strong>MkDocs not found:</strong> MkDocs container not running (start with <code>docker compose up -d mkdocs</code>) - <strong>Configuration error:</strong> <code>mkdocs.yml</code> has syntax errors - <strong>Theme error:</strong> Material theme not installed or version mismatch</p> <h3 id=\"deleting-a-page\">Deleting a Page<a class=\"headerlink\" href=\"#deleting-a-page\" title=\"Permanent link\">\u00b6</a></h3> <ol> <li>Locate page in table</li> <li>Click <strong>Delete icon</strong> (trash can, red) in Actions column</li> <li>Confirmation popconfirm appears: \"Delete this page? This action cannot be undone.\"</li> <li>Click <strong>\"OK\"</strong> to confirm (or click outside popconfirm to cancel)</li> <li>API request deletes page from database</li> <li>Success message: \"Page deleted\"</li> <li>Table refreshes to remove deleted page</li> </ol> <p><strong>Cascade Effects:</strong> - <strong>Page record:</strong> Deleted from database - <strong>Override file:</strong> Remains in <code>mkdocs/docs/overrides/</code> (manual cleanup required) - <strong>MkDocs stub:</strong> Remains in <code>mkdocs/docs/</code> (manual cleanup required) - <strong>Public URL:</strong> <code>/p/:slug</code> returns 404 Not Found</p> <p><strong>Important:</strong> Deletion is permanent. No undo functionality. Consider unpublishing instead of deleting for temporary removal.</p> <h2 id=\"component-breakdown\">Component Breakdown<a class=\"headerlink\" href=\"#component-breakdown\" title=\"Permanent link\">\u00b6</a></h2> <h3 id=\"ant-design-components-used\">Ant Design Components Used<a class=\"headerlink\" href=\"#ant-design-components-used\" title=\"Permanent link\">\u00b6</a></h3> <ul> <li><strong>Typography.Title</strong> \u2014 Page heading (\"Landing Pages\")</li> <li><strong>Row / Col</strong> \u2014 Grid layout for header and filters</li> <li><strong>Space</strong> \u2014 Button grouping</li> <li><strong>Button</strong> \u2014 Create, Sync, Validate, Build, Edit, Settings, View, Delete</li> <li><strong>Input</strong> \u2014 Search bar with SearchOutlined icon</li> <li><strong>Select</strong> \u2014 Status filter dropdown</li> <li><strong>Table</strong> \u2014 Main data table with pagination</li> <li><strong>Tag</strong> \u2014 Editor mode tags (Visual/Code), status tags (Published/Draft)</li> <li><strong>Modal</strong> \u2014 Create page modal, settings modal</li> <li><strong>Form</strong> \u2014 Create page form, settings form</li> <li><strong>Form.Item</strong> \u2014 Form field wrappers with labels</li> <li><strong>Input.TextArea</strong> \u2014 Description field (multi-line)</li> <li><strong>Radio.Group</strong> \u2014 Editor mode selector (Visual/Code buttons)</li> <li><strong>Checkbox</strong> \u2014 Settings checkboxes (skip export, hide nav, hide TOC)</li> <li><strong>Divider</strong> \u2014 Section separator in settings modal (MkDocs Integration)</li> <li><strong>Popconfirm</strong> \u2014 Delete confirmation dialog</li> <li><strong>message</strong> \u2014 Toast notifications for success/error feedback</li> </ul> <h3 id=\"table-structure\">Table Structure<a class=\"headerlink\" href=\"#table-structure\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-0-1\"><a id=\"__codelineno-0-1\" name=\"__codelineno-0-1\" href=\"#__codelineno-0-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">columns</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">ColumnsType</span><span class=\"o\"><</span><span class=\"nx\">LandingPage</span><span class=\"o\">></span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"p\">[</span> </span><span id=\"__span-0-2\"><a id=\"__codelineno-0-2\" name=\"__codelineno-0-2\" href=\"#__codelineno-0-2\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-0-3\"><a id=\"__codelineno-0-3\" name=\"__codelineno-0-3\" href=\"#__codelineno-0-3\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Title'</span><span class=\"p\">,</span> </span><span id=\"__span-0-4\"><a id=\"__codelineno-0-4\" name=\"__codelineno-0-4\" href=\"#__codelineno-0-4\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'title'</span><span class=\"p\">,</span> </span><span id=\"__span-0-5\"><a id=\"__codelineno-0-5\" name=\"__codelineno-0-5\" href=\"#__codelineno-0-5\"></a><span class=\"w\"> </span><span class=\"nx\">key</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'title'</span><span class=\"p\">,</span> </span><span id=\"__span-0-6\"><a id=\"__codelineno-0-6\" name=\"__codelineno-0-6\" href=\"#__codelineno-0-6\"></a><span class=\"w\"> </span><span class=\"nx\">render</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">record</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">LandingPage</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-0-7\"><a id=\"__codelineno-0-7\" name=\"__codelineno-0-7\" href=\"#__codelineno-0-7\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">div</span><span class=\"o\">></span> </span><span id=\"__span-0-8\"><a id=\"__codelineno-0-8\" name=\"__codelineno-0-8\" href=\"#__codelineno-0-8\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">span</span><span class=\"w\"> </span><span class=\"nx\">style</span><span class=\"o\">=</span><span class=\"p\">{{</span><span class=\"w\"> </span><span class=\"nx\">fontWeight</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">500</span><span class=\"w\"> </span><span class=\"p\">}}</span><span class=\"o\">></span><span class=\"p\">{</span><span class=\"nx\">title</span><span class=\"p\">}</span><span class=\"o\"><</span><span class=\"err\">/span></span> </span><span id=\"__span-0-9\"><a id=\"__codelineno-0-9\" name=\"__codelineno-0-9\" href=\"#__codelineno-0-9\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">div</span><span class=\"w\"> </span><span class=\"nx\">style</span><span class=\"o\">=</span><span class=\"p\">{{</span><span class=\"w\"> </span><span class=\"nx\">fontSize</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">12</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">color</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'rgba(255,255,255,0.45)'</span><span class=\"w\"> </span><span class=\"p\">}}</span><span class=\"o\">></span> </span><span id=\"__span-0-10\"><a id=\"__codelineno-0-10\" name=\"__codelineno-0-10\" href=\"#__codelineno-0-10\"></a><span class=\"w\"> </span><span class=\"sr\">/p/</span><span class=\"p\">{</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">slug</span><span class=\"p\">}</span> </span><span id=\"__span-0-11\"><a id=\"__codelineno-0-11\" name=\"__codelineno-0-11\" href=\"#__codelineno-0-11\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/div></span> </span><span id=\"__span-0-12\"><a id=\"__codelineno-0-12\" name=\"__codelineno-0-12\" href=\"#__codelineno-0-12\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/div></span> </span><span id=\"__span-0-13\"><a id=\"__codelineno-0-13\" name=\"__codelineno-0-13\" href=\"#__codelineno-0-13\"></a><span class=\"w\"> </span><span class=\"p\">),</span> </span><span id=\"__span-0-14\"><a id=\"__codelineno-0-14\" name=\"__codelineno-0-14\" href=\"#__codelineno-0-14\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-0-15\"><a id=\"__codelineno-0-15\" name=\"__codelineno-0-15\" href=\"#__codelineno-0-15\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-0-16\"><a id=\"__codelineno-0-16\" name=\"__codelineno-0-16\" href=\"#__codelineno-0-16\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Editor'</span><span class=\"p\">,</span> </span><span id=\"__span-0-17\"><a id=\"__codelineno-0-17\" name=\"__codelineno-0-17\" href=\"#__codelineno-0-17\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'editorMode'</span><span class=\"p\">,</span> </span><span id=\"__span-0-18\"><a id=\"__codelineno-0-18\" name=\"__codelineno-0-18\" href=\"#__codelineno-0-18\"></a><span class=\"w\"> </span><span class=\"nx\">key</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'editorMode'</span><span class=\"p\">,</span> </span><span id=\"__span-0-19\"><a id=\"__codelineno-0-19\" name=\"__codelineno-0-19\" href=\"#__codelineno-0-19\"></a><span class=\"w\"> </span><span class=\"nx\">render</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">mode</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">EditorMode</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-0-20\"><a id=\"__codelineno-0-20\" name=\"__codelineno-0-20\" href=\"#__codelineno-0-20\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Tag</span><span class=\"w\"> </span><span class=\"nx\">color</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">mode</span><span class=\"w\"> </span><span class=\"o\">===</span><span class=\"w\"> </span><span class=\"s1\">'VISUAL'</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'green'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'blue'</span><span class=\"p\">}</span><span class=\"o\">></span> </span><span id=\"__span-0-21\"><a id=\"__codelineno-0-21\" name=\"__codelineno-0-21\" href=\"#__codelineno-0-21\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"nx\">mode</span><span class=\"w\"> </span><span class=\"o\">===</span><span class=\"w\"> </span><span class=\"s1\">'VISUAL'</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'Visual'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Code'</span><span class=\"p\">}</span> </span><span id=\"__span-0-22\"><a id=\"__codelineno-0-22\" name=\"__codelineno-0-22\" href=\"#__codelineno-0-22\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Tag></span> </span><span id=\"__span-0-23\"><a id=\"__codelineno-0-23\" name=\"__codelineno-0-23\" href=\"#__codelineno-0-23\"></a><span class=\"w\"> </span><span class=\"p\">),</span> </span><span id=\"__span-0-24\"><a id=\"__codelineno-0-24\" name=\"__codelineno-0-24\" href=\"#__codelineno-0-24\"></a><span class=\"w\"> </span><span class=\"nx\">responsive</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"s1\">'sm'</span><span class=\"p\">],</span><span class=\"w\"> </span><span class=\"c1\">// Hidden on mobile</span> </span><span id=\"__span-0-25\"><a id=\"__codelineno-0-25\" name=\"__codelineno-0-25\" href=\"#__codelineno-0-25\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-0-26\"><a id=\"__codelineno-0-26\" name=\"__codelineno-0-26\" href=\"#__codelineno-0-26\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-0-27\"><a id=\"__codelineno-0-27\" name=\"__codelineno-0-27\" href=\"#__codelineno-0-27\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Status'</span><span class=\"p\">,</span> </span><span id=\"__span-0-28\"><a id=\"__codelineno-0-28\" name=\"__codelineno-0-28\" href=\"#__codelineno-0-28\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'published'</span><span class=\"p\">,</span> </span><span id=\"__span-0-29\"><a id=\"__codelineno-0-29\" name=\"__codelineno-0-29\" href=\"#__codelineno-0-29\"></a><span class=\"w\"> </span><span class=\"nx\">key</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'published'</span><span class=\"p\">,</span> </span><span id=\"__span-0-30\"><a id=\"__codelineno-0-30\" name=\"__codelineno-0-30\" href=\"#__codelineno-0-30\"></a><span class=\"w\"> </span><span class=\"nx\">render</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">boolean</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-0-31\"><a id=\"__codelineno-0-31\" name=\"__codelineno-0-31\" href=\"#__codelineno-0-31\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Tag</span><span class=\"w\"> </span><span class=\"nx\">color</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'green'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'default'</span><span class=\"p\">}</span><span class=\"o\">></span> </span><span id=\"__span-0-32\"><a id=\"__codelineno-0-32\" name=\"__codelineno-0-32\" href=\"#__codelineno-0-32\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'Published'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Draft'</span><span class=\"p\">}</span> </span><span id=\"__span-0-33\"><a id=\"__codelineno-0-33\" name=\"__codelineno-0-33\" href=\"#__codelineno-0-33\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Tag></span> </span><span id=\"__span-0-34\"><a id=\"__codelineno-0-34\" name=\"__codelineno-0-34\" href=\"#__codelineno-0-34\"></a><span class=\"w\"> </span><span class=\"p\">),</span> </span><span id=\"__span-0-35\"><a id=\"__codelineno-0-35\" name=\"__codelineno-0-35\" href=\"#__codelineno-0-35\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-0-36\"><a id=\"__codelineno-0-36\" name=\"__codelineno-0-36\" href=\"#__codelineno-0-36\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-0-37\"><a id=\"__codelineno-0-37\" name=\"__codelineno-0-37\" href=\"#__codelineno-0-37\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'MkDocs'</span><span class=\"p\">,</span> </span><span id=\"__span-0-38\"><a id=\"__codelineno-0-38\" name=\"__codelineno-0-38\" href=\"#__codelineno-0-38\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'mkdocsPath'</span><span class=\"p\">,</span> </span><span id=\"__span-0-39\"><a id=\"__codelineno-0-39\" name=\"__codelineno-0-39\" href=\"#__codelineno-0-39\"></a><span class=\"w\"> </span><span class=\"nx\">key</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'mkdocsPath'</span><span class=\"p\">,</span> </span><span id=\"__span-0-40\"><a id=\"__codelineno-0-40\" name=\"__codelineno-0-40\" href=\"#__codelineno-0-40\"></a><span class=\"w\"> </span><span class=\"nx\">render</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">_</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"w\"> </span><span class=\"o\">|</span><span class=\"w\"> </span><span class=\"kc\">null</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">record</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">LandingPage</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-0-41\"><a id=\"__codelineno-0-41\" name=\"__codelineno-0-41\" href=\"#__codelineno-0-41\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">div</span><span class=\"o\">></span> </span><span id=\"__span-0-42\"><a id=\"__codelineno-0-42\" name=\"__codelineno-0-42\" href=\"#__codelineno-0-42\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">div</span><span class=\"o\">></span><span class=\"p\">{</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">mkdocsPath</span><span class=\"w\"> </span><span class=\"o\">||</span><span class=\"w\"> </span><span class=\"s1\">'--'</span><span class=\"p\">}</span><span class=\"o\"><</span><span class=\"err\">/div></span> </span><span id=\"__span-0-43\"><a id=\"__codelineno-0-43\" name=\"__codelineno-0-43\" href=\"#__codelineno-0-43\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">mkdocsStubPath</span><span class=\"w\"> </span><span class=\"o\">&&</span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-0-44\"><a id=\"__codelineno-0-44\" name=\"__codelineno-0-44\" href=\"#__codelineno-0-44\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">div</span><span class=\"w\"> </span><span class=\"nx\">style</span><span class=\"o\">=</span><span class=\"p\">{{</span><span class=\"w\"> </span><span class=\"nx\">fontSize</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">11</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">color</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'rgba(255,255,255,0.45)'</span><span class=\"w\"> </span><span class=\"p\">}}</span><span class=\"o\">></span> </span><span id=\"__span-0-45\"><a id=\"__codelineno-0-45\" name=\"__codelineno-0-45\" href=\"#__codelineno-0-45\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">mkdocsStubPath</span><span class=\"p\">}</span> </span><span id=\"__span-0-46\"><a id=\"__codelineno-0-46\" name=\"__codelineno-0-46\" href=\"#__codelineno-0-46\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/div></span> </span><span id=\"__span-0-47\"><a id=\"__codelineno-0-47\" name=\"__codelineno-0-47\" href=\"#__codelineno-0-47\"></a><span class=\"w\"> </span><span class=\"p\">)}</span> </span><span id=\"__span-0-48\"><a id=\"__codelineno-0-48\" name=\"__codelineno-0-48\" href=\"#__codelineno-0-48\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/div></span> </span><span id=\"__span-0-49\"><a id=\"__codelineno-0-49\" name=\"__codelineno-0-49\" href=\"#__codelineno-0-49\"></a><span class=\"w\"> </span><span class=\"p\">),</span> </span><span id=\"__span-0-50\"><a id=\"__codelineno-0-50\" name=\"__codelineno-0-50\" href=\"#__codelineno-0-50\"></a><span class=\"w\"> </span><span class=\"nx\">responsive</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"s1\">'lg'</span><span class=\"p\">],</span><span class=\"w\"> </span><span class=\"c1\">// Hidden on mobile/tablet</span> </span><span id=\"__span-0-51\"><a id=\"__codelineno-0-51\" name=\"__codelineno-0-51\" href=\"#__codelineno-0-51\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-0-52\"><a id=\"__codelineno-0-52\" name=\"__codelineno-0-52\" href=\"#__codelineno-0-52\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-0-53\"><a id=\"__codelineno-0-53\" name=\"__codelineno-0-53\" href=\"#__codelineno-0-53\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Created'</span><span class=\"p\">,</span> </span><span id=\"__span-0-54\"><a id=\"__codelineno-0-54\" name=\"__codelineno-0-54\" href=\"#__codelineno-0-54\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'createdAt'</span><span class=\"p\">,</span> </span><span id=\"__span-0-55\"><a id=\"__codelineno-0-55\" name=\"__codelineno-0-55\" href=\"#__codelineno-0-55\"></a><span class=\"w\"> </span><span class=\"nx\">key</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'createdAt'</span><span class=\"p\">,</span> </span><span id=\"__span-0-56\"><a id=\"__codelineno-0-56\" name=\"__codelineno-0-56\" href=\"#__codelineno-0-56\"></a><span class=\"w\"> </span><span class=\"nx\">render</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">date</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">dayjs</span><span class=\"p\">(</span><span class=\"nx\">date</span><span class=\"p\">).</span><span class=\"nx\">format</span><span class=\"p\">(</span><span class=\"s1\">'YYYY-MM-DD'</span><span class=\"p\">),</span> </span><span id=\"__span-0-57\"><a id=\"__codelineno-0-57\" name=\"__codelineno-0-57\" href=\"#__codelineno-0-57\"></a><span class=\"w\"> </span><span class=\"nx\">responsive</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"s1\">'md'</span><span class=\"p\">],</span><span class=\"w\"> </span><span class=\"c1\">// Hidden on mobile</span> </span><span id=\"__span-0-58\"><a id=\"__codelineno-0-58\" name=\"__codelineno-0-58\" href=\"#__codelineno-0-58\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-0-59\"><a id=\"__codelineno-0-59\" name=\"__codelineno-0-59\" href=\"#__codelineno-0-59\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-0-60\"><a id=\"__codelineno-0-60\" name=\"__codelineno-0-60\" href=\"#__codelineno-0-60\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Updated'</span><span class=\"p\">,</span> </span><span id=\"__span-0-61\"><a id=\"__codelineno-0-61\" name=\"__codelineno-0-61\" href=\"#__codelineno-0-61\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'updatedAt'</span><span class=\"p\">,</span> </span><span id=\"__span-0-62\"><a id=\"__codelineno-0-62\" name=\"__codelineno-0-62\" href=\"#__codelineno-0-62\"></a><span class=\"w\"> </span><span class=\"nx\">key</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'updatedAt'</span><span class=\"p\">,</span> </span><span id=\"__span-0-63\"><a id=\"__codelineno-0-63\" name=\"__codelineno-0-63\" href=\"#__codelineno-0-63\"></a><span class=\"w\"> </span><span class=\"nx\">render</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">date</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">dayjs</span><span class=\"p\">(</span><span class=\"nx\">date</span><span class=\"p\">).</span><span class=\"nx\">format</span><span class=\"p\">(</span><span class=\"s1\">'YYYY-MM-DD'</span><span class=\"p\">),</span> </span><span id=\"__span-0-64\"><a id=\"__codelineno-0-64\" name=\"__codelineno-0-64\" href=\"#__codelineno-0-64\"></a><span class=\"w\"> </span><span class=\"nx\">responsive</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"s1\">'md'</span><span class=\"p\">],</span><span class=\"w\"> </span><span class=\"c1\">// Hidden on mobile</span> </span><span id=\"__span-0-65\"><a id=\"__codelineno-0-65\" name=\"__codelineno-0-65\" href=\"#__codelineno-0-65\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-0-66\"><a id=\"__codelineno-0-66\" name=\"__codelineno-0-66\" href=\"#__codelineno-0-66\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-0-67\"><a id=\"__codelineno-0-67\" name=\"__codelineno-0-67\" href=\"#__codelineno-0-67\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Actions'</span><span class=\"p\">,</span> </span><span id=\"__span-0-68\"><a id=\"__codelineno-0-68\" name=\"__codelineno-0-68\" href=\"#__codelineno-0-68\"></a><span class=\"w\"> </span><span class=\"nx\">key</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'actions'</span><span class=\"p\">,</span> </span><span id=\"__span-0-69\"><a id=\"__codelineno-0-69\" name=\"__codelineno-0-69\" href=\"#__codelineno-0-69\"></a><span class=\"w\"> </span><span class=\"nx\">render</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">_</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">unknown</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">record</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">LandingPage</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-0-70\"><a id=\"__codelineno-0-70\" name=\"__codelineno-0-70\" href=\"#__codelineno-0-70\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Space</span><span class=\"o\">></span> </span><span id=\"__span-0-71\"><a id=\"__codelineno-0-71\" name=\"__codelineno-0-71\" href=\"#__codelineno-0-71\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span> </span><span id=\"__span-0-72\"><a id=\"__codelineno-0-72\" name=\"__codelineno-0-72\" href=\"#__codelineno-0-72\"></a><span class=\"w\"> </span><span class=\"kr\">type</span><span class=\"o\">=</span><span class=\"s2\">\"link\"</span> </span><span id=\"__span-0-73\"><a id=\"__codelineno-0-73\" name=\"__codelineno-0-73\" href=\"#__codelineno-0-73\"></a><span class=\"w\"> </span><span class=\"nx\">size</span><span class=\"o\">=</span><span class=\"s2\">\"small\"</span> </span><span id=\"__span-0-74\"><a id=\"__codelineno-0-74\" name=\"__codelineno-0-74\" href=\"#__codelineno-0-74\"></a><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">EditOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span> </span><span id=\"__span-0-75\"><a id=\"__codelineno-0-75\" name=\"__codelineno-0-75\" href=\"#__codelineno-0-75\"></a><span class=\"w\"> </span><span class=\"nx\">onClick</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">navigate</span><span class=\"p\">(</span><span class=\"sb\">`/app/pages/</span><span class=\"si\">${</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">id</span><span class=\"si\">}</span><span class=\"sb\">/edit`</span><span class=\"p\">)}</span> </span><span id=\"__span-0-76\"><a id=\"__codelineno-0-76\" name=\"__codelineno-0-76\" href=\"#__codelineno-0-76\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">editorMode</span><span class=\"w\"> </span><span class=\"o\">===</span><span class=\"w\"> </span><span class=\"s1\">'CODE'</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'Edit code'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Edit in builder'</span><span class=\"p\">}</span> </span><span id=\"__span-0-77\"><a id=\"__codelineno-0-77\" name=\"__codelineno-0-77\" href=\"#__codelineno-0-77\"></a><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-0-78\"><a id=\"__codelineno-0-78\" name=\"__codelineno-0-78\" href=\"#__codelineno-0-78\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span> </span><span id=\"__span-0-79\"><a id=\"__codelineno-0-79\" name=\"__codelineno-0-79\" href=\"#__codelineno-0-79\"></a><span class=\"w\"> </span><span class=\"kr\">type</span><span class=\"o\">=</span><span class=\"s2\">\"link\"</span> </span><span id=\"__span-0-80\"><a id=\"__codelineno-0-80\" name=\"__codelineno-0-80\" href=\"#__codelineno-0-80\"></a><span class=\"w\"> </span><span class=\"nx\">size</span><span class=\"o\">=</span><span class=\"s2\">\"small\"</span> </span><span id=\"__span-0-81\"><a id=\"__codelineno-0-81\" name=\"__codelineno-0-81\" href=\"#__codelineno-0-81\"></a><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">SettingOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span> </span><span id=\"__span-0-82\"><a id=\"__codelineno-0-82\" name=\"__codelineno-0-82\" href=\"#__codelineno-0-82\"></a><span class=\"w\"> </span><span class=\"nx\">onClick</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">openSettings</span><span class=\"p\">(</span><span class=\"nx\">record</span><span class=\"p\">)}</span> </span><span id=\"__span-0-83\"><a id=\"__codelineno-0-83\" name=\"__codelineno-0-83\" href=\"#__codelineno-0-83\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"s2\">\"Page settings\"</span> </span><span id=\"__span-0-84\"><a id=\"__codelineno-0-84\" name=\"__codelineno-0-84\" href=\"#__codelineno-0-84\"></a><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-0-85\"><a id=\"__codelineno-0-85\" name=\"__codelineno-0-85\" href=\"#__codelineno-0-85\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">&&</span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-0-86\"><a id=\"__codelineno-0-86\" name=\"__codelineno-0-86\" href=\"#__codelineno-0-86\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span> </span><span id=\"__span-0-87\"><a id=\"__codelineno-0-87\" name=\"__codelineno-0-87\" href=\"#__codelineno-0-87\"></a><span class=\"w\"> </span><span class=\"kr\">type</span><span class=\"o\">=</span><span class=\"s2\">\"link\"</span> </span><span id=\"__span-0-88\"><a id=\"__codelineno-0-88\" name=\"__codelineno-0-88\" href=\"#__codelineno-0-88\"></a><span class=\"w\"> </span><span class=\"nx\">size</span><span class=\"o\">=</span><span class=\"s2\">\"small\"</span> </span><span id=\"__span-0-89\"><a id=\"__codelineno-0-89\" name=\"__codelineno-0-89\" href=\"#__codelineno-0-89\"></a><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">EyeOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span> </span><span id=\"__span-0-90\"><a id=\"__codelineno-0-90\" name=\"__codelineno-0-90\" href=\"#__codelineno-0-90\"></a><span class=\"w\"> </span><span class=\"nx\">onClick</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nb\">window</span><span class=\"p\">.</span><span class=\"nx\">open</span><span class=\"p\">(</span><span class=\"sb\">`/p/</span><span class=\"si\">${</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">slug</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"s1\">'_blank'</span><span class=\"p\">)}</span> </span><span id=\"__span-0-91\"><a id=\"__codelineno-0-91\" name=\"__codelineno-0-91\" href=\"#__codelineno-0-91\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"s2\">\"View page\"</span> </span><span id=\"__span-0-92\"><a id=\"__codelineno-0-92\" name=\"__codelineno-0-92\" href=\"#__codelineno-0-92\"></a><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-0-93\"><a id=\"__codelineno-0-93\" name=\"__codelineno-0-93\" href=\"#__codelineno-0-93\"></a><span class=\"w\"> </span><span class=\"p\">)}</span> </span><span id=\"__span-0-94\"><a id=\"__codelineno-0-94\" name=\"__codelineno-0-94\" href=\"#__codelineno-0-94\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span> </span><span id=\"__span-0-95\"><a id=\"__codelineno-0-95\" name=\"__codelineno-0-95\" href=\"#__codelineno-0-95\"></a><span class=\"w\"> </span><span class=\"kr\">type</span><span class=\"o\">=</span><span class=\"s2\">\"link\"</span> </span><span id=\"__span-0-96\"><a id=\"__codelineno-0-96\" name=\"__codelineno-0-96\" href=\"#__codelineno-0-96\"></a><span class=\"w\"> </span><span class=\"nx\">size</span><span class=\"o\">=</span><span class=\"s2\">\"small\"</span> </span><span id=\"__span-0-97\"><a id=\"__codelineno-0-97\" name=\"__codelineno-0-97\" href=\"#__codelineno-0-97\"></a><span class=\"w\"> </span><span class=\"nx\">onClick</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">handleTogglePublished</span><span class=\"p\">(</span><span class=\"nx\">record</span><span class=\"p\">)}</span> </span><span id=\"__span-0-98\"><a id=\"__codelineno-0-98\" name=\"__codelineno-0-98\" href=\"#__codelineno-0-98\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'Unpublish'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Publish'</span><span class=\"p\">}</span> </span><span id=\"__span-0-99\"><a id=\"__codelineno-0-99\" name=\"__codelineno-0-99\" href=\"#__codelineno-0-99\"></a><span class=\"w\"> </span><span class=\"o\">></span> </span><span id=\"__span-0-100\"><a id=\"__codelineno-0-100\" name=\"__codelineno-0-100\" href=\"#__codelineno-0-100\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'Unpublish'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Publish'</span><span class=\"p\">}</span> </span><span id=\"__span-0-101\"><a id=\"__codelineno-0-101\" name=\"__codelineno-0-101\" href=\"#__codelineno-0-101\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Button></span> </span><span id=\"__span-0-102\"><a id=\"__codelineno-0-102\" name=\"__codelineno-0-102\" href=\"#__codelineno-0-102\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Popconfirm</span> </span><span id=\"__span-0-103\"><a id=\"__codelineno-0-103\" name=\"__codelineno-0-103\" href=\"#__codelineno-0-103\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"s2\">\"Delete this page?\"</span> </span><span id=\"__span-0-104\"><a id=\"__codelineno-0-104\" name=\"__codelineno-0-104\" href=\"#__codelineno-0-104\"></a><span class=\"w\"> </span><span class=\"nx\">description</span><span class=\"o\">=</span><span class=\"s2\">\"This action cannot be undone.\"</span> </span><span id=\"__span-0-105\"><a id=\"__codelineno-0-105\" name=\"__codelineno-0-105\" href=\"#__codelineno-0-105\"></a><span class=\"w\"> </span><span class=\"nx\">onConfirm</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">handleDelete</span><span class=\"p\">(</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">id</span><span class=\"p\">)}</span> </span><span id=\"__span-0-106\"><a id=\"__codelineno-0-106\" name=\"__codelineno-0-106\" href=\"#__codelineno-0-106\"></a><span class=\"w\"> </span><span class=\"o\">></span> </span><span id=\"__span-0-107\"><a id=\"__codelineno-0-107\" name=\"__codelineno-0-107\" href=\"#__codelineno-0-107\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span><span class=\"w\"> </span><span class=\"kr\">type</span><span class=\"o\">=</span><span class=\"s2\">\"link\"</span><span class=\"w\"> </span><span class=\"nx\">size</span><span class=\"o\">=</span><span class=\"s2\">\"small\"</span><span class=\"w\"> </span><span class=\"nx\">danger</span><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">DeleteOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"s2\">\"Delete\"</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-0-108\"><a id=\"__codelineno-0-108\" name=\"__codelineno-0-108\" href=\"#__codelineno-0-108\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Popconfirm></span> </span><span id=\"__span-0-109\"><a id=\"__codelineno-0-109\" name=\"__codelineno-0-109\" href=\"#__codelineno-0-109\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Space></span> </span><span id=\"__span-0-110\"><a id=\"__codelineno-0-110\" name=\"__codelineno-0-110\" href=\"#__codelineno-0-110\"></a><span class=\"w\"> </span><span class=\"p\">),</span> </span><span id=\"__span-0-111\"><a id=\"__codelineno-0-111\" name=\"__codelineno-0-111\" href=\"#__codelineno-0-111\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-0-112\"><a id=\"__codelineno-0-112\" name=\"__codelineno-0-112\" href=\"#__codelineno-0-112\"></a><span class=\"p\">];</span> </span></code></pre></div> <p><strong>Column Features:</strong> - <strong>Title:</strong> Primary identifier with <code>/p/:slug</code> shown below (small gray text) - <strong>Editor:</strong> Color-coded tag (Visual=green, Code=blue), hidden on mobile - <strong>Status:</strong> Color-coded tag (Published=green, Draft=gray) - <strong>MkDocs:</strong> Shows mkdocsPath (custom path) and mkdocsStubPath (Markdown stub path) in gray, hidden on mobile/tablet - <strong>Created:</strong> Date only (YYYY-MM-DD format), hidden on mobile - <strong>Updated:</strong> Date only (YYYY-MM-DD format), hidden on mobile - <strong>Actions:</strong> 5 buttons (Edit, Settings, View [if published], Publish/Unpublish, Delete)</p> <h3 id=\"create-page-modal\">Create Page Modal<a class=\"headerlink\" href=\"#create-page-modal\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-1-1\"><a id=\"__codelineno-1-1\" name=\"__codelineno-1-1\" href=\"#__codelineno-1-1\"></a><span class=\"o\"><</span><span class=\"nx\">Modal</span> </span><span id=\"__span-1-2\"><a id=\"__codelineno-1-2\" name=\"__codelineno-1-2\" href=\"#__codelineno-1-2\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"s2\">\"Create Landing Page\"</span> </span><span id=\"__span-1-3\"><a id=\"__codelineno-1-3\" name=\"__codelineno-1-3\" href=\"#__codelineno-1-3\"></a><span class=\"w\"> </span><span class=\"nx\">open</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">createModalOpen</span><span class=\"p\">}</span> </span><span id=\"__span-1-4\"><a id=\"__codelineno-1-4\" name=\"__codelineno-1-4\" href=\"#__codelineno-1-4\"></a><span class=\"w\"> </span><span class=\"nx\">destroyOnHidden</span> </span><span id=\"__span-1-5\"><a id=\"__codelineno-1-5\" name=\"__codelineno-1-5\" href=\"#__codelineno-1-5\"></a><span class=\"w\"> </span><span class=\"nx\">onCancel</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-1-6\"><a id=\"__codelineno-1-6\" name=\"__codelineno-1-6\" href=\"#__codelineno-1-6\"></a><span class=\"w\"> </span><span class=\"nx\">setCreateModalOpen</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-1-7\"><a id=\"__codelineno-1-7\" name=\"__codelineno-1-7\" href=\"#__codelineno-1-7\"></a><span class=\"w\"> </span><span class=\"nx\">createForm</span><span class=\"p\">.</span><span class=\"nx\">resetFields</span><span class=\"p\">();</span> </span><span id=\"__span-1-8\"><a id=\"__codelineno-1-8\" name=\"__codelineno-1-8\" href=\"#__codelineno-1-8\"></a><span class=\"w\"> </span><span class=\"p\">}}</span> </span><span id=\"__span-1-9\"><a id=\"__codelineno-1-9\" name=\"__codelineno-1-9\" href=\"#__codelineno-1-9\"></a><span class=\"w\"> </span><span class=\"nx\">onOk</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">createForm</span><span class=\"p\">.</span><span class=\"nx\">submit</span><span class=\"p\">()}</span> </span><span id=\"__span-1-10\"><a id=\"__codelineno-1-10\" name=\"__codelineno-1-10\" href=\"#__codelineno-1-10\"></a><span class=\"w\"> </span><span class=\"nx\">okText</span><span class=\"o\">=</span><span class=\"s2\">\"Create & Edit\"</span> </span><span id=\"__span-1-11\"><a id=\"__codelineno-1-11\" name=\"__codelineno-1-11\" href=\"#__codelineno-1-11\"></a><span class=\"o\">></span> </span><span id=\"__span-1-12\"><a id=\"__codelineno-1-12\" name=\"__codelineno-1-12\" href=\"#__codelineno-1-12\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"w\"> </span><span class=\"nx\">form</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">createForm</span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"nx\">onFinish</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">handleCreate</span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"nx\">layout</span><span class=\"o\">=</span><span class=\"s2\">\"vertical\"</span><span class=\"w\"> </span><span class=\"nx\">initialValues</span><span class=\"o\">=</span><span class=\"p\">{{</span><span class=\"w\"> </span><span class=\"nx\">editorMode</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'VISUAL'</span><span class=\"w\"> </span><span class=\"p\">}}</span><span class=\"o\">></span> </span><span id=\"__span-1-13\"><a id=\"__codelineno-1-13\" name=\"__codelineno-1-13\" href=\"#__codelineno-1-13\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span> </span><span id=\"__span-1-14\"><a id=\"__codelineno-1-14\" name=\"__codelineno-1-14\" href=\"#__codelineno-1-14\"></a><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"title\"</span> </span><span id=\"__span-1-15\"><a id=\"__codelineno-1-15\" name=\"__codelineno-1-15\" href=\"#__codelineno-1-15\"></a><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"Title\"</span> </span><span id=\"__span-1-16\"><a id=\"__codelineno-1-16\" name=\"__codelineno-1-16\" href=\"#__codelineno-1-16\"></a><span class=\"w\"> </span><span class=\"nx\">rules</span><span class=\"o\">=</span><span class=\"p\">{[{</span><span class=\"w\"> </span><span class=\"nx\">required</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">true</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Title is required'</span><span class=\"w\"> </span><span class=\"p\">}]}</span> </span><span id=\"__span-1-17\"><a id=\"__codelineno-1-17\" name=\"__codelineno-1-17\" href=\"#__codelineno-1-17\"></a><span class=\"w\"> </span><span class=\"o\">></span> </span><span id=\"__span-1-18\"><a id=\"__codelineno-1-18\" name=\"__codelineno-1-18\" href=\"#__codelineno-1-18\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Input</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-1-19\"><a id=\"__codelineno-1-19\" name=\"__codelineno-1-19\" href=\"#__codelineno-1-19\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-1-20\"><a id=\"__codelineno-1-20\" name=\"__codelineno-1-20\" href=\"#__codelineno-1-20\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"description\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"Description\"</span><span class=\"o\">></span> </span><span id=\"__span-1-21\"><a id=\"__codelineno-1-21\" name=\"__codelineno-1-21\" href=\"#__codelineno-1-21\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">TextArea</span><span class=\"w\"> </span><span class=\"nx\">rows</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"mf\">3</span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-1-22\"><a id=\"__codelineno-1-22\" name=\"__codelineno-1-22\" href=\"#__codelineno-1-22\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-1-23\"><a id=\"__codelineno-1-23\" name=\"__codelineno-1-23\" href=\"#__codelineno-1-23\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"editorMode\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"Editor Mode\"</span><span class=\"o\">></span> </span><span id=\"__span-1-24\"><a id=\"__codelineno-1-24\" name=\"__codelineno-1-24\" href=\"#__codelineno-1-24\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Radio</span><span class=\"p\">.</span><span class=\"nx\">Group</span><span class=\"o\">></span> </span><span id=\"__span-1-25\"><a id=\"__codelineno-1-25\" name=\"__codelineno-1-25\" href=\"#__codelineno-1-25\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Radio</span><span class=\"p\">.</span><span class=\"nx\">Button</span><span class=\"w\"> </span><span class=\"nx\">value</span><span class=\"o\">=</span><span class=\"s2\">\"VISUAL\"</span><span class=\"o\">></span><span class=\"nx\">Visual</span><span class=\"w\"> </span><span class=\"nx\">Editor</span><span class=\"o\"><</span><span class=\"err\">/Radio.Button></span> </span><span id=\"__span-1-26\"><a id=\"__codelineno-1-26\" name=\"__codelineno-1-26\" href=\"#__codelineno-1-26\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Radio</span><span class=\"p\">.</span><span class=\"nx\">Button</span><span class=\"w\"> </span><span class=\"nx\">value</span><span class=\"o\">=</span><span class=\"s2\">\"CODE\"</span><span class=\"o\">></span><span class=\"nx\">Code</span><span class=\"w\"> </span><span class=\"nx\">Editor</span><span class=\"o\"><</span><span class=\"err\">/Radio.Button></span> </span><span id=\"__span-1-27\"><a id=\"__codelineno-1-27\" name=\"__codelineno-1-27\" href=\"#__codelineno-1-27\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Radio.Group></span> </span><span id=\"__span-1-28\"><a id=\"__codelineno-1-28\" name=\"__codelineno-1-28\" href=\"#__codelineno-1-28\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-1-29\"><a id=\"__codelineno-1-29\" name=\"__codelineno-1-29\" href=\"#__codelineno-1-29\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form></span> </span><span id=\"__span-1-30\"><a id=\"__codelineno-1-30\" name=\"__codelineno-1-30\" href=\"#__codelineno-1-30\"></a><span class=\"o\"><</span><span class=\"err\">/Modal></span> </span></code></pre></div> <p><strong>Modal Features:</strong> - <strong>destroyOnHidden:</strong> Form resets when modal closes (no stale data) - <strong>okText:</strong> \"Create & Edit\" (clarifies that page will open in editor after creation) - <strong>Initial values:</strong> Editor mode defaults to VISUAL (most users prefer visual editor) - <strong>Validation:</strong> Title required (cannot be empty)</p> <h3 id=\"settings-modal\">Settings Modal<a class=\"headerlink\" href=\"#settings-modal\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-2-1\"><a id=\"__codelineno-2-1\" name=\"__codelineno-2-1\" href=\"#__codelineno-2-1\"></a><span class=\"o\"><</span><span class=\"nx\">Modal</span> </span><span id=\"__span-2-2\"><a id=\"__codelineno-2-2\" name=\"__codelineno-2-2\" href=\"#__codelineno-2-2\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"s2\">\"Page Settings\"</span> </span><span id=\"__span-2-3\"><a id=\"__codelineno-2-3\" name=\"__codelineno-2-3\" href=\"#__codelineno-2-3\"></a><span class=\"w\"> </span><span class=\"nx\">open</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">settingsModalOpen</span><span class=\"p\">}</span> </span><span id=\"__span-2-4\"><a id=\"__codelineno-2-4\" name=\"__codelineno-2-4\" href=\"#__codelineno-2-4\"></a><span class=\"w\"> </span><span class=\"nx\">destroyOnHidden</span> </span><span id=\"__span-2-5\"><a id=\"__codelineno-2-5\" name=\"__codelineno-2-5\" href=\"#__codelineno-2-5\"></a><span class=\"w\"> </span><span class=\"nx\">width</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"mf\">560</span><span class=\"p\">}</span> </span><span id=\"__span-2-6\"><a id=\"__codelineno-2-6\" name=\"__codelineno-2-6\" href=\"#__codelineno-2-6\"></a><span class=\"w\"> </span><span class=\"nx\">onCancel</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-2-7\"><a id=\"__codelineno-2-7\" name=\"__codelineno-2-7\" href=\"#__codelineno-2-7\"></a><span class=\"w\"> </span><span class=\"nx\">setSettingsModalOpen</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-2-8\"><a id=\"__codelineno-2-8\" name=\"__codelineno-2-8\" href=\"#__codelineno-2-8\"></a><span class=\"w\"> </span><span class=\"nx\">setEditingPage</span><span class=\"p\">(</span><span class=\"kc\">null</span><span class=\"p\">);</span> </span><span id=\"__span-2-9\"><a id=\"__codelineno-2-9\" name=\"__codelineno-2-9\" href=\"#__codelineno-2-9\"></a><span class=\"w\"> </span><span class=\"nx\">settingsForm</span><span class=\"p\">.</span><span class=\"nx\">resetFields</span><span class=\"p\">();</span> </span><span id=\"__span-2-10\"><a id=\"__codelineno-2-10\" name=\"__codelineno-2-10\" href=\"#__codelineno-2-10\"></a><span class=\"w\"> </span><span class=\"p\">}}</span> </span><span id=\"__span-2-11\"><a id=\"__codelineno-2-11\" name=\"__codelineno-2-11\" href=\"#__codelineno-2-11\"></a><span class=\"w\"> </span><span class=\"nx\">onOk</span><span class=\"o\">=</span><span class=\"p\">{()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">settingsForm</span><span class=\"p\">.</span><span class=\"nx\">submit</span><span class=\"p\">()}</span> </span><span id=\"__span-2-12\"><a id=\"__codelineno-2-12\" name=\"__codelineno-2-12\" href=\"#__codelineno-2-12\"></a><span class=\"w\"> </span><span class=\"nx\">okText</span><span class=\"o\">=</span><span class=\"s2\">\"Save\"</span> </span><span id=\"__span-2-13\"><a id=\"__codelineno-2-13\" name=\"__codelineno-2-13\" href=\"#__codelineno-2-13\"></a><span class=\"o\">></span> </span><span id=\"__span-2-14\"><a id=\"__codelineno-2-14\" name=\"__codelineno-2-14\" href=\"#__codelineno-2-14\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"w\"> </span><span class=\"nx\">form</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">settingsForm</span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"nx\">onFinish</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">handleSettingsSave</span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"nx\">layout</span><span class=\"o\">=</span><span class=\"s2\">\"vertical\"</span><span class=\"o\">></span> </span><span id=\"__span-2-15\"><a id=\"__codelineno-2-15\" name=\"__codelineno-2-15\" href=\"#__codelineno-2-15\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"cm\">/* Basic Settings */</span><span class=\"p\">}</span> </span><span id=\"__span-2-16\"><a id=\"__codelineno-2-16\" name=\"__codelineno-2-16\" href=\"#__codelineno-2-16\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"title\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"Title\"</span><span class=\"w\"> </span><span class=\"nx\">rules</span><span class=\"o\">=</span><span class=\"p\">{[{</span><span class=\"w\"> </span><span class=\"nx\">required</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">true</span><span class=\"w\"> </span><span class=\"p\">}]}</span><span class=\"o\">></span> </span><span id=\"__span-2-17\"><a id=\"__codelineno-2-17\" name=\"__codelineno-2-17\" href=\"#__codelineno-2-17\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Input</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-2-18\"><a id=\"__codelineno-2-18\" name=\"__codelineno-2-18\" href=\"#__codelineno-2-18\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-19\"><a id=\"__codelineno-2-19\" name=\"__codelineno-2-19\" href=\"#__codelineno-2-19\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"description\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"Description\"</span><span class=\"o\">></span> </span><span id=\"__span-2-20\"><a id=\"__codelineno-2-20\" name=\"__codelineno-2-20\" href=\"#__codelineno-2-20\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">TextArea</span><span class=\"w\"> </span><span class=\"nx\">rows</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"mf\">2</span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-2-21\"><a id=\"__codelineno-2-21\" name=\"__codelineno-2-21\" href=\"#__codelineno-2-21\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-22\"><a id=\"__codelineno-2-22\" name=\"__codelineno-2-22\" href=\"#__codelineno-2-22\"></a> </span><span id=\"__span-2-23\"><a id=\"__codelineno-2-23\" name=\"__codelineno-2-23\" href=\"#__codelineno-2-23\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"cm\">/* SEO Settings */</span><span class=\"p\">}</span> </span><span id=\"__span-2-24\"><a id=\"__codelineno-2-24\" name=\"__codelineno-2-24\" href=\"#__codelineno-2-24\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"seoTitle\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"SEO Title\"</span><span class=\"o\">></span> </span><span id=\"__span-2-25\"><a id=\"__codelineno-2-25\" name=\"__codelineno-2-25\" href=\"#__codelineno-2-25\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Input</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-2-26\"><a id=\"__codelineno-2-26\" name=\"__codelineno-2-26\" href=\"#__codelineno-2-26\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-27\"><a id=\"__codelineno-2-27\" name=\"__codelineno-2-27\" href=\"#__codelineno-2-27\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"seoDescription\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"SEO Description\"</span><span class=\"o\">></span> </span><span id=\"__span-2-28\"><a id=\"__codelineno-2-28\" name=\"__codelineno-2-28\" href=\"#__codelineno-2-28\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">TextArea</span><span class=\"w\"> </span><span class=\"nx\">rows</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"mf\">2</span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-2-29\"><a id=\"__codelineno-2-29\" name=\"__codelineno-2-29\" href=\"#__codelineno-2-29\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-30\"><a id=\"__codelineno-2-30\" name=\"__codelineno-2-30\" href=\"#__codelineno-2-30\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"seoImage\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"SEO Image URL\"</span><span class=\"o\">></span> </span><span id=\"__span-2-31\"><a id=\"__codelineno-2-31\" name=\"__codelineno-2-31\" href=\"#__codelineno-2-31\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Input</span><span class=\"w\"> </span><span class=\"nx\">placeholder</span><span class=\"o\">=</span><span class=\"s2\">\"https://...\"</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-2-32\"><a id=\"__codelineno-2-32\" name=\"__codelineno-2-32\" href=\"#__codelineno-2-32\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-33\"><a id=\"__codelineno-2-33\" name=\"__codelineno-2-33\" href=\"#__codelineno-2-33\"></a> </span><span id=\"__span-2-34\"><a id=\"__codelineno-2-34\" name=\"__codelineno-2-34\" href=\"#__codelineno-2-34\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Divider</span><span class=\"o\">></span><span class=\"nx\">MkDocs</span><span class=\"w\"> </span><span class=\"nx\">Integration</span><span class=\"o\"><</span><span class=\"err\">/Divider></span> </span><span id=\"__span-2-35\"><a id=\"__codelineno-2-35\" name=\"__codelineno-2-35\" href=\"#__codelineno-2-35\"></a> </span><span id=\"__span-2-36\"><a id=\"__codelineno-2-36\" name=\"__codelineno-2-36\" href=\"#__codelineno-2-36\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"cm\">/* MkDocs Settings */</span><span class=\"p\">}</span> </span><span id=\"__span-2-37\"><a id=\"__codelineno-2-37\" name=\"__codelineno-2-37\" href=\"#__codelineno-2-37\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span> </span><span id=\"__span-2-38\"><a id=\"__codelineno-2-38\" name=\"__codelineno-2-38\" href=\"#__codelineno-2-38\"></a><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"mkdocsSkipExport\"</span> </span><span id=\"__span-2-39\"><a id=\"__codelineno-2-39\" name=\"__codelineno-2-39\" href=\"#__codelineno-2-39\"></a><span class=\"w\"> </span><span class=\"nx\">valuePropName</span><span class=\"o\">=</span><span class=\"s2\">\"checked\"</span> </span><span id=\"__span-2-40\"><a id=\"__codelineno-2-40\" name=\"__codelineno-2-40\" href=\"#__codelineno-2-40\"></a><span class=\"w\"> </span><span class=\"nx\">help</span><span class=\"o\">=</span><span class=\"s2\">\"When enabled, this page will not be exported to MkDocs even when published.\"</span> </span><span id=\"__span-2-41\"><a id=\"__codelineno-2-41\" name=\"__codelineno-2-41\" href=\"#__codelineno-2-41\"></a><span class=\"w\"> </span><span class=\"o\">></span> </span><span id=\"__span-2-42\"><a id=\"__codelineno-2-42\" name=\"__codelineno-2-42\" href=\"#__codelineno-2-42\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Checkbox</span><span class=\"o\">></span><span class=\"nx\">Skip</span><span class=\"w\"> </span><span class=\"nx\">MkDocs</span><span class=\"w\"> </span><span class=\"nx\">Export</span><span class=\"o\"><</span><span class=\"err\">/Checkbox></span> </span><span id=\"__span-2-43\"><a id=\"__codelineno-2-43\" name=\"__codelineno-2-43\" href=\"#__codelineno-2-43\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-44\"><a id=\"__codelineno-2-44\" name=\"__codelineno-2-44\" href=\"#__codelineno-2-44\"></a> </span><span id=\"__span-2-45\"><a id=\"__codelineno-2-45\" name=\"__codelineno-2-45\" href=\"#__codelineno-2-45\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"cm\">/* Conditional fields (only shown if not skipping export) */</span><span class=\"p\">}</span> </span><span id=\"__span-2-46\"><a id=\"__codelineno-2-46\" name=\"__codelineno-2-46\" href=\"#__codelineno-2-46\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">noStyle</span><span class=\"w\"> </span><span class=\"nx\">shouldUpdate</span><span class=\"o\">=</span><span class=\"p\">{(</span><span class=\"nx\">prev</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">cur</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">prev</span><span class=\"p\">.</span><span class=\"nx\">mkdocsSkipExport</span><span class=\"w\"> </span><span class=\"o\">!==</span><span class=\"w\"> </span><span class=\"nx\">cur</span><span class=\"p\">.</span><span class=\"nx\">mkdocsSkipExport</span><span class=\"p\">}</span><span class=\"o\">></span> </span><span id=\"__span-2-47\"><a id=\"__codelineno-2-47\" name=\"__codelineno-2-47\" href=\"#__codelineno-2-47\"></a><span class=\"w\"> </span><span class=\"p\">{({</span><span class=\"w\"> </span><span class=\"nx\">getFieldValue</span><span class=\"w\"> </span><span class=\"p\">})</span><span class=\"w\"> </span><span class=\"p\">=></span> </span><span id=\"__span-2-48\"><a id=\"__codelineno-2-48\" name=\"__codelineno-2-48\" href=\"#__codelineno-2-48\"></a><span class=\"w\"> </span><span class=\"o\">!</span><span class=\"nx\">getFieldValue</span><span class=\"p\">(</span><span class=\"s1\">'mkdocsSkipExport'</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"o\">&&</span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-2-49\"><a id=\"__codelineno-2-49\" name=\"__codelineno-2-49\" href=\"#__codelineno-2-49\"></a><span class=\"w\"> </span><span class=\"o\"><></span> </span><span id=\"__span-2-50\"><a id=\"__codelineno-2-50\" name=\"__codelineno-2-50\" href=\"#__codelineno-2-50\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"mkdocsPath\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"Override Path\"</span><span class=\"o\">></span> </span><span id=\"__span-2-51\"><a id=\"__codelineno-2-51\" name=\"__codelineno-2-51\" href=\"#__codelineno-2-51\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Input</span><span class=\"w\"> </span><span class=\"nx\">placeholder</span><span class=\"o\">=</span><span class=\"s2\">\"e.g. about.html\"</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-2-52\"><a id=\"__codelineno-2-52\" name=\"__codelineno-2-52\" href=\"#__codelineno-2-52\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-53\"><a id=\"__codelineno-2-53\" name=\"__codelineno-2-53\" href=\"#__codelineno-2-53\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span> </span><span id=\"__span-2-54\"><a id=\"__codelineno-2-54\" name=\"__codelineno-2-54\" href=\"#__codelineno-2-54\"></a><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"mkdocsExportMode\"</span> </span><span id=\"__span-2-55\"><a id=\"__codelineno-2-55\" name=\"__codelineno-2-55\" href=\"#__codelineno-2-55\"></a><span class=\"w\"> </span><span class=\"nx\">valuePropName</span><span class=\"o\">=</span><span class=\"s2\">\"checked\"</span> </span><span id=\"__span-2-56\"><a id=\"__codelineno-2-56\" name=\"__codelineno-2-56\" href=\"#__codelineno-2-56\"></a><span class=\"w\"> </span><span class=\"nx\">getValueFromEvent</span><span class=\"o\">=</span><span class=\"p\">{(</span><span class=\"nx\">e</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">e</span><span class=\"p\">.</span><span class=\"nx\">target</span><span class=\"p\">.</span><span class=\"nx\">checked</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'STANDALONE'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'THEMED'</span><span class=\"p\">}</span> </span><span id=\"__span-2-57\"><a id=\"__codelineno-2-57\" name=\"__codelineno-2-57\" href=\"#__codelineno-2-57\"></a><span class=\"w\"> </span><span class=\"nx\">getValueProps</span><span class=\"o\">=</span><span class=\"p\">{(</span><span class=\"nx\">value</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">({</span><span class=\"w\"> </span><span class=\"nx\">checked</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">value</span><span class=\"w\"> </span><span class=\"o\">===</span><span class=\"w\"> </span><span class=\"s1\">'STANDALONE'</span><span class=\"w\"> </span><span class=\"p\">})}</span> </span><span id=\"__span-2-58\"><a id=\"__codelineno-2-58\" name=\"__codelineno-2-58\" href=\"#__codelineno-2-58\"></a><span class=\"w\"> </span><span class=\"nx\">help</span><span class=\"o\">=</span><span class=\"s2\">\"Publish as a full HTML page with no MkDocs header, footer, or theme\"</span> </span><span id=\"__span-2-59\"><a id=\"__codelineno-2-59\" name=\"__codelineno-2-59\" href=\"#__codelineno-2-59\"></a><span class=\"w\"> </span><span class=\"o\">></span> </span><span id=\"__span-2-60\"><a id=\"__codelineno-2-60\" name=\"__codelineno-2-60\" href=\"#__codelineno-2-60\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Checkbox</span><span class=\"o\">></span><span class=\"nx\">Full</span><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"w\"> </span><span class=\"nx\">MkDocs</span><span class=\"o\"><</span><span class=\"err\">/Checkbox></span> </span><span id=\"__span-2-61\"><a id=\"__codelineno-2-61\" name=\"__codelineno-2-61\" href=\"#__codelineno-2-61\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-62\"><a id=\"__codelineno-2-62\" name=\"__codelineno-2-62\" href=\"#__codelineno-2-62\"></a> </span><span id=\"__span-2-63\"><a id=\"__codelineno-2-63\" name=\"__codelineno-2-63\" href=\"#__codelineno-2-63\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"cm\">/* Conditional theme fields (only shown if not standalone) */</span><span class=\"p\">}</span> </span><span id=\"__span-2-64\"><a id=\"__codelineno-2-64\" name=\"__codelineno-2-64\" href=\"#__codelineno-2-64\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">noStyle</span><span class=\"w\"> </span><span class=\"nx\">shouldUpdate</span><span class=\"o\">=</span><span class=\"p\">{(</span><span class=\"nx\">prev</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">cur</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">prev</span><span class=\"p\">.</span><span class=\"nx\">mkdocsExportMode</span><span class=\"w\"> </span><span class=\"o\">!==</span><span class=\"w\"> </span><span class=\"nx\">cur</span><span class=\"p\">.</span><span class=\"nx\">mkdocsExportMode</span><span class=\"p\">}</span><span class=\"o\">></span> </span><span id=\"__span-2-65\"><a id=\"__codelineno-2-65\" name=\"__codelineno-2-65\" href=\"#__codelineno-2-65\"></a><span class=\"w\"> </span><span class=\"p\">{({</span><span class=\"w\"> </span><span class=\"nx\">getFieldValue</span><span class=\"w\"> </span><span class=\"p\">})</span><span class=\"w\"> </span><span class=\"p\">=></span> </span><span id=\"__span-2-66\"><a id=\"__codelineno-2-66\" name=\"__codelineno-2-66\" href=\"#__codelineno-2-66\"></a><span class=\"w\"> </span><span class=\"nx\">getFieldValue</span><span class=\"p\">(</span><span class=\"s1\">'mkdocsExportMode'</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"o\">!==</span><span class=\"w\"> </span><span class=\"s1\">'STANDALONE'</span><span class=\"w\"> </span><span class=\"o\">&&</span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-2-67\"><a id=\"__codelineno-2-67\" name=\"__codelineno-2-67\" href=\"#__codelineno-2-67\"></a><span class=\"w\"> </span><span class=\"o\"><></span> </span><span id=\"__span-2-68\"><a id=\"__codelineno-2-68\" name=\"__codelineno-2-68\" href=\"#__codelineno-2-68\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"mkdocsHideNav\"</span><span class=\"w\"> </span><span class=\"nx\">valuePropName</span><span class=\"o\">=</span><span class=\"s2\">\"checked\"</span><span class=\"o\">></span> </span><span id=\"__span-2-69\"><a id=\"__codelineno-2-69\" name=\"__codelineno-2-69\" href=\"#__codelineno-2-69\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Checkbox</span><span class=\"o\">></span><span class=\"nx\">Hide</span><span class=\"w\"> </span><span class=\"nx\">navigation</span><span class=\"w\"> </span><span class=\"nx\">sidebar</span><span class=\"o\"><</span><span class=\"err\">/Checkbox></span> </span><span id=\"__span-2-70\"><a id=\"__codelineno-2-70\" name=\"__codelineno-2-70\" href=\"#__codelineno-2-70\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-71\"><a id=\"__codelineno-2-71\" name=\"__codelineno-2-71\" href=\"#__codelineno-2-71\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"mkdocsHideToc\"</span><span class=\"w\"> </span><span class=\"nx\">valuePropName</span><span class=\"o\">=</span><span class=\"s2\">\"checked\"</span><span class=\"o\">></span> </span><span id=\"__span-2-72\"><a id=\"__codelineno-2-72\" name=\"__codelineno-2-72\" href=\"#__codelineno-2-72\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Checkbox</span><span class=\"o\">></span><span class=\"nx\">Hide</span><span class=\"w\"> </span><span class=\"nx\">table</span><span class=\"w\"> </span><span class=\"k\">of</span><span class=\"w\"> </span><span class=\"nx\">contents</span><span class=\"o\"><</span><span class=\"err\">/Checkbox></span> </span><span id=\"__span-2-73\"><a id=\"__codelineno-2-73\" name=\"__codelineno-2-73\" href=\"#__codelineno-2-73\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-74\"><a id=\"__codelineno-2-74\" name=\"__codelineno-2-74\" href=\"#__codelineno-2-74\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/></span> </span><span id=\"__span-2-75\"><a id=\"__codelineno-2-75\" name=\"__codelineno-2-75\" href=\"#__codelineno-2-75\"></a><span class=\"w\"> </span><span class=\"p\">)</span> </span><span id=\"__span-2-76\"><a id=\"__codelineno-2-76\" name=\"__codelineno-2-76\" href=\"#__codelineno-2-76\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-2-77\"><a id=\"__codelineno-2-77\" name=\"__codelineno-2-77\" href=\"#__codelineno-2-77\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-78\"><a id=\"__codelineno-2-78\" name=\"__codelineno-2-78\" href=\"#__codelineno-2-78\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/></span> </span><span id=\"__span-2-79\"><a id=\"__codelineno-2-79\" name=\"__codelineno-2-79\" href=\"#__codelineno-2-79\"></a><span class=\"w\"> </span><span class=\"p\">)</span> </span><span id=\"__span-2-80\"><a id=\"__codelineno-2-80\" name=\"__codelineno-2-80\" href=\"#__codelineno-2-80\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-2-81\"><a id=\"__codelineno-2-81\" name=\"__codelineno-2-81\" href=\"#__codelineno-2-81\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-2-82\"><a id=\"__codelineno-2-82\" name=\"__codelineno-2-82\" href=\"#__codelineno-2-82\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form></span> </span><span id=\"__span-2-83\"><a id=\"__codelineno-2-83\" name=\"__codelineno-2-83\" href=\"#__codelineno-2-83\"></a><span class=\"o\"><</span><span class=\"err\">/Modal></span> </span></code></pre></div> <p><strong>Settings Modal Features:</strong> - <strong>Wider modal:</strong> 560px width (accommodates longer labels + descriptions) - <strong>Conditional fields:</strong> MkDocs fields hidden if \"Skip Export\" checked - <strong>Nested conditional fields:</strong> Theme fields hidden if \"Full page MkDocs\" checked - <strong>Help text:</strong> Explains what each setting does - <strong>Value transformations:</strong> <code>mkdocsExportMode</code> stored as 'STANDALONE'/'THEMED', displayed as checkbox</p> <h2 id=\"state-management\">State Management<a class=\"headerlink\" href=\"#state-management\" title=\"Permanent link\">\u00b6</a></h2> <h3 id=\"local-state-no-zustand-store\">Local State (No Zustand Store)<a class=\"headerlink\" href=\"#local-state-no-zustand-store\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-3-1\"><a id=\"__codelineno-3-1\" name=\"__codelineno-3-1\" href=\"#__codelineno-3-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">pages</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setPages</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"o\"><</span><span class=\"nx\">LandingPage</span><span class=\"p\">[]</span><span class=\"o\">></span><span class=\"p\">([]);</span> </span><span id=\"__span-3-2\"><a id=\"__codelineno-3-2\" name=\"__codelineno-3-2\" href=\"#__codelineno-3-2\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">pagination</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setPagination</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"p\">({</span><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">1</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">limit</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">20</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">total</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">0</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">totalPages</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">0</span><span class=\"w\"> </span><span class=\"p\">});</span> </span><span id=\"__span-3-3\"><a id=\"__codelineno-3-3\" name=\"__codelineno-3-3\" href=\"#__codelineno-3-3\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">loading</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setLoading</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-3-4\"><a id=\"__codelineno-3-4\" name=\"__codelineno-3-4\" href=\"#__codelineno-3-4\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">syncing</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setSyncing</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-3-5\"><a id=\"__codelineno-3-5\" name=\"__codelineno-3-5\" href=\"#__codelineno-3-5\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">validating</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setValidating</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-3-6\"><a id=\"__codelineno-3-6\" name=\"__codelineno-3-6\" href=\"#__codelineno-3-6\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">search</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setSearch</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"p\">(</span><span class=\"s1\">''</span><span class=\"p\">);</span> </span><span id=\"__span-3-7\"><a id=\"__codelineno-3-7\" name=\"__codelineno-3-7\" href=\"#__codelineno-3-7\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">debouncedSearch</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setDebouncedSearch</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"p\">(</span><span class=\"s1\">''</span><span class=\"p\">);</span> </span><span id=\"__span-3-8\"><a id=\"__codelineno-3-8\" name=\"__codelineno-3-8\" href=\"#__codelineno-3-8\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">searchTimerRef</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useRef</span><span class=\"o\"><</span><span class=\"nx\">ReturnType</span><span class=\"o\"><</span><span class=\"ow\">typeof</span><span class=\"w\"> </span><span class=\"nx\">setTimeout</span><span class=\"o\">>></span><span class=\"p\">(</span><span class=\"kc\">undefined</span><span class=\"p\">);</span> </span><span id=\"__span-3-9\"><a id=\"__codelineno-3-9\" name=\"__codelineno-3-9\" href=\"#__codelineno-3-9\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">publishedFilter</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setPublishedFilter</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"o\"><</span><span class=\"s1\">'true'</span><span class=\"w\"> </span><span class=\"o\">|</span><span class=\"w\"> </span><span class=\"s1\">'false'</span><span class=\"w\"> </span><span class=\"o\">|</span><span class=\"w\"> </span><span class=\"kc\">undefined</span><span class=\"o\">></span><span class=\"p\">();</span> </span><span id=\"__span-3-10\"><a id=\"__codelineno-3-10\" name=\"__codelineno-3-10\" href=\"#__codelineno-3-10\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">createModalOpen</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setCreateModalOpen</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-3-11\"><a id=\"__codelineno-3-11\" name=\"__codelineno-3-11\" href=\"#__codelineno-3-11\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">settingsModalOpen</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setSettingsModalOpen</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-3-12\"><a id=\"__codelineno-3-12\" name=\"__codelineno-3-12\" href=\"#__codelineno-3-12\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">editingPage</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">setEditingPage</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">useState</span><span class=\"o\"><</span><span class=\"nx\">LandingPage</span><span class=\"w\"> </span><span class=\"o\">|</span><span class=\"w\"> </span><span class=\"kc\">null</span><span class=\"o\">></span><span class=\"p\">(</span><span class=\"kc\">null</span><span class=\"p\">);</span> </span><span id=\"__span-3-13\"><a id=\"__codelineno-3-13\" name=\"__codelineno-3-13\" href=\"#__codelineno-3-13\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">createForm</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">useForm</span><span class=\"p\">();</span> </span><span id=\"__span-3-14\"><a id=\"__codelineno-3-14\" name=\"__codelineno-3-14\" href=\"#__codelineno-3-14\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">settingsForm</span><span class=\"p\">]</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">useForm</span><span class=\"p\">();</span> </span></code></pre></div> <p><strong>State Variables:</strong> - <code>pages</code> (array): Current page of landing pages - <code>pagination</code> (object): Pagination state (page, limit, total, totalPages) - <code>loading</code> (boolean): Table loading state - <code>syncing</code> (boolean): Sync Overrides button loading state - <code>validating</code> (boolean): Validate Exports button loading state - <code>search</code> (string): Immediate search input value - <code>debouncedSearch</code> (string): Debounced search value (triggers API call) - <code>searchTimerRef</code> (ref): Debounce timer reference - <code>publishedFilter</code> (string | undefined): Status filter ('true', 'false', or undefined for all) - <code>createModalOpen</code> (boolean): Create modal visibility - <code>settingsModalOpen</code> (boolean): Settings modal visibility - <code>editingPage</code> (LandingPage | null): Page being edited in settings modal - <code>createForm</code> (Form): Ant Design form instance for create modal - <code>settingsForm</code> (Form): Ant Design form instance for settings modal</p> <p><strong>No Global State:</strong></p> <p>This page does NOT use Zustand stores. Landing page data is fetched directly from the API and stored in local state. This is appropriate because: - Landing pages are admin-only data - Data changes infrequently (manual edits) - No need to share state between pages (public renderer fetches page independently) - Simpler architecture without store overhead</p> <h3 id=\"debounced-search-pattern\">Debounced Search Pattern<a class=\"headerlink\" href=\"#debounced-search-pattern\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-4-1\"><a id=\"__codelineno-4-1\" name=\"__codelineno-4-1\" href=\"#__codelineno-4-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">handleSearchChange</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">value</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-4-2\"><a id=\"__codelineno-4-2\" name=\"__codelineno-4-2\" href=\"#__codelineno-4-2\"></a><span class=\"w\"> </span><span class=\"nx\">setSearch</span><span class=\"p\">(</span><span class=\"nx\">value</span><span class=\"p\">);</span><span class=\"w\"> </span><span class=\"c1\">// Update immediate value (for input controlled state)</span> </span><span id=\"__span-4-3\"><a id=\"__codelineno-4-3\" name=\"__codelineno-4-3\" href=\"#__codelineno-4-3\"></a><span class=\"w\"> </span><span class=\"nx\">clearTimeout</span><span class=\"p\">(</span><span class=\"nx\">searchTimerRef</span><span class=\"p\">.</span><span class=\"nx\">current</span><span class=\"p\">);</span> </span><span id=\"__span-4-4\"><a id=\"__codelineno-4-4\" name=\"__codelineno-4-4\" href=\"#__codelineno-4-4\"></a><span class=\"w\"> </span><span class=\"nx\">searchTimerRef</span><span class=\"p\">.</span><span class=\"nx\">current</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">setTimeout</span><span class=\"p\">(()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">setDebouncedSearch</span><span class=\"p\">(</span><span class=\"nx\">value</span><span class=\"p\">),</span><span class=\"w\"> </span><span class=\"mf\">300</span><span class=\"p\">);</span> </span><span id=\"__span-4-5\"><a id=\"__codelineno-4-5\" name=\"__codelineno-4-5\" href=\"#__codelineno-4-5\"></a><span class=\"p\">};</span> </span><span id=\"__span-4-6\"><a id=\"__codelineno-4-6\" name=\"__codelineno-4-6\" href=\"#__codelineno-4-6\"></a> </span><span id=\"__span-4-7\"><a id=\"__codelineno-4-7\" name=\"__codelineno-4-7\" href=\"#__codelineno-4-7\"></a><span class=\"nx\">useEffect</span><span class=\"p\">(()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-4-8\"><a id=\"__codelineno-4-8\" name=\"__codelineno-4-8\" href=\"#__codelineno-4-8\"></a><span class=\"w\"> </span><span class=\"k\">return</span><span class=\"w\"> </span><span class=\"p\">()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">clearTimeout</span><span class=\"p\">(</span><span class=\"nx\">searchTimerRef</span><span class=\"p\">.</span><span class=\"nx\">current</span><span class=\"p\">);</span><span class=\"w\"> </span><span class=\"c1\">// Cleanup on unmount</span> </span><span id=\"__span-4-9\"><a id=\"__codelineno-4-9\" name=\"__codelineno-4-9\" href=\"#__codelineno-4-9\"></a><span class=\"p\">},</span><span class=\"w\"> </span><span class=\"p\">[]);</span> </span><span id=\"__span-4-10\"><a id=\"__codelineno-4-10\" name=\"__codelineno-4-10\" href=\"#__codelineno-4-10\"></a> </span><span id=\"__span-4-11\"><a id=\"__codelineno-4-11\" name=\"__codelineno-4-11\" href=\"#__codelineno-4-11\"></a><span class=\"nx\">useEffect</span><span class=\"p\">(()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-4-12\"><a id=\"__codelineno-4-12\" name=\"__codelineno-4-12\" href=\"#__codelineno-4-12\"></a><span class=\"w\"> </span><span class=\"nx\">fetchPages</span><span class=\"p\">({</span><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">1</span><span class=\"w\"> </span><span class=\"p\">});</span> </span><span id=\"__span-4-13\"><a id=\"__codelineno-4-13\" name=\"__codelineno-4-13\" href=\"#__codelineno-4-13\"></a><span class=\"p\">},</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"nx\">debouncedSearch</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">publishedFilter</span><span class=\"p\">]);</span><span class=\"w\"> </span><span class=\"c1\">// Trigger fetch when debounced search or filter changes</span> </span></code></pre></div> <p><strong>Why Two Search States?</strong></p> <ul> <li><strong>Immediate (<code>search</code>):</strong> Input value updates instantly (responsive input)</li> <li><strong>Debounced (<code>debouncedSearch</code>):</strong> API call triggers after 300ms pause</li> <li><strong>Result:</strong> Smooth typing experience without API spam</li> </ul> <h3 id=\"conditional-settings-form-fields\">Conditional Settings Form Fields<a class=\"headerlink\" href=\"#conditional-settings-form-fields\" title=\"Permanent link\">\u00b6</a></h3> <p>Settings form uses <code>shouldUpdate</code> to conditionally show/hide fields:</p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-5-1\"><a id=\"__codelineno-5-1\" name=\"__codelineno-5-1\" href=\"#__codelineno-5-1\"></a><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">noStyle</span><span class=\"w\"> </span><span class=\"nx\">shouldUpdate</span><span class=\"o\">=</span><span class=\"p\">{(</span><span class=\"nx\">prev</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">cur</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">prev</span><span class=\"p\">.</span><span class=\"nx\">mkdocsSkipExport</span><span class=\"w\"> </span><span class=\"o\">!==</span><span class=\"w\"> </span><span class=\"nx\">cur</span><span class=\"p\">.</span><span class=\"nx\">mkdocsSkipExport</span><span class=\"p\">}</span><span class=\"o\">></span> </span><span id=\"__span-5-2\"><a id=\"__codelineno-5-2\" name=\"__codelineno-5-2\" href=\"#__codelineno-5-2\"></a><span class=\"w\"> </span><span class=\"p\">{({</span><span class=\"w\"> </span><span class=\"nx\">getFieldValue</span><span class=\"w\"> </span><span class=\"p\">})</span><span class=\"w\"> </span><span class=\"p\">=></span> </span><span id=\"__span-5-3\"><a id=\"__codelineno-5-3\" name=\"__codelineno-5-3\" href=\"#__codelineno-5-3\"></a><span class=\"w\"> </span><span class=\"o\">!</span><span class=\"nx\">getFieldValue</span><span class=\"p\">(</span><span class=\"s1\">'mkdocsSkipExport'</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"o\">&&</span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-5-4\"><a id=\"__codelineno-5-4\" name=\"__codelineno-5-4\" href=\"#__codelineno-5-4\"></a><span class=\"w\"> </span><span class=\"o\"><></span> </span><span id=\"__span-5-5\"><a id=\"__codelineno-5-5\" name=\"__codelineno-5-5\" href=\"#__codelineno-5-5\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">name</span><span class=\"o\">=</span><span class=\"s2\">\"mkdocsPath\"</span><span class=\"w\"> </span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"s2\">\"Override Path\"</span><span class=\"o\">></span> </span><span id=\"__span-5-6\"><a id=\"__codelineno-5-6\" name=\"__codelineno-5-6\" href=\"#__codelineno-5-6\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Input</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-5-7\"><a id=\"__codelineno-5-7\" name=\"__codelineno-5-7\" href=\"#__codelineno-5-7\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span><span id=\"__span-5-8\"><a id=\"__codelineno-5-8\" name=\"__codelineno-5-8\" href=\"#__codelineno-5-8\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"cm\">/* Other MkDocs fields */</span><span class=\"p\">}</span> </span><span id=\"__span-5-9\"><a id=\"__codelineno-5-9\" name=\"__codelineno-5-9\" href=\"#__codelineno-5-9\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/></span> </span><span id=\"__span-5-10\"><a id=\"__codelineno-5-10\" name=\"__codelineno-5-10\" href=\"#__codelineno-5-10\"></a><span class=\"w\"> </span><span class=\"p\">)</span> </span><span id=\"__span-5-11\"><a id=\"__codelineno-5-11\" name=\"__codelineno-5-11\" href=\"#__codelineno-5-11\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-5-12\"><a id=\"__codelineno-5-12\" name=\"__codelineno-5-12\" href=\"#__codelineno-5-12\"></a><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span></code></pre></div> <p><strong>How It Works:</strong> - <code>shouldUpdate</code>: Function returns true when <code>mkdocsSkipExport</code> changes - <code>getFieldValue</code>: Reads current value of <code>mkdocsSkipExport</code> checkbox - Conditional rendering: MkDocs fields only rendered if checkbox NOT checked - <strong>Result:</strong> Form dynamically shows/hides fields based on checkbox state</p> <h2 id=\"api-integration\">API Integration<a class=\"headerlink\" href=\"#api-integration\" title=\"Permanent link\">\u00b6</a></h2> <h3 id=\"endpoints-used\">Endpoints Used<a class=\"headerlink\" href=\"#endpoints-used\" title=\"Permanent link\">\u00b6</a></h3> <table> <thead> <tr> <th>Method</th> <th>Endpoint</th> <th>Purpose</th> <th>Auth</th> </tr> </thead> <tbody> <tr> <td>GET</td> <td><code>/api/pages</code></td> <td>List pages (paginated, filtered)</td> <td>Required</td> </tr> <tr> <td>POST</td> <td><code>/api/pages</code></td> <td>Create new page</td> <td>Required</td> </tr> <tr> <td>PUT</td> <td><code>/api/pages/:id</code></td> <td>Update page metadata/settings</td> <td>Required</td> </tr> <tr> <td>DELETE</td> <td><code>/api/pages/:id</code></td> <td>Delete page</td> <td>Required</td> </tr> <tr> <td>POST</td> <td><code>/api/pages/sync</code></td> <td>Sync MkDocs overrides</td> <td>Required</td> </tr> <tr> <td>POST</td> <td><code>/api/pages/validate</code></td> <td>Validate MkDocs exports</td> <td>Required</td> </tr> <tr> <td>POST</td> <td><code>/api/docs/build</code></td> <td>Build MkDocs site</td> <td>Required (SUPER_ADMIN)</td> </tr> </tbody> </table> <h3 id=\"load-landing-pages-paginated-with-filters\">Load Landing Pages (Paginated with Filters)<a class=\"headerlink\" href=\"#load-landing-pages-paginated-with-filters\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Request:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-6-1\"><a id=\"__codelineno-6-1\" name=\"__codelineno-6-1\" href=\"#__codelineno-6-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">get</span><span class=\"o\"><</span><span class=\"nx\">LandingPagesListResponse</span><span class=\"o\">></span><span class=\"p\">(</span><span class=\"s1\">'/pages'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-6-2\"><a id=\"__codelineno-6-2\" name=\"__codelineno-6-2\" href=\"#__codelineno-6-2\"></a><span class=\"w\"> </span><span class=\"nx\">params</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-6-3\"><a id=\"__codelineno-6-3\" name=\"__codelineno-6-3\" href=\"#__codelineno-6-3\"></a><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">1</span><span class=\"p\">,</span> </span><span id=\"__span-6-4\"><a id=\"__codelineno-6-4\" name=\"__codelineno-6-4\" href=\"#__codelineno-6-4\"></a><span class=\"w\"> </span><span class=\"nx\">limit</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">20</span><span class=\"p\">,</span> </span><span id=\"__span-6-5\"><a id=\"__codelineno-6-5\" name=\"__codelineno-6-5\" href=\"#__codelineno-6-5\"></a><span class=\"w\"> </span><span class=\"nx\">search</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'campaign'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"c1\">// Optional: search query</span> </span><span id=\"__span-6-6\"><a id=\"__codelineno-6-6\" name=\"__codelineno-6-6\" href=\"#__codelineno-6-6\"></a><span class=\"w\"> </span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'true'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"c1\">// Optional: filter by published status</span> </span><span id=\"__span-6-7\"><a id=\"__codelineno-6-7\" name=\"__codelineno-6-7\" href=\"#__codelineno-6-7\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-6-8\"><a id=\"__codelineno-6-8\" name=\"__codelineno-6-8\" href=\"#__codelineno-6-8\"></a><span class=\"p\">});</span> </span></code></pre></div> <p><strong>Query Parameters:</strong> - <code>page</code> (number, required): Page number (1-indexed) - <code>limit</code> (number, required): Items per page (20, 50, or 100) - <code>search</code> (string, optional): Search query (matches title, description) - <code>published</code> (string, optional): Filter by status ('true', 'false', or omit for all)</p> <p><strong>Response (200 OK):</strong></p> <div class=\"language-json highlight\"><pre><span></span><code><span id=\"__span-7-1\"><a id=\"__codelineno-7-1\" name=\"__codelineno-7-1\" href=\"#__codelineno-7-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-7-2\"><a id=\"__codelineno-7-2\" name=\"__codelineno-7-2\" href=\"#__codelineno-7-2\"></a><span class=\"w\"> </span><span class=\"nt\">\"pages\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"p\">[</span> </span><span id=\"__span-7-3\"><a id=\"__codelineno-7-3\" name=\"__codelineno-7-3\" href=\"#__codelineno-7-3\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-7-4\"><a id=\"__codelineno-7-4\" name=\"__codelineno-7-4\" href=\"#__codelineno-7-4\"></a><span class=\"w\"> </span><span class=\"nt\">\"id\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"page_abc123\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-5\"><a id=\"__codelineno-7-5\" name=\"__codelineno-7-5\" href=\"#__codelineno-7-5\"></a><span class=\"w\"> </span><span class=\"nt\">\"title\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"About Our Campaign\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-6\"><a id=\"__codelineno-7-6\" name=\"__codelineno-7-6\" href=\"#__codelineno-7-6\"></a><span class=\"w\"> </span><span class=\"nt\">\"description\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Learn about our mission and values\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-7\"><a id=\"__codelineno-7-7\" name=\"__codelineno-7-7\" href=\"#__codelineno-7-7\"></a><span class=\"w\"> </span><span class=\"nt\">\"slug\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"about-our-campaign\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-8\"><a id=\"__codelineno-7-8\" name=\"__codelineno-7-8\" href=\"#__codelineno-7-8\"></a><span class=\"w\"> </span><span class=\"nt\">\"editorMode\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"VISUAL\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-9\"><a id=\"__codelineno-7-9\" name=\"__codelineno-7-9\" href=\"#__codelineno-7-9\"></a><span class=\"w\"> </span><span class=\"nt\">\"published\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">true</span><span class=\"p\">,</span> </span><span id=\"__span-7-10\"><a id=\"__codelineno-7-10\" name=\"__codelineno-7-10\" href=\"#__codelineno-7-10\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsPath\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"about.html\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-11\"><a id=\"__codelineno-7-11\" name=\"__codelineno-7-11\" href=\"#__codelineno-7-11\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsStubPath\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"about.md\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-12\"><a id=\"__codelineno-7-12\" name=\"__codelineno-7-12\" href=\"#__codelineno-7-12\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsExportMode\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"THEMED\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-13\"><a id=\"__codelineno-7-13\" name=\"__codelineno-7-13\" href=\"#__codelineno-7-13\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsHideNav\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-7-14\"><a id=\"__codelineno-7-14\" name=\"__codelineno-7-14\" href=\"#__codelineno-7-14\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsHideToc\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-7-15\"><a id=\"__codelineno-7-15\" name=\"__codelineno-7-15\" href=\"#__codelineno-7-15\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsSkipExport\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-7-16\"><a id=\"__codelineno-7-16\" name=\"__codelineno-7-16\" href=\"#__codelineno-7-16\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoTitle\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"About Our Campaign | Campaign Name\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-17\"><a id=\"__codelineno-7-17\" name=\"__codelineno-7-17\" href=\"#__codelineno-7-17\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoDescription\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Learn about our mission, values, and team\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-18\"><a id=\"__codelineno-7-18\" name=\"__codelineno-7-18\" href=\"#__codelineno-7-18\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoImage\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"https://example.com/og-image.png\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-19\"><a id=\"__codelineno-7-19\" name=\"__codelineno-7-19\" href=\"#__codelineno-7-19\"></a><span class=\"w\"> </span><span class=\"nt\">\"createdAt\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"2026-01-15T10:30:00.000Z\"</span><span class=\"p\">,</span> </span><span id=\"__span-7-20\"><a id=\"__codelineno-7-20\" name=\"__codelineno-7-20\" href=\"#__codelineno-7-20\"></a><span class=\"w\"> </span><span class=\"nt\">\"updatedAt\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"2026-02-10T14:25:00.000Z\"</span> </span><span id=\"__span-7-21\"><a id=\"__codelineno-7-21\" name=\"__codelineno-7-21\" href=\"#__codelineno-7-21\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-7-22\"><a id=\"__codelineno-7-22\" name=\"__codelineno-7-22\" href=\"#__codelineno-7-22\"></a><span class=\"w\"> </span><span class=\"p\">],</span> </span><span id=\"__span-7-23\"><a id=\"__codelineno-7-23\" name=\"__codelineno-7-23\" href=\"#__codelineno-7-23\"></a><span class=\"w\"> </span><span class=\"nt\">\"pagination\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-7-24\"><a id=\"__codelineno-7-24\" name=\"__codelineno-7-24\" href=\"#__codelineno-7-24\"></a><span class=\"w\"> </span><span class=\"nt\">\"page\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">1</span><span class=\"p\">,</span> </span><span id=\"__span-7-25\"><a id=\"__codelineno-7-25\" name=\"__codelineno-7-25\" href=\"#__codelineno-7-25\"></a><span class=\"w\"> </span><span class=\"nt\">\"limit\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">20</span><span class=\"p\">,</span> </span><span id=\"__span-7-26\"><a id=\"__codelineno-7-26\" name=\"__codelineno-7-26\" href=\"#__codelineno-7-26\"></a><span class=\"w\"> </span><span class=\"nt\">\"total\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">24</span><span class=\"p\">,</span> </span><span id=\"__span-7-27\"><a id=\"__codelineno-7-27\" name=\"__codelineno-7-27\" href=\"#__codelineno-7-27\"></a><span class=\"w\"> </span><span class=\"nt\">\"totalPages\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">2</span> </span><span id=\"__span-7-28\"><a id=\"__codelineno-7-28\" name=\"__codelineno-7-28\" href=\"#__codelineno-7-28\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-7-29\"><a id=\"__codelineno-7-29\" name=\"__codelineno-7-29\" href=\"#__codelineno-7-29\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Response Fields:</strong> - <code>id</code> (string): Unique page identifier (prefixed with \"page_\") - <code>title</code> (string): Page title - <code>description</code> (string | null): Optional page description - <code>slug</code> (string): URL-safe slug (used in <code>/p/:slug</code>) - <code>editorMode</code> (string): Editor type ('VISUAL' or 'CODE') - <code>published</code> (boolean): Publication status - <code>mkdocsPath</code> (string | null): Custom MkDocs path (e.g., \"about.html\") - <code>mkdocsStubPath</code> (string | null): Markdown stub path (e.g., \"about.md\") - <code>mkdocsExportMode</code> (string): Export mode ('THEMED' or 'STANDALONE') - <code>mkdocsHideNav</code> (boolean): Hide navigation sidebar in themed mode - <code>mkdocsHideToc</code> (boolean): Hide table of contents in themed mode - <code>mkdocsSkipExport</code> (boolean): Skip MkDocs export (keep /p/:slug only) - <code>seoTitle</code> (string | null): Custom SEO title (overrides page title) - <code>seoDescription</code> (string | null): Meta description for SEO - <code>seoImage</code> (string | null): Open Graph image URL - <code>createdAt</code> (ISO 8601): Creation timestamp - <code>updatedAt</code> (ISO 8601): Last update timestamp</p> <h3 id=\"create-landing-page\">Create Landing Page<a class=\"headerlink\" href=\"#create-landing-page\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Request:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-8-1\"><a id=\"__codelineno-8-1\" name=\"__codelineno-8-1\" href=\"#__codelineno-8-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">post</span><span class=\"o\"><</span><span class=\"nx\">LandingPage</span><span class=\"o\">></span><span class=\"p\">(</span><span class=\"s1\">'/pages'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-8-2\"><a id=\"__codelineno-8-2\" name=\"__codelineno-8-2\" href=\"#__codelineno-8-2\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'About Our Campaign'</span><span class=\"p\">,</span> </span><span id=\"__span-8-3\"><a id=\"__codelineno-8-3\" name=\"__codelineno-8-3\" href=\"#__codelineno-8-3\"></a><span class=\"w\"> </span><span class=\"nx\">description</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Learn about our mission and values'</span><span class=\"p\">,</span> </span><span id=\"__span-8-4\"><a id=\"__codelineno-8-4\" name=\"__codelineno-8-4\" href=\"#__codelineno-8-4\"></a><span class=\"w\"> </span><span class=\"nx\">editorMode</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'VISUAL'</span><span class=\"p\">,</span> </span><span id=\"__span-8-5\"><a id=\"__codelineno-8-5\" name=\"__codelineno-8-5\" href=\"#__codelineno-8-5\"></a><span class=\"p\">});</span> </span></code></pre></div> <p><strong>Request Body Schema:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-9-1\"><a id=\"__codelineno-9-1\" name=\"__codelineno-9-1\" href=\"#__codelineno-9-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-9-2\"><a id=\"__codelineno-9-2\" name=\"__codelineno-9-2\" href=\"#__codelineno-9-2\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"c1\">// Required, min 1 char, max 255 chars</span> </span><span id=\"__span-9-3\"><a id=\"__codelineno-9-3\" name=\"__codelineno-9-3\" href=\"#__codelineno-9-3\"></a><span class=\"w\"> </span><span class=\"nx\">description?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"c1\">// Optional, max 1000 chars</span> </span><span id=\"__span-9-4\"><a id=\"__codelineno-9-4\" name=\"__codelineno-9-4\" href=\"#__codelineno-9-4\"></a><span class=\"w\"> </span><span class=\"nx\">editorMode?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">EditorMode</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"c1\">// Optional, 'VISUAL' or 'CODE' (default: 'VISUAL')</span> </span><span id=\"__span-9-5\"><a id=\"__codelineno-9-5\" name=\"__codelineno-9-5\" href=\"#__codelineno-9-5\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Response (201 Created):</strong></p> <div class=\"language-json highlight\"><pre><span></span><code><span id=\"__span-10-1\"><a id=\"__codelineno-10-1\" name=\"__codelineno-10-1\" href=\"#__codelineno-10-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-10-2\"><a id=\"__codelineno-10-2\" name=\"__codelineno-10-2\" href=\"#__codelineno-10-2\"></a><span class=\"w\"> </span><span class=\"nt\">\"id\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"page_abc123\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-3\"><a id=\"__codelineno-10-3\" name=\"__codelineno-10-3\" href=\"#__codelineno-10-3\"></a><span class=\"w\"> </span><span class=\"nt\">\"title\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"About Our Campaign\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-4\"><a id=\"__codelineno-10-4\" name=\"__codelineno-10-4\" href=\"#__codelineno-10-4\"></a><span class=\"w\"> </span><span class=\"nt\">\"description\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Learn about our mission and values\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-5\"><a id=\"__codelineno-10-5\" name=\"__codelineno-10-5\" href=\"#__codelineno-10-5\"></a><span class=\"w\"> </span><span class=\"nt\">\"slug\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"about-our-campaign\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-6\"><a id=\"__codelineno-10-6\" name=\"__codelineno-10-6\" href=\"#__codelineno-10-6\"></a><span class=\"w\"> </span><span class=\"nt\">\"editorMode\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"VISUAL\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-7\"><a id=\"__codelineno-10-7\" name=\"__codelineno-10-7\" href=\"#__codelineno-10-7\"></a><span class=\"w\"> </span><span class=\"nt\">\"published\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-10-8\"><a id=\"__codelineno-10-8\" name=\"__codelineno-10-8\" href=\"#__codelineno-10-8\"></a><span class=\"w\"> </span><span class=\"nt\">\"htmlContent\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-9\"><a id=\"__codelineno-10-9\" name=\"__codelineno-10-9\" href=\"#__codelineno-10-9\"></a><span class=\"w\"> </span><span class=\"nt\">\"cssContent\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-10\"><a id=\"__codelineno-10-10\" name=\"__codelineno-10-10\" href=\"#__codelineno-10-10\"></a><span class=\"w\"> </span><span class=\"nt\">\"jsContent\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-11\"><a id=\"__codelineno-10-11\" name=\"__codelineno-10-11\" href=\"#__codelineno-10-11\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsPath\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">null</span><span class=\"p\">,</span> </span><span id=\"__span-10-12\"><a id=\"__codelineno-10-12\" name=\"__codelineno-10-12\" href=\"#__codelineno-10-12\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsStubPath\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">null</span><span class=\"p\">,</span> </span><span id=\"__span-10-13\"><a id=\"__codelineno-10-13\" name=\"__codelineno-10-13\" href=\"#__codelineno-10-13\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsExportMode\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"THEMED\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-14\"><a id=\"__codelineno-10-14\" name=\"__codelineno-10-14\" href=\"#__codelineno-10-14\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsHideNav\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-10-15\"><a id=\"__codelineno-10-15\" name=\"__codelineno-10-15\" href=\"#__codelineno-10-15\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsHideToc\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-10-16\"><a id=\"__codelineno-10-16\" name=\"__codelineno-10-16\" href=\"#__codelineno-10-16\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsSkipExport\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-10-17\"><a id=\"__codelineno-10-17\" name=\"__codelineno-10-17\" href=\"#__codelineno-10-17\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoTitle\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">null</span><span class=\"p\">,</span> </span><span id=\"__span-10-18\"><a id=\"__codelineno-10-18\" name=\"__codelineno-10-18\" href=\"#__codelineno-10-18\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoDescription\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">null</span><span class=\"p\">,</span> </span><span id=\"__span-10-19\"><a id=\"__codelineno-10-19\" name=\"__codelineno-10-19\" href=\"#__codelineno-10-19\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoImage\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">null</span><span class=\"p\">,</span> </span><span id=\"__span-10-20\"><a id=\"__codelineno-10-20\" name=\"__codelineno-10-20\" href=\"#__codelineno-10-20\"></a><span class=\"w\"> </span><span class=\"nt\">\"createdAt\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"2026-02-11T10:45:00.000Z\"</span><span class=\"p\">,</span> </span><span id=\"__span-10-21\"><a id=\"__codelineno-10-21\" name=\"__codelineno-10-21\" href=\"#__codelineno-10-21\"></a><span class=\"w\"> </span><span class=\"nt\">\"updatedAt\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"2026-02-11T10:45:00.000Z\"</span> </span><span id=\"__span-10-22\"><a id=\"__codelineno-10-22\" name=\"__codelineno-10-22\" href=\"#__codelineno-10-22\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Backend Workflow:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-11-1\"><a id=\"__codelineno-11-1\" name=\"__codelineno-11-1\" href=\"#__codelineno-11-1\"></a><span class=\"c1\">// 1. Generate slug from title</span> </span><span id=\"__span-11-2\"><a id=\"__codelineno-11-2\" name=\"__codelineno-11-2\" href=\"#__codelineno-11-2\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">slug</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">generateSlug</span><span class=\"p\">(</span><span class=\"nx\">title</span><span class=\"p\">);</span><span class=\"w\"> </span><span class=\"c1\">// \"About Our Campaign\" \u2192 \"about-our-campaign\"</span> </span><span id=\"__span-11-3\"><a id=\"__codelineno-11-3\" name=\"__codelineno-11-3\" href=\"#__codelineno-11-3\"></a> </span><span id=\"__span-11-4\"><a id=\"__codelineno-11-4\" name=\"__codelineno-11-4\" href=\"#__codelineno-11-4\"></a><span class=\"c1\">// 2. Ensure slug is unique</span> </span><span id=\"__span-11-5\"><a id=\"__codelineno-11-5\" name=\"__codelineno-11-5\" href=\"#__codelineno-11-5\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">existingPage</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">prisma</span><span class=\"p\">.</span><span class=\"nx\">landingPage</span><span class=\"p\">.</span><span class=\"nx\">findUnique</span><span class=\"p\">({</span><span class=\"w\"> </span><span class=\"nx\">where</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">slug</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"p\">});</span> </span><span id=\"__span-11-6\"><a id=\"__codelineno-11-6\" name=\"__codelineno-11-6\" href=\"#__codelineno-11-6\"></a><span class=\"k\">if</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">existingPage</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-11-7\"><a id=\"__codelineno-11-7\" name=\"__codelineno-11-7\" href=\"#__codelineno-11-7\"></a><span class=\"w\"> </span><span class=\"nx\">slug</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"sb\">`</span><span class=\"si\">${</span><span class=\"nx\">slug</span><span class=\"si\">}</span><span class=\"sb\">-2`</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"c1\">// Append -2 if duplicate (or -3, -4, etc.)</span> </span><span id=\"__span-11-8\"><a id=\"__codelineno-11-8\" name=\"__codelineno-11-8\" href=\"#__codelineno-11-8\"></a><span class=\"p\">}</span> </span><span id=\"__span-11-9\"><a id=\"__codelineno-11-9\" name=\"__codelineno-11-9\" href=\"#__codelineno-11-9\"></a> </span><span id=\"__span-11-10\"><a id=\"__codelineno-11-10\" name=\"__codelineno-11-10\" href=\"#__codelineno-11-10\"></a><span class=\"c1\">// 3. Create page record</span> </span><span id=\"__span-11-11\"><a id=\"__codelineno-11-11\" name=\"__codelineno-11-11\" href=\"#__codelineno-11-11\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">prisma</span><span class=\"p\">.</span><span class=\"nx\">landingPage</span><span class=\"p\">.</span><span class=\"nx\">create</span><span class=\"p\">({</span> </span><span id=\"__span-11-12\"><a id=\"__codelineno-11-12\" name=\"__codelineno-11-12\" href=\"#__codelineno-11-12\"></a><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-11-13\"><a id=\"__codelineno-11-13\" name=\"__codelineno-11-13\" href=\"#__codelineno-11-13\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"p\">,</span> </span><span id=\"__span-11-14\"><a id=\"__codelineno-11-14\" name=\"__codelineno-11-14\" href=\"#__codelineno-11-14\"></a><span class=\"w\"> </span><span class=\"nx\">description</span><span class=\"p\">,</span> </span><span id=\"__span-11-15\"><a id=\"__codelineno-11-15\" name=\"__codelineno-11-15\" href=\"#__codelineno-11-15\"></a><span class=\"w\"> </span><span class=\"nx\">slug</span><span class=\"p\">,</span> </span><span id=\"__span-11-16\"><a id=\"__codelineno-11-16\" name=\"__codelineno-11-16\" href=\"#__codelineno-11-16\"></a><span class=\"w\"> </span><span class=\"nx\">editorMode</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">editorMode</span><span class=\"w\"> </span><span class=\"o\">||</span><span class=\"w\"> </span><span class=\"s1\">'VISUAL'</span><span class=\"p\">,</span> </span><span id=\"__span-11-17\"><a id=\"__codelineno-11-17\" name=\"__codelineno-11-17\" href=\"#__codelineno-11-17\"></a><span class=\"w\"> </span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">false</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"c1\">// Always start as draft</span> </span><span id=\"__span-11-18\"><a id=\"__codelineno-11-18\" name=\"__codelineno-11-18\" href=\"#__codelineno-11-18\"></a><span class=\"w\"> </span><span class=\"nx\">htmlContent</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">''</span><span class=\"p\">,</span> </span><span id=\"__span-11-19\"><a id=\"__codelineno-11-19\" name=\"__codelineno-11-19\" href=\"#__codelineno-11-19\"></a><span class=\"w\"> </span><span class=\"nx\">cssContent</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">''</span><span class=\"p\">,</span> </span><span id=\"__span-11-20\"><a id=\"__codelineno-11-20\" name=\"__codelineno-11-20\" href=\"#__codelineno-11-20\"></a><span class=\"w\"> </span><span class=\"nx\">jsContent</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">''</span><span class=\"p\">,</span> </span><span id=\"__span-11-21\"><a id=\"__codelineno-11-21\" name=\"__codelineno-11-21\" href=\"#__codelineno-11-21\"></a><span class=\"w\"> </span><span class=\"c1\">// ... MkDocs defaults</span> </span><span id=\"__span-11-22\"><a id=\"__codelineno-11-22\" name=\"__codelineno-11-22\" href=\"#__codelineno-11-22\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-11-23\"><a id=\"__codelineno-11-23\" name=\"__codelineno-11-23\" href=\"#__codelineno-11-23\"></a><span class=\"p\">});</span> </span><span id=\"__span-11-24\"><a id=\"__codelineno-11-24\" name=\"__codelineno-11-24\" href=\"#__codelineno-11-24\"></a> </span><span id=\"__span-11-25\"><a id=\"__codelineno-11-25\" name=\"__codelineno-11-25\" href=\"#__codelineno-11-25\"></a><span class=\"k\">return</span><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"p\">;</span> </span></code></pre></div> <h3 id=\"update-page-settings\">Update Page Settings<a class=\"headerlink\" href=\"#update-page-settings\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Request:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-12-1\"><a id=\"__codelineno-12-1\" name=\"__codelineno-12-1\" href=\"#__codelineno-12-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">pageId</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"s1\">'page_abc123'</span><span class=\"p\">;</span> </span><span id=\"__span-12-2\"><a id=\"__codelineno-12-2\" name=\"__codelineno-12-2\" href=\"#__codelineno-12-2\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">updates</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-12-3\"><a id=\"__codelineno-12-3\" name=\"__codelineno-12-3\" href=\"#__codelineno-12-3\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'About Our Campaign (Updated)'</span><span class=\"p\">,</span> </span><span id=\"__span-12-4\"><a id=\"__codelineno-12-4\" name=\"__codelineno-12-4\" href=\"#__codelineno-12-4\"></a><span class=\"w\"> </span><span class=\"nx\">description</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Updated description'</span><span class=\"p\">,</span> </span><span id=\"__span-12-5\"><a id=\"__codelineno-12-5\" name=\"__codelineno-12-5\" href=\"#__codelineno-12-5\"></a><span class=\"w\"> </span><span class=\"nx\">seoTitle</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'About Our Campaign | Campaign Name 2026'</span><span class=\"p\">,</span> </span><span id=\"__span-12-6\"><a id=\"__codelineno-12-6\" name=\"__codelineno-12-6\" href=\"#__codelineno-12-6\"></a><span class=\"w\"> </span><span class=\"nx\">seoDescription</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Learn about our 2026 campaign mission'</span><span class=\"p\">,</span> </span><span id=\"__span-12-7\"><a id=\"__codelineno-12-7\" name=\"__codelineno-12-7\" href=\"#__codelineno-12-7\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsPath</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'about.html'</span><span class=\"p\">,</span> </span><span id=\"__span-12-8\"><a id=\"__codelineno-12-8\" name=\"__codelineno-12-8\" href=\"#__codelineno-12-8\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsExportMode</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'THEMED'</span><span class=\"p\">,</span> </span><span id=\"__span-12-9\"><a id=\"__codelineno-12-9\" name=\"__codelineno-12-9\" href=\"#__codelineno-12-9\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsHideNav</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">true</span><span class=\"p\">,</span> </span><span id=\"__span-12-10\"><a id=\"__codelineno-12-10\" name=\"__codelineno-12-10\" href=\"#__codelineno-12-10\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsHideToc</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">false</span><span class=\"p\">,</span> </span><span id=\"__span-12-11\"><a id=\"__codelineno-12-11\" name=\"__codelineno-12-11\" href=\"#__codelineno-12-11\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsSkipExport</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">false</span><span class=\"p\">,</span> </span><span id=\"__span-12-12\"><a id=\"__codelineno-12-12\" name=\"__codelineno-12-12\" href=\"#__codelineno-12-12\"></a><span class=\"p\">};</span> </span><span id=\"__span-12-13\"><a id=\"__codelineno-12-13\" name=\"__codelineno-12-13\" href=\"#__codelineno-12-13\"></a> </span><span id=\"__span-12-14\"><a id=\"__codelineno-12-14\" name=\"__codelineno-12-14\" href=\"#__codelineno-12-14\"></a><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">put</span><span class=\"p\">(</span><span class=\"sb\">`/pages/</span><span class=\"si\">${</span><span class=\"nx\">pageId</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">updates</span><span class=\"p\">);</span> </span></code></pre></div> <p><strong>Request Body Schema:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-13-1\"><a id=\"__codelineno-13-1\" name=\"__codelineno-13-1\" href=\"#__codelineno-13-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-13-2\"><a id=\"__codelineno-13-2\" name=\"__codelineno-13-2\" href=\"#__codelineno-13-2\"></a><span class=\"w\"> </span><span class=\"nx\">title?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span> </span><span id=\"__span-13-3\"><a id=\"__codelineno-13-3\" name=\"__codelineno-13-3\" href=\"#__codelineno-13-3\"></a><span class=\"w\"> </span><span class=\"nx\">description?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span> </span><span id=\"__span-13-4\"><a id=\"__codelineno-13-4\" name=\"__codelineno-13-4\" href=\"#__codelineno-13-4\"></a><span class=\"w\"> </span><span class=\"nx\">published?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">boolean</span><span class=\"p\">;</span> </span><span id=\"__span-13-5\"><a id=\"__codelineno-13-5\" name=\"__codelineno-13-5\" href=\"#__codelineno-13-5\"></a><span class=\"w\"> </span><span class=\"nx\">seoTitle?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span> </span><span id=\"__span-13-6\"><a id=\"__codelineno-13-6\" name=\"__codelineno-13-6\" href=\"#__codelineno-13-6\"></a><span class=\"w\"> </span><span class=\"nx\">seoDescription?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span> </span><span id=\"__span-13-7\"><a id=\"__codelineno-13-7\" name=\"__codelineno-13-7\" href=\"#__codelineno-13-7\"></a><span class=\"w\"> </span><span class=\"nx\">seoImage?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span> </span><span id=\"__span-13-8\"><a id=\"__codelineno-13-8\" name=\"__codelineno-13-8\" href=\"#__codelineno-13-8\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsPath?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span> </span><span id=\"__span-13-9\"><a id=\"__codelineno-13-9\" name=\"__codelineno-13-9\" href=\"#__codelineno-13-9\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsExportMode</span><span class=\"o\">?:</span><span class=\"w\"> </span><span class=\"s1\">'THEMED'</span><span class=\"w\"> </span><span class=\"o\">|</span><span class=\"w\"> </span><span class=\"s1\">'STANDALONE'</span><span class=\"p\">;</span> </span><span id=\"__span-13-10\"><a id=\"__codelineno-13-10\" name=\"__codelineno-13-10\" href=\"#__codelineno-13-10\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsHideNav?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">boolean</span><span class=\"p\">;</span> </span><span id=\"__span-13-11\"><a id=\"__codelineno-13-11\" name=\"__codelineno-13-11\" href=\"#__codelineno-13-11\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsHideToc?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">boolean</span><span class=\"p\">;</span> </span><span id=\"__span-13-12\"><a id=\"__codelineno-13-12\" name=\"__codelineno-13-12\" href=\"#__codelineno-13-12\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsSkipExport?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">boolean</span><span class=\"p\">;</span> </span><span id=\"__span-13-13\"><a id=\"__codelineno-13-13\" name=\"__codelineno-13-13\" href=\"#__codelineno-13-13\"></a><span class=\"w\"> </span><span class=\"c1\">// Note: htmlContent, cssContent, jsContent updated via editor, not settings modal</span> </span><span id=\"__span-13-14\"><a id=\"__codelineno-13-14\" name=\"__codelineno-13-14\" href=\"#__codelineno-13-14\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Response (200 OK):</strong></p> <div class=\"language-json highlight\"><pre><span></span><code><span id=\"__span-14-1\"><a id=\"__codelineno-14-1\" name=\"__codelineno-14-1\" href=\"#__codelineno-14-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-14-2\"><a id=\"__codelineno-14-2\" name=\"__codelineno-14-2\" href=\"#__codelineno-14-2\"></a><span class=\"w\"> </span><span class=\"nt\">\"id\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"page_abc123\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-3\"><a id=\"__codelineno-14-3\" name=\"__codelineno-14-3\" href=\"#__codelineno-14-3\"></a><span class=\"w\"> </span><span class=\"nt\">\"title\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"About Our Campaign (Updated)\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-4\"><a id=\"__codelineno-14-4\" name=\"__codelineno-14-4\" href=\"#__codelineno-14-4\"></a><span class=\"w\"> </span><span class=\"nt\">\"description\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Updated description\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-5\"><a id=\"__codelineno-14-5\" name=\"__codelineno-14-5\" href=\"#__codelineno-14-5\"></a><span class=\"w\"> </span><span class=\"nt\">\"slug\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"about-our-campaign\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-6\"><a id=\"__codelineno-14-6\" name=\"__codelineno-14-6\" href=\"#__codelineno-14-6\"></a><span class=\"w\"> </span><span class=\"nt\">\"editorMode\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"VISUAL\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-7\"><a id=\"__codelineno-14-7\" name=\"__codelineno-14-7\" href=\"#__codelineno-14-7\"></a><span class=\"w\"> </span><span class=\"nt\">\"published\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">true</span><span class=\"p\">,</span> </span><span id=\"__span-14-8\"><a id=\"__codelineno-14-8\" name=\"__codelineno-14-8\" href=\"#__codelineno-14-8\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsPath\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"about.html\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-9\"><a id=\"__codelineno-14-9\" name=\"__codelineno-14-9\" href=\"#__codelineno-14-9\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsExportMode\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"THEMED\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-10\"><a id=\"__codelineno-14-10\" name=\"__codelineno-14-10\" href=\"#__codelineno-14-10\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsHideNav\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">true</span><span class=\"p\">,</span> </span><span id=\"__span-14-11\"><a id=\"__codelineno-14-11\" name=\"__codelineno-14-11\" href=\"#__codelineno-14-11\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsHideToc\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-14-12\"><a id=\"__codelineno-14-12\" name=\"__codelineno-14-12\" href=\"#__codelineno-14-12\"></a><span class=\"w\"> </span><span class=\"nt\">\"mkdocsSkipExport\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">false</span><span class=\"p\">,</span> </span><span id=\"__span-14-13\"><a id=\"__codelineno-14-13\" name=\"__codelineno-14-13\" href=\"#__codelineno-14-13\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoTitle\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"About Our Campaign | Campaign Name 2026\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-14\"><a id=\"__codelineno-14-14\" name=\"__codelineno-14-14\" href=\"#__codelineno-14-14\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoDescription\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Learn about our 2026 campaign mission\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-15\"><a id=\"__codelineno-14-15\" name=\"__codelineno-14-15\" href=\"#__codelineno-14-15\"></a><span class=\"w\"> </span><span class=\"nt\">\"seoImage\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"kc\">null</span><span class=\"p\">,</span> </span><span id=\"__span-14-16\"><a id=\"__codelineno-14-16\" name=\"__codelineno-14-16\" href=\"#__codelineno-14-16\"></a><span class=\"w\"> </span><span class=\"nt\">\"createdAt\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"2026-01-15T10:30:00.000Z\"</span><span class=\"p\">,</span> </span><span id=\"__span-14-17\"><a id=\"__codelineno-14-17\" name=\"__codelineno-14-17\" href=\"#__codelineno-14-17\"></a><span class=\"w\"> </span><span class=\"nt\">\"updatedAt\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"2026-02-11T11:00:00.000Z\"</span> </span><span id=\"__span-14-18\"><a id=\"__codelineno-14-18\" name=\"__codelineno-14-18\" href=\"#__codelineno-14-18\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Important:</strong> Slug is NOT updated when title changes (prevents breaking /p/:slug URLs).</p> <h3 id=\"toggle-published-status\">Toggle Published Status<a class=\"headerlink\" href=\"#toggle-published-status\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Request:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-15-1\"><a id=\"__codelineno-15-1\" name=\"__codelineno-15-1\" href=\"#__codelineno-15-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">id</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'page_abc123'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">true</span><span class=\"w\"> </span><span class=\"p\">};</span> </span><span id=\"__span-15-2\"><a id=\"__codelineno-15-2\" name=\"__codelineno-15-2\" href=\"#__codelineno-15-2\"></a> </span><span id=\"__span-15-3\"><a id=\"__codelineno-15-3\" name=\"__codelineno-15-3\" href=\"#__codelineno-15-3\"></a><span class=\"c1\">// Toggle: if published, unpublish; if unpublished, publish</span> </span><span id=\"__span-15-4\"><a id=\"__codelineno-15-4\" name=\"__codelineno-15-4\" href=\"#__codelineno-15-4\"></a><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">put</span><span class=\"p\">(</span><span class=\"sb\">`/pages/</span><span class=\"si\">${</span><span class=\"nx\">page</span><span class=\"p\">.</span><span class=\"nx\">id</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-15-5\"><a id=\"__codelineno-15-5\" name=\"__codelineno-15-5\" href=\"#__codelineno-15-5\"></a><span class=\"w\"> </span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"o\">!</span><span class=\"nx\">page</span><span class=\"p\">.</span><span class=\"nx\">published</span><span class=\"p\">,</span> </span><span id=\"__span-15-6\"><a id=\"__codelineno-15-6\" name=\"__codelineno-15-6\" href=\"#__codelineno-15-6\"></a><span class=\"p\">});</span> </span></code></pre></div> <p><strong>Response (200 OK):</strong></p> <p>Same as Update Page Settings response.</p> <p><strong>Backend Side Effects:</strong></p> <p>When page is published (<code>published: true</code>): - If <code>mkdocsSkipExport: false</code>, export override file to <code>mkdocs/docs/overrides/</code> - If <code>mkdocsStubPath</code> set, create Markdown stub at <code>mkdocs/docs/{stubPath}</code> - Page becomes accessible at <code>/p/:slug</code></p> <p>When page is unpublished (<code>published: false</code>): - Override file remains (not deleted automatically) - Page becomes inaccessible at <code>/p/:slug</code> (404 error)</p> <h3 id=\"delete-page\">Delete Page<a class=\"headerlink\" href=\"#delete-page\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Request:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-16-1\"><a id=\"__codelineno-16-1\" name=\"__codelineno-16-1\" href=\"#__codelineno-16-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">pageId</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"s1\">'page_abc123'</span><span class=\"p\">;</span> </span><span id=\"__span-16-2\"><a id=\"__codelineno-16-2\" name=\"__codelineno-16-2\" href=\"#__codelineno-16-2\"></a><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"ow\">delete</span><span class=\"p\">(</span><span class=\"sb\">`/pages/</span><span class=\"si\">${</span><span class=\"nx\">pageId</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">);</span> </span></code></pre></div> <p><strong>Response (200 OK):</strong></p> <div class=\"language-json highlight\"><pre><span></span><code><span id=\"__span-17-1\"><a id=\"__codelineno-17-1\" name=\"__codelineno-17-1\" href=\"#__codelineno-17-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-17-2\"><a id=\"__codelineno-17-2\" name=\"__codelineno-17-2\" href=\"#__codelineno-17-2\"></a><span class=\"w\"> </span><span class=\"nt\">\"message\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Page deleted\"</span> </span><span id=\"__span-17-3\"><a id=\"__codelineno-17-3\" name=\"__codelineno-17-3\" href=\"#__codelineno-17-3\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Backend Workflow:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-18-1\"><a id=\"__codelineno-18-1\" name=\"__codelineno-18-1\" href=\"#__codelineno-18-1\"></a><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">prisma</span><span class=\"p\">.</span><span class=\"nx\">landingPage</span><span class=\"p\">.</span><span class=\"ow\">delete</span><span class=\"p\">({</span> </span><span id=\"__span-18-2\"><a id=\"__codelineno-18-2\" name=\"__codelineno-18-2\" href=\"#__codelineno-18-2\"></a><span class=\"w\"> </span><span class=\"nx\">where</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">id</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">pageId</span><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-18-3\"><a id=\"__codelineno-18-3\" name=\"__codelineno-18-3\" href=\"#__codelineno-18-3\"></a><span class=\"p\">});</span> </span></code></pre></div> <p><strong>Important:</strong> Override files and Markdown stubs are NOT automatically deleted. Manual cleanup required:</p> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-19-1\"><a id=\"__codelineno-19-1\" name=\"__codelineno-19-1\" href=\"#__codelineno-19-1\"></a>rm<span class=\"w\"> </span>mkdocs/docs/overrides/about.html </span><span id=\"__span-19-2\"><a id=\"__codelineno-19-2\" name=\"__codelineno-19-2\" href=\"#__codelineno-19-2\"></a>rm<span class=\"w\"> </span>mkdocs/docs/about.md </span></code></pre></div> <h3 id=\"sync-mkdocs-overrides\">Sync MkDocs Overrides<a class=\"headerlink\" href=\"#sync-mkdocs-overrides\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Request:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-20-1\"><a id=\"__codelineno-20-1\" name=\"__codelineno-20-1\" href=\"#__codelineno-20-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">post</span><span class=\"o\"><</span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">imported</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">updated</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">stubs</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"o\">></span><span class=\"p\">(</span><span class=\"s1\">'/pages/sync'</span><span class=\"p\">);</span> </span></code></pre></div> <p><strong>Response (200 OK):</strong></p> <div class=\"language-json highlight\"><pre><span></span><code><span id=\"__span-21-1\"><a id=\"__codelineno-21-1\" name=\"__codelineno-21-1\" href=\"#__codelineno-21-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-21-2\"><a id=\"__codelineno-21-2\" name=\"__codelineno-21-2\" href=\"#__codelineno-21-2\"></a><span class=\"w\"> </span><span class=\"nt\">\"imported\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">3</span><span class=\"p\">,</span> </span><span id=\"__span-21-3\"><a id=\"__codelineno-21-3\" name=\"__codelineno-21-3\" href=\"#__codelineno-21-3\"></a><span class=\"w\"> </span><span class=\"nt\">\"updated\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">2</span><span class=\"p\">,</span> </span><span id=\"__span-21-4\"><a id=\"__codelineno-21-4\" name=\"__codelineno-21-4\" href=\"#__codelineno-21-4\"></a><span class=\"w\"> </span><span class=\"nt\">\"stubs\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">1</span><span class=\"p\">,</span> </span><span id=\"__span-21-5\"><a id=\"__codelineno-21-5\" name=\"__codelineno-21-5\" href=\"#__codelineno-21-5\"></a><span class=\"w\"> </span><span class=\"nt\">\"message\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Synced: 3 imported, 2 updated, 1 stubs created\"</span> </span><span id=\"__span-21-6\"><a id=\"__codelineno-21-6\" name=\"__codelineno-21-6\" href=\"#__codelineno-21-6\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Response Fields:</strong> - <code>imported</code> (number): Number of new pages created from override files - <code>updated</code> (number): Number of existing pages updated from override files - <code>stubs</code> (number): Number of Markdown stubs created for new pages</p> <p><strong>Backend Workflow:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-22-1\"><a id=\"__codelineno-22-1\" name=\"__codelineno-22-1\" href=\"#__codelineno-22-1\"></a><span class=\"c1\">// 1. Scan mkdocs/docs/overrides/ directory</span> </span><span id=\"__span-22-2\"><a id=\"__codelineno-22-2\" name=\"__codelineno-22-2\" href=\"#__codelineno-22-2\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">overrideFiles</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">fs</span><span class=\"p\">.</span><span class=\"nx\">readdirSync</span><span class=\"p\">(</span><span class=\"s1\">'mkdocs/docs/overrides/'</span><span class=\"p\">);</span> </span><span id=\"__span-22-3\"><a id=\"__codelineno-22-3\" name=\"__codelineno-22-3\" href=\"#__codelineno-22-3\"></a> </span><span id=\"__span-22-4\"><a id=\"__codelineno-22-4\" name=\"__codelineno-22-4\" href=\"#__codelineno-22-4\"></a><span class=\"c1\">// 2. For each override file</span> </span><span id=\"__span-22-5\"><a id=\"__codelineno-22-5\" name=\"__codelineno-22-5\" href=\"#__codelineno-22-5\"></a><span class=\"k\">for</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">file</span><span class=\"w\"> </span><span class=\"k\">of</span><span class=\"w\"> </span><span class=\"nx\">overrideFiles</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-22-6\"><a id=\"__codelineno-22-6\" name=\"__codelineno-22-6\" href=\"#__codelineno-22-6\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">content</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">fs</span><span class=\"p\">.</span><span class=\"nx\">readFileSync</span><span class=\"p\">(</span><span class=\"sb\">`mkdocs/docs/overrides/</span><span class=\"si\">${</span><span class=\"nx\">file</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"s1\">'utf-8'</span><span class=\"p\">);</span> </span><span id=\"__span-22-7\"><a id=\"__codelineno-22-7\" name=\"__codelineno-22-7\" href=\"#__codelineno-22-7\"></a> </span><span id=\"__span-22-8\"><a id=\"__codelineno-22-8\" name=\"__codelineno-22-8\" href=\"#__codelineno-22-8\"></a><span class=\"w\"> </span><span class=\"c1\">// 3. Check if page record exists for this override</span> </span><span id=\"__span-22-9\"><a id=\"__codelineno-22-9\" name=\"__codelineno-22-9\" href=\"#__codelineno-22-9\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">existingPage</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">prisma</span><span class=\"p\">.</span><span class=\"nx\">landingPage</span><span class=\"p\">.</span><span class=\"nx\">findFirst</span><span class=\"p\">({</span> </span><span id=\"__span-22-10\"><a id=\"__codelineno-22-10\" name=\"__codelineno-22-10\" href=\"#__codelineno-22-10\"></a><span class=\"w\"> </span><span class=\"nx\">where</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">mkdocsPath</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">file</span><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-22-11\"><a id=\"__codelineno-22-11\" name=\"__codelineno-22-11\" href=\"#__codelineno-22-11\"></a><span class=\"w\"> </span><span class=\"p\">});</span> </span><span id=\"__span-22-12\"><a id=\"__codelineno-22-12\" name=\"__codelineno-22-12\" href=\"#__codelineno-22-12\"></a> </span><span id=\"__span-22-13\"><a id=\"__codelineno-22-13\" name=\"__codelineno-22-13\" href=\"#__codelineno-22-13\"></a><span class=\"w\"> </span><span class=\"k\">if</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">existingPage</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-22-14\"><a id=\"__codelineno-22-14\" name=\"__codelineno-22-14\" href=\"#__codelineno-22-14\"></a><span class=\"w\"> </span><span class=\"c1\">// 4a. Update existing page if content changed</span> </span><span id=\"__span-22-15\"><a id=\"__codelineno-22-15\" name=\"__codelineno-22-15\" href=\"#__codelineno-22-15\"></a><span class=\"w\"> </span><span class=\"k\">if</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">existingPage</span><span class=\"p\">.</span><span class=\"nx\">htmlContent</span><span class=\"w\"> </span><span class=\"o\">!==</span><span class=\"w\"> </span><span class=\"nx\">content</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-22-16\"><a id=\"__codelineno-22-16\" name=\"__codelineno-22-16\" href=\"#__codelineno-22-16\"></a><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">prisma</span><span class=\"p\">.</span><span class=\"nx\">landingPage</span><span class=\"p\">.</span><span class=\"nx\">update</span><span class=\"p\">({</span> </span><span id=\"__span-22-17\"><a id=\"__codelineno-22-17\" name=\"__codelineno-22-17\" href=\"#__codelineno-22-17\"></a><span class=\"w\"> </span><span class=\"nx\">where</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">id</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">existingPage.id</span><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-22-18\"><a id=\"__codelineno-22-18\" name=\"__codelineno-22-18\" href=\"#__codelineno-22-18\"></a><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">htmlContent</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">content</span><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-22-19\"><a id=\"__codelineno-22-19\" name=\"__codelineno-22-19\" href=\"#__codelineno-22-19\"></a><span class=\"w\"> </span><span class=\"p\">});</span> </span><span id=\"__span-22-20\"><a id=\"__codelineno-22-20\" name=\"__codelineno-22-20\" href=\"#__codelineno-22-20\"></a><span class=\"w\"> </span><span class=\"nx\">updated</span><span class=\"o\">++</span><span class=\"p\">;</span> </span><span id=\"__span-22-21\"><a id=\"__codelineno-22-21\" name=\"__codelineno-22-21\" href=\"#__codelineno-22-21\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-22-22\"><a id=\"__codelineno-22-22\" name=\"__codelineno-22-22\" href=\"#__codelineno-22-22\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">else</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-22-23\"><a id=\"__codelineno-22-23\" name=\"__codelineno-22-23\" href=\"#__codelineno-22-23\"></a><span class=\"w\"> </span><span class=\"c1\">// 4b. Create new page stub for untracked override</span> </span><span id=\"__span-22-24\"><a id=\"__codelineno-22-24\" name=\"__codelineno-22-24\" href=\"#__codelineno-22-24\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">prisma</span><span class=\"p\">.</span><span class=\"nx\">landingPage</span><span class=\"p\">.</span><span class=\"nx\">create</span><span class=\"p\">({</span> </span><span id=\"__span-22-25\"><a id=\"__codelineno-22-25\" name=\"__codelineno-22-25\" href=\"#__codelineno-22-25\"></a><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-22-26\"><a id=\"__codelineno-22-26\" name=\"__codelineno-22-26\" href=\"#__codelineno-22-26\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">extractTitleFromHtml</span><span class=\"p\">(</span><span class=\"nx\">content</span><span class=\"p\">),</span><span class=\"w\"> </span><span class=\"c1\">// Parse <title> tag</span> </span><span id=\"__span-22-27\"><a id=\"__codelineno-22-27\" name=\"__codelineno-22-27\" href=\"#__codelineno-22-27\"></a><span class=\"w\"> </span><span class=\"nx\">slug</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">file.replace</span><span class=\"p\">(</span><span class=\"s1\">'.html'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"s1\">''</span><span class=\"p\">),</span> </span><span id=\"__span-22-28\"><a id=\"__codelineno-22-28\" name=\"__codelineno-22-28\" href=\"#__codelineno-22-28\"></a><span class=\"w\"> </span><span class=\"nx\">editorMode</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'CODE'</span><span class=\"p\">,</span> </span><span id=\"__span-22-29\"><a id=\"__codelineno-22-29\" name=\"__codelineno-22-29\" href=\"#__codelineno-22-29\"></a><span class=\"w\"> </span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">false</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"c1\">// Start as draft</span> </span><span id=\"__span-22-30\"><a id=\"__codelineno-22-30\" name=\"__codelineno-22-30\" href=\"#__codelineno-22-30\"></a><span class=\"w\"> </span><span class=\"nx\">htmlContent</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">content</span><span class=\"p\">,</span> </span><span id=\"__span-22-31\"><a id=\"__codelineno-22-31\" name=\"__codelineno-22-31\" href=\"#__codelineno-22-31\"></a><span class=\"w\"> </span><span class=\"nx\">mkdocsPath</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">file</span><span class=\"p\">,</span> </span><span id=\"__span-22-32\"><a id=\"__codelineno-22-32\" name=\"__codelineno-22-32\" href=\"#__codelineno-22-32\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-22-33\"><a id=\"__codelineno-22-33\" name=\"__codelineno-22-33\" href=\"#__codelineno-22-33\"></a><span class=\"w\"> </span><span class=\"p\">});</span> </span><span id=\"__span-22-34\"><a id=\"__codelineno-22-34\" name=\"__codelineno-22-34\" href=\"#__codelineno-22-34\"></a><span class=\"w\"> </span><span class=\"nx\">imported</span><span class=\"o\">++</span><span class=\"p\">;</span> </span><span id=\"__span-22-35\"><a id=\"__codelineno-22-35\" name=\"__codelineno-22-35\" href=\"#__codelineno-22-35\"></a> </span><span id=\"__span-22-36\"><a id=\"__codelineno-22-36\" name=\"__codelineno-22-36\" href=\"#__codelineno-22-36\"></a><span class=\"w\"> </span><span class=\"c1\">// 5. Create Markdown stub if needed</span> </span><span id=\"__span-22-37\"><a id=\"__codelineno-22-37\" name=\"__codelineno-22-37\" href=\"#__codelineno-22-37\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">stubPath</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"sb\">`</span><span class=\"si\">${</span><span class=\"nx\">file</span><span class=\"p\">.</span><span class=\"nx\">replace</span><span class=\"p\">(</span><span class=\"s1\">'.html'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"s1\">'.md'</span><span class=\"p\">)</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">;</span> </span><span id=\"__span-22-38\"><a id=\"__codelineno-22-38\" name=\"__codelineno-22-38\" href=\"#__codelineno-22-38\"></a><span class=\"w\"> </span><span class=\"nx\">fs</span><span class=\"p\">.</span><span class=\"nx\">writeFileSync</span><span class=\"p\">(</span><span class=\"sb\">`mkdocs/docs/</span><span class=\"si\">${</span><span class=\"nx\">stubPath</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"sb\">`---\\ntemplate: </span><span class=\"si\">${</span><span class=\"nx\">file</span><span class=\"si\">}</span><span class=\"sb\">\\n---\\n`</span><span class=\"p\">);</span> </span><span id=\"__span-22-39\"><a id=\"__codelineno-22-39\" name=\"__codelineno-22-39\" href=\"#__codelineno-22-39\"></a><span class=\"w\"> </span><span class=\"nx\">stubs</span><span class=\"o\">++</span><span class=\"p\">;</span> </span><span id=\"__span-22-40\"><a id=\"__codelineno-22-40\" name=\"__codelineno-22-40\" href=\"#__codelineno-22-40\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-22-41\"><a id=\"__codelineno-22-41\" name=\"__codelineno-22-41\" href=\"#__codelineno-22-41\"></a><span class=\"p\">}</span> </span><span id=\"__span-22-42\"><a id=\"__codelineno-22-42\" name=\"__codelineno-22-42\" href=\"#__codelineno-22-42\"></a> </span><span id=\"__span-22-43\"><a id=\"__codelineno-22-43\" name=\"__codelineno-22-43\" href=\"#__codelineno-22-43\"></a><span class=\"k\">return</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">imported</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">updated</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">stubs</span><span class=\"w\"> </span><span class=\"p\">};</span> </span></code></pre></div> <h3 id=\"validate-mkdocs-exports\">Validate MkDocs Exports<a class=\"headerlink\" href=\"#validate-mkdocs-exports\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Request:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-23-1\"><a id=\"__codelineno-23-1\" name=\"__codelineno-23-1\" href=\"#__codelineno-23-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">post</span><span class=\"o\"><</span><span class=\"p\">{</span> </span><span id=\"__span-23-2\"><a id=\"__codelineno-23-2\" name=\"__codelineno-23-2\" href=\"#__codelineno-23-2\"></a><span class=\"w\"> </span><span class=\"nx\">validated</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"p\">;</span> </span><span id=\"__span-23-3\"><a id=\"__codelineno-23-3\" name=\"__codelineno-23-3\" href=\"#__codelineno-23-3\"></a><span class=\"w\"> </span><span class=\"nx\">repaired</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"p\">;</span> </span><span id=\"__span-23-4\"><a id=\"__codelineno-23-4\" name=\"__codelineno-23-4\" href=\"#__codelineno-23-4\"></a><span class=\"w\"> </span><span class=\"nx\">errors</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">Array</span><span class=\"o\"><</span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">pageId</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">slug</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">error</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"o\">></span><span class=\"p\">;</span> </span><span id=\"__span-23-5\"><a id=\"__codelineno-23-5\" name=\"__codelineno-23-5\" href=\"#__codelineno-23-5\"></a><span class=\"p\">}</span><span class=\"o\">></span><span class=\"p\">(</span><span class=\"s1\">'/pages/validate'</span><span class=\"p\">);</span> </span></code></pre></div> <p><strong>Response (200 OK):</strong></p> <div class=\"language-json highlight\"><pre><span></span><code><span id=\"__span-24-1\"><a id=\"__codelineno-24-1\" name=\"__codelineno-24-1\" href=\"#__codelineno-24-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-24-2\"><a id=\"__codelineno-24-2\" name=\"__codelineno-24-2\" href=\"#__codelineno-24-2\"></a><span class=\"w\"> </span><span class=\"nt\">\"validated\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">24</span><span class=\"p\">,</span> </span><span id=\"__span-24-3\"><a id=\"__codelineno-24-3\" name=\"__codelineno-24-3\" href=\"#__codelineno-24-3\"></a><span class=\"w\"> </span><span class=\"nt\">\"repaired\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">2</span><span class=\"p\">,</span> </span><span id=\"__span-24-4\"><a id=\"__codelineno-24-4\" name=\"__codelineno-24-4\" href=\"#__codelineno-24-4\"></a><span class=\"w\"> </span><span class=\"nt\">\"errors\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"p\">[</span> </span><span id=\"__span-24-5\"><a id=\"__codelineno-24-5\" name=\"__codelineno-24-5\" href=\"#__codelineno-24-5\"></a><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-24-6\"><a id=\"__codelineno-24-6\" name=\"__codelineno-24-6\" href=\"#__codelineno-24-6\"></a><span class=\"w\"> </span><span class=\"nt\">\"pageId\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"page_xyz789\"</span><span class=\"p\">,</span> </span><span id=\"__span-24-7\"><a id=\"__codelineno-24-7\" name=\"__codelineno-24-7\" href=\"#__codelineno-24-7\"></a><span class=\"w\"> </span><span class=\"nt\">\"slug\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"broken-page\"</span><span class=\"p\">,</span> </span><span id=\"__span-24-8\"><a id=\"__codelineno-24-8\" name=\"__codelineno-24-8\" href=\"#__codelineno-24-8\"></a><span class=\"w\"> </span><span class=\"nt\">\"error\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Invalid HTML: Unclosed <div> tag\"</span> </span><span id=\"__span-24-9\"><a id=\"__codelineno-24-9\" name=\"__codelineno-24-9\" href=\"#__codelineno-24-9\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-24-10\"><a id=\"__codelineno-24-10\" name=\"__codelineno-24-10\" href=\"#__codelineno-24-10\"></a><span class=\"w\"> </span><span class=\"p\">]</span> </span><span id=\"__span-24-11\"><a id=\"__codelineno-24-11\" name=\"__codelineno-24-11\" href=\"#__codelineno-24-11\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Response Fields:</strong> - <code>validated</code> (number): Total pages checked - <code>repaired</code> (number): Pages with missing export files (now re-exported) - <code>errors</code> (array): Pages with unfixable errors (invalid HTML, write permissions, etc.)</p> <p><strong>Backend Workflow:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-25-1\"><a id=\"__codelineno-25-1\" name=\"__codelineno-25-1\" href=\"#__codelineno-25-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">publishedPages</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">prisma</span><span class=\"p\">.</span><span class=\"nx\">landingPage</span><span class=\"p\">.</span><span class=\"nx\">findMany</span><span class=\"p\">({</span> </span><span id=\"__span-25-2\"><a id=\"__codelineno-25-2\" name=\"__codelineno-25-2\" href=\"#__codelineno-25-2\"></a><span class=\"w\"> </span><span class=\"nx\">where</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">true</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">mkdocsSkipExport</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">false</span><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-25-3\"><a id=\"__codelineno-25-3\" name=\"__codelineno-25-3\" href=\"#__codelineno-25-3\"></a><span class=\"p\">});</span> </span><span id=\"__span-25-4\"><a id=\"__codelineno-25-4\" name=\"__codelineno-25-4\" href=\"#__codelineno-25-4\"></a> </span><span id=\"__span-25-5\"><a id=\"__codelineno-25-5\" name=\"__codelineno-25-5\" href=\"#__codelineno-25-5\"></a><span class=\"k\">for</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"w\"> </span><span class=\"k\">of</span><span class=\"w\"> </span><span class=\"nx\">publishedPages</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-25-6\"><a id=\"__codelineno-25-6\" name=\"__codelineno-25-6\" href=\"#__codelineno-25-6\"></a><span class=\"w\"> </span><span class=\"c1\">// 1. Check if export file exists</span> </span><span id=\"__span-25-7\"><a id=\"__codelineno-25-7\" name=\"__codelineno-25-7\" href=\"#__codelineno-25-7\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">exportPath</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"sb\">`mkdocs/docs/overrides/</span><span class=\"si\">${</span><span class=\"nx\">page</span><span class=\"p\">.</span><span class=\"nx\">mkdocsPath</span><span class=\"w\"> </span><span class=\"o\">||</span><span class=\"w\"> </span><span class=\"sb\">`</span><span class=\"si\">${</span><span class=\"nx\">page</span><span class=\"p\">.</span><span class=\"nx\">slug</span><span class=\"si\">}</span><span class=\"sb\">.html`</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">;</span> </span><span id=\"__span-25-8\"><a id=\"__codelineno-25-8\" name=\"__codelineno-25-8\" href=\"#__codelineno-25-8\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">exists</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">fs</span><span class=\"p\">.</span><span class=\"nx\">existsSync</span><span class=\"p\">(</span><span class=\"nx\">exportPath</span><span class=\"p\">);</span> </span><span id=\"__span-25-9\"><a id=\"__codelineno-25-9\" name=\"__codelineno-25-9\" href=\"#__codelineno-25-9\"></a> </span><span id=\"__span-25-10\"><a id=\"__codelineno-25-10\" name=\"__codelineno-25-10\" href=\"#__codelineno-25-10\"></a><span class=\"w\"> </span><span class=\"k\">if</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"o\">!</span><span class=\"nx\">exists</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-25-11\"><a id=\"__codelineno-25-11\" name=\"__codelineno-25-11\" href=\"#__codelineno-25-11\"></a><span class=\"w\"> </span><span class=\"c1\">// 2a. Re-export missing file</span> </span><span id=\"__span-25-12\"><a id=\"__codelineno-25-12\" name=\"__codelineno-25-12\" href=\"#__codelineno-25-12\"></a><span class=\"w\"> </span><span class=\"k\">try</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-25-13\"><a id=\"__codelineno-25-13\" name=\"__codelineno-25-13\" href=\"#__codelineno-25-13\"></a><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">exportPageToMkDocs</span><span class=\"p\">(</span><span class=\"nx\">page</span><span class=\"p\">);</span> </span><span id=\"__span-25-14\"><a id=\"__codelineno-25-14\" name=\"__codelineno-25-14\" href=\"#__codelineno-25-14\"></a><span class=\"w\"> </span><span class=\"nx\">repaired</span><span class=\"o\">++</span><span class=\"p\">;</span> </span><span id=\"__span-25-15\"><a id=\"__codelineno-25-15\" name=\"__codelineno-25-15\" href=\"#__codelineno-25-15\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">catch</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">error</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-25-16\"><a id=\"__codelineno-25-16\" name=\"__codelineno-25-16\" href=\"#__codelineno-25-16\"></a><span class=\"w\"> </span><span class=\"nx\">errors</span><span class=\"p\">.</span><span class=\"nx\">push</span><span class=\"p\">({</span> </span><span id=\"__span-25-17\"><a id=\"__codelineno-25-17\" name=\"__codelineno-25-17\" href=\"#__codelineno-25-17\"></a><span class=\"w\"> </span><span class=\"nx\">pageId</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">page.id</span><span class=\"p\">,</span> </span><span id=\"__span-25-18\"><a id=\"__codelineno-25-18\" name=\"__codelineno-25-18\" href=\"#__codelineno-25-18\"></a><span class=\"w\"> </span><span class=\"nx\">slug</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">page.slug</span><span class=\"p\">,</span> </span><span id=\"__span-25-19\"><a id=\"__codelineno-25-19\" name=\"__codelineno-25-19\" href=\"#__codelineno-25-19\"></a><span class=\"w\"> </span><span class=\"nx\">error</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">error.message</span><span class=\"p\">,</span> </span><span id=\"__span-25-20\"><a id=\"__codelineno-25-20\" name=\"__codelineno-25-20\" href=\"#__codelineno-25-20\"></a><span class=\"w\"> </span><span class=\"p\">});</span> </span><span id=\"__span-25-21\"><a id=\"__codelineno-25-21\" name=\"__codelineno-25-21\" href=\"#__codelineno-25-21\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-25-22\"><a id=\"__codelineno-25-22\" name=\"__codelineno-25-22\" href=\"#__codelineno-25-22\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-25-23\"><a id=\"__codelineno-25-23\" name=\"__codelineno-25-23\" href=\"#__codelineno-25-23\"></a><span class=\"p\">}</span> </span><span id=\"__span-25-24\"><a id=\"__codelineno-25-24\" name=\"__codelineno-25-24\" href=\"#__codelineno-25-24\"></a> </span><span id=\"__span-25-25\"><a id=\"__codelineno-25-25\" name=\"__codelineno-25-25\" href=\"#__codelineno-25-25\"></a><span class=\"k\">return</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">validated</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">publishedPages.length</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">repaired</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">errors</span><span class=\"w\"> </span><span class=\"p\">};</span> </span></code></pre></div> <h3 id=\"build-mkdocs-site-super_admin-only\">Build MkDocs Site (SUPER_ADMIN Only)<a class=\"headerlink\" href=\"#build-mkdocs-site-super_admin-only\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Request:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-26-1\"><a id=\"__codelineno-26-1\" name=\"__codelineno-26-1\" href=\"#__codelineno-26-1\"></a><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">post</span><span class=\"p\">(</span><span class=\"s1\">'/docs/build'</span><span class=\"p\">);</span> </span></code></pre></div> <p><strong>Response (200 OK):</strong></p> <div class=\"language-json highlight\"><pre><span></span><code><span id=\"__span-27-1\"><a id=\"__codelineno-27-1\" name=\"__codelineno-27-1\" href=\"#__codelineno-27-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-27-2\"><a id=\"__codelineno-27-2\" name=\"__codelineno-27-2\" href=\"#__codelineno-27-2\"></a><span class=\"w\"> </span><span class=\"nt\">\"message\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"s2\">\"Site built successfully\"</span><span class=\"p\">,</span> </span><span id=\"__span-27-3\"><a id=\"__codelineno-27-3\" name=\"__codelineno-27-3\" href=\"#__codelineno-27-3\"></a><span class=\"w\"> </span><span class=\"nt\">\"duration\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mf\">12.5</span><span class=\"p\">,</span> </span><span id=\"__span-27-4\"><a id=\"__codelineno-27-4\" name=\"__codelineno-27-4\" href=\"#__codelineno-27-4\"></a><span class=\"w\"> </span><span class=\"nt\">\"pages\"</span><span class=\"p\">:</span><span class=\"w\"> </span><span class=\"mi\">156</span> </span><span id=\"__span-27-5\"><a id=\"__codelineno-27-5\" name=\"__codelineno-27-5\" href=\"#__codelineno-27-5\"></a><span class=\"p\">}</span> </span></code></pre></div> <p><strong>Response Fields:</strong> - <code>message</code> (string): Success confirmation - <code>duration</code> (number): Build time in seconds - <code>pages</code> (number): Number of pages built</p> <p><strong>Backend Workflow:</strong></p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-28-1\"><a id=\"__codelineno-28-1\" name=\"__codelineno-28-1\" href=\"#__codelineno-28-1\"></a><span class=\"c1\">// 1. Run mkdocs build command</span> </span><span id=\"__span-28-2\"><a id=\"__codelineno-28-2\" name=\"__codelineno-28-2\" href=\"#__codelineno-28-2\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">startTime</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nb\">Date</span><span class=\"p\">.</span><span class=\"nx\">now</span><span class=\"p\">();</span> </span><span id=\"__span-28-3\"><a id=\"__codelineno-28-3\" name=\"__codelineno-28-3\" href=\"#__codelineno-28-3\"></a><span class=\"nx\">exec</span><span class=\"p\">(</span><span class=\"s1\">'mkdocs build'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">cwd</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'/path/to/mkdocs'</span><span class=\"w\"> </span><span class=\"p\">},</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">error</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">stdout</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">stderr</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-28-4\"><a id=\"__codelineno-28-4\" name=\"__codelineno-28-4\" href=\"#__codelineno-28-4\"></a><span class=\"w\"> </span><span class=\"k\">if</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">error</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-28-5\"><a id=\"__codelineno-28-5\" name=\"__codelineno-28-5\" href=\"#__codelineno-28-5\"></a><span class=\"w\"> </span><span class=\"k\">throw</span><span class=\"w\"> </span><span class=\"ow\">new</span><span class=\"w\"> </span><span class=\"ne\">Error</span><span class=\"p\">(</span><span class=\"sb\">`Build failed: </span><span class=\"si\">${</span><span class=\"nx\">error</span><span class=\"p\">.</span><span class=\"nx\">message</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">);</span> </span><span id=\"__span-28-6\"><a id=\"__codelineno-28-6\" name=\"__codelineno-28-6\" href=\"#__codelineno-28-6\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-28-7\"><a id=\"__codelineno-28-7\" name=\"__codelineno-28-7\" href=\"#__codelineno-28-7\"></a> </span><span id=\"__span-28-8\"><a id=\"__codelineno-28-8\" name=\"__codelineno-28-8\" href=\"#__codelineno-28-8\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">duration</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nb\">Date</span><span class=\"p\">.</span><span class=\"nx\">now</span><span class=\"p\">()</span><span class=\"w\"> </span><span class=\"o\">-</span><span class=\"w\"> </span><span class=\"nx\">startTime</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"o\">/</span><span class=\"w\"> </span><span class=\"mf\">1000</span><span class=\"p\">;</span> </span><span id=\"__span-28-9\"><a id=\"__codelineno-28-9\" name=\"__codelineno-28-9\" href=\"#__codelineno-28-9\"></a> </span><span id=\"__span-28-10\"><a id=\"__codelineno-28-10\" name=\"__codelineno-28-10\" href=\"#__codelineno-28-10\"></a><span class=\"w\"> </span><span class=\"c1\">// 2. Count built pages</span> </span><span id=\"__span-28-11\"><a id=\"__codelineno-28-11\" name=\"__codelineno-28-11\" href=\"#__codelineno-28-11\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">siteDir</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"s1\">'/path/to/mkdocs/site'</span><span class=\"p\">;</span> </span><span id=\"__span-28-12\"><a id=\"__codelineno-28-12\" name=\"__codelineno-28-12\" href=\"#__codelineno-28-12\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">pages</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">countHtmlFiles</span><span class=\"p\">(</span><span class=\"nx\">siteDir</span><span class=\"p\">);</span> </span><span id=\"__span-28-13\"><a id=\"__codelineno-28-13\" name=\"__codelineno-28-13\" href=\"#__codelineno-28-13\"></a> </span><span id=\"__span-28-14\"><a id=\"__codelineno-28-14\" name=\"__codelineno-28-14\" href=\"#__codelineno-28-14\"></a><span class=\"w\"> </span><span class=\"k\">return</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Site built successfully'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">duration</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">pages</span><span class=\"w\"> </span><span class=\"p\">};</span> </span><span id=\"__span-28-15\"><a id=\"__codelineno-28-15\" name=\"__codelineno-28-15\" href=\"#__codelineno-28-15\"></a><span class=\"p\">});</span> </span></code></pre></div> <p><strong>Build Output:</strong></p> <ul> <li><strong>Built site:</strong> <code>mkdocs/site/</code> directory</li> <li><strong>Index:</strong> <code>mkdocs/site/index.html</code></li> <li><strong>Pages:</strong> <code>mkdocs/site/about/index.html</code>, <code>mkdocs/site/contact/index.html</code>, etc.</li> <li><strong>Assets:</strong> <code>mkdocs/site/assets/</code> (CSS, JS, images)</li> </ul> <h2 id=\"code-examples\">Code Examples<a class=\"headerlink\" href=\"#code-examples\" title=\"Permanent link\">\u00b6</a></h2> <h3 id=\"complete-create-page-flow\">Complete Create Page Flow<a class=\"headerlink\" href=\"#complete-create-page-flow\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-29-1\"><a id=\"__codelineno-29-1\" name=\"__codelineno-29-1\" href=\"#__codelineno-29-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">handleCreate</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">async</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">values</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">description?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">editorMode?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">EditorMode</span><span class=\"w\"> </span><span class=\"p\">})</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-29-2\"><a id=\"__codelineno-29-2\" name=\"__codelineno-29-2\" href=\"#__codelineno-29-2\"></a><span class=\"w\"> </span><span class=\"k\">try</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-29-3\"><a id=\"__codelineno-29-3\" name=\"__codelineno-29-3\" href=\"#__codelineno-29-3\"></a><span class=\"w\"> </span><span class=\"c1\">// 1. Send create request</span> </span><span id=\"__span-29-4\"><a id=\"__codelineno-29-4\" name=\"__codelineno-29-4\" href=\"#__codelineno-29-4\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">post</span><span class=\"o\"><</span><span class=\"nx\">LandingPage</span><span class=\"o\">></span><span class=\"p\">(</span><span class=\"s1\">'/pages'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">values</span><span class=\"p\">);</span> </span><span id=\"__span-29-5\"><a id=\"__codelineno-29-5\" name=\"__codelineno-29-5\" href=\"#__codelineno-29-5\"></a> </span><span id=\"__span-29-6\"><a id=\"__codelineno-29-6\" name=\"__codelineno-29-6\" href=\"#__codelineno-29-6\"></a><span class=\"w\"> </span><span class=\"c1\">// 2. Show success message</span> </span><span id=\"__span-29-7\"><a id=\"__codelineno-29-7\" name=\"__codelineno-29-7\" href=\"#__codelineno-29-7\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">success</span><span class=\"p\">(</span><span class=\"s1\">'Page created'</span><span class=\"p\">);</span> </span><span id=\"__span-29-8\"><a id=\"__codelineno-29-8\" name=\"__codelineno-29-8\" href=\"#__codelineno-29-8\"></a> </span><span id=\"__span-29-9\"><a id=\"__codelineno-29-9\" name=\"__codelineno-29-9\" href=\"#__codelineno-29-9\"></a><span class=\"w\"> </span><span class=\"c1\">// 3. Close modal and reset form</span> </span><span id=\"__span-29-10\"><a id=\"__codelineno-29-10\" name=\"__codelineno-29-10\" href=\"#__codelineno-29-10\"></a><span class=\"w\"> </span><span class=\"nx\">setCreateModalOpen</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-29-11\"><a id=\"__codelineno-29-11\" name=\"__codelineno-29-11\" href=\"#__codelineno-29-11\"></a><span class=\"w\"> </span><span class=\"nx\">createForm</span><span class=\"p\">.</span><span class=\"nx\">resetFields</span><span class=\"p\">();</span> </span><span id=\"__span-29-12\"><a id=\"__codelineno-29-12\" name=\"__codelineno-29-12\" href=\"#__codelineno-29-12\"></a> </span><span id=\"__span-29-13\"><a id=\"__codelineno-29-13\" name=\"__codelineno-29-13\" href=\"#__codelineno-29-13\"></a><span class=\"w\"> </span><span class=\"c1\">// 4. Navigate to editor</span> </span><span id=\"__span-29-14\"><a id=\"__codelineno-29-14\" name=\"__codelineno-29-14\" href=\"#__codelineno-29-14\"></a><span class=\"w\"> </span><span class=\"nx\">navigate</span><span class=\"p\">(</span><span class=\"sb\">`/app/pages/</span><span class=\"si\">${</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">id</span><span class=\"si\">}</span><span class=\"sb\">/edit`</span><span class=\"p\">);</span> </span><span id=\"__span-29-15\"><a id=\"__codelineno-29-15\" name=\"__codelineno-29-15\" href=\"#__codelineno-29-15\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">catch</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">err</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">unknown</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-29-16\"><a id=\"__codelineno-29-16\" name=\"__codelineno-29-16\" href=\"#__codelineno-29-16\"></a><span class=\"w\"> </span><span class=\"c1\">// 5. Extract specific error message from API response</span> </span><span id=\"__span-29-17\"><a id=\"__codelineno-29-17\" name=\"__codelineno-29-17\" href=\"#__codelineno-29-17\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">msg</span><span class=\"w\"> </span><span class=\"o\">=</span> </span><span id=\"__span-29-18\"><a id=\"__codelineno-29-18\" name=\"__codelineno-29-18\" href=\"#__codelineno-29-18\"></a><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">err</span><span class=\"w\"> </span><span class=\"kr\">as</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">response</span><span class=\"o\">?:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"o\">?:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">error</span><span class=\"o\">?:</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">message?</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"p\">})</span> </span><span id=\"__span-29-19\"><a id=\"__codelineno-29-19\" name=\"__codelineno-29-19\" href=\"#__codelineno-29-19\"></a><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"p\">.</span><span class=\"nx\">response</span><span class=\"o\">?</span><span class=\"p\">.</span><span class=\"nx\">data</span><span class=\"o\">?</span><span class=\"p\">.</span><span class=\"nx\">error</span><span class=\"o\">?</span><span class=\"p\">.</span><span class=\"nx\">message</span><span class=\"w\"> </span><span class=\"o\">||</span><span class=\"w\"> </span><span class=\"s1\">'Failed to create page'</span><span class=\"p\">;</span> </span><span id=\"__span-29-20\"><a id=\"__codelineno-29-20\" name=\"__codelineno-29-20\" href=\"#__codelineno-29-20\"></a> </span><span id=\"__span-29-21\"><a id=\"__codelineno-29-21\" name=\"__codelineno-29-21\" href=\"#__codelineno-29-21\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">error</span><span class=\"p\">(</span><span class=\"nx\">msg</span><span class=\"p\">);</span> </span><span id=\"__span-29-22\"><a id=\"__codelineno-29-22\" name=\"__codelineno-29-22\" href=\"#__codelineno-29-22\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-29-23\"><a id=\"__codelineno-29-23\" name=\"__codelineno-29-23\" href=\"#__codelineno-29-23\"></a><span class=\"p\">};</span> </span></code></pre></div> <p><strong>Key Steps:</strong> 1. Send POST request with form values 2. Show success message 3. Close modal and reset form (cleanup) 4. Navigate to editor page (user can start editing immediately) 5. Extract specific error message from API response (show useful feedback)</p> <h3 id=\"sync-overrides-flow\">Sync Overrides Flow<a class=\"headerlink\" href=\"#sync-overrides-flow\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-30-1\"><a id=\"__codelineno-30-1\" name=\"__codelineno-30-1\" href=\"#__codelineno-30-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">handleSyncOverrides</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">async</span><span class=\"w\"> </span><span class=\"p\">()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-30-2\"><a id=\"__codelineno-30-2\" name=\"__codelineno-30-2\" href=\"#__codelineno-30-2\"></a><span class=\"w\"> </span><span class=\"nx\">setSyncing</span><span class=\"p\">(</span><span class=\"kc\">true</span><span class=\"p\">);</span> </span><span id=\"__span-30-3\"><a id=\"__codelineno-30-3\" name=\"__codelineno-30-3\" href=\"#__codelineno-30-3\"></a><span class=\"w\"> </span><span class=\"k\">try</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-30-4\"><a id=\"__codelineno-30-4\" name=\"__codelineno-30-4\" href=\"#__codelineno-30-4\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">post</span><span class=\"o\"><</span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">imported</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">updated</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">stubs</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"o\">></span><span class=\"p\">(</span><span class=\"s1\">'/pages/sync'</span><span class=\"p\">);</span> </span><span id=\"__span-30-5\"><a id=\"__codelineno-30-5\" name=\"__codelineno-30-5\" href=\"#__codelineno-30-5\"></a> </span><span id=\"__span-30-6\"><a id=\"__codelineno-30-6\" name=\"__codelineno-30-6\" href=\"#__codelineno-30-6\"></a><span class=\"w\"> </span><span class=\"c1\">// Show different messages based on results</span> </span><span id=\"__span-30-7\"><a id=\"__codelineno-30-7\" name=\"__codelineno-30-7\" href=\"#__codelineno-30-7\"></a><span class=\"w\"> </span><span class=\"k\">if</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">imported</span><span class=\"w\"> </span><span class=\"o\">></span><span class=\"w\"> </span><span class=\"mf\">0</span><span class=\"w\"> </span><span class=\"o\">||</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">updated</span><span class=\"w\"> </span><span class=\"o\">></span><span class=\"w\"> </span><span class=\"mf\">0</span><span class=\"w\"> </span><span class=\"o\">||</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">stubs</span><span class=\"w\"> </span><span class=\"o\">></span><span class=\"w\"> </span><span class=\"mf\">0</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-30-8\"><a id=\"__codelineno-30-8\" name=\"__codelineno-30-8\" href=\"#__codelineno-30-8\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">success</span><span class=\"p\">(</span> </span><span id=\"__span-30-9\"><a id=\"__codelineno-30-9\" name=\"__codelineno-30-9\" href=\"#__codelineno-30-9\"></a><span class=\"w\"> </span><span class=\"sb\">`Synced: </span><span class=\"si\">${</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">imported</span><span class=\"si\">}</span><span class=\"sb\"> imported, </span><span class=\"si\">${</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">updated</span><span class=\"si\">}</span><span class=\"sb\"> updated, </span><span class=\"si\">${</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">stubs</span><span class=\"si\">}</span><span class=\"sb\"> stubs created`</span> </span><span id=\"__span-30-10\"><a id=\"__codelineno-30-10\" name=\"__codelineno-30-10\" href=\"#__codelineno-30-10\"></a><span class=\"w\"> </span><span class=\"p\">);</span> </span><span id=\"__span-30-11\"><a id=\"__codelineno-30-11\" name=\"__codelineno-30-11\" href=\"#__codelineno-30-11\"></a><span class=\"w\"> </span><span class=\"nx\">fetchPages</span><span class=\"p\">();</span><span class=\"w\"> </span><span class=\"c1\">// Refresh table to show new/updated pages</span> </span><span id=\"__span-30-12\"><a id=\"__codelineno-30-12\" name=\"__codelineno-30-12\" href=\"#__codelineno-30-12\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">else</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-30-13\"><a id=\"__codelineno-30-13\" name=\"__codelineno-30-13\" href=\"#__codelineno-30-13\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">info</span><span class=\"p\">(</span><span class=\"s1\">'No new overrides to sync'</span><span class=\"p\">);</span> </span><span id=\"__span-30-14\"><a id=\"__codelineno-30-14\" name=\"__codelineno-30-14\" href=\"#__codelineno-30-14\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-30-15\"><a id=\"__codelineno-30-15\" name=\"__codelineno-30-15\" href=\"#__codelineno-30-15\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">catch</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-30-16\"><a id=\"__codelineno-30-16\" name=\"__codelineno-30-16\" href=\"#__codelineno-30-16\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">error</span><span class=\"p\">(</span><span class=\"s1\">'Failed to sync overrides'</span><span class=\"p\">);</span> </span><span id=\"__span-30-17\"><a id=\"__codelineno-30-17\" name=\"__codelineno-30-17\" href=\"#__codelineno-30-17\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">finally</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-30-18\"><a id=\"__codelineno-30-18\" name=\"__codelineno-30-18\" href=\"#__codelineno-30-18\"></a><span class=\"w\"> </span><span class=\"nx\">setSyncing</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-30-19\"><a id=\"__codelineno-30-19\" name=\"__codelineno-30-19\" href=\"#__codelineno-30-19\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-30-20\"><a id=\"__codelineno-30-20\" name=\"__codelineno-30-20\" href=\"#__codelineno-30-20\"></a><span class=\"p\">};</span> </span></code></pre></div> <p><strong>Key Features:</strong> - <strong>Conditional message:</strong> Different message if sync found changes vs. no changes - <strong>Detailed counts:</strong> Shows imported, updated, and stubs created - <strong>Table refresh:</strong> Fetches pages again to show newly imported pages</p> <h3 id=\"validate-exports-flow\">Validate Exports Flow<a class=\"headerlink\" href=\"#validate-exports-flow\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-31-1\"><a id=\"__codelineno-31-1\" name=\"__codelineno-31-1\" href=\"#__codelineno-31-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">handleValidateExports</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">async</span><span class=\"w\"> </span><span class=\"p\">()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-31-2\"><a id=\"__codelineno-31-2\" name=\"__codelineno-31-2\" href=\"#__codelineno-31-2\"></a><span class=\"w\"> </span><span class=\"nx\">setValidating</span><span class=\"p\">(</span><span class=\"kc\">true</span><span class=\"p\">);</span> </span><span id=\"__span-31-3\"><a id=\"__codelineno-31-3\" name=\"__codelineno-31-3\" href=\"#__codelineno-31-3\"></a><span class=\"w\"> </span><span class=\"k\">try</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-31-4\"><a id=\"__codelineno-31-4\" name=\"__codelineno-31-4\" href=\"#__codelineno-31-4\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">post</span><span class=\"o\"><</span><span class=\"p\">{</span> </span><span id=\"__span-31-5\"><a id=\"__codelineno-31-5\" name=\"__codelineno-31-5\" href=\"#__codelineno-31-5\"></a><span class=\"w\"> </span><span class=\"nx\">validated</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"p\">;</span> </span><span id=\"__span-31-6\"><a id=\"__codelineno-31-6\" name=\"__codelineno-31-6\" href=\"#__codelineno-31-6\"></a><span class=\"w\"> </span><span class=\"nx\">repaired</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">number</span><span class=\"p\">;</span> </span><span id=\"__span-31-7\"><a id=\"__codelineno-31-7\" name=\"__codelineno-31-7\" href=\"#__codelineno-31-7\"></a><span class=\"w\"> </span><span class=\"nx\">errors</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">Array</span><span class=\"o\"><</span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">pageId</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">slug</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"p\">;</span><span class=\"w\"> </span><span class=\"nx\">error</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">string</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"o\">></span><span class=\"p\">;</span> </span><span id=\"__span-31-8\"><a id=\"__codelineno-31-8\" name=\"__codelineno-31-8\" href=\"#__codelineno-31-8\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"o\">></span><span class=\"p\">(</span><span class=\"s1\">'/pages/validate'</span><span class=\"p\">);</span> </span><span id=\"__span-31-9\"><a id=\"__codelineno-31-9\" name=\"__codelineno-31-9\" href=\"#__codelineno-31-9\"></a> </span><span id=\"__span-31-10\"><a id=\"__codelineno-31-10\" name=\"__codelineno-31-10\" href=\"#__codelineno-31-10\"></a><span class=\"w\"> </span><span class=\"c1\">// Show appropriate message based on results</span> </span><span id=\"__span-31-11\"><a id=\"__codelineno-31-11\" name=\"__codelineno-31-11\" href=\"#__codelineno-31-11\"></a><span class=\"w\"> </span><span class=\"k\">if</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">repaired</span><span class=\"w\"> </span><span class=\"o\">></span><span class=\"w\"> </span><span class=\"mf\">0</span><span class=\"w\"> </span><span class=\"o\">||</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">errors</span><span class=\"p\">.</span><span class=\"nx\">length</span><span class=\"w\"> </span><span class=\"o\">></span><span class=\"w\"> </span><span class=\"mf\">0</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-31-12\"><a id=\"__codelineno-31-12\" name=\"__codelineno-31-12\" href=\"#__codelineno-31-12\"></a><span class=\"w\"> </span><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">msg</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"sb\">`Validated </span><span class=\"si\">${</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">validated</span><span class=\"si\">}</span><span class=\"sb\"> pages: </span><span class=\"si\">${</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">repaired</span><span class=\"si\">}</span><span class=\"sb\"> repaired`</span><span class=\"p\">;</span> </span><span id=\"__span-31-13\"><a id=\"__codelineno-31-13\" name=\"__codelineno-31-13\" href=\"#__codelineno-31-13\"></a><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">errors</span><span class=\"p\">.</span><span class=\"nx\">length</span><span class=\"w\"> </span><span class=\"o\">></span><span class=\"w\"> </span><span class=\"mf\">0</span> </span><span id=\"__span-31-14\"><a id=\"__codelineno-31-14\" name=\"__codelineno-31-14\" href=\"#__codelineno-31-14\"></a><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">warning</span><span class=\"p\">(</span><span class=\"sb\">`</span><span class=\"si\">${</span><span class=\"nx\">msg</span><span class=\"si\">}</span><span class=\"sb\">, </span><span class=\"si\">${</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">errors</span><span class=\"p\">.</span><span class=\"nx\">length</span><span class=\"si\">}</span><span class=\"sb\"> errors`</span><span class=\"p\">)</span> </span><span id=\"__span-31-15\"><a id=\"__codelineno-31-15\" name=\"__codelineno-31-15\" href=\"#__codelineno-31-15\"></a><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">success</span><span class=\"p\">(</span><span class=\"nx\">msg</span><span class=\"p\">);</span> </span><span id=\"__span-31-16\"><a id=\"__codelineno-31-16\" name=\"__codelineno-31-16\" href=\"#__codelineno-31-16\"></a><span class=\"w\"> </span><span class=\"nx\">fetchPages</span><span class=\"p\">();</span><span class=\"w\"> </span><span class=\"c1\">// Refresh table</span> </span><span id=\"__span-31-17\"><a id=\"__codelineno-31-17\" name=\"__codelineno-31-17\" href=\"#__codelineno-31-17\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">else</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-31-18\"><a id=\"__codelineno-31-18\" name=\"__codelineno-31-18\" href=\"#__codelineno-31-18\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">info</span><span class=\"p\">(</span><span class=\"sb\">`Validated </span><span class=\"si\">${</span><span class=\"nx\">data</span><span class=\"p\">.</span><span class=\"nx\">validated</span><span class=\"si\">}</span><span class=\"sb\"> pages - all OK`</span><span class=\"p\">);</span> </span><span id=\"__span-31-19\"><a id=\"__codelineno-31-19\" name=\"__codelineno-31-19\" href=\"#__codelineno-31-19\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-31-20\"><a id=\"__codelineno-31-20\" name=\"__codelineno-31-20\" href=\"#__codelineno-31-20\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">catch</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-31-21\"><a id=\"__codelineno-31-21\" name=\"__codelineno-31-21\" href=\"#__codelineno-31-21\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">error</span><span class=\"p\">(</span><span class=\"s1\">'Failed to validate exports'</span><span class=\"p\">);</span> </span><span id=\"__span-31-22\"><a id=\"__codelineno-31-22\" name=\"__codelineno-31-22\" href=\"#__codelineno-31-22\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">finally</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-31-23\"><a id=\"__codelineno-31-23\" name=\"__codelineno-31-23\" href=\"#__codelineno-31-23\"></a><span class=\"w\"> </span><span class=\"nx\">setValidating</span><span class=\"p\">(</span><span class=\"kc\">false</span><span class=\"p\">);</span> </span><span id=\"__span-31-24\"><a id=\"__codelineno-31-24\" name=\"__codelineno-31-24\" href=\"#__codelineno-31-24\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-31-25\"><a id=\"__codelineno-31-25\" name=\"__codelineno-31-25\" href=\"#__codelineno-31-25\"></a><span class=\"p\">};</span> </span></code></pre></div> <p><strong>Key Features:</strong> - <strong>Conditional message type:</strong> Success (repaired only), warning (repaired + errors), info (all OK) - <strong>Error count:</strong> Shows number of errors found - <strong>Table refresh:</strong> Only refreshes if repairs were made</p> <h3 id=\"toggle-published-status_1\">Toggle Published Status<a class=\"headerlink\" href=\"#toggle-published-status_1\" title=\"Permanent link\">\u00b6</a></h3> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-32-1\"><a id=\"__codelineno-32-1\" name=\"__codelineno-32-1\" href=\"#__codelineno-32-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"nx\">handleTogglePublished</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">async</span><span class=\"w\"> </span><span class=\"p\">(</span><span class=\"nx\">page</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">LandingPage</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-32-2\"><a id=\"__codelineno-32-2\" name=\"__codelineno-32-2\" href=\"#__codelineno-32-2\"></a><span class=\"w\"> </span><span class=\"k\">try</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-32-3\"><a id=\"__codelineno-32-3\" name=\"__codelineno-32-3\" href=\"#__codelineno-32-3\"></a><span class=\"w\"> </span><span class=\"c1\">// 1. Toggle published status</span> </span><span id=\"__span-32-4\"><a id=\"__codelineno-32-4\" name=\"__codelineno-32-4\" href=\"#__codelineno-32-4\"></a><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">put</span><span class=\"p\">(</span><span class=\"sb\">`/pages/</span><span class=\"si\">${</span><span class=\"nx\">page</span><span class=\"p\">.</span><span class=\"nx\">id</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-32-5\"><a id=\"__codelineno-32-5\" name=\"__codelineno-32-5\" href=\"#__codelineno-32-5\"></a><span class=\"w\"> </span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"o\">!</span><span class=\"nx\">page</span><span class=\"p\">.</span><span class=\"nx\">published</span><span class=\"p\">,</span> </span><span id=\"__span-32-6\"><a id=\"__codelineno-32-6\" name=\"__codelineno-32-6\" href=\"#__codelineno-32-6\"></a><span class=\"w\"> </span><span class=\"p\">});</span> </span><span id=\"__span-32-7\"><a id=\"__codelineno-32-7\" name=\"__codelineno-32-7\" href=\"#__codelineno-32-7\"></a> </span><span id=\"__span-32-8\"><a id=\"__codelineno-32-8\" name=\"__codelineno-32-8\" href=\"#__codelineno-32-8\"></a><span class=\"w\"> </span><span class=\"c1\">// 2. Show success message</span> </span><span id=\"__span-32-9\"><a id=\"__codelineno-32-9\" name=\"__codelineno-32-9\" href=\"#__codelineno-32-9\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">success</span><span class=\"p\">(</span><span class=\"nx\">page</span><span class=\"p\">.</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'Page unpublished'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Page published'</span><span class=\"p\">);</span> </span><span id=\"__span-32-10\"><a id=\"__codelineno-32-10\" name=\"__codelineno-32-10\" href=\"#__codelineno-32-10\"></a> </span><span id=\"__span-32-11\"><a id=\"__codelineno-32-11\" name=\"__codelineno-32-11\" href=\"#__codelineno-32-11\"></a><span class=\"w\"> </span><span class=\"c1\">// 3. Refresh table</span> </span><span id=\"__span-32-12\"><a id=\"__codelineno-32-12\" name=\"__codelineno-32-12\" href=\"#__codelineno-32-12\"></a><span class=\"w\"> </span><span class=\"nx\">fetchPages</span><span class=\"p\">();</span> </span><span id=\"__span-32-13\"><a id=\"__codelineno-32-13\" name=\"__codelineno-32-13\" href=\"#__codelineno-32-13\"></a><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"k\">catch</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-32-14\"><a id=\"__codelineno-32-14\" name=\"__codelineno-32-14\" href=\"#__codelineno-32-14\"></a><span class=\"w\"> </span><span class=\"nx\">message</span><span class=\"p\">.</span><span class=\"nx\">error</span><span class=\"p\">(</span><span class=\"s1\">'Failed to update page'</span><span class=\"p\">);</span> </span><span id=\"__span-32-15\"><a id=\"__codelineno-32-15\" name=\"__codelineno-32-15\" href=\"#__codelineno-32-15\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-32-16\"><a id=\"__codelineno-32-16\" name=\"__codelineno-32-16\" href=\"#__codelineno-32-16\"></a><span class=\"p\">};</span> </span></code></pre></div> <p><strong>Key Features:</strong> - <strong>Conditional message:</strong> \"Page published\" vs. \"Page unpublished\" - <strong>No confirmation:</strong> Immediate toggle (can be toggled back easily) - <strong>Table refresh:</strong> Shows updated status tag</p> <h2 id=\"performance-considerations\">Performance Considerations<a class=\"headerlink\" href=\"#performance-considerations\" title=\"Permanent link\">\u00b6</a></h2> <h3 id=\"debounced-search-300ms\">Debounced Search (300ms)<a class=\"headerlink\" href=\"#debounced-search-300ms\" title=\"Permanent link\">\u00b6</a></h3> <p>Search queries API after 300ms delay:</p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-33-1\"><a id=\"__codelineno-33-1\" name=\"__codelineno-33-1\" href=\"#__codelineno-33-1\"></a><span class=\"nx\">searchTimerRef</span><span class=\"p\">.</span><span class=\"nx\">current</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"nx\">setTimeout</span><span class=\"p\">(()</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">setDebouncedSearch</span><span class=\"p\">(</span><span class=\"nx\">value</span><span class=\"p\">),</span><span class=\"w\"> </span><span class=\"mf\">300</span><span class=\"p\">);</span> </span></code></pre></div> <p><strong>Performance Impact:</strong> - Without debounce: Typing \"campaign\" (8 chars) = 8 API calls - With 300ms debounce: Typing \"campaign\" = 1 API call - <strong>88% reduction in API calls</strong></p> <h3 id=\"server-side-pagination\">Server-Side Pagination<a class=\"headerlink\" href=\"#server-side-pagination\" title=\"Permanent link\">\u00b6</a></h3> <p>Table uses server-side pagination to handle large page counts:</p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-34-1\"><a id=\"__codelineno-34-1\" name=\"__codelineno-34-1\" href=\"#__codelineno-34-1\"></a><span class=\"kd\">const</span><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"w\"> </span><span class=\"nx\">data</span><span class=\"w\"> </span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"k\">await</span><span class=\"w\"> </span><span class=\"nx\">api</span><span class=\"p\">.</span><span class=\"nx\">get</span><span class=\"p\">(</span><span class=\"s1\">'/pages'</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-34-2\"><a id=\"__codelineno-34-2\" name=\"__codelineno-34-2\" href=\"#__codelineno-34-2\"></a><span class=\"w\"> </span><span class=\"nx\">params</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">{</span> </span><span id=\"__span-34-3\"><a id=\"__codelineno-34-3\" name=\"__codelineno-34-3\" href=\"#__codelineno-34-3\"></a><span class=\"w\"> </span><span class=\"nx\">page</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">pagination.current</span><span class=\"p\">,</span> </span><span id=\"__span-34-4\"><a id=\"__codelineno-34-4\" name=\"__codelineno-34-4\" href=\"#__codelineno-34-4\"></a><span class=\"w\"> </span><span class=\"nx\">limit</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">pagination.pageSize</span><span class=\"p\">,</span> </span><span id=\"__span-34-5\"><a id=\"__codelineno-34-5\" name=\"__codelineno-34-5\" href=\"#__codelineno-34-5\"></a><span class=\"w\"> </span><span class=\"nx\">search</span><span class=\"p\">,</span> </span><span id=\"__span-34-6\"><a id=\"__codelineno-34-6\" name=\"__codelineno-34-6\" href=\"#__codelineno-34-6\"></a><span class=\"w\"> </span><span class=\"nx\">published</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"kt\">publishedFilter</span><span class=\"p\">,</span> </span><span id=\"__span-34-7\"><a id=\"__codelineno-34-7\" name=\"__codelineno-34-7\" href=\"#__codelineno-34-7\"></a><span class=\"w\"> </span><span class=\"p\">},</span> </span><span id=\"__span-34-8\"><a id=\"__codelineno-34-8\" name=\"__codelineno-34-8\" href=\"#__codelineno-34-8\"></a><span class=\"p\">});</span> </span></code></pre></div> <p><strong>Scalability:</strong> - Works efficiently with 10 to 1,000+ pages - Only fetches current page (20-100 items) - Backend applies filters before pagination</p> <h3 id=\"conditional-settings-form-fields_1\">Conditional Settings Form Fields<a class=\"headerlink\" href=\"#conditional-settings-form-fields_1\" title=\"Permanent link\">\u00b6</a></h3> <p>Settings modal uses <code>shouldUpdate</code> to avoid rendering hidden fields:</p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-35-1\"><a id=\"__codelineno-35-1\" name=\"__codelineno-35-1\" href=\"#__codelineno-35-1\"></a><span class=\"o\"><</span><span class=\"nx\">Form</span><span class=\"p\">.</span><span class=\"nx\">Item</span><span class=\"w\"> </span><span class=\"nx\">noStyle</span><span class=\"w\"> </span><span class=\"nx\">shouldUpdate</span><span class=\"o\">=</span><span class=\"p\">{(</span><span class=\"nx\">prev</span><span class=\"p\">,</span><span class=\"w\"> </span><span class=\"nx\">cur</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"p\">=></span><span class=\"w\"> </span><span class=\"nx\">prev</span><span class=\"p\">.</span><span class=\"nx\">mkdocsSkipExport</span><span class=\"w\"> </span><span class=\"o\">!==</span><span class=\"w\"> </span><span class=\"nx\">cur</span><span class=\"p\">.</span><span class=\"nx\">mkdocsSkipExport</span><span class=\"p\">}</span><span class=\"o\">></span> </span><span id=\"__span-35-2\"><a id=\"__codelineno-35-2\" name=\"__codelineno-35-2\" href=\"#__codelineno-35-2\"></a><span class=\"w\"> </span><span class=\"p\">{({</span><span class=\"w\"> </span><span class=\"nx\">getFieldValue</span><span class=\"w\"> </span><span class=\"p\">})</span><span class=\"w\"> </span><span class=\"p\">=></span> </span><span id=\"__span-35-3\"><a id=\"__codelineno-35-3\" name=\"__codelineno-35-3\" href=\"#__codelineno-35-3\"></a><span class=\"w\"> </span><span class=\"o\">!</span><span class=\"nx\">getFieldValue</span><span class=\"p\">(</span><span class=\"s1\">'mkdocsSkipExport'</span><span class=\"p\">)</span><span class=\"w\"> </span><span class=\"o\">&&</span><span class=\"w\"> </span><span class=\"p\">(</span> </span><span id=\"__span-35-4\"><a id=\"__codelineno-35-4\" name=\"__codelineno-35-4\" href=\"#__codelineno-35-4\"></a><span class=\"w\"> </span><span class=\"o\"><></span> </span><span id=\"__span-35-5\"><a id=\"__codelineno-35-5\" name=\"__codelineno-35-5\" href=\"#__codelineno-35-5\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"cm\">/* MkDocs fields only rendered if checkbox NOT checked */</span><span class=\"p\">}</span> </span><span id=\"__span-35-6\"><a id=\"__codelineno-35-6\" name=\"__codelineno-35-6\" href=\"#__codelineno-35-6\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"err\">/></span> </span><span id=\"__span-35-7\"><a id=\"__codelineno-35-7\" name=\"__codelineno-35-7\" href=\"#__codelineno-35-7\"></a><span class=\"w\"> </span><span class=\"p\">)</span> </span><span id=\"__span-35-8\"><a id=\"__codelineno-35-8\" name=\"__codelineno-35-8\" href=\"#__codelineno-35-8\"></a><span class=\"w\"> </span><span class=\"p\">}</span> </span><span id=\"__span-35-9\"><a id=\"__codelineno-35-9\" name=\"__codelineno-35-9\" href=\"#__codelineno-35-9\"></a><span class=\"o\"><</span><span class=\"err\">/Form.Item></span> </span></code></pre></div> <p><strong>Benefits:</strong> - <strong>Faster rendering:</strong> Hidden fields not added to DOM - <strong>Smaller bundle:</strong> Conditional renders reduce component tree size - <strong>Better UX:</strong> Form dynamically adapts to user selections</p> <h2 id=\"responsive-design\">Responsive Design<a class=\"headerlink\" href=\"#responsive-design\" title=\"Permanent link\">\u00b6</a></h2> <h3 id=\"mobile-table-layout\">Mobile Table Layout<a class=\"headerlink\" href=\"#mobile-table-layout\" title=\"Permanent link\">\u00b6</a></h3> <p>Table adapts to mobile viewports by hiding less important columns:</p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-36-1\"><a id=\"__codelineno-36-1\" name=\"__codelineno-36-1\" href=\"#__codelineno-36-1\"></a><span class=\"p\">{</span> </span><span id=\"__span-36-2\"><a id=\"__codelineno-36-2\" name=\"__codelineno-36-2\" href=\"#__codelineno-36-2\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Editor'</span><span class=\"p\">,</span> </span><span id=\"__span-36-3\"><a id=\"__codelineno-36-3\" name=\"__codelineno-36-3\" href=\"#__codelineno-36-3\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'editorMode'</span><span class=\"p\">,</span> </span><span id=\"__span-36-4\"><a id=\"__codelineno-36-4\" name=\"__codelineno-36-4\" href=\"#__codelineno-36-4\"></a><span class=\"w\"> </span><span class=\"nx\">responsive</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"s1\">'sm'</span><span class=\"p\">],</span><span class=\"w\"> </span><span class=\"c1\">// Hidden on mobile (xs)</span> </span><span id=\"__span-36-5\"><a id=\"__codelineno-36-5\" name=\"__codelineno-36-5\" href=\"#__codelineno-36-5\"></a><span class=\"p\">},</span> </span><span id=\"__span-36-6\"><a id=\"__codelineno-36-6\" name=\"__codelineno-36-6\" href=\"#__codelineno-36-6\"></a><span class=\"p\">{</span> </span><span id=\"__span-36-7\"><a id=\"__codelineno-36-7\" name=\"__codelineno-36-7\" href=\"#__codelineno-36-7\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'MkDocs'</span><span class=\"p\">,</span> </span><span id=\"__span-36-8\"><a id=\"__codelineno-36-8\" name=\"__codelineno-36-8\" href=\"#__codelineno-36-8\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'mkdocsPath'</span><span class=\"p\">,</span> </span><span id=\"__span-36-9\"><a id=\"__codelineno-36-9\" name=\"__codelineno-36-9\" href=\"#__codelineno-36-9\"></a><span class=\"w\"> </span><span class=\"nx\">responsive</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"s1\">'lg'</span><span class=\"p\">],</span><span class=\"w\"> </span><span class=\"c1\">// Hidden on mobile/tablet (xs, sm, md)</span> </span><span id=\"__span-36-10\"><a id=\"__codelineno-36-10\" name=\"__codelineno-36-10\" href=\"#__codelineno-36-10\"></a><span class=\"p\">},</span> </span><span id=\"__span-36-11\"><a id=\"__codelineno-36-11\" name=\"__codelineno-36-11\" href=\"#__codelineno-36-11\"></a><span class=\"p\">{</span> </span><span id=\"__span-36-12\"><a id=\"__codelineno-36-12\" name=\"__codelineno-36-12\" href=\"#__codelineno-36-12\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Created'</span><span class=\"p\">,</span> </span><span id=\"__span-36-13\"><a id=\"__codelineno-36-13\" name=\"__codelineno-36-13\" href=\"#__codelineno-36-13\"></a><span class=\"w\"> </span><span class=\"nx\">dataIndex</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'createdAt'</span><span class=\"p\">,</span> </span><span id=\"__span-36-14\"><a id=\"__codelineno-36-14\" name=\"__codelineno-36-14\" href=\"#__codelineno-36-14\"></a><span class=\"w\"> </span><span class=\"nx\">responsive</span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"p\">[</span><span class=\"s1\">'md'</span><span class=\"p\">],</span><span class=\"w\"> </span><span class=\"c1\">// Hidden on mobile (xs, sm)</span> </span><span id=\"__span-36-15\"><a id=\"__codelineno-36-15\" name=\"__codelineno-36-15\" href=\"#__codelineno-36-15\"></a><span class=\"p\">},</span> </span></code></pre></div> <p><strong>Mobile Columns (xs):</strong> - Title (with /p/:slug below) - Status - Actions</p> <p><strong>Tablet Columns (sm, md):</strong> - Title + Editor + Status + Created + Updated + Actions</p> <p><strong>Desktop Columns (lg+):</strong> - All columns including MkDocs</p> <h3 id=\"action-button-wrapping\">Action Button Wrapping<a class=\"headerlink\" href=\"#action-button-wrapping\" title=\"Permanent link\">\u00b6</a></h3> <p>Action buttons wrap on narrow viewports:</p> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-37-1\"><a id=\"__codelineno-37-1\" name=\"__codelineno-37-1\" href=\"#__codelineno-37-1\"></a><span class=\"o\"><</span><span class=\"nx\">Space</span><span class=\"o\">></span> </span><span id=\"__span-37-2\"><a id=\"__codelineno-37-2\" name=\"__codelineno-37-2\" href=\"#__codelineno-37-2\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">EditOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-37-3\"><a id=\"__codelineno-37-3\" name=\"__codelineno-37-3\" href=\"#__codelineno-37-3\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">SettingOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">/></span> </span><span id=\"__span-37-4\"><a id=\"__codelineno-37-4\" name=\"__codelineno-37-4\" href=\"#__codelineno-37-4\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">&&</span><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">EyeOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span> </span><span id=\"__span-37-5\"><a id=\"__codelineno-37-5\" name=\"__codelineno-37-5\" href=\"#__codelineno-37-5\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Button</span><span class=\"o\">></span><span class=\"p\">{</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'Unpublish'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Publish'</span><span class=\"p\">}</span><span class=\"o\"><</span><span class=\"err\">/Button></span> </span><span id=\"__span-37-6\"><a id=\"__codelineno-37-6\" name=\"__codelineno-37-6\" href=\"#__codelineno-37-6\"></a><span class=\"w\"> </span><span class=\"o\"><</span><span class=\"nx\">Popconfirm</span><span class=\"o\">><</span><span class=\"nx\">Button</span><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">DeleteOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span><span class=\"w\"> </span><span class=\"o\">/><</span><span class=\"err\">/Popconfirm></span> </span><span id=\"__span-37-7\"><a id=\"__codelineno-37-7\" name=\"__codelineno-37-7\" href=\"#__codelineno-37-7\"></a><span class=\"o\"><</span><span class=\"err\">/Space></span> </span></code></pre></div> <p><strong>Space Component:</strong> - Automatically wraps buttons when width insufficient - Maintains consistent spacing (8px gap) - No horizontal scrolling on mobile</p> <h2 id=\"accessibility\">Accessibility<a class=\"headerlink\" href=\"#accessibility\" title=\"Permanent link\">\u00b6</a></h2> <h3 id=\"keyboard-navigation\">Keyboard Navigation<a class=\"headerlink\" href=\"#keyboard-navigation\" title=\"Permanent link\">\u00b6</a></h3> <p>All interactive elements are keyboard-accessible:</p> <p><strong>Table Navigation:</strong> - <strong>Tab:</strong> Move between action buttons (Edit \u2192 Settings \u2192 View \u2192 Publish \u2192 Delete) - <strong>Enter/Space:</strong> Activate focused button - <strong>Arrow Keys:</strong> Navigate table rows (Ant Design built-in)</p> <p><strong>Modal Forms:</strong> - <strong>Tab:</strong> Move between form fields (Title \u2192 Description \u2192 Editor Mode) - <strong>Enter:</strong> Submit form (same as clicking OK button) - <strong>Escape:</strong> Close modal</p> <h3 id=\"screen-reader-support\">Screen Reader Support<a class=\"headerlink\" href=\"#screen-reader-support\" title=\"Permanent link\">\u00b6</a></h3> <p>All elements have proper ARIA labels:</p> <p><strong>Action Buttons:</strong> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-38-1\"><a id=\"__codelineno-38-1\" name=\"__codelineno-38-1\" href=\"#__codelineno-38-1\"></a><span class=\"o\"><</span><span class=\"nx\">Button</span> </span><span id=\"__span-38-2\"><a id=\"__codelineno-38-2\" name=\"__codelineno-38-2\" href=\"#__codelineno-38-2\"></a><span class=\"w\"> </span><span class=\"nx\">icon</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"o\"><</span><span class=\"nx\">EditOutlined</span><span class=\"w\"> </span><span class=\"o\">/></span><span class=\"p\">}</span> </span><span id=\"__span-38-3\"><a id=\"__codelineno-38-3\" name=\"__codelineno-38-3\" href=\"#__codelineno-38-3\"></a><span class=\"w\"> </span><span class=\"nx\">title</span><span class=\"o\">=</span><span class=\"s2\">\"Edit in builder\"</span> </span><span id=\"__span-38-4\"><a id=\"__codelineno-38-4\" name=\"__codelineno-38-4\" href=\"#__codelineno-38-4\"></a><span class=\"w\"> </span><span class=\"nx\">aria</span><span class=\"o\">-</span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"sb\">`Edit page </span><span class=\"si\">${</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">title</span><span class=\"si\">}</span><span class=\"sb\"> in </span><span class=\"si\">${</span><span class=\"nx\">record</span><span class=\"p\">.</span><span class=\"nx\">editorMode</span><span class=\"w\"> </span><span class=\"o\">===</span><span class=\"w\"> </span><span class=\"s1\">'CODE'</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'code editor'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'visual builder'</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">}</span> </span><span id=\"__span-38-5\"><a id=\"__codelineno-38-5\" name=\"__codelineno-38-5\" href=\"#__codelineno-38-5\"></a><span class=\"err\">/></span> </span></code></pre></div></p> <p><strong>Status Tags:</strong> <div class=\"language-typescript highlight\"><pre><span></span><code><span id=\"__span-39-1\"><a id=\"__codelineno-39-1\" name=\"__codelineno-39-1\" href=\"#__codelineno-39-1\"></a><span class=\"o\"><</span><span class=\"nx\">Tag</span> </span><span id=\"__span-39-2\"><a id=\"__codelineno-39-2\" name=\"__codelineno-39-2\" href=\"#__codelineno-39-2\"></a><span class=\"w\"> </span><span class=\"nx\">color</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'green'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'default'</span><span class=\"p\">}</span> </span><span id=\"__span-39-3\"><a id=\"__codelineno-39-3\" name=\"__codelineno-39-3\" href=\"#__codelineno-39-3\"></a><span class=\"w\"> </span><span class=\"nx\">aria</span><span class=\"o\">-</span><span class=\"nx\">label</span><span class=\"o\">=</span><span class=\"p\">{</span><span class=\"sb\">`Page status: </span><span class=\"si\">${</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'published'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'draft'</span><span class=\"si\">}</span><span class=\"sb\">`</span><span class=\"p\">}</span> </span><span id=\"__span-39-4\"><a id=\"__codelineno-39-4\" name=\"__codelineno-39-4\" href=\"#__codelineno-39-4\"></a><span class=\"o\">></span> </span><span id=\"__span-39-5\"><a id=\"__codelineno-39-5\" name=\"__codelineno-39-5\" href=\"#__codelineno-39-5\"></a><span class=\"w\"> </span><span class=\"p\">{</span><span class=\"nx\">published</span><span class=\"w\"> </span><span class=\"o\">?</span><span class=\"w\"> </span><span class=\"s1\">'Published'</span><span class=\"w\"> </span><span class=\"o\">:</span><span class=\"w\"> </span><span class=\"s1\">'Draft'</span><span class=\"p\">}</span> </span><span id=\"__span-39-6\"><a id=\"__codelineno-39-6\" name=\"__codelineno-39-6\" href=\"#__codelineno-39-6\"></a><span class=\"o\"><</span><span class=\"err\">/Tag></span> </span></code></pre></div></p> <h3 id=\"color-contrast\">Color Contrast<a class=\"headerlink\" href=\"#color-contrast\" title=\"Permanent link\">\u00b6</a></h3> <p>All color-coded elements meet WCAG AA standards:</p> <p><strong>Status Tags:</strong> - Published (green): <code>#52c41a</code> on white = 3.0:1 contrast (AA for large text) - Draft (gray): <code>#d9d9d9</code> on white = 2.6:1 contrast (AA for large text)</p> <p><strong>Editor Tags:</strong> - Visual (green): <code>#52c41a</code> on white = 3.0:1 contrast (AA for large text) - Code (blue): <code>#1890ff</code> on white = 4.5:1 contrast (AA)</p> <h2 id=\"troubleshooting\">Troubleshooting<a class=\"headerlink\" href=\"#troubleshooting\" title=\"Permanent link\">\u00b6</a></h2> <h3 id=\"sync-overrides-finds-no-new-pages\">Sync Overrides Finds No New Pages<a class=\"headerlink\" href=\"#sync-overrides-finds-no-new-pages\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Problem:</strong> Click \"Sync Overrides\", get message \"No new overrides to sync\", but you know you added HTML files to <code>mkdocs/docs/overrides/</code>.</p> <p><strong>Diagnosis:</strong></p> <p>Check override directory:</p> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-40-1\"><a id=\"__codelineno-40-1\" name=\"__codelineno-40-1\" href=\"#__codelineno-40-1\"></a>ls<span class=\"w\"> </span>mkdocs/docs/overrides/ </span></code></pre></div> <p>Expected: HTML files present</p> <p>Check if pages already exist in database:</p> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-41-1\"><a id=\"__codelineno-41-1\" name=\"__codelineno-41-1\" href=\"#__codelineno-41-1\"></a>docker<span class=\"w\"> </span>compose<span class=\"w\"> </span><span class=\"nb\">exec</span><span class=\"w\"> </span>v2-postgres<span class=\"w\"> </span>psql<span class=\"w\"> </span>-U<span class=\"w\"> </span>postgres<span class=\"w\"> </span>-d<span class=\"w\"> </span>v2<span class=\"w\"> </span>-c<span class=\"w\"> </span><span class=\"s2\">\"SELECT title, mkdocsPath FROM \\\"LandingPage\\\" WHERE \\\"mkdocsPath\\\" IS NOT NULL\"</span> </span></code></pre></div> <p><strong>Possible Causes:</strong></p> <ol> <li><strong>Files in wrong directory:</strong></li> <li>Files added to <code>mkdocs/docs/</code> instead of <code>mkdocs/docs/overrides/</code></li> <li> <p>API scans <code>overrides/</code> directory only</p> </li> <li> <p><strong>Pages already synced:</strong></p> </li> <li>Override files already imported in previous sync</li> <li> <p>Sync only imports new files, not existing ones</p> </li> <li> <p><strong>Invalid HTML files:</strong></p> </li> <li>Files have wrong extension (e.g., <code>.htm</code> instead of <code>.html</code>)</li> <li>Files are empty or corrupted</li> </ol> <p><strong>Solution:</strong></p> <ol> <li> <p><strong>Move files to correct directory:</strong> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-42-1\"><a id=\"__codelineno-42-1\" name=\"__codelineno-42-1\" href=\"#__codelineno-42-1\"></a>mv<span class=\"w\"> </span>mkdocs/docs/about.html<span class=\"w\"> </span>mkdocs/docs/overrides/ </span></code></pre></div></p> </li> <li> <p><strong>Force re-import (delete page records first):</strong> <div class=\"language-sql highlight\"><pre><span></span><code><span id=\"__span-43-1\"><a id=\"__codelineno-43-1\" name=\"__codelineno-43-1\" href=\"#__codelineno-43-1\"></a><span class=\"k\">DELETE</span><span class=\"w\"> </span><span class=\"k\">FROM</span><span class=\"w\"> </span><span class=\"ss\">\"LandingPage\"</span><span class=\"w\"> </span><span class=\"k\">WHERE</span><span class=\"w\"> </span><span class=\"ss\">\"mkdocsPath\"</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"s1\">'about.html'</span><span class=\"p\">;</span> </span></code></pre></div> Then click \"Sync Overrides\" again</p> </li> <li> <p><strong>Check file extensions:</strong> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-44-1\"><a id=\"__codelineno-44-1\" name=\"__codelineno-44-1\" href=\"#__codelineno-44-1\"></a>find<span class=\"w\"> </span>mkdocs/docs/overrides/<span class=\"w\"> </span>-type<span class=\"w\"> </span>f<span class=\"w\"> </span>!<span class=\"w\"> </span>-name<span class=\"w\"> </span><span class=\"s2\">\"*.html\"</span> </span></code></pre></div> Rename files to <code>.html</code> extension</p> </li> </ol> <hr /> <h3 id=\"validate-exports-shows-errors\">Validate Exports Shows Errors<a class=\"headerlink\" href=\"#validate-exports-shows-errors\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Problem:</strong> Click \"Validate Exports\", get message \"Validated 24 pages: 0 repaired, 3 errors\".</p> <p><strong>Diagnosis:</strong></p> <p>Check API logs for error details:</p> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-45-1\"><a id=\"__codelineno-45-1\" name=\"__codelineno-45-1\" href=\"#__codelineno-45-1\"></a>docker<span class=\"w\"> </span>compose<span class=\"w\"> </span>logs<span class=\"w\"> </span>api<span class=\"w\"> </span><span class=\"p\">|</span><span class=\"w\"> </span>grep<span class=\"w\"> </span><span class=\"s2\">\"validate\"</span> </span></code></pre></div> <p>Expected error messages:</p> <div class=\"language-text highlight\"><pre><span></span><code><span id=\"__span-46-1\"><a id=\"__codelineno-46-1\" name=\"__codelineno-46-1\" href=\"#__codelineno-46-1\"></a>Page broken-page: Invalid HTML: Unclosed <div> tag </span><span id=\"__span-46-2\"><a id=\"__codelineno-46-2\" name=\"__codelineno-46-2\" href=\"#__codelineno-46-2\"></a>Page test-page: Write error: EACCES: permission denied, open 'mkdocs/docs/overrides/test-page.html' </span></code></pre></div> <p><strong>Possible Causes:</strong></p> <ol> <li><strong>Invalid HTML:</strong></li> <li>Page content has syntax errors (unclosed tags, invalid attributes)</li> <li> <p>Cannot export to MkDocs (would break theme)</p> </li> <li> <p><strong>Write permissions:</strong></p> </li> <li>API container cannot write to <code>mkdocs/docs/overrides/</code> directory</li> <li> <p>Filesystem permissions issue</p> </li> <li> <p><strong>Missing parent directory:</strong></p> </li> <li>Custom mkdocsPath references subdirectory (e.g., \"pages/about.html\")</li> <li>Subdirectory <code>mkdocs/docs/overrides/pages/</code> doesn't exist</li> </ol> <p><strong>Solution:</strong></p> <ol> <li><strong>Fix invalid HTML:</strong></li> <li>Navigate to <code>/app/pages/:id/edit</code></li> <li>Fix syntax errors in editor</li> <li>Save changes</li> <li> <p>Click \"Validate Exports\" again</p> </li> <li> <p><strong>Fix write permissions:</strong> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-47-1\"><a id=\"__codelineno-47-1\" name=\"__codelineno-47-1\" href=\"#__codelineno-47-1\"></a>sudo<span class=\"w\"> </span>chown<span class=\"w\"> </span>-R<span class=\"w\"> </span><span class=\"m\">1000</span>:1000<span class=\"w\"> </span>mkdocs/docs/overrides/ </span><span id=\"__span-47-2\"><a id=\"__codelineno-47-2\" name=\"__codelineno-47-2\" href=\"#__codelineno-47-2\"></a>sudo<span class=\"w\"> </span>chmod<span class=\"w\"> </span>-R<span class=\"w\"> </span><span class=\"m\">755</span><span class=\"w\"> </span>mkdocs/docs/overrides/ </span></code></pre></div></p> </li> <li> <p><strong>Create missing directories:</strong> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-48-1\"><a id=\"__codelineno-48-1\" name=\"__codelineno-48-1\" href=\"#__codelineno-48-1\"></a>mkdir<span class=\"w\"> </span>-p<span class=\"w\"> </span>mkdocs/docs/overrides/pages </span></code></pre></div></p> </li> </ol> <hr /> <h3 id=\"build-site-button-not-visible\">Build Site Button Not Visible<a class=\"headerlink\" href=\"#build-site-button-not-visible\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Problem:</strong> Cannot see \"Build Site\" button in page list.</p> <p><strong>Diagnosis:</strong></p> <p>Check user role:</p> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-49-1\"><a id=\"__codelineno-49-1\" name=\"__codelineno-49-1\" href=\"#__codelineno-49-1\"></a>docker<span class=\"w\"> </span>compose<span class=\"w\"> </span><span class=\"nb\">exec</span><span class=\"w\"> </span>v2-postgres<span class=\"w\"> </span>psql<span class=\"w\"> </span>-U<span class=\"w\"> </span>postgres<span class=\"w\"> </span>-d<span class=\"w\"> </span>v2<span class=\"w\"> </span>-c<span class=\"w\"> </span><span class=\"s2\">\"SELECT email, role FROM \\\"User\\\" WHERE email = 'your-email@example.com'\"</span> </span></code></pre></div> <p>Expected: <code>role = 'SUPER_ADMIN'</code></p> <p><strong>Possible Causes:</strong></p> <ol> <li><strong>Insufficient role:</strong></li> <li>User role is not SUPER_ADMIN</li> <li> <p>Build Site button only visible to SUPER_ADMIN</p> </li> <li> <p><strong>Frontend cache:</strong></p> </li> <li>User role changed but frontend still using old auth token</li> <li>Need to refresh token or log out/in</li> </ol> <p><strong>Solution:</strong></p> <ol> <li> <p><strong>Upgrade user role:</strong> <div class=\"language-sql highlight\"><pre><span></span><code><span id=\"__span-50-1\"><a id=\"__codelineno-50-1\" name=\"__codelineno-50-1\" href=\"#__codelineno-50-1\"></a><span class=\"k\">UPDATE</span><span class=\"w\"> </span><span class=\"ss\">\"User\"</span><span class=\"w\"> </span><span class=\"k\">SET</span><span class=\"w\"> </span><span class=\"k\">role</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"s1\">'SUPER_ADMIN'</span><span class=\"w\"> </span><span class=\"k\">WHERE</span><span class=\"w\"> </span><span class=\"n\">email</span><span class=\"w\"> </span><span class=\"o\">=</span><span class=\"w\"> </span><span class=\"s1\">'your-email@example.com'</span><span class=\"p\">;</span> </span></code></pre></div></p> </li> <li> <p><strong>Refresh auth token:</strong></p> </li> <li>Log out</li> <li>Log back in</li> <li>Role check updates with new token</li> </ol> <hr /> <h3 id=\"mkdocs-build-fails\">MkDocs Build Fails<a class=\"headerlink\" href=\"#mkdocs-build-fails\" title=\"Permanent link\">\u00b6</a></h3> <p><strong>Problem:</strong> Click \"Build Site\", get error message \"Failed to build site\".</p> <p><strong>Diagnosis:</strong></p> <p>Check MkDocs container logs:</p> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-51-1\"><a id=\"__codelineno-51-1\" name=\"__codelineno-51-1\" href=\"#__codelineno-51-1\"></a>docker<span class=\"w\"> </span>compose<span class=\"w\"> </span>logs<span class=\"w\"> </span>mkdocs </span></code></pre></div> <p>Common error messages:</p> <div class=\"language-text highlight\"><pre><span></span><code><span id=\"__span-52-1\"><a id=\"__codelineno-52-1\" name=\"__codelineno-52-1\" href=\"#__codelineno-52-1\"></a>ERROR - Config file 'mkdocs.yml' does not exist </span><span id=\"__span-52-2\"><a id=\"__codelineno-52-2\" name=\"__codelineno-52-2\" href=\"#__codelineno-52-2\"></a>ERROR - Invalid value: 'material' is not installed </span><span id=\"__span-52-3\"><a id=\"__codelineno-52-3\" name=\"__codelineno-52-3\" href=\"#__codelineno-52-3\"></a>ERROR - Template not found: overrides/about.html </span></code></pre></div> <p><strong>Possible Causes:</strong></p> <ol> <li><strong>MkDocs container down:</strong></li> <li>MkDocs service not running</li> <li> <p>Cannot execute build command</p> </li> <li> <p><strong>Configuration error:</strong></p> </li> <li><code>mkdocs.yml</code> has syntax errors</li> <li> <p>Invalid theme or plugin configuration</p> </li> <li> <p><strong>Missing theme:</strong></p> </li> <li>Material theme not installed in MkDocs container</li> <li> <p>Need to rebuild container with theme</p> </li> <li> <p><strong>Missing override files:</strong></p> </li> <li>Markdown stubs reference override files that don't exist</li> <li>Need to run \"Validate Exports\" first</li> </ol> <p><strong>Solution:</strong></p> <ol> <li> <p><strong>Start MkDocs container:</strong> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-53-1\"><a id=\"__codelineno-53-1\" name=\"__codelineno-53-1\" href=\"#__codelineno-53-1\"></a>docker<span class=\"w\"> </span>compose<span class=\"w\"> </span>up<span class=\"w\"> </span>-d<span class=\"w\"> </span>mkdocs </span></code></pre></div></p> </li> <li> <p><strong>Fix configuration errors:</strong></p> </li> <li>Edit <code>mkdocs/mkdocs.yml</code></li> <li>Fix syntax errors (check YAML indentation)</li> <li> <p>Test locally: <code>cd mkdocs && mkdocs build</code></p> </li> <li> <p><strong>Rebuild container with theme:</strong> <div class=\"language-bash highlight\"><pre><span></span><code><span id=\"__span-54-1\"><a id=\"__codelineno-54-1\" name=\"__codelineno-54-1\" href=\"#__codelineno-54-1\"></a>docker<span class=\"w\"> </span>compose<span class=\"w\"> </span>build<span class=\"w\"> </span>mkdocs </span><span id=\"__span-54-2\"><a id=\"__codelineno-54-2\" name=\"__codelineno-54-2\" href=\"#__codelineno-54-2\"></a>docker<span class=\"w\"> </span>compose<span class=\"w\"> </span>up<span class=\"w\"> </span>-d<span class=\"w\"> </span>mkdocs </span></code></pre></div></p> </li> <li> <p><strong>Run validation first:</strong></p> </li> <li>Click \"Validate Exports\" to repair missing files</li> <li>Then click \"Build Site\"</li> </ol> <h2 id=\"related-documentation\">Related Documentation<a class=\"headerlink\" href=\"#related-documentation\" title=\"Permanent link\">\u00b6</a></h2> <ul> <li><a href=\"/v2/backend/modules/pages.md\">Landing Pages Backend Module</a> \u2014 Backend page service</li> <li><a href=\"/v2/api-reference/pages.md\">Landing Pages API Reference</a> \u2014 Page endpoints</li> <li><a href=\"/v2/frontend/components/grapesjs-editor.md\">GrapesJS Editor Component</a> \u2014 Visual editor wrapper</li> <li><a href=\"/v2/frontend/pages/admin/page-editor-page.md\">Page Editor Page</a> \u2014 Full-screen page editor</li> <li><a href=\"/v2/frontend/pages/public/landing-page.md\">Public Landing Page</a> \u2014 Public page renderer at /p/:slug</li> <li><a href=\"/v2/features/pages/mkdocs-integration.md\">MkDocs Integration</a> \u2014 MkDocs export + Material theme</li> <li><a href=\"/v2/frontend/pages/admin/docs-page.md\">DocsPage</a> \u2014 MkDocs management (site building, export table)</li> <li><a href=\"/v2/user-guides/content-editor-guide.md\">User Guide: Content Editor</a> \u2014 Landing page creation workflow</li> <li><a href=\"/v2/troubleshooting/mkdocs-issues.md\">Troubleshooting: MkDocs Issues</a> \u2014 MkDocs troubleshooting</li> </ul>"},{"location":"v2/frontend/pages/admin/listmonk-page/","title":"ListmonkPage","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#overview","title":"Overview","text":"

The ListmonkPage provides administrative management of the Listmonk newsletter integration, offering a dual-view interface with management controls (sync, status monitoring) on one tab and an embedded Listmonk admin interface on another. It enables synchronization of campaign participants, map locations, and users to Listmonk subscriber lists, monitors connection status, displays list statistics with subscriber counts, and provides advanced operations like reinitialization and connection testing. The embedded admin tab loads the full Listmonk web UI via iframe with auto-authentication, allowing direct management of campaigns, subscribers, and templates without leaving the admin interface.

Route: /app/listmonk Component: admin/src/pages/ListmonkPage.tsx (395 lines) Auth Required: Yes (SUPER_ADMIN or INFLUENCE_ADMIN role recommended) Layout: AppLayout Backend Module: api/src/modules/listmonk/

"},{"location":"v2/frontend/pages/admin/listmonk-page/#screenshot","title":"Screenshot","text":"

[Screenshot: ListmonkPage with \"Newsletter / Listmonk\" title. Right side has tab switcher (Management selected, Listmonk Admin grayed), \"Test Connection\" button, and \"Open Listmonk\" button (opens in new tab). Below are two cards side-by-side: \"Status\" card shows Sync Enabled (green checkmark), Connection (green Connected), Lists Initialized (green Yes), Last Sync (2 minutes ago), Last Error (None). \"Sync Actions\" card has 4 buttons in 2\u00d72 grid: \"Sync Participants\", \"Sync Locations\", \"Sync Users\", \"Sync All\" (primary blue). Below is \"List Statistics\" card with table showing List Name column (Participants, Locations, Users) and Subscribers column (347, 203, 15). At bottom is collapsed \"Advanced\" section. When \"Listmonk Admin\" tab selected, full Listmonk UI loads in iframe with dark theme, showing campaigns list, subscribers count, and send email button.]

"},{"location":"v2/frontend/pages/admin/listmonk-page/#features","title":"Features","text":"
  • Dual-view interface \u2014 Tab switcher between Management and Listmonk Admin
  • Status monitoring \u2014 Real-time sync status, connection state, initialization status
  • Selective synchronization \u2014 Sync participants, locations, or users individually
  • Bulk synchronization \u2014 Sync all lists at once
  • Connection testing \u2014 Test Listmonk API connectivity before syncing
  • List statistics \u2014 Subscriber counts for each list (Participants, Locations, Users)
  • Advanced operations \u2014 Reinitialize lists if corrupted or missing
  • Embedded Listmonk admin \u2014 Full Listmonk UI loaded in iframe with auto-authentication
  • External Listmonk access \u2014 Open Listmonk in new tab (direct access on port 9001)
  • Error reporting \u2014 Display last sync error with timestamp
  • Last sync tracking \u2014 Relative time since last successful sync
  • Sync failure counts \u2014 Track failed subscriber additions (shown in warnings)
  • Fullbleed iframe \u2014 Listmonk Admin tab removes padding for full-screen experience
"},{"location":"v2/frontend/pages/admin/listmonk-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#checking-sync-status","title":"Checking Sync Status","text":"
  1. Navigate to /app/listmonk
  2. Ensure \"Management\" tab is selected (default)
  3. Observe \"Status\" card (left column):
  4. Sync Enabled: Badge shows Enabled (green) or Disabled (red)
  5. Connection: Badge shows Connected (green), Disconnected (orange), or N/A (gray)
  6. Lists Initialized: Badge shows Yes (green) or No (gray)
  7. Last Sync: Relative time (e.g., \"2 minutes ago\") or \"Never\"
  8. Last Error: Error message or \"None\"
  9. Check \"List Statistics\" table:
  10. Participants: Subscriber count for campaign participants
  11. Locations: Subscriber count for map locations
  12. Users: Subscriber count for user accounts

Sync Enabled States: - Enabled (green): LISTMONK_SYNC_ENABLED=true in .env, sync operations allowed - Disabled (red): LISTMONK_SYNC_ENABLED=false in .env, sync operations blocked

Connection States: - Connected (green): Listmonk API reachable, credentials valid - Disconnected (orange): Listmonk API unreachable or credentials invalid (only shown if sync enabled) - N/A (gray): Sync disabled, connection not tested

Lists Initialized: - Yes (green): Listmonk lists (Participants, Locations, Users) exist and ready - No (gray): Lists not yet created (click \"Reinitialize Lists\" to create)

"},{"location":"v2/frontend/pages/admin/listmonk-page/#testing-listmonk-connection","title":"Testing Listmonk Connection","text":"

When to Test: - Before first sync (verify credentials) - After updating Listmonk URL/credentials - Troubleshooting sync failures

Steps:

  1. Click \"Test Connection\" button (top-right header)
  2. Loading spinner appears on button
  3. Backend tests Listmonk API connection:
  4. GET /api/health endpoint
  5. Verifies basic auth credentials
  6. Checks API version compatibility
  7. Result message appears:
  8. Success: \"Connection successful\" (green toast)
  9. Warning: \"Connection partially successful - check configuration\" (orange toast)
  10. Error: \"Connection failed - check Listmonk URL and credentials\" (red toast)
  11. Status card refreshes to show updated connection state

Success Criteria: - Listmonk API responds to /api/health endpoint - Credentials (username/password) authenticate successfully - API version is compatible (v2.0+)

"},{"location":"v2/frontend/pages/admin/listmonk-page/#syncing-participants-to-listmonk","title":"Syncing Participants to Listmonk","text":"

What is \"Participants\"?

Campaign participants who submitted responses via the response wall. Synced to Listmonk \"Participants\" list for newsletter targeting.

Steps:

  1. Click \"Sync Participants\" button in \"Sync Actions\" card
  2. Loading spinner appears on button
  3. Backend fetches all campaign participants from database:
  4. Query: SELECT DISTINCT email, name FROM Response WHERE verified = true
  5. Filter: Only verified responses (email confirmed)
  6. For each participant:
  7. Check if subscriber exists in Listmonk \"Participants\" list
  8. If not exists, create new subscriber with name and email
  9. If exists, update subscriber attributes (last campaign, response count)
  10. Result message appears:
  11. Success: \"Synced participants: 347 created, 23 updated\"
  12. Warning: \"Synced participants: 347 created, 23 updated, 5 failed - check logs\"
  13. Status card and list statistics update to show new counts

Sync Logic:

// Fetch participants from database\nconst participants = await prisma.response.findMany({\n  where: { verified: true },\n  distinct: ['email'],\n  select: { email: true, name: true, campaignId: true },\n});\n\n// For each participant\nfor (const participant of participants) {\n  try {\n    // Check if subscriber exists\n    const existingSubscriber = await listmonkClient.getSubscriberByEmail(participant.email);\n\n    if (existingSubscriber) {\n      // Update existing subscriber\n      await listmonkClient.updateSubscriber(existingSubscriber.id, {\n        name: participant.name,\n        attribs: { lastCampaign: participant.campaignId },\n      });\n      updated++;\n    } else {\n      // Create new subscriber\n      await listmonkClient.createSubscriber({\n        email: participant.email,\n        name: participant.name,\n        lists: [participantsListId],\n        attribs: { source: 'campaign_response' },\n      });\n      created++;\n    }\n  } catch (error) {\n    failed++;\n  }\n}\n
"},{"location":"v2/frontend/pages/admin/listmonk-page/#syncing-locations-to-listmonk","title":"Syncing Locations to Listmonk","text":"

What is \"Locations\"?

Map locations (residential addresses, campaign offices, etc.). Synced to Listmonk \"Locations\" list for geographic targeting.

Steps:

  1. Click \"Sync Locations\" button in \"Sync Actions\" card
  2. Loading spinner appears on button
  3. Backend fetches all locations with valid email addresses:
  4. Query: SELECT * FROM Location WHERE email IS NOT NULL AND deletedAt IS NULL
  5. Filter: Only locations with email, not soft-deleted
  6. For each location:
  7. Check if subscriber exists in Listmonk \"Locations\" list
  8. If not exists, create new subscriber with address details
  9. If exists, update subscriber attributes (address, postal code, cut)
  10. Result message appears:
  11. Success: \"Synced locations: 203 created, 45 updated\"
  12. Status card and list statistics update

Subscriber Attributes:

{\n  email: location.email,\n  name: location.name || location.address,  // Fallback to address if no name\n  lists: [locationsListId],\n  attribs: {\n    address: location.address,\n    postalCode: location.postalCode,\n    city: location.city,\n    cutId: location.cutId,\n    province: location.province,\n  },\n}\n
"},{"location":"v2/frontend/pages/admin/listmonk-page/#syncing-users-to-listmonk","title":"Syncing Users to Listmonk","text":"

What is \"Users\"?

User accounts (admins, volunteers, etc.). Synced to Listmonk \"Users\" list for internal communications.

Steps:

  1. Click \"Sync Users\" button in \"Sync Actions\" card
  2. Loading spinner appears on button
  3. Backend fetches all user accounts:
  4. Query: SELECT * FROM User WHERE deletedAt IS NULL
  5. Filter: Exclude soft-deleted users
  6. For each user:
  7. Check if subscriber exists in Listmonk \"Users\" list
  8. If not exists, create new subscriber with role info
  9. If exists, update subscriber attributes (role, last login)
  10. Result message appears:
  11. Success: \"Synced users: 15 created, 3 updated\"
  12. Status card and list statistics update

Subscriber Attributes:

{\n  email: user.email,\n  name: user.name,\n  lists: [usersListId],\n  attribs: {\n    role: user.role,\n    lastLogin: user.lastLogin,\n  },\n}\n
"},{"location":"v2/frontend/pages/admin/listmonk-page/#syncing-all-lists-at-once","title":"Syncing All Lists at Once","text":"

When to Use: - Initial setup (populate all lists) - After bulk data import (NAR import, CSV import) - Regular maintenance (weekly/monthly sync)

Steps:

  1. Click \"Sync All\" button (primary blue, bottom-right of \"Sync Actions\" card)
  2. Loading spinner appears on button
  3. Backend syncs all three lists sequentially:
  4. First: Sync participants
  5. Second: Sync locations
  6. Third: Sync users
  7. Result message shows aggregated counts:
  8. Success: \"Synced all lists: 347 participants, 203 locations, 15 users\"
  9. Warning: \"Synced all lists: 565 total, 8 failed - check logs\"
  10. Status card and list statistics update to show all new counts

Performance:

  • Sequential execution: Lists synced one at a time (not parallel)
  • Duration: Typically 10-30 seconds for 500+ subscribers
  • Idempotent: Safe to run multiple times (creates or updates, no duplicates)
"},{"location":"v2/frontend/pages/admin/listmonk-page/#reinitializing-listmonk-lists","title":"Reinitializing Listmonk Lists","text":"

When to Reinitialize: - Lists accidentally deleted in Listmonk - Fresh Listmonk installation - Corrupted list data

Steps:

  1. Scroll to \"Advanced\" section at bottom
  2. Click to expand \"Advanced\" collapse panel
  3. Click \"Reinitialize Lists\" button
  4. Confirmation popconfirm appears: \"Reinitialize Lists. This will re-create any missing lists in Listmonk. Existing lists are preserved.\"
  5. Click \"Reinitialize\" to confirm (or click outside to cancel)
  6. Loading spinner appears on button
  7. Backend checks for existence of each list (Participants, Locations, Users)
  8. For each missing list:
  9. Create new list with name and type (public/private)
  10. Set list description
  11. Success message: \"Lists reinitialized\" (or error if creation fails)
  12. Status card updates to show \"Lists Initialized: Yes\"

Important: Reinitialization only creates missing lists. Existing lists are NOT deleted or modified. Existing subscribers remain intact.

"},{"location":"v2/frontend/pages/admin/listmonk-page/#accessing-embedded-listmonk-admin","title":"Accessing Embedded Listmonk Admin","text":"

What is \"Listmonk Admin\" Tab?

Full Listmonk web UI embedded in iframe, allowing direct management without leaving admin interface.

Steps:

  1. Click \"Listmonk Admin\" button in tab switcher (top-right header)
  2. Active tab changes from \"Management\" to \"Listmonk Admin\"
  3. Page layout changes to fullbleed (removes padding for full-screen iframe)
  4. Loading spinner appears while iframe loads
  5. Backend generates auto-authentication token:
  6. GET /api/listmonk/proxy-url
  7. Response: { port: 9001, token: \"auto-auth-token-xyz\" }
  8. Iframe loads Listmonk URL with auth token:
  9. URL: //localhost:9001/auth?token=auto-auth-token-xyz
  10. Listmonk auto-authenticates user (no manual login required)
  11. Full Listmonk UI appears in iframe:
  12. Dashboard (campaign stats, subscriber counts)
  13. Campaigns (create/send newsletters)
  14. Subscribers (view/edit/import)
  15. Lists (manage subscriber lists)
  16. Templates (email templates with WYSIWYG editor)

Use Cases: - Create newsletter campaigns - View/edit subscribers directly - Import subscribers from CSV - Design email templates - View campaign analytics

Limitations: - Iframe may have slight performance overhead vs. direct access - Some Listmonk features may require full-screen (use \"Open Listmonk\" button instead)

"},{"location":"v2/frontend/pages/admin/listmonk-page/#opening-listmonk-in-new-tab","title":"Opening Listmonk in New Tab","text":"

When to Use: - Full-screen Listmonk access (no iframe constraints) - Better performance (no iframe overhead) - Working with large subscriber lists (better scrolling)

Steps:

  1. Click \"Open Listmonk\" button (top-right header, next to \"Test Connection\")
  2. New browser tab opens with Listmonk URL: //localhost:9001
  3. Listmonk login page appears (if not already logged in)
  4. Enter Listmonk admin credentials:
  5. Username: Value of LISTMONK_WEB_ADMIN_USER env var
  6. Password: Value of LISTMONK_WEB_ADMIN_PASSWORD env var
  7. Click \"Login\" to access full Listmonk interface

Note: This opens Listmonk directly on port 9001. User must manually authenticate (no auto-auth token). Use this for full-featured access without iframe restrictions.

"},{"location":"v2/frontend/pages/admin/listmonk-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  • Typography.Text \u2014 Labels, descriptions
  • Row / Col \u2014 Grid layout for status and sync action cards
  • Card \u2014 Container for Status, Sync Actions, List Statistics
  • Descriptions \u2014 Key-value pairs in Status card
  • Descriptions.Item \u2014 Individual status fields
  • Badge \u2014 Status indicators (Enabled/Disabled, Connected/Disconnected, Yes/No)
  • Space \u2014 Button grouping
  • Button \u2014 Sync buttons, Test Connection, Open Listmonk
  • Radio.Group \u2014 Tab switcher (Management / Listmonk Admin)
  • Radio.Button \u2014 Individual tab buttons
  • Table \u2014 List statistics table
  • Collapse \u2014 Advanced section (collapsible)
  • Popconfirm \u2014 Reinitialize confirmation dialog
  • Alert \u2014 Iframe error alert (if load fails)
  • Spin \u2014 Loading indicators (initial load, iframe load, button actions)
  • App.useApp \u2014 Access to message and modal contexts
  • message \u2014 Toast notifications for success/error feedback
"},{"location":"v2/frontend/pages/admin/listmonk-page/#dual-view-tab-switcher","title":"Dual-View Tab Switcher","text":"
<Radio.Group\n  value={activeTab}\n  onChange={(e) => {\n    const tab = e.target.value as 'management' | 'admin';\n    setActiveTab(tab);\n    if (tab === 'admin') loadIframe();  // Lazy-load iframe\n  }}\n  optionType=\"button\"\n  buttonStyle=\"solid\"\n  size=\"small\"\n>\n  <Radio.Button value=\"management\">\n    <SettingOutlined /> Management\n  </Radio.Button>\n  <Radio.Button value=\"admin\">\n    <DesktopOutlined /> Listmonk Admin\n  </Radio.Button>\n</Radio.Group>\n

Tab Switcher Features: - Button style: Solid background for selected tab (more prominent than default) - Icons: Visual indicators (Settings for Management, Desktop for Admin) - Lazy loading: Iframe only loads when Admin tab selected (performance optimization) - Size small: Compact header controls

"},{"location":"v2/frontend/pages/admin/listmonk-page/#status-card","title":"Status Card","text":"
<Card title=\"Status\" size=\"small\">\n  <Descriptions column={1} size=\"small\">\n    <Descriptions.Item label=\"Sync Enabled\">\n      <Badge\n        status={status?.enabled ? 'success' : 'error'}\n        text={status?.enabled ? 'Enabled' : 'Disabled'}\n      />\n    </Descriptions.Item>\n    <Descriptions.Item label=\"Connection\">\n      <Badge\n        status={status?.connected ? 'success' : status?.enabled ? 'warning' : 'default'}\n        text={status?.connected ? 'Connected' : status?.enabled ? 'Disconnected' : 'N/A'}\n      />\n    </Descriptions.Item>\n    <Descriptions.Item label=\"Lists Initialized\">\n      <Badge\n        status={status?.initialized ? 'success' : 'default'}\n        text={status?.initialized ? 'Yes' : 'No'}\n      />\n    </Descriptions.Item>\n    <Descriptions.Item label=\"Last Sync\">\n      {status?.lastSyncAt ? dayjs(status.lastSyncAt).fromNow() : 'Never'}\n    </Descriptions.Item>\n    <Descriptions.Item label=\"Last Error\">\n      {status?.lastError || 'None'}\n    </Descriptions.Item>\n  </Descriptions>\n</Card>\n

Status Badge Colors: - Success (green dot): Enabled, Connected, Initialized=Yes - Error (red dot): Disabled - Warning (orange dot): Enabled but Disconnected - Default (gray dot): N/A (sync disabled), Initialized=No

"},{"location":"v2/frontend/pages/admin/listmonk-page/#sync-actions-card","title":"Sync Actions Card","text":"
<Card title=\"Sync Actions\" size=\"small\">\n  <Space direction=\"vertical\" style={{ width: '100%' }} size=\"middle\">\n    <Row gutter={[8, 8]}>\n      <Col xs={24} sm={12}>\n        <Button\n          block\n          icon={<SyncOutlined />}\n          loading={syncing.participants}\n          onClick={() => handleSync('participants')}\n          disabled={!status?.enabled}\n        >\n          Sync Participants\n        </Button>\n      </Col>\n      <Col xs={24} sm={12}>\n        <Button\n          block\n          icon={<SyncOutlined />}\n          loading={syncing.locations}\n          onClick={() => handleSync('locations')}\n          disabled={!status?.enabled}\n        >\n          Sync Locations\n        </Button>\n      </Col>\n      <Col xs={24} sm={12}>\n        <Button\n          block\n          icon={<SyncOutlined />}\n          loading={syncing.users}\n          onClick={() => handleSync('users')}\n          disabled={!status?.enabled}\n        >\n          Sync Users\n        </Button>\n      </Col>\n      <Col xs={24} sm={12}>\n        <Button\n          block\n          type=\"primary\"\n          icon={<SyncOutlined />}\n          loading={syncing.all}\n          onClick={handleSyncAll}\n          disabled={!status?.enabled}\n        >\n          Sync All\n        </Button>\n      </Col>\n    </Row>\n  </Space>\n</Card>\n

Sync Actions Features: - Block buttons: Full-width buttons for easy clicking - 2\u00d72 grid: Responsive layout (stacked on mobile, side-by-side on desktop) - Individual loading states: Each button has its own loading spinner - Disabled state: Buttons disabled if sync not enabled in .env - Primary styling: \"Sync All\" button uses primary blue (most common action)

"},{"location":"v2/frontend/pages/admin/listmonk-page/#list-statistics-table","title":"List Statistics Table","text":"
<Table\n  dataSource={stats?.lists || []}\n  rowKey=\"name\"\n  size=\"small\"\n  loading={loading}\n  pagination={false}\n  columns={[\n    { title: 'List Name', dataIndex: 'name', key: 'name' },\n    {\n      title: 'Subscribers',\n      dataIndex: 'subscriberCount',\n      key: 'subscriberCount',\n      width: 120,\n      align: 'right' as const,\n    },\n  ]}\n  locale={{\n    emptyText: status?.initialized\n      ? 'No lists found'\n      : 'Lists not initialized \u2014 run a sync or reinitialize',\n  }}\n/>\n

Table Features: - Small size: Compact rows for dashboard-style display - No pagination: Only 3 lists (Participants, Locations, Users), always fit on one page - Right-aligned numbers: Subscriber counts right-aligned for easier comparison - Custom empty text: Different message if lists not initialized vs. genuinely empty

"},{"location":"v2/frontend/pages/admin/listmonk-page/#embedded-listmonk-iframe","title":"Embedded Listmonk Iframe","text":"
{activeTab === 'admin' && (\n  <div>\n    {iframeLoading && (\n      <div style={{ textAlign: 'center', padding: 80 }}>\n        <Spin size=\"large\" />\n      </div>\n    )}\n    {iframeError && (\n      <Alert\n        type=\"error\"\n        message={iframeError}\n        showIcon\n        action={\n          <Button size=\"small\" onClick={loadIframe}>\n            Retry\n          </Button>\n        }\n        style={{ marginBottom: 16 }}\n      />\n    )}\n    {iframeSrc && !iframeLoading && (\n      <iframe\n        src={iframeSrc}\n        style={{\n          width: '100%',\n          height: 'calc(100vh - 64px)',  // Full viewport height minus header\n          border: 'none',\n          display: 'block',\n        }}\n        title=\"Listmonk Admin\"\n      />\n    )}\n  </div>\n)}\n

Iframe Features: - Full viewport height: calc(100vh - 64px) fills available space - No border: Seamless integration with admin interface - Loading state: Large spinner while iframe loads - Error handling: Alert with retry button if iframe fails to load - Auto-authentication: Token in URL query string (?token=xyz) logs user in automatically

"},{"location":"v2/frontend/pages/admin/listmonk-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#local-state-no-zustand-store","title":"Local State (No Zustand Store)","text":"
const [status, setStatus] = useState<ListmonkStatus | null>(null);\nconst [stats, setStats] = useState<ListmonkStats | null>(null);\nconst [loading, setLoading] = useState(true);\nconst [syncing, setSyncing] = useState<Record<string, boolean>>({});\nconst [iframeSrc, setIframeSrc] = useState<string | null>(null);\nconst [iframeLoading, setIframeLoading] = useState(false);\nconst [iframeError, setIframeError] = useState<string | null>(null);\nconst iframeInitialized = useRef(false);\nconst [activeTab, setActiveTab] = useState<'management' | 'admin'>('management');\n

State Variables: - status (object | null): Sync status (enabled, connected, initialized, lastSyncAt, lastError) - stats (object | null): List statistics (lists array with name and subscriberCount) - loading (boolean): Initial page load state - syncing (object): Sync button loading states (participants, locations, users, all, test, reinit) - iframeSrc (string | null): Listmonk iframe URL with auth token - iframeLoading (boolean): Iframe loading state - iframeError (string | null): Iframe load error message - iframeInitialized (ref): Prevents redundant iframe loads (only load once) - activeTab (string): Currently active tab ('management' or 'admin')

No Global State:

This page does NOT use Zustand stores. Listmonk data is fetched directly from the API and stored in local state. This is appropriate because: - Listmonk data is admin-only - Data changes infrequently (manual sync operations) - No need to share state between pages - Simpler architecture without store overhead

"},{"location":"v2/frontend/pages/admin/listmonk-page/#lazy-iframe-loading","title":"Lazy Iframe Loading","text":"

Iframe only loads when Admin tab is selected:

const loadIframe = useCallback(async () => {\n  if (iframeInitialized.current && iframeSrc) return;  // Already loaded, skip\n\n  setIframeLoading(true);\n  setIframeError(null);\n  try {\n    const res = await api.get<{ port: number; token: string }>('/listmonk/proxy-url');\n    const { port, token } = res.data;\n    const url = `//${window.location.hostname}:${port}/auth?token=${encodeURIComponent(token)}`;\n    setIframeSrc(url);\n    iframeInitialized.current = true;  // Mark as initialized\n  } catch {\n    setIframeError('Failed to load Listmonk admin \u2014 ensure the proxy is running');\n  } finally {\n    setIframeLoading(false);\n  }\n}, [iframeSrc]);\n\n// Load iframe when Admin tab selected\nonChange={(e) => {\n  const tab = e.target.value as 'management' | 'admin';\n  setActiveTab(tab);\n  if (tab === 'admin') loadIframe();\n}}\n

Why Lazy Load?

  • Performance: Iframe not loaded until needed (saves memory + network)
  • User experience: Most users stay on Management tab (no iframe overhead)
  • One-time load: iframeInitialized ref prevents redundant loads
"},{"location":"v2/frontend/pages/admin/listmonk-page/#fullbleed-layout-for-iframe","title":"Fullbleed Layout for Iframe","text":"

When Admin tab is active, page header sets fullBleed: true:

useEffect(() => {\n  setPageHeader({\n    title: 'Newsletter / Listmonk',\n    actions: headerActions,\n    fullBleed: activeTab === 'admin',  // Remove padding for full-screen iframe\n  });\n  return () => setPageHeader(null);\n}, [setPageHeader, headerActions, activeTab]);\n

Result:

  • Management tab: Normal padding (comfortable reading)
  • Admin tab: No padding (iframe fills entire content area)
"},{"location":"v2/frontend/pages/admin/listmonk-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose Auth GET /api/listmonk Get sync status Required GET /api/listmonk/stats Get list statistics Required POST /api/listmonk/test-connection Test Listmonk API connection Required POST /api/listmonk/sync/participants Sync participants list Required POST /api/listmonk/sync/locations Sync locations list Required POST /api/listmonk/sync/users Sync users list Required POST /api/listmonk/sync/all Sync all lists Required POST /api/listmonk/reinitialize Reinitialize lists Required GET /api/listmonk/proxy-url Get iframe URL with auth token Required"},{"location":"v2/frontend/pages/admin/listmonk-page/#load-sync-status","title":"Load Sync Status","text":"

Request:

const { data } = await api.get<ListmonkStatus>('/listmonk');\n

Response (200 OK):

{\n  \"enabled\": true,\n  \"connected\": true,\n  \"initialized\": true,\n  \"lastSyncAt\": \"2026-02-11T10:30:00.000Z\",\n  \"lastError\": null\n}\n

Response Fields: - enabled (boolean): Value of LISTMONK_SYNC_ENABLED env var - connected (boolean): Listmonk API reachable and credentials valid - initialized (boolean): Listmonk lists (Participants, Locations, Users) exist - lastSyncAt (ISO 8601 | null): Timestamp of last successful sync - lastError (string | null): Last error message (or null if no errors)

"},{"location":"v2/frontend/pages/admin/listmonk-page/#load-list-statistics","title":"Load List Statistics","text":"

Request:

const { data } = await api.get<ListmonkStats>('/listmonk/stats');\n

Response (200 OK):

{\n  \"lists\": [\n    {\n      \"name\": \"Participants\",\n      \"subscriberCount\": 347\n    },\n    {\n      \"name\": \"Locations\",\n      \"subscriberCount\": 203\n    },\n    {\n      \"name\": \"Users\",\n      \"subscriberCount\": 15\n    }\n  ]\n}\n

Response Fields: - lists (array): Array of list objects - name (string): List name (Participants, Locations, or Users) - subscriberCount (number): Number of subscribers in list

Backend Calculation:

const lists = await listmonkClient.getLists();\nconst stats = await Promise.all(\n  lists.map(async (list) => {\n    const count = await listmonkClient.getSubscriberCount(list.id);\n    return { name: list.name, subscriberCount: count };\n  })\n);\nreturn { lists: stats };\n
"},{"location":"v2/frontend/pages/admin/listmonk-page/#test-listmonk-connection","title":"Test Listmonk Connection","text":"

Request:

const { data } = await api.post<{ success: boolean; message: string }>('/listmonk/test-connection');\n

Response (200 OK) - Success:

{\n  \"success\": true,\n  \"message\": \"Connection successful - Listmonk v2.3.0\"\n}\n

Response (200 OK) - Failure:

{\n  \"success\": false,\n  \"message\": \"Connection failed: Authentication error\"\n}\n

Response Fields: - success (boolean): Whether connection test passed - message (string): Result message (success details or error reason)

Backend Test:

try {\n  // Test Listmonk API health endpoint\n  const health = await listmonkClient.getHealth();\n\n  if (health.version) {\n    return { success: true, message: `Connection successful - Listmonk v${health.version}` };\n  } else {\n    return { success: false, message: 'Connection failed: Invalid response' };\n  }\n} catch (error) {\n  return { success: false, message: `Connection failed: ${error.message}` };\n}\n
"},{"location":"v2/frontend/pages/admin/listmonk-page/#sync-participantslocationsusers","title":"Sync Participants/Locations/Users","text":"

Request:

const type = 'participants';  // or 'locations' or 'users'\nconst { data } = await api.post<ListmonkSyncResult>(`/listmonk/sync/${type}`);\n

Response (200 OK):

{\n  \"success\": true,\n  \"message\": \"Synced participants: 347 created, 23 updated\",\n  \"results\": {\n    \"created\": 347,\n    \"updated\": 23,\n    \"failed\": 5\n  }\n}\n

Response Fields: - success (boolean): Whether sync operation completed - message (string): Result summary - results (object): - created (number): New subscribers added - updated (number): Existing subscribers updated - failed (number): Subscribers that failed to sync (API errors, validation errors)

Backend Workflow:

// 1. Fetch data from database\nconst participants = await prisma.response.findMany({\n  where: { verified: true },\n  distinct: ['email'],\n});\n\n// 2. Sync to Listmonk\nconst results = { created: 0, updated: 0, failed: 0 };\nfor (const participant of participants) {\n  try {\n    const existing = await listmonkClient.getSubscriberByEmail(participant.email);\n    if (existing) {\n      await listmonkClient.updateSubscriber(existing.id, { /* ... */ });\n      results.updated++;\n    } else {\n      await listmonkClient.createSubscriber({ /* ... */ });\n      results.created++;\n    }\n  } catch (error) {\n    results.failed++;\n  }\n}\n\n// 3. Update last sync timestamp\nawait prisma.listmonkStatus.update({\n  where: { id: 'singleton' },\n  data: { lastSyncAt: new Date() },\n});\n\nreturn { success: true, message: `Synced participants: ${results.created} created, ${results.updated} updated`, results };\n
"},{"location":"v2/frontend/pages/admin/listmonk-page/#sync-all-lists","title":"Sync All Lists","text":"

Request:

const { data } = await api.post<ListmonkSyncAllResult>('/listmonk/sync/all');\n

Response (200 OK):

{\n  \"success\": true,\n  \"message\": \"Synced all lists: 347 participants, 203 locations, 15 users\",\n  \"results\": {\n    \"participants\": {\n      \"created\": 347,\n      \"updated\": 23,\n      \"failed\": 5\n    },\n    \"locations\": {\n      \"created\": 203,\n      \"updated\": 12,\n      \"failed\": 2\n    },\n    \"users\": {\n      \"created\": 15,\n      \"updated\": 3,\n      \"failed\": 1\n    }\n  }\n}\n

Response Fields: - success (boolean): Whether all syncs completed - message (string): Result summary - results (object): - participants (object): Participant sync results - locations (object): Location sync results - users (object): User sync results

"},{"location":"v2/frontend/pages/admin/listmonk-page/#reinitialize-lists","title":"Reinitialize Lists","text":"

Request:

await api.post('/listmonk/reinitialize');\n

Response (200 OK):

{\n  \"message\": \"Lists reinitialized\"\n}\n

Backend Workflow:

// 1. Check for existence of each list\nconst lists = await listmonkClient.getLists();\nconst participantsList = lists.find(l => l.name === 'Participants');\nconst locationsList = lists.find(l => l.name === 'Locations');\nconst usersList = lists.find(l => l.name === 'Users');\n\n// 2. Create missing lists\nif (!participantsList) {\n  await listmonkClient.createList({\n    name: 'Participants',\n    type: 'public',\n    description: 'Campaign participants from response wall',\n  });\n}\n\nif (!locationsList) {\n  await listmonkClient.createList({\n    name: 'Locations',\n    type: 'public',\n    description: 'Map locations with email addresses',\n  });\n}\n\nif (!usersList) {\n  await listmonkClient.createList({\n    name: 'Users',\n    type: 'private',\n    description: 'User accounts (admins, volunteers)',\n  });\n}\n\n// 3. Update initialization status\nawait prisma.listmonkStatus.update({\n  where: { id: 'singleton' },\n  data: { initialized: true },\n});\n\nreturn { message: 'Lists reinitialized' };\n
"},{"location":"v2/frontend/pages/admin/listmonk-page/#get-iframe-proxy-url","title":"Get Iframe Proxy URL","text":"

Request:

const { data } = await api.get<{ port: number; token: string }>('/listmonk/proxy-url');\n

Response (200 OK):

{\n  \"port\": 9001,\n  \"token\": \"auto-auth-token-abc123xyz\"\n}\n

Response Fields: - port (number): Listmonk service port (typically 9001) - token (string): Auto-authentication token (valid for 5 minutes)

Backend Workflow:

// 1. Generate auto-authentication token\nconst token = crypto.randomBytes(32).toString('hex');\n\n// 2. Store token in Redis with 5-minute expiry\nawait redis.set(`listmonk-auth:${token}`, 'admin', 'EX', 300);\n\n// 3. Return port and token\nreturn {\n  port: process.env.LISTMONK_PORT || 9001,\n  token,\n};\n

Listmonk Auto-Authentication:

Listmonk checks Redis for token when /auth?token=xyz is accessed:

// Listmonk auth handler\nrouter.get('/auth', async (req, res) => {\n  const { token } = req.query;\n\n  // Verify token in Redis\n  const userId = await redis.get(`listmonk-auth:${token}`);\n\n  if (userId) {\n    // Auto-login user\n    req.session.userId = userId;\n    res.redirect('/admin');\n  } else {\n    res.status(401).send('Invalid or expired token');\n  }\n});\n
"},{"location":"v2/frontend/pages/admin/listmonk-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#complete-sync-flow","title":"Complete Sync Flow","text":"
const handleSync = async (type: 'participants' | 'locations' | 'users') => {\n  setSyncing(s => ({ ...s, [type]: true }));  // Set loading state for specific button\n  try {\n    const res = await api.post<ListmonkSyncResult>(`/listmonk/sync/${type}`);\n\n    // Show success or warning message\n    if (res.data.success) {\n      message.success(res.data.message);\n\n      // Show warning if some failed\n      if (res.data.results && res.data.results.failed > 0) {\n        message.warning(`${res.data.results.failed} failed \u2014 check logs for details`);\n      }\n    } else {\n      message.error(res.data.message);\n    }\n\n    // Refresh status and stats\n    await Promise.all([fetchStatus(), fetchStats()]);\n  } catch {\n    message.error(`Failed to sync ${type}`);\n  } finally {\n    setSyncing(s => ({ ...s, [type]: false }));  // Clear loading state\n  }\n};\n

Key Steps: 1. Set loading state for specific button (participants, locations, or users) 2. Send POST request to sync endpoint 3. Show success message 4. Show warning if some subscribers failed to sync 5. Refresh status and stats to show updated counts 6. Handle errors gracefully 7. Always clear loading state in finally block

"},{"location":"v2/frontend/pages/admin/listmonk-page/#sync-all-flow","title":"Sync All Flow","text":"
const handleSyncAll = async () => {\n  setSyncing(s => ({ ...s, all: true }));\n  try {\n    const res = await api.post<ListmonkSyncAllResult>('/listmonk/sync/all');\n\n    if (res.data.success) {\n      message.success(res.data.message);\n\n      // Show warning if any failed\n      if (res.data.results) {\n        const { participants, locations, users } = res.data.results;\n        const totalFailed = participants.failed + locations.failed + users.failed;\n        if (totalFailed > 0) {\n          message.warning(`${totalFailed} total failures \u2014 check logs for details`);\n        }\n      }\n    } else {\n      message.error(res.data.message);\n    }\n\n    await Promise.all([fetchStatus(), fetchStats()]);\n  } catch {\n    message.error('Failed to sync all');\n  } finally {\n    setSyncing(s => ({ ...s, all: false }));\n  }\n};\n

Aggregate Failure Count:

Sums failed count from all three lists (participants + locations + users) to show total failures.

"},{"location":"v2/frontend/pages/admin/listmonk-page/#lazy-iframe-loading_1","title":"Lazy Iframe Loading","text":"
const iframeInitialized = useRef(false);\n\nconst loadIframe = useCallback(async () => {\n  if (iframeInitialized.current && iframeSrc) return;  // Already loaded\n\n  setIframeLoading(true);\n  setIframeError(null);\n  try {\n    const res = await api.get<{ port: number; token: string }>('/listmonk/proxy-url');\n    const { port, token } = res.data;\n    const url = `//${window.location.hostname}:${port}/auth?token=${encodeURIComponent(token)}`;\n    setIframeSrc(url);\n    iframeInitialized.current = true;\n  } catch {\n    setIframeError('Failed to load Listmonk admin \u2014 ensure the proxy is running');\n  } finally {\n    setIframeLoading(false);\n  }\n}, [iframeSrc]);\n

Lazy Loading Logic:

  • First call: iframeInitialized.current is false, so iframe loads
  • Subsequent calls: iframeInitialized.current is true, so function returns early (no redundant loads)
  • Ref persists: useRef value persists across re-renders (unlike state)
"},{"location":"v2/frontend/pages/admin/listmonk-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#lazy-iframe-loading_2","title":"Lazy Iframe Loading","text":"

Iframe only loads when Admin tab is selected:

if (tab === 'admin') loadIframe();\n

Performance Impact: - Without lazy loading: Iframe loads on page mount (even if user never switches to Admin tab) - With lazy loading: Iframe only loads if needed - Result: Faster initial page load, reduced memory usage

"},{"location":"v2/frontend/pages/admin/listmonk-page/#parallel-status-and-stats-fetching","title":"Parallel Status and Stats Fetching","text":"

Status and stats fetched in parallel:

const fetchAll = useCallback(async () => {\n  setLoading(true);\n  await Promise.all([fetchStatus(), fetchStats()]);\n  setLoading(false);\n}, [fetchStatus, fetchStats]);\n

Performance Impact: - Sequential: 200ms (status) + 200ms (stats) = 400ms total - Parallel: max(200ms, 200ms) = 200ms total - Result: 2\u00d7 faster initial page load

"},{"location":"v2/frontend/pages/admin/listmonk-page/#conditional-iframe-rendering","title":"Conditional Iframe Rendering","text":"

Iframe not rendered until tab is selected:

{activeTab === 'admin' && (\n  <iframe src={iframeSrc} />\n)}\n

Performance Impact: - Always rendered: Iframe exists in DOM even when hidden (consumes memory) - Conditional: Iframe only exists in DOM when visible (no memory overhead)

"},{"location":"v2/frontend/pages/admin/listmonk-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#mobile-sync-actions-layout","title":"Mobile Sync Actions Layout","text":"

Sync action buttons adapt to mobile viewports:

<Row gutter={[8, 8]}>\n  <Col xs={24} sm={12}>  {/* Full width mobile, half width desktop */}\n    <Button block>Sync Participants</Button>\n  </Col>\n  <Col xs={24} sm={12}>\n    <Button block>Sync Locations</Button>\n  </Col>\n  <Col xs={24} sm={12}>\n    <Button block>Sync Users</Button>\n  </Col>\n  <Col xs={24} sm={12}>\n    <Button block>Sync All</Button>\n  </Col>\n</Row>\n

Responsive Grid: - Mobile (xs, <576px): Stacked buttons (full width) - Desktop (sm+, \u2265576px): 2\u00d72 grid (half width each)

"},{"location":"v2/frontend/pages/admin/listmonk-page/#iframe-height","title":"Iframe Height","text":"

Iframe fills available viewport height:

<iframe\n  src={iframeSrc}\n  style={{\n    height: 'calc(100vh - 64px)',  // Full viewport height minus header\n  }}\n/>\n

Calculation: - 100vh: Full viewport height - -64px: Subtract header height (64px) - Result: Iframe fills entire content area below header

"},{"location":"v2/frontend/pages/admin/listmonk-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

All interactive elements are keyboard-accessible:

Tab Switcher: - Tab: Focus on tab switcher - Arrow Keys: Navigate between Management and Admin tabs - Enter/Space: Activate selected tab

Buttons: - Tab: Move between buttons (Test Connection \u2192 Sync Participants \u2192 Sync Locations...) - Enter/Space: Activate focused button

Iframe: - Tab: Focus moves into iframe (Listmonk UI is keyboard-accessible) - Shift+Tab: Focus moves out of iframe back to page controls

"},{"location":"v2/frontend/pages/admin/listmonk-page/#screen-reader-support","title":"Screen Reader Support","text":"

All elements have proper ARIA labels:

Status Badges:

<Badge\n  status={status?.connected ? 'success' : 'warning'}\n  text={status?.connected ? 'Connected' : 'Disconnected'}\n  aria-label={`Listmonk connection status: ${status?.connected ? 'connected' : 'disconnected'}`}\n/>\n

Sync Buttons:

<Button\n  icon={<SyncOutlined />}\n  onClick={() => handleSync('participants')}\n  aria-label=\"Sync participants to Listmonk Participants list\"\n>\n  Sync Participants\n</Button>\n

"},{"location":"v2/frontend/pages/admin/listmonk-page/#color-contrast","title":"Color Contrast","text":"

All color-coded elements meet WCAG AA standards:

Status Badges: - Success (green dot): #52c41a = visible on all backgrounds - Warning (orange dot): #faad14 = visible on all backgrounds - Error (red dot): #ff4d4f = visible on all backgrounds - Default (gray dot): #d9d9d9 = visible on all backgrounds

"},{"location":"v2/frontend/pages/admin/listmonk-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#sync-disabled-buttons-grayed-out","title":"Sync Disabled (Buttons Grayed Out)","text":"

Problem: All sync buttons are grayed out (disabled state).

Diagnosis:

Check .env file:

grep LISTMONK_SYNC_ENABLED .env\n

Expected: LISTMONK_SYNC_ENABLED=true

Actual: LISTMONK_SYNC_ENABLED=false or missing

Solution:

  1. Edit .env file:

    nano .env\n

  2. Add or update line:

    LISTMONK_SYNC_ENABLED=true\n

  3. Restart API container:

    docker compose restart api\n

  4. Refresh page to see enabled buttons

"},{"location":"v2/frontend/pages/admin/listmonk-page/#connection-test-fails","title":"Connection Test Fails","text":"

Problem: Click \"Test Connection\", get error: \"Connection failed - check Listmonk URL and credentials\".

Diagnosis:

Check Listmonk container:

docker compose ps listmonk\n

Expected: STATUS = Up

Check Listmonk logs:

docker compose logs listmonk\n

Common errors:

ERROR: Database connection failed\nERROR: Authentication failed for user \"api\"\n

Possible Causes:

  1. Listmonk container down:
  2. Service not running
  3. Failed to start due to configuration error

  4. Wrong credentials:

  5. LISTMONK_ADMIN_USER or LISTMONK_ADMIN_PASSWORD incorrect
  6. API user not created in Listmonk database

  7. Network issue:

  8. API container cannot reach Listmonk container
  9. Docker network misconfigured

Solution:

  1. Start Listmonk:

    docker compose up -d listmonk\n

  2. Verify credentials:

    grep LISTMONK_ .env\n

Check that LISTMONK_ADMIN_USER and LISTMONK_ADMIN_PASSWORD match Listmonk configuration.

  1. Test connection manually:
    curl -u admin:password http://localhost:9001/api/health\n

Expected: {\"version\":\"2.3.0\"}

"},{"location":"v2/frontend/pages/admin/listmonk-page/#lists-not-initialized","title":"Lists Not Initialized","text":"

Problem: Status shows \"Lists Initialized: No\".

Diagnosis:

Check Listmonk lists:

docker compose exec listmonk listmonk --dump-all-lists\n

Expected: Participants, Locations, Users lists present

Actual: No lists found

Solution:

  1. Click \"Advanced\" to expand advanced section
  2. Click \"Reinitialize Lists\" button
  3. Confirm reinitialize
  4. Wait for success message: \"Lists reinitialized\"
  5. Refresh page to see \"Lists Initialized: Yes\"
"},{"location":"v2/frontend/pages/admin/listmonk-page/#iframe-not-loading","title":"Iframe Not Loading","text":"

Problem: Click \"Listmonk Admin\" tab, but only see loading spinner or error message.

Diagnosis:

Check iframe error message in Alert:

Failed to load Listmonk admin \u2014 ensure the proxy is running\n

Check browser console for errors:

Refused to display 'http://localhost:9001' in a frame because it set 'X-Frame-Options' to 'SAMEORIGIN'\n

Possible Causes:

  1. Proxy URL endpoint failed:
  2. API cannot generate auto-auth token
  3. Redis down (tokens stored in Redis)

  4. X-Frame-Options blocking:

  5. Listmonk sets X-Frame-Options: SAMEORIGIN
  6. Browser blocks iframe from different origin

  7. CORS issue:

  8. Listmonk does not allow iframe embedding from admin domain

Solution:

  1. Check Redis:
    docker compose ps redis\ndocker compose exec redis redis-cli PING\n

Expected: \"PONG\"

  1. Use \"Open Listmonk\" button instead:
  2. Opens Listmonk in new tab (no iframe, no X-Frame-Options issue)
  3. Manual login required (no auto-auth token)

  4. Configure Listmonk to allow iframes (developer fix):

  5. Edit Listmonk nginx config
  6. Remove or modify X-Frame-Options header
  7. Restart Listmonk container
"},{"location":"v2/frontend/pages/admin/listmonk-page/#related-documentation","title":"Related Documentation","text":"
  • Listmonk Backend Module \u2014 Backend Listmonk service
  • Listmonk Sync Service \u2014 Sync orchestration
  • Listmonk Client \u2014 API client
  • Listmonk API Reference \u2014 Listmonk endpoints
  • Newsletter Feature \u2014 Newsletter system overview
  • ResponsesPage \u2014 Campaign responses (synced to Listmonk)
  • LocationsPage \u2014 Map locations (synced to Listmonk)
  • UsersPage \u2014 User accounts (synced to Listmonk)
  • Listmonk Documentation \u2014 Official Listmonk docs
  • Troubleshooting: Listmonk Issues \u2014 Listmonk troubleshooting
"},{"location":"v2/frontend/pages/admin/locations-page/","title":"LocationsPage","text":""},{"location":"v2/frontend/pages/admin/locations-page/#overview","title":"Overview","text":"

The LocationsPage is the centerpiece of the Map module, providing comprehensive location database management with dual-tab view (table + interactive map), multi-format CSV import (standard, NAR upload, NAR server), multi-provider geocoding, bulk operations, location history tracking, and expandable address units for multi-unit buildings. This is the most feature-rich CRUD page in the admin interface, handling millions of location records for canvassing operations.

Route: /app/map/locations Component: admin/src/pages/LocationsPage.tsx (1960 lines) Auth Required: Yes (SUPER_ADMIN, MAP_ADMIN roles) Layout: AppLayout

"},{"location":"v2/frontend/pages/admin/locations-page/#screenshot","title":"Screenshot","text":"

[Screenshot: Locations page with two rows of statistics cards at top (Total, Single Family, Multi-Unit, Mixed Use, Commercial, Geocoded percentage in first row; High/Medium/Low/Manual confidence levels + Avg Confidence in second row). Below stats are two tabs: \"Table\" (active) and \"Map\". Table tab shows search bar + confidence filter dropdown + \"Delete Selected\" button (when rows selected). Main table has columns: Address, Building Type (tags), Total Units, Coordinates, Geocode (confidence tags with provider), Created, Actions (edit + delete). Page header has 6 action buttons: Settings, Export CSV, Import CSV, Geocode Missing, Bulk Re-Geocode, Add Location.]

"},{"location":"v2/frontend/pages/admin/locations-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/locations-page/#core-features","title":"Core Features","text":"
  • Dual-tab interface \u2014 Table view (CRUD operations) + Map view (visual management)
  • Full CRUD operations \u2014 Create, read, update, delete locations
  • Advanced search \u2014 300ms debounced search by address or postal code
  • Confidence filtering \u2014 Filter by High (\u226585%), Medium (60-84%), Low (<60%), Manual/None
  • Building type tracking \u2014 SINGLE_FAMILY, MULTI_UNIT, MIXED_USE, COMMERCIAL
  • Multi-unit support \u2014 Expandable rows show Address units (apartments) with contact info
  • Location history \u2014 Track all changes (created, updated, moved, geocoded) with user attribution
  • Bulk operations \u2014 Select multiple rows, bulk delete with confirmation
"},{"location":"v2/frontend/pages/admin/locations-page/#geocoding-features","title":"Geocoding Features","text":"
  • Multi-provider geocoding \u2014 6 providers (Google, Nominatim, ArcGIS, Photon, Mapbox, Pelias)
  • Inline geocoding \u2014 \"Geocode\" button in create/edit forms
  • Geocode Missing \u2014 Batch geocode all locations without coordinates
  • Bulk Re-Geocode \u2014 Background job to improve low-confidence locations
  • Confidence scoring \u2014 0-100% confidence with provider attribution
  • Reverse geocoding \u2014 Click map \u2192 auto-fill address from coordinates
"},{"location":"v2/frontend/pages/admin/locations-page/#import-features","title":"Import Features","text":"
  • Standard CSV import \u2014 Simple address/contact CSV with flexible column mapping
  • NAR Upload import \u2014 Statistics Canada NAR format (2025 + legacy) with client-side upload (max 100MB)
  • NAR Server import \u2014 Server-side streaming import for multi-GB NAR datasets
  • Geographic filters \u2014 Import by cut boundary, city name, postal prefix, province, or map area
  • Residential filtering \u2014 Skip non-residential addresses (commercial, industrial)
  • Deduplication \u2014 5m radius deduplication to prevent duplicate imports
  • Real-time progress \u2014 Live progress bars + stats during NAR server imports
"},{"location":"v2/frontend/pages/admin/locations-page/#map-features-map-tab","title":"Map Features (Map Tab)","text":"
  • Interactive Leaflet map \u2014 Click-to-add, drag-to-move, GPS locate, fullscreen
  • Color-coded markers \u2014 Visual building type distinction
  • Cut overlays \u2014 Polygon boundaries with toggle controls
  • Auto-refresh \u2014 Viewport-based loading (800ms debounce)
  • Bounds filtering \u2014 Only load locations in current view (max 5000 per request)
  • Click-to-add mode \u2014 Click map \u2192 reverse geocode \u2192 create form
  • Drag-to-move mode \u2014 Drag marker \u2192 update coordinates
"},{"location":"v2/frontend/pages/admin/locations-page/#statistics-dashboard","title":"Statistics Dashboard","text":"
  • Building type breakdown \u2014 5 cards (Total, Single Family, Multi-Unit, Mixed Use, Commercial)
  • Geocode coverage \u2014 Percentage geocoded + average confidence
  • Confidence distribution \u2014 4 cards (High, Medium, Low, Manual/None) with counts + icons
"},{"location":"v2/frontend/pages/admin/locations-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/locations-page/#viewing-locations-table-tab","title":"Viewing Locations (Table Tab)","text":"
  1. Navigate to /app/map/locations
  2. Page loads with statistics cards at top:
  3. First row: Total count, building type breakdowns, geocode percentage
  4. Second row: Confidence distribution (high/medium/low/none), average confidence
  5. Table tab active by default (20 locations per page)
  6. View location details:
  7. Address (bold)
  8. Building Type tag (color-coded)
  9. Total Units (1 for single-family, 2+ for multi-unit)
  10. Coordinates (lat, lng to 5 decimals)
  11. Geocode confidence (tag + provider name)
  12. Created date
  13. Actions (edit, delete)
  14. Expand row (click anywhere) if Total Units > 1:
  15. Shows Address units table (apartments)
  16. Columns: Unit, Name, Contact, Building Type, Notes
  17. Use pagination at bottom (10/20/50/100 per page)
"},{"location":"v2/frontend/pages/admin/locations-page/#searching-and-filtering","title":"Searching and Filtering","text":"
  1. Search bar (top left):
  2. Type address or postal code
  3. 300ms debounce (waits for typing pause)
  4. Search resets pagination to page 1
  5. Confidence filter (top right):
  6. Select High, Medium, Low, or Manual/None
  7. Filter resets pagination to page 1
  8. Clear to show all locations
  9. Filters persist during pagination
"},{"location":"v2/frontend/pages/admin/locations-page/#creating-a-location-manually","title":"Creating a Location Manually","text":"
  1. Click \"Add Location\" button in page header
  2. Modal opens (600px width) with vertical form
  3. Fill required fields:
  4. Street Address (base building address, no unit number)
    • Example: \"123 Main St, City, Province\"
    • Click \"Geocode\" button (in input addonAfter) to auto-fill coordinates
  5. Latitude (decimal degrees, 5 decimals, e.g. 45.42153)
  6. Longitude (decimal degrees, 5 decimals, e.g. -75.69719)
  7. Select Building Type (radio buttons, default: SINGLE_FAMILY):
  8. Single Family
  9. Multi-Unit
  10. Mixed Use
  11. Commercial
  12. Add Building Notes (optional):
  13. Access codes, manager contact, buzzer instructions
  14. Example: \"Access code: 1234, Ring buzzer for manager\"
  15. Click \"Create\" button
  16. Success message: \"Location created\"
  17. Modal closes, table refreshes to page 1, stats refresh
  18. If Map tab open, new marker appears
"},{"location":"v2/frontend/pages/admin/locations-page/#using-inline-geocoding","title":"Using Inline Geocoding","text":"
  1. In create/edit form, type address in Street Address field
  2. Click \"Geocode\" button (AimOutlined icon in input addonAfter)
  3. Button shows loading spinner
  4. API calls multi-provider geocoding service
  5. On success:
  6. Latitude and Longitude fields auto-fill
  7. Success message: \"Geocoded (Google, 95% confidence)\"
  8. Provider name + confidence shown in message
  9. On failure:
  10. Error message: \"Could not geocode address\"
  11. Manually enter coordinates or try different address
"},{"location":"v2/frontend/pages/admin/locations-page/#geocoding-missing-locations","title":"Geocoding Missing Locations","text":"
  1. Click \"Geocode Missing\" button in page header
  2. Button shows loading state
  3. API geocodes all locations without coordinates (latitude = null OR longitude = null)
  4. Success message: \"Geocoded 847 of 1250 locations (403 failed)\"
  5. Table refreshes, stats update
  6. Failed locations remain without coordinates (low-quality addresses)
"},{"location":"v2/frontend/pages/admin/locations-page/#bulk-re-geocoding-background-job","title":"Bulk Re-Geocoding (Background Job)","text":"
  1. Click \"Bulk Re-Geocode\" button in page header
  2. Modal opens (600px width) with form:
  3. Confidence Threshold (%): Only geocode below this (default: 60)
  4. Building Type Filter: Optionally filter by type
  5. Maximum Locations: Process up to N locations (default: 1000, max: 5000)
  6. Click \"Start Bulk Re-Geocode\" button
  7. Background job starts (BullMQ queue)
  8. Modal shows live progress:
  9. Progress bar (percentage complete)
  10. Current address being processed
  11. Stats: Processed X / Total, Improved, Unchanged, Failed
  12. Job completes:
  13. Final stats shown
  14. Success message: \"Bulk geocoding complete: 234 improved, 512 unchanged, 14 failed\"
  15. Click \"Close\" button
  16. Table refreshes, stats update
  17. Only locations with IMPROVED results are updated (unchanged locations left alone)
"},{"location":"v2/frontend/pages/admin/locations-page/#importing-standard-csv","title":"Importing Standard CSV","text":"
  1. Click \"Import CSV\" button in page header
  2. Modal opens (620px width) with 3 radio buttons:
  3. Standard CSV (selected by default)
  4. NAR Upload
  5. NAR Server
  6. Read format instructions:
  7. Columns: address, first name, last name, email, phone, unit number, support level (1-4), sign (yes/no), sign size, notes, latitude, longitude
  8. Column names matched flexibly (case-insensitive, ignores punctuation)
  9. Drag CSV file or click to upload
  10. File uploads, backend processes:
  11. Parses CSV rows
  12. Creates Location records (with addresses)
  13. Creates Address records (units) if unit number present
  14. Geocodes if lat/lng missing (optional)
  15. Success message: \"Imported 450 of 500 locations (30 warnings, 20 failed)\"
  16. If errors, warning modal shows error list (max 300px height, scrollable)
  17. Modal closes, table refreshes, stats update
"},{"location":"v2/frontend/pages/admin/locations-page/#importing-nar-upload-client-side","title":"Importing NAR Upload (Client-Side)","text":"
  1. Click \"Import CSV\" button
  2. Switch to \"NAR Upload\" radio button
  3. Read format instructions:
  4. Statistics Canada NAR Address CSV
  5. Supports 2025 format (CIVIC_NO, OFFICIAL_STREET_NAME, BG_X/BG_Y) and legacy format (STR_NBR, STR_NME, LAT/LNG)
  6. Auto-detects format
  7. Configure Geographic Filter dropdown:
  8. No filter \u2014 import all rows
  9. Map settings area \u2014 use configured center + zoom from Map Settings
  10. City name \u2014 enter city (e.g. \"Ottawa\", \"Edmonton\")
  11. Province / Territory \u2014 select from dropdown (13 provinces/territories)
  12. Cut boundary \u2014 select from cuts dropdown (only locations inside polygon)
  13. Toggle \"Residential only\" switch:
  14. ON (default): Skip commercial/industrial addresses
  15. OFF: Import all addresses
  16. Drag CSV file or click to upload (max 100MB)
  17. File uploads, backend processes:
  18. Parses NAR format (2025 or legacy)
  19. Joins Address + Location files if NAR 2025 format
  20. Converts BG_X/BG_Y (EPSG:3347 Lambert projection) to lat/lng using proj4
  21. Applies geographic filters (cut, city, province, map area)
  22. Deduplicates within 5m radius
  23. Batches 1000 rows at a time
  24. Progress indicator during import
  25. Results shown in modal:
  26. 6 statistics cards (Total Rows, Created, Duplicates, Out of Bounds, Invalid, Errors)
  27. Error list (if any)
  28. Success message: \"Created X of Y locations\"
  29. Table refreshes, stats update
"},{"location":"v2/frontend/pages/admin/locations-page/#importing-nar-server-server-side-streaming","title":"Importing NAR Server (Server-Side Streaming)","text":"
  1. Click \"Import CSV\" button
  2. Switch to \"NAR Server\" radio button
  3. Click \"Scan Server Directory\" button (first time only)
  4. Backend scans NAR_DATA_DIR (./data volume mount) for:
  5. Addresses/ directory with Address_{provinceCode}part.csv files
  6. Locations/ directory with Location_{provinceCode}.csv files
  7. Modal shows available provinces:
  8. Example: \"ON \u2014 Ontario (6 files, 2.3 GB)\"
  9. File count includes multi-part Address files
  10. Select province from dropdown
  11. Configure Geographic Filter:
  12. No filter \u2014 import all addresses
  13. City name \u2014 enter city
  14. Postal code prefix (FSA) \u2014 3 chars (e.g. K1A, E3B)
  15. Cut boundary \u2014 select from cuts dropdown
  16. Toggle \"Residential only\" switch (default: ON)
  17. Click \"Import {Province} Addresses\" button
  18. Backend starts streaming import:
    • Scans all Address files for province (multi-part files joined)
    • Loads Location file (lat/lng coordinates)
    • Joins Address + Location on LOC_GUID
    • Converts BG_X/BG_Y to lat/lng using proj4
    • Applies filters (city, postal, cut, residential)
    • Deduplicates within 5m radius
    • Batches 1000 rows at a time
    • Streams to DB (never loads full dataset in memory)
  19. Live progress display (polls every 2 seconds):
    • Progress bar (animated, 99.9% until complete)
    • Status text: \"Processing Address_24_part_3...\"
    • 3 statistics cards: Rows Processed, Locations Created, Skipped
  20. Import completes:
    • Final stats shown (Total Rows, Created, Duplicates, Out of Bounds, Non-Residential, Invalid)
    • Duration shown in seconds
    • Success message: \"Imported 12,847 locations from Ontario in 43.2s\"
  21. Table refreshes, stats update

NAR Server vs Upload: - NAR Server: For multi-GB datasets (1M+ addresses), streams from server disk, no file upload, no size limit - NAR Upload: For smaller datasets (<100MB), client uploads file, faster for small imports

"},{"location":"v2/frontend/pages/admin/locations-page/#viewing-map-map-tab","title":"Viewing Map (Map Tab)","text":"
  1. Click \"Map\" tab (EnvironmentOutlined icon)
  2. Map loads with AdminMapView component
  3. Initial load fetches all locations (no bounds filter)
  4. Locations render as colored circle markers:
  5. Blue: Single Family
  6. Green: Multi-Unit
  7. Orange: Mixed Use
  8. Purple: Commercial
  9. Cut polygons overlay map (if any cuts exist)
  10. Floating controls on map:
  11. Add \u2014 Enter click-to-add mode
  12. Move \u2014 Enter drag-to-move mode
  13. GPS \u2014 Geolocate to current position
  14. Fullscreen \u2014 Toggle fullscreen mode
  15. Refresh \u2014 Reload locations in current view
  16. Cut toggles \u2014 Show/hide cut overlays
  17. Pan/zoom map \u2192 auto-refreshes after 800ms debounce
  18. Click marker \u2192 location detail popup (address, building type, edit button)
"},{"location":"v2/frontend/pages/admin/locations-page/#adding-location-from-map","title":"Adding Location from Map","text":"
  1. In Map tab, click \"Add\" control button
  2. Click-to-add mode activated (cursor changes)
  3. Click anywhere on map
  4. Backend reverse geocodes coordinates (Nominatim)
  5. Create modal opens with pre-filled values:
  6. Latitude (rounded to 5 decimals)
  7. Longitude (rounded to 5 decimals)
  8. Address (reverse geocoded, e.g. \"123 Main St, City\")
  9. Adjust values if needed
  10. Select building type
  11. Click \"Create\"
  12. New marker appears on map
  13. Table updates if viewing Table tab
"},{"location":"v2/frontend/pages/admin/locations-page/#moving-location-on-map","title":"Moving Location on Map","text":"
  1. In Map tab, click \"Move\" control button
  2. Drag-to-move mode activated
  3. Click and drag any marker to new position
  4. On release, coordinates update:
  5. PUT /api/map/locations/:id with new lat/lng
  6. Marker snaps to new position
  7. Success message: \"Location moved\"
  8. Table updates if viewing Table tab
"},{"location":"v2/frontend/pages/admin/locations-page/#editing-a-location","title":"Editing a Location","text":"
  1. From Table tab:
  2. Click Edit icon button (EditOutlined) in Actions column
  3. From Map tab:
  4. Click marker \u2192 popup \u2192 click Edit button
  5. Drawer opens on right side (700px width) with 2 tabs:
  6. Details tab (active by default)
  7. History tab (ClockCircleOutlined icon)
  8. Details tab shows edit form:
  9. Same fields as create form
  10. Pre-filled with current values
  11. Geocode button available
  12. Modify any fields
  13. Click \"Save\" button in drawer header
  14. Success message: \"Location updated\"
  15. Drawer closes, table refreshes, stats update
  16. Map refreshes if viewing Map tab
"},{"location":"v2/frontend/pages/admin/locations-page/#viewing-location-history","title":"Viewing Location History","text":"
  1. Open location in edit drawer
  2. Click \"History\" tab (ClockCircleOutlined icon)
  3. Table loads with location history:
  4. Columns: Action, Field, Change, User, When
  5. Action tags (color-coded):
    • CREATED (green)
    • UPDATED (blue)
    • GEOCODED (cyan)
    • MOVED (orange)
  6. Field shows which field changed (e.g. address, latitude)
  7. Change shows old \u2192 new values (strikethrough old, bold new)
  8. User shows email or \"System\"
  9. When shows timestamp (MMM D, YYYY h:mm A)
  10. Pagination at bottom (20 per page)
  11. History sorted newest first (most recent at top)
"},{"location":"v2/frontend/pages/admin/locations-page/#bulk-deleting-locations","title":"Bulk Deleting Locations","text":"
  1. In Table tab, select checkbox for multiple rows
  2. \"Delete Selected (N)\" button appears above table
  3. Click button
  4. Popconfirm: \"Delete N locations?\"
  5. Click \"OK\"
  6. Success message: \"Deleted N locations\"
  7. Selection cleared, table refreshes, stats update
"},{"location":"v2/frontend/pages/admin/locations-page/#exporting-csv","title":"Exporting CSV","text":"
  1. Click \"Export CSV\" button in page header
  2. Browser downloads CSV file: locations-YYYY-MM-DD.csv
  3. File contains all locations (not just current page):
  4. Columns: id, address, latitude, longitude, buildingType, buildingNotes, postalCode, province, federalDistrict, buildingUse, geocodeProvider, geocodeConfidence, totalUnits, createdAt, updatedAt
  5. Open in Excel, Google Sheets, or text editor
  6. Use for backups, analysis, or importing to other systems
"},{"location":"v2/frontend/pages/admin/locations-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/locations-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  • Table \u2014 Main locations list with columns, pagination, row selection, expandable rows
  • Tabs \u2014 Dual-tab interface (Table, Map)
  • Input \u2014 Search bar with SearchOutlined prefix
  • Select \u2014 Confidence filter dropdown, province/cut filters in import modal
  • Button \u2014 6 header actions (Settings, Export, Import, Geocode Missing, Bulk Re-Geocode, Add Location) + table actions (Edit, Delete)
  • Card \u2014 Statistics cards (9 cards in 2 rows)
  • Statistic \u2014 Numeric displays with icons, prefixes, suffixes
  • Progress \u2014 Bulk geocode + NAR import progress bars
  • Modal \u2014 Create location, import CSV (3 modes), bulk re-geocode
  • Drawer \u2014 Edit location (700px width, 2 tabs)
  • Form \u2014 Create/edit location forms, bulk geocode config
  • Form.Item \u2014 Field wrappers with labels, rules, help text
  • InputNumber \u2014 Numeric fields (latitude, longitude, max locations)
  • Radio.Group \u2014 Import format selector (3 buttons), building type selector (4 buttons)
  • Switch \u2014 Residential-only toggle in import modals
  • Upload.Dragger \u2014 CSV file upload UI
  • Row, Col \u2014 Responsive grid for stats cards, form fields
  • Tag \u2014 Building type tags, confidence tags, geocode provider, history action tags
  • Space \u2014 Action button grouping
  • Popconfirm \u2014 Delete confirmation (single + bulk)
  • DatePicker \u2014 (Not used, but imported for future features)
  • TimePicker \u2014 (Not used, but imported for future features)
  • Typography.Text \u2014 Labels, descriptions, secondary text
"},{"location":"v2/frontend/pages/admin/locations-page/#table-columns","title":"Table Columns","text":"
const columns: ColumnsType<Location> = [\n  {\n    title: 'Address',\n    dataIndex: 'address',\n    render: (addr) => <span style={{ fontWeight: 500 }}>{addr || '--'}</span>,\n  },\n  {\n    title: 'Building Type',\n    dataIndex: 'buildingType',\n    render: (type: BuildingType) => (\n      <Tag color={BUILDING_TYPE_COLORS[type]}>\n        {BUILDING_TYPE_LABELS[type]}\n      </Tag>\n    ),\n    responsive: ['md'],\n  },\n  {\n    title: 'Total Units',\n    dataIndex: 'totalUnits',\n    align: 'center',\n    width: 120,\n    responsive: ['md'],\n  },\n  {\n    title: 'Coordinates',\n    render: (_, record) =>\n      record.latitude && record.longitude\n        ? `${Number(record.latitude).toFixed(5)}, ${Number(record.longitude).toFixed(5)}`\n        : '--',\n    responsive: ['lg'],\n  },\n  {\n    title: 'Geocode',\n    render: (_, record) => {\n      if (record.geocodeConfidence != null && record.geocodeConfidence > 0) {\n        const confidence = record.geocodeConfidence;\n        let color, icon, label;\n        if (confidence >= 85) {\n          color = 'success';\n          icon = <CheckCircleOutlined />;\n          label = `High (${confidence}%)`;\n        } else if (confidence >= 60) {\n          color = 'warning';\n          icon = <InfoCircleOutlined />;\n          label = `Medium (${confidence}%)`;\n        } else {\n          color = 'error';\n          icon = <WarningOutlined />;\n          label = `Low (${confidence}%)`;\n        }\n        return (\n          <Space direction=\"vertical\" size={0}>\n            <Tag color={color} icon={icon}>{label}</Tag>\n            {record.geocodeProvider && (\n              <Text type=\"secondary\" style={{ fontSize: 11 }}>\n                {record.geocodeProvider.toLowerCase()}\n              </Text>\n            )}\n          </Space>\n        );\n      }\n      if (record.latitude && record.longitude) {\n        return <Tag color=\"blue\">Manual</Tag>;\n      }\n      return <Tag>None</Tag>;\n    },\n    responsive: ['lg'],\n  },\n  {\n    title: 'Created',\n    dataIndex: 'createdAt',\n    render: (date) => dayjs(date).format('YYYY-MM-DD'),\n    responsive: ['xl'],\n  },\n  {\n    title: 'Actions',\n    width: 120,\n    render: (_, record) => (\n      <Space>\n        <Button type=\"link\" size=\"small\" icon={<EditOutlined />} onClick={() => openEdit(record)} />\n        <Popconfirm title=\"Delete this location?\" onConfirm={() => handleDelete(record.id)}>\n          <Button type=\"link\" size=\"small\" danger icon={<DeleteOutlined />} />\n        </Popconfirm>\n      </Space>\n    ),\n  },\n];\n

Key patterns: - responsive array controls column visibility on different screen sizes - render functions for custom content (tags, icons, formatted values) - align: 'center' for numeric columns - width prop for fixed-width columns

"},{"location":"v2/frontend/pages/admin/locations-page/#expandable-rows-address-units","title":"Expandable Rows (Address Units)","text":"
const expandedRowRender = (location: Location) => {\n  if (!location.addresses || location.addresses.length === 0) {\n    return (\n      <div style={{ padding: '12px 24px', textAlign: 'center' }}>\n        <Text type=\"secondary\">No units/addresses defined for this location yet.</Text>\n        <div style={{ marginTop: 8, fontSize: 12 }}>\n          <Text type=\"secondary\">Units can be added during canvassing or imported via NAR data.</Text>\n        </div>\n      </div>\n    );\n  }\n\n  const addressColumns: ColumnsType<Address> = [\n    { title: 'Unit', dataIndex: 'unitNumber', width: 100, render: (unit) => unit || '--' },\n    { title: 'Name', render: (_, addr) => [addr.firstName, addr.lastName].filter(Boolean).join(' ') || '--' },\n    { title: 'Contact', render: (_, addr) => [addr.email, addr.phone].filter(Boolean).join(' \u2022 ') || '--', responsive: ['md'] },\n    { title: 'Building Type', dataIndex: 'buildingType', render: (type) => <Tag color={colors[type]}>{labels[type]}</Tag> },\n    { title: 'Notes', dataIndex: 'notes', ellipsis: true, render: (notes) => notes || '--', responsive: ['lg'] },\n  ];\n\n  return (\n    <div style={{ padding: '0 24px 12px' }}>\n      <Table<Address>\n        columns={addressColumns}\n        dataSource={location.addresses}\n        rowKey=\"id\"\n        pagination={false}\n        size=\"small\"\n        bordered\n      />\n    </div>\n  );\n};\n\n// In main table:\n<Table\n  expandable={{\n    expandedRowRender,\n    rowExpandable: (record) => (record.totalUnits > 1 || (record.addresses && record.addresses.length > 0)),\n  }}\n/>\n

Pattern: Nested table shows Address units (apartments) for multi-unit buildings. Only expandable if totalUnits > 1 or addresses array exists.

"},{"location":"v2/frontend/pages/admin/locations-page/#statistics-cards","title":"Statistics Cards","text":"
{stats && (\n  <Row gutter={[12, 12]} style={{ marginBottom: 16 }}>\n    <Col xs={12} sm={8} md={4}>\n      <Card size=\"small\">\n        <Statistic title=\"Total\" value={stats.total} />\n      </Card>\n    </Col>\n    <Col xs={12} sm={8} md={4}>\n      <Card size=\"small\">\n        <Statistic title=\"Single Family\" value={stats.buildingTypes.SINGLE_FAMILY} valueStyle={{ color: '#1890ff' }} />\n      </Card>\n    </Col>\n    {/* 4 more building type cards */}\n  </Row>\n)}\n\n{stats && stats.confidence && (\n  <Row gutter={[12, 12]} style={{ marginBottom: 16 }}>\n    <Col xs={12} sm={6} md={4}>\n      <Card size=\"small\">\n        <Statistic\n          title=\"High Confidence\"\n          value={stats.confidence.high}\n          prefix={<CheckCircleOutlined />}\n          valueStyle={{ color: '#52c41a' }}\n          suffix={<Text type=\"secondary\" style={{ fontSize: 12 }}>\u226585%</Text>}\n        />\n      </Card>\n    </Col>\n    {/* 3 more confidence cards */}\n  </Row>\n)}\n

Layout: - First row: 6 cards (Total + 4 building types + Geocoded %) - Second row: 5 cards (High/Medium/Low/None confidence + Avg confidence) - Responsive: xs (2 columns), sm (3-4 columns), md+ (6 columns)

"},{"location":"v2/frontend/pages/admin/locations-page/#nar-format-detection","title":"NAR Format Detection","text":"

NAR import supports two formats: - 2025 format: CIVIC_NO, OFFICIAL_STREET_NAME, BG_X, BG_Y (Lambert projection EPSG:3347) - Legacy format: STR_NBR, STR_NME, LAT, LNG (decimal degrees)

Backend auto-detects format by checking for presence of CIVIC_NO column.

2025 format with proj4 conversion:

import proj4 from 'proj4';\n\n// Define Lambert Conformal Conic projection (Statistics Canada NAR)\nproj4.defs('EPSG:3347', '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=63.390675 +lon_0=-91.86666666666666 +x_0=6200000 +y_0=3000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs');\n\n// Convert BG_X, BG_Y (meters) to lat, lng (decimal degrees)\nconst [lng, lat] = proj4('EPSG:3347', 'EPSG:4326', [bgX, bgY]);\n

File join (2025 format only):

// Address file: CIVIC_NO, OFFICIAL_STREET_NAME, LOC_GUID (no coordinates)\n// Location file: LOC_GUID, BG_LATITUDE, BG_LONGITUDE (coordinates only)\n// Join on LOC_GUID to get address + coordinates\n
"},{"location":"v2/frontend/pages/admin/locations-page/#map-auto-refresh","title":"Map Auto-Refresh","text":"
const handleMapMove = useCallback((map: any) => {\n  // Skip if map is animating (prevents disrupting zoom transitions)\n  if (map._animatingZoom || map._moving) {\n    return;\n  }\n\n  const b = map.getBounds();\n  const newBounds = {\n    minLat: b.getSouth(),\n    maxLat: b.getNorth(),\n    minLng: b.getWest(),\n    maxLng: b.getEast(),\n  };\n\n  // Store current bounds for auto-refresh\n  currentBoundsRef.current = newBounds;\n\n  clearTimeout(fetchTimerRef.current);\n  fetchTimerRef.current = setTimeout(() => {\n    // Mark as background fetch to prevent loading state during viewport changes\n    fetchAllLocations(newBounds, true);\n  }, 800); // Increased debounce to 800ms to allow zoom animations to complete\n}, [fetchAllLocations]);\n

Why 800ms debounce? - Allows zoom/pan animations to complete - Prevents API spam during dragging - Only fetches when user pauses - Background fetch (no loading spinner) for smooth UX

Safety limit:

if (data.length === 5000) {\n  message.warning('Too many locations in view. Zoom in for more detail.', 3);\n}\n

Backend returns max 5000 locations per request to prevent memory issues.

"},{"location":"v2/frontend/pages/admin/locations-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/locations-page/#zustand-stores-used","title":"Zustand Stores Used","text":"

None \u2014 Locations fetched from API on each interaction. No global state required (unlike canvass or auth).

"},{"location":"v2/frontend/pages/admin/locations-page/#local-state","title":"Local State","text":"
const [locations, setLocations] = useState<Location[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });\nconst [loading, setLoading] = useState(false);\nconst [stats, setStats] = useState<LocationStats | null>(null);\nconst [search, setSearch] = useState('');\nconst [debouncedSearch, setDebouncedSearch] = useState('');\nconst searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\nconst [confidenceFilter, setConfidenceFilter] = useState<'high' | 'medium' | 'low' | 'none' | undefined>();\n\n// Modals/Drawers\nconst [createModalOpen, setCreateModalOpen] = useState(false);\nconst [editDrawerOpen, setEditDrawerOpen] = useState(false);\nconst [editingLocation, setEditingLocation] = useState<Location | null>(null);\nconst [locationHistory, setLocationHistory] = useState<LocationHistory[]>([]);\nconst [historyLoading, setHistoryLoading] = useState(false);\nconst [historyPagination, setHistoryPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });\nconst [importModalOpen, setImportModalOpen] = useState(false);\nconst [importing, setImporting] = useState(false);\nconst [geocodingMissing, setGeocodingMissing] = useState(false);\nconst [importFormat, setImportFormat] = useState<'standard' | 'nar' | 'server'>('standard');\nconst [bulkImportResult, setBulkImportResult] = useState<BulkImportResult | null>(null);\nconst [cuts, setCuts] = useState<Cut[]>([]);\n\n// NAR Server Import state\nconst [narDatasets, setNarDatasets] = useState<NarDataset[]>([]);\nconst [narDatasetsLoading, setNarDatasetsLoading] = useState(false);\nconst [narDir, setNarDir] = useState<string | null>(null);\nconst [narSelectedProvince, setNarSelectedProvince] = useState<string | undefined>();\nconst [narImportResult, setNarImportResult] = useState<NarServerImportResult | null>(null);\nconst [narProgress, setNarProgress] = useState<NarImportProgress | null>(null);\nconst narPollRef = useRef<ReturnType<typeof setInterval>>(undefined);\n\n// Bulk Re-Geocoding state\nconst [bulkGeocodeModalOpen, setBulkGeocodeModalOpen] = useState(false);\nconst [bulkGeocoding, setBulkGeocoding] = useState(false);\nconst [bulkGeocodeJobId, setBulkGeocodeJobId] = useState<string | null>(null);\nconst [bulkGeocodeStatus, setBulkGeocodeStatus] = useState<any>(null);\nconst bulkGeocodePollRef = useRef<ReturnType<typeof setInterval>>(undefined);\nconst [bulkGeocodeForm] = Form.useForm();\n\n// Tabs + map\nconst [activeTab, setActiveTab] = useState('table');\nconst [allLocations, setAllLocations] = useState<Location[]>([]);\nconst [mapLoading, setMapLoading] = useState(false);\nconst fetchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\nconst abortControllerRef = useRef<AbortController | null>(null);\nconst currentBoundsRef = useRef<{ minLat: number; maxLat: number; minLng: number; maxLng: number } | null>(null);\n\n// Selection for bulk ops\nconst [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);\n\nconst [createForm] = Form.useForm();\nconst [editForm] = Form.useForm();\nconst [geocoding, setGeocoding] = useState(false);\n

Complexity: 40+ state variables for comprehensive feature set.

"},{"location":"v2/frontend/pages/admin/locations-page/#polling-patterns","title":"Polling Patterns","text":"

NAR Server Import Polling:

// Start polling\nnarPollRef.current = setInterval(async () => {\n  try {\n    const { data: progress } = await api.get<NarImportProgress>(`/map/nar-import/status/${importId}`);\n    setNarProgress(progress);\n\n    if (progress.status === 'complete') {\n      stopNarPolling();\n      setImporting(false);\n      if (progress.result) {\n        setNarImportResult(progress.result);\n        message.success(`Imported ${progress.result.created} locations from ${progress.result.provinceName} in ${(progress.result.durationMs / 1000).toFixed(1)}s`);\n      }\n      fetchLocations({ page: 1 });\n      fetchStats();\n    } else if (progress.status === 'failed') {\n      stopNarPolling();\n      setImporting(false);\n      message.error(progress.error || 'NAR import failed');\n    }\n  } catch {\n    // Polling error \u2014 don't stop, might be transient\n  }\n}, 2000);  // Poll every 2 seconds\n\n// Cleanup on unmount\nuseEffect(() => {\n  return () => stopNarPolling();\n}, [stopNarPolling]);\n

Bulk Geocode Polling:

Similar pattern for bulk geocoding background job. Polls job status every 2 seconds until complete/failed.

"},{"location":"v2/frontend/pages/admin/locations-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/locations-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose GET /api/map/locations List locations (paginated, filtered) GET /api/map/locations/stats Fetch statistics (counts, confidence) GET /api/map/locations/all Fetch all locations (optionally bounds-filtered, max 5000) GET /api/map/locations/:id Fetch single location with addresses GET /api/map/locations/:id/history Fetch location history (paginated) POST /api/map/locations Create location PUT /api/map/locations/:id Update location DELETE /api/map/locations/:id Delete location POST /api/map/locations/bulk-delete Bulk delete locations POST /api/map/locations/geocode Geocode single address POST /api/map/locations/reverse-geocode Reverse geocode coordinates POST /api/map/locations/geocode-missing Batch geocode all missing POST /api/map/locations/bulk-geocode Start bulk re-geocode job GET /api/map/locations/bulk-geocode/:jobId Poll bulk geocode status GET /api/map/locations/export-csv Export all locations as CSV POST /api/map/locations/import-csv Import standard CSV POST /api/map/locations/import-bulk Import NAR CSV (client upload) GET /api/map/nar-import/datasets Scan server NAR directory POST /api/map/nar-import Start NAR server import GET /api/map/nar-import/status/:importId Poll NAR import progress"},{"location":"v2/frontend/pages/admin/locations-page/#list-locations","title":"List Locations","text":"

Request:

const { data } = await api.get<LocationsListResponse>('/map/locations', {\n  params: {\n    page: 1,\n    limit: 20,\n    search: '123 Main',           // Optional: search address or postal\n    confidenceLevel: 'high',      // Optional: high, medium, low, none\n  },\n});\n

Response:

{\n  \"locations\": [\n    {\n      \"id\": \"loc-123\",\n      \"address\": \"123 Main St, Ottawa, ON K1A 0A1\",\n      \"buildingType\": \"MULTI_UNIT\",\n      \"buildingNotes\": \"Access code: 1234\",\n      \"latitude\": \"45.42153\",\n      \"longitude\": \"-75.69719\",\n      \"postalCode\": \"K1A0A1\",\n      \"province\": \"ON\",\n      \"federalDistrict\": \"Ottawa\u2014Vanier\",\n      \"buildingUse\": \"RESIDENTIAL\",\n      \"geocodeProvider\": \"GOOGLE\",\n      \"geocodeConfidence\": 95,\n      \"geocodeAddress\": \"123 Main Street, Ottawa, Ontario K1A 0A1, Canada\",\n      \"totalUnits\": 12,\n      \"createdAt\": \"2026-01-15T10:00:00.000Z\",\n      \"updatedAt\": \"2026-01-20T14:30:00.000Z\",\n      \"addresses\": [\n        {\n          \"id\": \"addr-456\",\n          \"unitNumber\": \"101\",\n          \"firstName\": \"John\",\n          \"lastName\": \"Doe\",\n          \"email\": \"john@example.com\",\n          \"phone\": \"613-555-1234\",\n          \"buildingType\": \"MULTI_UNIT\",\n          \"notes\": \"Friendly, supports campaign\"\n        },\n        // ... 11 more units\n      ]\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 5847,\n    \"totalPages\": 293\n  }\n}\n

Key fields: - addresses \u2014 Nested array of Address units (apartments) - totalUnits \u2014 Count of units (1 for single-family, 2+ for multi-unit) - geocodeProvider \u2014 Provider name (GOOGLE, NOMINATIM, etc.) - geocodeConfidence \u2014 0-100% confidence score - postalCode, province, federalDistrict \u2014 NAR import fields

"},{"location":"v2/frontend/pages/admin/locations-page/#fetch-statistics","title":"Fetch Statistics","text":"

Request:

const { data } = await api.get<LocationStats>('/map/locations/stats');\n

Response:

{\n  \"total\": 5847,\n  \"buildingTypes\": {\n    \"SINGLE_FAMILY\": 4123,\n    \"MULTI_UNIT\": 1234,\n    \"MIXED_USE\": 345,\n    \"COMMERCIAL\": 145\n  },\n  \"geocoded\": 5421,\n  \"confidence\": {\n    \"high\": 4567,\n    \"medium\": 789,\n    \"low\": 65,\n    \"none\": 426,\n    \"average\": 87.3\n  }\n}\n
"},{"location":"v2/frontend/pages/admin/locations-page/#geocode-address","title":"Geocode Address","text":"

Request:

const { data } = await api.post<GeocodeResult>('/map/locations/geocode', {\n  address: '123 Main St, Ottawa, ON',\n});\n

Response:

{\n  \"latitude\": 45.42153,\n  \"longitude\": -75.69719,\n  \"provider\": \"GOOGLE\",\n  \"confidence\": 95,\n  \"geocodedAddress\": \"123 Main Street, Ottawa, Ontario K1A 0A1, Canada\"\n}\n
"},{"location":"v2/frontend/pages/admin/locations-page/#reverse-geocode","title":"Reverse Geocode","text":"

Request:

const { data } = await api.post<ReverseGeocodeResult>('/map/locations/reverse-geocode', {\n  latitude: 45.42153,\n  longitude: -75.69719,\n});\n

Response:

{\n  \"address\": \"123 Main St, Ottawa, ON K1A 0A1, Canada\",\n  \"provider\": \"NOMINATIM\"\n}\n
"},{"location":"v2/frontend/pages/admin/locations-page/#nar-server-import","title":"NAR Server Import","text":"

Request:

const { data } = await api.post<{ importId: string }>('/map/nar-import', {\n  provinceCode: '24',\n  filterType: 'city',\n  filterCity: 'Montreal',\n  residentialOnly: true,\n  deduplicateRadius: 5,\n  batchSize: 1000,\n});\n

Response:

{\n  \"importId\": \"import-789\"\n}\n

Poll status:

const { data } = await api.get<NarImportProgress>(`/map/nar-import/status/${importId}`);\n

Progress response:

{\n  \"importId\": \"import-789\",\n  \"status\": \"processing\",\n  \"currentFile\": \"Address_24_part_3.csv\",\n  \"totalRows\": 45678,\n  \"locationsCreated\": 12345,\n  \"skippedDuplicate\": 234,\n  \"skippedOutOfBounds\": 156,\n  \"skippedNonResidential\": 1234,\n  \"skippedInvalid\": 45\n}\n

Complete response:

{\n  \"importId\": \"import-789\",\n  \"status\": \"complete\",\n  \"result\": {\n    \"provinceName\": \"Quebec\",\n    \"totalRows\": 67890,\n    \"created\": 54321,\n    \"skippedDuplicate\": 456,\n    \"skippedOutOfBounds\": 234,\n    \"skippedNonResidential\": 12345,\n    \"skippedInvalid\": 78,\n    \"durationMs\": 43200,\n    \"errors\": []\n  }\n}\n
"},{"location":"v2/frontend/pages/admin/locations-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/locations-page/#geocode-confidence-always-low","title":"Geocode Confidence Always Low","text":"

Problem: All geocoded locations show Low (<60%) confidence tags.

Diagnosis:

Check geocoding provider priority in backend:

// api/src/modules/map/geocoding/geocoding.service.ts\nconst providers = ['GOOGLE', 'NOMINATIM', 'ARCGIS', 'PHOTON', 'MAPBOX', 'PELIAS'];\n

Common Issues:

  1. Google API key missing/invalid:
  2. Check .env: GOOGLE_GEOCODING_API_KEY=your-key-here
  3. Verify key has Geocoding API enabled
  4. Check quota limits

  5. Poor address quality:

  6. Addresses missing street number, city, or postal code
  7. Example: \"Main St\" (missing number + city) \u2192 low confidence
  8. Solution: Clean address data before import

  9. Provider fallback chain:

  10. Google fails \u2192 tries Nominatim (lower confidence)
  11. Nominatim fails \u2192 tries ArcGIS, etc.
  12. Solution: Fix primary provider (Google)

Solution:

Run bulk re-geocode with confidence threshold 60: 1. Click \"Bulk Re-Geocode\" button 2. Set threshold to 60 3. Start job 4. Improved locations update with higher confidence

"},{"location":"v2/frontend/pages/admin/locations-page/#nar-server-import-not-finding-files","title":"NAR Server Import Not Finding Files","text":"

Problem: Click \"Scan Server Directory\" \u2192 \"No NAR datasets found in /data\"

Diagnosis:

Check docker-compose volume mount:

volumes:\n  - ./data:/data:ro\n

Common Issues:

  1. Data directory doesn't exist:

    mkdir -p ./data\n

  2. NAR files not extracted:

  3. Download NAR zip from Statistics Canada
  4. Extract to ./data/ directory
  5. Ensure Addresses/ and Locations/ subdirectories exist

  6. Wrong directory structure:

    # Wrong (zip extracted to subdirectory):\n./data/NAR_2025/Addresses/Address_24.csv\n\n# Correct (direct in ./data):\n./data/Addresses/Address_24.csv\n./data/Locations/Location_24.csv\n

  7. File permissions:

    chmod -R 755 ./data\n

Solution:

cd changemaker-lite\nmkdir -p ./data\ncd ./data\n# Download NAR zip from Statistics Canada\nunzip NAR_2025.zip\n# Move Addresses/ and Locations/ to ./data root\nmv NAR_2025/Addresses .\nmv NAR_2025/Locations .\n# Restart API container\ndocker compose restart api\n
"},{"location":"v2/frontend/pages/admin/locations-page/#map-shows-too-many-locations-in-view","title":"Map Shows \"Too Many Locations in View\"","text":"

Problem: Zoom out on map \u2192 Warning: \"Too many locations in view. Zoom in for more detail.\"

Diagnosis:

Backend safety limit triggered:

// Max 5000 locations per request\nif (locations.length >= 5000) {\n  return res.json(locations.slice(0, 5000));\n}\n

Not an error: Protection against loading millions of markers.

Solution:

  1. Zoom in to reduce visible area
  2. Map auto-refreshes with smaller bounds
  3. Fewer locations load (no warning)

Alternative: Use Table tab + search/filters to find specific locations.

"},{"location":"v2/frontend/pages/admin/locations-page/#bulk-re-geocode-stuck-at-99","title":"Bulk Re-Geocode Stuck at 99%","text":"

Problem: Start bulk re-geocode \u2192 progress bar reaches 99% \u2192 never completes.

Diagnosis:

Check BullMQ queue health:

docker compose logs -f api\n# Look for: \"Bulk geocode job failed: ETIMEDOUT\"\n

Common Issues:

  1. Geocoding provider timeout:
  2. Google API rate limit exceeded (50 req/sec)
  3. Solution: Reduce job concurrency in backend

  4. Redis connection lost:

  5. Check redis container: docker compose ps redis
  6. Solution: Restart redis: docker compose restart redis

  7. Job worker crashed:

  8. Check API logs for errors
  9. Solution: Restart API: docker compose restart api

Solution:

Cancel stuck job: 1. Close bulk geocode modal 2. Restart API container: docker compose restart api 3. Retry bulk geocode with smaller limit (e.g., 500 instead of 1000)

"},{"location":"v2/frontend/pages/admin/locations-page/#csv-import-shows-invalid-errors","title":"CSV Import Shows \"Invalid\" Errors","text":"

Problem: Import CSV \u2192 Result: \"450 created, 50 invalid\"

Diagnosis:

Check error list in import modal: - Row 23: \"Missing required field: address\" - Row 45: \"Invalid coordinates: latitude > 90\" - Row 67: \"Address too long (max 500 chars)\"

Common Issues:

  1. Missing required columns:
  2. Standard CSV: address required, lat/lng optional
  3. NAR CSV: Address file requires CIVIC_NO + OFFICIAL_STREET_NAME (2025) or STR_NBR + STR_NME (legacy)

  4. Invalid coordinates:

  5. Latitude out of range (-90 to 90)
  6. Longitude out of range (-180 to 180)
  7. Non-numeric values in lat/lng columns

  8. Encoding issues:

  9. CSV not UTF-8 encoded
  10. Solution: Re-save CSV as UTF-8 in Excel/LibreOffice

Solution:

  1. Export failed rows to new CSV for fixing
  2. Clean data in spreadsheet:
  3. Fill missing addresses
  4. Fix coordinate ranges
  5. Remove invalid characters
  6. Re-import cleaned CSV
"},{"location":"v2/frontend/pages/admin/locations-page/#related-documentation","title":"Related Documentation","text":"
  • Locations Module (Backend) \u2014 API implementation, schemas, geocoding service
  • Geocoding Service \u2014 Multi-provider geocoding, confidence scoring
  • NAR Import Service \u2014 NAR format parsing, streaming import
  • Cuts Module \u2014 Polygon boundaries, spatial queries
  • AdminMapView Component \u2014 Interactive map with controls
  • Public Map Page \u2014 Public-facing map view
  • Map Feature Guide \u2014 End-to-end location management workflow
  • NAR Import Guide \u2014 Step-by-step NAR import instructions
  • Locations API Reference \u2014 Complete endpoint documentation
  • Troubleshooting: Geocoding Issues \u2014 Geocoding debugging
"},{"location":"v2/frontend/pages/admin/mailhog-page/","title":"MailHogPage","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#overview","title":"Overview","text":"

File: admin/src/pages/MailHogPage.tsx

Route: /app/services/mailhog

Role Requirements: Any authenticated user (uses authenticate middleware)

Purpose: Provides an embedded interface to the MailHog email testing service via iframe. MailHog is a development email capture tool that intercepts SMTP emails and displays them in a web interface, allowing developers to test email functionality without sending real emails. This page serves as a wrapper that embeds MailHog with online/offline status monitoring and mobile device detection.

Key Features: - Full-page iframe embed of MailHog service - Service online/offline status monitoring with Badge - Mobile device detection with warning screen - \"Refresh\" button to re-check service status - \"Open in New Tab\" button for external access - Fullbleed layout (no padding in AppLayout) - Automatic service health checks via API

Layout: AppLayout with fullbleed (no content padding)

Dependencies: - Ant Design v5 (Button, Space, Badge, Spin, Grid, Result) - react-router-dom (useOutletContext)

"},{"location":"v2/frontend/pages/admin/mailhog-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#1-service-status-monitoring","title":"1. Service Status Monitoring","text":"

Status Display: - Green \"Online\" badge when MailHog is accessible - Red \"Offline\" badge when MailHog is not accessible - Blue \"Checking...\" badge during status check - Badge displayed in page header

Status Checks: - Initial check on page load - Manual check via \"Refresh\" button - No automatic periodic refresh

"},{"location":"v2/frontend/pages/admin/mailhog-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"

Mobile Warning Screen: - Detects mobile devices using Grid.useBreakpoint() - Shows warning Result component on mobile - Recommends using desktop for better email viewing experience - Icon: MailOutlined (48px)

Breakpoint: !screens.md (screen width < 768px = mobile)

"},{"location":"v2/frontend/pages/admin/mailhog-page/#3-service-url-building","title":"3. Service URL Building","text":"

URL Construction: - Fetches service config from API (/api/services/config) - Builds URL using buildServiceUrl() helper - Uses subdomain + domain + port configuration - Example: http://mailhog.cmlite.org or http://localhost:8025

"},{"location":"v2/frontend/pages/admin/mailhog-page/#4-iframe-embedding","title":"4. Iframe Embedding","text":"

Fullbleed Layout: - No padding around iframe - Height: calc(100vh - 64px) (full viewport height minus header) - Width: 100% - No border for seamless integration

Error Handling: - Shows error Result if service offline - Provides \"Retry\" button to re-check status - Clear error messaging

"},{"location":"v2/frontend/pages/admin/mailhog-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#accessing-mailhog-service","title":"Accessing MailHog Service","text":"
  1. Navigate to MailHog:
  2. Click \"Services\" \u2192 \"MailHog\" in sidebar
  3. Page loads with status check

  4. Check Service Status:

  5. Status badge appears in page header:

    • \u2705 \"Online\" (green) - Service available
    • \u274c \"Offline\" (red) - Service unavailable
    • \ud83d\udd35 \"Checking...\" (blue) - Status check in progress
  6. View on Desktop:

  7. If on desktop (screen width \u2265 768px):

    • Iframe loads automatically
    • Full MailHog interface embedded in page
    • Can view captured emails, search, delete
  8. View on Mobile:

  9. If on mobile (screen width < 768px):

    • Warning message appears
    • Message: \"MailHog requires a desktop browser\"
    • \"Open in New Tab\" button provided
    • Click button to open service in separate browser tab
  10. Using MailHog Service:

  11. Inbox View: See all captured emails
  12. Email Preview: Click email to view full content (HTML + text)
  13. Search: Filter emails by sender, recipient, subject
  14. Delete: Delete individual emails or clear all
  15. Raw View: View raw email source (headers + body)

  16. Troubleshoot Offline Service:

  17. If service shows \"Offline\":
    • Click \"Retry\" button to re-check
    • Check Docker container: docker compose ps mailhog
    • Restart service: docker compose restart mailhog
    • Verify nginx routing: Check nginx/conf.d/services.conf
  18. Refresh page after fixing
"},{"location":"v2/frontend/pages/admin/mailhog-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#main-component-structure","title":"Main Component Structure","text":"
export default function MailHogPage() {\n  const { setPageHeader } = useOutletContext<AppOutletContext>();\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n\n  const [online, setOnline] = useState<boolean | null>(null);\n  const [config, setConfig] = useState<ServicesConfig | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  // Fetch service status and config\n  const fetchStatus = useCallback(async () => {\n    try {\n      const [statusRes, configRes] = await Promise.all([\n        api.get<ServicesStatus>('/services/status'),\n        api.get<ServicesConfig>('/services/config'),\n      ]);\n      setOnline(statusRes.data.mailhog.online);\n      setConfig(configRes.data);\n    } catch {\n      setOnline(false);\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    fetchStatus();\n  }, [fetchStatus]);\n\n  // Build service URL\n  const serviceUrl = config\n    ? buildServiceUrl(config.mailhogSubdomain, config.domain, config.mailhogPort)\n    : null;\n\n  // Page header with status badge and actions\n  const headerActions = useMemo(() => (\n    <Space>\n      <Badge\n        status={online === null ? 'processing' : online ? 'success' : 'error'}\n        text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}\n      />\n      <Button icon={<ReloadOutlined />} onClick={fetchStatus} size=\"small\">\n        Refresh\n      </Button>\n      {serviceUrl && (\n        <Button icon={<LinkOutlined />} href={serviceUrl} target=\"_blank\" size=\"small\">\n          Open in New Tab\n        </Button>\n      )}\n    </Space>\n  ), [online, fetchStatus, serviceUrl]);\n\n  useEffect(() => {\n    setPageHeader({ title: 'MailHog', actions: headerActions, fullBleed: true });\n    return () => setPageHeader(null);\n  }, [setPageHeader, headerActions]);\n\n  // Mobile warning\n  if (isMobile) {\n    return (\n      <Result\n        status=\"info\"\n        title=\"Desktop Required\"\n        subTitle=\"MailHog requires a desktop browser with a larger screen.\"\n        icon={<MailOutlined style={{ fontSize: 48 }} />}\n      />\n    );\n  }\n\n  // Loading state\n  if (loading) {\n    return (\n      <div style={{ textAlign: 'center', padding: 80 }}>\n        <Spin size=\"large\" />\n      </div>\n    );\n  }\n\n  // Offline state\n  if (!online || !serviceUrl) {\n    return (\n      <Result\n        status=\"error\"\n        title=\"MailHog Unavailable\"\n        subTitle=\"MailHog is not running or could not be reached. Check that the MailHog container is started.\"\n        extra={\n          <Button type=\"primary\" onClick={fetchStatus}>\n            Retry\n          </Button>\n        }\n      />\n    );\n  }\n\n  // Iframe embed\n  return (\n    <iframe\n      src={serviceUrl}\n      style={{\n        width: '100%',\n        height: 'calc(100vh - 64px)',\n        border: 'none',\n        display: 'block',\n      }}\n      title=\"MailHog\"\n    />\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/mailhog-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  1. Button - Refresh and \"Open in New Tab\" action buttons
  2. Space - Header action button grouping
  3. Badge - Service status indicator (success/error/processing)
  4. Spin - Loading spinner during status check
  5. Grid.useBreakpoint() - Responsive breakpoint detection
  6. Result - Mobile warning and offline error screens
"},{"location":"v2/frontend/pages/admin/mailhog-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#local-component-state-usestate","title":"Local Component State (useState)","text":"
// Service online/offline state\nconst [online, setOnline] = useState<boolean | null>(null);\n\n// Service configuration state (subdomain, domain, port)\nconst [config, setConfig] = useState<ServicesConfig | null>(null);\n\n// Loading state\nconst [loading, setLoading] = useState(true);\n\n// Responsive breakpoint detection\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n
"},{"location":"v2/frontend/pages/admin/mailhog-page/#state-flow","title":"State Flow","text":"
  1. Component Mounts:
  2. fetchStatus() called in useEffect
  3. Parallel API calls:
    • GET /api/services/status - Check MailHog online status
    • GET /api/services/config - Fetch service configuration
  4. Sets online to true or false
  5. Sets config with subdomain/domain/port
  6. Sets loading to false

  7. URL Construction:

  8. buildServiceUrl() constructs full service URL from config
  9. Example: http://mailhog.cmlite.org (production with subdomain)
  10. Or: http://localhost:8025 (development with port)

  11. User Clicks Refresh:

  12. fetchStatus() called again
  13. Re-checks service status
  14. Updates online and config states

  15. Service Online:

  16. online is true
  17. Badge shows \"Online\" (green)
  18. Iframe renders with MailHog interface

  19. Service Offline:

  20. online is false
  21. Badge shows \"Offline\" (red)
  22. Error Result displayed with \"Retry\" button
"},{"location":"v2/frontend/pages/admin/mailhog-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/services/status - Check all service health (includes MailHog)
  2. GET /api/services/config - Fetch service configuration (subdomains, ports)
"},{"location":"v2/frontend/pages/admin/mailhog-page/#example-api-calls","title":"Example API Calls","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#1-fetch-service-status","title":"1. Fetch Service Status","text":"
const statusRes = await api.get<ServicesStatus>('/services/status');\nsetOnline(statusRes.data.mailhog.online);\n

Response Format:

{\n  \"mailhog\": { \"online\": true },\n  \"nocodb\": { \"online\": true },\n  \"n8n\": { \"online\": true },\n  \"grafana\": { \"online\": true },\n  \"prometheus\": { \"online\": true }\n}\n

"},{"location":"v2/frontend/pages/admin/mailhog-page/#2-fetch-service-config","title":"2. Fetch Service Config","text":"
const configRes = await api.get<ServicesConfig>('/services/config');\nsetConfig(configRes.data);\n

Response Format:

{\n  \"domain\": \"cmlite.org\",\n  \"mailhogSubdomain\": \"mailhog\",\n  \"mailhogPort\": 8025,\n  \"nocodbSubdomain\": \"db\",\n  \"nocodbPort\": 8091,\n  \"n8nSubdomain\": \"n8n\",\n  \"n8nPort\": 5678\n}\n

"},{"location":"v2/frontend/pages/admin/mailhog-page/#3-build-service-url","title":"3. Build Service URL","text":"
import { buildServiceUrl } from '@/lib/service-url';\n\nconst serviceUrl = buildServiceUrl(\n  config.mailhogSubdomain,  // \"mailhog\"\n  config.domain,             // \"cmlite.org\"\n  config.mailhogPort         // 8025\n);\n\n// Returns: \"http://mailhog.cmlite.org\" (production)\n// Or: \"http://localhost:8025\" (development)\n
"},{"location":"v2/frontend/pages/admin/mailhog-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#complete-parallel-api-fetch-pattern","title":"Complete Parallel API Fetch Pattern","text":"
const fetchStatus = useCallback(async () => {\n  try {\n    setLoading(true);\n\n    // Parallel fetch: status + config\n    const [statusRes, configRes] = await Promise.all([\n      api.get<ServicesStatus>('/services/status'),\n      api.get<ServicesConfig>('/services/config'),\n    ]);\n\n    // Extract MailHog status\n    setOnline(statusRes.data.mailhog.online);\n\n    // Store full config\n    setConfig(configRes.data);\n  } catch (error) {\n    console.error('Failed to fetch MailHog status:', error);\n    setOnline(false);\n  } finally {\n    setLoading(false);\n  }\n}, []);\n
"},{"location":"v2/frontend/pages/admin/mailhog-page/#service-url-builder-utility","title":"Service URL Builder Utility","text":"
// lib/service-url.ts\nexport function buildServiceUrl(\n  subdomain: string,\n  domain: string,\n  port: number\n): string {\n  // Production: use subdomain routing\n  if (process.env.NODE_ENV === 'production') {\n    return `http://${subdomain}.${domain}`;\n  }\n\n  // Development: use localhost + port\n  return `http://localhost:${port}`;\n}\n\n// Usage:\nconst url = buildServiceUrl('mailhog', 'cmlite.org', 8025);\n// Production: \"http://mailhog.cmlite.org\"\n// Development: \"http://localhost:8025\"\n
"},{"location":"v2/frontend/pages/admin/mailhog-page/#conditional-rendering-pattern","title":"Conditional Rendering Pattern","text":"
// Mobile warning (early return)\nif (isMobile) {\n  return <Result status=\"info\" title=\"Desktop Required\" />;\n}\n\n// Loading state (early return)\nif (loading) {\n  return <Spin size=\"large\" />;\n}\n\n// Offline state (early return)\nif (!online || !serviceUrl) {\n  return <Result status=\"error\" title=\"MailHog Unavailable\" />;\n}\n\n// Online state (iframe)\nreturn <iframe src={serviceUrl} />;\n
"},{"location":"v2/frontend/pages/admin/mailhog-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#1-parallel-api-requests","title":"1. Parallel API Requests","text":"

Status and config fetched in parallel with Promise.all():

const [statusRes, configRes] = await Promise.all([\n  api.get<ServicesStatus>('/services/status'),\n  api.get<ServicesConfig>('/services/config'),\n]);\n

Benefit: Total loading time ~200ms (slowest request) instead of ~400ms (sum of both).

"},{"location":"v2/frontend/pages/admin/mailhog-page/#2-usecallback-for-fetchstatus","title":"2. useCallback for fetchStatus","text":"
const fetchStatus = useCallback(async () => {\n  // ... fetch logic\n}, []);\n

Benefit: Function identity stable across re-renders, prevents unnecessary effect triggers.

"},{"location":"v2/frontend/pages/admin/mailhog-page/#3-usememo-for-header-actions","title":"3. useMemo for Header Actions","text":"
const headerActions = useMemo(() => (\n  <Space>\n    <Badge />\n    <Button onClick={fetchStatus} />\n  </Space>\n), [online, fetchStatus, serviceUrl]);\n

Benefit: Header actions only recreated when dependencies change, preventing unnecessary re-renders.

"},{"location":"v2/frontend/pages/admin/mailhog-page/#4-early-mobile-detection","title":"4. Early Mobile Detection","text":"
if (isMobile) {\n  return <Result />;  // No API calls, no iframe\n}\n

Benefit: Avoids unnecessary service checks and iframe loading on mobile devices.

"},{"location":"v2/frontend/pages/admin/mailhog-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#mobile-detection","title":"Mobile Detection","text":"
const screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;  // Mobile if screen width < 768px\n\nif (isMobile) {\n  return (\n    <Result\n      status=\"info\"\n      title=\"Desktop Required\"\n      subTitle=\"MailHog requires a desktop browser with a larger screen.\"\n      icon={<MailOutlined style={{ fontSize: 48 }} />}\n    />\n  );\n}\n

Why Mobile Warning? - MailHog UI has complex table layout (email list) - Email preview requires horizontal space - Buttons/actions too small on mobile screens - Better UX to open in separate tab

"},{"location":"v2/frontend/pages/admin/mailhog-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#keyboard-navigation","title":"Keyboard Navigation","text":"
  1. Tab Key: Cycles through header buttons (Refresh, Open in New Tab)
  2. Enter Key: Activates focused button
  3. Iframe Focus: Tab enters iframe, navigates MailHog interface
"},{"location":"v2/frontend/pages/admin/mailhog-page/#aria-labels","title":"ARIA Labels","text":"
<iframe\n  src={serviceUrl}\n  title=\"MailHog\"  // Screen reader announces iframe purpose\n  aria-label=\"MailHog email testing service\"\n/>\n\n<Button\n  aria-label=\"Refresh MailHog service status\"\n  icon={<ReloadOutlined />}\n>\n  Refresh\n</Button>\n
"},{"location":"v2/frontend/pages/admin/mailhog-page/#color-contrast","title":"Color Contrast","text":"
  • Success badge (green): #52c41a on white background (contrast ratio 4.5:1)
  • Error badge (red): #ff4d4f on white background (contrast ratio 4.5:1)
  • Button text: White on #1890ff (contrast ratio 4.5:1)
"},{"location":"v2/frontend/pages/admin/mailhog-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#problem-service-shows-offline-despite-container-running","title":"Problem: Service Shows \"Offline\" Despite Container Running","text":"

Solutions:

  1. Verify Docker container:

    docker compose ps mailhog\n# Should show \"Up\" status\n

  2. Check MailHog logs:

    docker compose logs mailhog\n# Look for errors\n

  3. Test direct access:

  4. Open http://localhost:8025 in browser
  5. If accessible directly, nginx routing issue

  6. Check nginx config:

  7. Open nginx/conf.d/services.conf
  8. Verify MailHog proxy block exists
  9. Restart nginx: docker compose restart nginx

  10. Verify API endpoint:

  11. Check DevTools Network tab
  12. Look for /api/services/status request
  13. Verify mailhog.online: true in response
"},{"location":"v2/frontend/pages/admin/mailhog-page/#problem-iframe-not-loading","title":"Problem: Iframe Not Loading","text":"

Solutions:

  1. Check CORS/CSP headers:
  2. Open DevTools Console
  3. Look for errors like \"Refused to display in a frame\"
  4. Check nginx X-Frame-Options headers

  5. Verify service URL:

  6. Check console log: console.log(serviceUrl)
  7. Should be valid URL (not null/undefined)

  8. Test URL in new tab:

  9. Click \"Open in New Tab\" button
  10. If opens correctly, iframe issue
  11. If doesn't open, service issue
"},{"location":"v2/frontend/pages/admin/mailhog-page/#related-documentation","title":"Related Documentation","text":"
  • Service Management - Docker service orchestration
  • Email Testing - MailHog workflow
  • Services API - Service status endpoints
"},{"location":"v2/frontend/pages/admin/map-settings-page/","title":"MapSettingsPage","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#overview","title":"Overview","text":"

The MapSettingsPage provides configuration for the public map view's default center point and zoom level, plus walk sheet template customization with live preview functionality. It features a city search autocomplete that queries the geocoding service to quickly set coordinates, eliminating manual latitude/longitude entry. The walk sheet preview updates in real-time as settings are edited, showing exactly how printed walk sheets will appear with custom headers, footers, and up to 3 QR codes. The page uses a two-column layout: settings form on left, live walk sheet preview on right.

Route: /app/map/settings Component: admin/src/pages/MapSettingsPage.tsx (433 lines) Auth Required: Yes (SUPER_ADMIN or MAP_ADMIN role recommended) Layout: AppLayout Backend Module: api/src/modules/map/settings/

"},{"location":"v2/frontend/pages/admin/map-settings-page/#screenshot","title":"Screenshot","text":"

[Screenshot: MapSettingsPage with two-column layout. Left column has \"Map Center & Zoom\" card with city search autocomplete (showing \"Ottawa, Canada\" with dropdown suggestions), latitude/longitude inputs (45.4215, -75.6972), and zoom slider (12). Below that is \"Walk Sheet Configuration\" card with Title input (\"Canvassing Walk Sheet\"), Subtitle input (\"District Outreach Campaign 2026\"), Footer textarea, and three QR Code URL + Label input rows. Right column has \"Walk Sheet Preview\" card showing printed form layout with header, 3 QR codes, 2 contact entry blocks (First Name, Last Name, Email, Phone, Address, Support Level circles, Sign Request Y/N, Sign Size R/L/U, Visited Date), and Notes section. Top-right has \"Print Walk Sheet\" and \"Save Settings\" buttons.]

"},{"location":"v2/frontend/pages/admin/map-settings-page/#features","title":"Features","text":"
  • City search autocomplete \u2014 Search for cities/places to auto-fill coordinates (geocoding service integration)
  • Manual coordinate entry \u2014 Precise latitude/longitude control with decimal precision
  • Zoom level slider \u2014 Visual zoom selection (2-19 range)
  • Live walk sheet preview \u2014 Real-time preview updates as settings are edited
  • Custom walk sheet headers \u2014 Configurable title and subtitle
  • Custom walk sheet footer \u2014 Multi-line footer text
  • QR code integration \u2014 Up to 3 QR codes with custom URLs and labels
  • Print-optimized preview \u2014 Walk sheet preview matches actual printed output
  • Browser print support \u2014 Print directly from preview with window.print()
  • Two-column layout \u2014 Side-by-side settings and preview for immediate feedback
  • Form validation \u2014 Required fields (latitude, longitude)
  • Responsive design \u2014 Stacked layout on mobile, side-by-side on desktop
"},{"location":"v2/frontend/pages/admin/map-settings-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#setting-map-center-via-city-search","title":"Setting Map Center via City Search","text":"
  1. Navigate to /app/map/settings
  2. Locate \"Map Center & Zoom\" card (top of left column)
  3. Click city search autocomplete input field
  4. Start typing city name (e.g., \"Ottawa\")
  5. After 400ms delay, search results appear in dropdown:
  6. Display name: \"Ottawa, Ontario, Canada\"
  7. Type tag: \"city\" (gray text, right side)
  8. Click desired city from dropdown
  9. Coordinates auto-fill:
  10. Latitude: 45.4215
  11. Longitude: -75.6972
  12. Zoom: 12 (default zoom for city-level view)
  13. Success message: \"Coordinates auto-filled. Fine-tune below.\"
  14. Adjust zoom slider if needed (e.g., 14 for closer view)
  15. Click \"Save Settings\" button (top-right header)

Search Features: - Debounced search: 400ms delay prevents API spam during typing - Minimum 2 characters: Search requires at least 2 characters - Limit 5 results: Top 5 most relevant results shown - Result types: city, town, village, suburb, neighbourhood - Display format: \"City, State/Province, Country\" + type tag

"},{"location":"v2/frontend/pages/admin/map-settings-page/#setting-map-center-manually","title":"Setting Map Center Manually","text":"
  1. Locate latitude/longitude input fields
  2. Enter precise coordinates:
  3. Latitude: -90 to 90 (e.g., 45.4215)
  4. Longitude: -180 to 180 (e.g., -75.6972)
  5. Decimal precision: 4 decimal places = ~10 meter accuracy
  6. Adjust zoom slider (2-19 range):
  7. 2-5: Country/continent level
  8. 6-9: State/province level
  9. 10-12: City level (default)
  10. 13-15: Neighborhood level
  11. 16-19: Street level
  12. Click \"Save Settings\"

Use Cases: - Setting map center to campaign office location - Centering on specific neighborhood - Centering on landmark (e.g., Parliament Hill)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#customizing-walk-sheet-header","title":"Customizing Walk Sheet Header","text":"
  1. Scroll to \"Walk Sheet Configuration\" card
  2. Modify Title field (e.g., \"Canvassing Walk Sheet\")
  3. Modify Subtitle field (e.g., \"District Outreach Campaign 2026\")
  4. Observe live preview (right column):
  5. Header updates immediately as you type
  6. Title appears in large bold font (18pt)
  7. Subtitle appears below in smaller font (12pt)
  8. Click \"Save Settings\" when satisfied

Best Practices: - Title: Keep short (< 50 characters), campaign name or purpose - Subtitle: Add date, district, or organizer name - Avoid: Long text (will overflow on printed page)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#adding-qr-codes","title":"Adding QR Codes","text":"
  1. Locate \"QR Codes\" section (under footer field)
  2. Fill in QR Code 1 URL (e.g., \"https://cmlite.org/campaign-info\")
  3. Fill in QR Code 1 Label (e.g., \"Campaign Info\")
  4. Observe live preview:
  5. QR code appears below header (generated via /api/qr endpoint)
  6. Label appears below QR code in small font (9pt)
  7. Repeat for QR Code 2 and 3 (optional)
  8. Click \"Save Settings\"

QR Code Use Cases: - Campaign website: Link to campaign homepage - Survey/feedback: Google Form or Typeform link - Donation page: Link to fundraising platform - Social media: Link to Facebook page or Twitter profile - Contact form: Link to volunteer signup form

QR Code Behavior: - Empty URL: QR code not rendered (skipped) - Empty label: QR code rendered without label - Long label: Label truncates (keep < 20 characters) - Invalid URL: QR code still generated (ensure URL is correct)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#customizing-walk-sheet-footer","title":"Customizing Walk Sheet Footer","text":"
  1. Locate Footer field (multi-line textarea)
  2. Enter footer text (e.g., \"Return completed sheets to campaign office. Questions? Call 613-555-0100.\")
  3. Observe live preview:
  4. Footer appears at bottom of walk sheet
  5. Centered text, small font (9pt)
  6. Gray text color for subtlety
  7. Click \"Save Settings\"

Footer Content Suggestions: - Return instructions (where to submit completed sheets) - Contact information (phone, email) - Legal disclaimer (privacy policy compliance) - Thank you message (\"Thank you for volunteering!\")

"},{"location":"v2/frontend/pages/admin/map-settings-page/#printing-walk-sheet","title":"Printing Walk Sheet","text":"

Option 1: Print from Preview

  1. Click \"Print\" button in preview card header (top-right)
  2. Browser print dialog opens
  3. Configure print settings:
  4. Paper size: Letter (8.5\" \u00d7 11\")
  5. Orientation: Portrait
  6. Margins: Default (or custom 0.5\")
  7. Scale: 100% (do not scale)
  8. Click \"Print\" button in dialog
  9. Walk sheet prints exactly as shown in preview

Option 2: Print from Header Button

  1. Click \"Print Walk Sheet\" button (page header, next to Save Settings)
  2. Same print dialog appears
  3. Same print settings apply

Print Optimization:

The page includes print-specific CSS:

@media print {\n  body * { visibility: hidden !important; }\n  .walk-sheet-print, .walk-sheet-print * { visibility: visible !important; }\n  .walk-sheet-print {\n    position: fixed !important;\n    left: 0 !important;\n    top: 0 !important;\n    width: 8.5in !important;\n    height: 11in !important;\n    padding: 0.4in 0.5in !important;\n  }\n}\n

Result: - Only walk sheet prints (no navigation, buttons, etc.) - Exactly 8.5\" \u00d7 11\" Letter size - QR codes print with high contrast (print-color-adjust: exact) - Clean professional appearance

"},{"location":"v2/frontend/pages/admin/map-settings-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  • Typography.Text \u2014 Labels, descriptions, helper text
  • Form \u2014 Wrap all settings inputs
  • Form.Item \u2014 Individual field wrappers with labels
  • Input \u2014 Text fields (title, subtitle, QR labels)
  • Input.TextArea \u2014 Multi-line footer field
  • InputNumber \u2014 Numeric latitude/longitude inputs
  • Slider \u2014 Zoom level selection (visual slider with marks)
  • AutoComplete \u2014 City search with dropdown suggestions
  • Card \u2014 Section containers (Map Center, Walk Sheet Config, Preview)
  • Row / Col \u2014 Grid layout for two-column design
  • Button \u2014 Save Settings, Print buttons
  • Space \u2014 Button grouping
  • Spin \u2014 Loading indicators (city search, initial page load)
  • message \u2014 Toast notifications for success/error feedback
"},{"location":"v2/frontend/pages/admin/map-settings-page/#two-column-layout","title":"Two-Column Layout","text":"
<Row gutter={24}>\n  {/* Left column: Settings form */}\n  <Col xs={24} lg={10}>\n    <Form form={form} onFinish={handleSave} layout=\"vertical\">\n      <Card title=\"Map Center & Zoom\">{/* ... */}</Card>\n      <Card title=\"Walk Sheet Configuration\">{/* ... */}</Card>\n    </Form>\n  </Col>\n\n  {/* Right column: Live walk sheet preview */}\n  <Col xs={24} lg={14}>\n    <Card title=\"Walk Sheet Preview\">{/* ... */}</Card>\n  </Col>\n</Row>\n

Responsive Breakpoints: - Mobile (xs, <992px): Stacked layout (form on top, preview below) - Desktop (lg, \u2265992px): Side-by-side layout (form 40% width, preview 60% width)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#city-search-autocomplete","title":"City Search Autocomplete","text":"
<AutoComplete\n  value={citySearch}\n  options={cityOptions}\n  onSearch={handleCitySearch}\n  onSelect={handleCitySelect}\n  placeholder=\"Search for a city to auto-fill coordinates...\"\n  style={{ width: '100%' }}\n  suffixIcon={citySearching ? <Spin size=\"small\" /> : <SearchOutlined />}\n/>\n

City Search Handler:

const handleCitySearch = useCallback((value: string) => {\n  setCitySearch(value);\n  clearTimeout(cityTimerRef.current);\n\n  // Require minimum 2 characters\n  if (value.length < 2) {\n    setCityOptions([]);\n    return;\n  }\n\n  // Debounce 400ms\n  cityTimerRef.current = setTimeout(async () => {\n    setCitySearching(true);\n    try {\n      const { data } = await api.get<GeocodeSearchResult[]>('/map/geocoding/search', {\n        params: { q: value, limit: 5 },\n      });\n\n      setCityOptions(data.map((r) => ({\n        value: r.displayName,\n        label: (\n          <div style={{ display: 'flex', justifyContent: 'space-between' }}>\n            <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 300 }}>\n              {r.displayName}\n            </span>\n            <span style={{ color: '#888', fontSize: 11, marginLeft: 8, flexShrink: 0 }}>\n              {r.type}\n            </span>\n          </div>\n        ),\n        result: r,\n      })));\n    } catch {\n      setCityOptions([]);\n    } finally {\n      setCitySearching(false);\n    }\n  }, 400);\n}, []);\n

City Select Handler:

const handleCitySelect = useCallback((_value: string, option: { result: GeocodeSearchResult }) => {\n  // Auto-fill coordinates from selected result\n  form.setFieldsValue({\n    latitude: option.result.latitude,\n    longitude: option.result.longitude,\n    zoom: 12,  // Default zoom for city-level view\n  });\n\n  // Clear search\n  setCitySearch('');\n  setCityOptions([]);\n\n  message.success('Coordinates auto-filled. Fine-tune below.');\n}, [form]);\n
"},{"location":"v2/frontend/pages/admin/map-settings-page/#live-walk-sheet-preview","title":"Live Walk Sheet Preview","text":"
// Watch all form values for live preview\nconst watched = Form.useWatch(undefined, form) as Record<string, string | undefined> | undefined;\n\n// QR codes from live form values\nconst qrCodes = [\n  { url: watched?.qrCode1Url, label: watched?.qrCode1Label },\n  { url: watched?.qrCode2Url, label: watched?.qrCode2Label },\n  { url: watched?.qrCode3Url, label: watched?.qrCode3Label },\n].filter((qr) => qr.url);  // Only include QR codes with URLs\n\nreturn (\n  <div className=\"walk-sheet-print\" style={{ /* print styles */ }}>\n    {/* Header */}\n    <div style={{ borderBottom: '2px solid #000', paddingBottom: 6 }}>\n      <div style={{ fontSize: 18, fontWeight: 700, textAlign: 'center' }}>\n        {watched?.walkSheetTitle || 'Walk Sheet'}\n      </div>\n      {watched?.walkSheetSubtitle && (\n        <div style={{ fontSize: 12, textAlign: 'center', marginTop: 2 }}>\n          {watched.walkSheetSubtitle}\n        </div>\n      )}\n    </div>\n\n    {/* QR Codes */}\n    {qrCodes.length > 0 && (\n      <div style={{ display: 'flex', justifyContent: 'center', gap: 32 }}>\n        {qrCodes.map((qr, i) => (\n          <div key={i} style={{ textAlign: 'center' }}>\n            <img\n              src={`/api/qr?text=${encodeURIComponent(qr.url!)}&size=200`}\n              alt={qr.label || `QR Code ${i + 1}`}\n              style={{ width: 80, height: 80 }}\n            />\n            {qr.label && <div style={{ fontSize: 9 }}>{qr.label}</div>}\n          </div>\n        ))}\n      </div>\n    )}\n\n    {/* Contact entry blocks */}\n    {[1, 2].map((blockNum) => (\n      <div key={blockNum}>\n        <FormRow left=\"First Name\" right=\"Last Name\" />\n        <FormRow left=\"Email\" right=\"Phone\" />\n        <FormRow left=\"Address\" right=\"Unit Number\" />\n        {/* Support Level, Sign Request, Sign Size, Visited Date */}\n      </div>\n    ))}\n\n    {/* Notes section */}\n    <div style={{ marginTop: 8 }}>\n      <div style={{ fontSize: 9, color: '#666' }}>Notes &amp; Comments</div>\n      <div style={{ border: '1px solid #999', minHeight: 80 }} />\n    </div>\n\n    {/* Footer */}\n    {watched?.walkSheetFooter && (\n      <div style={{ marginTop: 10, textAlign: 'center', fontSize: 9 }}>\n        {watched.walkSheetFooter}\n      </div>\n    )}\n  </div>\n);\n

Live Preview Features: - Form.useWatch: Monitors all form fields for changes - Immediate updates: No need to click \"Save\" to see preview - Conditional rendering: QR codes only shown if URL provided - Fallback values: Default \"Walk Sheet\" title if empty - Print-ready: Preview matches actual printed output

"},{"location":"v2/frontend/pages/admin/map-settings-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#local-state-no-zustand-store","title":"Local State (No Zustand Store)","text":"
const [form] = Form.useForm();\nconst [loading, setLoading] = useState(true);\nconst [saving, setSaving] = useState(false);\nconst [citySearch, setCitySearch] = useState('');\nconst [cityOptions, setCityOptions] = useState<Array<{ value: string; label: React.ReactNode; result: GeocodeSearchResult }>>([]);\nconst [citySearching, setCitySearching] = useState(false);\nconst cityTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\nconst printRef = useRef<HTMLDivElement>(null);\n

State Variables: - form (Form): Ant Design form instance (all settings inputs) - loading (boolean): Initial page load state - saving (boolean): Save button loading state - citySearch (string): City search input value - cityOptions (array): Autocomplete dropdown options - citySearching (boolean): City search loading indicator - cityTimerRef (ref): Debounce timer for city search - printRef (ref): Reference to walk sheet preview div (for future print enhancements)

No Global State:

This page does NOT use Zustand stores. Map settings are fetched directly from the API and stored in the form. This is appropriate because: - Map settings are admin-only configuration - Settings change infrequently (set once during setup) - No need to share state between pages (public map fetches settings independently) - Simpler architecture without store overhead

"},{"location":"v2/frontend/pages/admin/map-settings-page/#form-initialization","title":"Form Initialization","text":"
const fetchSettings = useCallback(async () => {\n  try {\n    const { data } = await api.get<MapSettings>('/map/settings');\n    form.setFieldsValue({\n      latitude: data.latitude ? parseFloat(data.latitude) : 45.4215,\n      longitude: data.longitude ? parseFloat(data.longitude) : -75.6972,\n      zoom: data.zoom ?? 12,\n      walkSheetTitle: data.walkSheetTitle,\n      walkSheetSubtitle: data.walkSheetSubtitle,\n      walkSheetFooter: data.walkSheetFooter,\n      qrCode1Url: data.qrCode1Url,\n      qrCode1Label: data.qrCode1Label,\n      qrCode2Url: data.qrCode2Url,\n      qrCode2Label: data.qrCode2Label,\n      qrCode3Url: data.qrCode3Url,\n      qrCode3Label: data.qrCode3Label,\n    });\n  } catch {\n    message.error('Failed to load map settings');\n  } finally {\n    setLoading(false);\n  }\n}, [form]);\n\nuseEffect(() => {\n  fetchSettings();\n}, [fetchSettings]);\n

Default Values:

If settings not yet configured (first time), defaults are used: - Latitude: 45.4215 (Ottawa, Canada) - Longitude: -75.6972 (Ottawa, Canada) - Zoom: 12 (city-level view) - Walk Sheet Title: empty (shows \"Walk Sheet\" placeholder in preview) - Other fields: empty

"},{"location":"v2/frontend/pages/admin/map-settings-page/#debounced-city-search","title":"Debounced City Search","text":"
const handleCitySearch = useCallback((value: string) => {\n  setCitySearch(value);\n  clearTimeout(cityTimerRef.current);\n\n  if (value.length < 2) {\n    setCityOptions([]);\n    return;\n  }\n\n  cityTimerRef.current = setTimeout(async () => {\n    setCitySearching(true);\n    try {\n      const { data } = await api.get<GeocodeSearchResult[]>('/map/geocoding/search', {\n        params: { q: value, limit: 5 },\n      });\n      setCityOptions(data.map((r) => ({ /* ... */ })));\n    } catch {\n      setCityOptions([]);\n    } finally {\n      setCitySearching(false);\n    }\n  }, 400);\n}, []);\n\nuseEffect(() => {\n  return () => clearTimeout(cityTimerRef.current);  // Cleanup on unmount\n}, []);\n

Why 400ms Debounce?

  • Balance: Longer than typical 300ms to account for slower typing when entering city names
  • Network efficiency: Geocoding API queries are expensive (external service calls)
  • User experience: Users expect slight delay when searching for places (feels intentional)
"},{"location":"v2/frontend/pages/admin/map-settings-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose Auth GET /api/map/settings Load current settings Required PUT /api/map/settings Update settings Required GET /api/map/geocoding/search Search for cities/places Required GET /api/qr Generate QR code PNG Public (no auth)"},{"location":"v2/frontend/pages/admin/map-settings-page/#load-map-settings","title":"Load Map Settings","text":"

Request:

const { data } = await api.get<MapSettings>('/map/settings');\n

Response (200 OK):

{\n  \"id\": \"settings_singleton\",\n  \"latitude\": \"45.4215\",\n  \"longitude\": \"-75.6972\",\n  \"zoom\": 12,\n  \"walkSheetTitle\": \"Canvassing Walk Sheet\",\n  \"walkSheetSubtitle\": \"District Outreach Campaign 2026\",\n  \"walkSheetFooter\": \"Return completed sheets to campaign office. Questions? Call 613-555-0100.\",\n  \"qrCode1Url\": \"https://cmlite.org/campaign-info\",\n  \"qrCode1Label\": \"Campaign Info\",\n  \"qrCode2Url\": \"https://forms.gle/abc123\",\n  \"qrCode2Label\": \"Volunteer Survey\",\n  \"qrCode3Url\": null,\n  \"qrCode3Label\": null,\n  \"createdAt\": \"2026-01-15T10:30:00.000Z\",\n  \"updatedAt\": \"2026-02-10T14:25:00.000Z\"\n}\n

Response Fields: - latitude (string): Default map center latitude (stored as string for precision) - longitude (string): Default map center longitude - zoom (number): Default map zoom level (2-19) - walkSheetTitle (string | null): Walk sheet header title - walkSheetSubtitle (string | null): Walk sheet header subtitle - walkSheetFooter (string | null): Walk sheet footer text - qrCode1Url (string | null): First QR code URL - qrCode1Label (string | null): First QR code label - qrCode2Url (string | null): Second QR code URL - qrCode2Label (string | null): Second QR code label - qrCode3Url (string | null): Third QR code URL - qrCode3Label (string | null): Third QR code label

Backend Implementation:

Map settings are stored as singleton record (only one row in database):

const settings = await prisma.mapSettings.findFirst();\nif (!settings) {\n  // Return defaults if not yet configured\n  return {\n    latitude: '45.4215',\n    longitude: '-75.6972',\n    zoom: 12,\n    walkSheetTitle: null,\n    // ... other fields null\n  };\n}\nreturn settings;\n
"},{"location":"v2/frontend/pages/admin/map-settings-page/#update-map-settings","title":"Update Map Settings","text":"

Request:

const values = {\n  latitude: 45.4215,\n  longitude: -75.6972,\n  zoom: 14,\n  walkSheetTitle: 'Canvassing Walk Sheet',\n  walkSheetSubtitle: 'District Outreach Campaign 2026',\n  walkSheetFooter: 'Return completed sheets to campaign office.',\n  qrCode1Url: 'https://cmlite.org/campaign-info',\n  qrCode1Label: 'Campaign Info',\n  qrCode2Url: null,\n  qrCode2Label: null,\n  qrCode3Url: null,\n  qrCode3Label: null,\n};\n\nawait api.put('/map/settings', values);\n

Request Body Schema:

{\n  latitude?: number;          // Optional, -90 to 90\n  longitude?: number;         // Optional, -180 to 180\n  zoom?: number;              // Optional, 2 to 19\n  walkSheetTitle?: string;    // Optional, max 255 chars\n  walkSheetSubtitle?: string; // Optional, max 255 chars\n  walkSheetFooter?: string;   // Optional, max 1000 chars\n  qrCode1Url?: string | null; // Optional, valid URL or null\n  qrCode1Label?: string | null;\n  qrCode2Url?: string | null;\n  qrCode2Label?: string | null;\n  qrCode3Url?: string | null;\n  qrCode3Label?: string | null;\n}\n

Response (200 OK):

{\n  \"id\": \"settings_singleton\",\n  \"latitude\": \"45.4215\",\n  \"longitude\": \"-75.6972\",\n  \"zoom\": 14,\n  \"walkSheetTitle\": \"Canvassing Walk Sheet\",\n  \"walkSheetSubtitle\": \"District Outreach Campaign 2026\",\n  \"walkSheetFooter\": \"Return completed sheets to campaign office.\",\n  \"qrCode1Url\": \"https://cmlite.org/campaign-info\",\n  \"qrCode1Label\": \"Campaign Info\",\n  \"qrCode2Url\": null,\n  \"qrCode2Label\": null,\n  \"qrCode3Url\": null,\n  \"qrCode3Label\": null,\n  \"createdAt\": \"2026-01-15T10:30:00.000Z\",\n  \"updatedAt\": \"2026-02-11T10:45:00.000Z\"\n}\n

Backend Implementation (Upsert):

const settings = await prisma.mapSettings.upsert({\n  where: { id: 'settings_singleton' },\n  create: {\n    id: 'settings_singleton',\n    latitude: values.latitude?.toString(),\n    longitude: values.longitude?.toString(),\n    zoom: values.zoom,\n    // ... other fields\n  },\n  update: {\n    latitude: values.latitude?.toString(),\n    longitude: values.longitude?.toString(),\n    zoom: values.zoom,\n    // ... other fields\n  },\n});\n

Upsert Logic: - If settings don't exist (first time), create new record - If settings exist, update existing record - Ensures singleton pattern (only one settings record)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#search-for-cities-geocoding","title":"Search for Cities (Geocoding)","text":"

Request:

const { data } = await api.get<GeocodeSearchResult[]>('/map/geocoding/search', {\n  params: {\n    q: 'Ottawa',\n    limit: 5,\n  },\n});\n

Query Parameters: - q (string, required): Search query (city name, address, landmark) - limit (number, optional): Maximum results to return (default: 10, max: 20)

Response (200 OK):

[\n  {\n    \"displayName\": \"Ottawa, Ontario, Canada\",\n    \"latitude\": 45.4215,\n    \"longitude\": -75.6972,\n    \"type\": \"city\",\n    \"importance\": 0.98,\n    \"boundingBox\": {\n      \"minLat\": 45.2,\n      \"maxLat\": 45.6,\n      \"minLon\": -76.0,\n      \"maxLon\": -75.4\n    }\n  },\n  {\n    \"displayName\": \"Ottawa, Kansas, United States\",\n    \"latitude\": 38.6156,\n    \"longitude\": -95.2678,\n    \"type\": \"city\",\n    \"importance\": 0.75\n  },\n  {\n    \"displayName\": \"Ottawa, Illinois, United States\",\n    \"latitude\": 41.3456,\n    \"longitude\": -88.8426,\n    \"type\": \"city\",\n    \"importance\": 0.72\n  }\n]\n

Response Fields: - displayName (string): Human-readable location name (e.g., \"Ottawa, Ontario, Canada\") - latitude (number): Latitude coordinate - longitude (number): Longitude coordinate - type (string): Location type (city, town, village, suburb, neighbourhood, etc.) - importance (number): Relevance score (0.0-1.0, higher = more relevant) - boundingBox (object, optional): Geographic bounds for larger areas

Sorting: - Results sorted by importance DESC (most relevant first) - Limited to top N results (default 5 for autocomplete)

Backend Implementation:

Multi-provider geocoding service (see Geocoding Service documentation):

const results = await geocodingService.search(query, { limit });\nreturn results.map((r) => ({\n  displayName: r.display_name,\n  latitude: r.lat,\n  longitude: r.lon,\n  type: r.type,\n  importance: r.importance,\n}));\n

Providers Used (in order of preference): 1. Nominatim (OpenStreetMap) 2. ArcGIS 3. Photon 4. Mapbox (if API key provided)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#generate-qr-code","title":"Generate QR Code","text":"

Request:

const qrCodeUrl = `/api/qr?text=${encodeURIComponent('https://cmlite.org/campaign-info')}&size=200`;\n\n<img src={qrCodeUrl} alt=\"Campaign Info QR Code\" style={{ width: 80, height: 80 }} />\n

Query Parameters: - text (string, required): URL or text to encode (URL-encoded) - size (number, optional): QR code size in pixels (default: 200, max: 1000)

Response (200 OK):

Binary PNG image data (Content-Type: image/png)

Example URLs: - Campaign website: /api/qr?text=https%3A%2F%2Fcmlite.org%2Fcampaign-info&size=200 - Google Form: /api/qr?text=https%3A%2F%2Fforms.gle%2Fabc123&size=200 - Phone number: /api/qr?text=tel%3A%2B16135550100&size=200

QR Code Generation:

Backend uses qrcode npm package:

import QRCode from 'qrcode';\n\nconst qrBuffer = await QRCode.toBuffer(text, {\n  width: size,\n  margin: 1,\n  errorCorrectionLevel: 'M',\n});\n\nres.set('Content-Type', 'image/png');\nres.send(qrBuffer);\n

Error Correction Level: - M (Medium): Can recover from 15% damage - Balanced between size and error tolerance - Suitable for most use cases (printed walk sheets)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#complete-city-search-flow","title":"Complete City Search Flow","text":"
const handleCitySearch = useCallback((value: string) => {\n  setCitySearch(value);\n  clearTimeout(cityTimerRef.current);\n\n  // Require minimum 2 characters\n  if (value.length < 2) {\n    setCityOptions([]);\n    return;\n  }\n\n  // Debounce 400ms\n  cityTimerRef.current = setTimeout(async () => {\n    setCitySearching(true);\n    try {\n      // Query geocoding service\n      const { data } = await api.get<GeocodeSearchResult[]>('/map/geocoding/search', {\n        params: { q: value, limit: 5 },\n      });\n\n      // Map results to autocomplete options\n      setCityOptions(data.map((r) => ({\n        value: r.displayName,\n        label: (\n          <div style={{ display: 'flex', justifyContent: 'space-between' }}>\n            <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 300 }}>\n              {r.displayName}\n            </span>\n            <span style={{ color: '#888', fontSize: 11, marginLeft: 8, flexShrink: 0 }}>\n              {r.type}\n            </span>\n          </div>\n        ),\n        result: r,  // Store full result for onSelect handler\n      })));\n    } catch {\n      setCityOptions([]);\n    } finally {\n      setCitySearching(false);\n    }\n  }, 400);\n}, []);\n\nconst handleCitySelect = useCallback((_value: string, option: { result: GeocodeSearchResult }) => {\n  // Auto-fill coordinates from selected result\n  form.setFieldsValue({\n    latitude: option.result.latitude,\n    longitude: option.result.longitude,\n    zoom: 12,  // Default zoom for city-level view\n  });\n\n  // Clear search\n  setCitySearch('');\n  setCityOptions([]);\n\n  // Notify user\n  message.success('Coordinates auto-filled. Fine-tune below.');\n}, [form]);\n\n// Cleanup timer on unmount\nuseEffect(() => {\n  return () => clearTimeout(cityTimerRef.current);\n}, []);\n

Key Steps: 1. Check minimum length (2 characters) before searching 2. Debounce 400ms to prevent API spam 3. Query geocoding service with limit=5 4. Map results to autocomplete options with custom labels 5. Store full result object for use in onSelect handler 6. Auto-fill form fields when user selects result 7. Clear search input after selection 8. Show success message confirming auto-fill

"},{"location":"v2/frontend/pages/admin/map-settings-page/#live-walk-sheet-preview_1","title":"Live Walk Sheet Preview","text":"
// Watch all form values for live preview\nconst watched = Form.useWatch(undefined, form) as Record<string, string | undefined> | undefined;\n\n// Extract QR codes (filter out empty URLs)\nconst qrCodes = [\n  { url: watched?.qrCode1Url, label: watched?.qrCode1Label },\n  { url: watched?.qrCode2Url, label: watched?.qrCode2Label },\n  { url: watched?.qrCode3Url, label: watched?.qrCode3Label },\n].filter((qr) => qr.url);\n\nreturn (\n  <div className=\"walk-sheet-print\" style={{ /* ... */ }}>\n    {/* Header */}\n    <div style={{ borderBottom: '2px solid #000', paddingBottom: 6 }}>\n      <div style={{ fontSize: 18, fontWeight: 700, textAlign: 'center' }}>\n        {watched?.walkSheetTitle || 'Walk Sheet'}\n      </div>\n      {watched?.walkSheetSubtitle && (\n        <div style={{ fontSize: 12, textAlign: 'center', color: '#333' }}>\n          {watched.walkSheetSubtitle}\n        </div>\n      )}\n    </div>\n\n    {/* QR Codes */}\n    {qrCodes.length > 0 && (\n      <div style={{ display: 'flex', justifyContent: 'center', gap: 32 }}>\n        {qrCodes.map((qr, i) => (\n          <div key={i} style={{ textAlign: 'center' }}>\n            <img\n              src={`/api/qr?text=${encodeURIComponent(qr.url!)}&size=200`}\n              alt={qr.label || `QR Code ${i + 1}`}\n              style={{ width: 80, height: 80 }}\n            />\n            {qr.label && <div style={{ fontSize: 9 }}>{qr.label}</div>}\n          </div>\n        ))}\n      </div>\n    )}\n\n    {/* Contact entry blocks (2 blocks per page) */}\n    {[1, 2].map((blockNum) => (\n      <div key={blockNum}>\n        {blockNum > 1 && <div style={{ borderTop: '1px dashed #999' }} />}\n        <FormRow left=\"First Name\" right=\"Last Name\" />\n        <FormRow left=\"Email\" right=\"Phone\" />\n        <FormRow left=\"Address\" right=\"Unit Number\" />\n        {/* Support Level, Sign Request, Sign Size, Visited Date */}\n      </div>\n    ))}\n\n    {/* Footer */}\n    {watched?.walkSheetFooter && (\n      <div style={{ marginTop: 10, textAlign: 'center', fontSize: 9, color: '#666' }}>\n        {watched.walkSheetFooter}\n      </div>\n    )}\n  </div>\n);\n

Live Preview Features: - Form.useWatch(undefined, form): Watches all form fields (no specific field specified) - Immediate updates: Preview updates as user types (no debounce needed for preview) - Conditional rendering: QR codes only shown if URL provided - Fallback values: Shows \"Walk Sheet\" if title empty - Print-ready styling: Matches actual printed output exactly

"},{"location":"v2/frontend/pages/admin/map-settings-page/#print-specific-css","title":"Print-Specific CSS","text":"
<style>{`\n  @media print {\n    /* Hide everything except walk sheet */\n    body * { visibility: hidden !important; }\n    .walk-sheet-print, .walk-sheet-print * { visibility: visible !important; }\n\n    /* Position walk sheet at top-left of page */\n    .walk-sheet-print {\n      position: fixed !important;\n      left: 0 !important;\n      top: 0 !important;\n      width: 8.5in !important;\n      height: 11in !important;\n      padding: 0.4in 0.5in !important;\n      background: white !important;\n      color: black !important;\n      box-sizing: border-box !important;\n    }\n\n    /* Ensure QR codes print with high contrast */\n    .walk-sheet-print img {\n      print-color-adjust: exact;\n      -webkit-print-color-adjust: exact;\n    }\n\n    /* Set page size */\n    @page {\n      size: letter;\n      margin: 0;\n    }\n  }\n`}</style>\n

CSS Explanation:

  • Hide all: body * { visibility: hidden } hides navigation, buttons, etc.
  • Show walk sheet: .walk-sheet-print * { visibility: visible } shows only preview
  • Fixed positioning: Ensures walk sheet starts at top-left of printed page
  • Exact dimensions: 8.5\" \u00d7 11\" Letter size with 0.4\" padding
  • QR code printing: print-color-adjust: exact ensures QR codes print with high contrast
  • Page size: @page { size: letter } sets printer page size
"},{"location":"v2/frontend/pages/admin/map-settings-page/#form-submission-handler","title":"Form Submission Handler","text":"
const handleSave = async (values: Record<string, unknown>) => {\n  setSaving(true);\n  try {\n    await api.put('/map/settings', values);\n    message.success('Map settings saved');\n  } catch (err: unknown) {\n    const msg =\n      (err as { response?: { data?: { error?: { message?: string } } } })\n        ?.response?.data?.error?.message || 'Failed to save settings';\n    message.error(msg);\n  } finally {\n    setSaving(false);\n  }\n};\n

Error Handling:

Extracts specific error message from API response:

// API error response format:\n{\n  \"error\": {\n    \"message\": \"Latitude must be between -90 and 90\"\n  }\n}\n\n// Extracted error message shown to user:\n\"Latitude must be between -90 and 90\"\n

Generic Fallback:

If error message not in expected format, shows generic message:

\"Failed to save settings\"\n
"},{"location":"v2/frontend/pages/admin/map-settings-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#debounced-city-search-400ms","title":"Debounced City Search (400ms)","text":"

City search queries geocoding service after 400ms delay:

cityTimerRef.current = setTimeout(async () => {\n  // Query geocoding service\n}, 400);\n

Performance Impact: - Without debounce: Typing \"Ottawa\" (6 chars) = 6 API calls - With 400ms debounce: Typing \"Ottawa\" = 1 API call (after 400ms pause) - 84% reduction in API calls

Why 400ms?

  • Slower typing: City names are longer than typical search queries
  • Expensive queries: Geocoding service queries external APIs (Nominatim, ArcGIS)
  • User expectation: Users expect slight delay when searching for places
"},{"location":"v2/frontend/pages/admin/map-settings-page/#live-preview-performance","title":"Live Preview Performance","text":"

Walk sheet preview updates on every form keystroke:

const watched = Form.useWatch(undefined, form);\n

Performance Impact: - Re-renders: Preview re-renders on every form value change - Expensive renders: QR code images regenerated (browser fetches new PNG from /api/qr) - Trade-off: Immediate feedback vs. performance

Mitigation:

Consider debouncing QR code updates:

const debouncedQrUrls = useDebounce([watched?.qrCode1Url, watched?.qrCode2Url, watched?.qrCode3Url], 500);\n

Result: - User types QR code URL - Preview shows old QR code for 500ms - After 500ms pause, QR code updates to new URL - Reduces API calls to /api/qr endpoint

"},{"location":"v2/frontend/pages/admin/map-settings-page/#print-css-performance","title":"Print CSS Performance","text":"

Print-specific CSS uses visibility: hidden instead of display: none:

body * { visibility: hidden !important; }\n.walk-sheet-print * { visibility: visible !important; }\n

Why Visibility?

  • Layout preserved: Hidden elements still occupy space (prevents layout shift)
  • Print performance: Browser doesn't need to recalculate layout for print
  • Consistent output: Print preview matches actual printed page

Comparison:

  • visibility: hidden \u2014 Element invisible but still occupies space
  • display: none \u2014 Element removed from layout entirely (causes layout shift)
"},{"location":"v2/frontend/pages/admin/map-settings-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#two-column-layout_1","title":"Two-Column Layout","text":"

Settings form and preview adapt to viewport width:

<Row gutter={24}>\n  <Col xs={24} lg={10}>  {/* Full width mobile, 40% desktop */}\n    <Form>{/* ... */}</Form>\n  </Col>\n  <Col xs={24} lg={14}>  {/* Full width mobile, 60% desktop */}\n    <Card title=\"Walk Sheet Preview\">{/* ... */}</Card>\n  </Col>\n</Row>\n

Responsive Breakpoints: - Mobile (xs, <992px): Stacked layout (form on top, preview below) - Desktop (lg, \u2265992px): Side-by-side layout (form 40%, preview 60%)

Why 40/60 Split?

  • Form needs less space: Most inputs are single-line (latitude, longitude, title)
  • Preview needs more space: Walk sheet is 8.5\" \u00d7 11\" (benefits from larger width)
  • Visual balance: 40/60 ratio feels more balanced than 50/50
"},{"location":"v2/frontend/pages/admin/map-settings-page/#mobile-print-behavior","title":"Mobile Print Behavior","text":"

On mobile devices, print preview is less practical:

Option 1: Hide preview on mobile

<Col xs={0} lg={14}>  {/* Hidden on mobile (xs={0}) */}\n  <Card title=\"Walk Sheet Preview\">{/* ... */}</Card>\n</Col>\n

Option 2: Show preview below form

<Col xs={24} lg={14}>  {/* Full width mobile, shown below form */}\n  <Card title=\"Walk Sheet Preview\">{/* ... */}</Card>\n</Col>\n

Current Implementation: Option 2 (show preview below form on mobile)

Rationale:

  • Mobile users can still see preview (helpful for QR code testing)
  • Print button still works on mobile (triggers native print dialog)
  • Stacked layout is natural on mobile (no horizontal scrolling)
"},{"location":"v2/frontend/pages/admin/map-settings-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

All interactive elements are keyboard-accessible:

City Search Autocomplete: - Tab: Focus on autocomplete input - Type: Enter city name - Down Arrow: Navigate dropdown options - Enter: Select focused option - Escape: Close dropdown

Form Fields: - Tab: Move between fields (latitude \u2192 longitude \u2192 zoom \u2192 title...) - Arrow Keys: Adjust zoom slider value - Enter: Submit form (same as clicking \"Save Settings\")

Buttons: - Tab: Focus on button - Enter/Space: Activate button (print, save)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#screen-reader-support","title":"Screen Reader Support","text":"

All elements have proper ARIA labels:

City Search:

<AutoComplete\n  placeholder=\"Search for a city to auto-fill coordinates...\"\n  aria-label=\"Search for a city to auto-fill map center coordinates\"\n  aria-describedby=\"city-search-hint\"\n/>\n<Text id=\"city-search-hint\" type=\"secondary\">\n  Search for a city to auto-fill coordinates. Fine-tune below.\n</Text>\n

Form Fields:

<Form.Item name=\"latitude\" label=\"Latitude\">\n  <InputNumber\n    aria-label=\"Map center latitude in decimal degrees\"\n    aria-valuemin={-90}\n    aria-valuemax={90}\n  />\n</Form.Item>\n

Walk Sheet Preview:

<Card\n  title=\"Walk Sheet Preview\"\n  aria-label=\"Live preview of walk sheet with current settings\"\n>\n  {/* ... */}\n</Card>\n

"},{"location":"v2/frontend/pages/admin/map-settings-page/#color-contrast","title":"Color Contrast","text":"

All text meets WCAG AA standards:

Form Labels: - Label text: rgba(0,0,0,0.85) on white = 13.6:1 contrast (AAA)

Helper Text: - Helper text: rgba(0,0,0,0.45) on white = 7.0:1 contrast (AA)

Walk Sheet Preview: - Header text: #000 on white = 21:1 contrast (AAA) - Field labels: #666 on white = 5.7:1 contrast (AA)

"},{"location":"v2/frontend/pages/admin/map-settings-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#city-search-not-working","title":"City Search Not Working","text":"

Problem: Type city name in autocomplete, but no dropdown suggestions appear.

Diagnosis:

Check browser console for errors:

GET /api/map/geocoding/search?q=Ottawa&limit=5 500 Internal Server Error\n

Possible Causes:

  1. Geocoding service down:
  2. All providers (Nominatim, ArcGIS, Photon) unavailable
  3. Network connectivity issue

  4. Invalid query:

  5. Query too short (< 2 characters)
  6. Special characters causing parsing errors

  7. Rate limiting:

  8. Too many requests to geocoding providers
  9. IP temporarily blocked

Solution:

  1. For service issues:
  2. Check geocoding service logs: docker compose logs api | grep geocoding
  3. Test Nominatim directly: curl \"https://nominatim.openstreetmap.org/search?q=Ottawa&format=json\"
  4. If down, wait 5 minutes and retry

  5. For invalid queries:

  6. Ensure at least 2 characters entered
  7. Try simpler query (e.g., \"Ottawa\" instead of \"Ottawa, ON, Canada\")

  8. For rate limiting:

  9. Wait 1 hour before retrying
  10. Use manual coordinate entry instead
  11. Consider adding Mapbox API key (higher rate limits)
"},{"location":"v2/frontend/pages/admin/map-settings-page/#qr-codes-not-showing-in-preview","title":"QR Codes Not Showing in Preview","text":"

Problem: Enter QR code URL in form, but QR code doesn't appear in preview.

Diagnosis:

Check QR code API endpoint:

curl \"http://localhost:4000/api/qr?text=https%3A%2F%2Fcmlite.org&size=200\" -o test-qr.png\n

Expected: PNG file downloaded

Actual: Error response

{\n  \"error\": \"Invalid size parameter\"\n}\n

Possible Causes:

  1. Invalid URL:
  2. QR code URL field contains invalid URL
  3. Special characters not URL-encoded

  4. QR API endpoint down:

  5. API container not running
  6. QR code generation service crashed

  7. Browser caching:

  8. Browser cached old QR code PNG
  9. Need to clear cache or force refresh

Solution:

  1. For invalid URLs:
  2. Ensure URL includes protocol: https:// not www.
  3. Test URL in browser: Click URL to verify it opens correctly
  4. Check for special characters: URL-encode if necessary

  5. For API issues:

  6. Check API logs: docker compose logs api | grep qr
  7. Restart API: docker compose restart api
  8. Test endpoint: curl \"http://localhost:4000/api/qr?text=test&size=200\"

  9. For caching:

  10. Hard refresh browser: Ctrl+Shift+R (Windows) or Cmd+Shift+R (Mac)
  11. Clear browser cache: Settings \u2192 Clear browsing data
  12. Add cache-busting param: &v=${Date.now()} to QR code URL
"},{"location":"v2/frontend/pages/admin/map-settings-page/#walk-sheet-prints-with-navigation","title":"Walk Sheet Prints with Navigation","text":"

Problem: Click \"Print\" button, but printed page includes navigation sidebar and header.

Diagnosis:

Check print preview (Ctrl+P or Cmd+P):

Expected: Only walk sheet visible

Actual: Full page (navigation, header, footer) visible

Possible Causes:

  1. Print CSS not applied:
  2. Browser not detecting print media query
  3. CSS @media print not working

  4. CSS specificity issue:

  5. Other stylesheets overriding print CSS
  6. !important flags not effective

  7. Browser print settings:

  8. \"Print backgrounds\" option disabled
  9. \"Headers and footers\" option enabled

Solution:

  1. For CSS issues (developer fix):
  2. Increase specificity: body * { visibility: hidden !important; }
  3. Use @page { margin: 0; } to remove default margins
  4. Test in multiple browsers (Chrome, Firefox, Safari)

  5. For browser settings:

  6. Enable \"Print backgrounds\" in print dialog
  7. Disable \"Headers and footers\" in print dialog
  8. Select \"None\" for margins

  9. Alternative (if CSS fails):

  10. Open walk sheet in new window: window.open('/walk-sheet-preview')
  11. Print from dedicated preview page (no navigation)
  12. Use \"Print to PDF\" and print PDF separately
"},{"location":"v2/frontend/pages/admin/map-settings-page/#coordinates-dont-update-map-center","title":"Coordinates Don't Update Map Center","text":"

Problem: Save new latitude/longitude in settings, but public map still shows old center.

Diagnosis:

Check public map settings fetch:

curl \"http://localhost:4000/api/map/settings\"\n

Expected: New coordinates returned

{\n  \"latitude\": \"45.4215\",\n  \"longitude\": \"-75.6972\"\n}\n

Actual: Old coordinates returned

{\n  \"latitude\": \"43.6532\",\n  \"longitude\": \"-79.3832\"\n}\n

Possible Causes:

  1. Settings not saved:
  2. Save button clicked but API request failed
  3. Error message shown but not noticed

  4. Database not updated:

  5. Database write failed (permissions issue)
  6. Transaction rolled back due to error

  7. Public map caching:

  8. Public map caching old settings in browser
  9. Need to clear cache or force refresh

Solution:

  1. For save issues:
  2. Check API logs: docker compose logs api | grep \"settings saved\"
  3. Retry save: Click \"Save Settings\" again
  4. Check for error messages: Look for red toast notification

  5. For database issues:

  6. Check database: docker compose exec v2-postgres psql -U postgres -d v2 -c \"SELECT * FROM \\\"MapSettings\\\"\"
  7. Verify coordinates match expected values
  8. If mismatch, manually update: UPDATE \"MapSettings\" SET latitude = '45.4215', longitude = '-75.6972'

  9. For caching:

  10. Hard refresh public map: Ctrl+Shift+R (Windows) or Cmd+Shift+R (Mac)
  11. Clear browser cache: Settings \u2192 Clear browsing data
  12. Check API response: Verify /api/map/settings returns new coordinates
"},{"location":"v2/frontend/pages/admin/map-settings-page/#related-documentation","title":"Related Documentation","text":"
  • Map Settings Backend Module \u2014 Backend settings service
  • Map Settings API Reference \u2014 Settings endpoints
  • Geocoding Service \u2014 Multi-provider geocoding
  • QR Code Module \u2014 QR code generation service
  • Public Map Page \u2014 Public map using settings
  • Walk Sheet Page \u2014 Printable walk sheet generator
  • LocationsPage \u2014 Location management
  • User Guide: Map Organizer \u2014 Map setup workflow
  • Troubleshooting: Geocoding Issues \u2014 Geocoding troubleshooting
"},{"location":"v2/frontend/pages/admin/mini-qr-page/","title":"MiniQRPage","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#overview","title":"Overview","text":"

File: admin/src/pages/MiniQRPage.tsx

Route: /app/services/mini-qr

Role Requirements: Any authenticated user (uses authenticate middleware)

Purpose: Provides an embedded interface to the Mini QR code generator service via iframe. This page serves as a simple wrapper that embeds the external QR code generation service with online/offline status monitoring and mobile device detection.

Key Features: - Full-page iframe embed of Mini QR service - Service online/offline status monitoring - Mobile device detection with warning screen - Fullbleed layout (no padding in AppLayout) - Automatic service health checks

Layout: AppLayout with fullbleed (no content padding)

Dependencies: - Ant Design v5 (Alert, Spin, Typography, Result, Button, Grid) - react hooks (useState, useEffect)

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#1-service-status-monitoring","title":"1. Service Status Monitoring","text":"

Status Display: - Green checkmark with \"Service Online\" when Mini QR is accessible - Red X with \"Service Offline\" when Mini QR is not accessible - Loading spinner during status check

Auto-refresh: - Status checked on page load - No automatic periodic refresh (manual refresh via browser required)

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"

Mobile Warning Screen: - Detects mobile devices using Grid.useBreakpoint() - Shows warning Result component on mobile - Recommends using desktop for better QR code generation experience - Provides link to open service in new tab

Breakpoint: !screens.md (screen width < 768px = mobile)

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#3-iframe-embedding","title":"3. Iframe Embedding","text":"

Fullbleed Layout: - No padding around iframe - 100% width and height - Seamless integration with AppLayout

Error Handling: - Shows error message if iframe fails to load - Provides troubleshooting guidance

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#accessing-mini-qr-service","title":"Accessing Mini QR Service","text":"
  1. Navigate to Mini QR:
  2. Click \"Services\" \u2192 \"Mini QR\" in sidebar
  3. Page loads with status check

  4. Check Service Status:

  5. Status indicator appears at top:
    • \u2705 \"Service Online\" (green) - Service available
    • \u274c \"Service Offline\" (red) - Service unavailable
  6. Loading spinner shown during status check

  7. View on Desktop:

  8. If on desktop (screen width \u2265 768px):

    • Iframe loads automatically below status
    • Full Mini QR interface embedded in page
    • Use QR generator as normal
  9. View on Mobile:

  10. If on mobile (screen width < 768px):

    • Warning message appears instead of iframe
    • Message: \"Mini QR is best used on desktop\"
    • \"Open in New Tab\" button provided
    • Click button to open service in separate browser tab
  11. Using Mini QR Service:

  12. Enter text or URL to encode
  13. Select QR code size/format
  14. Generate QR code
  15. Download QR code image

  16. Troubleshoot Offline Service:

  17. If service shows \"Offline\":
    • Check Docker container: docker compose ps mini-qr
    • Restart service: docker compose restart mini-qr
    • Verify nginx routing: Check nginx/conf.d/services.conf
  18. Refresh page after fixing
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#main-component-structure","title":"Main Component Structure","text":"
const MiniQRPage: React.FC = () => {\n  // State\n  const [loading, setLoading] = useState(true);\n  const [online, setOnline] = useState(false);\n\n  // Responsive breakpoints\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n\n  // Check service status on mount\n  useEffect(() => {\n    checkServiceStatus();\n  }, []);\n\n  const checkServiceStatus = async () => {\n    try {\n      setLoading(true);\n      const response = await api.get('/api/services/mini-qr/status');\n      setOnline(response.data.online);\n    } catch (error) {\n      console.error('Failed to check Mini QR status:', error);\n      setOnline(false);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  // Show mobile warning if on mobile device\n  if (isMobile) {\n    return (\n      <Result\n        icon={<MobileOutlined />}\n        title=\"Desktop Recommended\"\n        subTitle=\"Mini QR is best used on desktop for optimal QR code generation experience.\"\n        extra={\n          <Button\n            type=\"primary\"\n            href=\"http://qr.cmlite.org\"\n            target=\"_blank\"\n          >\n            Open in New Tab\n          </Button>\n        }\n      />\n    );\n  }\n\n  return (\n    <div style={{ height: '100%' }}>\n      {/* Status indicator */}\n      {loading ? (\n        <Spin />\n      ) : (\n        <Alert\n          type={online ? 'success' : 'error'}\n          message={online ? 'Service Online' : 'Service Offline'}\n          showIcon\n        />\n      )}\n\n      {/* Iframe embed */}\n      {online && !loading && (\n        <iframe\n          src=\"http://qr.cmlite.org\"\n          style={{\n            width: '100%',\n            height: 'calc(100% - 60px)',  // Subtract status bar height\n            border: 'none',\n          }}\n          title=\"Mini QR Code Generator\"\n        />\n      )}\n    </div>\n  );\n};\n
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  1. Alert - Service status indicator (success/error)
  2. Spin - Loading spinner during status check
  3. Result - Mobile warning screen with icon and message
  4. Button - \"Open in New Tab\" action button
  5. Typography.Text - Descriptive text (if needed)
  6. Grid.useBreakpoint() - Responsive breakpoint detection
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#iframe-configuration","title":"Iframe Configuration","text":"
<iframe\n  src=\"http://qr.cmlite.org\"  // Mini QR service URL (nginx proxied)\n  style={{\n    width: '100%',             // Full container width\n    height: 'calc(100% - 60px)',  // Full height minus status bar\n    border: 'none',            // No border for seamless integration\n  }}\n  title=\"Mini QR Code Generator\"  // Accessibility title\n  sandbox=\"allow-same-origin allow-scripts allow-forms\"  // Security sandbox\n/>\n

Sandbox Attributes: - allow-same-origin - Allows iframe to access cookies/localStorage - allow-scripts - Allows JavaScript execution - allow-forms - Allows form submission

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#local-component-state-usestate","title":"Local Component State (useState)","text":"

No Zustand stores used - All state managed locally with React hooks.

// Service status loading state\nconst [loading, setLoading] = useState(true);\n\n// Service online/offline state\nconst [online, setOnline] = useState(false);\n\n// Responsive breakpoint detection\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;  // Mobile if screen width < 768px\n
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#state-flow","title":"State Flow","text":"
  1. Component Mounts:
  2. checkServiceStatus() called in useEffect
  3. Sets loading to true
  4. Fetches service status via GET /api/services/mini-qr/status
  5. Sets online to true or false based on response
  6. Sets loading to false

  7. Service Online:

  8. online is true
  9. Alert shows \"Service Online\" (green)
  10. Iframe renders with Mini QR service embedded

  11. Service Offline:

  12. online is false
  13. Alert shows \"Service Offline\" (red)
  14. No iframe rendered (blank space below alert)

  15. Mobile Device:

  16. isMobile is true
  17. Component returns early with warning Result
  18. No status check, no iframe
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/services/mini-qr/status - Check Mini QR service health
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#api-client","title":"API Client","text":"
import { api } from '@/lib/api';\n\n// All requests use authenticated API client with automatic token refresh\n
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#example-api-call","title":"Example API Call","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#check-service-status","title":"Check Service Status","text":"
const checkServiceStatus = async () => {\n  try {\n    setLoading(true);\n\n    // Check service health\n    const response = await api.get('/api/services/mini-qr/status');\n\n    // Set online state based on response\n    setOnline(response.data.online);\n  } catch (error) {\n    console.error('Failed to check Mini QR status:', error);\n\n    // Treat any error as offline\n    setOnline(false);\n  } finally {\n    setLoading(false);\n  }\n};\n\nuseEffect(() => {\n  checkServiceStatus();\n}, []);\n

Response Format (Online):

{\n  \"online\": true,\n  \"url\": \"http://qr.cmlite.org\",\n  \"message\": \"Mini QR service is online\"\n}\n

Response Format (Offline):

{\n  \"online\": false,\n  \"message\": \"Mini QR service is offline\",\n  \"error\": \"Connection refused\"\n}\n

Error Handling: - Network errors (500, 503): Treated as offline - Timeout errors: Treated as offline - CORS errors: Treated as offline (service not accessible)

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#complete-component-implementation","title":"Complete Component Implementation","text":"
import React, { useState, useEffect } from 'react';\nimport { Alert, Spin, Typography, Result, Button, Grid } from 'antd';\nimport { MobileOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';\nimport { api } from '@/lib/api';\n\nconst { Title } = Typography;\n\nconst MiniQRPage: React.FC = () => {\n  const [loading, setLoading] = useState(true);\n  const [online, setOnline] = useState(false);\n\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n\n  useEffect(() => {\n    checkServiceStatus();\n  }, []);\n\n  const checkServiceStatus = async () => {\n    try {\n      setLoading(true);\n      const response = await api.get('/api/services/mini-qr/status');\n      setOnline(response.data.online);\n    } catch (error) {\n      console.error('Failed to check Mini QR status:', error);\n      setOnline(false);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  // Show mobile warning on mobile devices\n  if (isMobile) {\n    return (\n      <Result\n        icon={<MobileOutlined style={{ fontSize: 72, color: '#faad14' }} />}\n        title=\"Desktop Recommended\"\n        subTitle=\"Mini QR is best used on desktop for optimal QR code generation experience. You can open the service in a new tab to use it on mobile.\"\n        extra={\n          <Button\n            type=\"primary\"\n            size=\"large\"\n            href=\"http://qr.cmlite.org\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n          >\n            Open in New Tab\n          </Button>\n        }\n      />\n    );\n  }\n\n  return (\n    <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>\n      {/* Status Bar */}\n      <div style={{ padding: '16px 0' }}>\n        {loading ? (\n          <div style={{ textAlign: 'center' }}>\n            <Spin tip=\"Checking service status...\" />\n          </div>\n        ) : (\n          <Alert\n            type={online ? 'success' : 'error'}\n            message={\n              online ? (\n                <>\n                  <CheckCircleOutlined style={{ marginRight: 8 }} />\n                  Service Online\n                </>\n              ) : (\n                <>\n                  <CloseCircleOutlined style={{ marginRight: 8 }} />\n                  Service Offline\n                </>\n              )\n            }\n            description={\n              online\n                ? 'Mini QR service is running and accessible'\n                : 'Mini QR service is currently unavailable. Please check Docker container status.'\n            }\n            showIcon\n            banner\n          />\n        )}\n      </div>\n\n      {/* Iframe Embed */}\n      {online && !loading && (\n        <iframe\n          src=\"http://qr.cmlite.org\"\n          style={{\n            width: '100%',\n            height: 'calc(100% - 80px)',  // Subtract status bar height\n            border: 'none',\n            flexGrow: 1,\n          }}\n          title=\"Mini QR Code Generator\"\n          sandbox=\"allow-same-origin allow-scripts allow-forms allow-downloads\"\n          loading=\"lazy\"\n        />\n      )}\n\n      {/* Offline Message */}\n      {!online && !loading && (\n        <div style={{ padding: 24, textAlign: 'center' }}>\n          <Title level={4}>Service Not Available</Title>\n          <Typography.Paragraph>\n            The Mini QR service is currently offline. Please contact your system administrator\n            or check the Docker container status.\n          </Typography.Paragraph>\n          <Button onClick={checkServiceStatus}>\n            Retry\n          </Button>\n        </div>\n      )}\n    </div>\n  );\n};\n\nexport default MiniQRPage;\n
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#mobile-detection-pattern","title":"Mobile Detection Pattern","text":"
import { Grid } from 'antd';\n\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;  // Mobile if screen width < 768px\n\nif (isMobile) {\n  return (\n    <Result\n      icon={<MobileOutlined />}\n      title=\"Desktop Recommended\"\n      subTitle=\"This service works best on desktop devices.\"\n      extra={\n        <Button type=\"primary\" href=\"<service-url>\" target=\"_blank\">\n          Open in New Tab\n        </Button>\n      }\n    />\n  );\n}\n

Breakpoint Values: - xs: < 576px (extra small) - sm: \u2265 576px (small) - md: \u2265 768px (medium) \u2190 Used for mobile detection - lg: \u2265 992px (large) - xl: \u2265 1200px (extra large) - xxl: \u2265 1600px (extra extra large)

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#fullbleed-layout-pattern","title":"Fullbleed Layout Pattern","text":"
// In App.tsx route configuration\n<Route\n  path=\"/app/services/mini-qr\"\n  element={<MiniQRPage />}\n/>\n\n// In MiniQRPage component\nconst MiniQRPage: React.FC = () => {\n  return (\n    <div style={{ height: '100%' }}>  {/* Full height container */}\n      <iframe\n        src=\"http://qr.cmlite.org\"\n        style={{\n          width: '100%',              // Full width\n          height: 'calc(100% - 60px)',  // Full height minus status bar\n          border: 'none',             // No border\n        }}\n      />\n    </div>\n  );\n};\n\n// AppLayout automatically applies fullbleed styling (no padding)\n// when route is detected as service page\n
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#service-status-check-with-error-handling","title":"Service Status Check with Error Handling","text":"
const checkServiceStatus = async () => {\n  try {\n    setLoading(true);\n\n    // Set timeout for status check (5 seconds)\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), 5000);\n\n    const response = await api.get('/api/services/mini-qr/status', {\n      signal: controller.signal,\n    });\n\n    clearTimeout(timeoutId);\n\n    // Check response\n    if (response.data.online) {\n      setOnline(true);\n    } else {\n      setOnline(false);\n      console.warn('Mini QR service reported as offline:', response.data.message);\n    }\n  } catch (error) {\n    // Handle different error types\n    if (error.name === 'AbortError') {\n      console.error('Mini QR status check timed out');\n    } else if (error.response) {\n      console.error('Mini QR status check failed with status:', error.response.status);\n    } else {\n      console.error('Mini QR status check failed:', error.message);\n    }\n\n    // Always treat errors as offline\n    setOnline(false);\n  } finally {\n    setLoading(false);\n  }\n};\n
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#1-lazy-iframe-loading","title":"1. Lazy Iframe Loading","text":"
<iframe\n  src=\"http://qr.cmlite.org\"\n  loading=\"lazy\"  // Defers iframe loading until near viewport\n  // ... other props\n/>\n

Benefit: Saves bandwidth and CPU by not loading iframe until needed. However, since iframe is typically in viewport immediately, this has minimal impact.

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#2-early-mobile-detection","title":"2. Early Mobile Detection","text":"
// Check mobile before any API calls or rendering\nif (isMobile) {\n  return <Result />;  // Render warning immediately, no API calls\n}\n

Benefit: Avoids unnecessary service status checks on mobile devices, saving API requests and improving page load time.

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#3-single-status-check","title":"3. Single Status Check","text":"
useEffect(() => {\n  checkServiceStatus();  // Only check once on mount\n}, []);  // Empty dependency array = run once\n

Benefit: Minimizes API requests. Status is checked once and cached. Manual refresh required to re-check (acceptable for service status).

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#4-abort-controller-for-timeout","title":"4. Abort Controller for Timeout","text":"
const controller = new AbortController();\nconst timeoutId = setTimeout(() => controller.abort(), 5000);\n\nconst response = await api.get('/api/services/mini-qr/status', {\n  signal: controller.signal,\n});\n\nclearTimeout(timeoutId);\n

Benefit: Prevents long-hanging requests if service is slow or unresponsive. Improves user experience by failing fast (5s timeout).

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#breakpoint-based-mobile-detection","title":"Breakpoint-Based Mobile Detection","text":"
const screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;  // Mobile if screen width < 768px\n

Responsive Behavior: - Desktop (\u2265 768px): Full iframe embed with status bar - Mobile (< 768px): Warning Result with \"Open in New Tab\" button

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#mobile-warning-screen","title":"Mobile Warning Screen","text":"
if (isMobile) {\n  return (\n    <Result\n      icon={<MobileOutlined style={{ fontSize: 72, color: '#faad14' }} />}\n      title=\"Desktop Recommended\"\n      subTitle=\"Mini QR is best used on desktop for optimal QR code generation experience.\"\n      extra={\n        <Button\n          type=\"primary\"\n          size=\"large\"\n          href=\"http://qr.cmlite.org\"\n          target=\"_blank\"\n        >\n          Open in New Tab\n        </Button>\n      }\n    />\n  );\n}\n

Why Mobile Warning? - QR code generation requires precise input (URLs, text) - Small mobile screens make QR code preview difficult - Download/save functionality better on desktop - Iframe scrolling awkward on mobile

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#iframe-height-calculation","title":"Iframe Height Calculation","text":"
<iframe\n  style={{\n    width: '100%',\n    height: 'calc(100% - 80px)',  // Full height minus status bar (80px)\n    border: 'none',\n  }}\n/>\n

Responsive Height: - Uses CSS calc() to subtract status bar height from 100% - Ensures iframe fills remaining vertical space - No fixed height, adapts to browser window size

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#keyboard-navigation","title":"Keyboard Navigation","text":"
  1. Tab Key:
  2. Focuses \"Open in New Tab\" button (mobile view)
  3. Enters iframe (desktop view)
  4. Navigates through iframe content (if iframe supports tab navigation)

  5. Enter Key:

  6. Activates \"Open in New Tab\" button
  7. Interacts with iframe elements

  8. Escape Key:

  9. No special behavior (iframe handles internally)
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#aria-labels","title":"ARIA Labels","text":"
<iframe\n  src=\"http://qr.cmlite.org\"\n  title=\"Mini QR Code Generator\"  // Screen reader announces iframe purpose\n  aria-label=\"Embedded Mini QR code generator service\"\n  role=\"application\"  // Indicates embedded application\n/>\n\n<Button\n  aria-label=\"Open Mini QR service in new browser tab\"\n  href=\"http://qr.cmlite.org\"\n  target=\"_blank\"\n>\n  Open in New Tab\n</Button>\n\n<Result\n  icon={<MobileOutlined aria-hidden=\"true\" />}  // Icon is decorative\n  title=\"Desktop Recommended\"  // Screen reader announces title\n  subTitle=\"Mini QR is best used on desktop...\"  // Screen reader announces subtitle\n/>\n
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#color-contrast","title":"Color Contrast","text":"

All text meets WCAG AA standards: - Success alert background: #f6ffed with text #52c41a (contrast ratio 4.5:1) - Error alert background: #fff2f0 with text #ff4d4f (contrast ratio 4.5:1) - Button text: White on #1890ff (contrast ratio 4.5:1)

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#screen-reader-support","title":"Screen Reader Support","text":"
  • Iframe has descriptive title attribute
  • Status alerts have role=\"alert\" for announcements
  • Result component announces title and subtitle
  • Button has descriptive text and aria-label
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#problem-service-shows-offline-despite-container-running","title":"Problem: Service Shows \"Offline\" Despite Container Running","text":"

Symptoms: - Status bar shows \"Service Offline\" (red) - Mini QR Docker container is running (docker compose ps shows \"Up\") - Iframe does not load

Causes: 1. Nginx routing misconfiguration 2. Service listening on wrong port 3. Network connectivity issues 4. CORS policy blocking status check

Solutions:

  1. Verify Docker container status:

    docker compose ps mini-qr\n# Should show \"Up\" status\n\ndocker compose logs mini-qr\n# Check for error messages\n

  2. Check nginx routing:

  3. Open nginx/conf.d/services.conf
  4. Verify Mini QR proxy block exists:
    location /qr/ {\n  proxy_pass http://mini-qr:8089/;\n  proxy_set_header Host $host;\n  proxy_set_header X-Real-IP $remote_addr;\n}\n
  5. Restart nginx: docker compose restart nginx

  6. Test direct access:

  7. Open browser
  8. Navigate to http://localhost:8089 (direct container port)
  9. If accessible directly but not through nginx, routing issue
  10. If not accessible directly, service issue

  11. Check service health endpoint:

    curl http://localhost:8089/health\n# Should return 200 OK\n

  12. Verify API endpoint:

  13. Open browser DevTools (F12)
  14. Go to Network tab
  15. Refresh page
  16. Look for GET /api/services/mini-qr/status request
  17. Check response:

    • 200 OK with {\"online\": true} - Service should work
    • 200 OK with {\"online\": false} - Service health check failed
    • 500/503 - API error
  18. Restart services:

    docker compose restart mini-qr nginx api\n

"},{"location":"v2/frontend/pages/admin/mini-qr-page/#problem-iframe-not-loading-even-when-service-is-online","title":"Problem: Iframe Not Loading Even When Service is Online","text":"

Symptoms: - Status bar shows \"Service Online\" (green) - Iframe appears as blank white rectangle - No error messages in console

Causes: 1. CORS policy blocking iframe embedding 2. X-Frame-Options header preventing embedding 3. Content Security Policy (CSP) blocking iframe 4. Service URL incorrect

Solutions:

  1. Check browser console for errors:
  2. Open DevTools (F12)
  3. Go to Console tab
  4. Look for errors like:
    • \"Refused to display in a frame because it set 'X-Frame-Options' to 'deny'\"
    • \"Refused to frame because it violates the following Content Security Policy directive\"
  5. These indicate CORS/CSP blocking

  6. Verify X-Frame-Options header:

  7. Check nginx config for Mini QR:
    location /qr/ {\n  proxy_pass http://mini-qr:8089/;\n  # Remove or comment out X-Frame-Options\n  # add_header X-Frame-Options \"DENY\";\n}\n
  8. Or set to SAMEORIGIN to allow same-domain embedding:

    add_header X-Frame-Options \"SAMEORIGIN\";\n

  9. Check iframe sandbox attributes:

    <iframe\n  sandbox=\"allow-same-origin allow-scripts allow-forms allow-downloads\"\n  // Add more permissions if needed:\n  // allow-popups, allow-top-navigation, etc.\n/>\n

  10. Test iframe in isolation:

  11. Create simple HTML file:
    <!DOCTYPE html>\n<html>\n<body>\n  <iframe src=\"http://qr.cmlite.org\" width=\"800\" height=\"600\"></iframe>\n</body>\n</html>\n
  12. Open in browser
  13. If iframe works here but not in React app, React-specific issue
  14. If iframe doesn't work here either, service configuration issue

  15. Verify service URL:

  16. Check iframe src attribute in code
  17. Should be http://qr.cmlite.org (nginx proxied)
  18. Try direct URL: http://localhost:8089 (for testing only)
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#problem-mobile-warning-shows-on-desktop","title":"Problem: Mobile Warning Shows on Desktop","text":"

Symptoms: - Viewing page on desktop computer (large screen) - Warning \"Desktop Recommended\" appears instead of iframe - Screen width clearly > 768px

Causes: 1. Browser zoom level causing incorrect breakpoint detection 2. Browser window width < 768px (narrow window) 3. DevTools open in side-by-side mode reducing width 4. Cached breakpoint state

Solutions:

  1. Check browser zoom:
  2. Press Ctrl+0 (Windows/Linux) or Cmd+0 (Mac) to reset zoom to 100%
  3. Refresh page

  4. Maximize browser window:

  5. Click maximize button or press F11 for fullscreen
  6. Ensure window width > 768px
  7. Refresh page

  8. Close DevTools or dock to bottom:

  9. If DevTools open in side-by-side mode, window width reduced
  10. Close DevTools (F12) or dock to bottom
  11. Refresh page

  12. Check breakpoint detection:

  13. Open browser console (F12)
  14. Type: window.innerWidth
  15. If < 768, window too narrow
  16. Resize window wider and refresh

  17. Clear browser cache:

  18. Hard refresh: Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (Mac)
  19. Or clear browser cache entirely
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#problem-retry-button-does-nothing","title":"Problem: \"Retry\" Button Does Nothing","text":"

Symptoms: - Service shows \"Offline\" - Click \"Retry\" button - Nothing happens, still shows \"Offline\"

Causes: 1. Service genuinely offline (not a UI bug) 2. Network connectivity issues 3. API endpoint not responding

Solutions:

  1. Wait before retrying:
  2. Service may need time to start
  3. Wait 30-60 seconds
  4. Click \"Retry\" again

  5. Check Docker containers:

    docker compose ps\n# Verify mini-qr, nginx, api all show \"Up\"\n\ndocker compose logs mini-qr\n# Check for startup errors\n

  6. Restart services:

    docker compose restart mini-qr nginx api\n# Wait 30 seconds for services to fully start\n# Refresh page and click \"Retry\"\n

  7. Check network connectivity:

    curl http://localhost:8089/health\n# Should return 200 OK\n\ncurl http://localhost:4000/api/services/mini-qr/status\n# Should return {\"online\": true}\n

  8. Hard refresh page:

  9. Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (Mac)
  10. Forces fresh status check
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#problem-iframe-content-not-responsive","title":"Problem: Iframe Content Not Responsive","text":"

Symptoms: - Iframe loads correctly - Mini QR interface inside iframe is cut off or has horizontal scrollbar - Cannot see full QR generator form

Causes: 1. Mini QR service not responsive 2. Iframe width constraints 3. Service has minimum width requirement

Solutions:

  1. Check iframe width:
  2. Inspect iframe element in DevTools
  3. Verify width: 100% applied
  4. Verify parent container has sufficient width

  5. Remove iframe sandbox (temporarily):

    <iframe\n  src=\"http://qr.cmlite.org\"\n  // Remove sandbox for testing\n  // sandbox=\"allow-same-origin allow-scripts allow-forms\"\n/>\n

  6. If content becomes responsive without sandbox, sandbox is blocking responsive behavior
  7. Add back sandbox with minimal restrictions

  8. Use viewport meta tag in service:

  9. If Mini QR is custom service, add to its HTML:

    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n

  10. Scale iframe content:

    <iframe\n  style={{\n    width: '100%',\n    height: 'calc(100% - 80px)',\n    border: 'none',\n    transform: 'scale(0.9)',  // Scale down content\n    transformOrigin: 'top left',\n  }}\n/>\n

  11. Open in new tab:

  12. If iframe content truly not responsive, use \"Open in New Tab\" approach
  13. Remove iframe, show button like mobile view
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#backend-documentation","title":"Backend Documentation","text":"
  • Services Routes - API endpoints for service health checks
  • Nginx Configuration - Reverse proxy setup for Mini QR
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#frontend-documentation","title":"Frontend Documentation","text":"
  • AppLayout - Fullbleed layout configuration
  • Service Pages Pattern - Common patterns for service iframe pages
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#feature-documentation","title":"Feature Documentation","text":"
  • QR Code System - Mini QR service integration
  • Service Management - Docker service orchestration
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#api-documentation","title":"API Documentation","text":"
  • GET /api/services/mini-qr/status - Check Mini QR service health
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#deployment-documentation","title":"Deployment Documentation","text":"
  • Mini QR Setup - Docker container configuration
  • Service Monitoring - Health check patterns
"},{"location":"v2/frontend/pages/admin/mini-qr-page/#development-documentation","title":"Development Documentation","text":"
  • Iframe Security - Sandbox attributes and CSP
  • Responsive Design - Mobile detection patterns
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/","title":"MkDocsSettingsPage","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#overview","title":"Overview","text":"

File: admin/src/pages/MkDocsSettingsPage.tsx

Route: /app/docs/settings

Role Requirements: SUPER_ADMIN only

Purpose: Comprehensive MkDocs configuration editor providing four different interfaces for managing documentation site settings, navigation structure, raw YAML configuration, and static site builds. This page serves as the central control panel for the documentation system.

Key Features: - Four-tab interface (Settings, Navigation, YAML Editor, Build) - Visual form-based settings editor with validation - Interactive drag-and-drop navigation tree builder - Raw YAML editor with syntax highlighting and keyboard shortcuts - Orphaned file detection and management - Campaign link integration - Static site build triggering - Mobile-responsive YAML editor warning

Layout: Full AppLayout with sidebar navigation

Dependencies: - Ant Design v5 (Form, Input, Switch, Tree, Modal, Button, Card, Tabs, Alert, Typography, message) - @monaco-editor/react for YAML editing - yaml library for parsing/stringifying - dayjs for date formatting - react-beautiful-dnd for drag-and-drop (tree operations)

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#1-settings-tab-form-based-configuration","title":"1. Settings Tab (Form-Based Configuration)","text":"

Form Fields: - Site Name (Input) - Documentation site title - Site URL (Input.TextArea) - Public site URL - Site Description (Input.TextArea) - Site meta description - Site Author (Input) - Author attribution - Copyright (Input) - Copyright notice - Repo URL (Input) - GitHub repository URL - Repo Name (Input) - Repository display name - Edit URI (Input) - Edit page URI pattern - Theme Name (Input) - MkDocs theme selection - Primary Color (Input) - Theme primary color (hex) - Accent Color (Input) - Theme accent color (hex) - Features (dynamic tag input) - Theme feature toggles - Plugins (dynamic tag input) - MkDocs plugins - Markdown Extensions (dynamic tag input) - Markdown extension list - Extra CSS (dynamic tag input) - Additional CSS file paths - Extra JavaScript (dynamic tag input) - Additional JS file paths

Validation: - URL fields validated for proper format - Color fields validated for hex format (#RRGGBB) - Required fields enforced (site name, theme)

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#2-navigation-tab-visual-tree-builder","title":"2. Navigation Tab (Visual Tree Builder)","text":"

Navigation Tree: - Hierarchical tree view of site navigation structure - Drag-and-drop reordering (via react-beautiful-dnd) - Section/page distinction (folder icons vs file icons) - Expandable/collapsible sections - Edit titles inline - Add/remove navigation items - Orphaned file detection

Orphaned Files Section: - Separate panel showing markdown files not in navigation - Drag files from orphaned list to navigation tree - One-click \"Add to Navigation\" buttons - File path display with relative paths

Campaign Link Integration: - Dedicated \"Add Campaign Link\" button - Fetches active campaigns via API - Generates campaign page links automatically - Inserts into navigation tree

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#3-yaml-editor-tab-raw-configuration","title":"3. YAML Editor Tab (Raw Configuration)","text":"

Monaco Editor: - Full YAML syntax highlighting - 600px height - Auto-formatting on save - Keyboard shortcuts: - Ctrl+S / Cmd+S - Save changes - Ctrl+F - Find - Ctrl+H - Find and replace

Custom YAML Parsing: - Handles Python tags (e.g., !relative $config_dir/includes) - Preserves tag structure during parse/stringify - Error handling for invalid YAML

Mobile Warning: - Detects mobile devices with Grid.useBreakpoint() - Shows warning Alert if !screens.md - Recommends using desktop for YAML editing

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#4-build-tab-static-site-generation","title":"4. Build Tab (Static Site Generation)","text":"

Build Trigger: - Manual build button - Builds MkDocs static site - Shows success/error messages - Displays build timestamp (last successful build)

Build Status: - Last build date (formatted with dayjs) - Build in progress indicator - Build error display

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#editing-basic-settings","title":"Editing Basic Settings","text":"
  1. Navigate to MkDocs Settings:
  2. Click \"Documentation\" \u2192 \"MkDocs Settings\" in sidebar
  3. Page loads with 4 tabs at top

  4. Select Settings Tab:

  5. First tab is active by default
  6. Shows form with ~15 fields

  7. Edit Site Information:

  8. Update site name: \"Changemaker Lite Documentation\"
  9. Update site description
  10. Set author name
  11. Configure copyright notice

  12. Configure Repository Links:

  13. Enter GitHub repository URL
  14. Set repository name (e.g., \"changemaker-lite\")
  15. Configure edit URI pattern (e.g., \"edit/main/docs/\")

  16. Customize Theme:

  17. Select theme name (e.g., \"material\")
  18. Set primary color (hex, e.g., \"#1976d2\")
  19. Set accent color (hex, e.g., \"#f50057\")
  20. Add theme features as tags:

    • Type \"navigation.tabs\" and press Enter
    • Type \"toc.integrate\" and press Enter
    • Repeat for other features
  21. Configure Plugins:

  22. Add plugins as tags:
    • \"search\"
    • \"minify\"
    • \"git-revision-date-localized\"
  23. Click X on tags to remove

  24. Add Markdown Extensions:

  25. Add extensions as tags:

    • \"pymdownx.highlight\"
    • \"pymdownx.superfences\"
    • \"admonition\"
  26. Save Settings:

  27. Click \"Save Changes\" button at bottom
  28. Success message appears
  29. Settings persisted to database
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#building-navigation-structure","title":"Building Navigation Structure","text":"
  1. Navigate to Navigation Tab:
  2. Click \"Navigation\" tab (second tab)
  3. Tree view loads with current navigation

  4. Understand Tree Structure:

  5. Sections: Items with children (folder icon)
    • Example: \"Getting Started\" with sub-pages
  6. Pages: Leaf items (file icon)
    • Example: \"Installation.md\"
  7. Expandable: Click arrow to expand/collapse sections

  8. Reorder Items (Drag and Drop):

  9. Click and hold on navigation item
  10. Drag to new position
  11. Drop to reorder
  12. Changes saved automatically

  13. Add New Section:

  14. Click \"Add Section\" button
  15. Modal appears
  16. Enter section title (e.g., \"API Reference\")
  17. Click \"Create\"
  18. New section appears in tree

  19. Add New Page:

  20. Click \"Add Page\" button
  21. Modal appears
  22. Enter page title (e.g., \"Authentication API\")
  23. Enter file path (e.g., \"api/authentication.md\")
  24. Click \"Create\"
  25. New page appears in tree

  26. Edit Navigation Item:

  27. Click \"Edit\" icon next to item
  28. Modal appears with title field
  29. Update title
  30. Click \"Save\"

  31. Remove Navigation Item:

  32. Click \"Delete\" icon next to item
  33. Confirmation modal appears
  34. Click \"Confirm\" to remove
  35. Item removed from navigation (file remains on disk)

  36. Handle Orphaned Files:

  37. Scroll to \"Orphaned Files\" section at bottom
  38. See list of markdown files not in navigation
  39. Two options per file:
    • Drag to tree: Click and drag file to navigation tree
    • Add button: Click \"Add to Navigation\" for default placement
  40. File moves from orphaned list to navigation tree

  41. Add Campaign Links:

  42. Click \"Add Campaign Link\" button
  43. Modal appears with campaign dropdown
  44. Select active campaign from list
  45. Click \"Add\"
  46. Campaign link inserted into navigation with auto-generated path

  47. Save Navigation:

    • Click \"Save Navigation\" button at bottom
    • Navigation structure persisted
    • MkDocs site rebuilt with new structure
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#advanced-yaml-editing","title":"Advanced YAML Editing","text":"
  1. Navigate to YAML Editor Tab:
  2. Click \"YAML Editor\" tab (third tab)
  3. Monaco editor loads with current mkdocs.yml content

  4. Check Mobile Warning:

  5. If on mobile device, warning Alert shows:

    • \"YAML Editor is best used on desktop\"
    • \"Consider using Settings or Navigation tabs instead\"
  6. Edit Raw YAML:

  7. Click in editor to position cursor
  8. Edit YAML directly:

    site_name: Changemaker Lite Documentation\nsite_url: https://docs.cmlite.org\ntheme:\n  name: material\n  palette:\n    primary: blue\n    accent: pink\n  features:\n    - navigation.tabs\n    - toc.integrate\n

  9. Use Keyboard Shortcuts:

  10. Ctrl+S (Cmd+S on Mac): Save changes immediately
  11. Ctrl+F: Open find dialog
  12. Ctrl+H: Open find and replace dialog
  13. Ctrl+Z: Undo
  14. Ctrl+Y: Redo

  15. Handle Python Tags:

  16. Editor preserves custom Python tags:
    nav:\n  - Home: !relative $config_dir/index.md\n
  17. Tags preserved during parse/stringify cycle

  18. Save YAML:

  19. Click \"Save Changes\" button below editor
  20. OR press Ctrl+S keyboard shortcut
  21. Success message appears
  22. YAML validated and saved to database

  23. Handle YAML Errors:

  24. If YAML is invalid, error message appears:
    • \"Invalid YAML syntax\"
    • Shows line number of error
  25. Fix syntax and try saving again
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#building-static-site","title":"Building Static Site","text":"
  1. Navigate to Build Tab:
  2. Click \"Build\" tab (fourth tab)
  3. Build status card loads

  4. Check Last Build:

  5. See \"Last Build\" timestamp
  6. Example: \"Built 2 hours ago\"

  7. Trigger New Build:

  8. Click \"Build Site\" button
  9. Button shows loading spinner
  10. Build starts in background

  11. Monitor Build Progress:

  12. \"Building...\" status appears
  13. Wait 10-30 seconds for build to complete

  14. View Build Result:

  15. Success: Green checkmark, \"Build completed successfully\"
  16. Error: Red X, error message displayed

  17. Access Built Site:

  18. Built site served at http://localhost:4001 (production)
  19. Or http://localhost:4003 (dev server)
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#main-component-structure","title":"Main Component Structure","text":"
const MkDocsSettingsPage: React.FC = () => {\n  // State\n  const [activeTab, setActiveTab] = useState<'settings' | 'navigation' | 'yaml' | 'build'>('settings');\n  const [config, setConfig] = useState<MkDocsConfig | null>(null);\n  const [navStructure, setNavStructure] = useState<NavItem[]>([]);\n  const [orphanedFiles, setOrphanedFiles] = useState<string[]>([]);\n  const [yamlContent, setYamlContent] = useState<string>('');\n  const [loading, setLoading] = useState(false);\n  const [saving, setSaving] = useState(false);\n  const [building, setBuilding] = useState(false);\n  const [lastBuild, setLastBuild] = useState<string | null>(null);\n\n  // Form instance for Settings tab\n  const [form] = Form.useForm();\n\n  // Responsive breakpoints\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n\n  // Load configuration on mount\n  useEffect(() => {\n    loadConfig();\n  }, []);\n\n  return (\n    <div>\n      <Typography.Title level={2}>MkDocs Settings</Typography.Title>\n\n      <Tabs activeKey={activeTab} onChange={setActiveTab}>\n        <Tabs.TabPane tab=\"Settings\" key=\"settings\">\n          {/* Form-based settings editor */}\n        </Tabs.TabPane>\n\n        <Tabs.TabPane tab=\"Navigation\" key=\"navigation\">\n          {/* Tree-based navigation builder */}\n        </Tabs.TabPane>\n\n        <Tabs.TabPane tab=\"YAML Editor\" key=\"yaml\">\n          {/* Monaco YAML editor */}\n        </Tabs.TabPane>\n\n        <Tabs.TabPane tab=\"Build\" key=\"build\">\n          {/* Static site build trigger */}\n        </Tabs.TabPane>\n      </Tabs>\n    </div>\n  );\n};\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  1. Tabs - Four-tab interface switcher
  2. Form - Settings form with validation
  3. Input / Input.TextArea - Text field inputs
  4. Switch - Boolean toggles
  5. Select - Dropdown selections (used in modals)
  6. Tree - Hierarchical navigation tree view
  7. Button - Action buttons (Save, Add, Delete, Build)
  8. Modal - Dialogs for add/edit operations
  9. Card - Content containers for each tab
  10. Alert - Warning messages (mobile YAML editor, orphaned files)
  11. Typography.Title - Page heading
  12. Typography.Text - Descriptive text
  13. message - Toast notifications (save success/error)
  14. Space - Component spacing
  15. Divider - Visual separators
  16. Tag - Closable tags for arrays (features, plugins, etc.)
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#monaco-editor-configuration","title":"Monaco Editor Configuration","text":"
<Editor\n  height=\"600px\"\n  defaultLanguage=\"yaml\"\n  value={yamlContent}\n  onChange={(value) => setYamlContent(value || '')}\n  theme=\"vs-dark\"\n  options={{\n    minimap: { enabled: false },\n    fontSize: 14,\n    wordWrap: 'on',\n    automaticLayout: true,\n    scrollBeyondLastLine: false,\n    renderWhitespace: 'selection',\n  }}\n  onMount={(editor) => {\n    // Register Ctrl+S keyboard shortcut\n    editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {\n      handleSaveYAML();\n    });\n  }}\n/>\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#navigation-tree-structure","title":"Navigation Tree Structure","text":"
interface NavItem {\n  key: string;\n  title: string;\n  children?: NavItem[];\n  file?: string;  // File path for pages (leaves)\n}\n\n// Example navigation structure\nconst navStructure: NavItem[] = [\n  {\n    key: '1',\n    title: 'Getting Started',\n    children: [\n      { key: '1-1', title: 'Installation', file: 'getting-started/installation.md' },\n      { key: '1-2', title: 'Quick Start', file: 'getting-started/quick-start.md' },\n    ],\n  },\n  {\n    key: '2',\n    title: 'API Reference',\n    children: [\n      { key: '2-1', title: 'Authentication', file: 'api/authentication.md' },\n      { key: '2-2', title: 'Users', file: 'api/users.md' },\n    ],\n  },\n];\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#custom-yaml-parser-python-tag-handling","title":"Custom YAML Parser (Python Tag Handling)","text":"
import yaml from 'yaml';\n\n// Custom YAML parser that preserves Python tags\nconst parseYAML = (yamlString: string): any => {\n  try {\n    // Parse with yaml library (supports tags)\n    const parsed = yaml.parse(yamlString);\n    return parsed;\n  } catch (error) {\n    console.error('YAML parse error:', error);\n    throw new Error('Invalid YAML syntax');\n  }\n};\n\n// Custom YAML stringifier\nconst stringifyYAML = (obj: any): string => {\n  try {\n    return yaml.stringify(obj, {\n      indent: 2,\n      lineWidth: 0,  // No line wrapping\n    });\n  } catch (error) {\n    console.error('YAML stringify error:', error);\n    throw new Error('Failed to stringify YAML');\n  }\n};\n\n// Example: Parsing YAML with Python tags\nconst yamlWithTags = `\nnav:\n  - Home: !relative $config_dir/index.md\n  - API: !relative $config_dir/api/index.md\n`;\n\nconst parsed = parseYAML(yamlWithTags);\n// Tags preserved as special objects\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#local-component-state-usestate","title":"Local Component State (useState)","text":"

No Zustand stores used - All state managed locally with React hooks.

// Active tab state\nconst [activeTab, setActiveTab] = useState<'settings' | 'navigation' | 'yaml' | 'build'>('settings');\n\n// Configuration state (loaded from API)\nconst [config, setConfig] = useState<MkDocsConfig | null>(null);\n\n// Navigation structure state\nconst [navStructure, setNavStructure] = useState<NavItem[]>([]);\n\n// Orphaned files state\nconst [orphanedFiles, setOrphanedFiles] = useState<string[]>([]);\n\n// YAML editor content state\nconst [yamlContent, setYamlContent] = useState<string>('');\n\n// Loading states\nconst [loading, setLoading] = useState(false);      // Initial load\nconst [saving, setSaving] = useState(false);        // Save operation\nconst [building, setBuilding] = useState(false);    // Build operation\n\n// Last build timestamp\nconst [lastBuild, setLastBuild] = useState<string | null>(null);\n\n// Form instance (Ant Design)\nconst [form] = Form.useForm();\n\n// Responsive breakpoints\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#state-flow","title":"State Flow","text":"
  1. Component Mounts:
  2. loadConfig() called in useEffect
  3. Fetches MkDocs config via GET /api/docs/config
  4. Sets config, navStructure, orphanedFiles, yamlContent
  5. Sets form field values

  6. User Edits Settings Tab:

  7. Form fields update form state (Ant Design managed)
  8. Click \"Save Changes\" \u2192 handleSaveSettings()
  9. Gets values from form.getFieldsValue()
  10. Sends PUT /api/docs/config with updated config
  11. Re-fetches config on success

  12. User Edits Navigation Tab:

  13. Drag-and-drop updates navStructure state
  14. Add/edit/delete modals update navStructure
  15. Click \"Save Navigation\" \u2192 handleSaveNavigation()
  16. Sends PUT /api/docs/config with updated nav structure
  17. Re-fetches config on success

  18. User Edits YAML Tab:

  19. Monaco editor updates yamlContent state on change
  20. Click \"Save Changes\" or Ctrl+S \u2192 handleSaveYAML()
  21. Parses YAML to validate
  22. Sends PUT /api/docs/config with parsed YAML object
  23. Re-fetches config on success

  24. User Triggers Build:

  25. Click \"Build Site\" \u2192 handleBuild()
  26. Sets building to true
  27. Sends POST /api/docs/build
  28. Sets building to false on completion
  29. Updates lastBuild timestamp
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/docs/config - Fetch MkDocs configuration
  2. PUT /api/docs/config - Update MkDocs configuration
  3. POST /api/docs/build - Trigger static site build
  4. GET /api/influence/campaigns - Fetch active campaigns (for campaign link insertion)
  5. GET /api/docs/orphaned-files - Fetch markdown files not in navigation
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#api-client","title":"API Client","text":"
import { api } from '@/lib/api';\n\n// All requests use authenticated API client with automatic token refresh\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#example-api-calls","title":"Example API Calls","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#1-load-configuration","title":"1. Load Configuration","text":"
const loadConfig = async () => {\n  try {\n    setLoading(true);\n    const response = await api.get('/api/docs/config');\n    const configData = response.data.data;\n\n    // Set configuration state\n    setConfig(configData);\n\n    // Set navigation structure\n    setNavStructure(configData.nav || []);\n\n    // Set orphaned files\n    setOrphanedFiles(configData.orphanedFiles || []);\n\n    // Set YAML content\n    const yamlString = stringifyYAML(configData);\n    setYamlContent(yamlString);\n\n    // Populate form fields\n    form.setFieldsValue({\n      site_name: configData.site_name,\n      site_url: configData.site_url,\n      site_description: configData.site_description,\n      site_author: configData.site_author,\n      copyright: configData.copyright,\n      repo_url: configData.repo_url,\n      repo_name: configData.repo_name,\n      edit_uri: configData.edit_uri,\n      theme_name: configData.theme?.name,\n      theme_primary: configData.theme?.palette?.primary,\n      theme_accent: configData.theme?.palette?.accent,\n      theme_features: configData.theme?.features || [],\n      plugins: configData.plugins || [],\n      markdown_extensions: configData.markdown_extensions || [],\n      extra_css: configData.extra_css || [],\n      extra_javascript: configData.extra_javascript || [],\n    });\n\n    // Set last build timestamp\n    setLastBuild(configData.last_build);\n  } catch (error) {\n    message.error('Failed to load MkDocs configuration');\n    console.error('Load config error:', error);\n  } finally {\n    setLoading(false);\n  }\n};\n

Response Format:

{\n  \"success\": true,\n  \"data\": {\n    \"site_name\": \"Changemaker Lite Documentation\",\n    \"site_url\": \"https://docs.cmlite.org\",\n    \"site_description\": \"Comprehensive documentation for Changemaker Lite platform\",\n    \"site_author\": \"Changemaker Team\",\n    \"copyright\": \"Copyright &copy; 2025 Changemaker\",\n    \"repo_url\": \"https://github.com/example/changemaker-lite\",\n    \"repo_name\": \"changemaker-lite\",\n    \"edit_uri\": \"edit/main/docs/\",\n    \"theme\": {\n      \"name\": \"material\",\n      \"palette\": {\n        \"primary\": \"blue\",\n        \"accent\": \"pink\"\n      },\n      \"features\": [\n        \"navigation.tabs\",\n        \"navigation.sections\",\n        \"toc.integrate\"\n      ]\n    },\n    \"plugins\": [\"search\", \"minify\"],\n    \"markdown_extensions\": [\"admonition\", \"pymdownx.highlight\"],\n    \"extra_css\": [\"stylesheets/extra.css\"],\n    \"extra_javascript\": [\"javascripts/extra.js\"],\n    \"nav\": [\n      {\n        \"key\": \"1\",\n        \"title\": \"Home\",\n        \"file\": \"index.md\"\n      },\n      {\n        \"key\": \"2\",\n        \"title\": \"Getting Started\",\n        \"children\": [\n          {\n            \"key\": \"2-1\",\n            \"title\": \"Installation\",\n            \"file\": \"getting-started/installation.md\"\n          }\n        ]\n      }\n    ],\n    \"orphanedFiles\": [\"changelog.md\", \"contributing.md\"],\n    \"last_build\": \"2025-02-11T10:30:00Z\"\n  }\n}\n

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#2-save-settings-form-based","title":"2. Save Settings (Form-Based)","text":"
const handleSaveSettings = async () => {\n  try {\n    setSaving(true);\n\n    // Get form values\n    const values = form.getFieldsValue();\n\n    // Construct updated config\n    const updatedConfig = {\n      ...config,\n      site_name: values.site_name,\n      site_url: values.site_url,\n      site_description: values.site_description,\n      site_author: values.site_author,\n      copyright: values.copyright,\n      repo_url: values.repo_url,\n      repo_name: values.repo_name,\n      edit_uri: values.edit_uri,\n      theme: {\n        ...config?.theme,\n        name: values.theme_name,\n        palette: {\n          primary: values.theme_primary,\n          accent: values.theme_accent,\n        },\n        features: values.theme_features,\n      },\n      plugins: values.plugins,\n      markdown_extensions: values.markdown_extensions,\n      extra_css: values.extra_css,\n      extra_javascript: values.extra_javascript,\n    };\n\n    // Send update request\n    await api.put('/api/docs/config', updatedConfig);\n\n    message.success('Settings saved successfully');\n\n    // Reload configuration\n    await loadConfig();\n  } catch (error) {\n    message.error('Failed to save settings');\n    console.error('Save settings error:', error);\n  } finally {\n    setSaving(false);\n  }\n};\n

Request Payload:

{\n  \"site_name\": \"Changemaker Lite Documentation\",\n  \"site_url\": \"https://docs.cmlite.org\",\n  \"theme\": {\n    \"name\": \"material\",\n    \"palette\": {\n      \"primary\": \"blue\",\n      \"accent\": \"pink\"\n    },\n    \"features\": [\"navigation.tabs\", \"toc.integrate\"]\n  },\n  \"plugins\": [\"search\", \"minify\"],\n  \"markdown_extensions\": [\"admonition\"]\n}\n

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#3-save-navigation","title":"3. Save Navigation","text":"
const handleSaveNavigation = async () => {\n  try {\n    setSaving(true);\n\n    // Construct updated config with new navigation\n    const updatedConfig = {\n      ...config,\n      nav: navStructure,\n    };\n\n    // Send update request\n    await api.put('/api/docs/config', updatedConfig);\n\n    message.success('Navigation saved successfully');\n\n    // Reload configuration\n    await loadConfig();\n  } catch (error) {\n    message.error('Failed to save navigation');\n    console.error('Save navigation error:', error);\n  } finally {\n    setSaving(false);\n  }\n};\n

Request Payload:

{\n  \"nav\": [\n    {\n      \"key\": \"1\",\n      \"title\": \"Home\",\n      \"file\": \"index.md\"\n    },\n    {\n      \"key\": \"2\",\n      \"title\": \"Getting Started\",\n      \"children\": [\n        {\n          \"key\": \"2-1\",\n          \"title\": \"Installation\",\n          \"file\": \"getting-started/installation.md\"\n        },\n        {\n          \"key\": \"2-2\",\n          \"title\": \"Quick Start\",\n          \"file\": \"getting-started/quick-start.md\"\n        }\n      ]\n    }\n  ]\n}\n

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#4-save-yaml","title":"4. Save YAML","text":"
const handleSaveYAML = async () => {\n  try {\n    setSaving(true);\n\n    // Parse YAML to validate and convert to object\n    const parsedConfig = parseYAML(yamlContent);\n\n    // Send update request\n    await api.put('/api/docs/config', parsedConfig);\n\n    message.success('YAML saved successfully');\n\n    // Reload configuration\n    await loadConfig();\n  } catch (error) {\n    if (error.message === 'Invalid YAML syntax') {\n      message.error('Invalid YAML syntax. Please check and try again.');\n    } else {\n      message.error('Failed to save YAML');\n    }\n    console.error('Save YAML error:', error);\n  } finally {\n    setSaving(false);\n  }\n};\n

Request Payload: (Parsed YAML object)

{\n  \"site_name\": \"Changemaker Lite Documentation\",\n  \"theme\": {\n    \"name\": \"material\"\n  },\n  \"nav\": [\n    {\n      \"Home\": \"index.md\"\n    }\n  ]\n}\n

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#5-build-site","title":"5. Build Site","text":"
const handleBuild = async () => {\n  try {\n    setBuilding(true);\n\n    // Trigger build\n    await api.post('/api/docs/build');\n\n    message.success('Site built successfully');\n\n    // Reload configuration to get updated last_build timestamp\n    await loadConfig();\n  } catch (error) {\n    message.error('Failed to build site');\n    console.error('Build error:', error);\n  } finally {\n    setBuilding(false);\n  }\n};\n

Response Format:

{\n  \"success\": true,\n  \"message\": \"Site built successfully\",\n  \"data\": {\n    \"build_time\": \"2025-02-11T10:35:00Z\",\n    \"output_dir\": \"/app/mkdocs/site\"\n  }\n}\n

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#6-fetch-active-campaigns-for-link-insertion","title":"6. Fetch Active Campaigns (for link insertion)","text":"
const fetchCampaigns = async () => {\n  try {\n    const response = await api.get('/api/influence/campaigns', {\n      params: {\n        status: 'active',\n        limit: 100,\n      },\n    });\n\n    return response.data.data.campaigns;\n  } catch (error) {\n    console.error('Fetch campaigns error:', error);\n    return [];\n  }\n};\n\nconst handleAddCampaignLink = async () => {\n  // Fetch campaigns\n  const campaigns = await fetchCampaigns();\n\n  // Show modal with campaign dropdown\n  Modal.confirm({\n    title: 'Add Campaign Link',\n    content: (\n      <Select\n        placeholder=\"Select campaign\"\n        options={campaigns.map(c => ({\n          label: c.title,\n          value: c.id,\n        }))}\n        onChange={(campaignId) => {\n          // Find selected campaign\n          const campaign = campaigns.find(c => c.id === campaignId);\n\n          // Generate navigation item\n          const navItem: NavItem = {\n            key: `campaign-${campaignId}`,\n            title: campaign.title,\n            file: `campaigns/${campaign.slug}.md`,\n          };\n\n          // Add to navigation structure\n          setNavStructure([...navStructure, navItem]);\n        }}\n      />\n    ),\n  });\n};\n

Response Format:

{\n  \"success\": true,\n  \"data\": {\n    \"campaigns\": [\n      {\n        \"id\": 1,\n        \"title\": \"Climate Action Now\",\n        \"slug\": \"climate-action-now\",\n        \"status\": \"active\"\n      },\n      {\n        \"id\": 2,\n        \"title\": \"Education Funding\",\n        \"slug\": \"education-funding\",\n        \"status\": \"active\"\n      }\n    ],\n    \"pagination\": {\n      \"page\": 1,\n      \"limit\": 100,\n      \"total\": 2\n    }\n  }\n}\n

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#7-fetch-orphaned-files","title":"7. Fetch Orphaned Files","text":"
const fetchOrphanedFiles = async () => {\n  try {\n    const response = await api.get('/api/docs/orphaned-files');\n\n    setOrphanedFiles(response.data.data.files);\n  } catch (error) {\n    console.error('Fetch orphaned files error:', error);\n  }\n};\n

Response Format:

{\n  \"success\": true,\n  \"data\": {\n    \"files\": [\n      \"changelog.md\",\n      \"contributing.md\",\n      \"troubleshooting/common-issues.md\"\n    ]\n  }\n}\n

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#complete-loadconfig-implementation","title":"Complete loadConfig Implementation","text":"
const loadConfig = useCallback(async () => {\n  try {\n    setLoading(true);\n    const response = await api.get('/api/docs/config');\n    const configData = response.data.data;\n\n    // Set all state from API response\n    setConfig(configData);\n    setNavStructure(configData.nav || []);\n    setOrphanedFiles(configData.orphanedFiles || []);\n    setYamlContent(stringifyYAML(configData));\n    setLastBuild(configData.last_build);\n\n    // Populate form fields\n    form.setFieldsValue({\n      site_name: configData.site_name,\n      site_url: configData.site_url,\n      site_description: configData.site_description,\n      site_author: configData.site_author,\n      copyright: configData.copyright,\n      repo_url: configData.repo_url,\n      repo_name: configData.repo_name,\n      edit_uri: configData.edit_uri,\n      theme_name: configData.theme?.name,\n      theme_primary: configData.theme?.palette?.primary,\n      theme_accent: configData.theme?.palette?.accent,\n      theme_features: configData.theme?.features || [],\n      plugins: configData.plugins || [],\n      markdown_extensions: configData.markdown_extensions || [],\n      extra_css: configData.extra_css || [],\n      extra_javascript: configData.extra_javascript || [],\n    });\n  } catch (error) {\n    message.error('Failed to load MkDocs configuration');\n    console.error('Load config error:', error);\n  } finally {\n    setLoading(false);\n  }\n}, [form]);\n\nuseEffect(() => {\n  loadConfig();\n}, [loadConfig]);\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#settings-tab-form-rendering","title":"Settings Tab Form Rendering","text":"
<Form\n  form={form}\n  layout=\"vertical\"\n  onFinish={handleSaveSettings}\n>\n  <Form.Item\n    label=\"Site Name\"\n    name=\"site_name\"\n    rules={[{ required: true, message: 'Site name is required' }]}\n  >\n    <Input placeholder=\"e.g., Changemaker Lite Documentation\" />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Site URL\"\n    name=\"site_url\"\n    rules={[\n      { required: true, message: 'Site URL is required' },\n      { type: 'url', message: 'Must be a valid URL' },\n    ]}\n  >\n    <Input.TextArea\n      rows={2}\n      placeholder=\"e.g., https://docs.cmlite.org\"\n    />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Site Description\"\n    name=\"site_description\"\n  >\n    <Input.TextArea\n      rows={3}\n      placeholder=\"Brief description of your documentation site\"\n    />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Site Author\"\n    name=\"site_author\"\n  >\n    <Input placeholder=\"e.g., Changemaker Team\" />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Copyright\"\n    name=\"copyright\"\n  >\n    <Input placeholder=\"e.g., Copyright &copy; 2025 Changemaker\" />\n  </Form.Item>\n\n  <Divider>Repository Configuration</Divider>\n\n  <Form.Item\n    label=\"Repository URL\"\n    name=\"repo_url\"\n    rules={[{ type: 'url', message: 'Must be a valid URL' }]}\n  >\n    <Input placeholder=\"e.g., https://github.com/username/repo\" />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Repository Name\"\n    name=\"repo_name\"\n  >\n    <Input placeholder=\"e.g., changemaker-lite\" />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Edit URI\"\n    name=\"edit_uri\"\n  >\n    <Input placeholder=\"e.g., edit/main/docs/\" />\n  </Form.Item>\n\n  <Divider>Theme Configuration</Divider>\n\n  <Form.Item\n    label=\"Theme Name\"\n    name=\"theme_name\"\n    rules={[{ required: true, message: 'Theme is required' }]}\n  >\n    <Input placeholder=\"e.g., material\" />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Primary Color\"\n    name=\"theme_primary\"\n    rules={[\n      { pattern: /^#[0-9A-Fa-f]{6}$/, message: 'Must be hex color (e.g., #1976d2)' },\n    ]}\n  >\n    <Input placeholder=\"e.g., #1976d2\" />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Accent Color\"\n    name=\"theme_accent\"\n    rules={[\n      { pattern: /^#[0-9A-Fa-f]{6}$/, message: 'Must be hex color (e.g., #f50057)' },\n    ]}\n  >\n    <Input placeholder=\"e.g., #f50057\" />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Theme Features\"\n    name=\"theme_features\"\n  >\n    <Select\n      mode=\"tags\"\n      placeholder=\"Type feature name and press Enter\"\n      style={{ width: '100%' }}\n    />\n  </Form.Item>\n\n  <Divider>Plugins & Extensions</Divider>\n\n  <Form.Item\n    label=\"Plugins\"\n    name=\"plugins\"\n  >\n    <Select\n      mode=\"tags\"\n      placeholder=\"Type plugin name and press Enter\"\n      style={{ width: '100%' }}\n    />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Markdown Extensions\"\n    name=\"markdown_extensions\"\n  >\n    <Select\n      mode=\"tags\"\n      placeholder=\"Type extension name and press Enter\"\n      style={{ width: '100%' }}\n    />\n  </Form.Item>\n\n  <Divider>Additional Assets</Divider>\n\n  <Form.Item\n    label=\"Extra CSS Files\"\n    name=\"extra_css\"\n  >\n    <Select\n      mode=\"tags\"\n      placeholder=\"Type CSS file path and press Enter\"\n      style={{ width: '100%' }}\n    />\n  </Form.Item>\n\n  <Form.Item\n    label=\"Extra JavaScript Files\"\n    name=\"extra_javascript\"\n  >\n    <Select\n      mode=\"tags\"\n      placeholder=\"Type JS file path and press Enter\"\n      style={{ width: '100%' }}\n    />\n  </Form.Item>\n\n  <Form.Item>\n    <Button\n      type=\"primary\"\n      htmlType=\"submit\"\n      loading={saving}\n      size=\"large\"\n    >\n      Save Changes\n    </Button>\n  </Form.Item>\n</Form>\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#navigation-tab-tree-rendering","title":"Navigation Tab Tree Rendering","text":"
<div>\n  <Space style={{ marginBottom: 16 }}>\n    <Button onClick={handleAddSection}>Add Section</Button>\n    <Button onClick={handleAddPage}>Add Page</Button>\n    <Button onClick={handleAddCampaignLink}>Add Campaign Link</Button>\n  </Space>\n\n  <Tree\n    treeData={convertNavToTreeData(navStructure)}\n    draggable\n    blockNode\n    onDrop={handleDrop}\n    titleRender={(node) => (\n      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>\n        <span>\n          {node.children ? (\n            <FolderOutlined style={{ marginRight: 8 }} />\n          ) : (\n            <FileOutlined style={{ marginRight: 8 }} />\n          )}\n          {node.title}\n        </span>\n        <Space>\n          <Button\n            type=\"text\"\n            size=\"small\"\n            icon={<EditOutlined />}\n            onClick={() => handleEditNavItem(node.key)}\n          />\n          <Button\n            type=\"text\"\n            size=\"small\"\n            danger\n            icon={<DeleteOutlined />}\n            onClick={() => handleDeleteNavItem(node.key)}\n          />\n        </Space>\n      </div>\n    )}\n  />\n\n  <Divider />\n\n  <Button\n    type=\"primary\"\n    onClick={handleSaveNavigation}\n    loading={saving}\n    size=\"large\"\n  >\n    Save Navigation\n  </Button>\n\n  {orphanedFiles.length > 0 && (\n    <>\n      <Divider />\n\n      <Alert\n        message=\"Orphaned Files\"\n        description={`${orphanedFiles.length} markdown files are not included in navigation`}\n        type=\"warning\"\n        showIcon\n      />\n\n      <div style={{ marginTop: 16 }}>\n        {orphanedFiles.map((file) => (\n          <div\n            key={file}\n            style={{\n              padding: '8px 12px',\n              marginBottom: 8,\n              border: '1px solid #d9d9d9',\n              borderRadius: 4,\n              display: 'flex',\n              justifyContent: 'space-between',\n              alignItems: 'center',\n            }}\n          >\n            <span>\n              <FileOutlined style={{ marginRight: 8 }} />\n              {file}\n            </span>\n            <Button\n              size=\"small\"\n              onClick={() => handleAddOrphanedFile(file)}\n            >\n              Add to Navigation\n            </Button>\n          </div>\n        ))}\n      </div>\n    </>\n  )}\n</div>\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#yaml-editor-tab-rendering","title":"YAML Editor Tab Rendering","text":"
<div>\n  {isMobile && (\n    <Alert\n      message=\"Desktop Recommended\"\n      description=\"YAML Editor is best used on desktop. Consider using Settings or Navigation tabs for mobile editing.\"\n      type=\"warning\"\n      showIcon\n      closable\n      style={{ marginBottom: 16 }}\n    />\n  )}\n\n  <Editor\n    height=\"600px\"\n    defaultLanguage=\"yaml\"\n    value={yamlContent}\n    onChange={(value) => setYamlContent(value || '')}\n    theme=\"vs-dark\"\n    options={{\n      minimap: { enabled: false },\n      fontSize: 14,\n      wordWrap: 'on',\n      automaticLayout: true,\n      scrollBeyondLastLine: false,\n      renderWhitespace: 'selection',\n      tabSize: 2,\n      insertSpaces: true,\n    }}\n    onMount={(editor, monaco) => {\n      // Register Ctrl+S keyboard shortcut\n      editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {\n        handleSaveYAML();\n      });\n    }}\n  />\n\n  <div style={{ marginTop: 16 }}>\n    <Space>\n      <Button\n        type=\"primary\"\n        onClick={handleSaveYAML}\n        loading={saving}\n        size=\"large\"\n      >\n        Save Changes\n      </Button>\n      <Typography.Text type=\"secondary\">\n        Tip: Press Ctrl+S (Cmd+S on Mac) to save\n      </Typography.Text>\n    </Space>\n  </div>\n</div>\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#build-tab-rendering","title":"Build Tab Rendering","text":"
<Card>\n  <Space direction=\"vertical\" size=\"large\" style={{ width: '100%' }}>\n    <div>\n      <Typography.Title level={4}>Build Static Site</Typography.Title>\n      <Typography.Paragraph>\n        Trigger a static site build using MkDocs. This will generate HTML files\n        from your markdown documentation.\n      </Typography.Paragraph>\n    </div>\n\n    {lastBuild && (\n      <div>\n        <Typography.Text strong>Last Build:</Typography.Text>\n        <Typography.Text style={{ marginLeft: 8 }}>\n          {dayjs(lastBuild).fromNow()}\n        </Typography.Text>\n      </div>\n    )}\n\n    <Button\n      type=\"primary\"\n      size=\"large\"\n      onClick={handleBuild}\n      loading={building}\n      icon={<RocketOutlined />}\n    >\n      {building ? 'Building...' : 'Build Site'}\n    </Button>\n\n    {building && (\n      <Alert\n        message=\"Build in Progress\"\n        description=\"Building static site... This may take 10-30 seconds.\"\n        type=\"info\"\n        showIcon\n      />\n    )}\n  </Space>\n</Card>\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#drag-and-drop-handler","title":"Drag-and-Drop Handler","text":"
const handleDrop = (info: any) => {\n  const dropKey = info.node.key;\n  const dragKey = info.dragNode.key;\n  const dropPos = info.node.pos.split('-');\n  const dropPosition = info.dropPosition - Number(dropPos[dropPos.length - 1]);\n\n  const loop = (data: NavItem[], key: string, callback: (item: NavItem, index: number, arr: NavItem[]) => void) => {\n    for (let i = 0; i < data.length; i++) {\n      if (data[i].key === key) {\n        callback(data[i], i, data);\n        return;\n      }\n      if (data[i].children) {\n        loop(data[i].children!, key, callback);\n      }\n    }\n  };\n\n  const data = [...navStructure];\n\n  // Find dragObject\n  let dragObj: NavItem;\n  loop(data, dragKey, (item, index, arr) => {\n    arr.splice(index, 1);\n    dragObj = item;\n  });\n\n  if (!info.dropToGap) {\n    // Drop on the content\n    loop(data, dropKey, (item) => {\n      item.children = item.children || [];\n      item.children.unshift(dragObj);\n    });\n  } else if (\n    (info.node.children || []).length > 0 &&\n    info.node.expanded &&\n    dropPosition === 1\n  ) {\n    // Drop to the bottom gap\n    loop(data, dropKey, (item) => {\n      item.children = item.children || [];\n      item.children.unshift(dragObj);\n    });\n  } else {\n    // Drop to the gap\n    let ar: NavItem[] = [];\n    let i: number;\n    loop(data, dropKey, (_item, index, arr) => {\n      ar = arr;\n      i = index;\n    });\n    if (dropPosition === -1) {\n      ar.splice(i!, 0, dragObj!);\n    } else {\n      ar.splice(i! + 1, 0, dragObj!);\n    }\n  }\n\n  setNavStructure(data);\n};\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#add-campaign-link-handler","title":"Add Campaign Link Handler","text":"
const handleAddCampaignLink = async () => {\n  try {\n    // Fetch active campaigns\n    const response = await api.get('/api/influence/campaigns', {\n      params: {\n        status: 'active',\n        limit: 100,\n      },\n    });\n\n    const campaigns = response.data.data.campaigns;\n\n    if (campaigns.length === 0) {\n      message.warning('No active campaigns found');\n      return;\n    }\n\n    // Show modal with campaign selector\n    let selectedCampaignId: number | null = null;\n\n    Modal.confirm({\n      title: 'Add Campaign Link',\n      content: (\n        <div style={{ marginTop: 16 }}>\n          <Typography.Text>Select a campaign to add to navigation:</Typography.Text>\n          <Select\n            style={{ width: '100%', marginTop: 8 }}\n            placeholder=\"Select campaign\"\n            onChange={(value) => {\n              selectedCampaignId = value;\n            }}\n            options={campaigns.map((campaign: any) => ({\n              label: campaign.title,\n              value: campaign.id,\n            }))}\n          />\n        </div>\n      ),\n      onOk: () => {\n        if (!selectedCampaignId) {\n          message.error('Please select a campaign');\n          return Promise.reject();\n        }\n\n        // Find selected campaign\n        const campaign = campaigns.find((c: any) => c.id === selectedCampaignId);\n\n        // Generate navigation item\n        const newNavItem: NavItem = {\n          key: `campaign-${campaign.id}`,\n          title: campaign.title,\n          file: `campaigns/${campaign.slug}.md`,\n        };\n\n        // Add to navigation structure (at end)\n        setNavStructure([...navStructure, newNavItem]);\n\n        message.success(`Added \"${campaign.title}\" to navigation`);\n      },\n    });\n  } catch (error) {\n    message.error('Failed to fetch campaigns');\n    console.error('Fetch campaigns error:', error);\n  }\n};\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#yaml-parser-with-python-tag-support","title":"YAML Parser with Python Tag Support","text":"
import yaml from 'yaml';\n\n// Parse YAML with Python tag preservation\nconst parseYAML = (yamlString: string): any => {\n  try {\n    const parsed = yaml.parse(yamlString, {\n      // Preserve Python tags\n      customTags: [\n        {\n          tag: '!relative',\n          resolve: (str: string) => ({ type: 'relative', value: str }),\n        },\n      ],\n    });\n    return parsed;\n  } catch (error) {\n    console.error('YAML parse error:', error);\n    throw new Error('Invalid YAML syntax');\n  }\n};\n\n// Stringify YAML with Python tag reconstruction\nconst stringifyYAML = (obj: any): string => {\n  try {\n    return yaml.stringify(obj, {\n      indent: 2,\n      lineWidth: 0,  // Disable line wrapping\n      // Custom replacer for Python tags\n      replacer: (key, value) => {\n        if (value && typeof value === 'object' && value.type === 'relative') {\n          return `!relative ${value.value}`;\n        }\n        return value;\n      },\n    });\n  } catch (error) {\n    console.error('YAML stringify error:', error);\n    throw new Error('Failed to stringify YAML');\n  }\n};\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#1-debounced-search-not-applicable","title":"1. Debounced Search (Not Applicable)","text":"

This page does not implement search functionality. Navigation filtering could be added with debouncing if needed.

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#2-lazy-tab-loading","title":"2. Lazy Tab Loading","text":"
// Only load tab content when tab is active\n{activeTab === 'settings' && (\n  <Form form={form} layout=\"vertical\">\n    {/* Settings form fields */}\n  </Form>\n)}\n\n{activeTab === 'navigation' && (\n  <div>\n    {/* Navigation tree */}\n  </div>\n)}\n\n{activeTab === 'yaml' && (\n  <div>\n    {/* Monaco editor */}\n  </div>\n)}\n\n{activeTab === 'build' && (\n  <Card>\n    {/* Build controls */}\n  </Card>\n)}\n

Benefit: Avoids rendering all tab contents simultaneously, especially heavy Monaco editor.

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#3-monaco-editor-lazy-loading","title":"3. Monaco Editor Lazy Loading","text":"

Monaco Editor only mounts when YAML Editor tab is active:

{activeTab === 'yaml' && (\n  <Editor\n    height=\"600px\"\n    defaultLanguage=\"yaml\"\n    value={yamlContent}\n    // ... editor config\n  />\n)}\n

Benefit: Saves ~500KB of JavaScript bundle loading and initialization time until needed.

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#4-usecallback-for-event-handlers","title":"4. useCallback for Event Handlers","text":"
const handleSaveSettings = useCallback(async () => {\n  try {\n    setSaving(true);\n    const values = form.getFieldsValue();\n    const updatedConfig = { ...config, ...values };\n    await api.put('/api/docs/config', updatedConfig);\n    message.success('Settings saved successfully');\n    await loadConfig();\n  } catch (error) {\n    message.error('Failed to save settings');\n  } finally {\n    setSaving(false);\n  }\n}, [config, form]);\n

Benefit: Prevents unnecessary re-renders of child components when handler function identity changes.

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#5-tree-data-memoization","title":"5. Tree Data Memoization","text":"
const treeData = useMemo(() => {\n  return convertNavToTreeData(navStructure);\n}, [navStructure]);\n\nreturn (\n  <Tree treeData={treeData} draggable onDrop={handleDrop} />\n);\n

Benefit: Avoids recalculating tree data structure on every render.

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#6-form-field-value-caching","title":"6. Form Field Value Caching","text":"

Ant Design Form automatically caches field values, but we explicitly set them only once after loading:

useEffect(() => {\n  if (config) {\n    form.setFieldsValue({\n      site_name: config.site_name,\n      // ... other fields\n    });\n  }\n}, [config, form]);\n

Benefit: Avoids unnecessary form re-renders and field value updates.

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#breakpoint-detection","title":"Breakpoint Detection","text":"
import { Grid } from 'antd';\n\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;  // Mobile if screen width < 768px\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#mobile-yaml-editor-warning","title":"Mobile YAML Editor Warning","text":"
{isMobile && (\n  <Alert\n    message=\"Desktop Recommended\"\n    description=\"YAML Editor is best used on desktop. Consider using Settings or Navigation tabs for mobile editing.\"\n    type=\"warning\"\n    showIcon\n    closable\n    style={{ marginBottom: 16 }}\n  />\n)}\n

Rationale: Monaco Editor provides poor UX on mobile (small screen, no keyboard shortcuts, slow rendering). Warning nudges users toward form-based Settings tab instead.

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#responsive-form-layout","title":"Responsive Form Layout","text":"
<Form layout=\"vertical\">  {/* Stacks labels above inputs on all screen sizes */}\n  <Form.Item label=\"Site Name\" name=\"site_name\">\n    <Input />\n  </Form.Item>\n</Form>\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#responsive-navigation-tree","title":"Responsive Navigation Tree","text":"

Tree component automatically adjusts to container width. Horizontal scrolling enabled for deep nesting:

<Tree\n  style={{ overflowX: 'auto' }}  // Horizontal scroll for wide trees\n  treeData={treeData}\n  draggable\n  blockNode\n/>\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#keyboard-navigation","title":"Keyboard Navigation","text":"
  1. Tab Key:
  2. Cycles through all interactive elements (tabs, form fields, buttons, tree nodes)

  3. Arrow Keys:

  4. Navigate between tabs in Tabs component
  5. Navigate tree structure (up/down/left/right)

  6. Enter Key:

  7. Submit forms
  8. Activate buttons
  9. Expand/collapse tree nodes

  10. Escape Key:

  11. Close modals
  12. Cancel drag-and-drop operations

  13. Monaco Editor Shortcuts:

  14. Ctrl+S / Cmd+S - Save YAML
  15. Ctrl+F - Find
  16. Ctrl+H - Find and replace
  17. Ctrl+Z - Undo
  18. Ctrl+Y - Redo
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#aria-labels","title":"ARIA Labels","text":"
<Button\n  aria-label=\"Save MkDocs settings\"\n  onClick={handleSaveSettings}\n>\n  Save Changes\n</Button>\n\n<Tree\n  aria-label=\"Documentation navigation tree\"\n  treeData={treeData}\n  draggable\n/>\n\n<Tabs\n  aria-label=\"MkDocs configuration tabs\"\n  activeKey={activeTab}\n  onChange={setActiveTab}\n>\n  <Tabs.TabPane tab=\"Settings\" key=\"settings\" />\n  <Tabs.TabPane tab=\"Navigation\" key=\"navigation\" />\n  <Tabs.TabPane tab=\"YAML Editor\" key=\"yaml\" />\n  <Tabs.TabPane tab=\"Build\" key=\"build\" />\n</Tabs>\n
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#color-contrast","title":"Color Contrast","text":"

All text meets WCAG AA standards: - Primary text: rgba(0, 0, 0, 0.85) on white background (contrast ratio 13.6:1) - Secondary text: rgba(0, 0, 0, 0.45) on white background (contrast ratio 7.5:1) - Button text: White on #1890ff (contrast ratio 4.5:1)

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#focus-indicators","title":"Focus Indicators","text":"

Ant Design provides default focus outlines for all interactive elements: - Blue outline on focused inputs - Highlight on focused tree nodes - Border on focused buttons

"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#screen-reader-support","title":"Screen Reader Support","text":"
  • Form labels properly associated with inputs via <Form.Item label>
  • Tree nodes have descriptive text
  • Buttons have descriptive text or aria-labels
  • Alerts have role=\"alert\" for screen reader announcements
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-yaml-fails-to-save-with-invalid-yaml-syntax","title":"Problem: YAML Fails to Save with \"Invalid YAML syntax\"","text":"

Symptoms: - Clicking \"Save Changes\" in YAML Editor shows error - Error message: \"Invalid YAML syntax\"

Causes: 1. Syntax errors (missing colons, incorrect indentation, unclosed quotes) 2. Invalid characters in YAML 3. Python tags not properly formatted

Solutions:

  1. Check for syntax errors:

    # \u274c Bad: Missing colon after key\nsite_name Changemaker Lite\n\n# \u2705 Good: Proper key-value syntax\nsite_name: Changemaker Lite\n

  2. Verify indentation:

    # \u274c Bad: Inconsistent indentation (mix of spaces and tabs)\ntheme:\n  name: material\n    palette:\n      primary: blue\n\n# \u2705 Good: Consistent 2-space indentation\ntheme:\n  name: material\n  palette:\n    primary: blue\n

  3. Escape special characters:

    # \u274c Bad: Unquoted special characters\nsite_description: This is a site with: special chars\n\n# \u2705 Good: Quoted string\nsite_description: \"This is a site with: special chars\"\n

  4. Use Monaco Find to locate errors:

  5. Press Ctrl+F to open find dialog
  6. Search for : to verify all keys have values
  7. Search for \" to verify all quotes are closed

  8. Copy YAML to external validator:

  9. Copy YAML content from editor
  10. Paste into online YAML validator (e.g., yamllint.com)
  11. Fix reported errors
  12. Paste corrected YAML back into editor
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-navigation-tree-drag-and-drop-not-working","title":"Problem: Navigation Tree Drag-and-Drop Not Working","text":"

Symptoms: - Cannot drag navigation items - Items snap back to original position after drop - No visual feedback during drag

Causes: 1. Browser compatibility issues 2. JavaScript errors breaking drag handlers 3. Tree data structure corruption

Solutions:

  1. Check browser console for errors:
  2. Open DevTools (F12)
  3. Check Console tab for red errors
  4. Look for errors related to \"onDrop\" or \"Tree\"

  5. Refresh page:

  6. Hard refresh (Ctrl+Shift+R) to clear cached JavaScript
  7. Check if drag-and-drop works after refresh

  8. Try alternative reordering:

  9. Instead of drag-and-drop, use Edit buttons to change order manually
  10. Delete item and re-add in desired position

  11. Verify tree data structure:

  12. Switch to YAML Editor tab
  13. Check nav: section for corrupt structure:

    # \u274c Bad: Missing keys or nested arrays\nnav:\n  - - Home: index.md  # Double-nested array\n\n# \u2705 Good: Proper structure\nnav:\n  - Home: index.md\n  - Getting Started:\n      - Installation: getting-started/installation.md\n

  14. Report browser compatibility:

  15. Drag-and-drop may not work in older browsers
  16. Update browser to latest version
  17. Try different browser (Chrome, Firefox, Edge)
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-orphaned-files-not-appearing","title":"Problem: Orphaned Files Not Appearing","text":"

Symptoms: - \"Orphaned Files\" section is empty - Know that markdown files exist but not in navigation - Expect files to show but don't see them

Causes: 1. Files actually included in navigation (just deep in tree) 2. API not detecting files correctly 3. Files located outside docs directory

Solutions:

  1. Expand all tree nodes:
  2. Click all expand arrows in navigation tree
  3. Verify file is truly not present in any section

  4. Check file location:

  5. Orphaned file detection only scans mkdocs/docs/ directory
  6. Files in other directories won't appear
  7. Move files to mkdocs/docs/ if needed

  8. Verify file has .md extension:

  9. Only .md files detected
  10. Files with .txt, .html, etc. won't appear

  11. Refresh orphaned files:

  12. Save navigation (even without changes)
  13. API re-scans for orphaned files on save
  14. Check if files appear after save

  15. Manually add files:

  16. If orphaned detection fails, use \"Add Page\" button
  17. Enter file path manually
  18. File will be added to navigation
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-build-fails-with-error-message","title":"Problem: Build Fails with Error Message","text":"

Symptoms: - Clicking \"Build Site\" shows error - Error message displayed in alert - Build timestamp not updated

Causes: 1. Invalid YAML configuration 2. Missing markdown files referenced in navigation 3. MkDocs Docker container not running 4. Theme or plugin not installed

Solutions:

  1. Check configuration validity:
  2. Switch to Settings tab
  3. Verify all required fields filled
  4. Check for validation errors (red borders on inputs)

  5. Verify file references:

  6. Switch to YAML Editor tab
  7. Check nav: section for file paths
  8. Ensure all referenced files exist:

    nav:\n  - Home: index.md  # Must exist at mkdocs/docs/index.md\n  - Guide: guide.md  # Must exist at mkdocs/docs/guide.md\n

  9. Check MkDocs container status:

  10. Open terminal
  11. Run: docker compose ps
  12. Verify mkdocs container is \"Up\"
  13. If not, start container: docker compose up -d mkdocs

  14. View build logs:

  15. Open terminal
  16. Run: docker compose logs mkdocs
  17. Look for error messages in logs
  18. Fix issues indicated in logs (e.g., missing plugin, theme error)

  19. Verify theme and plugins installed:

  20. Themes/plugins must be installed in MkDocs container
  21. Check api/mkdocs/requirements.txt for installed packages
  22. Add missing packages to requirements.txt
  23. Rebuild container: docker compose up -d --build mkdocs
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-settings-changes-not-persisting","title":"Problem: Settings Changes Not Persisting","text":"

Symptoms: - Click \"Save Changes\" in Settings tab - Success message appears - Reload page, changes reverted to old values

Causes: 1. Database write failure 2. Form validation errors preventing save 3. YAML overriding database values

Solutions:

  1. Check browser console:
  2. Open DevTools (F12)
  3. Check Console tab for errors
  4. Look for API errors (400, 500 status codes)

  5. Verify form validation:

  6. Look for red borders on input fields
  7. Red border indicates validation error
  8. Fix validation errors before saving:

    • URL fields must be valid URLs
    • Color fields must be hex format (#RRGGBB)
    • Required fields must be filled
  9. Check API response:

  10. Open DevTools Network tab
  11. Click \"Save Changes\"
  12. Click PUT /api/docs/config request
  13. Check Response tab for error details

  14. Verify database connection:

  15. Open terminal
  16. Run: docker compose logs api
  17. Look for database connection errors
  18. If errors, restart API: docker compose restart api

  19. Check YAML Editor for conflicts:

  20. Switch to YAML Editor tab
  21. Save YAML (even without changes)
  22. YAML save may override database values
  23. Re-enter settings and save again
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-monaco-editor-not-loading","title":"Problem: Monaco Editor Not Loading","text":"

Symptoms: - YAML Editor tab shows blank space or loading spinner - No code editor appears - Console errors related to Monaco

Causes: 1. Slow network loading Monaco assets 2. JavaScript bundle corruption 3. Browser compatibility issues

Solutions:

  1. Wait for loading:
  2. Monaco Editor takes 2-5 seconds to load
  3. Wait for editor to fully initialize before interacting

  4. Check network requests:

  5. Open DevTools Network tab
  6. Look for failed requests to Monaco assets
  7. Failed requests indicated by red text and 4xx/5xx status
  8. If failed, refresh page to retry

  9. Clear browser cache:

  10. Hard refresh (Ctrl+Shift+R)
  11. Or clear browser cache entirely
  12. Reload page to fetch fresh assets

  13. Update browser:

  14. Monaco requires modern browser (Chrome 90+, Firefox 88+, Edge 90+)
  15. Update browser to latest version
  16. Restart browser after update

  17. Use alternative tabs:

  18. If Monaco fails, use Settings or Navigation tabs instead
  19. Both provide same functionality without Monaco Editor
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-campaign-links-not-appearing-in-dropdown","title":"Problem: Campaign Links Not Appearing in Dropdown","text":"

Symptoms: - Click \"Add Campaign Link\" - Modal appears but dropdown is empty - No campaigns to select

Causes: 1. No active campaigns in database 2. API error fetching campaigns 3. Insufficient permissions

Solutions:

  1. Verify active campaigns exist:
  2. Navigate to \"Influence\" \u2192 \"Campaigns\" in sidebar
  3. Check if any campaigns have \"Active\" status
  4. If none, create active campaign first
  5. Return to MkDocs Settings and try again

  6. Check browser console:

  7. Open DevTools (F12)
  8. Click \"Add Campaign Link\"
  9. Check Console for errors
  10. Look for API errors (401, 403, 500)

  11. Verify permissions:

  12. Campaign link insertion requires SUPER_ADMIN role
  13. Check user role in profile dropdown
  14. If not SUPER_ADMIN, request role upgrade from administrator

  15. Check API endpoint:

  16. Open DevTools Network tab
  17. Click \"Add Campaign Link\"
  18. Look for GET /api/influence/campaigns request
  19. Check Response tab for campaign data
  20. If empty response, no campaigns available

  21. Manually add campaign pages:

  22. Instead of campaign link button, use \"Add Page\" button
  23. Manually enter campaign details:
    • Title: \"Climate Action Now\"
    • File: campaigns/climate-action-now.md
  24. Create markdown file manually in mkdocs/docs/campaigns/
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#backend-documentation","title":"Backend Documentation","text":"
  • Docs Routes - API endpoints for MkDocs configuration and build
  • Pages Module - Landing page system (related to MkDocs export)
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#frontend-documentation","title":"Frontend Documentation","text":"
  • DocsPage - MkDocs export management (generates pages for MkDocs)
  • LandingPagesPage - Landing page editor (exports to MkDocs)
  • AppLayout - Sidebar navigation structure
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#feature-documentation","title":"Feature Documentation","text":"
  • Documentation System - Complete docs architecture
  • MkDocs Integration - MkDocs Material theme setup
  • Content Management - Content creation workflow
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#api-documentation","title":"API Documentation","text":"
  • GET /api/docs/config - Fetch MkDocs configuration
  • PUT /api/docs/config - Update MkDocs configuration
  • POST /api/docs/build - Trigger static site build
  • GET /api/docs/orphaned-files - List markdown files not in navigation
  • GET /api/influence/campaigns - Fetch campaigns
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#user-guides","title":"User Guides","text":"
  • Admin Guide - Complete admin workflows
  • Content Editor Guide - Documentation authoring best practices
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#deployment-documentation","title":"Deployment Documentation","text":"
  • MkDocs Setup - Docker container configuration
  • Nginx Configuration - Subdomain routing for docs.cmlite.org
"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#development-documentation","title":"Development Documentation","text":"
  • Local Setup - Running MkDocs dev server
  • Monaco Editor Integration - Code editor setup patterns
"},{"location":"v2/frontend/pages/admin/n8n-page/","title":"N8nPage","text":""},{"location":"v2/frontend/pages/admin/n8n-page/#overview","title":"Overview","text":"

File: admin/src/pages/N8nPage.tsx

Route: /app/services/n8n

Role Requirements: Any authenticated user (uses authenticate middleware)

Purpose: Provides an embedded interface to the n8n workflow automation service via iframe. n8n is a node-based workflow automation tool that allows administrators to create automated workflows connecting different services (email, webhooks, databases, APIs) without writing code. This page serves as a wrapper that embeds the n8n editor with status monitoring and mobile detection.

Key Features: - Full-page iframe embed of n8n workflow editor - Service online/offline status monitoring - Mobile device detection with warning screen - \"Refresh\" and \"Open in New Tab\" buttons - Fullbleed layout for maximum editor space - Access to 200+ n8n integrations

Layout: AppLayout with fullbleed

Dependencies: - Ant Design v5 (Button, Space, Badge, Spin, Grid, Result) - Service URL builder utility

"},{"location":"v2/frontend/pages/admin/n8n-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/n8n-page/#1-service-status-monitoring","title":"1. Service Status Monitoring","text":"

Status Display: - Green \"Online\" badge when n8n is accessible - Red \"Offline\" badge when unavailable - Blue \"Checking...\" badge during status check

"},{"location":"v2/frontend/pages/admin/n8n-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"

Mobile Warning: - ApiOutlined icon (48px) - Message: \"The workflow editor requires a desktop browser\" - \"Open in New Tab\" button for external access

"},{"location":"v2/frontend/pages/admin/n8n-page/#3-workflow-automation","title":"3. Workflow Automation","text":"

n8n Features (within iframe): - Visual Editor: Node-based workflow canvas - 200+ Integrations: Pre-built nodes for popular services - Trigger Nodes: Start workflows on schedule, webhook, manual trigger - Action Nodes: Perform actions (send email, HTTP request, database query) - Logic Nodes: IF conditions, loops, merge data - Execution History: View workflow runs and debug errors - Credentials: Securely store API keys and passwords

Common Use Cases: - Email Notifications: Send campaign updates via SMTP - Webhook Handlers: Respond to external events (Stripe payments, GitHub webhooks) - Database Sync: Sync data between PostgreSQL and external services - Scheduled Tasks: Run cleanup jobs, generate reports, send reminders - API Integrations: Connect to Listmonk, Represent API, geocoding services

"},{"location":"v2/frontend/pages/admin/n8n-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/n8n-page/#creating-a-workflow","title":"Creating a Workflow","text":"
  1. Navigate to Workflow Automation:
  2. Click \"Services\" \u2192 \"Workflow Automation\" in sidebar
  3. Page loads with status check

  4. Wait for Load:

  5. Status badge shows \"Checking...\" then \"Online\"
  6. n8n editor loads in iframe

  7. Create New Workflow:

  8. Click \"New Workflow\" button in n8n
  9. Empty canvas appears

  10. Add Trigger Node:

  11. Click \"+\" button on canvas
  12. Select trigger type:

    • Schedule: Run on cron schedule (e.g., daily at 9am)
    • Webhook: Trigger via HTTP POST
    • Manual: Run manually via button
  13. Add Action Nodes:

  14. Click \"+\" button after trigger
  15. Search for integration (e.g., \"PostgreSQL\", \"Gmail\", \"HTTP Request\")
  16. Configure node:

    • Select credentials (API keys, database connection)
    • Set parameters (SQL query, email recipient, API endpoint)
    • Map data from previous nodes
  17. Test Workflow:

  18. Click \"Execute Workflow\" button
  19. View execution result for each node
  20. Check output data
  21. Debug errors if any

  22. Activate Workflow:

  23. Toggle \"Active\" switch in top-right
  24. Workflow now runs automatically based on trigger

  25. Monitor Executions:

  26. Click \"Executions\" tab
  27. View history of all workflow runs
  28. Click execution to see detailed logs
  29. Identify failed executions
"},{"location":"v2/frontend/pages/admin/n8n-page/#example-workflows","title":"Example Workflows","text":"

1. Daily Campaign Report Email: - Trigger: Schedule (daily at 9am) - Node 1: PostgreSQL query (count responses per campaign) - Node 2: Format data (create HTML email) - Node 3: Gmail send (email report to campaign manager)

2. Listmonk Subscriber Sync: - Trigger: Schedule (hourly) - Node 1: PostgreSQL query (fetch new shift signups) - Node 2: Listmonk API (create/update subscribers) - Node 3: Slack notification (notify team of sync completion)

3. Response Submission Webhook: - Trigger: Webhook (POST /webhook/response) - Node 1: Extract data from webhook payload - Node 2: PostgreSQL insert (create response record) - Node 3: Email notification (notify campaign manager)

"},{"location":"v2/frontend/pages/admin/n8n-page/#component-structure","title":"Component Structure","text":"
export default function N8nPage() {\n  const { setPageHeader } = useOutletContext<AppOutletContext>();\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n\n  const [online, setOnline] = useState<boolean | null>(null);\n  const [config, setConfig] = useState<ServicesConfig | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  const fetchStatus = useCallback(async () => {\n    try {\n      const [statusRes, configRes] = await Promise.all([\n        api.get<ServicesStatus>('/services/status'),\n        api.get<ServicesConfig>('/services/config'),\n      ]);\n      setOnline(statusRes.data.n8n.online);\n      setConfig(configRes.data);\n    } catch {\n      setOnline(false);\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    fetchStatus();\n  }, [fetchStatus]);\n\n  const serviceUrl = config\n    ? buildServiceUrl(config.n8nSubdomain, config.domain, config.n8nPort)\n    : null;\n\n  const headerActions = useMemo(() => (\n    <Space>\n      <Badge\n        status={online === null ? 'processing' : online ? 'success' : 'error'}\n        text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}\n      />\n      <Button icon={<ReloadOutlined />} onClick={fetchStatus} size=\"small\">\n        Refresh\n      </Button>\n      {serviceUrl && (\n        <Button icon={<LinkOutlined />} href={serviceUrl} target=\"_blank\" size=\"small\">\n          Open in New Tab\n        </Button>\n      )}\n    </Space>\n  ), [online, fetchStatus, serviceUrl]);\n\n  useEffect(() => {\n    setPageHeader({ title: 'Workflow Automation', actions: headerActions, fullBleed: true });\n    return () => setPageHeader(null);\n  }, [setPageHeader, headerActions]);\n\n  if (isMobile) {\n    return (\n      <Result\n        status=\"info\"\n        title=\"Desktop Required\"\n        subTitle=\"The workflow editor requires a desktop browser with a larger screen.\"\n        icon={<ApiOutlined style={{ fontSize: 48 }} />}\n      />\n    );\n  }\n\n  if (loading) {\n    return (\n      <div style={{ textAlign: 'center', padding: 80 }}>\n        <Spin size=\"large\" />\n      </div>\n    );\n  }\n\n  if (!online || !serviceUrl) {\n    return (\n      <Result\n        status=\"error\"\n        title=\"n8n Unavailable\"\n        subTitle=\"n8n is not running or could not be reached. Check that the n8n container is started.\"\n        extra={\n          <Button type=\"primary\" onClick={fetchStatus}>\n            Retry\n          </Button>\n        }\n      />\n    );\n  }\n\n  return (\n    <iframe\n      src={serviceUrl}\n      style={{\n        width: '100%',\n        height: 'calc(100vh - 64px)',\n        border: 'none',\n        display: 'block',\n      }}\n      title=\"n8n Workflows\"\n    />\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/n8n-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/n8n-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/services/status - Check n8n health
  2. GET /api/services/config - Fetch subdomain/port config
"},{"location":"v2/frontend/pages/admin/n8n-page/#example-responses","title":"Example Responses","text":"

Status:

{\n  \"n8n\": { \"online\": true },\n  \"mailhog\": { \"online\": true },\n  \"nocodb\": { \"online\": true }\n}\n

Config:

{\n  \"domain\": \"cmlite.org\",\n  \"n8nSubdomain\": \"n8n\",\n  \"n8nPort\": 5678\n}\n

Service URL: - Production: http://n8n.cmlite.org - Development: http://localhost:5678

"},{"location":"v2/frontend/pages/admin/n8n-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/n8n-page/#problem-n8n-login-required","title":"Problem: n8n Login Required","text":"

Symptoms: - Iframe shows n8n login screen - Cannot access workflows without credentials

Solutions:

  1. Check n8n credentials:
  2. Username: N8N_BASIC_AUTH_USER env var
  3. Password: N8N_BASIC_AUTH_PASSWORD env var

  4. Login manually:

  5. Enter credentials in n8n login form
  6. n8n saves session in browser cookies

  7. Disable authentication (dev only):

  8. Set N8N_BASIC_AUTH_ACTIVE=false in .env
  9. Restart n8n: docker compose restart n8n
"},{"location":"v2/frontend/pages/admin/n8n-page/#problem-workflow-execution-failed","title":"Problem: Workflow Execution Failed","text":"

Symptoms: - Workflow shows red error icon - Execution stopped at specific node - Error message displayed

Solutions:

  1. Check node configuration:
  2. Click failed node
  3. Review parameters
  4. Verify credentials valid

  5. Check credentials:

  6. Click \"Credentials\" in n8n sidebar
  7. Test credential connection
  8. Re-enter if expired

  9. View error details:

  10. Click execution in history
  11. Expand failed node
  12. Read error message
  13. Common errors:

    • \"Connection refused\": Service not accessible
    • \"Unauthorized\": Invalid API key/credentials
    • \"Timeout\": Request took too long
  14. Test individual nodes:

  15. Right-click node \u2192 \"Execute Node\"
  16. Test each node in isolation
  17. Identify problematic node
"},{"location":"v2/frontend/pages/admin/n8n-page/#problem-webhook-not-triggering","title":"Problem: Webhook Not Triggering","text":"

Symptoms: - Webhook workflow not executing - External service sending webhooks but n8n not responding

Solutions:

  1. Check webhook URL:
  2. Copy webhook URL from n8n trigger node
  3. Example: http://n8n.cmlite.org/webhook/response
  4. Verify URL accessible from external service

  5. Check workflow active:

  6. Toggle \"Active\" switch must be ON
  7. Inactive workflows don't respond to webhooks

  8. Test webhook manually:

    curl -X POST http://n8n.cmlite.org/webhook/response \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"test\": \"data\"}'\n

  9. Should return 200 OK
  10. Check execution history in n8n

  11. Check nginx routing:

  12. Webhook URL must route through nginx
  13. Verify proxy_pass configured for n8n
"},{"location":"v2/frontend/pages/admin/n8n-page/#related-documentation","title":"Related Documentation","text":"
  • n8n Setup - Docker configuration and credentials
  • Workflow Examples - Pre-built workflows
  • Services API - Status endpoints
  • n8n Documentation - Official n8n docs (external)
"},{"location":"v2/frontend/pages/admin/nocodb-page/","title":"NocoDBPage","text":""},{"location":"v2/frontend/pages/admin/nocodb-page/#overview","title":"Overview","text":"

File: admin/src/pages/NocoDBPage.tsx

Route: /app/services/nocodb

Role Requirements: Any authenticated user (uses authenticate middleware)

Purpose: Provides an embedded interface to the NocoDB database browser via iframe. NocoDB is a no-code database platform that provides a spreadsheet-like interface for viewing and editing database tables. This page serves as a read-only data browser for administrators to explore the PostgreSQL database without SQL knowledge.

Key Features: - Full-page iframe embed of NocoDB service - Service online/offline status monitoring - Mobile device detection with warning screen - \"Refresh\" and \"Open in New Tab\" buttons - Fullbleed layout (no padding) - Read-only access to database tables

Layout: AppLayout with fullbleed

Dependencies: - Ant Design v5 (Button, Space, Badge, Spin, Grid, Result) - Service URL builder utility

"},{"location":"v2/frontend/pages/admin/nocodb-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/nocodb-page/#1-service-status-monitoring","title":"1. Service Status Monitoring","text":"

Status Display: - Green \"Online\" badge when NocoDB is accessible - Red \"Offline\" badge when unavailable - Blue \"Checking...\" badge during status check

"},{"location":"v2/frontend/pages/admin/nocodb-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"

Mobile Warning: - DatabaseOutlined icon (48px) - Message: \"The database browser requires a desktop browser\" - \"Open in New Tab\" button for external access

"},{"location":"v2/frontend/pages/admin/nocodb-page/#3-database-browsing","title":"3. Database Browsing","text":"

NocoDB Features (within iframe): - Table View: Spreadsheet-like interface for all tables - Filter: Filter rows by column values - Sort: Sort rows by any column - Search: Global table search - Export: Export tables to CSV/Excel - Read-Only: No edit/delete capabilities (view only)

Tables Available: - User, RefreshToken (auth) - Campaign, Representative, Response, CampaignEmail (influence) - Location, Cut, Shift, ShiftSignup (map) - CanvassSession, CanvassVisit (canvassing) - LandingPage, PageBlock (pages) - And more...

"},{"location":"v2/frontend/pages/admin/nocodb-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/nocodb-page/#browsing-database-tables","title":"Browsing Database Tables","text":"
  1. Navigate to Database Browser:
  2. Click \"Services\" \u2192 \"Database Browser\" in sidebar
  3. Page loads with status check

  4. Wait for Load:

  5. Status badge shows \"Checking...\" then \"Online\"
  6. NocoDB interface loads in iframe

  7. Select Table:

  8. Left sidebar lists all tables
  9. Click table name to view contents

  10. View Table Data:

  11. Spreadsheet view with all rows and columns
  12. Scroll horizontally/vertically
  13. Click row to expand details

  14. Filter Data:

  15. Click filter icon in column header
  16. Select filter condition (equals, contains, etc.)
  17. Enter filter value
  18. View filtered results

  19. Export Data:

  20. Click \"...\" menu in table header
  21. Select \"Export\" \u2192 \"CSV\" or \"Excel\"
  22. Download file for offline analysis

  23. Common Use Cases:

  24. User Management: Browse User table to see all accounts
  25. Campaign Analysis: View Campaign responses by filtering Response table
  26. Location Data: Export Location table for mapping analysis
  27. Audit Trail: Check RefreshToken table for login activity
"},{"location":"v2/frontend/pages/admin/nocodb-page/#component-structure","title":"Component Structure","text":"
export default function NocoDBPage() {\n  const { setPageHeader } = useOutletContext<AppOutletContext>();\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n\n  const [online, setOnline] = useState<boolean | null>(null);\n  const [config, setConfig] = useState<ServicesConfig | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  const fetchStatus = useCallback(async () => {\n    try {\n      const [statusRes, configRes] = await Promise.all([\n        api.get<ServicesStatus>('/services/status'),\n        api.get<ServicesConfig>('/services/config'),\n      ]);\n      setOnline(statusRes.data.nocodb.online);\n      setConfig(configRes.data);\n    } catch {\n      setOnline(false);\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    fetchStatus();\n  }, [fetchStatus]);\n\n  const serviceUrl = config\n    ? buildServiceUrl(config.nocodbSubdomain, config.domain, config.nocodbPort)\n    : null;\n\n  const headerActions = useMemo(() => (\n    <Space>\n      <Badge\n        status={online === null ? 'processing' : online ? 'success' : 'error'}\n        text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}\n      />\n      <Button icon={<ReloadOutlined />} onClick={fetchStatus} size=\"small\">\n        Refresh\n      </Button>\n      {serviceUrl && (\n        <Button icon={<LinkOutlined />} href={serviceUrl} target=\"_blank\" size=\"small\">\n          Open in New Tab\n        </Button>\n      )}\n    </Space>\n  ), [online, fetchStatus, serviceUrl]);\n\n  useEffect(() => {\n    setPageHeader({ title: 'Database Browser', actions: headerActions, fullBleed: true });\n    return () => setPageHeader(null);\n  }, [setPageHeader, headerActions]);\n\n  if (isMobile) {\n    return (\n      <Result\n        status=\"info\"\n        title=\"Desktop Required\"\n        subTitle=\"The database browser requires a desktop browser with a larger screen.\"\n        icon={<DatabaseOutlined style={{ fontSize: 48 }} />}\n      />\n    );\n  }\n\n  if (loading) {\n    return (\n      <div style={{ textAlign: 'center', padding: 80 }}>\n        <Spin size=\"large\" />\n      </div>\n    );\n  }\n\n  if (!online || !serviceUrl) {\n    return (\n      <Result\n        status=\"error\"\n        title=\"NocoDB Unavailable\"\n        subTitle=\"NocoDB is not running or could not be reached. Check that the NocoDB container is started.\"\n        extra={\n          <Button type=\"primary\" onClick={fetchStatus}>\n            Retry\n          </Button>\n        }\n      />\n    );\n  }\n\n  return (\n    <iframe\n      src={serviceUrl}\n      style={{\n        width: '100%',\n        height: 'calc(100vh - 64px)',\n        border: 'none',\n        display: 'block',\n      }}\n      title=\"NocoDB\"\n    />\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/nocodb-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/nocodb-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/services/status - Check NocoDB health
  2. GET /api/services/config - Fetch subdomain/port config
"},{"location":"v2/frontend/pages/admin/nocodb-page/#example-responses","title":"Example Responses","text":"

Status:

{\n  \"nocodb\": { \"online\": true },\n  \"mailhog\": { \"online\": true },\n  \"n8n\": { \"online\": true }\n}\n

Config:

{\n  \"domain\": \"cmlite.org\",\n  \"nocodbSubdomain\": \"db\",\n  \"nocodbPort\": 8091\n}\n

Service URL: - Production: http://db.cmlite.org - Development: http://localhost:8091

"},{"location":"v2/frontend/pages/admin/nocodb-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/nocodb-page/#problem-nocodb-login-required","title":"Problem: NocoDB Login Required","text":"

Symptoms: - Iframe shows NocoDB login screen - Cannot access tables without credentials

Solutions:

  1. Check NocoDB admin credentials:
  2. Username: NC_ADMIN_EMAIL env var
  3. Password: NC_ADMIN_PASSWORD env var

  4. Login manually:

  5. Enter admin credentials in NocoDB login form
  6. NocoDB saves session in browser cookies

  7. Reset password:

    docker compose exec nocodb sh\nnc-cli reset-password --email admin@example.com\n

"},{"location":"v2/frontend/pages/admin/nocodb-page/#problem-tables-not-visible","title":"Problem: Tables Not Visible","text":"

Symptoms: - NocoDB loads but no tables in left sidebar - \"No projects found\" message

Solutions:

  1. Check NocoDB configuration:
  2. NocoDB must be connected to PostgreSQL
  3. Check NC_DB env var: postgresql://user:password@host:port/database

  4. Create NocoDB project:

  5. Click \"New Project\" button
  6. Connect to PostgreSQL database
  7. Enter database credentials (from V2_POSTGRES_* env vars)

  8. Restart NocoDB:

    docker compose restart nocodb\n

"},{"location":"v2/frontend/pages/admin/nocodb-page/#related-documentation","title":"Related Documentation","text":"
  • NocoDB Setup - Docker configuration
  • Database Schema - Complete table reference
  • Services API - Status endpoints
"},{"location":"v2/frontend/pages/admin/observability-page/","title":"ObservabilityPage","text":""},{"location":"v2/frontend/pages/admin/observability-page/#overview","title":"Overview","text":"

File: admin/src/pages/ObservabilityPage.tsx Route: /app/observability Role Requirements: SUPER_ADMIN

ObservabilityPage is the system monitoring and alerting dashboard for Changemaker Lite's observability stack. It provides a unified interface for viewing Prometheus metrics, Grafana dashboards, and Alertmanager alerts. The page features three tabs (Overview, Monitoring, Alerts), service status monitoring for 7 monitoring services, key metrics grid, active alerts table, and embedded iframes for Grafana and Alertmanager with lazy loading.

The page integrates with: - Prometheus (port 9090) - Metrics collection and time-series database - Grafana (port 3001) - Metrics visualization and dashboards - Alertmanager (port 9093) - Alert management and routing - cAdvisor (port 8080) - Container metrics - Node Exporter (port 9100) - Host system metrics - Redis Exporter (port 9121) - Redis metrics - Gotify (port 8889) - Notification service

Key Features: - Three-tab interface (Overview/Monitoring/Alerts) with radio button switcher - Service status cards (7 services) with online/offline indicators - Metrics grid showing key application metrics (API uptime, queue size, sessions, etc.) - Active alerts table with severity indicators - Lazy-loaded Grafana iframe (Application Overview dashboard) - Lazy-loaded Alertmanager iframe - Auto-start banner for offline services - \"Open Grafana\" button for full-screen access

Key Components: - ServiceStatusCard for each monitoring service - MetricsGrid for application metrics - AlertsTable for active alerts - IframeErrorBoundary for iframe error handling - Radio.Group for tab switching

"},{"location":"v2/frontend/pages/admin/observability-page/#screenshot","title":"Screenshot","text":"

[Screenshot: ObservabilityPage showing three-tab interface at top (Overview/Monitoring/Alerts radio buttons), Overview tab displaying service status cards in grid (Prometheus, Grafana, Alertmanager, cAdvisor, Node Exporter, Redis Exporter, Gotify) with green/red online/offline indicators, key metrics grid below showing API stats, and active alerts table at bottom. Header has Refresh and \"Open Grafana\" buttons.]

"},{"location":"v2/frontend/pages/admin/observability-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/observability-page/#core-features","title":"Core Features","text":"
  1. Three-Tab Interface
  2. Overview Tab: Service status + metrics + alerts summary
  3. Monitoring Tab: Embedded Grafana Application Overview dashboard
  4. Alerts Tab: Embedded Alertmanager UI
  5. Radio button switcher in page header
  6. Tab state preserved during session

  7. Service Status Monitoring

  8. 7 service status cards:
    • Prometheus - Metrics database
    • Grafana - Dashboard visualization
    • Alertmanager - Alert management
    • cAdvisor - Container metrics
    • Node Exporter - Host metrics
    • Redis Exporter - Redis metrics
    • Gotify - Notification service
  9. Online/offline badge indicators
  10. Clickable URL to open service in new tab
  11. Responsive grid layout (4 columns on desktop, 2 on tablet, 1 on mobile)

  12. Auto-Start Banner

  13. Warning alert at top of Overview tab when all services offline
  14. Shows Docker Compose command to start monitoring services
  15. Command: docker compose --profile monitoring up -d
  16. Only shows when servicesOnline === 0

  17. Key Metrics Grid

  18. Displays application-specific metrics from Prometheus
  19. Examples: API uptime, email queue size, active canvass sessions, total locations
  20. Only visible when at least one service online
  21. Powered by MetricsGrid component

  22. Active Alerts Table

  23. Shows currently firing alerts from Alertmanager
  24. Columns: Alert name, severity, status, start time
  25. Color-coded severity (critical=red, warning=orange, info=blue)
  26. Only visible when at least one service online
  27. Powered by AlertsTable component

  28. Grafana Dashboard Iframe

  29. Embedded Application Overview dashboard
  30. Lazy-loaded (only loads when Monitoring tab selected)
  31. Full-height iframe (calc(100vh - 200px))
  32. Sandboxed for security (allow-scripts, allow-same-origin, allow-forms)
  33. Error boundary for graceful failure handling
  34. Shows warning if Grafana offline

  35. Alertmanager Iframe

  36. Embedded Alertmanager UI
  37. Lazy-loaded (only loads when Alerts tab selected)
  38. Full-height iframe (calc(100vh - 200px))
  39. Sandboxed for security
  40. Error boundary for graceful failure handling
  41. Shows warning if Alertmanager offline

  42. Refresh Button

  43. Refreshes all data (status, metrics, alerts) in parallel
  44. Visible in all tabs
  45. Loading state during refresh

  46. Open Grafana Button

  47. Primary button in header (blue)
  48. Opens Grafana in new tab at full URL
  49. Only visible when Grafana online
  50. Provides full-screen Grafana access
"},{"location":"v2/frontend/pages/admin/observability-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/observability-page/#viewing-system-status-overview-tab","title":"Viewing System Status (Overview Tab)","text":"
  1. Navigate to page: Admin sidebar \u2192 System \u2192 Observability
  2. Overview tab loads: Shows service status cards, metrics grid, alerts table
  3. Check service status: Green badges = online, red badges = offline
  4. Review metrics: Scan key application metrics (uptime, queue size, etc.)
  5. Check alerts: Review active alerts table for firing alerts
"},{"location":"v2/frontend/pages/admin/observability-page/#starting-monitoring-services","title":"Starting Monitoring Services","text":"

If all services offline: 1. See warning banner: Yellow alert at top with Docker Compose command 2. Copy command: docker compose --profile monitoring up -d 3. Run in terminal: Execute command in project directory 4. Wait ~30 seconds: Services take time to start 5. Click Refresh: Reload page to verify services online 6. Banner disappears: Warning banner no longer shown

"},{"location":"v2/frontend/pages/admin/observability-page/#viewing-grafana-dashboards","title":"Viewing Grafana Dashboards","text":"
  1. Click \"Monitoring\" tab: Radio button in header
  2. Grafana iframe loads: Embedded Application Overview dashboard
  3. Interact with dashboard: Pan, zoom, change time range, etc.
  4. Full-screen access: Click \"Open Grafana\" button for new tab
  5. Explore more dashboards: In Grafana UI, browse other dashboards (Host Metrics, Docker Containers, etc.)
"},{"location":"v2/frontend/pages/admin/observability-page/#managing-alerts","title":"Managing Alerts","text":"
  1. Click \"Alerts\" tab: Radio button in header
  2. Alertmanager iframe loads: Embedded alert management UI
  3. View alert groups: See all firing alerts grouped by label
  4. Silence alerts: Click Silence button to temporarily suppress
  5. Configure routes: Modify alert routing rules (if SUPER_ADMIN)
"},{"location":"v2/frontend/pages/admin/observability-page/#refreshing-data","title":"Refreshing Data","text":"
  1. Click Refresh button: In header (any tab)
  2. All data reloads: Service status, metrics, alerts fetched in parallel
  3. Loading state: Brief spinner or loading indicator
  4. Data updates: New status/metrics/alerts displayed
"},{"location":"v2/frontend/pages/admin/observability-page/#opening-service-directly","title":"Opening Service Directly","text":"
  1. Click on service status card URL (if service online)
  2. New tab opens: Direct access to service (e.g., Prometheus, Grafana, Alertmanager)
  3. Full service UI: No iframe restrictions, full functionality
"},{"location":"v2/frontend/pages/admin/observability-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/observability-page/#tab-switcher-header","title":"Tab Switcher (Header)","text":"
<Radio.Group\n  value={activeTab}\n  onChange={e => setActiveTab(e.target.value)}\n  buttonStyle=\"solid\"\n>\n  <Radio.Button value=\"overview\">\n    <DashboardOutlined /> Overview\n  </Radio.Button>\n  <Radio.Button value=\"monitoring\">\n    <LineChartOutlined /> Monitoring\n  </Radio.Button>\n  <Radio.Button value=\"alerts\">\n    <AlertOutlined /> Alerts\n  </Radio.Button>\n</Radio.Group>\n

Solid button style: Active tab highlighted with blue background.

"},{"location":"v2/frontend/pages/admin/observability-page/#service-status-card","title":"Service Status Card","text":"
<ServiceStatusCard\n  name=\"Prometheus\"\n  online={status?.prometheus?.online || false}\n  url={status?.prometheus?.url || ''}\n  icon={<DashboardOutlined />}\n/>\n

ServiceStatusCard Component:

interface ServiceStatusCardProps {\n  name: string;\n  online: boolean;\n  url: string;\n  icon: React.ReactNode;\n}\n\n// Displays:\n// - Service name (bold)\n// - Badge (green \"Online\" or red \"Offline\")\n// - Icon\n// - Clickable link to service URL (if online)\n

"},{"location":"v2/frontend/pages/admin/observability-page/#auto-start-banner","title":"Auto-Start Banner","text":"
{allOffline && (\n  <Alert\n    message=\"Monitoring services are offline\"\n    description={\n      <>\n        Start monitoring services with: <code>docker compose --profile monitoring up -d</code>\n      </>\n    }\n    type=\"warning\"\n    showIcon\n    style={{ marginBottom: 16 }}\n  />\n)}\n

Condition: allOffline = servicesOnline === 0

"},{"location":"v2/frontend/pages/admin/observability-page/#service-status-grid","title":"Service Status Grid","text":"
<Card title=\"Service Status\" style={{ marginBottom: 16 }}>\n  <Row gutter={[16, 16]}>\n    <Col xs={24} sm={12} lg={6}>\n      <ServiceStatusCard name=\"Prometheus\" online={...} url={...} icon={<DashboardOutlined />} />\n    </Col>\n    <Col xs={24} sm={12} lg={6}>\n      <ServiceStatusCard name=\"Grafana\" online={...} url={...} icon={<LineChartOutlined />} />\n    </Col>\n    {/* 5 more cards... */}\n  </Row>\n</Card>\n

Responsive Grid: - Desktop (lg, \u2265 992px): 4 columns (6/24 = 25% width each) - Tablet (sm, \u2265 576px): 2 columns (12/24 = 50% width each) - Mobile (xs, < 576px): 1 column (24/24 = 100% width)

"},{"location":"v2/frontend/pages/admin/observability-page/#metrics-grid","title":"Metrics Grid","text":"
{!allOffline && <MetricsGrid metrics={metrics} loading={loading} />}\n

MetricsGrid Component: - Displays application metrics from Prometheus - Examples: API uptime, email queue size, active sessions, location count - Styled as grid of Statistic cards - Only renders when at least one service online

"},{"location":"v2/frontend/pages/admin/observability-page/#alerts-table","title":"Alerts Table","text":"
{!allOffline && alerts && (\n  <AlertsTable alerts={alerts.alerts || []} loading={loading} />\n)}\n

AlertsTable Component: - Ant Design Table with columns: - Alert name - Severity (color-coded tag) - Status (firing/resolved) - Start time (relative) - Pagination if > 10 alerts - Only renders when at least one service online

"},{"location":"v2/frontend/pages/admin/observability-page/#grafana-iframe-monitoring-tab","title":"Grafana Iframe (Monitoring Tab)","text":"
<IframeErrorBoundary serviceName=\"Grafana\">\n  <Card styles={{ body: { padding: 0 } }}>\n    {grafanaIframeSrc ? (\n      <iframe\n        src={grafanaIframeSrc}\n        style={{\n          width: '100%',\n          height: 'calc(100vh - 200px)',\n          border: 'none',\n        }}\n        title=\"Grafana Dashboard\"\n        aria-label=\"Embedded Grafana application overview dashboard\"\n        sandbox=\"allow-scripts allow-same-origin allow-forms\"\n        referrerPolicy=\"strict-origin-when-cross-origin\"\n        loading=\"lazy\"\n      />\n    ) : (\n      <Spin />\n    )}\n  </Card>\n</IframeErrorBoundary>\n

Lazy Loading Logic:

useEffect(() => {\n  if (activeTab === 'monitoring' && !grafanaInitialized.current && status?.grafana.online) {\n    try {\n      const url = buildMonitoringUrl('grafana', 3005, '/d/application-overview');\n      setGrafanaIframeSrc(url);\n      grafanaInitialized.current = true;\n    } catch (error) {\n      console.error('Failed to construct Grafana URL:', error);\n    }\n  }\n}, [activeTab, status]);\n

Pattern: Iframe src set only when: 1. Monitoring tab selected 2. Not already initialized (ref tracks this) 3. Grafana is online

"},{"location":"v2/frontend/pages/admin/observability-page/#alertmanager-iframe-alerts-tab","title":"Alertmanager Iframe (Alerts Tab)","text":"
<IframeErrorBoundary serviceName=\"Alertmanager\">\n  <Card styles={{ body: { padding: 0 } }}>\n    {alertmanagerIframeSrc ? (\n      <iframe\n        src={alertmanagerIframeSrc}\n        style={{\n          width: '100%',\n          height: 'calc(100vh - 200px)',\n          border: 'none',\n        }}\n        title=\"Alertmanager\"\n        aria-label=\"Embedded Alertmanager alert management interface\"\n        sandbox=\"allow-scripts allow-same-origin allow-forms\"\n        referrerPolicy=\"strict-origin-when-cross-origin\"\n        loading=\"lazy\"\n      />\n    ) : (\n      <Spin />\n    )}\n  </Card>\n</IframeErrorBoundary>\n

Same lazy loading pattern as Grafana.

"},{"location":"v2/frontend/pages/admin/observability-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/observability-page/#local-state","title":"Local State","text":"

Data State:

const [status, setStatus] = useState<ObservabilityStatus | null>(null);\nconst [metrics, setMetrics] = useState<MetricsSummary | null>(null);\nconst [alerts, setAlerts] = useState<AlertsResponse | null>(null);\nconst [loading, setLoading] = useState(true);\n

UI State:

const [activeTab, setActiveTab] = useState<TabKey>('overview');\nconst [grafanaIframeSrc, setGrafanaIframeSrc] = useState<string | null>(null);\nconst [alertmanagerIframeSrc, setAlertmanagerIframeSrc] = useState<string | null>(null);\nconst grafanaInitialized = useRef(false);\nconst alertmanagerInitialized = useRef(false);\n

"},{"location":"v2/frontend/pages/admin/observability-page/#data-fetching","title":"Data Fetching","text":"

Fetch Status:

const fetchStatus = useCallback(async () => {\n  try {\n    const res = await api.get<ObservabilityStatus>('/observability/status');\n    setStatus(res.data);\n  } catch {\n    // Status fetch failed \u2014 leave null\n  }\n}, []);\n

Fetch Metrics:

const fetchMetrics = useCallback(async () => {\n  try {\n    const res = await api.get<MetricsSummary>('/observability/metrics-summary');\n    setMetrics(res.data);\n  } catch {\n    // Metrics fetch may fail if Prometheus is offline\n  }\n}, []);\n

Fetch Alerts:

const fetchAlerts = useCallback(async () => {\n  try {\n    const res = await api.get<AlertsResponse>('/observability/alerts');\n    setAlerts(res.data);\n  } catch {\n    // Alerts fetch may fail if Alertmanager is offline\n  }\n}, []);\n

Fetch All (Parallel):

const fetchAll = useCallback(async () => {\n  setLoading(true);\n  await Promise.all([fetchStatus(), fetchMetrics(), fetchAlerts()]);\n  setLoading(false);\n}, [fetchStatus, fetchMetrics, fetchAlerts]);\n

Benefit: Parallel API calls load faster than sequential.

"},{"location":"v2/frontend/pages/admin/observability-page/#lazy-iframe-loading","title":"Lazy Iframe Loading","text":"
useEffect(() => {\n  if (activeTab === 'monitoring' && !grafanaInitialized.current && status?.grafana.online) {\n    try {\n      const url = buildMonitoringUrl('grafana', 3005, '/d/application-overview');\n      setGrafanaIframeSrc(url);\n      grafanaInitialized.current = true;\n    } catch (error) {\n      console.error('Failed to construct Grafana URL:', error);\n    }\n  }\n}, [activeTab, status]);\n

Why Lazy Loading? - Avoids loading heavy iframes until needed - Improves initial page load performance - Saves bandwidth if user never clicks Monitoring/Alerts tabs

Why useRef? - Tracks initialization state without triggering re-renders - Prevents redundant iframe loads on subsequent tab switches

"},{"location":"v2/frontend/pages/admin/observability-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/observability-page/#endpoints-used","title":"Endpoints Used","text":"

GET /observability/status - Fetch service online/offline status

const { data } = await api.get<ObservabilityStatus>('/observability/status');\n

Response:

{\n  \"prometheus\": {\n    \"online\": true,\n    \"url\": \"http://localhost:9090\"\n  },\n  \"grafana\": {\n    \"online\": true,\n    \"url\": \"http://localhost:3001\"\n  },\n  \"alertmanager\": {\n    \"online\": true,\n    \"url\": \"http://localhost:9093\"\n  },\n  \"cadvisor\": {\n    \"online\": true,\n    \"url\": \"http://localhost:8080\"\n  },\n  \"nodeExporter\": {\n    \"online\": true,\n    \"url\": \"http://localhost:9100\"\n  },\n  \"redisExporter\": {\n    \"online\": true,\n    \"url\": \"http://localhost:9121\"\n  },\n  \"gotify\": {\n    \"online\": false,\n    \"url\": \"http://localhost:8889\"\n  }\n}\n

GET /observability/metrics-summary - Fetch key application metrics

const { data } = await api.get<MetricsSummary>('/observability/metrics-summary');\n

Response:

{\n  \"apiUptime\": 99.8,\n  \"emailQueueSize\": 42,\n  \"activeCanvassSessions\": 5,\n  \"totalLocations\": 12543,\n  \"httpRequestsTotal\": 156789,\n  \"httpRequestDurationSeconds\": 0.234\n}\n

GET /observability/alerts - Fetch active alerts

const { data } = await api.get<AlertsResponse>('/observability/alerts');\n

Response:

{\n  \"alerts\": [\n    {\n      \"id\": \"alert_1\",\n      \"name\": \"HighMemoryUsage\",\n      \"severity\": \"warning\",\n      \"status\": \"firing\",\n      \"startTime\": \"2026-02-11T10:30:00Z\",\n      \"labels\": {\n        \"alertname\": \"HighMemoryUsage\",\n        \"instance\": \"api:4000\",\n        \"severity\": \"warning\"\n      },\n      \"annotations\": {\n        \"summary\": \"Memory usage above 80%\",\n        \"description\": \"API container using 85% memory\"\n      }\n    }\n  ]\n}\n

"},{"location":"v2/frontend/pages/admin/observability-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/observability-page/#parallel-api-calls","title":"Parallel API Calls","text":"
const fetchAll = useCallback(async () => {\n  setLoading(true);\n  await Promise.all([fetchStatus(), fetchMetrics(), fetchAlerts()]);\n  setLoading(false);\n}, [fetchStatus, fetchMetrics, fetchAlerts]);\n

Benefit: Loads all data simultaneously (faster than sequential).

"},{"location":"v2/frontend/pages/admin/observability-page/#lazy-iframe-loading-pattern","title":"Lazy Iframe Loading Pattern","text":"
const grafanaInitialized = useRef(false);\n\nuseEffect(() => {\n  if (activeTab === 'monitoring' && !grafanaInitialized.current && status?.grafana.online) {\n    const url = buildMonitoringUrl('grafana', 3005, '/d/application-overview');\n    setGrafanaIframeSrc(url);\n    grafanaInitialized.current = true;\n  }\n}, [activeTab, status]);\n

Pattern: 1. Check if tab active 2. Check if not already initialized (useRef) 3. Check if service online 4. Build URL and set iframe src 5. Mark as initialized (prevents redundant loads)

"},{"location":"v2/frontend/pages/admin/observability-page/#services-online-count","title":"Services Online Count","text":"
const servicesOnline = status\n  ? Object.values(status).filter((s: ServiceStatus) => s.online).length\n  : 0;\nconst allOffline = servicesOnline === 0;\n

Counts online services from status object values.

"},{"location":"v2/frontend/pages/admin/observability-page/#conditional-rendering-based-on-service-status","title":"Conditional Rendering Based on Service Status","text":"
{allOffline && (\n  <Alert\n    message=\"Monitoring services are offline\"\n    description={<>Start with: <code>docker compose --profile monitoring up -d</code></>}\n    type=\"warning\"\n  />\n)}\n\n{!allOffline && <MetricsGrid metrics={metrics} loading={loading} />}\n{!allOffline && alerts && <AlertsTable alerts={alerts.alerts || []} loading={loading} />}\n

Pattern: Show banner if all offline, hide metrics/alerts if all offline.

"},{"location":"v2/frontend/pages/admin/observability-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/observability-page/#parallel-api-calls_1","title":"Parallel API Calls","text":"

Three API calls made simultaneously instead of sequentially:

await Promise.all([fetchStatus(), fetchMetrics(), fetchAlerts()]);\n

Benefit: Reduces total load time from ~300ms (100ms \u00d7 3) to ~100ms (max of 3 parallel requests).

"},{"location":"v2/frontend/pages/admin/observability-page/#lazy-iframe-loading_1","title":"Lazy Iframe Loading","text":"

Iframes only load when tab selected: - Grafana iframe: activeTab === 'monitoring' - Alertmanager iframe: activeTab === 'alerts'

Benefit: Saves bandwidth and reduces initial page load time. Heavy iframes (~1-2MB each) not loaded unless needed.

"},{"location":"v2/frontend/pages/admin/observability-page/#useref-for-initialization-tracking","title":"useRef for Initialization Tracking","text":"
const grafanaInitialized = useRef(false);\n

Why useRef instead of useState? - Doesn't trigger re-renders when updated - Persists across re-renders - Perfect for tracking initialization state

"},{"location":"v2/frontend/pages/admin/observability-page/#conditional-component-rendering","title":"Conditional Component Rendering","text":"
{!allOffline && <MetricsGrid metrics={metrics} loading={loading} />}\n

Avoids rendering heavy components when no services online (no data to show).

"},{"location":"v2/frontend/pages/admin/observability-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/observability-page/#service-status-grid_1","title":"Service Status Grid","text":"
<Row gutter={[16, 16]}>\n  <Col xs={24} sm={12} lg={6}>\n    <ServiceStatusCard ... />\n  </Col>\n  {/* 6 more cards... */}\n</Row>\n

Responsive Breakpoints: - Desktop (lg, \u2265 992px): 4 columns (6/24 each) - Tablet (sm, \u2265 576px): 2 columns (12/24 each) - Mobile (xs, < 576px): 1 column (24/24 each)

"},{"location":"v2/frontend/pages/admin/observability-page/#iframe-height","title":"Iframe Height","text":"
<iframe style={{ height: 'calc(100vh - 200px)' }} />\n

Dynamic height: Fills viewport minus header/footer (responsive to window resize).

"},{"location":"v2/frontend/pages/admin/observability-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/observability-page/#iframe-labels","title":"Iframe Labels","text":"
<iframe\n  title=\"Grafana Dashboard\"\n  aria-label=\"Embedded Grafana application overview dashboard\"\n/>\n

Screen reader support: Clear description of iframe content.

"},{"location":"v2/frontend/pages/admin/observability-page/#button-labels","title":"Button Labels","text":"
<Button icon={<ReloadOutlined />}>Refresh</Button>\n<Button icon={<LinkOutlined />}>Open Grafana</Button>\n

Not icon-only buttons \u2013 text labels for clarity.

"},{"location":"v2/frontend/pages/admin/observability-page/#service-status-badges","title":"Service Status Badges","text":"
<Badge status=\"success\" text=\"Online\" />\n<Badge status=\"error\" text=\"Offline\" />\n

Color + text: Not relying on color alone for status indication.

"},{"location":"v2/frontend/pages/admin/observability-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/observability-page/#all-services-offline","title":"All Services Offline","text":"

Symptoms: - Warning banner at top - All service status cards show red \"Offline\" - No metrics or alerts displayed

Cause: Monitoring services not started (Docker Compose profile monitoring not active)

Solution:

# Start monitoring services\ndocker compose --profile monitoring up -d\n\n# Verify services running\ndocker compose ps | grep -E \"(prometheus|grafana|alertmanager)\"\n\n# Check logs if services fail to start\ndocker compose logs prometheus grafana alertmanager\n

"},{"location":"v2/frontend/pages/admin/observability-page/#grafanaalertmanager-iframe-not-loading","title":"Grafana/Alertmanager Iframe Not Loading","text":"

Symptoms: - Blank iframe or loading spinner forever - Console errors about iframe src

Causes: 1. Service offline (check Overview tab status) 2. CORS policy blocking iframe 3. Network error

Debug:

# Check Grafana container\ndocker compose logs grafana\n\n# Test Grafana directly\ncurl http://localhost:3001\n\n# Check nginx proxy (if using)\ndocker compose logs nginx | grep grafana\n

"},{"location":"v2/frontend/pages/admin/observability-page/#metrics-not-showing","title":"Metrics Not Showing","text":"

Symptoms: - MetricsGrid empty or shows zeros - \"Failed to load metrics\" error

Cause: Prometheus offline or not scraping metrics

Solutions:

# Check Prometheus status\ncurl http://localhost:9090/-/healthy\n\n# Check Prometheus targets (should show API as \"up\")\ncurl http://localhost:9090/api/v1/targets\n\n# Verify API is exposing /metrics endpoint\ncurl http://localhost:4000/metrics\n

"},{"location":"v2/frontend/pages/admin/observability-page/#alerts-not-showing","title":"Alerts Not Showing","text":"

Symptoms: - AlertsTable empty - No alerts firing (but should be)

Causes: 1. Alertmanager offline 2. No alerts configured in Prometheus 3. Alerts resolved (not firing)

Debug:

# Check Alertmanager status\ncurl http://localhost:9093/-/healthy\n\n# Check Prometheus alerts\ncurl http://localhost:9090/api/v1/alerts\n\n# Check alert rules config\ndocker compose exec api cat /app/configs/prometheus/alerts.yml\n

"},{"location":"v2/frontend/pages/admin/observability-page/#open-grafana-button-not-visible","title":"\"Open Grafana\" Button Not Visible","text":"

Cause: Grafana offline

Expected Behavior:

{status?.grafana.online && (\n  <Button href={status.grafana.url} target=\"_blank\">\n    Open Grafana\n  </Button>\n)}\n

Button only shows when Grafana online.

"},{"location":"v2/frontend/pages/admin/observability-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/observability-page/#backend-integration","title":"Backend Integration","text":"
  • Observability Module - Service, routes, Prometheus integration
  • Observability API Reference - Full endpoint documentation
"},{"location":"v2/frontend/pages/admin/observability-page/#features_1","title":"Features","text":"
  • Monitoring System - Feature overview
  • Prometheus Metrics - Custom metrics documentation
  • Alert Rules - Alert configuration
  • Grafana Dashboards - Dashboard documentation
"},{"location":"v2/frontend/pages/admin/observability-page/#deployment","title":"Deployment","text":"
  • Monitoring Stack Deployment - Docker Compose setup
  • Prometheus Configuration - Prometheus config
  • Grafana Configuration - Grafana setup
"},{"location":"v2/frontend/pages/admin/observability-page/#troubleshooting_1","title":"Troubleshooting","text":"
  • Monitoring Issues - Common monitoring problems
  • Docker Issues - Container troubleshooting
"},{"location":"v2/frontend/pages/admin/observability-page/#user-guides","title":"User Guides","text":"
  • Admin Guide - Observability - Monitoring workflows
"},{"location":"v2/frontend/pages/admin/observability-page/#external-resources","title":"External Resources","text":"
  • Prometheus Documentation - Prometheus reference
  • Grafana Documentation - Grafana docs
  • Alertmanager Documentation - Alertmanager reference
"},{"location":"v2/frontend/pages/admin/observability-page/#frontend-components","title":"Frontend Components","text":"
  • ServiceStatusCard Component - Status card documentation
  • MetricsGrid Component - Metrics grid component
  • AlertsTable Component - Alerts table component
  • IframeErrorBoundary Component - Error boundary wrapper
"},{"location":"v2/frontend/pages/admin/page-editor-page/","title":"PageEditorPage","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#overview","title":"Overview","text":"

File: admin/src/pages/PageEditorPage.tsx

Route: /app/pages/:id/edit

Role Requirements: Any authenticated user (uses authenticate middleware)

Purpose: Provides a full-screen dual-mode editor for landing pages with visual WYSIWYG editing (GrapesJS) and raw HTML code editing (Monaco Editor). This page is the primary interface for creating and editing landing pages, supporting both drag-and-drop visual design for non-technical users and direct HTML/CSS editing for developers. The editor operates in full-screen mode without the AppLayout wrapper to maximize editing space.

Key Features: - Dual editor modes (Visual/Code) toggled per page - Full-screen editing interface (no AppLayout) - GrapesJS visual editor with custom blocks - Monaco Editor for raw HTML editing - Real-time save with Ctrl+S keyboard shortcut - Live preview for published pages - Publish/unpublish toggle - Mobile device detection with warning screen - Auto-save on Ctrl+S in code mode - Editor state managed via useRef for performance

Layout: Full-screen (no AppLayout wrapper)

Dependencies: - Ant Design v5 (Button, Switch, Space, Typography, Tag, Spin, Grid, Result) - Monaco Editor (@monaco-editor/react) - GrapesJS (via GrapesJSEditor component wrapper) - react-router-dom (useParams, useNavigate)

"},{"location":"v2/frontend/pages/admin/page-editor-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#1-dual-editor-modes","title":"1. Dual Editor Modes","text":"

Visual Mode (GrapesJS): - Drag-and-drop interface - Custom block library (loaded from API) - Component tree navigation - Style manager (CSS properties) - Trait manager (component attributes) - Asset manager (images, files) - Canvas preview (desktop/tablet/mobile) - Undo/redo - Full-screen toggle - Export HTML/CSS

Code Mode (Monaco Editor): - Syntax highlighting for HTML - Line numbers - Word wrap enabled - Auto-formatting - Dark theme - Minimap disabled for cleaner view - Automatic layout adjustment - Ctrl+S keyboard shortcut for save - Direct HTML editing (no CSS/JS extraction)

Mode Selection: - Set when creating page in LandingPagesPage - editorMode field: \"VISUAL\" or \"CODE\" - Cannot switch modes within editor (navigate back to pages list to change) - Mode displayed as colored tag in toolbar

"},{"location":"v2/frontend/pages/admin/page-editor-page/#2-toolbar-controls","title":"2. Toolbar Controls","text":"

Left Section: - Back Button - Navigate to pages list - Page Title - Current page name - Slug Display - Public URL preview (/p/:slug) - Mode Tag - Visual (green) or Code (blue)

Right Section: - Published Toggle - Switch to enable/disable public access - Live Tag - Visible when published - Preview Button - Opens public page in new tab (only when published) - Save Button - Manual save trigger (primary action)

"},{"location":"v2/frontend/pages/admin/page-editor-page/#3-auto-save-keyboard-shortcuts","title":"3. Auto-Save & Keyboard Shortcuts","text":"

Code Mode Shortcuts: - Ctrl+S / Cmd+S - Save page (prevents browser default) - Keyboard event handler registered on mount - Handler cleaned up on unmount

Visual Mode Save: - Save button triggers editorRef.current?.triggerSave() - GrapesJS editor handles internal save via forwardRef

"},{"location":"v2/frontend/pages/admin/page-editor-page/#4-mobile-device-detection","title":"4. Mobile Device Detection","text":"

Mobile Warning: - Detects screen width < 768px (md breakpoint) - Shows Result component with \"Desktop Required\" message - \"Back to Pages\" button for navigation - Prevents editor loading on mobile devices - Different message for visual vs code mode

"},{"location":"v2/frontend/pages/admin/page-editor-page/#5-loading-error-states","title":"5. Loading & Error States","text":"

Loading State: - Full-screen centered spinner - Displayed while fetching page data + blocks - Minimum height: 100vh

Error Handling: - Failed fetch shows error message - Auto-navigates back to pages list - Prevents editor render on missing page

"},{"location":"v2/frontend/pages/admin/page-editor-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#opening-a-page-for-editing","title":"Opening a Page for Editing","text":"
  1. Navigate to Pages List:
  2. Go to /app/pages (LandingPagesPage)
  3. View table of all landing pages

  4. Select Page to Edit:

  5. Click \"Edit\" button in page row
  6. Opens editor in full-screen mode
  7. URL changes to /app/pages/:id/edit

  8. Wait for Editor Load:

  9. Loading spinner appears
  10. Page data fetched from API
  11. Visual mode: block library also loaded
  12. Editor renders based on mode
"},{"location":"v2/frontend/pages/admin/page-editor-page/#editing-in-visual-mode","title":"Editing in Visual Mode","text":"
  1. Use GrapesJS Interface:
  2. Add Components: Drag blocks from left sidebar onto canvas
  3. Move Components: Click and drag to reposition
  4. Edit Text: Double-click text to edit inline
  5. Style Components: Select component, use Style Manager in right panel
  6. Change Attributes: Use Trait Manager for component properties
  7. Upload Images: Use Asset Manager to add media

  8. Canvas Controls:

  9. Toggle device preview (desktop/tablet/mobile)
  10. Toggle fullscreen mode
  11. Toggle borders/padding visualization

  12. Save Changes:

  13. Click \"Save\" button in toolbar (or Ctrl+S)
  14. Editor extracts:
    • Project data (component tree JSON)
    • Rendered HTML output
    • Compiled CSS styles
  15. All three sent to API via PUT request
  16. Success message: \"Page saved\"
"},{"location":"v2/frontend/pages/admin/page-editor-page/#editing-in-code-mode","title":"Editing in Code Mode","text":"
  1. Edit HTML Directly:
  2. Monaco editor displays current HTML output
  3. Edit HTML structure, inline styles, content
  4. Syntax highlighting for HTML tags

  5. Save Changes:

  6. Press Ctrl+S (or Cmd+S on Mac)
  7. Or click \"Save\" button in toolbar
  8. Raw HTML content sent to API
  9. Success message: \"Page saved\"

  10. Limitations:

  11. No visual preview within editor
  12. Must publish and use Preview button to see changes
  13. Changes don't update GrapesJS project data (one-way sync)
"},{"location":"v2/frontend/pages/admin/page-editor-page/#publishing-a-page","title":"Publishing a Page","text":"
  1. Toggle Published Switch:
  2. Switch in top-right toolbar
  3. Green = published, Gray = unpublished
  4. API updates published field immediately

  5. When Published:

  6. \"Live\" tag appears next to switch
  7. \"Preview\" button becomes visible
  8. Page accessible at /p/:slug URL

  9. Preview Published Page:

  10. Click \"Preview\" button (eye icon)
  11. Opens new browser tab to /p/:slug
  12. Shows rendered page as public users see it
"},{"location":"v2/frontend/pages/admin/page-editor-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#main-component-structure","title":"Main Component Structure","text":"
export default function PageEditorPage() {\n  const { id } = useParams<{ id: string }>();\n  const navigate = useNavigate();\n  const screens = Grid.useBreakpoint();\n  const isMobile = !screens.md;\n  const { token } = theme.useToken();\n\n  // State\n  const [page, setPage] = useState<LandingPage | null>(null);\n  const [blocks, setBlocks] = useState<PageBlock[]>([]);\n  const [loading, setLoading] = useState(true);\n  const [saving, setSaving] = useState(false);\n  const [codeContent, setCodeContent] = useState('');\n  const editorRef = useRef<GrapesJSEditorHandle>(null);\n\n  // Derived state\n  const isCodeMode = page?.editorMode === 'CODE';\n\n  // Fetch page + blocks (Visual mode only)\n  useEffect(() => {\n    const fetchData = async () => {\n      try {\n        if (isCodeMode) {\n          const pageRes = await api.get<LandingPage>(`/pages/${id}`);\n          setPage(pageRes.data);\n          setCodeContent(pageRes.data.htmlOutput || '');\n        } else {\n          const [pageRes, blocksRes] = await Promise.all([\n            api.get<LandingPage>(`/pages/${id}`),\n            api.get<PageBlock[]>('/page-blocks'),\n          ]);\n          setPage(pageRes.data);\n          setBlocks(blocksRes.data);\n          setCodeContent(pageRes.data.htmlOutput || '');\n        }\n      } catch {\n        message.error('Failed to load page');\n        navigate('/app/pages');\n      } finally {\n        setLoading(false);\n      }\n    };\n    fetchData();\n  }, [id]);\n\n  // Ctrl+S keyboard shortcut (code mode only)\n  useEffect(() => {\n    if (!isCodeMode) return;\n    const handler = (e: KeyboardEvent) => {\n      if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n        e.preventDefault();\n        handleSaveCode();\n      }\n    };\n    window.addEventListener('keydown', handler);\n    return () => window.removeEventListener('keydown', handler);\n  }, [isCodeMode, handleSaveCode]);\n\n  // Conditional render based on state\n  if (loading) return <Spin />;\n  if (!page) return null;\n  if (isMobile) return <MobileWarning />;\n\n  return (\n    <div style={{ height: '100vh' }}>\n      <Toolbar />\n      {isCodeMode ? <MonacoEditor /> : <GrapesJSEditor />}\n    </div>\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/page-editor-page/#toolbar-component","title":"Toolbar Component","text":"

Structure: - Full-width sticky header - Dark background (colorBgBase) - Border bottom separator - Two-column layout (Space components)

Left Section:

<Space>\n  <Button type=\"text\" icon={<ArrowLeftOutlined />} onClick={goBack} />\n  <Text strong>{page.title}</Text>\n  <Text>/p/{page.slug}</Text>\n  <Tag color={isCodeMode ? 'blue' : 'green'}>\n    {isCodeMode ? 'Code' : 'Visual'}\n  </Tag>\n</Space>\n

Right Section:

<Space>\n  <Space size={4}>\n    <Text>Published</Text>\n    <Switch checked={page.published} onChange={handleTogglePublished} />\n  </Space>\n  {page.published && <Tag color=\"green\">Live</Tag>}\n  {page.published && (\n    <Button icon={<EyeOutlined />} onClick={() => window.open(`/p/${page.slug}`)}>\n      Preview\n    </Button>\n  )}\n  <Button type=\"primary\" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>\n    Save\n  </Button>\n</Space>\n

"},{"location":"v2/frontend/pages/admin/page-editor-page/#grapesjs-editor-integration","title":"GrapesJS Editor Integration","text":"

Component:

<GrapesJSEditor\n  ref={editorRef}\n  initialData={page.blocks as Record<string, unknown>}\n  onSave={handleSaveVisual}\n  customBlocks={blocks}\n/>\n

Save Callback:

const handleSaveVisual = useCallback(async (data: {\n  projectData: Record<string, unknown>;\n  html: string;\n  css: string;\n}) => {\n  setSaving(true);\n  try {\n    const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {\n      blocks: data.projectData,\n      htmlOutput: data.html,\n      cssOutput: data.css,\n    });\n    setPage(updated);\n    message.success('Page saved');\n  } catch {\n    message.error('Failed to save page');\n  } finally {\n    setSaving(false);\n  }\n}, [page]);\n

Trigger Save (from parent):

editorRef.current?.triggerSave();\n

"},{"location":"v2/frontend/pages/admin/page-editor-page/#monaco-editor-integration","title":"Monaco Editor Integration","text":"

Component:

<Editor\n  height=\"100%\"\n  defaultLanguage=\"html\"\n  theme=\"vs-dark\"\n  value={codeContent}\n  onChange={(value) => setCodeContent(value ?? '')}\n  options={{\n    wordWrap: 'on',\n    minimap: { enabled: false },\n    fontSize: 14,\n    scrollBeyondLastLine: false,\n    automaticLayout: true,\n  }}\n/>\n

Save Handler:

const handleSaveCode = useCallback(async () => {\n  setSaving(true);\n  try {\n    const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {\n      htmlOutput: codeContent,\n    });\n    setPage(updated);\n    message.success('Page saved');\n  } catch {\n    message.error('Failed to save page');\n  } finally {\n    setSaving(false);\n  }\n}, [page, codeContent]);\n

"},{"location":"v2/frontend/pages/admin/page-editor-page/#mobile-warning-component","title":"Mobile Warning Component","text":"

Conditional Render:

if (isMobile) {\n  return (\n    <div style={{\n      display: 'flex',\n      flexDirection: 'column',\n      alignItems: 'center',\n      justifyContent: 'center',\n      height: '100vh',\n      padding: 24,\n      background: token.colorBgBase,\n    }}>\n      <Result\n        status=\"info\"\n        title=\"Desktop Required\"\n        subTitle={`The ${isCodeMode ? 'code' : 'page'} editor requires a desktop browser. Please switch to a larger screen to edit this page.`}\n        extra={\n          <Button type=\"primary\" onClick={() => navigate('/app/pages')}>\n            Back to Pages\n          </Button>\n        }\n      />\n    </div>\n  );\n}\n

Breakpoint Detection: - Uses Grid.useBreakpoint() hook - isMobile = !screens.md (screen width < 768px) - Early return prevents editor initialization

"},{"location":"v2/frontend/pages/admin/page-editor-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#local-component-state-usestate","title":"Local Component State (useState)","text":"
// Page data\nconst [page, setPage] = useState<LandingPage | null>(null);\n\n// Block library (Visual mode only)\nconst [blocks, setBlocks] = useState<PageBlock[]>([]);\n\n// Loading state\nconst [loading, setLoading] = useState(true);\n\n// Save in progress state\nconst [saving, setSaving] = useState(false);\n\n// Monaco editor content (Code mode)\nconst [codeContent, setCodeContent] = useState('');\n
"},{"location":"v2/frontend/pages/admin/page-editor-page/#refs-useref","title":"Refs (useRef)","text":"
// GrapesJS editor handle\nconst editorRef = useRef<GrapesJSEditorHandle>(null);\n

Why useRef? - GrapesJS editor controlled externally - Parent triggers save via editorRef.current?.triggerSave() - No re-renders when editor state changes - Performance optimization for large canvas

"},{"location":"v2/frontend/pages/admin/page-editor-page/#derived-state","title":"Derived State","text":"
// Computed from page data\nconst isCodeMode = page?.editorMode === 'CODE';\n\n// Responsive breakpoint\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n\n// Theme tokens\nconst { token } = theme.useToken();\n
"},{"location":"v2/frontend/pages/admin/page-editor-page/#state-flow","title":"State Flow","text":"
  1. Component Mounts:
  2. loading set to true
  3. useEffect triggers fetch based on mode
  4. Visual mode: parallel fetch page + blocks
  5. Code mode: fetch page only
  6. Sets page, blocks, codeContent
  7. Sets loading to false

  8. User Edits Content:

  9. Visual mode: GrapesJS manages internal state
  10. Code mode: Monaco onChange updates codeContent

  11. User Saves:

  12. Visual mode: editorRef.current?.triggerSave() \u2192 handleSaveVisual callback
  13. Code mode: handleSaveCode directly
  14. Sets saving to true
  15. API PUT request
  16. Updates page with response
  17. Sets saving to false

  18. User Toggles Published:

  19. API PUT request with published field
  20. Updates page with response
  21. UI updates (Live tag, Preview button)
"},{"location":"v2/frontend/pages/admin/page-editor-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/pages/:id - Fetch page data
  2. GET /api/page-blocks - Fetch custom block library (Visual mode only)
  3. PUT /api/pages/:id - Update page (save, publish)
"},{"location":"v2/frontend/pages/admin/page-editor-page/#api-calls","title":"API Calls","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#1-fetch-page-blocks-visual-mode","title":"1. Fetch Page + Blocks (Visual Mode)","text":"
const [pageRes, blocksRes] = await Promise.all([\n  api.get<LandingPage>(`/pages/${id}`),\n  api.get<PageBlock[]>('/page-blocks'),\n]);\nsetPage(pageRes.data);\nsetBlocks(blocksRes.data);\nsetCodeContent(pageRes.data.htmlOutput || '');\n

LandingPage Response:

{\n  \"id\": \"123e4567-e89b-12d3-a456-426614174000\",\n  \"title\": \"Campaign Launch\",\n  \"slug\": \"campaign-launch\",\n  \"editorMode\": \"VISUAL\",\n  \"blocks\": {\n    \"pages\": [...],\n    \"styles\": [...],\n    \"components\": [...]\n  },\n  \"htmlOutput\": \"<html>...</html>\",\n  \"cssOutput\": \".container { ... }\",\n  \"published\": false,\n  \"createdAt\": \"2025-02-10T12:00:00Z\",\n  \"updatedAt\": \"2025-02-10T14:30:00Z\"\n}\n

PageBlock Response:

[\n  {\n    \"id\": \"block-hero\",\n    \"label\": \"Hero Section\",\n    \"category\": \"sections\",\n    \"content\": \"<div class='hero'>...</div>\",\n    \"media\": \"<svg>...</svg>\",\n    \"attributes\": { \"class\": \"gjs-block\" }\n  },\n  ...\n]\n

"},{"location":"v2/frontend/pages/admin/page-editor-page/#2-fetch-page-only-code-mode","title":"2. Fetch Page Only (Code Mode)","text":"
const pageRes = await api.get<LandingPage>(`/pages/${id}`);\nsetPage(pageRes.data);\nsetCodeContent(pageRes.data.htmlOutput || '');\n
"},{"location":"v2/frontend/pages/admin/page-editor-page/#3-save-visual-mode-changes","title":"3. Save Visual Mode Changes","text":"
const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {\n  blocks: data.projectData,\n  htmlOutput: data.html,\n  cssOutput: data.css,\n});\nsetPage(updated);\n

Request Body:

{\n  \"blocks\": {\n    \"pages\": [...],\n    \"styles\": [...],\n    \"components\": [...]\n  },\n  \"htmlOutput\": \"<html>...</html>\",\n  \"cssOutput\": \".container { ... }\"\n}\n

"},{"location":"v2/frontend/pages/admin/page-editor-page/#4-save-code-mode-changes","title":"4. Save Code Mode Changes","text":"
const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {\n  htmlOutput: codeContent,\n});\nsetPage(updated);\n

Request Body:

{\n  \"htmlOutput\": \"<!DOCTYPE html>\\n<html>...</html>\"\n}\n

"},{"location":"v2/frontend/pages/admin/page-editor-page/#5-toggle-published","title":"5. Toggle Published","text":"
const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {\n  published: !page.published,\n});\nsetPage(updated);\nmessage.success(updated.published ? 'Page published' : 'Page unpublished');\n

Request Body:

{\n  \"published\": true\n}\n

"},{"location":"v2/frontend/pages/admin/page-editor-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#complete-save-visual-mode-flow","title":"Complete Save Visual Mode Flow","text":"
const handleSaveVisual = useCallback(async (data: {\n  projectData: Record<string, unknown>;\n  html: string;\n  css: string;\n}) => {\n  if (!page) return;\n  setSaving(true);\n  try {\n    const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {\n      blocks: data.projectData,\n      htmlOutput: data.html,\n      cssOutput: data.css,\n    });\n    setPage(updated);\n    message.success('Page saved');\n  } catch {\n    message.error('Failed to save page');\n  } finally {\n    setSaving(false);\n  }\n}, [page]);\n
"},{"location":"v2/frontend/pages/admin/page-editor-page/#keyboard-shortcut-handler","title":"Keyboard Shortcut Handler","text":"
useEffect(() => {\n  if (!isCodeMode) return;  // Only in code mode\n\n  const handler = (e: KeyboardEvent) => {\n    if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n      e.preventDefault();  // Prevent browser save dialog\n      handleSaveCode();\n    }\n  };\n\n  window.addEventListener('keydown', handler);\n  return () => window.removeEventListener('keydown', handler);  // Cleanup\n}, [isCodeMode, handleSaveCode]);\n
"},{"location":"v2/frontend/pages/admin/page-editor-page/#conditional-api-fetch-pattern","title":"Conditional API Fetch Pattern","text":"
useEffect(() => {\n  const fetchData = async () => {\n    try {\n      if (isCodeMode) {\n        // Code mode: page only\n        const pageRes = await api.get<LandingPage>(`/pages/${id}`);\n        setPage(pageRes.data);\n        setCodeContent(pageRes.data.htmlOutput || '');\n      } else {\n        // Visual mode: page + blocks in parallel\n        const [pageRes, blocksRes] = await Promise.all([\n          api.get<LandingPage>(`/pages/${id}`),\n          api.get<PageBlock[]>('/page-blocks'),\n        ]);\n        setPage(pageRes.data);\n        setBlocks(blocksRes.data);\n        setCodeContent(pageRes.data.htmlOutput || '');\n      }\n    } catch {\n      message.error('Failed to load page');\n      navigate('/app/pages');\n    } finally {\n      setLoading(false);\n    }\n  };\n  fetchData();\n}, [id]); // Only re-fetch if page ID changes\n
"},{"location":"v2/frontend/pages/admin/page-editor-page/#editor-ref-save-trigger","title":"Editor Ref Save Trigger","text":"
// Parent component\n<Button\n  type=\"primary\"\n  icon={<SaveOutlined />}\n  loading={saving}\n  onClick={() => {\n    if (isCodeMode) {\n      handleSaveCode();\n    } else {\n      editorRef.current?.triggerSave();  // Trigger GrapesJS save\n    }\n  }}\n>\n  Save\n</Button>\n\n// GrapesJSEditor component\n<GrapesJSEditor\n  ref={editorRef}\n  initialData={page.blocks}\n  onSave={handleSaveVisual}  // Callback receives extracted data\n  customBlocks={blocks}\n/>\n
"},{"location":"v2/frontend/pages/admin/page-editor-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#1-parallel-api-requests-visual-mode","title":"1. Parallel API Requests (Visual Mode)","text":"
const [pageRes, blocksRes] = await Promise.all([\n  api.get<LandingPage>(`/pages/${id}`),\n  api.get<PageBlock[]>('/page-blocks'),\n]);\n

Benefit: Reduces loading time by ~50% (2 sequential requests \u2192 1 parallel batch).

"},{"location":"v2/frontend/pages/admin/page-editor-page/#2-conditional-block-loading","title":"2. Conditional Block Loading","text":"
if (isCodeMode) {\n  // Skip blocks fetch in code mode\n  const pageRes = await api.get<LandingPage>(`/pages/${id}`);\n} else {\n  // Load blocks only for visual mode\n  const [pageRes, blocksRes] = await Promise.all([...]);\n}\n

Benefit: Saves unnecessary API call in code mode (blocks not used).

"},{"location":"v2/frontend/pages/admin/page-editor-page/#3-useref-for-editor-handle","title":"3. useRef for Editor Handle","text":"
const editorRef = useRef<GrapesJSEditorHandle>(null);\n

Why useRef? - GrapesJS editor has large internal state (component tree, styles, assets) - useRef prevents re-renders when editor state changes - Parent only needs to trigger save, not track editor state - Performance critical for large page designs

"},{"location":"v2/frontend/pages/admin/page-editor-page/#4-usecallback-for-save-handlers","title":"4. useCallback for Save Handlers","text":"
const handleSaveVisual = useCallback(async (data) => {\n  // ...\n}, [page]);  // Only recreate when page changes\n\nconst handleSaveCode = useCallback(async () => {\n  // ...\n}, [page, codeContent]);  // Only recreate when deps change\n

Benefit: Prevents unnecessary function recreation on every render.

"},{"location":"v2/frontend/pages/admin/page-editor-page/#5-early-mobile-detection","title":"5. Early Mobile Detection","text":"
if (isMobile) {\n  return <MobileWarning />;  // No editor initialization\n}\n

Benefit: Skips heavy editor initialization on mobile devices (saves memory + CPU).

"},{"location":"v2/frontend/pages/admin/page-editor-page/#6-automatic-monaco-layout","title":"6. Automatic Monaco Layout","text":"
<Editor\n  options={{\n    automaticLayout: true,  // Auto-adjust on window resize\n    minimap: { enabled: false },  // Disable minimap to save CPU\n    scrollBeyondLastLine: false,  // Reduce DOM size\n  }}\n/>\n

Benefit: Reduces Monaco memory footprint by disabling minimap (can use 100MB+ on large files).

"},{"location":"v2/frontend/pages/admin/page-editor-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#mobile-detection","title":"Mobile Detection","text":"

Breakpoint: - Uses Grid.useBreakpoint() hook - Mobile if !screens.md (screen width < 768px)

Mobile Warning Screen:

if (isMobile) {\n  return (\n    <div style={{\n      display: 'flex',\n      flexDirection: 'column',\n      alignItems: 'center',\n      justifyContent: 'center',\n      height: '100vh',\n      padding: 24,\n      background: token.colorBgBase,\n    }}>\n      <Result\n        status=\"info\"\n        title=\"Desktop Required\"\n        subTitle={`The ${isCodeMode ? 'code' : 'page'} editor requires a desktop browser...`}\n        extra={\n          <Button type=\"primary\" onClick={() => navigate('/app/pages')}>\n            Back to Pages\n          </Button>\n        }\n      />\n    </div>\n  );\n}\n

Why Mobile Warning? - GrapesJS requires large screen for drag-and-drop UI (canvas + panels + toolbar) - Monaco editor impractical on mobile keyboards - Touch gestures conflict with editor interactions - Better UX to redirect users to desktop device

"},{"location":"v2/frontend/pages/admin/page-editor-page/#full-screen-layout","title":"Full-Screen Layout","text":"

No AppLayout Wrapper: - Page routed outside AppLayout component - Uses full viewport height (100vh) - No sidebar navigation - Maximizes editing canvas space

Layout Structure:

<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>\n  <Toolbar />  {/* Fixed header */}\n  <Editor />   {/* Flex-grow to fill remaining space */}\n</div>\n

"},{"location":"v2/frontend/pages/admin/page-editor-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#keyboard-navigation","title":"Keyboard Navigation","text":"
  1. Tab Key:
  2. Cycles through toolbar buttons (Back, Save, Preview, Toggle)
  3. Enters editor focus (Monaco or GrapesJS canvas)

  4. Ctrl+S / Cmd+S:

  5. Save shortcut (code mode only)
  6. Prevents browser default \"Save Page As\" dialog

  7. GrapesJS Keyboard Shortcuts:

  8. Ctrl+Z: Undo
  9. Ctrl+Shift+Z: Redo
  10. Delete: Remove selected component
  11. Ctrl+C/V: Copy/paste components

  12. Monaco Editor Shortcuts:

  13. Ctrl+S: Save (custom handler)
  14. Ctrl+F: Find
  15. Ctrl+H: Find and replace
  16. Ctrl+/: Toggle comment
  17. Alt+Up/Down: Move line up/down
"},{"location":"v2/frontend/pages/admin/page-editor-page/#aria-labels","title":"ARIA Labels","text":"
<Button\n  type=\"text\"\n  icon={<ArrowLeftOutlined />}\n  onClick={() => navigate('/app/pages')}\n  aria-label=\"Back to pages list\"\n/>\n

Screen Reader Announcements: - Button labels announced via aria-label - Switch state announced (\"Published\" / \"Unpublished\") - Tag colors announced by screen readers

"},{"location":"v2/frontend/pages/admin/page-editor-page/#focus-management","title":"Focus Management","text":"

Toolbar Focus Order: 1. Back button 2. Published switch 3. Preview button (if visible) 4. Save button

Editor Focus: - Monaco: automatic focus management via Monaco API - GrapesJS: focus enters canvas on click

"},{"location":"v2/frontend/pages/admin/page-editor-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#problem-editor-not-loading","title":"Problem: Editor Not Loading","text":"

Symptoms: - Blank screen after loading spinner disappears - Console errors related to GrapesJS or Monaco

Solutions:

  1. Check page data in API response:
    curl -H \"Authorization: Bearer <token>\" \\\n  http://localhost:4000/api/pages/<page-id>\n
  2. Verify blocks, htmlOutput, editorMode fields exist

  3. Check browser console:

  4. Open DevTools Console (F12)
  5. Look for JavaScript errors
  6. Common errors:

    • \"Cannot read property 'pages' of undefined\" \u2192 blocks field missing/corrupt
    • \"Monaco Editor failed to load\" \u2192 CDN blocked or slow network
  7. Clear browser cache:

  8. Monaco and GrapesJS cache resources
  9. Ctrl+Shift+R (hard refresh)

  10. Check network tab:

  11. Verify API requests complete successfully
  12. Verify block library loads (Visual mode)
"},{"location":"v2/frontend/pages/admin/page-editor-page/#problem-save-button-not-working","title":"Problem: Save Button Not Working","text":"

Symptoms: - Click Save button, no success message - Loading spinner appears but never completes - Console shows 400/500 errors

Solutions:

  1. Check API request in Network tab:
  2. Look for PUT /api/pages/:id request
  3. Check request payload (should have htmlOutput, blocks, cssOutput)
  4. Check response status code

  5. Visual Mode - Invalid blocks data:

  6. GrapesJS may generate invalid JSON
  7. Check console for serialization errors
  8. Try creating new page instead of editing corrupt one

  9. Code Mode - Invalid HTML:

  10. API may validate HTML structure
  11. Check for missing closing tags
  12. Check for script injection attempts (blocked by CSP)

  13. Network timeout:

  14. Large pages (>1MB HTML) may timeout
  15. Increase Axios timeout in admin/src/lib/api.ts
  16. Optimize HTML output (minify, remove unused CSS)
"},{"location":"v2/frontend/pages/admin/page-editor-page/#problem-ctrls-not-saving-code-mode","title":"Problem: Ctrl+S Not Saving (Code Mode)","text":"

Symptoms: - Press Ctrl+S, nothing happens - Browser \"Save Page As\" dialog appears instead

Solutions:

  1. Check browser focus:
  2. Ensure Monaco editor is focused (click inside editor)
  3. Keyboard handler requires window focus

  4. Check browser extensions:

  5. Extensions may intercept Ctrl+S
  6. Test in incognito mode
  7. Disable extensions one by one

  8. Mac users: Use Cmd+S instead of Ctrl+S

  9. Handler supports both e.ctrlKey and e.metaKey

  10. Manual save as fallback:

  11. Click \"Save\" button in toolbar
  12. Same effect as Ctrl+S
"},{"location":"v2/frontend/pages/admin/page-editor-page/#problem-published-page-not-accessible","title":"Problem: Published Page Not Accessible","text":"

Symptoms: - Toggle \"Published\" switch to ON - Navigate to /p/:slug, get 404 error

Solutions:

  1. Check slug uniqueness:
  2. Slug must be unique across all pages
  3. Check for URL conflicts with existing routes

  4. Check page published status:

    curl -H \"Authorization: Bearer <token>\" \\\n  http://localhost:4000/api/pages/<page-id>\n

  5. Verify \"published\": true in response

  6. Check public route registration:

  7. Open admin/src/App.tsx
  8. Verify public route exists:

    <Route path=\"/p/:slug\" element={<LandingPage />} />\n

  9. Check nginx routing:

  10. Public pages served through nginx
  11. Verify nginx reverse proxy configuration

  12. Hard refresh public page:

  13. Ctrl+Shift+R to bypass cache
  14. Browser may cache 404 response
"},{"location":"v2/frontend/pages/admin/page-editor-page/#problem-grapesjs-not-loading-custom-blocks","title":"Problem: GrapesJS Not Loading Custom Blocks","text":"

Symptoms: - Visual editor loads but block panel is empty - Only default blocks visible (Text, Image, etc.)

Solutions:

  1. Check blocks API response:
    curl -H \"Authorization: Bearer <token>\" \\\n  http://localhost:4000/api/page-blocks\n
  2. Should return array of blocks with label, content, media

  3. Check blocks passed to GrapesJS:

  4. Add console.log in PageEditorPage:
    console.log('Custom blocks:', blocks);\n
  5. Verify array not empty

  6. Check GrapesJS block registration:

  7. Open admin/src/components/GrapesJSEditor.tsx
  8. Verify blocks registered in editor.BlockManager.add()

  9. Clear GrapesJS localStorage:

  10. GrapesJS caches project data
  11. Open DevTools \u2192 Application \u2192 Local Storage
  12. Delete keys starting with gjsProject-
"},{"location":"v2/frontend/pages/admin/page-editor-page/#related-documentation","title":"Related Documentation","text":"
  • LandingPagesPage - Page list + create new page
  • Landing Pages Feature - Full feature documentation
  • Pages API - API endpoints
  • GrapesJSEditor Component - Editor wrapper
  • Block Library - Custom block system
  • Public Landing Pages - Rendered page component
  • MkDocs Export - Export to documentation site
"},{"location":"v2/frontend/pages/admin/pangolin-page/","title":"PangolinPage","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#overview","title":"Overview","text":"

File: admin/src/pages/PangolinPage.tsx Route: /app/tunnel Role Requirements: SUPER_ADMIN

PangolinPage is the tunnel management interface for Changemaker Lite's Pangolin Integration, which provides secure tunneling and public access to the platform via the Newt container. The page provides a complete setup wizard for first-time configuration, resource management for subdomains and services, exit node selection for multi-node setups, and Newt container lifecycle controls.

The page displays three main sections: 1. Status Dashboard: Shows Pangolin API health, configuration state, and Newt container status 2. Setup Wizard: Guides admins through site creation, subnet allocation, and exit node selection 3. Resource Management: Table of tunnel resources with SSL, active status, port, protocol, and access controls

Key Components: - Status card with Descriptions showing configuration and health - Setup form with auto-suggested subnet calculation - Exit node selector (optional for self-hosted setups) - Resource table with edit modal and delete confirmation - Newt container restart button - Credential display with show/hide toggle

"},{"location":"v2/frontend/pages/admin/pangolin-page/#screenshot","title":"Screenshot","text":"

[Screenshot: PangolinPage showing three sections: 1) Status card at top with configuration (Configured=Yes, Healthy, API URL, Newt Container Ready, Org ID, Site ID), 2) Setup wizard (if not configured) with site name input, subnet input, exit node dropdown, and Create button, 3) Resource management table showing domains with SSL, Active, Port, Protocol, Blocked columns and Edit/Delete actions. Setup wizard includes credential display alert with show/hide button after successful setup.]

"},{"location":"v2/frontend/pages/admin/pangolin-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#core-features","title":"Core Features","text":"
  1. Status Monitoring
  2. Pangolin API configuration check (Configured: Yes/No)
  3. Server health check (Healthy/Unreachable)
  4. API URL display with copy button
  5. Newt container status (Ready/Running/Stopped/Not configured)
  6. Organization ID and Site ID display
  7. Auto-refresh on status change

  8. Setup Wizard (First-Time Configuration)

  9. Site name input (defaults to changemaker-{domain})
  10. Subnet allocation with auto-suggestion (calculates next available subnet)
  11. Exit node selection (optional, only shown if exit nodes available)
  12. Online/offline exit node status indicators
  13. Auto-select single online exit node
  14. Create Site + Resources button
  15. Post-setup credential display with show/hide toggle

  16. Credential Management

  17. Shows PANGOLIN_SITE_ID and NEWT_* credentials after setup
  18. Show/Hide credentials button (security)
  19. Copy to Clipboard button
  20. Clear Credentials button
  21. Step-by-step instructions for .env setup
  22. Newt container restart button after credential update

  23. Resource Management

  24. Table showing all tunnel resources (subdomains)
  25. Columns: Name, Domain (copyable), SSL status, Active status, Port, Protocol, Blocked
  26. Edit button opens modal for resource configuration
  27. Delete button with Popconfirm
  28. Sync Resources button (creates missing resources from docker-compose.yml)
  29. Restart Newt button in table header

  30. Resource Editing

  31. Edit modal with form fields:
    • Name (text input)
    • Protocol (e.g., http, https)
    • Proxy Port (number input)
    • Enable SSL (checkbox)
    • Active (checkbox)
    • Block Access (checkbox for maintenance mode)
  32. Update button saves changes to Pangolin API

  33. Exit Node Support

  34. Fetches available exit nodes from Pangolin API
  35. Displays exit node name and location
  36. Shows online/offline status
  37. Filters out offline nodes (with user notice)
  38. Auto-selects if only one online node available
  39. Graceful fallback if no exit nodes (self-hosted setups)

  40. Newt Container Management

  41. Status monitoring (running/stopped/ready)
  42. Restart button in Status card
  43. Restart button in Resource table header
  44. 3-second delay after restart before status check
  45. Success/error messages for restart operations

  46. Subnet Auto-Suggestion

  47. Fetches existing sites from Pangolin API
  48. Parses last subnet (e.g., 100.90.128.2/24)
  49. Suggests next available subnet (increments last octet)
  50. Defaults to 100.90.128.3/24 if no sites exist
  51. Allows manual override if suggested subnet conflicts

  52. Security Features

  53. Credentials hidden by default
  54. Show/Hide toggle for sensitive values
  55. Clear button to remove credentials from screen
  56. Text sanitization for external API data (defense-in-depth)
"},{"location":"v2/frontend/pages/admin/pangolin-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#initial-setup-first-time-configuration","title":"Initial Setup (First-Time Configuration)","text":"
  1. Navigate to page: Admin sidebar \u2192 System \u2192 Tunnel Management
  2. Check status: If \"Configured: No\", see blue info alert
  3. Configure .env: Add PANGOLIN_API_URL, PANGOLIN_API_KEY, PANGOLIN_ORG_ID to .env
  4. Restart API: docker compose restart api
  5. Reload page: Status card shows \"Configured: Yes\"
  6. Fill setup form:
  7. Site Name: changemaker-cmlite.org (or custom)
  8. Subnet: 100.90.128.3/24 (auto-suggested, or override)
  9. Exit Node: Select if available (or leave empty for self-hosted)
  10. Click \"Create Site + Resources\"
  11. Wait for setup: Loading spinner, ~5-10 seconds
  12. View credentials: Success alert shows PANGOLIN_SITE_ID and NEWT_* values
  13. Show credentials: Click \"Show Credentials\" button
  14. Copy to .env: Click \"Copy to Clipboard\" button, paste into .env file
  15. Clear credentials: After copying, click \"Clear Credentials\" (security)
  16. Update .env: Save .env file with new values
  17. Restart Newt: Click \"Restart Newt Container\" button
  18. Verify status: Newt status changes to \"Ready\" (green tag)
"},{"location":"v2/frontend/pages/admin/pangolin-page/#viewing-status","title":"Viewing Status","text":"
  1. Open page: Navigate to /app/tunnel
  2. Check configuration: Status card shows Configured/Healthy/Newt Container status
  3. Verify API URL: Copy URL if needed for external tools
  4. Check Org/Site IDs: Verify correct organization and site selected
  5. Monitor Newt: Check if container is Ready (green) or Stopped (red)
"},{"location":"v2/frontend/pages/admin/pangolin-page/#managing-resources","title":"Managing Resources","text":"
  1. View resources: Scroll to \"Tunnel Resources\" card
  2. Check domains: Each row shows subdomain (e.g., api.cmlite.org)
  3. Verify SSL: Green \"Yes\" tag indicates SSL enabled
  4. Check active status: Green \"Active\" tag = resource enabled
  5. Review ports: Verify proxy port matches docker-compose.yml
  6. Edit resource: Click Edit button for a resource
  7. Modify settings: Change name, protocol, port, SSL, active, or block access
  8. Save changes: Click Update button in modal
  9. Verify update: Table refreshes with new values
"},{"location":"v2/frontend/pages/admin/pangolin-page/#syncing-resources","title":"Syncing Resources","text":"
  1. Add new service: Update docker-compose.yml with new subdomain
  2. Click \"Sync Resources\": Button in table header
  3. Wait for sync: Loading state, ~2-5 seconds
  4. View results: Success message shows {created} created, {skipped} skipped
  5. Check table: New resources appear in table
"},{"location":"v2/frontend/pages/admin/pangolin-page/#restarting-newt-container","title":"Restarting Newt Container","text":"

Scenario 1: After Credential Update 1. Update .env: Add PANGOLIN_SITE_ID and NEWT_* credentials 2. Click \"Restart Newt Container\" in setup wizard alert 3. Wait ~3 seconds: Container takes time to restart 4. Check status: \"Newt Container: Ready\" in status card

Scenario 2: Troubleshooting Connection 1. Notice Newt not ready: Status shows \"Running (Not configured)\" or \"Stopped\" 2. Click \"Restart Newt\" button in Resource table header 3. Wait for restart: Success message appears 4. Verify status: Refresh status after 3 seconds

"},{"location":"v2/frontend/pages/admin/pangolin-page/#deleting-resources","title":"Deleting Resources","text":"
  1. Identify resource: Find resource to delete in table
  2. Click Delete button: Red icon button on right
  3. Read Popconfirm: \"Delete this resource?\"
  4. Confirm deletion: Click OK
  5. Resource removed: Table refreshes, resource no longer shown
"},{"location":"v2/frontend/pages/admin/pangolin-page/#editing-resource-configuration","title":"Editing Resource Configuration","text":"
  1. Click Edit button: Opens Edit Resource modal
  2. Modify fields:
  3. Name: Display name for resource
  4. Protocol: http or https
  5. Proxy Port: Internal container port (e.g., 4000 for API)
  6. Enable SSL: Checkbox for HTTPS
  7. Active: Checkbox to enable/disable resource
  8. Block Access: Checkbox to block public access (maintenance mode)
  9. Click Update: Saves changes to Pangolin API
  10. Close modal: Modal closes automatically on success
  11. Verify changes: Table shows updated values
"},{"location":"v2/frontend/pages/admin/pangolin-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#status-card","title":"Status Card","text":"
<Card title={<><CloudServerOutlined /> Pangolin Tunnel Status</>}>\n  <Descriptions column={{ xs: 1, sm: 2 }} bordered size=\"small\">\n    <Descriptions.Item label=\"Configured\">\n      {isConfigured ? <Tag color=\"success\">Yes</Tag> : <Tag color=\"error\">No</Tag>}\n    </Descriptions.Item>\n    <Descriptions.Item label=\"Server Health\">\n      {isHealthy ? <Tag color=\"success\">Healthy</Tag> : <Tag color=\"error\">Unreachable</Tag>}\n    </Descriptions.Item>\n    <Descriptions.Item label=\"API URL\">\n      <Text copyable>{config?.pangolinApiUrl || 'Not set'}</Text>\n    </Descriptions.Item>\n    <Descriptions.Item label=\"Newt Container\">\n      {newtStatus?.ready ? <Tag color=\"success\">Ready</Tag> : <Tag color=\"error\">Stopped</Tag>}\n    </Descriptions.Item>\n    <Descriptions.Item label=\"Organization ID\">\n      <Text code>{config?.orgId || 'Not set'}</Text>\n    </Descriptions.Item>\n    <Descriptions.Item label=\"Site ID\">\n      <Text code>{config?.siteId || 'Not set'}</Text>\n    </Descriptions.Item>\n  </Descriptions>\n</Card>\n

Responsive: 1 column on mobile, 2 columns on desktop

"},{"location":"v2/frontend/pages/admin/pangolin-page/#setup-form","title":"Setup Form","text":"
<Form form={setupForm} layout=\"vertical\" onFinish={handleSetup}>\n  <Form.Item name=\"siteName\" label=\"Site Name\">\n    <Input placeholder={`changemaker-${config?.domain || 'cmlite.org'}`} />\n  </Form.Item>\n  <Form.Item\n    name=\"subnet\"\n    label=\"Subnet (CIDR notation)\"\n    tooltip=\"Network subnet for this site. Auto-suggested based on existing allocations.\"\n    initialValue={suggestedSubnet}\n  >\n    <Input placeholder=\"100.90.128.3/24\" />\n  </Form.Item>\n  {exitNodes.length > 0 && (\n    <Form.Item\n      name=\"exitNodeId\"\n      label=\"Exit Node (optional)\"\n      tooltip=\"Network exit point for tunneled traffic. Only needed for multi-node Pangolin setups.\"\n    >\n      <Select placeholder=\"Select an exit node (optional)\" allowClear>\n        {exitNodes.map(node => (\n          <Select.Option key={node.exitNodeId} value={node.exitNodeId} disabled={!node.online}>\n            {sanitizeText(node.name)}\n            {node.location && ` (${sanitizeText(node.location)})`}\n            {!node.online && ' [OFFLINE]'}\n          </Select.Option>\n        ))}\n      </Select>\n    </Form.Item>\n  )}\n  <Form.Item>\n    <Button type=\"primary\" htmlType=\"submit\" loading={actionLoading} icon={<RocketOutlined />}>\n      Create Site + Resources\n    </Button>\n  </Form.Item>\n</Form>\n
"},{"location":"v2/frontend/pages/admin/pangolin-page/#credential-display-alert","title":"Credential Display Alert","text":"
<Alert\n  type=\"success\"\n  showIcon\n  closable\n  onClose={() => {\n    setSetupResult(null);\n    setShowCredentials(false);\n  }}\n  message=\"Setup Complete\"\n  description={\n    <div>\n      <Paragraph>\n        <strong>Step 1:</strong> Add these to your <Text code>.env</Text> file:\n      </Paragraph>\n      <Space style={{ marginBottom: 12 }}>\n        <Button\n          size=\"small\"\n          icon={showCredentials ? <EyeInvisibleOutlined /> : <EyeOutlined />}\n          onClick={() => setShowCredentials(!showCredentials)}\n        >\n          {showCredentials ? 'Hide' : 'Show'} Credentials\n        </Button>\n        <Button\n          size=\"small\"\n          icon={<CopyOutlined />}\n          onClick={() => {\n            const text = setupResult.instructions.slice(1, -1).join('\\n');\n            navigator.clipboard.writeText(text);\n            message.success('Copied to clipboard');\n          }}\n        >\n          Copy to Clipboard\n        </Button>\n        <Button\n          size=\"small\"\n          danger\n          onClick={() => {\n            setSetupResult(null);\n            setShowCredentials(false);\n          }}\n        >\n          Clear Credentials\n        </Button>\n      </Space>\n\n      {showCredentials && (\n        <pre style={{ background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 6, fontSize: 12 }}>\n          {setupResult.instructions.slice(1, -1).join('\\n')}\n        </pre>\n      )}\n\n      <Paragraph style={{ marginTop: 16 }}>\n        <strong>Step 2:</strong> After updating .env, restart the Newt container:\n      </Paragraph>\n      <Button\n        type=\"primary\"\n        icon={<SyncOutlined />}\n        loading={restartLoading}\n        onClick={handleRestartNewt}\n      >\n        Restart Newt Container\n      </Button>\n    </div>\n  }\n/>\n
"},{"location":"v2/frontend/pages/admin/pangolin-page/#resource-table","title":"Resource Table","text":"
<Table\n  dataSource={resources}\n  rowKey=\"resourceId\"\n  size=\"small\"\n  pagination={false}\n  columns={[\n    {\n      title: 'Name',\n      dataIndex: 'name',\n    },\n    {\n      title: 'Domain',\n      render: (_, r: PangolinResource) => (\n        <Text copyable>{r.fullDomain || r.subdomain || '(root)'}</Text>\n      ),\n    },\n    {\n      title: 'SSL',\n      dataIndex: 'ssl',\n      render: (ssl: boolean) => ssl ? <Tag color=\"green\">Yes</Tag> : <Tag>No</Tag>,\n    },\n    {\n      title: 'Active',\n      dataIndex: 'active',\n      render: (active: boolean) => active !== false\n        ? <Tag color=\"success\">Active</Tag>\n        : <Tag color=\"error\">Inactive</Tag>,\n    },\n    {\n      title: 'Port',\n      dataIndex: 'proxyPort',\n      render: (port?: number) => port || '80',\n    },\n    {\n      title: 'Protocol',\n      dataIndex: 'protocol',\n      render: (p?: string) => p || 'http',\n    },\n    {\n      title: 'Blocked',\n      dataIndex: 'blockAccess',\n      render: (blocked?: boolean) => blocked ? <Tag color=\"red\">Blocked</Tag> : null,\n    },\n    {\n      title: 'Actions',\n      render: (_, r: PangolinResource) => (\n        <Space>\n          <Button size=\"small\" onClick={() => handleEditResource(r)}>Edit</Button>\n          <Popconfirm title=\"Delete this resource?\" onConfirm={() => handleDeleteResource(r.resourceId)}>\n            <Button size=\"small\" danger icon={<DeleteOutlined />} />\n          </Popconfirm>\n        </Space>\n      ),\n    },\n  ]}\n/>\n
"},{"location":"v2/frontend/pages/admin/pangolin-page/#edit-resource-modal","title":"Edit Resource Modal","text":"
<Modal\n  title=\"Edit Resource\"\n  open={editModalVisible}\n  onCancel={() => {\n    setEditModalVisible(false);\n    setEditingResource(null);\n    editForm.resetFields();\n  }}\n  footer={null}\n  width={600}\n>\n  <Form form={editForm} layout=\"vertical\" onFinish={handleUpdateResource}>\n    <Form.Item label=\"Name\" name=\"name\" rules={[{ required: true }]}>\n      <Input />\n    </Form.Item>\n    <Form.Item label=\"Protocol\" name=\"protocol\">\n      <Input placeholder=\"http\" />\n    </Form.Item>\n    <Form.Item label=\"Proxy Port\" name=\"proxyPort\">\n      <Input type=\"number\" placeholder=\"80\" />\n    </Form.Item>\n    <Form.Item name=\"ssl\" valuePropName=\"checked\">\n      <Checkbox>Enable SSL</Checkbox>\n    </Form.Item>\n    <Form.Item name=\"active\" valuePropName=\"checked\">\n      <Checkbox>Active</Checkbox>\n    </Form.Item>\n    <Form.Item name=\"blockAccess\" valuePropName=\"checked\">\n      <Checkbox>Block Access</Checkbox>\n    </Form.Item>\n    <Form.Item>\n      <Space>\n        <Button type=\"primary\" htmlType=\"submit\" loading={actionLoading}>Update</Button>\n        <Button onClick={() => setEditModalVisible(false)}>Cancel</Button>\n      </Space>\n    </Form.Item>\n  </Form>\n</Modal>\n
"},{"location":"v2/frontend/pages/admin/pangolin-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#local-state","title":"Local State","text":"

Status & Config:

const [status, setStatus] = useState<PangolinStatus | null>(null);\nconst [config, setConfig] = useState<PangolinConfig | null>(null);\nconst [resources, setResources] = useState<PangolinResource[]>([]);\nconst [newtStatus, setNewtStatus] = useState<PangolinNewtStatus | null>(null);\nconst [loading, setLoading] = useState(true);\n

Setup Wizard State:

const [setupResult, setSetupResult] = useState<Record<string, unknown> | null>(null);\nconst [suggestedSubnet, setSuggestedSubnet] = useState<string>('100.90.128.3/24');\nconst [exitNodes, setExitNodes] = useState<PangolinExitNode[]>([]);\nconst [exitNodesLoading, setExitNodesLoading] = useState(false);\nconst [showCredentials, setShowCredentials] = useState(false);\nconst [setupForm] = Form.useForm();\n

Resource Management State:

const [editModalVisible, setEditModalVisible] = useState(false);\nconst [editingResource, setEditingResource] = useState<PangolinResource | null>(null);\nconst [editForm] = Form.useForm();\nconst [actionLoading, setActionLoading] = useState(false);\nconst [restartLoading, setRestartLoading] = useState(false);\nconst [newtLoading, setNewtLoading] = useState(false);\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#data-fetching","title":"Data Fetching","text":"

Fetch Status + Config:

const fetchData = useCallback(async () => {\n  setLoading(true);\n  try {\n    const [statusRes, configRes] = await Promise.all([\n      api.get<PangolinStatus>('/pangolin/status'),\n      api.get<PangolinConfig>('/pangolin/config'),\n    ]);\n    setStatus(statusRes.data);\n    setConfig(configRes.data);\n\n    if (statusRes.data.configured) {\n      try {\n        const resourcesRes = await api.get<{ resources: PangolinResource[] }>('/pangolin/resources');\n        setResources(resourcesRes.data.resources);\n      } catch {\n        // Resources may not load if site isn't set up\n      }\n    }\n  } catch {\n    message.error('Failed to load Pangolin status');\n  } finally {\n    setLoading(false);\n  }\n}, [message]);\n

Fetch Newt Status:

const fetchNewtStatus = useCallback(async () => {\n  if (!status?.newtConfigured) return;\n\n  setNewtLoading(true);\n  try {\n    const res = await api.get<PangolinNewtStatus>('/pangolin/newt-status');\n    setNewtStatus(res.data);\n  } catch {\n    // Silently fail - status card will show \"unknown\"\n  } finally {\n    setNewtLoading(false);\n  }\n}, [status?.newtConfigured]);\n

Fetch Exit Nodes:

useEffect(() => {\n  if (status?.configured && !config?.siteId) {\n    setExitNodesLoading(true);\n    api.get<{ exitNodes: PangolinExitNode[] }>('/pangolin/exit-nodes')\n      .then(res => {\n        setExitNodes(res.data.exitNodes);\n\n        // Auto-select if only one ONLINE exit node available\n        const onlineNodes = res.data.exitNodes.filter(n => n.online);\n        if (onlineNodes.length === 1 && onlineNodes[0]) {\n          setupForm.setFieldsValue({ exitNodeId: onlineNodes[0].exitNodeId });\n        }\n      })\n      .catch(() => {\n        // Exit nodes not available - OK for self-hosted setups\n      })\n      .finally(() => setExitNodesLoading(false));\n  }\n}, [status?.configured, config?.siteId, setupForm]);\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#subnet-auto-suggestion","title":"Subnet Auto-Suggestion","text":"

Helper Function:

const suggestNextSubnet = (sites: PangolinSite[]): string => {\n  if (!sites || sites.length === 0) {\n    return '100.90.128.0/24'; // Default first subnet\n  }\n\n  const subnets = sites\n    .map(s => s.address || s.subnet)\n    .filter(Boolean)\n    .sort();\n\n  if (subnets.length === 0) {\n    return '100.90.128.0/24';\n  }\n\n  const lastSubnet = subnets[subnets.length - 1];\n  const match = lastSubnet.match(/^100\\.90\\.128\\.(\\d+)\\/24$/);\n\n  if (match && match[1]) {\n    const lastOctet = parseInt(match[1], 10);\n    const nextOctet = lastOctet + 1;\n    if (nextOctet <= 255) {\n      return `100.90.128.${nextOctet}/24`;\n    }\n  }\n\n  return '100.90.128.3/24'; // Fallback\n};\n

Fetch and Suggest:

useEffect(() => {\n  if (status?.configured && !config?.siteId) {\n    api.get<{ sites: PangolinSite[] }>('/pangolin/sites')\n      .then(res => {\n        const suggested = suggestNextSubnet(res.data.sites);\n        setSuggestedSubnet(suggested);\n        setupForm.setFieldsValue({ subnet: suggested });\n      })\n      .catch(() => {\n        setupForm.setFieldsValue({ subnet: '100.90.128.3/24' });\n      });\n  }\n}, [status?.configured, config?.siteId, setupForm]);\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#endpoints-used","title":"Endpoints Used","text":"

GET /pangolin/status - Check configuration and health

const { data } = await api.get<PangolinStatus>('/pangolin/status');\n

Response:

{\n  \"configured\": true,\n  \"healthy\": true,\n  \"newtConfigured\": true\n}\n

GET /pangolin/config - Fetch Pangolin configuration

const { data } = await api.get<PangolinConfig>('/pangolin/config');\n

Response:

{\n  \"pangolinApiUrl\": \"https://api.bnkserve.org/v1\",\n  \"orgId\": \"org_abc123\",\n  \"siteId\": \"site_xyz789\",\n  \"domain\": \"cmlite.org\"\n}\n

GET /pangolin/resources - List all tunnel resources

const { data } = await api.get<{ resources: PangolinResource[] }>('/pangolin/resources');\n

Response:

{\n  \"resources\": [\n    {\n      \"resourceId\": \"res_1\",\n      \"name\": \"API\",\n      \"subdomain\": \"api\",\n      \"fullDomain\": \"api.cmlite.org\",\n      \"ssl\": true,\n      \"active\": true,\n      \"proxyPort\": 4000,\n      \"protocol\": \"http\",\n      \"blockAccess\": false\n    }\n  ]\n}\n

POST /pangolin/setup - Create site and resources

const { data } = await api.post('/pangolin/setup', {\n  siteName: 'changemaker-cmlite.org',\n  subnet: '100.90.128.3/24',\n  exitNodeId: 'exit_node_123'  // Optional\n});\n

Response:

{\n  \"siteId\": \"site_xyz789\",\n  \"instructions\": [\n    \"# Add these to your .env file:\",\n    \"PANGOLIN_SITE_ID=site_xyz789\",\n    \"NEWT_ID=newt_abc456\",\n    \"NEWT_SECRET=secret_def789\",\n    \"# Then restart the Newt container\"\n  ]\n}\n

POST /pangolin/sync - Sync resources from docker-compose.yml

const { data } = await api.post<{ created: number; skipped: number; errors: number }>('/pangolin/sync');\n

Response:

{\n  \"created\": 3,\n  \"skipped\": 5,\n  \"errors\": 0\n}\n

PUT /pangolin/resource/:resourceId - Update resource

await api.put(`/pangolin/resource/${resourceId}`, {\n  name: 'Updated API',\n  protocol: 'http',\n  proxyPort: 4000,\n  ssl: true,\n  active: true,\n  blockAccess: false\n});\n

DELETE /pangolin/resource/:resourceId - Delete resource

await api.delete(`/pangolin/resource/${resourceId}`);\n

POST /pangolin/newt-restart - Restart Newt container

await api.post('/pangolin/newt-restart');\n

GET /pangolin/newt-status - Get Newt container status

const { data } = await api.get<PangolinNewtStatus>('/pangolin/newt-status');\n

Response:

{\n  \"containerRunning\": true,\n  \"ready\": true\n}\n

GET /pangolin/sites - List all sites (for subnet suggestion)

const { data } = await api.get<{ sites: PangolinSite[] }>('/pangolin/sites');\n

Response:

{\n  \"sites\": [\n    {\n      \"siteId\": \"site_1\",\n      \"name\": \"changemaker-dev\",\n      \"address\": \"100.90.128.2/24\"\n    }\n  ]\n}\n

GET /pangolin/exit-nodes - List available exit nodes

const { data } = await api.get<{ exitNodes: PangolinExitNode[] }>('/pangolin/exit-nodes');\n

Response:

{\n  \"exitNodes\": [\n    {\n      \"exitNodeId\": \"exit_1\",\n      \"name\": \"US East\",\n      \"location\": \"New York\",\n      \"online\": true\n    },\n    {\n      \"exitNodeId\": \"exit_2\",\n      \"name\": \"US West\",\n      \"location\": \"San Francisco\",\n      \"online\": false\n    }\n  ]\n}\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#subnet-auto-suggestion-logic","title":"Subnet Auto-Suggestion Logic","text":"
const suggestNextSubnet = (sites: PangolinSite[]): string => {\n  if (!sites || sites.length === 0) {\n    return '100.90.128.0/24';\n  }\n\n  // Get all existing subnets and sort\n  const subnets = sites\n    .map(s => s.address || s.subnet)\n    .filter(Boolean)\n    .sort();\n\n  if (subnets.length === 0) return '100.90.128.0/24';\n\n  // Parse last subnet to extract octet\n  const lastSubnet = subnets[subnets.length - 1];\n  const match = lastSubnet.match(/^100\\.90\\.128\\.(\\d+)\\/24$/);\n\n  if (match && match[1]) {\n    const lastOctet = parseInt(match[1], 10);\n    const nextOctet = lastOctet + 1;\n\n    if (nextOctet <= 255) {\n      return `100.90.128.${nextOctet}/24`;\n    }\n  }\n\n  return '100.90.128.3/24';\n};\n\n// Example usage:\n// Sites: [{ address: '100.90.128.2/24' }, { address: '100.90.128.3/24' }]\n// Returns: '100.90.128.4/24'\n
"},{"location":"v2/frontend/pages/admin/pangolin-page/#exit-node-auto-selection","title":"Exit Node Auto-Selection","text":"
const onlineNodes = exitNodes.filter(n => n.online);\n\nif (onlineNodes.length === 1 && onlineNodes[0]) {\n  // Auto-select the only online node\n  setupForm.setFieldsValue({ exitNodeId: onlineNodes[0].exitNodeId });\n} else if (onlineNodes.length > 1 && onlineNodes.length < exitNodes.length) {\n  // Some nodes offline, warn user\n  message.info('Some exit nodes are offline. Select from available nodes.');\n}\n
"},{"location":"v2/frontend/pages/admin/pangolin-page/#text-sanitization-for-external-api-data","title":"Text Sanitization for External API Data","text":"
const sanitizeText = (text: string | undefined): string => {\n  if (!text) return '';\n  return text.replace(/[<>'\"&]/g, (char) => {\n    const escapeMap: Record<string, string> = {\n      '<': '&lt;',\n      '>': '&gt;',\n      \"'\": '&#39;',\n      '\"': '&quot;',\n      '&': '&amp;',\n    };\n    return escapeMap[char] || char;\n  });\n};\n\n// Usage in exit node options\n<Select.Option value={node.exitNodeId}>\n  {sanitizeText(node.name)}\n  {node.location && ` (${sanitizeText(node.location)})`}\n</Select.Option>\n

Why: Defense-in-depth against XSS if Pangolin API returns malicious data.

"},{"location":"v2/frontend/pages/admin/pangolin-page/#credential-showhide-pattern","title":"Credential Show/Hide Pattern","text":"
const [showCredentials, setShowCredentials] = useState(false);\n\n<Button\n  icon={showCredentials ? <EyeInvisibleOutlined /> : <EyeOutlined />}\n  onClick={() => setShowCredentials(!showCredentials)}\n>\n  {showCredentials ? 'Hide' : 'Show'} Credentials\n</Button>\n\n{showCredentials && (\n  <pre>{setupResult.instructions.slice(1, -1).join('\\n')}</pre>\n)}\n

Security: Credentials hidden by default, require user action to view.

"},{"location":"v2/frontend/pages/admin/pangolin-page/#delayed-status-check-after-restart","title":"Delayed Status Check After Restart","text":"
const handleRestartNewt = async () => {\n  setRestartLoading(true);\n  try {\n    await api.post('/pangolin/newt-restart');\n    message.success('Newt container restarted successfully. Checking status...');\n\n    // Poll status after restart (container takes a few seconds to start)\n    setTimeout(() => {\n      fetchNewtStatus();\n    }, 3000);\n  } catch (err: unknown) {\n    const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Failed to restart container';\n    message.error(msg);\n  } finally {\n    setRestartLoading(false);\n  }\n};\n

Why 3 seconds: Newt container needs time to fully start before status check succeeds.

"},{"location":"v2/frontend/pages/admin/pangolin-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#parallel-api-calls-on-mount","title":"Parallel API Calls on Mount","text":"
const [statusRes, configRes] = await Promise.all([\n  api.get('/pangolin/status'),\n  api.get('/pangolin/config'),\n]);\n

Benefit: Loads status and config simultaneously (faster than sequential).

"},{"location":"v2/frontend/pages/admin/pangolin-page/#optional-newt-status-fetch","title":"Optional Newt Status Fetch","text":"
const fetchNewtStatus = useCallback(async () => {\n  if (!status?.newtConfigured) return; // Don't check if not configured\n  // ... fetch logic\n}, [status?.newtConfigured]);\n

Benefit: Avoids unnecessary API call when Newt not configured.

"},{"location":"v2/frontend/pages/admin/pangolin-page/#graceful-exit-node-failure","title":"Graceful Exit Node Failure","text":"
api.get('/pangolin/exit-nodes')\n  .catch(() => {\n    // Don't show error messages\n    // Exit nodes not available - OK for self-hosted setups\n  });\n

Benefit: Page works without exit nodes (self-hosted Pangolin doesn't use them).

"},{"location":"v2/frontend/pages/admin/pangolin-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#status-card-columns","title":"Status Card Columns","text":"
<Descriptions column={{ xs: 1, sm: 2 }}>\n

Mobile (< 576px): 1 column (stacked) Desktop (\u2265 576px): 2 columns (side-by-side)

"},{"location":"v2/frontend/pages/admin/pangolin-page/#form-layout","title":"Form Layout","text":"
<Form layout=\"vertical\">\n

Vertical layout: Label above input (works well on all screen sizes).

"},{"location":"v2/frontend/pages/admin/pangolin-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#button-labels","title":"Button Labels","text":"

All buttons have text labels (not icon-only):

<Button icon={<SyncOutlined />}>Sync Resources</Button>\n<Button icon={<SaveOutlined />}>Update</Button>\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#form-tooltips","title":"Form Tooltips","text":"
<Form.Item\n  tooltip=\"Network subnet for this site. Auto-suggested based on existing allocations.\"\n>\n

Provides context without cluttering label.

"},{"location":"v2/frontend/pages/admin/pangolin-page/#popconfirm-for-destructive-actions","title":"Popconfirm for Destructive Actions","text":"
<Popconfirm title=\"Delete this resource?\" onConfirm={handleDelete}>\n  <Button danger />\n</Popconfirm>\n

Prevents accidental deletion.

"},{"location":"v2/frontend/pages/admin/pangolin-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#configured-no-status","title":"\"Configured: No\" Status","text":"

Cause: Missing environment variables

Solution:

# Add to .env\nPANGOLIN_API_URL=https://api.bnkserve.org/v1\nPANGOLIN_API_KEY=your_api_key_here\nPANGOLIN_ORG_ID=org_abc123\n\n# Restart API\ndocker compose restart api\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#server-health-unreachable","title":"\"Server Health: Unreachable\"","text":"

Causes: 1. Pangolin API server down 2. Incorrect API URL 3. Network connectivity issue

Debug:

# Test API URL directly\ncurl -H \"Authorization: Bearer $PANGOLIN_API_KEY\" \\\n  https://api.bnkserve.org/v1/status\n\n# Check API logs\ndocker compose logs -f api | grep pangolin\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#setup-fails-with-subnet-already-in-use","title":"Setup Fails with \"Subnet already in use\"","text":"

Cause: Suggested subnet conflicts with existing site

Solution: 1. Manually override subnet in form (e.g., 100.90.128.5/24) 2. Try again

"},{"location":"v2/frontend/pages/admin/pangolin-page/#newt-container-shows-stopped","title":"Newt Container Shows \"Stopped\"","text":"

Causes: 1. Container crashed 2. Missing NEWT_ID or NEWT_SECRET in .env 3. Wrong credentials

Solutions:

# Check container logs\ndocker compose logs newt\n\n# Verify credentials in .env\ncat .env | grep NEWT\n\n# Restart container\ndocker compose restart newt\n\n# Or use UI button\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#resources-not-syncing","title":"Resources Not Syncing","text":"

Symptoms: - Sync button does nothing - New resources not created

Causes: 1. docker-compose.yml not updated 2. Subdomain naming mismatch 3. API error

Debug:

# Check API logs during sync\ndocker compose logs -f api\n\n# Verify docker-compose.yml has subdomain labels\ncat docker-compose.yml | grep subdomain\n

"},{"location":"v2/frontend/pages/admin/pangolin-page/#credentials-not-showing-after-setup","title":"Credentials Not Showing After Setup","text":"

Cause: setupResult state is null

Debug:

console.log('Setup result:', setupResult);\n

If null: Setup API call failed or returned unexpected format.

"},{"location":"v2/frontend/pages/admin/pangolin-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#backend-integration","title":"Backend Integration","text":"
  • Pangolin Module - Service, client, routes
  • Pangolin API Reference - Full endpoint documentation
"},{"location":"v2/frontend/pages/admin/pangolin-page/#features_1","title":"Features","text":"
  • Tunnel Management - Feature overview
  • Newt Container - Container configuration
"},{"location":"v2/frontend/pages/admin/pangolin-page/#deployment","title":"Deployment","text":"
  • Pangolin Deployment - Production setup guide
  • Environment Variables - All Pangolin env vars
"},{"location":"v2/frontend/pages/admin/pangolin-page/#troubleshooting_1","title":"Troubleshooting","text":"
  • Tunnel Issues - Connection troubleshooting
  • Common Errors - General error resolution
"},{"location":"v2/frontend/pages/admin/pangolin-page/#user-guides","title":"User Guides","text":"
  • Admin Guide - Tunnel Management - Setup workflows
"},{"location":"v2/frontend/pages/admin/pangolin-page/#external-resources","title":"External Resources","text":"
  • Pangolin Integration API Documentation - Pangolin API reference
  • Newt Container Documentation - Newt GitHub repo
"},{"location":"v2/frontend/pages/admin/representatives-page/","title":"RepresentativesPage","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#overview","title":"Overview","text":"

The RepresentativesPage provides administrative management of the representative cache system that powers the Influence module's postal code lookup functionality. It allows administrators to view cached representatives, search by postal code to populate the cache, clear stale cache entries, and monitor cache statistics. The page integrates with the Represent API (represent.opennorth.ca) to fetch Canadian elected officials.

Route: /app/influence/representatives Component: admin/src/pages/RepresentativesPage.tsx (387 lines) Auth Required: Yes (SUPER_ADMIN or INFLUENCE_ADMIN role recommended) Layout: AppLayout Backend Module: api/src/modules/influence/representatives/

"},{"location":"v2/frontend/pages/admin/representatives-page/#screenshot","title":"Screenshot","text":"

[Screenshot: RepresentativesPage with three statistics cards at top showing \"Cached Representatives: 245\", \"Unique Postal Codes: 89\", and \"Avg Reps per Postal: 2.8\". Below are two input fields side-by-side: \"Search Representatives\" and \"Postal Code Filter\", both with search icons. Below that is a table with columns: Name (sortable), Office (sortable), Level (colored tag), Party (colored tag), District Name, Email, and Actions. Each row has View and Delete action buttons. At the bottom is pagination showing \"1-10 of 245 representatives\". Top right corner has \"Lookup New Postal Code\" primary button and \"Clear All Cache\" danger button. A right-side drawer is open showing \"Representative Details\" with photo, contact info, and social media links.]

"},{"location":"v2/frontend/pages/admin/representatives-page/#features","title":"Features","text":"
  • Cache statistics dashboard \u2014 Real-time metrics (total reps, unique postal codes, average reps per postal)
  • Postal code lookup \u2014 Fetch representatives from Represent API and populate cache
  • Representative search \u2014 Filter by name, office, party, district (300ms debounce)
  • Postal code filter \u2014 Show representatives for specific postal code only (300ms debounce)
  • Government level filtering \u2014 Filter by Federal, Provincial, or Municipal
  • Sortable table \u2014 Sort by name, office, level, party, district
  • Detail drawer \u2014 View complete representative information with photo and links
  • Cache clearing \u2014 Delete individual representatives or clear entire cache
  • Color-coded tags \u2014 Visual indicators for government level and political party
  • Pagination \u2014 Configurable page size (10, 25, 50, 100 per page)
  • Responsive design \u2014 Mobile-friendly layout with stacked filters
  • Auto-refresh stats \u2014 Statistics update after lookup/delete operations
"},{"location":"v2/frontend/pages/admin/representatives-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#looking-up-representatives-for-a-postal-code","title":"Looking Up Representatives for a Postal Code","text":"
  1. Navigate to /app/influence/representatives
  2. Click \"Lookup New Postal Code\" button (top right)
  3. Modal appears: \"Lookup Representatives\"
  4. Enter a valid Canadian postal code (e.g., \"K1A 0A9\")
  5. Click \"Lookup\" button
  6. Wait for API request to Represent API
  7. Success scenarios:
  8. New representatives found: Success message \"Found 3 representatives for K1A 0A9 and cached them\"
  9. Already cached: Info message \"Representatives for K1A 0A9 are already cached\"
  10. No representatives found: Warning message \"No representatives found for K1A 0A9\"
  11. Table automatically refreshes to show newly cached representatives
  12. Statistics cards update to reflect new cache size
"},{"location":"v2/frontend/pages/admin/representatives-page/#searching-for-cached-representatives","title":"Searching for Cached Representatives","text":"
  1. Locate \"Search Representatives\" input field (below statistics cards)
  2. Start typing search query (e.g., \"John Smith\")
  3. Search automatically triggers after 300ms pause (debounce)
  4. Table filters to show matching representatives
  5. Matches on: name, office title, political party, district name
  6. Clear search by clicking X icon or deleting text
"},{"location":"v2/frontend/pages/admin/representatives-page/#filtering-by-postal-code","title":"Filtering by Postal Code","text":"
  1. Locate \"Postal Code Filter\" input field (next to search field)
  2. Start typing postal code (e.g., \"K1A\")
  3. Filter automatically triggers after 300ms pause (debounce)
  4. Table shows only representatives associated with that postal code
  5. Clear filter by clicking X icon or deleting text
  6. Can combine with search filter for more specific results
"},{"location":"v2/frontend/pages/admin/representatives-page/#viewing-representative-details","title":"Viewing Representative Details","text":"
  1. Locate representative in table
  2. Click \"View\" button in Actions column
  3. Right-side drawer opens: \"Representative Details\"
  4. View information sections:
  5. Photo: Representative's portrait (if available)
  6. Basic Info: Name, political party, government level
  7. Office: Office title, district name
  8. Contact: Email, phone numbers (if available)
  9. Addresses: Office address, mailing address (if available)
  10. Social Media: Twitter, Facebook, website links (if available)
  11. Other Data: Custom fields from Represent API
  12. Click Close button or drawer overlay to dismiss
"},{"location":"v2/frontend/pages/admin/representatives-page/#deleting-cached-representatives","title":"Deleting Cached Representatives","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#individual-deletion","title":"Individual Deletion","text":"
  1. Locate representative in table
  2. Click \"Delete\" button in Actions column (red text)
  3. Confirmation modal appears: \"Are you sure you want to delete this representative from cache?\"
  4. Click \"Delete\" to confirm (or \"Cancel\" to abort)
  5. Success message: \"Representative deleted from cache\"
  6. Representative removed from table immediately
  7. Statistics cards update to reflect reduced cache size
"},{"location":"v2/frontend/pages/admin/representatives-page/#bulk-cache-clearing","title":"Bulk Cache Clearing","text":"
  1. Click \"Clear All Cache\" button (top right, danger style)
  2. Confirmation modal appears: \"Are you sure you want to clear the entire representative cache? This will delete all 245 cached representatives.\"
  3. Enter confirmation phrase if prompted (optional safety measure)
  4. Click \"Clear Cache\" to confirm (or \"Cancel\" to abort)
  5. Loading indicator appears
  6. All cache entries deleted from database
  7. Success message: \"Cache cleared successfully. Deleted 245 representatives.\"
  8. Table refreshes to show empty state
  9. Statistics cards reset to zero
"},{"location":"v2/frontend/pages/admin/representatives-page/#sorting-the-table","title":"Sorting the Table","text":"
  1. Identify sortable columns (Name, Office, Level, Party, District Name)
  2. Click column header to sort ascending (\u2191 arrow appears)
  3. Click again to sort descending (\u2193 arrow appears)
  4. Click third time to remove sorting (no arrow)
  5. Default sort: Name ascending
  6. Can combine with search/filter (sorted results only)
"},{"location":"v2/frontend/pages/admin/representatives-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  • Typography.Title \u2014 Page heading (\"Representatives\")
  • Typography.Text \u2014 Labels, descriptions, empty state text
  • Row / Col \u2014 Grid layout for statistics cards and form fields
  • Card \u2014 Statistics card containers
  • Statistic \u2014 Formatted numeric statistics display
  • Space \u2014 Button grouping (top-right buttons)
  • Button \u2014 Primary actions (Lookup, Clear Cache), row actions (View, Delete)
  • Input.Search \u2014 Representative search field with debounce
  • Input \u2014 Postal code filter field
  • Select \u2014 Government level filter dropdown
  • Table \u2014 Main data table with sortable columns, pagination
  • Tag \u2014 Color-coded government level and party indicators
  • Modal \u2014 Confirmation dialogs (delete, clear cache), lookup modal
  • Form \u2014 Postal code lookup form
  • Form.Item \u2014 Form field wrapper with validation
  • Drawer \u2014 Representative detail side panel
  • Descriptions \u2014 Key-value pairs in detail drawer
  • Descriptions.Item \u2014 Individual detail fields
  • Image \u2014 Representative photo display
  • Empty \u2014 Empty state when no representatives cached
  • message \u2014 Toast notifications for success/error feedback
"},{"location":"v2/frontend/pages/admin/representatives-page/#table-structure","title":"Table Structure","text":"
const columns: ColumnsType<Representative> = [\n  {\n    title: 'Name',\n    dataIndex: 'name',\n    key: 'name',\n    sorter: (a, b) => a.name.localeCompare(b.name),\n    width: 200,\n  },\n  {\n    title: 'Office',\n    dataIndex: 'officeTitle',\n    key: 'officeTitle',\n    sorter: (a, b) => (a.officeTitle || '').localeCompare(b.officeTitle || ''),\n    width: 200,\n  },\n  {\n    title: 'Level',\n    dataIndex: 'level',\n    key: 'level',\n    width: 120,\n    render: (level: string) => {\n      const colorMap: Record<string, string> = {\n        Federal: 'red',\n        Provincial: 'blue',\n        Municipal: 'green',\n      };\n      return <Tag color={colorMap[level] || 'default'}>{level}</Tag>;\n    },\n    sorter: (a, b) => a.level.localeCompare(b.level),\n  },\n  {\n    title: 'Party',\n    dataIndex: 'politicalParty',\n    key: 'politicalParty',\n    width: 150,\n    render: (party: string | null) => {\n      if (!party) return <Text type=\"secondary\">\u2014</Text>;\n      return <Tag color=\"default\">{party}</Tag>;\n    },\n    sorter: (a, b) => (a.politicalParty || '').localeCompare(b.politicalParty || ''),\n  },\n  {\n    title: 'District Name',\n    dataIndex: 'districtName',\n    key: 'districtName',\n    sorter: (a, b) => (a.districtName || '').localeCompare(b.districtName || ''),\n  },\n  {\n    title: 'Email',\n    dataIndex: 'email',\n    key: 'email',\n    width: 200,\n    render: (email: string | null) => {\n      if (!email) return <Text type=\"secondary\">\u2014</Text>;\n      return <a href={`mailto:${email}`}>{email}</a>;\n    },\n  },\n  {\n    title: 'Actions',\n    key: 'actions',\n    width: 140,\n    fixed: 'right',\n    render: (_: unknown, record: Representative) => (\n      <Space size=\"small\">\n        <Button\n          size=\"small\"\n          type=\"link\"\n          icon={<EyeOutlined />}\n          onClick={() => handleViewDetails(record)}\n        >\n          View\n        </Button>\n        <Button\n          size=\"small\"\n          type=\"link\"\n          danger\n          icon={<DeleteOutlined />}\n          onClick={() => handleDeleteConfirm(record)}\n        >\n          Delete\n        </Button>\n      </Space>\n    ),\n  },\n];\n

Column Features: - Name: Primary identifier, sortable, 200px width - Office: Job title (e.g., \"Member of Parliament\"), sortable, 200px width - Level: Government level with color-coded tags (Federal=red, Provincial=blue, Municipal=green), sortable, 120px width - Party: Political party affiliation (e.g., \"Liberal Party of Canada\"), sortable, 150px width, nullable (shows \"\u2014\" if null) - District Name: Electoral district (e.g., \"Ottawa Centre\"), sortable - Email: Contact email with mailto: link, 200px width, nullable (shows \"\u2014\" if null) - Actions: View and Delete buttons, 140px width, fixed right

"},{"location":"v2/frontend/pages/admin/representatives-page/#statistics-cards","title":"Statistics Cards","text":"
{stats && (\n  <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>\n    <Col xs={24} sm={8}>\n      <Card size=\"small\">\n        <Statistic\n          title=\"Cached Representatives\"\n          value={stats.totalRepresentatives}\n          prefix={<TeamOutlined />}\n        />\n      </Card>\n    </Col>\n    <Col xs={24} sm={8}>\n      <Card size=\"small\">\n        <Statistic\n          title=\"Unique Postal Codes\"\n          value={stats.uniquePostalCodes}\n          prefix={<EnvironmentOutlined />}\n        />\n      </Card>\n    </Col>\n    <Col xs={24} sm={8}>\n      <Card size=\"small\">\n        <Statistic\n          title=\"Avg Reps per Postal\"\n          value={stats.avgRepsPerPostal}\n          precision={1}\n          prefix={<LineChartOutlined />}\n        />\n      </Card>\n    </Col>\n  </Row>\n)}\n

Responsive Grid: - Desktop (sm+): 3 cards side-by-side (8 columns each = 8/24 = \u2153 width) - Mobile (xs): Stacked cards (24 columns = full width) - Gutter: 16px horizontal and vertical spacing

"},{"location":"v2/frontend/pages/admin/representatives-page/#detail-drawer","title":"Detail Drawer","text":"
<Drawer\n  title=\"Representative Details\"\n  placement=\"right\"\n  width={600}\n  open={detailDrawerOpen}\n  onClose={() => setDetailDrawerOpen(false)}\n>\n  {selectedRep && (\n    <Space direction=\"vertical\" size=\"large\" style={{ width: '100%' }}>\n      {selectedRep.photoUrl && (\n        <Image\n          src={selectedRep.photoUrl}\n          alt={selectedRep.name}\n          width={200}\n          style={{ borderRadius: 8 }}\n        />\n      )}\n      <Descriptions column={1} bordered>\n        <Descriptions.Item label=\"Name\">{selectedRep.name}</Descriptions.Item>\n        <Descriptions.Item label=\"Office Title\">\n          {selectedRep.officeTitle || '\u2014'}\n        </Descriptions.Item>\n        <Descriptions.Item label=\"Government Level\">\n          <Tag color={getLevelColor(selectedRep.level)}>{selectedRep.level}</Tag>\n        </Descriptions.Item>\n        <Descriptions.Item label=\"Political Party\">\n          {selectedRep.politicalParty || '\u2014'}\n        </Descriptions.Item>\n        <Descriptions.Item label=\"District Name\">\n          {selectedRep.districtName || '\u2014'}\n        </Descriptions.Item>\n        <Descriptions.Item label=\"Email\">\n          {selectedRep.email ? <a href={`mailto:${selectedRep.email}`}>{selectedRep.email}</a> : '\u2014'}\n        </Descriptions.Item>\n        {selectedRep.phone && (\n          <Descriptions.Item label=\"Phone\">\n            <a href={`tel:${selectedRep.phone}`}>{selectedRep.phone}</a>\n          </Descriptions.Item>\n        )}\n        {selectedRep.fax && (\n          <Descriptions.Item label=\"Fax\">{selectedRep.fax}</Descriptions.Item>\n        )}\n        {selectedRep.officeAddress && (\n          <Descriptions.Item label=\"Office Address\">\n            {selectedRep.officeAddress}\n          </Descriptions.Item>\n        )}\n        {selectedRep.mailingAddress && (\n          <Descriptions.Item label=\"Mailing Address\">\n            {selectedRep.mailingAddress}\n          </Descriptions.Item>\n        )}\n        {selectedRep.personalUrl && (\n          <Descriptions.Item label=\"Website\">\n            <a href={selectedRep.personalUrl} target=\"_blank\" rel=\"noopener noreferrer\">\n              {selectedRep.personalUrl}\n            </a>\n          </Descriptions.Item>\n        )}\n        {selectedRep.socialMedia && (\n          <Descriptions.Item label=\"Social Media\">\n            {selectedRep.socialMedia.twitter && (\n              <a href={selectedRep.socialMedia.twitter} target=\"_blank\" rel=\"noopener noreferrer\">\n                Twitter\n              </a>\n            )}\n            {selectedRep.socialMedia.facebook && (\n              <>\n                {' | '}\n                <a href={selectedRep.socialMedia.facebook} target=\"_blank\" rel=\"noopener noreferrer\">\n                  Facebook\n                </a>\n              </>\n            )}\n          </Descriptions.Item>\n        )}\n        {selectedRep.otherData && Object.keys(selectedRep.otherData).length > 0 && (\n          <Descriptions.Item label=\"Other Data\">\n            <pre style={{ fontSize: 12, margin: 0 }}>\n              {JSON.stringify(selectedRep.otherData, null, 2)}\n            </pre>\n          </Descriptions.Item>\n        )}\n      </Descriptions>\n    </Space>\n  )}\n</Drawer>\n

Drawer Features: - Width: 600px on desktop, full-width on mobile - Placement: Right side slide-in - Photo: Representative portrait (if available from Represent API) - Contact Links: Clickable mailto: and tel: links for email and phone - External Links: Website and social media with target=\"_blank\" (opens new tab) - Other Data: JSON dump of any custom fields from Represent API (formatted with

)"},{"location":"v2/frontend/pages/admin/representatives-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#local-state-no-zustand-store","title":"Local State (No Zustand Store)","text":"
// Data state\nconst [representatives, setRepresentatives] = useState<Representative[]>([]);\nconst [stats, setStats] = useState<CacheStats | null>(null);\nconst [loading, setLoading] = useState(false);\n\n// Filter state\nconst [search, setSearch] = useState('');\nconst [postalCodeFilter, setPostalCodeFilter] = useState('');\nconst [levelFilter, setLevelFilter] = useState<string | undefined>(undefined);\n\n// Pagination state\nconst [pagination, setPagination] = useState({\n  current: 1,\n  pageSize: 10,\n  total: 0,\n});\n\n// UI state\nconst [detailDrawerOpen, setDetailDrawerOpen] = useState(false);\nconst [selectedRep, setSelectedRep] = useState<Representative | null>(null);\nconst [lookupModalOpen, setLookupModalOpen] = useState(false);\n\n// Debounce timers\nconst searchTimerRef = useRef<NodeJS.Timeout | null>(null);\nconst postalTimerRef = useRef<NodeJS.Timeout | null>(null);\n
\n

No Global State:

\n

This page does NOT use Zustand stores. Representative cache data is fetched directly from the API on mount and after mutations. This is appropriate because:\n- Representative cache is admin-only data (not needed globally)\n- Data changes infrequently (only on manual lookup/delete)\n- No need to share state between pages\n- Simpler architecture without store overhead

"},{"location":"v2/frontend/pages/admin/representatives-page/#debounced-search-pattern","title":"Debounced Search Pattern","text":"
const handleSearch = (value: string) => {\n  // Clear existing timer\n  if (searchTimerRef.current) {\n    clearTimeout(searchTimerRef.current);\n  }\n\n  // Set new timer\n  searchTimerRef.current = setTimeout(() => {\n    setSearch(value);\n    setPagination((prev) => ({ ...prev, current: 1 })); // Reset to page 1\n  }, 300);\n};\n\nconst handlePostalCodeFilterChange = (value: string) => {\n  // Clear existing timer\n  if (postalTimerRef.current) {\n    clearTimeout(postalTimerRef.current);\n  }\n\n  // Set new timer\n  postalTimerRef.current = setTimeout(() => {\n    setPostalCodeFilter(value);\n    setPagination((prev) => ({ ...prev, current: 1 })); // Reset to page 1\n  }, 300);\n};\n
\n

Why 300ms Debounce?

\n
    \n
  • Performance: Prevents API call on every keystroke
  • \n
  • User Experience: Long enough to avoid lag, short enough to feel responsive
  • \n
  • API Load: Reduces Represent API calls (external service rate limits)
  • \n
  • Two Separate Timers: Search and postal code filter have independent debounce timers
  • \n
"},{"location":"v2/frontend/pages/admin/representatives-page/#usecallback-optimization","title":"useCallback Optimization","text":"
const loadRepresentatives = useCallback(async () => {\n  setLoading(true);\n  try {\n    const params: Record<string, unknown> = {\n      page: pagination.current,\n      limit: pagination.pageSize,\n    };\n\n    if (search) params.search = search;\n    if (postalCodeFilter) params.postalCode = postalCodeFilter;\n    if (levelFilter) params.level = levelFilter;\n\n    const { data } = await api.get<{\n      data: Representative[];\n      pagination: { total: number };\n    }>('/representatives', { params });\n\n    setRepresentatives(data.data);\n    setPagination((prev) => ({\n      ...prev,\n      total: data.pagination.total,\n    }));\n  } catch (error) {\n    message.error('Failed to load representatives');\n  } finally {\n    setLoading(false);\n  }\n}, [pagination.current, pagination.pageSize, search, postalCodeFilter, levelFilter]);\n\nconst loadStats = useCallback(async () => {\n  try {\n    const { data } = await api.get<CacheStats>('/representatives/stats');\n    setStats(data);\n  } catch (error) {\n    message.error('Failed to load statistics');\n  }\n}, []);\n\nuseEffect(() => {\n  loadRepresentatives();\n}, [loadRepresentatives]);\n\nuseEffect(() => {\n  loadStats();\n}, [loadStats]);\n
\n

Why useCallback?

\n
    \n
  • Prevents infinite re-renders: Without useCallback, useEffect would create new function reference on every render, triggering effect again
  • \n
  • Optimized dependencies: Only re-creates function when filter/pagination values actually change
  • \n
  • Separate stats loading: Stats fetched independently, not affected by table filters
  • \n
"},{"location":"v2/frontend/pages/admin/representatives-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#endpoints-used","title":"Endpoints Used","text":"Method\nEndpoint\nPurpose\nAuth\n\n\n\n\nGET\n/api/representatives\nList cached representatives\nRequired\n\n\nGET\n/api/representatives/stats\nCache statistics\nRequired\n\n\nPOST\n/api/representatives/lookup/:postalCode\nLookup by postal code\nRequired\n\n\nDELETE\n/api/representatives/:id\nDelete single representative\nRequired\n\n\nDELETE\n/api/representatives/cache\nClear entire cache\nRequired"},{"location":"v2/frontend/pages/admin/representatives-page/#load-representatives-paginated-with-filters","title":"Load Representatives (Paginated with Filters)","text":"

Request:

\n
const params: Record<string, unknown> = {\n  page: 1,\n  limit: 10,\n  search: 'John Smith',          // Optional: search query\n  postalCode: 'K1A 0A9',          // Optional: postal code filter\n  level: 'Federal',               // Optional: government level filter\n};\n\nconst { data } = await api.get<{\n  data: Representative[];\n  pagination: { total: number; page: number; limit: number };\n}>('/representatives', { params });\n
\n

Query Parameters:\n- page (number, required): Page number (1-indexed)\n- limit (number, required): Items per page (10, 25, 50, or 100)\n- search (string, optional): Search query (matches name, office, party, district)\n- postalCode (string, optional): Filter by postal code\n- level (string, optional): Filter by government level (Federal, Provincial, Municipal)

\n

Response (200 OK):

\n
{\n  \"data\": [\n    {\n      \"id\": \"rep_abc123\",\n      \"name\": \"John Smith\",\n      \"officeTitle\": \"Member of Parliament\",\n      \"level\": \"Federal\",\n      \"politicalParty\": \"Liberal Party of Canada\",\n      \"districtName\": \"Ottawa Centre\",\n      \"email\": \"john.smith@parl.gc.ca\",\n      \"phone\": \"+1 613-555-0100\",\n      \"fax\": \"+1 613-555-0101\",\n      \"photoUrl\": \"https://represent.opennorth.ca/photos/john-smith.jpg\",\n      \"personalUrl\": \"https://johnsmith.ca\",\n      \"officeAddress\": \"Justice Building, 284 Wellington Street, Room 432, Ottawa, ON K1A 0A6\",\n      \"mailingAddress\": \"House of Commons, Ottawa, ON K1A 0A6\",\n      \"socialMedia\": {\n        \"twitter\": \"https://twitter.com/johnsmith\",\n        \"facebook\": \"https://facebook.com/johnsmithmp\"\n      },\n      \"otherData\": {\n        \"first_name\": \"John\",\n        \"last_name\": \"Smith\",\n        \"elected_office\": \"MP\"\n      },\n      \"postalCode\": \"K1A 0A9\",\n      \"createdAt\": \"2026-01-15T10:30:00.000Z\",\n      \"updatedAt\": \"2026-01-15T10:30:00.000Z\"\n    },\n    {\n      \"id\": \"rep_def456\",\n      \"name\": \"Jane Doe\",\n      \"officeTitle\": \"Member of Provincial Parliament\",\n      \"level\": \"Provincial\",\n      \"politicalParty\": \"Progressive Conservative Party of Ontario\",\n      \"districtName\": \"Ottawa West\u2014Nepean\",\n      \"email\": \"jane.doe@ola.org\",\n      \"phone\": null,\n      \"fax\": null,\n      \"photoUrl\": null,\n      \"personalUrl\": null,\n      \"officeAddress\": null,\n      \"mailingAddress\": null,\n      \"socialMedia\": null,\n      \"otherData\": {},\n      \"postalCode\": \"K1A 0A9\",\n      \"createdAt\": \"2026-01-15T10:35:00.000Z\",\n      \"updatedAt\": \"2026-01-15T10:35:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 10,\n    \"total\": 245\n  }\n}\n
\n

Response Fields:

\n

Core fields (always present):\n- id (string): Unique representative identifier (prefixed with \"rep_\")\n- name (string): Full name\n- level (string): Government level (Federal, Provincial, or Municipal)\n- postalCode (string): Associated postal code\n- createdAt (ISO 8601): Cache entry creation timestamp\n- updatedAt (ISO 8601): Cache entry last update timestamp

\n

Optional fields (may be null):\n- officeTitle (string | null): Job title\n- politicalParty (string | null): Political party affiliation\n- districtName (string | null): Electoral district name\n- email (string | null): Contact email\n- phone (string | null): Contact phone\n- fax (string | null): Fax number\n- photoUrl (string | null): Portrait image URL\n- personalUrl (string | null): Personal/campaign website\n- officeAddress (string | null): Physical office location\n- mailingAddress (string | null): Mailing address\n- socialMedia (object | null): Social media links (twitter, facebook)\n- otherData (object): Additional custom fields from Represent API

"},{"location":"v2/frontend/pages/admin/representatives-page/#load-cache-statistics","title":"Load Cache Statistics","text":"

Request:

\n
const { data } = await api.get<CacheStats>('/representatives/stats');\n
\n

Response (200 OK):

\n
{\n  \"totalRepresentatives\": 245,\n  \"uniquePostalCodes\": 89,\n  \"avgRepsPerPostal\": 2.8,\n  \"breakdown\": {\n    \"Federal\": 89,\n    \"Provincial\": 89,\n    \"Municipal\": 67\n  }\n}\n
\n

Response Fields:\n- totalRepresentatives (number): Total cached representatives across all postal codes\n- uniquePostalCodes (number): Number of distinct postal codes in cache\n- avgRepsPerPostal (number): Average representatives per postal code (decimal)\n- breakdown (object): Count by government level (Federal, Provincial, Municipal)

\n

Statistics Calculation:

\n
// Backend calculation (api/src/modules/influence/representatives/representatives.service.ts)\nconst totalRepresentatives = await prisma.representative.count();\nconst uniquePostalCodes = await prisma.representative.findMany({\n  select: { postalCode: true },\n  distinct: ['postalCode'],\n});\nconst avgRepsPerPostal = uniquePostalCodes.length > 0\n  ? totalRepresentatives / uniquePostalCodes.length\n  : 0;\n\nconst breakdown = await prisma.representative.groupBy({\n  by: ['level'],\n  _count: { id: true },\n});\n
"},{"location":"v2/frontend/pages/admin/representatives-page/#lookup-representatives-by-postal-code","title":"Lookup Representatives by Postal Code","text":"

Request:

\n
const postalCode = 'K1A 0A9';\nconst { data } = await api.post<{\n  message: string;\n  count: number;\n  representatives: Representative[];\n}>(`/representatives/lookup/${postalCode}`);\n
\n

URL Parameter:\n- postalCode (string): Canadian postal code (format: \"A1A 1A1\" or \"A1A1A1\")

\n

Response (200 OK) - New Representatives Found:

\n
{\n  \"message\": \"Found 3 representatives for K1A 0A9 and cached them\",\n  \"count\": 3,\n  \"representatives\": [\n    {\n      \"id\": \"rep_abc123\",\n      \"name\": \"John Smith\",\n      \"level\": \"Federal\",\n      ...\n    },\n    {\n      \"id\": \"rep_def456\",\n      \"name\": \"Jane Doe\",\n      \"level\": \"Provincial\",\n      ...\n    },\n    {\n      \"id\": \"rep_ghi789\",\n      \"name\": \"Bob Johnson\",\n      \"level\": \"Municipal\",\n      ...\n    }\n  ]\n}\n
\n

Response (200 OK) - Already Cached:

\n
{\n  \"message\": \"Representatives for K1A 0A9 are already cached\",\n  \"count\": 3,\n  \"representatives\": [\n    {\n      \"id\": \"rep_abc123\",\n      \"name\": \"John Smith\",\n      \"level\": \"Federal\",\n      ...\n    }\n  ]\n}\n
\n

Response (200 OK) - No Representatives Found:

\n
{\n  \"message\": \"No representatives found for K1A 0A9\",\n  \"count\": 0,\n  \"representatives\": []\n}\n
\n

Error Response (400 Bad Request) - Invalid Postal Code:

\n
{\n  \"error\": \"Validation Error\",\n  \"details\": [\n    {\n      \"field\": \"postalCode\",\n      \"message\": \"Invalid postal code format. Expected format: A1A 1A1\"\n    }\n  ]\n}\n
\n

Error Response (503 Service Unavailable) - Represent API Down:

\n
{\n  \"error\": \"External service unavailable\",\n  \"message\": \"Represent API is temporarily unavailable. Please try again later.\"\n}\n
\n

Backend Workflow:

\n
// 1. Check if postal code already cached\nconst existingReps = await prisma.representative.findMany({\n  where: { postalCode: normalizedPostalCode },\n});\n\nif (existingReps.length > 0) {\n  return { message: 'Already cached', count: existingReps.length, representatives: existingReps };\n}\n\n// 2. Fetch from Represent API\nconst representResponse = await axios.get(\n  `https://represent.opennorth.ca/postcodes/${normalizedPostalCode}/?sets=federal-electoral-districts,provincial-electoral-districts,municipal-wards`\n);\n\n// 3. Transform and save to database\nconst reps = representResponse.data.representatives_centroid.map((rep: any) => ({\n  name: rep.name,\n  officeTitle: rep.elected_office,\n  level: determineLevel(rep.representative_set_name),\n  politicalParty: rep.party_name,\n  districtName: rep.district_name,\n  email: rep.email,\n  phone: rep.phone,\n  photoUrl: rep.photo_url,\n  personalUrl: rep.personal_url,\n  officeAddress: rep.office_address,\n  socialMedia: rep.extra?.social_media || null,\n  otherData: rep.extra || {},\n  postalCode: normalizedPostalCode,\n}));\n\nawait prisma.representative.createMany({ data: reps });\n
"},{"location":"v2/frontend/pages/admin/representatives-page/#delete-representative","title":"Delete Representative","text":"

Request:

\n
const repId = 'rep_abc123';\nawait api.delete(`/representatives/${repId}`);\n
\n

URL Parameter:\n- id (string): Representative ID to delete

\n

Response (200 OK):

\n
{\n  \"message\": \"Representative deleted from cache\"\n}\n
\n

Error Response (404 Not Found):

\n
{\n  \"error\": \"Not Found\",\n  \"message\": \"Representative not found with ID: rep_abc123\"\n}\n
"},{"location":"v2/frontend/pages/admin/representatives-page/#clear-entire-cache","title":"Clear Entire Cache","text":"

Request:

\n
const { data } = await api.delete<{ message: string; count: number }>('/representatives/cache');\n
\n

Response (200 OK):

\n
{\n  \"message\": \"Cache cleared successfully\",\n  \"count\": 245\n}\n
\n

Response Fields:\n- message (string): Confirmation message\n- count (number): Number of representatives deleted

\n

Backend Implementation:

\n
const count = await prisma.representative.count();\nawait prisma.representative.deleteMany({});\nreturn { message: 'Cache cleared successfully', count };\n
"},{"location":"v2/frontend/pages/admin/representatives-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#complete-representative-lookup-flow","title":"Complete Representative Lookup Flow","text":"
const handleLookup = async (values: { postalCode: string }) => {\n  setLookupLoading(true);\n  try {\n    const normalizedPostalCode = values.postalCode.toUpperCase().replace(/\\s+/g, ' ');\n\n    const { data } = await api.post<{\n      message: string;\n      count: number;\n      representatives: Representative[];\n    }>(`/representatives/lookup/${encodeURIComponent(normalizedPostalCode)}`);\n\n    if (data.count === 0) {\n      message.warning(data.message);\n    } else if (data.representatives.length > 0 && data.message.includes('already cached')) {\n      message.info(data.message);\n    } else {\n      message.success(data.message);\n    }\n\n    // Refresh table and stats\n    await Promise.all([loadRepresentatives(), loadStats()]);\n\n    setLookupModalOpen(false);\n    lookupForm.resetFields();\n  } catch (error) {\n    if (axios.isAxiosError(error) && error.response?.status === 503) {\n      message.error('Represent API is temporarily unavailable. Please try again later.');\n    } else if (axios.isAxiosError(error) && error.response?.status === 400) {\n      message.error('Invalid postal code format. Expected format: A1A 1A1');\n    } else {\n      message.error('Failed to lookup representatives');\n    }\n  } finally {\n    setLookupLoading(false);\n  }\n};\n
\n

Key Steps:\n1. Normalize postal code (uppercase, single space)\n2. URL-encode postal code for API request\n3. Handle three success scenarios (new, cached, not found)\n4. Show appropriate message type (success, info, warning)\n5. Refresh both table and statistics after successful lookup\n6. Close modal and reset form on success\n7. Handle specific error codes (503, 400)\n8. Always set loading state in finally block

"},{"location":"v2/frontend/pages/admin/representatives-page/#delete-with-confirmation","title":"Delete with Confirmation","text":"
const handleDeleteConfirm = (rep: Representative) => {\n  Modal.confirm({\n    title: 'Delete Representative',\n    content: `Are you sure you want to delete \"${rep.name}\" from the cache?`,\n    okText: 'Delete',\n    okType: 'danger',\n    cancelText: 'Cancel',\n    onOk: async () => {\n      try {\n        await api.delete(`/representatives/${rep.id}`);\n        message.success('Representative deleted from cache');\n\n        // Refresh table and stats\n        await Promise.all([loadRepresentatives(), loadStats()]);\n      } catch (error) {\n        message.error('Failed to delete representative');\n      }\n    },\n  });\n};\n
\n

Confirmation Pattern:\n- Uses Ant Design Modal.confirm static method (no state needed)\n- Shows representative name in confirmation text for clarity\n- Async onOk handler performs delete and refresh\n- Refreshes both table and stats to keep UI in sync\n- Error handling within onOk (doesn't prevent modal close)

"},{"location":"v2/frontend/pages/admin/representatives-page/#clear-all-cache-with-confirmation","title":"Clear All Cache with Confirmation","text":"
const handleClearCache = () => {\n  Modal.confirm({\n    title: 'Clear Cache',\n    content: stats\n      ? `Are you sure you want to clear the entire representative cache? This will delete all ${stats.totalRepresentatives} cached representatives.`\n      : 'Are you sure you want to clear the entire representative cache?',\n    okText: 'Clear Cache',\n    okType: 'danger',\n    cancelText: 'Cancel',\n    onOk: async () => {\n      try {\n        const { data } = await api.delete<{ message: string; count: number }>('/representatives/cache');\n        message.success(`Cache cleared successfully. Deleted ${data.count} representatives.`);\n\n        // Refresh table and stats\n        await Promise.all([loadRepresentatives(), loadStats()]);\n      } catch (error) {\n        message.error('Failed to clear cache');\n      }\n    },\n  });\n};\n
\n

Enhanced Confirmation:\n- Dynamically includes total count in confirmation message (if stats loaded)\n- Shows exact number of representatives that will be deleted\n- Success message includes deleted count for verification\n- Uses danger button styling to emphasize destructive action

"},{"location":"v2/frontend/pages/admin/representatives-page/#color-coded-government-level-tags","title":"Color-Coded Government Level Tags","text":"
const getLevelColor = (level: string): string => {\n  const colorMap: Record<string, string> = {\n    Federal: 'red',\n    Provincial: 'blue',\n    Municipal: 'green',\n  };\n  return colorMap[level] || 'default';\n};\n\n// Usage in table column\n{\n  title: 'Level',\n  dataIndex: 'level',\n  key: 'level',\n  width: 120,\n  render: (level: string) => (\n    <Tag color={getLevelColor(level)}>{level}</Tag>\n  ),\n  sorter: (a, b) => a.level.localeCompare(b.level),\n}\n
\n

Color Mapping:\n- Federal: Red (highest level of government)\n- Provincial: Blue (middle level)\n- Municipal: Green (local level)\n- Default: Gray (unknown/other levels)

"},{"location":"v2/frontend/pages/admin/representatives-page/#debounced-filter-implementation","title":"Debounced Filter Implementation","text":"
// Component state\nconst searchTimerRef = useRef<NodeJS.Timeout | null>(null);\nconst postalTimerRef = useRef<NodeJS.Timeout | null>(null);\n\n// Search input handler\nconst handleSearch = (value: string) => {\n  if (searchTimerRef.current) {\n    clearTimeout(searchTimerRef.current);\n  }\n\n  searchTimerRef.current = setTimeout(() => {\n    setSearch(value);\n    setPagination((prev) => ({ ...prev, current: 1 }));\n  }, 300);\n};\n\n// Postal code filter handler\nconst handlePostalCodeFilterChange = (value: string) => {\n  if (postalTimerRef.current) {\n    clearTimeout(postalTimerRef.current);\n  }\n\n  postalTimerRef.current = setTimeout(() => {\n    setPostalCodeFilter(value);\n    setPagination((prev) => ({ ...prev, current: 1 }));\n  }, 300);\n};\n\n// Cleanup on unmount\nuseEffect(() => {\n  return () => {\n    if (searchTimerRef.current) clearTimeout(searchTimerRef.current);\n    if (postalTimerRef.current) clearTimeout(postalTimerRef.current);\n  };\n}, []);\n\n// Input components\n<Input.Search\n  placeholder=\"Search representatives...\"\n  allowClear\n  onChange={(e) => handleSearch(e.target.value)}\n  style={{ width: '100%' }}\n/>\n\n<Input\n  placeholder=\"Filter by postal code...\"\n  allowClear\n  onChange={(e) => handlePostalCodeFilterChange(e.target.value)}\n  style={{ width: '100%' }}\n/>\n
\n

Two Independent Debounce Timers:\n- Separate refs: searchTimerRef and postalTimerRef allow independent debouncing\n- 300ms delay: Balances responsiveness and performance\n- Reset pagination: Both filters reset to page 1 when changed\n- Cleanup effect: Clears timers on unmount to prevent memory leaks\n- allowClear: Ant Design Input feature adds X icon to clear field

"},{"location":"v2/frontend/pages/admin/representatives-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#efficient-pagination","title":"Efficient Pagination","text":"

The page uses server-side pagination to handle large cache datasets efficiently:

\n
const { data } = await api.get('/representatives', {\n  params: {\n    page: pagination.current,\n    limit: pagination.pageSize,\n    search,\n    postalCodeFilter,\n    levelFilter,\n  },\n});\n
\n

Benefits:\n- Reduced payload: Only fetches current page (10-100 items) instead of all 245+\n- Fast rendering: Table renders 10-100 rows instead of potentially thousands\n- Scalable: Works efficiently with cache sizes from 10 to 10,000+ representatives\n- Combined filtering: Backend applies filters before pagination, returning only relevant results

"},{"location":"v2/frontend/pages/admin/representatives-page/#debounced-search-300ms","title":"Debounced Search (300ms)","text":"

Prevents API spam during typing:

\n
searchTimerRef.current = setTimeout(() => {\n  setSearch(value);\n}, 300);\n
\n

Performance Impact:\n- Without debounce: Typing \"John Smith\" (10 characters) = 10 API calls\n- With 300ms debounce: Typing \"John Smith\" = 1 API call (after 300ms pause)\n- Network savings: 90% reduction in API requests for typical typing speed\n- Backend load: Reduces database queries and Represent API calls

"},{"location":"v2/frontend/pages/admin/representatives-page/#usecallback-for-fetch-functions","title":"useCallback for Fetch Functions","text":"

Prevents unnecessary re-renders:

\n
const loadRepresentatives = useCallback(async () => {\n  // ... fetch logic\n}, [pagination.current, pagination.pageSize, search, postalCodeFilter, levelFilter]);\n
\n

Why This Matters:\n- Without useCallback: Function reference changes every render, triggering useEffect infinitely\n- With useCallback: Function reference only changes when dependencies change\n- Result: useEffect runs only when filters/pagination actually change, not on every render

"},{"location":"v2/frontend/pages/admin/representatives-page/#statistics-caching","title":"Statistics Caching","text":"

Statistics are loaded separately and don't re-fetch on table filter changes:

\n
const loadStats = useCallback(async () => {\n  const { data } = await api.get<CacheStats>('/representatives/stats');\n  setStats(data);\n}, []);\n\nuseEffect(() => {\n  loadStats();\n}, [loadStats]); // Only runs on mount\n
\n

Benefits:\n- Independent updates: Stats only refresh after lookup/delete operations, not on search/filter\n- Reduced API calls: Stats don't need to be recalculated for every table filter\n- Better UX: Statistics cards remain stable while user searches/filters table

"},{"location":"v2/frontend/pages/admin/representatives-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#mobile-layout","title":"Mobile Layout","text":"

The page adapts gracefully to mobile viewports:

\n

Statistics Cards:\n

<Row gutter={[16, 16]}>\n  <Col xs={24} sm={8}>  {/* Full width mobile, 1/3 width desktop */}\n    <Card size=\"small\">\n      <Statistic title=\"Cached Representatives\" value={stats.totalRepresentatives} />\n    </Card>\n  </Col>\n  {/* Repeat for other cards */}\n</Row>\n

\n

Filter Inputs:\n

<Row gutter={[16, 16]}>\n  <Col xs={24} md={12}>  {/* Full width mobile, half width desktop */}\n    <Input.Search placeholder=\"Search representatives...\" />\n  </Col>\n  <Col xs={24} md={12}>  {/* Full width mobile, half width desktop */}\n    <Input placeholder=\"Filter by postal code...\" />\n  </Col>\n</Row>\n

\n

Responsive Grid Breakpoints:\n- xs (mobile, <576px): Stacked layout, full-width cards and inputs\n- sm (tablet, \u2265576px): 3-column statistics cards\n- md (desktop, \u2265768px): Side-by-side filter inputs\n- lg+ (large desktop, \u2265992px): Full table width with all columns visible

"},{"location":"v2/frontend/pages/admin/representatives-page/#table-column-responsiveness","title":"Table Column Responsiveness","text":"

Columns use responsive prop to hide on mobile:

\n
{\n  title: 'Email',\n  dataIndex: 'email',\n  key: 'email',\n  responsive: ['md'],  // Hidden on mobile (xs, sm)\n  render: (email: string | null) => (\n    email ? <a href={`mailto:${email}`}>{email}</a> : '\u2014'\n  ),\n}\n
\n

Mobile Table (xs, sm):\n- Name (visible)\n- Level (visible with color tags)\n- Actions (visible)

\n

Desktop Table (md+):\n- Name + Office + Level + Party + District + Email + Actions (all visible)

"},{"location":"v2/frontend/pages/admin/representatives-page/#drawer-width","title":"Drawer Width","text":"

Detail drawer adapts to screen size:

\n
<Drawer\n  width={600}  // 600px on desktop\n  // On mobile (xs), automatically becomes full-width\n  placement=\"right\"\n  open={detailDrawerOpen}\n  onClose={() => setDetailDrawerOpen(false)}\n>\n
\n

Behavior:\n- Desktop (\u2265768px): 600px slide-in panel from right\n- Mobile (<768px): Full-width slide-in panel (automatically handled by Ant Design)

"},{"location":"v2/frontend/pages/admin/representatives-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

All interactive elements are keyboard-accessible:

\n

Table Navigation:\n- Tab: Move between action buttons (View, Delete)\n- Enter/Space: Activate focused button\n- Arrow Keys: Navigate table rows (Ant Design built-in)

\n

Form Fields:\n- Tab: Move between search input, postal code filter, level dropdown\n- Escape: Clear input fields (when using allowClear)\n- Enter: Submit lookup form

\n

Modal/Drawer:\n- Escape: Close modal or drawer\n- Tab: Cycle through focusable elements inside modal/drawer\n- Enter: Confirm action (in confirmation modals)

"},{"location":"v2/frontend/pages/admin/representatives-page/#screen-reader-support","title":"Screen Reader Support","text":"

The page provides semantic HTML and ARIA labels:

\n

Statistics Cards:\n

<Statistic\n  title=\"Cached Representatives\"  // Read by screen readers\n  value={stats.totalRepresentatives}\n  prefix={<TeamOutlined />}\n/>\n

\n

Action Buttons:\n

<Button\n  icon={<EyeOutlined />}\n  onClick={() => handleViewDetails(record)}\n  aria-label={`View details for ${record.name}`}\n>\n  View\n</Button>\n\n<Button\n  icon={<DeleteOutlined />}\n  onClick={() => handleDeleteConfirm(record)}\n  aria-label={`Delete ${record.name} from cache`}\n  danger\n>\n  Delete\n</Button>\n

\n

Table Sorting:\n

{\n  title: 'Name',\n  sorter: (a, b) => a.name.localeCompare(b.name),\n  // Ant Design automatically adds aria-sort=\"ascending|descending|none\"\n}\n

"},{"location":"v2/frontend/pages/admin/representatives-page/#color-contrast","title":"Color Contrast","text":"

All color-coded elements meet WCAG AA standards:

\n

Government Level Tags:\n- Federal (red): #f5222d on white background = 4.5:1 contrast ratio\n- Provincial (blue): #1890ff on white background = 4.5:1 contrast ratio\n- Municipal (green): #52c41a on white background = 4.5:1 contrast ratio

\n

Text Colors:\n- Primary text: rgba(0, 0, 0, 0.85) = 13.6:1 contrast ratio\n- Secondary text: rgba(0, 0, 0, 0.45) = 7.0:1 contrast ratio (used for \"\u2014\" null values)

"},{"location":"v2/frontend/pages/admin/representatives-page/#focus-indicators","title":"Focus Indicators","text":"

All interactive elements have visible focus states:

\n

Buttons:\n

.ant-btn:focus {\n  outline: 2px solid #1890ff;\n  outline-offset: 2px;\n}\n

\n

Input Fields:\n

.ant-input:focus {\n  border-color: #40a9ff;\n  box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);\n}\n

"},{"location":"v2/frontend/pages/admin/representatives-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#representatives-not-loading","title":"Representatives Not Loading","text":"

Problem: Page shows empty state or loading spinner indefinitely.

\n

Diagnosis:

\n

Check browser console for errors:

\n
// Console error\nGET https://api.cmlite.org/representatives 401 Unauthorized\n
\n

Possible Causes:

\n
    \n
  1. Not authenticated:
  2. \n
  3. Check if JWT access token is expired
  4. \n
  5. \n

    Check if user has required role (SUPER_ADMIN or INFLUENCE_ADMIN)

    \n
  6. \n
  7. \n

    Backend API down:

    \n
  8. \n
  9. Verify API container is running: docker compose ps api
  10. \n
  11. \n

    Check API logs: docker compose logs api

    \n
  12. \n
  13. \n

    Database connection issue:

    \n
  14. \n
  15. Verify PostgreSQL is running: docker compose ps v2-postgres
  16. \n
  17. Check database connection: docker compose exec api npx prisma db push
  18. \n
\n

Solution:

\n
    \n
  1. Refresh page to trigger token refresh
  2. \n
  3. Log out and log back in to get new token
  4. \n
  5. Verify backend services are running: docker compose up -d api v2-postgres
  6. \n
  7. Check API logs for specific error: docker compose logs -f api | grep representatives
  8. \n
"},{"location":"v2/frontend/pages/admin/representatives-page/#postal-code-lookup-fails","title":"Postal Code Lookup Fails","text":"

Problem: Click \"Lookup New Postal Code\", enter postal code, get error: \"Failed to lookup representatives\".

\n

Diagnosis:

\n

Check network tab in browser DevTools:

\n
// Response from POST /representatives/lookup/K1A0A9\n{\n  \"error\": \"External service unavailable\",\n  \"message\": \"Represent API is temporarily unavailable. Please try again later.\"\n}\n
\n

Possible Causes:

\n
    \n
  1. Represent API down:
  2. \n
  3. represent.opennorth.ca is temporarily unavailable (503)
  4. \n
  5. \n

    Network firewall blocking external API requests

    \n
  6. \n
  7. \n

    Invalid postal code format:

    \n
  8. \n
  9. Missing space in postal code (should be \"K1A 0A9\", not \"K1A0A9\")
  10. \n
  11. \n

    Non-Canadian postal code entered

    \n
  12. \n
  13. \n

    Rate limit exceeded:

    \n
  14. \n
  15. Too many requests to Represent API from same IP
  16. \n
  17. Represent API rate limit: 60 requests/minute/IP
  18. \n
\n

Solution:

\n
    \n
  1. For Represent API downtime:
  2. \n
  3. Wait 5-10 minutes and retry
  4. \n
  5. Check Represent API status: https://represent.opennorth.ca/postcodes/K1A0A9/
  6. \n
  7. \n

    Use cached representatives if available (search for existing postal code)

    \n
  8. \n
  9. \n

    For invalid postal code:

    \n
  10. \n
  11. Ensure format is \"A1A 1A1\" with space (e.g., \"K1A 0A9\")
  12. \n
  13. Use Canadian postal codes only (Represent API only covers Canada)
  14. \n
  15. \n

    Try a known-valid postal code: \"K1A 0A9\" (Ottawa, Parliament Hill)

    \n
  16. \n
  17. \n

    For rate limits:

    \n
  18. \n
  19. Wait 1 minute before retrying
  20. \n
  21. Reduce lookup frequency (don't spam the button)
  22. \n
  23. Check existing cache first (use search/filter)
  24. \n
"},{"location":"v2/frontend/pages/admin/representatives-page/#delete-button-not-working","title":"Delete Button Not Working","text":"

Problem: Click \"Delete\" button, confirmation modal appears, click \"Delete\" again, but representative remains in table.

\n

Diagnosis:

\n

Check network tab:

\n
// Response from DELETE /representatives/rep_abc123\n{\n  \"error\": \"Forbidden\",\n  \"message\": \"Insufficient permissions. Required role: SUPER_ADMIN or INFLUENCE_ADMIN\"\n}\n
\n

Possible Causes:

\n
    \n
  1. Insufficient permissions:
  2. \n
  3. User role is USER (not INFLUENCE_ADMIN or SUPER_ADMIN)
  4. \n
  5. \n

    JWT token has expired and refresh failed

    \n
  6. \n
  7. \n

    Representative already deleted:

    \n
  8. \n
  9. Another admin deleted the representative concurrently
  10. \n
  11. \n

    Table hasn't refreshed to reflect deletion

    \n
  12. \n
  13. \n

    Database constraint violation:

    \n
  14. \n
  15. Representative is referenced by campaign emails (foreign key constraint)
  16. \n
  17. Cannot delete due to active references
  18. \n
\n

Solution:

\n
    \n
  1. For permission issues:
  2. \n
  3. Contact system administrator to grant INFLUENCE_ADMIN role
  4. \n
  5. Log out and log back in to refresh permissions
  6. \n
  7. \n

    Check user role in profile dropdown (top-right corner)

    \n
  8. \n
  9. \n

    For concurrent deletion:

    \n
  10. \n
  11. Refresh page to see current cache state
  12. \n
  13. \n

    If representative is gone, deletion succeeded (UI just didn't update)

    \n
  14. \n
  15. \n

    For constraint violations:

    \n
  16. \n
  17. Delete dependent records first (campaign emails referencing this representative)
  18. \n
  19. Or use \"Clear All Cache\" button to delete everything at once (cascading delete)
  20. \n
"},{"location":"v2/frontend/pages/admin/representatives-page/#search-not-working","title":"Search Not Working","text":"

Problem: Type search query in \"Search Representatives\" field, but table doesn't filter.

\n

Diagnosis:

\n

Check if debounce timer is working:

\n
// Wait 300ms after typing\n// If table still doesn't update, check console for errors\n
\n

Possible Causes:

\n
    \n
  1. Typing too fast:
  2. \n
  3. Debounce timer resets on every keystroke
  4. \n
  5. \n

    Must wait 300ms after last keystroke

    \n
  6. \n
  7. \n

    Case sensitivity:

    \n
  8. \n
  9. Search is case-insensitive on backend, but special characters may cause issues
  10. \n
  11. \n

    Accent characters (\u00e9, \u00e0, \u00f1) may not match correctly

    \n
  12. \n
  13. \n

    Search scope confusion:

    \n
  14. \n
  15. Search only matches: name, officeTitle, politicalParty, districtName
  16. \n
  17. Does NOT search: email, phone, addresses, otherData
  18. \n
\n

Solution:

\n
    \n
  1. For typing speed:
  2. \n
  3. Pause typing for 300ms (about half a second)
  4. \n
  5. \n

    Watch for table loading spinner to confirm search triggered

    \n
  6. \n
  7. \n

    For special characters:

    \n
  8. \n
  9. Remove accents (e.g., search \"Montreal\" instead of \"Montr\u00e9al\")
  10. \n
  11. \n

    Use partial matches (e.g., \"Smith\" instead of \"O'Smith\")

    \n
  12. \n
  13. \n

    For search scope:

    \n
  14. \n
  15. Search by name: \"John Smith\"
  16. \n
  17. Search by party: \"Liberal\"
  18. \n
  19. Search by district: \"Ottawa Centre\"
  20. \n
  21. Use postal code filter for location-based filtering (separate input field)
  22. \n
"},{"location":"v2/frontend/pages/admin/representatives-page/#statistics-not-updating","title":"Statistics Not Updating","text":"

Problem: Add new representatives via lookup, but statistics cards still show old counts.

\n

Diagnosis:

\n

Check if loadStats() is called after lookup:

\n
// Should see this in network tab after successful lookup\nGET /representatives/lookup/K1A0A9 \u2192 200 OK\nGET /representatives/stats \u2192 200 OK  // \u2190 Should appear here\n
\n

Possible Causes:

\n
    \n
  1. Frontend bug:
  2. \n
  3. loadStats() not called after successful lookup
  4. \n
  5. \n

    Promise.all([loadRepresentatives(), loadStats()]) failed silently

    \n
  6. \n
  7. \n

    Backend calculation error:

    \n
  8. \n
  9. Statistics endpoint returning cached/stale data
  10. \n
  11. \n

    Database aggregation query not reflecting new records

    \n
  12. \n
  13. \n

    Cache invalidation:

    \n
  14. \n
  15. Backend caching statistics for performance
  16. \n
  17. Cache not invalidated after lookup/delete operations
  18. \n
\n

Solution:

\n
    \n
  1. Manual refresh:
  2. \n
  3. Refresh entire page (F5 or Ctrl+R)
  4. \n
  5. \n

    Statistics should update to reflect current cache state

    \n
  6. \n
  7. \n

    Check backend logs:

    \n
  8. \n
  9. Look for statistics calculation errors: docker compose logs api | grep stats
  10. \n
  11. \n

    Verify database connection during stats calculation

    \n
  12. \n
  13. \n

    Developer fix (if bug):

    \n
  14. \n
  15. Ensure loadStats() is called after lookup/delete:\n
    await Promise.all([loadRepresentatives(), loadStats()]);\n
  16. \n
  17. Remove backend statistics caching (if implemented)
  18. \n
"},{"location":"v2/frontend/pages/admin/representatives-page/#related-documentation","title":"Related Documentation","text":"
    \n
  • Influence Module Overview \u2014 Advocacy campaigns feature set
  • \n
  • Represent API Integration \u2014 Backend representative service
  • \n
  • Representative Cache API \u2014 API endpoint reference
  • \n
  • Campaigns Backend Module \u2014 Campaign system using representatives
  • \n
  • Postal Codes Backend Module \u2014 Postal code cache system
  • \n
  • CampaignsPage \u2014 Campaign management page (uses representatives)
  • \n
  • Public Campaign Page \u2014 Public campaign page (postal code lookup)
  • \n
  • User Guide: Campaign Management \u2014 Campaign manager workflow
  • \n
  • Troubleshooting: Represent API \u2014 Represent API issues
  • \n
  • External Services \u2014 Represent API architecture integration
  • \n
"},{"location":"v2/frontend/pages/admin/responses-page/","title":"ResponsesPage","text":""},{"location":"v2/frontend/pages/admin/responses-page/#overview","title":"Overview","text":"

The ResponsesPage provides moderation and management for public campaign responses submitted through the response wall feature. Administrators can review user submissions, approve or reject responses for public display, resend verification emails, and view detailed response information. Features include advanced filtering by status and campaign, search functionality, and clickable rows for detailed views.

Route: /app/influence/responses Component: admin/src/pages/ResponsesPage.tsx (400 lines) Auth Required: Yes (SUPER_ADMIN, INFLUENCE_ADMIN roles) Layout: AppLayout

"},{"location":"v2/frontend/pages/admin/responses-page/#screenshot","title":"Screenshot","text":"

[Screenshot: Responses page with search bar + status dropdown + campaign dropdown at top. Main table shows columns: Representative (name), Level (government level tag), Type (EMAIL/PHONE tag), Campaign (title), Status (PENDING/APPROVED/REJECTED colored tag), Verified (checkmark icon if true), Upvotes (count), Submitted (date), Actions (Approve, Reject, Verify, Delete buttons). Rows clickable to open detail drawer. Page header has \"Refresh\" button.]

"},{"location":"v2/frontend/pages/admin/responses-page/#features","title":"Features","text":"
  • Full response moderation \u2014 Approve, reject, or delete public responses
  • Verification management \u2014 Resend verification emails for unverified responses
  • Advanced filtering \u2014 Filter by status (PENDING/APPROVED/REJECTED) and campaign
  • Search functionality \u2014 400ms debounced search across response text
  • Government level tags \u2014 Color-coded tags for FEDERAL, PROVINCIAL, MUNICIPAL, SCHOOL_BOARD
  • Response type tracking \u2014 EMAIL (sent via SMTP) vs PHONE (called representative)
  • Upvote display \u2014 Show public upvote count for each response
  • Verification badges \u2014 SafetyCertificateOutlined icon for verified email addresses
  • Clickable rows \u2014 Click any row to open detail drawer
  • Detail drawer \u2014 Comprehensive response view with all metadata
  • Action buttons \u2014 Conditional rendering based on status (Approve hidden if already approved)
  • Responsive table \u2014 Columns hide on smaller screens (Verified, Upvotes: md+, Submitted: sm+)
"},{"location":"v2/frontend/pages/admin/responses-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/responses-page/#viewing-responses-list","title":"Viewing Responses List","text":"
  1. Navigate to /app/influence/responses
  2. Page loads first 20 responses (paginated)
  3. View response details:
  4. Representative name
  5. Government level tag (colored)
  6. Response type (EMAIL/PHONE)
  7. Campaign title
  8. Status tag (PENDING: yellow, APPROVED: green, REJECTED: red)
  9. Verified checkmark icon (if email verified)
  10. Upvote count
  11. Submitted date
  12. Action buttons
  13. Click any row to open detail drawer
"},{"location":"v2/frontend/pages/admin/responses-page/#filtering-responses","title":"Filtering Responses","text":"
  1. Status filter (dropdown):
  2. Select PENDING, APPROVED, or REJECTED
  3. Clear to show all statuses
  4. Campaign filter (dropdown with search):
  5. Select campaign from list (up to 100 campaigns)
  6. Type to search campaign titles
  7. Clear to show all campaigns
  8. Search bar:
  9. Type keywords to search response text
  10. 400ms debounce (waits for typing pause)
  11. Search resets pagination to page 1
  12. Filters combine (AND logic): status + campaign + search
"},{"location":"v2/frontend/pages/admin/responses-page/#approving-a-response","title":"Approving a Response","text":"
  1. Locate PENDING or REJECTED response in table
  2. Click \"Approve\" button (green, CheckCircleOutlined icon)
  3. Button shows loading spinner
  4. Success message: \"Response approved\"
  5. Table refreshes with updated status
  6. Effect:
  7. Response status changes to APPROVED
  8. Response now visible on public response wall
  9. User receives confirmation email (if email verified)
  10. Approve button hidden if response already APPROVED
"},{"location":"v2/frontend/pages/admin/responses-page/#rejecting-a-response","title":"Rejecting a Response","text":"
  1. Locate PENDING or APPROVED response in table
  2. Click \"Reject\" button (red, CloseCircleOutlined icon)
  3. Button shows loading spinner
  4. Success message: \"Response rejected\"
  5. Table refreshes with updated status
  6. Effect:
  7. Response status changes to REJECTED
  8. Response hidden from public response wall
  9. User does NOT receive notification (silent rejection)
  10. Reject button hidden if response already REJECTED
"},{"location":"v2/frontend/pages/admin/responses-page/#resending-verification-email","title":"Resending Verification Email","text":"
  1. Locate response with representativeEmail (not null)
  2. Click \"Verify\" button (MailOutlined icon)
  3. Button shows loading spinner
  4. Success message: \"Verification email sent\"
  5. Email sent to user with verification link
  6. User clicks link \u2192 isVerified set to true
  7. Verified responses show SafetyCertificateOutlined icon

Verification flow: 1. User submits response on public page 2. System sends verification email to submittedByEmail 3. User clicks verification link in email 4. Backend marks response as verified (isVerified: true) 5. Verified responses show checkmark icon in table

"},{"location":"v2/frontend/pages/admin/responses-page/#deleting-a-response","title":"Deleting a Response","text":"
  1. Locate response in table
  2. Click Delete icon button (DeleteOutlined, red)
  3. Popconfirm: \"Delete this response?\"
  4. Click \"OK\" to confirm
  5. Button shows loading spinner
  6. Success message: \"Response deleted\"
  7. Table refreshes (response disappears)
  8. Cascade behavior: Response record permanently deleted from database

Warning: Deletion is permanent. No soft delete. Upvotes also deleted (cascade).

"},{"location":"v2/frontend/pages/admin/responses-page/#viewing-response-details","title":"Viewing Response Details","text":"
  1. Click any row in table
  2. Detail drawer opens on right side (520px width)
  3. Drawer content (Descriptions component with bordered layout):
  4. Representative: Name + Title (if present)
  5. Level: Government level tag (colored)
  6. Type: Response type tag (EMAIL/PHONE)
  7. Campaign: Campaign title
  8. Status: Status tag (colored)
  9. Verified: Yes/No + verified by + verified date (if verified)
  10. Upvotes: Count
  11. Response Text: Full response text (scrollable, max 300px height, pre-wrap)
  12. User Comment: Additional comment (if present)
  13. Submitted By: Name + Email OR \"Anonymous\" (if anonymous)
  14. Submitted: Full timestamp (MMM D, YYYY h:mm A)
  15. Click \"X\" or outside drawer to close
"},{"location":"v2/frontend/pages/admin/responses-page/#refreshing-data","title":"Refreshing Data","text":"
  1. Click \"Refresh\" button in page header
  2. Table reloads with current filters applied
  3. Fetches latest responses from API
  4. Useful for checking new submissions without full page reload
"},{"location":"v2/frontend/pages/admin/responses-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/responses-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  • Table \u2014 Main responses list with columns, pagination, row click handler
  • Input \u2014 Search bar with SearchOutlined prefix, 400ms debounce
  • Select \u2014 Status filter dropdown (3 options), Campaign filter dropdown (searchable)
  • Button \u2014 Refresh (header), Approve, Reject, Verify (table actions), Delete (icon button)
  • Tag \u2014 Government level tags, response type tags, status tags
  • Space \u2014 Action button grouping (allows wrapping)
  • Drawer \u2014 Response detail view (520px width, destroyOnClose)
  • Descriptions \u2014 Detail view with labeled fields (column: 1, bordered, size: small)
  • Popconfirm \u2014 Delete confirmation
  • Row, Col \u2014 Responsive grid for filters (3 columns on desktop, stacked on mobile)
  • SafetyCertificateOutlined \u2014 Verified email icon (green)
"},{"location":"v2/frontend/pages/admin/responses-page/#table-columns","title":"Table Columns","text":"
const columns: ColumnsType<RepresentativeResponse> = [\n  {\n    title: 'Representative',\n    dataIndex: 'representativeName',\n    ellipsis: true,\n  },\n  {\n    title: 'Level',\n    dataIndex: 'representativeLevel',\n    width: 120,\n    render: (level) => (\n      <Tag color={GOVERNMENT_LEVEL_COLORS[level]}>\n        {GOVERNMENT_LEVEL_LABELS[level]}\n      </Tag>\n    ),\n  },\n  {\n    title: 'Type',\n    dataIndex: 'responseType',\n    width: 110,\n    render: (type) => <Tag>{RESPONSE_TYPE_LABELS[type]}</Tag>,\n  },\n  {\n    title: 'Campaign',\n    width: 160,\n    ellipsis: true,\n    render: (_, record) => record.campaign?.title || '\u2014',\n  },\n  {\n    title: 'Status',\n    dataIndex: 'status',\n    width: 100,\n    render: (status) => (\n      <Tag color={RESPONSE_STATUS_COLORS[status]}>{status}</Tag>\n    ),\n  },\n  {\n    title: 'Verified',\n    width: 80,\n    align: 'center',\n    responsive: ['md'],\n    render: (_, record) =>\n      record.isVerified ? (\n        <SafetyCertificateOutlined style={{ color: '#16a34a', fontSize: 16 }} />\n      ) : null,\n  },\n  {\n    title: 'Upvotes',\n    dataIndex: 'upvoteCount',\n    width: 80,\n    align: 'center',\n    responsive: ['md'],\n  },\n  {\n    title: 'Submitted',\n    dataIndex: 'createdAt',\n    width: 120,\n    responsive: ['sm'],\n    render: (date) => dayjs(date).format('MMM D, YYYY'),\n  },\n  {\n    title: 'Actions',\n    width: 200,\n    render: (_, record) => (\n      <Space size=\"small\" wrap>\n        {record.status !== 'APPROVED' && (\n          <Button size=\"small\" type=\"link\" icon={<CheckCircleOutlined />} style={{ color: '#16a34a' }} loading={actionLoading === record.id} onClick={() => handleApprove(record.id)}>\n            Approve\n          </Button>\n        )}\n        {record.status !== 'REJECTED' && (\n          <Button size=\"small\" type=\"link\" icon={<CloseCircleOutlined />} danger loading={actionLoading === record.id} onClick={() => handleReject(record.id)}>\n            Reject\n          </Button>\n        )}\n        {record.representativeEmail && (\n          <Button size=\"small\" type=\"link\" icon={<MailOutlined />} loading={actionLoading === record.id} onClick={() => handleResendVerification(record.id)}>\n            Verify\n          </Button>\n        )}\n        <Popconfirm title=\"Delete this response?\" onConfirm={() => handleDelete(record.id)}>\n          <Button size=\"small\" type=\"link\" icon={<DeleteOutlined />} danger loading={actionLoading === record.id} />\n        </Popconfirm>\n      </Space>\n    ),\n  },\n];\n

Key patterns: - Conditional button rendering: Approve hidden if APPROVED, Reject hidden if REJECTED - Verify button only shown if representativeEmail exists - actionLoading state tracks which row's action is in progress - wrap on Space allows buttons to wrap on narrow screens

"},{"location":"v2/frontend/pages/admin/responses-page/#status-colors","title":"Status Colors","text":"
export const RESPONSE_STATUS_COLORS = {\n  PENDING: 'gold',      // Yellow (awaiting moderation)\n  APPROVED: 'green',    // Green (visible on public wall)\n  REJECTED: 'red',      // Red (hidden from public)\n};\n
"},{"location":"v2/frontend/pages/admin/responses-page/#government-level-colors","title":"Government Level Colors","text":"
export const GOVERNMENT_LEVEL_COLORS = {\n  FEDERAL: 'blue',\n  PROVINCIAL: 'purple',\n  MUNICIPAL: 'cyan',\n  SCHOOL_BOARD: 'magenta',\n};\n
"},{"location":"v2/frontend/pages/admin/responses-page/#response-type-labels","title":"Response Type Labels","text":"
export const RESPONSE_TYPE_LABELS = {\n  EMAIL: 'Email',      // Sent via SMTP to representative\n  PHONE: 'Phone',      // Called representative\n};\n
"},{"location":"v2/frontend/pages/admin/responses-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/responses-page/#zustand-stores-used","title":"Zustand Stores Used","text":"

None \u2014 Responses fetched from API on each interaction.

"},{"location":"v2/frontend/pages/admin/responses-page/#local-state","title":"Local State","text":"
const [responses, setResponses] = useState<RepresentativeResponse[]>([]);\nconst [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });\nconst [loading, setLoading] = useState(false);\nconst [actionLoading, setActionLoading] = useState<string | null>(null);  // Row ID with action in progress\nconst [statusFilter, setStatusFilter] = useState<ResponseStatus | undefined>();\nconst [campaignFilter, setCampaignFilter] = useState<string | undefined>();\nconst [search, setSearch] = useState('');\nconst [campaigns, setCampaigns] = useState<Campaign[]>([]);\nconst [detailResponse, setDetailResponse] = useState<RepresentativeResponse | null>(null);\nconst searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\n
"},{"location":"v2/frontend/pages/admin/responses-page/#debounced-search","title":"Debounced Search","text":"
const handleSearch = (value: string) => {\n  if (searchTimerRef.current) clearTimeout(searchTimerRef.current);\n  searchTimerRef.current = setTimeout(() => {\n    setSearch(value);\n  }, 400);\n};\n

Why 400ms? Slightly longer than other pages (300ms) \u2014 response text can be lengthy, giving users more time to type complete phrases.

"},{"location":"v2/frontend/pages/admin/responses-page/#per-row-action-loading","title":"Per-Row Action Loading","text":"
const [actionLoading, setActionLoading] = useState<string | null>(null);\n\nconst handleApprove = async (id: string) => {\n  setActionLoading(id);  // Mark this row as loading\n  try {\n    await api.patch(`/responses/${id}/status`, { status: 'APPROVED' });\n    message.success('Response approved');\n    fetchResponses(pagination.page);\n  } catch {\n    message.error('Failed to approve response');\n  } finally {\n    setActionLoading(null);  // Clear loading state\n  }\n};\n

Pattern: Only the clicked button shows loading spinner, not all buttons in table.

"},{"location":"v2/frontend/pages/admin/responses-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/responses-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose GET /api/responses List responses (paginated, filtered) PATCH /api/responses/:id/status Update response status (approve/reject) POST /api/responses/:id/resend-verification Resend verification email DELETE /api/responses/:id Delete response GET /api/campaigns List campaigns for filter dropdown"},{"location":"v2/frontend/pages/admin/responses-page/#list-responses","title":"List Responses","text":"

Request:

const { data } = await api.get<ResponsesListResponse>('/responses', {\n  params: {\n    page: 1,\n    limit: 20,\n    status: 'PENDING',           // Optional: filter by status\n    campaignId: 'cm-123',        // Optional: filter by campaign\n    search: 'climate change',    // Optional: search response text\n  },\n});\n

Response:

{\n  \"responses\": [\n    {\n      \"id\": \"resp-123\",\n      \"representativeName\": \"Hon. Jane Smith\",\n      \"representativeTitle\": \"MP for Ottawa Centre\",\n      \"representativeLevel\": \"FEDERAL\",\n      \"representativeEmail\": \"jane.smith@parl.gc.ca\",\n      \"responseType\": \"EMAIL\",\n      \"responseText\": \"I contacted my MP about climate action and urged support for renewable energy legislation.\",\n      \"userComment\": \"Looking forward to their response!\",\n      \"status\": \"PENDING\",\n      \"isVerified\": false,\n      \"verifiedBy\": null,\n      \"verifiedAt\": null,\n      \"upvoteCount\": 3,\n      \"isAnonymous\": false,\n      \"submittedByName\": \"John Doe\",\n      \"submittedByEmail\": \"john@example.com\",\n      \"campaignId\": \"cm-456\",\n      \"campaign\": {\n        \"id\": \"cm-456\",\n        \"title\": \"Contact Your MP About Climate Action\"\n      },\n      \"createdAt\": \"2026-02-10T14:30:00.000Z\",\n      \"updatedAt\": \"2026-02-10T14:30:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 47,\n    \"totalPages\": 3\n  }\n}\n

Key fields: - representativeEmail \u2014 Email address (may be null if not available from Represent API) - isVerified \u2014 Email address verified by user clicking link - verifiedBy / verifiedAt \u2014 Verification metadata - upvoteCount \u2014 Denormalized counter (updated on upvote/unvote) - isAnonymous \u2014 If true, hide submitter name/email on public wall

"},{"location":"v2/frontend/pages/admin/responses-page/#update-response-status","title":"Update Response Status","text":"

Request (Approve):

await api.patch(`/responses/${responseId}/status`, { status: 'APPROVED' });\n

Request (Reject):

await api.patch(`/responses/${responseId}/status`, { status: 'REJECTED' });\n

Response:

{\n  \"id\": \"resp-123\",\n  \"status\": \"APPROVED\",\n  \"updatedAt\": \"2026-02-11T10:15:00.000Z\"\n}\n
"},{"location":"v2/frontend/pages/admin/responses-page/#resend-verification-email","title":"Resend Verification Email","text":"

Request:

await api.post(`/responses/${responseId}/resend-verification`);\n

Response: 204 No Content

Email sent to: submittedByEmail with verification link

Verification link format:

https://app.cmlite.org/responses/verify?token={jwt_token}\n

Email template:

Subject: Verify Your Response Submission\n\nHi {submittedByName},\n\nPlease verify your email address by clicking the link below:\n\n{verification_link}\n\nThis ensures your response is authentic and can be displayed publicly.\n\nThank you for participating!\nChangemaker Lite\n
"},{"location":"v2/frontend/pages/admin/responses-page/#delete-response","title":"Delete Response","text":"

Request:

await api.delete(`/responses/${responseId}`);\n

Response: 204 No Content

Cascade behavior: - ResponseUpvote records deleted (Prisma cascade)

"},{"location":"v2/frontend/pages/admin/responses-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/responses-page/#conditional-action-buttons","title":"Conditional Action Buttons","text":"
<Space size=\"small\" wrap>\n  {/* Approve button: hidden if already APPROVED */}\n  {record.status !== 'APPROVED' && (\n    <Button\n      size=\"small\"\n      type=\"link\"\n      icon={<CheckCircleOutlined />}\n      style={{ color: '#16a34a' }}  // Green\n      loading={actionLoading === record.id}\n      onClick={() => handleApprove(record.id)}\n    >\n      Approve\n    </Button>\n  )}\n\n  {/* Reject button: hidden if already REJECTED */}\n  {record.status !== 'REJECTED' && (\n    <Button\n      size=\"small\"\n      type=\"link\"\n      icon={<CloseCircleOutlined />}\n      danger\n      loading={actionLoading === record.id}\n      onClick={() => handleReject(record.id)}\n    >\n      Reject\n    </Button>\n  )}\n\n  {/* Verify button: only shown if email address exists */}\n  {record.representativeEmail && (\n    <Button\n      size=\"small\"\n      type=\"link\"\n      icon={<MailOutlined />}\n      loading={actionLoading === record.id}\n      onClick={() => handleResendVerification(record.id)}\n    >\n      Verify\n    </Button>\n  )}\n\n  {/* Delete button: always shown */}\n  <Popconfirm title=\"Delete this response?\" onConfirm={() => handleDelete(record.id)}>\n    <Button\n      size=\"small\"\n      type=\"link\"\n      icon={<DeleteOutlined />}\n      danger\n      loading={actionLoading === record.id}\n    />\n  </Popconfirm>\n</Space>\n

Pattern: Conditional rendering prevents confusing UI (can't approve an already-approved response).

"},{"location":"v2/frontend/pages/admin/responses-page/#clickable-table-rows","title":"Clickable Table Rows","text":"
<Table\n  columns={columns}\n  dataSource={responses}\n  rowKey=\"id\"\n  loading={loading}\n  scroll={{ x: 900 }}  // Horizontal scroll for narrow screens\n  onRow={(record) => ({\n    onClick: () => setDetailResponse(record),  // Click row \u2192 open drawer\n    style: { cursor: 'pointer' },              // Show pointer cursor\n  })}\n  pagination={{\n    current: pagination.page,\n    pageSize: pagination.limit,\n    total: pagination.total,\n    showSizeChanger: false,\n    onChange: (page) => fetchResponses(page),\n  }}\n/>\n

Pattern: Entire row clickable (except action buttons use stopPropagation to prevent drawer opening when clicking button).

"},{"location":"v2/frontend/pages/admin/responses-page/#detail-drawer-with-conditional-verified-info","title":"Detail Drawer with Conditional Verified Info","text":"
<Drawer\n  title=\"Response Details\"\n  open={!!detailResponse}\n  onClose={() => setDetailResponse(null)}\n  width={520}\n  destroyOnClose\n>\n  {detailResponse && (\n    <Descriptions column={1} bordered size=\"small\">\n      {/* ... other fields */}\n      <Descriptions.Item label=\"Verified\">\n        {detailResponse.isVerified ? (\n          <Space>\n            <SafetyCertificateOutlined style={{ color: '#16a34a' }} />\n            {detailResponse.verifiedBy && `by ${detailResponse.verifiedBy}`}\n            {detailResponse.verifiedAt && ` on ${dayjs(detailResponse.verifiedAt).format('MMM D, YYYY')}`}\n          </Space>\n        ) : 'No'}\n      </Descriptions.Item>\n      <Descriptions.Item label=\"Response Text\">\n        <div style={{ whiteSpace: 'pre-wrap', maxHeight: 300, overflow: 'auto' }}>\n          {detailResponse.responseText}\n        </div>\n      </Descriptions.Item>\n      <Descriptions.Item label=\"Submitted By\">\n        {detailResponse.isAnonymous\n          ? 'Anonymous'\n          : `${detailResponse.submittedByName || '\u2014'} (${detailResponse.submittedByEmail || '\u2014'})`}\n      </Descriptions.Item>\n    </Descriptions>\n  )}\n</Drawer>\n

Pattern: - whiteSpace: 'pre-wrap' preserves line breaks in response text - maxHeight: 300px + overflow: 'auto' for scrolling long responses - Conditional rendering for verified metadata

"},{"location":"v2/frontend/pages/admin/responses-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/responses-page/#debounced-search-400ms","title":"Debounced Search (400ms)","text":"

Longer debounce than other pages: - Response text can be several paragraphs - Users need time to type complete search phrases - Reduces API calls during typing

"},{"location":"v2/frontend/pages/admin/responses-page/#per-row-action-loading_1","title":"Per-Row Action Loading","text":"
const [actionLoading, setActionLoading] = useState<string | null>(null);\n

Benefits: - Only one button shows spinner at a time - Other rows remain interactive - Better UX than disabling entire table

"},{"location":"v2/frontend/pages/admin/responses-page/#usecallback-optimization","title":"useCallback Optimization","text":"
const fetchResponses = useCallback(async (page = 1) => {\n  // ... fetch logic\n}, [statusFilter, campaignFilter, search]);\n

Memoized function prevents unnecessary re-fetches.

"},{"location":"v2/frontend/pages/admin/responses-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/responses-page/#mobile-576px","title":"Mobile (< 576px)","text":"
  • Filters: Stacked vertically (xs={24})
  • Search bar: Full width
  • Status filter: Full width below search
  • Campaign filter: Full width below status
  • Table: Minimal columns
  • Visible: Representative, Level, Type, Campaign, Status, Actions
  • Hidden: Verified, Upvotes, Submitted
  • Detail drawer: Full screen overlay (width: 100vw)
"},{"location":"v2/frontend/pages/admin/responses-page/#tablet-576px-992px","title":"Tablet (576px - 992px)","text":"
  • Filters: 3 columns (search: sm={8}, status: sm={5}, campaign: sm={7})
  • Table: Submitted column visible (responsive: ['sm'])
  • Detail drawer: 520px overlay (right side)
"},{"location":"v2/frontend/pages/admin/responses-page/#desktop-992px","title":"Desktop (\u2265 992px)","text":"
  • Filters: Compact layout
  • Table: All columns visible (Verified, Upvotes: responsive: ['md'])
  • Detail drawer: 520px overlay
"},{"location":"v2/frontend/pages/admin/responses-page/#accessibility","title":"Accessibility","text":"
  • Keyboard navigation: All buttons focusable via Tab
  • ARIA labels: Icon-only buttons have title attribute
  • Color coding: Status tags use color + text (not color alone)
  • Screen reader support: Descriptions component labels properly associated
  • Focus management: Drawer auto-focuses on open
"},{"location":"v2/frontend/pages/admin/responses-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/responses-page/#verify-button-not-showing","title":"Verify Button Not Showing","text":"

Problem: Response has email in table, but Verify button missing.

Diagnosis:

Check representativeEmail field:

{record.representativeEmail && (\n  <Button icon={<MailOutlined />} onClick={() => handleResendVerification(record.id)}>\n    Verify\n  </Button>\n)}\n

Common Issues:

  1. Email null in database:
  2. Represent API didn't return email for this representative
  3. Some reps don't have public emails
  4. Solution: Not fixable (representative doesn't have email)

  5. Email not fetched from API:

  6. Check API response includes representativeEmail field
  7. Solution: Ensure backend includes field in response serialization

Solution: Verify button only shown if email exists. No email = no verification possible.

"},{"location":"v2/frontend/pages/admin/responses-page/#approved-response-not-showing-on-public-wall","title":"Approved Response Not Showing on Public Wall","text":"

Problem: Approve response, but doesn't appear on /responses/:campaignId page.

Diagnosis:

Check campaign showResponseWall flag: 1. Navigate to /app/influence/campaigns 2. Edit campaign 3. Verify \"Show Response Wall\" toggle is ON

Common Issues:

  1. Response wall disabled for campaign:
  2. Campaign showResponseWall is false
  3. Solution: Edit campaign, enable Show Response Wall, save

  4. Response not verified:

  5. Some campaigns require verified responses only
  6. Solution: Click Verify button, user clicks email link

  7. Browser cache:

  8. Hard refresh public page (Ctrl+Shift+R)

Solution: Ensure campaign has response wall enabled + response is approved.

"},{"location":"v2/frontend/pages/admin/responses-page/#verification-email-not-sending","title":"Verification Email Not Sending","text":"

Problem: Click Verify button \u2192 Success message \u2192 User doesn't receive email.

Diagnosis:

Check SMTP configuration: 1. Navigate to Settings \u2192 Email tab 2. Verify active provider: Production (not MailHog) 3. Click \"Test Connection\" \u2192 Should succeed

Common Issues:

  1. MailHog active (dev mode):
  2. Check MailHog UI: http://localhost:8025
  3. Email sent to MailHog instead of real inbox
  4. Solution: Switch to Production provider in Settings

  5. SMTP credentials invalid:

  6. Test connection fails
  7. Solution: Update SMTP credentials, re-test

  8. Spam folder:

  9. Email marked as spam
  10. Solution: Check user's spam folder, whitelist sender

Solution: Verify SMTP settings, test connection, check MailHog vs Production.

"},{"location":"v2/frontend/pages/admin/responses-page/#delete-button-deletes-immediately","title":"Delete Button Deletes Immediately","text":"

Problem: Click Delete icon \u2192 Response deletes without confirmation.

Diagnosis:

Check Popconfirm placement:

<Popconfirm title=\"Delete this response?\" onConfirm={() => handleDelete(record.id)}>\n  <Button icon={<DeleteOutlined />} />\n</Popconfirm>\n

Solution: Popconfirm should wrap Button. If missing, delete happens immediately (bad UX).

"},{"location":"v2/frontend/pages/admin/responses-page/#campaign-filter-dropdown-empty","title":"Campaign Filter Dropdown Empty","text":"

Problem: Click Campaign filter \u2192 No options in dropdown.

Diagnosis:

Check campaigns API endpoint:

curl http://localhost:4000/api/campaigns?limit=100\n

Common Issues:

  1. No campaigns created:
  2. Navigate to /app/influence/campaigns
  3. Create at least one campaign
  4. Return to responses page

  5. Campaigns API failing:

  6. Check API logs: docker compose logs api | grep \"campaigns\"
  7. Verify database connection

Solution: Create at least one campaign before filtering responses.

"},{"location":"v2/frontend/pages/admin/responses-page/#related-documentation","title":"Related Documentation","text":"
  • Responses Module (Backend) \u2014 API implementation, schemas, verification flow
  • Campaigns Module \u2014 Campaign showResponseWall flag
  • Response Wall (Public) \u2014 Public response submission + display
  • Email Service \u2014 SMTP email sending, verification emails
  • Responses API Reference \u2014 Complete endpoint documentation
  • Influence Feature Guide \u2014 End-to-end response workflow
  • User Guide: Response Moderation \u2014 Moderation best practices
  • Troubleshooting: Response Issues \u2014 Response debugging
"},{"location":"v2/frontend/pages/admin/settings-page/","title":"SettingsPage","text":""},{"location":"v2/frontend/pages/admin/settings-page/#overview","title":"Overview","text":"

The SettingsPage provides a centralized interface for configuring all system-wide settings including organization branding, theme colors, email (SMTP), and feature toggles. It uses a tabbed interface with separate sections for each settings category.

Route: /app/settings Component: admin/src/pages/SettingsPage.tsx (420 lines) Auth Required: Yes (SUPER_ADMIN role recommended for production) Layout: AppLayout

"},{"location":"v2/frontend/pages/admin/settings-page/#screenshot","title":"Screenshot","text":"

[Screenshot: Settings page with 4 tabs (Organization, Theme Colors, Email, Feature Toggles). Currently showing Email tab with sections for Sender configuration, Active SMTP Provider toggle (MailHog vs Production), connection details, and test buttons. At bottom is a large \"Save Settings\" button.]

"},{"location":"v2/frontend/pages/admin/settings-page/#features","title":"Features","text":"
  • Tabbed interface \u2014 4 organized sections:
  • Organization (branding, logo, footer)
  • Theme Colors (admin + public themes with live preview)
  • Email (SMTP configuration with dual providers)
  • Feature Toggles (enable/disable modules)
  • SMTP provider switching \u2014 Toggle between MailHog (dev) and Production
  • Live theme preview \u2014 Color swatches + gradient preview
  • SMTP testing \u2014 Test connection + send test email
  • Form persistence \u2014 Settings loaded from Zustand store
  • Optimistic updates \u2014 Immediate UI feedback on save
  • ColorPicker integration \u2014 Visual color selection with hex output
  • Segmented control \u2014 Large toggle for SMTP provider switching
"},{"location":"v2/frontend/pages/admin/settings-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/settings-page/#updating-organization-settings","title":"Updating Organization Settings","text":"
  1. Navigate to /app/settings
  2. Verify \"Organization\" tab is selected (default)
  3. Modify fields:
  4. Organization Name
  5. Short Name (max 10 chars, shown in collapsed sidebar)
  6. Logo URL
  7. Favicon URL
  8. Footer Text
  9. Login Subtitle
  10. Click \"Save Settings\" button at bottom
  11. Success message: \"Settings saved successfully\"
  12. Changes apply immediately (refresh not required)
"},{"location":"v2/frontend/pages/admin/settings-page/#customizing-theme-colors","title":"Customizing Theme Colors","text":"
  1. Click \"Theme Colors\" tab
  2. Modify Admin Theme colors:
  3. Primary Color (ColorPicker)
  4. Background Color (ColorPicker)
  5. Modify Public Theme colors:
  6. Primary Color
  7. Background Color
  8. Container Color
  9. Header Gradient (CSS gradient string)
  10. View live preview swatches below form
  11. Click \"Save Settings\"
  12. Theme updates apply on next page load
"},{"location":"v2/frontend/pages/admin/settings-page/#configuring-smtp-email","title":"Configuring SMTP Email","text":"
  1. Click \"Email\" tab
  2. Set Sender info:
  3. From Name (e.g., \"Changemaker Lite\")
  4. From Address (e.g., \"noreply@cmlite.org\")
  5. Switch SMTP Provider:
  6. Click MailHog or Production segment
  7. Confirmation: \"Switched to [provider] SMTP\"
  8. Configure Production SMTP:
  9. SMTP Host (e.g., smtp.protonmail.ch)
  10. SMTP Port (587 for STARTTLS, 465 for SSL)
  11. SMTP User
  12. SMTP Password
  13. Enable Test Mode (optional):
  14. Toggle \"Enable Test Mode\" switch
  15. Set Test Recipient email
  16. All emails redirect to test recipient
  17. Click \"Save Settings\"
  18. Test configuration:
  19. Click \"Test Connection\" \u2192 Verify \"Connection successful\"
  20. Click \"Send Test Email\" \u2192 Check inbox for test message
"},{"location":"v2/frontend/pages/admin/settings-page/#testing-smtp-configuration","title":"Testing SMTP Configuration","text":"
  1. Navigate to Email tab
  2. Ensure production credentials are saved
  3. Switch to \"Production\" provider
  4. Click \"Test Connection\" button
  5. Wait for result (success/error alert)
  6. If successful, click \"Send Test Email\"
  7. Check email inbox for test message
  8. If failed, review error message and fix credentials
"},{"location":"v2/frontend/pages/admin/settings-page/#enablingdisabling-features","title":"Enabling/Disabling Features","text":"
  1. Click \"Feature Toggles\" tab
  2. Toggle switches:
  3. Enable Influence (campaigns, responses, reps)
  4. Enable Map (locations, cuts, shifts, canvassing)
  5. Enable Newsletter (Listmonk integration)
  6. Enable Landing Pages (page builder)
  7. Info alert: \"Disabling a module hides it from navigation but does not delete data\"
  8. Click \"Save Settings\"
  9. Navigation menu updates to hide/show disabled modules
"},{"location":"v2/frontend/pages/admin/settings-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/settings-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  • Typography.Text \u2014 Labels, descriptions
  • Tabs \u2014 Main navigation (4 tabs)
  • Form \u2014 All settings wrapped in single form instance
  • Form.Item \u2014 Individual fields with labels + extra descriptions
  • Input \u2014 Text fields (org name, logo URL, SMTP host, etc.)
  • Input.Password \u2014 SMTP password field (masked)
  • InputNumber \u2014 SMTP port (numeric, min 0, max 65535)
  • Switch \u2014 Boolean toggles (test mode, feature flags)
  • ColorPicker \u2014 Color selection with hex preview
  • Segmented \u2014 SMTP provider toggle (large button style)
  • Tag \u2014 Active provider indicator (green)
  • Alert \u2014 Info messages, connection/send test results
  • Divider \u2014 Section separators
  • Space \u2014 Button grouping
  • Button \u2014 Test actions + save button
  • Spin \u2014 Loading indicator during initial settings fetch
"},{"location":"v2/frontend/pages/admin/settings-page/#tab-structure","title":"Tab Structure","text":"
const items = [\n  {\n    key: 'organization',\n    label: 'Organization',\n    icon: <SettingOutlined />,\n    children: (/* Organization form fields */)\n  },\n  {\n    key: 'theme',\n    label: 'Theme Colors',\n    children: (/* Theme form fields */)\n  },\n  {\n    key: 'email',\n    label: 'Email',\n    children: (/* Email form fields */)\n  },\n  {\n    key: 'features',\n    label: 'Feature Toggles',\n    children: (/* Feature toggle switches */)\n  },\n];\n\nreturn (\n  <Form form={form} layout=\"vertical\">\n    <Tabs items={items} />\n    <Button type=\"primary\" icon={<SaveOutlined />} onClick={handleSave}>\n      Save Settings\n    </Button>\n  </Form>\n);\n
"},{"location":"v2/frontend/pages/admin/settings-page/#color-swatch-preview","title":"Color Swatch Preview","text":"
function Swatch({ label, color }: { label: string; color: string }) {\n  return (\n    <div style={{ textAlign: 'center' }}>\n      <div\n        style={{\n          width: 48,\n          height: 48,\n          borderRadius: 8,\n          background: color,\n          border: '2px solid rgba(255,255,255,0.2)',\n          marginBottom: 4,\n        }}\n      />\n      <Text style={{ fontSize: 11 }}>{label}</Text>\n    </div>\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/settings-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/settings-page/#zustand-store-used","title":"Zustand Store Used","text":"
  • settings.store \u2014 Centralized settings state
  • settings \u2014 Current settings object
  • loading \u2014 Loading state
  • fetchAdminSettings() \u2014 Load settings from API
  • updateSettings(partial) \u2014 Update and persist settings
import { useSettingsStore } from '@/stores/settings.store';\n\nconst { settings, loading, fetchAdminSettings, updateSettings } = useSettingsStore();\n\nuseEffect(() => {\n  fetchAdminSettings();\n}, [fetchAdminSettings]);\n
"},{"location":"v2/frontend/pages/admin/settings-page/#local-state","title":"Local State","text":"
const [form] = Form.useForm();\nconst [testingConnection, setTestingConnection] = useState(false);\nconst [connectionResult, setConnectionResult] = useState<SmtpTestResult | null>(null);\nconst [sendingTest, setSendingTest] = useState(false);\nconst [sendResult, setSendResult] = useState<SmtpSendTestResult | null>(null);\n
"},{"location":"v2/frontend/pages/admin/settings-page/#form-initialization","title":"Form Initialization","text":"
useEffect(() => {\n  if (settings) {\n    form.setFieldsValue(settings);\n  }\n}, [settings, form]);\n

When settings load from store, form automatically populates with current values.

"},{"location":"v2/frontend/pages/admin/settings-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/settings-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose GET /api/settings Load settings (via store) PUT /api/settings Update settings POST /api/settings/email/test-connection Test SMTP connection POST /api/settings/email/test-send Send test email"},{"location":"v2/frontend/pages/admin/settings-page/#save-settings","title":"Save Settings","text":"
const handleSave = async () => {\n  try {\n    const values = form.getFieldsValue();\n\n    // Convert ColorPicker values to hex strings\n    const colorFields = [\n      'adminColorPrimary',\n      'adminColorBgBase',\n      'publicColorPrimary',\n      'publicColorBgBase',\n      'publicColorBgContainer',\n    ] as const;\n\n    for (const field of colorFields) {\n      const val = values[field];\n      if (val && typeof val === 'object' && 'toHexString' in val) {\n        values[field] = val.toHexString();\n      }\n    }\n\n    await updateSettings(values);\n    setConnectionResult(null);\n    setSendResult(null);\n    message.success('Settings saved successfully');\n  } catch {\n    message.error('Failed to save settings');\n  }\n};\n

Request Payload:

{\n  \"organizationName\": \"Changemaker Lite\",\n  \"organizationShortName\": \"CML\",\n  \"organizationLogoUrl\": \"https://example.com/logo.png\",\n  \"smtpHost\": \"smtp.protonmail.ch\",\n  \"smtpPort\": 587,\n  \"smtpUser\": \"user@example.com\",\n  \"smtpPass\": \"***\",\n  \"smtpActiveProvider\": \"production\",\n  \"adminColorPrimary\": \"#1890ff\",\n  \"publicColorPrimary\": \"#3498db\",\n  \"enableInfluence\": true,\n  \"enableMap\": true\n}\n
"},{"location":"v2/frontend/pages/admin/settings-page/#test-smtp-connection","title":"Test SMTP Connection","text":"
const handleTestConnection = async () => {\n  setTestingConnection(true);\n  setConnectionResult(null);\n  try {\n    const { data } = await api.post<SmtpTestResult>('/settings/email/test-connection');\n    setConnectionResult(data);\n  } catch {\n    setConnectionResult({ success: false, message: 'Request failed' });\n  } finally {\n    setTestingConnection(false);\n  }\n};\n

Response (Success):

{\n  \"success\": true,\n  \"message\": \"Connection successful\"\n}\n

Response (Failure):

{\n  \"success\": false,\n  \"message\": \"Connection failed: Authentication failed\"\n}\n
"},{"location":"v2/frontend/pages/admin/settings-page/#send-test-email","title":"Send Test Email","text":"
const handleSendTest = async () => {\n  setSendingTest(true);\n  setSendResult(null);\n  try {\n    const to = form.getFieldValue('testEmailRecipient');\n    const { data } = await api.post<SmtpSendTestResult>('/settings/email/test-send', { to });\n    setSendResult(data);\n  } catch {\n    setSendResult({ success: false, testMode: false, recipient: '' });\n  } finally {\n    setSendingTest(false);\n  }\n};\n

Request:

{\n  \"to\": \"admin@example.com\"\n}\n

Response (Success):

{\n  \"success\": true,\n  \"testMode\": false,\n  \"recipient\": \"admin@example.com\",\n  \"messageId\": \"<abc123@smtp.protonmail.ch>\"\n}\n
"},{"location":"v2/frontend/pages/admin/settings-page/#toggle-smtp-provider","title":"Toggle SMTP Provider","text":"
const handleProviderToggle = async (value: string | number) => {\n  const provider = value as 'mailhog' | 'production';\n  try {\n    await updateSettings({ smtpActiveProvider: provider });\n    form.setFieldsValue({ smtpActiveProvider: provider });\n    message.success(`Switched to ${provider === 'mailhog' ? 'MailHog' : 'Production'} SMTP`);\n    setConnectionResult(null);\n    setSendResult(null);\n  } catch {\n    message.error('Failed to switch SMTP provider');\n  }\n};\n

Why clear test results?

Test results are provider-specific. Switching providers invalidates previous test results.

"},{"location":"v2/frontend/pages/admin/settings-page/#colorpicker-integration","title":"ColorPicker Integration","text":""},{"location":"v2/frontend/pages/admin/settings-page/#converting-color-values","title":"Converting Color Values","text":"

Ant Design ColorPicker returns an object with toHexString() method:

// ColorPicker value\nconst colorValue = {\n  toHexString: () => '#1890ff',\n  // ... other methods\n};\n\n// Convert before saving\nfor (const field of colorFields) {\n  const val = values[field];\n  if (val && typeof val === 'object' && 'toHexString' in val) {\n    values[field] = val.toHexString();\n  }\n}\n
"},{"location":"v2/frontend/pages/admin/settings-page/#theme-preview","title":"Theme Preview","text":"
{settings && (\n  <div style={{ marginTop: 24 }}>\n    <Text strong style={{ fontSize: 15 }}>Preview</Text>\n    <Divider style={{ margin: '12px 0' }} />\n    <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>\n      <Swatch label=\"Admin Primary\" color={settings.adminColorPrimary} />\n      <Swatch label=\"Admin BG\" color={settings.adminColorBgBase} />\n      <Swatch label=\"Public Primary\" color={settings.publicColorPrimary} />\n      <Swatch label=\"Public BG\" color={settings.publicColorBgBase} />\n      <Swatch label=\"Public Container\" color={settings.publicColorBgContainer} />\n    </div>\n    <div\n      style={{\n        marginTop: 12,\n        padding: '12px 24px',\n        background: settings.publicHeaderGradient,\n        borderRadius: 8,\n        color: '#fff',\n        fontWeight: 600,\n      }}\n    >\n      Header Gradient Preview\n    </div>\n  </div>\n)}\n
"},{"location":"v2/frontend/pages/admin/settings-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/settings-page/#single-form-instance","title":"Single Form Instance","text":"

All settings use one form instance:

const [form] = Form.useForm();\n\n<Form form={form} layout=\"vertical\">\n  <Tabs items={items} />\n  <Button onClick={handleSave}>Save Settings</Button>\n</Form>\n

Benefits:

  • Single save operation \u2014 One API call saves all modified fields
  • Consistent validation \u2014 All fields validated together
  • Simplified state \u2014 No need to track which tab has changes
"},{"location":"v2/frontend/pages/admin/settings-page/#optimistic-provider-switching","title":"Optimistic Provider Switching","text":"

Provider toggle updates immediately without waiting for API:

await updateSettings({ smtpActiveProvider: provider });\nform.setFieldsValue({ smtpActiveProvider: provider });  // Update form immediately\nmessage.success(`Switched to ${provider}`);\n

Why optimistic?

  • Instant feedback \u2014 User sees immediate response
  • Better UX \u2014 No loading delay for simple toggle
  • Safe operation \u2014 Provider toggle is low-risk (can always switch back)
"},{"location":"v2/frontend/pages/admin/settings-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/settings-page/#smtp-test-connection-failing","title":"SMTP Test Connection Failing","text":"

Problem: Click \"Test Connection\" \u2192 Error: \"Connection failed: Authentication failed\"

Diagnosis:

Check SMTP credentials:

{\n  smtpHost: \"smtp.protonmail.ch\",\n  smtpPort: 587,\n  smtpUser: \"user@protonmail.com\",\n  smtpPass: \"***\"\n}\n

Common Issues:

  1. Wrong port:
  2. Use 587 for STARTTLS
  3. Use 465 for SSL/TLS
  4. Port 25 often blocked by ISPs

  5. App-specific password required:

  6. Gmail requires app-specific passwords (not account password)
  7. ProtonMail requires ProtonMail Bridge for SMTP

  8. Wrong provider selected:

  9. Ensure \"Production\" is selected before testing production credentials

Solution:

  1. Verify credentials with email provider documentation
  2. Switch to \"Production\" provider
  3. Save settings before testing
  4. Check firewall rules (port 587/465 outbound)
"},{"location":"v2/frontend/pages/admin/settings-page/#theme-colors-not-applying","title":"Theme Colors Not Applying","text":"

Problem: Change colors, save settings, but theme doesn't update.

Diagnosis:

Check if page reload is required:

// Theme updates apply on NEXT page load, not immediately\nawait updateSettings({ adminColorPrimary: '#ff0000' });\n// Current page still shows old color\n

Solution:

Refresh page after saving theme changes:

const handleSave = async () => {\n  await updateSettings(values);\n  message.success('Settings saved. Refreshing page...');\n  setTimeout(() => window.location.reload(), 1000);\n};\n
"},{"location":"v2/frontend/pages/admin/settings-page/#feature-toggle-not-hiding-module","title":"Feature Toggle Not Hiding Module","text":"

Problem: Disable \"Enable Influence\" toggle, save, but Influence menu items still visible.

Diagnosis:

Check AppLayout navigation logic:

// AppLayout should check settings.enableInfluence\n{settings.enableInfluence && (\n  <SubMenu key=\"influence\" title=\"Influence\">\n    {/* Influence menu items */}\n  </SubMenu>\n)}\n

Solution:

Ensure AppLayout reads settings from store and conditionally renders menu items.

"},{"location":"v2/frontend/pages/admin/settings-page/#test-email-not-sending","title":"Test Email Not Sending","text":"

Problem: Click \"Send Test Email\" \u2192 Success message, but no email in inbox.

Diagnosis:

  1. Check active provider:

    settings.smtpActiveProvider === 'mailhog' // MailHog (dev)\nsettings.smtpActiveProvider === 'production' // Real SMTP\n

  2. Check test mode:

    settings.emailTestMode === true // All emails redirect to testEmailRecipient\n

  3. Check spam folder

  4. Check MailHog web UI (http://localhost:8025) if MailHog is active

Solution:

  • Switch to \"Production\" provider
  • Disable test mode if you want emails to go to actual recipients
  • Save settings before sending test email
"},{"location":"v2/frontend/pages/admin/settings-page/#related-documentation","title":"Related Documentation","text":"
  • Settings Module \u2014 Backend API reference
  • Email Service \u2014 Email sending service
  • Settings Store \u2014 Settings state management
  • AppLayout Component \u2014 Feature toggle integration
  • User Guide: Site Configuration \u2014 Configuration guide
  • Troubleshooting: Email Issues \u2014 Email debugging
"},{"location":"v2/frontend/pages/admin/shifts-page/","title":"ShiftsPage","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#overview","title":"Overview","text":"

The ShiftsPage provides complete volunteer shift management for the Map module, enabling administrators to schedule canvassing shifts, manage volunteer signups, and coordinate field operations. Features include date/time scheduling, volunteer capacity tracking, public shift publishing, area (cut) assignment, signup management, and bulk email notifications to confirmed volunteers.

Route: /app/map/shifts Component: admin/src/pages/ShiftsPage.tsx (757 lines) Auth Required: Yes (SUPER_ADMIN, MAP_ADMIN roles) Layout: AppLayout

"},{"location":"v2/frontend/pages/admin/shifts-page/#screenshot","title":"Screenshot","text":"

[Screenshot: Shifts page with 6 statistics cards at top (Total, Open, Full, Cancelled, Upcoming, Signups) showing counts with colored icons. Below stats are search bar + status filter dropdown. Main table shows columns: Title, Date, Time (start \u2014 end), Location, Area (cut name), Volunteers (progress bar showing X/Y), Status (colored tags), Public (checkmark icon if true), Actions (edit + delete). Rows clickable to open signups drawer. Page header has \"Create Shift\" button.]

"},{"location":"v2/frontend/pages/admin/shifts-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#core-features","title":"Core Features","text":"
  • Full CRUD operations \u2014 Create, read, update, delete shifts
  • Advanced search \u2014 300ms debounced search by title or location
  • Status filtering \u2014 Filter by OPEN, FULL, CANCELLED, COMPLETED
  • Statistics dashboard \u2014 6 cards showing total, open, full, cancelled, upcoming, total signups
  • Date/time scheduling \u2014 Date picker + time pickers with 5-minute intervals
  • Volunteer capacity \u2014 Max volunteers setting with progress bar visualization
  • Area assignment \u2014 Assign shift to a canvass cut (area)
  • Public publishing \u2014 Toggle to show shift on public /shifts page
  • Clickable rows \u2014 Click any row to open signups drawer
  • Responsive table \u2014 Columns hide on smaller screens (Time: md+, Location: lg+, Area: md+)
"},{"location":"v2/frontend/pages/admin/shifts-page/#signup-management","title":"Signup Management","text":"
  • Signups drawer \u2014 Click shift row to view all confirmed volunteers
  • Manual signup \u2014 Add volunteer by email + name (creates temp user if needed)
  • Signup source tracking \u2014 PUBLIC (self-signup), ADMIN (added by admin)
  • Remove volunteers \u2014 Delete button for each confirmed signup
  • Email all volunteers \u2014 Bulk email button in drawer header
  • Signup stats \u2014 Progress bar in table shows current/max volunteers
  • Auto-status management \u2014 Shift status auto-updates to FULL when capacity reached
"},{"location":"v2/frontend/pages/admin/shifts-page/#shift-status-workflow","title":"Shift Status Workflow","text":"
  1. OPEN \u2014 Shift created, accepting signups
  2. FULL \u2014 Max volunteers reached (currentVolunteers >= maxVolunteers)
  3. CANCELLED \u2014 Shift cancelled by admin
  4. COMPLETED \u2014 Shift date passed (auto-marked by backend)
"},{"location":"v2/frontend/pages/admin/shifts-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#viewing-shifts-list","title":"Viewing Shifts List","text":"
  1. Navigate to /app/map/shifts
  2. Page loads with statistics cards at top:
  3. Total: All shifts count
  4. Open: Shifts accepting signups (green)
  5. Full: Shifts at capacity (orange)
  6. Cancelled: Admin-cancelled shifts (red)
  7. Upcoming: Future shifts (blue)
  8. Signups: Total confirmed volunteers across all shifts
  9. Table shows first 20 shifts (paginated)
  10. View shift details:
  11. Title (bold)
  12. Date (YYYY-MM-DD)
  13. Time (HH:mm \u2014 HH:mm format)
  14. Location (e.g., \"Campaign HQ, 123 Main St\")
  15. Area (cut name if assigned)
  16. Volunteers (progress bar X/Y)
  17. Status tag (color-coded)
  18. Public checkmark icon (if published)
  19. Actions (edit, delete)
  20. Click any row to open signups drawer
"},{"location":"v2/frontend/pages/admin/shifts-page/#creating-a-shift","title":"Creating a Shift","text":"
  1. Click \"Create Shift\" button in page header
  2. Modal opens (560px width) with vertical form
  3. Fill required fields:
  4. Title \u2014 Shift name (e.g., \"Door Knocking\", \"Phone Banking\")
  5. Date \u2014 Date picker (calendar popup)
  6. Start Time \u2014 Time picker (HH:mm, 5-minute intervals)
  7. End Time \u2014 Time picker (HH:mm, 5-minute intervals)
  8. Max Volunteers \u2014 Number input (min: 1)
  9. Fill optional fields:
  10. Description \u2014 Multi-line text (shift details + instructions)
  11. Location \u2014 Text (e.g., \"Campaign HQ, 123 Main St\")
  12. Area (Cut) \u2014 Dropdown (select canvass area, searchable)
  13. Public \u2014 Switch toggle (default: false)
  14. Click \"Create\" button
  15. Success message: \"Shift created\"
  16. Modal closes, table refreshes to page 1, stats refresh
  17. New shift appears with status OPEN
"},{"location":"v2/frontend/pages/admin/shifts-page/#editing-a-shift","title":"Editing a Shift","text":"
  1. Locate shift in table
  2. Click Edit icon button (EditOutlined) in Actions column
  3. Drawer opens on right side (520px width) with vertical form
  4. Modify any fields (same as create, plus Status dropdown):
  5. Status options: OPEN, FULL, CANCELLED, COMPLETED
  6. Click \"Save\" button in drawer header
  7. Success message: \"Shift updated\"
  8. Drawer closes, table refreshes, stats refresh
"},{"location":"v2/frontend/pages/admin/shifts-page/#publishing-a-shift-to-public-page","title":"Publishing a Shift to Public Page","text":"
  1. Open shift in edit drawer
  2. Toggle \"Public\" switch to ON
  3. Click \"Save\"
  4. Shift now visible on public /shifts page
  5. Users can self-signup via public page
  6. Signups source tracked as PUBLIC
"},{"location":"v2/frontend/pages/admin/shifts-page/#assigning-a-cut-area-to-shift","title":"Assigning a Cut (Area) to Shift","text":"
  1. Open shift in edit drawer
  2. Click \"Area (Cut)\" dropdown
  3. Search for cut by name
  4. Select cut from list
  5. Click \"Save\"
  6. Volunteer portal integration:
  7. Volunteers assigned to this shift now see it in /volunteer/assignments page
  8. Shift with cut enables volunteer canvassing workflow
  9. No cut = general shift (no canvass area)
"},{"location":"v2/frontend/pages/admin/shifts-page/#viewing-shift-signups","title":"Viewing Shift Signups","text":"
  1. Click any shift row in table
  2. Signups drawer opens on right side (640px width)
  3. Drawer header shows:
  4. TeamOutlined icon + \"Signups \u2014 {Shift Title}\"
  5. \"Email All\" button in header (disabled if no confirmed volunteers)
  6. Info card at top displays shift summary:
  7. Date
  8. Time (start \u2014 end)
  9. Volunteers (current / max)
  10. Table shows confirmed volunteers:
  11. Columns: Email, Name, Phone, Source (PUBLIC/ADMIN tag), Date, Remove button
  12. Pagination: 20 per page (if > 20 signups)
  13. Cancelled signups hidden (filtered out)
  14. Add volunteer section at bottom:
  15. Email input (required)
  16. Name input (optional)
  17. \"Add\" button (disabled if email empty)
"},{"location":"v2/frontend/pages/admin/shifts-page/#manually-adding-a-volunteer","title":"Manually Adding a Volunteer","text":"
  1. Open signups drawer for any shift
  2. Scroll to bottom \"Add volunteer\" section
  3. Enter email (required)
  4. Enter name (optional)
  5. Click \"Add\" button
  6. Backend logic:
  7. If user exists: Create ShiftSignup record
  8. If user doesn't exist: Create temp User + ShiftSignup
  9. Signup source: ADMIN
  10. Signup status: CONFIRMED
  11. Success message: \"Volunteer added\"
  12. Table refreshes with new volunteer
  13. Email and name inputs clear
  14. Main shifts table progress bar updates

Temp user creation: - Role: TEMP - Email: provided email - Password: Readable format (e.g., \"BlueEagle42\") - Expires: shift date + 1 day - Used for public signups without account

"},{"location":"v2/frontend/pages/admin/shifts-page/#removing-a-volunteer","title":"Removing a Volunteer","text":"
  1. Open signups drawer
  2. Locate volunteer in table
  3. Click Delete icon button (red, last column)
  4. Popconfirm: \"Remove this volunteer?\"
  5. Click \"OK\"
  6. Success message: \"Volunteer removed\"
  7. Table refreshes (volunteer row disappears)
  8. Main shifts table progress bar updates
  9. Shift status may change from FULL to OPEN if capacity now available
"},{"location":"v2/frontend/pages/admin/shifts-page/#emailing-all-volunteers","title":"Emailing All Volunteers","text":"
  1. Open signups drawer with confirmed volunteers
  2. Click \"Email All\" button in drawer header
  3. Backend sends email to all confirmed volunteers:
  4. Email template: Shift details (title, date, time, location, description)
  5. Subject: \"Shift Reminder: {Shift Title}\"
  6. From: Site settings sender (e.g., \"Changemaker Lite noreply@cmlite.org\")
  7. Success message: \"Emailed N volunteer(s)\" (or \"N sent, M failed\" if failures)
  8. Email uses SMTP settings from Settings page
"},{"location":"v2/frontend/pages/admin/shifts-page/#searching-and-filtering","title":"Searching and Filtering","text":"
  1. Search bar (top left):
  2. Type title or location keywords
  3. 300ms debounce (waits for typing pause)
  4. Search resets pagination to page 1
  5. Status filter dropdown (top right):
  6. Select OPEN, FULL, CANCELLED, or COMPLETED
  7. Filter resets pagination to page 1
  8. Clear to show all shifts
  9. Filters persist during pagination
"},{"location":"v2/frontend/pages/admin/shifts-page/#deleting-a-shift","title":"Deleting a Shift","text":"
  1. Locate shift in table
  2. Click Delete icon button (DeleteOutlined) in Actions column
  3. Popconfirm: \"Delete this shift?\"
  4. Click \"OK\" to confirm
  5. Success message: \"Shift deleted\"
  6. Table refreshes, stats refresh
  7. Cascade behavior: All ShiftSignup records also deleted (Prisma cascade)
"},{"location":"v2/frontend/pages/admin/shifts-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  • Table \u2014 Main shifts list + signups table in drawer
  • Button \u2014 Create (primary), edit, delete, add volunteer, email all
  • Input \u2014 Search bar, email input, name input (signups drawer)
  • Select \u2014 Status filter dropdown, area (cut) dropdown
  • Tag \u2014 Status tags (color-coded), signup source tags
  • Space \u2014 Action button grouping, drawer header
  • Card \u2014 Statistics cards (6 cards), shift summary card in signups drawer
  • Statistic \u2014 Numeric displays with icons + prefixes
  • Progress \u2014 Volunteer capacity progress bar (in Volunteers column)
  • Modal \u2014 Create shift form
  • Drawer \u2014 Edit shift (520px), signups drawer (640px)
  • Form \u2014 Create/edit shift forms
  • Form.Item \u2014 Field wrappers with labels + rules
  • Input.TextArea \u2014 Description field (multi-line)
  • DatePicker \u2014 Date selection with calendar popup
  • TimePicker \u2014 Time selection with hour/minute dropdowns (5-minute steps)
  • InputNumber \u2014 Max volunteers numeric input (min: 1)
  • Switch \u2014 Public toggle (valuePropName=\"checked\")
  • Row, Col \u2014 Responsive grid for stats cards, date/time fields
  • Popconfirm \u2014 Delete confirmation (shift + volunteer removal)
  • Typography.Text \u2014 Labels, descriptions
"},{"location":"v2/frontend/pages/admin/shifts-page/#table-columns-main-shifts-table","title":"Table Columns (Main Shifts Table)","text":"
const columns: ColumnsType<Shift> = [\n  {\n    title: 'Title',\n    dataIndex: 'title',\n    render: (title) => <span style={{ fontWeight: 500 }}>{title}</span>,\n  },\n  {\n    title: 'Date',\n    dataIndex: 'date',\n    render: (date) => dayjs(date).format('YYYY-MM-DD'),\n  },\n  {\n    title: 'Time',\n    render: (_, record) => `${record.startTime} \u2014 ${record.endTime}`,\n    responsive: ['md'],\n  },\n  {\n    title: 'Location',\n    dataIndex: 'location',\n    render: (loc) => loc || '--',\n    responsive: ['lg'],\n  },\n  {\n    title: 'Area',\n    render: (_, record) => record.cut?.name || '--',\n    responsive: ['md'],\n  },\n  {\n    title: 'Volunteers',\n    width: 140,\n    render: (_, record) => {\n      const confirmed = record._count?.signups ?? record.currentVolunteers;\n      const pct = record.maxVolunteers > 0 ? Math.round((confirmed / record.maxVolunteers) * 100) : 0;\n      return (\n        <Progress\n          percent={pct}\n          size=\"small\"\n          status={pct >= 100 ? 'exception' : 'active'}\n          format={() => `${confirmed}/${record.maxVolunteers}`}\n        />\n      );\n    },\n  },\n  {\n    title: 'Status',\n    dataIndex: 'status',\n    width: 100,\n    render: (status) => <Tag color={SHIFT_STATUS_COLORS[status]}>{SHIFT_STATUS_LABELS[status]}</Tag>,\n  },\n  {\n    title: 'Public',\n    dataIndex: 'isPublic',\n    width: 70,\n    render: (isPublic) => isPublic ? <CheckCircleOutlined style={{ color: '#52c41a' }} /> : null,\n  },\n  {\n    title: 'Actions',\n    width: 120,\n    render: (_, record) => (\n      <Space>\n        <Button type=\"link\" size=\"small\" icon={<EditOutlined />} onClick={() => openEdit(record)} />\n        <Popconfirm title=\"Delete this shift?\" onConfirm={() => handleDelete(record.id)}>\n          <Button type=\"link\" size=\"small\" danger icon={<DeleteOutlined />} />\n        </Popconfirm>\n      </Space>\n    ),\n  },\n];\n

Key patterns: - _count.signups aggregation from Prisma (confirmed volunteers count) - responsive array hides columns on smaller screens - Progress bar shows visual capacity indicator (turns red when full) - onRow prop makes entire row clickable to open signups drawer

"},{"location":"v2/frontend/pages/admin/shifts-page/#signups-table-columns","title":"Signups Table Columns","text":"
const signupColumns: ColumnsType<ShiftSignup> = [\n  {\n    title: 'Email',\n    dataIndex: 'userEmail',\n  },\n  {\n    title: 'Name',\n    dataIndex: 'userName',\n    render: (name) => name || '--',\n  },\n  {\n    title: 'Phone',\n    render: (_, record) => record.userPhone || record.user?.phone || '--',\n    responsive: ['md'],\n  },\n  {\n    title: 'Source',\n    dataIndex: 'signupSource',\n    width: 100,\n    render: (source) => <Tag color={SIGNUP_SOURCE_COLORS[source]}>{source}</Tag>,\n  },\n  {\n    title: 'Date',\n    dataIndex: 'signupDate',\n    render: (date) => dayjs(date).format('YYYY-MM-DD'),\n    responsive: ['lg'],\n  },\n  {\n    title: '',\n    width: 60,\n    render: (_, record) =>\n      record.status === 'CONFIRMED' ? (\n        <Popconfirm title=\"Remove this volunteer?\" onConfirm={() => handleRemoveSignup(record.id)}>\n          <Button type=\"link\" size=\"small\" danger icon={<DeleteOutlined />} />\n        </Popconfirm>\n      ) : (\n        <Tag color=\"red\">Cancelled</Tag>\n      ),\n  },\n];\n

Filter: Table only shows CONFIRMED signups:

<Table dataSource={signups.filter((s) => s.status === 'CONFIRMED')} />\n

"},{"location":"v2/frontend/pages/admin/shifts-page/#status-colors","title":"Status Colors","text":"
export const SHIFT_STATUS_COLORS: Record<ShiftStatus, string> = {\n  OPEN: 'green',\n  FULL: 'orange',\n  CANCELLED: 'red',\n  COMPLETED: 'default',\n};\n\nexport const SHIFT_STATUS_LABELS: Record<ShiftStatus, string> = {\n  OPEN: 'Open',\n  FULL: 'Full',\n  CANCELLED: 'Cancelled',\n  COMPLETED: 'Completed',\n};\n
"},{"location":"v2/frontend/pages/admin/shifts-page/#signup-source-colors","title":"Signup Source Colors","text":"
export const SIGNUP_SOURCE_COLORS = {\n  PUBLIC: 'blue',    // User signed up via public /shifts page\n  ADMIN: 'purple',   // Admin added manually\n};\n
"},{"location":"v2/frontend/pages/admin/shifts-page/#form-fields","title":"Form Fields","text":"
const shiftFormFields = (isEdit = false) => (\n  <>\n    <Form.Item name=\"title\" label=\"Title\" rules={[{ required: true }]}>\n      <Input placeholder=\"e.g. Door Knocking, Phone Banking\" />\n    </Form.Item>\n    <Form.Item name=\"description\" label=\"Description\">\n      <Input.TextArea rows={3} placeholder=\"Shift details and instructions\" />\n    </Form.Item>\n    <Row gutter={12}>\n      <Col xs={24} sm={8}>\n        <Form.Item name=\"date\" label=\"Date\" rules={[{ required: true }]}>\n          <DatePicker style={{ width: '100%' }} />\n        </Form.Item>\n      </Col>\n      <Col xs={12} sm={8}>\n        <Form.Item name=\"startTime\" label=\"Start Time\" rules={[{ required: true }]}>\n          <TimePicker format=\"HH:mm\" style={{ width: '100%' }} minuteStep={5} />\n        </Form.Item>\n      </Col>\n      <Col xs={12} sm={8}>\n        <Form.Item name=\"endTime\" label=\"End Time\" rules={[{ required: true }]}>\n          <TimePicker format=\"HH:mm\" style={{ width: '100%' }} minuteStep={5} />\n        </Form.Item>\n      </Col>\n    </Row>\n    <Form.Item name=\"location\" label=\"Location\">\n      <Input placeholder=\"e.g. Campaign HQ, 123 Main St\" />\n    </Form.Item>\n    <Form.Item name=\"cutId\" label=\"Area (Cut)\">\n      <Select\n        options={cutOptions}\n        placeholder=\"Assign a canvass area...\"\n        allowClear\n        showSearch\n        optionFilterProp=\"label\"\n      />\n    </Form.Item>\n    <Row gutter={12}>\n      <Col xs={12} sm={8}>\n        <Form.Item name=\"maxVolunteers\" label=\"Max Volunteers\" rules={[{ required: true }]}>\n          <InputNumber min={1} style={{ width: '100%' }} />\n        </Form.Item>\n      </Col>\n      <Col xs={12} sm={8}>\n        <Form.Item name=\"isPublic\" label=\"Public\" valuePropName=\"checked\">\n          <Switch />\n        </Form.Item>\n      </Col>\n      {isEdit && (\n        <Col xs={12} sm={8}>\n          <Form.Item name=\"status\" label=\"Status\">\n            <Select options={statusOptions} />\n          </Form.Item>\n        </Col>\n      )}\n    </Row>\n  </>\n);\n

Reusable pattern: Same form fields for create + edit, with conditional Status field in edit mode.

"},{"location":"v2/frontend/pages/admin/shifts-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#zustand-stores-used","title":"Zustand Stores Used","text":"

None \u2014 Shifts fetched from API on each interaction. No global state required.

"},{"location":"v2/frontend/pages/admin/shifts-page/#local-state","title":"Local State","text":"
const [shifts, setShifts] = useState<Shift[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });\nconst [loading, setLoading] = useState(false);\nconst [search, setSearch] = useState('');\nconst [debouncedSearch, setDebouncedSearch] = useState('');\nconst searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\nconst [statusFilter, setStatusFilter] = useState<ShiftStatus | undefined>();\nconst [stats, setStats] = useState<ShiftStats | null>(null);\n\n// Create modal\nconst [createModalOpen, setCreateModalOpen] = useState(false);\nconst [createForm] = Form.useForm();\n\n// Edit drawer\nconst [editDrawerOpen, setEditDrawerOpen] = useState(false);\nconst [editingShift, setEditingShift] = useState<Shift | null>(null);\nconst [editForm] = Form.useForm();\n\n// Signups drawer\nconst [signupsDrawerOpen, setSignupsDrawerOpen] = useState(false);\nconst [signupsShift, setSignupsShift] = useState<Shift | null>(null);\nconst [signups, setSignups] = useState<ShiftSignup[]>([]);\nconst [signupsLoading, setSignupsLoading] = useState(false);\nconst [addEmail, setAddEmail] = useState('');\nconst [addName, setAddName] = useState('');\n\n// Cuts for area dropdown\nconst [cuts, setCuts] = useState<Cut[]>([]);\n
"},{"location":"v2/frontend/pages/admin/shifts-page/#debounced-search","title":"Debounced Search","text":"
const handleSearchChange = (value: string) => {\n  setSearch(value);               // Update input immediately\n  clearTimeout(searchTimerRef.current);\n  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);\n};\n\nuseEffect(() => {\n  return () => clearTimeout(searchTimerRef.current);  // Cleanup on unmount\n}, []);\n\nuseEffect(() => {\n  fetchShifts({ page: 1 });\n  fetchStats();\n  fetchCuts();\n}, [debouncedSearch, statusFilter]);  // Re-fetch when search or filter changes\n

Why 300ms? Same pattern as other pages \u2014 prevents API spam while typing.

"},{"location":"v2/frontend/pages/admin/shifts-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose GET /api/map/shifts List shifts (paginated, filtered) GET /api/map/shifts/stats Fetch statistics (counts by status) GET /api/map/shifts/:id Fetch single shift with signups POST /api/map/shifts Create shift PUT /api/map/shifts/:id Update shift DELETE /api/map/shifts/:id Delete shift (cascade signups) POST /api/map/shifts/:id/signups Add volunteer manually (admin) DELETE /api/map/shifts/:id/signups/:signupId Remove volunteer POST /api/map/shifts/:id/email-details Email all confirmed volunteers"},{"location":"v2/frontend/pages/admin/shifts-page/#list-shifts","title":"List Shifts","text":"

Request:

const { data } = await api.get<ShiftsListResponse>('/map/shifts', {\n  params: {\n    page: 1,\n    limit: 20,\n    search: 'door knocking',    // Optional: search title/location\n    status: 'OPEN',             // Optional: filter by status\n  },\n});\n

Response:

{\n  \"shifts\": [\n    {\n      \"id\": \"shift-123\",\n      \"title\": \"Door Knocking \u2014 Downtown\",\n      \"description\": \"Focus on high-density residential areas. Bring campaign materials.\",\n      \"date\": \"2026-02-15\",\n      \"startTime\": \"10:00\",\n      \"endTime\": \"14:00\",\n      \"location\": \"Campaign HQ, 123 Main St\",\n      \"maxVolunteers\": 15,\n      \"currentVolunteers\": 8,\n      \"status\": \"OPEN\",\n      \"isPublic\": true,\n      \"cutId\": \"cut-456\",\n      \"cut\": {\n        \"id\": \"cut-456\",\n        \"name\": \"Downtown Core\"\n      },\n      \"createdAt\": \"2026-01-10T09:00:00.000Z\",\n      \"updatedAt\": \"2026-01-15T14:30:00.000Z\",\n      \"_count\": {\n        \"signups\": 8\n      }\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 47,\n    \"totalPages\": 3\n  }\n}\n

Key fields: - cutId \u2014 Foreign key to Cut (polygon area) - cut \u2014 Nested cut object (if assigned) - currentVolunteers \u2014 Confirmed signups count - _count.signups \u2014 Prisma aggregation (confirmed signups count) - startTime, endTime \u2014 24-hour format strings (HH:mm)

"},{"location":"v2/frontend/pages/admin/shifts-page/#fetch-shift-statistics","title":"Fetch Shift Statistics","text":"

Request:

const { data } = await api.get<ShiftStats>('/map/shifts/stats');\n

Response:

{\n  \"total\": 47,\n  \"open\": 23,\n  \"full\": 8,\n  \"cancelled\": 2,\n  \"completed\": 14,\n  \"upcoming\": 31,\n  \"totalSignups\": 287\n}\n

Upcoming calculation: Future shifts (date >= today)

"},{"location":"v2/frontend/pages/admin/shifts-page/#create-shift","title":"Create Shift","text":"

Request:

const payload = {\n  title: \"Phone Banking\",\n  description: \"Call voters to discuss campaign issues\",\n  date: \"2026-02-20\",\n  startTime: \"18:00\",\n  endTime: \"21:00\",\n  location: \"Campaign HQ\",\n  maxVolunteers: 10,\n  isPublic: true,\n  cutId: \"cut-789\",  // Optional\n};\n\nawait api.post('/map/shifts', payload);\n

Response:

{\n  \"id\": \"shift-999\",\n  \"title\": \"Phone Banking\",\n  \"status\": \"OPEN\",\n  \"currentVolunteers\": 0,\n  \"createdAt\": \"2026-02-11T10:00:00.000Z\",\n  // ... all other fields\n}\n

Default values: - status \u2014 OPEN - currentVolunteers \u2014 0 - isPublic \u2014 false (if not specified)

"},{"location":"v2/frontend/pages/admin/shifts-page/#update-shift","title":"Update Shift","text":"

Request:

const payload = {\n  status: \"CANCELLED\",\n  description: \"Cancelled due to weather\",\n};\n\nawait api.put(`/map/shifts/${shiftId}`, payload);\n

Partial updates: Only send changed fields.

"},{"location":"v2/frontend/pages/admin/shifts-page/#add-volunteer-manual-signup","title":"Add Volunteer (Manual Signup)","text":"

Request:

await api.post(`/map/shifts/${shiftId}/signups`, {\n  userEmail: 'volunteer@example.com',\n  userName: 'Jane Doe',  // Optional\n});\n

Response:

{\n  \"id\": \"signup-456\",\n  \"shiftId\": \"shift-123\",\n  \"userId\": \"user-789\",  // Existing or newly created temp user\n  \"userEmail\": \"volunteer@example.com\",\n  \"userName\": \"Jane Doe\",\n  \"signupSource\": \"ADMIN\",\n  \"status\": \"CONFIRMED\",\n  \"signupDate\": \"2026-02-11T10:30:00.000Z\"\n}\n

Temp user creation logic: - If volunteer@example.com exists \u2192 link to existing user - If doesn't exist \u2192 create temp user:

{\n  \"email\": \"volunteer@example.com\",\n  \"name\": \"Jane Doe\",\n  \"role\": \"TEMP\",\n  \"password\": \"BlueEagle42\",  // Readable password\n  \"tempUserExpiresAt\": \"2026-02-21T00:00:00.000Z\"  // shift date + 1 day\n}\n

"},{"location":"v2/frontend/pages/admin/shifts-page/#email-all-volunteers","title":"Email All Volunteers","text":"

Request:

const { data } = await api.post<{ sent: number; failed: number }>(\n  `/map/shifts/${shiftId}/email-details`\n);\n

Response:

{\n  \"sent\": 8,\n  \"failed\": 0\n}\n

Email template:

Subject: Shift Reminder: {Shift Title}\n\nHi {Volunteer Name},\n\nThis is a reminder about your upcoming volunteer shift:\n\nTitle: {Shift Title}\nDate: {Date}\nTime: {Start Time} \u2014 {End Time}\nLocation: {Location}\n\nDescription:\n{Shift Description}\n\nThank you for volunteering!\n\nChangemaker Lite\n

SMTP: Uses site settings (Settings page \u2192 Email tab)

"},{"location":"v2/frontend/pages/admin/shifts-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#clickable-table-rows","title":"Clickable Table Rows","text":"
<Table\n  columns={columns}\n  dataSource={shifts}\n  rowKey=\"id\"\n  onRow={(record) => ({\n    onClick: () => openSignups(record),  // Click row \u2192 open signups drawer\n    style: { cursor: 'pointer' },        // Show pointer cursor on hover\n  })}\n/>\n

Pattern: Entire row clickable except action buttons (edit/delete use stopPropagation).

"},{"location":"v2/frontend/pages/admin/shifts-page/#progress-bar-for-volunteer-capacity","title":"Progress Bar for Volunteer Capacity","text":"
{\n  title: 'Volunteers',\n  width: 140,\n  render: (_, record) => {\n    const confirmed = record._count?.signups ?? record.currentVolunteers;\n    const pct = record.maxVolunteers > 0 ? Math.round((confirmed / record.maxVolunteers) * 100) : 0;\n    return (\n      <Progress\n        percent={pct}\n        size=\"small\"\n        status={pct >= 100 ? 'exception' : 'active'}\n        format={() => `${confirmed}/${record.maxVolunteers}`}\n      />\n    );\n  },\n}\n

Visual feedback: - Green progress bar: < 100% - Red progress bar: = 100% (full, status \"exception\") - Format shows: \"8/15\" (8 confirmed out of 15 max)

"},{"location":"v2/frontend/pages/admin/shifts-page/#datetime-payload-formatting","title":"Date/Time Payload Formatting","text":"
const handleCreate = async (values: Record<string, unknown>) => {\n  const payload = {\n    title: values.title,\n    date: dayjs(values.date as string).format('YYYY-MM-DD'),      // DatePicker \u2192 string\n    startTime: dayjs(values.startTime as string).format('HH:mm'), // TimePicker \u2192 string\n    endTime: dayjs(values.endTime as string).format('HH:mm'),     // TimePicker \u2192 string\n    maxVolunteers: values.maxVolunteers,\n    // ... other fields\n  };\n  await api.post('/map/shifts', payload);\n};\n

Why format? DatePicker and TimePicker return Dayjs objects. Backend expects ISO date string + HH:mm time strings.

"},{"location":"v2/frontend/pages/admin/shifts-page/#edit-form-pre-fill","title":"Edit Form Pre-Fill","text":"
const openEdit = (shift: Shift) => {\n  setEditingShift(shift);\n  editForm.setFieldsValue({\n    title: shift.title,\n    description: shift.description,\n    date: dayjs(shift.date),                    // String \u2192 Dayjs object\n    startTime: dayjs(shift.startTime, 'HH:mm'), // HH:mm string \u2192 Dayjs object with format\n    endTime: dayjs(shift.endTime, 'HH:mm'),\n    location: shift.location,\n    maxVolunteers: shift.maxVolunteers,\n    isPublic: shift.isPublic,\n    status: shift.status,\n    cutId: shift.cutId,\n  });\n  setEditDrawerOpen(true);\n};\n

Why dayjs(shift.startTime, 'HH:mm')? TimePicker needs Dayjs object with specific format. Backend stores as \"10:00\" string, convert to Dayjs with HH:mm format.

"},{"location":"v2/frontend/pages/admin/shifts-page/#conditional-status-field","title":"Conditional Status Field","text":"
const shiftFormFields = (isEdit = false) => (\n  <>\n    {/* ... other fields */}\n    <Row gutter={12}>\n      {/* ... maxVolunteers, isPublic */}\n      {isEdit && (\n        <Col xs={12} sm={8}>\n          <Form.Item name=\"status\" label=\"Status\">\n            <Select options={statusOptions} />\n          </Form.Item>\n        </Col>\n      )}\n    </Row>\n  </>\n);\n

Why conditional? Status dropdown only shown in edit mode. Create form defaults to OPEN (set by backend).

"},{"location":"v2/frontend/pages/admin/shifts-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#debounced-search_1","title":"Debounced Search","text":"

Same 300ms debounce pattern as other pages: - Prevents API spam while typing - Only fires after user pauses - Cleanup on unmount

"},{"location":"v2/frontend/pages/admin/shifts-page/#responsive-column-hiding","title":"Responsive Column Hiding","text":"
{ title: 'Time', responsive: ['md'] }       // Hide on < 768px\n{ title: 'Location', responsive: ['lg'] }   // Hide on < 992px\n{ title: 'Area', responsive: ['md'] }       // Hide on < 768px\n

Mobile users see: Title, Date, Volunteers, Status, Public, Actions

"},{"location":"v2/frontend/pages/admin/shifts-page/#usecallback-optimization","title":"useCallback Optimization","text":"
const fetchShifts = useCallback(async (params?: ShiftsListParams) => {\n  // ... fetch logic\n}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);\n\nconst fetchStats = useCallback(async () => {\n  // ... fetch logic\n}, []);\n\nconst fetchCuts = useCallback(async () => {\n  // ... fetch logic\n}, []);\n

Memoized functions prevent unnecessary re-renders.

"},{"location":"v2/frontend/pages/admin/shifts-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#mobile-576px","title":"Mobile (< 576px)","text":"
  • Stats cards: 2 columns (xs={12})
  • Search bar: Full width
  • Status filter: Full width below search
  • Table: Minimal columns (Title, Date, Volunteers, Status, Actions)
  • Signups drawer: Full screen overlay (width: 100%)
"},{"location":"v2/frontend/pages/admin/shifts-page/#tablet-576px-992px","title":"Tablet (576px - 992px)","text":"
  • Stats cards: 3-4 columns (sm={4} or sm={6})
  • Search bar: Half width (sm={12})
  • Status filter: Quarter width (sm={6})
  • Table: Time + Area columns visible
"},{"location":"v2/frontend/pages/admin/shifts-page/#desktop-992px","title":"Desktop (\u2265 992px)","text":"
  • Stats cards: 6 columns (md={4})
  • Filters: Compact (search \u2153, filter \u2159)
  • Table: All columns visible (Location, Date)
  • Signups drawer: 640px overlay (right side)
"},{"location":"v2/frontend/pages/admin/shifts-page/#accessibility","title":"Accessibility","text":"
  • Keyboard navigation: All buttons, inputs, selects focusable via Tab
  • ARIA labels: Icon buttons have title attribute
  • Form validation: Required fields marked, inline error messages
  • Color contrast: Status tags use Ant Design defaults (WCAG AA compliant)
  • Screen reader support: Form labels properly associated
  • Focus management: Modals/drawers auto-focus first input on open
"},{"location":"v2/frontend/pages/admin/shifts-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#shift-status-not-auto-updating-to-full","title":"Shift Status Not Auto-Updating to FULL","text":"

Problem: Shift reaches max volunteers (8/8), but status stays OPEN instead of changing to FULL.

Diagnosis:

Backend auto-status logic should run on every signup:

if (currentVolunteers >= maxVolunteers) {\n  await prisma.shift.update({\n    where: { id: shiftId },\n    data: { status: 'FULL' },\n  });\n}\n

Common Issues:

  1. Backend logic not running:
  2. Check API logs: docker compose logs api | grep \"shift status\"
  3. Verify signup endpoint includes auto-status update

  4. Race condition:

  5. Multiple signups at same time (public + admin)
  6. Solution: Use Prisma transaction for atomic updates

  7. Status manually set:

  8. Admin changed status to OPEN in edit drawer
  9. Solution: Status field warning: \"Auto-updates to FULL when capacity reached\"

Solution:

Refresh page to see latest status. Backend should auto-update on next signup/removal.

"},{"location":"v2/frontend/pages/admin/shifts-page/#email-all-volunteers-fails","title":"Email All Volunteers Fails","text":"

Problem: Click \"Email All\" button \u2192 Error: \"Failed to email volunteers\"

Diagnosis:

Check SMTP configuration: 1. Navigate to Settings \u2192 Email tab 2. Verify active provider: Production (not MailHog) 3. Click \"Test Connection\" \u2192 Should show success

Common Issues:

  1. MailHog active (dev mode):
  2. Switch to Production provider
  3. Save settings

  4. SMTP credentials invalid:

  5. Test connection fails
  6. Update credentials
  7. Re-test before emailing

  8. No confirmed volunteers:

  9. Email All button disabled if 0 confirmed
  10. Check signups drawer table (only CONFIRMED shown)

Solution:

  1. Fix SMTP settings
  2. Test connection
  3. Retry Email All
"},{"location":"v2/frontend/pages/admin/shifts-page/#volunteer-not-appearing-in-signups","title":"Volunteer Not Appearing in Signups","text":"

Problem: Add volunteer by email \u2192 Success message \u2192 Volunteer not in signups table

Diagnosis:

Check signups drawer filter:

<Table dataSource={signups.filter((s) => s.status === 'CONFIRMED')} />\n

Cancelled signups hidden.

Common Issues:

  1. Volunteer added but immediately cancelled:
  2. Check backend logs for cancellation endpoint calls
  3. Verify signup status in database

  4. Wrong shift:

  5. Added to different shift
  6. Verify shift ID in URL when opening drawer

  7. Duplicate email:

  8. Volunteer already signed up
  9. Backend returns 400: \"User already signed up for this shift\"
  10. Check error message

Solution:

Refresh drawer: Close and re-open signups drawer to fetch latest data.

"},{"location":"v2/frontend/pages/admin/shifts-page/#area-cut-dropdown-empty","title":"Area (Cut) Dropdown Empty","text":"

Problem: Create/edit shift \u2192 Area dropdown shows no options

Diagnosis:

Check cuts API endpoint:

curl http://localhost:4000/api/map/cuts\n

Common Issues:

  1. No cuts created yet:
  2. Navigate to /app/map/cuts
  3. Create at least one cut (polygon boundary)
  4. Return to shifts page

  5. Cuts API failing:

  6. Check API logs: docker compose logs api | grep \"cuts\"
  7. Verify database connection

  8. Cuts fetch not called:

  9. Check browser console for errors
  10. Verify fetchCuts() called in useEffect

Solution:

Create at least one cut in CutsPage before assigning to shifts.

"},{"location":"v2/frontend/pages/admin/shifts-page/#public-shift-not-showing-on-public-page","title":"Public Shift Not Showing on Public Page","text":"

Problem: Set isPublic to true, save shift \u2192 Public /shifts page doesn't show it

Diagnosis:

Check shift criteria for public page: - Status: OPEN or FULL (not CANCELLED or COMPLETED) - isPublic: true - Date: Future (not past)

Common Issues:

  1. Shift date in past:
  2. Past shifts hidden from public page
  3. Edit shift, update date to future

  4. Status CANCELLED:

  5. Cancelled shifts hidden from public page
  6. Change status to OPEN

  7. Browser cache:

  8. Hard refresh public page (Ctrl+Shift+R)

Solution:

Verify all 3 criteria met: OPEN/FULL status, isPublic true, future date.

"},{"location":"v2/frontend/pages/admin/shifts-page/#related-documentation","title":"Related Documentation","text":"
  • Shifts Module (Backend) \u2014 API implementation, schemas, service functions
  • Shift Signups \u2014 Signup creation, temp users, email logic
  • Cuts Module \u2014 Polygon boundaries for shift areas
  • Public Shifts Page \u2014 Public shift signup page
  • Volunteer Shifts Page \u2014 Volunteer portal assignments
  • Shifts API Reference \u2014 Complete endpoint documentation
  • Map Feature Guide \u2014 End-to-end shift workflow
  • User Guide: Volunteer Coordination \u2014 Shift scheduling best practices
  • Troubleshooting: Shift Issues \u2014 Shift debugging
"},{"location":"v2/frontend/pages/admin/users-page/","title":"UsersPage","text":""},{"location":"v2/frontend/pages/admin/users-page/#overview","title":"Overview","text":"

The UsersPage provides comprehensive user management with full CRUD operations, pagination, search, filtering, and role-based access control. It serves as the primary interface for managing all system users including admins, volunteers, and temporary users.

Route: /app/users Component: admin/src/pages/UsersPage.tsx (400+ lines) Auth Required: Yes (SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN) Layout: AppLayout

"},{"location":"v2/frontend/pages/admin/users-page/#screenshot","title":"Screenshot","text":"

[Screenshot: Users table with columns for Email, Name, Role (color-coded tags), Status (green/red/orange tags), Created At, and Actions. Top bar has search input, role filter dropdown, status filter dropdown, and \"Create User\" button. Each row has Edit and Delete icons. Pagination controls at bottom showing \"20 per page\" and navigation.]

"},{"location":"v2/frontend/pages/admin/users-page/#features","title":"Features","text":"
  • Full CRUD operations \u2014 Create, Read, Update, Delete users
  • Advanced search \u2014 Debounced search by email/name (300ms delay)
  • Dual filtering \u2014 Filter by role AND status simultaneously
  • Pagination \u2014 Configurable page size (20 per page default)
  • Color-coded roles \u2014 Visual role identification with Ant Design tags
  • SUPER_ADMIN: red
  • INFLUENCE_ADMIN: volcano (orange-red)
  • MAP_ADMIN: orange
  • USER: blue
  • TEMP: default (gray)
  • Status indicators \u2014 Color-coded status tags
  • ACTIVE: green
  • INACTIVE: gray
  • SUSPENDED: red
  • EXPIRED: orange
  • Temp user management \u2014 Create temporary users with expiration dates
  • DatePicker for expiration \u2014 Visual calendar for setting expiry
  • Optimistic UI \u2014 Immediate feedback on actions
  • Responsive table \u2014 Mobile-friendly with scroll overflow
"},{"location":"v2/frontend/pages/admin/users-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/users-page/#creating-a-user","title":"Creating a User","text":"
  1. Click \"Create User\" button (top right)
  2. Modal opens with form fields:
  3. Email (required)
  4. Name (optional)
  5. Phone (optional)
  6. Password (required, auto-generated option available)
  7. Role (dropdown: Super Admin, Influence Admin, Map Admin, User, Temp)
  8. Status (dropdown: Active, Inactive, Suspended, Expired)
  9. Expiration Date (optional, for TEMP users)
  10. Fill out form fields
  11. Click \"Create\"
  12. Success message: \"User created\"
  13. Modal closes, table refreshes to page 1
"},{"location":"v2/frontend/pages/admin/users-page/#editing-a-user","title":"Editing a User","text":"
  1. Click Edit icon in Actions column
  2. Modal opens with pre-populated form
  3. Modify fields (password optional, leave blank to keep existing)
  4. Click \"Save\"
  5. Success message: \"User updated\"
  6. Modal closes, table data refreshes
"},{"location":"v2/frontend/pages/admin/users-page/#deleting-a-user","title":"Deleting a User","text":"
  1. Click Delete icon in Actions column
  2. Popconfirm appears: \"Are you sure you want to delete this user?\"
  3. Click \"Yes\"
  4. Success message: \"User deleted\"
  5. Table data refreshes
"},{"location":"v2/frontend/pages/admin/users-page/#searching-users","title":"Searching Users","text":"
  1. Type in search input (top left)
  2. Wait 300ms (debounce delay)
  3. Table automatically filters results
  4. Search matches email OR name (case-insensitive)
  5. Resets to page 1 automatically
"},{"location":"v2/frontend/pages/admin/users-page/#filtering-by-rolestatus","title":"Filtering by Role/Status","text":"
  1. Select role from Role Filter dropdown
  2. OR/AND select status from Status Filter dropdown
  3. Table automatically filters results
  4. Filters combine with search (all must match)
  5. Resets to page 1 automatically
"},{"location":"v2/frontend/pages/admin/users-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/users-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  • Table \u2014 Main data table with columns, pagination, sorting
  • Button \u2014 Create User, modal actions (Create, Save, Cancel)
  • Input \u2014 Search input, text fields (email, name, phone, password)
  • Select \u2014 Role and status dropdowns
  • Tag \u2014 Color-coded role and status indicators
  • Space \u2014 Action button grouping
  • Modal \u2014 Create and edit user forms
  • Form \u2014 Form validation and submission
  • InputNumber \u2014 Expire days input
  • DatePicker \u2014 Expiration date picker
  • Popconfirm \u2014 Delete confirmation
  • message \u2014 Toast notifications (success, error)
  • Typography.Title \u2014 Page heading
  • Row, Col \u2014 Responsive form layout
"},{"location":"v2/frontend/pages/admin/users-page/#table-columns","title":"Table Columns","text":"Column Key Render Sortable Email email Plain text No Name name Plain text No Role role Color-coded Tag No Status status Color-coded Tag No Created At createdAt Formatted date (MMM DD, YYYY) No Actions - Edit + Delete icons No

Column Configuration:

const columns: ColumnsType<User> = [\n  {\n    title: 'Email',\n    dataIndex: 'email',\n    key: 'email',\n  },\n  {\n    title: 'Name',\n    dataIndex: 'name',\n    key: 'name',\n    render: (text: string) => text || '\u2014',\n  },\n  {\n    title: 'Role',\n    dataIndex: 'role',\n    key: 'role',\n    render: (role: UserRole) => (\n      <Tag color={roleColors[role]}>{role.replace('_', ' ')}</Tag>\n    ),\n  },\n  {\n    title: 'Status',\n    dataIndex: 'status',\n    key: 'status',\n    render: (status: UserStatus) => (\n      <Tag color={statusColors[status]}>{status}</Tag>\n    ),\n  },\n  {\n    title: 'Created At',\n    dataIndex: 'createdAt',\n    key: 'createdAt',\n    render: (date: string) => dayjs(date).format('MMM DD, YYYY'),\n  },\n  {\n    title: 'Actions',\n    key: 'actions',\n    render: (_, record: User) => (\n      <Space>\n        <Button\n          type=\"link\"\n          icon={<EditOutlined />}\n          onClick={() => handleEditClick(record)}\n        />\n        <Popconfirm\n          title=\"Are you sure you want to delete this user?\"\n          onConfirm={() => handleDelete(record.id)}\n        >\n          <Button type=\"link\" danger icon={<DeleteOutlined />} />\n        </Popconfirm>\n      </Space>\n    ),\n  },\n];\n
"},{"location":"v2/frontend/pages/admin/users-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/users-page/#local-state","title":"Local State","text":"
const [users, setUsers] = useState<User[]>([]);\nconst [pagination, setPagination] = useState({\n  page: 1,\n  limit: 20,\n  total: 0,\n  totalPages: 0\n});\nconst [loading, setLoading] = useState(false);\nconst [search, setSearch] = useState('');\nconst [debouncedSearch, setDebouncedSearch] = useState('');\nconst [roleFilter, setRoleFilter] = useState<UserRole | undefined>();\nconst [statusFilter, setStatusFilter] = useState<UserStatus | undefined>();\nconst [createModalOpen, setCreateModalOpen] = useState(false);\nconst [editModalOpen, setEditModalOpen] = useState(false);\nconst [editingUser, setEditingUser] = useState<User | null>(null);\nconst [createForm] = Form.useForm();\nconst [editForm] = Form.useForm();\nconst searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\n
"},{"location":"v2/frontend/pages/admin/users-page/#zustand-stores-used","title":"Zustand Stores Used","text":"
  • auth.store \u2014 Not directly used, but auth context ensures only admins access page
"},{"location":"v2/frontend/pages/admin/users-page/#search-debouncing","title":"Search Debouncing","text":"
const handleSearchChange = (value: string) => {\n  setSearch(value);\n  clearTimeout(searchTimerRef.current);\n  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);\n};\n\nuseEffect(() => {\n  return () => clearTimeout(searchTimerRef.current);\n}, []);\n

Why 300ms?

  • Fast enough \u2014 Users perceive instant response
  • Reduces API calls \u2014 Prevents API spam during typing
  • Balances UX \u2014 Not too slow (500ms+ feels laggy)
"},{"location":"v2/frontend/pages/admin/users-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/users-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose GET /api/users List users with pagination/filters POST /api/users Create new user PUT /api/users/:id Update user DELETE /api/users/:id Delete user"},{"location":"v2/frontend/pages/admin/users-page/#fetch-users-with-filters","title":"Fetch Users (with Filters)","text":"
const fetchUsers = useCallback(async (params?: UsersListParams) => {\n  setLoading(true);\n  try {\n    const { data } = await api.get<UsersListResponse>('/users', {\n      params: {\n        page: params?.page ?? pagination.page,\n        limit: params?.limit ?? pagination.limit,\n        search: params?.search ?? (debouncedSearch || undefined),\n        role: params?.role ?? roleFilter,\n        status: params?.status ?? statusFilter,\n      },\n    });\n    setUsers(data.users);\n    setPagination(data.pagination);\n  } catch {\n    message.error('Failed to load users');\n  } finally {\n    setLoading(false);\n  }\n}, [pagination.page, pagination.limit, debouncedSearch, roleFilter, statusFilter]);\n

Response Format:

{\n  \"users\": [\n    {\n      \"id\": \"clx1234567890\",\n      \"email\": \"admin@example.com\",\n      \"name\": \"Admin User\",\n      \"phone\": \"+1234567890\",\n      \"role\": \"SUPER_ADMIN\",\n      \"status\": \"ACTIVE\",\n      \"createdAt\": \"2026-01-15T12:00:00.000Z\",\n      \"updatedAt\": \"2026-02-11T15:30:00.000Z\",\n      \"expiresAt\": null,\n      \"lastLoginAt\": \"2026-02-11T10:00:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 45,\n    \"totalPages\": 3\n  }\n}\n
"},{"location":"v2/frontend/pages/admin/users-page/#create-user","title":"Create User","text":"
const handleCreate = async (values: CreateUserPayload & { expiresAtDate?: dayjs.Dayjs }) => {\n  try {\n    const payload: CreateUserPayload = { ...values };\n    if (values.expiresAtDate) {\n      payload.expiresAt = values.expiresAtDate.toISOString();\n    }\n    delete (payload as unknown as Record<string, unknown>).expiresAtDate;\n    await api.post('/users', payload);\n    message.success('User created');\n    setCreateModalOpen(false);\n    createForm.resetFields();\n    fetchUsers({ page: 1 });\n  } catch (err: unknown) {\n    const msg =\n      (err as { response?: { data?: { error?: { message?: string } } } })\n        ?.response?.data?.error?.message || 'Failed to create user';\n    message.error(msg);\n  }\n};\n

Request Payload:

{\n  \"email\": \"newuser@example.com\",\n  \"name\": \"New User\",\n  \"phone\": \"+1234567890\",\n  \"password\": \"SecurePassword123!\",\n  \"role\": \"USER\",\n  \"status\": \"ACTIVE\",\n  \"expiresAt\": \"2026-03-15T23:59:59.000Z\"\n}\n
"},{"location":"v2/frontend/pages/admin/users-page/#update-user","title":"Update User","text":"
const handleEdit = async (values: UpdateUserPayload & { expiresAtDate?: dayjs.Dayjs | null }) => {\n  if (!editingUser) return;\n  try {\n    const payload: UpdateUserPayload = { ...values };\n    if (values.expiresAtDate) {\n      payload.expiresAt = values.expiresAtDate.toISOString();\n    } else if (values.expiresAtDate === null) {\n      payload.expiresAt = null;\n    }\n    delete (payload as unknown as Record<string, unknown>).expiresAtDate;\n    await api.put(`/users/${editingUser.id}`, payload);\n    message.success('User updated');\n    setEditModalOpen(false);\n    editForm.resetFields();\n    fetchUsers();\n  } catch (err: unknown) {\n    const msg =\n      (err as { response?: { data?: { error?: { message?: string } } } })\n        ?.response?.data?.error?.message || 'Failed to update user';\n    message.error(msg);\n  }\n};\n

Update Payload (Partial):

{\n  \"name\": \"Updated Name\",\n  \"role\": \"MAP_ADMIN\",\n  \"status\": \"ACTIVE\"\n}\n

Note: Password is optional in updates. Leave blank to keep existing password.

"},{"location":"v2/frontend/pages/admin/users-page/#delete-user","title":"Delete User","text":"
const handleDelete = async (id: string) => {\n  try {\n    await api.delete(`/users/${id}`);\n    message.success('User deleted');\n    fetchUsers();\n  } catch {\n    message.error('Failed to delete user');\n  }\n};\n
"},{"location":"v2/frontend/pages/admin/users-page/#form-validation","title":"Form Validation","text":""},{"location":"v2/frontend/pages/admin/users-page/#create-user-form-rules","title":"Create User Form Rules","text":"
<Form.Item\n  name=\"email\"\n  label=\"Email\"\n  rules={[\n    { required: true, message: 'Email is required' },\n    { type: 'email', message: 'Invalid email address' },\n  ]}\n>\n  <Input placeholder=\"user@example.com\" />\n</Form.Item>\n\n<Form.Item\n  name=\"password\"\n  label=\"Password\"\n  rules={[\n    { required: true, message: 'Password is required' },\n    { min: 12, message: 'Password must be at least 12 characters' },\n    {\n      pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)/,\n      message: 'Password must contain uppercase, lowercase, and digit'\n    },\n  ]}\n>\n  <Input.Password placeholder=\"Min 12 chars, uppercase, lowercase, digit\" />\n</Form.Item>\n\n<Form.Item\n  name=\"role\"\n  label=\"Role\"\n  rules={[{ required: true, message: 'Role is required' }]}\n>\n  <Select options={roleOptions} />\n</Form.Item>\n

Password Policy:

  • Minimum 12 characters
  • At least one uppercase letter
  • At least one lowercase letter
  • At least one digit
"},{"location":"v2/frontend/pages/admin/users-page/#edit-user-form-rules","title":"Edit User Form Rules","text":"

Same as create form, but password is optional:

<Form.Item\n  name=\"password\"\n  label=\"Password\"\n  extra=\"Leave blank to keep existing password\"\n  rules={[\n    { min: 12, message: 'Password must be at least 12 characters' },\n    {\n      pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)/,\n      message: 'Password must contain uppercase, lowercase, and digit'\n    },\n  ]}\n>\n  <Input.Password placeholder=\"Min 12 chars (leave blank to keep existing)\" />\n</Form.Item>\n
"},{"location":"v2/frontend/pages/admin/users-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/users-page/#debounced-search","title":"Debounced Search","text":"

Prevents excessive API calls during typing:

const handleSearchChange = (value: string) => {\n  setSearch(value);\n  clearTimeout(searchTimerRef.current);\n  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);\n};\n

Performance Impact:

  • Without debounce: 10 keystrokes = 10 API calls
  • With 300ms debounce: 10 keystrokes = 1-2 API calls (significant reduction)
"},{"location":"v2/frontend/pages/admin/users-page/#usecallback-for-fetchusers","title":"useCallback for fetchUsers","text":"

Prevents unnecessary re-creation of fetch function:

const fetchUsers = useCallback(async (params?: UsersListParams) => {\n  // ... fetch logic\n}, [pagination.page, pagination.limit, debouncedSearch, roleFilter, statusFilter]);\n

Why useCallback?

  • Memoization \u2014 Function reference stays stable unless dependencies change
  • Prevents re-renders \u2014 Child components can use React.memo effectively
  • useEffect optimization \u2014 Avoids infinite loops in useEffect
"},{"location":"v2/frontend/pages/admin/users-page/#pagination","title":"Pagination","text":"

Server-side pagination reduces memory usage:

  • Client-side (bad): Fetch all 10,000 users \u2192 Paginate in browser \u2192 High memory usage
  • Server-side (good): Fetch 20 users per page \u2192 Low memory usage
"},{"location":"v2/frontend/pages/admin/users-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/users-page/#table-scroll","title":"Table Scroll","text":"

Table uses horizontal scroll on mobile:

<Table\n  columns={columns}\n  dataSource={users}\n  scroll={{ x: 'max-content' }}\n  loading={loading}\n  pagination={{\n    current: pagination.page,\n    pageSize: pagination.limit,\n    total: pagination.total,\n    showSizeChanger: true,\n    showTotal: (total) => `Total ${total} users`,\n  }}\n  onChange={handleTableChange}\n/>\n
"},{"location":"v2/frontend/pages/admin/users-page/#modal-forms","title":"Modal Forms","text":"

Forms use responsive columns:

<Row gutter={16}>\n  <Col xs={24} sm={12}>\n    <Form.Item label=\"Email\" name=\"email\">\n      <Input />\n    </Form.Item>\n  </Col>\n  <Col xs={24} sm={12}>\n    <Form.Item label=\"Name\" name=\"name\">\n      <Input />\n    </Form.Item>\n  </Col>\n</Row>\n

Mobile: Fields stack vertically (xs={24}) Tablet+: Fields display side-by-side (sm={12})

"},{"location":"v2/frontend/pages/admin/users-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/users-page/#search-not-working","title":"Search Not Working","text":"

Problem: Typing in search input doesn't filter results.

Diagnosis:

  1. Check debouncedSearch value in React DevTools
  2. Verify fetchUsers is called after 300ms delay
  3. Check network tab for API call with search param

Solution:

useEffect(() => {\n  fetchUsers({ page: 1 });\n}, [debouncedSearch, roleFilter, statusFilter]);\n

Ensure debouncedSearch is in dependency array, not search.

"},{"location":"v2/frontend/pages/admin/users-page/#failed-to-load-users-error","title":"\"Failed to load users\" Error","text":"

Problem: Table shows error message.

Diagnosis:

Check API response:

curl -H \"Authorization: Bearer <token>\" \\\n  \"http://api.cmlite.org/api/users?page=1&limit=20\"\n

Common Issues:

  1. 401 Unauthorized \u2014 Token expired
  2. 403 Forbidden \u2014 User lacks admin role
  3. 500 Internal Server Error \u2014 Database connection issue
"},{"location":"v2/frontend/pages/admin/users-page/#delete-confirmation-not-appearing","title":"Delete Confirmation Not Appearing","text":"

Problem: Click delete icon but nothing happens.

Diagnosis:

Check Popconfirm component:

<Popconfirm\n  title=\"Are you sure you want to delete this user?\"\n  onConfirm={() => handleDelete(record.id)}\n  okText=\"Yes\"\n  cancelText=\"No\"\n>\n  <Button type=\"link\" danger icon={<DeleteOutlined />} />\n</Popconfirm>\n

Solution:

Ensure Popconfirm wraps the button, not the other way around.

"},{"location":"v2/frontend/pages/admin/users-page/#modal-form-not-resetting","title":"Modal Form Not Resetting","text":"

Problem: Open create modal, enter data, close modal, reopen \u2192 old data still there.

Solution:

Reset form on modal close:

<Modal\n  title=\"Create User\"\n  open={createModalOpen}\n  onCancel={() => {\n    setCreateModalOpen(false);\n    createForm.resetFields();  // Reset form on close\n  }}\n  onOk={() => createForm.submit()}\n>\n  <Form form={createForm} onFinish={handleCreate}>\n    {/* form fields */}\n  </Form>\n</Modal>\n
"},{"location":"v2/frontend/pages/admin/users-page/#related-documentation","title":"Related Documentation","text":"
  • Users Module \u2014 Backend API reference
  • Auth Module \u2014 Authentication system
  • AppLayout Component \u2014 Layout wrapper
  • Auth Store \u2014 Authentication state
  • API Client \u2014 Axios instance with interceptors
  • User Guide: User Management \u2014 Admin guide
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/","title":"WalkSheetPage","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#overview","title":"Overview","text":"

File: admin/src/pages/WalkSheetPage.tsx

Route: /app/walk-sheet

Role Requirements: Any authenticated user (uses authenticate middleware)

Purpose: Generates a printable walk sheet form for canvassing volunteers. The walk sheet is a physical form that volunteers use to record contact information and support levels during door-to-door canvassing. The page fetches customizable settings (title, subtitle, QR codes, footer) and renders a standardized form optimized for printing.

Key Features: - Printable walk sheet with browser print support - Customizable title, subtitle, and footer from MapSettings - Up to 3 configurable QR codes with labels - 12-row contact table with pre-printed columns (Name, Address, Email, Phone, Support, Sign, Notes) - Support level circles (1-4 scale) for quick marking - Sign interest circles (R/L for Right/Left yard placement) - Volunteer name, date, and area/cut fields - Print-optimized styling with CSS @media print rules

Layout: Full AppLayout with Print button in header

Dependencies: - Ant Design v5 (Button, Typography, Spin, App) - react-router-dom (useOutletContext) - QR code generation via /api/qr endpoint

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#1-customizable-header","title":"1. Customizable Header","text":"

Configurable Fields: - Walk Sheet Title: Main heading (e.g., \"Volunteer Canvassing Walk Sheet\") - Walk Sheet Subtitle: Optional subtitle (e.g., \"Ward 5 - Downtown District\")

Source: MapSettings.walkSheetTitle and MapSettings.walkSheetSubtitle

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#2-qr-code-section","title":"2. QR Code Section","text":"

Up to 3 QR Codes: - Each QR code has: - URL: Link to encode in QR code (e.g., campaign page, response wall, shift signup) - Label: Descriptive text below QR code (e.g., \"Report In\", \"Submit Response\", \"Sign Up\") - QR codes displayed horizontally centered - 80\u00d780 pixel size - Generated via /api/qr?text={url}&size=100 endpoint

Source: MapSettings.qrCode1Url/Label, qrCode2Url/Label, qrCode3Url/Label

Example QR Code URLs: - https://cmlite.org/responses/1 - Response submission page - https://cmlite.org/shifts - Shift signup page - https://cmlite.org/campaigns - Campaign listing

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#3-volunteer-information-fields","title":"3. Volunteer Information Fields","text":"

Pre-printed Fields: - Volunteer: Name line (200px underline) - Date: Date line (120px underline) - Area/Cut: Assignment line (120px underline)

Purpose: Volunteer fills these in by hand before starting canvass

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#4-contact-table","title":"4. Contact Table","text":"

12 Rows with 8 Columns:

  1. # (Number): Row number 1-12 (pre-printed)
  2. Name: Blank field for recording contact name
  3. Address / Unit: Blank field for building address and unit number
  4. Email: Blank field for contact email
  5. Phone: Blank field for contact phone number
  6. Support: 4 circles for support level (1-4 scale)
  7. Circle 1 = Strong Support
  8. Circle 2 = Likely Support
  9. Circle 3 = Unsure
  10. Circle 4 = Oppose
  11. Sign: 2 circles for lawn sign interest (R/L)
  12. R = Right side of entrance
  13. L = Left side of entrance
  14. Notes: Blank field for additional notes

Table Styling: - 1px solid borders - 11px font size (print-optimized) - 28px row height (sufficient for handwriting) - Compact padding (4px\u00d76px)

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#5-customizable-footer","title":"5. Customizable Footer","text":"

Footer Text: Optional footer message (e.g., \"Thank you for volunteering!\")

Source: MapSettings.walkSheetFooter

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#6-print-optimization","title":"6. Print Optimization","text":"

CSS @media print Rules: - Hides everything except .walk-sheet-print container - Positions walk sheet at absolute top-left - Reduces font size to 11px for compact printing - Optimizes table borders for clear printing - Hides Print button (no-print class)

Print Trigger: \"Print\" button in page header (calls window.print())

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#configuring-walk-sheet-settings","title":"Configuring Walk Sheet Settings","text":"
  1. Navigate to Map Settings:
  2. Click \"Map\" \u2192 \"Settings\" in sidebar
  3. Scroll to \"Walk Sheet Configuration\" section

  4. Set Walk Sheet Title:

  5. Enter title (e.g., \"Volunteer Canvassing Walk Sheet\")
  6. This appears as main heading on printed sheet

  7. Set Walk Sheet Subtitle (Optional):

  8. Enter subtitle (e.g., \"Ward 5 - Downtown District\")
  9. Appears below title in smaller font

  10. Configure QR Codes (Up to 3):

  11. QR Code 1:
    • URL: Enter full URL to encode (e.g., https://cmlite.org/responses/1)
    • Label: Enter descriptive label (e.g., \"Submit Response\")
  12. QR Code 2: (Optional)
    • URL + Label
  13. QR Code 3: (Optional)
    • URL + Label
  14. QR codes appear centered above contact table

  15. Set Footer Text (Optional):

  16. Enter footer message (e.g., \"Thank you for your time!\")
  17. Appears at bottom of printed sheet

  18. Save Settings:

  19. Click \"Save\" button in Map Settings page
  20. Settings applied to all future walk sheets
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#printing-a-walk-sheet","title":"Printing a Walk Sheet","text":"
  1. Navigate to Walk Sheet:
  2. Click \"Map\" \u2192 \"Walk Sheet\" in sidebar
  3. Page loads with preview of walk sheet

  4. Review Preview:

  5. Check title, subtitle, QR codes
  6. Verify table has 12 rows
  7. Confirm footer text appears

  8. Print Walk Sheet:

  9. Click \"Print\" button in page header
  10. OR press Ctrl+P (Windows/Linux) or Cmd+P (Mac)
  11. Browser print dialog opens

  12. Configure Print Settings:

  13. Orientation: Portrait (recommended)
  14. Paper Size: Letter (8.5\" \u00d7 11\")
  15. Margins: Default or minimal
  16. Background graphics: ON (to print table borders clearly)

  17. Print or Save PDF:

  18. Click \"Print\" to send to printer
  19. OR select \"Save as PDF\" to create digital copy
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#using-walk-sheet-during-canvassing","title":"Using Walk Sheet During Canvassing","text":"
  1. Before Starting:
  2. Print walk sheet (1 per cut/area)
  3. Fill in volunteer name, date, area/cut at top

  4. At Each Door:

  5. Record contact information in next empty row:
    • Name
    • Address / Unit (if multi-unit building)
    • Email (if provided)
    • Phone (if provided)
  6. Circle support level (1-4)
  7. Circle sign interest (R/L) if applicable
  8. Write notes (e.g., \"Call back after 6pm\", \"Not home\")

  9. Completing Walk Sheet:

  10. Fill all 12 rows OR complete area
  11. Return walk sheet to campaign organizer
  12. Organizer enters data into system via Admin GUI

  13. QR Code Usage:

  14. Volunteers can scan QR codes with phone to:
    • Report their location/status
    • Submit response directly to response wall
    • Access campaign resources
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#main-component-structure","title":"Main Component Structure","text":"
export default function WalkSheetPage() {\n  const { setPageHeader } = useOutletContext<AppOutletContext>();\n  const { message } = App.useApp();\n  const [settings, setSettings] = useState<MapSettings | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  // Set page header with Print button\n  useEffect(() => {\n    setPageHeader({\n      title: 'Walk Sheet',\n      actions: (\n        <Button icon={<PrinterOutlined />} onClick={() => window.print()}>\n          Print\n        </Button>\n      ),\n    });\n    return () => setPageHeader(null);\n  }, [setPageHeader]);\n\n  // Load map settings (for walk sheet config)\n  useEffect(() => {\n    api.get('/map/settings')\n      .then(({ data }) => setSettings(data))\n      .catch(() => message.error('Failed to load settings'))\n      .finally(() => setLoading(false));\n  }, []);\n\n  if (loading) {\n    return <Spin size=\"large\" />;\n  }\n\n  // Filter QR codes (only include if URL provided)\n  const qrCodes = [\n    { url: settings?.qrCode1Url, label: settings?.qrCode1Label },\n    { url: settings?.qrCode2Url, label: settings?.qrCode2Label },\n    { url: settings?.qrCode3Url, label: settings?.qrCode3Label },\n  ].filter((q) => q.url);\n\n  // Generate 12 empty rows\n  const rows = Array.from({ length: 12 }, (_, i) => i);\n\n  return (\n    <>\n      <style>{/* Print CSS rules */}</style>\n\n      <div className=\"walk-sheet-print\">\n        {/* Header */}\n        <Title level={3}>{settings?.walkSheetTitle || 'Walk Sheet'}</Title>\n        {settings?.walkSheetSubtitle && <Text>{settings.walkSheetSubtitle}</Text>}\n\n        {/* QR Codes */}\n        {qrCodes.map((qr) => (\n          <img src={`/api/qr?text=${qr.url}&size=100`} alt={qr.label} />\n        ))}\n\n        {/* Volunteer Info */}\n        <div>\n          <Text strong>Volunteer: </Text><span className=\"underline\" />\n          <Text strong>Date: </Text><span className=\"underline\" />\n          <Text strong>Area/Cut: </Text><span className=\"underline\" />\n        </div>\n\n        {/* Contact Table */}\n        <table>\n          <thead>\n            <tr>\n              <th>#</th>\n              <th>Name</th>\n              <th>Address / Unit</th>\n              <th>Email</th>\n              <th>Phone</th>\n              <th>Support</th>\n              <th>Sign</th>\n              <th>Notes</th>\n            </tr>\n          </thead>\n          <tbody>\n            {rows.map((i) => (\n              <tr key={i}>\n                <td>{i + 1}</td>\n                <td>&nbsp;</td> {/* Blank cells for handwriting */}\n                {/* ... more blank cells ... */}\n                <td> {/* Support circles */}\n                  <span className=\"support-circle\">1</span>\n                  <span className=\"support-circle\">2</span>\n                  <span className=\"support-circle\">3</span>\n                  <span className=\"support-circle\">4</span>\n                </td>\n                <td> {/* Sign circles */}\n                  <span className=\"support-circle\">R</span>\n                  <span className=\"support-circle\">L</span>\n                </td>\n                <td>&nbsp;</td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n\n        {/* Footer */}\n        {settings?.walkSheetFooter && <Text>{settings.walkSheetFooter}</Text>}\n      </div>\n    </>\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#ant-design-components-used","title":"Ant Design Components Used","text":"
  1. Button - Print button in page header
  2. Typography.Title - Walk sheet main heading
  3. Typography.Text - Subtitle, labels, footer text
  4. Spin - Loading indicator while settings fetch
  5. App.useApp() - Toast message for errors
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#print-css-styling","title":"Print CSS Styling","text":"
<style>{`\n  @media print {\n    /* Hide everything except walk sheet */\n    body * { visibility: hidden; }\n    .walk-sheet-print, .walk-sheet-print * { visibility: visible; }\n\n    /* Position walk sheet at top-left */\n    .walk-sheet-print {\n      position: absolute;\n      left: 0;\n      top: 0;\n      width: 100%;\n      font-size: 11px;\n    }\n\n    /* Hide Print button */\n    .walk-sheet-print .no-print { display: none !important; }\n  }\n\n  /* Table styling (screen + print) */\n  .walk-sheet-print table {\n    width: 100%;\n    border-collapse: collapse;\n  }\n\n  .walk-sheet-print th,\n  .walk-sheet-print td {\n    border: 1px solid #555;\n    padding: 4px 6px;\n    text-align: left;\n    font-size: 11px;\n  }\n\n  .walk-sheet-print th {\n    background: rgba(255,255,255,0.05);\n    font-weight: 600;\n  }\n\n  /* Support level circles */\n  .support-circle {\n    display: inline-block;\n    width: 16px;\n    height: 16px;\n    border: 1.5px solid rgba(255,255,255,0.4);\n    border-radius: 50%;\n    text-align: center;\n    line-height: 14px;\n    font-size: 9px;\n    margin-right: 2px;\n  }\n`}</style>\n

Key Print Rules: - visibility: hidden on all elements except .walk-sheet-print - Absolute positioning at top-left (0, 0) - 11px base font size (compact, readable when printed) - Solid borders on table cells for clear gridlines - Support circles rendered as border-only circles (volunteer fills in by hand)

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#local-component-state-usestate","title":"Local Component State (useState)","text":"

No Zustand stores used - All state managed locally with React hooks.

// Map settings state (loaded from API)\nconst [settings, setSettings] = useState<MapSettings | null>(null);\n\n// Loading state\nconst [loading, setLoading] = useState(true);\n
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#state-flow","title":"State Flow","text":"
  1. Component Mounts:
  2. loadSettings() called in useEffect
  3. Fetches map settings via GET /api/map/settings
  4. Sets settings state
  5. Sets loading to false

  6. Settings Loaded:

  7. Extracts walk sheet configuration:
    • walkSheetTitle, walkSheetSubtitle, walkSheetFooter
    • qrCode1Url/Label, qrCode2Url/Label, qrCode3Url/Label
  8. Filters QR codes (only include if URL provided)
  9. Renders walk sheet with settings

  10. User Clicks Print:

  11. window.print() called
  12. Browser opens print dialog
  13. Print CSS rules activate
  14. Walk sheet rendered in print layout
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#endpoints-used","title":"Endpoints Used","text":"
  1. GET /api/map/settings - Fetch map settings (including walk sheet config)
  2. GET /api/qr - Generate QR code PNG (public endpoint, no auth)
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#api-client","title":"API Client","text":"
import { api } from '@/lib/api';\n\n// All authenticated requests use API client with automatic token refresh\n
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#example-api-calls","title":"Example API Calls","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#1-load-map-settings","title":"1. Load Map Settings","text":"
useEffect(() => {\n  api.get('/map/settings')\n    .then(({ data }) => {\n      setSettings(data);\n    })\n    .catch(() => {\n      message.error('Failed to load settings');\n    })\n    .finally(() => {\n      setLoading(false);\n    });\n}, []);\n

Response Format:

{\n  \"id\": 1,\n  \"walkSheetTitle\": \"Volunteer Canvassing Walk Sheet\",\n  \"walkSheetSubtitle\": \"Ward 5 - Downtown District\",\n  \"walkSheetFooter\": \"Thank you for volunteering!\",\n  \"qrCode1Url\": \"https://cmlite.org/responses/1\",\n  \"qrCode1Label\": \"Submit Response\",\n  \"qrCode2Url\": \"https://cmlite.org/shifts\",\n  \"qrCode2Label\": \"Sign Up for Shift\",\n  \"qrCode3Url\": null,\n  \"qrCode3Label\": null,\n  \"centerLat\": 45.5017,\n  \"centerLng\": -73.5673,\n  \"defaultZoom\": 13,\n  \"updatedAt\": \"2025-02-11T10:00:00Z\"\n}\n

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#2-generate-qr-code-embedded-in-img-src","title":"2. Generate QR Code (Embedded in img src)","text":"
const API_BASE = import.meta.env.VITE_API_URL || '';\n\n<img\n  src={`${API_BASE}/api/qr?text=${encodeURIComponent(qr.url)}&size=100`}\n  alt={qr.label || 'QR'}\n  style={{ width: 80, height: 80 }}\n/>\n

Endpoint: GET /api/qr?text={url}&size={pixels}

Query Parameters: - text (required): URL or text to encode in QR code - size (optional): QR code pixel size (default: 200)

Response: PNG image (binary data)

Example URL:

http://api.cmlite.org/api/qr?text=https%3A%2F%2Fcmlite.org%2Fresponses%2F1&size=100\n

Note: This endpoint is public (no authentication required) to allow QR codes to be scanned by anyone.

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#complete-component-with-print-button","title":"Complete Component with Print Button","text":"
import { useEffect, useState } from 'react';\nimport { Button, Typography, Spin, App } from 'antd';\nimport { PrinterOutlined } from '@ant-design/icons';\nimport { useOutletContext } from 'react-router-dom';\nimport { api } from '@/lib/api';\nimport type { AppOutletContext } from '@/components/AppLayout';\nimport type { MapSettings } from '@/types/api';\n\nconst { Title, Text } = Typography;\nconst API_BASE = import.meta.env.VITE_API_URL || '';\n\nexport default function WalkSheetPage() {\n  const { setPageHeader } = useOutletContext<AppOutletContext>();\n  const { message } = App.useApp();\n  const [settings, setSettings] = useState<MapSettings | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  // Set page header with Print button\n  useEffect(() => {\n    setPageHeader({\n      title: 'Walk Sheet',\n      actions: (\n        <Button icon={<PrinterOutlined />} onClick={() => window.print()}>\n          Print\n        </Button>\n      ),\n    });\n    return () => setPageHeader(null);\n  }, [setPageHeader]);\n\n  // Load settings\n  useEffect(() => {\n    api.get('/map/settings')\n      .then(({ data }) => setSettings(data))\n      .catch(() => message.error('Failed to load settings'))\n      .finally(() => setLoading(false));\n  }, [message]);\n\n  if (loading) {\n    return <div style={{ textAlign: 'center', padding: 48 }}><Spin size=\"large\" /></div>;\n  }\n\n  // Filter QR codes (only show if URL provided)\n  const qrCodes = [\n    { url: settings?.qrCode1Url, label: settings?.qrCode1Label },\n    { url: settings?.qrCode2Url, label: settings?.qrCode2Label },\n    { url: settings?.qrCode3Url, label: settings?.qrCode3Label },\n  ].filter((q) => q.url);\n\n  // Generate 12 empty rows\n  const rows = Array.from({ length: 12 }, (_, i) => i);\n\n  return (\n    <>\n      <style>{`\n        @media print {\n          body * { visibility: hidden; }\n          .walk-sheet-print, .walk-sheet-print * { visibility: visible; }\n          .walk-sheet-print {\n            position: absolute;\n            left: 0;\n            top: 0;\n            width: 100%;\n            font-size: 11px;\n          }\n          .walk-sheet-print .no-print { display: none !important; }\n        }\n        .walk-sheet-print table {\n          width: 100%;\n          border-collapse: collapse;\n        }\n        .walk-sheet-print th,\n        .walk-sheet-print td {\n          border: 1px solid #555;\n          padding: 4px 6px;\n          text-align: left;\n          font-size: 11px;\n        }\n        .walk-sheet-print th {\n          background: rgba(255,255,255,0.05);\n          font-weight: 600;\n        }\n        .support-circle {\n          display: inline-block;\n          width: 16px;\n          height: 16px;\n          border: 1.5px solid rgba(255,255,255,0.4);\n          border-radius: 50%;\n          text-align: center;\n          line-height: 14px;\n          font-size: 9px;\n          margin-right: 2px;\n        }\n      `}</style>\n\n      <div className=\"walk-sheet-print\">\n        {/* Header */}\n        <div style={{ textAlign: 'center', marginBottom: 16 }}>\n          <Title level={3} style={{ marginBottom: 2 }}>\n            {settings?.walkSheetTitle || 'Walk Sheet'}\n          </Title>\n          {settings?.walkSheetSubtitle && (\n            <Text type=\"secondary\" style={{ fontSize: 14 }}>{settings.walkSheetSubtitle}</Text>\n          )}\n        </div>\n\n        {/* QR Codes */}\n        {qrCodes.length > 0 && (\n          <div style={{ display: 'flex', justifyContent: 'center', gap: 24, marginBottom: 16 }}>\n            {qrCodes.map((qr, idx) => (\n              <div key={idx} style={{ textAlign: 'center' }}>\n                <img\n                  src={`${API_BASE}/api/qr?text=${encodeURIComponent(qr.url!)}&size=100`}\n                  alt={qr.label || 'QR'}\n                  style={{ width: 80, height: 80 }}\n                />\n                {qr.label && (\n                  <div style={{ fontSize: 10, marginTop: 2 }}>\n                    <Text type=\"secondary\">{qr.label}</Text>\n                  </div>\n                )}\n              </div>\n            ))}\n          </div>\n        )}\n\n        {/* Volunteer info line */}\n        <div style={{ display: 'flex', gap: 16, marginBottom: 12 }}>\n          <div style={{ flex: 1 }}>\n            <Text strong>Volunteer: </Text>\n            <span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 200 }}>&nbsp;</span>\n          </div>\n          <div>\n            <Text strong>Date: </Text>\n            <span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 120 }}>&nbsp;</span>\n          </div>\n          <div>\n            <Text strong>Area/Cut: </Text>\n            <span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 120 }}>&nbsp;</span>\n          </div>\n        </div>\n\n        {/* Contact table */}\n        <table>\n          <thead>\n            <tr>\n              <th style={{ width: 20 }}>#</th>\n              <th>Name</th>\n              <th>Address / Unit</th>\n              <th>Email</th>\n              <th>Phone</th>\n              <th style={{ width: 70 }}>Support</th>\n              <th style={{ width: 50 }}>Sign</th>\n              <th>Notes</th>\n            </tr>\n          </thead>\n          <tbody>\n            {rows.map((i) => (\n              <tr key={i}>\n                <td style={{ textAlign: 'center' }}>{i + 1}</td>\n                <td style={{ height: 28 }}>&nbsp;</td>\n                <td>&nbsp;</td>\n                <td>&nbsp;</td>\n                <td>&nbsp;</td>\n                <td style={{ textAlign: 'center' }}>\n                  <span className=\"support-circle\">1</span>\n                  <span className=\"support-circle\">2</span>\n                  <span className=\"support-circle\">3</span>\n                  <span className=\"support-circle\">4</span>\n                </td>\n                <td style={{ textAlign: 'center' }}>\n                  <span className=\"support-circle\">R</span>\n                  <span className=\"support-circle\">L</span>\n                </td>\n                <td>&nbsp;</td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n\n        {/* Footer */}\n        {settings?.walkSheetFooter && (\n          <div style={{ marginTop: 16, fontSize: 11, textAlign: 'center' }}>\n            <Text type=\"secondary\">{settings.walkSheetFooter}</Text>\n          </div>\n        )}\n      </div>\n    </>\n  );\n}\n
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#qr-code-generation-pattern","title":"QR Code Generation Pattern","text":"
// 1. In component\nconst API_BASE = import.meta.env.VITE_API_URL || '';\n\n// 2. In JSX\n<img\n  src={`${API_BASE}/api/qr?text=${encodeURIComponent(url)}&size=${size}`}\n  alt=\"QR Code\"\n  style={{ width: size, height: size }}\n/>\n\n// Examples:\n// Campaign response page\nconst url1 = 'https://cmlite.org/responses/1';\n<img src={`${API_BASE}/api/qr?text=${encodeURIComponent(url1)}&size=100`} />\n\n// Shift signup page\nconst url2 = 'https://cmlite.org/shifts';\n<img src={`${API_BASE}/api/qr?text=${encodeURIComponent(url2)}&size=150`} />\n

Important: Always use encodeURIComponent() to escape special characters in URL.

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#print-trigger-pattern","title":"Print Trigger Pattern","text":"
// 1. Add Print button to page header\nuseEffect(() => {\n  setPageHeader({\n    title: 'Walk Sheet',\n    actions: (\n      <Button icon={<PrinterOutlined />} onClick={() => window.print()}>\n        Print\n      </Button>\n    ),\n  });\n  return () => setPageHeader(null);\n}, [setPageHeader]);\n\n// 2. Add print CSS rules\n<style>{`\n  @media print {\n    body * { visibility: hidden; }\n    .printable-content, .printable-content * { visibility: visible; }\n    .printable-content {\n      position: absolute;\n      left: 0;\n      top: 0;\n      width: 100%;\n    }\n  }\n`}</style>\n\n// 3. Wrap content in printable class\n<div className=\"printable-content\">\n  {/* Content to print */}\n</div>\n
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#1-single-settings-fetch","title":"1. Single Settings Fetch","text":"

Settings loaded once on mount:

useEffect(() => {\n  api.get('/map/settings')\n    .then(({ data }) => setSettings(data))\n    .catch(() => message.error('Failed to load settings'))\n    .finally(() => setLoading(false));\n}, []); // Empty dependency array = run once\n

Benefit: Minimizes API requests. Settings cached in state until page unmount.

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#2-inline-qr-code-images","title":"2. Inline QR Code Images","text":"

QR codes embedded as <img> tags with src pointing to QR API:

<img src={`${API_BASE}/api/qr?text=${url}&size=100`} />\n

Benefit: Browser caches QR code images. No JavaScript overhead for QR generation.

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#3-static-row-generation","title":"3. Static Row Generation","text":"

12 rows generated once with Array.from():

const rows = Array.from({ length: 12 }, (_, i) => i);\n\n{rows.map((i) => <tr key={i}>...</tr>)}\n

Benefit: Simple, performant array mapping. No state updates or re-renders.

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#4-print-css-optimization","title":"4. Print CSS Optimization","text":"

Print rules use visibility: hidden instead of display: none:

body * { visibility: hidden; }\n.walk-sheet-print, .walk-sheet-print * { visibility: visible; }\n

Benefit: Preserves layout and spacing. Prevents reflow during print preparation.

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#no-mobile-responsiveness-needed","title":"No Mobile Responsiveness Needed","text":"

Walk sheet is print-only - not designed for mobile viewing: - Page intended for desktop browsers with print capability - Print layout fixed at Letter size (8.5\" \u00d7 11\") - No mobile-specific styling or breakpoints

Rationale: Physical walk sheets used by volunteers in field, printed from desktop computers before canvassing.

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#print-layout","title":"Print Layout","text":"
@media print {\n  .walk-sheet-print {\n    position: absolute;\n    left: 0;\n    top: 0;\n    width: 100%;\n    font-size: 11px;\n  }\n\n  @page {\n    size: letter portrait;\n    margin: 0.5in;\n  }\n}\n

Fixed Layout: - Letter size paper (8.5\" \u00d7 11\") - Portrait orientation - 0.5\" margins all sides - 11px base font size (fits 12 rows + header/footer on one page)

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#print-only-page","title":"Print-Only Page","text":"

Walk sheet is physical form, not interactive UI. Accessibility considerations minimal:

  1. Semantic HTML:
  2. <table> for contact grid
  3. <th> for column headers
  4. <td> for data cells

  5. Print Button:

  6. Keyboard accessible (Tab + Enter)
  7. Icon + text label (\"Print\")
  8. ARIA label implicit from button text

  9. Screen Reader Support:

  10. Table headers announced for each column
  11. Row numbers read in sequence
  12. QR code alt attributes describe purpose

Note: Once printed, walk sheet relies on visual cues (circles, lines, table borders) for volunteers to fill in by hand.

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#problem-qr-codes-not-appearing","title":"Problem: QR Codes Not Appearing","text":"

Symptoms: - Walk sheet loads but QR code section is empty - Expected 1-3 QR codes but none visible

Causes: 1. No QR code URLs configured in Map Settings 2. QR API endpoint not responding 3. CORS issues blocking QR images

Solutions:

  1. Check Map Settings:
  2. Navigate to \"Map\" \u2192 \"Settings\"
  3. Scroll to \"Walk Sheet Configuration\"
  4. Verify QR Code URLs filled in:
    • qrCode1Url, qrCode2Url, qrCode3Url
  5. At least one URL must be provided

  6. Test QR API endpoint:

    curl http://localhost:4000/api/qr?text=https://example.com&size=100 --output test-qr.png\n

  7. Should return PNG image
  8. Open test-qr.png to verify QR code generated

  9. Check browser console:

  10. Open DevTools (F12)
  11. Go to Network tab
  12. Refresh walk sheet page
  13. Look for /api/qr?text=... requests
  14. Check status codes (should be 200)
  15. If 404, QR API route not registered
  16. If CORS error, check nginx CORS headers

  17. Verify API base URL:

  18. Check .env file: VITE_API_URL=http://localhost:4000
  19. Restart admin dev server after changing .env
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#problem-walk-sheet-doesnt-print-correctly","title":"Problem: Walk Sheet Doesn't Print Correctly","text":"

Symptoms: - Print preview shows blank page - Print preview shows partial content - Table borders not visible when printed

Causes: 1. Browser print settings incorrect 2. Background graphics disabled 3. Print CSS not applying 4. Page margins too large

Solutions:

  1. Enable background graphics:
  2. In print dialog, check \"Background graphics\" option
  3. This ensures table borders and support circles print

  4. Adjust page margins:

  5. In print dialog, set margins to \"Default\" or \"Minimal\"
  6. Too large margins can cut off content

  7. Verify print CSS:

  8. View print preview (Ctrl+P or Cmd+P)
  9. Check that only walk sheet visible (no sidebar, no header)
  10. If other elements visible, print CSS not applying

  11. Check browser zoom:

  12. Reset zoom to 100% (Ctrl+0 or Cmd+0)
  13. Print preview at wrong zoom can cause layout issues

  14. Try different browser:

  15. Chrome, Firefox, and Edge have different print engines
  16. If one fails, try another
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#problem-support-circles-too-small-when-printed","title":"Problem: Support Circles Too Small When Printed","text":"

Symptoms: - Support level circles (1-4) and sign circles (R/L) are too small to fill in by hand - Volunteers complain circles hard to mark

Causes: 1. Print scaling set to \"Fit to page\" (shrinks content) 2. Circle size optimized for screen, not print

Solutions:

  1. Check print scaling:
  2. In print dialog, set scale to \"100%\" (not \"Fit to page\")
  3. \"Fit to page\" shrinks content to fit, making circles smaller

  4. Adjust circle size in code:

  5. Edit WalkSheetPage.tsx
  6. Increase .support-circle dimensions:
    .support-circle {\n  width: 20px;  /* Was 16px */\n  height: 20px; /* Was 16px */\n  font-size: 11px; /* Was 9px */\n}\n
  7. Save and refresh page
  8. Print again to test

  9. Increase row height:

  10. More vertical space gives volunteers more room to mark:
    <td style={{ height: 32 }}>&nbsp;</td> {/* Was 28px */}\n
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#problem-footer-text-cut-off","title":"Problem: Footer Text Cut Off","text":"

Symptoms: - Footer message not visible in print preview - Footer appears on screen but missing when printed

Causes: 1. Page margins cutting off bottom content 2. Footer outside printable area 3. Content too tall for one page

Causes: 1. Footer outside printable area due to margins 2. Content too tall to fit on one page

Solutions:

  1. Reduce page margins:
  2. In print dialog, set margins to \"Minimal\" (0.25\")
  3. This gives more vertical space for content

  4. Reduce font sizes:

  5. Edit print CSS:
    @media print {\n  .walk-sheet-print { font-size: 10px !important; } /* Was 11px */\n  .walk-sheet-print table { font-size: 8px !important; } /* Was 9px */\n}\n
  6. Smaller fonts = more content fits on page

  7. Reduce number of rows:

  8. If footer consistently cut off, reduce rows from 12 to 10:

    const rows = Array.from({ length: 10 }, (_, i) => i); // Was 12\n

  9. Remove footer (temporary):

  10. If footer not essential, remove from Map Settings:
    • Navigate to \"Map\" \u2192 \"Settings\"
    • Clear \"Walk Sheet Footer\" field
    • Save settings
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#problem-titlesubtitle-not-appearing","title":"Problem: Title/Subtitle Not Appearing","text":"

Symptoms: - Walk sheet header shows default \"Walk Sheet\" instead of custom title - Subtitle missing entirely

Causes: 1. Map Settings not saved correctly 2. API not returning settings 3. Settings state null/undefined

Solutions:

  1. Verify Map Settings saved:
  2. Navigate to \"Map\" \u2192 \"Settings\"
  3. Check \"Walk Sheet Title\" and \"Walk Sheet Subtitle\" fields
  4. Re-enter values if blank
  5. Click \"Save\" button
  6. Success message should appear

  7. Check browser console:

  8. Open DevTools (F12)
  9. Go to Console tab
  10. Look for error messages
  11. If \"Failed to load settings\", API request failed

  12. Check Network tab:

  13. Open DevTools (F12)
  14. Go to Network tab
  15. Refresh walk sheet page
  16. Look for GET /api/map/settings request
  17. Check Response tab for settings data:
    {\n  \"walkSheetTitle\": \"...\",\n  \"walkSheetSubtitle\": \"...\"\n}\n
  18. If fields null/missing, settings not saved in database

  19. Check database:

    docker compose exec api npx prisma studio\n# Navigate to MapSettings table\n# Verify walkSheetTitle and walkSheetSubtitle columns populated\n

"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#backend-documentation","title":"Backend Documentation","text":"
  • Map Settings Module - MapSettings CRUD API
  • QR Routes - QR code generation endpoint
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#frontend-documentation","title":"Frontend Documentation","text":"
  • MapSettingsPage - Configure walk sheet title, QR codes, footer
  • CutExportPage - Printable cut location report (related printable page)
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#feature-documentation","title":"Feature Documentation","text":"
  • Canvassing System - Complete volunteer canvassing workflow
  • Walk Sheet Workflow - Physical walk sheet \u2192 digital data entry
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#api-documentation","title":"API Documentation","text":"
  • GET /api/map/settings - Fetch map settings
  • GET /api/qr - Generate QR code PNG
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#user-guides","title":"User Guides","text":"
  • Volunteer Guide - Using walk sheets during canvassing
  • Campaign Organizer Guide - Walk sheet configuration and printing
"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#deployment-documentation","title":"Deployment Documentation","text":"
  • QR Service Setup - Mini QR Docker container
  • Printing Best Practices - Print server configuration for campaign offices
"},{"location":"v2/frontend/pages/public/","title":"Public Pages","text":"

Public pages provide the public-facing interface for campaign supporters and volunteers. These pages are accessible without authentication and use a dark theme for visual consistency.

"},{"location":"v2/frontend/pages/public/#route-context","title":"Route Context","text":"
  • Prefix: Various (/campaigns, /map, /shifts, /p/:slug, /media)
  • Layout: PublicLayout (dark theme)
  • Auth Required: No
  • Theme: Dark blue/teal (#0d1b2a background)
"},{"location":"v2/frontend/pages/public/#campaign-pages","title":"Campaign Pages","text":""},{"location":"v2/frontend/pages/public/#campaigns-list-page","title":"Campaigns List Page","text":"

Route: /campaigns

Featured campaign listing:

  • Hero section with call-to-action
  • Featured campaigns grid
  • Campaign cards with images
  • Search and filter (future)
  • Responsive grid layout

Features: - Public campaign discovery - Featured campaigns first - Card-based design - Mobile responsive

"},{"location":"v2/frontend/pages/public/#campaign-page","title":"Campaign Page","text":"

Route: /campaigns/:id

Campaign detail and action page:

  • Campaign description
  • Target representatives lookup
  • Email form with templates
  • Progress tracking
  • Social sharing

Features: - Postal code \u2192 representative lookup - Email to representatives - Form validation - Success confirmation - Response submission link

"},{"location":"v2/frontend/pages/public/#response-wall-page","title":"Response Wall Page","text":"

Route: /responses/:campaignId

Public response submissions and viewing:

  • Response submission form
  • Email verification flow
  • Response list (verified only)
  • Upvoting system
  • Sorting options

Features: - Submit responses anonymously - Email verification required - Upvote responses - Sort by newest/popular - Responsive cards

"},{"location":"v2/frontend/pages/public/#map-location-pages","title":"Map & Location Pages","text":""},{"location":"v2/frontend/pages/public/#map-page","title":"Map Page","text":"

Route: /map

Public interactive map:

  • Leaflet map with locations
  • Color-coded markers by status
  • Geographic cuts overlay
  • Cut visibility controls
  • Geolocate button
  • Fullscreen mode
  • Map legend

Features: - OpenStreetMap tiles - Custom markers - Polygon overlays - Popup information - Mobile responsive

"},{"location":"v2/frontend/pages/public/#shifts-page","title":"Shifts Page","text":"

Route: /shifts

Public shift signup:

  • Shift cards by date
  • Cut information
  • Signup modal
  • Temp user creation
  • Email confirmation

Features: - Filter by date/cut - Quick signup flow - Anonymous signups (creates TEMP user) - Email notifications - Mobile responsive

"},{"location":"v2/frontend/pages/public/#content-pages","title":"Content Pages","text":""},{"location":"v2/frontend/pages/public/#landing-page","title":"Landing Page","text":"

Route: /p/:slug

Rendered landing pages:

  • Custom HTML/CSS content
  • GrapesJS block rendering
  • Responsive design
  • SEO metadata
  • Custom scripts support

Features: - Dynamic content from database - Custom styling - Block-based layout - Published pages only

"},{"location":"v2/frontend/pages/public/#media-pages","title":"Media Pages","text":""},{"location":"v2/frontend/pages/public/#media-gallery-page","title":"Media Gallery Page","text":"

Route: /media

Public video gallery:

  • Shared videos grid
  • Category filtering
  • Search functionality
  • Reaction system (6 emojis)
  • Video cards with thumbnails

Features: - Public videos only (unlocked + shared) - Responsive grid - Click to view details - Emoji reactions - Mobile responsive

"},{"location":"v2/frontend/pages/public/#media-viewer-page","title":"Media Viewer Page","text":"

Route: /media/:id

Video detail page:

  • Video player
  • Title and description
  • Reaction buttons
  • Related videos
  • Share options

Features: - HTML5 video player - Reaction tracking - Social sharing - Mobile responsive

"},{"location":"v2/frontend/pages/public/#public-page-count","title":"Public Page Count","text":"

Total: 8 public pages

"},{"location":"v2/frontend/pages/public/#common-features","title":"Common Features","text":"

Public pages share:

  • Dark Theme - Blue/teal color scheme (#0d1b2a)
  • No Authentication - Open access
  • Responsive Design - Mobile-first approach
  • Grid Breakpoints - Uses Grid.useBreakpoint()
  • Loading States - Spinners and skeletons
  • Error Handling - User-friendly messages
  • SEO Friendly - Meta tags, semantic HTML
"},{"location":"v2/frontend/pages/public/#theme-colors","title":"Theme Colors","text":"
colorBgBase: '#0d1b2a'       // Dark navy background\ncolorBgContainer: '#1b2838'  // Container background\ncolorPrimary: '#3498db'      // Bright blue accent\ncolorLink: '#3498db'         // Link color\ncolorText: '#e0e0e0'         // Light text\ncolorTextSecondary: '#a0a0a0' // Secondary text\n
"},{"location":"v2/frontend/pages/public/#layout-structure","title":"Layout Structure","text":"

Public pages use PublicLayout which provides:

  • Header
  • Logo/branding
  • Navigation links
  • Login button (when not authenticated)

  • Content Area

  • Full-width container
  • Responsive padding
  • Dark theme styling

  • Footer

  • Contact links
  • About information
  • Social media
  • Copyright
"},{"location":"v2/frontend/pages/public/#mobile-responsiveness","title":"Mobile Responsiveness","text":"

Public pages are optimized for mobile:

  • Touch-friendly controls
  • Responsive grids
  • Mobile navigation
  • Optimized forms
  • Fast loading
"},{"location":"v2/frontend/pages/public/#api-integration","title":"API Integration","text":"

Public pages use direct axios (no auth interceptor):

import axios from 'axios';\n\nconst response = await axios.get(\n  `${import.meta.env.VITE_API_URL}/api/campaigns/public`\n);\n

Admin pages use authenticated api client from lib/api.ts.

"},{"location":"v2/frontend/pages/public/#form-validation","title":"Form Validation","text":"

Public forms use Zod validation:

const emailSchema = z.object({\n  email: z.string().email(),\n  message: z.string().min(10),\n});\n
"},{"location":"v2/frontend/pages/public/#related-documentation","title":"Related Documentation","text":"
  • Frontend Pages Overview
  • Admin Pages
  • Volunteer Pages
  • Public Layout
  • Backend Public Routes
  • Campaign Features
  • Map Features
"},{"location":"v2/frontend/pages/public/campaign-page/","title":"Campaign Detail Page","text":""},{"location":"v2/frontend/pages/public/campaign-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/public/CampaignPage.tsx (613 lines)

Route: /campaigns/:id

Role Requirements: Public access (no authentication required)

Purpose: Individual campaign detail page providing a complete advocacy workflow from representative lookup through email sending, with optional response wall integration and social sharing capabilities.

Key Features:

  • 3-step guided process: Info \u2192 Reps \u2192 Send
  • Step indicator with clickable navigation
  • Hero section with cover photo and real-time statistics
  • Postal code-based representative lookup with government level filtering
  • Dual email sending options: SMTP (tracked) and Email App (mailto)
  • Live email preview with optional editing
  • Response wall integration with CTA button
  • Social sharing buttons
  • Dark blue/teal theme consistent with public pages
  • Mobile-responsive with hamburger navigation

Layout: Uses PublicLayout component with dark theme

"},{"location":"v2/frontend/pages/public/campaign-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/campaign-page/#1-step-based-workflow","title":"1. Step-Based Workflow","text":"

Three-step process guides users through advocacy action:

  • Step 1: Campaign Info - Overview, description, statistics
  • Step 2: Your Representatives - Postal code lookup and rep selection
  • Step 3: Send Your Message - Email composition and sending

Step Indicator: - Ant Design Steps component - Clickable step headers for navigation - Current step highlighted in blue - Completed steps marked with checkmark - Mobile: Switches to vertical orientation

Navigation Controls: - \"Previous\" button (disabled on step 1) - \"Next\" button (changes to \"Send Emails\" on step 3) - \"Back to Campaigns\" link in header

"},{"location":"v2/frontend/pages/public/campaign-page/#2-hero-section","title":"2. Hero Section","text":"

Prominent campaign header with visual branding:

  • Cover Photo: Full-width image (400px desktop, 250px mobile) with gradient overlay
  • Fallback Gradient: Purple-to-blue when no cover photo
  • Title Overlay: Campaign title in white text over semi-transparent background
  • Statistics Circles: Floating overlay with two metrics
  • Emails Sent count (blue circle)
  • Responses count (green circle)
  • Positioning: Absolute positioned in top-right of hero
  • Responsive: Circles stack vertically on mobile
"},{"location":"v2/frontend/pages/public/campaign-page/#3-representative-lookup","title":"3. Representative Lookup","text":"

Government-level aware representative discovery:

  • Postal Code Input: Large text input with search icon
  • Loading State: Spinner in input suffix during lookup
  • Government Level Filtering: Shows only reps matching campaign targets
  • Federal campaigns \u2192 Federal MPs only
  • Provincial campaigns \u2192 Provincial MPPs/MLAs only
  • Municipal campaigns \u2192 Municipal councillors only
  • Multi-level campaigns \u2192 All applicable reps
  • Representative Cards: Grid layout with detailed info
  • Circular photo (120px diameter)
  • Name and title
  • District/riding
  • Party badge
  • Email address (copyable)
  • Phone number
  • Office address
  • Send button (primary CTA)
  • Email App button (secondary CTA)
  • Auto-advance: Automatically proceeds to step 3 when reps loaded
  • No Results State: Helpful message suggesting alternate contact methods
"},{"location":"v2/frontend/pages/public/campaign-page/#4-email-sending-system","title":"4. Email Sending System","text":"

Dual-mode email delivery with tracking:

SMTP Send (Tracked): - Sends via backend BullMQ queue - Tracked in CampaignEmail table - Statistics reflected in dashboard - Requires valid email address - Shows success confirmation - Increments \"Emails Sent\" counter

Email App (Mailto): - Opens user's default email client - Pre-populates to, subject, body fields - Not tracked in system - Works offline - Better for complex email setups (signatures, attachments) - No backend dependency

Email Preview: - Live rendering of email template - Substitutes {name}, {email}, {postalCode} placeholders - Shows subject line - Read-only by default - Optional editing mode (if allowEmailEditing=true)

"},{"location":"v2/frontend/pages/public/campaign-page/#5-response-wall-integration","title":"5. Response Wall Integration","text":"

Campaign-specific response display:

  • \"See What Others Are Saying\" Button: Links to response wall
  • Response Count Badge: Shows total verified responses
  • Conditional Display: Only shown if responses exist
  • Navigation: Links to /responses/:campaignId
"},{"location":"v2/frontend/pages/public/campaign-page/#6-social-sharing","title":"6. Social Sharing","text":"

ShareButtons component for campaign promotion:

  • Platforms: X, Facebook, LinkedIn, Reddit, Email, Copy Link
  • Share URL: Current campaign page URL
  • Share Title: Campaign title
  • Share Description: Campaign description (truncated to 200 chars)
  • Positioning: Below main content, above footer
"},{"location":"v2/frontend/pages/public/campaign-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/public/campaign-page/#complete-advocacy-flow","title":"Complete Advocacy Flow","text":"
  1. User arrives at campaign page (via /campaigns/:id)
  2. Step 1 loads automatically showing campaign info
  3. User reads description and decides to take action
  4. User clicks \"Next\" to proceed to Step 2
  5. User enters postal code in \"Your Representatives\" section
  6. API lookup triggered on blur or Enter key
  7. Representatives filtered by government level
  8. Auto-advance to Step 3 when reps loaded
  9. User reviews email preview with personalized content
  10. User edits email (if allowed by campaign settings)
  11. User clicks \"Send\" button on rep card (SMTP option)
    • OR clicks \"Open in Email App\" (mailto option)
  12. Backend creates CampaignEmail record and queues job
  13. Success message displays confirming email sent
  14. User repeats for additional representatives
  15. User views response wall (optional) to see others' activity
  16. User shares campaign on social media
"},{"location":"v2/frontend/pages/public/campaign-page/#representative-selection-flow","title":"Representative Selection Flow","text":"

Representative selection happens implicitly (no checkboxes):

  1. User clicks \"Send\" on specific rep card
  2. Email sent to that rep only
  3. User can send to multiple reps by clicking multiple cards
  4. Each send creates separate CampaignEmail record
  5. No bulk sending (encourages personalization)
"},{"location":"v2/frontend/pages/public/campaign-page/#error-recovery-flow","title":"Error Recovery Flow","text":"

Invalid Postal Code: 1. User enters malformed postal code 2. API returns 404 or empty array 3. Message displays: \"No representatives found\" 4. User corrects postal code 5. Re-triggers lookup

Email Send Failure: 1. User clicks Send button 2. API returns 500 error 3. Error message displays 4. Send button remains enabled 5. User can retry immediately

Missing Information: 1. User tries to send without entering email 2. Form validation triggers 3. Required field highlighted in red 4. User fills in email 5. Proceeds with send

"},{"location":"v2/frontend/pages/public/campaign-page/#component-structure","title":"Component Structure","text":"
import React, { useState, useEffect } from 'react';\nimport { useParams, Link } from 'react-router-dom';\nimport {\n  Steps,\n  Button,\n  Input,\n  Card,\n  Row,\n  Col,\n  Typography,\n  Form,\n  message,\n  Spin,\n  Tag,\n  Grid,\n  Space\n} from 'antd';\nimport {\n  MailOutlined,\n  SearchOutlined,\n  CommentOutlined,\n  ArrowLeftOutlined,\n  SendOutlined,\n  DesktopOutlined\n} from '@ant-design/icons';\nimport PublicLayout from '../../components/PublicLayout';\nimport ShareButtons from '../../components/ShareButtons';\nimport axios from 'axios';\n\nconst { Title, Paragraph, Text } = Typography;\nconst { Step } = Steps;\nconst { TextArea } = Input;\nconst { useBreakpoint } = Grid;\n\ninterface Campaign {\n  id: string;\n  title: string;\n  description: string | null;\n  slug: string;\n  coverPhoto: string | null;\n  governmentLevel: string[];\n  targetType: string;\n  emailSubject: string;\n  emailBody: string;\n  allowEmailEditing: boolean;\n  isActive: boolean;\n  emailsSentCount: number;\n  responsesCount: number;\n}\n\ninterface Representative {\n  name: string;\n  district_name: string;\n  elected_office: string;\n  party_name: string;\n  email: string;\n  photo_url: string;\n  government_level: string;\n  offices: Array<{\n    tel: string;\n    type: string;\n    postal: string;\n  }>;\n}\n\nconst CampaignPage: React.FC = () => {\n  const { id } = useParams<{ id: string }>();\n  const [currentStep, setCurrentStep] = useState(0);\n  const [campaign, setCampaign] = useState<Campaign | null>(null);\n  const [loading, setLoading] = useState(true);\n  const [postalCode, setPostalCode] = useState('');\n  const [representatives, setRepresentatives] = useState<Representative[]>([]);\n  const [repsLoading, setRepsLoading] = useState(false);\n  const [userEmail, setUserEmail] = useState('');\n  const [userName, setUserName] = useState('');\n  const [customEmailBody, setCustomEmailBody] = useState('');\n  const [sendingTo, setSendingTo] = useState<string | null>(null);\n  const screens = useBreakpoint();\n  const isMobile = !screens.md;\n\n  // Data fetching, handlers, etc.\n\n  return (\n    <PublicLayout>\n      {/* Hero Section */}\n      {/* Step Indicator */}\n      {/* Step Content */}\n      {/* Share Buttons */}\n    </PublicLayout>\n  );\n};\n\nexport default CampaignPage;\n
"},{"location":"v2/frontend/pages/public/campaign-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/public/campaign-page/#component-state","title":"Component State","text":"
// Navigation state\nconst [currentStep, setCurrentStep] = useState(0); // 0=Info, 1=Reps, 2=Send\n\n// Campaign data\nconst [campaign, setCampaign] = useState<Campaign | null>(null);\nconst [loading, setLoading] = useState(true);\n\n// Representative lookup\nconst [postalCode, setPostalCode] = useState('');\nconst [representatives, setRepresentatives] = useState<Representative[]>([]);\nconst [repsLoading, setRepsLoading] = useState(false);\n\n// User input for email\nconst [userEmail, setUserEmail] = useState('');\nconst [userName, setUserName] = useState('');\nconst [customEmailBody, setCustomEmailBody] = useState('');\n\n// Send state\nconst [sendingTo, setSendingTo] = useState<string | null>(null); // Rep email being sent to\n\n// Responsive\nconst screens = useBreakpoint();\nconst isMobile = !screens.md;\n
"},{"location":"v2/frontend/pages/public/campaign-page/#derived-state","title":"Derived State","text":"
// Filtered representatives by government level\nconst filteredReps = representatives.filter(rep => {\n  if (!campaign) return false;\n  // Show all reps if campaign targets multiple levels or 'all'\n  if (campaign.governmentLevel.includes('all')) return true;\n  // Otherwise only show reps matching campaign's government levels\n  return campaign.governmentLevel.includes(rep.government_level);\n});\n\n// Email preview with substitutions\nconst emailPreview = useMemo(() => {\n  if (!campaign) return '';\n\n  let body = customEmailBody || campaign.emailBody;\n\n  // Replace placeholders\n  body = body.replace(/\\{name\\}/g, userName || '[Your Name]');\n  body = body.replace(/\\{email\\}/g, userEmail || '[Your Email]');\n  body = body.replace(/\\{postalCode\\}/g, postalCode || '[Your Postal Code]');\n\n  return body;\n}, [campaign, customEmailBody, userName, userEmail, postalCode]);\n\n// Step navigation enabled states\nconst canProceedToStep2 = !!campaign; // Campaign loaded\nconst canProceedToStep3 = representatives.length > 0; // Reps found\n
"},{"location":"v2/frontend/pages/public/campaign-page/#state-flow","title":"State Flow","text":"
  1. Initial Load: loading=true, fetch campaign by ID
  2. Campaign Loaded: setCampaign(), setLoading(false)
  3. User Enters Postal Code: setPostalCode() updates input
  4. Lookup Triggered: setRepsLoading(true), fetch representatives
  5. Reps Loaded: setRepresentatives(), setRepsLoading(false), auto-advance to step 3
  6. User Customizes Email: setCustomEmailBody() if editing allowed
  7. User Clicks Send: setSendingTo(rep.email), post to API
  8. Send Complete: setSendingTo(null), show success message, increment counter
"},{"location":"v2/frontend/pages/public/campaign-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/public/campaign-page/#endpoints-used","title":"Endpoints Used","text":""},{"location":"v2/frontend/pages/public/campaign-page/#1-get-campaign-by-id","title":"1. Get Campaign by ID","text":"
GET /api/public/campaigns/:id\n

Response:

{\n  \"id\": \"cm1abc123\",\n  \"title\": \"Support Climate Action Bill\",\n  \"description\": \"Urge your representatives to support strong climate legislation...\",\n  \"slug\": \"climate-action-bill\",\n  \"coverPhoto\": \"https://example.com/photos/climate.jpg\",\n  \"governmentLevel\": [\"federal\"],\n  \"targetType\": \"representatives\",\n  \"emailSubject\": \"Please Support Bill C-123\",\n  \"emailBody\": \"Dear {representative},\\n\\nAs your constituent in {postalCode}, I urge you to support Bill C-123...\\n\\nSincerely,\\n{name}\\n{email}\",\n  \"allowEmailEditing\": true,\n  \"isActive\": true,\n  \"emailsSentCount\": 1247,\n  \"responsesCount\": 342,\n  \"createdAt\": \"2025-01-15T10:00:00.000Z\"\n}\n

"},{"location":"v2/frontend/pages/public/campaign-page/#2-lookup-representatives","title":"2. Lookup Representatives","text":"
GET /api/public/representatives/lookup?postalCode=K1A0B1\n

Response:

[\n  {\n    \"name\": \"John Smith\",\n    \"district_name\": \"Ottawa Centre\",\n    \"elected_office\": \"MP\",\n    \"party_name\": \"Liberal\",\n    \"email\": \"john.smith@parl.gc.ca\",\n    \"photo_url\": \"https://represent.opennorth.ca/media/photos/mp-john-smith.jpg\",\n    \"government_level\": \"federal\",\n    \"offices\": [\n      {\n        \"tel\": \"613-555-1234\",\n        \"type\": \"constituency\",\n        \"postal\": \"123 Main St, Ottawa ON K1A 0B1\"\n      }\n    ]\n  }\n]\n

"},{"location":"v2/frontend/pages/public/campaign-page/#3-send-campaign-email","title":"3. Send Campaign Email","text":"
POST /api/public/campaigns/:id/send-email\nContent-Type: application/json\n\n{\n  \"senderName\": \"Jane Doe\",\n  \"senderEmail\": \"jane@example.com\",\n  \"postalCode\": \"K1A 0B1\",\n  \"recipientName\": \"John Smith\",\n  \"recipientEmail\": \"john.smith@parl.gc.ca\",\n  \"customMessage\": \"Dear MP Smith,\\n\\nAs your constituent...\",\n  \"government_level\": \"federal\"\n}\n

Response:

{\n  \"success\": true,\n  \"emailId\": \"cm2def456\",\n  \"message\": \"Email queued for sending\"\n}\n

"},{"location":"v2/frontend/pages/public/campaign-page/#request-examples","title":"Request Examples","text":""},{"location":"v2/frontend/pages/public/campaign-page/#fetch-campaign","title":"Fetch Campaign","text":"
useEffect(() => {\n  const fetchCampaign = async () => {\n    if (!id) {\n      message.error('Invalid campaign ID');\n      return;\n    }\n\n    try {\n      setLoading(true);\n      const response = await axios.get(`/api/public/campaigns/${id}`);\n      setCampaign(response.data);\n      setCustomEmailBody(response.data.emailBody); // Initialize with template\n    } catch (error: any) {\n      console.error('Failed to fetch campaign:', error);\n      if (error.response?.status === 404) {\n        message.error('Campaign not found');\n      } else {\n        message.error('Failed to load campaign');\n      }\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  fetchCampaign();\n}, [id]);\n
"},{"location":"v2/frontend/pages/public/campaign-page/#lookup-representatives","title":"Lookup Representatives","text":"
const handlePostalCodeLookup = async () => {\n  if (!postalCode.trim()) {\n    message.warning('Please enter a postal code');\n    return;\n  }\n\n  try {\n    setRepsLoading(true);\n    const response = await axios.get('/api/public/representatives/lookup', {\n      params: { postalCode: postalCode.trim().toUpperCase() }\n    });\n\n    setRepresentatives(response.data);\n\n    if (response.data.length === 0) {\n      message.info('No representatives found for this postal code');\n    } else {\n      // Auto-advance to step 3\n      setCurrentStep(2);\n      message.success(`Found ${response.data.length} representative(s)`);\n    }\n  } catch (error) {\n    console.error('Lookup failed:', error);\n    message.error('Failed to find representatives. Please check the postal code.');\n  } finally {\n    setRepsLoading(false);\n  }\n};\n
"},{"location":"v2/frontend/pages/public/campaign-page/#send-email","title":"Send Email","text":"
const handleSendEmail = async (rep: Representative) => {\n  if (!userName.trim() || !userEmail.trim()) {\n    message.warning('Please enter your name and email');\n    return;\n  }\n\n  if (!campaign) return;\n\n  try {\n    setSendingTo(rep.email);\n\n    await axios.post(`/api/public/campaigns/${campaign.id}/send-email`, {\n      senderName: userName,\n      senderEmail: userEmail,\n      postalCode: postalCode.toUpperCase(),\n      recipientName: rep.name,\n      recipientEmail: rep.email,\n      customMessage: customEmailBody || campaign.emailBody,\n      government_level: rep.government_level\n    });\n\n    message.success(`Email sent to ${rep.name}!`);\n\n    // Update local counter (optimistic update)\n    setCampaign(prev => prev ? {\n      ...prev,\n      emailsSentCount: prev.emailsSentCount + 1\n    } : null);\n\n  } catch (error: any) {\n    console.error('Failed to send email:', error);\n    message.error(error.response?.data?.message || 'Failed to send email. Please try again.');\n  } finally {\n    setSendingTo(null);\n  }\n};\n
"},{"location":"v2/frontend/pages/public/campaign-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/public/campaign-page/#hero-section-with-statistics","title":"Hero Section with Statistics","text":"
<div style={{ position: 'relative', marginBottom: 32 }}>\n  {/* Cover Photo or Gradient */}\n  <div style={{\n    height: isMobile ? 250 : 400,\n    overflow: 'hidden',\n    position: 'relative',\n    borderRadius: 8\n  }}>\n    {campaign.coverPhoto ? (\n      <img\n        src={campaign.coverPhoto}\n        alt={campaign.title}\n        style={{\n          width: '100%',\n          height: '100%',\n          objectFit: 'cover'\n        }}\n      />\n    ) : (\n      <div style={{\n        width: '100%',\n        height: '100%',\n        background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'\n      }} />\n    )}\n\n    {/* Gradient Overlay */}\n    <div style={{\n      position: 'absolute',\n      bottom: 0,\n      left: 0,\n      right: 0,\n      height: '50%',\n      background: 'linear-gradient(to top, rgba(0,0,0,0.8), transparent)'\n    }} />\n\n    {/* Title Overlay */}\n    <div style={{\n      position: 'absolute',\n      bottom: 24,\n      left: 24,\n      right: isMobile ? 24 : '30%',\n      color: 'white'\n    }}>\n      <Title\n        level={1}\n        style={{\n          color: 'white',\n          marginBottom: 8,\n          fontSize: isMobile ? 24 : 36\n        }}\n      >\n        {campaign.title}\n      </Title>\n    </div>\n\n    {/* Statistics Circles */}\n    <div style={{\n      position: 'absolute',\n      top: 24,\n      right: 24,\n      display: 'flex',\n      flexDirection: isMobile ? 'column' : 'row',\n      gap: 16\n    }}>\n      {/* Emails Sent Circle */}\n      <div style={{\n        background: 'rgba(24, 144, 255, 0.9)',\n        borderRadius: '50%',\n        width: 100,\n        height: 100,\n        display: 'flex',\n        flexDirection: 'column',\n        alignItems: 'center',\n        justifyContent: 'center',\n        color: 'white',\n        boxShadow: '0 4px 12px rgba(0,0,0,0.3)'\n      }}>\n        <MailOutlined style={{ fontSize: 24, marginBottom: 4 }} />\n        <Text strong style={{ color: 'white', fontSize: 20 }}>\n          {campaign.emailsSentCount}\n        </Text>\n        <Text style={{ color: 'white', fontSize: 12 }}>\n          Emails\n        </Text>\n      </div>\n\n      {/* Responses Circle */}\n      <div style={{\n        background: 'rgba(82, 196, 26, 0.9)',\n        borderRadius: '50%',\n        width: 100,\n        height: 100,\n        display: 'flex',\n        flexDirection: 'column',\n        alignItems: 'center',\n        justifyContent: 'center',\n        color: 'white',\n        boxShadow: '0 4px 12px rgba(0,0,0,0.3)'\n      }}>\n        <CommentOutlined style={{ fontSize: 24, marginBottom: 4 }} />\n        <Text strong style={{ color: 'white', fontSize: 20 }}>\n          {campaign.responsesCount}\n        </Text>\n        <Text style={{ color: 'white', fontSize: 12 }}>\n          Responses\n        </Text>\n      </div>\n    </div>\n  </div>\n</div>\n
"},{"location":"v2/frontend/pages/public/campaign-page/#step-indicator","title":"Step Indicator","text":"
<Steps\n  current={currentStep}\n  onChange={setCurrentStep}\n  direction={isMobile ? 'vertical' : 'horizontal'}\n  style={{ marginBottom: 32 }}\n>\n  <Step\n    title=\"Campaign Info\"\n    description={!isMobile && \"Learn about the campaign\"}\n    icon={<MailOutlined />}\n  />\n  <Step\n    title=\"Your Representatives\"\n    description={!isMobile && \"Find your elected officials\"}\n    icon={<SearchOutlined />}\n    disabled={!canProceedToStep2}\n  />\n  <Step\n    title=\"Send Your Message\"\n    description={!isMobile && \"Take action now\"}\n    icon={<SendOutlined />}\n    disabled={!canProceedToStep3}\n  />\n</Steps>\n
"},{"location":"v2/frontend/pages/public/campaign-page/#representative-cards-with-dual-send-options","title":"Representative Cards with Dual Send Options","text":"
<Row gutter={[16, 16]}>\n  {filteredReps.map((rep, idx) => (\n    <Col xs={24} sm={12} lg={8} key={idx}>\n      <Card hoverable>\n        {/* Photo */}\n        <div style={{ textAlign: 'center', marginBottom: 16 }}>\n          <img\n            src={rep.photo_url || '/default-avatar.png'}\n            alt={rep.name}\n            style={{\n              width: 120,\n              height: 120,\n              borderRadius: '50%',\n              objectFit: 'cover',\n              border: '3px solid #1890ff'\n            }}\n          />\n        </div>\n\n        {/* Details */}\n        <Title level={4} style={{ marginBottom: 4, textAlign: 'center' }}>\n          {rep.name}\n        </Title>\n        <Text type=\"secondary\" style={{ display: 'block', textAlign: 'center', marginBottom: 8 }}>\n          {rep.elected_office} \u2022 {rep.district_name}\n        </Text>\n\n        <div style={{ textAlign: 'center', marginBottom: 16 }}>\n          <Tag color=\"blue\">{rep.party_name}</Tag>\n          <Tag color=\"purple\">\n            {rep.government_level.charAt(0).toUpperCase() + rep.government_level.slice(1)}\n          </Tag>\n        </div>\n\n        {/* Contact Info */}\n        <div style={{ marginBottom: 16, fontSize: 12 }}>\n          <Text strong>Email:</Text>\n          <br />\n          <Text copyable style={{ fontSize: 12 }}>{rep.email}</Text>\n          <br /><br />\n\n          {rep.offices?.[0]?.tel && (\n            <>\n              <Text strong>Phone:</Text>\n              <br />\n              <Text style={{ fontSize: 12 }}>{rep.offices[0].tel}</Text>\n              <br /><br />\n            </>\n          )}\n\n          {rep.offices?.[0]?.postal && (\n            <>\n              <Text strong>Office:</Text>\n              <br />\n              <Text type=\"secondary\" style={{ fontSize: 12 }}>\n                {rep.offices[0].postal}\n              </Text>\n            </>\n          )}\n        </div>\n\n        {/* Send Buttons */}\n        <Space direction=\"vertical\" style={{ width: '100%' }}>\n          {/* SMTP Send (Tracked) */}\n          <Button\n            type=\"primary\"\n            icon={<SendOutlined />}\n            block\n            loading={sendingTo === rep.email}\n            onClick={() => handleSendEmail(rep)}\n            disabled={!userName || !userEmail}\n          >\n            Send Email\n          </Button>\n\n          {/* Mailto (Untracked) */}\n          <Button\n            icon={<DesktopOutlined />}\n            block\n            onClick={() => {\n              const subject = encodeURIComponent(campaign.emailSubject);\n              const body = encodeURIComponent(emailPreview);\n              window.location.href = `mailto:${rep.email}?subject=${subject}&body=${body}`;\n            }}\n          >\n            Open in Email App\n          </Button>\n        </Space>\n      </Card>\n    </Col>\n  ))}\n</Row>\n
"},{"location":"v2/frontend/pages/public/campaign-page/#email-preview-with-optional-editing","title":"Email Preview with Optional Editing","text":"
<Card\n  title=\"Email Preview\"\n  style={{ marginBottom: 24 }}\n  extra={\n    campaign.allowEmailEditing && (\n      <Text type=\"secondary\" style={{ fontSize: 12 }}>\n        You can edit this message\n      </Text>\n    )\n  }\n>\n  {/* Subject Line */}\n  <div style={{ marginBottom: 16 }}>\n    <Text strong>Subject:</Text>\n    <br />\n    <Text>{campaign.emailSubject}</Text>\n  </div>\n\n  {/* Email Body */}\n  <div>\n    <Text strong>Message:</Text>\n    {campaign.allowEmailEditing ? (\n      <TextArea\n        value={customEmailBody}\n        onChange={(e) => setCustomEmailBody(e.target.value)}\n        rows={10}\n        style={{ marginTop: 8, fontFamily: 'monospace', fontSize: 13 }}\n      />\n    ) : (\n      <pre style={{\n        marginTop: 8,\n        padding: 16,\n        background: '#f5f5f5',\n        borderRadius: 4,\n        whiteSpace: 'pre-wrap',\n        fontFamily: 'inherit',\n        fontSize: 13\n      }}>\n        {emailPreview}\n      </pre>\n    )}\n  </div>\n\n  {/* Placeholder Legend */}\n  <div style={{\n    marginTop: 16,\n    padding: 12,\n    background: '#e6f7ff',\n    borderRadius: 4,\n    fontSize: 12\n  }}>\n    <Text type=\"secondary\">\n      <strong>Available placeholders:</strong> {'{name}'}, {'{email}'}, {'{postalCode}'}\n    </Text>\n  </div>\n</Card>\n
"},{"location":"v2/frontend/pages/public/campaign-page/#user-information-form","title":"User Information Form","text":"
<Card title=\"Your Information\" style={{ marginBottom: 24 }}>\n  <Form layout=\"vertical\">\n    <Form.Item\n      label=\"Your Name\"\n      required\n      validateStatus={!userName && 'error'}\n      help={!userName && 'Please enter your name'}\n    >\n      <Input\n        size=\"large\"\n        placeholder=\"Jane Doe\"\n        value={userName}\n        onChange={(e) => setUserName(e.target.value)}\n      />\n    </Form.Item>\n\n    <Form.Item\n      label=\"Your Email\"\n      required\n      validateStatus={!userEmail && 'error'}\n      help={!userEmail && 'Please enter your email'}\n    >\n      <Input\n        size=\"large\"\n        type=\"email\"\n        placeholder=\"jane@example.com\"\n        value={userEmail}\n        onChange={(e) => setUserEmail(e.target.value)}\n      />\n    </Form.Item>\n\n    <Form.Item\n      label=\"Postal Code\"\n      required\n      validateStatus={!postalCode && 'error'}\n      help={!postalCode && 'Entered in step 2'}\n    >\n      <Input\n        size=\"large\"\n        disabled\n        value={postalCode}\n        style={{ background: '#f5f5f5' }}\n      />\n    </Form.Item>\n  </Form>\n</Card>\n
"},{"location":"v2/frontend/pages/public/campaign-page/#response-wall-cta","title":"Response Wall CTA","text":"
{campaign.responsesCount > 0 && (\n  <Card\n    style={{\n      marginTop: 32,\n      background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',\n      border: 'none'\n    }}\n  >\n    <div style={{ textAlign: 'center', color: 'white' }}>\n      <CommentOutlined style={{ fontSize: 48, marginBottom: 16 }} />\n      <Title level={3} style={{ color: 'white', marginBottom: 16 }}>\n        See What Others Are Saying\n      </Title>\n      <Paragraph style={{ color: 'rgba(255,255,255,0.9)', marginBottom: 24 }}>\n        Read {campaign.responsesCount} responses from people who took action\n      </Paragraph>\n      <Link to={`/responses/${campaign.id}`}>\n        <Button type=\"default\" size=\"large\">\n          View Response Wall\n        </Button>\n      </Link>\n    </div>\n  </Card>\n)}\n
"},{"location":"v2/frontend/pages/public/campaign-page/#navigation-controls","title":"Navigation Controls","text":"
<div style={{\n  display: 'flex',\n  justifyContent: 'space-between',\n  marginTop: 32,\n  paddingTop: 24,\n  borderTop: '1px solid #303030'\n}}>\n  <Button\n    onClick={() => setCurrentStep(prev => Math.max(0, prev - 1))}\n    disabled={currentStep === 0}\n  >\n    <ArrowLeftOutlined /> Previous\n  </Button>\n\n  {currentStep < 2 ? (\n    <Button\n      type=\"primary\"\n      onClick={() => setCurrentStep(prev => Math.min(2, prev + 1))}\n      disabled={\n        (currentStep === 0 && !campaign) ||\n        (currentStep === 1 && representatives.length === 0)\n      }\n    >\n      Next <ArrowLeftOutlined style={{ transform: 'rotate(180deg)' }} />\n    </Button>\n  ) : (\n    <Text type=\"secondary\">\n      Click \"Send Email\" on any representative card above\n    </Text>\n  )}\n</div>\n
"},{"location":"v2/frontend/pages/public/campaign-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/public/campaign-page/#1-optimized-email-preview-rendering","title":"1. Optimized Email Preview Rendering","text":"

Uses useMemo to avoid re-computing on every render:

const emailPreview = useMemo(() => {\n  if (!campaign) return '';\n\n  let body = customEmailBody || campaign.emailBody;\n\n  body = body.replace(/\\{name\\}/g, userName || '[Your Name]');\n  body = body.replace(/\\{email\\}/g, userEmail || '[Your Email]');\n  body = body.replace(/\\{postalCode\\}/g, postalCode || '[Your Postal Code]');\n\n  return body;\n}, [campaign, customEmailBody, userName, userEmail, postalCode]);\n

Benefit: Preview only recalculates when dependencies change, not on every keystroke.

"},{"location":"v2/frontend/pages/public/campaign-page/#2-auto-advance-after-lookup","title":"2. Auto-advance After Lookup","text":"

Automatically proceeds to step 3 when representatives loaded:

if (response.data.length > 0) {\n  setCurrentStep(2); // Auto-advance\n  message.success(`Found ${response.data.length} representative(s)`);\n}\n

Benefit: Reduces user clicks, smoother workflow.

"},{"location":"v2/frontend/pages/public/campaign-page/#3-optimistic-ui-updates","title":"3. Optimistic UI Updates","text":"

Updates email counter immediately after send (before API response):

message.success(`Email sent to ${rep.name}!`);\n\nsetCampaign(prev => prev ? {\n  ...prev,\n  emailsSentCount: prev.emailsSentCount + 1\n} : null);\n

Benefit: Instant feedback, perceived performance improvement.

"},{"location":"v2/frontend/pages/public/campaign-page/#4-conditional-component-rendering","title":"4. Conditional Component Rendering","text":"

Response wall CTA only renders if responses exist:

{campaign.responsesCount > 0 && (\n  <Card>{/* Response wall CTA */}</Card>\n)}\n

Benefit: Cleaner DOM, faster initial render for new campaigns.

"},{"location":"v2/frontend/pages/public/campaign-page/#5-debounced-representative-filtering","title":"5. Debounced Representative Filtering","text":"

Filtering happens on blur/Enter, not on every keystroke:

<Input\n  onBlur={handlePostalCodeLookup}\n  onPressEnter={handlePostalCodeLookup}\n  // NOT: onChange={handlePostalCodeLookup}\n/>\n

Benefit: Prevents excessive API calls while user types.

"},{"location":"v2/frontend/pages/public/campaign-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/public/campaign-page/#breakpoint-behavior","title":"Breakpoint Behavior","text":"Breakpoint Hero Height Stats Position Steps Direction Rep Cards Columns xs (0-575px) 250px Vertical stack Vertical 1 sm (576-767px) 250px Vertical stack Vertical 2 md (768-991px) 400px Horizontal row Horizontal 2 lg (992px+) 400px Horizontal row Horizontal 3"},{"location":"v2/frontend/pages/public/campaign-page/#mobile-adaptations","title":"Mobile Adaptations","text":"

Hero Section: - Reduced height (250px vs 400px) - Statistics circles stack vertically - Title font size reduced (24px vs 36px) - Right margin for title increased to prevent overlap with stats

Steps Component: - Switches to vertical orientation - Step descriptions hidden on mobile (takes too much space) - Icons remain visible for visual guidance

Representative Cards: - Single column layout on xs - Two columns on sm (tablet portrait) - Three columns on lg+ (desktop)

Form Inputs: - Full-width inputs on mobile - size=\"large\" for better touch targets - Increased spacing between fields

Email Preview: - TextArea expands to full width - Font size slightly smaller (13px) for better fit - Scrollable if content exceeds viewport

"},{"location":"v2/frontend/pages/public/campaign-page/#tablet-optimization","title":"Tablet Optimization","text":"

At sm breakpoint (576-767px): - Rep cards show 2 per row (good balance) - Hero maintains mobile height (better above-fold) - Steps remain vertical (clearer on narrow viewports) - Send buttons remain full-width within cards

"},{"location":"v2/frontend/pages/public/campaign-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/public/campaign-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

Step Navigation: - Steps component is keyboard accessible (Tab + Enter) - Arrow keys navigate between steps (native Ant Design) - Space bar activates step

Form Fields: - All inputs focusable via Tab - Enter key submits postal code lookup - Escape key can close modals (future feature)

Send Buttons: - Both \"Send Email\" and \"Open in Email App\" are focusable - Enter/Space activates button - Loading state prevents double-submission

"},{"location":"v2/frontend/pages/public/campaign-page/#aria-labels","title":"ARIA Labels","text":"

Step Indicator:

<Steps\n  current={currentStep}\n  aria-label=\"Campaign action steps\"\n>\n  <Step\n    title=\"Campaign Info\"\n    icon={<MailOutlined aria-hidden=\"true\" />}\n  />\n</Steps>\n

Representative Photos:

<img\n  src={rep.photo_url}\n  alt={`Photo of ${rep.name}, ${rep.elected_office} for ${rep.district_name}`}\n  role=\"img\"\n/>\n

Loading States:

<Spin\n  size=\"small\"\n  aria-label=\"Loading representatives\"\n/>\n\n<Button\n  loading={sendingTo === rep.email}\n  aria-label={`Sending email to ${rep.name}`}\n>\n  Send Email\n</Button>\n

"},{"location":"v2/frontend/pages/public/campaign-page/#screen-reader-support","title":"Screen Reader Support","text":"

Step Announcements: - Current step announced when changed - Step titles are clear and descriptive - Disabled steps have appropriate aria-disabled attribute

Form Validation:

<Form.Item\n  label=\"Your Name\"\n  required\n  validateStatus={!userName && 'error'}\n  help={!userName && 'Please enter your name'}\n  aria-required=\"true\"\n>\n  <Input />\n</Form.Item>\n

Success/Error Messages: - Ant Design message component has ARIA live region - Screen reader announces \"Email sent successfully!\" - Error messages also announced automatically

Email Preview:

<pre\n  role=\"article\"\n  aria-label=\"Email message preview\"\n>\n  {emailPreview}\n</pre>\n

"},{"location":"v2/frontend/pages/public/campaign-page/#color-contrast","title":"Color Contrast","text":"

Statistics Circles: - Blue circle: #1890ff on white text (4.5:1 ratio \u2713) - Green circle: #52c41a on white text (4.7:1 ratio \u2713) - Both meet WCAG AA standards

Primary Buttons: - Ant Design primary button (#1890ff) meets AA contrast - Focus outline visible on all interactive elements

Text Hierarchy: - Primary text: white on #0d1b2a (15.8:1 ratio \u2713\u2713) - Secondary text: rgba(255,255,255,0.65) on dark (7.2:1 ratio \u2713) - Links: #1890ff with underline on focus

"},{"location":"v2/frontend/pages/public/campaign-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/public/campaign-page/#issue-representatives-not-filtered-by-government-level","title":"Issue: Representatives Not Filtered by Government Level","text":"

Symptoms: - Federal campaign shows provincial/municipal reps - All reps display regardless of campaign targets - Filtering logic not working

Causes: 1. government_level field missing in API response 2. governmentLevel array empty in campaign 3. Case mismatch (Federal vs federal) 4. Filtering logic bug

Solutions:

// Add debug logging\nuseEffect(() => {\n  if (representatives.length > 0 && campaign) {\n    console.log('Campaign levels:', campaign.governmentLevel);\n    console.log('Rep levels:', representatives.map(r => r.government_level));\n    console.log('Filtered count:', filteredReps.length);\n  }\n}, [representatives, campaign]);\n\n// Robust filtering with case-insensitive matching\nconst filteredReps = representatives.filter(rep => {\n  if (!campaign || !rep.government_level) return false;\n\n  // Normalize to lowercase for comparison\n  const campaignLevels = campaign.governmentLevel.map(l => l.toLowerCase());\n  const repLevel = rep.government_level.toLowerCase();\n\n  // Show all if campaign targets 'all' levels\n  if (campaignLevels.includes('all')) return true;\n\n  // Otherwise match exact level\n  return campaignLevels.includes(repLevel);\n});\n\n// Add fallback if no filtered reps\n{filteredReps.length === 0 && representatives.length > 0 && (\n  <Alert\n    type=\"warning\"\n    message=\"No matching representatives\"\n    description={`This campaign targets ${campaign.governmentLevel.join(', ')} representatives, but none were found for your postal code at that level.`}\n    style={{ marginBottom: 16 }}\n  />\n)}\n

Check API response:

# Verify government_level field present\ncurl http://localhost:4000/api/public/representatives/lookup?postalCode=K1A0B1 | jq '.[].government_level'\n# Should output: \"federal\", \"provincial\", etc.\n

"},{"location":"v2/frontend/pages/public/campaign-page/#issue-email-preview-not-updating","title":"Issue: Email Preview Not Updating","text":"

Symptoms: - Placeholders remain as {name} instead of actual values - User input not reflected in preview - Preview frozen on initial template

Causes: 1. useMemo dependencies missing 2. State not updating properly 3. Placeholder regex not matching 4. Component not re-rendering

Solutions:

// Ensure all dependencies in useMemo\nconst emailPreview = useMemo(() => {\n  if (!campaign) return '';\n\n  let body = customEmailBody || campaign.emailBody;\n\n  // Use global replace with /g flag\n  body = body.replace(/\\{name\\}/g, userName || '[Your Name]');\n  body = body.replace(/\\{email\\}/g, userEmail || '[Your Email]');\n  body = body.replace(/\\{postalCode\\}/g, postalCode || '[Your Postal Code]');\n\n  // Log for debugging\n  console.log('Preview updated:', {\n    userName,\n    userEmail,\n    postalCode,\n    bodyLength: body.length\n  });\n\n  return body;\n}, [campaign, customEmailBody, userName, userEmail, postalCode]);\n// ^^^ All dependencies must be listed\n\n// Alternative: Force re-render with key\n<pre key={`${userName}-${userEmail}-${postalCode}`}>\n  {emailPreview}\n</pre>\n
"},{"location":"v2/frontend/pages/public/campaign-page/#issue-send-button-not-working","title":"Issue: Send Button Not Working","text":"

Symptoms: - Clicking \"Send Email\" does nothing - No API request in Network tab - Button not disabled/loading

Causes: 1. Missing form validation 2. Event handler not bound 3. API endpoint incorrect 4. CORS error blocking request

Solutions:

// Add comprehensive validation\nconst handleSendEmail = async (rep: Representative) => {\n  // Validate user input\n  if (!userName.trim()) {\n    message.error('Please enter your name');\n    return;\n  }\n\n  if (!userEmail.trim()) {\n    message.error('Please enter your email');\n    return;\n  }\n\n  if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(userEmail)) {\n    message.error('Please enter a valid email address');\n    return;\n  }\n\n  if (!postalCode.trim()) {\n    message.error('Postal code is required (from step 2)');\n    return;\n  }\n\n  if (!campaign) {\n    message.error('Campaign data not loaded');\n    return;\n  }\n\n  // Log request details\n  console.log('Sending email:', {\n    campaignId: campaign.id,\n    to: rep.email,\n    from: userEmail\n  });\n\n  try {\n    setSendingTo(rep.email);\n\n    const payload = {\n      senderName: userName.trim(),\n      senderEmail: userEmail.trim(),\n      postalCode: postalCode.trim().toUpperCase(),\n      recipientName: rep.name,\n      recipientEmail: rep.email,\n      customMessage: customEmailBody || campaign.emailBody,\n      government_level: rep.government_level\n    };\n\n    console.log('Payload:', payload);\n\n    const response = await axios.post(\n      `/api/public/campaigns/${campaign.id}/send-email`,\n      payload,\n      { timeout: 10000 } // 10s timeout\n    );\n\n    console.log('Response:', response.data);\n\n    message.success(`Email sent to ${rep.name}!`);\n\n    // Optimistic update\n    setCampaign(prev => prev ? {\n      ...prev,\n      emailsSentCount: prev.emailsSentCount + 1\n    } : null);\n\n  } catch (error: any) {\n    console.error('Send error:', error);\n\n    if (error.code === 'ECONNABORTED') {\n      message.error('Request timed out. Please try again.');\n    } else if (error.response) {\n      message.error(error.response.data?.message || 'Failed to send email');\n    } else {\n      message.error('Network error. Please check your connection.');\n    }\n  } finally {\n    setSendingTo(null);\n  }\n};\n

Check CORS configuration:

// In api/src/server.ts\napp.use(cors({\n  origin: process.env.CORS_ORIGIN || 'http://localhost:3000',\n  credentials: true\n}));\n

"},{"location":"v2/frontend/pages/public/campaign-page/#issue-auto-advance-to-step-3-not-working","title":"Issue: Auto-advance to Step 3 Not Working","text":"

Symptoms: - Representatives load but page stays on step 2 - User must manually click \"Next\" - Auto-advance logic not triggering

Causes: 1. State update timing issue 2. Conditional check failing 3. React Strict Mode double-rendering 4. Missing setCurrentStep(2) call

Solutions:

// Move auto-advance inside success branch\nconst handlePostalCodeLookup = async () => {\n  if (!postalCode.trim()) {\n    message.warning('Please enter a postal code');\n    return;\n  }\n\n  try {\n    setRepsLoading(true);\n    const response = await axios.get('/api/public/representatives/lookup', {\n      params: { postalCode: postalCode.trim().toUpperCase() }\n    });\n\n    setRepresentatives(response.data);\n\n    // Auto-advance ONLY if reps found\n    if (response.data.length > 0) {\n      // Use setTimeout to ensure state update completes\n      setTimeout(() => {\n        setCurrentStep(2);\n        message.success(`Found ${response.data.length} representative(s)`);\n      }, 100);\n    } else {\n      message.info('No representatives found for this postal code');\n    }\n  } catch (error) {\n    console.error('Lookup failed:', error);\n    message.error('Failed to find representatives');\n  } finally {\n    setRepsLoading(false);\n  }\n};\n\n// Alternative: Use useEffect to watch for reps\nuseEffect(() => {\n  if (representatives.length > 0 && currentStep === 1) {\n    setCurrentStep(2);\n  }\n}, [representatives.length, currentStep]);\n
"},{"location":"v2/frontend/pages/public/campaign-page/#issue-mailto-links-not-working","title":"Issue: Mailto Links Not Working","text":"

Symptoms: - Clicking \"Open in Email App\" does nothing - Browser blocks mailto: protocol - Email client doesn't open

Causes: 1. Browser security settings blocking mailto 2. No default email client configured 3. URL encoding issues 4. Email body too long (URL length limit)

Solutions:

// Add error handling for mailto\nconst handleMailtoClick = (rep: Representative) => {\n  try {\n    const subject = encodeURIComponent(campaign.emailSubject);\n    const body = encodeURIComponent(emailPreview);\n\n    // Check URL length (browsers have ~2000 char limit)\n    const mailtoUrl = `mailto:${rep.email}?subject=${subject}&body=${body}`;\n\n    if (mailtoUrl.length > 2000) {\n      message.warning(\n        'Email message is too long for mailto link. ' +\n        'Please use the \"Send Email\" button instead.',\n        5\n      );\n      return;\n    }\n\n    // Try to open mailto\n    window.location.href = mailtoUrl;\n\n    // Show informative message\n    message.info(\n      'Opening your email client. If nothing happens, please check your browser settings.',\n      5\n    );\n\n  } catch (error) {\n    console.error('Mailto error:', error);\n    message.error('Failed to open email client. Please use the \"Send Email\" button instead.');\n  }\n};\n\n// Update button\n<Button\n  icon={<DesktopOutlined />}\n  block\n  onClick={() => handleMailtoClick(rep)}\n>\n  Open in Email App\n</Button>\n
"},{"location":"v2/frontend/pages/public/campaign-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/public/campaign-page/#public-pages","title":"Public Pages","text":"
  • Campaigns List Page - Campaign directory and featured campaigns
  • Response Wall Page - Campaign-specific response display
  • Map Page - Public location mapping
"},{"location":"v2/frontend/pages/public/campaign-page/#admin-pages","title":"Admin Pages","text":"
  • Campaigns Management - Campaign CRUD and settings
  • Email Queue Page - Queue monitoring and management
  • Response Moderation - Admin response management
"},{"location":"v2/frontend/pages/public/campaign-page/#components","title":"Components","text":"
  • PublicLayout - Dark theme layout wrapper
  • ShareButtons - Social sharing functionality
"},{"location":"v2/frontend/pages/public/campaign-page/#api-documentation","title":"API Documentation","text":"
  • Public Campaigns API
  • Campaign Email Sending
  • Representatives Lookup
"},{"location":"v2/frontend/pages/public/campaign-page/#architecture","title":"Architecture","text":"
  • Email Queue System - BullMQ email processing
  • Representative Caching
  • Postal Code Lookup
"},{"location":"v2/frontend/pages/public/campaigns-list-page/","title":"Campaigns List Page","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/public/CampaignsListPage.tsx (566 lines)

Route: /campaigns

Role Requirements: Public access (no authentication required)

Purpose: Primary landing page for the advocacy campaign system, providing a browseable directory of active campaigns with featured campaign highlighting, postal code-based representative lookup, and social sharing capabilities.

Key Features:

  • Hero banner with organization name and gradient background
  • \"Find Your Representatives\" postal code lookup section
  • Featured campaign card with gold border and star icon
  • Responsive campaigns grid (3 columns on desktop)
  • Individual campaign cards with cover photos or gradient backgrounds
  • ShareButtons component for social media sharing
  • Dark blue/teal theme consistent with public pages
  • Real-time campaign statistics (emails sent, responses)
  • Mobile-responsive design with hamburger navigation

Layout: Uses PublicLayout component with dark theme (colorBgBase: '#0d1b2a', colorBgContainer: '#1b2838')

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#1-hero-banner","title":"1. Hero Banner","text":"

The hero section provides visual branding and context:

  • Organization Name Display: Fetched from site settings API
  • Gradient Background: linear-gradient(135deg, #667eea 0%, #764ba2 100%)
  • Typography: Large heading (32px desktop, 24px mobile)
  • Tagline: \"Join thousands taking action\" with email icon
  • Height: 250px on desktop, 200px on mobile
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#2-find-your-representatives-section","title":"2. Find Your Representatives Section","text":"

Postal code lookup interface for representative discovery:

  • Input Field: Text input with search icon prefix
  • Loading States: Spinning icon during API lookup
  • Representative Cards: Grid display (xs=1, sm=2, lg=3 columns)
  • Card Details:
  • Representative photo (150x150 circular avatar)
  • Name with title formatting
  • District/riding information
  • Political party with badge styling
  • Contact information (email, phone)
  • Office address
  • No Results State: Informative message with alternate contact suggestion
  • Government Level Filtering: Shows reps from all applicable levels
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#3-featured-campaign-card","title":"3. Featured Campaign Card","text":"

Highlighted campaign with premium styling:

  • Gold Border: 2px solid #f39c12 with glow shadow
  • Star Icon: Antd StarFilled in gold color
  • \"Featured Campaign\" Badge: Gold text on dark background
  • Cover Photo: Full-width image (300px height) with overlay gradient
  • Fallback Gradient: Purple-to-blue gradient when no cover photo
  • Statistics Display: Emails sent and responses count
  • Action Button: Primary styled \"View Campaign\" link
  • Positioning: Always appears first in grid
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#4-campaigns-grid","title":"4. Campaigns Grid","text":"

Responsive grid layout for all campaigns:

  • Responsive Columns:
  • xs: 1 column (mobile)
  • sm: 2 columns (tablet)
  • lg: 3 columns (desktop)
  • Gutter: 24px horizontal and vertical spacing
  • Card Components: Ant Design Card with hover effects
  • Card Contents:
  • Cover photo or gradient background (200px height)
  • Campaign title (Typography.Title level 4)
  • Truncated description (2-line ellipsis)
  • Government level tags (federal, provincial, municipal)
  • Statistics row (emails sent, responses)
  • \"View Campaign\" link button
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#5-social-sharing","title":"5. Social Sharing","text":"

ShareButtons component integration:

  • Platforms: X (Twitter), Facebook, LinkedIn, Reddit, Email, Copy Link
  • URL Sharing: Current page URL
  • Title Sharing: \"Check out these advocacy campaigns!\"
  • Positioning: Below campaigns grid
  • Icon Buttons: Circular buttons with platform-specific colors
  • Copy Link Feedback: Success message notification
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#6-empty-states","title":"6. Empty States","text":"

Graceful handling of no-data scenarios:

  • No Campaigns: Large icon with \"No campaigns available\" message
  • No Featured Campaign: Skips featured section, shows all campaigns equally
  • Loading State: Ant Design Spin component with centered alignment
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#initial-page-load","title":"Initial Page Load","text":"
  1. User navigates to /campaigns
  2. PublicLayout renders with dark theme
  3. Component fetches settings from /api/settings
  4. Component fetches campaigns from /api/public/campaigns
  5. Hero banner displays organization name
  6. Campaigns grid renders with featured campaign (if exists) highlighted
  7. ShareButtons component appears at bottom
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#representative-lookup-flow","title":"Representative Lookup Flow","text":"
  1. User enters postal code in \"Find Your Representatives\" input
  2. On blur or Enter key, component triggers lookup
  3. Loading spinner appears in input suffix
  4. API request to /api/public/representatives/lookup?postalCode=X
  5. Results display in grid format with rep cards
  6. User can view contact details for each representative
  7. Empty state message if no results found
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#campaign-browsing","title":"Campaign Browsing","text":"
  1. User scrolls through campaigns grid
  2. Featured campaign (if exists) appears first with gold border
  3. User clicks \"View Campaign\" on any card
  4. Navigation to /campaigns/:id detail page
  5. Statistics update dynamically based on campaign activity
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#social-sharing","title":"Social Sharing","text":"
  1. User scrolls to bottom of page
  2. User clicks desired social platform icon
  3. Platform-specific share dialog opens (new window)
  4. For \"Copy Link\", URL copied to clipboard with notification
  5. User can share to multiple platforms sequentially
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#component-structure","title":"Component Structure","text":"
import React, { useState, useEffect } from 'react';\nimport { Link } from 'react-router-dom';\nimport { Row, Col, Card, Typography, Input, Spin, message, Tag, Grid } from 'antd';\nimport {\n  MailOutlined,\n  SearchOutlined,\n  CommentOutlined,\n  StarFilled,\n  InboxOutlined\n} from '@ant-design/icons';\nimport PublicLayout from '../../components/PublicLayout';\nimport ShareButtons from '../../components/ShareButtons';\nimport axios from 'axios';\n\nconst { Title, Paragraph, Text } = Typography;\nconst { useBreakpoint } = Grid;\n\ninterface Campaign {\n  id: string;\n  title: string;\n  description: string | null;\n  slug: string;\n  coverPhoto: string | null;\n  governmentLevel: string[];\n  targetType: string;\n  isFeatured: boolean;\n  isActive: boolean;\n  emailsSentCount: number;\n  responsesCount: number;\n}\n\ninterface Representative {\n  name: string;\n  district_name: string;\n  elected_office: string;\n  party_name: string;\n  email: string;\n  photo_url: string;\n  offices: Array<{\n    tel: string;\n    type: string;\n    postal: string;\n  }>;\n}\n\ninterface Settings {\n  organizationName: string;\n}\n\nconst CampaignsListPage: React.FC = () => {\n  const [campaigns, setCampaigns] = useState<Campaign[]>([]);\n  const [settings, setSettings] = useState<Settings | null>(null);\n  const [loading, setLoading] = useState(true);\n  const [postalCode, setPostalCode] = useState('');\n  const [representatives, setRepresentatives] = useState<Representative[]>([]);\n  const [repsLoading, setRepsLoading] = useState(false);\n  const screens = useBreakpoint();\n  const isMobile = !screens.md;\n\n  // Data fetching, event handlers, etc.\n\n  return (\n    <PublicLayout>\n      {/* Hero Banner */}\n      <div className=\"hero-banner\">\n        {/* Content */}\n      </div>\n\n      {/* Find Your Representatives */}\n      <div className=\"find-reps-section\">\n        {/* Postal code input and results */}\n      </div>\n\n      {/* Campaigns Grid */}\n      <div className=\"campaigns-grid\">\n        <Row gutter={[24, 24]}>\n          {/* Featured campaign */}\n          {/* Regular campaigns */}\n        </Row>\n      </div>\n\n      {/* Social Sharing */}\n      <ShareButtons\n        url={window.location.href}\n        title=\"Check out these advocacy campaigns!\"\n      />\n    </PublicLayout>\n  );\n};\n\nexport default CampaignsListPage;\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#component-state","title":"Component State","text":"
// Campaign data state\nconst [campaigns, setCampaigns] = useState<Campaign[]>([]);\nconst [loading, setLoading] = useState(true);\n\n// Settings state\nconst [settings, setSettings] = useState<Settings | null>(null);\n\n// Representative lookup state\nconst [postalCode, setPostalCode] = useState('');\nconst [representatives, setRepresentatives] = useState<Representative[]>([]);\nconst [repsLoading, setRepsLoading] = useState(false);\n\n// Responsive design state\nconst screens = useBreakpoint();\nconst isMobile = !screens.md;\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#derived-state","title":"Derived State","text":"
// Separate featured and regular campaigns\nconst featuredCampaign = campaigns.find(c => c.isFeatured);\nconst regularCampaigns = campaigns.filter(c => !c.isFeatured);\n\n// Filter active campaigns only (done server-side in API)\n// API returns only isActive=true campaigns\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#state-flow","title":"State Flow","text":"
  1. Initial Load: loading=true, fetch campaigns and settings in parallel
  2. Data Received: setCampaigns(), setSettings(), setLoading(false)
  3. Postal Code Entry: User types, setPostalCode() updates state
  4. Lookup Trigger: On blur/Enter, setRepsLoading(true), fetch reps
  5. Reps Received: setRepresentatives(), setRepsLoading(false)
  6. Error Handling: Display message.error(), reset loading states
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#endpoints-used","title":"Endpoints Used","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#1-get-settings","title":"1. Get Settings","text":"
GET /api/settings\n

Response:

{\n  \"organizationName\": \"Progressive Action Network\",\n  \"contactEmail\": \"contact@example.org\",\n  \"allowPublicRegistration\": true,\n  \"defaultMapCenter\": [45.5017, -73.5673],\n  \"defaultMapZoom\": 12\n}\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#2-list-public-campaigns","title":"2. List Public Campaigns","text":"
GET /api/public/campaigns\n

Response:

[\n  {\n    \"id\": \"cm1abc123\",\n    \"title\": \"Support Climate Action Bill\",\n    \"description\": \"Urge your representatives to support strong climate legislation\",\n    \"slug\": \"climate-action-bill\",\n    \"coverPhoto\": \"https://example.com/photos/climate.jpg\",\n    \"governmentLevel\": [\"federal\"],\n    \"targetType\": \"representatives\",\n    \"isFeatured\": true,\n    \"isActive\": true,\n    \"emailsSentCount\": 1247,\n    \"responsesCount\": 342,\n    \"createdAt\": \"2025-01-15T10:00:00.000Z\",\n    \"updatedAt\": \"2025-02-10T14:30:00.000Z\"\n  }\n]\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#3-lookup-representatives","title":"3. Lookup Representatives","text":"
GET /api/public/representatives/lookup?postalCode=K1A0B1\n

Response:

[\n  {\n    \"name\": \"John Smith\",\n    \"district_name\": \"Ottawa Centre\",\n    \"elected_office\": \"MP\",\n    \"party_name\": \"Liberal\",\n    \"email\": \"john.smith@parl.gc.ca\",\n    \"photo_url\": \"https://represent.opennorth.ca/media/photos/mp-john-smith.jpg\",\n    \"offices\": [\n      {\n        \"tel\": \"613-555-1234\",\n        \"type\": \"constituency\",\n        \"postal\": \"123 Main St, Ottawa ON K1A 0B1\"\n      }\n    ],\n    \"government_level\": \"federal\"\n  }\n]\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#request-examples","title":"Request Examples","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#fetch-campaigns","title":"Fetch Campaigns","text":"
useEffect(() => {\n  const fetchData = async () => {\n    try {\n      setLoading(true);\n      const [campaignsRes, settingsRes] = await Promise.all([\n        axios.get('/api/public/campaigns'),\n        axios.get('/api/settings')\n      ]);\n      setCampaigns(campaignsRes.data);\n      setSettings(settingsRes.data);\n    } catch (error) {\n      console.error('Failed to fetch data:', error);\n      message.error('Failed to load campaigns');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  fetchData();\n}, []);\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#lookup-representatives","title":"Lookup Representatives","text":"
const handlePostalCodeLookup = async () => {\n  if (!postalCode.trim()) {\n    message.warning('Please enter a postal code');\n    return;\n  }\n\n  try {\n    setRepsLoading(true);\n    const response = await axios.get('/api/public/representatives/lookup', {\n      params: { postalCode: postalCode.trim().toUpperCase() }\n    });\n    setRepresentatives(response.data);\n\n    if (response.data.length === 0) {\n      message.info('No representatives found for this postal code');\n    }\n  } catch (error) {\n    console.error('Lookup failed:', error);\n    message.error('Failed to find representatives. Please check the postal code.');\n  } finally {\n    setRepsLoading(false);\n  }\n};\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#hero-banner-component","title":"Hero Banner Component","text":"
<div style={{\n  background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',\n  padding: isMobile ? '60px 20px' : '80px 40px',\n  textAlign: 'center',\n  marginBottom: 48,\n  borderRadius: 8\n}}>\n  <Title\n    level={1}\n    style={{\n      color: 'white',\n      marginBottom: 16,\n      fontSize: isMobile ? 24 : 32\n    }}\n  >\n    {settings?.organizationName || 'Changemaker Lite'}\n  </Title>\n  <Paragraph\n    style={{\n      color: 'rgba(255,255,255,0.9)',\n      fontSize: isMobile ? 16 : 18,\n      maxWidth: 600,\n      margin: '0 auto'\n    }}\n  >\n    <MailOutlined style={{ marginRight: 8 }} />\n    Join thousands taking action on the issues that matter\n  </Paragraph>\n</div>\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#representative-lookup-section","title":"Representative Lookup Section","text":"
<div style={{\n  background: theme.token.colorBgContainer,\n  padding: isMobile ? 24 : 40,\n  borderRadius: 8,\n  marginBottom: 48\n}}>\n  <Title level={2} style={{ textAlign: 'center', marginBottom: 24 }}>\n    Find Your Representatives\n  </Title>\n\n  <Input\n    size=\"large\"\n    placeholder=\"Enter your postal code (e.g., K1A 0B1)\"\n    prefix={<SearchOutlined />}\n    suffix={repsLoading ? <Spin size=\"small\" /> : null}\n    value={postalCode}\n    onChange={(e) => setPostalCode(e.target.value)}\n    onBlur={handlePostalCodeLookup}\n    onPressEnter={handlePostalCodeLookup}\n    style={{\n      maxWidth: 500,\n      display: 'block',\n      margin: '0 auto 24px'\n    }}\n  />\n\n  {representatives.length > 0 && (\n    <Row gutter={[16, 16]}>\n      {representatives.map((rep, idx) => (\n        <Col xs={24} sm={12} lg={8} key={idx}>\n          <Card hoverable>\n            <div style={{ textAlign: 'center' }}>\n              <img\n                src={rep.photo_url || '/default-avatar.png'}\n                alt={rep.name}\n                style={{\n                  width: 150,\n                  height: 150,\n                  borderRadius: '50%',\n                  objectFit: 'cover',\n                  marginBottom: 16\n                }}\n              />\n              <Title level={4} style={{ marginBottom: 4 }}>\n                {rep.name}\n              </Title>\n              <Text type=\"secondary\">\n                {rep.elected_office} \u2022 {rep.district_name}\n              </Text>\n              <div style={{ marginTop: 12 }}>\n                <Tag color=\"blue\">{rep.party_name}</Tag>\n              </div>\n              <div style={{ marginTop: 16, textAlign: 'left' }}>\n                <Text strong>Email:</Text>\n                <br />\n                <Text copyable>{rep.email}</Text>\n                <br /><br />\n                {rep.offices?.[0] && (\n                  <>\n                    <Text strong>Phone:</Text>\n                    <br />\n                    <Text>{rep.offices[0].tel}</Text>\n                    <br /><br />\n                    <Text strong>Address:</Text>\n                    <br />\n                    <Text type=\"secondary\">{rep.offices[0].postal}</Text>\n                  </>\n                )}\n              </div>\n            </div>\n          </Card>\n        </Col>\n      ))}\n    </Row>\n  )}\n</div>\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#featured-campaign-card","title":"Featured Campaign Card","text":"
{featuredCampaign && (\n  <Col span={24} key={featuredCampaign.id}>\n    <Card\n      hoverable\n      style={{\n        border: '2px solid #f39c12',\n        boxShadow: '0 4px 12px rgba(243, 156, 18, 0.3)',\n        position: 'relative'\n      }}\n      cover={\n        <div style={{ position: 'relative', height: 300, overflow: 'hidden' }}>\n          {featuredCampaign.coverPhoto ? (\n            <img\n              src={featuredCampaign.coverPhoto}\n              alt={featuredCampaign.title}\n              style={{\n                width: '100%',\n                height: '100%',\n                objectFit: 'cover'\n              }}\n            />\n          ) : (\n            <div style={{\n              width: '100%',\n              height: '100%',\n              background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'\n            }} />\n          )}\n          <div style={{\n            position: 'absolute',\n            top: 16,\n            right: 16,\n            background: 'rgba(243, 156, 18, 0.9)',\n            color: 'white',\n            padding: '8px 16px',\n            borderRadius: 4,\n            display: 'flex',\n            alignItems: 'center',\n            gap: 8\n          }}>\n            <StarFilled />\n            <Text strong style={{ color: 'white' }}>\n              Featured Campaign\n            </Text>\n          </div>\n        </div>\n      }\n    >\n      <Title level={3} style={{ marginBottom: 12 }}>\n        {featuredCampaign.title}\n      </Title>\n\n      <Paragraph\n        ellipsis={{ rows: 2 }}\n        style={{ marginBottom: 16 }}\n      >\n        {featuredCampaign.description}\n      </Paragraph>\n\n      <div style={{ marginBottom: 16 }}>\n        {featuredCampaign.governmentLevel.map(level => (\n          <Tag key={level} color=\"blue\">\n            {level.charAt(0).toUpperCase() + level.slice(1)}\n          </Tag>\n        ))}\n      </div>\n\n      <Row gutter={16} style={{ marginBottom: 16 }}>\n        <Col span={12}>\n          <div style={{ textAlign: 'center' }}>\n            <MailOutlined style={{ fontSize: 24, color: '#1890ff' }} />\n            <div>\n              <Text strong>{featuredCampaign.emailsSentCount}</Text>\n              <br />\n              <Text type=\"secondary\">Emails Sent</Text>\n            </div>\n          </div>\n        </Col>\n        <Col span={12}>\n          <div style={{ textAlign: 'center' }}>\n            <CommentOutlined style={{ fontSize: 24, color: '#52c41a' }} />\n            <div>\n              <Text strong>{featuredCampaign.responsesCount}</Text>\n              <br />\n              <Text type=\"secondary\">Responses</Text>\n            </div>\n          </div>\n        </Col>\n      </Row>\n\n      <Link to={`/campaigns/${featuredCampaign.id}`}>\n        <Button type=\"primary\" block size=\"large\">\n          View Campaign\n        </Button>\n      </Link>\n    </Card>\n  </Col>\n)}\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#regular-campaign-cards","title":"Regular Campaign Cards","text":"
{regularCampaigns.map((campaign) => (\n  <Col xs={24} sm={12} lg={8} key={campaign.id}>\n    <Card\n      hoverable\n      cover={\n        <div style={{ height: 200, overflow: 'hidden' }}>\n          {campaign.coverPhoto ? (\n            <img\n              src={campaign.coverPhoto}\n              alt={campaign.title}\n              style={{\n                width: '100%',\n                height: '100%',\n                objectFit: 'cover'\n              }}\n            />\n          ) : (\n            <div style={{\n              width: '100%',\n              height: '100%',\n              background: 'linear-gradient(135deg, #3498db 0%, #8e44ad 100%)'\n            }} />\n          )}\n        </div>\n      }\n    >\n      <Title level={4} style={{ marginBottom: 8 }}>\n        {campaign.title}\n      </Title>\n\n      <Paragraph\n        ellipsis={{ rows: 2 }}\n        type=\"secondary\"\n        style={{ marginBottom: 12, minHeight: 44 }}\n      >\n        {campaign.description || 'No description available'}\n      </Paragraph>\n\n      <div style={{ marginBottom: 12 }}>\n        {campaign.governmentLevel.map(level => (\n          <Tag key={level} color=\"purple\">\n            {level.charAt(0).toUpperCase() + level.slice(1)}\n          </Tag>\n        ))}\n      </div>\n\n      <Row gutter={8} style={{ marginBottom: 12, fontSize: 12 }}>\n        <Col span={12}>\n          <MailOutlined /> {campaign.emailsSentCount} sent\n        </Col>\n        <Col span={12}>\n          <CommentOutlined /> {campaign.responsesCount} responses\n        </Col>\n      </Row>\n\n      <Link to={`/campaigns/${campaign.id}`}>\n        <Button type=\"link\" block>\n          View Campaign \u2192\n        </Button>\n      </Link>\n    </Card>\n  </Col>\n))}\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#empty-state","title":"Empty State","text":"
{!loading && campaigns.length === 0 && (\n  <div style={{\n    textAlign: 'center',\n    padding: 60,\n    background: theme.token.colorBgContainer,\n    borderRadius: 8\n  }}>\n    <InboxOutlined style={{ fontSize: 64, color: '#999', marginBottom: 16 }} />\n    <Title level={3} type=\"secondary\">\n      No campaigns available\n    </Title>\n    <Paragraph type=\"secondary\">\n      Check back soon for new advocacy opportunities!\n    </Paragraph>\n  </div>\n)}\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#1-parallel-data-fetching","title":"1. Parallel Data Fetching","text":"

Campaigns and settings fetched simultaneously using Promise.all():

const [campaignsRes, settingsRes] = await Promise.all([\n  axios.get('/api/public/campaigns'),\n  axios.get('/api/settings')\n]);\n

Benefit: Reduces initial page load time by ~50% vs sequential requests.

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#2-image-loading-optimization","title":"2. Image Loading Optimization","text":"
  • Object-fit: objectFit: 'cover' prevents layout shift
  • Fixed Heights: Cover photos have defined heights (300px featured, 200px regular)
  • Fallback Gradients: Instant render when no cover photo exists
  • Lazy Loading: Browser-native lazy loading for off-screen images (future enhancement)
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#3-conditional-rendering","title":"3. Conditional Rendering","text":"

Representative lookup section only renders when results exist:

{representatives.length > 0 && (\n  <Row gutter={[16, 16]}>\n    {/* Rep cards */}\n  </Row>\n)}\n

Benefit: Avoids unnecessary DOM nodes and improves TTI (Time to Interactive).

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#4-responsive-grid-optimization","title":"4. Responsive Grid Optimization","text":"

Ant Design Grid uses CSS Grid under the hood:

<Row gutter={[24, 24]}>\n  <Col xs={24} sm={12} lg={8}>\n

Benefit: No JavaScript-based layout calculations, pure CSS performance.

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#5-memoization-opportunities-future-enhancement","title":"5. Memoization Opportunities (Future Enhancement)","text":"

Featured/regular campaign split could use useMemo:

const { featuredCampaign, regularCampaigns } = useMemo(() => ({\n  featuredCampaign: campaigns.find(c => c.isFeatured),\n  regularCampaigns: campaigns.filter(c => !c.isFeatured)\n}), [campaigns]);\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#breakpoint-behavior","title":"Breakpoint Behavior","text":"
const screens = useBreakpoint();\nconst isMobile = !screens.md; // md breakpoint = 768px\n
Breakpoint Hero Padding Hero Font Grid Columns Rep Cards xs (0-575px) 60px 20px 24px 1 1 sm (576-767px) 60px 20px 24px 2 2 md (768-991px) 80px 40px 32px 2 2 lg (992px+) 80px 40px 32px 3 3"},{"location":"v2/frontend/pages/public/campaigns-list-page/#mobile-adaptations","title":"Mobile Adaptations","text":"

Hero Banner: - Reduced padding (60px vs 80px vertical) - Smaller title font (24px vs 32px) - Maintained gradient for visual impact

Representative Cards: - Stack to single column on mobile - Maintain circular avatar size (150px) - Full-width buttons for better touch targets

Campaign Cards: - Single column layout on mobile - Cover photo height remains 200px (cropped if needed) - Action buttons become full-width

Find Your Representatives Input: - Full-width on mobile (maxWidth: 500px on desktop) - Larger touch target (size=\"large\") - Enter key triggers lookup for better mobile UX

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#tablet-optimization","title":"Tablet Optimization","text":"

At sm breakpoint (576-767px): - Campaign grid shows 2 columns - Representative cards show 2 per row - Hero banner uses mobile padding but desktop font size - Maintains visual hierarchy without overwhelming narrow viewports

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

Interactive Elements: - All buttons and links focusable via Tab key - Postal code input supports Enter key submission - Card hover states also apply on keyboard focus

Focus Management:

<Input\n  onPressEnter={handlePostalCodeLookup}\n  // Focus indicator via Ant Design theme\n/>\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#aria-labels","title":"ARIA Labels","text":"

Representative Photos:

<img\n  src={rep.photo_url || '/default-avatar.png'}\n  alt={`Photo of ${rep.name}, ${rep.elected_office} for ${rep.district_name}`}\n  // Descriptive alt text for screen readers\n/>\n

Loading States:

<Spin size=\"small\" aria-label=\"Loading representatives\" />\n

Icon Buttons:

<Button\n  icon={<SearchOutlined />}\n  aria-label=\"Search for representatives\"\n>\n  Find Representatives\n</Button>\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#screen-reader-support","title":"Screen Reader Support","text":"

Structural Headings: - Page uses semantic heading hierarchy (h1 \u2192 h2 \u2192 h3 \u2192 h4) - Hero uses <Title level={1}> for main page title - Sections use <Title level={2}> for logical grouping

Empty States: - Informative messages for \"No campaigns\" and \"No representatives found\" - Visual icons paired with text labels

Statistics:

<Text strong>{campaign.emailsSentCount}</Text>\n<br />\n<Text type=\"secondary\">Emails Sent</Text>\n// Screen reader announces: \"1247 Emails Sent\"\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#color-contrast","title":"Color Contrast","text":"

Dark Theme Compliance: - Background #0d1b2a with white text meets WCAG AA (7.8:1 ratio) - Links use #1890ff with sufficient contrast (4.6:1 ratio) - Tag colors (blue, purple, gold) all meet AA standards

Interactive States: - Hover effects use opacity changes (accessible to screen readers) - Focus states use browser default outline (visible on all elements)

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#issue-representatives-not-loading","title":"Issue: Representatives Not Loading","text":"

Symptoms: - Postal code input shows no results - Console shows 404 or 500 error - Loading spinner stuck

Causes: 1. Invalid postal code format (must be Canadian: A1A 1A1) 2. Represent API rate limiting (429 response) 3. Redis cache connection failure 4. Network timeout

Solutions:

// Add postal code validation\nconst isValidPostalCode = (code: string) => {\n  const regex = /^[A-Z]\\d[A-Z]\\s?\\d[A-Z]\\d$/i;\n  return regex.test(code);\n};\n\nconst handlePostalCodeLookup = async () => {\n  const cleanCode = postalCode.trim().toUpperCase();\n\n  if (!isValidPostalCode(cleanCode)) {\n    message.error('Please enter a valid Canadian postal code (e.g., K1A 0B1)');\n    return;\n  }\n\n  try {\n    setRepsLoading(true);\n    const response = await axios.get('/api/public/representatives/lookup', {\n      params: { postalCode: cleanCode },\n      timeout: 10000 // 10s timeout\n    });\n\n    setRepresentatives(response.data);\n  } catch (error: any) {\n    if (error.code === 'ECONNABORTED') {\n      message.error('Request timed out. Please try again.');\n    } else if (error.response?.status === 429) {\n      message.error('Too many requests. Please wait a moment and try again.');\n    } else {\n      message.error('Failed to find representatives. Please try again later.');\n    }\n    console.error('Lookup error:', error);\n  } finally {\n    setRepsLoading(false);\n  }\n};\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#issue-cover-photos-not-displaying","title":"Issue: Cover Photos Not Displaying","text":"

Symptoms: - Campaign cards show gradient instead of uploaded photos - Console shows CORS errors - Broken image icons

Causes: 1. Invalid image URL in database 2. CORS policy blocking external images 3. Image file deleted from storage 4. Incorrect Nginx configuration

Solutions:

// Add image error handling\nconst [imageErrors, setImageErrors] = useState<Set<string>>(new Set());\n\nconst handleImageError = (campaignId: string) => {\n  setImageErrors(prev => new Set(prev).add(campaignId));\n};\n\n// In card cover render:\ncover={\n  <div style={{ height: 200, overflow: 'hidden' }}>\n    {campaign.coverPhoto && !imageErrors.has(campaign.id) ? (\n      <img\n        src={campaign.coverPhoto}\n        alt={campaign.title}\n        onError={() => handleImageError(campaign.id)}\n        style={{\n          width: '100%',\n          height: '100%',\n          objectFit: 'cover'\n        }}\n      />\n    ) : (\n      <div style={{\n        width: '100%',\n        height: '100%',\n        background: 'linear-gradient(135deg, #3498db 0%, #8e44ad 100%)'\n      }} />\n    )}\n  </div>\n}\n

Check Nginx configuration:

# In nginx/conf.d/default.conf\nlocation /uploads/ {\n    add_header Access-Control-Allow-Origin *;\n    add_header Access-Control-Allow-Methods \"GET, OPTIONS\";\n}\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#issue-featured-campaign-not-appearing-first","title":"Issue: Featured Campaign Not Appearing First","text":"

Symptoms: - Featured campaign appears in middle/end of grid - Gold border not visible - Star icon missing

Causes: 1. isFeatured flag not set in database 2. Multiple campaigns marked as featured 3. Grid rendering logic error

Solutions:

// Add debug logging\nuseEffect(() => {\n  if (campaigns.length > 0) {\n    const featured = campaigns.filter(c => c.isFeatured);\n    console.log(`Found ${featured.length} featured campaigns:`, featured);\n\n    if (featured.length > 1) {\n      console.warn('Multiple campaigns marked as featured! Only first will display.');\n    }\n  }\n}, [campaigns]);\n\n// Ensure only one featured campaign\nconst featuredCampaign = campaigns.find(c => c.isFeatured);\nconst regularCampaigns = campaigns.filter(c => !c.isFeatured);\n\n// Render in correct order\n<Row gutter={[24, 24]}>\n  {featuredCampaign && (\n    <Col span={24} key={featuredCampaign.id}>\n      {/* Featured card */}\n    </Col>\n  )}\n\n  {regularCampaigns.map((campaign) => (\n    <Col xs={24} sm={12} lg={8} key={campaign.id}>\n      {/* Regular card */}\n    </Col>\n  ))}\n</Row>\n

Check database:

-- Find all featured campaigns\nSELECT id, title, \"isFeatured\"\nFROM \"Campaign\"\nWHERE \"isFeatured\" = true\nAND \"isActive\" = true;\n\n-- Fix multiple featured campaigns (keep most recent)\nUPDATE \"Campaign\"\nSET \"isFeatured\" = false\nWHERE \"isFeatured\" = true\nAND id != (\n  SELECT id\n  FROM \"Campaign\"\n  WHERE \"isFeatured\" = true\n  ORDER BY \"updatedAt\" DESC\n  LIMIT 1\n);\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#issue-sharebuttons-not-working","title":"Issue: ShareButtons Not Working","text":"

Symptoms: - Clicking share icons does nothing - \"Copy Link\" doesn't copy to clipboard - No new windows opening

Causes: 1. Popup blockers preventing window.open() 2. Clipboard API not available (non-HTTPS) 3. ShareButtons component not imported 4. Missing event handlers

Solutions:

// Ensure HTTPS for clipboard API\nif (!navigator.clipboard) {\n  console.warn('Clipboard API requires HTTPS');\n  // Fallback to textarea copy method\n}\n\n// Add user interaction check for popups\nconst handleShare = (platform: string) => {\n  // Must be triggered by user action (not async callback)\n  const url = encodeURIComponent(window.location.href);\n  const title = encodeURIComponent('Check out these advocacy campaigns!');\n\n  let shareUrl = '';\n  switch (platform) {\n    case 'twitter':\n      shareUrl = `https://twitter.com/intent/tweet?url=${url}&text=${title}`;\n      break;\n    case 'facebook':\n      shareUrl = `https://www.facebook.com/sharer/sharer.php?u=${url}`;\n      break;\n    // ... other platforms\n  }\n\n  const popup = window.open(shareUrl, '_blank', 'width=600,height=400');\n  if (!popup) {\n    message.warning('Please allow popups to share on social media');\n  }\n};\n
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#issue-page-loading-very-slowly","title":"Issue: Page Loading Very Slowly","text":"

Symptoms: - Spinner shows for 5+ seconds - Network tab shows slow API responses - Images take long to load

Causes: 1. Large campaign list (100+ campaigns) 2. High-resolution cover photos (5MB+ files) 3. No database indexes on isActive column 4. N+1 query problem (not in this case, single query)

Solutions:

Add pagination (API change required):

const [page, setPage] = useState(1);\nconst [total, setTotal] = useState(0);\nconst pageSize = 12;\n\nuseEffect(() => {\n  const fetchCampaigns = async () => {\n    try {\n      setLoading(true);\n      const response = await axios.get('/api/public/campaigns', {\n        params: { page, limit: pageSize }\n      });\n      setCampaigns(response.data.campaigns);\n      setTotal(response.data.total);\n    } catch (error) {\n      console.error('Failed to fetch campaigns:', error);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  fetchCampaigns();\n}, [page]);\n\n// Add Pagination component\n<Pagination\n  current={page}\n  total={total}\n  pageSize={pageSize}\n  onChange={setPage}\n  style={{ marginTop: 24, textAlign: 'center' }}\n/>\n

Optimize images server-side:

# Add image resizing in upload pipeline\n# Max width: 1200px, quality: 80%\nconvert input.jpg -resize 1200x -quality 80 output.jpg\n

Add database index:

CREATE INDEX idx_campaign_active_featured\nON \"Campaign\" (\"isActive\", \"isFeatured\", \"updatedAt\" DESC);\n

"},{"location":"v2/frontend/pages/public/campaigns-list-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#public-pages","title":"Public Pages","text":"
  • Campaign Detail Page - Individual campaign view with email sending
  • Response Wall Page - Public response submission and display
  • Map Page - Public location map
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#admin-pages","title":"Admin Pages","text":"
  • Campaigns Management - Campaign CRUD and configuration
  • Representatives Admin - Rep cache management
  • Settings Page - Organization name configuration
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#components","title":"Components","text":"
  • PublicLayout - Dark theme layout wrapper
  • ShareButtons - Social sharing component
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#api-documentation","title":"API Documentation","text":"
  • Public Campaigns API
  • Representatives API
  • Settings API
"},{"location":"v2/frontend/pages/public/campaigns-list-page/#architecture","title":"Architecture","text":"
  • V2 Architecture Overview
  • Public vs Admin Routing
  • Ant Design Theme Configuration
"},{"location":"v2/frontend/pages/public/landing-page/","title":"Landing Page (Public Page Renderer)","text":""},{"location":"v2/frontend/pages/public/landing-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/public/LandingPage.tsx (68 lines)

Route: /p/:slug

Role Requirements: Public access

Purpose: Simple renderer for admin-authored landing pages created with GrapesJS editor. Fetches page by slug and displays HTML/CSS content with SEO meta tags.

Key Features:

  • Minimal wrapper around admin-authored HTML
  • SEO meta tags (title, description, og:image)
  • dangerouslySetInnerHTML for HTML + CSS rendering
  • Loading spinner during fetch
  • 404 page for invalid slugs
  • No layout wrapper (pages are self-contained)
"},{"location":"v2/frontend/pages/public/landing-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/landing-page/#1-seo-meta-tags","title":"1. SEO Meta Tags","text":"
<Helmet>\n  <title>{page.title}</title>\n  <meta name=\"description\" content={page.description || ''} />\n  <meta property=\"og:title\" content={page.title} />\n  <meta property=\"og:description\" content={page.description || ''} />\n  {page.coverImage && <meta property=\"og:image\" content={page.coverImage} />}\n</Helmet>\n
"},{"location":"v2/frontend/pages/public/landing-page/#2-html-rendering","title":"2. HTML Rendering","text":"
<div dangerouslySetInnerHTML={{ __html: page.html }} />\n<style>{page.css}</style>\n
"},{"location":"v2/frontend/pages/public/landing-page/#3-loading-state","title":"3. Loading State","text":"
{loading && (\n  <div style={{ textAlign: 'center', padding: 100 }}>\n    <Spin size=\"large\" />\n  </div>\n)}\n
"},{"location":"v2/frontend/pages/public/landing-page/#4-404-handling","title":"4. 404 Handling","text":"
{!loading && !page && (\n  <Result\n    status=\"404\"\n    title=\"Page Not Found\"\n    subTitle=\"The page you're looking for doesn't exist.\"\n    extra={<Link to=\"/\"><Button type=\"primary\">Go Home</Button></Link>}\n  />\n)}\n
"},{"location":"v2/frontend/pages/public/landing-page/#api-integration","title":"API Integration","text":"
GET /api/public/pages/:slug\n

Response:

{\n  \"slug\": \"welcome\",\n  \"title\": \"Welcome to Our Campaign\",\n  \"description\": \"Join us in making a difference\",\n  \"html\": \"<div><h1>Welcome</h1>...</div>\",\n  \"css\": \"h1 { color: #1890ff; }\",\n  \"coverImage\": \"https://example.com/cover.jpg\",\n  \"isPublished\": true\n}\n

"},{"location":"v2/frontend/pages/public/landing-page/#security-considerations","title":"Security Considerations","text":"

XSS Risk Accepted: - Pages authored by trusted admins only - dangerouslySetInnerHTML allows full HTML/JS - No user-submitted content - Alternative would break GrapesJS output

"},{"location":"v2/frontend/pages/public/landing-page/#related-documentation","title":"Related Documentation","text":"
  • Landing Pages Admin
  • Page Editor
  • GrapesJS Component
"},{"location":"v2/frontend/pages/public/map-page/","title":"Public Map Page","text":""},{"location":"v2/frontend/pages/public/map-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/public/MapPage.tsx (474 lines)

Route: /map

Role Requirements: Public access (no authentication required)

Purpose: Interactive public-facing map displaying campaign locations with color-coded support levels, cut polygons, and multi-unit building support. Provides geographic visualization of campaign activity and volunteer canvass coverage.

Key Features:

  • Full-viewport Leaflet map with minimal header (48px)
  • OpenStreetMap tile layer
  • Color-coded circle markers by support level (Strong/Leaning/Undecided/Opposed/No Answer)
  • Multi-unit building popups with sorted unit lists
  • Cut polygon overlays with toggle controls
  • Geolocate button (find my location)
  • Fullscreen button
  • Viewport-based location loading with 800ms debounce
  • GPS position marker when geolocation active
  • Dark theme header consistent with public pages

Layout: Uses PublicLayout with custom header override (thin, 48px)

"},{"location":"v2/frontend/pages/public/map-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/map-page/#1-thin-header-design","title":"1. Thin Header Design","text":"

Minimal header to maximize map space:

  • Height: 48px (vs standard 64px)
  • Background: Dark blue (#0d1b2a)
  • Logo: Organization name with map icon
  • No Navigation Menu: Map is primary content
  • Mobile Responsive: Hamburger menu available
"},{"location":"v2/frontend/pages/public/map-page/#2-color-coded-location-markers","title":"2. Color-Coded Location Markers","text":"

Visual support level indication:

  • Strong Support: Green (#52c41a)
  • Leaning Support: Light green (#95de64)
  • Undecided: Yellow (#fadb14)
  • Leaning Opposed: Orange (#ff7a45)
  • Opposed: Red (#f5222d)
  • No Answer: Gray (#8c8c8c)
  • Not Home: Light gray (#d9d9d9)

Marker Styling: - Circle radius: 8px - Stroke: White 2px - Fill opacity: 0.8 - Hover: Increased opacity (1.0)

"},{"location":"v2/frontend/pages/public/map-page/#3-multi-unit-building-popups","title":"3. Multi-Unit Building Popups","text":"

Aggregated building display:

Popup Header: - Purple background (#722ed1) - Building address - Total unit count badge

Unit List: - Sorted by unit number (alphanumeric) - Each row: Unit | Support Level | Notes - Color-coded support badges - Scrollable if >10 units - Max height: 300px

Example:

123 Main St [5 units]\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nUnit 101 | Strong Support | Yard sign\nUnit 102 | Undecided | -\nUnit 201 | No Answer | Left flyer\n

"},{"location":"v2/frontend/pages/public/map-page/#4-cut-polygon-overlays","title":"4. Cut Polygon Overlays","text":"

Geographic boundary visualization:

Polygon Rendering: - GeoJSON format from database - Blue stroke (#1890ff) - Semi-transparent fill (opacity: 0.2) - Label at centroid (cut name)

Toggle Controls: - Floating panel (bottom-left, above zoom) - Checkbox per cut - Select All / Deselect All buttons - Collapse/expand panel

Cut Label Styling: - White text with black outline - Always visible (not obscured by fill) - Click cut to toggle visibility

"},{"location":"v2/frontend/pages/public/map-page/#5-viewport-based-loading","title":"5. Viewport-Based Loading","text":"

Performance optimization for large datasets:

Loading Strategy: - Fetch only locations in current map bounds - Trigger on moveend event (pan/zoom complete) - Debounce 800ms to prevent excessive requests - Loading spinner in top-right during fetch

Bounds Calculation:

const bounds = map.getBounds();\nconst params = {\n  minLat: bounds.getSouth(),\n  maxLat: bounds.getNorth(),\n  minLng: bounds.getWest(),\n  maxLng: bounds.getEast()\n};\n

"},{"location":"v2/frontend/pages/public/map-page/#6-geolocation","title":"6. Geolocation","text":"

User position tracking:

Features: - Blue pulsing circle marker at user's position - Accuracy circle (outer ring) - Automatic pan to location on click - \"Locating...\" loading state - Error handling for denied permissions

Geolocate Button: - Floating control (top-right) - Compass icon - Primary color when active - Error message if unavailable

"},{"location":"v2/frontend/pages/public/map-page/#7-fullscreen-mode","title":"7. Fullscreen Mode","text":"

Immersive map experience:

Activation: - Fullscreen button (top-right, below geolocate) - Browser Fullscreen API - Fallback for Safari (webkitRequestFullscreen)

Exit: - ESC key - Exit fullscreen button (shows when active) - Browser native controls

"},{"location":"v2/frontend/pages/public/map-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/public/map-page/#initial-map-view","title":"Initial Map View","text":"
  1. User navigates to /map
  2. PublicLayout renders with thin header
  3. Map initializes at default center/zoom (from settings)
  4. Viewport bounds calculated
  5. API fetches locations within bounds
  6. Circle markers render for each location
  7. Cuts fetched and rendered (all visible by default)
"},{"location":"v2/frontend/pages/public/map-page/#exploring-locations","title":"Exploring Locations","text":"
  1. User pans map to new area
  2. moveend event triggers after 800ms debounce
  3. New viewport bounds calculated
  4. API fetches locations in new bounds
  5. Existing markers cleared
  6. New markers rendered
  7. User clicks marker to view popup
  8. Popup shows address, support level, notes, last visit date
"},{"location":"v2/frontend/pages/public/map-page/#viewing-multi-unit-buildings","title":"Viewing Multi-Unit Buildings","text":"
  1. User clicks purple building marker
  2. Popup opens with building header
  3. Unit list displays sorted units
  4. User scrolls list (if >10 units)
  5. User sees color-coded support levels per unit
  6. User closes popup by clicking outside or X button
"},{"location":"v2/frontend/pages/public/map-page/#using-geolocation","title":"Using Geolocation","text":"
  1. User clicks geolocate button
  2. Browser prompts for location permission
  3. User grants permission
  4. Blue pulsing marker appears at user's position
  5. Map pans to center on user
  6. Accuracy circle shows GPS precision
  7. User can pan away (marker remains visible)
"},{"location":"v2/frontend/pages/public/map-page/#toggling-cut-visibility","title":"Toggling Cut Visibility","text":"
  1. User clicks \"Cut Controls\" button (bottom-left)
  2. Panel expands showing cut checkboxes
  3. User unchecks \"Cut A\"
  4. \"Cut A\" polygon disappears from map
  5. User clicks \"Deselect All\"
  6. All polygons hidden
  7. User clicks \"Select All\"
  8. All polygons re-appear
"},{"location":"v2/frontend/pages/public/map-page/#fullscreen-mode","title":"Fullscreen Mode","text":"
  1. User clicks fullscreen button
  2. Map expands to fill entire screen
  3. Header hidden
  4. Controls remain visible
  5. User explores map at full size
  6. User presses ESC key
  7. Map returns to normal layout
"},{"location":"v2/frontend/pages/public/map-page/#component-structure","title":"Component Structure","text":"
import React, { useState, useEffect, useCallback } from 'react';\nimport { MapContainer, TileLayer, CircleMarker, Popup, useMap, useMapEvents, Polygon } from 'react-leaflet';\nimport { Button, Spin, Checkbox, Space, Typography, Badge } from 'antd';\nimport {\n  AimOutlined,\n  FullscreenOutlined,\n  FullscreenExitOutlined,\n  EnvironmentOutlined\n} from '@ant-design/icons';\nimport { debounce } from 'lodash';\nimport PublicLayout from '../../components/PublicLayout';\nimport axios from 'axios';\nimport 'leaflet/dist/leaflet.css';\n\nconst { Text } = Typography;\n\ninterface Location {\n  id: string;\n  address: string;\n  latitude: number;\n  longitude: number;\n  supportLevel: string | null;\n  notes: string | null;\n  lastVisitDate: string | null;\n  isMultiUnit: boolean;\n  units?: Array<{\n    unitNumber: string;\n    supportLevel: string | null;\n    notes: string | null;\n  }>;\n}\n\ninterface Cut {\n  id: string;\n  name: string;\n  color: string;\n  polygon: any; // GeoJSON\n}\n\nconst MapPage: React.FC = () => {\n  const [locations, setLocations] = useState<Location[]>([]);\n  const [cuts, setCuts] = useState<Cut[]>([]);\n  const [visibleCuts, setVisibleCuts] = useState<Set<string>>(new Set());\n  const [loading, setLoading] = useState(false);\n  const [userPosition, setUserPosition] = useState<[number, number] | null>(null);\n  const [mapCenter, setMapCenter] = useState<[number, number]>([45.5017, -73.5673]);\n  const [mapZoom, setMapZoom] = useState(13);\n\n  // Component logic...\n\n  return (\n    <PublicLayout headerHeight={48}>\n      <MapContainer\n        center={mapCenter}\n        zoom={mapZoom}\n        style={{ height: 'calc(100vh - 48px)', width: '100%' }}\n        zoomControl={false}\n      >\n        <TileLayer\n          url=\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\"\n          attribution='&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a>'\n        />\n\n        {/* Locations */}\n        {/* Cuts */}\n        {/* User Position */}\n        {/* Controls */}\n      </MapContainer>\n    </PublicLayout>\n  );\n};\n
"},{"location":"v2/frontend/pages/public/map-page/#state-management","title":"State Management","text":"
// Location data\nconst [locations, setLocations] = useState<Location[]>([]);\nconst [cuts, setCuts] = useState<Cut[]>([]);\nconst [visibleCuts, setVisibleCuts] = useState<Set<string>>(new Set());\n\n// Map state\nconst [mapCenter, setMapCenter] = useState<[number, number]>([45.5017, -73.5673]);\nconst [mapZoom, setMapZoom] = useState(13);\n\n// User interaction\nconst [loading, setLoading] = useState(false);\nconst [userPosition, setUserPosition] = useState<[number, number] | null>(null);\nconst [fullscreen, setFullscreen] = useState(false);\n
"},{"location":"v2/frontend/pages/public/map-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/public/map-page/#endpoints","title":"Endpoints","text":""},{"location":"v2/frontend/pages/public/map-page/#1-get-locations-by-bounds","title":"1. Get Locations by Bounds","text":"
GET /api/public/map/locations?minLat=45.4&maxLat=45.6&minLng=-73.7&maxLng=-73.4\n

Response:

[\n  {\n    \"id\": \"cm1abc123\",\n    \"address\": \"123 Main St\",\n    \"latitude\": 45.5017,\n    \"longitude\": -73.5673,\n    \"supportLevel\": \"strong_support\",\n    \"notes\": \"Yard sign requested\",\n    \"lastVisitDate\": \"2025-02-10T14:00:00.000Z\",\n    \"isMultiUnit\": false\n  }\n]\n

"},{"location":"v2/frontend/pages/public/map-page/#2-get-cuts","title":"2. Get Cuts","text":"
GET /api/public/map/cuts\n

Response:

[\n  {\n    \"id\": \"cm2def456\",\n    \"name\": \"Downtown District\",\n    \"color\": \"#1890ff\",\n    \"polygon\": {\n      \"type\": \"Polygon\",\n      \"coordinates\": [[[-73.6, 45.5], [-73.5, 45.5], [-73.5, 45.6], [-73.6, 45.6], [-73.6, 45.5]]]\n    }\n  }\n]\n

"},{"location":"v2/frontend/pages/public/map-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/public/map-page/#viewport-based-loading-with-debounce","title":"Viewport-Based Loading with Debounce","text":"
const MapEventsHandler = () => {\n  const map = useMap();\n\n  const fetchLocationsInBounds = useCallback(async () => {\n    const bounds = map.getBounds();\n    setLoading(true);\n\n    try {\n      const response = await axios.get('/api/public/map/locations', {\n        params: {\n          minLat: bounds.getSouth(),\n          maxLat: bounds.getNorth(),\n          minLng: bounds.getWest(),\n          maxLng: bounds.getEast()\n        }\n      });\n      setLocations(response.data);\n    } catch (error) {\n      console.error('Failed to fetch locations:', error);\n    } finally {\n      setLoading(false);\n    }\n  }, [map]);\n\n  const debouncedFetch = useCallback(\n    debounce(fetchLocationsInBounds, 800),\n    [fetchLocationsInBounds]\n  );\n\n  useMapEvents({\n    moveend: debouncedFetch\n  });\n\n  return null;\n};\n
"},{"location":"v2/frontend/pages/public/map-page/#color-coded-location-markers","title":"Color-Coded Location Markers","text":"
const getSupportLevelColor = (level: string | null): string => {\n  switch (level) {\n    case 'strong_support': return '#52c41a';\n    case 'leaning_support': return '#95de64';\n    case 'undecided': return '#fadb14';\n    case 'leaning_opposed': return '#ff7a45';\n    case 'opposed': return '#f5222d';\n    case 'no_answer': return '#8c8c8c';\n    case 'not_home': return '#d9d9d9';\n    default: return '#8c8c8c';\n  }\n};\n\n{locations.map(location => (\n  <CircleMarker\n    key={location.id}\n    center={[location.latitude, location.longitude]}\n    radius={8}\n    pathOptions={{\n      color: 'white',\n      weight: 2,\n      fillColor: getSupportLevelColor(location.supportLevel),\n      fillOpacity: 0.8\n    }}\n  >\n    <Popup>\n      <div style={{ minWidth: 200 }}>\n        <Text strong style={{ display: 'block', marginBottom: 8 }}>\n          {location.address}\n        </Text>\n        {location.supportLevel && (\n          <Text>Support: {location.supportLevel.replace('_', ' ')}</Text>\n        )}\n        {location.notes && (\n          <Text type=\"secondary\" style={{ display: 'block', marginTop: 4, fontSize: 12 }}>\n            {location.notes}\n          </Text>\n        )}\n      </div>\n    </Popup>\n  </CircleMarker>\n))}\n
"},{"location":"v2/frontend/pages/public/map-page/#multi-unit-building-popup","title":"Multi-Unit Building Popup","text":"
{location.isMultiUnit && location.units && (\n  <Popup>\n    <div style={{ minWidth: 300, maxHeight: 400, overflow: 'auto' }}>\n      <div style={{\n        background: '#722ed1',\n        color: 'white',\n        padding: 12,\n        margin: -12,\n        marginBottom: 12\n      }}>\n        <Text strong style={{ color: 'white', fontSize: 16 }}>\n          {location.address}\n        </Text>\n        <Badge\n          count={location.units.length}\n          style={{ marginLeft: 8, background: 'white', color: '#722ed1' }}\n        />\n      </div>\n\n      <table style={{ width: '100%', fontSize: 12 }}>\n        <thead>\n          <tr style={{ borderBottom: '1px solid #f0f0f0' }}>\n            <th style={{ textAlign: 'left', padding: 4 }}>Unit</th>\n            <th style={{ textAlign: 'left', padding: 4 }}>Support</th>\n            <th style={{ textAlign: 'left', padding: 4 }}>Notes</th>\n          </tr>\n        </thead>\n        <tbody>\n          {location.units\n            .sort((a, b) => a.unitNumber.localeCompare(b.unitNumber, undefined, { numeric: true }))\n            .map((unit, idx) => (\n              <tr key={idx} style={{ borderBottom: '1px solid #f5f5f5' }}>\n                <td style={{ padding: 4 }}>{unit.unitNumber}</td>\n                <td style={{ padding: 4 }}>\n                  <span style={{\n                    background: getSupportLevelColor(unit.supportLevel),\n                    color: 'white',\n                    padding: '2px 6px',\n                    borderRadius: 3,\n                    fontSize: 11\n                  }}>\n                    {unit.supportLevel?.replace('_', ' ') || '-'}\n                  </span>\n                </td>\n                <td style={{ padding: 4, fontSize: 11, color: '#666' }}>\n                  {unit.notes || '-'}\n                </td>\n              </tr>\n            ))}\n        </tbody>\n      </table>\n    </div>\n  </Popup>\n)}\n
"},{"location":"v2/frontend/pages/public/map-page/#performance-considerations","title":"Performance Considerations","text":"
  1. Debounced Loading: 800ms debounce prevents excessive API calls during panning
  2. Viewport Filtering: Only loads visible locations (scalable to 10,000+ locations)
  3. React-Leaflet Optimization: Uses key prop to prevent unnecessary re-renders
  4. Lazy Popup Rendering: Popups created on-demand, not upfront
"},{"location":"v2/frontend/pages/public/map-page/#responsive-design","title":"Responsive Design","text":"
  • Mobile: Full viewport height minus 48px header
  • Touch Gestures: Native Leaflet touch support (pinch zoom, swipe pan)
  • Fullscreen: Available on all devices via browser API
"},{"location":"v2/frontend/pages/public/map-page/#accessibility","title":"Accessibility","text":"
  • Keyboard Navigation: Map focusable, arrow keys pan
  • Button Labels: All control buttons have aria-labels
  • Color Contrast: Marker strokes ensure visibility on all backgrounds
  • Screen Reader: Popup content readable, location count announced
"},{"location":"v2/frontend/pages/public/map-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/public/map-page/#issue-markers-not-appearing","title":"Issue: Markers Not Appearing","text":"

Causes: 1. Locations outside viewport bounds 2. API returning empty array 3. Leaflet CSS not imported

Solutions:

import 'leaflet/dist/leaflet.css'; // Must be imported\n\n// Add debug logging\nuseEffect(() => {\n  console.log(`Loaded ${locations.length} locations`);\n}, [locations]);\n

"},{"location":"v2/frontend/pages/public/map-page/#issue-geolocation-not-working","title":"Issue: Geolocation Not Working","text":"

Causes: 1. HTTPS required for geolocation API 2. User denied permission 3. Browser doesn't support geolocation

Solutions:

const handleGeolocate = () => {\n  if (!navigator.geolocation) {\n    message.error('Geolocation not supported by your browser');\n    return;\n  }\n\n  navigator.geolocation.getCurrentPosition(\n    (position) => {\n      const pos: [number, number] = [\n        position.coords.latitude,\n        position.coords.longitude\n      ];\n      setUserPosition(pos);\n      map.flyTo(pos, 16);\n    },\n    (error) => {\n      if (error.code === error.PERMISSION_DENIED) {\n        message.error('Location permission denied');\n      } else {\n        message.error('Unable to get your location');\n      }\n    }\n  );\n};\n

"},{"location":"v2/frontend/pages/public/map-page/#related-documentation","title":"Related Documentation","text":"
  • Admin Map View
  • Locations Page
  • Cuts Page
  • Map Settings
"},{"location":"v2/frontend/pages/public/media-gallery-page/","title":"Media Gallery Page","text":""},{"location":"v2/frontend/pages/public/media-gallery-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/public/MediaGalleryPage.tsx (195 lines)

Route: /media (with optional ?category=X query param)

Role Requirements: Public access

Purpose: Public-facing video gallery displaying shared media content with search, sort, category filtering, and pagination.

Key Features:

  • Search input with 300ms debounce
  • Sort dropdown (Recent, Popular, Most Viewed)
  • Responsive grid (xs=1, sm=2, md=3, lg=4 columns)
  • PublicVideoCard component
  • Pagination (24 videos per page)
  • Category filter from URL params
  • Dark theme consistency

Layout: Uses MediaPublicLayout (specialized public layout for media)

"},{"location":"v2/frontend/pages/public/media-gallery-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/media-gallery-page/#1-search-bar","title":"1. Search Bar","text":"
<Input.Search\n  placeholder=\"Search videos...\"\n  size=\"large\"\n  onChange={(e) => {\n    clearTimeout(searchDebounce);\n    searchDebounce = setTimeout(() => {\n      setSearchTerm(e.target.value);\n      setPage(1);\n    }, 300);\n  }}\n  style={{ maxWidth: 500 }}\n/>\n
"},{"location":"v2/frontend/pages/public/media-gallery-page/#2-sort-dropdown","title":"2. Sort Dropdown","text":"

Options: - Recent: createdAt DESC - Popular: reactionCount DESC - Most Viewed: viewCount DESC

"},{"location":"v2/frontend/pages/public/media-gallery-page/#3-video-grid","title":"3. Video Grid","text":"
<Row gutter={[16, 16]}>\n  {videos.map(video => (\n    <Col xs={24} sm={12} md={8} lg={6} key={video.id}>\n      <PublicVideoCard video={video} />\n    </Col>\n  ))}\n</Row>\n
"},{"location":"v2/frontend/pages/public/media-gallery-page/#4-category-filter","title":"4. Category Filter","text":"

URL-based filtering: - /media - All categories - /media?category=testimonials - Testimonials only - /media?category=events - Events only

"},{"location":"v2/frontend/pages/public/media-gallery-page/#api-integration","title":"API Integration","text":"
GET /api/media/public?page=1&limit=24&search=climate&sort=recent&category=testimonials\n

Response:

{\n  \"videos\": [\n    {\n      \"id\": \"vid123\",\n      \"title\": \"Climate Rally Highlights\",\n      \"thumbnailUrl\": \"/media/thumbnails/vid123.jpg\",\n      \"duration\": 245,\n      \"viewCount\": 1523,\n      \"upvotes\": 87,\n      \"category\": \"events\"\n    }\n  ],\n  \"total\": 156,\n  \"page\": 1,\n  \"limit\": 24\n}\n

"},{"location":"v2/frontend/pages/public/media-gallery-page/#related-documentation","title":"Related Documentation","text":"
  • Media Viewer Page
  • Library Management
  • Shared Media Admin
"},{"location":"v2/frontend/pages/public/media-viewer-page/","title":"Media Viewer Page","text":""},{"location":"v2/frontend/pages/public/media-viewer-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/public/MediaViewerPage.tsx (306 lines)

Route: /media/:id

Role Requirements: Public access (locked videos require login)

Purpose: Individual video player page with metadata, reactions, comments, and related videos.

Key Features:

  • Back button to gallery
  • VideoPlayer component with time tracking
  • Metadata display (views, upvotes, category, quality tags)
  • Upvote button (toggleable, session-based)
  • ReactionButtons component (6 emojis)
  • CommentSection component
  • Related videos grid (3 cards)
  • Locked video modal (redirect to login)
"},{"location":"v2/frontend/pages/public/media-viewer-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/media-viewer-page/#1-video-player","title":"1. Video Player","text":"
<VideoPlayer\n  videoUrl={video.videoUrl}\n  onTimeUpdate={(currentTime) => {\n    // Track view progress\n    if (currentTime > lastTrackedTime + 30) {\n      trackView(video.id, currentTime);\n      setLastTrackedTime(currentTime);\n    }\n  }}\n/>\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#2-metadata-display","title":"2. Metadata Display","text":"
<Space size={16}>\n  <Text type=\"secondary\">\n    <EyeOutlined /> {video.viewCount} views\n  </Text>\n  <Text type=\"secondary\">\n    <LikeOutlined /> {video.upvotes} upvotes\n  </Text>\n  <Tag color=\"blue\">{video.category}</Tag>\n  {video.quality && <Tag color=\"green\">{video.quality}p</Tag>}\n</Space>\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#3-upvote-button","title":"3. Upvote Button","text":"
<Button\n  type={hasUpvoted ? 'primary' : 'default'}\n  icon={hasUpvoted ? <LikeFilled /> : <LikeOutlined />}\n  onClick={handleUpvote}\n  size=\"large\"\n>\n  Upvote ({video.upvotes})\n</Button>\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#4-reaction-buttons","title":"4. Reaction Buttons","text":"

6 emoji reactions: - \ud83d\udc4d Like - \u2764\ufe0f Love - \ud83d\ude02 Haha - \ud83d\ude2e Wow - \ud83d\ude22 Sad - \ud83d\ude21 Angry

<ReactionButtons\n  videoId={video.id}\n  reactions={video.reactions}\n  onReact={handleReact}\n/>\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#5-comment-section","title":"5. Comment Section","text":"
<CommentSection\n  videoId={video.id}\n  comments={comments}\n  onSubmit={handleCommentSubmit}\n/>\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#6-related-videos","title":"6. Related Videos","text":"
<Title level={4}>Related Videos</Title>\n<Row gutter={[16, 16]}>\n  {relatedVideos.slice(0, 3).map(video => (\n    <Col xs={24} sm={8} key={video.id}>\n      <PublicVideoCard video={video} />\n    </Col>\n  ))}\n</Row>\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#7-locked-video-handling","title":"7. Locked Video Handling","text":"
{video.isLocked && !user && (\n  <Modal\n    title=\"Login Required\"\n    open={true}\n    footer={\n      <Button type=\"primary\" onClick={() => navigate('/login')}>\n        Go to Login\n      </Button>\n    }\n  >\n    This video requires login to view.\n  </Modal>\n)}\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/public/media-viewer-page/#endpoints","title":"Endpoints","text":""},{"location":"v2/frontend/pages/public/media-viewer-page/#1-get-video","title":"1. Get Video","text":"
GET /api/media/public/:id\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#2-track-view","title":"2. Track View","text":"
POST /api/media/public/:id/view\nContent-Type: application/json\n\n{\n  \"currentTime\": 67.5\n}\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#3-toggle-upvote","title":"3. Toggle Upvote","text":"
POST /api/media/public/:id/upvote\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#4-add-reaction","title":"4. Add Reaction","text":"
POST /api/media/public/:id/react\nContent-Type: application/json\n\n{\n  \"reactionType\": \"love\"\n}\n
"},{"location":"v2/frontend/pages/public/media-viewer-page/#performance-considerations","title":"Performance Considerations","text":"
  1. View Tracking: Throttled to 30-second intervals
  2. Related Videos: Limited to 3 (prevents over-fetching)
  3. Lazy Comments: Loaded separately after video metadata
  4. Video Preload: preload=\"metadata\" for faster initial render
"},{"location":"v2/frontend/pages/public/media-viewer-page/#accessibility","title":"Accessibility","text":"
  • Keyboard Controls: Native video player controls
  • Captions: Support for WebVTT subtitle files
  • Screen Reader: All buttons have aria-labels
  • Focus Management: Reaction buttons keyboard navigable
"},{"location":"v2/frontend/pages/public/media-viewer-page/#related-documentation","title":"Related Documentation","text":"
  • Media Gallery Page
  • Video Upload
  • Media API
"},{"location":"v2/frontend/pages/public/response-wall-page/","title":"Response Wall Page","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/public/ResponseWallPage.tsx (492 lines)

Route: /responses/:campaignId

Role Requirements: Public access (no authentication required)

Purpose: Community-driven response wall displaying user-submitted campaign feedback, verification status, government official replies, and social engagement through upvoting. Serves as social proof and community building tool for advocacy campaigns.

Key Features:

  • Campaign-specific response display with back navigation
  • Real-time statistics cards (Total Responses, Verified, Total Upvotes)
  • Multi-criteria sorting (Recent, Most Upvoted, Verified Only)
  • Government level filtering (Federal, Provincial, Municipal, All)
  • Response cards with upvote functionality
  • User comments and representative details
  • Verification badges for confirmed responses
  • Response submission modal with long-form input
  • Pagination for large response sets
  • Dark blue/teal theme consistency
  • Mobile-responsive grid layout

Layout: Uses PublicLayout component with dark theme

"},{"location":"v2/frontend/pages/public/response-wall-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#1-campaign-context-header","title":"1. Campaign Context Header","text":"

Navigation and campaign identification:

  • Back Link: Returns to campaign detail page (/campaigns/:campaignId)
  • Campaign Title: Displays as page heading
  • Breadcrumb: \"Response Wall\" subtitle
  • Icon: Comment icon for visual context
"},{"location":"v2/frontend/pages/public/response-wall-page/#2-statistics-dashboard","title":"2. Statistics Dashboard","text":"

Three key metrics displayed as cards:

  • Total Responses: Count of all submissions (verified + unverified)
  • Verified Responses: Count of email-verified submissions
  • Total Upvotes: Aggregate upvote count across all responses

Card Design: - Large numeric display (32px font) - Icon with brand color - Label text below number - Responsive grid (xs=1, sm=3 columns) - Hover effect for visual feedback

"},{"location":"v2/frontend/pages/public/response-wall-page/#3-filtering-and-sorting-controls","title":"3. Filtering and Sorting Controls","text":"

User controls for response discovery:

Sort Dropdown: - Recent: Newest first (default, createdAt DESC) - Most Upvoted: Highest upvote count first (upvoteCount DESC) - Verified Only: Only email-verified responses

Government Level Filter: - All Levels: No filtering (default) - Federal: Federal government responses only - Provincial: Provincial/territorial responses only - Municipal: Municipal/local responses only

Layout: - Row with two columns - Sort on left, filter on right - Full-width selects on mobile - Margin below for spacing

"},{"location":"v2/frontend/pages/public/response-wall-page/#4-response-cards","title":"4. Response Cards","text":"

Individual response display with rich metadata:

Card Header: - User name (bold, 16px) - Timestamp (relative: \"2 hours ago\") - Verification badge (if isVerified=true)

Card Content: - User comment (full text, auto-wrapping) - Quoted text if available (italicized, gray background) - Representative details: - Name (bold) - District/riding - Government level tag (colored by level)

Card Footer: - Upvote button with count - Heart icon (filled if user upvoted) - Click toggles upvote status - Optimistic UI update

Styling: - Dark background (colorBgContainer) - Rounded corners (8px) - Hover elevation shadow - Dividers between sections

"},{"location":"v2/frontend/pages/public/response-wall-page/#5-submit-response-modal","title":"5. Submit Response Modal","text":"

Long-form response submission interface:

Form Fields: - Your Name (required, text input) - Your Email (required, email validation) - Your Postal Code (optional, for rep lookup context) - Representative (read-only, from parent campaign context) - Your Comment (required, TextArea, 5 rows min) - Email Me a Copy (checkbox, default checked)

Validation: - Required field indicators - Email format validation - Min/max length checks (comment: 10-5000 chars) - Disabled submit until valid

Submission Flow: 1. User clicks \"Submit Your Response\" button 2. Modal opens with empty form 3. User fills fields 4. Clicks \"Submit Response\" button 5. API creates response (status: unverified) 6. Verification email sent if checkbox checked 7. Success modal displays 8. Form resets 9. Responses list refreshes

"},{"location":"v2/frontend/pages/public/response-wall-page/#6-pagination","title":"6. Pagination","text":"

Ant Design Pagination component:

  • Page Size: 20 responses per page
  • Total Count: Fetched from API
  • Page Change: Triggers new API request
  • Positioning: Centered below response grid
  • Styling: Inherits dark theme from PublicLayout
"},{"location":"v2/frontend/pages/public/response-wall-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#browsing-responses","title":"Browsing Responses","text":"
  1. User arrives from campaign page via \"View Response Wall\" link
  2. Page loads responses (default: recent, all levels)
  3. User views statistics cards showing community engagement
  4. User scrolls through response cards
  5. User reads comments and representative details
  6. User upvotes responses they agree with
  7. User clicks pagination to view more responses
"},{"location":"v2/frontend/pages/public/response-wall-page/#filtering-and-sorting","title":"Filtering and Sorting","text":"
  1. User selects \"Most Upvoted\" from sort dropdown
  2. API re-fetches responses with new sort order
  3. Grid updates with reordered responses
  4. User selects \"Federal\" from government level filter
  5. API re-fetches with government level filter
  6. Grid shows only federal responses
  7. User resets filters to \"All Levels\" to see everything
"},{"location":"v2/frontend/pages/public/response-wall-page/#submitting-a-response","title":"Submitting a Response","text":"
  1. User clicks \"Submit Your Response\" button
  2. Modal opens with blank form
  3. User enters name: \"Jane Doe\"
  4. User enters email: \"jane@example.com\"
  5. User enters postal code: \"K1A 0B1\" (optional)
  6. User writes comment: \"I strongly support this bill because...\"
  7. User checks \"Email me a copy\" checkbox
  8. User clicks \"Submit Response\"
  9. API creates response with isVerified=false
  10. Backend sends verification email to jane@example.com
  11. Success modal displays: \"Response submitted! Check your email to verify.\"
  12. User clicks \"OK\"
  13. Modal closes
  14. Responses grid refreshes (may not show new response if \"Verified Only\" filter active)
"},{"location":"v2/frontend/pages/public/response-wall-page/#upvoting","title":"Upvoting","text":"
  1. User sees response they agree with
  2. User clicks heart icon button
  3. Optimistic update: upvote count increments, heart fills with color
  4. API request to /api/public/responses/:id/upvote
  5. If API succeeds: update persists
  6. If API fails: revert to previous state, show error message
  7. User can click again to remove upvote (toggle behavior)
"},{"location":"v2/frontend/pages/public/response-wall-page/#component-structure","title":"Component Structure","text":"
import React, { useState, useEffect } from 'react';\nimport { useParams, Link } from 'react-router-dom';\nimport {\n  Card,\n  Row,\n  Col,\n  Typography,\n  Button,\n  Select,\n  Statistic,\n  Modal,\n  Form,\n  Input,\n  Checkbox,\n  Pagination,\n  Tag,\n  Space,\n  message,\n  Grid\n} from 'antd';\nimport {\n  ArrowLeftOutlined,\n  CommentOutlined,\n  HeartOutlined,\n  HeartFilled,\n  CheckCircleOutlined,\n  TrophyOutlined,\n  FireOutlined\n} from '@ant-design/icons';\nimport dayjs from 'dayjs';\nimport relativeTime from 'dayjs/plugin/relativeTime';\nimport PublicLayout from '../../components/PublicLayout';\nimport axios from 'axios';\n\ndayjs.extend(relativeTime);\n\nconst { Title, Paragraph, Text } = Typography;\nconst { TextArea } = Input;\nconst { Option } = Select;\nconst { useBreakpoint } = Grid;\n\ninterface Response {\n  id: string;\n  userName: string;\n  userEmail: string;\n  postalCode: string | null;\n  comment: string;\n  quotedText: string | null;\n  isVerified: boolean;\n  upvoteCount: number;\n  representativeName: string;\n  representativeDistrict: string;\n  governmentLevel: string;\n  createdAt: string;\n  hasUpvoted?: boolean; // Client-side tracking\n}\n\ninterface Campaign {\n  id: string;\n  title: string;\n}\n\ninterface Stats {\n  totalResponses: number;\n  verifiedResponses: number;\n  totalUpvotes: number;\n}\n\nconst ResponseWallPage: React.FC = () => {\n  const { campaignId } = useParams<{ campaignId: string }>();\n  const [responses, setResponses] = useState<Response[]>([]);\n  const [campaign, setCampaign] = useState<Campaign | null>(null);\n  const [stats, setStats] = useState<Stats>({ totalResponses: 0, verifiedResponses: 0, totalUpvotes: 0 });\n  const [loading, setLoading] = useState(true);\n  const [sortBy, setSortBy] = useState<string>('recent');\n  const [governmentLevel, setGovernmentLevel] = useState<string>('all');\n  const [page, setPage] = useState(1);\n  const [total, setTotal] = useState(0);\n  const [submitModalVisible, setSubmitModalVisible] = useState(false);\n  const [form] = Form.useForm();\n  const screens = useBreakpoint();\n  const isMobile = !screens.md;\n\n  const pageSize = 20;\n\n  // Data fetching, handlers, etc.\n\n  return (\n    <PublicLayout>\n      {/* Back link and title */}\n      {/* Statistics cards */}\n      {/* Sort and filter controls */}\n      {/* Response cards grid */}\n      {/* Pagination */}\n      {/* Submit modal */}\n    </PublicLayout>\n  );\n};\n\nexport default ResponseWallPage;\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#component-state","title":"Component State","text":"
// Response data\nconst [responses, setResponses] = useState<Response[]>([]);\nconst [campaign, setCampaign] = useState<Campaign | null>(null);\nconst [stats, setStats] = useState<Stats>({\n  totalResponses: 0,\n  verifiedResponses: 0,\n  totalUpvotes: 0\n});\nconst [loading, setLoading] = useState(true);\n\n// Filtering and sorting\nconst [sortBy, setSortBy] = useState<string>('recent'); // 'recent' | 'upvotes' | 'verified'\nconst [governmentLevel, setGovernmentLevel] = useState<string>('all'); // 'all' | 'federal' | 'provincial' | 'municipal'\n\n// Pagination\nconst [page, setPage] = useState(1);\nconst [total, setTotal] = useState(0);\nconst pageSize = 20;\n\n// Modal state\nconst [submitModalVisible, setSubmitModalVisible] = useState(false);\nconst [form] = Form.useForm();\n\n// Responsive\nconst screens = useBreakpoint();\nconst isMobile = !screens.md;\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#derived-state","title":"Derived State","text":"
// No complex derived state - filtering happens server-side\n// All data transformations done by API\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#state-flow","title":"State Flow","text":"
  1. Initial Load: loading=true, fetch campaign + responses + stats
  2. Data Received: setCampaign(), setResponses(), setStats(), setTotal(), loading=false
  3. Sort Changed: setSortBy(), setPage(1), refetch responses
  4. Filter Changed: setGovernmentLevel(), setPage(1), refetch responses
  5. Page Changed: setPage(), refetch responses (keep sort/filter)
  6. Upvote Clicked: Optimistic update to responses array, API call
  7. Submit Clicked: setSubmitModalVisible(true), open form
  8. Response Submitted: API call, setSubmitModalVisible(false), refetch responses
"},{"location":"v2/frontend/pages/public/response-wall-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#endpoints-used","title":"Endpoints Used","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#1-get-campaign-basic-info","title":"1. Get Campaign (Basic Info)","text":"
GET /api/public/campaigns/:campaignId\n

Response:

{\n  \"id\": \"cm1abc123\",\n  \"title\": \"Support Climate Action Bill\"\n}\n

"},{"location":"v2/frontend/pages/public/response-wall-page/#2-get-response-statistics","title":"2. Get Response Statistics","text":"
GET /api/public/responses/campaigns/:campaignId/stats\n

Response:

{\n  \"totalResponses\": 342,\n  \"verifiedResponses\": 287,\n  \"totalUpvotes\": 1829\n}\n

"},{"location":"v2/frontend/pages/public/response-wall-page/#3-list-responses","title":"3. List Responses","text":"
GET /api/public/responses/campaigns/:campaignId?page=1&limit=20&sortBy=recent&governmentLevel=all\n

Query Parameters: - page: Page number (1-indexed) - limit: Items per page (default 20, max 100) - sortBy: recent | upvotes | verified - governmentLevel: all | federal | provincial | municipal

Response:

{\n  \"responses\": [\n    {\n      \"id\": \"cm2abc123\",\n      \"userName\": \"Jane Doe\",\n      \"userEmail\": \"jane@example.com\",\n      \"postalCode\": \"K1A 0B1\",\n      \"comment\": \"I strongly support this bill because it addresses critical climate issues...\",\n      \"quotedText\": null,\n      \"isVerified\": true,\n      \"upvoteCount\": 47,\n      \"representativeName\": \"John Smith\",\n      \"representativeDistrict\": \"Ottawa Centre\",\n      \"governmentLevel\": \"federal\",\n      \"createdAt\": \"2025-02-10T14:30:00.000Z\"\n    }\n  ],\n  \"total\": 342,\n  \"page\": 1,\n  \"limit\": 20\n}\n

"},{"location":"v2/frontend/pages/public/response-wall-page/#4-submit-response","title":"4. Submit Response","text":"
POST /api/public/responses\nContent-Type: application/json\n\n{\n  \"campaignId\": \"cm1abc123\",\n  \"userName\": \"Jane Doe\",\n  \"userEmail\": \"jane@example.com\",\n  \"postalCode\": \"K1A 0B1\",\n  \"comment\": \"I strongly support this bill...\",\n  \"representativeName\": \"John Smith\",\n  \"representativeDistrict\": \"Ottawa Centre\",\n  \"governmentLevel\": \"federal\",\n  \"sendCopy\": true\n}\n

Response:

{\n  \"success\": true,\n  \"responseId\": \"cm2def456\",\n  \"message\": \"Response submitted successfully. Please check your email to verify.\"\n}\n

"},{"location":"v2/frontend/pages/public/response-wall-page/#5-upvote-response","title":"5. Upvote Response","text":"
POST /api/public/responses/:id/upvote\n

Response:

{\n  \"success\": true,\n  \"upvoteCount\": 48,\n  \"action\": \"added\"\n}\n

Note: Second request to same endpoint toggles (removes upvote), returns \"action\": \"removed\".

"},{"location":"v2/frontend/pages/public/response-wall-page/#request-examples","title":"Request Examples","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#fetch-responses","title":"Fetch Responses","text":"
useEffect(() => {\n  const fetchData = async () => {\n    if (!campaignId) return;\n\n    try {\n      setLoading(true);\n\n      const [campaignRes, statsRes, responsesRes] = await Promise.all([\n        axios.get(`/api/public/campaigns/${campaignId}`),\n        axios.get(`/api/public/responses/campaigns/${campaignId}/stats`),\n        axios.get(`/api/public/responses/campaigns/${campaignId}`, {\n          params: {\n            page,\n            limit: pageSize,\n            sortBy,\n            governmentLevel: governmentLevel === 'all' ? undefined : governmentLevel\n          }\n        })\n      ]);\n\n      setCampaign(campaignRes.data);\n      setStats(statsRes.data);\n      setResponses(responsesRes.data.responses);\n      setTotal(responsesRes.data.total);\n\n    } catch (error) {\n      console.error('Failed to fetch data:', error);\n      message.error('Failed to load responses');\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  fetchData();\n}, [campaignId, page, sortBy, governmentLevel]);\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#submit-response","title":"Submit Response","text":"
const handleSubmit = async (values: any) => {\n  try {\n    await axios.post('/api/public/responses', {\n      campaignId,\n      userName: values.userName,\n      userEmail: values.userEmail,\n      postalCode: values.postalCode || null,\n      comment: values.comment,\n      representativeName: values.representativeName,\n      representativeDistrict: values.representativeDistrict || '',\n      governmentLevel: values.governmentLevel || 'federal',\n      sendCopy: values.sendCopy\n    });\n\n    Modal.success({\n      title: 'Response Submitted!',\n      content: 'Please check your email to verify your response.',\n    });\n\n    setSubmitModalVisible(false);\n    form.resetFields();\n\n    // Refresh responses list\n    setPage(1);\n    // Triggers useEffect refetch\n\n  } catch (error: any) {\n    console.error('Submit failed:', error);\n    message.error(error.response?.data?.message || 'Failed to submit response');\n  }\n};\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#upvote-response","title":"Upvote Response","text":"
const handleUpvote = async (responseId: string) => {\n  // Optimistic update\n  setResponses(prev => prev.map(r => {\n    if (r.id === responseId) {\n      const hasUpvoted = !r.hasUpvoted;\n      return {\n        ...r,\n        hasUpvoted,\n        upvoteCount: r.upvoteCount + (hasUpvoted ? 1 : -1)\n      };\n    }\n    return r;\n  }));\n\n  try {\n    const response = await axios.post(`/api/public/responses/${responseId}/upvote`);\n\n    // Update with server count (in case of race condition)\n    setResponses(prev => prev.map(r =>\n      r.id === responseId\n        ? { ...r, upvoteCount: response.data.upvoteCount }\n        : r\n    ));\n\n  } catch (error) {\n    console.error('Upvote failed:', error);\n\n    // Revert on error\n    setResponses(prev => prev.map(r => {\n      if (r.id === responseId) {\n        return {\n          ...r,\n          hasUpvoted: !r.hasUpvoted,\n          upvoteCount: r.upvoteCount + (r.hasUpvoted ? -1 : 1)\n        };\n      }\n      return r;\n    }));\n\n    message.error('Failed to upvote. Please try again.');\n  }\n};\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#statistics-cards","title":"Statistics Cards","text":"
<Row gutter={[16, 16]} style={{ marginBottom: 32 }}>\n  <Col xs={24} sm={8}>\n    <Card>\n      <Statistic\n        title=\"Total Responses\"\n        value={stats.totalResponses}\n        prefix={<CommentOutlined style={{ color: '#1890ff' }} />}\n        valueStyle={{ color: '#1890ff', fontSize: 32 }}\n      />\n    </Card>\n  </Col>\n\n  <Col xs={24} sm={8}>\n    <Card>\n      <Statistic\n        title=\"Verified Responses\"\n        value={stats.verifiedResponses}\n        prefix={<CheckCircleOutlined style={{ color: '#52c41a' }} />}\n        valueStyle={{ color: '#52c41a', fontSize: 32 }}\n      />\n    </Card>\n  </Col>\n\n  <Col xs={24} sm={8}>\n    <Card>\n      <Statistic\n        title=\"Total Upvotes\"\n        value={stats.totalUpvotes}\n        prefix={<HeartFilled style={{ color: '#eb2f96' }} />}\n        valueStyle={{ color: '#eb2f96', fontSize: 32 }}\n      />\n    </Card>\n  </Col>\n</Row>\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#sort-and-filter-controls","title":"Sort and Filter Controls","text":"
<Row gutter={16} style={{ marginBottom: 24 }}>\n  <Col xs={24} sm={12}>\n    <Space direction=\"vertical\" style={{ width: '100%' }} size={4}>\n      <Text type=\"secondary\">Sort by:</Text>\n      <Select\n        value={sortBy}\n        onChange={(value) => {\n          setSortBy(value);\n          setPage(1); // Reset to page 1 when sorting changes\n        }}\n        style={{ width: '100%' }}\n        size=\"large\"\n      >\n        <Option value=\"recent\">\n          <FireOutlined /> Recent\n        </Option>\n        <Option value=\"upvotes\">\n          <TrophyOutlined /> Most Upvoted\n        </Option>\n        <Option value=\"verified\">\n          <CheckCircleOutlined /> Verified Only\n        </Option>\n      </Select>\n    </Space>\n  </Col>\n\n  <Col xs={24} sm={12}>\n    <Space direction=\"vertical\" style={{ width: '100%' }} size={4}>\n      <Text type=\"secondary\">Government Level:</Text>\n      <Select\n        value={governmentLevel}\n        onChange={(value) => {\n          setGovernmentLevel(value);\n          setPage(1); // Reset to page 1 when filter changes\n        }}\n        style={{ width: '100%' }}\n        size=\"large\"\n      >\n        <Option value=\"all\">All Levels</Option>\n        <Option value=\"federal\">Federal</Option>\n        <Option value=\"provincial\">Provincial</Option>\n        <Option value=\"municipal\">Municipal</Option>\n      </Select>\n    </Space>\n  </Col>\n</Row>\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#response-cards","title":"Response Cards","text":"
<Row gutter={[16, 16]}>\n  {responses.map((response) => (\n    <Col xs={24} key={response.id}>\n      <Card hoverable>\n        {/* Header */}\n        <div style={{\n          display: 'flex',\n          justifyContent: 'space-between',\n          alignItems: 'center',\n          marginBottom: 16\n        }}>\n          <Space>\n            <Text strong style={{ fontSize: 16 }}>\n              {response.userName}\n            </Text>\n            {response.isVerified && (\n              <Tag color=\"green\" icon={<CheckCircleOutlined />}>\n                Verified\n              </Tag>\n            )}\n          </Space>\n          <Text type=\"secondary\" style={{ fontSize: 12 }}>\n            {dayjs(response.createdAt).fromNow()}\n          </Text>\n        </div>\n\n        {/* Comment */}\n        <Paragraph style={{ marginBottom: 16, fontSize: 14 }}>\n          {response.comment}\n        </Paragraph>\n\n        {/* Quoted Text (if any) */}\n        {response.quotedText && (\n          <div style={{\n            padding: 12,\n            background: 'rgba(255,255,255,0.05)',\n            borderLeft: '3px solid #1890ff',\n            marginBottom: 16,\n            fontStyle: 'italic'\n          }}>\n            <Text type=\"secondary\" style={{ fontSize: 13 }}>\n              \"{response.quotedText}\"\n            </Text>\n          </div>\n        )}\n\n        {/* Representative Info */}\n        <div style={{\n          paddingTop: 16,\n          borderTop: '1px solid rgba(255,255,255,0.1)',\n          marginBottom: 12\n        }}>\n          <Text type=\"secondary\" style={{ fontSize: 12 }}>\n            Sent to:{' '}\n          </Text>\n          <Text strong style={{ fontSize: 13 }}>\n            {response.representativeName}\n          </Text>\n          {response.representativeDistrict && (\n            <Text type=\"secondary\" style={{ fontSize: 12 }}>\n              {' '}\u2022 {response.representativeDistrict}\n            </Text>\n          )}\n          <div style={{ marginTop: 4 }}>\n            <Tag color={\n              response.governmentLevel === 'federal' ? 'blue' :\n              response.governmentLevel === 'provincial' ? 'purple' :\n              'green'\n            }>\n              {response.governmentLevel.charAt(0).toUpperCase() + response.governmentLevel.slice(1)}\n            </Tag>\n          </div>\n        </div>\n\n        {/* Upvote Button */}\n        <Button\n          type={response.hasUpvoted ? 'primary' : 'default'}\n          icon={response.hasUpvoted ? <HeartFilled /> : <HeartOutlined />}\n          onClick={() => handleUpvote(response.id)}\n          style={{\n            borderColor: '#eb2f96',\n            color: response.hasUpvoted ? 'white' : '#eb2f96'\n          }}\n        >\n          {response.upvoteCount} {response.upvoteCount === 1 ? 'Upvote' : 'Upvotes'}\n        </Button>\n      </Card>\n    </Col>\n  ))}\n</Row>\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#submit-response-modal","title":"Submit Response Modal","text":"
<Modal\n  title=\"Submit Your Response\"\n  open={submitModalVisible}\n  onCancel={() => {\n    setSubmitModalVisible(false);\n    form.resetFields();\n  }}\n  footer={null}\n  width={600}\n>\n  <Form\n    form={form}\n    layout=\"vertical\"\n    onFinish={handleSubmit}\n  >\n    <Form.Item\n      name=\"userName\"\n      label=\"Your Name\"\n      rules={[\n        { required: true, message: 'Please enter your name' },\n        { min: 2, message: 'Name must be at least 2 characters' }\n      ]}\n    >\n      <Input size=\"large\" placeholder=\"Jane Doe\" />\n    </Form.Item>\n\n    <Form.Item\n      name=\"userEmail\"\n      label=\"Your Email\"\n      rules={[\n        { required: true, message: 'Please enter your email' },\n        { type: 'email', message: 'Please enter a valid email' }\n      ]}\n    >\n      <Input size=\"large\" type=\"email\" placeholder=\"jane@example.com\" />\n    </Form.Item>\n\n    <Form.Item\n      name=\"postalCode\"\n      label=\"Your Postal Code (Optional)\"\n    >\n      <Input\n        size=\"large\"\n        placeholder=\"K1A 0B1\"\n        maxLength={7}\n        style={{ textTransform: 'uppercase' }}\n      />\n    </Form.Item>\n\n    <Form.Item\n      name=\"representativeName\"\n      label=\"Representative You Contacted\"\n      rules={[{ required: true, message: 'Please enter representative name' }]}\n    >\n      <Input size=\"large\" placeholder=\"e.g., John Smith\" />\n    </Form.Item>\n\n    <Form.Item\n      name=\"representativeDistrict\"\n      label=\"District/Riding (Optional)\"\n    >\n      <Input size=\"large\" placeholder=\"e.g., Ottawa Centre\" />\n    </Form.Item>\n\n    <Form.Item\n      name=\"governmentLevel\"\n      label=\"Government Level\"\n      rules={[{ required: true, message: 'Please select government level' }]}\n      initialValue=\"federal\"\n    >\n      <Select size=\"large\">\n        <Option value=\"federal\">Federal</Option>\n        <Option value=\"provincial\">Provincial/Territorial</Option>\n        <Option value=\"municipal\">Municipal</Option>\n      </Select>\n    </Form.Item>\n\n    <Form.Item\n      name=\"comment\"\n      label=\"Your Comment\"\n      rules={[\n        { required: true, message: 'Please enter your comment' },\n        { min: 10, message: 'Comment must be at least 10 characters' },\n        { max: 5000, message: 'Comment must be less than 5000 characters' }\n      ]}\n    >\n      <TextArea\n        rows={5}\n        placeholder=\"Share your thoughts, the response you received, or why this issue matters to you...\"\n        showCount\n        maxLength={5000}\n      />\n    </Form.Item>\n\n    <Form.Item\n      name=\"sendCopy\"\n      valuePropName=\"checked\"\n      initialValue={true}\n    >\n      <Checkbox>\n        Email me a copy and verification link\n      </Checkbox>\n    </Form.Item>\n\n    <Form.Item>\n      <Space style={{ width: '100%', justifyContent: 'flex-end' }}>\n        <Button onClick={() => {\n          setSubmitModalVisible(false);\n          form.resetFields();\n        }}>\n          Cancel\n        </Button>\n        <Button type=\"primary\" htmlType=\"submit\" size=\"large\">\n          Submit Response\n        </Button>\n      </Space>\n    </Form.Item>\n  </Form>\n</Modal>\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#pagination","title":"Pagination","text":"
{total > pageSize && (\n  <div style={{ textAlign: 'center', marginTop: 32 }}>\n    <Pagination\n      current={page}\n      total={total}\n      pageSize={pageSize}\n      onChange={(newPage) => {\n        setPage(newPage);\n        window.scrollTo({ top: 0, behavior: 'smooth' });\n      }}\n      showSizeChanger={false}\n      showTotal={(total, range) => `${range[0]}-${range[1]} of ${total} responses`}\n    />\n  </div>\n)}\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#1-parallel-data-fetching","title":"1. Parallel Data Fetching","text":"

Campaign, stats, and responses fetched simultaneously:

const [campaignRes, statsRes, responsesRes] = await Promise.all([\n  axios.get(`/api/public/campaigns/${campaignId}`),\n  axios.get(`/api/public/responses/campaigns/${campaignId}/stats`),\n  axios.get(`/api/public/responses/campaigns/${campaignId}`, { params })\n]);\n

Benefit: Reduces initial load time by ~60% vs sequential requests.

"},{"location":"v2/frontend/pages/public/response-wall-page/#2-optimistic-upvote-updates","title":"2. Optimistic Upvote Updates","text":"

UI updates immediately before API confirmation:

// Update UI first\nsetResponses(prev => prev.map(r => {\n  if (r.id === responseId) {\n    return { ...r, hasUpvoted: !r.hasUpvoted, upvoteCount: r.upvoteCount + 1 };\n  }\n  return r;\n}));\n\n// Then API call\nawait axios.post(`/api/public/responses/${responseId}/upvote`);\n

Benefit: Perceived performance improvement, instant feedback.

"},{"location":"v2/frontend/pages/public/response-wall-page/#3-server-side-filtering","title":"3. Server-Side Filtering","text":"

All filtering/sorting done via API query params (not client-side):

params: {\n  sortBy,\n  governmentLevel: governmentLevel === 'all' ? undefined : governmentLevel\n}\n

Benefit: Scalable to thousands of responses, no client memory issues.

"},{"location":"v2/frontend/pages/public/response-wall-page/#4-pagination","title":"4. Pagination","text":"

Limited to 20 responses per page:

const pageSize = 20;\n

Benefit: Reduces DOM nodes, faster render, better mobile performance.

"},{"location":"v2/frontend/pages/public/response-wall-page/#5-scroll-to-top-on-page-change","title":"5. Scroll to Top on Page Change","text":"

Smooth scroll when pagination changes:

onChange={(newPage) => {\n  setPage(newPage);\n  window.scrollTo({ top: 0, behavior: 'smooth' });\n}}\n

Benefit: Better UX, user doesn't miss new content.

"},{"location":"v2/frontend/pages/public/response-wall-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#breakpoint-behavior","title":"Breakpoint Behavior","text":"Breakpoint Stats Columns Response Cards Filter Layout Modal Width xs (0-575px) 1 column 1 column Stacked 90% viewport sm (576-767px) 3 columns 1 column Stacked 90% viewport md (768-991px) 3 columns 1 column Side-by-side 600px lg (992px+) 3 columns 1 column Side-by-side 600px"},{"location":"v2/frontend/pages/public/response-wall-page/#mobile-adaptations","title":"Mobile Adaptations","text":"

Statistics Cards: - Stack vertically on xs (easier to scan) - Show 3 columns on sm+ (compact display) - Font size remains large (32px) for impact

Response Cards: - Always full-width (xs=24) - Better readability on narrow screens - Upvote button full-width on mobile (future enhancement)

Sort/Filter Controls: - Stack vertically on xs (full-width selects) - Side-by-side on sm+ (50% width each) - Labels above selects for clarity

Submit Modal: - Width adapts to viewport (90% on mobile, 600px desktop) - Form fields always full-width - TextArea shrinks to 3 rows on mobile (vs 5 desktop)

"},{"location":"v2/frontend/pages/public/response-wall-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#keyboard-navigation","title":"Keyboard Navigation","text":"

Response Cards: - Upvote button focusable via Tab - Enter/Space toggles upvote

Sort/Filter Controls: - Dropdowns keyboard navigable (Arrow keys + Enter) - Focus visible on all select elements

Pagination: - Page numbers focusable - Arrow keys navigate pages (native Ant Design)

"},{"location":"v2/frontend/pages/public/response-wall-page/#aria-labels","title":"ARIA Labels","text":"

Upvote Button:

<Button\n  aria-label={`Upvote response by ${response.userName}. Current upvotes: ${response.upvoteCount}`}\n  onClick={() => handleUpvote(response.id)}\n>\n  {response.upvoteCount} Upvotes\n</Button>\n

Statistics Cards:

<Statistic\n  title=\"Total Responses\"\n  value={stats.totalResponses}\n  aria-label={`Total responses: ${stats.totalResponses}`}\n/>\n

Modal:

<Modal\n  title=\"Submit Your Response\"\n  aria-labelledby=\"submit-response-title\"\n  aria-describedby=\"submit-response-description\"\n>\n

"},{"location":"v2/frontend/pages/public/response-wall-page/#screen-reader-support","title":"Screen Reader Support","text":"

Verification Badge:

<Tag color=\"green\" icon={<CheckCircleOutlined />}>\n  <span aria-label=\"Email verified\">Verified</span>\n</Tag>\n

Timestamp:

<Text\n  type=\"secondary\"\n  aria-label={`Posted ${dayjs(response.createdAt).format('MMMM D, YYYY at h:mm A')}`}\n>\n  {dayjs(response.createdAt).fromNow()}\n</Text>\n

Form Validation: - Error messages announced automatically - Required field indicators (required attribute) - Help text linked via aria-describedby

"},{"location":"v2/frontend/pages/public/response-wall-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#issue-upvotes-not-persisting","title":"Issue: Upvotes Not Persisting","text":"

Symptoms: - User clicks upvote, count increments - Page refresh resets upvote - Heart icon reverts to outline

Causes: 1. API call failing silently 2. Session/cookie not persisting user ID 3. Optimistic update not reverting on error 4. Backend not tracking upvote source

Solutions:

const handleUpvote = async (responseId: string) => {\n  // Save previous state for rollback\n  const previousResponses = [...responses];\n\n  // Optimistic update\n  setResponses(prev => prev.map(r => {\n    if (r.id === responseId) {\n      return {\n        ...r,\n        hasUpvoted: !r.hasUpvoted,\n        upvoteCount: r.upvoteCount + (r.hasUpvoted ? -1 : 1)\n      };\n    }\n    return r;\n  }));\n\n  try {\n    const response = await axios.post(\n      `/api/public/responses/${responseId}/upvote`,\n      {},\n      { timeout: 5000 }\n    );\n\n    console.log('Upvote response:', response.data);\n\n    // Update with server count (authoritative)\n    setResponses(prev => prev.map(r =>\n      r.id === responseId\n        ? {\n            ...r,\n            upvoteCount: response.data.upvoteCount,\n            hasUpvoted: response.data.action === 'added'\n          }\n        : r\n    ));\n\n  } catch (error: any) {\n    console.error('Upvote failed:', error);\n\n    // Revert to previous state\n    setResponses(previousResponses);\n\n    if (error.code === 'ECONNABORTED') {\n      message.error('Request timed out. Please try again.');\n    } else {\n      message.error('Failed to upvote. Please try again.');\n    }\n  }\n};\n

Check backend upvote tracking:

-- Verify upvote records created\nSELECT * FROM \"ResponseUpvote\"\nWHERE \"responseId\" = 'cm2abc123'\nORDER BY \"createdAt\" DESC;\n

"},{"location":"v2/frontend/pages/public/response-wall-page/#issue-statistics-not-updating-after-submission","title":"Issue: Statistics Not Updating After Submission","text":"

Symptoms: - User submits response - Response appears in list - Statistics cards show old counts

Causes: 1. Stats fetched once on mount, never refreshed 2. New response not included in stats query 3. Cache invalidation not working

Solutions:

// Refetch stats after successful submission\nconst handleSubmit = async (values: any) => {\n  try {\n    await axios.post('/api/public/responses', { ... });\n\n    Modal.success({\n      title: 'Response Submitted!',\n      content: 'Please check your email to verify your response.',\n    });\n\n    setSubmitModalVisible(false);\n    form.resetFields();\n\n    // Refresh all data\n    const [statsRes, responsesRes] = await Promise.all([\n      axios.get(`/api/public/responses/campaigns/${campaignId}/stats`),\n      axios.get(`/api/public/responses/campaigns/${campaignId}`, {\n        params: { page: 1, limit: pageSize, sortBy, governmentLevel }\n      })\n    ]);\n\n    setStats(statsRes.data);\n    setResponses(responsesRes.data.responses);\n    setTotal(responsesRes.data.total);\n    setPage(1); // Reset to first page\n\n  } catch (error: any) {\n    message.error(error.response?.data?.message || 'Failed to submit response');\n  }\n};\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#issue-verified-only-filter-shows-no-results","title":"Issue: \"Verified Only\" Filter Shows No Results","text":"

Symptoms: - User selects \"Verified Only\" sort - Grid shows empty state - Total count remains high

Causes: 1. No verified responses exist yet 2. API not filtering correctly 3. Frontend not passing correct param

Solutions:

// Add empty state for no verified responses\n{!loading && responses.length === 0 && sortBy === 'verified' && (\n  <Card style={{ textAlign: 'center', padding: 40 }}>\n    <CheckCircleOutlined style={{ fontSize: 64, color: '#999', marginBottom: 16 }} />\n    <Title level={3} type=\"secondary\">\n      No Verified Responses Yet\n    </Title>\n    <Paragraph type=\"secondary\">\n      Responses appear here after users verify their email address.\n      <br />\n      Try selecting \"Recent\" or \"Most Upvoted\" to see all responses.\n    </Paragraph>\n    <Button\n      type=\"primary\"\n      onClick={() => setSortBy('recent')}\n    >\n      View All Responses\n    </Button>\n  </Card>\n)}\n\n// Verify API param correctly passed\nuseEffect(() => {\n  console.log('Fetching with params:', {\n    page,\n    limit: pageSize,\n    sortBy,\n    governmentLevel\n  });\n}, [page, sortBy, governmentLevel]);\n

Check backend:

-- Count verified vs unverified\nSELECT \"isVerified\", COUNT(*)\nFROM \"Response\"\nWHERE \"campaignId\" = 'cm1abc123'\nGROUP BY \"isVerified\";\n

"},{"location":"v2/frontend/pages/public/response-wall-page/#issue-pagination-showing-wrong-total","title":"Issue: Pagination Showing Wrong Total","text":"

Symptoms: - Pagination shows \"1-20 of 342\" - Only 50 total responses exist - Total count doesn't match stats card

Causes: 1. Stats query counting all campaigns 2. Responses query filtering by campaign correctly 3. Stats API endpoint broken

Solutions:

// Use responses total, not stats total, for pagination\nconst [responsesTotal, setResponsesTotal] = useState(0);\n\n// In fetch responses:\nsetResponsesTotal(responsesRes.data.total);\n\n// In pagination:\n<Pagination\n  current={page}\n  total={responsesTotal} // Not stats.totalResponses\n  pageSize={pageSize}\n  onChange={setPage}\n/>\n\n// Add validation\nuseEffect(() => {\n  if (stats.totalResponses !== responsesTotal) {\n    console.warn('Mismatch between stats and pagination totals:', {\n      stats: stats.totalResponses,\n      pagination: responsesTotal\n    });\n  }\n}, [stats.totalResponses, responsesTotal]);\n
"},{"location":"v2/frontend/pages/public/response-wall-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#public-pages","title":"Public Pages","text":"
  • Campaigns List Page - Campaign directory
  • Campaign Detail Page - Email sending workflow
  • Map Page - Public location mapping
"},{"location":"v2/frontend/pages/public/response-wall-page/#admin-pages","title":"Admin Pages","text":"
  • Response Moderation - Admin moderation tools
  • Campaigns Management - Campaign configuration
"},{"location":"v2/frontend/pages/public/response-wall-page/#components","title":"Components","text":"
  • PublicLayout - Dark theme wrapper
  • ShareButtons - Social sharing
"},{"location":"v2/frontend/pages/public/response-wall-page/#api-documentation","title":"API Documentation","text":"
  • Public Responses API
  • Response Upvoting
"},{"location":"v2/frontend/pages/public/response-wall-page/#architecture","title":"Architecture","text":"
  • Response Verification System
  • Email Templates
"},{"location":"v2/frontend/pages/public/shifts-page/","title":"Public Shifts Page","text":""},{"location":"v2/frontend/pages/public/shifts-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/public/ShiftsPage.tsx (344 lines)

Route: /shifts

Role Requirements: Public access (no authentication required)

Purpose: Public volunteer shift signup interface allowing community members to register for canvassing shifts, creating temporary user accounts automatically, and receiving email confirmations.

Key Features:

  • Hero banner with gradient background
  • Responsive shift cards grid (xs=1, sm=2, lg=3 columns)
  • Real-time volunteer capacity progress bars
  • Signup modal with name/email/phone fields
  • Temporary user creation for non-authenticated signups
  • Email confirmation after successful signup
  • Success modal with shift details
  • Visual opacity indication for full shifts
  • Dark theme consistency with public pages

Layout: Uses PublicLayout with dark theme

"},{"location":"v2/frontend/pages/public/shifts-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/shifts-page/#1-hero-banner","title":"1. Hero Banner","text":"

Prominent call-to-action header:

  • Gradient Background: Purple-to-blue gradient
  • Title: \"Volunteer Opportunities\"
  • Subtitle: \"Join us in making a difference in your community\"
  • Icon: Calendar icon
  • Padding: 80px vertical (desktop), 60px (mobile)
"},{"location":"v2/frontend/pages/public/shifts-page/#2-shift-cards-grid","title":"2. Shift Cards Grid","text":"

Responsive grid displaying available shifts:

Card Contents: - Shift title (Typography.Title level 4) - Date and time range (formatted with dayjs) - Location address - Cut/district name (if assigned) - Description (truncated, 3-line ellipsis) - Volunteer capacity progress bar - \"Sign Up\" button (primary, full-width)

Styling: - Dark card background (colorBgContainer) - Hover elevation effect - 24px gutter between cards - Rounded corners (8px)

Capacity Indicators: - Green progress bar (0-70% full) - Yellow progress bar (71-90% full) - Red progress bar (91-100% full) - Text: \"X of Y volunteers signed up\"

Full Shifts: - Card opacity reduced to 0.6 - Button disabled with \"Full\" text - Badge showing \"Full\" in red

"},{"location":"v2/frontend/pages/public/shifts-page/#3-signup-modal","title":"3. Signup Modal","text":"

User registration form:

Form Fields: - Your Name (required, min 2 chars) - Email (required, email validation) - Phone (required, phone number format)

Shift Details Display: - Shift title (read-only) - Date/time (read-only) - Location (read-only)

Submission: - Creates temporary user if not logged in - Creates shift signup record - Sends confirmation email - Opens success modal

Validation: - Required field indicators - Email format check - Phone format (10 digits, (XXX) XXX-XXXX) - Duplicate signup prevention

"},{"location":"v2/frontend/pages/public/shifts-page/#4-success-modal","title":"4. Success Modal","text":"

Post-signup confirmation:

Content: - Green checkmark icon - \"Successfully Signed Up!\" heading - Shift details (title, date, time, location) - Email confirmation message - \"OK\" button to close

Behavior: - Auto-opens after successful signup - Reloads shift list on close (to show updated capacity)

"},{"location":"v2/frontend/pages/public/shifts-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/public/shifts-page/#browsing-shifts","title":"Browsing Shifts","text":"
  1. User navigates to /shifts
  2. Hero banner loads with CTA
  3. API fetches active shifts
  4. Shift cards render in grid
  5. User sees capacity bars (green/yellow/red)
  6. User scrolls through available shifts
"},{"location":"v2/frontend/pages/public/shifts-page/#signing-up-for-shift","title":"Signing Up for Shift","text":"
  1. User finds desired shift card
  2. User clicks \"Sign Up\" button
  3. Modal opens with signup form
  4. User enters name: \"Jane Doe\"
  5. User enters email: \"jane@example.com\"
  6. User enters phone: \"(555) 123-4567\"
  7. User clicks \"Sign Up\" submit button
  8. API creates temp user (role: TEMP)
  9. API creates shift signup
  10. Confirmation email sent
  11. Success modal displays
  12. User clicks \"OK\"
  13. Modal closes
  14. Shift list refreshes
  15. Signed-up shift shows updated capacity
"},{"location":"v2/frontend/pages/public/shifts-page/#full-shift-handling","title":"Full Shift Handling","text":"
  1. User sees shift with red progress bar (full)
  2. Card has reduced opacity
  3. Button shows \"Full\" and is disabled
  4. User cannot click signup
  5. \"Full\" badge visible on card
"},{"location":"v2/frontend/pages/public/shifts-page/#component-structure","title":"Component Structure","text":"
import React, { useState, useEffect } from 'react';\nimport { Card, Row, Col, Typography, Button, Form, Input, Modal, Progress, Tag, Grid, message } from 'antd';\nimport { CalendarOutlined, CheckCircleOutlined, ClockCircleOutlined, EnvironmentOutlined } from '@ant-design/icons';\nimport dayjs from 'dayjs';\nimport PublicLayout from '../../components/PublicLayout';\nimport axios from 'axios';\n\nconst { Title, Paragraph, Text } = Typography;\nconst { useBreakpoint } = Grid;\n\ninterface Shift {\n  id: string;\n  title: string;\n  description: string | null;\n  date: string;\n  startTime: string;\n  endTime: string;\n  location: string;\n  maxVolunteers: number;\n  currentSignups: number;\n  cutName: string | null;\n}\n\nconst ShiftsPage: React.FC = () => {\n  const [shifts, setShifts] = useState<Shift[]>([]);\n  const [loading, setLoading] = useState(true);\n  const [signupModalVisible, setSignupModalVisible] = useState(false);\n  const [selectedShift, setSelectedShift] = useState<Shift | null>(null);\n  const [form] = Form.useForm();\n  const screens = useBreakpoint();\n  const isMobile = !screens.md;\n\n  return (\n    <PublicLayout>\n      {/* Hero Banner */}\n      {/* Shift Cards Grid */}\n      {/* Signup Modal */}\n      {/* Success Modal */}\n    </PublicLayout>\n  );\n};\n
"},{"location":"v2/frontend/pages/public/shifts-page/#state-management","title":"State Management","text":"
const [shifts, setShifts] = useState<Shift[]>([]);\nconst [loading, setLoading] = useState(true);\nconst [signupModalVisible, setSignupModalVisible] = useState(false);\nconst [selectedShift, setSelectedShift] = useState<Shift | null>(null);\nconst [successModalVisible, setSuccessModalVisible] = useState(false);\nconst [form] = Form.useForm();\n
"},{"location":"v2/frontend/pages/public/shifts-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/public/shifts-page/#endpoints","title":"Endpoints","text":""},{"location":"v2/frontend/pages/public/shifts-page/#1-list-public-shifts","title":"1. List Public Shifts","text":"
GET /api/public/map/shifts\n

Response:

[\n  {\n    \"id\": \"cm1abc123\",\n    \"title\": \"Weekend Canvass - Downtown\",\n    \"description\": \"Door-to-door canvassing in the downtown district\",\n    \"date\": \"2025-02-15\",\n    \"startTime\": \"10:00\",\n    \"endTime\": \"14:00\",\n    \"location\": \"123 Main St, Campaign Office\",\n    \"maxVolunteers\": 10,\n    \"currentSignups\": 7,\n    \"cutName\": \"Downtown District\"\n  }\n]\n

"},{"location":"v2/frontend/pages/public/shifts-page/#2-sign-up-for-shift","title":"2. Sign Up for Shift","text":"
POST /api/public/map/shifts/:id/signup\nContent-Type: application/json\n\n{\n  \"name\": \"Jane Doe\",\n  \"email\": \"jane@example.com\",\n  \"phone\": \"(555) 123-4567\"\n}\n

Response:

{\n  \"success\": true,\n  \"signupId\": \"cm2def456\",\n  \"message\": \"Successfully signed up! Confirmation email sent.\"\n}\n

"},{"location":"v2/frontend/pages/public/shifts-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/public/shifts-page/#shift-card-with-capacity-bar","title":"Shift Card with Capacity Bar","text":"
{shifts.map(shift => {\n  const isFull = shift.currentSignups >= shift.maxVolunteers;\n  const percentage = (shift.currentSignups / shift.maxVolunteers) * 100;\n\n  const progressColor = \n    percentage < 70 ? '#52c41a' :\n    percentage < 90 ? '#faad14' :\n    '#f5222d';\n\n  return (\n    <Col xs={24} sm={12} lg={8} key={shift.id}>\n      <Card\n        hoverable={!isFull}\n        style={{ opacity: isFull ? 0.6 : 1 }}\n      >\n        {isFull && (\n          <Tag color=\"red\" style={{ position: 'absolute', top: 16, right: 16 }}>\n            Full\n          </Tag>\n        )}\n\n        <Title level={4} style={{ marginBottom: 12 }}>\n          {shift.title}\n        </Title>\n\n        <Space direction=\"vertical\" size={8} style={{ width: '100%', marginBottom: 16 }}>\n          <Text>\n            <CalendarOutlined /> {dayjs(shift.date).format('MMMM D, YYYY')}\n          </Text>\n          <Text>\n            <ClockCircleOutlined /> {shift.startTime} - {shift.endTime}\n          </Text>\n          <Text>\n            <EnvironmentOutlined /> {shift.location}\n          </Text>\n          {shift.cutName && (\n            <Tag color=\"blue\">{shift.cutName}</Tag>\n          )}\n        </Space>\n\n        {shift.description && (\n          <Paragraph ellipsis={{ rows: 3 }} type=\"secondary\" style={{ marginBottom: 16, minHeight: 66 }}>\n            {shift.description}\n          </Paragraph>\n        )}\n\n        <div style={{ marginBottom: 16 }}>\n          <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>\n            <Text type=\"secondary\" style={{ fontSize: 12 }}>\n              {shift.currentSignups} of {shift.maxVolunteers} volunteers\n            </Text>\n            <Text type=\"secondary\" style={{ fontSize: 12 }}>\n              {Math.round(percentage)}%\n            </Text>\n          </div>\n          <Progress\n            percent={percentage}\n            strokeColor={progressColor}\n            showInfo={false}\n          />\n        </div>\n\n        <Button\n          type=\"primary\"\n          block\n          disabled={isFull}\n          onClick={() => {\n            setSelectedShift(shift);\n            setSignupModalVisible(true);\n          }}\n        >\n          {isFull ? 'Full' : 'Sign Up'}\n        </Button>\n      </Card>\n    </Col>\n  );\n})}\n
"},{"location":"v2/frontend/pages/public/shifts-page/#signup-modal","title":"Signup Modal","text":"
<Modal\n  title=\"Sign Up for Shift\"\n  open={signupModalVisible}\n  onCancel={() => {\n    setSignupModalVisible(false);\n    form.resetFields();\n  }}\n  footer={null}\n  width={500}\n>\n  {selectedShift && (\n    <>\n      <div style={{\n        background: '#e6f7ff',\n        padding: 16,\n        borderRadius: 8,\n        marginBottom: 24\n      }}>\n        <Title level={5} style={{ marginBottom: 8 }}>\n          {selectedShift.title}\n        </Title>\n        <Text>\n          <CalendarOutlined /> {dayjs(selectedShift.date).format('MMMM D, YYYY')}\n        </Text>\n        <br />\n        <Text>\n          <ClockCircleOutlined /> {selectedShift.startTime} - {selectedShift.endTime}\n        </Text>\n        <br />\n        <Text>\n          <EnvironmentOutlined /> {selectedShift.location}\n        </Text>\n      </div>\n\n      <Form form={form} layout=\"vertical\" onFinish={handleSignup}>\n        <Form.Item\n          name=\"name\"\n          label=\"Your Name\"\n          rules={[\n            { required: true, message: 'Please enter your name' },\n            { min: 2, message: 'Name must be at least 2 characters' }\n          ]}\n        >\n          <Input size=\"large\" placeholder=\"Jane Doe\" />\n        </Form.Item>\n\n        <Form.Item\n          name=\"email\"\n          label=\"Email\"\n          rules={[\n            { required: true, message: 'Please enter your email' },\n            { type: 'email', message: 'Please enter a valid email' }\n          ]}\n        >\n          <Input size=\"large\" type=\"email\" placeholder=\"jane@example.com\" />\n        </Form.Item>\n\n        <Form.Item\n          name=\"phone\"\n          label=\"Phone Number\"\n          rules={[\n            { required: true, message: 'Please enter your phone number' },\n            { pattern: /^\\(?([0-9]{3})\\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/, message: 'Invalid phone format' }\n          ]}\n        >\n          <Input size=\"large\" placeholder=\"(555) 123-4567\" />\n        </Form.Item>\n\n        <Form.Item>\n          <Button type=\"primary\" htmlType=\"submit\" size=\"large\" block loading={loading}>\n            Sign Up\n          </Button>\n        </Form.Item>\n      </Form>\n    </>\n  )}\n</Modal>\n
"},{"location":"v2/frontend/pages/public/shifts-page/#performance-considerations","title":"Performance Considerations","text":"
  1. Single API Call: All shifts fetched once on mount
  2. Optimistic UI: Capacity updates immediately after signup
  3. Form Reset: Clears fields after successful submission
  4. Debounced Validation: Email/phone validation on blur, not keystroke
"},{"location":"v2/frontend/pages/public/shifts-page/#accessibility","title":"Accessibility","text":"
  • Keyboard Navigation: All buttons focusable
  • Form Labels: Associated with inputs via htmlFor
  • Progress Bars: Include sr-only text for screen readers
  • Color Contrast: All text meets WCAG AA standards
"},{"location":"v2/frontend/pages/public/shifts-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/public/shifts-page/#issue-phone-validation-failing","title":"Issue: Phone Validation Failing","text":"

Solution:

// Normalize phone input\nconst normalizePhone = (value: string) => {\n  const cleaned = value.replace(/\\D/g, '');\n  if (cleaned.length === 10) {\n    return `(${cleaned.slice(0,3)}) ${cleaned.slice(3,6)}-${cleaned.slice(6)}`;\n  }\n  return value;\n};\n\n<Form.Item normalize={normalizePhone}>\n  <Input />\n</Form.Item>\n

"},{"location":"v2/frontend/pages/public/shifts-page/#related-documentation","title":"Related Documentation","text":"
  • Admin Shifts Page
  • Volunteer Shifts Page
  • Shift Signups API
"},{"location":"v2/frontend/pages/volunteer/","title":"Volunteer Pages","text":"

Volunteer pages provide the volunteer portal interface for canvassing activities. These pages require authentication and are optimized for field use with GPS tracking and mobile responsiveness.

"},{"location":"v2/frontend/pages/volunteer/#route-context","title":"Route Context","text":"
  • Prefix: /volunteer/*
  • Layout: VolunteerLayout (top navigation)
  • Auth Required: Yes (any role)
  • Theme: Dark theme (consistent with public pages)
  • Mobile: Optimized for mobile/tablet use
"},{"location":"v2/frontend/pages/volunteer/#dashboard","title":"Dashboard","text":""},{"location":"v2/frontend/pages/volunteer/#volunteer-dashboard","title":"Volunteer Dashboard","text":"

Route: /volunteer/dashboard

Volunteer overview with:

  • Personal statistics
  • Upcoming assignments
  • Recent activity
  • Quick actions

Features: - Visit count and outcomes - Next shift information - Activity summary - Mobile responsive

"},{"location":"v2/frontend/pages/volunteer/#shift-management","title":"Shift Management","text":""},{"location":"v2/frontend/pages/volunteer/#volunteer-shifts-page","title":"Volunteer Shifts Page","text":"

Route: /volunteer/assignments

Assigned shifts for logged-in volunteer:

  • Upcoming shifts list
  • Cut information
  • Start canvass button
  • Shift details

Features: - Filter by date - Cut assignment display - Quick start canvassing - Email notifications - Mobile responsive

Note: Only shows shifts with cutId assigned (required for canvassing).

"},{"location":"v2/frontend/pages/volunteer/#canvassing","title":"Canvassing","text":""},{"location":"v2/frontend/pages/volunteer/#volunteer-map-page","title":"Volunteer Map Page","text":"

Route: /volunteer/canvass/:cutId

Full-screen GPS canvass map:

  • Leaflet map with GPS tracking
  • Location markers by status
  • Walking route polyline
  • Bottom sheet toolbar
  • Visit recording form
  • Session timer

Features: - GPS position tracking - Auto-center on position - Color-coded markers: - Red - Not visited - Blue - Next in route - Green - Visited (success outcomes) - Orange - Visited (neutral outcomes) - Red - Visited (negative outcomes) - Click marker to record visit - Walking route algorithm - Session management - Mobile optimized

Full-Screen: - No layout wrapper - Custom header with timer - Bottom sheet controls - Optimized for field use

GPS Tracking: - Watch position API - Blue GPS marker - Accuracy circle - Auto-update every 5 seconds

Visit Recording: - Outcome selection (NOT_HOME, MOVED, REFUSED, etc.) - Notes field - GPS coordinates captured - Rate limited (30/min)

Session Management: - Start/end session - Elapsed timer - Abandoned session cleanup (12h) - Progress tracking

"},{"location":"v2/frontend/pages/volunteer/#activity-tracking","title":"Activity Tracking","text":""},{"location":"v2/frontend/pages/volunteer/#my-activity-page","title":"My Activity Page","text":"

Route: /volunteer/activity

Visit history and statistics:

  • Visit list by date
  • Outcome breakdown
  • Session summary
  • Export options

Features: - Filter by date range - Group by session - Outcome pie chart - Total visit count - Mobile responsive

"},{"location":"v2/frontend/pages/volunteer/#my-routes-page","title":"My Routes Page","text":"

Route: /volunteer/routes

Walking route history:

  • Route list by session
  • Distance traveled
  • Time elapsed
  • Location count

Features: - Route visualization - Session details - Statistics summary - Mobile responsive

"},{"location":"v2/frontend/pages/volunteer/#volunteer-page-count","title":"Volunteer Page Count","text":"

Total: 4 volunteer pages

"},{"location":"v2/frontend/pages/volunteer/#common-features","title":"Common Features","text":"

Volunteer pages share:

  • Mobile First - Touch-friendly controls
  • GPS Integration - Location tracking
  • Dark Theme - Low-light visibility
  • Session State - Zustand canvass store
  • Real-time Updates - Auto-refresh data
  • Offline Support (future) - Service worker caching
"},{"location":"v2/frontend/pages/volunteer/#authentication","title":"Authentication","text":"

All volunteer pages require authentication:

// Volunteer routes use authenticate middleware\nrouter.get('/api/canvass/session', authenticate, ...)\n

Role-based redirect after login: - ADMIN roles \u2192 /app/dashboard - USER/TEMP roles \u2192 /volunteer/dashboard

"},{"location":"v2/frontend/pages/volunteer/#state-management","title":"State Management","text":"

Volunteer pages use Zustand canvass store:

// stores/canvass.store.ts\ninterface CanvassStore {\n  session: CanvassSession | null;\n  locations: CanvassLocation[];\n  route: WalkingRoute | null;\n  gpsPosition: { lat: number; lng: number } | null;\n  currentLocationIndex: number;\n  // ... actions\n}\n
"},{"location":"v2/frontend/pages/volunteer/#gps-tracking","title":"GPS Tracking","text":"

GPS tracking uses browser Geolocation API:

navigator.geolocation.watchPosition(\n  (position) => {\n    setGpsPosition({\n      lat: position.coords.latitude,\n      lng: position.coords.longitude,\n    });\n  },\n  (error) => console.error('GPS error:', error),\n  {\n    enableHighAccuracy: true,\n    timeout: 5000,\n    maximumAge: 0,\n  }\n);\n
"},{"location":"v2/frontend/pages/volunteer/#walking-route-algorithm","title":"Walking Route Algorithm","text":"

Routes are calculated server-side using nearest-neighbor algorithm:

  1. Start at closest location to shift start
  2. For each subsequent location:
  3. Find nearest unvisited location
  4. Add to route
  5. Return ordered location list

Frontend displays route as blue polyline connecting locations.

"},{"location":"v2/frontend/pages/volunteer/#visit-outcomes","title":"Visit Outcomes","text":"

Available outcomes in recording form:

  • SUCCESS - Successful contact
  • NOT_HOME - No one home
  • MOVED - Resident moved
  • REFUSED - Contact refused
  • WRONG_ADDRESS - Address error
  • INACCESSIBLE - Cannot access
  • OTHER - Other outcome
"},{"location":"v2/frontend/pages/volunteer/#session-lifecycle","title":"Session Lifecycle","text":"
  1. Start Session - Create session record, generate route
  2. GPS Tracking - Track position, update markers
  3. Visit Locations - Record outcomes, update route
  4. End Session - Close session, save statistics
  5. Abandoned Cleanup - Auto-close after 12 hours
"},{"location":"v2/frontend/pages/volunteer/#mobile-optimization","title":"Mobile Optimization","text":"

Volunteer pages are optimized for mobile:

  • Touch Targets - Minimum 44px touch areas
  • GPS Integration - Native geolocation
  • Offline Maps (future) - Cached tiles
  • Battery Optimization - Efficient GPS polling
  • Low-Light Mode - Dark theme
  • Network Resilience - Queue failed requests
"},{"location":"v2/frontend/pages/volunteer/#performance","title":"Performance","text":"

GPS canvass map optimizations:

  • Marker clustering for large datasets
  • Route simplification
  • Debounced position updates
  • Lazy loading location details
  • Service worker caching (future)
"},{"location":"v2/frontend/pages/volunteer/#related-documentation","title":"Related Documentation","text":"
  • Frontend Pages Overview
  • Admin Pages
  • Public Pages
  • Volunteer Layout
  • Canvass Components
  • Canvassing Feature
  • Backend Canvass Module
  • Volunteer User Guide
"},{"location":"v2/frontend/pages/volunteer/my-activity-page/","title":"My Activity Page","text":""},{"location":"v2/frontend/pages/volunteer/my-activity-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/volunteer/MyActivityPage.tsx (137 lines)

Route: /volunteer/activity

Role Requirements: Authenticated users

Purpose: Volunteer activity dashboard showing canvassing statistics and visit history.

Key Features:

  • Statistics cards (Today's Visits, Total Doors, Total Sessions)
  • Outcome breakdown with color-coded tags
  • Visit history table (address, outcome, timestamp)
  • Pagination (20 visits per page)
  • Parallel stats + visits fetch
  • Dark theme (VolunteerLayout)
"},{"location":"v2/frontend/pages/volunteer/my-activity-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/volunteer/my-activity-page/#1-statistics-cards","title":"1. Statistics Cards","text":"
<Row gutter={16}>\n  <Col xs={24} sm={8}>\n    <Card>\n      <Statistic\n        title=\"Today's Visits\"\n        value={stats.todayVisits}\n        prefix={<CheckCircleOutlined />}\n        valueStyle={{ color: '#52c41a' }}\n      />\n    </Card>\n  </Col>\n\n  <Col xs={24} sm={8}>\n    <Card>\n      <Statistic\n        title=\"Total Doors\"\n        value={stats.totalDoors}\n        prefix={<HomeOutlined />}\n        valueStyle={{ color: '#1890ff' }}\n      />\n    </Card>\n  </Col>\n\n  <Col xs={24} sm={8}>\n    <Card>\n      <Statistic\n        title=\"Total Sessions\"\n        value={stats.totalSessions}\n        prefix={<ClockCircleOutlined />}\n        valueStyle={{ color: '#722ed1' }}\n      />\n    </Card>\n  </Col>\n</Row>\n
"},{"location":"v2/frontend/pages/volunteer/my-activity-page/#2-outcome-breakdown","title":"2. Outcome Breakdown","text":"
<Card title=\"Outcome Breakdown\">\n  <Space wrap>\n    <Tag color=\"green\">Strong Support: {outcomes.strongSupport}</Tag>\n    <Tag color=\"blue\">Leaning Support: {outcomes.leaningSupport}</Tag>\n    <Tag color=\"yellow\">Undecided: {outcomes.undecided}</Tag>\n    <Tag color=\"orange\">Leaning Opposed: {outcomes.leaningOpposed}</Tag>\n    <Tag color=\"red\">Opposed: {outcomes.opposed}</Tag>\n    <Tag color=\"default\">No Answer: {outcomes.noAnswer}</Tag>\n    <Tag color=\"gray\">Not Home: {outcomes.notHome}</Tag>\n  </Space>\n</Card>\n
"},{"location":"v2/frontend/pages/volunteer/my-activity-page/#3-visit-history-table","title":"3. Visit History Table","text":"
<Table\n  dataSource={visits}\n  columns={[\n    {\n      title: 'Address',\n      dataIndex: 'address',\n      key: 'address'\n    },\n    {\n      title: 'Outcome',\n      dataIndex: 'outcome',\n      key: 'outcome',\n      render: (outcome) => (\n        <Tag color={getOutcomeColor(outcome)}>\n          {outcome.replace('_', ' ')}\n        </Tag>\n      )\n    },\n    {\n      title: 'Notes',\n      dataIndex: 'notes',\n      key: 'notes',\n      ellipsis: true\n    },\n    {\n      title: 'Time',\n      dataIndex: 'createdAt',\n      key: 'createdAt',\n      render: (date) => dayjs(date).format('MMM D, h:mm A')\n    }\n  ]}\n  pagination={{\n    current: page,\n    total: total,\n    pageSize: 20,\n    onChange: setPage\n  }}\n/>\n
"},{"location":"v2/frontend/pages/volunteer/my-activity-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/volunteer/my-activity-page/#endpoints","title":"Endpoints","text":""},{"location":"v2/frontend/pages/volunteer/my-activity-page/#1-get-stats","title":"1. Get Stats","text":"
GET /api/map/canvass/my-stats\nAuthorization: Bearer {token}\n

Response:

{\n  \"todayVisits\": 23,\n  \"totalDoors\": 187,\n  \"totalSessions\": 12,\n  \"outcomes\": {\n    \"strongSupport\": 45,\n    \"leaningSupport\": 32,\n    \"undecided\": 28,\n    \"leaningOpposed\": 15,\n    \"opposed\": 12,\n    \"noAnswer\": 38,\n    \"notHome\": 17\n  }\n}\n

"},{"location":"v2/frontend/pages/volunteer/my-activity-page/#2-get-visit-history","title":"2. Get Visit History","text":"
GET /api/map/canvass/my-visits?page=1&limit=20\nAuthorization: Bearer {token}\n

Response:

{\n  \"visits\": [\n    {\n      \"id\": \"cm1visit123\",\n      \"address\": \"123 Main St\",\n      \"outcome\": \"strong_support\",\n      \"notes\": \"Very enthusiastic, requested yard sign\",\n      \"createdAt\": \"2025-02-12T14:30:00.000Z\"\n    }\n  ],\n  \"total\": 187,\n  \"page\": 1\n}\n

"},{"location":"v2/frontend/pages/volunteer/my-activity-page/#related-documentation","title":"Related Documentation","text":"
  • Volunteer Map Page
  • My Routes Page
  • Canvass Dashboard
"},{"location":"v2/frontend/pages/volunteer/my-routes-page/","title":"My Routes Page","text":""},{"location":"v2/frontend/pages/volunteer/my-routes-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/volunteer/MyRoutesPage.tsx (275 lines)

Route: /volunteer/routes

Role Requirements: Authenticated users

Purpose: Visual display of volunteer's canvassing routes with map visualization and session history.

Key Features:

  • Statistics cards (Total Sessions, Total Distance, Total Time)
  • Interactive map with route polyline
  • Color-coded event markers (Session Start/End, Visit, Location Added)
  • Legend for event types
  • Session history table (date, duration, distance, point count)
  • View/Hide route toggle button
  • FitBounds component to center map on route
  • Dark CARTO basemap
"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/volunteer/my-routes-page/#1-statistics-summary","title":"1. Statistics Summary","text":"
<Row gutter={16}>\n  <Col xs={24} sm={8}>\n    <Statistic\n      title=\"Total Sessions\"\n      value={stats.totalSessions}\n      prefix={<ClockCircleOutlined />}\n    />\n  </Col>\n  <Col xs={24} sm={8}>\n    <Statistic\n      title=\"Total Distance\"\n      value={`${(stats.totalDistance / 1000).toFixed(1)} km`}\n      prefix={<EnvironmentOutlined />}\n    />\n  </Col>\n  <Col xs={24} sm=8}>\n    <Statistic\n      title=\"Total Time\"\n      value={formatDuration(stats.totalDuration)}\n      prefix={<FieldTimeOutlined />}\n    />\n  </Col>\n</Row>\n
"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#2-route-map","title":"2. Route Map","text":"
<MapContainer\n  center={[45.5017, -73.5673]}\n  zoom={13}\n  style={{ height: 400 }}\n>\n  <TileLayer\n    url=\"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png\"\n    attribution='&copy; <a href=\"https://carto.com/\">CARTO</a>'\n  />\n\n  {/* Route polyline */}\n  {routeVisible && selectedRoute && (\n    <Polyline\n      positions={selectedRoute.points.map(p => [p.latitude, p.longitude])}\n      pathOptions={{ color: '#1890ff', weight: 3 }}\n    />\n  )}\n\n  {/* Event markers */}\n  {selectedRoute?.points.map(point => (\n    <CircleMarker\n      key={point.id}\n      center={[point.latitude, point.longitude]}\n      radius={6}\n      pathOptions={{\n        color: getEventColor(point.eventType),\n        fillColor: getEventColor(point.eventType),\n        fillOpacity: 1\n      }}\n    >\n      <Popup>\n        <Text strong>{point.eventType}</Text>\n        <br />\n        <Text type=\"secondary\">\n          {dayjs(point.timestamp).format('h:mm:ss A')}\n        </Text>\n      </Popup>\n    </CircleMarker>\n  ))}\n\n  {/* Fit bounds to route */}\n  {selectedRoute && <FitBounds points={selectedRoute.points} />}\n</MapContainer>\n
"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#3-event-type-legend","title":"3. Event Type Legend","text":"
<div style={{ padding: 12, background: 'rgba(0,0,0,0.7)', borderRadius: 4 }}>\n  <Space direction=\"vertical\" size={4}>\n    <Space>\n      <div style={{ width: 12, height: 12, background: '#52c41a', borderRadius: '50%' }} />\n      <Text style={{ color: 'white', fontSize: 12 }}>Session Start</Text>\n    </Space>\n    <Space>\n      <div style={{ width: 12, height: 12, background: '#f5222d', borderRadius: '50%' }} />\n      <Text style={{ color: 'white', fontSize: 12 }}>Session End</Text>\n    </Space>\n    <Space>\n      <div style={{ width: 12, height: 12, background: '#1890ff', borderRadius: '50%' }} />\n      <Text style={{ color: 'white', fontSize: 12 }}>Visit</Text>\n    </Space>\n    <Space>\n      <div style={{ width: 12, height: 12, background: '#722ed1', borderRadius: '50%' }} />\n      <Text style={{ color: 'white', fontSize: 12 }}>Location Added</Text>\n    </Space>\n  </Space>\n</div>\n
"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#4-session-history-table","title":"4. Session History Table","text":"
<Table\n  dataSource={sessions}\n  columns={[\n    {\n      title: 'Date',\n      dataIndex: 'startTime',\n      render: (date) => dayjs(date).format('MMM D, YYYY')\n    },\n    {\n      title: 'Duration',\n      key: 'duration',\n      render: (_, record) => {\n        const start = dayjs(record.startTime);\n        const end = dayjs(record.endTime);\n        return formatDuration(end.diff(start, 'seconds'));\n      }\n    },\n    {\n      title: 'Distance',\n      dataIndex: 'distance',\n      render: (distance) => `${(distance / 1000).toFixed(1)} km`\n    },\n    {\n      title: 'Points',\n      dataIndex: 'pointCount',\n      render: (count) => `${count} points`\n    },\n    {\n      title: 'Action',\n      key: 'action',\n      render: (_, record) => (\n        <Button\n          type=\"link\"\n          onClick={() => {\n            setSelectedRoute(record);\n            setRouteVisible(true);\n          }}\n        >\n          View Route\n        </Button>\n      )\n    }\n  ]}\n/>\n
"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/volunteer/my-routes-page/#endpoints","title":"Endpoints","text":""},{"location":"v2/frontend/pages/volunteer/my-routes-page/#1-get-route-stats","title":"1. Get Route Stats","text":"
GET /api/map/canvass/my-routes/stats\nAuthorization: Bearer {token}\n

Response:

{\n  \"totalSessions\": 12,\n  \"totalDistance\": 34567,\n  \"totalDuration\": 18900\n}\n

"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#2-get-session-routes","title":"2. Get Session Routes","text":"
GET /api/map/canvass/my-routes\nAuthorization: Bearer {token}\n

Response:

[\n  {\n    \"sessionId\": \"cm1session123\",\n    \"startTime\": \"2025-02-12T10:00:00.000Z\",\n    \"endTime\": \"2025-02-12T12:30:00.000Z\",\n    \"distance\": 2834,\n    \"pointCount\": 45,\n    \"points\": [\n      {\n        \"id\": \"cm1point1\",\n        \"latitude\": 45.5017,\n        \"longitude\": -73.5673,\n        \"eventType\": \"session_start\",\n        \"timestamp\": \"2025-02-12T10:00:00.000Z\"\n      }\n    ]\n  }\n]\n

"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#utility-functions","title":"Utility Functions","text":"
const formatDuration = (seconds: number): string => {\n  const hours = Math.floor(seconds / 3600);\n  const minutes = Math.floor((seconds % 3600) / 60);\n\n  if (hours > 0) {\n    return `${hours}h ${minutes}m`;\n  }\n  return `${minutes}m`;\n};\n\nconst getEventColor = (eventType: string): string => {\n  switch (eventType) {\n    case 'session_start': return '#52c41a';\n    case 'session_end': return '#f5222d';\n    case 'visit': return '#1890ff';\n    case 'location_added': return '#722ed1';\n    default: return '#8c8c8c';\n  }\n};\n
"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#related-documentation","title":"Related Documentation","text":"
  • My Activity Page
  • Volunteer Map Page
  • GPS Tracking System
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/","title":"Volunteer Map Page","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/volunteer/VolunteerMapPage.tsx (570 lines)

Route: /volunteer/canvass/:cutId

Role Requirements: Authenticated users (USER or TEMP role)

Purpose: Full-screen GPS-enabled canvassing map for door-to-door volunteer work with visit recording, route navigation, location management, and session tracking.

Key Features:

  • Full-screen map (no AppLayout wrapper)
  • Real-time GPS tracking with following mode
  • Canvass session management (start/end with auto-pause)
  • Walking route display with toggle
  • CanvassMarkerGroup for clustered address display
  • VisitRecordingForm in bottom drawer
  • AddLocationDrawer (crosshair + tap to add)
  • VolunteerMapDrawer (menu, stats, session picker)
  • VolunteerFooterNav (bottom navigation bar)
  • VolunteerSessionBar (active session indicator above footer)
  • TileLayerToggle (OpenStreetMap, CARTO, Satellite)
  • AddressSearchOverlay
  • Next door button (finds nearest unvisited location)
  • Cut polygon overlays with toggle controls
  • Admin edit mode (LocationEditDrawer for MAP_ADMIN users)
  • Tracking integration (links to GPS tracking sessions)

Layout: No layout wrapper - full viewport with custom overlays

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#1-gps-tracking","title":"1. GPS Tracking","text":"

Real-time position tracking with following mode:

Components: - GPSTracker component (useEffect with watchPosition) - Blue pulsing circle marker at user's position - Accuracy circle (outer ring) - GPS path polyline (breadcrumb trail) - Follow mode toggle (auto-pan to user)

Code:

<GPSTracker\n  enabled={sessionActive}\n  onPositionUpdate={(position) => {\n    setUserPosition(position);\n    if (followMode) {\n      map.panTo(position.coords);\n    }\n    // Track position for session\n    trackPosition(position);\n  }}\n  onError={(error) => {\n    message.error('GPS unavailable: ' + error.message);\n  }}\n/>\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#2-session-management","title":"2. Session Management","text":"

Canvass session lifecycle:

States: - ACTIVE: Session in progress - PAUSED: Session paused (GPS stopped) - ENDED: Session completed

Controls: - Start Session button (green) - Pause Session button (yellow) - End Session button (red, with confirmation) - Auto-pause after 30min inactivity

Session Bar:

<VolunteerSessionBar\n  session={activeSession}\n  onPause={handlePauseSession}\n  onEnd={() => setEndModalVisible(true)}\n  style={{\n    position: 'fixed',\n    bottom: 60,\n    left: 0,\n    right: 0,\n    zIndex: 1000\n  }}\n/>\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#3-walking-route-display","title":"3. Walking Route Display","text":"

Optimized door-to-door route:

Algorithm: - Nearest neighbor with deduplication - Starts from user's current position - Visits unvisited locations in order - Avoids backtracking

Visual: - Blue polyline connecting locations - Dashed line style - Toggle button to show/hide - Route recalculates when locations visited

Code:

{routeVisible && walkingRoute && (\n  <WalkingRouteLine\n    route={walkingRoute}\n    userPosition={userPosition}\n  />\n)}\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#4-canvass-markers","title":"4. Canvass Markers","text":"

Location markers with clustering:

CanvassMarkerGroup Component: - Clusters nearby markers (radius: 50px) - Color-coded by support level - Click to open VisitRecordingForm - Shows last visit outcome if visited - Purple markers for multi-unit buildings

Marker States: - Unvisited: Gray circle - Visited: Color-coded by outcome - Selected: Larger radius + pulsing animation

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#5-visit-recording-form","title":"5. Visit Recording Form","text":"

Bottom drawer for recording visits:

Fields: - Address (read-only, pre-filled) - Outcome (dropdown: 7 options) - Notes (TextArea, optional) - Contact Interest (checkbox) - Follow-up Required (checkbox)

Outcome Options: 1. Strong Support 2. Leaning Support 3. Undecided 4. Leaning Opposed 5. Opposed 6. No Answer 7. Not Home

Submission: - Creates CanvassVisit record - Updates location supportLevel - Closes drawer - Marker updates color - Next door button finds new nearest

Code:

<VisitRecordingForm\n  location={selectedLocation}\n  sessionId={activeSession?.id}\n  visible={recordingDrawerVisible}\n  onClose={() => setRecordingDrawerVisible(false)}\n  onSubmit={handleVisitSubmit}\n/>\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#6-add-location-mode","title":"6. Add Location Mode","text":"

Crosshair interface for adding missing addresses:

Activation: - \"Add Location\" button in menu - Opens AddLocationDrawer - Crosshair appears at map center - User pans map to position crosshair - \"Tap Here to Add\" button

AddLocationDrawer: - Address input (with geocoding suggestion) - Unit number (for multi-unit buildings) - Notes - Cancel / Confirm buttons

Code:

<AddLocationDrawer\n  visible={addLocationMode}\n  position={map.getCenter()}\n  onConfirm={handleAddLocation}\n  onCancel={() => setAddLocationMode(false)}\n/>\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#7-map-controls","title":"7. Map Controls","text":"

Floating control panels:

VolunteerMapDrawer (left side): - Menu button (hamburger) - Session stats (visits today, doors knocked) - Session picker dropdown - Tile layer toggle - Cut overlays toggle - Address search - Add location button - End session button

Control Buttons (right side): - Geolocate (find my location) - Toggle walking route - Next door (find nearest unvisited) - Fullscreen toggle

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#8-tile-layer-toggle","title":"8. Tile Layer Toggle","text":"

Three basemap options:

  1. OpenStreetMap: Default, detailed streets
  2. CARTO Dark: High contrast, good for day/night
  3. Satellite: Aerial imagery from Esri

Component:

<TileLayerToggle\n  activeLayer={activeLayer}\n  onChange={setActiveLayer}\n  position=\"topright\"\n/>\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#9-address-search-overlay","title":"9. Address Search Overlay","text":"

Quick location lookup:

Features: - Input with search icon - Autocomplete from locations in cut - Fly to location on select - Opens visit recording form

Code:

<AddressSearchOverlay\n  locations={locations}\n  onSelect={(location) => {\n    map.flyTo([location.latitude, location.longitude], 18);\n    setSelectedLocation(location);\n    setRecordingDrawerVisible(true);\n  }}\n/>\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#10-next-door-button","title":"10. Next Door Button","text":"

Intelligent location finder:

Algorithm: 1. Filter unvisited locations in cut 2. Calculate distance from user position 3. Sort by distance (haversine) 4. Select nearest 5. Pan map and open recording form

Code:

const handleNextDoor = () => {\n  const unvisited = locations.filter(loc => \n    !visits.some(v => v.locationId === loc.id)\n  );\n\n  if (unvisited.length === 0) {\n    message.info('All locations visited!');\n    return;\n  }\n\n  const nearest = unvisited.reduce((prev, curr) => {\n    const prevDist = haversineDistance(userPosition, prev);\n    const currDist = haversineDistance(userPosition, curr);\n    return currDist < prevDist ? curr : prev;\n  });\n\n  map.flyTo([nearest.latitude, nearest.longitude], 18);\n  setSelectedLocation(nearest);\n  setRecordingDrawerVisible(true);\n};\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#11-admin-edit-mode","title":"11. Admin Edit Mode","text":"

MAP_ADMIN users can edit locations:

Features: - Edit button on location popup - LocationEditDrawer with full form - Update address, support level, notes - Delete location (with confirmation) - Move location (drag marker)

Conditional Render:

{user?.role === 'MAP_ADMIN' && (\n  <Button onClick={() => setEditMode(true)}>\n    Edit Location\n  </Button>\n)}\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#12-cut-overlay-toggle","title":"12. Cut Overlay Toggle","text":"

Show/hide cut boundaries:

Component:

<CutOverlayControls\n  cuts={cuts}\n  visibleCuts={visibleCuts}\n  onToggle={(cutId) => {\n    setVisibleCuts(prev => {\n      const next = new Set(prev);\n      if (next.has(cutId)) {\n        next.delete(cutId);\n      } else {\n        next.add(cutId);\n      }\n      return next;\n    });\n  }}\n  position=\"bottomleft\"\n/>\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#starting-a-canvass-session","title":"Starting a Canvass Session","text":"
  1. Volunteer navigates to /volunteer/canvass/:cutId
  2. Map loads centered on cut bounds
  3. Locations load within cut
  4. Volunteer clicks \"Start Session\" in drawer
  5. GPS tracking activates
  6. Session bar appears at bottom (above footer)
  7. Walking route calculates and displays
  8. User position marker appears and updates
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#recording-a-visit","title":"Recording a Visit","text":"
  1. Volunteer walks to address
  2. Clicks marker or uses \"Next Door\" button
  3. VisitRecordingForm opens in bottom drawer
  4. Volunteer selects outcome from dropdown
  5. Volunteer adds notes (optional)
  6. Volunteer checks \"Follow-up Required\" if needed
  7. Volunteer clicks \"Save Visit\"
  8. API creates CanvassVisit record
  9. Marker updates to color-coded outcome
  10. Drawer closes
  11. Walking route recalculates
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#adding-a-missing-location","title":"Adding a Missing Location","text":"
  1. Volunteer encounters unlisted address
  2. Opens menu drawer
  3. Clicks \"Add Location\"
  4. Crosshair appears at map center
  5. Volunteer pans map to position crosshair over address
  6. Clicks \"Tap Here to Add\"
  7. AddLocationDrawer opens
  8. Volunteer enters address
  9. Volunteer clicks \"Confirm\"
  10. API creates Location record
  11. New marker appears on map
  12. Volunteer can immediately record visit
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#finding-next-door","title":"Finding Next Door","text":"
  1. Volunteer finishes current visit
  2. Clicks \"Next Door\" button (right side)
  3. Algorithm finds nearest unvisited location
  4. Map animates (flyTo) to location
  5. VisitRecordingForm opens automatically
  6. Volunteer records visit
  7. Repeats process
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#ending-session","title":"Ending Session","text":"
  1. Volunteer clicks \"End Session\" in drawer
  2. Confirmation modal appears
  3. Modal shows session stats (duration, visits, distance)
  4. Volunteer clicks \"End Session\" confirm button
  5. GPS tracking stops
  6. Session marked as ENDED in database
  7. Session bar disappears
  8. Volunteer can start new session or navigate away
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#component-structure","title":"Component Structure","text":"
import React, { useState, useEffect, useCallback } from 'react';\nimport { useParams } from 'react-router-dom';\nimport { MapContainer, TileLayer, useMap } from 'react-leaflet';\nimport { Button, message, Modal } from 'antd';\nimport {\n  AimOutlined,\n  PlusOutlined,\n  ArrowRightOutlined,\n  FullscreenOutlined\n} from '@ant-design/icons';\nimport GPSTracker from '../../components/canvass/GPSTracker';\nimport CanvassMarkerGroup from '../../components/canvass/CanvassMarkerGroup';\nimport WalkingRouteLine from '../../components/canvass/WalkingRouteLine';\nimport VisitRecordingForm from '../../components/canvass/VisitRecordingForm';\nimport AddLocationDrawer from '../../components/canvass/AddLocationDrawer';\nimport VolunteerMapDrawer from '../../components/canvass/VolunteerMapDrawer';\nimport VolunteerFooterNav from '../../components/canvass/VolunteerFooterNav';\nimport VolunteerSessionBar from '../../components/canvass/VolunteerSessionBar';\nimport { useCanvassStore } from '../../stores/canvass.store';\nimport { api } from '../../lib/api';\nimport 'leaflet/dist/leaflet.css';\n\nconst VolunteerMapPage: React.FC = () => {\n  const { cutId } = useParams<{ cutId: string }>();\n  const [map, setMap] = useState<L.Map | null>(null);\n\n  // Canvass store\n  const {\n    activeSession,\n    locations,\n    visits,\n    walkingRoute,\n    userPosition,\n    setActiveSession,\n    addVisit,\n    updateLocation,\n    setUserPosition\n  } = useCanvassStore();\n\n  // UI state\n  const [recordingDrawerVisible, setRecordingDrawerVisible] = useState(false);\n  const [selectedLocation, setSelectedLocation] = useState<Location | null>(null);\n  const [addLocationMode, setAddLocationMode] = useState(false);\n  const [drawerVisible, setDrawerVisible] = useState(false);\n  const [routeVisible, setRouteVisible] = useState(true);\n  const [followMode, setFollowMode] = useState(true);\n\n  // Fetch locations in cut\n  useEffect(() => {\n    const fetchLocations = async () => {\n      try {\n        const response = await api.get(`/api/map/canvass/locations/${cutId}`);\n        // Store in Zustand\n      } catch (error) {\n        message.error('Failed to load locations');\n      }\n    };\n\n    fetchLocations();\n  }, [cutId]);\n\n  return (\n    <div style={{ height: '100vh', width: '100vw', position: 'relative' }}>\n      <MapContainer\n        center={[45.5017, -73.5673]}\n        zoom={16}\n        zoomControl={false}\n        style={{ height: '100%', width: '100%' }}\n        whenCreated={setMap}\n      >\n        <TileLayer\n          url=\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\"\n          attribution='OSM'\n        />\n\n        <GPSTracker\n          enabled={!!activeSession}\n          onPositionUpdate={handlePositionUpdate}\n        />\n\n        <CanvassMarkerGroup\n          locations={locations}\n          visits={visits}\n          onMarkerClick={handleMarkerClick}\n        />\n\n        {routeVisible && walkingRoute && (\n          <WalkingRouteLine\n            route={walkingRoute}\n            userPosition={userPosition}\n          />\n        )}\n      </MapContainer>\n\n      <VolunteerMapDrawer\n        visible={drawerVisible}\n        onClose={() => setDrawerVisible(false)}\n        onStartSession={handleStartSession}\n        onEndSession={() => setEndModalVisible(true)}\n        onAddLocation={() => setAddLocationMode(true)}\n        session={activeSession}\n        stats={sessionStats}\n      />\n\n      <VisitRecordingForm\n        location={selectedLocation}\n        sessionId={activeSession?.id}\n        visible={recordingDrawerVisible}\n        onClose={() => setRecordingDrawerVisible(false)}\n        onSubmit={handleVisitSubmit}\n      />\n\n      <AddLocationDrawer\n        visible={addLocationMode}\n        position={map?.getCenter()}\n        onConfirm={handleAddLocation}\n        onCancel={() => setAddLocationMode(false)}\n      />\n\n      {activeSession && (\n        <VolunteerSessionBar\n          session={activeSession}\n          onPause={handlePause}\n          onEnd={() => setEndModalVisible(true)}\n        />\n      )}\n\n      <VolunteerFooterNav activeKey=\"canvass\" />\n\n      {/* Floating controls */}\n      <div style={{ position: 'absolute', right: 16, top: 16, zIndex: 1000 }}>\n        <Button\n          icon={<AimOutlined />}\n          onClick={handleGeolocate}\n          size=\"large\"\n          style={{ display: 'block', marginBottom: 8 }}\n        />\n        <Button\n          icon={<ArrowRightOutlined />}\n          onClick={handleNextDoor}\n          size=\"large\"\n        />\n      </div>\n    </div>\n  );\n};\n\nexport default VolunteerMapPage;\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#zustand-store-canvassstorets","title":"Zustand Store (canvass.store.ts)","text":"
interface CanvassState {\n  // Session\n  activeSession: CanvassSession | null;\n  setActiveSession: (session: CanvassSession | null) => void;\n\n  // Locations\n  locations: CanvassLocation[];\n  setLocations: (locations: CanvassLocation[]) => void;\n  updateLocation: (id: string, updates: Partial<CanvassLocation>) => void;\n\n  // Visits\n  visits: CanvassVisit[];\n  addVisit: (visit: CanvassVisit) => void;\n\n  // Route\n  walkingRoute: WalkingRoute | null;\n  calculateRoute: () => void;\n\n  // GPS\n  userPosition: GPSPosition | null;\n  setUserPosition: (position: GPSPosition) => void;\n  gpsPath: GPSPosition[];\n  addGPSPoint: (position: GPSPosition) => void;\n}\n\nexport const useCanvassStore = create<CanvassState>((set, get) => ({\n  activeSession: null,\n  locations: [],\n  visits: [],\n  walkingRoute: null,\n  userPosition: null,\n  gpsPath: [],\n\n  setActiveSession: (session) => {\n    set({ activeSession: session });\n    if (session) {\n      get().calculateRoute();\n    }\n  },\n\n  addVisit: (visit) => {\n    set((state) => ({\n      visits: [...state.visits, visit]\n    }));\n    get().calculateRoute(); // Recalculate after visit\n  },\n\n  calculateRoute: () => {\n    const { locations, visits, userPosition } = get();\n\n    if (!userPosition) return;\n\n    const unvisited = locations.filter(loc =>\n      !visits.some(v => v.locationId === loc.id)\n    );\n\n    const route = calculateWalkingRoute(userPosition, unvisited);\n    set({ walkingRoute: route });\n  }\n}));\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#component-state","title":"Component State","text":"
// UI state\nconst [recordingDrawerVisible, setRecordingDrawerVisible] = useState(false);\nconst [selectedLocation, setSelectedLocation] = useState<Location | null>(null);\nconst [addLocationMode, setAddLocationMode] = useState(false);\nconst [drawerVisible, setDrawerVisible] = useState(false);\nconst [endModalVisible, setEndModalVisible] = useState(false);\n\n// Map state\nconst [map, setMap] = useState<L.Map | null>(null);\nconst [routeVisible, setRouteVisible] = useState(true);\nconst [followMode, setFollowMode] = useState(true);\nconst [activeLayer, setActiveLayer] = useState<'osm' | 'carto' | 'satellite'>('osm');\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#endpoints","title":"Endpoints","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#1-get-locations-in-cut","title":"1. Get Locations in Cut","text":"
GET /api/map/canvass/locations/:cutId\nAuthorization: Bearer {token}\n

Response:

[\n  {\n    \"id\": \"cm1loc123\",\n    \"address\": \"123 Main St\",\n    \"latitude\": 45.5017,\n    \"longitude\": -73.5673,\n    \"supportLevel\": null,\n    \"lastVisitDate\": null,\n    \"isMultiUnit\": false\n  }\n]\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#2-start-session","title":"2. Start Session","text":"
POST /api/map/canvass/sessions/start\nAuthorization: Bearer {token}\nContent-Type: application/json\n\n{\n  \"cutId\": \"cm1cut123\"\n}\n

Response:

{\n  \"sessionId\": \"cm2session456\",\n  \"startTime\": \"2025-02-12T10:00:00.000Z\",\n  \"status\": \"ACTIVE\"\n}\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#3-record-visit","title":"3. Record Visit","text":"
POST /api/map/canvass/visits\nAuthorization: Bearer {token}\nContent-Type: application/json\n\n{\n  \"sessionId\": \"cm2session456\",\n  \"locationId\": \"cm1loc123\",\n  \"outcome\": \"strong_support\",\n  \"notes\": \"Very enthusiastic, requested yard sign\",\n  \"contactInterested\": true,\n  \"followUpRequired\": false\n}\n

Response:

{\n  \"visitId\": \"cm3visit789\",\n  \"createdAt\": \"2025-02-12T10:15:00.000Z\"\n}\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#4-track-gps-position","title":"4. Track GPS Position","text":"
POST /api/map/canvass/sessions/:sessionId/track\nAuthorization: Bearer {token}\nContent-Type: application/json\n\n{\n  \"latitude\": 45.5017,\n  \"longitude\": -73.5673,\n  \"accuracy\": 12.5,\n  \"timestamp\": \"2025-02-12T10:15:30.000Z\"\n}\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#5-add-location","title":"5. Add Location","text":"
POST /api/map/locations\nAuthorization: Bearer {token}\nContent-Type: application/json\n\n{\n  \"address\": \"125 Main St\",\n  \"latitude\": 45.5018,\n  \"longitude\": -73.5672,\n  \"cutId\": \"cm1cut123\",\n  \"notes\": \"Added during canvass\"\n}\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#6-end-session","title":"6. End Session","text":"
POST /api/map/canvass/sessions/:sessionId/end\nAuthorization: Bearer {token}\n

Response:

{\n  \"sessionId\": \"cm2session456\",\n  \"endTime\": \"2025-02-12T12:00:00.000Z\",\n  \"duration\": 7200,\n  \"visitCount\": 23,\n  \"distance\": 2834\n}\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#gps-tracker-component","title":"GPS Tracker Component","text":"
// components/canvass/GPSTracker.tsx\nconst GPSTracker: React.FC<{\n  enabled: boolean;\n  onPositionUpdate: (position: GeolocationPosition) => void;\n  onError?: (error: GeolocationPositionError) => void;\n}> = ({ enabled, onPositionUpdate, onError }) => {\n  useEffect(() => {\n    if (!enabled || !navigator.geolocation) return;\n\n    const watchId = navigator.geolocation.watchPosition(\n      onPositionUpdate,\n      onError,\n      {\n        enableHighAccuracy: true,\n        maximumAge: 5000,\n        timeout: 10000\n      }\n    );\n\n    return () => navigator.geolocation.clearWatch(watchId);\n  }, [enabled, onPositionUpdate, onError]);\n\n  return null;\n};\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#walking-route-calculation","title":"Walking Route Calculation","text":"
// utils/walking-route.ts\nexport const calculateWalkingRoute = (\n  start: GPSPosition,\n  locations: CanvassLocation[]\n): WalkingRoute => {\n  const unvisited = [...locations];\n  const route: CanvassLocation[] = [];\n  let current = { latitude: start.latitude, longitude: start.longitude };\n\n  // Nearest neighbor algorithm\n  while (unvisited.length > 0) {\n    let nearestIdx = 0;\n    let nearestDist = Infinity;\n\n    unvisited.forEach((loc, idx) => {\n      const dist = haversineDistance(current, loc);\n      if (dist < nearestDist) {\n        nearestDist = dist;\n        nearestIdx = idx;\n      }\n    });\n\n    const nearest = unvisited.splice(nearestIdx, 1)[0];\n    route.push(nearest);\n    current = nearest;\n  }\n\n  return {\n    locations: route,\n    totalDistance: calculateTotalDistance(route)\n  };\n};\n\nconst haversineDistance = (\n  a: { latitude: number; longitude: number },\n  b: { latitude: number; longitude: number }\n): number => {\n  const R = 6371e3; // Earth radius in meters\n  const \u03c61 = (a.latitude * Math.PI) / 180;\n  const \u03c62 = (b.latitude * Math.PI) / 180;\n  const \u0394\u03c6 = ((b.latitude - a.latitude) * Math.PI) / 180;\n  const \u0394\u03bb = ((b.longitude - a.longitude) * Math.PI) / 180;\n\n  const a1 = Math.sin(\u0394\u03c6 / 2) * Math.sin(\u0394\u03c6 / 2) +\n          Math.cos(\u03c61) * Math.cos(\u03c62) *\n          Math.sin(\u0394\u03bb / 2) * Math.sin(\u0394\u03bb / 2);\n  const c = 2 * Math.atan2(Math.sqrt(a1), Math.sqrt(1 - a1));\n\n  return R * c; // Distance in meters\n};\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#canvass-marker-group","title":"Canvass Marker Group","text":"
// components/canvass/CanvassMarkerGroup.tsx\nconst CanvassMarkerGroup: React.FC<{\n  locations: CanvassLocation[];\n  visits: CanvassVisit[];\n  onMarkerClick: (location: CanvassLocation) => void;\n}> = ({ locations, visits, onMarkerClick }) => {\n  const getMarkerColor = (location: CanvassLocation) => {\n    const visit = visits.find(v => v.locationId === location.id);\n\n    if (!visit) return '#8c8c8c'; // Unvisited\n\n    switch (visit.outcome) {\n      case 'strong_support': return '#52c41a';\n      case 'leaning_support': return '#95de64';\n      case 'undecided': return '#fadb14';\n      case 'leaning_opposed': return '#ff7a45';\n      case 'opposed': return '#f5222d';\n      case 'no_answer': return '#8c8c8c';\n      case 'not_home': return '#d9d9d9';\n      default: return '#8c8c8c';\n    }\n  };\n\n  return (\n    <>\n      {locations.map(location => (\n        <CircleMarker\n          key={location.id}\n          center={[location.latitude, location.longitude]}\n          radius={10}\n          pathOptions={{\n            color: 'white',\n            weight: 2,\n            fillColor: getMarkerColor(location),\n            fillOpacity: 0.8\n          }}\n          eventHandlers={{\n            click: () => onMarkerClick(location)\n          }}\n        >\n          <Popup>\n            <Text strong>{location.address}</Text>\n            {location.unitNumber && (\n              <>\n                <br />\n                <Text>Unit: {location.unitNumber}</Text>\n              </>\n            )}\n          </Popup>\n        </CircleMarker>\n      ))}\n    </>\n  );\n};\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#performance-considerations","title":"Performance Considerations","text":"
  1. Zustand Store: Global state prevents prop drilling
  2. Debounced GPS: Position tracked every 5 seconds (not every update)
  3. Route Recalc: Only recalculates when visits added
  4. Marker Clustering: Reduces DOM nodes on dense maps
  5. Lazy Drawers: Components mount only when opened
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#responsive-design","title":"Responsive Design","text":"
  • Mobile-First: Designed for phones (primary use case)
  • Touch Gestures: Native Leaflet touch support
  • Bottom Drawers: Accessible with thumb
  • Large Touch Targets: All buttons 44px+ minimum
  • Portrait Orientation: Optimized for vertical screens
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#accessibility","title":"Accessibility","text":"
  • GPS Feedback: Audible alerts for position updates (optional)
  • High Contrast: CARTO Dark mode for low light
  • Large Text: All UI text 14px minimum
  • Voice Input: Notes field supports speech-to-text (browser)
"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#issue-gps-not-working","title":"Issue: GPS Not Working","text":"

Causes: 1. HTTPS required (geolocation API restriction) 2. User denied permission 3. GPS unavailable (indoors, bad signal)

Solutions:

const handleGPSError = (error: GeolocationPositionError) => {\n  switch (error.code) {\n    case error.PERMISSION_DENIED:\n      Modal.error({\n        title: 'GPS Permission Required',\n        content: 'Please enable location permissions in your browser settings.'\n      });\n      break;\n    case error.POSITION_UNAVAILABLE:\n      message.warning('GPS unavailable. Try moving outdoors.');\n      break;\n    case error.TIMEOUT:\n      message.warning('GPS timeout. Check your device settings.');\n      break;\n  }\n};\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#issue-route-not-displaying","title":"Issue: Route Not Displaying","text":"

Causes: 1. No unvisited locations 2. Route calculation error 3. Route toggle off

Solutions:

// Add debug logging\nuseEffect(() => {\n  console.log('Route calculation:', {\n    unvisited: locations.filter(l => !visits.some(v => v.locationId === l.id)).length,\n    routeVisible,\n    walkingRoute: walkingRoute?.locations.length\n  });\n}, [locations, visits, routeVisible, walkingRoute]);\n\n// Show message if no unvisited\nif (unvisitedCount === 0) {\n  message.success('All locations visited! Great work!');\n}\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#issue-session-not-ending","title":"Issue: Session Not Ending","text":"

Causes: 1. API timeout 2. Pending GPS uploads 3. Network disconnection

Solutions:

const handleEndSession = async () => {\n  try {\n    // Upload any pending GPS points first\n    await uploadPendingGPSPoints();\n\n    // Then end session\n    await api.post(`/api/map/canvass/sessions/${activeSession.id}/end`, {}, {\n      timeout: 10000\n    });\n\n    message.success('Session ended');\n    setActiveSession(null);\n\n  } catch (error: any) {\n    if (error.code === 'ECONNABORTED') {\n      // Force local end if server timeout\n      setActiveSession(null);\n      message.warning('Session ended locally (server unreachable)');\n    } else {\n      message.error('Failed to end session');\n    }\n  }\n};\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#related-documentation","title":"Related Documentation","text":"
  • Canvass System Architecture
  • GPS Tracking
  • Walking Route Algorithm
  • Canvass Dashboard
  • My Activity Page
  • My Routes Page
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/","title":"Volunteer Shifts Page","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#overview","title":"Overview","text":"

File Path: admin/src/pages/volunteer/VolunteerShiftsPage.tsx (312 lines)

Route: /volunteer/assignments

Role Requirements: Authenticated users (USER or TEMP role)

Purpose: Volunteer-facing shift management showing upcoming shifts and personal signups with signup/cancel functionality.

Key Features:

  • Segmented tabs: \"Upcoming Shifts\" / \"My Signups\"
  • Responsive shift cards grid (xs=1, sm=2 columns)
  • Progress bar showing volunteer capacity
  • Signup confirmation modal
  • Cancel signup modal (danger button)
  • \"Signed Up\" badge + cancel link on signed-up shifts
  • Empty states for no shifts/signups
  • Dark theme (VolunteerLayout)

Layout: Uses VolunteerLayout with top navigation

"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#1-segmented-tabs","title":"1. Segmented Tabs","text":"
<Segmented\n  value={activeTab}\n  onChange={setActiveTab}\n  options={[\n    { label: 'Upcoming Shifts', value: 'upcoming' },\n    { label: 'My Signups', value: 'signups' }\n  ]}\n  size=\"large\"\n  block\n/>\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#2-shift-cards","title":"2. Shift Cards","text":"

Upcoming Shifts Tab: - Shows all available shifts - \"Sign Up\" button (primary) - \"Signed Up\" badge if user already signed up - \"Cancel Signup\" link if signed up

My Signups Tab: - Shows only user's signups - \"Cancel Signup\" button (danger) - Shift details emphasized

"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#3-capacity-progress-bar","title":"3. Capacity Progress Bar","text":"
const percentage = (shift.currentSignups / shift.maxVolunteers) * 100;\nconst color = percentage < 70 ? 'success' : percentage < 90 ? 'warning' : 'exception';\n\n<Progress percent={percentage} status={color} />\n<Text type=\"secondary\">\n  {shift.currentSignups} of {shift.maxVolunteers} volunteers\n</Text>\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#4-signup-confirmation-modal","title":"4. Signup Confirmation Modal","text":"
<Modal\n  title=\"Confirm Signup\"\n  open={signupModalVisible}\n  onOk={handleSignup}\n  onCancel={() => setSignupModalVisible(false)}\n>\n  <Text>Are you sure you want to sign up for:</Text>\n  <div style={{ marginTop: 16, padding: 16, background: '#f5f5f5' }}>\n    <Text strong>{selectedShift?.title}</Text>\n    <br />\n    <Text>{dayjs(selectedShift?.date).format('MMMM D, YYYY')}</Text>\n    <br />\n    <Text>{selectedShift?.startTime} - {selectedShift?.endTime}</Text>\n  </div>\n</Modal>\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#5-cancel-signup-modal","title":"5. Cancel Signup Modal","text":"
<Modal\n  title=\"Cancel Signup\"\n  open={cancelModalVisible}\n  onOk={handleCancel}\n  onCancel={() => setCancelModalVisible(false)}\n  okText=\"Yes, Cancel Signup\"\n  okButtonProps={{ danger: true }}\n>\n  <Text>Are you sure you want to cancel your signup for this shift?</Text>\n  <Alert\n    type=\"warning\"\n    message=\"This action cannot be undone\"\n    style={{ marginTop: 16 }}\n  />\n</Modal>\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#state-management","title":"State Management","text":"
const [activeTab, setActiveTab] = useState<'upcoming' | 'signups'>('upcoming');\nconst [shifts, setShifts] = useState<Shift[]>([]);\nconst [mySignups, setMySignups] = useState<Shift[]>([]);\nconst [loading, setLoading] = useState(true);\nconst [signupModalVisible, setSignupModalVisible] = useState(false);\nconst [cancelModalVisible, setCancelModalVisible] = useState(false);\nconst [selectedShift, setSelectedShift] = useState<Shift | null>(null);\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#endpoints","title":"Endpoints","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#1-get-upcoming-shifts","title":"1. Get Upcoming Shifts","text":"
GET /api/map/shifts/upcoming\nAuthorization: Bearer {token}\n

Response:

[\n  {\n    \"id\": \"cm1abc123\",\n    \"title\": \"Weekend Canvass\",\n    \"date\": \"2025-02-15\",\n    \"startTime\": \"10:00\",\n    \"endTime\": \"14:00\",\n    \"location\": \"Campaign Office\",\n    \"maxVolunteers\": 10,\n    \"currentSignups\": 7,\n    \"cutId\": \"cm2cut123\",\n    \"cutName\": \"Downtown\",\n    \"isSignedUp\": false\n  }\n]\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#2-get-my-signups","title":"2. Get My Signups","text":"
GET /api/map/shifts/my-signups\nAuthorization: Bearer {token}\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#3-sign-up","title":"3. Sign Up","text":"
POST /api/map/shifts/:id/signup\nAuthorization: Bearer {token}\n

Response:

{\n  \"success\": true,\n  \"signupId\": \"cm3signup456\",\n  \"message\": \"Successfully signed up for shift\"\n}\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#4-cancel-signup","title":"4. Cancel Signup","text":"
DELETE /api/map/shifts/:id/cancel\nAuthorization: Bearer {token}\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#shift-card-upcoming-tab","title":"Shift Card (Upcoming Tab)","text":"
<Card hoverable={!shift.isSignedUp}>\n  {shift.isSignedUp && (\n    <Tag color=\"green\" style={{ position: 'absolute', top: 16, right: 16 }}>\n      Signed Up\n    </Tag>\n  )}\n\n  <Title level={4}>{shift.title}</Title>\n\n  <Space direction=\"vertical\" size={8} style={{ width: '100%', marginBottom: 16 }}>\n    <Text><CalendarOutlined /> {dayjs(shift.date).format('MMMM D, YYYY')}</Text>\n    <Text><ClockCircleOutlined /> {shift.startTime} - {shift.endTime}</Text>\n    <Text><EnvironmentOutlined /> {shift.location}</Text>\n    {shift.cutName && <Tag color=\"blue\">{shift.cutName}</Tag>}\n  </Space>\n\n  <Progress\n    percent={(shift.currentSignups / shift.maxVolunteers) * 100}\n    strokeColor={shift.currentSignups < shift.maxVolunteers ? '#52c41a' : '#f5222d'}\n    showInfo={false}\n    style={{ marginBottom: 16 }}\n  />\n\n  {shift.isSignedUp ? (\n    <Button\n      danger\n      block\n      onClick={() => {\n        setSelectedShift(shift);\n        setCancelModalVisible(true);\n      }}\n    >\n      Cancel Signup\n    </Button>\n  ) : (\n    <Button\n      type=\"primary\"\n      block\n      disabled={shift.currentSignups >= shift.maxVolunteers}\n      onClick={() => {\n        setSelectedShift(shift);\n        setSignupModalVisible(true);\n      }}\n    >\n      {shift.currentSignups >= shift.maxVolunteers ? 'Full' : 'Sign Up'}\n    </Button>\n  )}\n</Card>\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#my-signups-tab","title":"My Signups Tab","text":"
{activeTab === 'signups' && (\n  <>\n    {mySignups.length === 0 ? (\n      <Empty\n        description=\"You haven't signed up for any shifts yet\"\n        image={Empty.PRESENTED_IMAGE_SIMPLE}\n      />\n    ) : (\n      <Row gutter={[16, 16]}>\n        {mySignups.map(shift => (\n          <Col xs={24} sm={12} key={shift.id}>\n            <Card>\n              <Title level={4}>{shift.title}</Title>\n              {/* Shift details */}\n              <Button\n                danger\n                block\n                onClick={() => handleCancelClick(shift)}\n              >\n                Cancel Signup\n              </Button>\n            </Card>\n          </Col>\n        ))}\n      </Row>\n    )}\n  </>\n)}\n
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#performance-considerations","title":"Performance Considerations","text":"
  1. Parallel Fetches: Upcoming shifts and signups fetched simultaneously
  2. Optimistic Updates: Signup/cancel updates UI immediately
  3. Tab State: No refetch when switching tabs (cached)
  4. Debounced Modals: Prevent double-submission with loading state
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#responsive-design","title":"Responsive Design","text":"
  • Mobile: Single column cards (xs=24)
  • Tablet: Two column grid (sm=12)
  • Desktop: Two column grid maintained (not 3+ for readability)
  • Segmented Tabs: Full-width on mobile, auto-width on desktop
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#accessibility","title":"Accessibility","text":"
  • Tab Navigation: Segmented component keyboard accessible
  • Button Labels: Clear action labels (\"Sign Up\", \"Cancel Signup\")
  • Modal Focus: Auto-focus on OK button
  • Screen Reader: Empty states announce \"No shifts available\"
"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#issue-signed-up-badge-not-showing","title":"Issue: Signed Up Badge Not Showing","text":"

Cause: isSignedUp field not populated by API

Solution:

// Backend: Include isSignedUp in shift query\nconst shifts = await prisma.shift.findMany({\n  where: { date: { gte: new Date() } },\n  include: {\n    signups: {\n      where: { userId: req.user!.id },\n      select: { id: true }\n    }\n  }\n});\n\n// Map to include isSignedUp\nreturn shifts.map(shift => ({\n  ...shift,\n  isSignedUp: shift.signups.length > 0\n}));\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#issue-cancel-not-refreshing-list","title":"Issue: Cancel Not Refreshing List","text":"

Solution:

const handleCancel = async () => {\n  try {\n    await api.delete(`/api/map/shifts/${selectedShift.id}/cancel`);\n    message.success('Signup cancelled');\n\n    // Refresh both lists\n    const [upcomingRes, signupsRes] = await Promise.all([\n      api.get('/api/map/shifts/upcoming'),\n      api.get('/api/map/shifts/my-signups')\n    ]);\n\n    setShifts(upcomingRes.data);\n    setMySignups(signupsRes.data);\n\n  } catch (error) {\n    message.error('Failed to cancel signup');\n  } finally {\n    setCancelModalVisible(false);\n  }\n};\n

"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#related-documentation","title":"Related Documentation","text":"
  • Public Shifts Page
  • Admin Shifts Page
  • Volunteer Map Page
  • VolunteerLayout
"},{"location":"v2/getting-started/","title":"Getting Started with Changemaker Lite V2","text":"

Welcome to Changemaker Lite V2! This guide will help you get up and running quickly with your self-hosted political campaign platform.

"},{"location":"v2/getting-started/#what-is-changemaker-lite-v2","title":"What is Changemaker Lite V2?","text":"

Changemaker Lite V2 is a complete rebuild of the platform with a modern TypeScript stack, offering:

  • Email Advocacy Campaigns: Target elected representatives with automated email campaigns
  • Geographic Mapping: Manage locations, cuts (territories), and canvassing operations
  • Volunteer Management: Schedule shifts, track canvassing visits with GPS
  • Landing Page Builder: Create public-facing pages with GrapesJS editor
  • Newsletter Integration: Sync with Listmonk for email marketing
  • Media Library: Manage video content with public gallery
  • Comprehensive Monitoring: Prometheus + Grafana observability stack
"},{"location":"v2/getting-started/#prerequisites","title":"Prerequisites","text":"

Before you begin, ensure you have:

  • Linux server or macOS with Docker installed
  • Docker 20.10+ and Docker Compose 2.0+
  • 4GB RAM minimum (8GB recommended for monitoring stack)
  • 20GB disk space (more for media uploads)
  • Root or sudo access
  • Basic command line familiarity
"},{"location":"v2/getting-started/#optional-but-recommended","title":"Optional but Recommended","text":"
  • Domain name with DNS control (for production deployment)
  • SMTP server for email sending (or use MailHog for testing)
  • S3-compatible storage for backups (Backblaze B2, AWS S3, etc.)
"},{"location":"v2/getting-started/#quick-start-options","title":"Quick Start Options","text":"

Choose your path based on your needs:

"},{"location":"v2/getting-started/#option-1-quick-start-5-minutes","title":"Option 1: Quick Start (5 Minutes)","text":"

Get the platform running locally for evaluation and testing.

\u2192 Quick Start Guide

"},{"location":"v2/getting-started/#option-2-full-installation-30-minutes","title":"Option 2: Full Installation (30 Minutes)","text":"

Set up for production with custom configuration, monitoring, and backups.

\u2192 Full Installation Guide

"},{"location":"v2/getting-started/#option-3-local-development-45-minutes","title":"Option 3: Local Development (45 Minutes)","text":"

Set up a complete development environment with hot reload and debugging.

\u2192 Development Setup

"},{"location":"v2/getting-started/#architecture-overview","title":"Architecture Overview","text":"

Changemaker Lite V2 uses a microservices architecture:

graph LR\n    User[User Browser] --> Nginx[Nginx<br/>Reverse Proxy]\n    Nginx --> Admin[Admin GUI<br/>React]\n    Nginx --> ExpressAPI[Express API<br/>Main Features]\n    Nginx --> FastifyAPI[Fastify API<br/>Media Library]\n    ExpressAPI --> DB[(PostgreSQL)]\n    FastifyAPI --> DB\n    ExpressAPI --> Redis[(Redis)]\n    FastifyAPI --> Redis

Key Components:

  • Nginx: Routes requests to appropriate services based on subdomain
  • Admin GUI: React application (Vite + Ant Design) for platform management
  • Express API: Main backend with 14 feature modules (Prisma ORM)
  • Fastify API: Media library microservice (Drizzle ORM)
  • PostgreSQL: Primary database (Prisma + Drizzle schemas)
  • Redis: Caching, rate limiting, job queue backend

Learn more about the architecture \u2192

"},{"location":"v2/getting-started/#whats-next","title":"What's Next?","text":"

After installation, you'll want to:

  1. First Login - Access the admin interface and change default credentials
  2. Environment Configuration - Customize your .env file for your needs
  3. Docker Management - Learn to start, stop, and manage services
  4. Admin Guide - Platform administration workflows
"},{"location":"v2/getting-started/#common-installation-issues","title":"Common Installation Issues","text":"

If you encounter problems during setup, check our troubleshooting guides:

  • Docker Issues - Port conflicts, volume permissions
  • Database Issues - Connection errors, migrations
  • Common Errors - General troubleshooting
"},{"location":"v2/getting-started/#getting-help","title":"Getting Help","text":"
  • Documentation Search: Use the search bar above to find specific topics
  • FAQ: Check the Frequently Asked Questions
  • Issue Tracker: Report bugs or request features on GitHub
"},{"location":"v2/getting-started/#feature-highlights","title":"Feature Highlights","text":""},{"location":"v2/getting-started/#influence-module","title":"Influence Module","text":"

Run sophisticated email advocacy campaigns with: - Multi-target campaigns (MPs, MPPs, councillors) - Public response walls with moderation - Email queue with retry logic - Tracking and analytics

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

Coordinate field operations with: - Multi-provider geocoding (6 services) - Territory management (cuts) - GPS-tracked canvassing - Printable walk sheets with QR codes

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

Build custom public pages with: - GrapesJS drag-and-drop editor - MkDocs export for static sites - Mobile-responsive templates

"},{"location":"v2/getting-started/#monitoring","title":"Monitoring","text":"

Keep your platform healthy with: - Real-time metrics dashboards - Custom alerts - Service health monitoring - Data quality tracking

Explore all features \u2192

Ready to get started? Choose your installation path above!

"},{"location":"v2/getting-started/quick-start/","title":"Quick Start Guide","text":"

Get Changemaker Lite V2 running in 5 minutes with this streamlined guide.

For Evaluation Only

This quick start uses default credentials and minimal configuration. Do not use in production without following the Full Installation Guide and changing all default passwords.

"},{"location":"v2/getting-started/quick-start/#step-1-clone-the-repository","title":"Step 1: Clone the Repository","text":"
git clone <repo-url> changemaker.lite\ncd changemaker.lite\ngit checkout v2\n

Tip

If you're evaluating locally, you can skip the domain configuration and use localhost URLs.

"},{"location":"v2/getting-started/quick-start/#step-2-create-environment-file","title":"Step 2: Create Environment File","text":"
cp .env.example .env\n

Edit .env and set the minimum required variables:

# Database\nV2_POSTGRES_PASSWORD=your_secure_password_here\n\n# Redis\nREDIS_PASSWORD=another_secure_password\n\n# JWT Secrets (generate with: openssl rand -hex 32)\nJWT_ACCESS_SECRET=<your-access-secret>\nJWT_REFRESH_SECRET=<your-refresh-secret>\n\n# Encryption Key (must differ from JWT secrets)\nENCRYPTION_KEY=<your-encryption-key>\n\n# Email (use test mode for evaluation)\nEMAIL_TEST_MODE=true\n

Generate Secure Secrets

echo \"JWT_ACCESS_SECRET=$(openssl rand -hex 32)\"\necho \"JWT_REFRESH_SECRET=$(openssl rand -hex 32)\"\necho \"ENCRYPTION_KEY=$(openssl rand -hex 32)\"\n
"},{"location":"v2/getting-started/quick-start/#step-3-start-core-services","title":"Step 3: Start Core Services","text":"
# Start database and cache\ndocker compose up -d v2-postgres redis\n\n# Wait for database to be ready (about 10 seconds)\nsleep 10\n\n# Start API and admin\ndocker compose up -d api admin nginx\n
"},{"location":"v2/getting-started/quick-start/#step-4-run-database-migrations","title":"Step 4: Run Database Migrations","text":"
# Run Prisma migrations\ndocker compose exec api npx prisma migrate deploy\n\n# Seed initial data (creates admin user)\ndocker compose exec api npx prisma db seed\n

This creates: - Admin user: admin@example.com / Admin123! - Site settings: Default configuration - Page blocks: Landing page components

"},{"location":"v2/getting-started/quick-start/#step-5-access-the-platform","title":"Step 5: Access the Platform","text":"

Open your browser and navigate to:

  • Admin Interface: http://localhost:3000
  • API: http://localhost:4000
  • API Health Check: http://localhost:4000/health

Login with: - Email: admin@example.com - Password: Admin123!

Change Default Credentials

Immediately change the default admin password:

  1. Navigate to Settings in the sidebar
  2. Click your profile
  3. Change password to something secure (12+ chars, mixed case, numbers)
"},{"location":"v2/getting-started/quick-start/#step-6-verify-installation","title":"Step 6: Verify Installation","text":"

Check that all services are running:

docker compose ps\n

You should see: - v2-postgres - Database (port 5433) - redis-changemaker - Cache (port 6379) - api - Express API (port 4000) - admin - React admin (port 3000) - nginx - Reverse proxy (port 80)

Test the API:

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

Expected response:

{\n  \"status\": \"healthy\",\n  \"timestamp\": \"2026-02-11T18:00:00.000Z\"\n}\n

"},{"location":"v2/getting-started/quick-start/#whats-next","title":"What's Next?","text":"

Now that you have Changemaker Lite running:

  1. First Login - Tour the admin interface
  2. Environment Configuration - Customize your setup
  3. Create Your First Campaign - Run an advocacy campaign
  4. Import Locations - Set up your map
"},{"location":"v2/getting-started/quick-start/#optional-start-additional-services","title":"Optional: Start Additional Services","text":""},{"location":"v2/getting-started/quick-start/#email-testing-mailhog","title":"Email Testing (MailHog)","text":"

Capture emails in development without sending real messages:

docker compose up -d mailhog\n

Access at: http://localhost:8025

"},{"location":"v2/getting-started/quick-start/#data-browser-nocodb","title":"Data Browser (NocoDB)","text":"

Read-only database browser:

docker compose up -d nocodb-v2\n

Access at: http://localhost:8091

"},{"location":"v2/getting-started/quick-start/#newsletter-platform-listmonk","title":"Newsletter Platform (Listmonk)","text":"

Email marketing integration:

docker compose up -d listmonk-postgres listmonk listmonk-init\n

Access at: http://localhost:9001

"},{"location":"v2/getting-started/quick-start/#monitoring-stack","title":"Monitoring Stack","text":"

Prometheus + Grafana + Alertmanager:

docker compose --profile monitoring up -d\n

Access: - Grafana: http://localhost:3001 (admin/admin) - Prometheus: http://localhost:9090 - Alertmanager: http://localhost:9093

"},{"location":"v2/getting-started/quick-start/#common-issues","title":"Common Issues","text":""},{"location":"v2/getting-started/quick-start/#port-already-in-use","title":"Port Already in Use","text":"

If you see errors like port is already allocated:

# Check what's using the port\nsudo lsof -i :3000\n\n# Stop the conflicting service or change ports in .env\n
"},{"location":"v2/getting-started/quick-start/#database-connection-failed","title":"Database Connection Failed","text":"
# Check if PostgreSQL is running\ndocker compose ps v2-postgres\n\n# View logs\ndocker compose logs v2-postgres\n\n# Restart database\ndocker compose restart v2-postgres\n
"},{"location":"v2/getting-started/quick-start/#api-wont-start","title":"API Won't Start","text":"
# View API logs\ndocker compose logs api\n\n# Common fix: rebuild the container\ndocker compose build api\ndocker compose up -d api\n

See full troubleshooting guide \u2192

"},{"location":"v2/getting-started/quick-start/#stopping-services","title":"Stopping Services","text":"
# Stop all services\ndocker compose down\n\n# Stop and remove volumes (WARNING: deletes all data)\ndocker compose down -v\n
"},{"location":"v2/getting-started/quick-start/#next-steps-for-production","title":"Next Steps for Production","text":"

This quick start is for evaluation only. Before production deployment:

  1. Full Installation Guide - Production-ready setup
  2. Security Checklist - Harden your installation
  3. Backup Strategy - Protect your data
  4. Tunneling Setup - Public access via Pangolin
  5. Monitoring Configuration - Production observability

Congratulations! You now have Changemaker Lite V2 running locally. Explore the admin interface and check out the User Guides to learn what you can do.

"},{"location":"v2/migration/","title":"Migration Guide: V1 to V2 Overview","text":"

This comprehensive guide covers the complete migration process from Changemaker Lite V1 to V2, including architectural changes, data migration, and rollback procedures.

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

Changemaker Lite V2 is a complete rebuild of the platform, not an incremental upgrade. The migration represents a fundamental shift in architecture, technology stack, and approach to campaign management.

"},{"location":"v2/migration/#v1-vs-v2-at-a-glance","title":"V1 vs V2 at a Glance","text":"Aspect V1 V2 Architecture Two separate Express apps Single unified Express + Fastify API Data Layer NocoDB REST API Prisma ORM + PostgreSQL 16 Frontend Embedded EJS templates React SPA (Vite + Ant Design) Authentication Session cookies + bcrypt JWT tokens (access + refresh) API Style REST via NocoDB REST with Zod validation State Management Server-side sessions Zustand client state + JWT Job Queue Bull (Redis) BullMQ (Redis) Database NocoDB tables Prisma migrations Email Nodemailer + Bull BullMQ + Listmonk integration Ports 3333 (influence), 3000 (map) 4000 (API), 3000 (admin)"},{"location":"v2/migration/#why-migrate-to-v2","title":"Why Migrate to V2?","text":""},{"location":"v2/migration/#technical-benefits","title":"Technical Benefits","text":"
  1. Unified Codebase: Single API codebase instead of two separate applications
  2. Type Safety: Full TypeScript coverage with Prisma type generation
  3. Modern Stack: Latest React, Vite build tooling, Ant Design components
  4. Better Performance: Direct database access via Prisma vs REST API abstraction
  5. Improved Security: JWT refresh token rotation, RBAC, comprehensive audit trail
  6. Scalability: Separation of concerns (dual API architecture for media)
  7. Developer Experience: Hot reload, better tooling, comprehensive documentation
"},{"location":"v2/migration/#feature-enhancements","title":"Feature Enhancements","text":"
  1. New Features:
  2. Landing page builder with GrapesJS
  3. Email template system with versioning
  4. Media library with video uploads and reactions
  5. Volunteer canvassing system with GPS tracking
  6. Data quality dashboard for geocoding
  7. Comprehensive monitoring (Prometheus + Grafana)
  8. NAR 2025 electoral data import
  9. Pangolin tunnel integration

  10. Enhanced Existing Features:

  11. Response wall with upvoting and moderation
  12. Multi-provider geocoding (6 providers)
  13. Advanced shift management with cut assignments
  14. Printable walk sheets with QR codes
  15. Listmonk newsletter sync

  16. Improved Admin Experience:

  17. Modern React UI with consistent design
  18. Real-time updates with optimistic UI
  19. Advanced filtering and search
  20. Bulk operations
  21. Responsive mobile support
"},{"location":"v2/migration/#migration-timeline","title":"Migration Timeline","text":""},{"location":"v2/migration/#planned-phases-from-v2_planmd","title":"Planned Phases (from V2_PLAN.md)","text":"
  • Phase 1-14: \u2705 COMPLETE (Foundation through Monitoring)
  • Phase 15: \ud83d\udea7 Testing + Polish (current)
"},{"location":"v2/migration/#actual-development-timeline","title":"Actual Development Timeline","text":"
  • 2025-01: V2 rebuild initiated (clean-room approach)
  • 2025-02: Security audit completed, NAR import, media upload
  • 2026-02: Phase 14 complete, ready for production migration
"},{"location":"v2/migration/#migration-duration-estimate","title":"Migration Duration Estimate","text":"Migration Step Duration Downtime Required V1 data export 1-2 hours No Data transformation 2-4 hours No V2 database setup 30 minutes No V2 data import 1-3 hours No Testing & validation 2-4 hours No DNS/service switchover 15 minutes Yes Post-migration verification 1 hour No Total 8-15 hours 15 minutes

Minimize Downtime

Perform all data export, transformation, and testing on a separate V2 staging environment. Only switch production traffic after full validation.

"},{"location":"v2/migration/#risk-assessment","title":"Risk Assessment","text":""},{"location":"v2/migration/#high-risk-areas","title":"High Risk Areas","text":"
  1. Data Loss
  2. Risk: Campaign data, locations, or user accounts lost during migration
  3. Mitigation: Full V1 backup before migration, validation checksums, rollback plan
  4. Impact: High (business-critical data)

  5. Authentication Disruption

  6. Risk: Users unable to login after migration (password hash incompatibility)
  7. Mitigation: Test password migration with sample users, password reset flow ready
  8. Impact: High (blocks all access)

  9. Email Delivery Failure

  10. Risk: Campaign emails stop sending after migration
  11. Mitigation: Test SMTP configuration, BullMQ queue verification, MailHog testing
  12. Impact: High (core feature)
"},{"location":"v2/migration/#medium-risk-areas","title":"Medium Risk Areas","text":"
  1. Representative Data
  2. Risk: Cached representative data doesn't migrate correctly
  3. Mitigation: Cache can be rebuilt from Represent API, non-critical
  4. Impact: Medium (cacheable data)

  5. Location Geocoding

  6. Risk: Geocoded coordinates lost or corrupted
  7. Mitigation: V2 multi-provider geocoding can re-geocode, bulk geocode endpoint
  8. Impact: Medium (can be re-geocoded)

  9. Shift Signups

  10. Risk: Volunteer shift assignments lost
  11. Mitigation: Export signups separately, manual verification, confirmation emails
  12. Impact: Medium (time-sensitive data)
"},{"location":"v2/migration/#low-risk-areas","title":"Low Risk Areas","text":"
  1. Response Wall Data
  2. Risk: Public responses or upvotes lost
  3. Mitigation: CSV export, manual re-entry if needed
  4. Impact: Low (public-facing only)

  5. Custom Settings

  6. Risk: V1 settings don't map to V2 schema
  7. Mitigation: Manual reconfiguration in V2 SettingsPage
  8. Impact: Low (quick to reconfigure)
"},{"location":"v2/migration/#rollback-plan","title":"Rollback Plan","text":""},{"location":"v2/migration/#if-migration-fails","title":"If Migration Fails","text":"
  1. Immediate Actions (within 15 minutes):

    # Stop V2 services\ndocker compose down\n\n# Restore V1 services\ndocker compose -f docker-compose.v1.yml up -d\n\n# Restore DNS (point back to V1)\n# Update tunnel/proxy configuration\n

  2. Data Restoration (if V2 data was modified):

    # Restore V1 database from backup\ndocker compose -f docker-compose.v1.yml exec -T v1-postgres \\\n  psql -U nocodb nocodb < backups/v1-nocodb-backup.sql\n\n# Verify data integrity\ndocker compose -f docker-compose.v1.yml logs -f\n

  3. Verification:

  4. Test V1 login
  5. Verify campaign data visible
  6. Check location map loads
  7. Send test campaign email
  8. Verify response wall displays
"},{"location":"v2/migration/#rollback-window","title":"Rollback Window","text":"
  • First 24 hours: Simple rollback (V1 backup unchanged)
  • After 24 hours: Complex rollback (may need to merge V2 changes back to V1)
  • After 1 week: Rollback not recommended (significant V2 data divergence)

Rollback Deadline

Plan your migration with a clear rollback deadline. After this window, V2 becomes the source of truth.

"},{"location":"v2/migration/#support-resources","title":"Support Resources","text":""},{"location":"v2/migration/#documentation","title":"Documentation","text":"
  • Migration Docs:
  • Breaking Changes - Detailed V1\u2192V2 differences
  • Data Migration - Step-by-step data transfer
  • API Changes - Endpoint mapping table
  • Feature Parity - Feature comparison matrix

  • V2 Docs:

  • Getting Started - V2 installation
  • Architecture - System design
  • Deployment - Production setup
"},{"location":"v2/migration/#community-support","title":"Community & Support","text":"
  • GitHub Issues: Report bugs or migration problems
  • Discussions: Ask questions, share migration experiences
  • Email: support@cmlite.org for direct assistance
"},{"location":"v2/migration/#professional-services","title":"Professional Services","text":"

For organizations requiring: - Custom data migration scripts - Zero-downtime migration - Training for administrators - Priority support during migration

Contact: enterprise@cmlite.org

"},{"location":"v2/migration/#prerequisites","title":"Prerequisites","text":"

Before beginning migration, ensure you have:

"},{"location":"v2/migration/#v1-environment","title":"V1 Environment","text":"
  • V1 backup completed (database + uploads)
  • V1 environment variables documented (.env file)
  • V1 access credentials (NocoDB admin, database passwords)
  • V1 running and healthy (all services operational)
  • V1 data export tested (able to export NocoDB tables)
"},{"location":"v2/migration/#v2-environment","title":"V2 Environment","text":"
  • V2 repository cloned (git checkout v2)
  • Docker and Docker Compose installed (20.10+, 2.0+)
  • PostgreSQL 16 compatible (for V2 database)
  • 4GB+ RAM available (8GB recommended)
  • 20GB+ disk space (for database + uploads)
"},{"location":"v2/migration/#migration-planning","title":"Migration Planning","text":"
  • Downtime window scheduled (notify users)
  • Rollback plan reviewed (tested on staging)
  • Team assigned (minimum 2 people recommended)
  • Backup storage ready (S3 bucket or local storage)
  • Testing checklist prepared (critical workflows to verify)
"},{"location":"v2/migration/#migration-steps-overview","title":"Migration Steps Overview","text":"

This is a high-level overview. Detailed steps are in Data Migration.

"},{"location":"v2/migration/#phase-1-preparation-no-downtime","title":"Phase 1: Preparation (No Downtime)","text":"
  1. Export V1 Data

    # Export all NocoDB tables to JSON\n./scripts/export-v1-data.sh\n\n# Backup file uploads\ntar -czf v1-uploads.tar.gz ./uploads/\n

  2. Set Up V2 Environment

    git checkout v2\ncp .env.example .env\n# Edit .env with V2 configuration\n

  3. Start V2 Services (parallel to V1)

    docker compose up -d v2-postgres redis\ndocker compose exec api npx prisma migrate deploy\n

"},{"location":"v2/migration/#phase-2-data-transformation-no-downtime","title":"Phase 2: Data Transformation (No Downtime)","text":"
  1. Transform V1 Data for V2

    # Run transformation scripts\nnode scripts/transform-users.js\nnode scripts/transform-campaigns.js\nnode scripts/transform-locations.js\n

  2. Import into V2 Database

    # Import transformed data\ndocker compose exec api node scripts/import-data.js\n

  3. Validate Data Integrity

    # Compare record counts\ndocker compose exec api node scripts/validate-migration.js\n

"},{"location":"v2/migration/#phase-3-testing-no-downtime","title":"Phase 3: Testing (No Downtime)","text":"
  1. Test V2 Functionality
  2. Login with test users (verify password migration)
  3. View campaigns, locations, shifts
  4. Submit test response
  5. Send test email
  6. Check admin permissions

  7. Performance Testing

  8. Load campaigns page (check query performance)
  9. Geocode sample addresses
  10. Test map rendering with all locations
  11. Verify Redis caching
"},{"location":"v2/migration/#phase-4-switchover-15-minutes-downtime","title":"Phase 4: Switchover (15 Minutes Downtime)","text":"
  1. Enable Maintenance Mode (V1)

    # Stop V1 services\ndocker compose -f docker-compose.v1.yml down\n

  2. Start V2 Services

    # Start all V2 services\ndocker compose up -d\n

  3. Update DNS/Proxy

    • Point cmlite.org to V2 nginx
    • Update Pangolin tunnel endpoint
    • Verify SSL certificates
"},{"location":"v2/migration/#phase-5-verification-post-migration","title":"Phase 5: Verification (Post-Migration)","text":"
  1. Smoke Tests

    • Admin login works
    • Campaign list loads
    • Location map renders
    • Email sending functional
    • Response wall displays
  2. Monitor for Issues

    # Watch logs for errors\ndocker compose logs -f api admin\n\n# Check metrics\nopen http://localhost:3001  # Grafana\n

  3. Announce Migration Complete

    • Email all users with V2 login URL
    • Update documentation links
    • Monitor support channels
"},{"location":"v2/migration/#post-migration-checklist","title":"Post-Migration Checklist","text":"

After successful migration, complete these tasks:

"},{"location":"v2/migration/#immediate-day-1","title":"Immediate (Day 1)","text":"
  • Verify all user accounts can login
  • Test campaign email sending (real SMTP, not MailHog)
  • Confirm location geocoding works
  • Check shift signup flow (public)
  • Verify response wall displays correctly
  • Test admin CRUD operations (create campaign, location, shift)
  • Monitor error logs for exceptions
  • Verify Prometheus metrics collecting
"},{"location":"v2/migration/#first-week","title":"First Week","text":"
  • Review Grafana dashboards for anomalies
  • Check BullMQ job queue (no stuck jobs)
  • Verify geocoding cache hit rate
  • Test all user roles (SUPER_ADMIN, MAP_ADMIN, etc.)
  • Confirm Listmonk sync working (if enabled)
  • Validate backup script runs successfully
  • Review user feedback and support tickets
"},{"location":"v2/migration/#first-month","title":"First Month","text":"
  • Optimize slow queries (check Prometheus API duration metrics)
  • Review disk usage (PostgreSQL, uploads, logs)
  • Audit user permissions (remove temp accounts)
  • Update documentation based on issues encountered
  • Train administrators on new V2 features
  • Plan rollout of new features (landing pages, canvassing)
  • Schedule security audit
"},{"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":"
  • Migration Duration: 4-6 hours
  • Downtime: 10 minutes
  • Recommended Approach:
  • Export V1 data Friday evening
  • Transform and import over weekend
  • Test Saturday/Sunday
  • Switchover Monday morning
  • Rollback window: 48 hours
"},{"location":"v2/migration/#scenario-2-medium-organization-1000-10000-locations","title":"Scenario 2: Medium Organization (1000-10000 locations)","text":"
  • Migration Duration: 8-12 hours
  • Downtime: 15 minutes
  • Recommended Approach:
  • Set up V2 staging environment 1 week prior
  • Perform test migration on staging
  • Document issues and solutions
  • Schedule production migration for low-traffic period
  • Rollback window: 24 hours
"},{"location":"v2/migration/#scenario-3-large-organization-10000-locations","title":"Scenario 3: Large Organization (10000+ locations)","text":"
  • Migration Duration: 12-20 hours
  • Downtime: 20-30 minutes
  • Recommended Approach:
  • Hire professional services (enterprise@cmlite.org)
  • Perform multiple test migrations on staging
  • Use incremental data sync (minimize final catchup)
  • Blue-green deployment (parallel V1/V2 for 1 week)
  • Rollback window: 1 week with data sync
"},{"location":"v2/migration/#scenario-4-active-campaign-during-migration","title":"Scenario 4: Active Campaign During Migration","text":"

Problem: Can't afford downtime during critical campaign period.

Solution: 1. Set up V2 as read-only mirror (import V1 data, disable writes) 2. Continue using V1 for all active operations 3. Schedule final catchup migration after campaign concludes 4. Or: Use blue-green deployment with manual data sync

Active Campaign Warning

Do NOT migrate during active campaign periods. Schedule migration between campaigns or during organizational downtime.

"},{"location":"v2/migration/#migration-validation-checklist","title":"Migration Validation Checklist","text":"

Use this checklist to verify successful migration:

"},{"location":"v2/migration/#data-integrity","title":"Data Integrity","text":"
  • User count matches: V1 users = V2 users (excluding duplicates)
  • Campaign count matches: V1 campaigns = V2 campaigns
  • Location count matches: V1 locations = V2 locations
  • Shift count matches: V1 shifts = V2 shifts
  • Response count matches: V1 responses = V2 responses
  • Representative cache count: V1 reps = V2 reps (approximate, can refresh)
"},{"location":"v2/migration/#functional-testing","title":"Functional Testing","text":"
  • Login works: Test with 5 different user accounts
  • Password authentication: All migrated passwords validate correctly
  • Campaign email sends: Queue job, verify SMTP delivery
  • Representative lookup: Postal code returns correct reps
  • Location geocoding: Bulk geocode 10 addresses successfully
  • Map rendering: All locations display on map
  • Shift signup: Public user can sign up for shift
  • Response submission: Can submit and view responses
  • Admin CRUD: Create, edit, delete test records
"},{"location":"v2/migration/#performance-testing","title":"Performance Testing","text":"
  • Campaign list loads < 2 seconds: 100+ campaigns
  • Location map loads < 3 seconds: 1000+ locations
  • Search response time < 500ms: User, campaign, location search
  • Geocoding batch < 30 seconds: 100 addresses
  • Email queue processing: 10 emails/minute minimum
  • No N+1 queries: Check Prisma logs for query count
"},{"location":"v2/migration/#security-testing","title":"Security Testing","text":"
  • JWT authentication works: Access + refresh token flow
  • RBAC enforced: SUPER_ADMIN vs USER vs TEMP roles
  • Rate limiting active: Auth endpoints limited to 10/min
  • Password policy enforced: 12+ chars, complexity requirements
  • Redis authenticated: Connection requires password
  • Encryption key set: ENCRYPTION_KEY env var different from JWT secrets
"},{"location":"v2/migration/#troubleshooting-migration-issues","title":"Troubleshooting Migration Issues","text":"

Common problems and solutions:

"},{"location":"v2/migration/#issue-user-login-fails-after-migration","title":"Issue: User Login Fails After Migration","text":"

Symptoms: Users receive \"Invalid credentials\" error despite correct password.

Causes: - Bcrypt hash corruption during export/import - Password field length truncation - Character encoding issues

Solutions:

# Check password hash format in V2\ndocker compose exec api npx prisma studio\n# User table \u2192 password field should start with $2b$\n\n# Reset affected user password\ndocker compose exec api node scripts/reset-password.js user@example.com\n

"},{"location":"v2/migration/#issue-missing-data-after-import","title":"Issue: Missing Data After Import","text":"

Symptoms: User count, campaign count, or location count lower than V1.

Causes: - Incomplete V1 export (pagination issues) - Transformation script errors (check logs) - Unique constraint violations (duplicates skipped)

Solutions:

# Compare record counts\ndocker compose exec api node scripts/compare-counts.js\n\n# Re-run import for specific table\ndocker compose exec api node scripts/import-data.js --table=users\n\n# Check import logs for errors\ndocker compose logs api | grep ERROR\n

"},{"location":"v2/migration/#issue-geocoding-data-lost","title":"Issue: Geocoding Data Lost","text":"

Symptoms: Locations missing latitude/longitude coordinates.

Causes: - V1 geocoding provider different from V2 - Coordinates not exported from V1 - Transformation script didn't map geocoding fields

Solutions:

# Bulk re-geocode all locations\ncurl -X POST http://localhost:4000/api/map/locations/bulk-geocode \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\"\n\n# Check geocoding provider configuration\ndocker compose exec api node scripts/test-geocoding.js\n

"},{"location":"v2/migration/#issue-campaign-emails-not-sending","title":"Issue: Campaign Emails Not Sending","text":"

Symptoms: BullMQ queue shows \"failed\" jobs.

Causes: - SMTP configuration incorrect - EMAIL_TEST_MODE still enabled (sends to MailHog) - Nodemailer authentication failure

Solutions:

# Check SMTP configuration\ndocker compose exec api node scripts/test-smtp.js\n\n# View failed job details\n# Visit http://localhost:4000/api/influence/email-queue/stats\n\n# Retry failed jobs\ncurl -X POST http://localhost:4000/api/influence/email-queue/retry-failed \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\"\n

"},{"location":"v2/migration/#issue-high-memory-usage-after-migration","title":"Issue: High Memory Usage After Migration","text":"

Symptoms: V2 services consuming > 4GB RAM, slow response times.

Causes: - Prisma connection pool too large - Redis cache not evicting old entries - Large JSON fields in database (campaign data, page blocks)

Solutions:

# Reduce Prisma connection pool\n# Edit .env: DATABASE_URL=\"...?connection_limit=5\"\n\n# Clear Redis cache\ndocker compose exec redis redis-cli FLUSHDB\n\n# Optimize database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"VACUUM ANALYZE;\"\n

"},{"location":"v2/migration/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/migration/#migration-guides","title":"Migration Guides","text":"
  • Breaking Changes - Comprehensive V1\u2192V2 differences
  • Data Migration - Detailed migration procedures
  • API Changes - Endpoint reference mapping
  • Feature Parity - Feature comparison matrix
"},{"location":"v2/migration/#v2-setup-guides","title":"V2 Setup Guides","text":"
  • Quick Start - 5-minute V2 setup
  • Production Deployment - Production configuration
  • Environment Variables - .env reference
"},{"location":"v2/migration/#v2-architecture","title":"V2 Architecture","text":"
  • Architecture Overview - System design
  • Dual API Design - Express + Fastify
  • Authentication - JWT flow
  • Database Schema - Prisma models
"},{"location":"v2/migration/#post-migration","title":"Post-Migration","text":"
  • Admin Guide - Platform administration
  • Monitoring - Prometheus + Grafana
  • Backups - Backup procedures
  • Troubleshooting - Common issues
"},{"location":"v2/migration/#next-steps","title":"Next Steps","text":"

Ready to begin migration?

  1. Review Breaking Changes - Understand all V1\u2192V2 differences
  2. Plan Data Migration - Create migration timeline
  3. Set Up V2 Staging - Test environment
  4. Perform Test Migration - Validate process
  5. Execute Production Migration - Go live

Migration Support

Need help with your migration? Email support@cmlite.org or open a GitHub discussion.

"},{"location":"v2/migration/api-changes/","title":"API Endpoint Changes","text":"

This document provides a comprehensive mapping of V1 API endpoints to their V2 equivalents, including request/response format changes, authentication differences, and code migration examples.

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

V2 API represents a complete redesign with:

  • RESTful conventions (proper HTTP methods)
  • Unified namespace (single API at /api/*)
  • JWT authentication (Bearer tokens instead of sessions)
  • Zod validation (type-safe request validation)
  • Standardized responses ({ success, data, pagination } structure)

Migration Strategy

Update frontend API calls incrementally, starting with authentication (foundational), then module by module (campaigns, locations, shifts, etc.).

"},{"location":"v2/migration/api-changes/#authentication-changes","title":"Authentication Changes","text":""},{"location":"v2/migration/api-changes/#v1-authentication-session-cookies","title":"V1 Authentication (Session Cookies)","text":"

V1 Login:

// POST /auth/login\nfetch('http://localhost:3333/auth/login', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  credentials: 'include', // Send/receive cookies\n  body: JSON.stringify({\n    email: 'admin@example.com',\n    password: 'password123'\n  })\n});\n\n// Response: 302 Redirect to /dashboard\n// Session cookie set automatically\n\n// Subsequent requests\nfetch('http://localhost:3333/campaigns', {\n  credentials: 'include' // Sends session cookie\n});\n

"},{"location":"v2/migration/api-changes/#v2-authentication-jwt-bearer-tokens","title":"V2 Authentication (JWT Bearer Tokens)","text":"

V2 Login:

// POST /api/auth/login\nconst response = await fetch('http://localhost:4000/api/auth/login', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  body: JSON.stringify({\n    email: 'admin@example.com',\n    password: 'Admin123!'\n  })\n});\n\nconst data = await response.json();\n\n// Response:\n// {\n//   \"success\": true,\n//   \"data\": {\n//     \"user\": {\n//       \"id\": \"clx1a2b3c4d5e6f7g8h9i\",\n//       \"email\": \"admin@example.com\",\n//       \"name\": \"Admin User\",\n//       \"role\": \"SUPER_ADMIN\"\n//     },\n//     \"accessToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n//     \"refreshToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n//   }\n// }\n\n// Store tokens (localStorage, sessionStorage, or memory)\nlocalStorage.setItem('accessToken', data.data.accessToken);\nlocalStorage.setItem('refreshToken', data.data.refreshToken);\n\n// Subsequent requests\nfetch('http://localhost:4000/api/influence/campaigns', {\n  headers: {\n    'Authorization': `Bearer ${localStorage.getItem('accessToken')}`\n  }\n});\n

V2 Token Refresh:

// POST /api/auth/refresh\nconst response = await fetch('http://localhost:4000/api/auth/refresh', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  body: JSON.stringify({\n    refreshToken: localStorage.getItem('refreshToken')\n  })\n});\n\nconst data = await response.json();\n\n// Response:\n// {\n//   \"success\": true,\n//   \"data\": {\n//     \"accessToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n//     \"refreshToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\" // New token (rotation)\n//   }\n// }\n\n// Update stored tokens\nlocalStorage.setItem('accessToken', data.data.accessToken);\nlocalStorage.setItem('refreshToken', data.data.refreshToken);\n

"},{"location":"v2/migration/api-changes/#authentication-endpoint-mapping","title":"Authentication Endpoint Mapping","text":"V1 Endpoint V2 Endpoint Method Changes /auth/login /api/auth/login POST Returns JWT tokens instead of setting cookie /auth/logout /api/auth/logout POST Requires refreshToken in body /auth/register /api/auth/register POST Always creates USER role (no role in request) /auth/me /api/auth/me GET Returns 401 if invalid (not 404) - /api/auth/refresh POST New: refresh token rotation"},{"location":"v2/migration/api-changes/#influence-module-api","title":"Influence Module API","text":""},{"location":"v2/migration/api-changes/#campaigns","title":"Campaigns","text":""},{"location":"v2/migration/api-changes/#v1-campaign-endpoints","title":"V1 Campaign Endpoints","text":"
// List campaigns\nGET /campaigns\nQuery: ?page=1\n\n// View campaign\nGET /campaigns/:id\n\n// Create campaign (admin)\nPOST /campaigns/create\nBody: { Title, Description, Slug, IsActive }\n\n// Update campaign (admin)\nPOST /campaigns/:id/edit\nBody: { Title, Description, Slug, IsActive }\n\n// Delete campaign (admin)\nPOST /campaigns/:id/delete\n
"},{"location":"v2/migration/api-changes/#v2-campaign-endpoints","title":"V2 Campaign Endpoints","text":"
// List campaigns\nGET /api/influence/campaigns\nQuery: ?page=1&limit=20&search=query&active=true&highlighted=false\nAuth: Optional (public returns only active campaigns)\n\n// Get campaign by ID\nGET /api/influence/campaigns/:id\nAuth: Required (admin)\n\n// Get campaign by slug (public)\nGET /api/influence/campaigns/public/:slug\nAuth: None\n\n// Create campaign\nPOST /api/influence/campaigns\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nBody: {\n  \"title\": \"Save the Trees\",\n  \"description\": \"Campaign description\",\n  \"slug\": \"save-the-trees\",\n  \"active\": true,\n  \"highlighted\": false,\n  \"targetLevel\": \"federal\",\n  \"targetPosition\": \"MP\",\n  \"responseWallEnabled\": true\n}\n\n// Update campaign\nPUT /api/influence/campaigns/:id\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nBody: { title, description, ... } // Partial update\n\n// Delete campaign\nDELETE /api/influence/campaigns/:id\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Toggle active status\nPATCH /api/influence/campaigns/:id/toggle-active\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Toggle highlighted status\nPATCH /api/influence/campaigns/:id/toggle-highlighted\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n
"},{"location":"v2/migration/api-changes/#campaign-response-format-changes","title":"Campaign Response Format Changes","text":"

V1 Response:

{\n  \"list\": [\n    {\n      \"Id\": 1,\n      \"Title\": \"Save the Trees\",\n      \"Description\": \"Campaign description\",\n      \"Slug\": \"save-the-trees\",\n      \"IsActive\": true,\n      \"Created\": \"2024-01-15T10:30:00Z\"\n    }\n  ],\n  \"pageInfo\": {\n    \"totalRows\": 100,\n    \"page\": 1,\n    \"pageSize\": 20\n  }\n}\n

V2 Response:

{\n  \"success\": true,\n  \"data\": [\n    {\n      \"id\": \"clx1a2b3c4d5e6f7g8h9i\",\n      \"title\": \"Save the Trees\",\n      \"description\": \"Campaign description\",\n      \"slug\": \"save-the-trees\",\n      \"active\": true,\n      \"highlighted\": false,\n      \"targetLevel\": \"federal\",\n      \"targetPosition\": \"MP\",\n      \"responseWallEnabled\": true,\n      \"createdAt\": \"2024-01-15T10:30:00.000Z\",\n      \"updatedAt\": \"2024-01-15T10:30:00.000Z\",\n      \"createdBy\": {\n        \"id\": \"clx1a2b3c4d5e6f7g8h9i\",\n        \"name\": \"Admin User\",\n        \"email\": \"admin@example.com\"\n      }\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 100,\n    \"totalPages\": 5\n  }\n}\n

"},{"location":"v2/migration/api-changes/#representatives","title":"Representatives","text":""},{"location":"v2/migration/api-changes/#v1-representative-endpoints","title":"V1 Representative Endpoints","text":"
// Lookup representatives by postal code\nPOST /representatives/lookup\nBody: { postalCode: \"M5V 1A1\" }\n\n// List cached representatives (admin)\nGET /admin/representatives\n
"},{"location":"v2/migration/api-changes/#v2-representative-endpoints","title":"V2 Representative Endpoints","text":"
// Lookup representatives (public)\nPOST /api/influence/representatives/lookup\nAuth: None\nBody: { \"postalCode\": \"M5V1A1\" }\nResponse: {\n  \"success\": true,\n  \"data\": [\n    {\n      \"name\": \"John Doe\",\n      \"email\": \"john.doe@parl.gc.ca\",\n      \"district\": \"Toronto Centre\",\n      \"party\": \"Liberal\",\n      \"level\": \"federal\",\n      \"photoUrl\": \"https://...\"\n    }\n  ]\n}\n\n// List cached representatives (admin)\nGET /api/influence/representatives\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nQuery: ?page=1&limit=20&level=federal&party=Liberal&search=John\n\n// Get representative stats (admin)\nGET /api/influence/representatives/stats\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nResponse: {\n  \"success\": true,\n  \"data\": {\n    \"total\": 338,\n    \"byLevel\": { \"federal\": 338, \"provincial\": 124 },\n    \"byParty\": { \"Liberal\": 159, \"Conservative\": 119, \"NDP\": 25 }\n  }\n}\n\n// Get representative by ID (admin)\nGET /api/influence/representatives/:id\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Delete representative (admin)\nDELETE /api/influence/representatives/:id\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Health check\nGET /api/influence/representatives/health\nAuth: None\n
"},{"location":"v2/migration/api-changes/#campaign-emails","title":"Campaign Emails","text":""},{"location":"v2/migration/api-changes/#v1-email-endpoints","title":"V1 Email Endpoints","text":"
// Send campaign email\nPOST /campaigns/:id/send-email\nBody: { senderName, senderEmail, postalCode }\n
"},{"location":"v2/migration/api-changes/#v2-email-endpoints","title":"V2 Email Endpoints","text":"
// Send campaign email (public)\nPOST /api/influence/campaign-emails/send-email\nAuth: None\nRate Limit: 30 requests/hour per IP\nBody: {\n  \"campaignId\": \"clx1a2b3c4d5e6f7g8h9i\",\n  \"postalCode\": \"M5V1A1\",\n  \"senderName\": \"Jane Doe\",\n  \"senderEmail\": \"jane@example.com\",\n  \"customMessage\": \"Optional custom message\"\n}\n\n// Track mailto clicks (public)\nGET /api/influence/campaign-emails/track-mailto/:emailId\nAuth: None\n\n// List campaign emails (admin)\nGET /api/influence/campaign-emails\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nQuery: ?campaignId=xxx&page=1&limit=20&sortBy=createdAt&sortOrder=desc\n\n// Get campaign email stats (admin)\nGET /api/influence/campaign-emails/stats/:campaignId\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nResponse: {\n  \"success\": true,\n  \"data\": {\n    \"totalEmails\": 1234,\n    \"queuedEmails\": 5,\n    \"sentEmails\": 1200,\n    \"failedEmails\": 29,\n    \"mailtoClicks\": 340\n  }\n}\n
"},{"location":"v2/migration/api-changes/#email-queue","title":"Email Queue","text":""},{"location":"v2/migration/api-changes/#v2-email-queue-endpoints-new","title":"V2 Email Queue Endpoints (New)","text":"
// Get queue stats (admin)\nGET /api/influence/email-queue/stats\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nResponse: {\n  \"success\": true,\n  \"data\": {\n    \"waiting\": 10,\n    \"active\": 2,\n    \"completed\": 5000,\n    \"failed\": 15,\n    \"delayed\": 0,\n    \"paused\": false\n  }\n}\n\n// Pause queue (admin)\nPOST /api/influence/email-queue/pause\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Resume queue (admin)\nPOST /api/influence/email-queue/resume\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Clean completed jobs (admin)\nPOST /api/influence/email-queue/clean\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nQuery: ?grace=3600 (seconds)\n\n// Retry failed jobs (admin)\nPOST /api/influence/email-queue/retry-failed\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n
"},{"location":"v2/migration/api-changes/#response-wall","title":"Response Wall","text":""},{"location":"v2/migration/api-changes/#v1-response-endpoints","title":"V1 Response Endpoints","text":"
// Submit response\nPOST /responses/submit\nBody: { campaignId, name, email, message }\n\n// List responses\nGET /responses/:campaignId\n
"},{"location":"v2/migration/api-changes/#v2-response-endpoints","title":"V2 Response Endpoints","text":"
// Submit response (public)\nPOST /api/influence/responses/submit\nAuth: None\nBody: {\n  \"campaignId\": \"clx1a2b3c4d5e6f7g8h9i\",\n  \"name\": \"Jane Doe\",\n  \"email\": \"jane@example.com\",\n  \"message\": \"I support this campaign!\",\n  \"ipAddress\": \"192.168.1.1\" // Auto-captured by server\n}\n// Sends verification email\n\n// Verify response email\nGET /api/influence/responses/verify/:token\nAuth: None\n\n// List responses (public)\nGET /api/influence/responses/campaign/:campaignId\nAuth: None\nQuery: ?page=1&limit=20&sortBy=upvotes&sortOrder=desc\nResponse: Only returns APPROVED responses\n\n// Upvote response (public)\nPOST /api/influence/responses/:id/upvote\nAuth: Optional (tracks by IP + userId if logged in)\nBody: { \"ipAddress\": \"192.168.1.1\" }\n\n// List responses (admin)\nGET /api/influence/responses\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nQuery: ?page=1&limit=20&campaignId=xxx&status=PENDING&sortBy=createdAt&sortOrder=desc\n\n// Get response detail (admin)\nGET /api/influence/responses/:id\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Approve response (admin)\nPATCH /api/influence/responses/:id/approve\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Reject response (admin)\nPATCH /api/influence/responses/:id/reject\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Delete response (admin)\nDELETE /api/influence/responses/:id\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n
"},{"location":"v2/migration/api-changes/#map-module-api","title":"Map Module API","text":""},{"location":"v2/migration/api-changes/#locations","title":"Locations","text":""},{"location":"v2/migration/api-changes/#v1-location-endpoints","title":"V1 Location Endpoints","text":"
// List locations\nGET /locations\nQuery: ?page=1\n\n// Create location (admin)\nPOST /locations/create\nBody: { Address, Latitude, Longitude, SupportLevel, Notes }\n\n// Update location (admin)\nPOST /locations/:id/edit\nBody: { Address, Latitude, Longitude, SupportLevel, Notes }\n\n// Delete location (admin)\nPOST /locations/:id/delete\n
"},{"location":"v2/migration/api-changes/#v2-location-endpoints","title":"V2 Location Endpoints","text":"
// List locations (admin)\nGET /api/map/locations\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?page=1&limit=20&search=query&supportLevel=SUPPORT&cutId=xxx&geocoded=true\n\n// List locations (public map)\nGET /api/map/locations/public\nAuth: None\nQuery: ?bounds=minLat,minLng,maxLat,maxLng (returns only geocoded locations)\n\n// Get location by ID (admin)\nGET /api/map/locations/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Create location (admin)\nPOST /api/map/locations\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: {\n  \"address\": \"123 Main St\",\n  \"city\": \"Toronto\",\n  \"province\": \"ON\",\n  \"postalCode\": \"M5V1A1\",\n  \"country\": \"Canada\",\n  \"latitude\": 43.6532,\n  \"longitude\": -79.3832,\n  \"supportLevel\": \"SUPPORT\",\n  \"notes\": \"Spoke with resident\",\n  \"contactName\": \"John Doe\",\n  \"contactPhone\": \"416-555-1234\",\n  \"contactEmail\": \"john@example.com\",\n  \"cutId\": \"clx1a2b3c4d5e6f7g8h9i\"\n}\n\n// Update location (admin)\nPUT /api/map/locations/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: { address, city, ... } // Partial update\n\n// Delete location (admin)\nDELETE /api/map/locations/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Bulk delete locations (admin)\nPOST /api/map/locations/bulk-delete\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: { \"ids\": [\"id1\", \"id2\", \"id3\"] }\n\n// Export locations CSV (admin)\nGET /api/map/locations/export\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?supportLevel=SUPPORT&cutId=xxx\n\n// Import locations CSV (admin)\nPOST /api/map/locations/import\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nContent-Type: multipart/form-data\nBody: FormData with CSV file\n\n// Geocode location (admin)\nPOST /api/map/locations/:id/geocode\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?provider=nominatim (optional)\n\n// Bulk geocode (admin)\nPOST /api/map/locations/bulk-geocode\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?limit=100&provider=nominatim\n\n// Reverse geocode (admin)\nPOST /api/map/locations/reverse-geocode\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: { \"latitude\": 43.6532, \"longitude\": -79.3832 }\n\n// Get location stats (admin)\nGET /api/map/locations/stats\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nResponse: {\n  \"success\": true,\n  \"data\": {\n    \"total\": 10000,\n    \"geocoded\": 9500,\n    \"notGeocoded\": 500,\n    \"bySupportLevel\": {\n      \"STRONG_SUPPORT\": 1200,\n      \"SUPPORT\": 3400,\n      \"UNDECIDED\": 2100,\n      \"OPPOSED\": 1800,\n      \"STRONG_OPPOSED\": 800,\n      \"UNKNOWN\": 700\n    }\n  }\n}\n\n// NAR Import (admin, new in V2)\nGET /api/map/locations/nar/datasets\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nResponse: List of available NAR datasets (provinces)\n\nPOST /api/map/locations/nar/import\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: {\n  \"province\": \"24\",\n  \"cityFilter\": \"Toronto\",\n  \"postalCodeFilter\": \"M5V\",\n  \"cutId\": \"clx1a2b3c4d5e6f7g8h9i\",\n  \"residentialOnly\": true\n}\n
"},{"location":"v2/migration/api-changes/#cuts-territories","title":"Cuts (Territories)","text":""},{"location":"v2/migration/api-changes/#v1-cut-endpoints","title":"V1 Cut Endpoints","text":"
// List cuts (admin)\nGET /admin/cuts\n\n// Create cut (admin)\nPOST /admin/cuts/create\nBody: { Name, GeoJSON }\n
"},{"location":"v2/migration/api-changes/#v2-cut-endpoints","title":"V2 Cut Endpoints","text":"
// List cuts (admin)\nGET /api/map/cuts\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?page=1&limit=20&search=query\n\n// List cuts (public map)\nGET /api/map/cuts/public\nAuth: None\nResponse: Only returns active cuts with GeoJSON\n\n// Get cut by ID (admin)\nGET /api/map/cuts/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Create cut (admin)\nPOST /api/map/cuts\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: {\n  \"name\": \"Downtown Toronto\",\n  \"description\": \"Downtown canvassing area\",\n  \"color\": \"#FF5733\",\n  \"coordinates\": [[[-79.4, 43.6], [-79.3, 43.6], ...]]\n}\n\n// Update cut (admin)\nPUT /api/map/cuts/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: { name, description, color, coordinates }\n\n// Delete cut (admin)\nDELETE /api/map/cuts/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Get locations in cut (admin)\nGET /api/map/cuts/:id/locations\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?page=1&limit=20\n
"},{"location":"v2/migration/api-changes/#shifts","title":"Shifts","text":""},{"location":"v2/migration/api-changes/#v1-shift-endpoints","title":"V1 Shift Endpoints","text":"
// List shifts\nGET /shifts\n\n// Create shift (admin)\nPOST /shifts/create\nBody: { Name, StartTime, EndTime, Location, Capacity }\n\n// Signup for shift\nPOST /shifts/:id/signup\nBody: { name, email, phone }\n
"},{"location":"v2/migration/api-changes/#v2-shift-endpoints","title":"V2 Shift Endpoints","text":"
// List shifts (public)\nGET /api/map/shifts/public\nAuth: None\nQuery: ?upcoming=true&startDate=2024-01-01&endDate=2024-12-31\nResponse: Only returns future shifts with available capacity\n\n// List shifts (admin)\nGET /api/map/shifts\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?page=1&limit=20&startDate=2024-01-01&endDate=2024-12-31&cutId=xxx\n\n// Get shift by ID (admin)\nGET /api/map/shifts/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Create shift (admin)\nPOST /api/map/shifts\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: {\n  \"name\": \"Downtown Canvassing\",\n  \"description\": \"Canvassing shift for downtown area\",\n  \"startTime\": \"2024-02-15T09:00:00Z\",\n  \"endTime\": \"2024-02-15T12:00:00Z\",\n  \"location\": \"Community Center, 123 Main St\",\n  \"capacity\": 20,\n  \"requirements\": \"Comfortable shoes, water bottle\",\n  \"cutId\": \"clx1a2b3c4d5e6f7g8h9i\"\n}\n\n// Update shift (admin)\nPUT /api/map/shifts/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: { name, startTime, ... }\n\n// Delete shift (admin)\nDELETE /api/map/shifts/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Signup for shift (public)\nPOST /api/map/shifts/:id/signup\nAuth: None\nBody: {\n  \"name\": \"Jane Doe\",\n  \"email\": \"jane@example.com\",\n  \"phone\": \"416-555-1234\",\n  \"notes\": \"First time volunteering\"\n}\n// Creates TEMP user if email doesn't exist, sends confirmation email\n\n// Cancel signup (public)\nDELETE /api/map/shifts/:shiftId/signups/:userId\nAuth: Optional (user can cancel own signup)\n\n// List signups for shift (admin)\nGET /api/map/shifts/:id/signups\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Update signup status (admin)\nPATCH /api/map/shifts/:shiftId/signups/:userId\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: { \"status\": \"COMPLETED\" }\n\n// Email all shift signups (admin)\nPOST /api/map/shifts/:id/email-signups\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nBody: {\n  \"subject\": \"Shift Reminder\",\n  \"message\": \"Don't forget about tomorrow's shift!\"\n}\n\n// Get shift stats (admin)\nGET /api/map/shifts/stats\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nResponse: {\n  \"success\": true,\n  \"data\": {\n    \"totalShifts\": 50,\n    \"upcomingShifts\": 12,\n    \"totalSignups\": 234,\n    \"signupsByStatus\": {\n      \"CONFIRMED\": 200,\n      \"COMPLETED\": 30,\n      \"CANCELLED\": 4\n    }\n  }\n}\n
"},{"location":"v2/migration/api-changes/#canvassing-new-in-v2","title":"Canvassing (New in V2)","text":"
// Start canvass session (volunteer)\nPOST /api/map/canvass/sessions/start\nAuth: Required (any authenticated user)\nBody: {\n  \"shiftId\": \"clx1a2b3c4d5e6f7g8h9i\",\n  \"cutId\": \"clx1a2b3c4d5e6f7g8h9i\"\n}\n\n// End canvass session (volunteer)\nPOST /api/map/canvass/sessions/end\nAuth: Required (any authenticated user)\nBody: { \"sessionId\": \"clx1a2b3c4d5e6f7g8h9i\" }\n\n// Get walking route (volunteer)\nGET /api/map/canvass/routes/:cutId\nAuth: Required (any authenticated user)\nResponse: Optimized walking route (nearest-neighbor algorithm)\n\n// Record visit (volunteer)\nPOST /api/map/canvass/visits\nAuth: Required (any authenticated user)\nRate Limit: 30 requests/minute\nBody: {\n  \"sessionId\": \"clx1a2b3c4d5e6f7g8h9i\",\n  \"locationId\": \"clx1a2b3c4d5e6f7g8h9i\",\n  \"outcome\": \"CONTACT_MADE\",\n  \"supportLevel\": \"SUPPORT\",\n  \"notes\": \"Very interested in campaign\"\n}\n\n// Get canvass dashboard stats (admin)\nGET /api/map/canvass/dashboard/stats\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nResponse: {\n  \"success\": true,\n  \"data\": {\n    \"activeSessions\": 5,\n    \"totalVisitsToday\": 234,\n    \"totalVisitsWeek\": 1420,\n    \"avgVisitsPerSession\": 47\n  }\n}\n\n// Get activity feed (admin)\nGET /api/map/canvass/dashboard/activity\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?limit=50\n\n// Get cut progress (admin)\nGET /api/map/canvass/dashboard/cut-progress\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Get leaderboard (admin)\nGET /api/map/canvass/dashboard/leaderboard\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\nQuery: ?period=week&limit=10\n
"},{"location":"v2/migration/api-changes/#gps-tracking-new-in-v2","title":"GPS Tracking (New in V2)","text":"
// Start tracking session (volunteer)\nPOST /api/map/tracking/sessions/start\nAuth: Required (any authenticated user)\nBody: { \"sessionId\": \"clx1a2b3c4d5e6f7g8h9i\" }\n\n// Record GPS point (volunteer)\nPOST /api/map/tracking/points\nAuth: Required (any authenticated user)\nBody: {\n  \"sessionId\": \"clx1a2b3c4d5e6f7g8h9i\",\n  \"latitude\": 43.6532,\n  \"longitude\": -79.3832,\n  \"accuracy\": 10.5,\n  \"altitude\": 120.3,\n  \"speed\": 1.2\n}\n\n// End tracking session (volunteer)\nPOST /api/map/tracking/sessions/end\nAuth: Required (any authenticated user)\nBody: { \"sessionId\": \"clx1a2b3c4d5e6f7g8h9i\" }\n\n// Get tracking session (admin)\nGET /api/map/tracking/sessions/:id\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n\n// Get tracking points (admin)\nGET /api/map/tracking/sessions/:id/points\nAuth: Required (SUPER_ADMIN, MAP_ADMIN)\n
"},{"location":"v2/migration/api-changes/#landing-pages-email-templates-new-in-v2","title":"Landing Pages & Email Templates (New in V2)","text":""},{"location":"v2/migration/api-changes/#landing-pages","title":"Landing Pages","text":"
// List landing pages (admin)\nGET /api/pages/admin\nAuth: Required (SUPER_ADMIN)\nQuery: ?page=1&limit=20&search=query\n\n// Get page by ID (admin)\nGET /api/pages/admin/:id\nAuth: Required (SUPER_ADMIN)\n\n// Create page (admin)\nPOST /api/pages/admin\nAuth: Required (SUPER_ADMIN)\nBody: {\n  \"title\": \"About Us\",\n  \"slug\": \"about\",\n  \"content\": \"<html>...</html>\",\n  \"published\": true\n}\n\n// Update page (admin)\nPUT /api/pages/admin/:id\nAuth: Required (SUPER_ADMIN)\nBody: { title, slug, content, published }\n\n// Delete page (admin)\nDELETE /api/pages/admin/:id\nAuth: Required (SUPER_ADMIN)\n\n// Export page to MkDocs (admin)\nPOST /api/pages/admin/:id/export\nAuth: Required (SUPER_ADMIN)\nQuery: ?format=themed&filename=about.html\n\n// Get page by slug (public)\nGET /api/pages/public/:slug\nAuth: None\nResponse: Rendered HTML page\n
"},{"location":"v2/migration/api-changes/#email-templates","title":"Email Templates","text":"
// List templates (admin)\nGET /api/email-templates\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nQuery: ?page=1&limit=20&category=campaign&published=true\n\n// Get template by ID (admin)\nGET /api/email-templates/:id\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Create template (admin)\nPOST /api/email-templates\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nBody: {\n  \"name\": \"Campaign Launch\",\n  \"category\": \"campaign\",\n  \"subject\": \"New Campaign: {{campaignTitle}}\",\n  \"htmlBody\": \"<html>...</html>\",\n  \"textBody\": \"Plain text version\",\n  \"published\": true\n}\n\n// Update template (admin)\nPUT /api/email-templates/:id\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nBody: { name, subject, htmlBody, ... }\n\n// Publish template version (admin)\nPOST /api/email-templates/:id/publish\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\n\n// Send test email (admin)\nPOST /api/email-templates/:id/test\nAuth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)\nBody: {\n  \"toEmail\": \"test@example.com\",\n  \"variables\": {\n    \"campaignTitle\": \"Save the Trees\",\n    \"userName\": \"Test User\"\n  }\n}\n
"},{"location":"v2/migration/api-changes/#response-format-standards","title":"Response Format Standards","text":""},{"location":"v2/migration/api-changes/#success-response","title":"Success Response","text":"
{\n  \"success\": true,\n  \"data\": { /* response data */ }\n}\n
"},{"location":"v2/migration/api-changes/#paginated-response","title":"Paginated Response","text":"
{\n  \"success\": true,\n  \"data\": [ /* items */ ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 100,\n    \"totalPages\": 5\n  }\n}\n
"},{"location":"v2/migration/api-changes/#error-response","title":"Error Response","text":"
{\n  \"success\": false,\n  \"error\": {\n    \"code\": \"VALIDATION_ERROR\",\n    \"message\": \"Validation failed\",\n    \"details\": [\n      {\n        \"path\": [\"email\"],\n        \"message\": \"Invalid email format\"\n      }\n    ]\n  }\n}\n
"},{"location":"v2/migration/api-changes/#http-status-codes","title":"HTTP Status Codes","text":"Code V1 Usage V2 Usage 200 Success (all responses) Success (GET, PUT, PATCH) 201 - Created (POST) 204 - No Content (DELETE) 400 Validation error Bad Request (validation error) 401 Not logged in Unauthorized (invalid token) 403 - Forbidden (insufficient permissions) 404 Not found Not Found 409 - Conflict (duplicate resource) 422 - Unprocessable Entity (business logic error) 429 - Too Many Requests (rate limit) 500 Server error Internal Server Error"},{"location":"v2/migration/api-changes/#migration-examples","title":"Migration Examples","text":""},{"location":"v2/migration/api-changes/#example-1-campaign-list-page","title":"Example 1: Campaign List Page","text":"

V1 Code:

// Fetch campaigns\nfetch('/campaigns?page=1', {\n  credentials: 'include'\n})\n  .then(res => res.json())\n  .then(data => {\n    displayCampaigns(data.list);\n    displayPagination(data.pageInfo);\n  });\n

V2 Code:

// Fetch campaigns\nconst token = localStorage.getItem('accessToken');\n\nfetch('/api/influence/campaigns?page=1&limit=20', {\n  headers: {\n    'Authorization': `Bearer ${token}`\n  }\n})\n  .then(res => res.json())\n  .then(response => {\n    if (response.success) {\n      displayCampaigns(response.data);\n      displayPagination(response.pagination);\n    } else {\n      handleError(response.error);\n    }\n  });\n

"},{"location":"v2/migration/api-changes/#example-2-location-creation","title":"Example 2: Location Creation","text":"

V1 Code:

// Create location\nfetch('/locations/create', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  credentials: 'include',\n  body: JSON.stringify({\n    Address: '123 Main St, Toronto, ON M5V 1A1',\n    Latitude: 43.6532,\n    Longitude: -79.3832,\n    SupportLevel: 'support',\n    Notes: 'Spoke with resident'\n  })\n});\n

V2 Code:

// Create location\nconst token = localStorage.getItem('accessToken');\n\nfetch('/api/map/locations', {\n  method: 'POST',\n  headers: {\n    'Content-Type': 'application/json',\n    'Authorization': `Bearer ${token}`\n  },\n  body: JSON.stringify({\n    address: '123 Main St',\n    city: 'Toronto',\n    province: 'ON',\n    postalCode: 'M5V1A1',\n    country: 'Canada',\n    latitude: 43.6532,\n    longitude: -79.3832,\n    supportLevel: 'SUPPORT',\n    notes: 'Spoke with resident'\n  })\n})\n  .then(res => res.json())\n  .then(response => {\n    if (response.success) {\n      console.log('Created location:', response.data);\n    } else {\n      handleError(response.error);\n    }\n  });\n

"},{"location":"v2/migration/api-changes/#rate-limiting","title":"Rate Limiting","text":"

V2 adds rate limiting to prevent abuse:

Endpoint Limit Window /api/auth/login 10 requests 1 minute /api/auth/register 10 requests 1 minute /api/influence/campaign-emails/send-email 30 requests 1 hour /api/map/canvass/visits 30 requests 1 minute

Rate Limit Headers (V2 only):

X-RateLimit-Limit: 10\nX-RateLimit-Remaining: 8\nX-RateLimit-Reset: 1707835200\n

"},{"location":"v2/migration/api-changes/#related-documentation","title":"Related Documentation","text":"
  • Migration Overview - Migration planning
  • Breaking Changes - V1\u2192V2 differences
  • Data Migration - Data transfer guide
  • Authentication - JWT flow details
  • API Reference - Full API documentation
"},{"location":"v2/migration/api-changes/#next-steps","title":"Next Steps","text":"
  1. Review endpoint mappings for your application's usage
  2. Update API client to use JWT authentication
  3. Migrate endpoints incrementally (auth first, then modules)
  4. Test error handling with new response format
  5. Implement rate limit handling (exponential backoff)

API Testing

Use tools like Postman or Thunder Client to test V2 endpoints before frontend migration. Import the V2 API collection from /docs/postman-collection.json (if available).

"},{"location":"v2/migration/breaking-changes/","title":"Breaking Changes: V1 to V2","text":"

This document comprehensively details all breaking changes between Changemaker Lite V1 and V2. Review this carefully before migration to understand required code changes, configuration updates, and data transformations.

"},{"location":"v2/migration/breaking-changes/#overview","title":"Overview","text":"

V2 is a clean-room rebuild with fundamental architectural changes. Almost every aspect of the platform has changed, requiring careful planning for migration.

Not a Drop-In Replacement

V2 cannot be deployed alongside V1 without migration. Database schemas, APIs, and authentication are completely incompatible.

"},{"location":"v2/migration/breaking-changes/#major-architectural-changes","title":"Major Architectural Changes","text":""},{"location":"v2/migration/breaking-changes/#1-application-structure","title":"1. Application Structure","text":"

V1 Architecture:

changemaker.lite/\n\u251c\u2500\u2500 influence/          # Separate Express app (port 3333)\n\u2502   \u251c\u2500\u2500 app.js\n\u2502   \u251c\u2500\u2500 routes/\n\u2502   \u2514\u2500\u2500 views/ (EJS)\n\u251c\u2500\u2500 map/                # Separate Express app (port 3000)\n\u2502   \u251c\u2500\u2500 app.js\n\u2502   \u251c\u2500\u2500 routes/\n\u2502   \u2514\u2500\u2500 views/ (EJS)\n\u2514\u2500\u2500 docker-compose.yml\n

V2 Architecture:

changemaker.lite/\n\u251c\u2500\u2500 api/                # Unified Express + Fastify (ports 4000, 4100)\n\u2502   \u251c\u2500\u2500 src/server.ts          # Express main API\n\u2502   \u251c\u2500\u2500 src/media-server.ts    # Fastify media API\n\u2502   \u2514\u2500\u2500 prisma/schema.prisma\n\u251c\u2500\u2500 admin/              # React SPA (port 3000)\n\u2502   \u2514\u2500\u2500 src/\n\u2514\u2500\u2500 docker-compose.yml\n

Impact: V1 had two separate codebases with duplicated auth, middleware, and configuration. V2 consolidates everything into a single unified API.

"},{"location":"v2/migration/breaking-changes/#2-data-layer-transformation","title":"2. Data Layer Transformation","text":"Aspect V1 V2 ORM None (direct NocoDB REST API) Prisma ORM + Drizzle (media) Database NocoDB internal PostgreSQL PostgreSQL 16 direct access Migrations NocoDB auto-migrations Prisma migrate Validation Manual (express-validator) Zod schemas Queries HTTP requests to NocoDB prisma.model.findMany()

V1 Example (NocoDB REST API):

// influence/routes/campaigns.js\nconst campaigns = await axios.get('http://nocodb:8080/api/v1/db/data/v1/campaigns', {\n  headers: { 'xc-token': process.env.NOCODB_API_TOKEN }\n});\n

V2 Example (Prisma ORM):

// api/src/modules/influence/campaigns/campaigns.service.ts\nconst campaigns = await prisma.campaign.findMany({\n  where: { active: true },\n  include: { createdBy: true }\n});\n

Impact: All database queries must be rewritten from HTTP requests to Prisma queries. No migration script can automate this.

"},{"location":"v2/migration/breaking-changes/#3-authentication-system","title":"3. Authentication System","text":""},{"location":"v2/migration/breaking-changes/#session-based-v1-jwt-v2","title":"Session-Based (V1) \u2192 JWT (V2)","text":"

V1 Authentication:

// Session cookies + express-session + Redis store\napp.use(session({\n  store: redisStore,\n  secret: process.env.SESSION_SECRET,\n  resave: false,\n  saveUninitialized: false,\n  cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 hours\n}));\n\n// Login sets session\nreq.session.userId = user.id;\nreq.session.role = user.role;\n

V2 Authentication:

// JWT access + refresh tokens\nconst accessToken = jwt.sign(\n  { id: user.id, email: user.email, role: user.role },\n  env.JWT_ACCESS_SECRET,\n  { expiresIn: '15m' }\n);\n\nconst refreshToken = jwt.sign(\n  { id: user.id },\n  env.JWT_REFRESH_SECRET,\n  { expiresIn: '7d' }\n);\n\n// Store refresh token in database (rotation on use)\nawait prisma.refreshToken.create({\n  data: { token: refreshToken, userId: user.id }\n});\n

Impact: - V1 sessions do not migrate. All users must re-login after V2 deployment. - Frontend must be rewritten to store JWT tokens (localStorage/sessionStorage). - API requests must include Authorization: Bearer <token> header instead of cookies.

"},{"location":"v2/migration/breaking-changes/#password-hashing","title":"Password Hashing","text":"

Compatibility: Both V1 and V2 use bcrypt, so password hashes can migrate directly.

// V1 (influence/routes/auth.js)\nconst hashedPassword = await bcrypt.hash(password, 10);\n\n// V2 (api/src/modules/auth/auth.service.ts)\nconst hashedPassword = await bcrypt.hash(password, 10);\n\n// Comparison works\nawait bcrypt.compare(inputPassword, migratedHash); // \u2705 Works\n

Password Migration Safe

V1 bcrypt hashes can be copied directly to V2 User.password field. Users can login with existing passwords.

"},{"location":"v2/migration/breaking-changes/#4-user-model-changes","title":"4. User Model Changes","text":""},{"location":"v2/migration/breaking-changes/#v1-user-models-separate-tables","title":"V1 User Models (Separate Tables)","text":"

Influence Users (influence_users table):

{\n  \"id\": 1,\n  \"email\": \"admin@example.com\",\n  \"password\": \"$2b$10...\",\n  \"role\": \"admin\"\n}\n

Login Users (login table):

{\n  \"id\": 1,\n  \"email\": \"admin@example.com\",\n  \"password\": \"$2b$10...\",\n  \"name\": \"Admin User\"\n}\n

Problem: V1 had two separate user tables (one per app) with potential email duplicates.

"},{"location":"v2/migration/breaking-changes/#v2-user-model-unified","title":"V2 User Model (Unified)","text":"
model User {\n  id            String          @id @default(cuid())\n  email         String          @unique  // Enforced unique\n  password      String\n  name          String?\n  phone         String?\n  role          UserRole        @default(USER)\n  status        UserStatus      @default(ACTIVE)\n  createdVia    UserCreatedVia  @default(STANDARD)\n  expiresAt     DateTime?\n  emailVerified Boolean         @default(false)\n  createdAt     DateTime        @default(now())\n  updatedAt     DateTime        @updatedAt\n}\n\nenum UserRole {\n  SUPER_ADMIN\n  INFLUENCE_ADMIN\n  MAP_ADMIN\n  USER\n  TEMP\n}\n

Migration Challenges: 1. Email deduplication: Merge influence_users + login where email matches 2. Role mapping: V1 \"admin\" \u2192 V2 SUPER_ADMIN, V1 \"user\" \u2192 V2 USER 3. Missing fields: V2 adds phone, status, createdVia, emailVerified 4. ID format: V1 integer IDs \u2192 V2 CUID strings (breaks foreign keys)

Migration Script (conceptual):

// Merge V1 users into V2\nconst v1InfluenceUsers = await fetchFromNocoDB('influence_users');\nconst v1LoginUsers = await fetchFromNocoDB('login');\n\nconst mergedUsers = mergeByEmail(v1InfluenceUsers, v1LoginUsers);\n\nfor (const user of mergedUsers) {\n  await prisma.user.create({\n    data: {\n      email: user.email,\n      password: user.password, // bcrypt hash migrates directly\n      name: user.name || null,\n      role: mapRole(user.role), // 'admin' \u2192 'SUPER_ADMIN'\n      createdAt: user.created_at || new Date()\n    }\n  });\n}\n

"},{"location":"v2/migration/breaking-changes/#5-frontend-stack","title":"5. Frontend Stack","text":"Aspect V1 V2 Framework Server-rendered EJS React 19 SPA Build Tool None (direct EJS rendering) Vite 5 UI Library Bootstrap 4 Ant Design 5 State Management Server session Zustand stores Routing Express routes (server-side) React Router (client-side) Styling CSS + Bootstrap CSS Modules + Ant Design tokens

V1 View (EJS template):

<!-- influence/views/campaigns.ejs -->\n<% campaigns.forEach(campaign => { %>\n  <div class=\"card\">\n    <h3><%= campaign.title %></h3>\n    <p><%= campaign.description %></p>\n  </div>\n<% }) %>\n

V2 Component (React + TypeScript):

// admin/src/pages/CampaignsPage.tsx\nconst CampaignsPage = () => {\n  const [campaigns, setCampaigns] = useState<Campaign[]>([]);\n\n  useEffect(() => {\n    api.get('/api/influence/campaigns').then(res => {\n      setCampaigns(res.data.data);\n    });\n  }, []);\n\n  return (\n    <Table dataSource={campaigns} columns={columns} />\n  );\n};\n

Impact: - V1 views cannot be reused. All UI must be rewritten in React. - Client-side routing requires API design (RESTful endpoints). - State management shifts from server (session) to client (Zustand).

"},{"location":"v2/migration/breaking-changes/#api-changes","title":"API Changes","text":""},{"location":"v2/migration/breaking-changes/#endpoint-url-structure","title":"Endpoint URL Structure","text":"

V1 Endpoints:

# Influence app (port 3333)\nGET  /campaigns\nPOST /campaigns/create\nGET  /campaigns/:id/edit\nPOST /representatives/lookup\n\n# Map app (port 3000)\nGET  /locations\nPOST /locations/create\nGET  /shifts\n

V2 Endpoints:

# Unified API (port 4000)\nGET    /api/influence/campaigns\nPOST   /api/influence/campaigns\nGET    /api/influence/campaigns/:id\nPUT    /api/influence/campaigns/:id\nDELETE /api/influence/campaigns/:id\nPOST   /api/influence/representatives/lookup\n\nGET    /api/map/locations\nPOST   /api/map/locations\nGET    /api/map/locations/:id\nGET    /api/map/shifts\n

Changes: 1. All endpoints prefixed with /api/ 2. RESTful conventions (GET/POST/PUT/DELETE instead of /create, /edit) 3. Single port (4000) instead of two apps 4. Namespaced by module (/influence/, /map/)

"},{"location":"v2/migration/breaking-changes/#requestresponse-format","title":"Request/Response Format","text":"

V1 Response (NocoDB-style):

{\n  \"list\": [\n    {\n      \"Id\": 1,\n      \"Title\": \"Save the Trees\",\n      \"Created\": \"2024-01-15T10:30:00Z\"\n    }\n  ],\n  \"pageInfo\": {\n    \"totalRows\": 100,\n    \"page\": 1,\n    \"pageSize\": 20\n  }\n}\n

V2 Response (standardized):

{\n  \"success\": true,\n  \"data\": [\n    {\n      \"id\": \"clx1a2b3c4d5e6f7g8h9i\",\n      \"title\": \"Save the Trees\",\n      \"createdAt\": \"2024-01-15T10:30:00.000Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 100,\n    \"totalPages\": 5\n  }\n}\n

Changes: - V2 wraps responses in { success, data, pagination } structure - Field names: camelCase (createdAt) vs mixed case (Created) - IDs: CUID strings vs integers - Timestamps: ISO 8601 with milliseconds

"},{"location":"v2/migration/breaking-changes/#authentication-headers","title":"Authentication Headers","text":"

V1 Requests:

// Session cookie sent automatically\nfetch('/campaigns', {\n  method: 'GET',\n  credentials: 'include' // Sends session cookie\n});\n

V2 Requests:

// JWT Bearer token required\nfetch('/api/influence/campaigns', {\n  method: 'GET',\n  headers: {\n    'Authorization': `Bearer ${accessToken}`\n  }\n});\n

Impact: All API calls must be updated to include Authorization header. No more cookie-based authentication.

"},{"location":"v2/migration/breaking-changes/#validation-errors","title":"Validation Errors","text":"

V1 Validation (express-validator):

{\n  \"errors\": [\n    {\n      \"msg\": \"Invalid email\",\n      \"param\": \"email\",\n      \"location\": \"body\"\n    }\n  ]\n}\n

V2 Validation (Zod):

{\n  \"success\": false,\n  \"error\": {\n    \"code\": \"VALIDATION_ERROR\",\n    \"message\": \"Validation failed\",\n    \"details\": [\n      {\n        \"path\": [\"email\"],\n        \"message\": \"Invalid email\"\n      }\n    ]\n  }\n}\n

"},{"location":"v2/migration/breaking-changes/#database-schema-changes","title":"Database Schema Changes","text":""},{"location":"v2/migration/breaking-changes/#campaign-model","title":"Campaign Model","text":"

V1 NocoDB Table (campaigns):

Columns:\n- Id (integer, auto-increment)\n- Title (string)\n- Description (text)\n- Slug (string)\n- IsActive (boolean)\n- Created (datetime)\n

V2 Prisma Model:

model Campaign {\n  id                   String    @id @default(cuid())\n  title                String\n  description          String?\n  slug                 String    @unique\n  active               Boolean   @default(true)\n  highlighted          Boolean   @default(false)\n  targetLevel          String?\n  targetPosition       String?\n  targetName           String?\n  targetEmail          String?\n  targetPostalCode     String?\n  customSubject        String?\n  customBody           String?\n  responseWallEnabled  Boolean   @default(true)\n  createdAt            DateTime  @default(now())\n  updatedAt            DateTime  @updatedAt\n  createdByUserId      String\n  createdBy            User      @relation(\"CampaignCreator\", fields: [createdByUserId], references: [id])\n\n  emails               CampaignEmail[]\n  responses            RepresentativeResponse[]\n}\n

Changes: 1. New fields: highlighted, targetLevel, responseWallEnabled, createdByUserId 2. Relations: Foreign key to User (V1 had no user relation) 3. Renamed: IsActive \u2192 active, Created \u2192 createdAt 4. Type changes: Description text \u2192 String? (nullable)

"},{"location":"v2/migration/breaking-changes/#location-model","title":"Location Model","text":"

V1 NocoDB Table (locations):

Columns:\n- Id (integer)\n- Address (string)\n- Latitude (float)\n- Longitude (float)\n- SupportLevel (string)\n- Notes (text)\n

V2 Prisma Model:

model Location {\n  id                 String              @id @default(cuid())\n  address            String\n  addressLine2       String?\n  city               String?\n  province           String?\n  postalCode         String?\n  country            String              @default(\"Canada\")\n  latitude           Float?\n  longitude          Float?\n  geocoded           Boolean             @default(false)\n  geocodedAt         DateTime?\n  geocodeProvider    String?\n  geocodeQuality     String?\n  supportLevel       SupportLevel        @default(UNKNOWN)\n  notes              String?\n  contactName        String?\n  contactPhone       String?\n  contactEmail       String?\n  unitNumber         String?\n  buildingName       String?\n  buildingUse        String?\n  federalDistrict    String?\n  cutId              String?\n  createdAt          DateTime            @default(now())\n  updatedAt          DateTime            @updatedAt\n  createdByUserId    String\n  updatedByUserId    String?\n\n  createdBy          User                @relation(\"LocationCreator\", fields: [createdByUserId], references: [id])\n  updatedBy          User?               @relation(\"LocationUpdater\", fields: [updatedByUserId], references: [id])\n  cut                Cut?                @relation(fields: [cutId], references: [id])\n  addresses          Address[]\n  history            LocationHistory[]\n  canvassVisits      CanvassVisit[]\n}\n\nenum SupportLevel {\n  STRONG_SUPPORT\n  SUPPORT\n  UNDECIDED\n  OPPOSED\n  STRONG_OPPOSED\n  UNKNOWN\n  NOT_HOME\n  MOVED\n  DECEASED\n}\n

Changes: 1. Structured address: V1 single Address \u2192 V2 address, city, province, postalCode 2. Geocoding metadata: geocoded, geocodedAt, geocodeProvider, geocodeQuality 3. Contact fields: contactName, contactPhone, contactEmail 4. NAR fields: unitNumber, buildingName, buildingUse, federalDistrict 5. Relations: cutId, createdByUserId, updatedByUserId 6. SupportLevel: V1 string \u2192 V2 enum

"},{"location":"v2/migration/breaking-changes/#shift-model","title":"Shift Model","text":"

V1 NocoDB Table (shifts):

Columns:\n- Id (integer)\n- Name (string)\n- StartTime (datetime)\n- EndTime (datetime)\n- Location (string)\n- Capacity (integer)\n

V2 Prisma Model:

model Shift {\n  id                String         @id @default(cuid())\n  name              String\n  description       String?\n  startTime         DateTime\n  endTime           DateTime\n  location          String?\n  capacity          Int?\n  requirements      String?\n  cutId             String?\n  createdAt         DateTime       @default(now())\n  updatedAt         DateTime       @updatedAt\n\n  cut               Cut?           @relation(fields: [cutId], references: [id])\n  signups           ShiftSignup[]\n}\n\nmodel ShiftSignup {\n  id                String         @id @default(cuid())\n  shiftId           String\n  userId            String\n  status            SignupStatus   @default(CONFIRMED)\n  notes             String?\n  confirmedAt       DateTime?\n  cancelledAt       DateTime?\n  createdAt         DateTime       @default(now())\n\n  shift             Shift          @relation(fields: [shiftId], references: [id], onDelete: Cascade)\n  user              User           @relation(fields: [userId], references: [id])\n\n  @@unique([shiftId, userId])\n}\n\nenum SignupStatus {\n  PENDING\n  CONFIRMED\n  CANCELLED\n  COMPLETED\n  NO_SHOW\n}\n

Changes: 1. Separate signups: V1 embedded \u2192 V2 ShiftSignup relation table 2. New fields: description, requirements, cutId 3. Signup tracking: status, confirmedAt, cancelledAt 4. Unique constraint: One signup per user per shift

"},{"location":"v2/migration/breaking-changes/#configuration-changes","title":"Configuration Changes","text":""},{"location":"v2/migration/breaking-changes/#environment-variables","title":"Environment Variables","text":"

V1 Environment (.env):

# V1 used separate .env files per app\n\n# influence/.env\nPORT=3333\nNOCODB_URL=http://nocodb:8080\nNOCODB_API_TOKEN=xxxxx\nSESSION_SECRET=xxxxx\nREDIS_URL=redis://redis:6379\nSMTP_HOST=smtp.example.com\nSMTP_USER=user@example.com\nSMTP_PASS=password\n\n# map/.env\nPORT=3000\nNOCODB_URL=http://nocodb:8080\nNOCODB_API_TOKEN=xxxxx\nSESSION_SECRET=xxxxx  # Different secret!\nREDIS_URL=redis://redis:6379\n

V2 Environment (.env):

# Single unified .env file\n\n# Database\nDATABASE_URL=postgresql://changemaker:password@v2-postgres:5432/changemaker_v2?schema=public\nV2_POSTGRES_USER=changemaker\nV2_POSTGRES_PASSWORD=strongpassword\nV2_POSTGRES_DB=changemaker_v2\n\n# Redis\nREDIS_URL=redis://:password@redis:6379\nREDIS_PASSWORD=redispassword\n\n# JWT Authentication\nJWT_ACCESS_SECRET=access_secret_32_chars_minimum\nJWT_REFRESH_SECRET=refresh_secret_32_chars_minimum\nENCRYPTION_KEY=encryption_key_32_chars_different_from_jwt\n\n# API\nAPI_PORT=4000\nMEDIA_API_PORT=4100\nNODE_ENV=production\n\n# Email (SMTP)\nSMTP_HOST=smtp.protonmail.com\nSMTP_PORT=587\nSMTP_SECURE=false\nSMTP_USER=your@protonmail.com\nSMTP_PASS=yourapppassword\nSMTP_FROM=noreply@cmlite.org\nEMAIL_TEST_MODE=false\n\n# BullMQ\nBULLMQ_REDIS_URL=redis://:password@redis:6379\n\n# Listmonk (optional)\nLISTMONK_SYNC_ENABLED=true\nLISTMONK_URL=http://listmonk:9001\nLISTMONK_ADMIN_USER=api_user\nLISTMONK_ADMIN_PASSWORD=api_token\n\n# Feature Flags\nENABLE_MEDIA_FEATURES=true\n

Removed V1 Variables: - NOCODB_URL (no longer using NocoDB as data layer) - NOCODB_API_TOKEN - SESSION_SECRET (replaced by JWT secrets)

New V2 Variables: - DATABASE_URL (direct PostgreSQL connection) - JWT_ACCESS_SECRET, JWT_REFRESH_SECRET - ENCRYPTION_KEY (for encrypting sensitive DB fields) - LISTMONK_SYNC_ENABLED (newsletter integration) - ENABLE_MEDIA_FEATURES (media library toggle)

"},{"location":"v2/migration/breaking-changes/#docker-compose-changes","title":"Docker Compose Changes","text":"

V1 Services:

services:\n  influence-app:\n    build: ./influence\n    ports:\n      - \"3333:3333\"\n\n  map-app:\n    build: ./map\n    ports:\n      - \"3000:3000\"\n\n  nocodb:\n    image: nocodb/nocodb:latest\n    ports:\n      - \"8080:8080\"\n\n  redis:\n    image: redis:alpine\n

V2 Services:

services:\n  api:\n    build: ./api\n    ports:\n      - \"4000:4000\"\n    depends_on:\n      - v2-postgres\n      - redis\n\n  media-api:\n    build:\n      context: ./api\n      dockerfile: Dockerfile.media\n    ports:\n      - \"4100:4100\"\n\n  admin:\n    build: ./admin\n    ports:\n      - \"3000:3000\"\n\n  v2-postgres:\n    image: postgres:16-alpine\n    ports:\n      - \"5433:5432\"\n\n  redis:\n    image: redis:7-alpine\n    command: redis-server --requirepass ${REDIS_PASSWORD}\n\n  nginx:\n    image: nginx:alpine\n    ports:\n      - \"80:80\"\n      - \"443:443\"\n

Changes: 1. Removed: influence-app, map-app, nocodb 2. Added: api, media-api, admin, v2-postgres, nginx 3. Port changes: API 3333/3000 \u2192 4000, Admin GUI on 3000 4. Redis: Now requires authentication (--requirepass)

"},{"location":"v2/migration/breaking-changes/#code-migration-examples","title":"Code Migration Examples","text":""},{"location":"v2/migration/breaking-changes/#campaign-list-endpoint","title":"Campaign List Endpoint","text":"

V1 Implementation:

// influence/routes/campaigns.js\nrouter.get('/campaigns', async (req, res) => {\n  try {\n    const response = await axios.get(\n      `${process.env.NOCODB_URL}/api/v1/db/data/v1/campaigns`,\n      {\n        headers: { 'xc-token': process.env.NOCODB_API_TOKEN },\n        params: {\n          where: '(IsActive,eq,true)',\n          sort: '-Created',\n          limit: 20,\n          offset: req.query.page ? (req.query.page - 1) * 20 : 0\n        }\n      }\n    );\n\n    res.render('campaigns', {\n      campaigns: response.data.list,\n      pageInfo: response.data.pageInfo\n    });\n  } catch (error) {\n    console.error(error);\n    res.status(500).send('Error fetching campaigns');\n  }\n});\n

V2 Implementation:

// api/src/modules/influence/campaigns/campaigns.routes.ts\nrouter.get('/', authenticate, async (req: Request, res: Response) => {\n  const query = listCampaignsSchema.parse(req.query);\n  const result = await campaignService.list(query);\n  res.json({ success: true, ...result });\n});\n\n// api/src/modules/influence/campaigns/campaigns.service.ts\nasync list(params: ListCampaignsParams) {\n  const { page = 1, limit = 20, search, active, highlighted } = params;\n\n  const where: Prisma.CampaignWhereInput = {\n    ...(active !== undefined && { active }),\n    ...(highlighted !== undefined && { highlighted }),\n    ...(search && {\n      OR: [\n        { title: { contains: search, mode: 'insensitive' } },\n        { description: { contains: search, mode: 'insensitive' } }\n      ]\n    })\n  };\n\n  const [campaigns, total] = await Promise.all([\n    prisma.campaign.findMany({\n      where,\n      include: { createdBy: { select: { id: true, name: true, email: true } } },\n      orderBy: { createdAt: 'desc' },\n      skip: (page - 1) * limit,\n      take: limit\n    }),\n    prisma.campaign.count({ where })\n  ]);\n\n  return {\n    data: campaigns,\n    pagination: {\n      page,\n      limit,\n      total,\n      totalPages: Math.ceil(total / limit)\n    }\n  };\n}\n

Changes: 1. V1: HTTP request to NocoDB \u2192 V2: Prisma ORM query 2. V1: Query string filtering \u2192 V2: Zod schema validation 3. V1: EJS rendering \u2192 V2: JSON API response 4. V1: Manual pagination \u2192 V2: Standardized pagination object 5. V2: Type safety (TypeScript), includes relations

"},{"location":"v2/migration/breaking-changes/#user-login","title":"User Login","text":"

V1 Implementation:

// influence/routes/auth.js\nrouter.post('/login', async (req, res) => {\n  const { email, password } = req.body;\n\n  const response = await axios.get(\n    `${process.env.NOCODB_URL}/api/v1/db/data/v1/influence_users`,\n    {\n      headers: { 'xc-token': process.env.NOCODB_API_TOKEN },\n      params: { where: `(Email,eq,${email})` }\n    }\n  );\n\n  if (response.data.list.length === 0) {\n    return res.status(401).send('Invalid credentials');\n  }\n\n  const user = response.data.list[0];\n  const validPassword = await bcrypt.compare(password, user.Password);\n\n  if (!validPassword) {\n    return res.status(401).send('Invalid credentials');\n  }\n\n  req.session.userId = user.Id;\n  req.session.role = user.Role;\n\n  res.redirect('/dashboard');\n});\n

V2 Implementation:

// api/src/modules/auth/auth.service.ts\nasync login(email: string, password: string) {\n  const user = await prisma.user.findUnique({ where: { email } });\n\n  if (!user) {\n    // Prevent user enumeration - same error for wrong email or password\n    throw new UnauthorizedError('Invalid credentials');\n  }\n\n  const validPassword = await bcrypt.compare(password, user.password);\n  if (!validPassword) {\n    throw new UnauthorizedError('Invalid credentials');\n  }\n\n  if (user.status !== 'ACTIVE') {\n    throw new UnauthorizedError('Account is not active');\n  }\n\n  // Generate JWT tokens\n  const accessToken = jwt.sign(\n    { id: user.id, email: user.email, role: user.role },\n    env.JWT_ACCESS_SECRET,\n    { expiresIn: '15m' }\n  );\n\n  const refreshToken = jwt.sign(\n    { id: user.id },\n    env.JWT_REFRESH_SECRET,\n    { expiresIn: '7d' }\n  );\n\n  // Store refresh token (with rotation on use)\n  await prisma.refreshToken.create({\n    data: {\n      token: refreshToken,\n      userId: user.id,\n      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)\n    }\n  });\n\n  // Update last login\n  await prisma.user.update({\n    where: { id: user.id },\n    data: { lastLoginAt: new Date() }\n  });\n\n  return {\n    user: { id: user.id, email: user.email, name: user.name, role: user.role },\n    accessToken,\n    refreshToken\n  };\n}\n

Changes: 1. V1: Session storage \u2192 V2: JWT tokens returned to client 2. V1: Redirect to dashboard \u2192 V2: JSON response with tokens 3. V2: User enumeration prevention (same error message) 4. V2: Account status check (ACTIVE, SUSPENDED, etc.) 5. V2: Refresh token storage for rotation 6. V2: Last login tracking

"},{"location":"v2/migration/breaking-changes/#deployment-changes","title":"Deployment Changes","text":""},{"location":"v2/migration/breaking-changes/#port-mapping","title":"Port Mapping","text":"Service V1 Port V2 Port Notes Influence App 3333 - Removed Map App 3000 - Removed Admin GUI - 3000 New React app Express API - 4000 New unified API Fastify Media API - 4100 New media service NocoDB 8080 8091 Now read-only browser PostgreSQL (main) - 5433 New V2 database Listmonk - 9001 New newsletter service Grafana - 3001 New monitoring Prometheus - 9090 New metrics"},{"location":"v2/migration/breaking-changes/#nginx-routing","title":"Nginx Routing","text":"

V1 Nginx (simple proxy):

server {\n  listen 80;\n  server_name cmlite.org;\n\n  location /influence {\n    proxy_pass http://influence-app:3333;\n  }\n\n  location /map {\n    proxy_pass http://map-app:3000;\n  }\n}\n

V2 Nginx (subdomain routing):

# Admin GUI\nserver {\n  listen 80;\n  server_name app.cmlite.org;\n  location / {\n    proxy_pass http://admin:3000;\n  }\n}\n\n# Main API\nserver {\n  listen 80;\n  server_name api.cmlite.org;\n  location / {\n    proxy_pass http://api:4000;\n  }\n}\n\n# Media API\nserver {\n  listen 80;\n  server_name media.cmlite.org;\n  location / {\n    proxy_pass http://media-api:4100;\n  }\n}\n\n# Public site (MkDocs)\nserver {\n  listen 80;\n  server_name cmlite.org;\n  location / {\n    proxy_pass http://mkdocs:4001;\n  }\n}\n

Impact: V2 requires DNS configuration for subdomains (app., api., media., etc.).

"},{"location":"v2/migration/breaking-changes/#feature-changes","title":"Feature Changes","text":""},{"location":"v2/migration/breaking-changes/#features-removed-in-v2","title":"Features Removed in V2","text":"
  1. NocoDB Data Browser (as primary interface)
  2. V2 uses NocoDB only as read-only browser
  3. All CRUD operations via API/Admin GUI

  4. Embedded EJS Views

  5. No server-rendered templates
  6. All UI is React SPA

  7. Session-Based Multi-Tenancy

  8. V1 supported multiple campaigns with session isolation
  9. V2 is single-tenant (one installation per organization)
"},{"location":"v2/migration/breaking-changes/#features-added-in-v2","title":"Features Added in V2","text":"
  1. Landing Page Builder
  2. GrapesJS visual editor
  3. Custom blocks library
  4. MkDocs export (Jinja2 templates)

  5. Email Templates System

  6. Template versioning
  7. Variable substitution
  8. Live preview
  9. HTML + plain text variants

  10. Media Library

  11. Video upload with FFprobe metadata
  12. Public gallery with categories
  13. Reaction system (6 emoji types)
  14. Bulk operations

  15. Volunteer Canvassing

  16. GPS tracking sessions
  17. Walking route algorithm
  18. Visit outcome recording
  19. Admin dashboard with leaderboards

  20. Data Quality Dashboard

  21. Geocoding quality metrics
  22. Provider performance comparison
  23. Bulk re-geocoding tools

  24. Comprehensive Monitoring

  25. Prometheus metrics (12 custom cm_* metrics)
  26. Grafana dashboards (3 pre-configured)
  27. Alertmanager with Gotify integration
  28. Docker healthchecks

  29. NAR 2025 Import

  30. Canadian electoral data import
  31. Server-side streaming (large files)
  32. Location + Address file joining
  33. Province/city/postal filtering

  34. Pangolin Tunnel

  35. Self-hosted tunnel alternative to Cloudflare
  36. Newt container integration
  37. Admin setup wizard
"},{"location":"v2/migration/breaking-changes/#features-changed-in-v2","title":"Features Changed in V2","text":"
  1. Campaign Email Sending
  2. V1: Bull job queue \u2192 V2: BullMQ with monitoring
  3. V1: Single SMTP config \u2192 V2: Test mode + Listmonk integration

  4. Response Wall

  5. V1: Simple submission form \u2192 V2: Moderation + upvoting + verification

  6. Geocoding

  7. V1: Single provider (Nominatim) \u2192 V2: 6 providers with fallback
  8. V2 adds: ArcGIS, Photon, Mapbox, Google, OpenCage

  9. User Roles

  10. V1: admin, user \u2192 V2: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP
  11. V2: Role-based access control (RBAC) middleware
"},{"location":"v2/migration/breaking-changes/#security-changes","title":"Security Changes","text":""},{"location":"v2/migration/breaking-changes/#enhancements-in-v2","title":"Enhancements in V2","text":"
  1. Password Policy
  2. V1: No requirements \u2192 V2: 12+ chars, uppercase, lowercase, digit (Zod schema)

  3. Rate Limiting

  4. V1: None \u2192 V2: Auth endpoints 10/min per IP, canvass visits 30/min

  5. Refresh Token Rotation

  6. V1: Static sessions \u2192 V2: Atomic token rotation (prevents replay attacks)

  7. User Enumeration Prevention

  8. V2: Login returns 401 for both invalid email and password (V1 returned different errors)

  9. Redis Authentication

  10. V1: No password \u2192 V2: Required REDIS_PASSWORD

  11. Encryption Key

  12. V2: Separate ENCRYPTION_KEY for sensitive DB fields (different from JWT secrets)

  13. Input Sanitization

  14. V2: HTML escaping for user content (responses, emails, templates)

  15. Path Traversal Protection

  16. V2: Null byte checks, path normalization, encoded traversal blocking
"},{"location":"v2/migration/breaking-changes/#security-audit","title":"Security Audit","text":"

V2 underwent comprehensive security audit (2025-02-11) addressing 13 findings: - 1 Critical, 6 Important, 3 Medium, 2 Low, 1 Suggestion

See Security Audit Report for details.

"},{"location":"v2/migration/breaking-changes/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/migration/breaking-changes/#v1-performance-characteristics","title":"V1 Performance Characteristics","text":"
  • Database Access: HTTP requests to NocoDB (REST API overhead)
  • N+1 Queries: Common due to REST API pagination
  • Caching: Redis sessions only
  • Concurrency: Limited by Node.js single-threaded event loop
"},{"location":"v2/migration/breaking-changes/#v2-performance-improvements","title":"V2 Performance Improvements","text":"
  1. Direct Database Access
  2. Prisma ORM eliminates REST API overhead
  3. Connection pooling reduces latency

  4. Query Optimization

  5. Prisma includes relations in single query (no N+1)
  6. Indexed foreign keys, unique constraints

  7. Caching Strategy

  8. Redis cache for representatives (60min TTL)
  9. Redis cache for postal codes (persistent)
  10. Prisma query result caching

  11. Dual API Architecture

  12. Media API (Fastify) handles video uploads separately
  13. Prevents main API blocking on large file uploads

  14. Monitoring

  15. Prometheus http_request_duration_seconds histogram
  16. Slow query detection via metrics
  17. Grafana alerting on high latency
"},{"location":"v2/migration/breaking-changes/#related-documentation","title":"Related Documentation","text":"
  • Data Migration Procedures - Step-by-step migration guide
  • API Endpoint Changes - Complete endpoint mapping
  • Feature Parity Matrix - Feature comparison
  • V2 Architecture - System design
  • V2 Database Schema - Prisma models
"},{"location":"v2/migration/breaking-changes/#next-steps","title":"Next Steps","text":"
  1. Review this breaking changes document thoroughly
  2. Plan data transformation scripts (user merging, ID mapping)
  3. Test authentication migration (password hashes, login flow)
  4. Set up V2 staging environment for testing
  5. Proceed to Data Migration Guide

Migration Complexity

V2 migration is complex due to fundamental architectural changes. Budget 2-4 weeks for planning, scripting, testing, and execution.

"},{"location":"v2/migration/data-migration/","title":"Data Migration Procedures","text":"

This guide provides step-by-step procedures for migrating data from Changemaker Lite V1 to V2, including export scripts, transformation logic, import procedures, and validation steps.

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

V2 data migration involves:

  1. Export - Extract data from V1 NocoDB tables
  2. Transform - Convert V1 schema to V2 Prisma models
  3. Import - Load transformed data into V2 PostgreSQL
  4. Validate - Verify data integrity and completeness

Production Migration Warning

ALWAYS perform a test migration on a staging environment before production. Data loss is possible if scripts contain errors.

"},{"location":"v2/migration/data-migration/#prerequisites","title":"Prerequisites","text":"

Before beginning data migration:

  • V1 backup completed (PostgreSQL dump + uploads)
  • V2 environment running (docker compose up -d v2-postgres redis api)
  • Prisma migrations applied (npx prisma migrate deploy)
  • Node.js 20+ installed (for transformation scripts)
  • Sufficient disk space (3x current database size recommended)
  • Network access (V1 NocoDB API, V2 database)
"},{"location":"v2/migration/data-migration/#data-mapping","title":"Data Mapping","text":""},{"location":"v2/migration/data-migration/#v1-tables-v2-prisma-models","title":"V1 Tables \u2192 V2 Prisma Models","text":"V1 NocoDB Table V2 Prisma Model Notes influence_users User Merge with login table login User Merge with influence_users campaigns Campaign Add createdByUserId relation representatives Representative Direct migration responses RepresentativeResponse Add verification fields response_upvotes ResponseUpvote Add IP dedup field postal_code_cache PostalCodeCache Direct migration locations Location Split address, add geocoding fields shifts Shift Extract signups to ShiftSignup shift_signups ShiftSignup Add status enum cuts Cut Parse GeoJSON coordinates (none) RefreshToken New in V2 (generated on first login) (none) SiteSettings New in V2 (seed with defaults) (none) MapSettings New in V2 (seed with defaults)"},{"location":"v2/migration/data-migration/#field-mapping-tables","title":"Field Mapping Tables","text":""},{"location":"v2/migration/data-migration/#users","title":"Users","text":"V1 Field (influence_users) V1 Field (login) V2 Field Transformation Id Id - Discard (V2 uses CUID) Email Email email Merge by email, enforce unique Password Password password Bcrypt hash (direct copy) - Name name From login.Name - - phone NULL (not in V1) Role - role Map: 'admin'\u2192'SUPER_ADMIN', 'user'\u2192'USER' - - status Default: 'ACTIVE' - - createdVia Default: 'STANDARD' - - expiresAt NULL - - emailVerified Default: false Created Created createdAt ISO 8601 timestamp - - updatedAt Use createdAt or current time

Merge Logic:

// Pseudocode\nconst mergeUsers = (influenceUsers, loginUsers) => {\n  const merged = new Map();\n\n  // Add all login users first (has name field)\n  loginUsers.forEach(user => {\n    merged.set(user.Email.toLowerCase(), {\n      email: user.Email,\n      password: user.Password,\n      name: user.Name,\n      role: 'USER', // Default, may be overridden\n      createdAt: user.Created || new Date()\n    });\n  });\n\n  // Override with influence_users (has role field)\n  influenceUsers.forEach(user => {\n    const existing = merged.get(user.Email.toLowerCase());\n    if (existing) {\n      existing.role = mapRole(user.Role);\n    } else {\n      merged.set(user.Email.toLowerCase(), {\n        email: user.Email,\n        password: user.Password,\n        name: null,\n        role: mapRole(user.Role),\n        createdAt: user.Created || new Date()\n      });\n    }\n  });\n\n  return Array.from(merged.values());\n};\n\nconst mapRole = (v1Role) => {\n  const roleMap = {\n    'admin': 'SUPER_ADMIN',\n    'moderator': 'INFLUENCE_ADMIN',\n    'user': 'USER'\n  };\n  return roleMap[v1Role] || 'USER';\n};\n

"},{"location":"v2/migration/data-migration/#campaigns","title":"Campaigns","text":"V1 Field V2 Field Transformation Id - Discard (use CUID) Title title Direct copy Description description Direct copy Slug slug Direct copy IsActive active Boolean conversion - highlighted Default: false TargetLevel targetLevel Direct copy or NULL TargetPosition targetPosition Direct copy or NULL - targetName NULL (not in V1) - targetEmail NULL - targetPostalCode NULL - customSubject NULL - customBody NULL - responseWallEnabled Default: true Created createdAt ISO 8601 timestamp - updatedAt Use createdAt - createdByUserId Requires user lookup

CreatedBy Mapping:

// V1 campaigns may not have createdBy field\n// Options:\n// 1. Assign all to first SUPER_ADMIN user\n// 2. Use separate mapping table if V1 tracked creators\n// 3. Create placeholder \"System\" user\n\nconst assignCreator = async (campaign) => {\n  // Find first SUPER_ADMIN user\n  const admin = await prisma.user.findFirst({\n    where: { role: 'SUPER_ADMIN' }\n  });\n\n  if (!admin) {\n    throw new Error('No SUPER_ADMIN user found. Create admin user first.');\n  }\n\n  return admin.id;\n};\n

"},{"location":"v2/migration/data-migration/#locations","title":"Locations","text":"V1 Field V2 Field Transformation Id - Discard (use CUID) Address address, city, province, postalCode Parse address string - addressLine2 NULL - country Default: 'Canada' Latitude latitude Float conversion Longitude longitude Float conversion - geocoded latitude != NULL && longitude != NULL - geocodedAt Use createdAt if geocoded - geocodeProvider 'Legacy V1' or NULL - geocodeQuality NULL (unknown) SupportLevel supportLevel Map string to enum Notes notes Direct copy - contactName NULL - contactPhone NULL - contactEmail NULL - cutId NULL (assign later if needed) Created createdAt ISO 8601 timestamp - updatedAt Use createdAt - createdByUserId First MAP_ADMIN or SUPER_ADMIN

Address Parsing:

// V1 stored full address as single string\n// V2 requires structured fields\n\nconst parseAddress = (addressString) => {\n  // Example V1 address: \"123 Main St, Toronto, ON M5V 1A1\"\n  // Basic parsing (may need refinement for edge cases)\n\n  const parts = addressString.split(',').map(s => s.trim());\n\n  if (parts.length === 1) {\n    // Only street address\n    return {\n      address: parts[0],\n      city: null,\n      province: null,\n      postalCode: null\n    };\n  }\n\n  // Extract postal code (last part if matches pattern)\n  const postalRegex = /^[A-Z]\\d[A-Z]\\s?\\d[A-Z]\\d$/i;\n  let postalCode = null;\n  let province = null;\n  let city = null;\n\n  if (parts.length >= 3) {\n    const lastPart = parts[parts.length - 1];\n    const postalMatch = lastPart.match(/([A-Z]\\d[A-Z]\\s?\\d[A-Z]\\d)/i);\n\n    if (postalMatch) {\n      postalCode = postalMatch[1].replace(/\\s/, '').toUpperCase();\n      // Province usually before postal code\n      const provincePart = lastPart.replace(postalMatch[0], '').trim();\n      if (provincePart) {\n        province = provincePart;\n      } else if (parts.length >= 4) {\n        province = parts[parts.length - 2];\n      }\n    }\n\n    // City is second-to-last or third-to-last\n    if (parts.length >= 4 && province) {\n      city = parts[parts.length - 3];\n    } else if (parts.length >= 3) {\n      city = parts[parts.length - 2];\n    }\n  }\n\n  return {\n    address: parts[0],\n    city: city || null,\n    province: province || null,\n    postalCode: postalCode || null\n  };\n};\n\n// Example usage:\nparseAddress(\"123 Main St, Toronto, ON M5V 1A1\");\n// \u2192 { address: \"123 Main St\", city: \"Toronto\", province: \"ON\", postalCode: \"M5V1A1\" }\n

SupportLevel Enum Mapping:

const mapSupportLevel = (v1Level) => {\n  // V1 used inconsistent strings\n  const levelMap = {\n    'strong support': 'STRONG_SUPPORT',\n    'support': 'SUPPORT',\n    'undecided': 'UNDECIDED',\n    'oppose': 'OPPOSED',\n    'strong oppose': 'STRONG_OPPOSED',\n    'unknown': 'UNKNOWN',\n    'not home': 'NOT_HOME',\n    'moved': 'MOVED',\n    'deceased': 'DECEASED',\n    '': 'UNKNOWN'\n  };\n\n  return levelMap[v1Level?.toLowerCase()] || 'UNKNOWN';\n};\n

"},{"location":"v2/migration/data-migration/#export-v1-data","title":"Export V1 Data","text":""},{"location":"v2/migration/data-migration/#option-1-nocodb-api-export","title":"Option 1: NocoDB API Export","text":"

Script: scripts/export-v1-nocodb.js

#!/usr/bin/env node\nconst axios = require('axios');\nconst fs = require('fs').promises;\nconst path = require('path');\n\nconst NOCODB_URL = process.env.V1_NOCODB_URL || 'http://localhost:8080';\nconst NOCODB_TOKEN = process.env.V1_NOCODB_TOKEN;\nconst OUTPUT_DIR = process.env.OUTPUT_DIR || './v1-export';\n\nconst tables = [\n  'influence_users',\n  'login',\n  'campaigns',\n  'representatives',\n  'responses',\n  'response_upvotes',\n  'postal_code_cache',\n  'locations',\n  'shifts',\n  'shift_signups',\n  'cuts'\n];\n\nconst exportTable = async (tableName) => {\n  console.log(`Exporting ${tableName}...`);\n\n  let allRecords = [];\n  let offset = 0;\n  const limit = 100;\n  let hasMore = true;\n\n  while (hasMore) {\n    const response = await axios.get(\n      `${NOCODB_URL}/api/v1/db/data/v1/${tableName}`,\n      {\n        headers: { 'xc-token': NOCODB_TOKEN },\n        params: { limit, offset }\n      }\n    );\n\n    const records = response.data.list || [];\n    allRecords = allRecords.concat(records);\n\n    console.log(`  Fetched ${records.length} records (total: ${allRecords.length})`);\n\n    if (records.length < limit) {\n      hasMore = false;\n    } else {\n      offset += limit;\n    }\n  }\n\n  await fs.writeFile(\n    path.join(OUTPUT_DIR, `${tableName}.json`),\n    JSON.stringify(allRecords, null, 2)\n  );\n\n  console.log(`\u2713 Exported ${allRecords.length} records from ${tableName}`);\n  return allRecords.length;\n};\n\nconst main = async () => {\n  await fs.mkdir(OUTPUT_DIR, { recursive: true });\n\n  const counts = {};\n  for (const table of tables) {\n    try {\n      counts[table] = await exportTable(table);\n    } catch (error) {\n      console.error(`\u2717 Failed to export ${table}:`, error.message);\n      counts[table] = 0;\n    }\n  }\n\n  // Write summary\n  await fs.writeFile(\n    path.join(OUTPUT_DIR, 'export-summary.json'),\n    JSON.stringify({ exportedAt: new Date(), counts }, null, 2)\n  );\n\n  console.log('\\nExport Summary:');\n  console.table(counts);\n};\n\nmain().catch(console.error);\n

Usage:

cd /home/bunker-admin/changemaker.lite\nmkdir -p v1-export\n\n# Export from running V1 instance\nV1_NOCODB_URL=http://localhost:8080 \\\nV1_NOCODB_TOKEN=your-token \\\nOUTPUT_DIR=./v1-export \\\nnode scripts/export-v1-nocodb.js\n

"},{"location":"v2/migration/data-migration/#option-2-postgresql-direct-export","title":"Option 2: PostgreSQL Direct Export","text":"

If you have direct access to V1 PostgreSQL database:

# Export each table as CSV\ndocker compose -f docker-compose.v1.yml exec v1-postgres \\\n  psql -U nocodb -d nocodb -c \"\\COPY influence_users TO STDOUT CSV HEADER\" > v1-export/influence_users.csv\n\ndocker compose -f docker-compose.v1.yml exec v1-postgres \\\n  psql -U nocodb -d nocodb -c \"\\COPY login TO STDOUT CSV HEADER\" > v1-export/login.csv\n\ndocker compose -f docker-compose.v1.yml exec v1-postgres \\\n  psql -U nocodb -d nocodb -c \"\\COPY campaigns TO STDOUT CSV HEADER\" > v1-export/campaigns.csv\n\n# Repeat for all tables...\n
"},{"location":"v2/migration/data-migration/#backup-file-uploads","title":"Backup File Uploads","text":"
# V1 uploads directory\ntar -czf v1-uploads-backup.tar.gz ./uploads/\n\n# Verify archive\ntar -tzf v1-uploads-backup.tar.gz | head -20\n
"},{"location":"v2/migration/data-migration/#transform-data","title":"Transform Data","text":""},{"location":"v2/migration/data-migration/#user-transformation","title":"User Transformation","text":"

Script: scripts/transform-users.js

#!/usr/bin/env node\nconst fs = require('fs').promises;\nconst path = require('path');\n\nconst INPUT_DIR = process.env.INPUT_DIR || './v1-export';\nconst OUTPUT_DIR = process.env.OUTPUT_DIR || './v2-import';\n\nconst mapRole = (v1Role) => {\n  const roleMap = {\n    'admin': 'SUPER_ADMIN',\n    'moderator': 'INFLUENCE_ADMIN',\n    'user': 'USER'\n  };\n  return roleMap[v1Role] || 'USER';\n};\n\nconst transformUsers = async () => {\n  const influenceUsers = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'influence_users.json'), 'utf-8')\n  );\n  const loginUsers = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'login.json'), 'utf-8')\n  );\n\n  const merged = new Map();\n\n  // Add login users (has name field)\n  loginUsers.forEach(user => {\n    merged.set(user.Email.toLowerCase(), {\n      email: user.Email,\n      password: user.Password,\n      name: user.Name || null,\n      role: 'USER',\n      status: 'ACTIVE',\n      createdVia: 'STANDARD',\n      emailVerified: false,\n      createdAt: user.Created || new Date().toISOString(),\n      updatedAt: user.Created || new Date().toISOString()\n    });\n  });\n\n  // Override with influence_users (has role field)\n  influenceUsers.forEach(user => {\n    const existing = merged.get(user.Email.toLowerCase());\n    if (existing) {\n      existing.role = mapRole(user.Role);\n    } else {\n      merged.set(user.Email.toLowerCase(), {\n        email: user.Email,\n        password: user.Password,\n        name: null,\n        role: mapRole(user.Role),\n        status: 'ACTIVE',\n        createdVia: 'STANDARD',\n        emailVerified: false,\n        createdAt: user.Created || new Date().toISOString(),\n        updatedAt: user.Created || new Date().toISOString()\n      });\n    }\n  });\n\n  const users = Array.from(merged.values());\n\n  await fs.writeFile(\n    path.join(OUTPUT_DIR, 'users.json'),\n    JSON.stringify(users, null, 2)\n  );\n\n  console.log(`\u2713 Transformed ${users.length} users`);\n  console.log(`  influence_users: ${influenceUsers.length}`);\n  console.log(`  login: ${loginUsers.length}`);\n  console.log(`  merged: ${users.length}`);\n\n  return users;\n};\n\nconst main = async () => {\n  await fs.mkdir(OUTPUT_DIR, { recursive: true });\n  await transformUsers();\n};\n\nmain().catch(console.error);\n
"},{"location":"v2/migration/data-migration/#campaign-transformation","title":"Campaign Transformation","text":"

Script: scripts/transform-campaigns.js

#!/usr/bin/env node\nconst fs = require('fs').promises;\nconst path = require('path');\n\nconst INPUT_DIR = process.env.INPUT_DIR || './v1-export';\nconst OUTPUT_DIR = process.env.OUTPUT_DIR || './v2-import';\n\nconst transformCampaigns = async () => {\n  const v1Campaigns = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'campaigns.json'), 'utf-8')\n  );\n\n  // Note: createdByUserId must be populated after users are imported\n  // This transformation creates placeholder field\n  const campaigns = v1Campaigns.map(campaign => ({\n    title: campaign.Title,\n    description: campaign.Description || null,\n    slug: campaign.Slug,\n    active: Boolean(campaign.IsActive),\n    highlighted: false,\n    targetLevel: campaign.TargetLevel || null,\n    targetPosition: campaign.TargetPosition || null,\n    targetName: null,\n    targetEmail: null,\n    targetPostalCode: null,\n    customSubject: null,\n    customBody: null,\n    responseWallEnabled: true,\n    createdAt: campaign.Created || new Date().toISOString(),\n    updatedAt: campaign.Created || new Date().toISOString(),\n    _v1Id: campaign.Id // Keep for reference in import script\n  }));\n\n  await fs.writeFile(\n    path.join(OUTPUT_DIR, 'campaigns.json'),\n    JSON.stringify(campaigns, null, 2)\n  );\n\n  console.log(`\u2713 Transformed ${campaigns.length} campaigns`);\n  return campaigns;\n};\n\nconst main = async () => {\n  await fs.mkdir(OUTPUT_DIR, { recursive: true });\n  await transformCampaigns();\n};\n\nmain().catch(console.error);\n
"},{"location":"v2/migration/data-migration/#location-transformation","title":"Location Transformation","text":"

Script: scripts/transform-locations.js

#!/usr/bin/env node\nconst fs = require('fs').promises;\nconst path = require('path');\n\nconst INPUT_DIR = process.env.INPUT_DIR || './v1-export';\nconst OUTPUT_DIR = process.env.OUTPUT_DIR || './v2-import';\n\nconst parseAddress = (addressString) => {\n  if (!addressString) {\n    return { address: '', city: null, province: null, postalCode: null };\n  }\n\n  const parts = addressString.split(',').map(s => s.trim());\n\n  if (parts.length === 1) {\n    return {\n      address: parts[0],\n      city: null,\n      province: null,\n      postalCode: null\n    };\n  }\n\n  const postalRegex = /([A-Z]\\d[A-Z]\\s?\\d[A-Z]\\d)/i;\n  let postalCode = null;\n  let province = null;\n  let city = null;\n\n  if (parts.length >= 3) {\n    const lastPart = parts[parts.length - 1];\n    const postalMatch = lastPart.match(postalRegex);\n\n    if (postalMatch) {\n      postalCode = postalMatch[1].replace(/\\s/, '').toUpperCase();\n      const provincePart = lastPart.replace(postalMatch[0], '').trim();\n      if (provincePart) {\n        province = provincePart;\n      } else if (parts.length >= 4) {\n        province = parts[parts.length - 2];\n      }\n    }\n\n    if (parts.length >= 4 && province) {\n      city = parts[parts.length - 3];\n    } else if (parts.length >= 3) {\n      city = parts[parts.length - 2];\n    }\n  }\n\n  return {\n    address: parts[0],\n    city: city || null,\n    province: province || null,\n    postalCode: postalCode || null\n  };\n};\n\nconst mapSupportLevel = (v1Level) => {\n  const levelMap = {\n    'strong support': 'STRONG_SUPPORT',\n    'support': 'SUPPORT',\n    'undecided': 'UNDECIDED',\n    'oppose': 'OPPOSED',\n    'strong oppose': 'STRONG_OPPOSED',\n    'unknown': 'UNKNOWN',\n    'not home': 'NOT_HOME',\n    'moved': 'MOVED',\n    'deceased': 'DECEASED',\n    '': 'UNKNOWN'\n  };\n  return levelMap[v1Level?.toLowerCase()] || 'UNKNOWN';\n};\n\nconst transformLocations = async () => {\n  const v1Locations = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'locations.json'), 'utf-8')\n  );\n\n  const locations = v1Locations.map(loc => {\n    const { address, city, province, postalCode } = parseAddress(loc.Address);\n\n    const hasCoordinates = loc.Latitude != null && loc.Longitude != null;\n\n    return {\n      ...parseAddress(loc.Address),\n      country: 'Canada',\n      latitude: loc.Latitude ? parseFloat(loc.Latitude) : null,\n      longitude: loc.Longitude ? parseFloat(loc.Longitude) : null,\n      geocoded: hasCoordinates,\n      geocodedAt: hasCoordinates ? (loc.Created || new Date().toISOString()) : null,\n      geocodeProvider: hasCoordinates ? 'Legacy V1' : null,\n      geocodeQuality: null,\n      supportLevel: mapSupportLevel(loc.SupportLevel),\n      notes: loc.Notes || null,\n      contactName: null,\n      contactPhone: null,\n      contactEmail: null,\n      createdAt: loc.Created || new Date().toISOString(),\n      updatedAt: loc.Created || new Date().toISOString(),\n      _v1Id: loc.Id\n    };\n  });\n\n  await fs.writeFile(\n    path.join(OUTPUT_DIR, 'locations.json'),\n    JSON.stringify(locations, null, 2)\n  );\n\n  console.log(`\u2713 Transformed ${locations.length} locations`);\n\n  const geocodedCount = locations.filter(l => l.geocoded).length;\n  console.log(`  Geocoded: ${geocodedCount} (${(geocodedCount/locations.length*100).toFixed(1)}%)`);\n\n  return locations;\n};\n\nconst main = async () => {\n  await fs.mkdir(OUTPUT_DIR, { recursive: true });\n  await transformLocations();\n};\n\nmain().catch(console.error);\n
"},{"location":"v2/migration/data-migration/#import-v2-data","title":"Import V2 Data","text":""},{"location":"v2/migration/data-migration/#import-script","title":"Import Script","text":"

Script: scripts/import-v2-data.js

#!/usr/bin/env node\nconst { PrismaClient } = require('@prisma/client');\nconst fs = require('fs').promises;\nconst path = require('path');\n\nconst prisma = new PrismaClient();\nconst INPUT_DIR = process.env.INPUT_DIR || './v2-import';\n\nconst importUsers = async () => {\n  const users = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'users.json'), 'utf-8')\n  );\n\n  console.log(`Importing ${users.length} users...`);\n\n  const created = [];\n  for (const user of users) {\n    try {\n      const newUser = await prisma.user.create({ data: user });\n      created.push(newUser);\n    } catch (error) {\n      if (error.code === 'P2002') {\n        console.warn(`  \u26a0 User ${user.email} already exists, skipping`);\n      } else {\n        console.error(`  \u2717 Failed to import user ${user.email}:`, error.message);\n      }\n    }\n  }\n\n  console.log(`\u2713 Imported ${created.length}/${users.length} users`);\n  return created;\n};\n\nconst importCampaigns = async () => {\n  const campaigns = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'campaigns.json'), 'utf-8')\n  );\n\n  // Find first SUPER_ADMIN user\n  const admin = await prisma.user.findFirst({\n    where: { role: 'SUPER_ADMIN' }\n  });\n\n  if (!admin) {\n    throw new Error('No SUPER_ADMIN user found. Import users first.');\n  }\n\n  console.log(`Importing ${campaigns.length} campaigns (creator: ${admin.email})...`);\n\n  const created = [];\n  for (const campaign of campaigns) {\n    try {\n      const { _v1Id, ...data } = campaign;\n      const newCampaign = await prisma.campaign.create({\n        data: {\n          ...data,\n          createdByUserId: admin.id\n        }\n      });\n      created.push(newCampaign);\n    } catch (error) {\n      if (error.code === 'P2002') {\n        console.warn(`  \u26a0 Campaign ${campaign.slug} already exists, skipping`);\n      } else {\n        console.error(`  \u2717 Failed to import campaign ${campaign.title}:`, error.message);\n      }\n    }\n  }\n\n  console.log(`\u2713 Imported ${created.length}/${campaigns.length} campaigns`);\n  return created;\n};\n\nconst importLocations = async () => {\n  const locations = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'locations.json'), 'utf-8')\n  );\n\n  // Find first MAP_ADMIN or SUPER_ADMIN user\n  const admin = await prisma.user.findFirst({\n    where: { OR: [{ role: 'MAP_ADMIN' }, { role: 'SUPER_ADMIN' }] }\n  });\n\n  if (!admin) {\n    throw new Error('No MAP_ADMIN or SUPER_ADMIN user found. Import users first.');\n  }\n\n  console.log(`Importing ${locations.length} locations (creator: ${admin.email})...`);\n\n  const created = [];\n  for (const location of locations) {\n    try {\n      const { _v1Id, ...data } = location;\n      const newLocation = await prisma.location.create({\n        data: {\n          ...data,\n          createdByUserId: admin.id\n        }\n      });\n      created.push(newLocation);\n    } catch (error) {\n      console.error(`  \u2717 Failed to import location ${location.address}:`, error.message);\n    }\n  }\n\n  console.log(`\u2713 Imported ${created.length}/${locations.length} locations`);\n  return created;\n};\n\nconst main = async () => {\n  try {\n    console.log('Starting V2 data import...\\n');\n\n    await importUsers();\n    console.log();\n\n    await importCampaigns();\n    console.log();\n\n    await importLocations();\n    console.log();\n\n    console.log('\u2713 Import complete!');\n  } catch (error) {\n    console.error('Import failed:', error);\n    process.exit(1);\n  } finally {\n    await prisma.$disconnect();\n  }\n};\n\nmain();\n

Usage:

cd /home/bunker-admin/changemaker.lite\n\n# Ensure V2 database is running and migrated\ndocker compose up -d v2-postgres\ndocker compose exec api npx prisma migrate deploy\n\n# Run import\nINPUT_DIR=./v2-import node scripts/import-v2-data.js\n

"},{"location":"v2/migration/data-migration/#validate-migration","title":"Validate Migration","text":""},{"location":"v2/migration/data-migration/#validation-script","title":"Validation Script","text":"

Script: scripts/validate-migration.js

#!/usr/bin/env node\nconst { PrismaClient } = require('@prisma/client');\nconst fs = require('fs').promises;\nconst path = require('path');\n\nconst prisma = new PrismaClient();\nconst V1_EXPORT_DIR = './v1-export';\n\nconst validateCounts = async () => {\n  console.log('Validating record counts...\\n');\n\n  const v1Summary = JSON.parse(\n    await fs.readFile(path.join(V1_EXPORT_DIR, 'export-summary.json'), 'utf-8')\n  );\n\n  const v2Counts = {\n    users: await prisma.user.count(),\n    campaigns: await prisma.campaign.count(),\n    locations: await prisma.location.count(),\n    shifts: await prisma.shift.count(),\n    representatives: await prisma.representative.count()\n  };\n\n  const comparison = [\n    {\n      Table: 'Users',\n      V1: v1Summary.counts.influence_users + v1Summary.counts.login,\n      V2: v2Counts.users,\n      Match: '\u2248' // Approximate due to deduplication\n    },\n    {\n      Table: 'Campaigns',\n      V1: v1Summary.counts.campaigns,\n      V2: v2Counts.campaigns,\n      Match: v1Summary.counts.campaigns === v2Counts.campaigns ? '\u2713' : '\u2717'\n    },\n    {\n      Table: 'Locations',\n      V1: v1Summary.counts.locations,\n      V2: v2Counts.locations,\n      Match: v1Summary.counts.locations === v2Counts.locations ? '\u2713' : '\u2717'\n    },\n    {\n      Table: 'Shifts',\n      V1: v1Summary.counts.shifts,\n      V2: v2Counts.shifts,\n      Match: v1Summary.counts.shifts === v2Counts.shifts ? '\u2713' : '\u2717'\n    },\n    {\n      Table: 'Representatives',\n      V1: v1Summary.counts.representatives,\n      V2: v2Counts.representatives,\n      Match: v1Summary.counts.representatives === v2Counts.representatives ? '\u2713' : '\u2717'\n    }\n  ];\n\n  console.table(comparison);\n};\n\nconst validateSampleData = async () => {\n  console.log('\\nValidating sample data integrity...\\n');\n\n  // Check first user\n  const firstUser = await prisma.user.findFirst({\n    orderBy: { createdAt: 'asc' }\n  });\n  console.log('First User:', {\n    email: firstUser.email,\n    role: firstUser.role,\n    hasPassword: firstUser.password?.startsWith('$2b$') ? 'Yes (bcrypt)' : 'No'\n  });\n\n  // Check first campaign\n  const firstCampaign = await prisma.campaign.findFirst({\n    include: { createdBy: { select: { email: true } } },\n    orderBy: { createdAt: 'asc' }\n  });\n  console.log('First Campaign:', {\n    title: firstCampaign.title,\n    slug: firstCampaign.slug,\n    creator: firstCampaign.createdBy.email\n  });\n\n  // Check first location\n  const firstLocation = await prisma.location.findFirst({\n    orderBy: { createdAt: 'asc' }\n  });\n  console.log('First Location:', {\n    address: firstLocation.address,\n    city: firstLocation.city,\n    geocoded: firstLocation.geocoded,\n    supportLevel: firstLocation.supportLevel\n  });\n\n  // Geocoding statistics\n  const totalLocations = await prisma.location.count();\n  const geocodedLocations = await prisma.location.count({\n    where: { geocoded: true }\n  });\n  console.log('\\nGeocoding Stats:', {\n    total: totalLocations,\n    geocoded: geocodedLocations,\n    percentage: `${(geocodedLocations / totalLocations * 100).toFixed(1)}%`\n  });\n};\n\nconst main = async () => {\n  try {\n    await validateCounts();\n    await validateSampleData();\n\n    console.log('\\n\u2713 Validation complete');\n  } catch (error) {\n    console.error('Validation failed:', error);\n    process.exit(1);\n  } finally {\n    await prisma.$disconnect();\n  }\n};\n\nmain();\n
"},{"location":"v2/migration/data-migration/#special-cases","title":"Special Cases","text":""},{"location":"v2/migration/data-migration/#handling-duplicate-emails","title":"Handling Duplicate Emails","text":"

During user merge, you may encounter duplicate emails:

// Option 1: Keep first occurrence, log duplicates\nconst handleDuplicates = (users) => {\n  const seen = new Set();\n  const duplicates = [];\n\n  const unique = users.filter(user => {\n    if (seen.has(user.email.toLowerCase())) {\n      duplicates.push(user);\n      return false;\n    }\n    seen.add(user.email.toLowerCase());\n    return true;\n  });\n\n  if (duplicates.length > 0) {\n    console.warn(`Found ${duplicates.length} duplicate emails:`);\n    duplicates.forEach(d => console.warn(`  - ${d.email}`));\n  }\n\n  return unique;\n};\n\n// Option 2: Append suffix to duplicates\nconst handleDuplicatesWithSuffix = (users) => {\n  const counts = new Map();\n\n  return users.map(user => {\n    const email = user.email.toLowerCase();\n    const count = counts.get(email) || 0;\n    counts.set(email, count + 1);\n\n    if (count > 0) {\n      const [local, domain] = email.split('@');\n      return {\n        ...user,\n        email: `${local}+v1dup${count}@${domain}`\n      };\n    }\n\n    return user;\n  });\n};\n
"},{"location":"v2/migration/data-migration/#migrating-representative-cache","title":"Migrating Representative Cache","text":"

Representative cache can be rebuilt from Represent API, but to preserve it:

const transformRepresentatives = async () => {\n  const v1Reps = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'representatives.json'), 'utf-8')\n  );\n\n  const reps = v1Reps.map(rep => ({\n    name: rep.Name,\n    email: rep.Email,\n    district: rep.District,\n    party: rep.Party,\n    level: rep.Level,\n    photoUrl: rep.PhotoUrl || null,\n    postalCodes: rep.PostalCodes ? JSON.parse(rep.PostalCodes) : [],\n    createdAt: rep.Created || new Date().toISOString(),\n    updatedAt: rep.Updated || new Date().toISOString()\n  }));\n\n  await fs.writeFile(\n    path.join(OUTPUT_DIR, 'representatives.json'),\n    JSON.stringify(reps, null, 2)\n  );\n\n  return reps;\n};\n
"},{"location":"v2/migration/data-migration/#migrating-shift-signups","title":"Migrating Shift Signups","text":"

V1 may have embedded signups; V2 uses separate ShiftSignup table:

const transformShiftSignups = async () => {\n  const v1Shifts = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'shifts.json'), 'utf-8')\n  );\n\n  const signups = [];\n\n  v1Shifts.forEach(shift => {\n    if (shift.Signups && Array.isArray(shift.Signups)) {\n      shift.Signups.forEach(signup => {\n        signups.push({\n          shiftId: shift.Id, // V1 ID, will need mapping in import\n          userId: signup.UserId, // V1 ID, will need mapping\n          status: 'CONFIRMED',\n          notes: signup.Notes || null,\n          confirmedAt: signup.CreatedAt || new Date().toISOString(),\n          createdAt: signup.CreatedAt || new Date().toISOString()\n        });\n      });\n    }\n  });\n\n  await fs.writeFile(\n    path.join(OUTPUT_DIR, 'shift-signups.json'),\n    JSON.stringify(signups, null, 2)\n  );\n\n  return signups;\n};\n\n// Import with ID mapping\nconst importShiftSignups = async (idMappings) => {\n  const signups = JSON.parse(\n    await fs.readFile(path.join(INPUT_DIR, 'shift-signups.json'), 'utf-8')\n  );\n\n  for (const signup of signups) {\n    const v2ShiftId = idMappings.shifts[signup.shiftId];\n    const v2UserId = idMappings.users[signup.userId];\n\n    if (!v2ShiftId || !v2UserId) {\n      console.warn(`Skipping signup: shift ${signup.shiftId} or user ${signup.userId} not found`);\n      continue;\n    }\n\n    await prisma.shiftSignup.create({\n      data: {\n        shiftId: v2ShiftId,\n        userId: v2UserId,\n        status: signup.status,\n        notes: signup.notes,\n        confirmedAt: signup.confirmedAt,\n        createdAt: signup.createdAt\n      }\n    });\n  }\n};\n
"},{"location":"v2/migration/data-migration/#testing-migration","title":"Testing Migration","text":""},{"location":"v2/migration/data-migration/#pre-production-test-migration","title":"Pre-Production Test Migration","text":"

Before production migration, perform full test on staging:

# 1. Clone production V1 data to staging\n./scripts/backup.sh\nscp backups/latest.tar.gz staging-server:/tmp/\n\n# 2. Restore V1 on staging\nssh staging-server\ncd /opt/changemaker-lite\ntar -xzf /tmp/latest.tar.gz -C ./\ndocker compose -f docker-compose.v1.yml up -d\n\n# 3. Export V1 data\ndocker compose -f docker-compose.v1.yml exec influence-app node /app/scripts/export-data.js\n\n# 4. Set up V2 on staging\ngit checkout v2\ndocker compose up -d v2-postgres redis\ndocker compose exec api npx prisma migrate deploy\n\n# 5. Transform and import\nnode scripts/transform-users.js\nnode scripts/transform-campaigns.js\nnode scripts/transform-locations.js\nnode scripts/import-v2-data.js\n\n# 6. Validate\nnode scripts/validate-migration.js\n\n# 7. Test critical workflows\n./scripts/test-v2-workflows.sh\n
"},{"location":"v2/migration/data-migration/#test-critical-workflows","title":"Test Critical Workflows","text":"

Script: scripts/test-v2-workflows.sh

#!/bin/bash\nset -e\n\nAPI_URL=\"http://localhost:4000\"\nADMIN_TOKEN=\"\"\n\necho \"Testing V2 Critical Workflows\"\necho \"==============================\"\n\n# 1. Admin Login\necho -n \"1. Admin login... \"\nLOGIN_RESPONSE=$(curl -s -X POST \"$API_URL/api/auth/login\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\":\"admin@example.com\",\"password\":\"Admin123!\"}')\n\nADMIN_TOKEN=$(echo $LOGIN_RESPONSE | jq -r '.data.accessToken')\n\nif [ \"$ADMIN_TOKEN\" != \"null\" ] && [ -n \"$ADMIN_TOKEN\" ]; then\n  echo \"\u2713\"\nelse\n  echo \"\u2717 Failed\"\n  exit 1\nfi\n\n# 2. List Campaigns\necho -n \"2. List campaigns... \"\nCAMPAIGNS=$(curl -s \"$API_URL/api/influence/campaigns\" \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\")\n\nCAMPAIGN_COUNT=$(echo $CAMPAIGNS | jq '.data | length')\necho \"\u2713 ($CAMPAIGN_COUNT campaigns)\"\n\n# 3. Representative Lookup\necho -n \"3. Representative lookup (M5V 1A1)... \"\nREPS=$(curl -s -X POST \"$API_URL/api/influence/representatives/lookup\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"postalCode\":\"M5V1A1\"}')\n\nREP_COUNT=$(echo $REPS | jq '.data | length')\necho \"\u2713 ($REP_COUNT representatives)\"\n\n# 4. List Locations\necho -n \"4. List locations... \"\nLOCATIONS=$(curl -s \"$API_URL/api/map/locations\" \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\")\n\nLOCATION_COUNT=$(echo $LOCATIONS | jq '.data | length')\necho \"\u2713 ($LOCATION_COUNT locations)\"\n\n# 5. Send Test Email\necho -n \"5. Queue test email... \"\nEMAIL_RESPONSE=$(curl -s -X POST \"$API_URL/api/influence/campaign-emails/send-email\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"campaignId\":\"'$(echo $CAMPAIGNS | jq -r '.data[0].id')'\",\n    \"postalCode\":\"M5V1A1\",\n    \"senderName\":\"Test User\",\n    \"senderEmail\":\"test@example.com\"\n  }')\n\nif echo $EMAIL_RESPONSE | jq -e '.success' > /dev/null; then\n  echo \"\u2713\"\nelse\n  echo \"\u2717 Failed\"\nfi\n\necho\necho \"All critical workflows passed \u2713\"\n
"},{"location":"v2/migration/data-migration/#production-migration","title":"Production Migration","text":""},{"location":"v2/migration/data-migration/#step-by-step-procedure","title":"Step-by-Step Procedure","text":""},{"location":"v2/migration/data-migration/#phase-1-preparation-1-2-days-before","title":"Phase 1: Preparation (1-2 days before)","text":"
  1. Announce Downtime Window

    Subject: Scheduled Maintenance - System Upgrade\n\nWe will be performing a major system upgrade on [DATE] at [TIME].\n\nExpected downtime: 15-30 minutes\n\nWhat to expect:\n- All users will be logged out\n- You will need to re-login after the upgrade\n- Your data and passwords remain unchanged\n\nPlease save any unsaved work before [TIME].\n

  2. Backup V1

    ./scripts/backup.sh --include-uploads\n\n# Verify backup\ntar -tzf backups/changemaker-v1-$(date +%Y%m%d).tar.gz | head -20\n

  3. Test V2 on Staging (use procedure above)

"},{"location":"v2/migration/data-migration/#phase-2-export-t-60min","title":"Phase 2: Export (T-60min)","text":"
  1. Enable V1 Read-Only Mode

    # Stop V1 write services\ndocker compose -f docker-compose.v1.yml stop influence-app map-app\n\n# Keep database running for export\n

  2. Export V1 Data

    V1_NOCODB_URL=http://localhost:8080 \\\nV1_NOCODB_TOKEN=$(cat .env | grep NOCODB_API_TOKEN | cut -d= -f2) \\\nnode scripts/export-v1-nocodb.js\n\n# Verify export\nls -lh v1-export/\n

"},{"location":"v2/migration/data-migration/#phase-3-transform-t-30min","title":"Phase 3: Transform (T-30min)","text":"
  1. Transform Data
    node scripts/transform-users.js\nnode scripts/transform-campaigns.js\nnode scripts/transform-locations.js\nnode scripts/transform-shifts.js\n\n# Verify transformed data\nls -lh v2-import/\n
"},{"location":"v2/migration/data-migration/#phase-4-import-t-15min","title":"Phase 4: Import (T-15min)","text":"
  1. Stop V1 Completely

    docker compose -f docker-compose.v1.yml down\n

  2. Start V2 Database

    docker compose up -d v2-postgres redis\ndocker compose exec api npx prisma migrate deploy\n

  3. Import Data

    node scripts/import-v2-data.js | tee migration.log\n

  4. Validate Import

    node scripts/validate-migration.js\n

"},{"location":"v2/migration/data-migration/#phase-5-launch-v2-t0min","title":"Phase 5: Launch V2 (T+0min)","text":"
  1. Start All V2 Services

    docker compose up -d\n\n# Wait for health checks\nsleep 30\n\n# Verify all healthy\ndocker compose ps\n

  2. Smoke Test

    ./scripts/test-v2-workflows.sh\n

  3. Update DNS/Tunnel

    • Pangolin: Update endpoint in admin
    • Cloudflare: Update tunnel configuration
    • Manual DNS: Update A/CNAME records
"},{"location":"v2/migration/data-migration/#phase-6-monitor-t15min-to-t24hr","title":"Phase 6: Monitor (T+15min to T+24hr)","text":"
  1. Watch Logs

    docker compose logs -f api admin\n

  2. Monitor Metrics

    • Open Grafana: http://localhost:3001
    • Check API Performance dashboard
    • Watch for error spikes
  3. Test User Logins

    • Admin login
    • Regular user login
    • Temp user creation (shift signup)
  4. Announce Migration Complete

    Subject: System Upgrade Complete\n\nOur system upgrade is complete! You can now log in at:\nhttps://app.cmlite.org\n\nYour username and password remain unchanged.\n\nNew features available:\n- [List new V2 features]\n\nIf you experience any issues, please contact support@cmlite.org.\n

"},{"location":"v2/migration/data-migration/#rollback-procedures","title":"Rollback Procedures","text":"

If migration fails, follow these steps:

"},{"location":"v2/migration/data-migration/#emergency-rollback-t0-to-t2hr","title":"Emergency Rollback (T+0 to T+2hr)","text":"
# 1. Stop V2 services\ndocker compose down\n\n# 2. Restore V1 services\ndocker compose -f docker-compose.v1.yml up -d\n\n# 3. Restore V1 database from backup (if modified)\ndocker compose -f docker-compose.v1.yml exec -T v1-postgres \\\n  psql -U nocodb nocodb < backups/v1-postgres-backup.sql\n\n# 4. Verify V1 operational\ncurl -I http://localhost:3333/health\n\n# 5. Revert DNS/tunnel\n\n# 6. Announce rollback\necho \"Migration has been rolled back. V1 is operational.\" | \\\n  mail -s \"Migration Rollback\" admin@cmlite.org\n
"},{"location":"v2/migration/data-migration/#post-rollback-analysis","title":"Post-Rollback Analysis","text":"
  1. Review Migration Logs

    cat migration.log | grep ERROR\n

  2. Identify Root Cause

  3. Data transformation errors?
  4. Database constraint violations?
  5. Application bugs?

  6. Fix Issues on Staging

  7. Update transformation scripts
  8. Test again on staging
  9. Validate thoroughly

  10. Reschedule Migration

  11. New downtime window
  12. Communicate lessons learned
"},{"location":"v2/migration/data-migration/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/migration/data-migration/#issue-prisma-unique-constraint-violation","title":"Issue: Prisma Unique Constraint Violation","text":"

Error: P2002: Unique constraint failed on the constraint: unique_email

Cause: Duplicate emails in merged user data.

Solution:

// Before import, deduplicate\nconst users = JSON.parse(await fs.readFile('v2-import/users.json', 'utf-8'));\nconst unique = handleDuplicates(users);\nawait fs.writeFile('v2-import/users.json', JSON.stringify(unique, null, 2));\n

"},{"location":"v2/migration/data-migration/#issue-foreign-key-constraint-violation","title":"Issue: Foreign Key Constraint Violation","text":"

Error: P2003: Foreign key constraint failed on the field: createdByUserId

Cause: Campaign references user that doesn't exist (import order).

Solution: Always import in order: 1. Users first 2. Campaigns (references users) 3. Locations (references users) 4. Shifts, responses, etc.

"},{"location":"v2/migration/data-migration/#issue-bcrypt-hashes-not-working","title":"Issue: Bcrypt Hashes Not Working","text":"

Symptoms: Users can't login after migration despite correct password.

Cause: Password field truncated or corrupted.

Diagnosis:

-- Check password hash format\nSELECT email, LEFT(password, 10), LENGTH(password) FROM \"User\" LIMIT 5;\n\n-- Should be: \"$2b$10...\", length 60\n

Solution:

# Re-import users, ensure password field is text type\n# Or batch reset passwords:\ndocker compose exec api node scripts/reset-all-passwords.js\n

"},{"location":"v2/migration/data-migration/#related-documentation","title":"Related Documentation","text":"
  • Migration Overview - Migration planning guide
  • Breaking Changes - V1\u2192V2 differences
  • API Changes - Endpoint mapping
  • Feature Parity - Feature comparison
"},{"location":"v2/migration/data-migration/#next-steps","title":"Next Steps","text":"

After successful migration:

  1. Configure V2 Settings
  2. Site Settings
  3. Map Settings
  4. Email Configuration

  5. Train Administrators

  6. Admin Guide
  7. Campaign Management
  8. Volunteer Canvassing

  9. Enable New Features

  10. Landing Page Builder
  11. Email Templates
  12. Media Library

  13. Set Up Monitoring

  14. Observability Guide
  15. Backup Procedures

Migration Complete

Congratulations on completing your V2 migration! Welcome to the modern Changemaker Lite platform.

"},{"location":"v2/migration/feature-parity/","title":"Feature Parity: V1 vs V2","text":"

This document provides a comprehensive comparison of features between Changemaker Lite V1 and V2, including feature status, implementation differences, and migration priorities.

"},{"location":"v2/migration/feature-parity/#overview","title":"Overview","text":"

V2 achieves 100% feature parity with V1 core functionality and adds significant new capabilities. Some V1 features are implemented differently (better!) in V2.

V2 Feature Status

  • \u2705 All V1 Core Features: Campaigns, locations, shifts, response wall
  • \u2705 Enhanced Features: Multi-provider geocoding, canvassing with GPS, monitoring
  • \u2705 New Features: Landing pages, email templates, media library, NAR import
"},{"location":"v2/migration/feature-parity/#feature-comparison-matrix","title":"Feature Comparison Matrix","text":""},{"location":"v2/migration/feature-parity/#core-features","title":"Core Features","text":"Feature V1 V2 Status Notes Email Advocacy Campaigns \u2705 \u2705 Enhanced V2 adds BullMQ queue, Listmonk sync Representative Lookup \u2705 \u2705 Enhanced V2 adds caching, multi-level support Response Wall \u2705 \u2705 Enhanced V2 adds moderation, upvoting, verification Location Management \u2705 \u2705 Enhanced V2 adds structured address, geocoding quality Geocoding \u2705 \u2705 Enhanced V1: Nominatim only \u2192 V2: 6 providers Volunteer Shifts \u2705 \u2705 Enhanced V2 adds cut assignments, status tracking Public Shift Signup \u2705 \u2705 Same V2 creates temp users automatically User Management \u2705 \u2705 Enhanced V2 adds unified user model, RBAC Admin Authentication \u2705 \u2705 Changed V1: Sessions \u2192 V2: JWT"},{"location":"v2/migration/feature-parity/#map-features","title":"Map Features","text":"Feature V1 V2 Status Notes Location Map (Public) \u2705 \u2705 Enhanced V2 adds color-coded markers, cut overlays Location Map (Admin) \u2705 \u2705 Enhanced V2 adds click-to-add, move mode, geolocate Cuts (Territories) \u2705 \u2705 Enhanced V2 adds drawing mode, point-in-polygon CSV Import/Export \u2705 \u2705 Enhanced V2 adds flexible column mapping Bulk Geocoding \u274c \u2705 New V2 adds bulk geocode endpoint Reverse Geocoding \u274c \u2705 New V2 adds lat/lng \u2192 address lookup Walk Sheets \u274c \u2705 New V2 adds printable walk sheets with QR codes Cut Export \u274c \u2705 New V2 adds printable location reports NAR Import \u274c \u2705 New V2 adds Canadian electoral data import Data Quality Dashboard \u274c \u2705 New V2 adds geocoding quality metrics"},{"location":"v2/migration/feature-parity/#canvassing-features","title":"Canvassing Features","text":"Feature V1 V2 Status Notes Canvassing System \u274c \u2705 New V2 adds full canvassing workflow GPS Tracking \u274c \u2705 New V2 adds volunteer GPS trail recording Walking Routes \u274c \u2705 New V2 adds optimized route algorithm Visit Recording \u274c \u2705 New V2 adds outcome tracking, notes Canvass Dashboard \u274c \u2705 New V2 adds admin analytics, leaderboards Volunteer Portal \u274c \u2705 New V2 adds dedicated volunteer interface Activity History \u274c \u2705 New V2 adds visit history, stats"},{"location":"v2/migration/feature-parity/#content-management","title":"Content Management","text":"Feature V1 V2 Status Notes Landing Page Builder \u274c \u2705 New V2 adds GrapesJS editor Block Library \u274c \u2705 New V2 adds reusable content blocks MkDocs Export \u274c \u2705 New V2 adds static site generation Email Templates \u274c \u2705 New V2 adds template system with versioning Template Variables \u274c \u2705 New V2 adds dynamic content substitution"},{"location":"v2/migration/feature-parity/#media-management","title":"Media Management","text":"Feature V1 V2 Status Notes Video Library \u274c \u2705 New V2 adds video CRUD, categories Video Upload \u274c \u2705 New V2 adds upload with metadata extraction Public Gallery \u274c \u2705 New V2 adds public video gallery Reactions \u274c \u2705 New V2 adds 6 emoji reactions Video Sharing \u274c \u2705 New V2 adds lock/unlock system"},{"location":"v2/migration/feature-parity/#email-newsletters","title":"Email & Newsletters","text":"Feature V1 V2 Status Notes SMTP Email Sending \u2705 \u2705 Enhanced V2 adds BullMQ queue, test mode Email Queue \u2705 \u2705 Enhanced V1: Bull \u2192 V2: BullMQ with monitoring Email Tracking \u2705 \u2705 Enhanced V2 adds sent/failed stats per campaign Listmonk Integration \u274c \u2705 New V2 adds newsletter sync Subscriber Management \u274c \u2705 New V2 adds campaign participant \u2192 list sync"},{"location":"v2/migration/feature-parity/#monitoring-devops","title":"Monitoring & DevOps","text":"Feature V1 V2 Status Notes Prometheus Metrics \u274c \u2705 New V2 adds 12 custom cm_* metrics Grafana Dashboards \u274c \u2705 New V2 adds 3 pre-configured dashboards Alertmanager \u274c \u2705 New V2 adds alert rules, Gotify integration Health Checks \u274c \u2705 New V2 adds Docker healthchecks (7 services) Backup Script \u2705 \u2705 Enhanced V2 adds PostgreSQL + Listmonk + uploads Observability Dashboard \u274c \u2705 New V2 adds admin observability page"},{"location":"v2/migration/feature-parity/#platform-services","title":"Platform Services","text":"Feature V1 V2 Status Notes NocoDB \u2705 (data layer) \u2705 (read-only) Changed V2 uses Prisma, NocoDB for browsing Redis \u2705 \u2705 Enhanced V2 adds authentication required PostgreSQL \u2705 (NocoDB) \u2705 (direct) Enhanced V2 uses PostgreSQL 16 directly MkDocs \u274c \u2705 New V2 adds documentation site Code Server \u274c \u2705 New V2 adds web-based IDE n8n \u274c \u2705 New V2 adds workflow automation Gitea \u274c \u2705 New V2 adds Git repository hosting Homepage \u274c \u2705 New V2 adds service dashboard Pangolin Tunnel \u274c \u2705 New V2 adds self-hosted tunnel alternative Cloudflare Tunnel \u2705 \u274c Removed Replaced by Pangolin"},{"location":"v2/migration/feature-parity/#detailed-feature-comparisons","title":"Detailed Feature Comparisons","text":""},{"location":"v2/migration/feature-parity/#1-email-advocacy-campaigns","title":"1. Email Advocacy Campaigns","text":""},{"location":"v2/migration/feature-parity/#v1-implementation","title":"V1 Implementation","text":"
Features:\n- Create campaign (title, description, slug)\n- Target representatives via postal code lookup\n- Send emails to representatives (SMTP)\n- Track sent emails\n- Basic campaign listing\n\nTechnology:\n- NocoDB tables (campaigns, campaign_emails)\n- Bull job queue for async sending\n- Nodemailer SMTP\n- Represent API integration\n
"},{"location":"v2/migration/feature-parity/#v2-implementation","title":"V2 Implementation","text":"
Features:\n- All V1 features plus:\n  - Highlighted campaigns (featured on homepage)\n  - Response wall toggle per campaign\n  - Custom email subject/body templates\n  - Target filtering (level, position, name, email, postal code)\n  - Email stats dashboard (queued, sent, failed, mailto clicks)\n  - BullMQ queue admin (pause, resume, retry failed)\n  - Listmonk newsletter sync (campaign participants \u2192 list)\n  - Public campaign gallery\n  - Public campaign detail page\n\nTechnology:\n- Prisma models (Campaign, CampaignEmail, Representative, etc.)\n- BullMQ job queue with monitoring\n- Nodemailer SMTP + MailHog test mode\n- Represent API client with in-memory rate limiter (55/min)\n- Redis cache for representatives (60min TTL)\n

Migration Impact: V1 campaigns migrate directly. New fields default to sensible values.

"},{"location":"v2/migration/feature-parity/#2-representative-lookup","title":"2. Representative Lookup","text":""},{"location":"v2/migration/feature-parity/#v1-implementation_1","title":"V1 Implementation","text":"
Features:\n- Lookup by postal code (Represent API)\n- Display representative name, email, district, party\n- No caching (every lookup hits API)\n\nLimitations:\n- Rate limit issues (API throttling)\n- Slow response times\n- No offline capability\n
"},{"location":"v2/migration/feature-parity/#v2-implementation_1","title":"V2 Implementation","text":"
Features:\n- All V1 features plus:\n  - Redis cache (60min TTL)\n  - Fire-and-forget cache writes (non-blocking)\n  - Multi-level support (federal, provincial, municipal)\n  - Representative admin (view cache, stats, delete)\n  - Cache stats (total, by level, by party)\n  - Health check endpoint\n\nPerformance:\n- First lookup: ~500ms (API call + cache write)\n- Cached lookup: ~20ms (Redis)\n- Rate limiter: 55 requests/min (Represent API limit)\n

Migration Impact: Representative cache can be migrated or rebuilt from API.

"},{"location":"v2/migration/feature-parity/#3-location-management-geocoding","title":"3. Location Management & Geocoding","text":""},{"location":"v2/migration/feature-parity/#v1-implementation_2","title":"V1 Implementation","text":"
Features:\n- Create location (single address field)\n- Geocode via Nominatim (single provider)\n- Support level (string field)\n- Public map display (circle markers)\n\nLimitations:\n- No structured address (city, province separate)\n- Single geocoding provider (Nominatim)\n- No geocoding quality tracking\n- No bulk operations\n
"},{"location":"v2/migration/feature-parity/#v2-implementation_2","title":"V2 Implementation","text":"
Features:\n- All V1 features plus:\n  - Structured address (street, city, province, postal code, country)\n  - Multi-provider geocoding (6 providers with fallback):\n    1. Nominatim (default, free)\n    2. ArcGIS (enterprise)\n    3. Photon (European focus)\n    4. Mapbox (if API key provided)\n    5. Google (if API key provided)\n    6. OpenCage (if API key provided)\n  - Geocoding metadata (provider, quality, timestamp)\n  - Bulk geocoding endpoint (100 at a time)\n  - Reverse geocoding (lat/lng \u2192 address)\n  - CSV import with flexible column mapping\n  - CSV export with filters\n  - Location history (edit trail)\n  - Contact fields (name, phone, email)\n  - NAR import (Canadian electoral data, 50k+ locations)\n  - Data quality dashboard (geocoding success rate by provider)\n  - Click-to-add location on map\n  - Drag-to-move location on map\n  - Geolocate button (browser location)\n  - Fullscreen map mode\n\nTechnology:\n- Prisma Location model (structured schema)\n- Multi-provider geocoding service with retry logic\n- PostgreSQL spatial extensions (future: PostGIS)\n- React Leaflet map components\n

Migration Impact: V1 single address field parsed into structured fields. Geocoding metadata added.

"},{"location":"v2/migration/feature-parity/#4-volunteer-shifts","title":"4. Volunteer Shifts","text":""},{"location":"v2/migration/feature-parity/#v1-implementation_3","title":"V1 Implementation","text":"
Features:\n- Create shift (name, start/end time, location, capacity)\n- Public signup form\n- Email confirmation\n- Admin view signups\n\nLimitations:\n- No cut assignment (shifts not linked to territories)\n- No signup status tracking\n- No volunteer portal\n
"},{"location":"v2/migration/feature-parity/#v2-implementation_3","title":"V2 Implementation","text":"
Features:\n- All V1 features plus:\n  - Cut assignment (link shift to territory)\n  - Signup status (PENDING, CONFIRMED, CANCELLED, COMPLETED, NO_SHOW)\n  - Shift requirements field\n  - Temp user creation (public signup creates USER with 30-day expiry)\n  - Signup cancellation (volunteer self-service)\n  - Admin signup management (update status, notes)\n  - Email all signups (broadcast to shift volunteers)\n  - Shift stats (total shifts, upcoming, signups by status)\n  - Volunteer portal (view assigned shifts)\n\nTechnology:\n- Prisma models (Shift, ShiftSignup with status enum)\n- TEMP user creation (automatic expiry)\n- Email templates for confirmations\n

Migration Impact: V1 shifts migrate. Signups extracted to separate table. Status defaults to CONFIRMED.

"},{"location":"v2/migration/feature-parity/#5-canvassing-system-new-in-v2","title":"5. Canvassing System (New in V2)","text":""},{"location":"v2/migration/feature-parity/#v2-features","title":"V2 Features","text":"
Complete canvassing workflow:\n- Start/end canvass session (track volunteer time)\n- GPS tracking (real-time trail recording, 30-day retention)\n- Walking route algorithm (nearest-neighbor with haversine distance)\n- Visit recording (outcome, support level, notes, rate-limited 30/min)\n- Visit outcomes:\n  - CONTACT_MADE\n  - NOT_HOME\n  - REFUSED\n  - MOVED\n  - DECEASED\n  - WRONG_ADDRESS\n- Admin dashboard:\n  - Active sessions\n  - Total visits (today, week)\n  - Activity feed (recent visits)\n  - Cut progress (locations visited vs total)\n  - Leaderboard (top volunteers by visits, period filter)\n- Volunteer portal:\n  - Full-screen canvass map\n  - GPS position tracking\n  - Walking route display\n  - Bottom sheet visit recording\n  - Activity history (my visits)\n  - Route history (past sessions)\n\nTechnology:\n- Prisma models (CanvassSession, CanvassVisit, TrackingSession, TrackPoint)\n- React Leaflet map with custom controls\n- Zustand canvass store (client state)\n- Abandoned session cleanup (hourly, ACTIVE > 12h \u2192 ABANDONED)\n- Stale tracking cleanup (no data for 2h)\n

Migration Impact: New feature, no V1 equivalent.

"},{"location":"v2/migration/feature-parity/#6-landing-page-builder-new-in-v2","title":"6. Landing Page Builder (New in V2)","text":""},{"location":"v2/migration/feature-parity/#v2-features_1","title":"V2 Features","text":"
Visual page builder:\n- GrapesJS WYSIWYG editor\n- Drag-and-drop block placement\n- Custom block library (Hero, Features, CTA, etc.)\n- Live preview\n- Desktop-only editor (mobile warning)\n- Save hotkey (Ctrl+S)\n\nPage management:\n- CRUD operations (create, edit, delete, publish)\n- Slug-based routing (/p/:slug)\n- Public rendering\n- MkDocs export (Jinja2 Material theme overrides)\n- Export formats: themed (with header/footer) or standalone\n\nTechnology:\n- GrapesJS 0.21+\n- Prisma models (LandingPage, PageBlock)\n- React admin UI\n- MkDocs integration (override templates)\n

Migration Impact: New feature, no V1 equivalent.

"},{"location":"v2/migration/feature-parity/#7-email-templates-new-in-v2","title":"7. Email Templates (New in V2)","text":""},{"location":"v2/migration/feature-parity/#v2-features_2","title":"V2 Features","text":"
Template management:\n- Create templates (HTML + plain text)\n- Template categories (campaign, shift, response, system)\n- Variable substitution ({{campaignTitle}}, {{userName}}, etc.)\n- Version control (publish creates new version)\n- Live preview\n- Test email sending\n\nAdmin features:\n- Template library\n- Version history\n- Rollback to previous version\n- Duplicate template\n- Delete template (soft delete)\n\nTechnology:\n- Prisma models (EmailTemplate, EmailTemplateVersion)\n- Handlebars-style variable syntax\n- HTML + plain text variants\n

Migration Impact: New feature, no V1 equivalent.

"},{"location":"v2/migration/feature-parity/#8-media-library-new-in-v2","title":"8. Media Library (New in V2)","text":""},{"location":"v2/migration/feature-parity/#v2-features_3","title":"V2 Features","text":"
Video management:\n- Upload videos (MP4, MOV, AVI, MKV, WebM, M4V, FLV up to 10GB)\n- Automatic metadata extraction (FFprobe):\n  - Duration, dimensions, orientation\n  - Video quality (resolution-based)\n  - Audio track detection\n- Bulk operations (delete, lock/unlock)\n- Categories (assign to shared gallery)\n- Lock/unlock system (public visibility control)\n\nPublic gallery:\n- Category-based filtering\n- Video detail page\n- Reactions (6 emoji types: like, love, laugh, wow, sad, angry)\n- Comment system (future)\n\nTechnology:\n- Fastify microservice (port 4100)\n- Drizzle ORM (separate from Prisma)\n- FFprobe metadata extraction (30s timeout)\n- Dual API architecture (media separate from main API)\n

Migration Impact: New feature, no V1 equivalent.

"},{"location":"v2/migration/feature-parity/#9-monitoring-stack-new-in-v2","title":"9. Monitoring Stack (New in V2)","text":""},{"location":"v2/migration/feature-parity/#v2-features_4","title":"V2 Features","text":"
Metrics collection:\n- 12 custom cm_* metrics:\n  - cm_api_uptime_seconds\n  - cm_emails_sent_total\n  - cm_emails_failed_total\n  - cm_email_queue_size\n  - cm_email_send_duration_seconds\n  - cm_login_attempts_total\n  - cm_active_sessions\n  - cm_campaign_emails_total\n  - cm_response_submissions_total\n  - cm_canvass_visits_total\n  - cm_active_canvass_sessions\n  - cm_shift_signups_total\n  - cm_external_service_up\n\nDashboards:\n- System Health (CPU, memory, disk, network)\n- Application Overview (API requests, errors, response times)\n- API Performance (endpoint latency, throughput)\n\nAlerts:\n- High error rate (> 5% for 5min)\n- API down\n- High email queue size (> 1000)\n- External service down (NocoDB, Redis, PostgreSQL)\n\nTechnology:\n- Prometheus (metrics collection, 15s scrape)\n- Grafana (visualization, 3 dashboards)\n- Alertmanager (alert routing)\n- Gotify (notification delivery, optional)\n- cAdvisor (container metrics)\n- Node Exporter (host metrics)\n- Redis Exporter (Redis metrics)\n

Migration Impact: New feature, no V1 equivalent. Enable with --profile monitoring.

"},{"location":"v2/migration/feature-parity/#feature-status-summary","title":"Feature Status Summary","text":""},{"location":"v2/migration/feature-parity/#v1-features-in-v2","title":"V1 Features in V2","text":"Feature V2 Status Implementation Campaigns \u2705 Complete Enhanced with highlighting, response wall toggle Representative Lookup \u2705 Complete Enhanced with caching, stats Response Wall \u2705 Complete Enhanced with moderation, upvoting Locations \u2705 Complete Enhanced with structured address, multi-provider geocoding Shifts \u2705 Complete Enhanced with cut assignment, status tracking Public Shift Signup \u2705 Complete Same functionality, improved UX User Management \u2705 Complete Enhanced with unified model, RBAC Email Sending \u2705 Complete Enhanced with BullMQ, monitoring CSV Import/Export \u2705 Complete Enhanced with flexible mapping

Result: 100% V1 feature parity achieved

"},{"location":"v2/migration/feature-parity/#v1-features-not-in-v2","title":"V1 Features NOT in V2","text":"Feature Reason Alternative NocoDB as primary data layer Replaced by Prisma ORM NocoDB available as read-only browser Session-based authentication Replaced by JWT More scalable, stateless auth Separate apps (influence, map) Unified into single API Better code reuse, consistency"},{"location":"v2/migration/feature-parity/#v2-only-features","title":"V2-Only Features","text":"Feature Status Phase Landing Page Builder \u2705 Complete Phase 12 Email Templates \u2705 Complete Phase 12 Media Library \u2705 Complete Phase 12 Canvassing System \u2705 Complete Phase 13 GPS Tracking \u2705 Complete Phase 13 Walk Sheets \u2705 Complete Phase 10 NAR Import \u2705 Complete Phase 14 Data Quality Dashboard \u2705 Complete Phase 14 Monitoring Stack \u2705 Complete Phase 14 Pangolin Tunnel \u2705 Complete Phase 14 Observability Dashboard \u2705 Complete Phase 14"},{"location":"v2/migration/feature-parity/#migration-priority","title":"Migration Priority","text":"

When migrating from V1 to V2, prioritize features in this order:

"},{"location":"v2/migration/feature-parity/#1-critical-must-migrate-first","title":"1. Critical (Must Migrate First)","text":"
  • User Authentication - Foundational for all access
  • User Management - Admin accounts
  • Campaigns - Core advocacy feature
  • Locations - Core mapping feature
  • Representative Lookup - Core advocacy feature
"},{"location":"v2/migration/feature-parity/#2-high-priority-migrate-early","title":"2. High Priority (Migrate Early)","text":"
  • Response Wall - Public engagement
  • Email Sending - Campaign functionality
  • Shift Management - Volunteer coordination
  • Public Shift Signup - Volunteer onboarding
"},{"location":"v2/migration/feature-parity/#3-medium-priority-migrate-mid-phase","title":"3. Medium Priority (Migrate Mid-Phase)","text":"
  • Representative Cache - Performance optimization
  • Postal Code Cache - Performance optimization
  • Cuts (Territories) - Advanced mapping
  • CSV Import/Export - Bulk operations
"},{"location":"v2/migration/feature-parity/#4-low-priority-migrate-later","title":"4. Low Priority (Migrate Later)","text":"
  • Email Queue Monitoring - Admin analytics
  • Campaign Email Tracking - Admin analytics
  • Representative Admin - Cache management
"},{"location":"v2/migration/feature-parity/#5-optional-new-v2-features","title":"5. Optional (New V2 Features)","text":"
  • Landing Pages - Public content
  • Email Templates - Email customization
  • Media Library - Video management
  • Canvassing - Field operations
  • Monitoring - System observability
  • NAR Import - Canadian data
"},{"location":"v2/migration/feature-parity/#workarounds-for-missing-features","title":"Workarounds for Missing Features","text":"

If you need a V1 feature not yet migrated:

"},{"location":"v2/migration/feature-parity/#1-run-v1-and-v2-in-parallel","title":"1. Run V1 and V2 in Parallel","text":"
# Keep V1 running for specific features\ndocker compose -f docker-compose.v1.yml up -d\n\n# Run V2 for new features\ndocker compose up -d\n\n# Use reverse proxy to route by path:\n# /v1/* \u2192 V1 apps\n# /v2/* \u2192 V2 API\n
"},{"location":"v2/migration/feature-parity/#2-manual-data-entry","title":"2. Manual Data Entry","text":"

For small datasets, manually re-enter data in V2 admin:

  • Campaigns: Use Campaigns page (CRUD)
  • Locations: Use Locations page or CSV import
  • Shifts: Use Shifts page (CRUD)
"},{"location":"v2/migration/feature-parity/#3-custom-migration-scripts","title":"3. Custom Migration Scripts","text":"

For unique V1 customizations, write custom transformation scripts:

// scripts/migrate-custom-fields.js\nconst customFieldMapping = {\n  v1Field: 'v2Field',\n  // Add your mappings\n};\n\n// Transform and import\n
"},{"location":"v2/migration/feature-parity/#future-roadmap","title":"Future Roadmap","text":""},{"location":"v2/migration/feature-parity/#planned-for-v2-phase-15","title":"Planned for V2 Phase 15+","text":"
  • Multi-tenancy - Multiple organizations per instance
  • Mobile apps - iOS/Android native apps
  • Advanced analytics - Campaign performance, volunteer metrics
  • AI integration - Campaign suggestions, email drafting
  • Social media integration - Share campaigns, auto-post
  • SMS campaigns - Text message advocacy
  • Phone banking - Call tracking, scripts
  • Donation tracking - Fundraising integration
  • Event management - Rally, town hall scheduling
"},{"location":"v2/migration/feature-parity/#community-feature-requests","title":"Community Feature Requests","text":"

Vote on features at: https://github.com/changemaker-lite/v2/discussions

"},{"location":"v2/migration/feature-parity/#related-documentation","title":"Related Documentation","text":"
  • Migration Overview - Migration planning
  • Breaking Changes - V1\u2192V2 differences
  • Data Migration - Migration procedures
  • V2 Features - Complete feature documentation
"},{"location":"v2/migration/feature-parity/#next-steps","title":"Next Steps","text":"
  1. Review feature matrix - Identify features you use
  2. Prioritize migration - Critical features first
  3. Test on staging - Verify feature parity
  4. Provide feedback - Report missing features
  5. Plan new feature adoption - Landing pages, canvassing, etc.

Feature Parity Achieved

V2 provides 100% V1 feature parity plus significant new capabilities. No functionality will be lost in migration.

"},{"location":"v2/troubleshooting/","title":"Troubleshooting Guide","text":"

This section covers common issues, error messages, and solutions for Changemaker Lite V2. Use this guide to diagnose and resolve problems with installation, configuration, and operation.

"},{"location":"v2/troubleshooting/#quick-reference","title":"Quick Reference","text":""},{"location":"v2/troubleshooting/#common-errors","title":"Common Errors","text":"

Frequently encountered error messages:

  • Error codes and meanings
  • Stack trace interpretation
  • Quick fixes
  • When to escalate
"},{"location":"v2/troubleshooting/#faq","title":"FAQ","text":"

Frequently asked questions:

  • Installation questions
  • Configuration questions
  • Feature questions
  • Troubleshooting tips
"},{"location":"v2/troubleshooting/#docker-issues","title":"Docker Issues","text":"

Container and orchestration problems:

  • Container won't start
  • Port conflicts
  • Volume permission errors
  • Network connectivity
  • Resource constraints
"},{"location":"v2/troubleshooting/#database-issues","title":"Database Issues","text":"

PostgreSQL and Prisma problems:

  • Connection errors
  • Migration failures
  • Query performance
  • Data corruption
  • Backup/restore issues
"},{"location":"v2/troubleshooting/#authentication-issues","title":"Authentication Issues","text":"

Login and permission problems:

  • Can't log in
  • Token expired
  • Invalid credentials
  • Role permission denied
  • Session management
"},{"location":"v2/troubleshooting/#email-issues","title":"Email Issues","text":"

Email delivery problems:

  • SMTP connection failed
  • Emails not sending
  • Queue backed up
  • Template errors
  • Test mode not working
"},{"location":"v2/troubleshooting/#geocoding-issues","title":"Geocoding Issues","text":"

Address geocoding problems:

  • Geocoding fails
  • Wrong coordinates
  • Provider errors
  • Rate limiting
  • Bulk geocoding stuck
"},{"location":"v2/troubleshooting/#monitoring-issues","title":"Monitoring Issues","text":"

Observability and metrics problems:

  • Prometheus not scraping
  • Grafana dashboard errors
  • Alert not firing
  • Metrics missing
  • Service health incorrect
"},{"location":"v2/troubleshooting/#performance-optimization","title":"Performance Optimization","text":"

Speed and efficiency improvements:

  • Slow API responses
  • Database query optimization
  • Frontend performance
  • Cache optimization
  • Resource usage
"},{"location":"v2/troubleshooting/#common-issues","title":"Common Issues","text":""},{"location":"v2/troubleshooting/#installation-problems","title":"Installation Problems","text":"

Symptom: Docker containers fail to start

Common Causes: - Port conflicts - Missing environment variables - Insufficient resources - Corrupted volumes

Solutions: 1. Check port availability: netstat -tulpn | grep <port> 2. Verify .env file exists and is complete 3. Increase Docker memory/CPU limits 4. Remove volumes: docker compose down -v

Symptom: Database migration fails

Common Causes: - Database not running - Connection string incorrect - Migration conflict - Permission issues

Solutions: 1. Verify PostgreSQL is running: docker compose ps 2. Check DATABASE_URL in .env 3. Reset database (dev only): npx prisma migrate reset 4. Check user permissions

Symptom: \"Cannot connect to Redis\"

Common Causes: - Redis not started - Wrong password - Port conflict - Network issue

Solutions: 1. Start Redis: docker compose up -d redis 2. Verify REDIS_PASSWORD matches in all services 3. Check port 6379 not in use 4. Test connection: docker compose exec redis redis-cli ping

"},{"location":"v2/troubleshooting/#runtime-problems","title":"Runtime Problems","text":"

Symptom: API returns 500 errors

Common Causes: - Unhandled exception - Database query error - Service unavailable - Configuration issue

Solutions: 1. Check API logs: docker compose logs -f api 2. Review error stack trace 3. Test database connection 4. Verify environment variables

Symptom: Frontend shows blank page

Common Causes: - Build error - API not reachable - CORS issue - JavaScript error

Solutions: 1. Check browser console (F12) 2. Verify VITE_API_URL in .env 3. Check nginx CORS headers 4. Rebuild admin: docker compose build admin

Symptom: Emails not sending

Common Causes: - SMTP credentials wrong - Test mode enabled - Queue worker not running - Network blocked

Solutions: 1. Check EMAIL_TEST_MODE setting 2. Verify SMTP settings in .env 3. Check email queue: docker compose logs -f api | grep email 4. Test with MailHog (port 8025)

"},{"location":"v2/troubleshooting/#configuration-issues","title":"Configuration Issues","text":"

Symptom: Subdomain routing not working

Common Causes: - Nginx config error - DNS not set up - Tunnel not configured - Certificate issue

Solutions: 1. Check nginx config: docker compose exec nginx nginx -t 2. Verify DNS records 3. Review tunnel status in Pangolin page 4. Check SSL certificate validity

Symptom: Feature not working (media, listmonk, etc.)

Common Causes: - Feature flag disabled - Service not started - API credentials missing - Integration not configured

Solutions: 1. Check feature flag in .env (e.g., ENABLE_MEDIA_FEATURES) 2. Start required services: docker compose up -d <service> 3. Verify API keys/credentials 4. Complete setup wizard in admin

"},{"location":"v2/troubleshooting/#diagnostic-commands","title":"Diagnostic Commands","text":""},{"location":"v2/troubleshooting/#check-service-status","title":"Check Service Status","text":"
# All services\ndocker compose ps\n\n# Specific service\ndocker compose ps api\n\n# Service logs\ndocker compose logs -f api\ndocker compose logs --tail=100 v2-postgres\n
"},{"location":"v2/troubleshooting/#test-connectivity","title":"Test Connectivity","text":"
# API health check\ncurl http://localhost:4000/health\n\n# Database connection\ndocker compose exec api npx prisma db execute --stdin <<< \"SELECT 1\"\n\n# Redis connection\ndocker compose exec redis redis-cli ping\n
"},{"location":"v2/troubleshooting/#database-diagnostics","title":"Database Diagnostics","text":"
# Open Prisma Studio\ncd api && npx prisma studio\n\n# Check migrations\ncd api && npx prisma migrate status\n\n# View database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2\n
"},{"location":"v2/troubleshooting/#view-logs","title":"View Logs","text":"
# All services\ndocker compose logs -f\n\n# Specific service\ndocker compose logs -f api\ndocker compose logs -f admin\n\n# Error logs only\ndocker compose logs -f | grep ERROR\n
"},{"location":"v2/troubleshooting/#resource-usage","title":"Resource Usage","text":"
# Docker stats\ndocker stats\n\n# Disk usage\ndocker system df\n\n# Container resource limits\ndocker compose config | grep mem_limit\n
"},{"location":"v2/troubleshooting/#error-message-reference","title":"Error Message Reference","text":""},{"location":"v2/troubleshooting/#database-errors","title":"Database Errors","text":"

P2002: Unique constraint failed\n
Cause: Duplicate value for unique field (email, slug, etc.) Fix: Use different value or update existing record

P2025: Record not found\n
Cause: Trying to access non-existent record Fix: Verify ID exists, check deletion

P2021: Table does not exist\n
Cause: Missing migration Fix: Run npx prisma migrate deploy

"},{"location":"v2/troubleshooting/#api-errors","title":"API Errors","text":"

401 Unauthorized\n
Cause: Missing/invalid JWT token Fix: Login again, check token expiration

403 Forbidden\n
Cause: Insufficient permissions Fix: Check user role, verify RBAC middleware

429 Too Many Requests\n
Cause: Rate limit exceeded Fix: Wait, reduce request frequency

"},{"location":"v2/troubleshooting/#docker-errors","title":"Docker Errors","text":"

port is already allocated\n
Cause: Port conflict Fix: Stop conflicting service, change port in docker-compose.yml

no space left on device\n
Cause: Disk full Fix: Clean up: docker system prune -a

network not found\n
Cause: Docker network missing Fix: Recreate: docker compose down && docker compose up -d

"},{"location":"v2/troubleshooting/#when-to-get-help","title":"When to Get Help","text":"

Escalate to GitHub issues if:

  • Error persists after troubleshooting
  • Data corruption or loss
  • Security vulnerability discovered
  • Bug in core functionality
  • Documentation unclear
"},{"location":"v2/troubleshooting/#related-documentation","title":"Related Documentation","text":"
  • Common Errors
  • FAQ
  • Docker Issues
  • Database Issues
  • Authentication Issues
  • Email Issues
  • Geocoding Issues
  • Monitoring Issues
  • Performance Optimization
  • Development Guide
  • Deployment Guide
"},{"location":"v2/troubleshooting/auth-issues/","title":"Authentication and Authorization Issues","text":"

This guide covers authentication (who you are) and authorization (what you can do) problems in Changemaker Lite V2.

"},{"location":"v2/troubleshooting/auth-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/auth-issues/#authentication-system","title":"Authentication System","text":"

Changemaker Lite V2 uses JWT-based authentication:

  • Access tokens - Short-lived (15 minutes), stored in memory
  • Refresh tokens - Long-lived (7 days), stored in database
  • bcrypt passwords - Hashed with salt (10 rounds)
  • Token rotation - New refresh token on each refresh
"},{"location":"v2/troubleshooting/auth-issues/#authorization-system","title":"Authorization System","text":"

Role-based access control (RBAC) with 5 roles:

Role Level Permissions SUPER_ADMIN 5 Full access to everything INFLUENCE_ADMIN 4 Manage campaigns, responses, email queue MAP_ADMIN 3 Manage locations, cuts, shifts, canvass USER 2 View public content, canvass (if assigned shift) TEMP 1 Very limited - shift signup confirmation only"},{"location":"v2/troubleshooting/auth-issues/#security-features","title":"Security Features","text":"
  • Password policy - 12+ chars, uppercase, lowercase, digit
  • User enumeration prevention - Generic error messages
  • Rate limiting - 10 requests/min on auth endpoints
  • Refresh token rotation - Atomic transaction prevents race conditions
  • Redis authentication - Required password for Redis connection
"},{"location":"v2/troubleshooting/auth-issues/#login-failures","title":"Login Failures","text":""},{"location":"v2/troubleshooting/auth-issues/#invalid-credentials","title":"Invalid Credentials","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/auth-issues/#symptoms","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Invalid credentials\"\n}\n

Same message for: - User not found - Wrong password - User suspended

This is intentional (prevents user enumeration).

"},{"location":"v2/troubleshooting/auth-issues/#common-causes","title":"Common Causes","text":"
  1. Wrong password - Password incorrect
  2. User doesn't exist - Email not registered
  3. Typo in email - Email address wrong
  4. Account suspended - User marked as suspended
"},{"location":"v2/troubleshooting/auth-issues/#solutions","title":"Solutions","text":"

Solution 1: Verify user exists

# Check database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email, role FROM \\\"User\\\" WHERE email = 'user@example.com';\"\n\n# If no result, user doesn't exist\n

Solution 2: Reset password

# Generate bcrypt hash for new password\ndocker compose exec api node -e \"\nconst bcrypt = require('bcryptjs');\nconst hash = bcrypt.hashSync('NewPassword123!', 10);\nconsole.log(hash);\n\"\n\n# Update password\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET password = '\\$2a\\$10\\$...' WHERE email = 'user@example.com';\"\n

Solution 3: Create missing user

# Via API\ncurl -X POST http://localhost:4000/api/auth/register \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"email\": \"user@example.com\",\n    \"password\": \"SecurePass123!\",\n    \"name\": \"User Name\"\n  }'\n\n# Or via admin UI at /app/users\n

Solution 4: Check for suspended account

# Check user status\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email, role, \\\"createdAt\\\" FROM \\\"User\\\" WHERE email = 'user@example.com';\"\n\n# If suspended field exists and is true, unsuspend:\n# (Note: V2 doesn't have suspended field yet, but may be added)\n

Solution 5: Check password requirements

Password must meet requirements: - 12+ characters - At least 1 uppercase letter - At least 1 lowercase letter - At least 1 digit

# Valid examples:\nSecurePass123!\nMyP@ssword99\nAdmin12345678\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention","title":"Prevention","text":"
  • Password manager - Use password manager to avoid typos
  • Password reset flow - Implement forgot password feature
  • Clear requirements - Display password requirements on register
  • User-friendly errors - Guide users without revealing if email exists

User Enumeration

The same error message for \"user not found\" and \"wrong password\" is intentional security behavior to prevent attackers from discovering valid email addresses.

"},{"location":"v2/troubleshooting/auth-issues/#account-suspended","title":"Account Suspended","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_1","title":"Symptoms","text":"
{\n  \"error\": \"Forbidden\",\n  \"message\": \"Account suspended\"\n}\n

User can't log in even with correct credentials.

"},{"location":"v2/troubleshooting/auth-issues/#common-causes_1","title":"Common Causes","text":"
  1. Manual suspension - Admin suspended account
  2. Security violation - Account flagged for suspicious activity
  3. Terms violation - Account suspended for policy violation
"},{"location":"v2/troubleshooting/auth-issues/#solutions_1","title":"Solutions","text":"

Solution 1: Check suspension status

# Check if user has suspended flag (if implemented)\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email, role FROM \\\"User\\\" WHERE email = 'user@example.com';\"\n

Solution 2: Unsuspend account

# If suspension field exists:\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET suspended = false WHERE email = 'user@example.com';\"\n\n# Or delete user and recreate\n

Solution 3: Contact administrator

If you're a user: 1. Contact system administrator 2. Provide your email address 3. Wait for account review

"},{"location":"v2/troubleshooting/auth-issues/#prevention_1","title":"Prevention","text":"
  • Suspension policy - Clear policy on suspension reasons
  • Appeal process - Allow users to appeal suspensions
  • Audit trail - Log suspension reasons and who suspended

V2 Status

V2 doesn't currently have a suspended field. This section is for future implementation or if added via custom migration.

"},{"location":"v2/troubleshooting/auth-issues/#email-not-verified","title":"Email Not Verified","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_2","title":"Symptoms","text":"
{\n  \"error\": \"Forbidden\",\n  \"message\": \"Email not verified. Please check your email for verification link.\"\n}\n
"},{"location":"v2/troubleshooting/auth-issues/#common-causes_2","title":"Common Causes","text":"
  1. Email not verified - User didn't click verification link
  2. Verification email not received - Email went to spam
  3. Verification link expired - Link older than 24 hours
"},{"location":"v2/troubleshooting/auth-issues/#solutions_2","title":"Solutions","text":"

Solution 1: Check verification status

# Check if emailVerified field exists\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email, \\\"createdAt\\\" FROM \\\"User\\\" WHERE email = 'user@example.com';\"\n

Solution 2: Manually verify email

# Mark email as verified\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET \\\"emailVerified\\\" = true WHERE email = 'user@example.com';\"\n

Solution 3: Resend verification email

# Via API (if endpoint exists)\ncurl -X POST http://localhost:4000/api/auth/resend-verification \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\": \"user@example.com\"}'\n

Solution 4: Check spam folder

Verification emails may be marked as spam. Check: 1. Spam/Junk folder 2. Promotions tab (Gmail) 3. Email filters

"},{"location":"v2/troubleshooting/auth-issues/#prevention_2","title":"Prevention","text":"
  • Clear instructions - Tell users to check spam
  • From address - Use recognizable from address
  • SPF/DKIM/DMARC - Configure email authentication
  • Resend option - Easy way to resend verification

V2 Status

V2 doesn't currently require email verification for login. This section is for future implementation.

"},{"location":"v2/troubleshooting/auth-issues/#token-issues","title":"Token Issues","text":""},{"location":"v2/troubleshooting/auth-issues/#token-expired","title":"Token Expired","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_3","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Token expired\"\n}\n

Or:

Error: jwt expired\n
"},{"location":"v2/troubleshooting/auth-issues/#common-causes_3","title":"Common Causes","text":"
  1. Access token expired - Normal after 15 minutes inactive
  2. Refresh token expired - After 7 days
  3. System clock skew - Server/client time difference
"},{"location":"v2/troubleshooting/auth-issues/#solutions_3","title":"Solutions","text":"

Solution 1: Frontend auto-refresh

Frontend automatically refreshes tokens on 401. If this fails:

// Check refresh token in localStorage\nconst storage = JSON.parse(localStorage.getItem('auth-storage'));\nconsole.log('Has refresh token:', !!storage?.state?.refreshToken);\n\n// If missing, need to log in again\n

Solution 2: Manual refresh

# Refresh token via API\ncurl -X POST http://localhost:4000/api/auth/refresh \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"refreshToken\": \"YOUR_REFRESH_TOKEN\"}'\n\n# Returns new access + refresh tokens\n

Solution 3: Check token expiration

// Decode JWT to see expiration\nconst token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';\nconst payload = JSON.parse(atob(token.split('.')[1]));\nconsole.log('Expires:', new Date(payload.exp * 1000));\nconsole.log('Now:', new Date());\n

Solution 4: Check system time

# On server\ndocker compose exec api date\n\n# Should match actual time\n# If not, sync clock:\nsudo ntpdate -s time.nist.gov\n

Solution 5: Log in again

If refresh token also expired:

  1. You'll be redirected to login automatically
  2. Log in with email/password
  3. New tokens issued
"},{"location":"v2/troubleshooting/auth-issues/#prevention_3","title":"Prevention","text":"
  • Sliding sessions - Auto-refresh extends session
  • Long refresh window - 7-day refresh token validity
  • Activity tracking - Keep track of last activity
  • Clock sync - Keep server time accurate
"},{"location":"v2/troubleshooting/auth-issues/#invalid-token","title":"Invalid Token","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_4","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Invalid token\"\n}\n

Or:

Error: jwt malformed\nError: invalid signature\nError: invalid algorithm\n
"},{"location":"v2/troubleshooting/auth-issues/#common-causes_4","title":"Common Causes","text":"
  1. Corrupted token - LocalStorage corruption
  2. Wrong secret - JWT_ACCESS_SECRET changed
  3. Tampered token - Someone modified the token
  4. Format error - Not a valid JWT
"},{"location":"v2/troubleshooting/auth-issues/#solutions_4","title":"Solutions","text":"

Solution 1: Verify JWT format

Valid JWT has 3 parts separated by dots:

const token = 'header.payload.signature';\nconsole.log(token.split('.').length); // Should be 3\n\n// Example valid token:\n// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.\n// eyJpZCI6IjEyMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInJvbGUiOiJVU0VSIiwiaWF0IjoxNzA4MDAwMDAwLCJleHAiOjE3MDgwMDA5MDB9.\n// signature-here\n

Solution 2: Clear localStorage and re-login

// In browser console\nlocalStorage.clear();\n// Then log in again\n

Solution 3: Verify JWT_ACCESS_SECRET

# Check API .env\ndocker compose exec api sh -c 'echo $JWT_ACCESS_SECRET'\n\n# Should be:\n# - At least 32 characters\n# - Never changed (changing invalidates all tokens)\n# - Different from JWT_REFRESH_SECRET\n

Solution 4: Test token verification

# Test token via API\ncurl http://localhost:4000/api/auth/me \\\n  -H \"Authorization: Bearer YOUR_ACCESS_TOKEN\"\n\n# If returns user, token is valid\n# If 401, token is invalid\n

Solution 5: Check API logs

# View token validation errors\ndocker compose logs api | grep -i \"jwt\\|token\"\n\n# Common errors:\n# - \"jwt malformed\" - not a valid JWT format\n# - \"invalid signature\" - wrong secret or tampered\n# - \"invalid algorithm\" - algorithm mismatch\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention_4","title":"Prevention","text":"
  • Secure secrets - Use strong, random secrets
  • Never change secrets - Changing logs out all users
  • Don't expose secrets - Never commit to git
  • Token validation - Validate before using
"},{"location":"v2/troubleshooting/auth-issues/#token-not-found-in-header","title":"Token Not Found in Header","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_5","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"No token provided\"\n}\n

Or:

{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Invalid authorization header format\"\n}\n
"},{"location":"v2/troubleshooting/auth-issues/#common-causes_5","title":"Common Causes","text":"
  1. Missing Authorization header - Header not sent
  2. Wrong header format - Not \"Bearer token\"
  3. Token not in localStorage - User not logged in
  4. API client misconfigured - axios interceptor not working
"},{"location":"v2/troubleshooting/auth-issues/#solutions_5","title":"Solutions","text":"

Solution 1: Check if logged in

// In browser console\nconst storage = JSON.parse(localStorage.getItem('auth-storage'));\nconsole.log('Access token:', storage?.state?.accessToken);\n\n// If null, not logged in\n

Solution 2: Verify header format

# Correct format\ncurl http://localhost:4000/api/users \\\n  -H \"Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n\n# Wrong formats:\n# -H \"Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"  # Missing \"Bearer\"\n# -H \"Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"       # Wrong header name\n# -H \"Authorization: Token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"  # Wrong prefix\n

Solution 3: Check axios interceptor

In admin/src/lib/api.ts:

// Request interceptor should add token\napi.interceptors.request.use((config) => {\n  const storage = JSON.parse(localStorage.getItem('auth-storage') || '{}');\n  const token = storage?.state?.accessToken;\n\n  if (token) {\n    config.headers.Authorization = `Bearer ${token}`;\n  }\n\n  return config;\n});\n

Solution 4: Test with curl

# Get token from localStorage (browser console)\nconst token = JSON.parse(localStorage.getItem('auth-storage')).state.accessToken;\nconsole.log(token);\n\n# Test with curl\ncurl http://localhost:4000/api/users \\\n  -H \"Authorization: Bearer [paste-token-here]\"\n

Solution 5: Log in again

# If all else fails, log out and log in\nlocalStorage.clear();\n# Navigate to /login\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention_5","title":"Prevention","text":"
  • axios interceptor - Automatically add token to requests
  • Error handling - Redirect to login on 401
  • Token persistence - Store token in localStorage
  • Testing - Test auth flow regularly
"},{"location":"v2/troubleshooting/auth-issues/#refresh-token-invalid","title":"Refresh Token Invalid","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_6","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Invalid refresh token\"\n}\n

Auto-refresh fails, user logged out.

"},{"location":"v2/troubleshooting/auth-issues/#common-causes_6","title":"Common Causes","text":"
  1. Refresh token expired - Older than 7 days
  2. Token revoked - User logged out explicitly
  3. Token not in database - Database was reset
  4. Token rotation - Token already used (consumed)
"},{"location":"v2/troubleshooting/auth-issues/#solutions_6","title":"Solutions","text":"

Solution 1: Check refresh token in database

# Find refresh token\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, \\\"userId\\\", \\\"expiresAt\\\", \\\"createdAt\\\" FROM \\\"RefreshToken\\\"\n      WHERE token = 'YOUR_REFRESH_TOKEN_HASH';\"\n\n# If no result, token doesn't exist (revoked or expired)\n

Solution 2: Check expiration

# Find all refresh tokens for user\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, \\\"expiresAt\\\", \\\"createdAt\\\" FROM \\\"RefreshToken\\\"\n      WHERE \\\"userId\\\" = 'USER_UUID'\n      ORDER BY \\\"createdAt\\\" DESC;\"\n\n# Check if expiresAt < NOW()\n

Solution 3: Log in again

Refresh token can't be renewed. Must log in with email/password:

curl -X POST http://localhost:4000/api/auth/login \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"email\": \"user@example.com\",\n    \"password\": \"SecurePass123!\"\n  }'\n

Solution 4: Clear old refresh tokens

# Delete expired refresh tokens\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"DELETE FROM \\\"RefreshToken\\\" WHERE \\\"expiresAt\\\" < NOW();\"\n\n# Or delete all refresh tokens for user (logs out all devices)\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"DELETE FROM \\\"RefreshToken\\\" WHERE \\\"userId\\\" = 'USER_UUID';\"\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention_6","title":"Prevention","text":"
  • Long expiration - 7-day refresh token validity
  • Token rotation - New refresh token on each refresh
  • Cleanup job - Delete expired tokens periodically
  • Multi-device support - Multiple refresh tokens per user
"},{"location":"v2/troubleshooting/auth-issues/#permission-errors","title":"Permission Errors","text":""},{"location":"v2/troubleshooting/auth-issues/#insufficient-permissions","title":"Insufficient Permissions","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_7","title":"Symptoms","text":"
{\n  \"error\": \"Forbidden\",\n  \"message\": \"Insufficient permissions\"\n}\n

Or role-specific:

{\n  \"error\": \"Forbidden\",\n  \"message\": \"Requires one of: SUPER_ADMIN, MAP_ADMIN\"\n}\n
"},{"location":"v2/troubleshooting/auth-issues/#common-causes_7","title":"Common Causes","text":"
  1. Wrong role - User doesn't have required role
  2. TEMP user - Temporary user trying to access admin features
  3. Feature disabled - Feature flag not enabled
  4. Endpoint restricted - Endpoint requires specific role
"},{"location":"v2/troubleshooting/auth-issues/#solutions_7","title":"Solutions","text":"

Solution 1: Check user role

# View user role\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email, role FROM \\\"User\\\" WHERE email = 'user@example.com';\"\n\n# Roles:\n# SUPER_ADMIN - full access\n# INFLUENCE_ADMIN - campaigns/responses\n# MAP_ADMIN - locations/cuts/shifts\n# USER - public content + canvass\n# TEMP - very limited\n

Solution 2: Update user role

# Via Prisma Studio (recommended)\ndocker compose exec api npx prisma studio\n# Navigate to User table, edit role field\n\n# Or via SQL\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET role = 'MAP_ADMIN' WHERE email = 'user@example.com';\"\n

Solution 3: Check endpoint requirements

In API code (api/src/modules/*/routes.ts):

// Example from campaigns.routes.ts\nrouter.post('/',\n  authenticate,  // Must be logged in\n  requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'),  // Must be admin\n  validate(createCampaignSchema),\n  campaignsController.create\n);\n

Solution 4: Verify feature flags

# Check .env for feature flags\ncat .env | grep ENABLE\n\n# Example:\nENABLE_MEDIA_FEATURES=true\nLISTMONK_SYNC_ENABLED=true\n

Solution 5: Check TEMP user restrictions

TEMP users are created during shift signup and have very limited permissions:

// TEMP users blocked by requireNonTemp middleware\nrouter.get('/my-data',\n  authenticate,\n  requireNonTemp,  // Blocks TEMP users\n  controller.getData\n);\n

To convert TEMP to USER:

docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET role = 'USER' WHERE email = 'temp@example.com';\"\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention_7","title":"Prevention","text":"
  • Clear role descriptions - Document what each role can do
  • Role matrix - Table showing role \u2192 permission mapping
  • Upgrade flow - Easy way for users to upgrade from TEMP to USER
  • Helpful errors - Show which role is required
"},{"location":"v2/troubleshooting/auth-issues/#role-restrictions","title":"Role Restrictions","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_8","title":"Symptoms","text":"

User logged in but can't access certain features.

"},{"location":"v2/troubleshooting/auth-issues/#common-causes_8","title":"Common Causes","text":"
  1. Not enough permissions - Role too low for feature
  2. Feature flag - Feature not enabled
  3. TEMP user - Temporary account with restrictions
"},{"location":"v2/troubleshooting/auth-issues/#solutions_8","title":"Solutions","text":"

Solution 1: View role permissions

Feature SUPER_ADMIN INFLUENCE_ADMIN MAP_ADMIN USER TEMP User management \u2705 \u274c \u274c \u274c \u274c Settings \u2705 \u274c \u274c \u274c \u274c Campaigns \u2705 \u2705 \u274c \u274c \u274c Responses \u2705 \u2705 \u274c \u274c \u274c Email queue \u2705 \u2705 \u274c \u274c \u274c Locations \u2705 \u274c \u2705 \u274c \u274c Cuts \u2705 \u274c \u2705 \u274c \u274c Shifts \u2705 \u274c \u2705 \u274c \u274c Canvass dashboard \u2705 \u274c \u2705 \u274c \u274c Public campaigns \u2705 \u2705 \u2705 \u2705 \u274c Public shifts \u2705 \u2705 \u2705 \u2705 \u2705 Volunteer canvass \u2705 \u2705 \u2705 \u2705 \u274c

Solution 2: Request role upgrade

If you need higher permissions: 1. Contact system administrator 2. Explain why you need the role 3. Wait for approval and role change

Solution 3: Create admin account

For first admin (if none exist):

# Connect to database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2\n\n# Create SUPER_ADMIN\n# (Password must be pre-hashed with bcrypt)\nINSERT INTO \"User\" (id, email, password, name, role)\nVALUES (\n  gen_random_uuid(),\n  'admin@example.com',\n  '$2a$10$...',  -- bcrypt hash of 'Admin123!'\n  'System Admin',\n  'SUPER_ADMIN'\n);\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention_8","title":"Prevention","text":"
  • Document roles - Clear description of each role
  • Role request process - Easy way to request role upgrade
  • Audit trail - Log role changes
  • Principle of least privilege - Give minimum role needed
"},{"location":"v2/troubleshooting/auth-issues/#session-issues","title":"Session Issues","text":""},{"location":"v2/troubleshooting/auth-issues/#session-timeout","title":"Session Timeout","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_9","title":"Symptoms","text":"

User inactive for a while, then gets logged out.

"},{"location":"v2/troubleshooting/auth-issues/#current-behavior","title":"Current Behavior","text":"

V2 uses JWT tokens (not sessions): - Access token expires after 15 minutes - Refresh token expires after 7 days - Auto-refresh on API calls extends session

"},{"location":"v2/troubleshooting/auth-issues/#solutions_9","title":"Solutions","text":"

Solution 1: Configure token expiration

In .env:

# Access token (default: 15m)\nJWT_ACCESS_EXPIRATION=30m\n\n# Refresh token (default: 7d)\nJWT_REFRESH_EXPIRATION=14d\n

Restart API:

docker compose restart api\n

Solution 2: Implement activity tracking

// In frontend, track last activity\nconst updateActivity = () => {\n  localStorage.setItem('lastActivity', Date.now().toString());\n};\n\n// On any user action\ndocument.addEventListener('click', updateActivity);\ndocument.addEventListener('keypress', updateActivity);\n\n// Check on load\nuseEffect(() => {\n  const lastActivity = parseInt(localStorage.getItem('lastActivity') || '0');\n  const now = Date.now();\n  const thirtyMinutes = 30 * 60 * 1000;\n\n  if (now - lastActivity > thirtyMinutes) {\n    // Log out due to inactivity\n    authStore.logout();\n  }\n}, []);\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention_9","title":"Prevention","text":"
  • Sliding sessions - Auto-refresh extends session
  • Long refresh window - 7-day default
  • Activity tracking - Reset timeout on activity
  • Warning before logout - Show countdown before timeout
"},{"location":"v2/troubleshooting/auth-issues/#multiple-device-conflicts","title":"Multiple Device Conflicts","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_10","title":"Symptoms","text":"

User logged in on multiple devices, behavior inconsistent.

"},{"location":"v2/troubleshooting/auth-issues/#current-behavior_1","title":"Current Behavior","text":"

V2 supports multiple devices: - Each login creates new refresh token - All devices stay logged in independently - No device limit by default

"},{"location":"v2/troubleshooting/auth-issues/#solutions_10","title":"Solutions","text":"

Solution 1: View user's devices

# List all refresh tokens for user\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, \\\"createdAt\\\", \\\"expiresAt\\\" FROM \\\"RefreshToken\\\"\n      WHERE \\\"userId\\\" = 'USER_UUID'\n      ORDER BY \\\"createdAt\\\" DESC;\"\n\n# Each row = one device/session\n

Solution 2: Log out all devices

# Delete all refresh tokens for user\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"DELETE FROM \\\"RefreshToken\\\" WHERE \\\"userId\\\" = 'USER_UUID';\"\n\n# User must log in again on all devices\n

Solution 3: Log out specific device

# Delete specific refresh token\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"DELETE FROM \\\"RefreshToken\\\" WHERE id = 'REFRESH_TOKEN_UUID';\"\n

Solution 4: Implement device limit

In api/src/modules/auth/auth.service.ts:

// After creating refresh token, limit to 5 devices\nconst userTokens = await prisma.refreshToken.findMany({\n  where: { userId },\n  orderBy: { createdAt: 'desc' }\n});\n\n// Delete oldest tokens if > 5\nif (userTokens.length > 5) {\n  await prisma.refreshToken.deleteMany({\n    where: {\n      id: { in: userTokens.slice(5).map(t => t.id) }\n    }\n  });\n}\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention_10","title":"Prevention","text":"
  • Device management UI - Show logged-in devices
  • Device limit - Max 5-10 devices per user
  • Device naming - Let users name their devices
  • Remote logout - Let users log out other devices
"},{"location":"v2/troubleshooting/auth-issues/#password-reset-issues","title":"Password Reset Issues","text":""},{"location":"v2/troubleshooting/auth-issues/#reset-link-expired","title":"Reset Link Expired","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_11","title":"Symptoms","text":"
{\n  \"error\": \"Bad Request\",\n  \"message\": \"Password reset link expired or invalid\"\n}\n
"},{"location":"v2/troubleshooting/auth-issues/#common-causes_9","title":"Common Causes","text":"
  1. Link expired - Older than 24 hours
  2. Already used - Link can only be used once
  3. Wrong token - Token doesn't match database
"},{"location":"v2/troubleshooting/auth-issues/#solutions_11","title":"Solutions","text":"

Solution 1: Request new reset link

# Via API (if endpoint exists)\ncurl -X POST http://localhost:4000/api/auth/forgot-password \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"email\": \"user@example.com\"}'\n

Solution 2: Manually reset password

# Generate bcrypt hash\ndocker compose exec api node -e \"\nconst bcrypt = require('bcryptjs');\nconsole.log(bcrypt.hashSync('NewPassword123!', 10));\n\"\n\n# Update password\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET password = '\\$2a\\$10\\$...' WHERE email = 'user@example.com';\"\n
"},{"location":"v2/troubleshooting/auth-issues/#prevention_11","title":"Prevention","text":"
  • Longer expiration - 24-hour expiration is reasonable
  • Clear messaging - Tell users link expires
  • Easy re-request - Simple way to request new link

V2 Status

V2 doesn't currently have password reset flow. This section is for future implementation.

"},{"location":"v2/troubleshooting/auth-issues/#email-not-received","title":"Email Not Received","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_12","title":"Symptoms","text":"

User requests password reset but doesn't receive email.

"},{"location":"v2/troubleshooting/auth-issues/#common-causes_10","title":"Common Causes","text":"
  1. Email in spam - Filtered to spam folder
  2. SMTP issue - Email sending failed
  3. Wrong email - Typo in email address
  4. Email delay - Taking long to deliver
"},{"location":"v2/troubleshooting/auth-issues/#solutions_12","title":"Solutions","text":"

Solution 1: Check spam folder

  1. Check Spam/Junk folder
  2. Check Promotions tab (Gmail)
  3. Check email filters

Solution 2: Check email logs

# API logs show email sending\ndocker compose logs api | grep -i \"email\\|smtp\"\n\n# Should show:\n# Email sent to user@example.com: Password Reset\n

Solution 3: Check MailHog (dev mode)

If EMAIL_TEST_MODE=true:

# Open MailHog\nhttp://localhost:8025\n\n# All emails appear here instead of being sent\n

Solution 4: Test SMTP connection

# Test SMTP via API\ncurl -X POST http://localhost:4000/api/test-email \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\n    \"to\": \"test@example.com\",\n    \"subject\": \"Test\",\n    \"text\": \"Test email\"\n  }'\n

Solution 5: Manually reset password

See \"Manually reset password\" in previous section.

"},{"location":"v2/troubleshooting/auth-issues/#prevention_12","title":"Prevention","text":"
  • Email testing - Test email delivery in production
  • Clear from address - Use recognizable sender
  • SPF/DKIM/DMARC - Configure email authentication
  • Resend option - Easy way to resend email
"},{"location":"v2/troubleshooting/auth-issues/#rate-limiting","title":"Rate Limiting","text":""},{"location":"v2/troubleshooting/auth-issues/#too-many-login-attempts","title":"Too Many Login Attempts","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_13","title":"Symptoms","text":"
{\n  \"error\": \"Too Many Requests\",\n  \"message\": \"Too many login attempts. Please try again later.\"\n}\n
"},{"location":"v2/troubleshooting/auth-issues/#common-causes_11","title":"Common Causes","text":"
  1. Too many failed logins - More than 10/minute
  2. Automated attack - Bot trying to brute-force
  3. Shared IP - Multiple users behind same NAT
"},{"location":"v2/troubleshooting/auth-issues/#solutions_13","title":"Solutions","text":"

Solution 1: Wait and retry

Rate limit is per IP address: - Limit: 10 requests per minute - Window: 1 minute - Action: Wait 1 minute, then try again

Solution 2: Check Redis rate limit

# Connect to Redis\ndocker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD\n\n# Find rate limit keys\nKEYS rl:auth:*\n\n# Check specific IP\nGET rl:auth:192.168.1.100\n\n# Shows number of requests in current window\n

Solution 3: Clear rate limit (admin)

# Delete rate limit key for IP\ndocker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD DEL rl:auth:192.168.1.100\n\n# Or clear all auth rate limits\ndocker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD --scan --pattern \"rl:auth:*\" | xargs docker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD DEL\n

Solution 4: Adjust rate limit

In api/src/middleware/rate-limit.ts:

export const authRateLimit = rateLimit({\n  windowMs: 60 * 1000,  // 1 minute\n  max: 10,  // 10 requests per minute\n  message: 'Too many login attempts. Please try again later.',\n  standardHeaders: true,\n  legacyHeaders: false,\n});\n\n// Increase to 20/minute:\nmax: 20,\n

Solution 5: Use different IP

If behind NAT with many users: 1. Use VPN 2. Use mobile network 3. Contact administrator to whitelist IP

"},{"location":"v2/troubleshooting/auth-issues/#prevention_13","title":"Prevention","text":"
  • Reasonable limits - 10/min is reasonable
  • Per-account limit - Also limit by email (not just IP)
  • CAPTCHA - Add CAPTCHA after 3 failed attempts
  • Account lockout - Lock account after 10 failed attempts
"},{"location":"v2/troubleshooting/auth-issues/#account-temporarily-locked","title":"Account Temporarily Locked","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/auth-issues/#symptoms_14","title":"Symptoms","text":"
{\n  \"error\": \"Forbidden\",\n  \"message\": \"Account temporarily locked due to too many failed login attempts. Please try again in 30 minutes.\"\n}\n
"},{"location":"v2/troubleshooting/auth-issues/#solutions_14","title":"Solutions","text":"

Solution 1: Wait for unlock

Accounts auto-unlock after lockout period (default: 30 minutes).

Solution 2: Manually unlock (admin)

# If lockout implemented in database:\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET \\\"lockedUntil\\\" = NULL WHERE email = 'user@example.com';\"\n

Solution 3: Contact administrator

If you're a user: 1. Contact system administrator 2. Verify your identity 3. Request account unlock

"},{"location":"v2/troubleshooting/auth-issues/#prevention_14","title":"Prevention","text":"
  • Reasonable threshold - 10 failed attempts is reasonable
  • Automatic unlock - Auto-unlock after time period
  • Email notification - Notify user of lockout
  • Appeal process - Way to appeal false positive

V2 Status

V2 doesn't currently have account lockout. This section is for future implementation.

"},{"location":"v2/troubleshooting/auth-issues/#debugging-auth","title":"Debugging Auth","text":""},{"location":"v2/troubleshooting/auth-issues/#checking-jwt-payload","title":"Checking JWT Payload","text":"

Severity: \ud83d\udfe2 Low (informational)

"},{"location":"v2/troubleshooting/auth-issues/#how-to-decode-jwt","title":"How to Decode JWT","text":"
// In browser console\nconst token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInJvbGUiOiJVU0VSIiwiaWF0IjoxNzA4MDAwMDAwLCJleHAiOjE3MDgwMDA5MDB9.signature';\n\n// Decode header\nconst header = JSON.parse(atob(token.split('.')[0]));\nconsole.log('Header:', header);\n// { alg: 'HS256', typ: 'JWT' }\n\n// Decode payload\nconst payload = JSON.parse(atob(token.split('.')[1]));\nconsole.log('Payload:', payload);\n// {\n//   id: '123',\n//   email: 'test@example.com',\n//   role: 'USER',\n//   iat: 1708000000,  // Issued at (Unix timestamp)\n//   exp: 1708000900   // Expires at (Unix timestamp)\n// }\n\n// Check expiration\nconsole.log('Issued:', new Date(payload.iat * 1000));\nconsole.log('Expires:', new Date(payload.exp * 1000));\nconsole.log('Is expired:', Date.now() > payload.exp * 1000);\n
"},{"location":"v2/troubleshooting/auth-issues/#verifying-refresh-tokens","title":"Verifying Refresh Tokens","text":""},{"location":"v2/troubleshooting/auth-issues/#check-refresh-token-in-database","title":"Check Refresh Token in Database","text":"
# Find all refresh tokens for user\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT rt.id, rt.\\\"createdAt\\\", rt.\\\"expiresAt\\\", u.email\n      FROM \\\"RefreshToken\\\" rt\n      JOIN \\\"User\\\" u ON rt.\\\"userId\\\" = u.id\n      WHERE u.email = 'user@example.com'\n      ORDER BY rt.\\\"createdAt\\\" DESC;\"\n\n# Output:\n# id                                   | createdAt            | expiresAt            | email\n# uuid-here                            | 2026-02-10 10:00:00 | 2026-02-17 10:00:00 | user@example.com\n\n# Check if expired:\n# SELECT id FROM \"RefreshToken\" WHERE id = 'uuid' AND \"expiresAt\" > NOW();\n
"},{"location":"v2/troubleshooting/auth-issues/#checking-user-status","title":"Checking User Status","text":""},{"location":"v2/troubleshooting/auth-issues/#view-user-details","title":"View User Details","text":"
# Full user details\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, email, name, role, \\\"createdAt\\\", \\\"updatedAt\\\"\n      FROM \\\"User\\\"\n      WHERE email = 'user@example.com';\"\n\n# Check active sessions\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT COUNT(*) as active_sessions\n      FROM \\\"RefreshToken\\\" rt\n      JOIN \\\"User\\\" u ON rt.\\\"userId\\\" = u.id\n      WHERE u.email = 'user@example.com'\n        AND rt.\\\"expiresAt\\\" > NOW();\"\n
"},{"location":"v2/troubleshooting/auth-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/auth-issues/#authentication-documentation","title":"Authentication Documentation","text":"
  • Auth Issues - This guide
  • API Reference - Auth endpoints
  • Security Audit - Security improvements
"},{"location":"v2/troubleshooting/auth-issues/#other-troubleshooting","title":"Other Troubleshooting","text":"
  • Common Errors - General errors
  • Database Issues - Database problems
  • Email Issues - Email and password reset
"},{"location":"v2/troubleshooting/auth-issues/#security-resources","title":"Security Resources","text":"
  • OWASP Authentication Cheat Sheet
  • JWT Best Practices
  • bcrypt

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/troubleshooting/common-errors/","title":"Common Errors and Solutions","text":"

This guide covers the most frequently encountered errors in Changemaker Lite V2 and their solutions.

"},{"location":"v2/troubleshooting/common-errors/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/common-errors/#how-to-use-this-guide","title":"How to Use This Guide","text":"
  1. Find your error - Use the error code or message to locate the section
  2. Diagnose - Read the symptoms and causes
  3. Apply solution - Follow step-by-step instructions
  4. Prevent recurrence - Implement preventive measures
"},{"location":"v2/troubleshooting/common-errors/#error-severity-levels","title":"Error Severity Levels","text":"Level Icon Meaning Action Critical \ud83d\udd34 System down or data at risk Fix immediately High \ud83d\udfe0 Feature unavailable Fix within hours Medium \ud83d\udfe1 Degraded performance Fix within days Low \ud83d\udfe2 Minor inconvenience Fix when convenient"},{"location":"v2/troubleshooting/common-errors/#quick-error-lookup","title":"Quick Error Lookup","text":"Error Code Category Page 401 Authentication Link 403 Authorization Link 404 Not Found Link 422 Validation Link 500 Server Error Link CORS Frontend Link ECONNREFUSED Database Link"},{"location":"v2/troubleshooting/common-errors/#authentication-errors","title":"Authentication Errors","text":""},{"location":"v2/troubleshooting/common-errors/#401-unauthorized","title":"401 Unauthorized","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/common-errors/#symptoms","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Invalid or missing token\"\n}\n

Browser console:

Error: Request failed with status code 401\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes","title":"Common Causes","text":"
  1. Missing token - No Authorization header sent
  2. Expired token - Access token older than 15 minutes
  3. Invalid token - Corrupted or tampered token
  4. Wrong environment - Token from dev used in production
"},{"location":"v2/troubleshooting/common-errors/#solutions","title":"Solutions","text":"

Solution 1: Check if logged in

// In browser console\nconsole.log(localStorage.getItem('auth-storage'));\n

If null or missing accessToken, you need to log in again.

Solution 2: Refresh token

The frontend automatically refreshes tokens. If this fails:

  1. Log out completely
  2. Clear localStorage: localStorage.clear()
  3. Log in again

Solution 3: Verify API configuration

Check admin/.env:

VITE_API_URL=http://localhost:4000  # Must match actual API URL\n

Solution 4: Check token expiration

// In browser console\nconst storage = JSON.parse(localStorage.getItem('auth-storage'));\nconst payload = JSON.parse(atob(storage.state.accessToken.split('.')[1]));\nconsole.log('Token expires:', new Date(payload.exp * 1000));\nconsole.log('Current time:', new Date());\n

If expired, the refresh interceptor should handle this. If not working:

# Check API logs\ndocker compose logs api | grep \"refresh\"\n
"},{"location":"v2/troubleshooting/common-errors/#prevention","title":"Prevention","text":"
  • Auto-refresh works - Frontend handles token refresh automatically
  • Long sessions - Refresh tokens valid for 7 days
  • Activity-based - Tokens refresh on API calls
  • Clear error handling - Frontend redirects to login on failure

Security Note

401 errors may return generic messages to prevent user enumeration. This is intentional security behavior.

"},{"location":"v2/troubleshooting/common-errors/#403-forbidden","title":"403 Forbidden","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/common-errors/#symptoms_1","title":"Symptoms","text":"
{\n  \"error\": \"Forbidden\",\n  \"message\": \"Insufficient permissions\"\n}\n

Or role-specific:

{\n  \"error\": \"Forbidden\",\n  \"message\": \"Requires one of: SUPER_ADMIN, MAP_ADMIN\"\n}\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_1","title":"Common Causes","text":"
  1. Wrong role - User lacks required role
  2. TEMP user - Temporary users restricted from most features
  3. Feature disabled - Feature flag not enabled
  4. Wrong endpoint - Using admin endpoint as public user
"},{"location":"v2/troubleshooting/common-errors/#solutions_1","title":"Solutions","text":"

Solution 1: Check user role

# In database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email, role FROM \\\"User\\\" WHERE email = 'your@email.com';\"\n

Solution 2: Update user role

-- Via Prisma Studio (recommended)\ndocker compose exec api npx prisma studio\n-- Navigate to User table, edit role\n\n-- Or via SQL\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET role = 'MAP_ADMIN' WHERE email = 'your@email.com';\"\n

Solution 3: Check feature flags

# In API logs\ndocker compose logs api | grep \"ENABLE_\"\n\n# Check .env\ncat .env | grep ENABLE\n

Solution 4: Verify endpoint permissions

Check api/src/modules/*/routes.ts:

// Admin endpoint\nrouter.post('/', authenticate, requireRole('SUPER_ADMIN'), ...);\n\n// Public endpoint (no auth)\nrouter.get('/public', ...);\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_1","title":"Prevention","text":"
  • Role-based access control - Clear role hierarchy
  • Explicit permissions - Each endpoint lists required roles
  • Audit trail - Track permission changes
  • Documentation - Role matrix in Access Control
"},{"location":"v2/troubleshooting/common-errors/#invalid-token","title":"Invalid Token","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/common-errors/#symptoms_2","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Invalid token\"\n}\n

Or in API logs:

Error: jwt malformed\nError: invalid signature\nError: jwt must be provided\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_2","title":"Common Causes","text":"
  1. Corrupted token - LocalStorage corruption
  2. Wrong secret - JWT_ACCESS_SECRET changed
  3. Modified token - Attempted tampering
  4. Format error - Not a valid JWT structure
"},{"location":"v2/troubleshooting/common-errors/#solutions_2","title":"Solutions","text":"

Solution 1: Clear and re-login

// In browser console\nlocalStorage.clear();\n// Then log in again\n

Solution 2: Verify JWT structure

Valid JWT has 3 parts separated by dots:

const token = 'header.payload.signature';\nconsole.log(token.split('.').length); // Should be 3\n

Solution 3: Check secret configuration

# In .env\nJWT_ACCESS_SECRET=your-secret-here-32-chars-min\nJWT_REFRESH_SECRET=different-secret-here-32-chars-min\n\n# Secrets must:\n# - Be different from each other\n# - Be at least 32 characters\n# - Remain unchanged (changing invalidates all tokens)\n

Solution 4: Verify token in logs

# API logs show token validation errors\ndocker compose logs api | tail -100 | grep \"jwt\"\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_2","title":"Prevention","text":"
  • Secure secrets - Use openssl rand -hex 32
  • Never commit secrets - Keep in .env (gitignored)
  • Rotate carefully - Changing secrets logs out all users
  • Monitor errors - Alert on spike in invalid token errors
"},{"location":"v2/troubleshooting/common-errors/#token-expired","title":"Token Expired","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_3","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Token expired\"\n}\n

Or:

Error: jwt expired\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_3","title":"Common Causes","text":"
  1. Access token expired - Normal after 15 minutes of inactivity
  2. Refresh token expired - Refresh token older than 7 days
  3. System clock skew - Server/client time mismatch
  4. Refresh failed - Refresh token invalid or revoked
"},{"location":"v2/troubleshooting/common-errors/#solutions_3","title":"Solutions","text":"

Solution 1: Automatic refresh

Frontend automatically refreshes tokens on 401. If this fails:

// Check refresh token in localStorage\nconst storage = JSON.parse(localStorage.getItem('auth-storage'));\nconsole.log('Has refresh token:', !!storage?.state?.refreshToken);\n

Solution 2: Manual login

If refresh token expired (after 7 days):

  1. You'll be redirected to login automatically
  2. Log in with email/password
  3. New tokens issued

Solution 3: Check system time

# On server\ndate\n\n# Sync if incorrect\nsudo ntpdate -s time.nist.gov\n

Solution 4: Verify token expiration

# In API logs\ndocker compose logs api | grep \"expired\"\n\n# Check token age\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email, \\\"createdAt\\\", \\\"expiresAt\\\" FROM \\\"RefreshToken\\\" ORDER BY \\\"createdAt\\\" DESC LIMIT 10;\"\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_3","title":"Prevention","text":"
  • Sliding sessions - Tokens auto-refresh on activity
  • Long refresh window - 7-day refresh token validity
  • Graceful handling - Automatic re-login redirect
  • Activity tracking - Monitor token refresh patterns

Developer Tip

During development, use longer token expiration in .env:

JWT_ACCESS_EXPIRATION=1d  # Instead of 15m\nJWT_REFRESH_EXPIRATION=30d  # Instead of 7d\n

"},{"location":"v2/troubleshooting/common-errors/#user-not-found","title":"User Not Found","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_4","title":"Symptoms","text":"
{\n  \"error\": \"Unauthorized\",\n  \"message\": \"Invalid credentials\"\n}\n

Note: Same message for both \"user not found\" and \"wrong password\" (security feature).

"},{"location":"v2/troubleshooting/common-errors/#common-causes_4","title":"Common Causes","text":"
  1. Wrong email - Typo in email address
  2. User deleted - Account removed from database
  3. Wrong database - Connected to wrong environment
  4. Case sensitivity - Email stored differently
"},{"location":"v2/troubleshooting/common-errors/#solutions_4","title":"Solutions","text":"

Solution 1: Verify user exists

# Check database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email, role FROM \\\"User\\\" WHERE email ILIKE '%search%';\"\n

Solution 2: Check email format

Emails are stored lowercase:

-- Find user case-insensitive\nSELECT * FROM \"User\" WHERE LOWER(email) = LOWER('User@Example.com');\n

Solution 3: Create user if missing

# Via API\ncurl -X POST http://localhost:4000/api/auth/register \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"email\": \"user@example.com\",\n    \"password\": \"SecurePass123!\",\n    \"name\": \"User Name\"\n  }'\n\n# Or via admin UI at /app/users\n

Solution 4: Check database connection

# Verify correct database\ndocker compose exec api npx prisma db pull\n\n# Check DATABASE_URL in .env\ncat .env | grep DATABASE_URL\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_4","title":"Prevention","text":"
  • Email validation - Enforce valid email format
  • Case normalization - Store emails lowercase
  • Soft deletes - Consider flagging instead of deleting
  • Audit trail - Log user deletions
"},{"location":"v2/troubleshooting/common-errors/#api-errors","title":"API Errors","text":""},{"location":"v2/troubleshooting/common-errors/#500-internal-server-error","title":"500 Internal Server Error","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/common-errors/#symptoms_5","title":"Symptoms","text":"
{\n  \"error\": \"Internal Server Error\",\n  \"message\": \"An unexpected error occurred\"\n}\n

Or frontend error:

Error: Request failed with status code 500\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_5","title":"Common Causes","text":"
  1. Unhandled exception - Code threw unexpected error
  2. Database error - Query failed
  3. Missing environment variable - Required config missing
  4. Type error - Runtime type mismatch
"},{"location":"v2/troubleshooting/common-errors/#solutions_5","title":"Solutions","text":"

Solution 1: Check API logs

# View recent logs\ndocker compose logs api --tail=100\n\n# Follow logs in real-time\ndocker compose logs -f api\n\n# Search for errors\ndocker compose logs api | grep -i error | tail -20\n

Solution 2: Common error patterns

// Missing environment variable\nError: SMTP_HOST is required\n// Solution: Add to .env\n\n// Database connection error\nError: Can't reach database server at `v2-postgres:5432`\n// Solution: Check database is running\n\n// Type error\nTypeError: Cannot read property 'id' of undefined\n// Solution: Check code for null checks\n

Solution 3: Restart API

# Restart API container\ndocker compose restart api\n\n# Or rebuild if code changed\ndocker compose up -d --build api\n

Solution 4: Enable debug logging

# In .env\nLOG_LEVEL=debug\n\n# Restart API\ndocker compose restart api\n\n# Check detailed logs\ndocker compose logs api\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_5","title":"Prevention","text":"
  • Error handling - Try/catch in all routes
  • Input validation - Validate all inputs with Zod
  • Type safety - Use TypeScript strictly
  • Health checks - Monitor API health
  • Alerting - Set up alerts for 500 errors

Production Alert

500 errors indicate bugs. Always investigate and fix root cause.

"},{"location":"v2/troubleshooting/common-errors/#400-bad-request","title":"400 Bad Request","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_6","title":"Symptoms","text":"
{\n  \"error\": \"Bad Request\",\n  \"message\": \"Invalid request format\"\n}\n

Or with validation details:

{\n  \"error\": \"Bad Request\",\n  \"message\": \"Validation failed: 2 errors\"\n}\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_6","title":"Common Causes","text":"
  1. Invalid JSON - Malformed request body
  2. Wrong Content-Type - Missing or incorrect header
  3. Missing required field - Required parameter not sent
  4. Invalid data type - String sent for number field
"},{"location":"v2/troubleshooting/common-errors/#solutions_6","title":"Solutions","text":"

Solution 1: Check request format

// Correct format\nconst response = await api.post('/api/users', {\n  email: 'user@example.com',\n  password: 'SecurePass123!',\n  name: 'User Name'\n}, {\n  headers: {\n    'Content-Type': 'application/json'\n  }\n});\n\n// Common mistakes:\n// \u274c Missing Content-Type header\n// \u274c Sending FormData to JSON endpoint\n// \u274c Malformed JSON (trailing comma, unquoted keys)\n

Solution 2: Validate against schema

Check API schema in api/src/modules/*/schemas.ts:

// Example: User creation schema\nexport const createUserSchema = z.object({\n  email: z.string().email(),\n  password: z.string().min(12),\n  name: z.string().min(1),\n  role: z.enum(['USER', 'MAP_ADMIN', 'INFLUENCE_ADMIN', 'SUPER_ADMIN']).optional()\n});\n

Solution 3: Check API logs for details

# Logs show validation errors\ndocker compose logs api | grep \"Validation failed\"\n\n# Example output:\n# Validation failed: {\n#   \"email\": \"Invalid email format\",\n#   \"password\": \"Must be at least 12 characters\"\n# }\n

Solution 4: Test with curl

# Test request\ncurl -X POST http://localhost:4000/api/users \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\n    \"email\": \"test@example.com\",\n    \"password\": \"SecurePass123!\",\n    \"name\": \"Test User\"\n  }'\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_6","title":"Prevention","text":"
  • Client-side validation - Validate before sending
  • TypeScript types - Use generated types from API
  • Schema documentation - Document all endpoints
  • Error messages - Clear validation error messages
"},{"location":"v2/troubleshooting/common-errors/#404-not-found","title":"404 Not Found","text":"

Severity: \ud83d\udfe2 Low to \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_7","title":"Symptoms","text":"
{\n  \"error\": \"Not Found\",\n  \"message\": \"Resource not found\"\n}\n

Or specific:

{\n  \"error\": \"Not Found\",\n  \"message\": \"Campaign not found\"\n}\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_7","title":"Common Causes","text":"
  1. Wrong ID - Resource doesn't exist
  2. Wrong URL - Typo in endpoint path
  3. Deleted resource - Resource was deleted
  4. Wrong HTTP method - GET instead of POST
"},{"location":"v2/troubleshooting/common-errors/#solutions_7","title":"Solutions","text":"

Solution 1: Verify resource exists

# Check database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, name FROM \\\"Campaign\\\" WHERE id = 'YOUR_ID';\"\n

Solution 2: Check URL format

// Correct formats\nGET /api/campaigns/:id          // Single campaign\nGET /api/campaigns              // List campaigns\nPOST /api/campaigns             // Create campaign\nPUT /api/campaigns/:id          // Update campaign\nDELETE /api/campaigns/:id       // Delete campaign\n\n// Common mistakes:\n// \u274c /api/campaign/:id (singular, should be plural)\n// \u274c /api/campaigns/id/:id (extra 'id/' in path)\n// \u274c /api/campaign (wrong singular/plural)\n

Solution 3: Check route registration

# API logs show registered routes on startup\ndocker compose logs api | grep \"Registered route\"\n\n# Or check routes file\ncat api/src/modules/*/routes.ts\n

Solution 4: Test endpoint

# List all campaigns to verify endpoint\ncurl http://localhost:4000/api/campaigns\n\n# Test specific ID\ncurl http://localhost:4000/api/campaigns/YOUR_ID\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_7","title":"Prevention","text":"
  • UUID validation - Validate ID format before querying
  • Soft deletes - Flag as deleted instead of removing
  • Resource existence checks - Verify before operations
  • Clear error messages - Specify which resource not found
"},{"location":"v2/troubleshooting/common-errors/#422-unprocessable-entity","title":"422 Unprocessable Entity","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_8","title":"Symptoms","text":"
{\n  \"error\": \"Unprocessable Entity\",\n  \"message\": \"Validation failed\",\n  \"details\": {\n    \"email\": \"Email already exists\",\n    \"password\": \"Must contain uppercase, lowercase, and digit\"\n  }\n}\n
"},{"location":"v2/troubleshooting/common-errors/#common-causes_8","title":"Common Causes","text":"
  1. Business logic violation - Email already exists
  2. Data integrity - Foreign key doesn't exist
  3. Complex validation - Password requirements not met
  4. State conflict - Can't delete resource in use
"},{"location":"v2/troubleshooting/common-errors/#solutions_8","title":"Solutions","text":"

Solution 1: Read validation details

The details field shows exactly what's wrong:

try {\n  await api.post('/api/users', userData);\n} catch (error) {\n  if (error.response?.status === 422) {\n    console.log('Validation errors:', error.response.data.details);\n    // Show to user field-by-field\n  }\n}\n

Solution 2: Check constraints

# Email uniqueness\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT email FROM \\\"User\\\" WHERE email = 'test@example.com';\"\n\n# Foreign key exists\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id FROM \\\"Campaign\\\" WHERE id = 'CAMPAIGN_ID';\"\n

Solution 3: Fix data

Common fixes:

// Email already exists \u2192 Use different email\nemail: 'newuser@example.com'\n\n// Password too weak \u2192 Meet requirements\npassword: 'SecurePass123!'  // 12+ chars, upper, lower, digit\n\n// Foreign key missing \u2192 Create parent first\n// Create campaign before creating email\n\n// Resource in use \u2192 Delete dependents first\n// Delete locations before deleting cut\n

Solution 4: Check database schema

# View constraints\ndocker compose exec api npx prisma studio\n# Navigate to model, see unique fields and relations\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_8","title":"Prevention","text":"
  • Client validation - Check constraints before submitting
  • Clear requirements - Document validation rules
  • Helpful messages - Explain how to fix
  • Cascade deletes - Auto-delete dependents when safe
"},{"location":"v2/troubleshooting/common-errors/#database-errors","title":"Database Errors","text":""},{"location":"v2/troubleshooting/common-errors/#connection-refused","title":"Connection Refused","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/common-errors/#symptoms_9","title":"Symptoms","text":"
Error: connect ECONNREFUSED 127.0.0.1:5433\n

Or:

Error: Can't reach database server at `v2-postgres:5432`\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_9","title":"Common Causes","text":"
  1. Database not running - Container stopped
  2. Wrong connection string - Incorrect host/port
  3. Network issue - Container can't reach database
  4. Port conflict - Port already in use
"},{"location":"v2/troubleshooting/common-errors/#solutions_9","title":"Solutions","text":"

Solution 1: Check database status

# List running containers\ndocker compose ps\n\n# Database should show as \"running\"\n# If not:\ndocker compose up -d v2-postgres\n

Solution 2: Verify connection string

# Check .env\ncat .env | grep DATABASE_URL\n\n# Should be (from API container):\nDATABASE_URL=\"postgresql://changemaker:PASSWORD@v2-postgres:5432/changemaker_v2\"\n\n# Or (from host):\nDATABASE_URL=\"postgresql://changemaker:PASSWORD@localhost:5433/changemaker_v2\"\n

Solution 3: Check database logs

# View database logs\ndocker compose logs v2-postgres\n\n# Look for:\n# - \"database system is ready to accept connections\" (good)\n# - \"FATAL: password authentication failed\" (bad - wrong password)\n# - \"port 5432 already in use\" (bad - port conflict)\n

Solution 4: Test connection manually

# From host\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"SELECT NOW();\"\n\n# Should return current timestamp\n# If fails, database isn't running properly\n

Solution 5: Restart database

# Restart database container\ndocker compose restart v2-postgres\n\n# Or recreate if corrupted\ndocker compose down v2-postgres\ndocker compose up -d v2-postgres\n\n# Wait for \"ready to accept connections\" message\ndocker compose logs -f v2-postgres\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_9","title":"Prevention","text":"
  • Health checks - Monitor database availability
  • Auto-restart - Configure restart policy
  • Connection pooling - Handle transient failures
  • Alerting - Alert on connection failures
"},{"location":"v2/troubleshooting/common-errors/#too-many-connections","title":"Too Many Connections","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/common-errors/#symptoms_10","title":"Symptoms","text":"
Error: too many connections for database \"changemaker_v2\"\n

Or:

Error: Prepared statement \"prisma_xxx\" already exists\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_10","title":"Common Causes","text":"
  1. Connection leak - Connections not released
  2. Pool too small - Not enough connections for load
  3. Long-running queries - Blocking connections
  4. Multiple clients - Too many Prisma instances
"},{"location":"v2/troubleshooting/common-errors/#solutions_10","title":"Solutions","text":"

Solution 1: Check active connections

# View connections\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT count(*) FROM pg_stat_activity WHERE datname = 'changemaker_v2';\"\n\n# PostgreSQL default max: 100 connections\n# Prisma default pool: 10 connections\n

Solution 2: Kill idle connections

-- Find idle connections\nSELECT pid, usename, state, query_start\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2' AND state = 'idle';\n\n-- Kill specific connection\nSELECT pg_terminate_backend(PID_HERE);\n\n-- Kill all idle connections (careful!)\nSELECT pg_terminate_backend(pid)\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2' AND state = 'idle';\n

Solution 3: Adjust connection pool

In api/prisma/schema.prisma:

datasource db {\n  provider = \"postgresql\"\n  url      = env(\"DATABASE_URL\")\n  // Add connection pool config\n  // connectionLimit = 10  // Default\n}\n

Or via DATABASE_URL:

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

Solution 4: Restart API

# Restart releases all connections\ndocker compose restart api\n\n# Check if connections cleared\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT count(*) FROM pg_stat_activity WHERE datname = 'changemaker_v2';\"\n

Solution 5: Increase PostgreSQL max connections

In docker-compose.yml:

v2-postgres:\n  # ...\n  command: postgres -c max_connections=200\n

Then restart:

docker compose up -d v2-postgres\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_10","title":"Prevention","text":"
  • Proper cleanup - Always close Prisma clients
  • Connection pooling - Use appropriate pool size
  • Monitor connections - Alert on high usage
  • Query optimization - Reduce long-running queries
"},{"location":"v2/troubleshooting/common-errors/#unique-constraint-violation","title":"Unique Constraint Violation","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_11","title":"Symptoms","text":"
Error: Unique constraint failed on the fields: (`email`)\n

Or:

PrismaClientKnownRequestError:\nUnique constraint failed on the constraint: `User_email_key`\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_11","title":"Common Causes","text":"
  1. Duplicate email - User already exists
  2. Race condition - Two creates at same time
  3. Case sensitivity - Email differs only in case
  4. Retry logic - Request sent multiple times
"},{"location":"v2/troubleshooting/common-errors/#solutions_11","title":"Solutions","text":"

Solution 1: Check existing records

# Find duplicate\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, email, \\\"createdAt\\\" FROM \\\"User\\\" WHERE email = 'duplicate@example.com';\"\n

Solution 2: Update instead of create

// Instead of:\nawait prisma.user.create({ data: { email, ... } });\n\n// Use upsert:\nawait prisma.user.upsert({\n  where: { email },\n  update: { name, ... },\n  create: { email, name, ... }\n});\n

Solution 3: Handle error gracefully

try {\n  await prisma.user.create({ data });\n} catch (error) {\n  if (error.code === 'P2002') {\n    // Unique constraint violation\n    const field = error.meta?.target?.[0];\n    throw new Error(`${field} already exists`);\n  }\n  throw error;\n}\n

Solution 4: Delete duplicate

# If truly duplicate, delete one\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"DELETE FROM \\\"User\\\" WHERE id = 'ID_TO_DELETE';\"\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_11","title":"Prevention","text":"
  • Check before create - Query first to check existence
  • Use upsert - Update or create atomically
  • Unique indexes - Database enforces uniqueness
  • Case normalization - Store emails lowercase
"},{"location":"v2/troubleshooting/common-errors/#foreign-key-constraint","title":"Foreign Key Constraint","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_12","title":"Symptoms","text":"
Error: Foreign key constraint failed on the field: `campaignId`\n

Or:

Error: An operation failed because it depends on one or more records that were required but not found. Record to update not found.\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_12","title":"Common Causes","text":"
  1. Parent doesn't exist - Referenced record missing
  2. Wrong ID - Typo in foreign key value
  3. Delete order - Trying to delete parent before children
  4. Null constraint - Foreign key required but null provided
"},{"location":"v2/troubleshooting/common-errors/#solutions_12","title":"Solutions","text":"

Solution 1: Verify parent exists

# Check campaign exists\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, name FROM \\\"Campaign\\\" WHERE id = 'CAMPAIGN_ID';\"\n

Solution 2: Create parent first

// Create campaign first\nconst campaign = await prisma.campaign.create({\n  data: { name: 'My Campaign', ... }\n});\n\n// Then create email with campaignId\nconst email = await prisma.campaignEmail.create({\n  data: {\n    campaignId: campaign.id,  // Use created campaign's ID\n    ...\n  }\n});\n

Solution 3: Delete children first

// Delete all emails in campaign\nawait prisma.campaignEmail.deleteMany({\n  where: { campaignId }\n});\n\n// Then delete campaign\nawait prisma.campaign.delete({\n  where: { id: campaignId }\n});\n\n// Or use cascade delete in schema:\n// @@relation(onDelete: Cascade)\n

Solution 4: Use transactions

// Ensure atomicity\nawait prisma.$transaction([\n  prisma.campaignEmail.deleteMany({ where: { campaignId } }),\n  prisma.campaign.delete({ where: { id: campaignId } })\n]);\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_12","title":"Prevention","text":"
  • Cascade deletes - Configure in schema where appropriate
  • Soft deletes - Flag as deleted instead of removing
  • Validation - Check foreign keys exist before creating
  • Transactions - Use for multi-step operations
"},{"location":"v2/troubleshooting/common-errors/#frontend-errors","title":"Frontend Errors","text":""},{"location":"v2/troubleshooting/common-errors/#network-error","title":"Network Error","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/common-errors/#symptoms_13","title":"Symptoms","text":"

Browser console:

Error: Network Error\n

Or:

AxiosError: Request failed with status code undefined\n

User sees: API request fails, loading spinner never stops.

"},{"location":"v2/troubleshooting/common-errors/#common-causes_13","title":"Common Causes","text":"
  1. API down - API container not running
  2. Wrong API URL - VITE_API_URL misconfigured
  3. CORS issue - Browser blocking request
  4. Network timeout - Request taking too long
"},{"location":"v2/troubleshooting/common-errors/#solutions_13","title":"Solutions","text":"

Solution 1: Check API status

# Is API running?\ndocker compose ps api\n\n# Check API logs\ndocker compose logs api --tail=50\n\n# Test API directly\ncurl http://localhost:4000/api/health\n

Solution 2: Verify API URL

# Check admin .env\ncat admin/.env\n\n# Should have:\nVITE_API_URL=http://localhost:4000\n\n# In Docker, use:\nVITE_API_URL=http://api:4000\n

Solution 3: Check browser console

Press F12, check:

  • Network tab - Does request appear? What's the status?
  • Console tab - Any CORS errors?

Solution 4: Test from different client

# From command line\ncurl http://localhost:4000/api/campaigns\n\n# If this works but browser doesn't, it's a CORS issue\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_13","title":"Prevention","text":"
  • Health checks - Monitor API availability
  • Error boundaries - Catch and display network errors
  • Retry logic - Auto-retry failed requests
  • Offline detection - Detect and handle offline state
"},{"location":"v2/troubleshooting/common-errors/#cors-errors","title":"CORS Errors","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/common-errors/#symptoms_14","title":"Symptoms","text":"

Browser console:

Access to XMLHttpRequest at 'http://localhost:4000/api/users' from origin\n'http://localhost:3000' has been blocked by CORS policy: No\n'Access-Control-Allow-Origin' header is present on the requested resource.\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_14","title":"Common Causes","text":"
  1. Missing CORS config - API not configured for CORS
  2. Wrong origin - Admin URL not in allowed origins
  3. Credentials flag - withCredentials set but not allowed
  4. Preflight failure - OPTIONS request failing
"},{"location":"v2/troubleshooting/common-errors/#solutions_14","title":"Solutions","text":"

Solution 1: Check API CORS configuration

In api/src/server.ts:

app.use(cors({\n  origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'],\n  credentials: true\n}));\n

Solution 2: Verify CORS_ORIGINS

# Check .env\ncat .env | grep CORS_ORIGINS\n\n# Should include admin URL:\nCORS_ORIGINS=http://localhost:3000,https://app.cmlite.org\n

Solution 3: Add origin temporarily

For development:

# In .env\nCORS_ORIGINS=*  # Allow all origins (dev only!)\n\n# Restart API\ndocker compose restart api\n

Solution 4: Check preflight request

In browser Network tab:

  1. Find OPTIONS request before actual request
  2. Check if it returns 200 OK
  3. Check response headers include:
  4. Access-Control-Allow-Origin
  5. Access-Control-Allow-Methods
  6. Access-Control-Allow-Headers
"},{"location":"v2/troubleshooting/common-errors/#prevention_14","title":"Prevention","text":"
  • Explicit origins - List all allowed origins
  • Environment-based - Different origins per environment
  • Credentials support - Enable if using cookies/auth
  • Preflight caching - Cache OPTIONS responses

Security Note

Never use CORS_ORIGINS=* in production with credentials enabled.

"},{"location":"v2/troubleshooting/common-errors/#module-not-found","title":"Module Not Found","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_15","title":"Symptoms","text":"
Error: Cannot find module '@/components/MyComponent'\n

Or:

Module not found: Can't resolve 'some-package'\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_15","title":"Common Causes","text":"
  1. Missing dependency - Package not installed
  2. Wrong import path - Typo in path
  3. Path alias issue - @ alias not configured
  4. Case sensitivity - Wrong case in filename
"},{"location":"v2/troubleshooting/common-errors/#solutions_15","title":"Solutions","text":"

Solution 1: Install missing package

cd admin\n\n# Install package\nnpm install some-package\n\n# Or if dev dependency\nnpm install -D some-package\n\n# Restart dev server\nnpm run dev\n

Solution 2: Check import path

// Wrong:\nimport MyComponent from '@/Component/MyComponent';\n\n// Right:\nimport MyComponent from '@/components/MyComponent';\n\n// Verify file exists:\n// admin/src/components/MyComponent.tsx\n

Solution 3: Verify path alias

In admin/vite.config.ts:

export default defineConfig({\n  resolve: {\n    alias: {\n      '@': path.resolve(__dirname, './src')\n    }\n  }\n});\n

In admin/tsconfig.json:

{\n  \"compilerOptions\": {\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  }\n}\n

Solution 4: Clear cache and reinstall

cd admin\n\n# Remove node_modules and lock file\nrm -rf node_modules package-lock.json\n\n# Reinstall\nnpm install\n\n# Restart\nnpm run dev\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_15","title":"Prevention","text":"
  • Type checking - Use TypeScript for import validation
  • IDE support - Configure path aliases in IDE
  • Linting - Use ESLint with import plugin
  • Documentation - Document custom path aliases
"},{"location":"v2/troubleshooting/common-errors/#hydration-errors","title":"Hydration Errors","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_16","title":"Symptoms","text":"

Browser console:

Warning: Text content did not match. Server: \"...\" Client: \"...\"\n

Or:

Error: Hydration failed because the initial UI does not match what was\nrendered on the server.\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_16","title":"Common Causes","text":"
  1. Date formatting - Server/client timezone difference
  2. Random values - Using Math.random() or uuid
  3. localStorage - Reading from localStorage during render
  4. User agent - Checking window.navigator during SSR
  5. Third-party scripts - Injected by browser extensions
"},{"location":"v2/troubleshooting/common-errors/#solutions_16","title":"Solutions","text":"

Solution 1: Use useEffect for client-only code

// Wrong:\nconst Component = () => {\n  const value = localStorage.getItem('key');\n  return <div>{value}</div>;\n};\n\n// Right:\nconst Component = () => {\n  const [value, setValue] = useState<string | null>(null);\n\n  useEffect(() => {\n    setValue(localStorage.getItem('key'));\n  }, []);\n\n  return <div>{value}</div>;\n};\n

Solution 2: Consistent date formatting

// Wrong:\n<div>{new Date().toLocaleString()}</div>  // Varies by locale\n\n// Right:\nimport dayjs from 'dayjs';\n<div>{dayjs().format('YYYY-MM-DD HH:mm:ss')}</div>\n

Solution 3: suppressHydrationWarning for known mismatches

// For values that intentionally differ (like timestamps)\n<time suppressHydrationWarning>\n  {new Date().toISOString()}\n</time>\n

Solution 4: Check browser extensions

Disable browser extensions temporarily to see if error persists.

"},{"location":"v2/troubleshooting/common-errors/#prevention_16","title":"Prevention","text":"
  • Avoid client-only APIs during render - Use useEffect
  • Consistent formatting - Same format server and client
  • Test without extensions - Regular testing
  • React DevTools - Use to identify mismatches

Changemaker Lite V2

Current admin is CSR (Client-Side Rendered) only, so hydration errors shouldn't occur. This section is for future SSR/SSG implementations.

"},{"location":"v2/troubleshooting/common-errors/#file-upload-errors","title":"File Upload Errors","text":""},{"location":"v2/troubleshooting/common-errors/#file-too-large","title":"File Too Large","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_17","title":"Symptoms","text":"
{\n  \"error\": \"Payload Too Large\",\n  \"message\": \"File size exceeds maximum of 10485760 bytes\"\n}\n

Or browser:

Request Entity Too Large\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_17","title":"Common Causes","text":"
  1. File exceeds limit - Video larger than 10GB
  2. Nginx limit - Reverse proxy blocking
  3. Wrong content type - Not multipart/form-data
  4. Network timeout - Upload taking too long
"},{"location":"v2/troubleshooting/common-errors/#solutions_17","title":"Solutions","text":"

Solution 1: Check file size

// Before upload\nconst file = event.target.files[0];\nconst maxSize = 10 * 1024 * 1024 * 1024; // 10GB\n\nif (file.size > maxSize) {\n  alert(`File too large. Max size: ${maxSize / 1024 / 1024 / 1024}GB`);\n  return;\n}\n

Solution 2: Increase limits

In api/src/modules/media/routes/upload.routes.ts:

fastify.register(multipart, {\n  limits: {\n    fileSize: 10 * 1024 * 1024 * 1024  // 10GB\n  }\n});\n

In nginx/conf.d/api.conf:

client_max_body_size 10G;\n

Solution 3: Use chunked upload

For very large files, implement resumable upload:

// TODO: Implement chunked upload in Phase 15\n

Solution 4: Compress video

# Before uploading, compress with ffmpeg\nffmpeg -i input.mp4 -c:v libx264 -crf 23 -c:a aac output.mp4\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_17","title":"Prevention","text":"
  • Client validation - Check size before upload
  • Progress indicator - Show upload progress
  • Compression - Compress large videos
  • Chunked uploads - For files > 1GB
"},{"location":"v2/troubleshooting/common-errors/#invalid-file-type","title":"Invalid File Type","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/common-errors/#symptoms_18","title":"Symptoms","text":"
{\n  \"error\": \"Bad Request\",\n  \"message\": \"Invalid file type. Allowed: mp4, mov, avi, mkv, webm, m4v, flv\"\n}\n
"},{"location":"v2/troubleshooting/common-errors/#common-causes_18","title":"Common Causes","text":"
  1. Wrong extension - File has unsupported extension
  2. Missing extension - Filename has no extension
  3. Mismatched extension - Extension doesn't match content
  4. MIME type issue - Browser sends wrong MIME type
"},{"location":"v2/troubleshooting/common-errors/#solutions_18","title":"Solutions","text":"

Solution 1: Check supported formats

Supported video formats:

  • MP4 (.mp4)
  • MOV (.mov)
  • AVI (.avi)
  • MKV (.mkv)
  • WebM (.webm)
  • M4V (.m4v)
  • FLV (.flv)

Solution 2: Convert video

# Convert to MP4 (most compatible)\nffmpeg -i input.avi -c:v libx264 -c:a aac output.mp4\n

Solution 3: Check file extension

const file = event.target.files[0];\nconst ext = file.name.split('.').pop().toLowerCase();\nconst allowed = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'flv'];\n\nif (!allowed.includes(ext)) {\n  alert(`Invalid file type: .${ext}`);\n  return;\n}\n

Solution 4: Verify with file command

# Check actual file type\nfile video.mp4\n\n# Should show:\n# video.mp4: ISO Media, MP4 v2 [ISO 14496-14]\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_18","title":"Prevention","text":"
  • Client validation - Check extension before upload
  • MIME type checking - Validate content type
  • File magic numbers - Check file signature
  • Clear documentation - List supported formats
"},{"location":"v2/troubleshooting/common-errors/#upload-timeout","title":"Upload Timeout","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_19","title":"Symptoms","text":"
Error: timeout of 30000ms exceeded\n

Or:

504 Gateway Timeout\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_19","title":"Common Causes","text":"
  1. Slow network - Large file, slow connection
  2. Server timeout - Request timeout too short
  3. Processing delay - FFprobe taking too long
  4. Network interruption - Connection dropped
"},{"location":"v2/troubleshooting/common-errors/#solutions_19","title":"Solutions","text":"

Solution 1: Increase timeout

In admin/src/lib/media-api.ts:

export const mediaApi = axios.create({\n  baseURL: import.meta.env.VITE_MEDIA_API_URL,\n  timeout: 300000  // 5 minutes instead of 30 seconds\n});\n

Solution 2: Check upload progress

await mediaApi.post('/upload', formData, {\n  onUploadProgress: (progressEvent) => {\n    const percent = (progressEvent.loaded / progressEvent.total) * 100;\n    console.log(`Upload: ${percent.toFixed(2)}%`);\n  }\n});\n

Solution 3: Increase nginx timeout

In nginx/conf.d/api.conf:

proxy_read_timeout 300s;\nproxy_connect_timeout 300s;\nproxy_send_timeout 300s;\n

Solution 4: Upload via chunks

// TODO: Implement chunked upload for large files\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_19","title":"Prevention","text":"
  • Progress indicator - Show upload progress
  • Generous timeouts - Allow enough time for large files
  • Retry logic - Auto-retry on network errors
  • Chunked uploads - For files > 1GB
"},{"location":"v2/troubleshooting/common-errors/#email-errors","title":"Email Errors","text":""},{"location":"v2/troubleshooting/common-errors/#smtp-connection-failed","title":"SMTP Connection Failed","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/common-errors/#symptoms_20","title":"Symptoms","text":"

API logs:

Error: Connection timeout\nError: connect ECONNREFUSED 127.0.0.1:587\n

Or:

Error: Invalid login: 535-5.7.8 Username and Password not accepted\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_20","title":"Common Causes","text":"
  1. SMTP server down - Mail server unreachable
  2. Wrong credentials - Invalid username/password
  3. Port blocked - Firewall blocking SMTP port
  4. TLS/SSL issue - Certificate validation failed
"},{"location":"v2/troubleshooting/common-errors/#solutions_20","title":"Solutions","text":"

Solution 1: Test SMTP connection

# Test with telnet\ntelnet smtp.gmail.com 587\n\n# Should connect and show:\n# 220 smtp.gmail.com ESMTP...\n

Solution 2: Verify SMTP configuration

# Check .env\ncat .env | grep SMTP\n\n# Required settings:\nSMTP_HOST=smtp.gmail.com\nSMTP_PORT=587\nSMTP_SECURE=false\nSMTP_USER=your-email@gmail.com\nSMTP_PASS=your-app-password\nSMTP_FROM=your-email@gmail.com\n

Solution 3: Use test mode

# In .env\nEMAIL_TEST_MODE=true\n\n# Restart API\ndocker compose restart api\n\n# Emails now sent to MailHog (http://localhost:8025)\n

Solution 4: Check Gmail app password

For Gmail:

  1. Enable 2-factor authentication
  2. Generate app password at https://myaccount.google.com/apppasswords
  3. Use app password (not regular password) in SMTP_PASS

Solution 5: Test with curl

# Send test email via API\ncurl -X POST http://localhost:4000/api/test-email \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\n    \"to\": \"test@example.com\",\n    \"subject\": \"Test Email\",\n    \"text\": \"This is a test\"\n  }'\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_20","title":"Prevention","text":"
  • Test mode for dev - Use MailHog locally
  • Monitor SMTP health - Alert on connection failures
  • Fallback providers - Configure backup SMTP server
  • Queue system - BullMQ retries failed emails
"},{"location":"v2/troubleshooting/common-errors/#template-not-found","title":"Template Not Found","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/common-errors/#symptoms_21","title":"Symptoms","text":"

API logs:

Error: Email template not found: campaign-email\n

Or:

Error: ENOENT: no such file or directory, open 'templates/campaign-email.html'\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_21","title":"Common Causes","text":"
  1. Missing template file - Template not created
  2. Wrong template name - Typo in template name
  3. Wrong path - Looking in wrong directory
  4. Deleted template - Template was removed
"},{"location":"v2/troubleshooting/common-errors/#solutions_21","title":"Solutions","text":"

Solution 1: Check template exists

# List all templates\ndocker compose exec api ls -la templates/\n\n# Should show:\n# campaign-email.html\n# shift-confirmation.html\n# verification-email.html\n# etc.\n

Solution 2: Verify template name

In api/src/services/email.service.ts:

// Template names must match filenames (without .html)\nawait emailService.sendEmail({\n  to: email,\n  subject: 'Campaign Email',\n  template: 'campaign-email',  // Looks for templates/campaign-email.html\n  variables: { ... }\n});\n

Solution 3: Create missing template

# Create template\ndocker compose exec api sh -c 'cat > templates/my-template.html << EOF\n<!DOCTYPE html>\n<html>\n<body>\n  <h1>Hello {{name}}</h1>\n  <p>{{message}}</p>\n</body>\n</html>\nEOF'\n

Solution 4: Use email template system

# Navigate to admin UI\nhttp://localhost:3000/app/email-templates\n\n# Create template there (saved to database + file)\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_21","title":"Prevention","text":"
  • Seed templates - Include in database seed
  • Template management - Use admin UI to manage
  • Version control - Keep templates in git
  • Validation - Check template exists before sending
"},{"location":"v2/troubleshooting/common-errors/#variable-missing","title":"Variable Missing","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/common-errors/#symptoms_22","title":"Symptoms","text":"

Email received with placeholders not replaced:

Hello {{name}},\nYour campaign {{campaignName}} is ready.\n

Or API logs:

Warning: Template variable 'campaignName' not provided\n

"},{"location":"v2/troubleshooting/common-errors/#common-causes_22","title":"Common Causes","text":"
  1. Variable not passed - Missing from variables object
  2. Variable name mismatch - Typo in variable name
  3. Wrong template - Using wrong template
  4. Case sensitivity - Variable name case mismatch
"},{"location":"v2/troubleshooting/common-errors/#solutions_22","title":"Solutions","text":"

Solution 1: Check template variables

In template file:

<!-- templates/campaign-email.html -->\n<h1>Hello {{firstName}}</h1>\n<p>Your campaign \"{{campaignName}}\" is ready.</p>\n<p>Visit: {{campaignUrl}}</p>\n

Solution 2: Provide all variables

await emailService.sendEmail({\n  to: email,\n  subject: 'Campaign Ready',\n  template: 'campaign-email',\n  variables: {\n    firstName: user.name.split(' ')[0],\n    campaignName: campaign.name,\n    campaignUrl: `${process.env.PUBLIC_URL}/campaigns/${campaign.id}`\n  }\n});\n

Solution 3: Use default values

<!-- In template, provide fallback -->\n<h1>Hello {{firstName || 'Friend'}}</h1>\n

Solution 4: Validate before sending

// Check all required variables exist\nconst required = ['firstName', 'campaignName', 'campaignUrl'];\nconst missing = required.filter(key => !variables[key]);\n\nif (missing.length > 0) {\n  throw new Error(`Missing template variables: ${missing.join(', ')}`);\n}\n
"},{"location":"v2/troubleshooting/common-errors/#prevention_22","title":"Prevention","text":"
  • Template validation - Check variables on save
  • TypeScript types - Type template variables
  • Documentation - Document required variables
  • Default values - Provide sensible defaults
"},{"location":"v2/troubleshooting/common-errors/#quick-reference-table","title":"Quick Reference Table","text":"Error Code/Message Category Common Cause Quick Fix Severity 401 Unauthorized Auth Token expired Re-login \ud83d\udfe0 403 Forbidden Auth Wrong role Check user role \ud83d\udfe0 404 Not Found API Wrong URL/ID Verify resource exists \ud83d\udfe2 422 Unprocessable Validation Constraint violation Check validation details \ud83d\udfe1 500 Server Error API Code bug Check API logs \ud83d\udd34 ECONNREFUSED Database DB not running Start database \ud83d\udd34 Too many connections Database Connection leak Restart API \ud83d\udfe0 Unique constraint Database Duplicate record Use upsert or different value \ud83d\udfe1 Foreign key constraint Database Parent missing Create parent first \ud83d\udfe1 Network Error Frontend API down Check API status \ud83d\udfe0 CORS Error Frontend Origin not allowed Add to CORS_ORIGINS \ud83d\udfe0 Module not found Frontend Missing package npm install \ud83d\udfe1 File too large Upload Exceeds 10GB Compress or increase limit \ud83d\udfe1 Invalid file type Upload Wrong format Convert to MP4 \ud83d\udfe2 Upload timeout Upload Slow network Increase timeout \ud83d\udfe1 SMTP failed Email Wrong credentials Check SMTP config \ud83d\udd34 Template not found Email Missing file Create template \ud83d\udfe0 Variable missing Email Not provided Add to variables object \ud83d\udfe1"},{"location":"v2/troubleshooting/common-errors/#when-to-report-bugs","title":"When to Report Bugs","text":""},{"location":"v2/troubleshooting/common-errors/#report-these","title":"Report These","text":"

\u2705 Unexpected behavior - System does something wrong

  • 500 errors (unless caused by your config)
  • Data corruption
  • Security vulnerabilities
  • Performance regressions

\u2705 Missing features - Documented feature doesn't work

  • API endpoint returns 404 but is documented
  • UI button does nothing
  • Feature flag doesn't enable feature

\u2705 Unclear documentation - Can't figure out how to do something

  • Documentation contradicts actual behavior
  • Missing setup steps
  • Confusing error messages
"},{"location":"v2/troubleshooting/common-errors/#dont-report-these","title":"Don't Report These","text":"

\u274c Configuration errors - Your setup is wrong

  • Missing .env variables
  • Wrong database credentials
  • Port conflicts

\u274c Environment issues - Your system is incompatible

  • Old Docker version
  • Missing dependencies
  • Network restrictions

\u274c User errors - Misunderstanding how to use

  • Wrong API endpoint used
  • Invalid data format
  • Permission errors from lack of role
"},{"location":"v2/troubleshooting/common-errors/#how-to-report","title":"How to Report","text":"
  1. Check this troubleshooting guide first
  2. Search existing GitHub issues
  3. If new, create issue with:
  4. Clear title describing problem
  5. Steps to reproduce
  6. Expected vs actual behavior
  7. Relevant logs (sanitize sensitive data)
  8. System information (Docker version, OS, etc.)
"},{"location":"v2/troubleshooting/common-errors/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/common-errors/#general-documentation","title":"General Documentation","text":"
  • Installation Guide - Setup instructions
  • Architecture Overview - System design
  • API Reference - API endpoints
"},{"location":"v2/troubleshooting/common-errors/#specific-troubleshooting","title":"Specific Troubleshooting","text":"
  • Docker Issues - Container problems
  • Database Issues - PostgreSQL errors
  • Auth Issues - Authentication problems
  • Geocoding Issues - Map and geocoding
  • Email Issues - SMTP and templates
  • Monitoring Issues - Prometheus and Grafana
"},{"location":"v2/troubleshooting/common-errors/#support","title":"Support","text":"
  • FAQ - Frequently asked questions
  • Performance Optimization - Speed improvements
  • GitHub Issues - Report bugs

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/troubleshooting/database-issues/","title":"Database and PostgreSQL Issues","text":"

This guide covers PostgreSQL and database-related problems in Changemaker Lite V2.

"},{"location":"v2/troubleshooting/database-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/database-issues/#database-architecture","title":"Database Architecture","text":"

Changemaker Lite V2 uses:

  • PostgreSQL 16 - Primary database
  • Prisma ORM - Main API (Express)
  • Drizzle ORM - Media API (Fastify)
  • Same database - Shared by both APIs
  • Separate schemas - Tables owned by different ORMs
"},{"location":"v2/troubleshooting/database-issues/#database-connection-info","title":"Database Connection Info","text":"
# From API container\nDATABASE_URL=\"postgresql://changemaker:password@v2-postgres:5432/changemaker_v2\"\n\n# From host\nDATABASE_URL=\"postgresql://changemaker:password@localhost:5433/changemaker_v2\"\n\n# Connection details:\n# User: changemaker\n# Password: set in V2_POSTGRES_PASSWORD env var\n# Host: v2-postgres (container) or localhost (host)\n# Port: 5432 (inside Docker), 5433 (host)\n# Database: changemaker_v2\n
"},{"location":"v2/troubleshooting/database-issues/#essential-commands","title":"Essential Commands","text":"
# Connect to database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2\n\n# Run single query\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"SELECT NOW();\"\n\n# Run SQL file\ndocker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < script.sql\n\n# Database logs\ndocker compose logs v2-postgres\n\n# Prisma Studio (GUI)\ndocker compose exec api npx prisma studio\n
"},{"location":"v2/troubleshooting/database-issues/#connection-errors","title":"Connection Errors","text":""},{"location":"v2/troubleshooting/database-issues/#connection-refused","title":"Connection Refused","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/database-issues/#symptoms","title":"Symptoms","text":"

API logs:

Error: connect ECONNREFUSED 127.0.0.1:5433\nError: Can't reach database server at `v2-postgres:5432`\n

Or direct connection:

psql: error: connection to server at \"localhost\" (127.0.0.1), port 5433 failed:\nConnection refused\n

"},{"location":"v2/troubleshooting/database-issues/#common-causes","title":"Common Causes","text":"
  1. Database not running - Container stopped
  2. Wrong connection string - Incorrect host/port
  3. Port not exposed - Missing port mapping
  4. Network issue - Container can't reach database
"},{"location":"v2/troubleshooting/database-issues/#solutions","title":"Solutions","text":"

Solution 1: Check database status

# Is database running?\ndocker compose ps v2-postgres\n\n# Should show:\n# NAME                          STATUS\n# changemaker-lite-v2-postgres-1   Up 5 minutes\n\n# If not running:\ndocker compose up -d v2-postgres\n

Solution 2: Wait for database to be ready

# Check logs for \"ready to accept connections\"\ndocker compose logs v2-postgres | grep \"ready\"\n\n# Should show:\n# database system is ready to accept connections\n\n# If not ready, wait 10-20 seconds and check again\n

Solution 3: Verify connection string

# Check .env\ncat .env | grep DATABASE_URL\n\n# From API container should use container name:\nDATABASE_URL=\"postgresql://changemaker:password@v2-postgres:5432/changemaker_v2\"\n\n# From host should use localhost:\nDATABASE_URL=\"postgresql://changemaker:password@localhost:5433/changemaker_v2\"\n\n# Common mistakes:\n# \u274c Using localhost from container\n# \u274c Using v2-postgres from host\n# \u274c Wrong port (5432 vs 5433)\n# \u274c Wrong password\n

Solution 4: Test connection manually

# From API container\ndocker compose exec api sh -c 'psql $DATABASE_URL -c \"SELECT NOW();\"'\n\n# From host\npsql \"postgresql://changemaker:password@localhost:5433/changemaker_v2\" -c \"SELECT NOW();\"\n\n# If fails, connection string is wrong\n

Solution 5: Check port mapping

In docker-compose.yml:

v2-postgres:\n  ports:\n    - \"5433:5432\"  # host:container\n

Verify:

docker compose ps v2-postgres\n\n# Should show:\n# PORTS: 0.0.0.0:5433->5432/tcp\n
"},{"location":"v2/troubleshooting/database-issues/#prevention","title":"Prevention","text":"
  • Health checks - Wait for database health before starting API
  • Connection retry - Retry connection on startup
  • Correct env vars - Validate DATABASE_URL format
  • Monitoring - Alert on connection failures
"},{"location":"v2/troubleshooting/database-issues/#too-many-clients","title":"Too Many Clients","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/database-issues/#symptoms_1","title":"Symptoms","text":"
FATAL: sorry, too many clients already\n

Or:

Error: remaining connection slots are reserved for non-replication superuser connections\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_1","title":"Common Causes","text":"
  1. Connection leak - Connections not closed
  2. Pool too large - Connection pool size too high
  3. Multiple Prisma instances - Each creates own pool
  4. Long-running transactions - Holding connections
"},{"location":"v2/troubleshooting/database-issues/#solutions_1","title":"Solutions","text":"

Solution 1: Check active connections

-- View all connections\nSELECT count(*) FROM pg_stat_activity;\n\n-- View connections by state\nSELECT state, count(*)\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2'\nGROUP BY state;\n\n-- View connection details\nSELECT pid, usename, application_name, state, query_start, query\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2'\nORDER BY query_start;\n

Solution 2: Kill idle connections

-- Find idle connections\nSELECT pid, usename, state, state_change\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2'\n  AND state = 'idle'\n  AND state_change < NOW() - INTERVAL '5 minutes';\n\n-- Kill specific connection\nSELECT pg_terminate_backend(12345);  -- Replace with actual PID\n\n-- Kill all idle connections (careful!)\nSELECT pg_terminate_backend(pid)\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2'\n  AND state = 'idle'\n  AND state_change < NOW() - INTERVAL '5 minutes';\n

Solution 3: Adjust connection pool

In DATABASE_URL:

# Limit connection pool size\nDATABASE_URL=\"postgresql://changemaker:password@v2-postgres:5432/changemaker_v2?connection_limit=10\"\n

Or in Prisma code:

// api/src/config/database.ts\nimport { PrismaClient } from '@prisma/client';\n\nexport const prisma = new PrismaClient({\n  datasources: {\n    db: {\n      url: process.env.DATABASE_URL\n    }\n  }\n  // Connection pool defaults:\n  // connection_limit: 10\n  // pool_timeout: 10 (seconds)\n});\n

Solution 4: Increase max connections

In docker-compose.yml:

v2-postgres:\n  command: postgres -c max_connections=200\n  # Default is 100\n

Restart:

docker compose up -d v2-postgres\n

Verify:

SHOW max_connections;\n

Solution 5: Restart API to release connections

# Restart API releases all connections\ndocker compose restart api\ndocker compose restart media-api\n\n# Check connection count dropped\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT count(*) FROM pg_stat_activity WHERE datname = 'changemaker_v2';\"\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_1","title":"Prevention","text":"
  • Proper cleanup - Always close Prisma clients in tests
  • Appropriate pool size - Balance performance vs connections
  • Monitor connections - Alert when approaching max
  • Idle timeout - Automatically close idle connections

Connection Math

Total connections = (number of API instances) \u00d7 (connection pool size) + (other clients)

Example: - 2 API instances \u00d7 10 pool size = 20 connections - 1 media API \u00d7 5 pool size = 5 connections - Prisma Studio = 1 connection - Total = 26 connections

Set max_connections to 2-3\u00d7 expected usage.

"},{"location":"v2/troubleshooting/database-issues/#authentication-failed","title":"Authentication Failed","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/database-issues/#symptoms_2","title":"Symptoms","text":"
FATAL: password authentication failed for user \"changemaker\"\n

Or:

FATAL: role \"changemaker\" does not exist\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_2","title":"Common Causes","text":"
  1. Wrong password - PASSWORD in DATABASE_URL doesn't match
  2. Wrong username - User doesn't exist
  3. Password changed - Database password changed but not .env
  4. Case sensitivity - PostgreSQL usernames are case-sensitive
"},{"location":"v2/troubleshooting/database-issues/#solutions_2","title":"Solutions","text":"

Solution 1: Verify credentials

# Check .env\ncat .env | grep V2_POSTGRES_PASSWORD\n\n# Check DATABASE_URL\ncat .env | grep DATABASE_URL\n\n# Password in DATABASE_URL must match V2_POSTGRES_PASSWORD\n

Solution 2: Test connection directly

# Test with password\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2\n\n# If prompted for password, enter V2_POSTGRES_PASSWORD\n# If fails, credentials are wrong\n

Solution 3: Check user exists

# Connect as postgres superuser\ndocker compose exec v2-postgres psql -U postgres -c \"\\du\"\n\n# Should show changemaker user:\n# Role name | Attributes\n# changemaker |\n\n# If missing, create user:\ndocker compose exec v2-postgres psql -U postgres -c \\\n  \"CREATE USER changemaker WITH PASSWORD 'your-password';\"\n\ndocker compose exec v2-postgres psql -U postgres -c \\\n  \"GRANT ALL PRIVILEGES ON DATABASE changemaker_v2 TO changemaker;\"\n

Solution 4: Reset password

# As postgres superuser\ndocker compose exec v2-postgres psql -U postgres -c \\\n  \"ALTER USER changemaker WITH PASSWORD 'new-password';\"\n\n# Update .env\nV2_POSTGRES_PASSWORD=new-password\nDATABASE_URL=\"postgresql://changemaker:new-password@v2-postgres:5432/changemaker_v2\"\n\n# Restart API\ndocker compose restart api\n

Solution 5: Recreate database

If completely broken:

# Backup first!\ndocker compose exec v2-postgres pg_dump -U postgres changemaker_v2 > backup.sql\n\n# Stop database\ndocker compose down v2-postgres\n\n# Remove volume (\u26a0\ufe0f DELETES DATA!)\ndocker volume rm changemaker-lite_postgres-data\n\n# Start fresh\ndocker compose up -d v2-postgres\n\n# Wait for ready\ndocker compose logs -f v2-postgres | grep \"ready\"\n\n# Run migrations\ndocker compose exec api npx prisma migrate deploy\ndocker compose exec api npx prisma db seed\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_2","title":"Prevention","text":"
  • Secure passwords - Strong passwords in .env
  • Consistent credentials - Same password in all places
  • Version control .env.example - Template with placeholders
  • Documentation - Document credential structure
"},{"location":"v2/troubleshooting/database-issues/#database-does-not-exist","title":"Database Does Not Exist","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/database-issues/#symptoms_3","title":"Symptoms","text":"
FATAL: database \"changemaker_v2\" does not exist\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_3","title":"Common Causes","text":"
  1. First run - Database not created yet
  2. Wrong database name - Typo in DATABASE_URL
  3. Database deleted - Volume was removed
  4. Wrong postgres instance - Connected to different database
"},{"location":"v2/troubleshooting/database-issues/#solutions_3","title":"Solutions","text":"

Solution 1: Check database exists

# List databases\ndocker compose exec v2-postgres psql -U postgres -l\n\n# Should show:\n# Name          | Owner\n# changemaker_v2 | changemaker\n\n# If missing, database wasn't created\n

Solution 2: Create database

# Create database\ndocker compose exec v2-postgres psql -U postgres -c \\\n  \"CREATE DATABASE changemaker_v2 OWNER changemaker;\"\n\n# Verify\ndocker compose exec v2-postgres psql -U postgres -l | grep changemaker_v2\n

Solution 3: Run migrations

# Prisma migrations create tables\ndocker compose exec api npx prisma migrate deploy\n\n# Drizzle push creates media tables\ndocker compose exec api npx drizzle-kit push\n\n# Seed initial data\ndocker compose exec api npx prisma db seed\n

Solution 4: Check DATABASE_URL

# Verify database name in URL\ncat .env | grep DATABASE_URL\n\n# Should end with /changemaker_v2\n# Not:\n# /changemaker (missing _v2)\n# /postgres (wrong database)\n

Solution 5: Full reset

# \u26a0\ufe0f Deletes all data!\ndocker compose down -v\ndocker compose up -d v2-postgres\n\n# Wait for ready\nsleep 10\n\n# Create and migrate\ndocker compose exec api npx prisma migrate deploy\ndocker compose exec api npx drizzle-kit push\ndocker compose exec api npx prisma db seed\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_3","title":"Prevention","text":"
  • Initialization scripts - Auto-create database on first run
  • Health checks - Verify database exists before app starts
  • Migrations - Run migrations in deployment script
  • Documentation - Clear setup instructions
"},{"location":"v2/troubleshooting/database-issues/#migration-errors","title":"Migration Errors","text":""},{"location":"v2/troubleshooting/database-issues/#migration-conflict","title":"Migration Conflict","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/database-issues/#symptoms_4","title":"Symptoms","text":"
Error: Migration failed to apply cleanly to the shadow database.\nError: P3006 Migration `20260101000000_init` failed to apply cleanly to a temporary database.\n

Or:

Error: The migration `20260201000000_add_field` cannot be applied to the database:\n- Added the required column `fieldName` to the `User` table without a default value.\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_4","title":"Common Causes","text":"
  1. Schema drift - Database schema doesn't match Prisma schema
  2. Non-nullable column - Adding required field to table with data
  3. Conflicting migration - Different migration with same name
  4. Shadow database issue - Can't create shadow database
"},{"location":"v2/troubleshooting/database-issues/#solutions_4","title":"Solutions","text":"

Solution 1: Check migration status

# View migration history\ndocker compose exec api npx prisma migrate status\n\n# Shows:\n# - Applied migrations\n# - Pending migrations\n# - Failed migrations\n

Solution 2: Add default value for new field

If adding non-nullable column to table with existing data:

// In prisma/schema.prisma\nmodel User {\n  id    String @id @default(uuid())\n  email String @unique\n  name  String @default(\"\")  // Add default for existing rows\n}\n

Or use two-step migration:

-- Migration 1: Add nullable field\nALTER TABLE \"User\" ADD COLUMN \"name\" TEXT;\n\n-- Migration 2: Make non-nullable (after backfilling)\nUPDATE \"User\" SET \"name\" = 'Unknown' WHERE \"name\" IS NULL;\nALTER TABLE \"User\" ALTER COLUMN \"name\" SET NOT NULL;\n

Solution 3: Reset database (dev only)

# \u26a0\ufe0f DELETES ALL DATA!\ndocker compose exec api npx prisma migrate reset\n\n# This:\n# 1. Drops database\n# 2. Creates database\n# 3. Applies all migrations\n# 4. Runs seed\n

Solution 4: Manually fix schema drift

# Compare database schema to Prisma schema\ndocker compose exec api npx prisma db pull\n\n# This creates a new schema.prisma from database\n# Compare with your current schema.prisma\n# Manually fix differences\n

Solution 5: Mark migration as applied (if already applied manually)

# If you manually ran migration SQL, mark as applied:\ndocker compose exec api npx prisma migrate resolve --applied \"20260201000000_migration_name\"\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_4","title":"Prevention","text":"
  • Development workflow - Use prisma migrate dev in dev
  • Production workflow - Use prisma migrate deploy in prod
  • Never edit migrations - Don't modify files in migrations/
  • Test migrations - Test on copy of prod data first
"},{"location":"v2/troubleshooting/database-issues/#schema-drift","title":"Schema Drift","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/database-issues/#symptoms_5","title":"Symptoms","text":"
Warning: Your database schema is not in sync with your Prisma schema.\n

Or:

Error: P2021 The table `main.NewTable` does not exist in the current database.\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_5","title":"Common Causes","text":"
  1. Manual schema changes - Changed database without migration
  2. Missing migrations - Migrations not run on this database
  3. Different environment - Prod vs dev schema mismatch
  4. Failed migration - Migration partially applied
"},{"location":"v2/troubleshooting/database-issues/#solutions_5","title":"Solutions","text":"

Solution 1: Detect drift

# Check for drift\ndocker compose exec api npx prisma migrate diff \\\n  --from-schema-datamodel prisma/schema.prisma \\\n  --to-schema-datasource prisma/schema.prisma \\\n  --script\n\n# If output is empty, no drift\n# If shows SQL, that's the drift\n

Solution 2: Create migration from drift

# Generate migration to fix drift\ndocker compose exec api npx prisma migrate dev --name fix_drift\n\n# Reviews changes and creates migration\n

Solution 3: Pull schema from database

# Update Prisma schema from database\ndocker compose exec api npx prisma db pull\n\n# This overwrites schema.prisma with actual database schema\n# Review changes before committing\n

Solution 4: Deploy missing migrations

# Apply all pending migrations\ndocker compose exec api npx prisma migrate deploy\n\n# Check status\ndocker compose exec api npx prisma migrate status\n

Solution 5: Reset and re-migrate (dev only)

# \u26a0\ufe0f DELETES ALL DATA!\ndocker compose exec api npx prisma migrate reset\n\n# Applies all migrations fresh\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_5","title":"Prevention","text":"
  • Never manual schema changes - Always use migrations
  • Consistent workflow - Same process in all environments
  • CI/CD validation - Check for drift in CI pipeline
  • Documentation - Document migration process
"},{"location":"v2/troubleshooting/database-issues/#failed-migration-rollback","title":"Failed Migration Rollback","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/database-issues/#symptoms_6","title":"Symptoms","text":"
Error: Migration failed. Cannot rollback without losing data.\n

Or:

Error: Database is in an inconsistent state after a failed migration\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_6","title":"Common Causes","text":"
  1. Data migration failed - Migration includes data changes that failed
  2. Constraint violation - Migration violates database constraints
  3. No rollback - Prisma doesn't support automatic rollback
  4. Partial application - Migration partially applied before error
"},{"location":"v2/troubleshooting/database-issues/#solutions_6","title":"Solutions","text":"

Solution 1: Mark migration as rolled back

# Mark as failed (doesn't undo changes)\ndocker compose exec api npx prisma migrate resolve --rolled-back \"20260201000000_migration_name\"\n

Solution 2: Manually revert changes

-- Find what migration did\ncat api/prisma/migrations/20260201000000_migration_name/migration.sql\n\n-- Write reverse SQL\n-- If migration did:\nALTER TABLE \"User\" ADD COLUMN \"newField\" TEXT;\n\n-- Reverse is:\nALTER TABLE \"User\" DROP COLUMN \"newField\";\n\n-- Apply reverse\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c 'ALTER TABLE \"User\" DROP COLUMN \"newField\";'\n

Solution 3: Restore from backup

# If you have backup before migration\ndocker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup-before-migration.sql\n\n# Then mark migration as rolled back\ndocker compose exec api npx prisma migrate resolve --rolled-back \"20260201000000_migration_name\"\n

Solution 4: Fix forward

Instead of rolling back, fix the issue and continue:

# Fix the issue (e.g., add missing default value)\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c 'ALTER TABLE \"User\" ALTER COLUMN \"newField\" SET DEFAULT '\\''value'\\'';'\n\n# Retry migration\ndocker compose exec api npx prisma migrate deploy\n

Solution 5: Baseline from current state

If database is in unknown state:

# Create new migration from current state\ndocker compose exec api npx prisma migrate dev --name baseline --create-only\n\n# Review generated migration\n# If it looks correct, apply:\ndocker compose exec api npx prisma migrate deploy\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_6","title":"Prevention","text":"
  • Test migrations - Test on copy of prod data first
  • Backup before migrate - Always backup before production migration
  • Reversible migrations - Design migrations to be reversible
  • Small migrations - Small, focused migrations easier to fix

Prisma Doesn't Auto-Rollback

Prisma Migrate does NOT automatically rollback failed migrations. You must manually fix issues.

"},{"location":"v2/troubleshooting/database-issues/#query-performance","title":"Query Performance","text":""},{"location":"v2/troubleshooting/database-issues/#slow-queries","title":"Slow Queries","text":"

Severity: \ud83d\udfe1 Medium to \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/database-issues/#symptoms_7","title":"Symptoms","text":"

API requests taking seconds to respond:

GET /api/users - 5000ms\n

Database logs show slow queries:

LOG: duration: 4521.234 ms statement: SELECT * FROM \"User\" WHERE ...\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_7","title":"Common Causes","text":"
  1. Missing indexes - Querying without index
  2. Full table scan - WHERE clause doesn't use index
  3. N+1 queries - Multiple queries instead of JOIN
  4. Large result set - Fetching too many rows
  5. Complex query - Too many JOINs or subqueries
"},{"location":"v2/troubleshooting/database-issues/#solutions_7","title":"Solutions","text":"

Solution 1: Enable slow query logging

In docker-compose.yml:

v2-postgres:\n  command: postgres -c log_min_duration_statement=1000\n  # Logs queries taking > 1 second\n

Restart:

docker compose up -d v2-postgres\n\n# View slow query log\ndocker compose logs v2-postgres | grep \"duration:\"\n

Solution 2: Analyze query

-- Use EXPLAIN to see query plan\nEXPLAIN ANALYZE\nSELECT * FROM \"User\"\nWHERE email LIKE '%@example.com%';\n\n-- Output shows:\n-- Seq Scan on \"User\"  (cost=0.00..20.00 rows=1000 width=100) (actual time=0.123..5.234 rows=50 loops=1)\n--   Filter: (email ~~ '%@example.com%'::text)\n--   Rows Removed by Filter: 950\n-- Planning Time: 0.456 ms\n-- Execution Time: 5.678 ms\n\n-- \"Seq Scan\" = full table scan (slow)\n-- \"Index Scan\" = using index (fast)\n

Solution 3: Add indexes

// In prisma/schema.prisma\nmodel User {\n  id    String @id @default(uuid())\n  email String @unique  // Creates index automatically\n  name  String\n\n  @@index([name])  // Add index for name searches\n}\n

Create migration:

docker compose exec api npx prisma migrate dev --name add_user_name_index\n

Verify index used:

EXPLAIN SELECT * FROM \"User\" WHERE name = 'John';\n-- Should show: Index Scan using User_name_idx\n

Solution 4: Fix N+1 queries

// Bad - N+1 queries\nconst campaigns = await prisma.campaign.findMany();\nfor (const campaign of campaigns) {\n  const emails = await prisma.campaignEmail.findMany({\n    where: { campaignId: campaign.id }\n  });\n}\n// 1 query for campaigns + N queries for emails = N+1\n\n// Good - single query with include\nconst campaigns = await prisma.campaign.findMany({\n  include: {\n    emails: true\n  }\n});\n// 1 query total\n

Solution 5: Limit result size

// Bad - fetch all users\nconst users = await prisma.user.findMany();\n\n// Good - paginate\nconst users = await prisma.user.findMany({\n  take: 50,  // Limit to 50 rows\n  skip: page * 50,  // Offset for pagination\n});\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_7","title":"Prevention","text":"
  • Index frequently queried fields - email, createdAt, etc.
  • Use includes - Avoid N+1 queries
  • Paginate results - Never fetch all rows
  • Monitor query performance - Alert on slow queries
"},{"location":"v2/troubleshooting/database-issues/#missing-indexes","title":"Missing Indexes","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/database-issues/#symptoms_8","title":"Symptoms","text":"

Slow queries on filtered/sorted columns:

SELECT * FROM \"Location\" WHERE \"postalCode\" = 'M5H 2N2';\n-- Slow without index on postalCode\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_8","title":"Common Causes","text":"
  1. No index on filter column - WHERE clause column not indexed
  2. No index on sort column - ORDER BY column not indexed
  3. No index on foreign key - JOIN column not indexed
  4. Composite index needed - Multiple columns in WHERE
"},{"location":"v2/troubleshooting/database-issues/#solutions_8","title":"Solutions","text":"

Solution 1: Identify missing indexes

-- Find tables without indexes\nSELECT schemaname, tablename, indexname\nFROM pg_indexes\nWHERE schemaname = 'public'\nORDER BY tablename;\n\n-- Find columns used in WHERE but not indexed\n-- (requires pg_stat_statements extension)\n

Solution 2: Add single-column index

model Location {\n  id         String @id @default(uuid())\n  address    String\n  postalCode String\n\n  @@index([postalCode])  // Add index\n}\n

Solution 3: Add composite index

For queries filtering on multiple columns:

model Location {\n  id         String @id @default(uuid())\n  province   String\n  city       String\n  postalCode String\n\n  @@index([province, city])  // Composite index\n  // Speeds up: WHERE province = 'ON' AND city = 'Toronto'\n  // Also speeds up: WHERE province = 'ON'\n  // Does NOT speed up: WHERE city = 'Toronto' (must start with first column)\n}\n

Solution 4: Add index on foreign key

model CampaignEmail {\n  id         String @id @default(uuid())\n  campaignId String\n\n  campaign Campaign @relation(fields: [campaignId], references: [id])\n\n  @@index([campaignId])  // Index foreign key for JOINs\n}\n

Solution 5: Create migration

# Generate migration for index\ndocker compose exec api npx prisma migrate dev --name add_indexes\n\n# Apply to production\ndocker compose exec api npx prisma migrate deploy\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_8","title":"Prevention","text":"
  • Index foreign keys - Always index foreign keys
  • Index filter columns - Index columns used in WHERE
  • Index sort columns - Index columns used in ORDER BY
  • Monitor query patterns - Add indexes based on actual usage

Index Guidelines

  • Unique constraints auto-create indexes
  • Foreign keys should be indexed
  • Columns in WHERE/ORDER BY/GROUP BY are candidates
  • Don't over-index (slows down writes)
"},{"location":"v2/troubleshooting/database-issues/#n1-queries","title":"N+1 Queries","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/database-issues/#symptoms_9","title":"Symptoms","text":"

API slow when fetching related data:

GET /api/campaigns - 2000ms\n

Database logs show many similar queries:

SELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = 'uuid1'\nSELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = 'uuid2'\nSELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = 'uuid3'\n...\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_9","title":"Common Causes","text":"
  1. No eager loading - Fetching relations in loop
  2. Separate queries - Not using include/select
  3. Nested loops - Multiple levels of relations
"},{"location":"v2/troubleshooting/database-issues/#solutions_9","title":"Solutions","text":"

Solution 1: Detect N+1 queries

Enable query logging:

// In api/src/config/database.ts\nexport const prisma = new PrismaClient({\n  log: ['query'],  // Log all queries\n});\n

Look for repeated patterns:

Query: SELECT * FROM \"Campaign\"\nQuery: SELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = '...'\nQuery: SELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = '...'\nQuery: SELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = '...'\n

Solution 2: Use include

// Bad - N+1\nconst campaigns = await prisma.campaign.findMany();\nfor (const campaign of campaigns) {\n  campaign.emails = await prisma.campaignEmail.findMany({\n    where: { campaignId: campaign.id }\n  });\n}\n// 1 + N queries\n\n// Good - single query\nconst campaigns = await prisma.campaign.findMany({\n  include: {\n    emails: true\n  }\n});\n// 2 queries (1 for campaigns, 1 for all emails with JOIN)\n

Solution 3: Nested includes

// Multi-level relations\nconst campaigns = await prisma.campaign.findMany({\n  include: {\n    emails: {\n      include: {\n        user: true  // Include user who sent email\n      }\n    },\n    createdBy: true\n  }\n});\n

Solution 4: Select only needed fields

// Fetch only needed data\nconst campaigns = await prisma.campaign.findMany({\n  select: {\n    id: true,\n    name: true,\n    emails: {\n      select: {\n        id: true,\n        sentAt: true\n      }\n    }\n  }\n});\n

Solution 5: Use findUnique with include for single record

// Bad\nconst campaign = await prisma.campaign.findUnique({\n  where: { id }\n});\nconst emails = await prisma.campaignEmail.findMany({\n  where: { campaignId: id }\n});\n\n// Good\nconst campaign = await prisma.campaign.findUnique({\n  where: { id },\n  include: { emails: true }\n});\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_9","title":"Prevention","text":"
  • Always use include - Load relations in single query
  • Enable query logging - Monitor for N+1 patterns
  • Code review - Check for loops with queries
  • Testing - Load test with realistic data
"},{"location":"v2/troubleshooting/database-issues/#connection-pool-exhaustion","title":"Connection Pool Exhaustion","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/database-issues/#symptoms_10","title":"Symptoms","text":"
Error: Timed out fetching a new connection from the connection pool.\n

Or:

Error: Can't create connection pool - all connections are in use\n

API becomes unresponsive.

"},{"location":"v2/troubleshooting/database-issues/#common-causes_10","title":"Common Causes","text":"
  1. Pool too small - Not enough connections for load
  2. Connections not released - Long-running transactions
  3. Too many workers - BullMQ workers using all connections
  4. Connection leak - Connections never closed
"},{"location":"v2/troubleshooting/database-issues/#solutions_10","title":"Solutions","text":"

Solution 1: Check pool size

# View DATABASE_URL\ncat .env | grep DATABASE_URL\n\n# Default connection_limit is 10\n# Check if you've set it:\npostgresql://user:pass@host:5432/db?connection_limit=10\n

Solution 2: Increase pool size

# In .env\nDATABASE_URL=\"postgresql://changemaker:password@v2-postgres:5432/changemaker_v2?connection_limit=20\"\n\n# Restart API\ndocker compose restart api\n

Solution 3: Check active connections

-- View connection pool usage\nSELECT count(*), state\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2'\nGROUP BY state;\n\n-- Should show:\n-- count | state\n--    5  | active\n--    2  | idle\n--    3  | idle in transaction\n

Solution 4: Find long-running transactions

-- Find transactions running > 1 minute\nSELECT pid, usename, state, NOW() - xact_start AS duration, query\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2'\n  AND state = 'idle in transaction'\n  AND NOW() - xact_start > INTERVAL '1 minute';\n\n-- Kill if stuck\nSELECT pg_terminate_backend(pid);\n

Solution 5: Configure pool timeout

# Increase timeout from 10s to 30s\nDATABASE_URL=\"postgresql://...?connection_limit=20&pool_timeout=30\"\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_10","title":"Prevention","text":"
  • Appropriate pool size - Size based on load
  • Release connections - Always close transactions
  • Monitor pool usage - Alert when near limit
  • Connection timeout - Kill stuck connections

Pool Sizing

Recommended pool size = (CPU cores \u00d7 2) + effective_spindle_count

For most applications: 10-20 connections per API instance

"},{"location":"v2/troubleshooting/database-issues/#data-issues","title":"Data Issues","text":""},{"location":"v2/troubleshooting/database-issues/#duplicate-records","title":"Duplicate Records","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/database-issues/#symptoms_11","title":"Symptoms","text":"
Error: Unique constraint failed on the fields: (`email`)\n

Or finding multiple records:

SELECT email, count(*)\nFROM \"User\"\nGROUP BY email\nHAVING count(*) > 1;\n-- Returns duplicates\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_11","title":"Common Causes","text":"
  1. Race condition - Two creates at exact same time
  2. Import error - CSV import created duplicates
  3. Migration bug - Migration didn't handle duplicates
  4. No unique constraint - Database allows duplicates
"},{"location":"v2/troubleshooting/database-issues/#solutions_11","title":"Solutions","text":"

Solution 1: Find duplicates

-- Find duplicate emails\nSELECT email, array_agg(id) AS ids, count(*)\nFROM \"User\"\nGROUP BY email\nHAVING count(*) > 1;\n\n-- Example output:\n-- email              | ids                                    | count\n-- john@example.com   | {uuid1, uuid2}                        | 2\n

Solution 2: Delete duplicates (keep oldest)

-- Delete newer duplicates, keep oldest\nDELETE FROM \"User\" u1\nWHERE EXISTS (\n  SELECT 1 FROM \"User\" u2\n  WHERE u2.email = u1.email\n    AND u2.\"createdAt\" < u1.\"createdAt\"\n);\n\n-- Or keep newest:\nDELETE FROM \"User\" u1\nWHERE EXISTS (\n  SELECT 1 FROM \"User\" u2\n  WHERE u2.email = u1.email\n    AND u2.\"createdAt\" > u1.\"createdAt\"\n);\n

Solution 3: Merge duplicates

-- If duplicates have different data, merge:\n-- 1. Update foreign keys to point to kept record\nUPDATE \"Campaign\" SET \"createdByUserId\" = 'uuid-to-keep'\nWHERE \"createdByUserId\" = 'uuid-to-delete';\n\n-- 2. Delete duplicate\nDELETE FROM \"User\" WHERE id = 'uuid-to-delete';\n

Solution 4: Add unique constraint

model User {\n  id    String @id @default(uuid())\n  email String @unique  // Ensures uniqueness\n}\n

Create migration:

# This will fail if duplicates exist\n# Delete duplicates first (Solution 2)\ndocker compose exec api npx prisma migrate dev --name add_unique_email\n

Solution 5: Prevent in application code

// Use upsert instead of create\nconst user = await prisma.user.upsert({\n  where: { email },\n  update: {},  // Don't change if exists\n  create: { email, name, password }\n});\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_11","title":"Prevention","text":"
  • Unique constraints - Database enforces uniqueness
  • Use upsert - Update or create atomically
  • Validation - Check existence before creating
  • Transaction isolation - Prevent race conditions
"},{"location":"v2/troubleshooting/database-issues/#constraint-violations","title":"Constraint Violations","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/database-issues/#symptoms_12","title":"Symptoms","text":"
Error: Foreign key constraint failed on the field: `campaignId`\n

Or:

Error: Null value in column \"name\" violates not-null constraint\n

Or:

Error: Check constraint \"positive_age\" is violated\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_12","title":"Common Causes","text":"
  1. Foreign key missing - Referenced record doesn't exist
  2. Null in required field - NULL when NOT NULL constraint
  3. Check constraint - Value violates CHECK constraint
  4. Data type mismatch - Wrong type for column
"},{"location":"v2/troubleshooting/database-issues/#solutions_12","title":"Solutions","text":"

Solution 1: Verify foreign key exists

-- Check if campaign exists\nSELECT id FROM \"Campaign\" WHERE id = 'campaign-uuid';\n\n-- If not found, create parent first\n

Solution 2: Provide required fields

// Bad - missing required field\nawait prisma.user.create({\n  data: {\n    email: 'user@example.com'\n    // Missing: name (required)\n  }\n});\n\n// Good - all required fields\nawait prisma.user.create({\n  data: {\n    email: 'user@example.com',\n    name: 'User Name',\n    password: 'hashed-password'\n  }\n});\n

Solution 3: Handle check constraints

-- If schema has:\nALTER TABLE \"User\" ADD CONSTRAINT age_check CHECK (age >= 0);\n\n-- Ensure value meets constraint:\nINSERT INTO \"User\" (email, age) VALUES ('user@example.com', 25);\n-- Not: VALUES ('user@example.com', -5);\n

Solution 4: Fix data type

// Bad - passing string for number\nawait prisma.location.create({\n  data: {\n    latitude: \"43.65\" as any  // Wrong type\n  }\n});\n\n// Good - use number\nawait prisma.location.create({\n  data: {\n    latitude: 43.65  // Correct type\n  }\n});\n

Solution 5: Use transactions for dependent creates

// Create parent and child atomically\nawait prisma.$transaction(async (tx) => {\n  const campaign = await tx.campaign.create({\n    data: { name: 'My Campaign' }\n  });\n\n  const email = await tx.campaignEmail.create({\n    data: {\n      campaignId: campaign.id,\n      subject: 'Email Subject'\n    }\n  });\n});\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_12","title":"Prevention","text":"
  • TypeScript types - Catch type errors at compile time
  • Zod validation - Validate before database operations
  • Foreign key checks - Verify parent exists
  • Transactions - Atomic multi-step operations
"},{"location":"v2/troubleshooting/database-issues/#data-corruption","title":"Data Corruption","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/database-issues/#symptoms_13","title":"Symptoms","text":"
  • Invalid JSON in JSON columns
  • Truncated text
  • Wrong character encoding
  • Inconsistent relationships
SELECT * FROM \"Campaign\" WHERE \"settings\"::text LIKE '%\\\\u0000%';\n-- Null bytes in JSON\n
"},{"location":"v2/troubleshooting/database-issues/#common-causes_13","title":"Common Causes","text":"
  1. Bad import - CSV/JSON import with bad data
  2. Encoding issues - Wrong character encoding
  3. Failed migration - Migration partially applied
  4. Application bug - Code writing bad data
"},{"location":"v2/troubleshooting/database-issues/#solutions_13","title":"Solutions","text":"

Solution 1: Detect corruption

-- Find invalid JSON\nSELECT id, settings\nFROM \"Campaign\"\nWHERE settings IS NOT NULL\n  AND settings::text !~ '^[\\[\\{].*[\\]\\}]$';\n\n-- Find null bytes\nSELECT id, name\nFROM \"Location\"\nWHERE name LIKE '%' || chr(0) || '%';\n\n-- Find wrong encoding\nSELECT id, address\nFROM \"Location\"\nWHERE address ~ '[^\\x00-\\x7F]' AND address !~ '[\u00c0-\u00ff]';\n

Solution 2: Fix invalid JSON

-- Replace invalid JSON with valid default\nUPDATE \"Campaign\"\nSET settings = '{}'::jsonb\nWHERE settings IS NOT NULL\n  AND settings::text !~ '^[\\[\\{].*[\\]\\}]$';\n

Solution 3: Fix encoding

-- Convert encoding\nUPDATE \"Location\"\nSET address = convert_from(convert_to(address, 'LATIN1'), 'UTF8')\nWHERE address ~ '[^\\x00-\\x7F]';\n

Solution 4: Restore from backup

# If corruption is widespread, restore from backup\ndocker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup-before-corruption.sql\n

Solution 5: Prevent future corruption

// Validate data before saving\nimport { z } from 'zod';\n\nconst settingsSchema = z.object({\n  key: z.string(),\n  value: z.any()\n});\n\n// Before save\nconst validated = settingsSchema.parse(settings);\nawait prisma.campaign.update({\n  where: { id },\n  data: { settings: validated as any }\n});\n
"},{"location":"v2/troubleshooting/database-issues/#prevention_13","title":"Prevention","text":"
  • Input validation - Validate all inputs with Zod
  • UTF-8 encoding - Use UTF-8 everywhere
  • Regular backups - Daily backups
  • Data integrity checks - Regular validation scripts
"},{"location":"v2/troubleshooting/database-issues/#prisma-studio-issues","title":"Prisma Studio Issues","text":""},{"location":"v2/troubleshooting/database-issues/#wont-connect","title":"Won't Connect","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/database-issues/#symptoms_14","title":"Symptoms","text":"
docker compose exec api npx prisma studio\n

Opens browser but shows:

Error connecting to database\n
"},{"location":"v2/troubleshooting/database-issues/#solutions_14","title":"Solutions","text":"

Solution 1: Check DATABASE_URL

# Verify DATABASE_URL in container\ndocker compose exec api sh -c 'echo $DATABASE_URL'\n\n# Should be valid connection string\n

Solution 2: Test connection

# Test database connection\ndocker compose exec api npx prisma db pull\n\n# If fails, connection string is wrong\n

Solution 3: Use correct port

Prisma Studio runs on port 5555 by default. If port conflicts:

# Use different port\ndocker compose exec api npx prisma studio --port 5556\n

Solution 4: Check database is running

docker compose ps v2-postgres\n# Must be \"Up\"\n
"},{"location":"v2/troubleshooting/database-issues/#slow-loading","title":"Slow Loading","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/database-issues/#symptoms_15","title":"Symptoms","text":"

Prisma Studio takes minutes to load tables with many rows.

"},{"location":"v2/troubleshooting/database-issues/#solutions_15","title":"Solutions","text":"

Solution 1: Limit rows

Prisma Studio loads all rows. For large tables, use SQL instead:

# Instead of Prisma Studio for large tables\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2\n

Solution 2: Add pagination

-- In psql, paginate manually\nSELECT * FROM \"Location\" LIMIT 50 OFFSET 0;\nSELECT * FROM \"Location\" LIMIT 50 OFFSET 50;\n
"},{"location":"v2/troubleshooting/database-issues/#drizzle-kit-issues","title":"Drizzle Kit Issues","text":""},{"location":"v2/troubleshooting/database-issues/#push-failures","title":"Push Failures","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/database-issues/#symptoms_16","title":"Symptoms","text":"
docker compose exec api npx drizzle-kit push\n

Fails with:

Error: Failed to push schema changes\n
"},{"location":"v2/troubleshooting/database-issues/#solutions_16","title":"Solutions","text":"

Solution 1: Check Drizzle config

// In api/drizzle.config.ts\nimport { defineConfig } from 'drizzle-kit';\n\nexport default defineConfig({\n  schema: './src/modules/media/db/schema.ts',\n  out: './drizzle',\n  dialect: 'postgresql',\n  dbCredentials: {\n    url: process.env.DATABASE_URL!\n  }\n});\n

Solution 2: Verify schema file

# Check schema file exists\ndocker compose exec api ls -la src/modules/media/db/schema.ts\n\n# Check for syntax errors\ndocker compose exec api npx tsc --noEmit src/modules/media/db/schema.ts\n

Solution 3: Check for conflicts with Prisma tables

Drizzle and Prisma share same database. Ensure table names don't conflict:

// Drizzle tables\nexport const videos = pgTable('media_videos', { ... });\nexport const reactions = pgTable('media_reactions', { ... });\n\n// Prisma uses: User, Campaign, etc. (no conflict)\n

Solution 4: Manually apply schema

# Generate SQL\ndocker compose exec api npx drizzle-kit generate:pg\n\n# Review SQL in drizzle/ directory\n# Apply manually if needed\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 < drizzle/0000_schema.sql\n
"},{"location":"v2/troubleshooting/database-issues/#backuprestore-issues","title":"Backup/Restore Issues","text":""},{"location":"v2/troubleshooting/database-issues/#pg_dump-errors","title":"pg_dump Errors","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/database-issues/#symptoms_17","title":"Symptoms","text":"
docker compose exec v2-postgres pg_dump -U changemaker changemaker_v2 > backup.sql\n

Fails with:

pg_dump: error: connection to server on socket \"/var/run/postgresql/.s.PGSQL.5432\" failed: No such file or directory\n
"},{"location":"v2/troubleshooting/database-issues/#solutions_17","title":"Solutions","text":"

Solution 1: Use correct connection

# From inside container\ndocker compose exec v2-postgres pg_dump -U changemaker changemaker_v2 > backup.sql\n\n# Or specify host explicitly\ndocker compose exec v2-postgres pg_dump -U changemaker -h v2-postgres changemaker_v2 > backup.sql\n

Solution 2: Backup to file inside container

# Dump to file inside container\ndocker compose exec v2-postgres pg_dump -U changemaker changemaker_v2 -f /tmp/backup.sql\n\n# Copy to host\ndocker cp changemaker-lite-v2-postgres-1:/tmp/backup.sql ./backup.sql\n

Solution 3: Use backup script

# Use provided backup script\n./scripts/backup.sh\n
"},{"location":"v2/troubleshooting/database-issues/#restore-failures","title":"Restore Failures","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/database-issues/#symptoms_18","title":"Symptoms","text":"
docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup.sql\n

Fails with errors:

ERROR: relation \"User\" already exists\nERROR: duplicate key value violates unique constraint\n
"},{"location":"v2/troubleshooting/database-issues/#solutions_18","title":"Solutions","text":"

Solution 1: Drop database first

# \u26a0\ufe0f DELETES ALL DATA!\ndocker compose exec v2-postgres psql -U postgres -c \"DROP DATABASE changemaker_v2;\"\ndocker compose exec v2-postgres psql -U postgres -c \"CREATE DATABASE changemaker_v2 OWNER changemaker;\"\n\n# Then restore\ndocker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup.sql\n

Solution 2: Use --clean flag

# Create backup with clean option\ndocker compose exec v2-postgres pg_dump -U changemaker --clean changemaker_v2 > backup.sql\n\n# Restore (drops existing objects first)\ndocker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup.sql\n

Solution 3: Ignore errors for existing objects

# Restore and ignore \"already exists\" errors\ndocker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup.sql 2>&1 | grep -v \"already exists\"\n
"},{"location":"v2/troubleshooting/database-issues/#useful-commands","title":"Useful Commands","text":""},{"location":"v2/troubleshooting/database-issues/#query-database","title":"Query Database","text":"
# Connect to database\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2\n\n# Run single query\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"SELECT NOW();\"\n\n# Run SQL file\ndocker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < script.sql\n\n# Export query results to CSV\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"COPY (SELECT * FROM \\\"User\\\") TO STDOUT WITH CSV HEADER\" > users.csv\n
"},{"location":"v2/troubleshooting/database-issues/#database-inspection","title":"Database Inspection","text":"
# List databases\ndocker compose exec v2-postgres psql -U postgres -l\n\n# List tables\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"\\dt\"\n\n# Describe table\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"\\d \\\"User\\\"\"\n\n# List indexes\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"\\di\"\n\n# View table sizes\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"\nSELECT\n  schemaname,\n  tablename,\n  pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size\nFROM pg_tables\nWHERE schemaname = 'public'\nORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;\n\"\n
"},{"location":"v2/troubleshooting/database-issues/#performance-analysis","title":"Performance Analysis","text":"
# Current activity\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"\nSELECT pid, usename, application_name, state, query_start, query\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2'\nORDER BY query_start;\n\"\n\n# Table statistics\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"\nSELECT schemaname, tablename, n_live_tup, n_dead_tup, last_autovacuum\nFROM pg_stat_user_tables\nORDER BY n_live_tup DESC;\n\"\n\n# Index usage\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"\nSELECT schemaname, tablename, indexname, idx_scan, idx_tup_read, idx_tup_fetch\nFROM pg_stat_user_indexes\nORDER BY idx_scan DESC;\n\"\n\n# Unused indexes\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c \"\nSELECT schemaname, tablename, indexname, idx_scan\nFROM pg_stat_user_indexes\nWHERE idx_scan = 0 AND indexname NOT LIKE '%pkey'\nORDER BY pg_relation_size(indexname::regclass) DESC;\n\"\n
"},{"location":"v2/troubleshooting/database-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/database-issues/#database-documentation","title":"Database Documentation","text":"
  • Database Issues - This guide
  • Installation Guide - Initial database setup
  • Architecture Overview - Database architecture
"},{"location":"v2/troubleshooting/database-issues/#other-troubleshooting","title":"Other Troubleshooting","text":"
  • Common Errors - General errors
  • Docker Issues - Container problems
  • Performance Optimization - Database tuning
"},{"location":"v2/troubleshooting/database-issues/#postgresql-resources","title":"PostgreSQL Resources","text":"
  • PostgreSQL Documentation
  • Prisma Documentation
  • Drizzle Documentation

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/troubleshooting/docker-issues/","title":"Docker and Container Issues","text":"

This guide covers Docker-specific problems in Changemaker Lite V2.

"},{"location":"v2/troubleshooting/docker-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/docker-issues/#docker-troubleshooting-approach","title":"Docker Troubleshooting Approach","text":"
  1. Check status - Are containers running?
  2. Read logs - What do container logs show?
  3. Inspect configuration - Is docker-compose.yml correct?
  4. Test connectivity - Can containers communicate?
  5. Resource check - Enough CPU/memory/disk?
"},{"location":"v2/troubleshooting/docker-issues/#essential-docker-commands","title":"Essential Docker Commands","text":"
# View running containers\ndocker compose ps\n\n# View all containers (including stopped)\ndocker compose ps -a\n\n# View logs\ndocker compose logs [service-name]\n\n# Follow logs in real-time\ndocker compose logs -f [service-name]\n\n# Execute command in container\ndocker compose exec [service-name] [command]\n\n# Restart service\ndocker compose restart [service-name]\n\n# Stop all services\ndocker compose down\n\n# Start services\ndocker compose up -d\n\n# Rebuild and start\ndocker compose up -d --build [service-name]\n
"},{"location":"v2/troubleshooting/docker-issues/#container-wont-start","title":"Container Won't Start","text":""},{"location":"v2/troubleshooting/docker-issues/#port-already-in-use","title":"Port Already in Use","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms","title":"Symptoms","text":"
Error response from daemon: driver failed programming external connectivity\non endpoint changemaker-lite-admin-1: Bind for 0.0.0.0:3000 failed:\nport is already allocated\n

Or:

ERROR: for api  Cannot start service api: Ports are not available:\nexposing port TCP 0.0.0.0:4000 -> 0.0.0.0:0: listen tcp 0.0.0.0:4000:\nbind: address already in use\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes","title":"Common Causes","text":"
  1. Another container using port - Different Docker project
  2. Host process using port - npm dev server running
  3. Previous container not stopped - Old container still running
  4. Port conflict in docker-compose.yml - Two services same port
"},{"location":"v2/troubleshooting/docker-issues/#solutions","title":"Solutions","text":"

Solution 1: Find what's using the port

# Linux/Mac\nsudo lsof -i :4000\n\n# Or with netstat\nnetstat -tuln | grep :4000\n\n# Windows\nnetstat -ano | findstr :4000\n

Output shows:

COMMAND   PID  USER   FD   TYPE DEVICE SIZE/OFF NODE NAME\nnode    12345  user   23u  IPv4 123456      0t0  TCP *:4000 (LISTEN)\n

Solution 2: Stop conflicting process

# Kill process by PID\nkill 12345\n\n# Or kill all node processes (careful!)\nkillall node\n\n# Or stop other Docker containers\ndocker ps  # List all running containers\ndocker stop container-name-or-id\n

Solution 3: Change port in docker-compose.yml

# In docker-compose.yml\napi:\n  ports:\n    - \"4002:4000\"  # Changed from 4000:4000\n

Then:

# Restart with new port\ndocker compose up -d api\n\n# Update .env to use new port\nVITE_API_URL=http://localhost:4002\n

Solution 4: Stop all and restart

# Stop all Changemaker Lite containers\ndocker compose down\n\n# Verify nothing running\ndocker compose ps\n\n# Start fresh\ndocker compose up -d\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention","title":"Prevention","text":"
  • Use unique ports - Avoid common ports (3000, 4000, 8000, 8080)
  • Stop properly - Always use docker compose down
  • Check before start - Run docker compose ps first
  • Document ports - Keep port reference updated
"},{"location":"v2/troubleshooting/docker-issues/#volume-mount-errors","title":"Volume Mount Errors","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_1","title":"Symptoms","text":"
Error response from daemon: invalid mount config for type \"bind\":\nbind source path does not exist: /home/user/changemaker.lite/uploads\n

Or:

Error: EACCES: permission denied, open '/media/local/inbox/video.mp4'\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes_1","title":"Common Causes","text":"
  1. Path doesn't exist - Directory not created
  2. Permission denied - Container can't access directory
  3. Wrong path - Typo in docker-compose.yml
  4. SELinux blocking - Linux security policy
"},{"location":"v2/troubleshooting/docker-issues/#solutions_1","title":"Solutions","text":"

Solution 1: Create missing directories

# Create all required directories\nmkdir -p uploads\nmkdir -p media/local/inbox\nmkdir -p media/local/library\nmkdir -p data\nmkdir -p configs/prometheus\nmkdir -p configs/grafana\n\n# Verify they exist\nls -la\n

Solution 2: Fix permissions

# Make directories writable\nchmod -R 777 uploads\nchmod -R 777 media/local/inbox\n\n# Or set ownership to container user\n# Check container user ID\ndocker compose exec api id\n# uid=1000(node) gid=1000(node)\n\n# Set ownership\nsudo chown -R 1000:1000 uploads\nsudo chown -R 1000:1000 media\n

Solution 3: Check volume configuration

In docker-compose.yml:

api:\n  volumes:\n    # Correct format:\n    - ./uploads:/app/uploads:rw        # Read-write\n    - ./media:/media:ro                 # Read-only\n\n    # Wrong formats:\n    # - uploads:/app/uploads            # Named volume, not bind mount\n    # - /uploads:/app/uploads           # Absolute path on host\n

Solution 4: Disable SELinux (last resort)

# Check if SELinux is the issue\ngetenforce\n# If \"Enforcing\":\n\n# Option 1: Add :z flag to volume\n# In docker-compose.yml:\n    - ./uploads:/app/uploads:z\n\n# Option 2: Temporarily disable (not recommended)\nsudo setenforce 0\n

Solution 5: Verify mount inside container

# Check if mount exists\ndocker compose exec api ls -la /app/uploads\n\n# Check permissions\ndocker compose exec api ls -ld /app/uploads\n\n# Try creating file\ndocker compose exec api touch /app/uploads/test.txt\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_1","title":"Prevention","text":"
  • Create directories first - Before docker compose up
  • Set permissions early - In setup script
  • Use relative paths - Start with ./ in docker-compose.yml
  • Document requirements - List all required directories
"},{"location":"v2/troubleshooting/docker-issues/#missing-environment-variables","title":"Missing Environment Variables","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_2","title":"Symptoms","text":"

Container logs show:

Error: DATABASE_URL is required\n

Or:

ZodError: [\n  {\n    \"code\": \"invalid_type\",\n    \"expected\": \"string\",\n    \"received\": \"undefined\",\n    \"path\": [\"SMTP_HOST\"],\n    \"message\": \"Required\"\n  }\n]\n

Or container exits immediately:

changemaker-lite-api-1 exited with code 1\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes_2","title":"Common Causes","text":"
  1. .env not found - Missing .env file
  2. Variable not set - Missing required variable
  3. Wrong .env location - .env not in project root
  4. Syntax error - Malformed .env file
"},{"location":"v2/troubleshooting/docker-issues/#solutions_2","title":"Solutions","text":"

Solution 1: Check .env exists

# Verify .env file\nls -la .env\n\n# If missing, copy from example\ncp .env.example .env\n

Solution 2: Find missing variables

# View container logs to see which variable\ndocker compose logs api | grep -i \"required\\|undefined\"\n\n# Example output:\n# Error: SMTP_HOST is required\n

Solution 3: Add missing variables

# Edit .env\nnano .env\n\n# Add missing variable\nSMTP_HOST=smtp.gmail.com\n\n# Save and restart\ndocker compose restart api\n

Solution 4: Validate .env format

# Check for common issues:\n# - No spaces around =\n# - Quotes for values with spaces\n# - No trailing commas\n# - No comments on same line as value\n\n# Good:\nDATABASE_URL=\"postgresql://user:pass@host:5432/db\"\nCORS_ORIGINS=http://localhost:3000,http://localhost:4000\n\n# Bad:\nDATABASE_URL = \"postgresql://...\"  # Space around =\nCORS_ORIGINS=http://localhost:3000, http://localhost:4000  # Space after comma\nSMTP_HOST=smtp.gmail.com # Gmail  # Comment on same line\n

Solution 5: Check which variables are loaded

# View environment inside container\ndocker compose exec api env | grep -E \"DATABASE_URL|SMTP_HOST|JWT_\"\n\n# Should show actual values (not undefined)\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_2","title":"Prevention","text":"
  • Use .env.example - Keep template updated
  • Validation on startup - Zod validates env in config/env.ts
  • Documentation - Document all required variables
  • Setup script - Validate .env before starting
"},{"location":"v2/troubleshooting/docker-issues/#health-check-failures","title":"Health Check Failures","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_3","title":"Symptoms","text":"
docker compose ps\n

Shows:

NAME                    STATUS\napi                     Up 30 seconds (unhealthy)\nv2-postgres            Up 1 minute (healthy)\n

Or logs show:

Health check failed\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes_3","title":"Common Causes","text":"
  1. Service not ready - Still starting up
  2. Health check endpoint failing - /health returns error
  3. Timeout too short - Service needs more time
  4. Dependencies not ready - Database not connected
"},{"location":"v2/troubleshooting/docker-issues/#solutions_3","title":"Solutions","text":"

Solution 1: Check health check configuration

In docker-compose.yml:

api:\n  healthcheck:\n    test: [\"CMD\", \"wget\", \"--quiet\", \"--tries=1\", \"--spider\", \"http://localhost:4000/api/health\"]\n    interval: 30s\n    timeout: 10s\n    retries: 3\n    start_period: 40s\n

Solution 2: Test health endpoint manually

# From inside container\ndocker compose exec api wget -O- http://localhost:4000/api/health\n\n# Should return:\n# {\"status\":\"healthy\",\"timestamp\":\"2026-02-13T...\"}\n\n# From host\ncurl http://localhost:4000/api/health\n

Solution 3: View health check logs

# Detailed health check output\ndocker inspect changemaker-lite-api-1 --format='{{json .State.Health}}' | jq\n\n# Shows:\n# {\n#   \"Status\": \"unhealthy\",\n#   \"FailingStreak\": 3,\n#   \"Log\": [\n#     {\n#       \"Start\": \"2026-02-13T...\",\n#       \"End\": \"2026-02-13T...\",\n#       \"ExitCode\": 1,\n#       \"Output\": \"Error: Connection refused\"\n#     }\n#   ]\n# }\n

Solution 4: Increase timeout/interval

api:\n  healthcheck:\n    interval: 60s      # Check less frequently\n    timeout: 30s       # Allow more time\n    start_period: 90s  # Wait longer before first check\n

Solution 5: Check service logs

# Real issue is usually in service logs\ndocker compose logs api | tail -50\n\n# Common issues:\n# - Database connection failed\n# - Missing environment variable\n# - Port already in use\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_3","title":"Prevention","text":"
  • Reasonable timeouts - Allow enough time for startup
  • Accurate health checks - Check actual readiness
  • Monitor health - Alert on unhealthy containers
  • Dependencies - Use depends_on with condition: service_healthy
"},{"location":"v2/troubleshooting/docker-issues/#container-crashes","title":"Container Crashes","text":""},{"location":"v2/troubleshooting/docker-issues/#out-of-memory","title":"Out of Memory","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_4","title":"Symptoms","text":"

Container logs show:

<--- Last few GCs --->\n[1:0x5588e4f8e000]    65432 ms: Mark-sweep 2048.0 (2048.4) -> 2047.9 (2048.4) MB, 1845.2 / 0.0 ms  (average mu = 0.123, current mu = 0.001) allocation failure scavenge might not succeed\n\n<--- JS stacktrace --->\nFATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory\n

Or:

Killed\n

Or docker compose ps shows:

api   Exit 137\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes_4","title":"Common Causes","text":"
  1. Memory leak - Application leaking memory
  2. Large dataset - Processing too much data
  3. Too many connections - Database connection pool too large
  4. Container limit - Memory limit too low
"},{"location":"v2/troubleshooting/docker-issues/#solutions_4","title":"Solutions","text":"

Solution 1: Check memory usage

# View container memory usage\ndocker stats\n\n# Shows:\n# CONTAINER           CPU %    MEM USAGE / LIMIT     MEM %\n# api                 15.5%    1.2GiB / 2GiB        60%\n

Solution 2: Increase Node.js heap size

In docker-compose.yml:

api:\n  environment:\n    - NODE_OPTIONS=--max-old-space-size=4096  # 4GB heap\n

Or in api/package.json:

{\n  \"scripts\": {\n    \"start\": \"node --max-old-space-size=4096 dist/server.js\"\n  }\n}\n

Solution 3: Increase container memory limit

api:\n  deploy:\n    resources:\n      limits:\n        memory: 4G  # Increase from 2G\n      reservations:\n        memory: 2G\n

Solution 4: Find memory leak

# Enable heap snapshots\ndocker compose exec api node --inspect dist/server.js\n\n# Or use clinic.js\nnpm install -g clinic\nclinic doctor -- node dist/server.js\n

Solution 5: Reduce memory usage

// Reduce database connection pool\n// In prisma/schema.prisma\ndatasource db {\n  provider = \"postgresql\"\n  url      = env(\"DATABASE_URL\")\n  // Add connection limit\n}\n\n// In DATABASE_URL:\nDATABASE_URL=\"postgresql://...?connection_limit=5\"\n\n// Process data in batches\nconst users = await prisma.user.findMany({\n  take: 100,  // Limit batch size\n  skip: offset\n});\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_4","title":"Prevention","text":"
  • Monitor memory - Alert on high usage
  • Generous limits - Set limits higher than expected usage
  • Memory profiling - Regular memory audits
  • Optimize queries - Reduce data fetched
"},{"location":"v2/troubleshooting/docker-issues/#application-errors","title":"Application Errors","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_5","title":"Symptoms","text":"

Container exits immediately:

api-1 exited with code 1\n

Logs show:

Error: Cannot find module 'express'\n

Or:

SyntaxError: Unexpected token 'export'\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes_5","title":"Common Causes","text":"
  1. Missing dependencies - npm install not run
  2. Build not run - TypeScript not compiled
  3. Syntax error - Code has errors
  4. Wrong Node version - Incompatible Node.js version
"},{"location":"v2/troubleshooting/docker-issues/#solutions_5","title":"Solutions","text":"

Solution 1: Rebuild container

# Rebuild with no cache\ndocker compose build --no-cache api\n\n# Start\ndocker compose up -d api\n\n# View logs\ndocker compose logs -f api\n

Solution 2: Check dependencies

# Verify package.json and package-lock.json exist\ndocker compose exec api ls -la package*.json\n\n# Verify node_modules exists\ndocker compose exec api ls -la node_modules | head\n\n# If missing, install\ndocker compose exec api npm install\n

Solution 3: Verify build

# Check if TypeScript compiled\ndocker compose exec api ls -la dist/\n\n# If missing, build\ndocker compose exec api npm run build\n\n# Or rebuild container\ndocker compose up -d --build api\n

Solution 4: Check Node version

# Check version in container\ndocker compose exec api node --version\n\n# Should match Dockerfile\ncat api/Dockerfile | grep \"FROM node:\"\n\n# Example:\n# FROM node:20-alpine\n

Solution 5: Test locally

# Test build locally\ncd api\nnpm install\nnpm run build\nnpm start\n\n# If works locally but not in Docker, check:\n# - Dockerfile COPY commands\n# - .dockerignore file\n# - Volume mounts\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_5","title":"Prevention","text":"
  • Multi-stage builds - Separate build and runtime
  • Lock files - Commit package-lock.json
  • CI/CD - Automated build testing
  • Version pinning - Pin Node.js version
"},{"location":"v2/troubleshooting/docker-issues/#database-connection-failures","title":"Database Connection Failures","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_6","title":"Symptoms","text":"

API logs show:

Error: Can't reach database server at `v2-postgres:5432`\nError: connect ECONNREFUSED 172.18.0.2:5432\n

Container restarts repeatedly.

"},{"location":"v2/troubleshooting/docker-issues/#common-causes_6","title":"Common Causes","text":"
  1. Database not ready - API started before database
  2. Wrong host - Incorrect database hostname
  3. Network issue - Containers on different networks
  4. Database crashed - PostgreSQL container down
"},{"location":"v2/troubleshooting/docker-issues/#solutions_6","title":"Solutions","text":"

Solution 1: Check database status

# Is database running?\ndocker compose ps v2-postgres\n\n# Should show \"Up\" status\n# If not:\ndocker compose up -d v2-postgres\n\n# Check logs\ndocker compose logs v2-postgres | tail -50\n

Solution 2: Verify DATABASE_URL

# Check .env\ncat .env | grep DATABASE_URL\n\n# From API container, should use container name:\nDATABASE_URL=\"postgresql://changemaker:password@v2-postgres:5432/changemaker_v2\"\n\n# From host, use localhost:\nDATABASE_URL=\"postgresql://changemaker:password@localhost:5433/changemaker_v2\"\n

Solution 3: Test database connection

# From API container\ndocker compose exec api sh -c 'psql $DATABASE_URL -c \"SELECT NOW();\"'\n\n# Should return current timestamp\n# If fails, database connection is broken\n

Solution 4: Check Docker network

# List networks\ndocker network ls\n\n# Inspect changemaker-lite network\ndocker network inspect changemaker-lite\n\n# All containers should be on same network\n

Solution 5: Use depends_on with health check

In docker-compose.yml:

api:\n  depends_on:\n    v2-postgres:\n      condition: service_healthy\n  # ...\n\nv2-postgres:\n  healthcheck:\n    test: [\"CMD-SHELL\", \"pg_isready -U changemaker\"]\n    interval: 10s\n    timeout: 5s\n    retries: 5\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_6","title":"Prevention","text":"
  • Health checks - Wait for database to be ready
  • Retry logic - Retry connection on startup
  • Connection pooling - Handle connection failures gracefully
  • Monitoring - Alert on connection failures
"},{"location":"v2/troubleshooting/docker-issues/#networking-issues","title":"Networking Issues","text":""},{"location":"v2/troubleshooting/docker-issues/#containers-cant-communicate","title":"Containers Can't Communicate","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_7","title":"Symptoms","text":"
Error: getaddrinfo ENOTFOUND v2-postgres\n

Or:

Error: connect EHOSTUNREACH 172.18.0.2:5432\n

Containers can't ping each other.

"},{"location":"v2/troubleshooting/docker-issues/#common-causes_7","title":"Common Causes","text":"
  1. Different networks - Containers on separate Docker networks
  2. Wrong hostname - Using IP instead of container name
  3. Firewall - Host firewall blocking
  4. DNS issue - Docker DNS not working
"},{"location":"v2/troubleshooting/docker-issues/#solutions_7","title":"Solutions","text":"

Solution 1: Verify same network

# Check container networks\ndocker inspect changemaker-lite-api-1 | grep NetworkMode\ndocker inspect changemaker-lite-v2-postgres-1 | grep NetworkMode\n\n# Should both show \"changemaker-lite\"\n

Solution 2: Use container names

# Correct - use service names\napi:\n  environment:\n    - DATABASE_URL=postgresql://user:pass@v2-postgres:5432/db\n\n# Wrong - using IPs\napi:\n  environment:\n    - DATABASE_URL=postgresql://user:pass@172.18.0.2:5432/db\n

Solution 3: Test connectivity

# Ping from one container to another\ndocker compose exec api ping v2-postgres\n\n# DNS lookup\ndocker compose exec api nslookup v2-postgres\n\n# Telnet to port\ndocker compose exec api telnet v2-postgres 5432\n

Solution 4: Recreate network

# Stop all containers\ndocker compose down\n\n# Remove network\ndocker network rm changemaker-lite\n\n# Start fresh (network auto-created)\ndocker compose up -d\n

Solution 5: Check firewall

# Temporarily disable firewall (Linux)\nsudo ufw disable\n\n# Test if containers can communicate\n# If yes, firewall is blocking\n\n# Re-enable and add rules\nsudo ufw enable\nsudo ufw allow from 172.18.0.0/16 to any\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_7","title":"Prevention","text":"
  • Use service names - Never hardcode IPs
  • Single network - All services on same network
  • Docker DNS - Rely on Docker's built-in DNS
  • Health checks - Verify connectivity on startup
"},{"location":"v2/troubleshooting/docker-issues/#port-not-accessible-from-host","title":"Port Not Accessible from Host","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_8","title":"Symptoms","text":"

From host:

curl http://localhost:4000/api/health\n# curl: (7) Failed to connect to localhost port 4000: Connection refused\n

But from inside container:

docker compose exec api curl http://localhost:4000/api/health\n# {\"status\":\"healthy\"}\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes_8","title":"Common Causes","text":"
  1. Port not published - Missing ports: in docker-compose.yml
  2. Bound to 127.0.0.1 - Only listening on localhost inside container
  3. Firewall blocking - Host firewall blocking port
  4. Wrong port - Trying different port than published
"},{"location":"v2/troubleshooting/docker-issues/#solutions_8","title":"Solutions","text":"

Solution 1: Check port publishing

In docker-compose.yml:

api:\n  ports:\n    - \"4000:4000\"  # host:container\n

Verify:

docker compose ps api\n\n# Should show:\n# PORTS: 0.0.0.0:4000->4000/tcp\n

Solution 2: Bind to 0.0.0.0

In api/src/server.ts:

// Wrong - only localhost\napp.listen(4000, '127.0.0.1');\n\n// Right - all interfaces\napp.listen(4000, '0.0.0.0');\n\n// Or just\napp.listen(4000);  // Defaults to 0.0.0.0\n

Solution 3: Check firewall

# Check if port allowed (Linux)\nsudo ufw status\n\n# Allow port\nsudo ufw allow 4000/tcp\n\n# Or disable temporarily for testing\nsudo ufw disable\n

Solution 4: Verify correct port

# Check what ports are actually listening\ndocker compose exec api netstat -tuln\n\n# Should show:\n# tcp6  0  0  :::4000  :::*  LISTEN\n

Solution 5: Restart with port forwarding

# Stop container\ndocker compose stop api\n\n# Remove container\ndocker compose rm -f api\n\n# Start fresh\ndocker compose up -d api\n\n# Verify port\ncurl http://localhost:4000/api/health\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_8","title":"Prevention","text":"
  • Always publish ports - In docker-compose.yml
  • Bind to 0.0.0.0 - Not 127.0.0.1
  • Test from host - Verify accessibility
  • Document ports - Keep port reference updated
"},{"location":"v2/troubleshooting/docker-issues/#dns-resolution-failures","title":"DNS Resolution Failures","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_9","title":"Symptoms","text":"
Error: getaddrinfo ENOTFOUND smtp.gmail.com\n

Or:

Error: getaddrinfo EAI_AGAIN api.represent.org\n

Container can't resolve external hostnames.

"},{"location":"v2/troubleshooting/docker-issues/#common-causes_9","title":"Common Causes","text":"
  1. Docker DNS issue - Docker DNS not working
  2. No internet - Container has no internet access
  3. Firewall blocking DNS - Port 53 blocked
  4. Wrong DNS servers - Using invalid DNS servers
"},{"location":"v2/troubleshooting/docker-issues/#solutions_9","title":"Solutions","text":"

Solution 1: Test DNS resolution

# From inside container\ndocker compose exec api nslookup google.com\n\n# Should return IP address\n# If not, DNS is broken\n

Solution 2: Check Docker DNS

# View container DNS config\ndocker compose exec api cat /etc/resolv.conf\n\n# Should show:\n# nameserver 127.0.0.11  # Docker's embedded DNS\n

Solution 3: Use custom DNS servers

In docker-compose.yml:

api:\n  dns:\n    - 8.8.8.8      # Google DNS\n    - 8.8.4.4\n

Or in /etc/docker/daemon.json:

{\n  \"dns\": [\"8.8.8.8\", \"8.8.4.4\"]\n}\n

Then restart Docker:

sudo systemctl restart docker\n

Solution 4: Check internet connectivity

# Ping external host\ndocker compose exec api ping -c 3 8.8.8.8\n\n# If fails, no internet access\n# Check host internet connection\nping -c 3 8.8.8.8\n

Solution 5: Restart Docker daemon

# Sometimes Docker DNS gets stuck\nsudo systemctl restart docker\n\n# Then restart containers\ndocker compose down\ndocker compose up -d\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_9","title":"Prevention","text":"
  • Reliable DNS - Use public DNS servers as backup
  • Monitor connectivity - Alert on DNS failures
  • Health checks - Include external connectivity checks
  • Retry logic - Handle transient DNS failures
"},{"location":"v2/troubleshooting/docker-issues/#volume-issues","title":"Volume Issues","text":""},{"location":"v2/troubleshooting/docker-issues/#permission-denied","title":"Permission Denied","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_10","title":"Symptoms","text":"
Error: EACCES: permission denied, open '/app/uploads/image.jpg'\n

Or:

Error: EACCES: permission denied, mkdir '/media/local/inbox'\n

File operations fail inside container.

"},{"location":"v2/troubleshooting/docker-issues/#common-causes_10","title":"Common Causes","text":"
  1. Wrong ownership - Host directory owned by different user
  2. Wrong permissions - Directory not writable
  3. SELinux - Linux security policy blocking
  4. Read-only mount - Volume mounted as read-only
"},{"location":"v2/troubleshooting/docker-issues/#solutions_10","title":"Solutions","text":"

Solution 1: Check ownership

# On host\nls -la uploads/\n\n# Shows:\n# drwxr-xr-x  2 root   root   4096 Feb 13 10:00 uploads\n\n# Check container user\ndocker compose exec api id\n# uid=1000(node) gid=1000(node)\n\n# Fix ownership\nsudo chown -R 1000:1000 uploads/\n

Solution 2: Fix permissions

# Make writable\nchmod -R 755 uploads/\n\n# Or more permissive (dev only)\nchmod -R 777 uploads/\n

Solution 3: Check mount mode

In docker-compose.yml:

api:\n  volumes:\n    - ./uploads:/app/uploads:rw  # Read-write\n    # Not:\n    # - ./uploads:/app/uploads:ro  # Read-only\n

Solution 4: SELinux labels

# Add :z flag to volume\n# In docker-compose.yml:\n    - ./uploads:/app/uploads:z\n\n# Or relabel directory\nsudo chcon -Rt svirt_sandbox_file_t uploads/\n

Solution 5: Run as root (not recommended)

# In docker-compose.yml (last resort)\napi:\n  user: \"0:0\"  # Run as root\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_10","title":"Prevention","text":"
  • Set permissions early - In setup script
  • Match UIDs - Container user matches host user
  • SELinux-aware - Use :z flag on volumes
  • Document requirements - List permission requirements
"},{"location":"v2/troubleshooting/docker-issues/#volume-not-mounted","title":"Volume Not Mounted","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_11","title":"Symptoms","text":"

Container can't see files that exist on host.

# On host\nls uploads/\n# image.jpg  video.mp4\n\n# In container\ndocker compose exec api ls /app/uploads/\n# (empty)\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes_11","title":"Common Causes","text":"
  1. Wrong path - Volume path incorrect
  2. Typo - Syntax error in docker-compose.yml
  3. Not mounted - Volume mount missing
  4. Cached old config - Using old container
"},{"location":"v2/troubleshooting/docker-issues/#solutions_11","title":"Solutions","text":"

Solution 1: Verify volume configuration

In docker-compose.yml:

api:\n  volumes:\n    - ./uploads:/app/uploads  # host:container\n

Solution 2: Check mounts in running container

# Inspect container mounts\ndocker inspect changemaker-lite-api-1 | grep -A 10 Mounts\n\n# Should show:\n# \"Mounts\": [\n#   {\n#     \"Type\": \"bind\",\n#     \"Source\": \"/home/user/changemaker.lite/uploads\",\n#     \"Destination\": \"/app/uploads\",\n#     \"Mode\": \"\",\n#     \"RW\": true,\n#     \"Propagation\": \"rprivate\"\n#   }\n# ]\n

Solution 3: Recreate container

# Stop and remove container\ndocker compose down api\n\n# Start fresh\ndocker compose up -d api\n\n# Verify mount\ndocker compose exec api ls /app/uploads/\n

Solution 4: Use absolute path

# Sometimes relative paths don't work\napi:\n  volumes:\n    - /home/user/changemaker.lite/uploads:/app/uploads\n

Solution 5: Check Docker Compose version

# Check version\ndocker compose version\n\n# Should be v2+\n# If v1, syntax might differ\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_11","title":"Prevention","text":"
  • Test mounts - Verify after container start
  • Use relative paths - Start with ./
  • Documentation - Document all volume mounts
  • Health checks - Verify critical files exist
"},{"location":"v2/troubleshooting/docker-issues/#data-persistence-problems","title":"Data Persistence Problems","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_12","title":"Symptoms","text":"

Data disappears after docker compose down:

  • Database data lost
  • Uploaded files missing
  • Configuration reset
"},{"location":"v2/troubleshooting/docker-issues/#common-causes_12","title":"Common Causes","text":"
  1. Using containers, not volumes - Data stored in container filesystem
  2. Anonymous volumes - Volume not named or bound
  3. Deleting volumes - docker compose down -v removes volumes
  4. Wrong volume type - tmpfs instead of volume
"},{"location":"v2/troubleshooting/docker-issues/#solutions_12","title":"Solutions","text":"

Solution 1: Use named volumes

In docker-compose.yml:

v2-postgres:\n  volumes:\n    - postgres-data:/var/lib/postgresql/data  # Named volume\n\nvolumes:\n  postgres-data:  # Declare named volume\n

Solution 2: Use bind mounts

v2-postgres:\n  volumes:\n    - ./data/postgres:/var/lib/postgresql/data  # Bind to host directory\n

Solution 3: Don't use -v flag

# Wrong - deletes volumes\ndocker compose down -v\n\n# Right - keeps volumes\ndocker compose down\n

Solution 4: Check volume exists

# List volumes\ndocker volume ls\n\n# Should show:\n# changemaker-lite_postgres-data\n\n# Inspect volume\ndocker volume inspect changemaker-lite_postgres-data\n

Solution 5: Backup before down

# Backup database before stopping\ndocker compose exec v2-postgres pg_dump -U changemaker changemaker_v2 > backup.sql\n\n# Then safe to:\ndocker compose down -v\n\n# Restore after up:\ndocker compose up -d v2-postgres\ndocker compose exec -T v2-postgres psql -U changemaker changemaker_v2 < backup.sql\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_12","title":"Prevention","text":"
  • Named volumes - For all persistent data
  • Regular backups - Automated backup script
  • Never use -v - Unless intentionally resetting
  • Documentation - Document what data persists where
"},{"location":"v2/troubleshooting/docker-issues/#performance-issues","title":"Performance Issues","text":""},{"location":"v2/troubleshooting/docker-issues/#slow-container-startup","title":"Slow Container Startup","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_13","title":"Symptoms","text":"

Container takes minutes to start:

docker compose up -d api\n# Creating api ... (2 minutes)\n# Creating api ... done\n
"},{"location":"v2/troubleshooting/docker-issues/#common-causes_13","title":"Common Causes","text":"
  1. Large image - Downloading/extracting large image
  2. Many dependencies - npm install taking long
  3. Health check delay - Waiting for health checks
  4. Slow disk - I/O bottleneck
"},{"location":"v2/troubleshooting/docker-issues/#solutions_13","title":"Solutions","text":"

Solution 1: Use pre-built image

# Instead of building locally\napi:\n  build: ./api\n\n# Use pre-built image from registry\napi:\n  image: ghcr.io/yourorg/changemaker-api:latest\n

Solution 2: Layer caching

# In Dockerfile, copy package files first\nCOPY package*.json ./\nRUN npm ci\n\n# Then copy code (changes more frequently)\nCOPY . .\nRUN npm run build\n

Solution 3: Multi-stage builds

# Build stage\nFROM node:20-alpine AS builder\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nRUN npm run build\n\n# Runtime stage (smaller)\nFROM node:20-alpine\nWORKDIR /app\nCOPY --from=builder /app/dist ./dist\nCOPY --from=builder /app/node_modules ./node_modules\nCOPY package*.json ./\nCMD [\"node\", \"dist/server.js\"]\n

Solution 4: Increase Docker resources

In Docker Desktop settings:

  • CPU: 4+ cores
  • Memory: 8GB+
  • Disk: Fast SSD

Solution 5: Parallel builds

# Build all services in parallel\ndocker compose build --parallel\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_13","title":"Prevention","text":"
  • Optimize Dockerfile - Layer caching, multi-stage
  • Small base images - Alpine instead of full images
  • Registry caching - Pull from registry instead of building
  • Resource allocation - Adequate CPU/memory for Docker
"},{"location":"v2/troubleshooting/docker-issues/#high-cpu-usage","title":"High CPU Usage","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_14","title":"Symptoms","text":"
docker stats\n# CONTAINER   CPU %\n# api         95%\n

Container consuming excessive CPU.

"},{"location":"v2/troubleshooting/docker-issues/#common-causes_14","title":"Common Causes","text":"
  1. Infinite loop - Bug causing tight loop
  2. Heavy computation - Processing large dataset
  3. Too many workers - Worker threads maxed out
  4. Memory thrashing - Swapping due to low memory
"},{"location":"v2/troubleshooting/docker-issues/#solutions_14","title":"Solutions","text":"

Solution 1: Identify process

# Top inside container\ndocker compose exec api top\n\n# Shows process using CPU\n

Solution 2: Check for loops

# View logs for repeated messages\ndocker compose logs api | tail -100\n\n# Restart if stuck\ndocker compose restart api\n

Solution 3: Limit worker threads

// In BullMQ worker\nnew Worker('queueName', processor, {\n  concurrency: 2,  // Reduce from 10\n  limiter: {\n    max: 10,\n    duration: 1000  // Max 10 jobs per second\n  }\n});\n

Solution 4: Set CPU limits

api:\n  deploy:\n    resources:\n      limits:\n        cpus: '2.0'  # Max 2 CPUs\n

Solution 5: Profile application

# Use Node.js profiler\ndocker compose exec api node --prof dist/server.js\n\n# Or clinic.js\nnpm install -g clinic\nclinic doctor -- node dist/server.js\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_14","title":"Prevention","text":"
  • Monitor CPU - Alert on high usage
  • Rate limiting - Limit request rate
  • Queue management - Control worker concurrency
  • Performance testing - Load test regularly
"},{"location":"v2/troubleshooting/docker-issues/#high-memory-usage","title":"High Memory Usage","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/docker-issues/#symptoms_15","title":"Symptoms","text":"
docker stats\n# CONTAINER   MEM USAGE / LIMIT\n# api         3.8GiB / 4GiB\n

Memory usage keeps increasing.

"},{"location":"v2/troubleshooting/docker-issues/#common-causes_15","title":"Common Causes","text":"
  1. Memory leak - Not releasing memory
  2. Large cache - Caching too much data
  3. Database connections - Too many open connections
  4. Large response bodies - Sending huge payloads
"},{"location":"v2/troubleshooting/docker-issues/#solutions_15","title":"Solutions","text":"

Solution 1: Identify memory usage

# Memory breakdown inside container\ndocker compose exec api sh -c 'cat /proc/meminfo'\n\n# Node.js heap stats\ndocker compose exec api node -e \"console.log(process.memoryUsage())\"\n

Solution 2: Restart to free memory

# Temporary fix\ndocker compose restart api\n\n# Memory should drop\ndocker stats api\n

Solution 3: Reduce cache size

// In Redis cache\nredis.set(key, value, 'EX', 3600);  // Expire after 1 hour\n\n// Limit cache size\nconst cache = new LRU({\n  max: 1000,  // Max 1000 entries\n  maxAge: 3600000  // 1 hour\n});\n

Solution 4: Set memory limit

api:\n  deploy:\n    resources:\n      limits:\n        memory: 2G  # Hard limit\n      reservations:\n        memory: 1G  # Reserved amount\n

Solution 5: Find memory leak

# Take heap snapshot\ndocker compose exec api node --expose-gc --inspect dist/server.js\n\n# Use Chrome DevTools to analyze\n# chrome://inspect\n
"},{"location":"v2/troubleshooting/docker-issues/#prevention_15","title":"Prevention","text":"
  • Monitor memory - Alert on high usage
  • Memory limits - Prevent runaway processes
  • Regular restarts - Restart daily if leaking
  • Memory profiling - Profile in staging
"},{"location":"v2/troubleshooting/docker-issues/#useful-commands","title":"Useful Commands","text":""},{"location":"v2/troubleshooting/docker-issues/#viewing-logs","title":"Viewing Logs","text":"
# Last 100 lines\ndocker compose logs api --tail=100\n\n# Follow logs (real-time)\ndocker compose logs -f api\n\n# All services\ndocker compose logs\n\n# Since timestamp\ndocker compose logs --since=\"2026-02-13T10:00:00\"\n\n# Filter by keyword\ndocker compose logs api | grep -i error\n\n# Save to file\ndocker compose logs api > api-logs.txt\n
"},{"location":"v2/troubleshooting/docker-issues/#executing-commands","title":"Executing Commands","text":"
# Run command in running container\ndocker compose exec api npm run migrate\n\n# Interactive shell\ndocker compose exec api sh\n\n# Run as different user\ndocker compose exec -u root api sh\n\n# Run in new container (one-off)\ndocker compose run --rm api npm test\n
"},{"location":"v2/troubleshooting/docker-issues/#inspecting-containers","title":"Inspecting Containers","text":"
# View container details\ndocker inspect changemaker-lite-api-1\n\n# View specific field\ndocker inspect changemaker-lite-api-1 --format='{{.State.Status}}'\n\n# View environment variables\ndocker inspect changemaker-lite-api-1 --format='{{range .Config.Env}}{{println .}}{{end}}'\n\n# View mounts\ndocker inspect changemaker-lite-api-1 --format='{{json .Mounts}}' | jq\n
"},{"location":"v2/troubleshooting/docker-issues/#container-management","title":"Container Management","text":"
# Start all services\ndocker compose up -d\n\n# Start specific service\ndocker compose up -d api\n\n# Stop all services\ndocker compose stop\n\n# Stop specific service\ndocker compose stop api\n\n# Restart service\ndocker compose restart api\n\n# Remove stopped containers\ndocker compose rm\n\n# Stop and remove\ndocker compose down\n
"},{"location":"v2/troubleshooting/docker-issues/#rebuilding","title":"Rebuilding","text":"
# Rebuild single service\ndocker compose build api\n\n# Rebuild without cache\ndocker compose build --no-cache api\n\n# Build all services\ndocker compose build\n\n# Build and start\ndocker compose up -d --build\n\n# Force recreate containers\ndocker compose up -d --force-recreate\n
"},{"location":"v2/troubleshooting/docker-issues/#log-analysis","title":"Log Analysis","text":""},{"location":"v2/troubleshooting/docker-issues/#reading-container-logs","title":"Reading Container Logs","text":"

Logs follow this pattern:

[timestamp] [level] [message]\n2026-02-13T10:30:00.000Z INFO Server started on port 4000\n
"},{"location":"v2/troubleshooting/docker-issues/#common-log-patterns","title":"Common Log Patterns","text":"

Successful startup:

INFO Connecting to database...\nINFO Database connected\nINFO Registered route: GET /api/health\nINFO Registered route: POST /api/auth/login\nINFO Server started on port 4000\n

Database connection error:

INFO Connecting to database...\nERROR Can't reach database server at `v2-postgres:5432`\nERROR Retrying in 5 seconds...\n

Missing environment variable:

ERROR Environment validation failed:\nERROR   SMTP_HOST is required\nERROR   JWT_ACCESS_SECRET is required\n

Health check failure:

WARN Health check failed: Database not connected\n
"},{"location":"v2/troubleshooting/docker-issues/#filtering-logs","title":"Filtering Logs","text":"
# Only errors\ndocker compose logs api | grep ERROR\n\n# Only warnings and errors\ndocker compose logs api | grep -E \"ERROR|WARN\"\n\n# Exclude health checks\ndocker compose logs api | grep -v \"GET /api/health\"\n\n# Find specific request\ndocker compose logs api | grep \"POST /api/users\"\n\n# Find by request ID\ndocker compose logs api | grep \"req-abc123\"\n
"},{"location":"v2/troubleshooting/docker-issues/#cleanup-commands","title":"Cleanup Commands","text":""},{"location":"v2/troubleshooting/docker-issues/#remove-stopped-containers","title":"Remove Stopped Containers","text":"
# Remove all stopped containers\ndocker compose down\n\n# Remove specific service containers\ndocker compose rm api\n\n# Force remove running containers\ndocker compose rm -f api\n
"},{"location":"v2/troubleshooting/docker-issues/#remove-images","title":"Remove Images","text":"
# Remove all images for project\ndocker compose down --rmi all\n\n# Remove only project-built images (not postgres, redis, etc.)\ndocker compose down --rmi local\n\n# Remove specific image\ndocker rmi changemaker-lite-api\n\n# Remove dangling images\ndocker image prune\n
"},{"location":"v2/troubleshooting/docker-issues/#remove-volumes","title":"Remove Volumes","text":"
# \u26a0\ufe0f WARNING: Deletes all data!\ndocker compose down -v\n\n# Remove specific volume\ndocker volume rm changemaker-lite_postgres-data\n\n# Remove unused volumes\ndocker volume prune\n
"},{"location":"v2/troubleshooting/docker-issues/#remove-networks","title":"Remove Networks","text":"
# Remove project network (containers must be stopped first)\ndocker network rm changemaker-lite\n\n# Remove unused networks\ndocker network prune\n
"},{"location":"v2/troubleshooting/docker-issues/#full-cleanup","title":"Full Cleanup","text":"
# \u26a0\ufe0f DANGER: Removes everything!\ndocker compose down -v --rmi all\ndocker system prune -a --volumes\n\n# This deletes:\n# - All containers\n# - All volumes (data lost!)\n# - All images\n# - All networks\n# - All build cache\n
"},{"location":"v2/troubleshooting/docker-issues/#safe-cleanup","title":"Safe Cleanup","text":"
# Safe cleanup (keeps volumes)\ndocker compose down\ndocker image prune -a\ndocker network prune\n\n# This keeps:\n# - Volumes (data safe)\n# - .env file\n# - Application code\n
"},{"location":"v2/troubleshooting/docker-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/docker-issues/#docker-documentation","title":"Docker Documentation","text":"
  • Docker Issues - This guide
  • Installation Guide - Initial setup
  • Architecture Overview - System design
"},{"location":"v2/troubleshooting/docker-issues/#other-troubleshooting","title":"Other Troubleshooting","text":"
  • Common Errors - General errors
  • Database Issues - PostgreSQL problems
  • Monitoring Issues - Observability problems
"},{"location":"v2/troubleshooting/docker-issues/#docker-resources","title":"Docker Resources","text":"
  • Docker Compose Reference
  • Dockerfile Best Practices
  • Docker Networking

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/troubleshooting/email-issues/","title":"Email and SMTP Issues","text":"

This guide covers email sending, SMTP configuration, and template-related problems in Changemaker Lite V2.

"},{"location":"v2/troubleshooting/email-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/email-issues/#email-system-architecture","title":"Email System Architecture","text":"

Changemaker Lite V2 has dual email systems:

  1. Transactional Emails (BullMQ + Nodemailer)
  2. Campaign advocacy emails
  3. Shift confirmation emails
  4. Response verification emails
  5. System notifications

  6. Newsletter Emails (Listmonk)

  7. Marketing campaigns
  8. Newsletter broadcasts
  9. Subscriber management
"},{"location":"v2/troubleshooting/email-issues/#email-flow","title":"Email Flow","text":"
User Action \u2192 Email Service \u2192 BullMQ Queue \u2192 Worker \u2192 SMTP Server \u2192 Recipient\n
"},{"location":"v2/troubleshooting/email-issues/#key-components","title":"Key Components","text":"
  • BullMQ - Job queue for async email sending
  • Nodemailer - SMTP client library
  • Redis - Queue backend
  • MailHog - Development email capture (test mode)
  • Listmonk - Newsletter platform (optional)
"},{"location":"v2/troubleshooting/email-issues/#smtp-configuration","title":"SMTP Configuration","text":""},{"location":"v2/troubleshooting/email-issues/#connection-refused","title":"Connection Refused","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/email-issues/#symptoms","title":"Symptoms","text":"

API logs:

Error: Connection timeout\nError: connect ECONNREFUSED smtp.gmail.com:587\nError: Invalid login: 535-5.7.8 Username and Password not accepted\n

Emails not sending.

"},{"location":"v2/troubleshooting/email-issues/#common-causes","title":"Common Causes","text":"
  1. Wrong SMTP host - Incorrect hostname
  2. Port blocked - Firewall blocking port 587/465
  3. Wrong credentials - Invalid username/password
  4. TLS/SSL mismatch - Wrong secure setting
"},{"location":"v2/troubleshooting/email-issues/#solutions","title":"Solutions","text":"

Solution 1: Test SMTP connection

# Test with telnet\ntelnet smtp.gmail.com 587\n\n# Should show:\n# 220 smtp.gmail.com ESMTP ...\n\n# Or test with openssl (for SSL)\nopenssl s_client -connect smtp.gmail.com:465\n

Solution 2: Verify SMTP configuration

In .env:

# Gmail example (requires app password)\nSMTP_HOST=smtp.gmail.com\nSMTP_PORT=587\nSMTP_SECURE=false  # false for STARTTLS on 587, true for SSL on 465\nSMTP_USER=your-email@gmail.com\nSMTP_PASS=your-app-password  # NOT regular password\nSMTP_FROM=your-email@gmail.com\n\n# Office365 example\nSMTP_HOST=smtp.office365.com\nSMTP_PORT=587\nSMTP_SECURE=false\nSMTP_USER=your-email@outlook.com\nSMTP_PASS=your-password\nSMTP_FROM=your-email@outlook.com\n\n# SendGrid example\nSMTP_HOST=smtp.sendgrid.net\nSMTP_PORT=587\nSMTP_SECURE=false\nSMTP_USER=apikey  # Literally \"apikey\"\nSMTP_PASS=your-sendgrid-api-key\nSMTP_FROM=your-verified-sender@example.com\n

Solution 3: Use test mode

# In .env\nEMAIL_TEST_MODE=true\n\n# Restart API\ndocker compose restart api\n\n# All emails now sent to MailHog\n# View at http://localhost:8025\n

Solution 4: Test email sending

# Send test email via API\ncurl -X POST http://localhost:4000/api/test-email \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\n    \"to\": \"test@example.com\",\n    \"subject\": \"Test Email\",\n    \"text\": \"This is a test email from Changemaker Lite\"\n  }'\n\n# Check API logs\ndocker compose logs api | grep -i \"email\\|smtp\"\n

Solution 5: Gmail app password

For Gmail (required if 2FA enabled):

  1. Go to https://myaccount.google.com/apppasswords
  2. Select app: Mail
  3. Select device: Other (Changemaker Lite)
  4. Click Generate
  5. Copy 16-character password
  6. Use in SMTP_PASS (no spaces)
"},{"location":"v2/troubleshooting/email-issues/#prevention","title":"Prevention","text":"
  • Test mode for dev - Use MailHog locally
  • Secure credentials - Use app passwords, not real passwords
  • Environment-specific - Different SMTP per environment
  • Health checks - Test SMTP on API startup
"},{"location":"v2/troubleshooting/email-issues/#authentication-failed","title":"Authentication Failed","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/email-issues/#symptoms_1","title":"Symptoms","text":"
Error: Invalid login: 535-5.7.8 Username and Password not accepted\nError: 535 Authentication failed\n
"},{"location":"v2/troubleshooting/email-issues/#common-causes_1","title":"Common Causes","text":"
  1. Wrong password - Incorrect password
  2. 2FA enabled - Need app password
  3. Less secure apps - Gmail blocking
  4. Account locked - Too many failed attempts
"},{"location":"v2/troubleshooting/email-issues/#solutions_1","title":"Solutions","text":"

Solution 1: Verify credentials

# Check .env\ncat .env | grep SMTP_\n\n# Test login manually (if possible)\n# Gmail doesn't allow this, but some SMTP servers do\n

Solution 2: Enable less secure apps (Gmail)

\u26a0\ufe0f Not recommended. Use app password instead.

  1. Go to https://myaccount.google.com/lesssecureapps
  2. Turn on \"Allow less secure apps\"

Solution 3: Check account status

  1. Try logging into email account via web
  2. Check for security alerts
  3. Verify account not locked

Solution 4: Use OAuth2 (advanced)

For production Gmail:

// In email.service.ts\nconst transporter = nodemailer.createTransporter({\n  service: 'gmail',\n  auth: {\n    type: 'OAuth2',\n    user: process.env.SMTP_USER,\n    clientId: process.env.GMAIL_CLIENT_ID,\n    clientSecret: process.env.GMAIL_CLIENT_SECRET,\n    refreshToken: process.env.GMAIL_REFRESH_TOKEN\n  }\n});\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_1","title":"Prevention","text":"
  • App passwords - Always use app-specific passwords
  • Test credentials - Verify before deploying
  • Monitor failures - Alert on auth failures
  • Backup SMTP - Configure fallback SMTP server
"},{"location":"v2/troubleshooting/email-issues/#invalid-credentials","title":"Invalid Credentials","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/email-issues/#symptoms_2","title":"Symptoms","text":"
Error: Invalid SMTP credentials\nError: Username and Password not accepted\n
"},{"location":"v2/troubleshooting/email-issues/#solutions_2","title":"Solutions","text":"

See \"Authentication Failed\" section above.

"},{"location":"v2/troubleshooting/email-issues/#port-blocked","title":"Port Blocked","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/email-issues/#symptoms_3","title":"Symptoms","text":"
Error: connect ETIMEDOUT smtp.gmail.com:587\nError: Connection timeout\n

Connection attempt hangs, then times out after 30+ seconds.

"},{"location":"v2/troubleshooting/email-issues/#common-causes_2","title":"Common Causes","text":"
  1. Firewall blocking - Network firewall blocking port
  2. ISP blocking - ISP blocks port 25/587
  3. Docker network - Container can't reach external SMTP
"},{"location":"v2/troubleshooting/email-issues/#solutions_3","title":"Solutions","text":"

Solution 1: Test port access

# From API container\ndocker compose exec api telnet smtp.gmail.com 587\n\n# If timeout, port is blocked\n

Solution 2: Try alternative port

# Try port 465 (SSL) instead of 587 (STARTTLS)\nSMTP_PORT=465\nSMTP_SECURE=true\n\n# Or try port 2525 (some providers)\nSMTP_PORT=2525\nSMTP_SECURE=false\n

Solution 3: Check Docker network

# Test external connectivity\ndocker compose exec api ping -c 3 smtp.gmail.com\n\n# Test DNS resolution\ndocker compose exec api nslookup smtp.gmail.com\n\n# If fails, Docker network issue\n

Solution 4: Use SMTP relay

If ISP blocks SMTP, use relay service: - SendGrid - Mailgun - Amazon SES - Postmark

Solution 5: VPN or proxy

As last resort, route SMTP through VPN/proxy.

"},{"location":"v2/troubleshooting/email-issues/#prevention_2","title":"Prevention","text":"
  • Use relay services - More reliable than direct SMTP
  • Multiple ports - Try 587, 465, 2525
  • Test on deploy - Verify SMTP works in production
  • Documentation - Document network requirements
"},{"location":"v2/troubleshooting/email-issues/#template-issues","title":"Template Issues","text":""},{"location":"v2/troubleshooting/email-issues/#template-not-found","title":"Template Not Found","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/email-issues/#symptoms_4","title":"Symptoms","text":"

API logs:

Error: Email template not found: campaign-email\nError: ENOENT: no such file or directory, open 'templates/campaign-email.html'\n

"},{"location":"v2/troubleshooting/email-issues/#common-causes_3","title":"Common Causes","text":"
  1. Template file missing - File doesn't exist
  2. Wrong template name - Typo in name
  3. Wrong directory - Looking in wrong path
  4. Deleted template - Template was removed
"},{"location":"v2/troubleshooting/email-issues/#solutions_4","title":"Solutions","text":"

Solution 1: List available templates

# List template files\ndocker compose exec api ls -la templates/\n\n# Should show:\n# campaign-email.html\n# shift-confirmation.html\n# verification-email.html\n# response-verification.html\n

Solution 2: Create missing template

# Create template file\ndocker compose exec api sh -c 'cat > templates/my-template.html << EOF\n<!DOCTYPE html>\n<html>\n<head>\n  <meta charset=\"UTF-8\">\n  <title>{{title}}</title>\n</head>\n<body>\n  <h1>Hello {{name}}</h1>\n  <p>{{message}}</p>\n</body>\n</html>\nEOF'\n

Solution 3: Use email template system

Navigate to /app/email-templates:

  1. Click \"Create Template\"
  2. Fill in details
  3. Design template
  4. Save (creates file + DB record)

Solution 4: Check template name

// In code, template name must match filename (without .html)\nawait emailService.sendEmail({\n  to: email,\n  subject: 'Campaign Email',\n  template: 'campaign-email',  // Looks for templates/campaign-email.html\n  variables: { ... }\n});\n

Solution 5: Verify template path

In api/src/services/email.service.ts:

const templatePath = path.join(__dirname, '../../templates', `${template}.html`);\n// Resolves to: api/templates/campaign-email.html\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_3","title":"Prevention","text":"
  • Seed templates - Include default templates in seed
  • Template management - Use admin UI to manage
  • Version control - Keep templates in git
  • Validation - Check template exists before sending
"},{"location":"v2/troubleshooting/email-issues/#variable-not-replaced","title":"Variable Not Replaced","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/email-issues/#symptoms_5","title":"Symptoms","text":"

Email received with unreplaced placeholders:

Hello {{name}},\n\nYour campaign {{campaignName}} is ready.\n
"},{"location":"v2/troubleshooting/email-issues/#common-causes_4","title":"Common Causes","text":"
  1. Variable not provided - Missing from variables object
  2. Typo in variable name - Mismatch between template and code
  3. Wrong delimiter - Using ${} instead of {{}}
  4. Escaping issue - HTML entities interfering
"},{"location":"v2/troubleshooting/email-issues/#solutions_5","title":"Solutions","text":"

Solution 1: List template variables

# Find all variables in template\ndocker compose exec api grep -o '{{[^}]*}}' templates/campaign-email.html\n\n# Shows:\n# {{name}}\n# {{campaignName}}\n# {{campaignUrl}}\n

Solution 2: Provide all variables

await emailService.sendEmail({\n  to: email,\n  subject: 'Campaign Ready',\n  template: 'campaign-email',\n  variables: {\n    name: user.name,  // Must provide ALL variables in template\n    campaignName: campaign.name,\n    campaignUrl: `${process.env.PUBLIC_URL}/campaigns/${campaign.id}`\n  }\n});\n

Solution 3: Check variable delimiter

<!-- Correct (Handlebars-style) -->\n<h1>Hello {{name}}</h1>\n<p>Your campaign {{campaignName}} is ready.</p>\n\n<!-- Wrong -->\n<h1>Hello ${name}</h1>  <!-- JavaScript template literal -->\n<p>Your campaign {campaignName} is ready.</p>  <!-- Single braces -->\n

Solution 4: Test template rendering

# Test template rendering\ncurl -X POST http://localhost:4000/api/test-template \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\n    \"template\": \"campaign-email\",\n    \"variables\": {\n      \"name\": \"John\",\n      \"campaignName\": \"Save the Planet\",\n      \"campaignUrl\": \"https://example.com/campaigns/123\"\n    }\n  }'\n\n# Returns rendered HTML\n

Solution 5: Use default values

<!-- In template, provide fallback -->\n<h1>Hello {{name || \"Friend\"}}</h1>\n

Or in code:

const variables = {\n  name: user.name || 'Friend',\n  campaignName: campaign.name || 'Campaign',\n  campaignUrl: campaignUrl || '#'\n};\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_4","title":"Prevention","text":"
  • Template validation - Check all variables exist
  • TypeScript types - Type template variables
  • Default values - Always provide defaults
  • Testing - Test all templates with sample data
"},{"location":"v2/troubleshooting/email-issues/#syntax-errors","title":"Syntax Errors","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/email-issues/#symptoms_6","title":"Symptoms","text":"
Error: Parse error in template at line 15\nError: Unexpected token in template\n

Email fails to send.

"},{"location":"v2/troubleshooting/email-issues/#common-causes_5","title":"Common Causes","text":"
  1. Invalid HTML - Malformed HTML
  2. Unclosed tags - Missing closing tags
  3. Special characters - Unescaped < > &
  4. Handlebars syntax - Invalid {{}} usage
"},{"location":"v2/troubleshooting/email-issues/#solutions_6","title":"Solutions","text":"

Solution 1: Validate HTML

# Use HTML validator\n# Copy template content to https://validator.w3.org/nu/\n\n# Or validate locally\ndocker compose exec api npx html-validate templates/campaign-email.html\n

Solution 2: Check common errors

<!-- Unclosed tag -->\n<div>Content here\n<!-- Should be: -->\n<div>Content here</div>\n\n<!-- Unescaped characters -->\nPrice: $50 < $100\n<!-- Should be: -->\nPrice: $50 &lt; $100\n\n<!-- Invalid Handlebars -->\n{{if name}}  <!-- No \"if\" helper by default -->\n<!-- Should be: -->\n{{#if name}}...{{/if}}  <!-- Or don't use if -->\n

Solution 3: Escape HTML

// In email.service.ts\nimport handlebars from 'handlebars';\n\n// Register escape helper\nhandlebars.registerHelper('escape', (str) => {\n  return handlebars.escapeExpression(str);\n});\n\n// In template\n<p>Message: {{escape message}}</p>\n

Solution 4: Test template compilation

// Test if template compiles\nimport handlebars from 'handlebars';\nimport fs from 'fs';\n\nconst templateSource = fs.readFileSync('templates/campaign-email.html', 'utf8');\ntry {\n  const template = handlebars.compile(templateSource);\n  console.log('Template compiles successfully');\n} catch (error) {\n  console.error('Template error:', error.message);\n}\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_5","title":"Prevention","text":"
  • HTML validation - Validate before saving
  • Linting - Use HTML linter in editor
  • Simple templates - Keep templates simple
  • Testing - Test rendering before deploying
"},{"location":"v2/troubleshooting/email-issues/#queue-issues","title":"Queue Issues","text":""},{"location":"v2/troubleshooting/email-issues/#queue-stuck","title":"Queue Stuck","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/email-issues/#symptoms_7","title":"Symptoms","text":"

Emails queued but not sending. Queue shows jobs but no progress.

"},{"location":"v2/troubleshooting/email-issues/#solutions_7","title":"Solutions","text":"

Solution 1: Check queue status

# View queue stats\ncurl http://localhost:4000/api/influence/email-queue/stats \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Shows:\n# {\n#   \"waiting\": 50,\n#   \"active\": 0,  # Should be > 0 if processing\n#   \"completed\": 1000,\n#   \"failed\": 5\n# }\n

Solution 2: Check worker is running

# Worker should log processing\ndocker compose logs api | grep -i \"email worker\\|processing email\"\n\n# Should show:\n# Email worker started\n# Processing email job for campaign: abc-123\n

Solution 3: Restart worker

# Restart API (restarts worker)\ndocker compose restart api\n\n# Check worker started\ndocker compose logs api | grep \"Email worker started\"\n

Solution 4: Check Redis

# Test Redis connection\ndocker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD ping\n\n# Check queue keys\ndocker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD keys \"bull:email-queue:*\"\n

Solution 5: Process stuck jobs

# Retry failed jobs\ncurl -X POST http://localhost:4000/api/influence/email-queue/retry-failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Clean old jobs\ncurl -X POST http://localhost:4000/api/influence/email-queue/clean \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\"status\": \"completed\", \"grace\": 86400000}'  # Clean completed > 1 day\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_6","title":"Prevention","text":"
  • Health checks - Monitor worker health
  • Auto-restart - Restart worker if stuck
  • Alerting - Alert if queue backed up
  • Dead letter queue - Move repeatedly failed jobs
"},{"location":"v2/troubleshooting/email-issues/#jobs-failing","title":"Jobs Failing","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/email-issues/#symptoms_8","title":"Symptoms","text":"

High failed job count. Emails not reaching recipients.

"},{"location":"v2/troubleshooting/email-issues/#solutions_8","title":"Solutions","text":"

Solution 1: View failed jobs

# Get failed job details\ncurl http://localhost:4000/api/influence/email-queue/failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Shows:\n# [\n#   {\n#     \"id\": \"123\",\n#     \"data\": { \"to\": \"user@example.com\", \"subject\": \"...\" },\n#     \"failedReason\": \"SMTP connection failed\",\n#     \"attemptsMade\": 3\n#   }\n# ]\n

Solution 2: Check error patterns

# Common failure reasons\ndocker compose logs api | grep \"Email failed\" | sort | uniq -c\n\n# Example output:\n#  25 Email failed: Invalid email address\n#  10 Email failed: SMTP connection refused\n#   3 Email failed: Recipient mailbox full\n

Solution 3: Retry with fixes

# Fix SMTP config if needed\n# Then retry failed jobs\ncurl -X POST http://localhost:4000/api/influence/email-queue/retry-failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n

Solution 4: Manual intervention

For repeatedly failing emails:

  1. Check email address validity
  2. Verify SMTP configuration
  3. Test with different recipient
  4. Check if recipient's mailbox full
"},{"location":"v2/troubleshooting/email-issues/#prevention_7","title":"Prevention","text":"
  • Retry logic - Auto-retry with exponential backoff
  • Email validation - Validate before queuing
  • Error categorization - Permanent vs transient failures
  • Bounce handling - Handle bounce notifications
"},{"location":"v2/troubleshooting/email-issues/#delivery-issues","title":"Delivery Issues","text":""},{"location":"v2/troubleshooting/email-issues/#emails-not-arriving","title":"Emails Not Arriving","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/email-issues/#symptoms_9","title":"Symptoms","text":"

Emails sent successfully (no errors) but not received.

"},{"location":"v2/troubleshooting/email-issues/#common-causes_6","title":"Common Causes","text":"
  1. Spam folder - Filtered to spam
  2. Email delay - Taking long to deliver
  3. Email blocking - Recipient server blocking
  4. Wrong address - Typo in email address
"},{"location":"v2/troubleshooting/email-issues/#solutions_9","title":"Solutions","text":"

Solution 1: Check spam folder

  1. Check spam/junk folder
  2. Check promotions tab (Gmail)
  3. Mark as \"Not Spam\" to whitelist

Solution 2: Check email logs

# Verify email was sent\ndocker compose logs api | grep \"Email sent\"\n\n# Should show:\n# Email sent to user@example.com: Campaign Email\n

Solution 3: Use MailHog to test

# In .env\nEMAIL_TEST_MODE=true\n\n# Restart API\ndocker compose restart api\n\n# Send test email\ncurl -X POST http://localhost:4000/api/test-email \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\"to\": \"test@example.com\", \"subject\": \"Test\", \"text\": \"Test\"}'\n\n# Check MailHog\n# http://localhost:8025\n\n# If appears in MailHog, SMTP working\n# If not appearing in real inbox, delivery issue\n

Solution 4: Check email headers

In MailHog or received email: 1. View full headers 2. Check \"Received\" path 3. Look for spam scores 4. Check SPF/DKIM/DMARC status

Solution 5: Test with different address

# Try sending to different email provider\n# Gmail vs Outlook vs Yahoo\n# If some work and others don't, specific provider blocking\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_8","title":"Prevention","text":"
  • Email authentication - SPF, DKIM, DMARC
  • Reputation management - Maintain good sender reputation
  • Bounce handling - Monitor bounces
  • Testing - Regular delivery tests
"},{"location":"v2/troubleshooting/email-issues/#marked-as-spam","title":"Marked as Spam","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/email-issues/#symptoms_10","title":"Symptoms","text":"

Emails consistently go to spam folder.

"},{"location":"v2/troubleshooting/email-issues/#solutions_10","title":"Solutions","text":"

Solution 1: Configure SPF

Add TXT record to DNS:

v=spf1 include:_spf.google.com ~all\n

Or for SendGrid:

v=spf1 include:sendgrid.net ~all\n

Solution 2: Configure DKIM

  1. Generate DKIM keys (via email provider)
  2. Add DKIM TXT record to DNS
  3. Enable DKIM signing in SMTP settings

Solution 3: Configure DMARC

Add TXT record to DNS:

v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com\n

Solution 4: Improve email content

  • Use plain text version alongside HTML
  • Avoid spam trigger words (\"FREE\", \"CLICK HERE\", \"ACT NOW\")
  • Proper from/reply-to addresses
  • Unsubscribe link
  • Physical address in footer

Solution 5: Warm up IP

If using dedicated IP: 1. Start with low volume 2. Gradually increase over weeks 3. Monitor reputation scores

"},{"location":"v2/troubleshooting/email-issues/#prevention_9","title":"Prevention","text":"
  • Email authentication - SPF, DKIM, DMARC mandatory
  • Content quality - Professional, non-spammy content
  • Reputation monitoring - Monitor sender scores
  • Engagement - High engagement = good reputation
"},{"location":"v2/troubleshooting/email-issues/#bounce-errors","title":"Bounce Errors","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/email-issues/#symptoms_11","title":"Symptoms","text":"
Email bounced: user@example.com\n554 Recipient address rejected: User unknown\n
"},{"location":"v2/troubleshooting/email-issues/#common-causes_7","title":"Common Causes","text":"
  1. Invalid address - Email doesn't exist
  2. Full mailbox - Recipient mailbox full
  3. Temporary failure - Server temporarily unavailable
  4. Blocked sender - Your domain/IP blocked
"},{"location":"v2/troubleshooting/email-issues/#solutions_11","title":"Solutions","text":"

Solution 1: Categorize bounces

Hard bounces (permanent): - User unknown - Domain doesn't exist - Invalid address format

Soft bounces (temporary): - Mailbox full - Server temporarily unavailable - Message too large

Solution 2: Handle hard bounces

# Remove hard bounce addresses\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET \\\"emailBounced\\\" = true\n      WHERE email = 'bounced@example.com';\"\n\n# Don't send to bounced addresses\n

Solution 3: Retry soft bounces

# Retry soft bounces after delay\ncurl -X POST http://localhost:4000/api/influence/email-queue/retry-failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n

Solution 4: Validate emails before sending

import validator from 'validator';\n\nconst isValidEmail = validator.isEmail(email);\nif (!isValidEmail) {\n  throw new Error('Invalid email address');\n}\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_10","title":"Prevention","text":"
  • Email validation - Validate before saving
  • Bounce tracking - Track bounces per address
  • Automatic removal - Don't send to bounced addresses
  • Double opt-in - Confirm email addresses work
"},{"location":"v2/troubleshooting/email-issues/#listmonk-integration","title":"Listmonk Integration","text":""},{"location":"v2/troubleshooting/email-issues/#api-connection-failed","title":"API Connection Failed","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/email-issues/#symptoms_12","title":"Symptoms","text":"
Error: Failed to connect to Listmonk API\nError: ECONNREFUSED localhost:9001\n
"},{"location":"v2/troubleshooting/email-issues/#solutions_12","title":"Solutions","text":"

Solution 1: Check Listmonk is running

docker compose ps listmonk\n\n# Should show \"Up\"\n# If not:\ndocker compose up -d listmonk\n

Solution 2: Verify API credentials

# Check .env\ncat .env | grep LISTMONK_\n\n# Required:\nLISTMONK_URL=http://listmonk:9001\nLISTMONK_ADMIN_USER=admin\nLISTMONK_ADMIN_PASSWORD=password\n

Solution 3: Test API connection

# From API container\ndocker compose exec api curl -u admin:password http://listmonk:9001/api/health\n\n# Should return:\n# {\"data\": \"OK\"}\n

Solution 4: Check Docker network

# Both on same network?\ndocker inspect changemaker-lite-api-1 | grep NetworkMode\ndocker inspect changemaker-lite-listmonk-1 | grep NetworkMode\n\n# Should both show \"changemaker-lite\"\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_11","title":"Prevention","text":"
  • Health checks - Verify Listmonk health on API startup
  • Proper credentials - Use API user (not web admin)
  • Network config - Ensure same Docker network
  • Error handling - Graceful degradation if Listmonk down
"},{"location":"v2/troubleshooting/email-issues/#sync-errors","title":"Sync Errors","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/email-issues/#symptoms_13","title":"Symptoms","text":"
Error: Failed to sync subscribers to Listmonk\nError: 400 Bad Request: Invalid email format\n
"},{"location":"v2/troubleshooting/email-issues/#solutions_13","title":"Solutions","text":"

Solution 1: Check sync status

Navigate to /app/listmonk:

  • View sync statistics
  • See last sync time
  • Check error count

Solution 2: View sync logs

docker compose logs api | grep -i \"listmonk\\|sync\"\n\n# Shows:\n# Syncing 150 participants to Listmonk\n# Created list: Campaign Participants\n# Added 145 subscribers, 5 failed\n

Solution 3: Manual sync

# Trigger manual sync\ncurl -X POST http://localhost:4000/api/listmonk/sync \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n

Solution 4: Check subscriber data

# View failed subscribers\ndocker compose logs api | grep \"Failed to add subscriber\"\n\n# Common issues:\n# - Invalid email format\n# - Email already exists\n# - Missing required fields\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_12","title":"Prevention","text":"
  • Data validation - Validate before sync
  • Duplicate handling - Handle existing subscribers
  • Error logging - Log sync errors
  • Regular syncs - Automated periodic syncs
"},{"location":"v2/troubleshooting/email-issues/#performance-issues","title":"Performance Issues","text":""},{"location":"v2/troubleshooting/email-issues/#slow-email-sending","title":"Slow Email Sending","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/email-issues/#symptoms_14","title":"Symptoms","text":"

Sending emails takes several seconds each. Bulk sends very slow.

"},{"location":"v2/troubleshooting/email-issues/#solutions_14","title":"Solutions","text":"

Solution 1: Use queue system

# Don't send synchronously\n# Queue emails instead\ncurl -X POST http://localhost:4000/api/influence/campaigns/CAMPAIGN_ID/send-bulk \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Processes in background via queue\n

Solution 2: Increase worker concurrency

In api/src/services/email-queue.service.ts:

const worker = new Worker('email-queue', processor, {\n  concurrency: 5,  // Process 5 emails at a time (default: 1)\n  limiter: {\n    max: 50,  // Max 50 emails per second\n    duration: 1000\n  }\n});\n

Solution 3: Use batch sending

For transactional email services:

// Some SMTP services support batch sending\n// Send 100 emails in single API call instead of 100 separate calls\n

Solution 4: Check SMTP performance

# Test SMTP connection speed\ntime curl -X POST http://localhost:4000/api/test-email \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\"to\": \"test@example.com\", \"subject\": \"Test\", \"text\": \"Test\"}'\n\n# Should complete in < 2 seconds\n# If > 5 seconds, SMTP server slow\n

Solution 5: Use email service

For high volume, use transactional email service: - SendGrid - Mailgun - Amazon SES - Postmark

Faster and more reliable than SMTP.

"},{"location":"v2/troubleshooting/email-issues/#prevention_13","title":"Prevention","text":"
  • Queue system - Never send synchronously
  • Worker concurrency - Process multiple at once
  • Email service - Use dedicated email service
  • Rate limiting - Respect provider limits
"},{"location":"v2/troubleshooting/email-issues/#queue-backlog","title":"Queue Backlog","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/email-issues/#symptoms_15","title":"Symptoms","text":"

Thousands of emails waiting in queue. Taking hours to process.

"},{"location":"v2/troubleshooting/email-issues/#solutions_15","title":"Solutions","text":"

Solution 1: Increase worker count

Start multiple API instances:

# In docker-compose.yml\napi:\n  deploy:\n    replicas: 3  # 3 API instances\n

Each instance runs its own worker.

Solution 2: Increase concurrency

See \"Slow Email Sending\" section above.

Solution 3: Pause new emails

# Pause queue\ncurl -X POST http://localhost:4000/api/influence/email-queue/pause \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Process backlog\n# Resume when caught up\ncurl -X POST http://localhost:4000/api/influence/email-queue/resume \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n

Solution 4: Clean old jobs

# Remove completed jobs\ncurl -X POST http://localhost:4000/api/influence/email-queue/clean \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\"status\": \"completed\", \"grace\": 3600000}'  # Older than 1 hour\n
"},{"location":"v2/troubleshooting/email-issues/#prevention_14","title":"Prevention","text":"
  • Monitor queue size - Alert when > 1000 waiting
  • Rate limiting - Don't queue faster than can process
  • Capacity planning - Size workers for expected load
  • Cleanup jobs - Regular cleanup of old jobs
"},{"location":"v2/troubleshooting/email-issues/#useful-commands","title":"Useful Commands","text":""},{"location":"v2/troubleshooting/email-issues/#testing-email","title":"Testing Email","text":"
# Send test email\ncurl -X POST http://localhost:4000/api/test-email \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\n    \"to\": \"test@example.com\",\n    \"subject\": \"Test Email\",\n    \"text\": \"This is a test email\",\n    \"html\": \"<h1>Test Email</h1><p>This is a test email</p>\"\n  }'\n\n# Test with template\ncurl -X POST http://localhost:4000/api/test-template-email \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\n    \"to\": \"test@example.com\",\n    \"subject\": \"Test Template\",\n    \"template\": \"campaign-email\",\n    \"variables\": {\n      \"name\": \"Test User\",\n      \"campaignName\": \"Test Campaign\"\n    }\n  }'\n
"},{"location":"v2/troubleshooting/email-issues/#queue-management","title":"Queue Management","text":"
# Get queue stats\ncurl http://localhost:4000/api/influence/email-queue/stats \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Pause queue\ncurl -X POST http://localhost:4000/api/influence/email-queue/pause \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Resume queue\ncurl -X POST http://localhost:4000/api/influence/email-queue/resume \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Retry failed\ncurl -X POST http://localhost:4000/api/influence/email-queue/retry-failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Clean completed\ncurl -X POST http://localhost:4000/api/influence/email-queue/clean \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\"status\": \"completed\", \"grace\": 86400000}'\n
"},{"location":"v2/troubleshooting/email-issues/#listmonk-operations","title":"Listmonk Operations","text":"
# Test Listmonk connection\ncurl -u admin:password http://localhost:9001/api/health\n\n# Get lists\ncurl -u admin:password http://localhost:9001/api/lists\n\n# Sync subscribers\ncurl -X POST http://localhost:4000/api/listmonk/sync \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Get sync status\ncurl http://localhost:4000/api/listmonk/status \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n
"},{"location":"v2/troubleshooting/email-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/email-issues/#email-documentation","title":"Email Documentation","text":"
  • Email Issues - This guide
  • Email Templates Feature - Template management
  • Email Queue - Queue monitoring
"},{"location":"v2/troubleshooting/email-issues/#other-troubleshooting","title":"Other Troubleshooting","text":"
  • Common Errors - General errors
  • Performance Optimization - Email performance
"},{"location":"v2/troubleshooting/email-issues/#external-resources","title":"External Resources","text":"
  • Nodemailer Documentation
  • BullMQ Documentation
  • Listmonk Documentation
  • Gmail SMTP Settings
  • SPF/DKIM/DMARC Guide

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/troubleshooting/faq/","title":"Frequently Asked Questions (FAQ)","text":"

Comprehensive answers to common questions about Changemaker Lite V2.

"},{"location":"v2/troubleshooting/faq/#general-questions","title":"General Questions","text":""},{"location":"v2/troubleshooting/faq/#what-is-changemaker-lite","title":"What is Changemaker Lite?","text":"

Changemaker Lite is a self-hosted political campaign platform that consolidates:

  • Advocacy email campaigns - Contact elected representatives
  • Geographic mapping - Location management and visualization
  • Canvassing system - Door-to-door volunteer coordination
  • Volunteer management - Shift scheduling and tracking
  • Landing pages - Custom campaign pages with GrapesJS editor
  • Newsletter platform - Listmonk integration for marketing emails
  • Media library - Video management and public galleries
  • Admin dashboard - Comprehensive management interface

Key features:

  • 100% self-hosted (no external services required except email)
  • Docker Compose deployment (single command to start)
  • Full TypeScript stack (type-safe development)
  • Production-ready security (JWT auth, bcrypt passwords, rate limiting)
  • Monitoring included (Prometheus + Grafana)
  • Canadian electoral data support (NAR format)
"},{"location":"v2/troubleshooting/faq/#v1-vs-v2-differences","title":"V1 vs V2 Differences","text":"Aspect V1 V2 Architecture Two separate Node apps (Influence + Map) Single unified Express API Database NocoDB REST API PostgreSQL 16 + Prisma ORM Authentication Sessions (express-session) JWT (access + refresh tokens) Frontend EJS templates React + Vite + Ant Design State Server-side Zustand (client-side) Email Bull queues BullMQ queues Monitoring Basic logging Prometheus + Grafana + Alertmanager Security Basic Production-grade (audit completed) Status Legacy (reference only) Current (active development)

Migration path: V1 \u2192 V2 requires data export/import. See Migration Guide.

"},{"location":"v2/troubleshooting/faq/#system-requirements","title":"System Requirements","text":"

Minimum (Development):

  • CPU: 2 cores
  • RAM: 4GB
  • Disk: 10GB
  • OS: Linux, macOS, or Windows with WSL2
  • Docker: 20.10+ and Docker Compose v2+

Recommended (Production):

  • CPU: 4+ cores
  • RAM: 8-16GB
  • Disk: 50GB+ SSD
  • OS: Ubuntu 22.04 LTS or similar
  • Docker: Latest stable version

External services (optional):

  • SMTP server (for emails) - can use Gmail, SendGrid, Mailgun, etc.
  • Pangolin/Cloudflare tunnel (for HTTPS) - or use your own reverse proxy
"},{"location":"v2/troubleshooting/faq/#browser-compatibility","title":"Browser Compatibility","text":"

Supported browsers:

  • \u2705 Chrome 90+ (recommended)
  • \u2705 Firefox 88+
  • \u2705 Safari 14+
  • \u2705 Edge 90+
  • \u274c Internet Explorer (not supported)

Mobile browsers:

  • \u2705 Chrome on Android
  • \u2705 Safari on iOS
  • \u26a0\ufe0f Some features desktop-only (GrapesJS editor, map drawing)

Required features:

  • JavaScript enabled
  • Local Storage enabled
  • Cookies enabled (for Listmonk only)
  • WebSockets supported (for real-time features)
"},{"location":"v2/troubleshooting/faq/#installation-setup","title":"Installation & Setup","text":""},{"location":"v2/troubleshooting/faq/#how-to-install","title":"How to Install?","text":"

Quick start:

# 1. Clone repository\ngit clone <repo-url> changemaker.lite\ncd changemaker.lite\ngit checkout v2\n\n# 2. Create environment file\ncp .env.example .env\nnano .env  # Edit and set passwords/secrets\n\n# 3. Start services\ndocker compose up -d v2-postgres redis api admin\n\n# 4. Run migrations\ndocker compose exec api npx prisma migrate deploy\ndocker compose exec api npx prisma db seed\n\n# 5. Access application\n# Admin GUI: http://localhost:3000\n# API: http://localhost:4000\n# Login: admin@example.com / Admin123!\n\n# 6. Change default password immediately\n

See Installation Guide for detailed instructions.

"},{"location":"v2/troubleshooting/faq/#default-credentials","title":"Default Credentials","text":"

Admin user (created by seed):

  • Email: admin@example.com
  • Password: Admin123!
  • Role: SUPER_ADMIN

\u26a0\ufe0f IMPORTANT: Change this password immediately after first login!

Other services:

  • Grafana: admin / admin
  • NocoDB: Set via NC_ADMIN_EMAIL / NC_ADMIN_PASSWORD in .env
  • Listmonk: Set via LISTMONK_WEB_ADMIN_USER / LISTMONK_WEB_ADMIN_PASSWORD in .env
"},{"location":"v2/troubleshooting/faq/#how-to-change-password","title":"How to Change Password?","text":"

Via Admin UI (recommended):

  1. Login to admin at http://localhost:3000
  2. Navigate to Users (/app/users)
  3. Click user row
  4. Click Edit
  5. Enter new password (12+ chars, uppercase, lowercase, digit)
  6. Save

Via database:

# Generate bcrypt hash\ndocker compose exec api node -e \"\nconst bcrypt = require('bcryptjs');\nconsole.log(bcrypt.hashSync('NewPassword123!', 10));\n\"\n\n# Update password\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"User\\\" SET password = 'PASTE_HASH_HERE' WHERE email = 'admin@example.com';\"\n

Password requirements:

  • Minimum 12 characters
  • At least 1 uppercase letter
  • At least 1 lowercase letter
  • At least 1 digit
  • No maximum length
"},{"location":"v2/troubleshooting/faq/#how-to-enable-https","title":"How to Enable HTTPS?","text":"

Changemaker Lite doesn't include HTTPS natively. Use one of these options:

Option 1: Pangolin Tunnel (Recommended)

Built-in integration:

  1. Navigate to /app/pangolin
  2. Follow setup wizard
  3. Configure tunnel
  4. Access via HTTPS URL provided by Pangolin

See Pangolin Integration.

Option 2: Cloudflare Tunnel

  1. Install cloudflared
  2. Configure tunnel
  3. Point to localhost:3000 (admin) and localhost:4000 (API)

Option 3: Reverse Proxy

Add nginx/Caddy in front:

# docker-compose.yml\nreverse-proxy:\n  image: nginx:alpine\n  ports:\n    - \"443:443\"\n  volumes:\n    - ./nginx/ssl.conf:/etc/nginx/nginx.conf\n    - ./ssl:/etc/nginx/ssl  # Your SSL certificates\n

Option 4: Hosting Provider

Deploy to provider with built-in HTTPS: - DigitalOcean App Platform - Heroku - Railway - Render

"},{"location":"v2/troubleshooting/faq/#user-management","title":"User Management","text":""},{"location":"v2/troubleshooting/faq/#how-to-create-users","title":"How to Create Users?","text":"

Via Admin UI (recommended):

  1. Navigate to /app/users
  2. Click \"Create User\"
  3. Fill in form:
  4. Email (required, unique)
  5. Password (required, 12+ chars)
  6. Name (required)
  7. Role (default: USER)
  8. Click \"Create\"

Via API:

curl -X POST http://localhost:4000/api/auth/register \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"email\": \"newuser@example.com\",\n    \"password\": \"SecurePass123!\",\n    \"name\": \"New User\"\n  }'\n

Via database:

# Generate password hash first\ndocker compose exec api node -e \"\nconst bcrypt = require('bcryptjs');\nconsole.log(bcrypt.hashSync('Password123!', 10));\n\"\n\n# Create user\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"INSERT INTO \\\"User\\\" (id, email, password, name, role)\n      VALUES (gen_random_uuid(), 'user@example.com', 'HASH_HERE', 'User Name', 'USER');\"\n
"},{"location":"v2/troubleshooting/faq/#how-to-reset-passwords","title":"How to Reset Passwords?","text":"

Current: V2 doesn't have password reset flow yet (planned for Phase 15).

Workaround: Reset manually via database (see \"How to Change Password?\" above).

Future: Will include: - Forgot password form - Email with reset link - 24-hour expiration - One-time use tokens

"},{"location":"v2/troubleshooting/faq/#what-are-the-user-roles","title":"What are the User Roles?","text":"Role Level Capabilities SUPER_ADMIN 5 Full access to everything (users, settings, all features) INFLUENCE_ADMIN 4 Manage campaigns, responses, email queue MAP_ADMIN 3 Manage locations, cuts, shifts, canvassing USER 2 View public content, participate in canvassing (if assigned) TEMP 1 Very limited - shift signup confirmation only

Permission matrix:

Feature SUPER_ADMIN INFLUENCE_ADMIN MAP_ADMIN USER TEMP User management \u2705 \u274c \u274c \u274c \u274c Site settings \u2705 \u274c \u274c \u274c \u274c Campaigns (admin) \u2705 \u2705 \u274c \u274c \u274c Responses (moderation) \u2705 \u2705 \u274c \u274c \u274c Email queue \u2705 \u2705 \u274c \u274c \u274c Locations (admin) \u2705 \u274c \u2705 \u274c \u274c Cuts (admin) \u2705 \u274c \u2705 \u274c \u274c Shifts (admin) \u2705 \u274c \u2705 \u274c \u274c Canvass dashboard \u2705 \u274c \u2705 \u274c \u274c View public campaigns \u2705 \u2705 \u2705 \u2705 \u274c View public map \u2705 \u2705 \u2705 \u2705 \u274c Sign up for shifts \u2705 \u2705 \u2705 \u2705 \u2705 Canvass (volunteer) \u2705 \u2705 \u2705 \u2705 \u274c

Login redirects:

  • SUPER_ADMIN / INFLUENCE_ADMIN / MAP_ADMIN \u2192 /app (admin dashboard)
  • USER / TEMP \u2192 /volunteer (volunteer portal)
"},{"location":"v2/troubleshooting/faq/#how-to-suspend-users","title":"How to Suspend Users?","text":"

Current: V2 doesn't have user suspension yet (planned for Phase 15).

Workaround: Delete user account or change role to TEMP (limited permissions).

Future: Will include: - Suspended flag on User model - Suspension reason tracking - Auto-logout suspended users - Reactivation workflow

"},{"location":"v2/troubleshooting/faq/#campaigns","title":"Campaigns","text":""},{"location":"v2/troubleshooting/faq/#how-to-create-campaign","title":"How to Create Campaign?","text":"
  1. Navigate to /app/influence/campaigns
  2. Click \"Create Campaign\"
  3. Fill in form:
  4. Name (required) - Campaign title
  5. Slug (required, unique) - URL-friendly name
  6. Description (optional) - Campaign details
  7. Email Subject (optional) - Default email subject
  8. Email Body (optional) - Default email template
  9. Active (checkbox) - Show on public site
  10. Allow Custom Message (checkbox) - Let users edit message
  11. Click \"Create\"
  12. Campaign now appears in admin table and public listing (if active)
"},{"location":"v2/troubleshooting/faq/#how-to-publish-campaign","title":"How to Publish Campaign?","text":"
  1. Navigate to /app/influence/campaigns
  2. Find campaign in table
  3. Click row to expand
  4. Toggle \"Active\" switch to ON
  5. Campaign now visible at /campaigns (public)
"},{"location":"v2/troubleshooting/faq/#how-to-track-emails","title":"How to Track Emails?","text":"
  1. Navigate to /app/influence/campaigns
  2. Click campaign row
  3. Click \"View Emails\" button
  4. Drawer shows:
  5. Total emails sent
  6. Email list with timestamps
  7. Recipient addresses
  8. Email status (sent/failed)

Via Email Queue Page:

  1. Navigate to /app/influence/email-queue
  2. View stats:
  3. Total emails processed
  4. Success/fail counts
  5. Queue depth
  6. View recent jobs
  7. Retry failed jobs if needed
"},{"location":"v2/troubleshooting/faq/#how-to-moderate-responses","title":"How to Moderate Responses?","text":"
  1. Navigate to /app/influence/responses
  2. Table shows all responses with:
  3. Participant name/email
  4. Campaign
  5. Message excerpt
  6. Submission timestamp
  7. Verification status
  8. Filters:
  9. Campaign dropdown
  10. Verified/unverified toggle
  11. Click row to view full response
  12. Actions:
  13. Verify (if unverified)
  14. Delete (if inappropriate)

Response verification workflow:

  1. User submits response \u2192 marked unverified
  2. User receives verification email
  3. User clicks verification link \u2192 marked verified
  4. Only verified responses show on public response wall
"},{"location":"v2/troubleshooting/faq/#map-canvassing","title":"Map & Canvassing","text":""},{"location":"v2/troubleshooting/faq/#how-to-import-locations","title":"How to Import Locations?","text":"

Via CSV:

  1. Navigate to /app/map/locations
  2. Click \"Import CSV\"
  3. Prepare CSV with columns:
    address,city,province,postalCode,notes\n123 Main St,Toronto,ON,M5H 2N2,Corner house\n456 Oak Ave,Toronto,ON,M5H 2N3,Blue door\n
  4. Upload file
  5. Map columns (if headers don't match exactly)
  6. Click \"Import\"
  7. Locations imported, geocoding starts automatically

Via NAR (Canadian Electoral Data):

  1. Obtain NAR data files (Location + Address)
  2. Place in /data directory (mapped volume)
  3. Navigate to /app/map/locations
  4. Click \"NAR Import\" tab
  5. Select province
  6. Select dataset
  7. Apply filters (city, postal code, cut, residential only)
  8. Preview count
  9. Click \"Import\"
  10. Import processes in background (can take minutes for large files)

See NAR Import Guide.

"},{"location":"v2/troubleshooting/faq/#how-to-create-cuts","title":"How to Create Cuts?","text":"

Via Map Drawing:

  1. Navigate to /app/map/cuts
  2. Click \"Map Drawing\" tab
  3. Map shows with drawing controls
  4. Click \"Draw Cut\" button
  5. Click on map to place vertices
  6. Click first vertex again to close polygon (or click \"Finish\")
  7. Fill in form:
  8. Name (required)
  9. Description (optional)
  10. Color (pick color for map display)
  11. Click \"Save\"

Via GeoJSON Import:

  1. Prepare GeoJSON file:
    {\n  \"type\": \"Polygon\",\n  \"coordinates\": [[\n    [-79.38, 43.65],\n    [-79.37, 43.65],\n    [-79.37, 43.64],\n    [-79.38, 43.64],\n    [-79.38, 43.65]\n  ]]\n}\n
  2. Navigate to /app/map/cuts
  3. Click \"Create Cut\"
  4. Paste GeoJSON in geometry field
  5. Fill in name/description
  6. Click \"Create\"
"},{"location":"v2/troubleshooting/faq/#how-to-organize-shifts","title":"How to Organize Shifts?","text":"
  1. Navigate to /app/map/shifts
  2. Click \"Create Shift\"
  3. Fill in form:
  4. Title (required) - Shift name
  5. Description (optional) - Shift details
  6. Start Time (required) - When shift starts
  7. End Time (required) - When shift ends
  8. Cut (optional) - Assign to specific cut
  9. Max Volunteers (optional) - Capacity limit
  10. Public (checkbox) - Show on public shifts page
  11. Click \"Create\"
  12. Shift appears in admin table and public listing (if public)

Manage signups:

  1. Click shift row in table
  2. Click \"View Signups\"
  3. Drawer shows:
  4. Signup count
  5. List of volunteers
  6. Email addresses
  7. Actions:
  8. \"Email All\" - Send message to all volunteers
  9. Remove individual signups if needed
"},{"location":"v2/troubleshooting/faq/#how-to-start-canvassing","title":"How to Start Canvassing?","text":"

For volunteers:

  1. Login to volunteer portal
  2. Navigate to \"My Assignments\" (/volunteer/assignments)
  3. Find assigned shift
  4. Click \"Start Canvassing\"
  5. Full-screen map opens (/volunteer/canvass/:cutId)
  6. GPS tracks your location
  7. Map shows:
  8. Your current position (blue dot)
  9. Locations in cut (markers)
  10. Walking route (blue line)
  11. Legend (outcome colors)
  12. Click location marker to record visit:
  13. Select outcome (Home, Away, Refused, etc.)
  14. Add notes (optional)
  15. Save
  16. Continue until all locations visited
  17. Session auto-saves progress

For admins (monitoring):

  1. Navigate to /app/canvass/dashboard
  2. View:
  3. Active sessions count
  4. Total visits recorded
  5. Recent activity feed
  6. Cut progress (% complete)
  7. Leaderboard (top canvassers)
  8. Click activity item to see details

See Canvassing Guide.

"},{"location":"v2/troubleshooting/faq/#technical-questions","title":"Technical Questions","text":""},{"location":"v2/troubleshooting/faq/#which-database","title":"Which Database?","text":"

PostgreSQL 16 with two ORMs:

  1. Prisma - Main API (Express)
  2. Schema: api/prisma/schema.prisma
  3. Migrations: api/prisma/migrations/
  4. 30+ models (User, Campaign, Location, etc.)

  5. Drizzle - Media API (Fastify)

  6. Schema: api/src/modules/media/db/schema.ts
  7. Tables: media_videos, media_reactions, etc.

Connection:

  • Host: v2-postgres (container) or localhost:5433 (host)
  • Database: changemaker_v2
  • User: changemaker
  • Password: V2_POSTGRES_PASSWORD (from .env)

Shared database: Both ORMs use same PostgreSQL database, different tables.

"},{"location":"v2/troubleshooting/faq/#which-orm","title":"Which ORM?","text":"

Prisma for main API:

  • Type-safe queries
  • Auto-generated client
  • Migrations workflow
  • Prisma Studio GUI

Drizzle for media API:

  • Lightweight
  • SQL-like API
  • Schema-first approach
  • No migration files (push to sync)

Why two ORMs?

Media API was added later as separate Fastify microservice. Using Drizzle allowed faster development without modifying main Prisma schema.

"},{"location":"v2/troubleshooting/faq/#api-architecture","title":"API Architecture?","text":"

Dual API architecture:

  1. Express API (Main)
  2. Port: 4000
  3. Language: TypeScript
  4. ORM: Prisma
  5. Features: Auth, campaigns, locations, shifts, canvass, pages
  6. Endpoints: /api/*

  7. Fastify Media API (Microservice)

  8. Port: 4100
  9. Language: TypeScript
  10. ORM: Drizzle
  11. Features: Video library, uploads, reactions
  12. Endpoints: /api/media/*

Shared:

  • Same PostgreSQL database
  • Same Redis instance
  • Same Docker network
  • Separate containerization (can scale independently)

Frontend:

  • React SPA (Vite)
  • Port: 3000
  • State: Zustand
  • UI: Ant Design
  • Routing: React Router v6
"},{"location":"v2/troubleshooting/faq/#authentication-method","title":"Authentication Method?","text":"

JWT-based authentication:

Tokens:

  1. Access Token
  2. Duration: 15 minutes
  3. Stored: Memory (localStorage)
  4. Contains: userId, email, role
  5. Used: All authenticated requests

  6. Refresh Token

  7. Duration: 7 days
  8. Stored: Database + localStorage
  9. Used: Renew access token
  10. Rotation: New refresh token on each refresh

Flow:

  1. Login \u2192 Returns access + refresh tokens
  2. Store in localStorage (Zustand persist)
  3. Add access token to Authorization header
  4. Access token expires after 15min
  5. Frontend auto-refreshes using refresh token
  6. New access + refresh tokens returned
  7. Continue seamlessly

Security features:

  • bcrypt password hashing (10 rounds)
  • Token rotation prevents replay attacks
  • Refresh tokens stored in database (can revoke)
  • Rate limiting on auth endpoints (10/min)
  • User enumeration prevention
  • Redis authentication required

See Authentication Flow.

"},{"location":"v2/troubleshooting/faq/#performance","title":"Performance","text":""},{"location":"v2/troubleshooting/faq/#how-many-users-supported","title":"How Many Users Supported?","text":"

Concurrent users:

  • Development: 10-50 users
  • Production (default config): 100-500 users
  • Production (optimized): 1000+ users

Factors:

  • Database connection pool (default: 10 connections)
  • API worker concurrency (default: 1 worker)
  • Server resources (CPU/RAM)
  • Network bandwidth

Scaling:

  • Horizontal: Run multiple API instances
  • Vertical: Increase server resources
  • Database: Read replicas for read-heavy loads
  • Caching: Redis caching for frequently accessed data
"},{"location":"v2/troubleshooting/faq/#how-to-scale","title":"How to Scale?","text":"

Horizontal scaling (recommended):

# docker-compose.yml\napi:\n  deploy:\n    replicas: 3  # Run 3 API instances\n  # Each instance:\n  # - Handles requests independently\n  # - Connects to same database\n  # - Processes queue jobs\n  # - Shares Redis cache\n

Add load balancer in front:

nginx:\n  image: nginx:alpine\n  ports:\n    - \"80:80\"\n  volumes:\n    - ./nginx/lb.conf:/etc/nginx/nginx.conf\n  # Distributes requests across API instances\n

Vertical scaling:

Increase resources:

api:\n  deploy:\n    resources:\n      limits:\n        cpus: '4.0'  # More CPU\n        memory: 8G   # More RAM\n

Database scaling:

  • Add read replicas for read-heavy queries
  • Use connection pooler (PgBouncer)
  • Optimize queries and indexes

Caching:

  • Redis caching for geocoding results
  • Redis caching for representative lookups
  • HTTP caching headers (Nginx)
  • Static asset CDN
"},{"location":"v2/troubleshooting/faq/#database-size-limits","title":"Database Size Limits?","text":"

PostgreSQL:

  • Maximum database size: ~32 TB (theoretical)
  • Practical limit: Depends on storage and backup strategy

Typical sizes (after 1 year):

  • Small campaign: 100MB-500MB (1k locations, 10 campaigns)
  • Medium campaign: 500MB-2GB (10k locations, 50 campaigns)
  • Large campaign: 2GB-10GB (100k locations, 200 campaigns)

Storage requirements:

  • Database: 1-10GB
  • Uploads: 5-50GB (videos)
  • Backups: 2\u00d7 database size (keep multiple backups)
  • Logs: 1-5GB/month
  • Total: 20-100GB recommended

Optimization:

  • Regular VACUUM (auto-enabled)
  • Archive old campaigns
  • Delete old logs
  • Compress backups
"},{"location":"v2/troubleshooting/faq/#security","title":"Security","text":""},{"location":"v2/troubleshooting/faq/#is-data-encrypted","title":"Is Data Encrypted?","text":"

At rest:

  • Database: Not encrypted by default (enable PostgreSQL encryption if needed)
  • Passwords: bcrypt hashed (cannot be decrypted)
  • Sensitive fields: ENCRYPTION_KEY env var for encrypting secrets

In transit:

  • HTTPS: Use Pangolin/Cloudflare tunnel (encrypts all traffic)
  • Database: PostgreSQL connections within Docker network (isolated)
  • Redis: Authenticated (password required)

Recommendations:

  • Use HTTPS in production
  • Rotate ENCRYPTION_KEY periodically
  • Enable PostgreSQL SSL if database exposed
  • Use strong passwords for all services
"},{"location":"v2/troubleshooting/faq/#password-requirements","title":"Password Requirements?","text":"

Enforced policy:

  • Minimum 12 characters
  • At least 1 uppercase letter (A-Z)
  • At least 1 lowercase letter (a-z)
  • At least 1 digit (0-9)
  • No maximum length

Valid examples:

  • SecurePass123!
  • MyPassword99
  • Admin12345678

Invalid:

  • short (too short)
  • nouppercase123 (no uppercase)
  • NOLOWERCASE123 (no lowercase)
  • NoDigitsHere (no digit)

Storage:

  • bcrypt hashed with salt (10 rounds)
  • Hash stored in database (not plaintext)
  • Cannot be decrypted (one-way hash)
"},{"location":"v2/troubleshooting/faq/#how-to-backup","title":"How to Backup?","text":"

Manual backup:

# Use provided script\n./scripts/backup.sh\n\n# Creates:\n# - PostgreSQL dump\n# - Listmonk dump (if enabled)\n# - Uploads archive (videos, images)\n# - Timestamped filename: backup_2026-02-13_100000.tar.gz\n

What's included:

  • Complete database dump (all tables)
  • Uploaded files (videos, images, documents)
  • Listmonk database (if enabled)

What's NOT included:

  • Docker images (rebuild from Dockerfile)
  • .env file (keep separate, has secrets)
  • Temporary files (logs, cache)

Automated backups:

Add cron job:

# Run daily at 2 AM\n0 2 * * * cd /path/to/changemaker.lite && ./scripts/backup.sh\n\n# With S3 upload (if configured)\n0 2 * * * cd /path/to/changemaker.lite && ./scripts/backup.sh --upload-s3\n

Restore:

# Stop services\ndocker compose down\n\n# Restore database\ngunzip -c backup.sql.gz | docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2\n\n# Restore uploads\ntar -xzf uploads.tar.gz -C ./uploads\n\n# Start services\ndocker compose up -d\n

See Backup Guide.

"},{"location":"v2/troubleshooting/faq/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/troubleshooting/faq/#where-are-logs","title":"Where are Logs?","text":"

Docker logs:

# View API logs\ndocker compose logs api\n\n# View all logs\ndocker compose logs\n\n# Follow logs (real-time)\ndocker compose logs -f api\n\n# Last 100 lines\ndocker compose logs api --tail=100\n\n# Since timestamp\ndocker compose logs api --since=\"2026-02-13T10:00:00\"\n\n# Save to file\ndocker compose logs api > api-logs.txt\n

Log locations inside containers:

  • API: Console output (docker logs)
  • PostgreSQL: Console + /var/lib/postgresql/data/log/ (if logging enabled)
  • Nginx: /var/log/nginx/access.log, /var/log/nginx/error.log

Log levels:

  • ERROR: Errors requiring attention
  • WARN: Warnings (not critical)
  • INFO: Informational messages
  • DEBUG: Debugging information (enable with LOG_LEVEL=debug)
"},{"location":"v2/troubleshooting/faq/#how-to-restart-services","title":"How to Restart Services?","text":"

Restart specific service:

# Restart API\ndocker compose restart api\n\n# Restart multiple services\ndocker compose restart api admin v2-postgres\n

Restart all services:

# Graceful restart (preserves data)\ndocker compose restart\n\n# Stop and start (recreates containers)\ndocker compose down\ndocker compose up -d\n

Force recreate:

# Rebuild and recreate\ndocker compose up -d --build --force-recreate\n\n# Recreate specific service\ndocker compose up -d --build --force-recreate api\n

Restart single container:

# Get container name\ndocker compose ps\n\n# Restart by name\ndocker restart changemaker-lite-api-1\n
"},{"location":"v2/troubleshooting/faq/#how-to-reset-database","title":"How to Reset Database?","text":"

\u26a0\ufe0f WARNING: This deletes ALL data!

Full reset:

# Stop services\ndocker compose down\n\n# Delete database volume\ndocker volume rm changemaker-lite_postgres-data\n\n# Start fresh\ndocker compose up -d v2-postgres\n\n# Wait for database ready\nsleep 10\n\n# Run migrations\ndocker compose exec api npx prisma migrate deploy\n\n# Seed initial data\ndocker compose exec api npx prisma db seed\n\n# Default admin: admin@example.com / Admin123!\n

Reset specific tables:

# Delete all users (keeps schema)\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c 'TRUNCATE \"User\" CASCADE;'\n\n# Re-seed\ndocker compose exec api npx prisma db seed\n

Reset without deleting volumes:

# Drop and recreate database\ndocker compose exec v2-postgres psql -U postgres \\\n  -c 'DROP DATABASE changemaker_v2;'\n\ndocker compose exec v2-postgres psql -U postgres \\\n  -c 'CREATE DATABASE changemaker_v2 OWNER changemaker;'\n\n# Run migrations\ndocker compose exec api npx prisma migrate deploy\ndocker compose exec api npx prisma db seed\n
"},{"location":"v2/troubleshooting/faq/#getting-help","title":"Getting Help","text":""},{"location":"v2/troubleshooting/faq/#documentation-links","title":"Documentation Links","text":"

User Guides:

  • Installation Guide
  • User Guide
  • Admin Guide
  • Canvassing Guide
  • NAR Import Guide

Technical Documentation:

  • Architecture Overview
  • API Reference
  • Database Schema
  • Authentication Flow

Troubleshooting:

  • Common Errors
  • Docker Issues
  • Database Issues
  • Auth Issues
  • Email Issues
  • Geocoding Issues
  • Monitoring Issues
  • Performance Optimization
"},{"location":"v2/troubleshooting/faq/#github-issues","title":"GitHub Issues","text":"

Before creating issue:

  1. Check existing issues
  2. Search closed issues (may already be fixed)
  3. Check Troubleshooting guides
  4. Try latest version (git pull origin v2)

Creating good issues:

Bug reports:

**Describe the bug**\nClear description of what's wrong.\n\n**To Reproduce**\n1. Go to '...'\n2. Click on '...'\n3. See error\n\n**Expected behavior**\nWhat should happen instead.\n\n**Screenshots**\nIf applicable, add screenshots.\n\n**Environment**\n- OS: [e.g. Ubuntu 22.04]\n- Docker version: [e.g. 20.10.21]\n- Browser: [e.g. Chrome 120]\n\n**Logs**\nPaste relevant logs (sanitize sensitive data).\n

Feature requests:

**Is your feature request related to a problem?**\nDescription of problem.\n\n**Describe the solution you'd like**\nClear description of feature.\n\n**Describe alternatives you've considered**\nOther solutions considered.\n\n**Additional context**\nAny other context or screenshots.\n
"},{"location":"v2/troubleshooting/faq/#community-support","title":"Community Support","text":"

Official channels:

  • GitHub Issues (bugs and features)
  • GitHub Discussions (questions and ideas)
  • Documentation (this site)

Response time:

  • Bug reports: 1-7 days
  • Feature requests: Variable (depends on priority)
  • Questions: 1-3 days

Contributing:

  • Pull requests welcome
  • Follow Contributing Guide
  • Follow Code of Conduct

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/troubleshooting/geocoding-issues/","title":"Geocoding and Map Issues","text":"

This guide covers geocoding, map display, and location-related problems in Changemaker Lite V2.

"},{"location":"v2/troubleshooting/geocoding-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-system","title":"Geocoding System","text":"

Changemaker Lite V2 uses multi-provider geocoding with automatic fallback:

  1. Google Geocoding API - Most accurate, requires API key
  2. Mapbox Geocoding API - Good quality, requires API key
  3. Nominatim (OpenStreetMap) - Free, no key required
  4. ArcGIS Geocoding Service - Good for North America
  5. Photon (OpenStreetMap) - Free alternative
  6. HERE Geocoding API - Paid option
"},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-queue","title":"Geocoding Queue","text":"
  • BullMQ queue - Async geocoding for bulk imports
  • Rate limiting - Respects provider rate limits
  • Retry logic - Auto-retry failed geocodes
  • Priority - Manual geocodes prioritized over bulk
"},{"location":"v2/troubleshooting/geocoding-issues/#map-display","title":"Map Display","text":"
  • Leaflet.js - Open-source map library
  • OpenStreetMap tiles - Free map tiles
  • Circle markers - Color-coded by cut assignment
  • Polygon overlays - Cut boundaries
  • Geolocate - Find user's current location
"},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-failures","title":"Geocoding Failures","text":""},{"location":"v2/troubleshooting/geocoding-issues/#address-not-found","title":"Address Not Found","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms","title":"Symptoms","text":"

Location shows null latitude/longitude after geocoding attempt.

API logs:

WARN Geocoding failed for address: \"123 Fake St, Nowhere\": No results from any provider\n

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes","title":"Common Causes","text":"
  1. Invalid address - Address doesn't exist
  2. Typo - Misspelled street/city/postal code
  3. Incomplete address - Missing city or postal code
  4. Wrong country - Address in different country
  5. Rural address - Not in geocoding databases
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions","title":"Solutions","text":"

Solution 1: Verify address format

# Good address format (Canadian):\n123 Main Street, Toronto, ON M5H 2N2\n\n# Good address format (US):\n123 Main Street, New York, NY 10001\n\n# Bad formats:\n123 Main  # Missing city/postal\nMain Street  # Missing number\nToronto  # Too vague\n

Solution 2: Test address manually

# Test via Nominatim (no API key needed)\ncurl \"https://nominatim.openstreetmap.org/search?q=123+Main+Street,Toronto,ON&format=json\"\n\n# Should return array with results\n# If empty, address not found\n

Solution 3: Try alternative formats

# If \"123 Main Street, Toronto ON M5H 2N2\" fails, try:\n# - \"123 Main St, Toronto ON M5H2N2\" (no space in postal)\n# - \"123 Main Street, Toronto Ontario M5H 2N2\" (full province)\n# - \"123 Main Street, M5H 2N2\" (postal code only)\n# - \"M5H 2N2\" (postal code geocoding)\n

Solution 4: Check geocoding logs

# View detailed geocoding attempts\ndocker compose logs api | grep \"Geocoding\\|geocode\"\n\n# Shows:\n# Trying provider: google\n# Google geocoding failed: Invalid request\n# Trying provider: nominatim\n# Nominatim geocoding succeeded\n

Solution 5: Manually set coordinates

In admin UI (LocationsPage):

  1. Find location in table
  2. Click Edit
  3. Manually enter lat/lng (from Google Maps)
  4. Save

Or via SQL:

docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"UPDATE \\\"Location\\\" SET latitude = 43.65, longitude = -79.38\n      WHERE address = '123 Main Street';\"\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention","title":"Prevention","text":"
  • Address validation - Validate format before saving
  • Postal code lookup - Use postal code if full address fails
  • Manual review - Flag failed geocodes for manual review
  • Alternative sources - Try multiple address formats
"},{"location":"v2/troubleshooting/geocoding-issues/#all-providers-failed","title":"All Providers Failed","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_1","title":"Symptoms","text":"
ERROR Geocoding failed: All providers failed for address: \"123 Main St\"\n

All 6 geocoding providers returned no results or errors.

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_1","title":"Common Causes","text":"
  1. Network issue - Can't reach external APIs
  2. Rate limits - All providers rate limited
  3. Invalid API keys - Google/Mapbox keys invalid
  4. Bad address - Address truly doesn't exist
  5. Provider outages - Services down
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_1","title":"Solutions","text":"

Solution 1: Check network connectivity

# Test DNS resolution\ndocker compose exec api ping -c 3 nominatim.openstreetmap.org\n\n# Test HTTPS connection\ndocker compose exec api curl -I https://nominatim.openstreetmap.org\n\n# If fails, network issue\n

Solution 2: Test each provider manually

# Nominatim (free, no key)\ncurl \"https://nominatim.openstreetmap.org/search?q=123+Main+Street,Toronto&format=json\"\n\n# Google (requires GOOGLE_GEOCODING_API_KEY)\ncurl \"https://maps.googleapis.com/maps/api/geocode/json?address=123+Main+Street,Toronto&key=YOUR_KEY\"\n\n# Mapbox (requires MAPBOX_API_KEY)\ncurl \"https://api.mapbox.com/geocoding/v5/mapbox.places/123+Main+Street,Toronto.json?access_token=YOUR_KEY\"\n

Solution 3: Check API keys

# Verify API keys in .env\ncat .env | grep -E \"GOOGLE_GEOCODING_API_KEY|MAPBOX_API_KEY|HERE_API_KEY\"\n\n# Should show non-empty values\n# If empty, providers requiring keys won't work\n

Solution 4: Check rate limits

# View geocoding stats\ncurl http://localhost:4000/api/map/geocoding/stats \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Shows:\n# {\n#   \"totalAttempts\": 1523,\n#   \"successful\": 1450,\n#   \"failed\": 73,\n#   \"byProvider\": {\n#     \"google\": { \"attempts\": 500, \"successes\": 480 },\n#     \"nominatim\": { \"attempts\": 600, \"successes\": 570 }\n#   }\n# }\n

Solution 5: Wait and retry

Rate limits reset after time: - Nominatim: 1 request/second (resets immediately) - Google: 50 requests/second (resets after 1 second) - Mapbox: 600 requests/minute (resets after 1 minute)

# Retry geocoding after wait\ncurl -X POST http://localhost:4000/api/map/locations/LOCATION_ID/geocode \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_1","title":"Prevention","text":"
  • API key monitoring - Alert on API key errors
  • Rate limit tracking - Monitor usage against limits
  • Provider rotation - Distribute load across providers
  • Graceful degradation - Continue with partial results
"},{"location":"v2/troubleshooting/geocoding-issues/#low-confidence-results","title":"Low Confidence Results","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_2","title":"Symptoms","text":"

Geocoding succeeds but coordinates seem wrong or imprecise.

Example: - Address: \"123 Main Street, Toronto\" - Geocoded to: Center of Toronto (not specific address)

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_2","title":"Common Causes","text":"
  1. Ambiguous address - Multiple matches
  2. Incomplete address - Missing street number
  3. Rural address - Only city-level precision
  4. Provider limitation - Provider doesn't have precise data
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_2","title":"Solutions","text":"

Solution 1: Check geocoding confidence

# View location details\ncurl http://localhost:4000/api/map/locations/LOCATION_ID \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Response includes:\n# {\n#   \"geocodingProvider\": \"nominatim\",\n#   \"geocodingConfidence\": \"low\",  # or \"high\", \"medium\"\n#   \"latitude\": 43.65,\n#   \"longitude\": -79.38\n# }\n

Solution 2: Add more detail to address

# Low confidence:\n\"Main Street, Toronto\"\n\n# Higher confidence:\n\"123 Main Street, Toronto, ON M5H 2N2\"\n\n# Best confidence:\n\"123 Main Street, Toronto, Ontario M5H 2N2, Canada\"\n

Solution 3: Use postal code geocoding

For Canadian addresses, postal code is often more accurate:

# Update location with postal code\nUPDATE \"Location\"\nSET \"postalCode\" = 'M5H 2N2'\nWHERE id = 'LOCATION_ID';\n\n# Re-geocode (will use postal code)\ncurl -X POST http://localhost:4000/api/map/locations/LOCATION_ID/geocode \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n

Solution 4: Manually verify on map

In LocationsPage: 1. Click location row 2. View on map 3. If wrong, manually drag marker to correct location 4. Save

Solution 5: Flag for review

# Mark low-confidence results for manual review\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, address, \\\"geocodingConfidence\\\"\n      FROM \\\"Location\\\"\n      WHERE \\\"geocodingConfidence\\\" = 'low'\n      ORDER BY \\\"createdAt\\\" DESC\n      LIMIT 50;\"\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_2","title":"Prevention","text":"
  • Confidence tracking - Store confidence score
  • Manual review queue - Review low-confidence results
  • Address validation - Validate format before geocoding
  • Postal code priority - Use postal code when available
"},{"location":"v2/troubleshooting/geocoding-issues/#rate-limit-exceeded","title":"Rate Limit Exceeded","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_3","title":"Symptoms","text":"
ERROR Geocoding rate limit exceeded for provider: google\nWARN Retrying with next provider: mapbox\n

Or:

ERROR 429 Too Many Requests from https://maps.googleapis.com/\n
"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_3","title":"Common Causes","text":"
  1. Bulk import - Geocoding thousands of addresses at once
  2. No API key - Free tier has lower limits
  3. Shared IP - Multiple users on same IP
  4. Testing - Repeated manual geocodes
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_3","title":"Solutions","text":"

Solution 1: Check rate limits

Per-provider limits:

Provider Free Tier With API Key Nominatim 1/sec N/A Google N/A 50/sec (or paid limit) Mapbox N/A 600/min ArcGIS 1000/day Varies Photon Unlimited N/A HERE N/A Varies by plan

Solution 2: Use geocoding queue

For bulk operations:

# Queue all ungeocoded locations\ncurl -X POST http://localhost:4000/api/map/locations/queue-geocoding \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"batchSize\": 100}'\n\n# Queue processes at rate-limit-safe speed\n

Solution 3: Add API keys

# In .env\nGOOGLE_GEOCODING_API_KEY=your-key-here\nMAPBOX_API_KEY=your-key-here\n\n# Restart API\ndocker compose restart api\n

Solution 4: Distribute across providers

# Check provider usage\ncurl http://localhost:4000/api/map/geocoding/stats \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# If one provider is overused, system auto-rotates to others\n

Solution 5: Wait and retry

# Wait for rate limit window to reset\n# Nominatim: 1 second\n# Google: Check quota reset time\n# Mapbox: 1 minute\n\n# Retry failed geocodes\ncurl -X POST http://localhost:4000/api/map/locations/retry-failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_3","title":"Prevention","text":"
  • API keys - Use paid tiers for higher limits
  • Queue system - Respect rate limits automatically
  • Provider rotation - Distribute load
  • Monitor usage - Alert when approaching limits
"},{"location":"v2/troubleshooting/geocoding-issues/#map-display-issues","title":"Map Display Issues","text":""},{"location":"v2/troubleshooting/geocoding-issues/#map-not-loading","title":"Map Not Loading","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_4","title":"Symptoms","text":"

Map container shows blank white/gray box. No tiles loaded.

Browser console:

Error loading tile: https://tile.openstreetmap.org/...\nFailed to load resource: net::ERR_BLOCKED_BY_CLIENT\n

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_4","title":"Common Causes","text":"
  1. Ad blocker - Blocking OSM tile requests
  2. Network issue - Can't reach tile server
  3. CSP headers - Content Security Policy blocking
  4. Leaflet CSS missing - Styles not imported
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_4","title":"Solutions","text":"

Solution 1: Disable ad blocker

  1. Disable ad blocker for your site
  2. Or whitelist *.openstreetmap.org
  3. Refresh page

Solution 2: Check network

# Test tile server\ncurl -I https://tile.openstreetmap.org/0/0/0.png\n\n# Should return 200 OK\n# If fails, network or DNS issue\n

Solution 3: Verify Leaflet CSS

In map component file:

// Must import Leaflet CSS\nimport 'leaflet/dist/leaflet.css';\n

Check in browser DevTools: - Elements tab \u2192 Check if .leaflet-container has styles - Network tab \u2192 Check if leaflet.css loaded

Solution 4: Check CSP headers

In nginx/conf.d/default.conf:

# Allow OSM tiles\nadd_header Content-Security-Policy \"... img-src 'self' data: https://*.openstreetmap.org;\";\n

Solution 5: Try alternative tile provider

// In map component\n<TileLayer\n  attribution='&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a>'\n  url=\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\"\n  // Or try Carto:\n  // url=\"https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png\"\n/>\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_4","title":"Prevention","text":"
  • Ad blocker warning - Detect and show warning
  • Fallback tiles - Multiple tile providers
  • Error boundaries - Catch map loading errors
  • Clear documentation - Document ad blocker issue
"},{"location":"v2/troubleshooting/geocoding-issues/#markers-not-appearing","title":"Markers Not Appearing","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_5","title":"Symptoms","text":"

Map loads but location markers don't appear.

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_5","title":"Common Causes","text":"
  1. No data - No locations fetched
  2. Null coordinates - Locations not geocoded
  3. Out of bounds - Markers outside map view
  4. Rendering error - React component error
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_5","title":"Solutions","text":"

Solution 1: Check data loaded

// In browser console\nconsole.log('Locations:', locations);\n\n// Should show array of locations with lat/lng\n// If empty or undefined, data not loaded\n

Solution 2: Verify coordinates

# Check locations have coordinates\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT COUNT(*) FROM \\\"Location\\\" WHERE latitude IS NOT NULL AND longitude IS NOT NULL;\"\n\n# If 0, no locations geocoded\n

Solution 3: Zoom to markers

// In map component, fit bounds to markers\nuseEffect(() => {\n  if (locations.length > 0 && mapRef.current) {\n    const bounds = locations\n      .filter(l => l.latitude && l.longitude)\n      .map(l => [l.latitude, l.longitude]);\n\n    if (bounds.length > 0) {\n      mapRef.current.fitBounds(bounds, { padding: [50, 50] });\n    }\n  }\n}, [locations]);\n

Solution 4: Check marker rendering

// Verify CircleMarker component\n{locations.map((location) => {\n  if (!location.latitude || !location.longitude) return null;\n\n  return (\n    <CircleMarker\n      key={location.id}\n      center={[location.latitude, location.longitude]}\n      radius={8}\n      // ...\n    />\n  );\n})}\n

Solution 5: Check browser console

Look for React errors:

Warning: Each child in a list should have a unique \"key\" prop\nError: Invalid latitude/longitude\n

"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_5","title":"Prevention","text":"
  • Data validation - Ensure data has coordinates
  • Error boundaries - Catch rendering errors
  • Loading states - Show loading while fetching
  • Empty states - Show message if no data
"},{"location":"v2/troubleshooting/geocoding-issues/#cuts-not-rendering","title":"Cuts Not Rendering","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_6","title":"Symptoms","text":"

Cut polygons don't appear on map.

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_6","title":"Common Causes","text":"
  1. Invalid GeoJSON - Malformed polygon data
  2. Wrong coordinate order - GeoJSON uses [lng, lat], Leaflet uses [lat, lng]
  3. Self-intersecting polygon - Invalid polygon geometry
  4. Out of bounds - Polygon outside map view
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_6","title":"Solutions","text":"

Solution 1: Validate GeoJSON

# Check cut geometry\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, name, ST_AsGeoJSON(geometry) FROM \\\"Cut\\\" WHERE id = 'CUT_ID';\"\n\n# Verify format:\n# {\n#   \"type\": \"Polygon\",\n#   \"coordinates\": [[[lng1, lat1], [lng2, lat2], ...]]\n# }\n

Solution 2: Convert coordinates

// GeoJSON uses [lng, lat]\nconst geojson = {\n  type: 'Polygon',\n  coordinates: [[[-79.38, 43.65], [-79.37, 43.65], ...]]\n};\n\n// Convert to Leaflet [lat, lng]\nconst leafletCoords = geojson.coordinates[0].map(([lng, lat]) => [lat, lng]);\n

Solution 3: Check for self-intersection

-- Validate polygon geometry\nSELECT id, name, ST_IsValid(geometry) as is_valid\nFROM \"Cut\"\nWHERE NOT ST_IsValid(geometry);\n\n-- If invalid, show reason\nSELECT id, name, ST_IsValidReason(geometry)\nFROM \"Cut\"\nWHERE NOT ST_IsValid(geometry);\n\n-- Fix with buffer(0)\nUPDATE \"Cut\"\nSET geometry = ST_Buffer(geometry, 0)\nWHERE NOT ST_IsValid(geometry);\n

Solution 4: Zoom to cut

// Fit map to cut bounds\nuseEffect(() => {\n  if (cut?.geometry && mapRef.current) {\n    const coords = cut.geometry.coordinates[0].map(([lng, lat]) => [lat, lng]);\n    const bounds = L.latLngBounds(coords);\n    mapRef.current.fitBounds(bounds, { padding: [50, 50] });\n  }\n}, [cut]);\n

Solution 5: Check Polygon component

// Verify Polygon rendering\n<Polygon\n  positions={coords}  // Array of [lat, lng]\n  pathOptions={{\n    color: '#3498db',\n    fillColor: '#3498db',\n    fillOpacity: 0.2\n  }}\n/>\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_6","title":"Prevention","text":"
  • Geometry validation - Validate on save
  • Drawing tools - Use validated drawing library
  • Import validation - Check imported geometries
  • Error handling - Gracefully handle invalid geometries
"},{"location":"v2/troubleshooting/geocoding-issues/#gps-not-working","title":"GPS Not Working","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_7","title":"Symptoms","text":"

Geolocate button doesn't work or shows error.

Browser shows permission prompt but location never loads.

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_7","title":"Common Causes","text":"
  1. HTTPS required - Geolocation API requires HTTPS (or localhost)
  2. Permission denied - User denied location permission
  3. GPS unavailable - Device has no GPS
  4. Browser doesn't support - Old browser
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_7","title":"Solutions","text":"

Solution 1: Check HTTPS

Geolocation API requires: - HTTPS (https://) - OR localhost (http://localhost) - OR 127.0.0.1 (http://127.0.0.1)

# In production, ensure HTTPS\n# Via Pangolin tunnel or Cloudflare\n

Solution 2: Grant permission

  1. Click lock icon in address bar
  2. Location \u2192 Allow
  3. Refresh page
  4. Try geolocate again

Solution 3: Test geolocation API

// In browser console\nnavigator.geolocation.getCurrentPosition(\n  (pos) => console.log('Location:', pos.coords),\n  (err) => console.error('Error:', err)\n);\n\n// Errors:\n// PERMISSION_DENIED - User denied\n// POSITION_UNAVAILABLE - GPS unavailable\n// TIMEOUT - Taking too long\n

Solution 4: Increase timeout

// In geolocate code\nnavigator.geolocation.getCurrentPosition(\n  successCallback,\n  errorCallback,\n  {\n    timeout: 10000,  // 10 seconds (default: 5000)\n    enableHighAccuracy: true,\n    maximumAge: 0\n  }\n);\n

Solution 5: Fallback to IP geolocation

// If GPS fails, use IP-based location\nconst fallbackLocation = async () => {\n  const response = await fetch('https://ipapi.co/json/');\n  const data = await response.json();\n  return {\n    latitude: data.latitude,\n    longitude: data.longitude\n  };\n};\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_7","title":"Prevention","text":"
  • HTTPS in production - Use secure connection
  • Permission prompts - Clear instructions
  • Fallback options - IP geolocation as backup
  • Error handling - User-friendly error messages
"},{"location":"v2/troubleshooting/geocoding-issues/#coordinate-issues","title":"Coordinate Issues","text":""},{"location":"v2/troubleshooting/geocoding-issues/#invalid-latlng","title":"Invalid Lat/Lng","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_8","title":"Symptoms","text":"
Error: Invalid latitude/longitude values\n

Or markers appear in wrong location (ocean, wrong country).

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_8","title":"Common Causes","text":"
  1. Swapped coordinates - Latitude and longitude reversed
  2. Out of range - Latitude > 90 or Longitude > 180
  3. Wrong sign - Positive instead of negative (or vice versa)
  4. Decimal precision - Too many/few decimal places
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_8","title":"Solutions","text":"

Solution 1: Validate ranges

Valid ranges: - Latitude: -90 to 90 - Longitude: -180 to 180

# Find invalid coordinates\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, address, latitude, longitude\n      FROM \\\"Location\\\"\n      WHERE latitude < -90 OR latitude > 90\n         OR longitude < -180 OR longitude > 180;\"\n

Solution 2: Check coordinate order

# Common mistake: swapped lat/lng\n# Toronto should be:\n# Latitude: 43.65 (positive, North)\n# Longitude: -79.38 (negative, West)\n\n# If showing as 79.38, -43.65, they're swapped\n\n# Fix:\nUPDATE \"Location\"\nSET latitude = longitude, longitude = latitude\nWHERE id = 'LOCATION_ID';\n

Solution 3: Verify hemisphere

For North American locations: - Latitude: Positive (North) - Longitude: Negative (West)

# If US/Canada location has positive longitude, wrong sign\nUPDATE \"Location\"\nSET longitude = longitude * -1\nWHERE country = 'Canada' AND longitude > 0;\n

Solution 4: Check decimal precision

# Good precision (6 decimals \u2248 0.1m accuracy):\nLatitude: 43.651234\nLongitude: -79.381234\n\n# Too few decimals (imprecise):\nLatitude: 43.65\nLongitude: -79.38\n\n# Too many decimals (unnecessary):\nLatitude: 43.651234567890\nLongitude: -79.381234567890\n

Solution 5: Visual verification

  1. Open Google Maps
  2. Enter coordinates: 43.651234, -79.381234
  3. Verify location matches address
  4. If wrong, get correct coordinates from Google Maps
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_8","title":"Prevention","text":"
  • Coordinate validation - Check ranges before save
  • Visual preview - Show on map before save
  • Import validation - Validate imported coordinates
  • Decimal precision - Round to 6 decimals
"},{"location":"v2/troubleshooting/geocoding-issues/#out-of-bounds-coordinates","title":"Out of Bounds Coordinates","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_9","title":"Symptoms","text":"

Markers appear outside expected area (different country/continent).

"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_9","title":"Solutions","text":"

Solution 1: Set map bounds

// Limit map to expected region\nconst bounds = L.latLngBounds(\n  [41.0, -95.0],  // Southwest corner\n  [50.0, -74.0]   // Northeast corner (covers eastern Canada/US)\n);\n\n<MapContainer\n  maxBounds={bounds}\n  maxBoundsViscosity={1.0}\n  // ...\n/>\n

Solution 2: Filter locations by bounds

// Only show locations in expected region\nconst filteredLocations = locations.filter(location => {\n  return location.latitude >= 41 && location.latitude <= 50 &&\n         location.longitude >= -95 && location.longitude <= -74;\n});\n
"},{"location":"v2/troubleshooting/geocoding-issues/#projection-errors-nar-data","title":"Projection Errors (NAR Data)","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_10","title":"Symptoms","text":"

Locations imported from NAR data appear in wrong place.

"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_9","title":"Common Causes","text":"
  1. Wrong projection - NAR uses EPSG:3347 (Lambert), not WGS84
  2. Missing conversion - Coordinates not converted to lat/lng
  3. Coordinate swap - BG_X and BG_Y reversed
"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_10","title":"Solutions","text":"

Solution 1: Verify NAR import uses proj4

In api/src/modules/map/locations/nar-import.service.ts:

import proj4 from 'proj4';\n\n// Define EPSG:3347 (NAR projection)\nproj4.defs('EPSG:3347',\n  '+proj=lcc +lat_0=63.390675 +lon_0=-91.86666666666666 ' +\n  '+lat_1=49 +lat_2=77 +x_0=6200000 +y_0=3000000 ' +\n  '+ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs'\n);\n\n// Convert\nconst [longitude, latitude] = proj4('EPSG:3347', 'WGS84', [bgX, bgY]);\n

Solution 2: Check coordinate order

NAR Address files: - BG_X: Easting (X coordinate in meters) - BG_Y: Northing (Y coordinate in meters)

Conversion order: [BG_X, BG_Y] \u2192 [longitude, latitude]

Solution 3: Verify conversion

# Test conversion manually\ndocker compose exec api node -e \"\nconst proj4 = require('proj4');\nproj4.defs('EPSG:3347', '+proj=lcc +lat_0=63.390675 +lon_0=-91.86666666666666 +lat_1=49 +lat_2=77 +x_0=6200000 +y_0=3000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs');\n\n// Example Toronto coordinates in EPSG:3347:\nconst [lng, lat] = proj4('EPSG:3347', 'WGS84', [6458123, 3534567]);\nconsole.log('Lat:', lat, 'Lng:', lng);\n// Should be approximately: Lat: 43.65 Lng: -79.38\n\"\n

Solution 4: Re-import NAR data

If imported incorrectly:

# Delete bad data\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"DELETE FROM \\\"Location\\\" WHERE \\\"importSource\\\" = 'NAR';\"\n\n# Re-import with correct projection\n# Via admin UI: /app/map/locations \u2192 NAR Import tab\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_9","title":"Prevention","text":"
  • Projection validation - Test conversion on sample data
  • Visual verification - Show import preview on map
  • Documentation - Document NAR projection requirements
  • Import validation - Check coordinates are in expected range
"},{"location":"v2/troubleshooting/geocoding-issues/#queue-issues","title":"Queue Issues","text":""},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-queue-stuck","title":"Geocoding Queue Stuck","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_11","title":"Symptoms","text":"

Locations remain ungeocoded even though queue is running.

Queue shows jobs but they never process.

"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_11","title":"Solutions","text":"

Solution 1: Check queue status

# View queue stats\ncurl http://localhost:4000/api/map/geocoding/queue/stats \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Shows:\n# {\n#   \"waiting\": 150,\n#   \"active\": 0,  # Should be > 0 if processing\n#   \"completed\": 2500,\n#   \"failed\": 25\n# }\n

Solution 2: Check worker is running

# Worker should log processing\ndocker compose logs api | grep -i \"geocoding worker\\|processing geocode\"\n\n# Should show:\n# Geocoding worker started\n# Processing geocode job for location: abc-123\n

Solution 3: Restart queue worker

# Restart API (restarts worker)\ndocker compose restart api\n\n# Check worker started\ndocker compose logs api | grep \"Geocoding worker started\"\n

Solution 4: Check Redis connection

# Test Redis\ndocker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD ping\n# Should return: PONG\n\n# Check queue keys\ndocker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD keys \"bull:geocoding:*\"\n

Solution 5: Manually process stuck jobs

# Retry failed jobs\ncurl -X POST http://localhost:4000/api/map/geocoding/queue/retry-failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Clean stuck jobs\ncurl -X POST http://localhost:4000/api/map/geocoding/queue/clean \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -d '{\"status\": \"failed\", \"grace\": 86400000}'  # Clean failed jobs older than 1 day\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_10","title":"Prevention","text":"
  • Health checks - Monitor worker health
  • Dead letter queue - Move repeatedly failed jobs
  • Alerting - Alert if queue backed up
  • Auto-restart - Restart worker if stuck
"},{"location":"v2/troubleshooting/geocoding-issues/#jobs-failing","title":"Jobs Failing","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_12","title":"Symptoms","text":"

Queue shows high failed job count.

Locations remain ungeocoded with error status.

"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_12","title":"Solutions","text":"

Solution 1: View failed jobs

# Get failed job details\ncurl http://localhost:4000/api/map/geocoding/queue/failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Shows:\n# [\n#   {\n#     \"id\": \"123\",\n#     \"data\": { \"locationId\": \"abc\", \"address\": \"...\" },\n#     \"failedReason\": \"All providers failed\",\n#     \"attemptsMade\": 3\n#   }\n# ]\n

Solution 2: Check error patterns

# Common failure reasons\ndocker compose logs api | grep \"Geocoding failed\" | sort | uniq -c\n\n# Example output:\n#  45 Geocoding failed: Rate limit exceeded\n#  12 Geocoding failed: No results found\n#   3 Geocoding failed: Network error\n

Solution 3: Retry with different settings

# Retry with longer timeout\ncurl -X POST http://localhost:4000/api/map/locations/LOCATION_ID/geocode \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"timeout\": 30000}'  # 30 seconds\n

Solution 4: Manual intervention

For repeatedly failing addresses:

  1. Open LocationsPage
  2. Find failed locations
  3. Review address (fix typos)
  4. Manually set coordinates if needed
  5. Or delete if invalid
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_11","title":"Prevention","text":"
  • Retry logic - Auto-retry with exponential backoff
  • Error categorization - Permanent vs transient failures
  • Manual review queue - Flag for manual review after N attempts
  • Address validation - Validate before geocoding
"},{"location":"v2/troubleshooting/geocoding-issues/#performance-issues","title":"Performance Issues","text":""},{"location":"v2/troubleshooting/geocoding-issues/#slow-geocoding","title":"Slow Geocoding","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_13","title":"Symptoms","text":"

Geocoding takes 5-10+ seconds per address.

Bulk imports very slow.

"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_13","title":"Solutions","text":"

Solution 1: Use faster providers first

Provider speed (fastest to slowest): 1. Google (with API key) - ~200ms 2. Mapbox (with API key) - ~300ms 3. Nominatim - ~500ms 4. ArcGIS - ~800ms 5. Photon - ~1000ms 6. HERE - ~400ms

Configure in api/src/modules/map/geocoding/geocoding.service.ts.

Solution 2: Increase concurrency

In geocoding queue worker:

// Increase concurrent geocoding\nconst worker = new Worker('geocoding', processor, {\n  concurrency: 5,  // Process 5 at a time (default: 1)\n  limiter: {\n    max: 50,  // Max 50 jobs per second\n    duration: 1000\n  }\n});\n

Solution 3: Use bulk geocoding APIs

Some providers offer batch geocoding:

# Google Batch Geocoding (requires Business plan)\n# Can geocode up to 100 addresses in one request\n

Solution 4: Cache results

// Cache geocoding results in Redis\nconst cacheKey = `geocode:${address}`;\nconst cached = await redis.get(cacheKey);\n\nif (cached) {\n  return JSON.parse(cached);\n}\n\nconst result = await geocode(address);\nawait redis.setex(cacheKey, 86400, JSON.stringify(result));  // Cache 24h\nreturn result;\n

Solution 5: Parallel processing

// Geocode multiple addresses in parallel\nconst addresses = ['123 Main St', '456 Oak Ave', ...];\n\nconst results = await Promise.all(\n  addresses.map(address => geocode(address))\n);\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_12","title":"Prevention","text":"
  • Queue system - Don't block UI on geocoding
  • Paid tiers - Faster with API keys
  • Caching - Cache frequent addresses
  • Parallel processing - Process multiple at once
"},{"location":"v2/troubleshooting/geocoding-issues/#too-many-api-calls","title":"Too Many API Calls","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_14","title":"Symptoms","text":"

High API usage on Google/Mapbox.

Approaching or exceeding quota.

"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_14","title":"Solutions","text":"

Solution 1: Monitor usage

# Check geocoding stats\ncurl http://localhost:4000/api/map/geocoding/stats \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Track API costs:\n# Google: $5 per 1000 requests (after 40k free/month)\n# Mapbox: $0.50 per 1000 requests (after 100k free/month)\n

Solution 2: Use free providers first

Reorder provider priority:

// In geocodingService.ts\nconst providers = [\n  'nominatim',  // Free (try first)\n  'photon',     // Free\n  'arcgis',     // Free (1000/day)\n  'google',     // Paid (use only if others fail)\n  'mapbox',     // Paid\n  'here'        // Paid\n];\n

Solution 3: Cache aggressively

// Cache geocoding results permanently\nconst cacheKey = `geocode:${normalizeAddress(address)}`;\nconst cached = await redis.get(cacheKey);\n\nif (cached) {\n  return JSON.parse(cached);\n}\n\nconst result = await geocode(address);\nawait redis.set(cacheKey, JSON.stringify(result));  // No expiration\nreturn result;\n

Solution 4: Deduplicate requests

# Before geocoding, check if address already geocoded\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT latitude, longitude FROM \\\"Location\\\"\n      WHERE LOWER(address) = LOWER('123 Main Street, Toronto')\n        AND latitude IS NOT NULL\n      LIMIT 1;\"\n\n# If exists, copy coordinates instead of geocoding again\n

Solution 5: Set quota alerts

In Google Cloud Console: 1. Navigate to Geocoding API 2. Set quota alerts (e.g., 80% of limit) 3. Receive email before exceeding quota

"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_13","title":"Prevention","text":"
  • Cache everything - Never geocode same address twice
  • Free providers first - Use paid only as fallback
  • Quota monitoring - Alert before exceeding
  • Cost tracking - Monitor API costs monthly
"},{"location":"v2/troubleshooting/geocoding-issues/#data-quality","title":"Data Quality","text":""},{"location":"v2/troubleshooting/geocoding-issues/#duplicate-locations","title":"Duplicate Locations","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_15","title":"Symptoms","text":"

Same address appears multiple times in locations table.

"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_15","title":"Solutions","text":"

Solution 1: Find duplicates

# Find duplicate addresses\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT address, COUNT(*), array_agg(id)\n      FROM \\\"Location\\\"\n      GROUP BY LOWER(address)\n      HAVING COUNT(*) > 1;\"\n

Solution 2: Merge duplicates

# Keep oldest, delete newer\n# (After reassigning foreign keys to kept record)\nDELETE FROM \"Location\" AS l1\nWHERE EXISTS (\n  SELECT 1 FROM \"Location\" AS l2\n  WHERE LOWER(l2.address) = LOWER(l1.address)\n    AND l2.\"createdAt\" < l1.\"createdAt\"\n);\n

Solution 3: Add unique constraint

model Location {\n  id      String @id @default(uuid())\n  address String\n\n  @@unique([address])  // Prevent duplicates\n}\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_14","title":"Prevention","text":"
  • Unique constraints - Database prevents duplicates
  • Upsert logic - Update if exists, create if not
  • Import validation - Check for duplicates before import
  • Case-insensitive comparison - Normalize before checking
"},{"location":"v2/troubleshooting/geocoding-issues/#ungeocoded-locations","title":"Ungeocoded Locations","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_16","title":"Symptoms","text":"

Many locations with null latitude/longitude.

"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_16","title":"Solutions","text":"

Solution 1: Count ungeocoded

docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT COUNT(*) FROM \\\"Location\\\" WHERE latitude IS NULL;\"\n

Solution 2: Queue all ungeocoded

# Via API\ncurl -X POST http://localhost:4000/api/map/locations/queue-geocoding \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Queues all locations with null coordinates\n

Solution 3: View on Data Quality Dashboard

Navigate to /app/map/data-quality:

  • Shows geocoding rate
  • Lists ungeocoded locations
  • Allows bulk geocoding

Solution 4: Export ungeocoded for manual review

# Export to CSV\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"COPY (SELECT id, address, city, \\\"postalCode\\\" FROM \\\"Location\\\"\n            WHERE latitude IS NULL) TO STDOUT WITH CSV HEADER\" > ungeocoded.csv\n
"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_15","title":"Prevention","text":"
  • Geocode on create - Auto-geocode new locations
  • Required coordinates - Don't allow creating without geocoding
  • Dashboard monitoring - Track geocoding rate
  • Regular cleanup - Periodic geocoding of ungeocoded
"},{"location":"v2/troubleshooting/geocoding-issues/#useful-commands","title":"Useful Commands","text":""},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-operations","title":"Geocoding Operations","text":"
# Geocode single location\ncurl -X POST http://localhost:4000/api/map/locations/LOCATION_ID/geocode \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Bulk geocode via queue\ncurl -X POST http://localhost:4000/api/map/locations/queue-geocoding \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Check geocoding stats\ncurl http://localhost:4000/api/map/geocoding/stats \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n\n# Retry failed geocodes\ncurl -X POST http://localhost:4000/api/map/geocoding/queue/retry-failed \\\n  -H \"Authorization: Bearer YOUR_TOKEN\"\n
"},{"location":"v2/troubleshooting/geocoding-issues/#database-queries","title":"Database Queries","text":"
# Count by geocoding status\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT\n        COUNT(*) FILTER (WHERE latitude IS NOT NULL) as geocoded,\n        COUNT(*) FILTER (WHERE latitude IS NULL) as ungeocoded\n      FROM \\\"Location\\\";\"\n\n# List ungeocoded\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT id, address FROM \\\"Location\\\"\n      WHERE latitude IS NULL\n      LIMIT 50;\"\n\n# Geocoding provider stats\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n  -c \"SELECT \\\"geocodingProvider\\\", COUNT(*)\n      FROM \\\"Location\\\"\n      WHERE \\\"geocodingProvider\\\" IS NOT NULL\n      GROUP BY \\\"geocodingProvider\\\";\"\n
"},{"location":"v2/troubleshooting/geocoding-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-documentation","title":"Geocoding Documentation","text":"
  • Geocoding Issues - This guide
  • Locations Feature - Location management
  • Data Quality Dashboard - Monitoring geocoding
"},{"location":"v2/troubleshooting/geocoding-issues/#other-troubleshooting","title":"Other Troubleshooting","text":"
  • Common Errors - General errors
  • Performance Optimization - Speed improvements
"},{"location":"v2/troubleshooting/geocoding-issues/#external-resources","title":"External Resources","text":"
  • Nominatim Usage Policy
  • Google Geocoding API
  • Mapbox Geocoding API
  • Leaflet Documentation

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/troubleshooting/monitoring-issues/","title":"Monitoring and Observability Issues","text":"

This guide covers Prometheus, Grafana, and observability stack problems in Changemaker Lite V2.

"},{"location":"v2/troubleshooting/monitoring-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/monitoring-issues/#monitoring-stack","title":"Monitoring Stack","text":"

Changemaker Lite V2 uses profile-based monitoring (optional):

# Start with monitoring\ndocker compose --profile monitoring up -d\n

Components:

  • Prometheus - Metrics collection and storage (port 9090)
  • Grafana - Metrics visualization (port 3001)
  • Alertmanager - Alert routing and notification (port 9093)
  • cAdvisor - Container metrics (port 8080)
  • Node Exporter - Host metrics (port 9100)
  • Redis Exporter - Redis metrics (port 9121)
"},{"location":"v2/troubleshooting/monitoring-issues/#custom-metrics","title":"Custom Metrics","text":"

12 custom cm_* Prometheus metrics:

  1. cm_api_uptime_seconds - API uptime
  2. cm_database_uptime_seconds - Database uptime
  3. cm_email_queue_size - Email queue depth
  4. cm_geocoding_queue_size - Geocoding queue depth
  5. cm_users_total - Total users
  6. cm_campaigns_total - Total campaigns
  7. cm_locations_total - Total locations
  8. cm_geocoded_locations_total - Geocoded locations
  9. cm_active_canvass_sessions - Active sessions
  10. cm_external_service_up - Service health (0/1)
  11. cm_listmonk_subscribers_total - Listmonk subscribers
  12. cm_media_videos_total - Total videos

Plus standard HTTP metrics: - http_request_duration_seconds - http_requests_total

"},{"location":"v2/troubleshooting/monitoring-issues/#prometheus-not-scraping","title":"Prometheus Not Scraping","text":""},{"location":"v2/troubleshooting/monitoring-issues/#target-down","title":"Target Down","text":"

Severity: \ud83d\udd34 Critical

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms","title":"Symptoms","text":"

Prometheus UI (localhost:9090) shows targets as \"DOWN\":

Target: api (localhost:4000/metrics)\nState: DOWN\nError: Get \"http://api:4000/metrics\": connection refused\n

No data in Grafana dashboards.

"},{"location":"v2/troubleshooting/monitoring-issues/#common-causes","title":"Common Causes","text":"
  1. Service not running - API container stopped
  2. Metrics endpoint missing - /metrics endpoint not registered
  3. Network issue - Prometheus can't reach service
  4. Authentication required - Metrics endpoint requires auth
"},{"location":"v2/troubleshooting/monitoring-issues/#solutions","title":"Solutions","text":"

Solution 1: Check service is running

# Is API running?\ndocker compose ps api\n\n# Should show \"Up\"\n# If not:\ndocker compose up -d api\n

Solution 2: Test metrics endpoint

# From host\ncurl http://localhost:4000/metrics\n\n# Should return Prometheus metrics:\n# # HELP cm_api_uptime_seconds API uptime in seconds\n# # TYPE cm_api_uptime_seconds gauge\n# cm_api_uptime_seconds 123.45\n\n# From Prometheus container\ndocker compose exec prometheus wget -O- http://api:4000/metrics\n

Solution 3: Check Prometheus config

In configs/prometheus/prometheus.yml:

scrape_configs:\n  - job_name: 'api'\n    static_configs:\n      - targets: ['api:4000']  # Use service name, not localhost\n

Solution 4: Verify network

# Both on same network?\ndocker inspect changemaker-lite-prometheus-1 | grep NetworkMode\ndocker inspect changemaker-lite-api-1 | grep NetworkMode\n\n# Should both show \"changemaker-lite\"\n

Solution 5: Check metrics are registered

In API logs:

docker compose logs api | grep -i \"metrics\\|prometheus\"\n\n# Should show:\n# Metrics endpoint registered at /metrics\n# Prometheus metrics initialized\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention","title":"Prevention","text":"
  • Health checks - Monitor Prometheus target health
  • Service dependencies - Ensure services start in order
  • Network config - Use Docker service names
  • Testing - Test /metrics endpoint on deploy
"},{"location":"v2/troubleshooting/monitoring-issues/#scrape-timeout","title":"Scrape Timeout","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_1","title":"Symptoms","text":"
Target: api\nState: UP\nLast Scrape: 5.2s (slow)\nLast Error: context deadline exceeded\n

Scrapes taking too long or timing out.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_1","title":"Solutions","text":"

Solution 1: Increase scrape timeout

In configs/prometheus/prometheus.yml:

global:\n  scrape_interval: 15s\n  scrape_timeout: 10s  # Increase from 10s to 30s\n\nscrape_configs:\n  - job_name: 'api'\n    scrape_interval: 30s  # Scrape less frequently\n    scrape_timeout: 20s\n    static_configs:\n      - targets: ['api:4000']\n

Reload config:

# Reload Prometheus config\ndocker compose exec prometheus kill -HUP 1\n\n# Or restart\ndocker compose restart prometheus\n

Solution 2: Optimize metrics generation

// In api/src/utils/metrics.ts\n// Cache expensive metrics\nlet cachedUserCount = 0;\nlet lastUserCountUpdate = 0;\n\nregister.registerMetric(new Gauge({\n  name: 'cm_users_total',\n  help: 'Total number of users',\n  async collect() {\n    const now = Date.now();\n    // Only query database every 60 seconds\n    if (now - lastUserCountUpdate > 60000) {\n      cachedUserCount = await prisma.user.count();\n      lastUserCountUpdate = now;\n    }\n    this.set(cachedUserCount);\n  }\n}));\n

Solution 3: Reduce metric cardinality

// Bad - high cardinality (creates metric per user)\nnew Counter({\n  name: 'requests_by_user',\n  labelNames: ['userId']  // Don't do this!\n});\n\n// Good - low cardinality\nnew Counter({\n  name: 'requests_by_role',\n  labelNames: ['role']  // Only 5 roles\n});\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_1","title":"Prevention","text":"
  • Cache expensive metrics - Don't query DB on every scrape
  • Reasonable timeouts - 10-30s timeouts
  • Low cardinality - Avoid high-cardinality labels
  • Optimize queries - Fast metric queries
"},{"location":"v2/troubleshooting/monitoring-issues/#authentication-errors","title":"Authentication Errors","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_2","title":"Symptoms","text":"
Error: 401 Unauthorized when scraping /metrics\n
"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_2","title":"Solutions","text":"

Changemaker Lite V2 metrics endpoint is public (no auth required).

If you see auth errors:

Solution 1: Remove auth middleware from /metrics

In api/src/server.ts:

// Metrics endpoint should be BEFORE authenticate middleware\napp.get('/metrics', async (req, res) => {\n  res.set('Content-Type', register.contentType);\n  res.end(await register.metrics());\n});\n\n// Auth middleware comes after\napp.use(authenticate);\n

Solution 2: Configure basic auth in Prometheus

If you DO want to protect /metrics:

In configs/prometheus/prometheus.yml:

scrape_configs:\n  - job_name: 'api'\n    static_configs:\n      - targets: ['api:4000']\n    basic_auth:\n      username: 'prometheus'\n      password: 'your-password'\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_2","title":"Prevention","text":"
  • Public metrics - Keep /metrics public for simplicity
  • Network isolation - Use Docker networks for security
  • IP whitelist - Only allow Prometheus IP
"},{"location":"v2/troubleshooting/monitoring-issues/#grafana-issues","title":"Grafana Issues","text":""},{"location":"v2/troubleshooting/monitoring-issues/#dashboards-not-loading","title":"Dashboards Not Loading","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_3","title":"Symptoms","text":"

Grafana shows blank dashboards or \"No data\" panels.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_3","title":"Solutions","text":"

Solution 1: Check Grafana is running

docker compose --profile monitoring ps grafana\n\n# Should show \"Up\"\n# If not:\ndocker compose --profile monitoring up -d grafana\n

Solution 2: Verify Prometheus datasource

  1. Open Grafana: http://localhost:3001
  2. Login (admin/admin)
  3. Settings \u2192 Data Sources
  4. Click Prometheus
  5. URL should be: http://prometheus:9090
  6. Click \"Save & Test\"
  7. Should show \"Data source is working\"

Solution 3: Check dashboard provisioning

# List provisioned dashboards\ndocker compose exec grafana ls -la /etc/grafana/provisioning/dashboards/\n\n# Should show:\n# dashboard-provider.yml\n# changemaker-api.json\n# changemaker-queue.json\n# changemaker-external-services.json\n

Solution 4: Import dashboard manually

If auto-provisioning fails:

  1. Grafana \u2192 Dashboards \u2192 Import
  2. Upload JSON from configs/grafana/dashboards/
  3. Select Prometheus datasource
  4. Click Import

Solution 5: Check for data

# Test query in Grafana Explore\n# Query: cm_api_uptime_seconds\n\n# Or test in Prometheus:\ncurl 'http://localhost:9090/api/v1/query?query=cm_api_uptime_seconds'\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_3","title":"Prevention","text":"
  • Dashboard versioning - Keep dashboards in git
  • Auto-provisioning - Use provisioning instead of manual import
  • Testing - Test dashboards after changes
  • Documentation - Document dashboard variables
"},{"location":"v2/troubleshooting/monitoring-issues/#datasource-errors","title":"Datasource Errors","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_4","title":"Symptoms","text":"
Error: Failed to query Prometheus\nError: connection refused\n

Red error bars on Grafana panels.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_4","title":"Solutions","text":"

Solution 1: Test Prometheus connection

# From Grafana container\ndocker compose exec grafana wget -O- http://prometheus:9090/api/v1/query?query=up\n\n# Should return JSON:\n# {\"status\":\"success\",\"data\":{\"resultType\":\"vector\",\"result\":[...]}}\n

Solution 2: Check Prometheus is running

docker compose --profile monitoring ps prometheus\n\n# Should show \"Up\"\n

Solution 3: Verify datasource URL

In Grafana datasource settings: - URL: http://prometheus:9090 (NOT http://localhost:9090) - Access: Server (NOT Browser)

Solution 4: Check Docker network

# Same network?\ndocker inspect changemaker-lite-grafana-1 | grep NetworkMode\ndocker inspect changemaker-lite-prometheus-1 | grep NetworkMode\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_4","title":"Prevention","text":"
  • Health checks - Monitor datasource health
  • Service dependencies - Start Prometheus before Grafana
  • Error handling - Graceful error messages
"},{"location":"v2/troubleshooting/monitoring-issues/#query-errors","title":"Query Errors","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_5","title":"Symptoms","text":"
Error executing query: parse error at char X: unexpected identifier\n

Panel shows \"Error loading data\".

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_5","title":"Solutions","text":"

Solution 1: Validate PromQL syntax

Common errors:

# Bad - missing {}\ncm_api_uptime_seconds{job=api}\n\n# Good\ncm_api_uptime_seconds{job=\"api\"}\n\n# Bad - wrong function\naverage(cm_api_uptime_seconds)\n\n# Good\navg(cm_api_uptime_seconds)\n

Solution 2: Test query in Explore

  1. Grafana \u2192 Explore
  2. Enter query
  3. Run
  4. Fix errors before adding to dashboard

Solution 3: Check metric exists

# List all metrics\ncurl http://localhost:9090/api/v1/label/__name__/values | jq\n\n# Search for metric\ncurl http://localhost:9090/api/v1/label/__name__/values | jq '.data[]' | grep cm_\n

Solution 4: Use metric browser

In Grafana query editor: 1. Click \"Metrics\" button 2. Browse available metrics 3. Select metric (auto-fills query)

"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_5","title":"Prevention","text":"
  • Query validation - Validate before saving
  • Testing - Test queries in Explore
  • Documentation - Document available metrics
  • Examples - Provide query examples
"},{"location":"v2/troubleshooting/monitoring-issues/#alertmanager-issues","title":"Alertmanager Issues","text":""},{"location":"v2/troubleshooting/monitoring-issues/#alerts-not-firing","title":"Alerts Not Firing","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_6","title":"Symptoms","text":"

Conditions met but alert not triggering.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_6","title":"Solutions","text":"

Solution 1: Check alert rules

In Prometheus UI (localhost:9090):

  1. Click \"Alerts\"
  2. Find your alert
  3. Check state:
  4. Inactive: Condition not met
  5. Pending: Met but waiting for for: duration
  6. Firing: Alert active

Solution 2: Verify alert rule syntax

In configs/prometheus/alerts.yml:

groups:\n  - name: changemaker_alerts\n    interval: 30s\n    rules:\n      - alert: APIDown\n        expr: up{job=\"api\"} == 0\n        for: 1m  # Must be down for 1 minute before firing\n        labels:\n          severity: critical\n        annotations:\n          summary: \"API is down\"\n          description: \"API has been down for 1 minute\"\n

Solution 3: Check Alertmanager config

# Test Alertmanager\ncurl http://localhost:9093/api/v1/alerts\n\n# Should return alert list\n

Solution 4: View Prometheus logs

docker compose logs prometheus | grep -i alert\n\n# Shows:\n# Loaded alert rules\n# Alert X is firing\n

Solution 5: Reload alert rules

# Reload Prometheus config\ndocker compose exec prometheus kill -HUP 1\n\n# Check rules loaded\ncurl http://localhost:9090/api/v1/rules\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_6","title":"Prevention","text":"
  • Test alert conditions - Trigger manually to test
  • Reasonable thresholds - Not too sensitive or too lenient
  • Documentation - Document alert thresholds
  • Regular review - Review alert effectiveness
"},{"location":"v2/troubleshooting/monitoring-issues/#notifications-not-sent","title":"Notifications Not Sent","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_7","title":"Symptoms","text":"

Alert firing in Prometheus but no notification received.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_7","title":"Solutions","text":"

Solution 1: Check Alertmanager config

In configs/alertmanager/alertmanager.yml:

route:\n  receiver: 'email'\n  group_wait: 30s\n  group_interval: 5m\n  repeat_interval: 12h\n\nreceivers:\n  - name: 'email'\n    email_configs:\n      - to: 'alerts@example.com'\n        from: 'alertmanager@example.com'\n        smarthost: 'smtp.gmail.com:587'\n        auth_username: 'your-email@gmail.com'\n        auth_password: 'your-app-password'\n

Solution 2: Test Alertmanager notification

# Send test alert\ncurl -X POST http://localhost:9093/api/v1/alerts \\\n  -H 'Content-Type: application/json' \\\n  -d '[{\n    \"labels\": {\n      \"alertname\": \"Test\",\n      \"severity\": \"critical\"\n    },\n    \"annotations\": {\n      \"summary\": \"Test alert\"\n    }\n  }]'\n\n# Check if notification sent\ndocker compose logs alertmanager | grep -i \"notification\\|email\"\n

Solution 3: Check SMTP config

See Email Issues for SMTP troubleshooting.

Solution 4: Use alternative notification channels

receivers:\n  - name: 'slack'\n    slack_configs:\n      - api_url: 'https://hooks.slack.com/services/...'\n        channel: '#alerts'\n\n  - name: 'webhook'\n    webhook_configs:\n      - url: 'http://your-webhook-url.com/alerts'\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_7","title":"Prevention","text":"
  • Test notifications - Regular notification tests
  • Multiple channels - Email + Slack + webhook
  • Fallback receivers - Backup notification method
  • Documentation - Document notification setup
"},{"location":"v2/troubleshooting/monitoring-issues/#routing-errors","title":"Routing Errors","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_8","title":"Symptoms","text":"

Alerts going to wrong receiver or being silenced incorrectly.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_8","title":"Solutions","text":"

Solution 1: Check routing rules

In configs/alertmanager/alertmanager.yml:

route:\n  receiver: 'default'\n  routes:\n    - match:\n        severity: critical\n      receiver: 'pager'\n    - match:\n        severity: warning\n      receiver: 'email'\n

Solution 2: Test routing

# Use amtool to test routing\ndocker compose exec alertmanager amtool config routes test \\\n  --config.file=/etc/alertmanager/alertmanager.yml \\\n  alertname=TestAlert severity=critical\n\n# Shows which receiver will be used\n

Solution 3: View active silences

In Alertmanager UI (localhost:9093):

  1. Click \"Silences\"
  2. Check if alert is silenced
  3. Expire or delete silence if wrong

Solution 4: Check inhibition rules

inhibit_rules:\n  - source_match:\n      severity: critical\n    target_match:\n      severity: warning\n    equal: ['alertname', 'instance']\n# Critical alerts inhibit warnings for same instance\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_8","title":"Prevention","text":"
  • Clear routing logic - Simple, understandable rules
  • Test routing - Test before deploying
  • Documentation - Document routing rules
  • Regular review - Review silences and inhibitions
"},{"location":"v2/troubleshooting/monitoring-issues/#metrics-issues","title":"Metrics Issues","text":""},{"location":"v2/troubleshooting/monitoring-issues/#missing-metrics","title":"Missing Metrics","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_9","title":"Symptoms","text":"

Expected metric not appearing in Prometheus or Grafana.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_9","title":"Solutions","text":"

Solution 1: Check metric is registered

In API code (api/src/utils/metrics.ts):

import { Counter } from 'prom-client';\n\nconst requestCounter = new Counter({\n  name: 'cm_my_metric_total',\n  help: 'Description of metric'\n});\n\nregister.registerMetric(requestCounter);  // Must register!\n

Solution 2: Check metric is collected

# Test /metrics endpoint\ncurl http://localhost:4000/metrics | grep cm_my_metric\n\n# Should show:\n# # HELP cm_my_metric_total Description of metric\n# # TYPE cm_my_metric_total counter\n# cm_my_metric_total 42\n

Solution 3: Check scrape config

In configs/prometheus/prometheus.yml:

scrape_configs:\n  - job_name: 'api'\n    static_configs:\n      - targets: ['api:4000']\n    metric_relabel_configs:  # Don't accidentally drop metric\n      - source_labels: [__name__]\n        regex: 'cm_.*'  # Keep cm_* metrics\n        action: keep\n

Solution 4: Verify metric type

// Counter - only increases (counts)\nconst counter = new Counter({ name: 'cm_requests_total' });\ncounter.inc();  // Increment\n\n// Gauge - can go up or down (current value)\nconst gauge = new Gauge({ name: 'cm_queue_size' });\ngauge.set(42);  // Set value\n\n// Histogram - distribution of values\nconst histogram = new Histogram({ name: 'cm_request_duration_seconds' });\nhistogram.observe(0.5);  // Record duration\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_9","title":"Prevention","text":"
  • Register all metrics - Don't forget register.registerMetric()
  • Test endpoint - Check /metrics shows metric
  • Naming convention - Use cm_* prefix for custom metrics
  • Documentation - Document all custom metrics
"},{"location":"v2/troubleshooting/monitoring-issues/#incorrect-values","title":"Incorrect Values","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_10","title":"Symptoms","text":"

Metric showing wrong or unexpected values.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_10","title":"Solutions","text":"

Solution 1: Check metric logic

// Wrong - gauge not updated\nconst gauge = new Gauge({ name: 'cm_users_total' });\n// Never set, always 0\n\n// Right - gauge updated\nconst gauge = new Gauge({\n  name: 'cm_users_total',\n  async collect() {\n    const count = await prisma.user.count();\n    this.set(count);\n  }\n});\n

Solution 2: Check metric type

// Wrong - using Counter for value that can decrease\nconst queueSize = new Counter({ name: 'cm_queue_size' });\nqueueSize.inc(50);  // Add 50\nqueueSize.inc(-20);  // Try to subtract 20 - ERROR!\n\n// Right - use Gauge\nconst queueSize = new Gauge({ name: 'cm_queue_size' });\nqueueSize.set(50);  // Set to 50\nqueueSize.set(30);  // Set to 30 (can decrease)\n

Solution 3: Check label values

// Labels must match exactly\nconst counter = new Counter({\n  name: 'requests_total',\n  labelNames: ['method', 'status']\n});\n\ncounter.inc({ method: 'GET', status: '200' });\n// Creates: requests_total{method=\"GET\",status=\"200\"} 1\n\ncounter.inc({ method: 'GET', status: 200 });  // Wrong - number not string\n// Creates separate metric: requests_total{method=\"GET\",status=200} 1\n

Solution 4: Check query aggregation

# Wrong - sums across all labels\nsum(cm_requests_total)\n\n# Right - sum by specific label\nsum by (status) (cm_requests_total)\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_10","title":"Prevention","text":"
  • Correct metric type - Counter vs Gauge vs Histogram
  • Type consistency - Label values always same type
  • Testing - Test metric values with sample data
  • Validation - Validate metric values are reasonable
"},{"location":"v2/troubleshooting/monitoring-issues/#stale-metrics","title":"Stale Metrics","text":"

Severity: \ud83d\udfe2 Low

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_11","title":"Symptoms","text":"

Metric values not updating, showing old data.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_11","title":"Solutions","text":"

Solution 1: Check collection frequency

// Metrics only updated when scraped\nconst gauge = new Gauge({\n  name: 'cm_queue_size',\n  async collect() {\n    // This runs on every Prometheus scrape (every 15s)\n    const size = await getQueueSize();\n    this.set(size);\n  }\n});\n

Solution 2: Force metric update

// Update metric on event, not just scrape\neventEmitter.on('queueSizeChanged', (size) => {\n  queueSizeGauge.set(size);\n});\n

Solution 3: Check scrape interval

In configs/prometheus/prometheus.yml:

global:\n  scrape_interval: 15s  # Scrape every 15 seconds\n\n# Increase for more frequent updates\nglobal:\n  scrape_interval: 5s  # Scrape every 5 seconds\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_11","title":"Prevention","text":"
  • Appropriate intervals - Balance freshness vs overhead
  • Event-driven updates - Update on change, not just scrape
  • Cache expensive metrics - Don't query DB every scrape
  • Staleness markers - Set metrics to NaN when stale
"},{"location":"v2/troubleshooting/monitoring-issues/#performance-issues","title":"Performance Issues","text":""},{"location":"v2/troubleshooting/monitoring-issues/#high-memory-usage","title":"High Memory Usage","text":"

Severity: \ud83d\udfe0 High

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_12","title":"Symptoms","text":"

Prometheus container using excessive memory (multiple GB).

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_12","title":"Solutions","text":"

Solution 1: Reduce retention period

In docker-compose.yml:

prometheus:\n  command:\n    - '--config.file=/etc/prometheus/prometheus.yml'\n    - '--storage.tsdb.retention.time=7d'  # Reduce from 15d to 7d\n    - '--storage.tsdb.retention.size=10GB'  # Add size limit\n

Restart:

docker compose --profile monitoring restart prometheus\n

Solution 2: Reduce metric cardinality

// Bad - creates metric per user (thousands)\nnew Counter({\n  name: 'requests_by_user',\n  labelNames: ['userId']\n});\n\n// Good - creates metric per role (5)\nnew Counter({\n  name: 'requests_by_role',\n  labelNames: ['role']\n});\n

Solution 3: Drop unnecessary metrics

In configs/prometheus/prometheus.yml:

scrape_configs:\n  - job_name: 'api'\n    static_configs:\n      - targets: ['api:4000']\n    metric_relabel_configs:\n      # Drop metrics we don't use\n      - source_labels: [__name__]\n        regex: 'go_.*|process_.*'  # Drop Go/process metrics\n        action: drop\n

Solution 4: Increase memory limit

prometheus:\n  deploy:\n    resources:\n      limits:\n        memory: 4G  # Increase from 2G\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_12","title":"Prevention","text":"
  • Low cardinality - Avoid high-cardinality labels
  • Appropriate retention - 7-30 days is usually enough
  • Regular cleanup - Drop unused metrics
  • Monitor memory - Alert on high usage
"},{"location":"v2/troubleshooting/monitoring-issues/#slow-queries","title":"Slow Queries","text":"

Severity: \ud83d\udfe1 Medium

"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_13","title":"Symptoms","text":"

Grafana dashboards slow to load. Queries taking 10+ seconds.

"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_13","title":"Solutions","text":"

Solution 1: Optimize query

# Slow - calculates rate for all time\nrate(cm_requests_total[1y])\n\n# Fast - only last 5 minutes\nrate(cm_requests_total[5m])\n\n# Slow - many time series\nsum(rate(cm_requests_total[5m]))\n\n# Faster - aggregate before rate\nsum(increase(cm_requests_total[5m])) / 300\n

Solution 2: Use recording rules

In configs/prometheus/alerts.yml:

groups:\n  - name: recording_rules\n    interval: 30s\n    rules:\n      # Pre-calculate expensive query every 30s\n      - record: job:cm_request_rate:sum\n        expr: sum(rate(cm_requests_total[5m])) by (job)\n\n# Then use in dashboard:\n# job:cm_request_rate:sum  # Fast!\n

Solution 3: Reduce time range

In Grafana: - Change dashboard time range from \"Last 30 days\" to \"Last 24 hours\" - Queries are faster with less data

Solution 4: Increase Prometheus resources

prometheus:\n  deploy:\n    resources:\n      limits:\n        cpus: '2.0'  # More CPU for queries\n        memory: 4G\n
"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_13","title":"Prevention","text":"
  • Efficient queries - Keep queries simple
  • Recording rules - Pre-calculate expensive queries
  • Appropriate time ranges - Don't query months of data
  • Indexing - Prometheus auto-indexes, but cardinality affects performance
"},{"location":"v2/troubleshooting/monitoring-issues/#useful-commands","title":"Useful Commands","text":""},{"location":"v2/troubleshooting/monitoring-issues/#prometheus-operations","title":"Prometheus Operations","text":"
# Check targets\ncurl http://localhost:9090/api/v1/targets\n\n# Query metric\ncurl 'http://localhost:9090/api/v1/query?query=cm_api_uptime_seconds'\n\n# Query range\ncurl 'http://localhost:9090/api/v1/query_range?query=cm_api_uptime_seconds&start=2026-02-13T00:00:00Z&end=2026-02-13T23:59:59Z&step=15s'\n\n# Reload config\ndocker compose exec prometheus kill -HUP 1\n\n# Check config\ndocker compose exec prometheus promtool check config /etc/prometheus/prometheus.yml\n\n# Check rules\ndocker compose exec prometheus promtool check rules /etc/prometheus/alerts.yml\n
"},{"location":"v2/troubleshooting/monitoring-issues/#grafana-operations","title":"Grafana Operations","text":"
# Test datasource\ncurl http://admin:admin@localhost:3001/api/datasources/1/health\n\n# List dashboards\ncurl http://admin:admin@localhost:3001/api/search?type=dash-db\n\n# Export dashboard\ncurl http://admin:admin@localhost:3001/api/dashboards/uid/YOUR_UID | jq .dashboard > dashboard.json\n\n# Import dashboard\ncurl -X POST http://admin:admin@localhost:3001/api/dashboards/db \\\n  -H \"Content-Type: application/json\" \\\n  -d @dashboard.json\n
"},{"location":"v2/troubleshooting/monitoring-issues/#alertmanager-operations","title":"Alertmanager Operations","text":"
# Check alerts\ncurl http://localhost:9093/api/v1/alerts\n\n# Send test alert\ncurl -X POST http://localhost:9093/api/v1/alerts \\\n  -H 'Content-Type: application/json' \\\n  -d '[{\"labels\":{\"alertname\":\"Test\",\"severity\":\"critical\"},\"annotations\":{\"summary\":\"Test\"}}]'\n\n# List silences\ncurl http://localhost:9093/api/v1/silences\n\n# Create silence\ncurl -X POST http://localhost:9093/api/v1/silences \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"matchers\":[{\"name\":\"alertname\",\"value\":\"Test\"}],\"startsAt\":\"2026-02-13T00:00:00Z\",\"endsAt\":\"2026-02-14T00:00:00Z\",\"createdBy\":\"admin\",\"comment\":\"Test silence\"}'\n
"},{"location":"v2/troubleshooting/monitoring-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/monitoring-issues/#monitoring-documentation","title":"Monitoring Documentation","text":"
  • Monitoring Issues - This guide
  • Observability Dashboard - Using dashboard
  • Monitoring Guide - Setup and configuration
"},{"location":"v2/troubleshooting/monitoring-issues/#other-troubleshooting","title":"Other Troubleshooting","text":"
  • Common Errors - General errors
  • Performance Optimization - Performance tuning
"},{"location":"v2/troubleshooting/monitoring-issues/#external-resources","title":"External Resources","text":"
  • Prometheus Documentation
  • Grafana Documentation
  • Alertmanager Documentation
  • PromQL Tutorial

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/troubleshooting/performance-optimization/","title":"Performance Optimization","text":"

This guide covers performance tuning and optimization strategies for Changemaker Lite V2.

"},{"location":"v2/troubleshooting/performance-optimization/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/performance-optimization/#performance-areas","title":"Performance Areas","text":"
  1. Database - Query optimization, indexing, connection pooling
  2. API - Caching, rate limiting, pagination
  3. Frontend - Code splitting, lazy loading, bundling
  4. Docker - Resource limits, multi-stage builds
  5. Nginx - Compression, caching, keep-alive
  6. Email Queue - Worker count, batch processing
  7. Monitoring - Prometheus metrics, Grafana dashboards
"},{"location":"v2/troubleshooting/performance-optimization/#performance-metrics","title":"Performance Metrics","text":"

Target performance:

  • API response time: < 200ms (p95)
  • Database query time: < 50ms (p95)
  • Frontend load time: < 2s (initial)
  • Email sending: 100+ emails/minute
  • Concurrent users: 500+
"},{"location":"v2/troubleshooting/performance-optimization/#database-optimization","title":"Database Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#index-optimization","title":"Index Optimization","text":"

Find missing indexes:

-- Find tables without indexes\nSELECT schemaname, tablename, indexname\nFROM pg_indexes\nWHERE schemaname = 'public'\nORDER BY tablename;\n\n-- Find columns used in WHERE but not indexed\nSELECT *\nFROM pg_stat_user_tables\nWHERE schemaname = 'public'\n  AND seq_scan > 1000\n  AND seq_tup_read / seq_scan > 10000\nORDER BY seq_scan DESC;\n

Add indexes to frequently queried columns:

model Location {\n  id         String @id @default(uuid())\n  address    String\n  city       String\n  province   String\n  postalCode String\n  createdAt  DateTime @default(now())\n\n  // Add indexes for WHERE clauses\n  @@index([postalCode])  // WHERE postalCode = '...'\n  @@index([city])        // WHERE city = '...'\n  @@index([province])    // WHERE province = '...'\n  @@index([createdAt])   // ORDER BY createdAt\n\n  // Composite index for multi-column queries\n  @@index([province, city])  // WHERE province = '...' AND city = '...'\n}\n

Create migration:

docker compose exec api npx prisma migrate dev --name add_location_indexes\n

Verify index usage:

EXPLAIN ANALYZE\nSELECT * FROM \"Location\"\nWHERE \"postalCode\" = 'M5H 2N2';\n\n-- Should show:\n-- Index Scan using Location_postalCode_idx\n-- NOT: Seq Scan on \"Location\"\n
"},{"location":"v2/troubleshooting/performance-optimization/#query-optimization","title":"Query Optimization","text":"

Use select instead of fetching all fields:

// Bad - fetches all fields\nconst users = await prisma.user.findMany();\n// Returns: id, email, password, name, role, createdAt, updatedAt, ...\n\n// Good - only needed fields\nconst users = await prisma.user.findMany({\n  select: {\n    id: true,\n    email: true,\n    name: true,\n    role: true\n  }\n});\n

Use include instead of separate queries:

// Bad - N+1 queries\nconst campaigns = await prisma.campaign.findMany();\nfor (const campaign of campaigns) {\n  const emails = await prisma.campaignEmail.findMany({\n    where: { campaignId: campaign.id }\n  });\n  campaign.emails = emails;\n}\n\n// Good - single query with join\nconst campaigns = await prisma.campaign.findMany({\n  include: {\n    emails: true\n  }\n});\n

Paginate large result sets:

// Bad - fetch all\nconst locations = await prisma.location.findMany();\n// Returns 10,000+ rows\n\n// Good - paginate\nconst locations = await prisma.location.findMany({\n  take: 50,  // Limit\n  skip: page * 50,  // Offset\n  orderBy: { createdAt: 'desc' }\n});\n

Use aggregations efficiently:

// Bad - count all then filter\nconst allUsers = await prisma.user.findMany();\nconst activeCount = allUsers.filter(u => u.role !== 'TEMP').length;\n\n// Good - count in database\nconst activeCount = await prisma.user.count({\n  where: {\n    role: { not: 'TEMP' }\n  }\n});\n
"},{"location":"v2/troubleshooting/performance-optimization/#connection-pooling","title":"Connection Pooling","text":"

Configure pool size:

# In .env\nDATABASE_URL=\"postgresql://changemaker:password@v2-postgres:5432/changemaker_v2?connection_limit=20&pool_timeout=30\"\n\n# connection_limit: Max connections (default: 10)\n# pool_timeout: Max wait time in seconds (default: 10)\n

Recommended pool sizes:

  • Development: 5-10 connections
  • Production (1 API instance): 10-20 connections
  • Production (3 API instances): 5-10 per instance

Formula:

Total connections = (API instances \u00d7 pool size) + overhead\nOverhead = Prisma Studio (1) + other clients (5)\n\nExample:\n3 instances \u00d7 10 pool + 6 overhead = 36 connections\nSet PostgreSQL max_connections = 50 (1.4\u00d7 usage)\n

Monitor pool usage:

-- View active connections\nSELECT count(*), state\nFROM pg_stat_activity\nWHERE datname = 'changemaker_v2'\nGROUP BY state;\n\n-- Alert if nearing limit\nSELECT count(*) FROM pg_stat_activity WHERE datname = 'changemaker_v2';\n-- If > 80% of max_connections, increase limit or reduce pool size\n
"},{"location":"v2/troubleshooting/performance-optimization/#read-replicas","title":"Read Replicas","text":"

For read-heavy workloads, add read replicas:

# docker-compose.yml\nv2-postgres-read:\n  image: postgres:16-alpine\n  environment:\n    POSTGRES_DB: changemaker_v2\n    POSTGRES_USER: changemaker\n    POSTGRES_PASSWORD: ${V2_POSTGRES_PASSWORD}\n  command: postgres -c wal_level=replica -c max_wal_senders=3\n

Configure replication in Prisma:

// Use read replica for read queries\nconst readPrisma = new PrismaClient({\n  datasources: {\n    db: { url: process.env.READ_DATABASE_URL }\n  }\n});\n\n// Read from replica\nconst users = await readPrisma.user.findMany();\n\n// Write to primary\nconst user = await prisma.user.create({ data: { ... } });\n
"},{"location":"v2/troubleshooting/performance-optimization/#api-optimization","title":"API Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#caching-strategies","title":"Caching Strategies","text":"

Redis caching:

// Cache expensive operations\nimport { redis } from './config/redis';\n\nexport const getCampaigns = async () => {\n  // Check cache\n  const cacheKey = 'campaigns:all';\n  const cached = await redis.get(cacheKey);\n\n  if (cached) {\n    return JSON.parse(cached);\n  }\n\n  // Query database\n  const campaigns = await prisma.campaign.findMany({\n    include: { emails: true }\n  });\n\n  // Cache for 5 minutes\n  await redis.setex(cacheKey, 300, JSON.stringify(campaigns));\n\n  return campaigns;\n};\n

Invalidate cache on updates:

export const updateCampaign = async (id: string, data: any) => {\n  // Update database\n  const campaign = await prisma.campaign.update({\n    where: { id },\n    data\n  });\n\n  // Invalidate cache\n  await redis.del('campaigns:all');\n  await redis.del(`campaign:${id}`);\n\n  return campaign;\n};\n

Cache patterns:

  • Cache-aside: Check cache, fetch from DB if miss
  • Write-through: Update DB and cache simultaneously
  • Write-behind: Update cache, async update DB
  • TTL: Set expiration time (5min-1hour typical)
"},{"location":"v2/troubleshooting/performance-optimization/#rate-limiting","title":"Rate Limiting","text":"

Configure rate limits:

// In api/src/middleware/rate-limit.ts\nimport rateLimit from 'express-rate-limit';\n\n// General API\nexport const apiRateLimit = rateLimit({\n  windowMs: 60 * 1000,  // 1 minute\n  max: 100,  // 100 requests per minute\n  standardHeaders: true,\n  legacyHeaders: false,\n});\n\n// Auth endpoints (stricter)\nexport const authRateLimit = rateLimit({\n  windowMs: 60 * 1000,\n  max: 10,  // 10 requests per minute\n  message: 'Too many login attempts. Please try again later.'\n});\n\n// Public endpoints (more lenient)\nexport const publicRateLimit = rateLimit({\n  windowMs: 60 * 1000,\n  max: 200  // 200 requests per minute\n});\n

Apply to routes:

// In server.ts\napp.use('/api/auth', authRateLimit);\napp.use('/api', apiRateLimit);\napp.use('/public', publicRateLimit);\n
"},{"location":"v2/troubleshooting/performance-optimization/#pagination","title":"Pagination","text":"

Implement cursor-based pagination:

// api/src/modules/users/users.controller.ts\nexport const getUsers = async (req: Request, res: Response) => {\n  const { cursor, limit = 50 } = req.query;\n\n  const users = await prisma.user.findMany({\n    take: Number(limit) + 1,  // Fetch one extra to check if more\n    skip: cursor ? 1 : 0,\n    cursor: cursor ? { id: cursor as string } : undefined,\n    orderBy: { createdAt: 'desc' }\n  });\n\n  const hasMore = users.length > Number(limit);\n  if (hasMore) users.pop();  // Remove extra\n\n  res.json({\n    data: users,\n    cursor: hasMore ? users[users.length - 1].id : null,\n    hasMore\n  });\n};\n

Frontend pagination:

// admin/src/pages/UsersPage.tsx\nconst [users, setUsers] = useState([]);\nconst [cursor, setCursor] = useState<string | null>(null);\nconst [hasMore, setHasMore] = useState(true);\n\nconst loadMore = async () => {\n  const response = await api.get('/api/users', {\n    params: { cursor, limit: 50 }\n  });\n\n  setUsers([...users, ...response.data.data]);\n  setCursor(response.data.cursor);\n  setHasMore(response.data.hasMore);\n};\n
"},{"location":"v2/troubleshooting/performance-optimization/#response-compression","title":"Response Compression","text":"

Enable gzip compression:

// In server.ts\nimport compression from 'compression';\n\napp.use(compression({\n  level: 6,  // Compression level (0-9)\n  threshold: 1024  // Only compress responses > 1KB\n}));\n
"},{"location":"v2/troubleshooting/performance-optimization/#frontend-optimization","title":"Frontend Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#code-splitting","title":"Code Splitting","text":"

Route-based splitting:

// admin/src/App.tsx\nimport { lazy, Suspense } from 'react';\n\n// Lazy load pages\nconst UsersPage = lazy(() => import('./pages/UsersPage'));\nconst CampaignsPage = lazy(() => import('./pages/CampaignsPage'));\nconst LocationsPage = lazy(() => import('./pages/LocationsPage'));\n\nfunction App() {\n  return (\n    <Suspense fallback={<Spin />}>\n      <Routes>\n        <Route path=\"/app/users\" element={<UsersPage />} />\n        <Route path=\"/app/campaigns\" element={<CampaignsPage />} />\n        <Route path=\"/app/locations\" element={<LocationsPage />} />\n      </Routes>\n    </Suspense>\n  );\n}\n

Component splitting:

// Lazy load heavy components\nconst MapView = lazy(() => import('./components/MapView'));\n\nfunction Page() {\n  return (\n    <Suspense fallback={<Spin />}>\n      <MapView />\n    </Suspense>\n  );\n}\n
"},{"location":"v2/troubleshooting/performance-optimization/#lazy-loading","title":"Lazy Loading","text":"

Images:

<img\n  src={imageUrl}\n  loading=\"lazy\"  // Native lazy loading\n  alt=\"Description\"\n/>\n

Large libraries:

// Don't import large libs at top level\nimport dayjs from 'dayjs';  // \u274c Always loads\n\n// Import only when needed\nconst formatDate = async (date: Date) => {\n  const dayjs = (await import('dayjs')).default;  // \u2705 Loads on demand\n  return dayjs(date).format('YYYY-MM-DD');\n};\n
"},{"location":"v2/troubleshooting/performance-optimization/#bundle-optimization","title":"Bundle Optimization","text":"

Analyze bundle size:

cd admin\nnpm run build\nnpx vite-bundle-visualizer\n

Tree shaking:

// Import only what you need\nimport { Button } from 'antd';  // \u274c Imports all of antd\n\nimport Button from 'antd/es/button';  // \u2705 Only button\n

Configure Vite:

// admin/vite.config.ts\nexport default defineConfig({\n  build: {\n    rollupOptions: {\n      output: {\n        manualChunks: {\n          vendor: ['react', 'react-dom', 'react-router-dom'],\n          antd: ['antd'],\n          maps: ['leaflet', 'react-leaflet']\n        }\n      }\n    },\n    chunkSizeWarningLimit: 1000\n  }\n});\n
"},{"location":"v2/troubleshooting/performance-optimization/#memoization","title":"Memoization","text":"

React.memo for expensive components:

import { memo } from 'react';\n\nconst LocationMarker = memo(({ location }) => {\n  return (\n    <CircleMarker\n      center={[location.latitude, location.longitude]}\n      radius={8}\n    />\n  );\n}, (prev, next) => {\n  // Only re-render if location changed\n  return prev.location.id === next.location.id;\n});\n

useMemo for expensive calculations:

import { useMemo } from 'react';\n\nfunction MapView({ locations }) {\n  // Only recalculate when locations change\n  const bounds = useMemo(() => {\n    if (!locations.length) return null;\n    const coords = locations.map(l => [l.latitude, l.longitude]);\n    return L.latLngBounds(coords);\n  }, [locations]);\n\n  return <MapContainer bounds={bounds} />;\n}\n

useCallback for stable functions:

import { useCallback } from 'react';\n\nfunction Table({ data }) {\n  // Stable reference for row click handler\n  const handleRowClick = useCallback((row) => {\n    console.log('Clicked:', row.id);\n  }, []);\n\n  return <Table data={data} onRowClick={handleRowClick} />;\n}\n
"},{"location":"v2/troubleshooting/performance-optimization/#docker-optimization","title":"Docker Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#resource-limits","title":"Resource Limits","text":"
# docker-compose.yml\napi:\n  deploy:\n    resources:\n      limits:\n        cpus: '2.0'  # Max 2 CPU cores\n        memory: 4G   # Max 4GB RAM\n      reservations:\n        cpus: '0.5'  # Reserve 0.5 cores\n        memory: 1G   # Reserve 1GB\n

Monitor resource usage:

docker stats\n\n# Shows:\n# CONTAINER   CPU %   MEM USAGE / LIMIT   MEM %\n# api         15%     1.2GB / 4GB         30%\n
"},{"location":"v2/troubleshooting/performance-optimization/#multi-stage-builds","title":"Multi-Stage Builds","text":"

Optimize Dockerfile:

# Build stage\nFROM node:20-alpine AS builder\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --only=production\nCOPY . .\nRUN npm run build\n\n# Runtime stage (smaller)\nFROM node:20-alpine\nWORKDIR /app\nCOPY --from=builder /app/dist ./dist\nCOPY --from=builder /app/node_modules ./node_modules\nCOPY package*.json ./\n\nCMD [\"node\", \"dist/server.js\"]\n

Benefits:

  • Smaller final image (no build tools)
  • Faster deployment
  • Better security (fewer packages)
"},{"location":"v2/troubleshooting/performance-optimization/#volume-performance","title":"Volume Performance","text":"

Use cached volumes for dependencies:

api:\n  volumes:\n    - ./api:/app\n    - /app/node_modules  # Don't bind-mount node_modules\n    - api-build:/app/dist:cached  # Cache build output\n

For macOS/Windows:

api:\n  volumes:\n    - ./api:/app:cached  # Cached mode for better performance\n
"},{"location":"v2/troubleshooting/performance-optimization/#nginx-optimization","title":"Nginx Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#gzip-compression","title":"Gzip Compression","text":"
# nginx/nginx.conf\nhttp {\n  gzip on;\n  gzip_vary on;\n  gzip_min_length 1024;\n  gzip_comp_level 6;\n  gzip_types\n    text/plain\n    text/css\n    text/xml\n    text/javascript\n    application/json\n    application/javascript\n    application/xml+rss\n    application/atom+xml\n    image/svg+xml;\n}\n
"},{"location":"v2/troubleshooting/performance-optimization/#caching","title":"Caching","text":"

Static assets:

# nginx/conf.d/default.conf\nlocation ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {\n  expires 1y;\n  add_header Cache-Control \"public, immutable\";\n}\n

API responses:

location /api/ {\n  proxy_cache api_cache;\n  proxy_cache_valid 200 5m;  # Cache 200 responses for 5 minutes\n  proxy_cache_bypass $http_cache_control;  # Honor Cache-Control header\n  add_header X-Cache-Status $upstream_cache_status;\n\n  proxy_pass http://api:4000;\n}\n
"},{"location":"v2/troubleshooting/performance-optimization/#keep-alive","title":"Keep-Alive","text":"
# nginx/nginx.conf\nhttp {\n  keepalive_timeout 65;\n  keepalive_requests 100;\n\n  upstream api {\n    server api:4000;\n    keepalive 32;  # Keep 32 connections alive to backend\n  }\n}\n
"},{"location":"v2/troubleshooting/performance-optimization/#email-queue-optimization","title":"Email Queue Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#worker-concurrency","title":"Worker Concurrency","text":"

Increase parallel processing:

// api/src/services/email-queue.service.ts\nconst worker = new Worker('email-queue', emailProcessor, {\n  connection: redis,\n  concurrency: 5,  // Process 5 emails simultaneously\n  limiter: {\n    max: 50,  // Max 50 jobs per second\n    duration: 1000\n  }\n});\n

Recommended concurrency:

  • Development: 1-2
  • Production (low volume): 3-5
  • Production (high volume): 10-20
"},{"location":"v2/troubleshooting/performance-optimization/#batch-processing","title":"Batch Processing","text":"

Process emails in batches:

export const sendBulkEmails = async (emails: Email[]) => {\n  const batchSize = 100;\n\n  for (let i = 0; i < emails.length; i += batchSize) {\n    const batch = emails.slice(i, i + batchSize);\n\n    // Add batch to queue\n    await emailQueue.addBulk(\n      batch.map(email => ({\n        name: 'send-email',\n        data: email\n      }))\n    );\n  }\n};\n
"},{"location":"v2/troubleshooting/performance-optimization/#rate-limiting_1","title":"Rate Limiting","text":"

Respect SMTP provider limits:

const worker = new Worker('email-queue', emailProcessor, {\n  limiter: {\n    // Gmail: 500 emails/day (free), 2000/day (workspace)\n    max: 100,  // 100 emails per hour\n    duration: 3600 * 1000  // 1 hour\n  }\n});\n
"},{"location":"v2/troubleshooting/performance-optimization/#monitoring-performance","title":"Monitoring Performance","text":""},{"location":"v2/troubleshooting/performance-optimization/#prometheus-metrics","title":"Prometheus Metrics","text":"

Track response times:

import { Histogram } from 'prom-client';\n\nconst httpRequestDuration = new Histogram({\n  name: 'http_request_duration_seconds',\n  help: 'HTTP request duration in seconds',\n  labelNames: ['method', 'route', 'status'],\n  buckets: [0.01, 0.05, 0.1, 0.5, 1, 5]\n});\n\n// Middleware to track duration\napp.use((req, res, next) => {\n  const start = Date.now();\n\n  res.on('finish', () => {\n    const duration = (Date.now() - start) / 1000;\n    httpRequestDuration\n      .labels(req.method, req.route?.path || req.path, res.statusCode.toString())\n      .observe(duration);\n  });\n\n  next();\n});\n

Track query counts:

const dbQueries = new Counter({\n  name: 'cm_database_queries_total',\n  help: 'Total database queries',\n  labelNames: ['model', 'operation']\n});\n\n// In Prisma middleware\nprisma.$use(async (params, next) => {\n  dbQueries.labels(params.model, params.action).inc();\n  return next(params);\n});\n
"},{"location":"v2/troubleshooting/performance-optimization/#grafana-dashboards","title":"Grafana Dashboards","text":"

Create performance dashboard:

# API response time (p95)\nhistogram_quantile(0.95,\n  rate(http_request_duration_seconds_bucket[5m])\n)\n\n# Database query rate\nrate(cm_database_queries_total[5m])\n\n# Cache hit rate\nrate(cm_cache_hits_total[5m]) /\n(rate(cm_cache_hits_total[5m]) + rate(cm_cache_misses_total[5m]))\n
"},{"location":"v2/troubleshooting/performance-optimization/#slow-query-log","title":"Slow Query Log","text":"

Enable in PostgreSQL:

# docker-compose.yml\nv2-postgres:\n  command: postgres -c log_min_duration_statement=100\n  # Logs queries taking > 100ms\n

View slow queries:

docker compose logs v2-postgres | grep \"duration:\"\n\n# Output:\n# LOG:  duration: 523.456 ms  statement: SELECT * FROM \"Location\" WHERE ...\n
"},{"location":"v2/troubleshooting/performance-optimization/#load-testing","title":"Load Testing","text":""},{"location":"v2/troubleshooting/performance-optimization/#k6-load-testing","title":"k6 Load Testing","text":"

Install k6:

# macOS\nbrew install k6\n\n# Linux\nsudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69\necho \"deb https://dl.k6.io/deb stable main\" | sudo tee /etc/apt/sources.list.d/k6.list\nsudo apt-get update\nsudo apt-get install k6\n

Create test script:

// load-test.js\nimport http from 'k6/http';\nimport { check, sleep } from 'k6';\n\nexport const options = {\n  stages: [\n    { duration: '2m', target: 100 },  // Ramp up to 100 users\n    { duration: '5m', target: 100 },  // Stay at 100 users\n    { duration: '2m', target: 0 },    // Ramp down\n  ],\n  thresholds: {\n    http_req_duration: ['p(95)<500'],  // 95% of requests < 500ms\n  },\n};\n\nexport default function () {\n  // Test login\n  const loginRes = http.post('http://localhost:4000/api/auth/login', {\n    email: 'admin@example.com',\n    password: 'Admin123!',\n  });\n  check(loginRes, { 'login succeeded': (r) => r.status === 200 });\n\n  const token = loginRes.json('accessToken');\n\n  // Test API endpoints\n  const headers = { Authorization: `Bearer ${token}` };\n\n  const campaignsRes = http.get('http://localhost:4000/api/campaigns', { headers });\n  check(campaignsRes, { 'campaigns loaded': (r) => r.status === 200 });\n\n  const locationsRes = http.get('http://localhost:4000/api/map/locations', { headers });\n  check(locationsRes, { 'locations loaded': (r) => r.status === 200 });\n\n  sleep(1);\n}\n

Run test:

k6 run load-test.js\n

Interpret results:

     \u2713 login succeeded\n     \u2713 campaigns loaded\n     \u2713 locations loaded\n\n     checks.........................: 100.00%\n     data_received..................: 8.2 MB\n     data_sent......................: 1.1 MB\n     http_req_duration..............: avg=145ms  min=12ms  med=89ms  max=2.1s  p(95)=423ms\n     http_reqs......................: 12450\n     vus............................: 100\n     vus_max........................: 100\n
"},{"location":"v2/troubleshooting/performance-optimization/#apache-bench","title":"Apache Bench","text":"

Quick load test:

# 1000 requests, 10 concurrent\nab -n 1000 -c 10 http://localhost:4000/api/health\n\n# With authentication\nab -n 1000 -c 10 -H \"Authorization: Bearer TOKEN\" http://localhost:4000/api/campaigns\n
"},{"location":"v2/troubleshooting/performance-optimization/#performance-checklist","title":"Performance Checklist","text":""},{"location":"v2/troubleshooting/performance-optimization/#database","title":"Database","text":"
  • Indexes on frequently queried columns
  • Composite indexes for multi-column queries
  • Connection pool sized appropriately
  • Slow query log enabled
  • VACUUM run regularly (auto by default)
  • Read replicas for read-heavy loads
"},{"location":"v2/troubleshooting/performance-optimization/#api","title":"API","text":"
  • Redis caching for expensive operations
  • Rate limiting on all endpoints
  • Pagination on list endpoints
  • Response compression enabled
  • N+1 queries eliminated
  • Select only needed fields
"},{"location":"v2/troubleshooting/performance-optimization/#frontend","title":"Frontend","text":"
  • Route-based code splitting
  • Lazy loading for heavy components
  • Images optimized and lazy-loaded
  • Bundle size < 500KB (gzipped)
  • React.memo for expensive components
  • useCallback/useMemo for stable references
"},{"location":"v2/troubleshooting/performance-optimization/#docker","title":"Docker","text":"
  • Multi-stage builds
  • Resource limits set
  • Health checks configured
  • Volumes optimized
  • Images use Alpine base
"},{"location":"v2/troubleshooting/performance-optimization/#nginx","title":"Nginx","text":"
  • Gzip compression enabled
  • Static asset caching (1 year)
  • Keep-alive connections
  • Worker processes = CPU cores
  • Access logs rotated
"},{"location":"v2/troubleshooting/performance-optimization/#email-queue","title":"Email Queue","text":"
  • Worker concurrency optimized
  • Rate limiting respects SMTP limits
  • Batch processing for bulk sends
  • Failed jobs retry with backoff
  • Queue size monitored
"},{"location":"v2/troubleshooting/performance-optimization/#monitoring","title":"Monitoring","text":"
  • Prometheus metrics collected
  • Grafana dashboards created
  • Alerts configured
  • Slow queries logged
  • Resource usage tracked
"},{"location":"v2/troubleshooting/performance-optimization/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/performance-optimization/#performance-documentation","title":"Performance Documentation","text":"
  • Performance Optimization - This guide
  • Monitoring Issues - Observability troubleshooting
  • Database Issues - Database troubleshooting
"},{"location":"v2/troubleshooting/performance-optimization/#other-guides","title":"Other Guides","text":"
  • Architecture Overview - System design
  • Deployment Guide - Production setup
  • Monitoring Guide - Monitoring setup
"},{"location":"v2/troubleshooting/performance-optimization/#external-resources","title":"External Resources","text":"
  • PostgreSQL Performance Tips
  • Prisma Performance Guide
  • React Performance
  • Vite Performance

Last Updated: February 2026 Version: V2.0 Status: Complete

"},{"location":"v2/user-guides/","title":"User Guides","text":"

This section provides step-by-step guides for different user roles and common tasks. Each guide is tailored to specific workflows and responsibilities.

"},{"location":"v2/user-guides/#role-based-guides","title":"Role-Based Guides","text":""},{"location":"v2/user-guides/#admin-guide","title":"Admin Guide","text":"

For system administrators and site managers:

  • Initial setup and configuration
  • User management
  • Site settings
  • Service integration
  • Monitoring and maintenance
  • Security best practices

Target Audience: SUPER_ADMIN role

"},{"location":"v2/user-guides/#campaign-manager-guide","title":"Campaign Manager Guide","text":"

For advocacy campaign coordinators:

  • Creating campaigns
  • Managing representatives
  • Email template design
  • Response moderation
  • Campaign analytics
  • Email queue monitoring

Target Audience: INFLUENCE_ADMIN role

"},{"location":"v2/user-guides/#map-organizer-guide","title":"Map Organizer Guide","text":"

For field organizing coordinators:

  • Location management
  • Importing data (CSV, NAR)
  • Creating geographic cuts
  • Scheduling volunteer shifts
  • Monitoring canvassing progress
  • Printing walk sheets

Target Audience: MAP_ADMIN role

"},{"location":"v2/user-guides/#volunteer-guide","title":"Volunteer Guide","text":"

For field canvassers:

  • Viewing shift assignments
  • Starting canvass session
  • Using GPS map
  • Recording visit outcomes
  • Tracking personal activity
  • Best practices for canvassing

Target Audience: USER role

"},{"location":"v2/user-guides/#content-editor-guide","title":"Content Editor Guide","text":"

For content creators:

  • Creating landing pages
  • Using GrapesJS editor
  • Email template creation
  • Managing media library
  • Publishing content
  • SEO best practices

Target Audience: SUPER_ADMIN role

"},{"location":"v2/user-guides/#common-tasks","title":"Common Tasks","text":""},{"location":"v2/user-guides/#getting-started","title":"Getting Started","text":"
  1. First Login
  2. Navigate to http://your-domain.com or http://localhost:3000
  3. Login with credentials
  4. Change default password
  5. Explore dashboard

  6. User Role Redirection

  7. Admin roles \u2192 /app/dashboard
  8. User/volunteer roles \u2192 /volunteer/dashboard
"},{"location":"v2/user-guides/#campaign-workflow","title":"Campaign Workflow","text":"
  1. Create Campaign
  2. Navigate to /app/influence/campaigns
  3. Click \"New Campaign\"
  4. Fill in details
  5. Save campaign

  6. Design Email Template

  7. Set email subject
  8. Write email body
  9. Use variable placeholders
  10. Preview template

  11. Launch Campaign

  12. Set to published
  13. Share public URL
  14. Monitor responses
"},{"location":"v2/user-guides/#location-workflow","title":"Location Workflow","text":"
  1. Import Locations
  2. Prepare CSV file
  3. Navigate to /app/map/locations
  4. Click \"Import CSV\"
  5. Map columns
  6. Import data

  7. Geocode Addresses

  8. Select ungeocode locations
  9. Click \"Geocode Selected\"
  10. Monitor progress
  11. Review quality metrics

  12. Create Geographic Cuts

  13. Navigate to /app/map/cuts
  14. Click \"Draw on Map\"
  15. Draw polygon
  16. Save cut
  17. Assign locations
"},{"location":"v2/user-guides/#volunteer-canvassing-workflow","title":"Volunteer Canvassing Workflow","text":"
  1. View Assignments
  2. Login as volunteer
  3. Navigate to /volunteer/assignments
  4. View upcoming shifts

  5. Start Canvassing

  6. Click \"Start Canvass\"
  7. Grant GPS permissions
  8. Follow walking route
  9. Visit locations

  10. Record Visits

  11. Click location marker
  12. Select outcome
  13. Add notes
  14. Submit

  15. End Session

  16. Click \"End Session\"
  17. Review statistics
  18. View in activity history
"},{"location":"v2/user-guides/#task-guides","title":"Task Guides","text":""},{"location":"v2/user-guides/#import-canadian-electoral-data-nar","title":"Import Canadian Electoral Data (NAR)","text":"
  1. Prepare Data
  2. Download NAR 2025 data
  3. Place in /data directory
  4. Ensure Address + Location files present

  5. Import via Admin

  6. Navigate to /app/map/locations
  7. Click \"Import NAR\"
  8. Select province
  9. Apply filters
  10. Start import

  11. Review Import

  12. Check location count
  13. Verify geocoding
  14. Review quality dashboard
"},{"location":"v2/user-guides/#set-up-public-campaign-page","title":"Set Up Public Campaign Page","text":"
  1. Create Campaign
  2. Configure targeting (federal/provincial)
  3. Write email template
  4. Set to published

  5. Share URL

  6. Copy public URL: /campaigns/:id
  7. Share on social media
  8. Embed in website

  9. Monitor Engagement

  10. View email statistics
  11. Moderate responses
  12. Check response wall
"},{"location":"v2/user-guides/#configure-newsletter-sync","title":"Configure Newsletter Sync","text":"
  1. Enable Listmonk
  2. Set LISTMONK_SYNC_ENABLED=true
  3. Configure API credentials
  4. Restart services

  5. Initialize Sync

  6. Navigate to /app/services/listmonk
  7. Click \"Test Connection\"
  8. Click \"Sync Participants\"

  9. Manage Lists

  10. View list statistics
  11. Configure sync settings
  12. Monitor sync status
"},{"location":"v2/user-guides/#set-up-public-tunnel","title":"Set Up Public Tunnel","text":"
  1. Create Pangolin Account
  2. Sign up at pangolin.bnkserve.org
  3. Generate API key

  4. Configure Tunnel

  5. Navigate to /app/services/pangolin
  6. Enter API key
  7. Follow setup wizard
  8. Deploy Newt container

  9. Test Public Access

  10. Visit public URL
  11. Verify subdomain routing
  12. Check SSL/TLS
"},{"location":"v2/user-guides/#create-landing-page","title":"Create Landing Page","text":"
  1. Start New Page
  2. Navigate to /app/pages
  3. Click \"New Page\"
  4. Enter title and slug

  5. Design Page

  6. Click \"Edit\"
  7. Use GrapesJS editor
  8. Drag blocks
  9. Customize content
  10. Save (Ctrl+S)

  11. Publish

  12. Set to published
  13. View at /p/:slug
  14. Share URL
"},{"location":"v2/user-guides/#best-practices","title":"Best Practices","text":""},{"location":"v2/user-guides/#campaign-management","title":"Campaign Management","text":"
  • Use clear, action-oriented language
  • Test email templates before launch
  • Monitor response rates
  • Moderate responses promptly
  • Follow up with engaged supporters
"},{"location":"v2/user-guides/#field-organizing","title":"Field Organizing","text":"
  • Clean location data before import
  • Create manageable cut sizes (100-200 locations)
  • Assign volunteers to familiar areas
  • Print walk sheets in advance
  • Review canvass progress daily
"},{"location":"v2/user-guides/#content-creation","title":"Content Creation","text":"
  • Write mobile-responsive pages
  • Use SEO-friendly titles and descriptions
  • Test pages on multiple devices
  • Keep content concise
  • Include clear calls-to-action
"},{"location":"v2/user-guides/#system-administration","title":"System Administration","text":"
  • Change default passwords immediately
  • Enable monitoring stack
  • Set up automated backups
  • Review security audit findings
  • Keep services updated
"},{"location":"v2/user-guides/#mobile-usage","title":"Mobile Usage","text":""},{"location":"v2/user-guides/#volunteer-canvassing","title":"Volunteer Canvassing","text":"

Best on mobile devices:

  • Full-screen map
  • GPS tracking
  • Touch-friendly controls
  • Offline support (future)
"},{"location":"v2/user-guides/#admin-tasks","title":"Admin Tasks","text":"

Best on desktop:

  • Content editing (GrapesJS, email templates)
  • Data import/export
  • Configuration
  • Monitoring dashboards
"},{"location":"v2/user-guides/#keyboard-shortcuts","title":"Keyboard Shortcuts","text":""},{"location":"v2/user-guides/#page-editor","title":"Page Editor","text":"
  • Ctrl+S - Save page
  • Ctrl+Z - Undo
  • Ctrl+Y - Redo
"},{"location":"v2/user-guides/#general","title":"General","text":"
  • / - Focus search (tables)
  • Esc - Close modal/drawer
"},{"location":"v2/user-guides/#related-documentation","title":"Related Documentation","text":"
  • Admin Guide
  • Campaign Manager Guide
  • Map Organizer Guide
  • Volunteer Guide
  • Content Editor Guide
  • Features Overview
  • Troubleshooting
"},{"location":"v2/user-guides/admin-guide/","title":"Administrator Guide","text":""},{"location":"v2/user-guides/admin-guide/#overview","title":"Overview","text":"

The Administrator role is the highest-level role in Changemaker Lite. As an administrator, you have complete control over the platform, including:

  • User management: Create, edit, suspend, and delete user accounts
  • Campaign oversight: Manage all advocacy campaigns and moderate responses
  • Location and mapping: Import locations, create territorial cuts, and organize canvassing efforts
  • Volunteer coordination: Create shifts, manage signups, and monitor canvassing activity
  • Site configuration: Configure global settings, themes, email, and feature toggles
  • Content management: Create landing pages, edit email templates, and manage media library
  • Monitoring: View queue status, geocoding quality, and system health

This guide will walk you through all administrative functions in Changemaker Lite V2.

"},{"location":"v2/user-guides/admin-guide/#getting-started","title":"Getting Started","text":""},{"location":"v2/user-guides/admin-guide/#first-login","title":"First Login","text":"

When you first access Changemaker Lite, you'll log in at the admin portal URL:

https://app.cmlite.org\n

Or if running locally:

http://localhost:3000\n

Default credentials (change immediately after first login):

  • Email: admin@example.com
  • Password: Admin123!

Security Critical

The default password is publicly known. Change it immediately after your first login to prevent unauthorized access.

Screenshot placeholder: Login page showing email/password fields and \"Remember me\" checkbox

"},{"location":"v2/user-guides/admin-guide/#dashboard-overview","title":"Dashboard Overview","text":"

After logging in, you'll see the Administrator Dashboard, which provides an at-a-glance overview of your platform:

Key dashboard sections:

  1. Statistics Cards
  2. Total users (breakdown by role)
  3. Active campaigns
  4. Total locations
  5. Active canvass sessions

  6. Recent Activity Feed

  7. New user registrations
  8. Campaign responses
  9. Shift signups
  10. Canvass visits

  11. Quick Actions

  12. Create new campaign
  13. Import locations
  14. Create volunteer shift
  15. View email queue

  16. System Health

  17. API status
  18. Database connectivity
  19. Redis cache status
  20. Queue worker status

Screenshot placeholder: Dashboard showing statistics cards, activity feed, and quick action buttons

"},{"location":"v2/user-guides/admin-guide/#changing-your-password","title":"Changing Your Password","text":"

Required First Step

You must change the default password before performing any other administrative tasks.

To change your password:

  1. Click your email address in the top-right corner
  2. Select \"Change Password\" from the dropdown
  3. Enter your current password
  4. Enter new password (must meet requirements below)
  5. Confirm new password
  6. Click \"Update Password\"

Password requirements:

  • Minimum 12 characters
  • At least one uppercase letter (A-Z)
  • At least one lowercase letter (a-z)
  • At least one digit (0-9)
  • Cannot reuse recent passwords

Screenshot placeholder: Change password modal showing current/new password fields and requirements checklist

"},{"location":"v2/user-guides/admin-guide/#navigating-the-admin-interface","title":"Navigating the Admin Interface","text":"

The admin interface uses a sidebar navigation with the following sections:

Main Navigation:

  • Dashboard \u2014 Overview and quick actions
  • Influence \u2014 Campaigns, responses, representatives, email queue
  • Map \u2014 Locations, cuts, shifts, map settings, data quality
  • Canvass \u2014 Dashboard, sessions, activity reports
  • Content \u2014 Landing pages, email templates, media library
  • Services \u2014 Listmonk, Pangolin, docs, integrations
  • Observability \u2014 Monitoring, metrics, alerts
  • Users \u2014 User management
  • Settings \u2014 Global site settings

Screenshot placeholder: Sidebar navigation showing expanded Influence and Map sections

"},{"location":"v2/user-guides/admin-guide/#user-management","title":"User Management","text":""},{"location":"v2/user-guides/admin-guide/#creating-users","title":"Creating Users","text":"

To create a new user:

  1. Navigate to Users in the sidebar
  2. Click \"Create User\" button (top-right)
  3. Fill in user details:
  4. Email: User's email address (must be unique)
  5. Name: User's full name
  6. Password: Temporary password (user should change on first login)
  7. Role: Select from dropdown (see roles below)
  8. Status: ACTIVE or SUSPENDED
  9. Click \"Create\"

The new user will receive a welcome email (if email is configured) with their login credentials.

Screenshot placeholder: Create User modal showing email, name, password, role dropdown, and status toggle

"},{"location":"v2/user-guides/admin-guide/#understanding-roles","title":"Understanding Roles","text":"

Changemaker Lite has five user roles with different permission levels:

"},{"location":"v2/user-guides/admin-guide/#1-super_admin-you","title":"1. SUPER_ADMIN (You)","text":"
  • Access: Everything
  • Capabilities: All administrative functions, user management, site configuration
  • Use case: Primary administrator(s)
"},{"location":"v2/user-guides/admin-guide/#2-influence_admin","title":"2. INFLUENCE_ADMIN","text":"
  • Access: Influence module only
  • Capabilities:
  • Create and manage campaigns
  • Moderate responses
  • View representative cache
  • Monitor email queue
  • Restrictions: Cannot manage users, locations, or site settings
  • Use case: Campaign managers who don't need full admin access
"},{"location":"v2/user-guides/admin-guide/#3-map_admin","title":"3. MAP_ADMIN","text":"
  • Access: Map module only
  • Capabilities:
  • Import and manage locations
  • Create cuts
  • Organize shifts
  • Monitor canvassing
  • Restrictions: Cannot manage users, campaigns, or site settings
  • Use case: Field organizers, volunteer coordinators
"},{"location":"v2/user-guides/admin-guide/#4-user","title":"4. USER","text":"
  • Access: Volunteer portal only
  • Capabilities:
  • View assigned shifts
  • Start canvassing sessions
  • Record door visits
  • View own activity
  • Restrictions: Cannot access admin areas
  • Use case: Regular volunteers
"},{"location":"v2/user-guides/admin-guide/#5-temp","title":"5. TEMP","text":"
  • Access: Very limited, volunteer portal only
  • Capabilities:
  • Sign up for public shifts (creates TEMP account automatically)
  • Cannot start canvassing sessions
  • Restrictions: Cannot access most features until upgraded to USER
  • Use case: Anonymous shift signups (converted to USER by admin)

Role Upgrading

You can upgrade TEMP users to USER role to give them full volunteer access. This is common after a volunteer attends their first shift.

Screenshot placeholder: User list table showing users with different roles and color-coded role badges

"},{"location":"v2/user-guides/admin-guide/#managing-existing-users","title":"Managing Existing Users","text":"

The Users page shows all user accounts in a searchable, filterable table.

Table columns:

  • Name \u2014 User's full name
  • Email \u2014 Login email
  • Role \u2014 Current role (color-coded badge)
  • Status \u2014 ACTIVE (green) or SUSPENDED (red)
  • Last Login \u2014 Most recent login timestamp
  • Created \u2014 Account creation date
  • Actions \u2014 Edit, suspend/activate, delete

Available filters:

  • Search: Search by name or email
  • Role filter: Show only specific roles
  • Status filter: Active, suspended, or all
  • Date range: Filter by creation date

Screenshot placeholder: Users table with search bar, role filter dropdown, and action buttons

"},{"location":"v2/user-guides/admin-guide/#editing-users","title":"Editing Users","text":"

To edit a user:

  1. Click the Edit icon (pencil) in the Actions column
  2. Modify any of:
  3. Name
  4. Email (must remain unique)
  5. Role (change permissions)
  6. Status (activate/suspend)
  7. Click \"Save\"

Email Changes

Changing a user's email will require them to log in with the new email address. Notify them before making this change.

"},{"location":"v2/user-guides/admin-guide/#suspending-users","title":"Suspending Users","text":"

To temporarily disable a user account:

  1. Find the user in the table
  2. Click \"Suspend\" in the Actions column
  3. Confirm suspension

Suspended users:

  • Cannot log in
  • Existing sessions are invalidated immediately
  • Can be reactivated at any time
  • Data and history are preserved

When to suspend:

  • Volunteer is temporarily unavailable
  • Security concerns (investigate before deleting)
  • User requests account pause

Screenshot placeholder: Suspend confirmation dialog explaining effects

"},{"location":"v2/user-guides/admin-guide/#password-resets","title":"Password Resets","text":"

To reset a user's password:

  1. Edit the user
  2. Click \"Reset Password\"
  3. Choose one of:
  4. Generate temporary password (shown on screen, expires in 24 hours)
  5. Send reset email (user clicks link to set new password)
  6. Provide temporary password to user securely (not via email)

Security Best Practice

Always use \"Send reset email\" option when possible. Only generate temporary passwords for in-person support scenarios.

"},{"location":"v2/user-guides/admin-guide/#deleting-users","title":"Deleting Users","text":"

Permanent Action

Deleting a user is permanent and cannot be undone. All associated data (canvass visits, responses, etc.) will be anonymized.

To delete a user:

  1. Click the Delete icon (trash) in the Actions column
  2. Type the user's email to confirm
  3. Click \"Delete Permanently\"

When deletion is appropriate:

  • Duplicate accounts
  • Test accounts in production
  • User requests account deletion (GDPR compliance)

Data handling on deletion:

  • User account record is deleted
  • Associated content (responses, visits) remains but user reference is nullified
  • Email queue jobs remain (email address is preserved for audit)
"},{"location":"v2/user-guides/admin-guide/#viewing-login-activity","title":"Viewing Login Activity","text":"

To see recent login activity:

  1. Navigate to Users
  2. Check the \"Last Login\" column
  3. Click on a user to see detailed login history (if audit logging is enabled)

Screenshot placeholder: User detail view showing login history table with timestamps and IP addresses

"},{"location":"v2/user-guides/admin-guide/#campaign-management","title":"Campaign Management","text":""},{"location":"v2/user-guides/admin-guide/#campaign-overview","title":"Campaign Overview","text":"

Campaigns are at the heart of the Influence module. A campaign allows citizens to:

  1. Enter their postal code
  2. Find their elected representatives
  3. Send advocacy emails
  4. Share their story on a public response wall

As an administrator, you can create, configure, publish, and monitor campaigns.

"},{"location":"v2/user-guides/admin-guide/#creating-a-campaign","title":"Creating a Campaign","text":"

To create a new campaign:

  1. Navigate to Influence > Campaigns
  2. Click \"Create Campaign\" (top-right)
  3. Fill in the campaign form (see fields below)
  4. Click \"Create\"

Required fields:

Basic Information:

  • Title: Campaign name (shown to public)
  • Example: \"Protect Our Climate\"
  • Slug: URL-friendly identifier (auto-generated from title)
  • Example: protect-our-climate
  • Used in public URL: /campaigns/protect-our-climate
  • Description: Campaign overview (supports HTML)
  • Shown on campaign listing page
  • Recommended: 2-3 sentences

Email Configuration:

  • Email Subject: Subject line for advocacy emails
  • Example: \"Please support climate action legislation\"
  • Variables supported: {{USER_NAME}}, {{REP_NAME}}
  • Email Body: The email message citizens send
  • HTML editor available
  • Variables: {{USER_NAME}}, {{USER_EMAIL}}, {{REP_NAME}}, {{REP_EMAIL}}, {{USER_MESSAGE}}
  • Preview before publishing

Targeting:

  • Government Level: FEDERAL, PROVINCIAL, or MUNICIPAL
  • Determines which representatives are looked up
  • Can select multiple levels

Screenshot placeholder: Create Campaign form showing title, slug, description, email subject, and body editor

"},{"location":"v2/user-guides/admin-guide/#understanding-feature-flags","title":"Understanding Feature Flags","text":"

Campaigns have 12 feature flags that control functionality:

"},{"location":"v2/user-guides/admin-guide/#core-features","title":"Core Features","text":"
  1. Published
  2. Controls public visibility
  3. Unpublished campaigns only visible to admins
  4. Toggle to launch/pause campaign

  5. Featured

  6. Featured campaigns appear at top of listing page
  7. Use for high-priority campaigns
  8. Limit to 2-3 featured campaigns

  9. Has Response Wall

  10. Enables public response wall
  11. Citizens can share their story after emailing
  12. Responses require admin approval (unless auto_approve_responses)

  13. Collect Phone Numbers

  14. Adds optional phone number field
  15. Used for call-in campaigns
  16. Numbers stored for admin use

  17. Track Calls

  18. Adds \"I called my representative\" button
  19. Tracks call attempts separately from emails
  20. Good for blended campaigns
"},{"location":"v2/user-guides/admin-guide/#advanced-features","title":"Advanced Features","text":"
  1. Require Verification
  2. Sends verification email before submitting
  3. Prevents spam and bot submissions
  4. Recommended for public campaigns

  5. Auto Approve Responses

  6. Response wall submissions appear immediately
  7. No admin moderation required
  8. Only use for trusted campaigns

  9. Allow Anonymous

  10. Citizens can submit without creating account
  11. Reduces friction but limits tracking
  12. Good for privacy-sensitive topics

  13. Custom Recipients

  14. Override representative lookup
  15. Send to specific email addresses
  16. Use for non-government campaigns

  17. Show Progress Bar

    • Displays email count goal and progress
    • Motivates participation
    • Requires setting email_goal field
  18. Disable After Date

    • Automatically unpublish after specified date
    • Good for time-sensitive campaigns
    • Requires setting disable_date field
  19. Enable Comments

    • Allow comments on response wall entries
    • Creates discussion threads
    • Requires moderation

Screenshot placeholder: Campaign feature flags showing toggles for all 12 flags with descriptive labels

Recommended Defaults

For most campaigns, enable: Published, Has Response Wall, Require Verification. Leave others off unless specifically needed.

"},{"location":"v2/user-guides/admin-guide/#configuring-email-template","title":"Configuring Email Template","text":"

The email template is what citizens send to their representatives. Make it:

Effective email guidelines:

  • Personal: Use variables like {{USER_NAME}} to personalize
  • Clear: State the ask in first paragraph
  • Specific: Reference specific legislation or issue
  • Respectful: Professional tone, even if issue is urgent
  • Actionable: Tell representatives exactly what you want them to do

Example template:

Subject: Please vote YES on Bill C-123 for climate action\n\nDear {{REP_NAME}},\n\nMy name is {{USER_NAME}}, and I am a constituent in your riding. I'm writing to urge you to vote YES on Bill C-123, the Climate Action Framework.\n\nClimate change is the defining issue of our generation. This bill provides a realistic pathway to reduce emissions while protecting jobs and supporting workers.\n\nI'm specifically asking you to:\n1. Vote YES on Bill C-123 when it comes to the floor\n2. Speak publicly in support of climate action\n3. Oppose any amendments that weaken the bill\n\nThank you for considering my views. I look forward to your response.\n\nSincerely,\n{{USER_NAME}}\n{{USER_EMAIL}}\n\n---\n{{USER_MESSAGE}}\n

Available variables:

  • {{USER_NAME}} \u2014 Citizen's full name
  • {{USER_EMAIL}} \u2014 Citizen's email address
  • {{USER_PHONE}} \u2014 Citizen's phone (if collected)
  • {{REP_NAME}} \u2014 Representative's name
  • {{REP_EMAIL}} \u2014 Representative's email
  • {{REP_TITLE}} \u2014 Representative's title (MP, MPP, Councillor)
  • {{USER_MESSAGE}} \u2014 Custom message from citizen (optional field)

Screenshot placeholder: Email template editor showing subject and body fields with variable insertion dropdown

"},{"location":"v2/user-guides/admin-guide/#publishing-a-campaign","title":"Publishing a Campaign","text":"

Before publishing, verify:

  • Email template is proofread (send test email to yourself)
  • Feature flags are configured correctly
  • Representative lookup is working (test with your postal code)
  • Response wall moderation is ready (if enabled)

To publish:

  1. Edit the campaign
  2. Toggle \"Published\" flag to ON
  3. Click \"Save\"

The campaign is now live at /campaigns/[slug].

Promoting your campaign:

  • Share direct link: https://yourdomain.org/campaigns/protect-our-climate
  • Embed in email newsletter
  • Post on social media
  • Add to landing page

Screenshot placeholder: Published campaign card on public campaigns listing page

"},{"location":"v2/user-guides/admin-guide/#monitoring-email-sends","title":"Monitoring Email Sends","text":"

To view email statistics:

  1. Navigate to Influence > Campaigns
  2. Click \"Emails\" button in the Actions column for your campaign

The Campaign Emails drawer shows:

Statistics:

  • Total emails sent
  • Successful deliveries
  • Failed deliveries
  • Emails waiting in queue

Email list table:

  • Recipient name and email
  • Status (PENDING, SENT, FAILED)
  • Sent timestamp
  • Representative targeted
  • Error message (if failed)

Actions:

  • Retry failed: Re-queue failed emails
  • Export CSV: Download full email list

Screenshot placeholder: Campaign Emails drawer showing statistics cards and email list table

"},{"location":"v2/user-guides/admin-guide/#managing-the-email-queue","title":"Managing the Email Queue","text":"

The email queue processes advocacy emails asynchronously using BullMQ.

To monitor queue health:

  1. Navigate to Influence > Email Queue

Queue statistics:

  • Waiting: Emails queued but not yet processing
  • Active: Emails currently being sent
  • Completed: Successfully sent emails (last 24 hours)
  • Failed: Failed emails requiring retry
  • Delayed: Scheduled for future sending

Queue controls:

  • Pause Queue: Stop processing new emails (emergencies only)
  • Resume Queue: Restart after pause
  • Clean Completed: Remove old completed jobs (frees memory)
  • Retry Failed: Re-queue all failed emails

Queue Pausing

Only pause the queue during system maintenance or if email configuration is broken. Citizens expect immediate sends.

Screenshot placeholder: Email Queue page showing statistics cards, job counts, and control buttons

"},{"location":"v2/user-guides/admin-guide/#moderating-responses","title":"Moderating Responses","text":"

If your campaign has \"Has Response Wall\" enabled, citizens can share their stories publicly.

To moderate responses:

  1. Navigate to Influence > Responses
  2. Use filters to find pending responses
  3. Review each response
  4. Approve or reject

Response filters:

  • Campaign: Filter by specific campaign
  • Status: PENDING, APPROVED, REJECTED
  • Search: Search response text
  • Date range: Filter by submission date

Response table columns:

  • Name: Citizen's name
  • Campaign: Which campaign
  • Status: Approval status (color-coded)
  • Upvotes: Number of upvotes received
  • Submitted: Submission date
  • Actions: View, approve, reject, delete

Screenshot placeholder: Responses table with filter controls and status badges

To review a response:

  1. Click \"View\" in Actions column
  2. Read full response text
  3. Decide:
  4. Approve: Make public (appears on response wall)
  5. Reject: Hide from public (not deleted)
  6. Delete: Permanently remove

Moderation guidelines:

Approve responses that:

  • Are authentic personal stories
  • Relate to the campaign issue
  • Use respectful language
  • Add value to the public conversation

Reject responses that:

  • Contain profanity or hate speech
  • Are spam or off-topic
  • Violate privacy (include private information about others)
  • Are duplicate submissions

Screenshot placeholder: Response detail modal showing full text, citizen info, and approve/reject buttons

"},{"location":"v2/user-guides/admin-guide/#location-management","title":"Location Management","text":""},{"location":"v2/user-guides/admin-guide/#location-data-overview","title":"Location Data Overview","text":"

Locations represent physical addresses where canvassing occurs. Each location has:

  • Address: Street address, city, province, postal code
  • Coordinates: Latitude/longitude (from geocoding)
  • Metadata: Building type, federal district, unit count
  • Cut assignment: Which territorial cut it belongs to
  • Canvass history: Visits, outcomes, support levels
"},{"location":"v2/user-guides/admin-guide/#importing-locations-from-csv","title":"Importing Locations from CSV","text":"

To import locations:

  1. Navigate to Map > Locations
  2. Click \"Import CSV\" button
  3. Upload CSV file
  4. Map CSV columns to location fields
  5. Click \"Import\"

Required CSV columns:

  • address \u2014 Full street address
  • city \u2014 City name
  • province \u2014 Province/state code (e.g., \"ON\", \"BC\")
  • postalCode \u2014 Postal code (e.g., \"K1A 0B1\")

Optional columns:

  • latitude \u2014 Pre-geocoded latitude
  • longitude \u2014 Pre-geocoded longitude
  • buildingType \u2014 RESIDENTIAL, APARTMENT, BUSINESS
  • unitCount \u2014 Number of units in building
  • federalDistrict \u2014 Electoral district
  • notes \u2014 Internal notes

CSV example:

address,city,province,postalCode,buildingType\n\"123 Main St\",\"Ottawa\",\"ON\",\"K1A 0B1\",\"RESIDENTIAL\"\n\"456 Queen St E\",\"Toronto\",\"ON\",\"M5A 1T1\",\"APARTMENT\"\n\"789 Granville St\",\"Vancouver\",\"BC\",\"V6Z 1K3\",\"BUSINESS\"\n

Excel to CSV

If your data is in Excel, use \"Save As\" > \"CSV (Comma delimited)\" to export.

Screenshot placeholder: CSV import dialog showing file upload, column mapping interface, and preview table

"},{"location":"v2/user-guides/admin-guide/#nar-import-canadian-electoral-data","title":"NAR Import (Canadian Electoral Data)","text":"

For Canadian campaigns, you can import official electoral data from Elections Canada NAR (National Address Register) files.

To import NAR data:

  1. Navigate to Map > Locations
  2. Click \"NAR Import\" button
  3. Select province
  4. Choose NAR dataset (year)
  5. Apply filters:
  6. City filter (optional)
  7. Postal code filter (optional)
  8. Cut filter (assign to specific cut)
  9. Residential only (exclude commercial)
  10. Click \"Start Import\"

The import runs server-side and can take several minutes for large provinces.

NAR data includes:

  • Precise civic addresses (from Address files)
  • Geocoded coordinates (from Location files)
  • Federal electoral districts
  • Building use (residential, commercial, institutional)

Screenshot placeholder: NAR Import modal showing province selector, dataset picker, and filter options

NAR Data Source

NAR data must be obtained from Elections Canada and placed in the /data directory on the server. Contact your system administrator.

"},{"location":"v2/user-guides/admin-guide/#geocoding-addresses","title":"Geocoding Addresses","text":"

Geocoding converts addresses to latitude/longitude coordinates for map display.

Automatic geocoding:

  • CSV imports without lat/lng are automatically geocoded
  • NAR imports include pre-geocoded coordinates
  • Manual location creation triggers geocoding

Manual geocoding:

  1. Navigate to Map > Locations
  2. Filter for \"Ungeocoded\" locations
  3. Select locations to geocode
  4. Click \"Geocode Selected\" (bulk action)

Geocoding providers (tried in order):

  1. Nominatim (OpenStreetMap) \u2014 Free, no API key required
  2. ArcGIS \u2014 Free tier, accurate for North America
  3. Photon \u2014 Free, Europe-focused
  4. Mapbox \u2014 Requires API key, very accurate
  5. Google \u2014 Requires API key, most accurate
  6. LocationIQ \u2014 Requires API key, Nominatim-based

Geocoding Quality

Check Map > Data Quality to review geocoding confidence levels. Re-geocode low-confidence addresses.

Screenshot placeholder: Locations table with \"Geocode Selected\" button and geocoding status column

"},{"location":"v2/user-guides/admin-guide/#creating-cuts","title":"Creating Cuts","text":"

Cuts are geographic areas (wards, neighborhoods, districts) used to organize canvassing.

To create a cut:

  1. Navigate to Map > Cuts
  2. Click the \"Map Drawing\" tab
  3. Click \"Start Drawing\"
  4. Click on the map to add polygon vertices
  5. Close the polygon (click near first point)
  6. Fill in cut details:
  7. Name: Cut identifier (e.g., \"Ward 5\", \"Downtown\")
  8. Category: WARD, NEIGHBORHOOD, DISTRICT, or CUSTOM
  9. Color: Display color on map
  10. Description: Internal notes
  11. Click \"Save Cut\"

Cut best practices:

  • Size: 200-500 locations per cut (manageable for canvassing)
  • Boundaries: Use natural boundaries (roads, rivers, parks)
  • Naming: Use official ward/district names when available
  • Colors: Use distinct colors for adjacent cuts

Screenshot placeholder: Cut drawing map interface showing polygon being drawn with vertex markers

"},{"location":"v2/user-guides/admin-guide/#assigning-locations-to-cuts","title":"Assigning Locations to Cuts","text":"

Automatic assignment (during cut creation):

  • Locations inside polygon are automatically assigned
  • Uses point-in-polygon algorithm

Manual assignment:

  1. Navigate to Map > Locations
  2. Select locations to assign
  3. Choose \"Assign to Cut\" from bulk actions
  4. Select target cut
  5. Click \"Assign\"

Viewing cut assignments:

  • Location table has \"Cut\" column
  • Filter locations by cut using dropdown

Screenshot placeholder: Bulk action modal showing \"Assign to Cut\" with cut selector dropdown

"},{"location":"v2/user-guides/admin-guide/#managing-locations","title":"Managing Locations","text":"

To edit a location:

  1. Navigate to Map > Locations
  2. Click \"Edit\" in Actions column
  3. Modify fields:
  4. Address details
  5. Coordinates (manually adjust map pin)
  6. Building type
  7. Unit count
  8. Notes
  9. Cut assignment
  10. Click \"Save\"

To delete locations:

  1. Select locations in table
  2. Choose \"Delete\" from bulk actions
  3. Confirm deletion

Canvass History

Deleting a location preserves associated canvass visits (visits are linked to coordinates, not location records).

Screenshot placeholder: Edit Location modal showing address fields, map with draggable pin, and metadata fields

"},{"location":"v2/user-guides/admin-guide/#exporting-walk-sheets","title":"Exporting Walk Sheets","text":"

Walk sheets are printable lists of addresses for door-to-door canvassing.

To generate a walk sheet:

  1. Navigate to Map > Locations
  2. Filter to specific cut
  3. Click \"Walk Sheet\" in the cut's action menu

OR:

  1. Navigate to Canvass > Walk Sheet
  2. Select cut from dropdown
  3. Configure settings (see below)
  4. Click \"Print\"

Walk sheet settings (from Map > Map Settings):

  • Header text: Organization name, campaign info
  • Instructions: How to use the walk sheet
  • QR code: Include QR code linking to volunteer canvass map
  • Sorting: Sort by street name or walking route
  • Include map: Embed cut map on first page

Walk sheet contents:

  • Cut name and statistics
  • QR code (volunteers scan to start canvass session)
  • Location table:
  • Address
  • Unit count
  • Last visit date
  • Last outcome
  • Notes field (blank for volunteers to fill)

Screenshot placeholder: Walk sheet PDF preview showing header, QR code, and address table

"},{"location":"v2/user-guides/admin-guide/#volunteer-management","title":"Volunteer Management","text":""},{"location":"v2/user-guides/admin-guide/#creating-shifts","title":"Creating Shifts","text":"

Shifts are scheduled volunteer canvassing sessions assigned to specific cuts.

To create a shift:

  1. Navigate to Map > Shifts
  2. Click \"Create Shift\"
  3. Fill in shift details:
  4. Title: Shift name (e.g., \"Saturday Morning Canvass - Ward 5\")
  5. Description: Additional details for volunteers
  6. Start Time: Shift start date and time
  7. End Time: Shift end date and time
  8. Cut: Which cut to canvass (optional, but recommended)
  9. Max Signups: Capacity limit (0 = unlimited)
  10. Meeting Location: Where volunteers should meet
  11. Click \"Create\"

Screenshot placeholder: Create Shift modal showing date/time picker, cut selector, and capacity field

Cut Assignment

Shifts assigned to a cut appear in the volunteer portal under \"My Assignments\" for volunteers who signed up. Volunteers can start canvassing directly from their dashboard.

"},{"location":"v2/user-guides/admin-guide/#managing-shift-signups","title":"Managing Shift Signups","text":"

To view shift signups:

  1. Navigate to Map > Shifts
  2. Click \"Signups\" in Actions column

The signups drawer shows:

  • Total signups vs capacity
  • Signup list: Name, email, role, signup date
  • Actions: Remove signup, upgrade TEMP users to USER

Signup sources:

  • Public signup form: /shifts page (creates TEMP users)
  • Admin-created: You manually add volunteers
  • Volunteer portal: USER-role volunteers sign up themselves

Screenshot placeholder: Shift Signups drawer showing capacity gauge and signup list table

"},{"location":"v2/user-guides/admin-guide/#emailing-shift-volunteers","title":"Emailing Shift Volunteers","text":"

To email all volunteers in a shift:

  1. Navigate to Map > Shifts
  2. Click \"Signups\" for the shift
  3. Click \"Email All\" button
  4. Compose email:
  5. Subject: Email subject line
  6. Body: Message (supports HTML)
  7. Variables: Use {{NAME}}, {{SHIFT_TITLE}}, {{SHIFT_START}}
  8. Click \"Send\"

Common email scenarios:

  • Reminder: Day before shift
  • Cancellation: Weather or other issues
  • Location change: Meeting point updated
  • Follow-up: Thank you after shift

Screenshot placeholder: Email Volunteers modal showing subject, body editor, and variable insertion buttons

"},{"location":"v2/user-guides/admin-guide/#monitoring-canvass-sessions","title":"Monitoring Canvass Sessions","text":"

To view active canvass sessions:

  1. Navigate to Canvass > Dashboard

The dashboard shows:

Statistics cards:

  • Active sessions: Currently in progress
  • Total visits today: Doors knocked
  • Completed sessions: Finished today
  • Average session duration

Activity feed:

  • Real-time visit stream
  • Shows: Volunteer name, address, outcome, timestamp
  • Updates every 30 seconds

Cut progress table:

  • Progress by cut (% of locations visited)
  • Session count per cut
  • Visit count per cut

Leaderboard:

  • Top volunteers by visit count
  • Session count
  • Success rate (SPOKE_WITH outcomes)

Screenshot placeholder: Canvass Dashboard showing stats cards, activity feed, and leaderboard

"},{"location":"v2/user-guides/admin-guide/#viewing-canvass-activity-reports","title":"Viewing Canvass Activity Reports","text":"

To see detailed canvassing data:

  1. Navigate to Canvass > Dashboard
  2. Use filters:
  3. Date range: Last 7 days, last 30 days, custom
  4. Cut: Specific cut or all
  5. Volunteer: Specific volunteer or all
  6. Outcome: Filter by visit outcome

Exportable reports:

  • Visit history CSV: All visits with outcomes, notes, timestamps
  • Support levels CSV: LEVEL_1 through LEVEL_4 breakdown
  • Session summary CSV: Session duration, visit count, volunteer info

Screenshot placeholder: Activity report filters and export buttons

"},{"location":"v2/user-guides/admin-guide/#site-configuration","title":"Site Configuration","text":""},{"location":"v2/user-guides/admin-guide/#site-settings","title":"Site Settings","text":"

To configure global site settings:

  1. Navigate to Settings (gear icon in sidebar)

Available settings:

Branding:

  • Site Name: Your organization name
  • Site URL: Public website URL
  • Logo URL: URL to your logo image
  • Primary Color: Brand color (hex code)
  • Secondary Color: Accent color

Email Configuration:

  • From Name: Sender name for system emails
  • From Email: Sender email address
  • SMTP Host: Email server hostname
  • SMTP Port: Usually 587 (TLS) or 465 (SSL)
  • SMTP Username: SMTP authentication username
  • SMTP Password: SMTP authentication password
  • Test Mode: Send to MailHog instead of real SMTP (dev only)

Representative API:

  • Represent API Base URL: Usually https://represent.opennorth.ca
  • API Key: If required by provider
  • Cache TTL: How long to cache representative data (hours)

Feature Toggles:

  • Enable Media Features: Enable video library and media management
  • Enable Listmonk Sync: Sync contacts to Listmonk newsletter platform
  • Allow Public Shift Signup: Anyone can sign up for shifts (creates TEMP users)
  • Require Email Verification: Campaign responses require email confirmation

Screenshot placeholder: Settings page showing branding, email, and feature toggle sections

Test Email Configuration

After changing SMTP settings, click \"Send Test Email\" to verify configuration before publishing campaigns.

"},{"location":"v2/user-guides/admin-guide/#map-settings","title":"Map Settings","text":"

To configure map defaults:

  1. Navigate to Map > Map Settings

Map Configuration:

  • Default Center: Latitude/longitude for map center
  • Used on public map and admin map
  • Usually your city center
  • Default Zoom: Zoom level (1-18)
  • 12 = city-wide view
  • 15 = neighborhood view
  • Enable Fullscreen: Allow fullscreen button on public map
  • Enable Geolocation: Allow \"Find My Location\" button

Walk Sheet Configuration:

  • Header Text: Appears at top of walk sheets
  • Footer Text: Appears at bottom
  • Include QR Code: Add QR code linking to volunteer map
  • QR Code Size: Small, medium, or large
  • Instructions: Text explaining how to use walk sheet

Screenshot placeholder: Map Settings page showing map center picker and walk sheet config

"},{"location":"v2/user-guides/admin-guide/#feature-toggles","title":"Feature Toggles","text":"

Feature toggles allow you to enable/disable major platform features without code changes.

To manage feature toggles:

  1. Navigate to Settings
  2. Scroll to Feature Toggles section
  3. Toggle features on/off
  4. Click \"Save\"

Available toggles:

ENABLE_MEDIA_FEATURES

  • Enables Media Library and video management
  • Shows Media menu in sidebar
  • Allows video uploads and public media gallery
  • Requires media-api service running

ENABLE_LISTMONK_SYNC

  • Enables newsletter integration
  • Syncs campaign participants to Listmonk lists
  • Shows Listmonk menu in sidebar
  • Requires Listmonk service configured

ALLOW_PUBLIC_SHIFT_SIGNUP

  • Public can sign up for shifts at /shifts
  • Creates TEMP user accounts automatically
  • Shows shifts on public pages
  • Disable for invitation-only volunteering

REQUIRE_EMAIL_VERIFICATION

  • Campaign responses require email verification
  • Prevents spam and fake submissions
  • Sends verification link before recording response
  • Recommended for public campaigns

Screenshot placeholder: Feature Toggles section showing four toggles with descriptions

Media Features

Enabling media features requires the media-api Docker container to be running. Check with your system administrator.

"},{"location":"v2/user-guides/admin-guide/#email-templates","title":"Email Templates","text":""},{"location":"v2/user-guides/admin-guide/#understanding-email-templates","title":"Understanding Email Templates","text":"

Changemaker Lite uses email templates for system-generated emails:

System templates:

  • Welcome Email: Sent to new users
  • Password Reset: Sent when user requests password reset
  • Shift Confirmation: Sent when volunteer signs up for shift
  • Shift Reminder: Sent day before shift
  • Response Verification: Sent to verify campaign response

Custom templates:

  • You can create custom templates for specific needs
  • Use in shift emails, campaign follow-ups, etc.
"},{"location":"v2/user-guides/admin-guide/#editing-templates","title":"Editing Templates","text":"

To edit an email template:

  1. Navigate to Content > Email Templates
  2. Click \"Edit\" for the template
  3. Modify:
  4. Subject: Email subject line
  5. HTML Body: Rich email content
  6. Plain Text Body: Fallback for text-only clients
  7. Use variables (e.g., {{USER_NAME}}, {{SHIFT_TITLE}})
  8. Click \"Preview\" to see rendered email
  9. Click \"Save\"

Screenshot placeholder: Email Template Editor showing subject field, HTML editor, and variable buttons

"},{"location":"v2/user-guides/admin-guide/#available-variables","title":"Available Variables","text":"

Templates support variable interpolation:

User variables:

  • {{USER_NAME}} \u2014 User's full name
  • {{USER_EMAIL}} \u2014 User's email address

Shift variables:

  • {{SHIFT_TITLE}} \u2014 Shift name
  • {{SHIFT_START}} \u2014 Start date/time
  • {{SHIFT_END}} \u2014 End date/time
  • {{SHIFT_LOCATION}} \u2014 Meeting location
  • {{SHIFT_CUT}} \u2014 Cut name

Campaign variables:

  • {{CAMPAIGN_TITLE}} \u2014 Campaign name
  • {{CAMPAIGN_URL}} \u2014 Link to campaign page

System variables:

  • {{SITE_NAME}} \u2014 Your organization name
  • {{SITE_URL}} \u2014 Website URL

Screenshot placeholder: Variable reference table in template editor sidebar

"},{"location":"v2/user-guides/admin-guide/#testing-templates","title":"Testing Templates","text":"

To test an email template:

  1. Edit the template
  2. Click \"Send Test Email\"
  3. Enter your email address
  4. Click \"Send\"

You'll receive the email with sample data filled in for variables.

Always Test

Test templates before using them in production. Check both HTML and plain text versions.

"},{"location":"v2/user-guides/admin-guide/#media-library","title":"Media Library","text":"

Optional Feature

Media features must be enabled via Settings > Feature Toggles > ENABLE_MEDIA_FEATURES. Requires media-api service.

"},{"location":"v2/user-guides/admin-guide/#uploading-videos","title":"Uploading Videos","text":"

To upload a video:

  1. Navigate to Content > Media > Library
  2. Click \"Upload Video\"
  3. Either:
  4. Drag and drop video file
  5. Click to browse and select file
  6. Fill in metadata:
  7. Title: Video title
  8. Description: Video description
  9. Producer: Organization or creator
  10. Creator: Individual creator/director
  11. Tags: Comma-separated tags
  12. Directory: Organize into folders
  13. Click \"Upload\"

Supported formats:

  • MP4 (recommended)
  • MOV
  • AVI
  • MKV
  • WebM
  • M4V
  • FLV

File size limit: 10 GB per file

Screenshot placeholder: Upload Video modal showing drag-drop area, metadata form, and progress bar

"},{"location":"v2/user-guides/admin-guide/#automatic-metadata-extraction","title":"Automatic Metadata Extraction","text":"

When you upload a video, the system automatically extracts:

  • Duration: Length in seconds
  • Dimensions: Width x height in pixels
  • Orientation: PORTRAIT, LANDSCAPE, or SQUARE
  • Quality: SD, HD, FULL_HD, or 4K
  • Has Audio: Boolean
  • File Size: Bytes

This metadata is used for filtering and organizing videos.

"},{"location":"v2/user-guides/admin-guide/#organizing-the-library","title":"Organizing the Library","text":"

Directory structure:

  • Create directories to organize videos
  • Directories are simple text paths (e.g., \"events/2024\", \"testimonials\")
  • Set directory when uploading or editing

Filtering videos:

  • Search: Search title, description, tags
  • Directory: Filter by directory
  • Quality: Filter by SD, HD, etc.
  • Orientation: Portrait, landscape, square
  • Locked: Show only locked or unlocked

Sorting:

  • Upload date (newest first)
  • Title (A-Z)
  • Duration (shortest first)

Screenshot placeholder: Media Library showing directory tree, filters, and video grid

"},{"location":"v2/user-guides/admin-guide/#sharing-videos-publicly","title":"Sharing Videos Publicly","text":"

To make videos public:

  1. Navigate to Content > Media > Shared Media
  2. Select videos from library
  3. Choose category:
  4. TESTIMONIAL
  5. EVENT
  6. EDUCATIONAL
  7. PROMOTIONAL
  8. Click \"Share\"

Shared videos appear on the public media gallery at /media.

To unshare videos:

  1. Go to Shared Media
  2. Select videos
  3. Click \"Unshare\"

Screenshot placeholder: Shared Media page showing category filter and share/unshare buttons

"},{"location":"v2/user-guides/admin-guide/#locking-videos","title":"Locking Videos","text":"

Locked videos cannot be deleted or moved. Use locks to protect important content.

To lock a video:

  1. Select video in library
  2. Click \"Lock\" (padlock icon)

To unlock:

  1. Select locked video
  2. Click \"Unlock\"

Lock Before Sharing

Lock videos before sharing publicly to prevent accidental deletion.

"},{"location":"v2/user-guides/admin-guide/#monitoring-reports","title":"Monitoring & Reports","text":""},{"location":"v2/user-guides/admin-guide/#viewing-queue-status","title":"Viewing Queue Status","text":"

To monitor the email queue:

  1. Navigate to Influence > Email Queue

Key metrics:

  • Waiting: Emails queued for sending
  • High number = slow processing (check SMTP)
  • Active: Currently processing
  • Should be 1-5 (concurrent workers)
  • Completed: Sent in last 24 hours
  • Failed: Delivery failures
  • Click \"View Failed\" to see error messages

Queue health indicators:

  • Green: < 50 waiting, < 5 failed
  • Yellow: 50-200 waiting, 5-20 failed
  • Red: > 200 waiting, > 20 failed

Screenshot placeholder: Email Queue dashboard showing job counts with color-coded health indicators

"},{"location":"v2/user-guides/admin-guide/#geocoding-quality-dashboard","title":"Geocoding Quality Dashboard","text":"

To review geocoding quality:

  1. Navigate to Map > Data Quality

Quality metrics:

  • Total locations: All location records
  • Geocoded: Have lat/lng coordinates
  • Ungeocoded: Missing coordinates
  • Low confidence: Confidence < 0.5
  • Medium confidence: 0.5-0.8
  • High confidence: > 0.8

Quality breakdown:

  • Provider distribution: Which geocoding service was used
  • Confidence histogram: Distribution of confidence scores
  • Error analysis: Common geocoding failures

Actions:

  • Re-geocode low confidence: Retry with different provider
  • Export ungeocoded: CSV of failed addresses
  • Manual review: Edit addresses and re-geocode

Screenshot placeholder: Data Quality Dashboard showing geocoding statistics and confidence distribution chart

"},{"location":"v2/user-guides/admin-guide/#canvass-completion-statistics","title":"Canvass Completion Statistics","text":"

To view canvass progress:

  1. Navigate to Canvass > Dashboard

Completion metrics:

  • Locations visited: Total unique addresses visited
  • Visit rate: Visits per day/week
  • Completion by cut: % of each cut visited
  • Outcome breakdown: % NOT_HOME, REFUSED, SPOKE_WITH, etc.

Support level analysis:

  • LEVEL_1 (Strong support): Count and percentage
  • LEVEL_2 (Leaning support): Count and percentage
  • LEVEL_3 (Undecided): Count and percentage
  • LEVEL_4 (Opposition): Count and percentage

Volunteer performance:

  • Sessions per volunteer: Distribution histogram
  • Visits per volunteer: Leaderboard
  • Average session duration: Time spent canvassing

Screenshot placeholder: Canvass statistics showing completion gauges, outcome pie chart, and support level breakdown

"},{"location":"v2/user-guides/admin-guide/#observability-dashboard","title":"Observability Dashboard","text":"

To monitor system health:

  1. Navigate to Observability

The observability dashboard has three tabs:

"},{"location":"v2/user-guides/admin-guide/#metrics-tab","title":"Metrics Tab","text":"
  • Custom metrics: 12 cm_* Prometheus metrics
  • API uptime
  • Request counts
  • Email queue size
  • Active sessions
  • Geocoding success rate
  • HTTP metrics: Request duration, status codes
  • System metrics: CPU, memory, disk

Screenshot placeholder: Metrics tab showing API uptime gauge and request count graph

"},{"location":"v2/user-guides/admin-guide/#dashboards-tab","title":"Dashboards Tab","text":"
  • Links to Grafana dashboards:
  • API Health (uptime, response times, error rates)
  • Queue Monitoring (email queue, geocoding queue)
  • Canvassing Activity (sessions, visits, outcomes)
  • Click dashboard name to open in Grafana

Screenshot placeholder: Dashboards tab showing three dashboard cards with \"Open\" buttons

"},{"location":"v2/user-guides/admin-guide/#alerts-tab","title":"Alerts Tab","text":"
  • Active alerts: Currently firing alerts
  • Alert history: Recent resolved alerts
  • Alert rules: Configured thresholds
  • Silence alerts: Temporarily mute alerts

Common alerts:

  • API Down: API not responding
  • High Error Rate: > 5% requests failing
  • Queue Backed Up: > 1000 emails waiting
  • Disk Space Low: < 10% free space

Screenshot placeholder: Alerts tab showing active alert for \"Queue Backed Up\" with severity and details

"},{"location":"v2/user-guides/admin-guide/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/user-guides/admin-guide/#common-admin-issues","title":"Common Admin Issues","text":""},{"location":"v2/user-guides/admin-guide/#issue-cannot-log-in","title":"Issue: Cannot Log In","text":"

Symptoms: \"Invalid credentials\" error

Solutions:

  1. Verify email address: Check for typos, spaces
  2. Try password reset: Use \"Forgot Password\" link
  3. Check account status: Ask another admin if account is suspended
  4. Check browser console: Look for API errors
"},{"location":"v2/user-guides/admin-guide/#issue-emails-not-sending","title":"Issue: Emails Not Sending","text":"

Symptoms: Emails stuck in \"Waiting\" status

Solutions:

  1. Check SMTP configuration:
  2. Navigate to Settings
  3. Verify SMTP host, port, username, password
  4. Click \"Send Test Email\"
  5. Check email queue:
  6. Navigate to Influence > Email Queue
  7. Look for error messages in failed jobs
  8. Check email test mode:
  9. If EMAIL_TEST_MODE=true, emails go to MailHog (not real recipients)
  10. Change in environment settings
  11. Restart queue worker:
  12. Ask system administrator to restart api service
"},{"location":"v2/user-guides/admin-guide/#issue-csv-import-fails","title":"Issue: CSV Import Fails","text":"

Symptoms: Error during CSV upload

Solutions:

  1. Check CSV format:
  2. Must be valid CSV (comma-separated)
  3. First row must be headers
  4. Required columns: address, city, province, postalCode
  5. Check file encoding:
  6. Use UTF-8 encoding
  7. Excel users: \"Save As\" > \"CSV UTF-8\"
  8. Check file size:
  9. Maximum 10,000 rows per import
  10. Split large files
  11. Check for special characters:
  12. Remove emoji, unusual symbols
  13. Use standard quotes (\"not \"\" or '')
"},{"location":"v2/user-guides/admin-guide/#issue-geocoding-fails","title":"Issue: Geocoding Fails","text":"

Symptoms: Addresses remain ungeocoded after import

Solutions:

  1. Check address format:
  2. Include full civic address
  3. Include city and postal code
  4. Use standard abbreviations (St, Ave, Rd)
  5. Check geocoding providers:
  6. Navigate to Map > Data Quality
  7. See which providers are responding
  8. Try manual geocoding:
  9. Edit location
  10. Click and drag map pin to correct position
  11. Save
  12. Use NAR data (Canada only):
  13. NAR import includes pre-geocoded coordinates
  14. More reliable than automatic geocoding
"},{"location":"v2/user-guides/admin-guide/#issue-map-not-loading","title":"Issue: Map Not Loading","text":"

Symptoms: Blank map or loading spinner

Solutions:

  1. Check browser console: Look for JavaScript errors
  2. Check internet connection: Map tiles require network
  3. Try different browser: Test in Chrome, Firefox
  4. Clear browser cache: Hard refresh (Ctrl+Shift+R)
  5. Check locations:
  6. Navigate to Map > Locations
  7. Verify locations have coordinates
  8. At least one location needed to display map
"},{"location":"v2/user-guides/admin-guide/#issue-campaign-not-appearing-publicly","title":"Issue: Campaign Not Appearing Publicly","text":"

Symptoms: Campaign visible in admin but not on /campaigns

Solutions:

  1. Check \"Published\" flag:
  2. Edit campaign
  3. Ensure \"Published\" toggle is ON
  4. Save
  5. Check URL:
  6. Campaign URL is /campaigns/[slug]
  7. Slug is auto-generated from title
  8. Must be unique
  9. Clear browser cache: Public pages may be cached
  10. Check representative lookup:
  11. Test with your postal code
  12. If lookup fails, campaign won't display form
"},{"location":"v2/user-guides/admin-guide/#issue-volunteer-cannot-start-canvass-session","title":"Issue: Volunteer Cannot Start Canvass Session","text":"

Symptoms: Error when volunteer clicks \"Start Canvassing\"

Solutions:

  1. Check shift assignment:
  2. Navigate to Map > Shifts
  3. Verify shift has a cut assigned
  4. Shifts without cuts cannot be canvassed
  5. Check volunteer role:
  6. Navigate to Users
  7. Verify volunteer is USER role (not TEMP)
  8. Upgrade TEMP users to USER
  9. Check cut locations:
  10. Navigate to Map > Cuts
  11. Verify cut has locations assigned
  12. Empty cuts cannot be canvassed
  13. Check for existing session:
  14. Volunteer may have abandoned session
  15. Ask admin to close abandoned session
"},{"location":"v2/user-guides/admin-guide/#getting-help","title":"Getting Help","text":"

Documentation:

  • Feature docs: /docs/v2/features/ (detailed feature guides)
  • API reference: /docs/v2/api/ (API endpoint documentation)
  • User guides: /docs/v2/user-guides/ (this guide and others)
  • Deployment: /docs/v2/deployment/ (server setup, Docker, backups)

Support channels:

  • GitHub Issues: Report bugs, request features
  • Community Forum: Ask questions, share tips
  • Email Support: Contact your system administrator

Before asking for help:

  1. Check browser console for errors (F12)
  2. Try in different browser
  3. Check server logs (if you have access)
  4. Document steps to reproduce issue
"},{"location":"v2/user-guides/admin-guide/#related-documentation","title":"Related Documentation","text":"
  • Volunteer Guide: Guide for volunteers using the canvassing portal
  • Campaign Manager Guide: Deep dive on campaign strategy and management
  • Map Organizer Guide: Advanced location and territory management
  • Content Editor Guide: Landing pages and media library
  • Influence Module: Technical details on campaigns and email sending
  • Map Module: Technical details on geocoding and canvassing
  • API Reference: REST API documentation for integrations

Last updated: February 2026 (V2 complete)

"},{"location":"v2/user-guides/campaign-manager-guide/","title":"Campaign Manager Guide","text":""},{"location":"v2/user-guides/campaign-manager-guide/#overview","title":"Overview","text":"

As a Campaign Manager, you're responsible for planning, launching, and optimizing advocacy campaigns using Changemaker Lite's Influence module. This guide will help you:

  • Plan effective campaigns: Set goals, define targets, craft messaging
  • Configure campaigns: Set up email templates, feature flags, and targeting
  • Launch campaigns: Publish and promote to maximize participation
  • Monitor performance: Track email sends, response rates, and engagement
  • Optimize results: A/B test messaging, improve conversion, encourage responses
  • Moderate content: Review and approve response wall submissions

Whether you're running a small local campaign or a national advocacy push, this guide provides strategies and best practices for success.

"},{"location":"v2/user-guides/campaign-manager-guide/#understanding-campaign-roles","title":"Understanding Campaign Roles","text":"

You may have one of two roles that allow campaign management:

"},{"location":"v2/user-guides/campaign-manager-guide/#super_admin","title":"SUPER_ADMIN","text":"
  • Access: Full platform access
  • Capabilities: All campaign functions plus user management, site settings, etc.
  • Use case: Primary administrator
"},{"location":"v2/user-guides/campaign-manager-guide/#influence_admin","title":"INFLUENCE_ADMIN","text":"
  • Access: Influence module only
  • Capabilities:
  • Create and edit campaigns
  • Moderate responses
  • Monitor email queue
  • View representative cache
  • Restrictions: Cannot manage users, locations, or site settings
  • Use case: Dedicated campaign manager without full admin access

Role Specialization

If you only manage campaigns (not volunteers or locations), ask for INFLUENCE_ADMIN role. This keeps the interface focused on your work.

"},{"location":"v2/user-guides/campaign-manager-guide/#planning-a-campaign","title":"Planning a Campaign","text":""},{"location":"v2/user-guides/campaign-manager-guide/#defining-campaign-goals","title":"Defining Campaign Goals","text":"

Before creating a campaign in the system, clarify your objectives:

Advocacy goals:

  1. Awareness: Educate the public about an issue
  2. Pressure: Generate constituent contact to influence decision-makers
  3. Mobilization: Build a list of supporters for future action
  4. Visibility: Demonstrate public support through response wall

Measurable targets:

  • Email goal: How many emails do you want sent?
  • Example: \"1,000 emails to MPs by end of month\"
  • Response goal: How many public responses?
  • Example: \"100 personal stories shared on response wall\"
  • Conversion rate: What % of visitors should take action?
  • Benchmark: 5-15% is typical for advocacy campaigns
  • Timeline: When does the campaign start/end?
  • Align with legislative calendar, events, deadlines

Example campaign plan:

Campaign: Stop Bill 123 - Protect Clean Water\nGoal: Generate 5,000 emails to provincial MPPs before second reading vote\nTarget audience: Ontario residents (all 124 ridings)\nTimeline: 3 weeks (Feb 1-22)\nSuccess metrics:\n- 5,000+ emails sent\n- 500+ response wall submissions\n- 10% conversion rate (visitors \u2192 emails sent)\n- 50% email delivery success rate\n
"},{"location":"v2/user-guides/campaign-manager-guide/#understanding-your-target-audience","title":"Understanding Your Target Audience","text":"

Who are you trying to reach?

By government level:

  • Federal campaigns: Target MPs (Members of Parliament)
  • Use for: National legislation, federal regulations, federal budgets
  • Example: \"Urge your MP to support climate action\"

  • Provincial campaigns: Target MPPs/MLAs (provincial legislators)

  • Use for: Provincial laws, education, healthcare, transportation
  • Example: \"Tell your MPP to fund public transit\"

  • Municipal campaigns: Target city councillors, mayors

  • Use for: Local zoning, development, city services
  • Example: \"Ask your councillor to protect the park\"

By geography:

  • National: All postal codes
  • Provincial: Specific province(s)
  • Municipal: Specific city or ward
  • Custom: Specific ridings or districts

By demographics (requires custom targeting):

  • Age groups
  • Interests
  • Previous engagement

Representative Lookup

Changemaker Lite uses postal codes to look up representatives via the Represent API. Ensure your target government level has postal code coverage.

"},{"location":"v2/user-guides/campaign-manager-guide/#crafting-your-message","title":"Crafting Your Message","text":"

Your campaign email is the core of your advocacy effort. It should be:

1. Personal

  • Written in first person (\"I am writing to...\")
  • Uses resident's name and contact info
  • Mentions specific representative's name

2. Clear and Specific

  • States the ask in the first paragraph
  • References specific legislation (bill number, name)
  • Explains what you want the representative to do

3. Compelling

  • Explains why the issue matters
  • Uses facts and statistics (credibly sourced)
  • Includes emotional appeal (stories, impacts)

4. Actionable

  • Numbered list of specific requests
  • Clear deadline (if applicable)
  • Follow-up mechanism (reply, meeting, public statement)

5. Respectful

  • Professional tone
  • Acknowledges representative's position
  • Thanks them for considering your views

Example effective email:

Subject: Vote YES on Bill C-234 to Support Family Farms\n\nDear [Representative Name],\n\nMy name is [Your Name], and I am a constituent in [Riding]. I'm writing\nto urge you to vote YES on Bill C-234, which would exempt farmers from\nthe carbon tax on natural gas and propane used for farming.\n\nFamily farms are the backbone of our food system, yet they face rising\ncosts that threaten their viability. This bill would save farmers an\naverage of $14,000 per year, helping them stay in business and keep\nfood prices stable.\n\nI'm specifically asking you to:\n1. Vote YES when Bill C-234 comes to the floor\n2. Speak publicly in support of family farms\n3. Oppose any amendments that weaken the bill\n\nFarming is already a low-margin business. Every dollar counts. Please\nsupport our farmers by supporting this bill.\n\nThank you for considering my views. I look forward to hearing your\nposition on this important issue.\n\nSincerely,\n[Your Name]\n[Your Email]\n[Your Phone]\n

What makes this email effective:

  • \u2705 Specific bill number (C-234)
  • \u2705 Clear ask (vote YES)
  • \u2705 Compelling reason (saves $14k/year)
  • \u2705 Numbered action items
  • \u2705 Respectful tone
  • \u2705 Personal voice
"},{"location":"v2/user-guides/campaign-manager-guide/#creating-a-campaign","title":"Creating a Campaign","text":""},{"location":"v2/user-guides/campaign-manager-guide/#basic-campaign-setup","title":"Basic Campaign Setup","text":"

To create a new campaign:

  1. Navigate to Influence > Campaigns
  2. Click \"Create Campaign\"
  3. Fill in the form (detailed below)
  4. Click \"Create\"

Your campaign starts in DRAFT status (not published).

"},{"location":"v2/user-guides/campaign-manager-guide/#campaign-fields","title":"Campaign Fields","text":""},{"location":"v2/user-guides/campaign-manager-guide/#title","title":"Title","text":"

What it is: Public-facing campaign name

Best practices:

  • Keep it short (3-7 words)
  • Make it action-oriented
  • Include the issue/goal
  • Avoid jargon or acronyms

Examples:

  • \u2705 \"Protect Our Forests from Logging\"
  • \u2705 \"Fund Public Transit Now\"
  • \u2705 \"Stop Bill 123\"
  • \u274c \"Environmental Advocacy Initiative 2024\" (too vague)
  • \u274c \"FPTA Campaign\" (acronym unclear)
"},{"location":"v2/user-guides/campaign-manager-guide/#slug","title":"Slug","text":"

What it is: URL-friendly identifier, auto-generated from title

Format: lowercase, hyphens for spaces, no special characters

Examples:

  • Title: \"Protect Our Forests\" \u2192 Slug: protect-our-forests
  • Title: \"Fund Public Transit\" \u2192 Slug: fund-public-transit

Used in URL: https://yoursite.org/campaigns/protect-our-forests

Slug Uniqueness

Slugs must be unique. If you try to use a duplicate, the system will add a number (e.g., protect-our-forests-2).

"},{"location":"v2/user-guides/campaign-manager-guide/#description","title":"Description","text":"

What it is: Campaign overview shown on listing page and campaign detail page

Best practices:

  • 2-3 sentences
  • Explain the issue briefly
  • Explain why it matters
  • Include call to action
  • HTML supported (bold, links, etc.)

Example:

<p>Ancient forests in our region are being clear-cut at an alarming rate.\nThese forests provide habitat for endangered species, clean our air and\nwater, and offer recreational spaces for our communities.</p>\n\n<p><strong>Tell your MPP to enact a moratorium on old-growth logging\nuntil sustainable forestry practices are in place.</strong></p>\n
"},{"location":"v2/user-guides/campaign-manager-guide/#government-level","title":"Government Level","text":"

What it is: Which level of government to target for representative lookup

Options:

  • FEDERAL: MPs (Members of Parliament)
  • PROVINCIAL: MPPs/MLAs (provincial/territorial legislators)
  • MUNICIPAL: City councillors, mayors

You can select multiple levels if your issue spans jurisdictions.

Example scenarios:

  • Climate legislation \u2192 FEDERAL only
  • Education funding \u2192 PROVINCIAL only
  • Park development \u2192 MUNICIPAL only
  • Transit expansion \u2192 PROVINCIAL + MUNICIPAL (both levels involved)
"},{"location":"v2/user-guides/campaign-manager-guide/#email-subject","title":"Email Subject","text":"

What it is: Subject line for emails citizens send to representatives

Best practices:

  • Keep under 60 characters (avoids truncation)
  • Start with action verb (Support, Oppose, Protect, Fund)
  • Include specific bill/issue name
  • Use variables for personalization

Variables available:

  • {{USER_NAME}} \u2014 Sender's name
  • {{REP_NAME}} \u2014 Representative's name
  • {{REP_TITLE}} \u2014 Representative's title (MP, MPP, Councillor)

Examples:

  • \u2705 \"Please support Bill C-234 for family farms\"
  • \u2705 \"Vote YES on climate action legislation\"
  • \u2705 \"Oppose the proposed park development\"
  • \u274c \"Your constituent has an important message for you\" (too vague)
  • \u274c \"I am writing to express my concern about the environmental degradation...\" (too long)
"},{"location":"v2/user-guides/campaign-manager-guide/#email-body","title":"Email Body","text":"

What it is: The email message template citizens send

Structure:

Greeting (uses {{REP_NAME}})\n\nOpening paragraph: Who I am, why I'm writing\n\nBody paragraphs: Issue explanation, impact, evidence\n\nSpecific asks: Numbered list of actions\n\nClosing: Thank you, request for response\n\nSignature (uses {{USER_NAME}}, {{USER_EMAIL}}, etc.)\n\nOptional: User's personal message ({{USER_MESSAGE}})\n

Variables available:

  • {{USER_NAME}} \u2014 Citizen's full name
  • {{USER_EMAIL}} \u2014 Citizen's email
  • {{USER_PHONE}} \u2014 Citizen's phone (if collected)
  • {{REP_NAME}} \u2014 Representative's name
  • {{REP_EMAIL}} \u2014 Representative's email
  • {{REP_TITLE}} \u2014 Representative's title (MP, MPP, Councillor)
  • {{USER_MESSAGE}} \u2014 Citizen's custom message (optional field on form)

Tips:

  • Use HTML editor for formatting (bold, lists, links)
  • Include {{USER_MESSAGE}} at the end so citizens can add personal stories
  • Keep base template to 200-400 words (short enough to read, detailed enough to be persuasive)
  • Preview before publishing (send test email to yourself)
"},{"location":"v2/user-guides/campaign-manager-guide/#cover-photo-optional","title":"Cover Photo (Optional)","text":"

What it is: Image shown on campaign listing and detail pages

Best practices:

  • Use high-quality image (at least 1200x630 px)
  • Relevant to issue (photo of forest for forestry campaign, etc.)
  • Not too busy (text overlays should be readable)
  • Use your own photos or Creative Commons licensed images

Upload: Provide URL to image (must host image externally or use media library)

"},{"location":"v2/user-guides/campaign-manager-guide/#configuring-feature-flags","title":"Configuring Feature Flags","text":"

Feature flags control campaign functionality. Here's a detailed guide on when to use each:

"},{"location":"v2/user-guides/campaign-manager-guide/#core-feature-flags","title":"Core Feature Flags","text":""},{"location":"v2/user-guides/campaign-manager-guide/#1-published","title":"1. Published","text":"

What it does: Makes campaign visible on public listing page

When to enable:

  • \u2705 Campaign is ready to launch
  • \u2705 Email template is proofread and tested
  • \u2705 Representative lookup is working

When to disable:

  • \u274c Campaign is still being built (draft)
  • \u274c Campaign has ended (or use disable_after_date)
  • \u274c Need to make changes (unpublish temporarily)

Unpublishing

Unpublishing a campaign removes it from the public listing but preserves all data (emails sent, responses, etc.). The campaign page URL still works for anyone with a direct link.

"},{"location":"v2/user-guides/campaign-manager-guide/#2-featured","title":"2. Featured","text":"

What it does: Displays campaign prominently at top of listing page

When to enable:

  • \u2705 Highest priority campaign
  • \u2705 Time-sensitive (vote happening soon)
  • \u2705 Major organizational focus

Best practices:

  • Limit to 2-3 featured campaigns
  • Rotate featured status based on priority
  • Feature new campaigns for first week to boost initial signups
"},{"location":"v2/user-guides/campaign-manager-guide/#3-has-response-wall","title":"3. Has Response Wall","text":"

What it does: Allows citizens to share personal stories publicly after emailing

When to enable:

  • \u2705 You want to showcase public support
  • \u2705 You have capacity for moderation (unless auto-approve)
  • \u2705 Issue benefits from personal stories

When to disable:

  • \u274c Privacy concerns (sensitive issues)
  • \u274c No moderation capacity
  • \u274c Campaign is purely about email volume (not stories)

Moderation required: Unless auto_approve_responses is enabled, all responses must be manually approved.

"},{"location":"v2/user-guides/campaign-manager-guide/#advanced-feature-flags","title":"Advanced Feature Flags","text":""},{"location":"v2/user-guides/campaign-manager-guide/#4-collect-phone-numbers","title":"4. Collect Phone Numbers","text":"

What it does: Adds optional phone number field to campaign form

When to enable:

  • \u2705 Running a blended email + phone campaign
  • \u2705 Want to follow up with phone calls
  • \u2705 Building contact list for future outreach

When to disable:

  • \u274c Privacy concerns (reduces conversion)
  • \u274c No plan to use phone numbers

Data usage: Phone numbers are stored in campaign responses and visible to admins.

"},{"location":"v2/user-guides/campaign-manager-guide/#5-track-calls","title":"5. Track Calls","text":"

What it does: Adds \"I called my representative\" button and tracks call attempts

When to enable:

  • \u2705 Running a call-in campaign
  • \u2705 Encouraging both emails and calls
  • \u2705 Want to track total contact attempts (emails + calls)

How it works:

  • After sending email, user sees \"I also called\" button
  • Clicking increments call counter
  • Calls tracked separately from emails
"},{"location":"v2/user-guides/campaign-manager-guide/#6-require-verification","title":"6. Require Verification","text":"

What it does: Sends verification email before recording email send

When to enable:

  • \u2705 Public campaigns (prevents spam)
  • \u2705 High-profile campaigns (media attention)
  • \u2705 Need accurate email counts

When to disable:

  • \u274c Internal campaigns (trusted users only)
  • \u274c Want to reduce friction (lowers completion rate by ~20%)

How it works:

  1. User fills out form and clicks \"Send\"
  2. System sends verification email
  3. User clicks link in email
  4. Email to representative is sent
  5. Response is recorded

Recommended

Enable verification for all public campaigns to prevent spam and ensure data quality.

"},{"location":"v2/user-guides/campaign-manager-guide/#7-auto-approve-responses","title":"7. Auto Approve Responses","text":"

What it does: Response wall submissions appear immediately without moderation

When to enable:

  • \u2705 Trusted audience (members-only campaign)
  • \u2705 Low-risk issue (unlikely to attract trolls)
  • \u2705 No moderation capacity

When to disable:

  • \u274c Public campaigns (risk of spam/abuse)
  • \u274c Controversial issues (may attract hostile responses)
  • \u274c Need quality control

Moderation Recommended

Most public campaigns should NOT auto-approve. Manual moderation ensures quality and prevents abuse.

"},{"location":"v2/user-guides/campaign-manager-guide/#8-allow-anonymous","title":"8. Allow Anonymous","text":"

What it does: Citizens can send emails without creating an account

When to enable:

  • \u2705 Want to maximize participation
  • \u2705 Privacy-sensitive issue
  • \u2705 One-time campaign (no need to track individuals)

When to disable:

  • \u274c Building supporter list (want account creation)
  • \u274c Need to prevent duplicate submissions
  • \u274c Want to track individual engagement over time

Trade-offs:

  • \u2705 Higher conversion (less friction)
  • \u274c Cannot prevent duplicate emails from same person
  • \u274c No account to re-engage supporters later
"},{"location":"v2/user-guides/campaign-manager-guide/#9-custom-recipients","title":"9. Custom Recipients","text":"

What it does: Override representative lookup and send to specific email addresses

When to enable:

  • \u2705 Targeting non-government decision-makers (corporate executives, university presidents)
  • \u2705 Representative lookup doesn't cover your target (small municipalities)
  • \u2705 Want to target specific individuals regardless of postal code

How to use:

  1. Enable flag
  2. Enter comma-separated email addresses in custom_recipient_emails field
  3. Optionally enter custom recipient names in custom_recipient_names field

Example:

custom_recipient_emails: ceo@corporation.com,president@university.edu\ncustom_recipient_names: CEO John Smith,University President Jane Doe\n

All emails will go to these addresses instead of postal code lookup.

"},{"location":"v2/user-guides/campaign-manager-guide/#10-show-progress-bar","title":"10. Show Progress Bar","text":"

What it does: Displays progress bar showing emails sent toward goal

When to enable:

  • \u2705 Have a specific email goal
  • \u2705 Want to motivate participation (\"We're 75% to our goal!\")
  • \u2705 Creating urgency

How to use:

  1. Enable flag
  2. Set email_goal field (e.g., 1000)
  3. Progress bar appears on campaign page showing current count / goal

Example display:

[=========>           ] 734 / 1,000 emails sent (73%)\n

Set Realistic Goals

Research similar campaigns to set achievable goals. Falling short publicly can be demotivating.

"},{"location":"v2/user-guides/campaign-manager-guide/#11-disable-after-date","title":"11. Disable After Date","text":"

What it does: Automatically unpublish campaign after specified date

When to enable:

  • \u2705 Time-sensitive campaign (vote deadline)
  • \u2705 Want campaign to auto-close
  • \u2705 Don't want to manually unpublish

How to use:

  1. Enable flag
  2. Set disable_date field (date picker)
  3. Campaign automatically unpublishes at midnight on that date

Example:

Legislative vote is March 15. Set disable_date to March 15, 2024. Campaign automatically closes that day.

"},{"location":"v2/user-guides/campaign-manager-guide/#12-enable-comments","title":"12. Enable Comments","text":"

What it does: Allows comments on response wall entries (discussion threads)

When to enable:

  • \u2705 Want to encourage discussion
  • \u2705 Have moderation capacity for comments
  • \u2705 Building community

When to disable:

  • \u274c No comment moderation capacity
  • \u274c Risk of hostile/off-topic discussion
  • \u274c Prefer clean, simple response wall

Experimental Feature

Comments require additional moderation. Consider carefully before enabling.

"},{"location":"v2/user-guides/campaign-manager-guide/#email-template-best-practices","title":"Email Template Best Practices","text":""},{"location":"v2/user-guides/campaign-manager-guide/#writing-effective-subject-lines","title":"Writing Effective Subject Lines","text":"

Do:

  • \u2705 Keep under 60 characters
  • \u2705 Start with action verb (Vote, Support, Oppose, Protect)
  • \u2705 Include bill number or issue name
  • \u2705 Create urgency (if appropriate)

Don't:

  • \u274c Use ALL CAPS (looks like spam)
  • \u274c Use excessive punctuation (!!!)
  • \u274c Make false claims or exaggerations
  • \u274c Use clickbait (\"You won't believe...\")

Examples:

Good Why \"Vote YES on Bill C-123 for climate action\" Clear, specific, action-oriented \"Support funding for public transit\" Simple, direct ask \"Protect our forests from logging\" Emotional appeal, clear issue Bad Why \"URGENT: Read this NOW!!!\" Spammy, no substance \"About the issue we discussed\" Vague, no context \"I am writing to you regarding...\" Wordy, buries the lede"},{"location":"v2/user-guides/campaign-manager-guide/#structuring-the-email-body","title":"Structuring the Email Body","text":"

Recommended structure:

1. Greeting\n   Dear {{REP_NAME}},\n\n2. Introduction (1 sentence)\n   Who you are, where you live\n\n3. Main ask (1 sentence)\n   What you want them to do\n\n4. Context (2-3 sentences)\n   Why it matters, impact, urgency\n\n5. Evidence (2-3 sentences)\n   Facts, statistics, expert opinions\n\n6. Specific actions (numbered list)\n   Exactly what you want them to do\n\n7. Closing (1-2 sentences)\n   Thank you, request for response\n\n8. Signature\n   {{USER_NAME}}\n   {{USER_EMAIL}}\n\n9. Personal message (optional)\n   {{USER_MESSAGE}}\n
"},{"location":"v2/user-guides/campaign-manager-guide/#using-variables-effectively","title":"Using Variables Effectively","text":"

Available variables:

Variable Description Example Output {{USER_NAME}} Sender's full name \"John Smith\" {{USER_EMAIL}} Sender's email \"john@example.com\" {{USER_PHONE}} Sender's phone \"555-1234\" {{REP_NAME}} Representative's name \"Hon. Jane Doe\" {{REP_EMAIL}} Representative's email \"jane.doe@parl.gc.ca\" {{REP_TITLE}} Representative's title \"Member of Parliament\" {{USER_MESSAGE}} Custom message (whatever user typed)

Best practices:

  1. Always use {{REP_NAME}} in greeting \u2014 Personalizes email
  2. Include {{USER_NAME}} in signature \u2014 Shows it's from a real person
  3. Add {{USER_MESSAGE}} at end \u2014 Allows personalization
  4. Use {{REP_TITLE}} for variety \u2014 Avoid repeating \"Member of Parliament\"

Example usage:

Dear {{REP_NAME}},\n\nMy name is {{USER_NAME}}, and I am a constituent in your riding. As a\n{{REP_TITLE}}, you have the power to make a difference on this issue.\n\n[... campaign message ...]\n\nI look forward to hearing your position on this matter. You can reach me\nat {{USER_EMAIL}}.\n\nSincerely,\n{{USER_NAME}}\n\n---\n\n{{USER_MESSAGE}}\n
"},{"location":"v2/user-guides/campaign-manager-guide/#html-formatting-tips","title":"HTML Formatting Tips","text":"

The email editor supports HTML. Use formatting to improve readability:

Headings:

<h3>Why This Matters</h3>\n

Bold text:

<strong>Vote YES on Bill C-123</strong>\n

Lists:

<p>I'm asking you to:</p>\n<ol>\n  <li>Vote YES when the bill comes to the floor</li>\n  <li>Speak publicly in support</li>\n  <li>Oppose weakening amendments</li>\n</ol>\n

Links:

<a href=\"https://example.com/research\">Read the full study here</a>\n

Line breaks:

<p>First paragraph.</p>\n<p>Second paragraph.</p>\n

Email Client Compatibility

Avoid complex CSS or JavaScript. Stick to basic HTML tags (p, strong, em, ul, ol, a). Many email clients strip advanced formatting.

"},{"location":"v2/user-guides/campaign-manager-guide/#publishing-your-campaign","title":"Publishing Your Campaign","text":""},{"location":"v2/user-guides/campaign-manager-guide/#pre-launch-checklist","title":"Pre-Launch Checklist","text":"

Before publishing, verify:

  • Email template proofread \u2014 No typos, grammar errors
  • Variables working \u2014 Test with your own postal code
  • Representative lookup functional \u2014 Test multiple postal codes
  • Feature flags configured \u2014 Review all 12 flags
  • Cover photo uploaded \u2014 Image displays correctly
  • Response wall ready \u2014 Moderation plan in place (if enabled)
  • Email goal set \u2014 If using progress bar
  • Disable date set \u2014 If time-sensitive campaign
  • Test email sent \u2014 Send to yourself, verify formatting

To send a test email:

  1. Edit the campaign
  2. Scroll to email section
  3. Click \"Send Test Email\"
  4. Enter your email address
  5. Check your inbox

The test email uses sample data for variables.

"},{"location":"v2/user-guides/campaign-manager-guide/#publishing","title":"Publishing","text":"

To publish:

  1. Edit the campaign
  2. Toggle \"Published\" flag to ON
  3. Click \"Save\"

The campaign is now live at /campaigns/[slug].

"},{"location":"v2/user-guides/campaign-manager-guide/#promoting-your-campaign","title":"Promoting Your Campaign","text":"

Promotion channels:

  1. Direct link: Share https://yoursite.org/campaigns/protect-our-forests
  2. Email newsletter: Include in your regular newsletter
  3. Social media: Post on Facebook, Twitter, Instagram with link
  4. Website: Add to your main website's homepage or action page
  5. Partner organizations: Ask allies to share
  6. Earned media: Pitch to journalists, bloggers

Sample social media post:

\ud83c\udf32 Our forests are in danger. Tell your MPP to stop old-growth logging.\n\n\ud83d\udce7 Send an email in under 2 minutes: [link]\n\nSo far, [X] people have taken action. Will you join them?\n\n#ProtectOurForests #ClimateAction\n

Sample email newsletter:

Subject: Take Action: Protect Our Forests\n\nHi [Name],\n\nAncient forests in our region are being clear-cut at an alarming rate.\nBut we can stop this.\n\n[Your MPP's name] has the power to enact a moratorium on old-growth\nlogging. We need you to tell them this matters to you.\n\n[CALL TO ACTION BUTTON: Send Your Email Now]\n\nIt takes less than 2 minutes. Over [X] people have already sent emails.\nTogether, we can make a difference.\n\nThank you for taking action,\n[Your organization]\n
"},{"location":"v2/user-guides/campaign-manager-guide/#monitoring-performance","title":"Monitoring Performance","text":""},{"location":"v2/user-guides/campaign-manager-guide/#campaign-email-statistics","title":"Campaign Email Statistics","text":"

To view email stats:

  1. Navigate to Influence > Campaigns
  2. Click \"Emails\" button for your campaign

The drawer shows:

Overall statistics:

  • Total emails sent: All emails successfully delivered
  • Emails waiting: Queued but not yet sent
  • Failed emails: Delivery failures
  • Success rate: Sent / (Sent + Failed)

Email list table:

  • Sender name and email
  • Recipient representative
  • Status (PENDING, SENT, FAILED)
  • Sent timestamp
  • Error message (if failed)

Screenshot placeholder: Campaign Emails drawer showing statistics and email list

"},{"location":"v2/user-guides/campaign-manager-guide/#understanding-email-status","title":"Understanding Email Status","text":"

PENDING:

  • Email is queued for sending
  • Usually sent within minutes
  • If stuck for > 1 hour, check queue (see below)

SENT:

  • Email successfully delivered to representative
  • Does NOT guarantee representative read it (that's on them)

FAILED:

  • Email delivery failed
  • Common reasons:
  • Invalid recipient email (representative email wrong in database)
  • SMTP error (email server rejected)
  • Network timeout

Retry failed emails:

  1. Click \"Retry Failed\" button
  2. System re-queues failed emails
  3. Check again in 10 minutes

Representative Emails

Representative email addresses come from the Represent API. If many emails fail to a specific representative, the database may be outdated. Contact Represent API maintainers.

"},{"location":"v2/user-guides/campaign-manager-guide/#response-wall-statistics","title":"Response Wall Statistics","text":"

To view response wall stats:

  1. Navigate to Influence > Responses
  2. Filter by your campaign

Metrics:

  • Total responses: All submissions (approved + pending + rejected)
  • Approved: Visible on public response wall
  • Pending: Awaiting moderation
  • Rejected: Hidden from public
  • Upvotes: Total upvotes across all responses

Response rate:

Response rate = Responses / Emails sent\n

Typical response rates:

  • 5-10% \u2014 Good response rate
  • 10-20% \u2014 Excellent response rate
  • < 5% \u2014 Low engagement (consider improving response wall CTA)
"},{"location":"v2/user-guides/campaign-manager-guide/#email-queue-health","title":"Email Queue Health","text":"

To monitor the queue:

  1. Navigate to Influence > Email Queue

Key metrics:

  • Waiting: Emails in queue, not yet processing
  • Normal: < 50
  • Concerning: 50-200
  • Critical: > 200 (likely queue backup)

  • Active: Emails currently being sent

  • Normal: 1-5 (concurrent workers)

  • Completed (last 24 hours): Successfully sent

  • Failed: Delivery failures

  • Normal: < 5% of sent
  • Concerning: 5-20%
  • Critical: > 20% (SMTP issue)

Queue controls:

  • Pause Queue: Emergency stop (only use during SMTP issues)
  • Resume Queue: Restart after pause
  • Retry Failed: Re-queue all failed emails
  • Clean Completed: Remove old completed jobs (frees memory)

Queue Pausing

Only pause the queue if SMTP is broken or you're changing email configuration. Citizens expect immediate sends.

"},{"location":"v2/user-guides/campaign-manager-guide/#moderating-responses","title":"Moderating Responses","text":""},{"location":"v2/user-guides/campaign-manager-guide/#response-moderation-workflow","title":"Response Moderation Workflow","text":"

To moderate responses:

  1. Navigate to Influence > Responses
  2. Filter to Status: PENDING
  3. Review each response
  4. Approve or reject

Moderation decisions:

Approve if:

  • \u2705 Authentic personal story
  • \u2705 Relates to campaign issue
  • \u2705 Respectful language
  • \u2705 Adds value to public conversation

Reject if:

  • \u274c Spam or bot submission
  • \u274c Profanity, hate speech, or harassment
  • \u274c Off-topic or unrelated to campaign
  • \u274c Contains personal information about others (privacy violation)
  • \u274c Duplicate submission (approve one, reject others)

Delete if:

  • Illegal content
  • Severe harassment or threats
  • Privacy violation (doxxing)
"},{"location":"v2/user-guides/campaign-manager-guide/#reviewing-a-response","title":"Reviewing a Response","text":"

To review in detail:

  1. Click \"View\" in Actions column
  2. Read full response text
  3. Check submitter info (name, email, timestamp)
  4. Decide: Approve, Reject, or Delete

Response detail shows:

  • Full text of response
  • Submitter name and email (not public)
  • Submission timestamp
  • Associated campaign
  • Current status
  • Upvote count (if already approved)

Actions:

  • Approve: Make public (appears on response wall)
  • Reject: Hide from public (not deleted, can reverse later)
  • Delete: Permanently remove (cannot undo)
  • Edit: Fix typos or formatting (use sparingly)

Editing Responses

Only edit responses to fix obvious typos or remove sensitive info (phone numbers, addresses). Don't change meaning.

"},{"location":"v2/user-guides/campaign-manager-guide/#moderation-best-practices","title":"Moderation Best Practices","text":"

Speed matters:

  • Review pending responses daily (at minimum)
  • For time-sensitive campaigns, review 2-3x per day
  • Long moderation delays reduce participation (people won't share if they never see results)

Consistency:

  • Use same criteria for all responses
  • Document your moderation guidelines
  • If multiple moderators, ensure they're aligned

Encourage quality:

  • Spotlight particularly good responses (if feature available)
  • Share excellent responses on social media
  • Thank respondents for sharing their stories

Handle edge cases:

  • Political/controversial: Allow diverse viewpoints as long as respectful
  • Emotional language: Allow passion, reject profanity
  • Minor inaccuracies: Approve (you're not fact-checking everything)
  • Self-promotion: Reject if primary purpose is advertising
"},{"location":"v2/user-guides/campaign-manager-guide/#responding-to-moderation-issues","title":"Responding to Moderation Issues","text":"

If you accidentally reject a good response:

  1. Find the response in table
  2. Change status from REJECTED to APPROVED
  3. Response immediately appears on response wall

If inappropriate content slips through:

  1. Find the response
  2. Change status from APPROVED to REJECTED (or delete)
  3. Response immediately removed from public view

If user complains about rejection:

  1. Review the response again
  2. If rejection was correct, explain your moderation policy
  3. If rejection was incorrect, approve and apologize
  4. Consider revising moderation guidelines to prevent future issues
"},{"location":"v2/user-guides/campaign-manager-guide/#optimization-strategies","title":"Optimization Strategies","text":""},{"location":"v2/user-guides/campaign-manager-guide/#improving-email-conversion-rates","title":"Improving Email Conversion Rates","text":"

Conversion rate = Emails sent / Page visitors

Typical conversion rates:

  • 5-10% \u2014 Average for advocacy campaigns
  • 10-20% \u2014 Good (well-designed campaign)
  • 20%+ \u2014 Excellent (highly motivated audience)

Tactics to improve conversion:

"},{"location":"v2/user-guides/campaign-manager-guide/#1-simplify-the-form","title":"1. Simplify the Form","text":"
  • Remove optional fields (phone number, custom message)
  • Use postal code autofill
  • Pre-fill email for logged-in users
"},{"location":"v2/user-guides/campaign-manager-guide/#2-reduce-friction","title":"2. Reduce Friction","text":"
  • Disable email verification (if spam isn't an issue)
  • Allow anonymous submissions (no account required)
  • Use clear, simple language
"},{"location":"v2/user-guides/campaign-manager-guide/#3-strengthen-the-call-to-action","title":"3. Strengthen the Call to Action","text":"
  • Use large, prominent \"Send Email\" button
  • Add urgency (\"Vote is tomorrow \u2014 act now!\")
  • Show social proof (\"Join 1,234 others who've sent emails\")
"},{"location":"v2/user-guides/campaign-manager-guide/#4-improve-email-template","title":"4. Improve Email Template","text":"
  • Make it personal (use variables)
  • Keep it short (200-300 words)
  • Include specific ask (bill number, action)
  • Allow personalization ({{USER_MESSAGE}})
"},{"location":"v2/user-guides/campaign-manager-guide/#5-add-trust-signals","title":"5. Add Trust Signals","text":"
  • Show organization logo
  • Display privacy policy link
  • Explain what happens after they send (\"Your representative will receive this email within minutes\")
"},{"location":"v2/user-guides/campaign-manager-guide/#ab-testing","title":"A/B Testing","text":"

Test different versions of your campaign to find what works best.

Elements to test:

  1. Email subject line
  2. Action-oriented vs question
  3. Include bill number vs generic
  4. Urgent vs neutral tone

  5. Call to action

  6. \"Send Email\" vs \"Take Action\" vs \"Email Your MP\"
  7. Button color (blue vs red vs green)
  8. Button size

  9. Campaign description

  10. Short (1 sentence) vs detailed (3 paragraphs)
  11. Emotional appeal vs factual
  12. Include statistics vs stories

  13. Feature flags

  14. Email verification ON vs OFF
  15. Response wall ON vs OFF
  16. Progress bar ON vs OFF

How to A/B test:

  1. Create two versions of the campaign (duplicate the campaign)
  2. Change ONE variable (e.g., subject line)
  3. Send 50% of traffic to each version (promote both equally)
  4. After 100+ emails sent per version, compare conversion rates
  5. Keep the winner, discard the loser

Sample A/B test:

Version A: Subject line \"Support Bill C-123 for climate action\"\nResult: 100 emails sent from 1,000 visitors = 10% conversion\n\nVersion B: Subject line \"Vote YES on climate action \u2014 your MP is listening\"\nResult: 150 emails sent from 1,000 visitors = 15% conversion\n\nWinner: Version B (50% improvement)\nAction: Update Version A subject to match Version B\n
"},{"location":"v2/user-guides/campaign-manager-guide/#encouraging-response-wall-participation","title":"Encouraging Response Wall Participation","text":"

Response wall benefits:

  • Shows public support visibly
  • Creates peer pressure (\"If they can share, so can I\")
  • Provides human stories for media and decision-makers

Tactics to increase responses:

"},{"location":"v2/user-guides/campaign-manager-guide/#1-highlight-the-response-wall","title":"1. Highlight the Response Wall","text":"
  • Add text after email send: \"Share your story with the community\"
  • Show recent responses on campaign page
  • Feature excellent responses on social media
"},{"location":"v2/user-guides/campaign-manager-guide/#2-reduce-friction_1","title":"2. Reduce Friction","text":"
  • Auto-approve responses (if audience is trusted)
  • Pre-fill response form with email content
  • Allow anonymous responses
"},{"location":"v2/user-guides/campaign-manager-guide/#3-provide-examples","title":"3. Provide Examples","text":"
  • Seed the response wall with 3-5 initial responses (from staff/volunteers)
  • Show variety of response types (personal story, factual argument, emotional appeal)
"},{"location":"v2/user-guides/campaign-manager-guide/#4-incentivize-participation","title":"4. Incentivize Participation","text":"
  • Run a contest (best response wins a prize)
  • Feature responses in newsletter
  • Invite top responders to speak at event
"},{"location":"v2/user-guides/campaign-manager-guide/#5-moderate-quickly","title":"5. Moderate Quickly","text":"
  • Approve responses within hours (not days)
  • People won't share if they never see results
"},{"location":"v2/user-guides/campaign-manager-guide/#boosting-upvotes","title":"Boosting Upvotes","text":"

Upvotes signal which responses resonate most with your community.

Tactics:

  1. Make upvoting easy: One-click, no login required
  2. Show upvote counts: Create competition
  3. Promote top responses: Share high-upvote responses on social
  4. Create urgency: \"Most upvoted response will be featured in our newsletter\"
"},{"location":"v2/user-guides/campaign-manager-guide/#reporting-and-analytics","title":"Reporting and Analytics","text":""},{"location":"v2/user-guides/campaign-manager-guide/#campaign-performance-report","title":"Campaign Performance Report","text":"

Key metrics to track:

Metric Formula Benchmark Total emails sent Count of SENT status N/A (goal-dependent) Conversion rate Emails / Page visitors 5-15% Response rate Responses / Emails sent 5-15% Upvote rate Upvotes / Responses 20-40% Email success rate SENT / (SENT + FAILED) > 95% Avg time to send Queue wait time < 5 minutes"},{"location":"v2/user-guides/campaign-manager-guide/#exporting-data","title":"Exporting Data","text":"

To export campaign data:

  1. Navigate to Influence > Campaigns
  2. Click \"Emails\" for your campaign
  3. Click \"Export CSV\"

CSV includes:

  • Sender name and email
  • Recipient representative
  • Email sent timestamp
  • Status (SENT, FAILED, PENDING)
  • Error message (if failed)

Use cases:

  • Analyze email volume by date (chart over time)
  • Identify which representatives received most emails (top targets)
  • Follow up with failed sends
  • Import into CRM or email tool

Response wall export:

  1. Navigate to Influence > Responses
  2. Filter by campaign
  3. Click \"Export CSV\"

CSV includes:

  • Respondent name and email
  • Response text
  • Submission date
  • Status (APPROVED, PENDING, REJECTED)
  • Upvote count

Use cases:

  • Analyze themes in responses (word cloud, sentiment analysis)
  • Share stories with media or decision-makers
  • Feature responses in reports or presentations
"},{"location":"v2/user-guides/campaign-manager-guide/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/user-guides/campaign-manager-guide/#low-email-conversion-rate","title":"Low Email Conversion Rate","text":"

Symptoms: Few people sending emails despite high traffic

Diagnostic questions:

  1. Is representative lookup working?
  2. Test with multiple postal codes
  3. Check representative cache (Influence > Representatives)

  4. Is the form too complex?

  5. Remove optional fields
  6. Simplify email template
  7. Disable verification

  8. Is the call to action clear?

  9. Review campaign description
  10. Check button text and prominence
  11. Add urgency or social proof

  12. Is trust an issue?

  13. Add organization branding
  14. Display privacy policy
  15. Explain what happens after they send

Solutions:

  • A/B test simpler version
  • Add trust signals (logo, privacy link)
  • Reduce form fields
  • Strengthen CTA
"},{"location":"v2/user-guides/campaign-manager-guide/#low-response-wall-participation","title":"Low Response Wall Participation","text":"

Symptoms: Emails being sent but few response wall submissions

Possible causes:

  1. Response wall not prominent
  2. Add section on campaign page highlighting response wall
  3. Show recent responses below email form

  4. Friction too high

  5. Require verification \u2192 people abandon
  6. Long approval delay \u2192 people think it didn't work

  7. No examples/social proof

  8. Empty response wall \u2192 people don't know what to share
  9. Seed with initial responses

Solutions:

  • Auto-approve responses (if trusted audience)
  • Add examples/prompts (\"Share why this issue matters to you\")
  • Feature excellent responses on social media (encourages others)
"},{"location":"v2/user-guides/campaign-manager-guide/#emails-stuck-in-queue","title":"Emails Stuck in Queue","text":"

Symptoms: Emails remain in PENDING status for > 1 hour

Diagnostic steps:

  1. Check queue status: Influence > Email Queue
  2. Check SMTP configuration: Settings > Email Configuration
  3. Test email send: Settings > Send Test Email

Common causes:

  1. Queue worker not running
  2. Contact system administrator
  3. Restart api service

  4. SMTP credentials wrong

  5. Verify username/password in Settings
  6. Send test email to verify

  7. SMTP server rejecting

  8. Check spam/rate limits on SMTP server
  9. Contact email service provider

  10. Network issue

  11. Check API server connectivity
  12. Try different SMTP provider

Emergency solution:

  • If queue is badly backed up, pause queue
  • Fix SMTP issue
  • Resume queue
  • Retry failed
"},{"location":"v2/user-guides/campaign-manager-guide/#high-email-failure-rate","title":"High Email Failure Rate","text":"

Symptoms: Many emails with FAILED status

Check error messages:

  1. \"Invalid recipient email\"
  2. Representative email is wrong in database
  3. Contact Represent API maintainers
  4. Use custom recipients as workaround

  5. \"SMTP authentication failed\"

  6. Wrong SMTP username/password
  7. Update in Settings > Email Configuration

  8. \"Connection timeout\"

  9. Network issue between API server and SMTP
  10. Contact system administrator

  11. \"Mailbox full\"

  12. Representative's email inbox is full
  13. Nothing you can do (contact representative's office)

  14. \"Spam filter rejected\"

  15. Email looks like spam
  16. Revise email template (less spammy language)
  17. Contact SMTP provider about reputation

Solutions:

  • Fix SMTP configuration
  • Update representative emails
  • Retry failed emails after fixing
"},{"location":"v2/user-guides/campaign-manager-guide/#related-documentation","title":"Related Documentation","text":"
  • Admin Guide: Full administrator guide (includes campaign management)
  • Influence Module: Technical documentation on campaigns and email system
  • Email Queue: BullMQ queue technical details
  • Response Wall: Response moderation and upvoting
  • API Reference: Influence API endpoints

Last updated: February 2026 (V2 complete)

"},{"location":"v2/user-guides/content-editor-guide/","title":"Content Editor Guide","text":""},{"location":"v2/user-guides/content-editor-guide/#overview","title":"Overview","text":"

As a Content Editor, you're responsible for creating and managing public-facing content in Changemaker Lite, including:

  • Landing pages: Custom web pages using the visual editor
  • Email templates: System email templates (welcome, password reset, shift reminders)
  • Media library: Video uploads and organization (if enabled)

This guide will help you create professional, engaging content that drives participation in your campaigns and volunteer activities.

"},{"location":"v2/user-guides/content-editor-guide/#getting-started","title":"Getting Started","text":""},{"location":"v2/user-guides/content-editor-guide/#content-editor-access","title":"Content Editor Access","text":"

Content editing features are available to:

  • SUPER_ADMIN: Full access to all content features
  • INFLUENCE_ADMIN: Email templates (for campaign-related emails)
  • MAP_ADMIN: Email templates (for shift-related emails)

Landing pages and media library are typically managed by SUPER_ADMIN only.

"},{"location":"v2/user-guides/content-editor-guide/#content-areas","title":"Content Areas","text":"

1. Landing Pages (/app/pages)

  • Custom public pages at /p/[slug]
  • Visual editor (GrapesJS) or code editor
  • Use for: Campaign pages, donation pages, event pages

2. Email Templates (/app/email-templates)

  • System email templates
  • HTML + plain text versions
  • Use for: Welcome emails, shift reminders, password resets

3. Media Library (/app/media/library, if enabled)

  • Video uploads and organization
  • Shareable public gallery
  • Use for: Testimonials, events, educational content
"},{"location":"v2/user-guides/content-editor-guide/#creating-landing-pages","title":"Creating Landing Pages","text":""},{"location":"v2/user-guides/content-editor-guide/#landing-page-overview","title":"Landing Page Overview","text":"

Landing pages are custom web pages published at /p/[slug]. Use them for:

  • Campaign-specific pages: Dedicated page for a major campaign
  • Event registration: Custom signup forms for events
  • Donation pages: Integrated donation forms
  • About pages: \"About Us\", \"Our Team\", \"Our Mission\"
  • Volunteer recruitment: Custom volunteer signup pages
"},{"location":"v2/user-guides/content-editor-guide/#creating-a-new-page","title":"Creating a New Page","text":"

To create a landing page:

  1. Navigate to Content > Landing Pages
  2. Click \"Create Page\"
  3. Fill in page details:
  4. Title: Page title (shown in browser tab, used for SEO)
  5. Slug: URL identifier (e.g., about-us \u2192 /p/about-us)
  6. Description: Meta description for SEO (160 characters max)
  7. Status: DRAFT or PUBLISHED
  8. Click \"Create\"
  9. Click \"Edit\" to open the page editor

Screenshot placeholder: Create Page modal showing title, slug, description, and status fields

"},{"location":"v2/user-guides/content-editor-guide/#page-editor-overview","title":"Page Editor Overview","text":"

The page editor has two modes:

Visual Mode (default):

  • Drag-and-drop interface (GrapesJS)
  • No coding required
  • What-you-see-is-what-you-get (WYSIWYG)
  • Best for: Non-technical users, quick page creation

Code Mode:

  • HTML/CSS editor
  • Full control over markup
  • Best for: Experienced users, complex layouts

Switch modes using the tabs at the top of the editor.

Screenshot placeholder: Page editor showing Visual/Code mode tabs and toolbar

Desktop Only

The page editor is designed for desktop use (minimum 1024px width). Mobile users will see a warning to switch to desktop.

"},{"location":"v2/user-guides/content-editor-guide/#using-the-visual-editor","title":"Using the Visual Editor","text":""},{"location":"v2/user-guides/content-editor-guide/#editor-interface","title":"Editor Interface","text":"

The visual editor has three main areas:

1. Canvas (center):

  • Preview of your page
  • Click blocks to select
  • Drag to reposition

2. Block Toolbar (left):

  • Drag blocks onto canvas
  • Categories: Layout, Text, Media, Forms, Components

3. Settings Panel (right):

  • Style selected block
  • Adjust colors, fonts, spacing
  • Configure block settings

Screenshot placeholder: Visual editor showing block toolbar, canvas, and settings panel

"},{"location":"v2/user-guides/content-editor-guide/#adding-blocks","title":"Adding Blocks","text":"

To add a block:

  1. Find block in left toolbar (or search)
  2. Drag block onto canvas
  3. Drop where you want it

Available block categories:

Layout:

  • Section: Full-width container
  • Container: Centered content wrapper
  • Row: Multi-column row
  • Column: Single column within row

Text:

  • Text: Paragraph text
  • Heading: H1, H2, H3 headings
  • Quote: Blockquote
  • List: Bulleted or numbered list

Media:

  • Image: Single image
  • Video: Embedded video (YouTube, Vimeo, or self-hosted)
  • Icon: Font Awesome icon

Forms:

  • Form: Form container
  • Input: Text input field
  • Textarea: Multi-line text input
  • Button: Submit button

Components (custom blocks):

  • Hero: Large header with background image and CTA
  • Features: Three-column feature grid
  • Testimonial: Quote with author photo
  • Call to Action: Centered CTA with button
  • Stats: Number counter grid

Screenshot placeholder: Block toolbar showing categories and block preview thumbnails

"},{"location":"v2/user-guides/content-editor-guide/#configuring-blocks","title":"Configuring Blocks","text":"

To configure a block:

  1. Click the block on canvas (selects it)
  2. Settings panel opens on right
  3. Adjust settings (varies by block type)

Common settings:

Style tab:

  • Typography: Font family, size, weight, color
  • Spacing: Margin, padding
  • Background: Color, image, gradient
  • Border: Width, color, radius
  • Dimensions: Width, height

Settings tab (varies by block):

  • Image: URL, alt text, link
  • Video: Video URL, autoplay, controls
  • Button: Text, link, style
  • Form: Action URL, method

Screenshot placeholder: Settings panel showing style options for a selected heading block

"},{"location":"v2/user-guides/content-editor-guide/#styling-blocks","title":"Styling Blocks","text":"

To change text color:

  1. Select text block
  2. Settings panel > Style tab
  3. Color picker under Typography
  4. Choose color or enter hex code

To change background:

  1. Select section or container block
  2. Settings panel > Style tab
  3. Background section
  4. Choose color, image, or gradient

To adjust spacing:

  1. Select block
  2. Settings panel > Style tab
  3. Margin/Padding section
  4. Adjust top, right, bottom, left values

Screenshot placeholder: Background settings showing color picker, image upload, and gradient options

"},{"location":"v2/user-guides/content-editor-guide/#using-pre-built-components","title":"Using Pre-Built Components","text":"

Changemaker Lite includes pre-built components for common page sections:

"},{"location":"v2/user-guides/content-editor-guide/#hero-component","title":"Hero Component","text":"

What it is: Large header section with background image, headline, and call-to-action button

How to use:

  1. Drag Hero block from Components category
  2. Click headline to edit text
  3. Click button to edit text and link
  4. Select block, then in settings:
  5. Upload background image
  6. Adjust overlay opacity
  7. Change text color

Screenshot placeholder: Hero component on canvas showing headline, subheading, and CTA button

"},{"location":"v2/user-guides/content-editor-guide/#features-component","title":"Features Component","text":"

What it is: Three-column grid showcasing features or benefits

How to use:

  1. Drag Features block onto canvas
  2. Click each feature to edit:
  3. Icon (Font Awesome icon name)
  4. Heading
  5. Description
  6. Adjust colors and spacing in settings panel

Screenshot placeholder: Features component showing three columns with icons, headings, and text

"},{"location":"v2/user-guides/content-editor-guide/#testimonial-component","title":"Testimonial Component","text":"

What it is: Quote with author photo and name

How to use:

  1. Drag Testimonial block onto canvas
  2. Click quote text to edit
  3. Click author name to edit
  4. Upload author photo in settings panel
"},{"location":"v2/user-guides/content-editor-guide/#call-to-action-component","title":"Call to Action Component","text":"

What it is: Centered section with headline and button

How to use:

  1. Drag Call to Action block onto canvas
  2. Edit headline and description
  3. Edit button text and link
  4. Adjust background color
"},{"location":"v2/user-guides/content-editor-guide/#saving-your-page","title":"Saving Your Page","text":"

To save changes:

Method 1: Keyboard shortcut

  • Press Ctrl+S (Windows/Linux) or Cmd+S (Mac)

Method 2: Save button

  • Click \"Save\" button in editor toolbar

Auto-save:

  • Changes are NOT auto-saved
  • Save frequently to avoid losing work

Save Often

Use Ctrl+S frequently. Browser crashes or network issues can cause unsaved work to be lost.

Screenshot placeholder: Save button in editor toolbar

"},{"location":"v2/user-guides/content-editor-guide/#using-the-code-editor","title":"Using the Code Editor","text":""},{"location":"v2/user-guides/content-editor-guide/#switching-to-code-mode","title":"Switching to Code Mode","text":"

To switch to code editor:

  1. Click \"Code\" tab at top of editor
  2. HTML code appears in text editor
  3. Edit HTML directly
  4. Click \"Visual\" tab to return to visual mode

When to use code mode:

  • Need precise control over HTML structure
  • Adding custom CSS or JavaScript
  • Copying HTML from another source
  • Working with complex layouts

Screenshot placeholder: Code editor showing HTML markup in text editor

"},{"location":"v2/user-guides/content-editor-guide/#html-structure","title":"HTML Structure","text":"

Basic page structure:

<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <title>Page Title</title>\n  <style>\n    /* CSS goes here */\n  </style>\n</head>\n<body>\n  <!-- Page content goes here -->\n</body>\n</html>\n

Recommended structure:

<body>\n  <!-- Hero Section -->\n  <section class=\"hero\">\n    <h1>Welcome to Our Campaign</h1>\n    <p>Join us in making a difference.</p>\n    <a href=\"/campaigns/climate-action\" class=\"btn\">Take Action</a>\n  </section>\n\n  <!-- Features Section -->\n  <section class=\"features\">\n    <div class=\"container\">\n      <div class=\"row\">\n        <div class=\"col\">\n          <h3>Easy to Use</h3>\n          <p>Send emails in under 2 minutes.</p>\n        </div>\n        <div class=\"col\">\n          <h3>High Impact</h3>\n          <p>Your voice reaches decision-makers.</p>\n        </div>\n        <div class=\"col\">\n          <h3>Community</h3>\n          <p>Join thousands of advocates.</p>\n        </div>\n      </div>\n    </div>\n  </section>\n</body>\n
"},{"location":"v2/user-guides/content-editor-guide/#adding-custom-css","title":"Adding Custom CSS","text":"

To add custom styles:

  1. In code mode, add a <style> block in the <head>:
<head>\n  <style>\n    .hero {\n      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n      color: white;\n      padding: 100px 20px;\n      text-align: center;\n    }\n\n    .hero h1 {\n      font-size: 3rem;\n      margin-bottom: 20px;\n    }\n\n    .btn {\n      background: #ff6b6b;\n      color: white;\n      padding: 15px 40px;\n      text-decoration: none;\n      border-radius: 5px;\n      display: inline-block;\n      margin-top: 20px;\n    }\n\n    .btn:hover {\n      background: #ee5a52;\n    }\n  </style>\n</head>\n
"},{"location":"v2/user-guides/content-editor-guide/#using-variables","title":"Using Variables","text":"

Landing pages support variable interpolation:

Available variables:

  • {{SITE_NAME}} \u2014 Organization name (from settings)
  • {{SITE_URL}} \u2014 Website URL
  • {{USER_NAME}} \u2014 Logged-in user's name (if authenticated)

Example usage:

<p>Welcome to {{SITE_NAME}}, {{USER_NAME}}!</p>\n

Renders as:

Welcome to Community Action Network, John Smith!\n
"},{"location":"v2/user-guides/content-editor-guide/#keyboard-shortcuts-in-code-mode","title":"Keyboard Shortcuts in Code Mode","text":"
  • Ctrl+S / Cmd+S: Save
  • Ctrl+F / Cmd+F: Find
  • Ctrl+H / Cmd+H: Find and replace
  • Tab: Indent
  • Shift+Tab: Unindent
"},{"location":"v2/user-guides/content-editor-guide/#publishing-pages","title":"Publishing Pages","text":""},{"location":"v2/user-guides/content-editor-guide/#publishing-workflow","title":"Publishing Workflow","text":"

Draft \u2192 Published:

  1. Create page (status: DRAFT)
  2. Build page in editor
  3. Preview page (see below)
  4. Publish page (change status to PUBLISHED)

Draft pages:

  • Not visible to public
  • Only accessible to admins via direct URL
  • Use for: Work in progress, testing

Published pages:

  • Visible at /p/[slug]
  • Accessible to anyone
  • Indexed by search engines (if SEO configured)
"},{"location":"v2/user-guides/content-editor-guide/#previewing-pages","title":"Previewing Pages","text":"

To preview a page before publishing:

  1. Save the page (Ctrl+S)
  2. Click \"Preview\" button in editor toolbar
  3. Page opens in new tab at /p/[slug]?preview=true

OR:

  1. Navigate to Content > Landing Pages
  2. Click page title to view published version

Screenshot placeholder: Preview button in editor toolbar

"},{"location":"v2/user-guides/content-editor-guide/#publishing-a-page","title":"Publishing a Page","text":"

To publish a draft page:

  1. Navigate to Content > Landing Pages
  2. Find the page in the table
  3. Click \"Edit\" in Actions column
  4. Change status from DRAFT to PUBLISHED
  5. Click \"Save\"

To unpublish a page:

  1. Change status from PUBLISHED to DRAFT
  2. Save

Unpublishing removes the page from public access but preserves all content.

"},{"location":"v2/user-guides/content-editor-guide/#seo-settings","title":"SEO Settings","text":"

To optimize for search engines:

  1. Edit the page
  2. Fill in SEO fields:
  3. Title: Page title (shown in search results, max 60 characters)
  4. Description: Meta description (shown in search results, max 160 characters)
  5. Keywords: Comma-separated keywords (e.g., \"climate action, advocacy, environment\")
  6. OG Image: Social media share image (Facebook, Twitter)

Best practices:

  • Title: Include primary keyword near beginning
  • Description: Compelling, action-oriented, includes keyword
  • Keywords: 5-10 relevant keywords
  • OG Image: 1200x630 px, high-quality, relevant to page content

Screenshot placeholder: SEO settings form showing title, description, keywords, and OG image fields

"},{"location":"v2/user-guides/content-editor-guide/#mkdocs-export","title":"MkDocs Export","text":"

What it is: Export landing page as Jinja2 template for MkDocs (static site generator)

Use case: Publish landing pages on your static documentation site

To export:

  1. Navigate to Content > Landing Pages
  2. Click \"Export\" in Actions column
  3. Choose export format:
  4. Jinja2 Template: Wraps HTML in MkDocs Material theme layout
  5. Standalone HTML: Raw HTML (no wrapper)
  6. File is saved to MkDocs docs/overrides/ directory
  7. Access via MkDocs site navigation

Screenshot placeholder: Export modal showing Jinja2/Standalone options

"},{"location":"v2/user-guides/content-editor-guide/#managing-email-templates","title":"Managing Email Templates","text":""},{"location":"v2/user-guides/content-editor-guide/#email-template-overview","title":"Email Template Overview","text":"

Email templates control the content and formatting of system-generated emails:

System templates:

  • Welcome Email: Sent to new users after registration
  • Password Reset: Sent when user requests password reset
  • Shift Confirmation: Sent when volunteer signs up for shift
  • Shift Reminder: Sent day before shift
  • Response Verification: Sent to verify campaign response

Custom templates:

  • Create custom templates for specific campaigns or events
  • Use in shift emails, follow-up campaigns, etc.
"},{"location":"v2/user-guides/content-editor-guide/#email-template-structure","title":"Email Template Structure","text":"

Each template has three parts:

1. Subject Line

  • Text shown in email inbox
  • Supports variables (e.g., {{USER_NAME}}, {{SHIFT_TITLE}})
  • Keep under 60 characters

2. HTML Body

  • Rich-formatted email (colors, images, links)
  • What users see in modern email clients
  • Supports variables

3. Plain Text Body

  • Unformatted text version
  • Fallback for old email clients or user preference
  • Should convey same information as HTML
"},{"location":"v2/user-guides/content-editor-guide/#editing-an-email-template","title":"Editing an Email Template","text":"

To edit a template:

  1. Navigate to Content > Email Templates
  2. Click \"Edit\" for the template you want to modify
  3. Edit subject, HTML body, and/or plain text body
  4. Click \"Preview\" to see rendered email
  5. Click \"Save\"

Screenshot placeholder: Email template editor showing subject field, HTML editor, and plain text editor

"},{"location":"v2/user-guides/content-editor-guide/#using-variables-in-templates","title":"Using Variables in Templates","text":"

Variables are placeholders that get replaced with real data when the email is sent.

Available variables:

User variables:

  • {{USER_NAME}} \u2014 User's full name
  • {{USER_EMAIL}} \u2014 User's email address

Shift variables:

  • {{SHIFT_TITLE}} \u2014 Shift name
  • {{SHIFT_START}} \u2014 Start date/time (formatted)
  • {{SHIFT_END}} \u2014 End date/time (formatted)
  • {{SHIFT_LOCATION}} \u2014 Meeting location
  • {{SHIFT_CUT}} \u2014 Cut name

Campaign variables:

  • {{CAMPAIGN_TITLE}} \u2014 Campaign name
  • {{CAMPAIGN_URL}} \u2014 Link to campaign page

System variables:

  • {{SITE_NAME}} \u2014 Organization name (from settings)
  • {{SITE_URL}} \u2014 Website URL
  • {{RESET_LINK}} \u2014 Password reset link (password reset emails only)
  • {{VERIFICATION_LINK}} \u2014 Verification link (response verification emails only)

Example template:

Subject:

Welcome to {{SITE_NAME}}, {{USER_NAME}}!\n

HTML Body:

<h1>Welcome, {{USER_NAME}}!</h1>\n\n<p>Thank you for joining {{SITE_NAME}}. We're excited to have you as part of our community.</p>\n\n<p>Here's what you can do next:</p>\n<ul>\n  <li><a href=\"{{SITE_URL}}/campaigns\">Take action on a campaign</a></li>\n  <li><a href=\"{{SITE_URL}}/shifts\">Sign up for a volunteer shift</a></li>\n  <li><a href=\"{{SITE_URL}}/app\">Explore your dashboard</a></li>\n</ul>\n\n<p>If you have questions, reply to this email or visit our <a href=\"{{SITE_URL}}/docs\">help center</a>.</p>\n\n<p>Together, we can make a difference!</p>\n\n<p>\u2014 The {{SITE_NAME}} Team</p>\n

Plain Text Body:

Welcome, {{USER_NAME}}!\n\nThank you for joining {{SITE_NAME}}. We're excited to have you as part of our community.\n\nHere's what you can do next:\n- Take action on a campaign: {{SITE_URL}}/campaigns\n- Sign up for a volunteer shift: {{SITE_URL}}/shifts\n- Explore your dashboard: {{SITE_URL}}/app\n\nIf you have questions, reply to this email or visit our help center: {{SITE_URL}}/docs.\n\nTogether, we can make a difference!\n\n\u2014 The {{SITE_NAME}} Team\n
"},{"location":"v2/user-guides/content-editor-guide/#html-email-best-practices","title":"HTML Email Best Practices","text":"

Do:

  • \u2705 Use inline CSS (not external stylesheets)
  • \u2705 Use tables for layout (old email clients don't support flexbox/grid)
  • \u2705 Test in multiple email clients (Gmail, Outlook, Apple Mail)
  • \u2705 Include alt text for images
  • \u2705 Use web-safe fonts (Arial, Verdana, Georgia)
  • \u2705 Keep width under 600px (mobile friendly)
  • \u2705 Always provide plain text version

Don't:

  • \u274c Use JavaScript (email clients strip it)
  • \u274c Use CSS positioning (absolute, fixed)
  • \u274c Use background images (not universally supported)
  • \u274c Rely on external resources (may be blocked)
  • \u274c Use tiny fonts (< 14px)
"},{"location":"v2/user-guides/content-editor-guide/#testing-email-templates","title":"Testing Email Templates","text":"

To test a template:

  1. Click \"Send Test Email\" button in editor
  2. Enter your email address
  3. Click \"Send\"
  4. Check your inbox (may take 1-2 minutes)

The test email uses sample data for variables:

  • {{USER_NAME}} \u2192 \"Test User\"
  • {{SHIFT_TITLE}} \u2192 \"Sample Shift\"
  • etc.

Test in multiple email clients:

  • Gmail (web)
  • Outlook (Windows)
  • Apple Mail (Mac/iOS)
  • Outlook.com (web)

Look for:

  • \u2705 Formatting intact (no broken layout)
  • \u2705 Images loading
  • \u2705 Links working
  • \u2705 Variables replaced correctly
  • \u2705 Readable on mobile (check phone)

Screenshot placeholder: Send Test Email modal showing email address input and send button

"},{"location":"v2/user-guides/content-editor-guide/#managing-the-media-library","title":"Managing the Media Library","text":"

Optional Feature

Media features must be enabled via Settings > Feature Toggles > ENABLE_MEDIA_FEATURES. Contact your administrator if this option is not visible.

"},{"location":"v2/user-guides/content-editor-guide/#media-library-overview","title":"Media Library Overview","text":"

The media library allows you to:

  • Upload videos: MP4, MOV, AVI, MKV, WebM, M4V, FLV
  • Organize by directory: Folder structure for categorization
  • Edit metadata: Title, description, producer, creator, tags
  • Share publicly: Publish videos to public gallery at /media
  • Lock videos: Prevent accidental deletion of important content

Use cases:

  • Event recordings (rallies, town halls, speeches)
  • Testimonials (supporter stories)
  • Educational content (issue explainers, how-to guides)
  • Promotional videos (recruitment, fundraising appeals)
"},{"location":"v2/user-guides/content-editor-guide/#uploading-videos","title":"Uploading Videos","text":"

To upload a video:

  1. Navigate to Content > Media > Library
  2. Click \"Upload Video\" button (top-right)
  3. Either:
  4. Drag and drop video file into upload area, OR
  5. Click to browse and select file
  6. Fill in metadata (see below)
  7. Click \"Upload\"

Screenshot placeholder: Upload Video modal showing drag-drop area and metadata form

Supported formats:

  • MP4 (recommended, best compatibility)
  • MOV (Apple QuickTime)
  • AVI (older format, large file size)
  • MKV (Matroska, open format)
  • WebM (web-optimized)
  • M4V (Apple iTunes)
  • FLV (Flash video, legacy)

File size limit: 10 GB per file

Upload time: Varies by file size and connection speed. A 1 GB file takes ~5-10 minutes on typical broadband.

"},{"location":"v2/user-guides/content-editor-guide/#video-metadata","title":"Video Metadata","text":"

Metadata fields:

Title (required):

  • Video title
  • Displayed in library and public gallery
  • Example: \"Climate Rally - June 2024\"

Description (optional):

  • Longer description of video content
  • Supports HTML (bold, links, etc.)
  • Displayed on video detail page

Producer (optional):

  • Organization or individual who produced the video
  • Example: \"Community Action Network\"

Creator (optional):

  • Videographer or director
  • Example: \"John Smith\"

Tags (optional):

  • Comma-separated keywords for search/filtering
  • Example: \"climate, rally, 2024, toronto\"

Directory (optional):

  • Folder path for organization
  • Use forward slashes for nested folders
  • Examples: \"events/2024\", \"testimonials\", \"educational\"

Screenshot placeholder: Metadata form showing title, description, producer, creator, tags, and directory fields

"},{"location":"v2/user-guides/content-editor-guide/#automatic-metadata-extraction","title":"Automatic Metadata Extraction","text":"

When you upload a video, the system automatically extracts:

  • Duration: Length in seconds (shown as MM:SS)
  • Dimensions: Width x height in pixels (e.g., 1920x1080)
  • Orientation: PORTRAIT, LANDSCAPE, or SQUARE
  • Quality: SD, HD, FULL_HD, or 4K
  • Has Audio: Boolean (detected from audio track)
  • File Size: Bytes (shown as MB/GB)

Quality detection:

  • SD (Standard Definition): Height < 720px
  • HD (High Definition): Height 720-1079px
  • FULL_HD (1080p): Height 1080-2159px
  • 4K (Ultra HD): Height \u2265 2160px

Orientation detection:

  • PORTRAIT: Height > Width (e.g., 1080x1920, vertical phone video)
  • LANDSCAPE: Width > Height (e.g., 1920x1080, standard video)
  • SQUARE: Width = Height (e.g., 1080x1080, Instagram video)

You cannot edit these fields manually\u2014they're extracted automatically.

"},{"location":"v2/user-guides/content-editor-guide/#organizing-videos","title":"Organizing Videos","text":"

Directory structure:

Use directories to organize videos by:

  • Type: \"events\", \"testimonials\", \"educational\", \"promotional\"
  • Year: \"2024\", \"2023\"
  • Campaign: \"climate-campaign\", \"housing-campaign\"
  • Combination: \"events/2024\", \"testimonials/climate\"

Example directory structure:

events/\n  2024/\n    rally-june.mp4\n    townhall-july.mp4\n  2023/\n    rally-september.mp4\ntestimonials/\n  climate/\n    jane-smith.mp4\n    john-doe.mp4\n  housing/\n    maria-garcia.mp4\neducational/\n  climate-101.mp4\n  how-to-canvass.mp4\n

To move videos between directories:

  1. Select videos in library (checkboxes)
  2. Choose \"Move\" from bulk actions
  3. Enter new directory path
  4. Click \"Move\"

Screenshot placeholder: Library showing directory tree sidebar and video grid

"},{"location":"v2/user-guides/content-editor-guide/#filtering-and-searching-videos","title":"Filtering and Searching Videos","text":"

To find videos:

Search:

  • Enter keywords in search box
  • Searches title, description, tags, producer, creator

Filters:

  • Directory: Show only videos in specific directory
  • Quality: Filter by SD, HD, FULL_HD, 4K
  • Orientation: Filter by portrait, landscape, square
  • Locked: Show only locked or unlocked videos

Sort:

  • Upload date (newest first, oldest first)
  • Title (A-Z, Z-A)
  • Duration (shortest first, longest first)

Screenshot placeholder: Library filters showing directory dropdown, quality checkboxes, and sort options

"},{"location":"v2/user-guides/content-editor-guide/#editing-video-metadata","title":"Editing Video Metadata","text":"

To edit a video:

  1. Click on video thumbnail (or click \"Edit\" in actions menu)
  2. Edit metadata fields
  3. Click \"Save\"

Editable fields:

  • Title
  • Description
  • Producer
  • Creator
  • Tags
  • Directory

Non-editable fields (auto-extracted):

  • Duration
  • Dimensions
  • Orientation
  • Quality
  • Has Audio
  • File Size
"},{"location":"v2/user-guides/content-editor-guide/#deleting-videos","title":"Deleting Videos","text":"

To delete a video:

  1. Select video in library
  2. Click \"Delete\" (trash icon)
  3. Confirm deletion

Permanent Deletion

Deleting a video is permanent. The video file is removed from the server and cannot be recovered.

Locked videos cannot be deleted (unlock first).

"},{"location":"v2/user-guides/content-editor-guide/#locking-videos","title":"Locking Videos","text":"

What is locking?

Locked videos cannot be:

  • Deleted
  • Moved to a different directory
  • Unshared from public gallery (if already shared)

When to lock:

  • \u2705 Important historical videos
  • \u2705 Videos currently shared publicly
  • \u2705 Videos linked from landing pages or campaigns

To lock a video:

  1. Select video
  2. Click \"Lock\" (padlock icon)

To unlock:

  1. Select locked video
  2. Click \"Unlock\"

Screenshot placeholder: Video card showing lock icon badge

"},{"location":"v2/user-guides/content-editor-guide/#sharing-videos-publicly","title":"Sharing Videos Publicly","text":""},{"location":"v2/user-guides/content-editor-guide/#public-media-gallery","title":"Public Media Gallery","text":"

The public media gallery (/media) showcases videos to the public. It's organized by categories.

Categories:

  • TESTIMONIAL: Personal stories from supporters
  • EVENT: Rally videos, town halls, speeches
  • EDUCATIONAL: Issue explainers, how-to guides
  • PROMOTIONAL: Recruitment, fundraising appeals
"},{"location":"v2/user-guides/content-editor-guide/#sharing-videos","title":"Sharing Videos","text":"

To share videos publicly:

  1. Navigate to Content > Media > Shared Media
  2. Click \"Share Videos\" button
  3. Select videos from library (search, filter, select)
  4. Choose category (TESTIMONIAL, EVENT, EDUCATIONAL, PROMOTIONAL)
  5. Click \"Share\"

Videos immediately appear on public gallery at /media.

Screenshot placeholder: Share Videos modal showing library selector, category dropdown, and share button

"},{"location":"v2/user-guides/content-editor-guide/#managing-shared-media","title":"Managing Shared Media","text":"

To view shared videos:

  1. Navigate to Content > Media > Shared Media

Table shows:

  • Video title
  • Category
  • Shared date
  • View count (if tracking enabled)
  • Actions: Unshare, change category

To unshare videos:

  1. Select videos in table
  2. Click \"Unshare\"
  3. Confirm

Videos are removed from public gallery but remain in library.

To change category:

  1. Click \"Edit\" for video
  2. Select new category
  3. Click \"Save\"
"},{"location":"v2/user-guides/content-editor-guide/#public-gallery-customization","title":"Public Gallery Customization","text":"

Gallery settings (managed by admin):

  • Gallery title (e.g., \"Our Videos\")
  • Category order
  • Videos per page
  • Allow reactions (like, love, etc.)

Ask your administrator to configure these settings.

"},{"location":"v2/user-guides/content-editor-guide/#content-best-practices","title":"Content Best Practices","text":""},{"location":"v2/user-guides/content-editor-guide/#writing-for-the-web","title":"Writing for the Web","text":"

Scannable:

  • Use headings and subheadings
  • Short paragraphs (2-3 sentences)
  • Bulleted lists
  • Bold key points

Actionable:

  • Clear call to action on every page
  • Tell users what to do next
  • Use action verbs (Join, Donate, Sign Up, Learn More)

Accessible:

  • Use alt text for images
  • Sufficient color contrast (WCAG AA: 4.5:1 for text)
  • Descriptive link text (not \"click here\")
  • Readable font size (\u2265 16px)
"},{"location":"v2/user-guides/content-editor-guide/#mobile-optimization","title":"Mobile Optimization","text":"

Mobile traffic is 50-70% of web traffic. Optimize for mobile:

Responsive design:

  • Use mobile-friendly templates
  • Test on actual phones (not just desktop browser resize)

Touch targets:

  • Buttons at least 44x44 px
  • Adequate spacing between links (avoid accidental taps)

Load time:

  • Compress images (use tools like TinyPNG)
  • Minimize video file sizes
  • Avoid large background images

Readability:

  • Large font (\u2265 16px)
  • Short paragraphs
  • Simple navigation
"},{"location":"v2/user-guides/content-editor-guide/#seo-optimization","title":"SEO Optimization","text":"

On-page SEO:

  1. Title tag: Include primary keyword, under 60 characters
  2. Meta description: Compelling, includes keyword, under 160 characters
  3. Headings: Use H1 for main title, H2 for sections, H3 for subsections
  4. Keywords: Use naturally in content (don't stuff)
  5. Internal links: Link to other pages on your site
  6. External links: Link to authoritative sources
  7. Image alt text: Describe images for screen readers and SEO

Technical SEO:

  • Fast load time (< 3 seconds)
  • Mobile-friendly
  • HTTPS (secure)
  • Clean URLs (e.g., /p/about-us, not /p/page?id=123)
"},{"location":"v2/user-guides/content-editor-guide/#accessibility","title":"Accessibility","text":"

WCAG 2.1 Level AA compliance:

Perceivable:

  • Alt text for images
  • Captions for videos
  • Color contrast (4.5:1 for text, 3:1 for large text)

Operable:

  • Keyboard navigation (all interactive elements reachable via Tab)
  • Skip links (skip to main content)
  • No keyboard traps

Understandable:

  • Clear language (avoid jargon)
  • Consistent navigation
  • Error messages explain how to fix

Robust:

  • Valid HTML (no unclosed tags, proper nesting)
  • Semantic markup (use <nav>, <main>, <article>, not just <div>)
"},{"location":"v2/user-guides/content-editor-guide/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/user-guides/content-editor-guide/#landing-pages","title":"Landing Pages","text":"

Issue: Page editor won't load

Solutions:

  1. Check browser console for errors (F12)
  2. Try different browser (Chrome recommended)
  3. Clear browser cache (Ctrl+Shift+Delete)
  4. Disable browser extensions (ad blockers may interfere)

Issue: Changes not saving

Solutions:

  1. Check internet connection
  2. Try Ctrl+S (keyboard shortcut)
  3. Check browser console for errors
  4. Try refreshing and re-editing

Issue: Page looks different when published

Causes:

  • Preview mode shows editor styles (not exact public view)
  • Browser caching old version

Solutions:

  1. Hard refresh published page (Ctrl+Shift+R)
  2. Test in incognito/private window
  3. Clear browser cache
"},{"location":"v2/user-guides/content-editor-guide/#email-templates","title":"Email Templates","text":"

Issue: Variables not replacing

Symptoms: Email shows {{USER_NAME}} instead of actual name

Causes:

  • Variable name misspelled
  • Variable not supported in this template type
  • Email sent via test (test uses sample data)

Solutions:

  1. Check variable spelling (case-sensitive)
  2. Consult variable reference (see \"Using Variables\" above)
  3. Send real email (not test) to see actual data

Issue: Email looks broken in Outlook

Causes: Outlook uses Microsoft Word rendering engine (poor CSS support)

Solutions:

  1. Use table-based layout (not flexbox/grid)
  2. Use inline CSS (not external styles)
  3. Test specifically in Outlook (use Litmus or Email on Acid)
"},{"location":"v2/user-guides/content-editor-guide/#media-library","title":"Media Library","text":"

Issue: Video won't upload

Solutions:

  1. Check file size (max 10 GB)
  2. Check file format (must be MP4, MOV, AVI, MKV, WebM, M4V, or FLV)
  3. Check internet connection (large files need stable connection)
  4. Try different browser

Issue: Metadata extraction failed

Symptoms: Duration shows \"Unknown\", quality shows \"N/A\"

Causes:

  • Video file is corrupted
  • Unsupported codec
  • FFprobe service not running (server issue)

Solutions:

  1. Try re-encoding video (use HandBrake or similar)
  2. Convert to MP4 with H.264 codec (most compatible)
  3. Contact administrator (may be server configuration issue)

Issue: Video won't play on public gallery

Causes:

  • Video not shared (still in library only)
  • Unsupported codec in browser
  • Video file missing (deleted from server)

Solutions:

  1. Verify video is shared (Content > Media > Shared Media)
  2. Re-encode as H.264 MP4 (best browser compatibility)
  3. Check server logs (ask administrator)
"},{"location":"v2/user-guides/content-editor-guide/#related-documentation","title":"Related Documentation","text":"
  • Admin Guide: Full administrator guide
  • Campaign Manager Guide: Campaign-specific content (email templates)
  • Landing Pages Feature: Technical documentation on GrapesJS editor
  • Media Library Feature: Technical documentation on video upload and storage
  • API Reference: Pages API endpoints

Last updated: February 2026 (V2 complete)

"},{"location":"v2/user-guides/map-organizer-guide/","title":"Map Organizer Guide","text":""},{"location":"v2/user-guides/map-organizer-guide/#overview","title":"Overview","text":"

As a Map Organizer, you're responsible for managing territories, coordinating volunteers, and organizing door-to-door canvassing using Changemaker Lite's Map module. This guide will help you:

  • Import and manage locations: Build your canvassing database from CSV or NAR data
  • Create territorial cuts: Divide your area into manageable canvassing zones
  • Organize volunteer shifts: Schedule and coordinate door-to-door canvassing
  • Monitor canvass progress: Track coverage, outcomes, and volunteer performance
  • Ensure data quality: Review geocoding accuracy and fix issues
  • Generate walk sheets: Create printable canvassing materials

Whether you're organizing a local ward campaign or a city-wide canvass, this guide provides strategies for effective territory management.

"},{"location":"v2/user-guides/map-organizer-guide/#understanding-map-roles","title":"Understanding Map Roles","text":"

You may have one of two roles for map management:

"},{"location":"v2/user-guides/map-organizer-guide/#super_admin","title":"SUPER_ADMIN","text":"
  • Access: Full platform access
  • Capabilities: All map functions plus user management, campaigns, site settings
  • Use case: Primary administrator
"},{"location":"v2/user-guides/map-organizer-guide/#map_admin","title":"MAP_ADMIN","text":"
  • Access: Map module only
  • Capabilities:
  • Import and manage locations
  • Create cuts
  • Organize shifts
  • Monitor canvassing
  • Generate walk sheets
  • Restrictions: Cannot manage users (except shift assignments), campaigns, or site settings
  • Use case: Dedicated field organizer without full admin access

Role Specialization

If you only manage field operations (not campaigns), ask for MAP_ADMIN role. This keeps the interface focused on your work.

"},{"location":"v2/user-guides/map-organizer-guide/#understanding-location-data","title":"Understanding Location Data","text":""},{"location":"v2/user-guides/map-organizer-guide/#what-is-a-location","title":"What is a Location?","text":"

A location is a physical address where canvassing occurs. Each location represents:

  • A single-family home, OR
  • An apartment/condo building (multi-unit), OR
  • A business address (if canvassing businesses)

Location data includes:

  • Address: Full civic address (street, city, province, postal code)
  • Coordinates: Latitude and longitude (from geocoding)
  • Building type: RESIDENTIAL, APARTMENT, BUSINESS
  • Unit count: Number of dwelling units (1 for houses, 10+ for apartments)
  • Cut assignment: Which territorial cut the location belongs to
  • Canvass history: Past visits, outcomes, support levels
"},{"location":"v2/user-guides/map-organizer-guide/#building-vs-unit-level","title":"Building vs Unit Level","text":"

Building-level data (recommended):

  • One location record per building
  • unitCount field indicates multi-unit buildings
  • Example: \"123 Main St\" with unitCount: 24 (apartment building)

Unit-level data (alternative):

  • One location record per unit
  • Example: \"123 Main St, Unit 1\", \"123 Main St, Unit 2\", etc.
  • More granular but creates more records

Recommended Approach

Use building-level data for apartments (one record with unitCount). This reduces database size and simplifies canvassing (volunteers visit building once, not once per unit).

"},{"location":"v2/user-guides/map-organizer-guide/#data-sources","title":"Data Sources","text":"

1. CSV Import \u2014 Your own data

  • Volunteer sign-up forms
  • Voter registration data
  • Membership lists
  • Custom databases

2. NAR Import \u2014 Canadian electoral data

  • Elections Canada National Address Register
  • All residential addresses in Canada
  • Pre-geocoded coordinates
  • Federal electoral districts

3. Manual Entry \u2014 Individual addresses

  • Add one location at a time via admin interface
  • Click-to-add on map
"},{"location":"v2/user-guides/map-organizer-guide/#importing-locations-from-csv","title":"Importing Locations from CSV","text":""},{"location":"v2/user-guides/map-organizer-guide/#preparing-your-csv-file","title":"Preparing Your CSV File","text":"

Required columns:

  • address \u2014 Full street address (e.g., \"123 Main St\")
  • city \u2014 City name (e.g., \"Ottawa\")
  • province \u2014 Province/state code (e.g., \"ON\", \"BC\")
  • postalCode \u2014 Postal code (e.g., \"K1A 0B1\")

Optional columns:

  • latitude \u2014 Pre-geocoded latitude (decimal degrees)
  • longitude \u2014 Pre-geocoded longitude (decimal degrees)
  • buildingType \u2014 RESIDENTIAL, APARTMENT, or BUSINESS
  • unitCount \u2014 Number of units (integer, default: 1)
  • federalDistrict \u2014 Electoral district name
  • notes \u2014 Internal notes

CSV example:

address,city,province,postalCode,buildingType,unitCount\n\"123 Main St\",\"Ottawa\",\"ON\",\"K1A 0B1\",\"RESIDENTIAL\",1\n\"456 Queen St E, Unit 5\",\"Toronto\",\"ON\",\"M5A 1T1\",\"APARTMENT\",36\n\"789 Granville St\",\"Vancouver\",\"BC\",\"V6Z 1K3\",\"RESIDENTIAL\",1\n

CSV formatting tips:

  1. Use quotes around addresses with commas
  2. Remove special characters (emoji, unusual symbols)
  3. Use UTF-8 encoding (not Windows-1252 or ASCII)
  4. One header row (first row = column names)
  5. No blank rows (delete empty rows at end)
  6. Consistent province codes (use 2-letter abbreviations)

Excel to CSV:

  1. Open your Excel file
  2. File > Save As
  3. Format: \"CSV UTF-8 (Comma delimited) (*.csv)\"
  4. Save
"},{"location":"v2/user-guides/map-organizer-guide/#importing-the-csv","title":"Importing the CSV","text":"

To import locations:

  1. Navigate to Map > Locations
  2. Click \"Import CSV\" button (top-right)
  3. Upload your CSV file (drag-drop or browse)
  4. Map CSV columns to location fields
  5. Preview imported data (first 10 rows shown)
  6. Click \"Import\"

Screenshot placeholder: CSV import dialog showing file upload area and column mapping interface

Column mapping:

The system tries to auto-detect columns, but verify:

  • CSV \"address\" \u2192 Location \"address\"
  • CSV \"city\" \u2192 Location \"city\"
  • CSV \"province\" \u2192 Location \"province\"
  • CSV \"postalCode\" \u2192 Location \"postalCode\"

If your CSV uses different column names (e.g., \"Street Address\" instead of \"address\"), map manually using the dropdowns.

What happens during import:

  1. System validates each row (checks required fields)
  2. Skips invalid rows (logs errors)
  3. Creates location records
  4. Geocodes addresses (if lat/lng not provided)
  5. Shows summary: X imported, Y skipped

Import limits:

  • Maximum 10,000 rows per import
  • For larger datasets, split into multiple files
"},{"location":"v2/user-guides/map-organizer-guide/#troubleshooting-import-issues","title":"Troubleshooting Import Issues","text":"

Issue: \"Invalid CSV format\"

Causes:

  • File is not actually CSV (e.g., Excel .xlsx)
  • Missing header row
  • Inconsistent column count (some rows have more/fewer columns)

Solutions:

  • Save as CSV UTF-8 from Excel
  • Ensure first row is headers
  • Remove blank rows and columns

Issue: \"Missing required field\"

Causes:

  • CSV missing required column (address, city, province, or postalCode)
  • Column name doesn't match (e.g., \"Street\" instead of \"address\")

Solutions:

  • Add missing column to CSV
  • Use column mapping to map \"Street\" \u2192 \"address\"

Issue: \"Geocoding failed for X addresses\"

Causes:

  • Addresses are invalid (typos, wrong format)
  • Addresses are too vague (\"Main Street\" without number)
  • Geocoding service is down

Solutions:

  • Review failed addresses in Data Quality dashboard
  • Fix typos and re-import those rows
  • Manually place locations on map (see below)
"},{"location":"v2/user-guides/map-organizer-guide/#nar-import-canadian-electoral-data","title":"NAR Import (Canadian Electoral Data)","text":""},{"location":"v2/user-guides/map-organizer-guide/#what-is-nar-data","title":"What is NAR Data?","text":"

NAR (National Address Register) is Elections Canada's official database of all residential addresses in Canada. It includes:

  • Precise civic addresses (from Address files)
  • Geocoded coordinates (from Location files)
  • Federal electoral districts
  • Building use classification (residential, commercial, institutional)

Advantages:

  • \u2705 Comprehensive (all Canadian addresses)
  • \u2705 Pre-geocoded (high accuracy)
  • \u2705 Includes federal district data
  • \u2705 Updated regularly by Elections Canada

Disadvantages:

  • \u274c Canada only (not available for other countries)
  • \u274c Requires server access to install data files
  • \u274c Large file size (multi-GB for provinces like Ontario)
"},{"location":"v2/user-guides/map-organizer-guide/#obtaining-nar-data","title":"Obtaining NAR Data","text":"

NAR data must be obtained from Elections Canada:

  1. Contact Elections Canada Open Data team
  2. Request latest NAR dataset (e.g., \"NAR 2025 Server\")
  3. Download Address and Location files
  4. Provide files to your system administrator

Files needed:

  • Address_[province]_part_[X].csv \u2014 Civic addresses
  • Location_[province].csv \u2014 Geocoded coordinates

System administrator places files in /data directory on server.

"},{"location":"v2/user-guides/map-organizer-guide/#importing-nar-data","title":"Importing NAR Data","text":"

To import NAR data:

  1. Navigate to Map > Locations
  2. Click \"NAR Import\" button
  3. Select province (e.g., Ontario)
  4. Choose dataset (if multiple years available)
  5. Apply filters (see below)
  6. Click \"Start Import\"

Screenshot placeholder: NAR Import modal showing province selector, dataset picker, and filter options

Import filters:

Province filter (required):

  • Select province to import (ON, BC, AB, etc.)
  • Each province has separate Address/Location files

City filter (optional):

  • Import only specific cities
  • Example: \"Toronto,Ottawa,Mississauga\" (comma-separated)
  • Leave blank to import entire province

Postal code filter (optional):

  • Import only specific postal code prefixes
  • Example: \"K1A,K1B,K1C\" (forward sortation areas)
  • Useful for targeting specific neighborhoods

Cut filter (optional):

  • Assign imported locations to a specific cut
  • If left blank, locations are imported without cut assignment
  • You can assign to cuts later

Residential only (toggle):

  • ON: Import only residential buildings (exclude commercial, institutional)
  • OFF: Import all buildings
  • Recommended: ON (unless you're canvassing businesses)

What happens during NAR import:

  1. System scans NAR files for selected province
  2. Joins Address and Location files on LOC_GUID (internal Elections Canada ID)
  3. Filters by city, postal code (if specified)
  4. Converts coordinates from EPSG:3347 (Lambert projection) to WGS84 (lat/lng)
  5. Creates location records
  6. Shows progress (can take several minutes for large provinces)

Import performance:

  • Small municipality (10k addresses): ~30 seconds
  • Large city (500k addresses): ~5 minutes
  • Full province (3M addresses): ~20 minutes

Server-Side Processing

NAR import runs on the server (not in your browser). Do not close the modal during import\u2014wait for completion message.

"},{"location":"v2/user-guides/map-organizer-guide/#nar-data-fields","title":"NAR Data Fields","text":"

NAR import populates these location fields:

  • address \u2014 From Address file: CIVIC_NO + OFFICIAL_STREET_NAME + STREET_TYPE + STREET_DIRECTION
  • city \u2014 From Address file: MUNICIPALITY_NAME
  • province \u2014 From province code
  • postalCode \u2014 From Address file: POSTAL_CODE
  • latitude \u2014 From Location file: BG_LATITUDE (converted to WGS84)
  • longitude \u2014 From Location file: BG_LONGITUDE (converted to WGS84)
  • federalDistrict \u2014 From Location file: FED_NUM (district number) + name lookup
  • buildingUse \u2014 From Address file: BUILDING_USE (RESIDENTIAL, COMMERCIAL, INSTITUTIONAL)
"},{"location":"v2/user-guides/map-organizer-guide/#creating-and-managing-cuts","title":"Creating and Managing Cuts","text":""},{"location":"v2/user-guides/map-organizer-guide/#what-is-a-cut","title":"What is a Cut?","text":"

A cut is a geographic area used to organize canvassing. Cuts are polygons drawn on a map.

Common cut types:

  • WARD: Municipal electoral ward
  • NEIGHBORHOOD: Informal neighborhood (e.g., \"Downtown\", \"Riverside\")
  • DISTRICT: Federal or provincial electoral district
  • CUSTOM: Any other boundary (e.g., \"North of Highway\", \"Priority Zone\")

Why use cuts?

  • \u2705 Assign territories to volunteers: \"You canvass Ward 5\"
  • \u2705 Track progress by area: \"Ward 5 is 75% complete\"
  • \u2705 Generate walk sheets: Print addresses for Ward 5 only
  • \u2705 Prevent duplication: Volunteers know their boundaries
"},{"location":"v2/user-guides/map-organizer-guide/#cut-best-practices","title":"Cut Best Practices","text":"

Size:

  • Recommended: 200-500 locations per cut
  • Too small (< 100): Inefficient (volunteers finish too quickly)
  • Too large (> 1000): Overwhelming (takes many sessions to complete)

Boundaries:

  • Use natural boundaries: Roads, rivers, parks, rail lines
  • Avoid cutting through neighborhoods arbitrarily
  • Use official boundaries when available (ward maps, district maps)

Naming:

  • Use official names when available (\"Ward 5\", \"Riverdale\")
  • Be consistent (don't mix \"Ward 5\" and \"Fifth Ward\")
  • Avoid abbreviations unless universally understood

Colors:

  • Use distinct colors for adjacent cuts
  • Use color coding meaningfully (e.g., priority cuts in red)
  • Ensure colors are visible on both light and dark backgrounds
"},{"location":"v2/user-guides/map-organizer-guide/#creating-a-cut-drawing-on-map","title":"Creating a Cut (Drawing on Map)","text":"

To create a cut:

  1. Navigate to Map > Cuts
  2. Click the \"Map Drawing\" tab
  3. Click \"Start Drawing\"
  4. Click on the map to add polygon vertices
  5. Close the polygon (click near first vertex)
  6. Fill in cut details (see form below)
  7. Click \"Save Cut\"

Screenshot placeholder: Cut drawing interface showing map with polygon being drawn

Drawing tips:

  1. Start at a corner: Begin at a distinct landmark (intersection, park corner)
  2. Follow roads: Click along roads and boundaries
  3. Use zoom: Zoom in for precision, out for overview
  4. Closing detection: System detects when you're near the first point and offers to close
  5. Undo: Click \"Undo Last Point\" if you make a mistake

Cut form fields:

Name (required):

  • Cut identifier (e.g., \"Ward 5\", \"Downtown\")
  • Displayed on map, walk sheets, volunteer portal

Category (required):

  • WARD, NEIGHBORHOOD, DISTRICT, or CUSTOM
  • Used for filtering and organizing

Color (required):

  • Display color on map
  • Use color picker or enter hex code (#FF5733)

Description (optional):

  • Internal notes about the cut
  • Example: \"Priority area, high support expected\"

Screenshot placeholder: Cut creation form showing name, category, color picker, and description

"},{"location":"v2/user-guides/map-organizer-guide/#automatic-location-assignment","title":"Automatic Location Assignment","text":"

When you save a cut, the system automatically:

  1. Checks which locations fall inside the polygon (point-in-polygon algorithm)
  2. Assigns those locations to the cut
  3. Shows count: \"X locations assigned\"

Re-assignment:

  • Locations can only belong to one cut
  • If you draw overlapping cuts, later cuts override earlier assignments
  • Review location table to verify assignments
"},{"location":"v2/user-guides/map-organizer-guide/#editing-cuts","title":"Editing Cuts","text":"

To edit a cut:

  1. Navigate to Map > Cuts
  2. Click \"Edit\" in Actions column
  3. Modify name, category, color, or description
  4. Click \"Save\"

Note: You cannot edit the polygon shape after creation. To change boundaries, delete the cut and redraw.

To delete a cut:

  1. Click \"Delete\" in Actions column
  2. Confirm deletion

What happens to locations?

  • Cut assignment is removed (locations become unassigned)
  • Locations are NOT deleted
  • Historical canvass data is preserved (visits remain linked to coordinates)
"},{"location":"v2/user-guides/map-organizer-guide/#managing-locations","title":"Managing Locations","text":""},{"location":"v2/user-guides/map-organizer-guide/#viewing-and-filtering-locations","title":"Viewing and Filtering Locations","text":"

To view all locations:

  1. Navigate to Map > Locations

The locations table shows:

  • Address: Full civic address
  • City: City name
  • Cut: Assigned cut (if any)
  • Geocoded: \u2705 (has coordinates) or \u274c (needs geocoding)
  • Last Visit: Date of most recent canvass visit
  • Actions: Edit, delete

Filters:

  • Search: Search by address or postal code
  • Cut: Filter to specific cut
  • Geocoded: Show only geocoded or ungeocoded
  • Building Type: Filter by RESIDENTIAL, APARTMENT, BUSINESS
  • Date Added: Filter by import/creation date

Screenshot placeholder: Locations table with search bar, cut filter, and geocoded status column

"},{"location":"v2/user-guides/map-organizer-guide/#editing-a-location","title":"Editing a Location","text":"

To edit a location:

  1. Click \"Edit\" in Actions column
  2. Modify fields (see below)
  3. Click \"Save\"

Editable fields:

Address details:

  • Street address
  • City
  • Province
  • Postal code

Coordinates:

  • Latitude (decimal degrees, e.g., 45.4215)
  • Longitude (decimal degrees, e.g., -75.6972)
  • Drag map pin to adjust visually

Metadata:

  • Building type (RESIDENTIAL, APARTMENT, BUSINESS)
  • Unit count (integer)
  • Federal district (text)
  • Notes (internal notes)

Cut assignment:

  • Select cut from dropdown
  • Or leave blank (unassigned)

Screenshot placeholder: Edit Location modal showing address fields, map with draggable pin, and metadata fields

"},{"location":"v2/user-guides/map-organizer-guide/#manually-placing-locations-on-map","title":"Manually Placing Locations on Map","text":"

If geocoding fails, you can manually place a location:

  1. Edit the location
  2. Use the map at the bottom of the form
  3. Drag the red pin to the correct position
  4. Latitude and longitude fields update automatically
  5. Click \"Save\"

Tip: Use satellite view or street view to identify exact building location.

"},{"location":"v2/user-guides/map-organizer-guide/#bulk-operations","title":"Bulk Operations","text":"

To perform bulk actions:

  1. Select locations (checkboxes in table)
  2. Choose action from \"Bulk Actions\" dropdown:
  3. Assign to Cut: Assign selected locations to a cut
  4. Geocode: Re-geocode selected locations
  5. Delete: Delete selected locations
  6. Confirm action

Screenshot placeholder: Bulk actions dropdown with selected locations and action buttons

"},{"location":"v2/user-guides/map-organizer-guide/#deleting-locations","title":"Deleting Locations","text":"

To delete locations:

  1. Select locations in table (or filter and select all)
  2. Choose \"Delete\" from bulk actions
  3. Confirm deletion

Canvass History Preserved

Deleting a location removes the address record but preserves canvass visit data (visits are linked to coordinates, not location IDs). Historical data remains for reporting.

"},{"location":"v2/user-guides/map-organizer-guide/#geocoding-and-data-quality","title":"Geocoding and Data Quality","text":""},{"location":"v2/user-guides/map-organizer-guide/#understanding-geocoding","title":"Understanding Geocoding","text":"

Geocoding converts addresses to latitude/longitude coordinates for map display.

Why geocoding matters:

  • Locations without coordinates cannot appear on map
  • Inaccurate coordinates place locations in wrong areas
  • Poor geocoding affects canvassing efficiency (volunteers can't find addresses)
"},{"location":"v2/user-guides/map-organizer-guide/#geocoding-providers","title":"Geocoding Providers","text":"

Changemaker Lite tries multiple geocoding providers in order:

  1. Nominatim (OpenStreetMap) \u2014 Free, no API key, global coverage
  2. ArcGIS \u2014 Free tier, accurate for North America
  3. Photon \u2014 Free, Europe-focused
  4. Mapbox \u2014 Requires API key, very accurate
  5. Google Geocoding \u2014 Requires API key, most accurate
  6. LocationIQ \u2014 Requires API key, Nominatim-based

How it works:

  • System tries Nominatim first
  • If confidence < 0.5, tries next provider
  • If all fail, location remains ungeocoded

API keys (optional, configured by admin):

  • Mapbox: MAPBOX_API_KEY
  • Google: GOOGLE_MAPS_API_KEY
  • LocationIQ: LOCATIONIQ_API_KEY

Without API keys, only free providers (Nominatim, ArcGIS, Photon) are used.

"},{"location":"v2/user-guides/map-organizer-guide/#geocode-confidence-levels","title":"Geocode Confidence Levels","text":"

Each geocoded location has a confidence score (0.0 to 1.0):

  • 0.9-1.0: High confidence (exact address match)
  • 0.7-0.9: Medium-high confidence (likely correct)
  • 0.5-0.7: Medium confidence (street or area match)
  • 0.3-0.5: Low confidence (approximate)
  • 0.0-0.3: Very low confidence (city or region only)

Confidence affects accuracy:

  • High confidence \u2192 Pin is at exact building
  • Low confidence \u2192 Pin may be at street midpoint or city center
"},{"location":"v2/user-guides/map-organizer-guide/#data-quality-dashboard","title":"Data Quality Dashboard","text":"

To review geocoding quality:

  1. Navigate to Map > Data Quality

The dashboard shows:

Statistics cards:

  • Total locations: All location records
  • Geocoded: Locations with coordinates
  • Ungeocoded: Locations without coordinates
  • Low confidence: Confidence < 0.5
  • Medium confidence: Confidence 0.5-0.8
  • High confidence: Confidence > 0.8

Geocoding provider breakdown:

  • Chart showing which providers geocoded how many locations
  • Example: 60% Nominatim, 30% ArcGIS, 10% Mapbox

Confidence distribution:

  • Histogram showing confidence score distribution
  • Identify patterns (many low-confidence addresses?)

Action items:

  • Re-geocode low confidence: Button to retry with different provider
  • Export ungeocoded: CSV of failed addresses
  • Manual review: Link to locations table filtered for low confidence

Screenshot placeholder: Data Quality Dashboard showing statistics cards, provider pie chart, and confidence histogram

"},{"location":"v2/user-guides/map-organizer-guide/#improving-geocoding-quality","title":"Improving Geocoding Quality","text":"

Strategy 1: Fix Address Typos

  1. Export ungeocoded locations (CSV)
  2. Review addresses in Excel
  3. Fix typos, formatting errors
  4. Re-import corrected CSV

Common issues:

  • Missing civic number (\"Main Street\" \u2192 \"123 Main Street\")
  • Misspelled street name (\"Mane St\" \u2192 \"Main St\")
  • Wrong province (\"ON\" \u2192 \"BC\")

Strategy 2: Re-geocode with Better Provider

  1. Configure API keys for Mapbox or Google (ask admin)
  2. Select low-confidence locations
  3. Click \"Geocode Selected\" (bulk action)
  4. System retries with all available providers

Strategy 3: Manually Place Locations

  1. Filter locations with confidence < 0.5
  2. Edit each location
  3. Find correct position on map (use satellite view)
  4. Drag pin to correct location
  5. Save

Strategy 4: Use NAR Data (Canada Only)

NAR data includes pre-geocoded coordinates with very high accuracy. If you imported from CSV and have poor geocoding, consider switching to NAR import.

"},{"location":"v2/user-guides/map-organizer-guide/#organizing-volunteer-shifts","title":"Organizing Volunteer Shifts","text":""},{"location":"v2/user-guides/map-organizer-guide/#what-is-a-shift","title":"What is a Shift?","text":"

A shift is a scheduled volunteer canvassing session. Shifts have:

  • Title: Name of the canvass (e.g., \"Saturday Morning Canvass - Ward 5\")
  • Start/End Time: When volunteers should arrive and finish
  • Cut Assignment: Which area to canvass (optional but recommended)
  • Max Signups: Capacity limit (0 = unlimited)
  • Meeting Location: Where volunteers meet before canvassing

Why shifts matter:

  • \u2705 Coordinate volunteers: Everyone knows when and where to show up
  • \u2705 Track assignments: Volunteers see \"their\" shifts in portal
  • \u2705 Enable canvassing: Volunteers can only start canvass sessions if they have a shift
  • \u2705 Measure progress: See which shifts generated most visits
"},{"location":"v2/user-guides/map-organizer-guide/#creating-a-shift","title":"Creating a Shift","text":"

To create a shift:

  1. Navigate to Map > Shifts
  2. Click \"Create Shift\"
  3. Fill in shift details (see below)
  4. Click \"Create\"

Shift fields:

Title (required):

  • Descriptive name
  • Include date, time, and area
  • Example: \"Saturday Morning Canvass - Ward 5\"

Description (optional):

  • Additional details for volunteers
  • Example: \"Bring water, comfortable shoes. We'll provide clipboards and walk sheets.\"

Start Time (required):

  • Date and time picker
  • When volunteers should arrive

End Time (required):

  • Expected end time
  • Helps volunteers plan their day

Cut (optional but recommended):

  • Select which cut to canvass
  • Volunteers assigned to this shift will see this cut in their portal
  • Shifts without cuts cannot be canvassed

Cut Assignment Required for Canvassing

Volunteers can only start canvass sessions for shifts assigned to a cut. Always assign a cut unless the shift is for training or other non-canvassing purposes.

Max Signups (optional):

  • Capacity limit (e.g., 10 volunteers)
  • Set to 0 for unlimited
  • Useful for managing group size

Meeting Location (optional):

  • Address or description of meeting point
  • Example: \"Community Centre, 123 Main St\" or \"Corner of Main & Oak\"

Screenshot placeholder: Create Shift form showing date/time picker, cut dropdown, capacity field, and meeting location

"},{"location":"v2/user-guides/map-organizer-guide/#managing-shift-signups","title":"Managing Shift Signups","text":"

To view shift signups:

  1. Navigate to Map > Shifts
  2. Click \"Signups\" in Actions column for a shift

The signups drawer shows:

Capacity gauge:

  • Current signups / Max signups
  • Example: \"8 / 10 signups (80% full)\"

Signup list:

  • Volunteer name
  • Email
  • Role (USER or TEMP)
  • Signup date
  • Actions: Remove signup, upgrade TEMP to USER

Signup sources:

  1. Public signup form (/shifts page):
  2. Anyone can sign up
  3. Creates TEMP user account automatically
  4. Sends confirmation email

  5. Admin-added:

  6. You manually add volunteers
  7. Select existing users or create new

  8. Volunteer portal:

  9. USER-role volunteers sign up themselves
  10. See My Shifts page in their portal

Screenshot placeholder: Shift Signups drawer showing capacity gauge and signup list

"},{"location":"v2/user-guides/map-organizer-guide/#adding-volunteers-to-a-shift","title":"Adding Volunteers to a Shift","text":"

To manually add a volunteer:

  1. Click \"Signups\" for the shift
  2. Click \"Add Volunteer\"
  3. Select existing user from dropdown (or click \"Create New User\")
  4. Click \"Add\"

Upgrading TEMP users to USER:

After a TEMP user attends their first shift:

  1. Open shift signups
  2. Find the TEMP user
  3. Click \"Upgrade to USER\"
  4. Confirm

This gives them full canvassing access for future shifts.

"},{"location":"v2/user-guides/map-organizer-guide/#emailing-shift-volunteers","title":"Emailing Shift Volunteers","text":"

To email all volunteers in a shift:

  1. Click \"Signups\" for the shift
  2. Click \"Email All\"
  3. Compose email:
  4. Subject
  5. Body (HTML supported)
  6. Variables: {{NAME}}, {{SHIFT_TITLE}}, {{SHIFT_START}}, {{MEETING_LOCATION}}
  7. Click \"Send\"

Common email scenarios:

Reminder (day before shift):

Subject: Reminder: Tomorrow's Canvass - {{SHIFT_TITLE}}\n\nHi {{NAME}},\n\nThis is a reminder about tomorrow's canvass:\n\nShift: {{SHIFT_TITLE}}\nTime: {{SHIFT_START}}\nMeeting Point: {{MEETING_LOCATION}}\n\nPlease arrive 10 minutes early. We'll provide walk sheets and materials.\n\nLooking forward to seeing you there!\n

Cancellation (weather, etc.):

Subject: CANCELLED: {{SHIFT_TITLE}}\n\nHi {{NAME}},\n\nUnfortunately, we need to cancel tomorrow's canvass due to severe weather.\n\nWe'll reschedule and send you a new date soon. Thank you for your understanding.\n

Follow-up (after shift):

Subject: Thank you for canvassing!\n\nHi {{NAME}},\n\nThank you for participating in {{SHIFT_TITLE}}! Your efforts made a real difference.\n\nTogether, we knocked on [X] doors and spoke with [Y] residents.\n\nSee you at the next shift!\n

Screenshot placeholder: Email Shift Volunteers modal showing subject, body editor, and variable buttons

"},{"location":"v2/user-guides/map-organizer-guide/#generating-walk-sheets","title":"Generating Walk Sheets","text":""},{"location":"v2/user-guides/map-organizer-guide/#what-is-a-walk-sheet","title":"What is a Walk Sheet?","text":"

A walk sheet is a printed list of addresses for door-to-door canvassing. It includes:

  • Cut name and statistics
  • QR code (volunteers scan to start canvass session)
  • List of addresses in walking order
  • Fields for volunteers to record outcomes
"},{"location":"v2/user-guides/map-organizer-guide/#walk-sheet-settings","title":"Walk Sheet Settings","text":"

To configure walk sheet defaults:

  1. Navigate to Map > Map Settings
  2. Scroll to \"Walk Sheet Configuration\"
  3. Set:
  4. Header Text: Organization name, campaign info
  5. Footer Text: Contact info, instructions
  6. Include QR Code: Toggle ON/OFF
  7. QR Code Size: Small, medium, large
  8. Instructions: How to use the walk sheet

Example header:

Community Action Network\nFall 2024 Canvass\nContact: organizer@example.com | (555) 123-4567\n

Example footer:

Record outcomes: NH (Not Home), R (Refused), SW (Spoke With), S1-S4 (Support Level)\nReturn completed walk sheets to the office by end of week.\n

Screenshot placeholder: Map Settings page showing walk sheet configuration section

"},{"location":"v2/user-guides/map-organizer-guide/#generating-a-walk-sheet","title":"Generating a Walk Sheet","text":"

To generate a walk sheet for a cut:

  1. Navigate to Canvass > Walk Sheet
  2. Select cut from dropdown
  3. Click \"Generate\"
  4. Review PDF preview
  5. Click \"Print\" or \"Download PDF\"

OR:

  1. Navigate to Map > Locations
  2. Filter to specific cut
  3. Click \"Walk Sheet\" button (top-right)

Walk sheet contents:

Page 1:

  • Header (from settings)
  • Cut name and statistics:
  • Total locations
  • Last visit summary
  • Completion percentage
  • QR code (links to /volunteer/canvass/[cutId])
  • Instructions (from settings)
  • Cut map (small overview map)

Subsequent pages:

  • Address table:
  • Street address
  • Unit count (if apartment building)
  • Last visit date (if previously canvassed)
  • Last outcome (if previously canvassed)
  • Blank fields for volunteers to fill:
    • Date visited
    • Outcome
    • Support level
    • Notes

Screenshot placeholder: Walk sheet PDF showing header, QR code, map, and address table

"},{"location":"v2/user-guides/map-organizer-guide/#walking-order-optimization","title":"Walking Order Optimization","text":"

Walk sheets sort addresses in walking order to minimize backtracking.

Algorithm:

  1. Start at center of cut
  2. Find nearest unvisited address
  3. Move to that address
  4. Repeat until all addresses covered

This creates an efficient route similar to the GPS route in the volunteer portal.

"},{"location":"v2/user-guides/map-organizer-guide/#using-walk-sheets-in-the-field","title":"Using Walk Sheets in the Field","text":"

Distribute to volunteers:

  1. Print one walk sheet per volunteer (or per pair, if canvassing in pairs)
  2. Bring clipboards and pens
  3. Brief volunteers on how to record outcomes

Volunteers record:

  • Date visited
  • Outcome code (NH, R, SW, etc.)
  • Support level (S1-S4 if spoke with)
  • Notes (brief comments)

After the canvass:

  1. Collect completed walk sheets
  2. Enter data into system (or scan QR code during canvass for automatic recording)
"},{"location":"v2/user-guides/map-organizer-guide/#monitoring-canvass-progress","title":"Monitoring Canvass Progress","text":""},{"location":"v2/user-guides/map-organizer-guide/#canvass-dashboard","title":"Canvass Dashboard","text":"

To view overall canvass progress:

  1. Navigate to Canvass > Dashboard

The dashboard shows:

Statistics cards:

  • Active sessions: Volunteers currently canvassing
  • Total visits today: Doors knocked today
  • Completed sessions: Finished sessions today
  • Average session duration: Time spent canvassing

Activity feed:

  • Real-time stream of visits
  • Shows: Volunteer name, address, outcome, timestamp
  • Updates every 30 seconds

Cut progress table:

  • Progress by cut (% of locations visited)
  • Session count per cut
  • Visit count per cut
  • Click cut name to view details

Leaderboard:

  • Top volunteers by visit count
  • Session count
  • Success rate (% SPOKE_WITH outcomes)

Screenshot placeholder: Canvass Dashboard showing stats cards, activity feed, cut progress table, and leaderboard

"},{"location":"v2/user-guides/map-organizer-guide/#cut-level-progress","title":"Cut-Level Progress","text":"

To view progress for a specific cut:

  1. Navigate to Canvass > Dashboard
  2. Click cut name in cut progress table

Cut detail view shows:

  • Completion gauge: % of locations visited
  • Outcome breakdown: Pie chart of outcomes (NOT_HOME, REFUSED, SPOKE_WITH, etc.)
  • Support levels: Count of LEVEL_1 through LEVEL_4
  • Visit history: Recent visits in this cut
  • Active sessions: Volunteers currently canvassing this cut

Export cut data:

  • Click \"Export CSV\" to download all visits for this cut
  • Use for analysis, reporting, follow-up planning
"},{"location":"v2/user-guides/map-organizer-guide/#session-monitoring","title":"Session Monitoring","text":"

To view active canvass sessions:

  1. Navigate to Canvass > Dashboard
  2. Scroll to \"Active Sessions\" section

Each active session shows:

  • Volunteer name
  • Cut being canvassed
  • Start time
  • Visit count
  • Last activity (how long since last visit)

Warning signs:

  • \u26a0\ufe0f No activity for > 30 minutes (volunteer may be stuck or abandoned session)
  • \u26a0\ufe0f Very low visit rate (volunteer may need help)

Actions:

  • Contact volunteer to check in
  • Manually end session if abandoned
"},{"location":"v2/user-guides/map-organizer-guide/#data-analysis-and-reporting","title":"Data Analysis and Reporting","text":""},{"location":"v2/user-guides/map-organizer-guide/#outcome-analysis","title":"Outcome Analysis","text":"

To understand canvassing results:

  1. Navigate to Canvass > Dashboard
  2. View Outcome Breakdown chart

Outcome categories:

  • NOT_HOME: Nobody answered (typical: 40-60% of visits)
  • REFUSED: Refused to talk (typical: 5-15%)
  • SPOKE_WITH: Had a conversation (typical: 20-40%)
  • MOVED_AWAY: Resident moved (typical: 2-5%)
  • WRONG_ADDRESS: Address doesn't exist (typical: 1-3%)
  • DO_NOT_CONTACT: Requested no contact (typical: < 1%)
  • OTHER: Other situation (typical: < 5%)

Interpreting outcomes:

High NOT_HOME rate (> 60%):

  • Canvassing at wrong time (try evenings or weekends)
  • Multi-unit buildings (hard to access)

High REFUSED rate (> 20%):

  • Issue is unpopular or controversial
  • Volunteers may need better training on approach
  • Consider different messaging

Low SPOKE_WITH rate (< 20%):

  • See above (related to NOT_HOME and REFUSED)
  • Canvassing at wrong time
  • Poor volunteer approach

High WRONG_ADDRESS (> 5%):

  • Data quality issues
  • Need to clean location database
"},{"location":"v2/user-guides/map-organizer-guide/#support-level-analysis","title":"Support Level Analysis","text":"

To understand voter sentiment:

  1. View Support Levels on Canvass Dashboard

Support level breakdown:

  • LEVEL_1 (Strong support): Target for GOTV (Get Out The Vote)
  • LEVEL_2 (Leaning support): Persuasion targets
  • LEVEL_3 (Undecided): Persuasion targets
  • LEVEL_4 (Opposition): Deprioritize future contact

Targeting strategy:

For GOTV:

  • Focus on LEVEL_1 (strong support)
  • Ensure they vote (door knock day before election, offer rides)

For persuasion:

  • Focus on LEVEL_2 and LEVEL_3 (undecided, leaning)
  • Provide information, answer questions, invite to events

For opposition:

  • LEVEL_4: Don't waste time (respect their decision)
"},{"location":"v2/user-guides/map-organizer-guide/#volunteer-performance","title":"Volunteer Performance","text":"

To evaluate volunteer effectiveness:

  1. View Leaderboard on Canvass Dashboard

Metrics:

  • Visit count: Total doors knocked
  • Session count: Number of canvassing sessions
  • Success rate: % of visits that resulted in SPOKE_WITH outcome
  • Average session duration: Time spent canvassing

Identifying top performers:

  • High visit count + high success rate = Star volunteer (recognize publicly, ask to mentor others)
  • High visit count + low success rate = May be rushing (provide feedback)
  • Low visit count + high success rate = Quality over quantity (consider assigning harder areas)

Coaching opportunities:

  • Low success rate: Offer training on approach, scripting
  • Short sessions: Ask why (time constraints? Lack of confidence?)
  • High REFUSED rate: Review volunteer's approach (too pushy? Poor messaging?)
"},{"location":"v2/user-guides/map-organizer-guide/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/user-guides/map-organizer-guide/#geocoding-issues","title":"Geocoding Issues","text":"

Issue: Many locations ungeocoded after import

Solutions:

  1. Review ungeocoded addresses (Data Quality > Export Ungeocoded)
  2. Fix typos and re-import
  3. Configure additional geocoding API keys (Mapbox, Google)
  4. Manually place locations on map

Issue: Locations geocoded to wrong area

Symptoms: Locations appear far from where they should be

Solutions:

  1. Check confidence score (likely low confidence)
  2. Edit location and manually place on map
  3. Re-geocode with better provider (if API key available)
"},{"location":"v2/user-guides/map-organizer-guide/#cut-issues","title":"Cut Issues","text":"

Issue: Locations not assigning to cut

Symptoms: Locations inside polygon not assigned after cut creation

Solutions:

  1. Verify polygon is properly closed (check vertices)
  2. Check for very complex polygons (may hit algorithm limits)
  3. Manually assign locations using bulk action

Issue: Overlapping cuts

Symptoms: Some locations assigned to wrong cut

Cause: Multiple cuts cover the same area

Solution:

  • Locations can only belong to one cut
  • Later cuts override earlier assignments
  • Redraw cuts to avoid overlap, OR
  • Accept overlap and use manual assignment for edge cases
"},{"location":"v2/user-guides/map-organizer-guide/#shift-issues","title":"Shift Issues","text":"

Issue: Volunteer cannot start canvass session

Symptoms: \"No active shift found\" error

Solutions:

  1. Verify shift date is today
  2. Verify volunteer is signed up for shift
  3. Verify shift has a cut assigned (required for canvassing)
  4. Verify volunteer role is USER (not TEMP)

Issue: Shift signups not appearing

Symptoms: Public signup form doesn't show shift

Solutions:

  1. Check shift start time (past shifts don't appear)
  2. Check max signups (if full, shift is hidden)
  3. Check feature toggle (Settings > Allow Public Shift Signup must be ON)
"},{"location":"v2/user-guides/map-organizer-guide/#canvassing-issues","title":"Canvassing Issues","text":"

Issue: Walking route not updating

Symptoms: Route doesn't change after completing visits

Solutions:

  1. Route updates every 30 seconds (wait a moment)
  2. Refresh volunteer's map (pull down)
  3. Check internet connection (route calculation requires server)

Issue: Visit won't save

Symptoms: Volunteer reports \"Save Visit\" doesn't work

Solutions:

  1. Check internet connection (visits save to server)
  2. Verify outcome is selected (required field)
  3. Check for abandoned session (volunteer may need to start new session)
"},{"location":"v2/user-guides/map-organizer-guide/#related-documentation","title":"Related Documentation","text":"
  • Admin Guide: Full administrator guide (includes map management)
  • Volunteer Guide: Guide for volunteers using canvassing portal
  • Map Module: Technical documentation on locations, geocoding, cuts
  • Canvassing System: Technical documentation on canvass sessions and GPS tracking
  • API Reference: Map API endpoints

Last updated: February 2026 (V2 complete)

"},{"location":"v2/user-guides/volunteer-guide/","title":"Volunteer Guide","text":""},{"location":"v2/user-guides/volunteer-guide/#overview","title":"Overview","text":"

Welcome to Changemaker Lite! As a volunteer, you'll use the volunteer portal to:

  • View your assigned shifts: See upcoming canvassing shifts you've signed up for
  • Canvas neighborhoods: Go door-to-door talking to voters
  • Record visit outcomes: Track who you spoke with and their responses
  • Navigate efficiently: Use GPS and walking routes to cover your territory
  • Track your activity: View your canvassing history and statistics

This guide will help you get started and make the most of your canvassing time.

"},{"location":"v2/user-guides/volunteer-guide/#getting-started","title":"Getting Started","text":""},{"location":"v2/user-guides/volunteer-guide/#creating-your-account","title":"Creating Your Account","text":"

There are two ways to get a volunteer account:

"},{"location":"v2/user-guides/volunteer-guide/#option-1-sign-up-for-a-shift-creates-temporary-account","title":"Option 1: Sign Up for a Shift (Creates Temporary Account)","text":"
  1. Visit the public shifts page (your organizer will send you the link)
  2. Find a shift that works for your schedule
  3. Click \"Sign Up\"
  4. Fill in:
  5. Your name
  6. Your email address
  7. Phone number (optional)
  8. Click \"Confirm Signup\"

You'll receive a confirmation email with your temporary login credentials.

Temporary Accounts

When you sign up for a shift publicly, you get a TEMP account. This gives you limited access. After your first shift, an administrator will upgrade you to a full USER account with canvassing access.

"},{"location":"v2/user-guides/volunteer-guide/#option-2-admin-creates-your-account","title":"Option 2: Admin Creates Your Account","text":"

Your organizer may create an account for you directly. You'll receive a welcome email with:

  • Your login email address
  • A temporary password
  • Instructions to change your password on first login

Screenshot placeholder: Shift signup form showing name, email, and phone fields

"},{"location":"v2/user-guides/volunteer-guide/#logging-in","title":"Logging In","text":"

To access the volunteer portal:

  1. Go to your organization's login page (usually https://app.yourorg.org)
  2. Enter your email address
  3. Enter your password
  4. Click \"Log In\"

After logging in, you'll be automatically redirected to the volunteer dashboard at /volunteer.

Remember Me

Check \"Remember me\" to stay logged in for 7 days. Only do this on your personal device.

Screenshot placeholder: Login page with email/password fields and \"Remember me\" checkbox

"},{"location":"v2/user-guides/volunteer-guide/#first-login-change-your-password","title":"First Login: Change Your Password","text":"

If you received a temporary password, change it immediately:

  1. After logging in, click your email in the top-right corner
  2. Select \"Change Password\"
  3. Enter your temporary password
  4. Enter new password (must meet requirements)
  5. Confirm new password
  6. Click \"Update Password\"

Password requirements:

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

Screenshot placeholder: Change password modal showing current/new password fields

"},{"location":"v2/user-guides/volunteer-guide/#volunteer-dashboard-overview","title":"Volunteer Dashboard Overview","text":"

Your volunteer dashboard shows:

Top Navigation:

  • Dashboard \u2014 Overview and quick stats
  • My Shifts \u2014 Upcoming and past shifts
  • My Activity \u2014 Canvassing history and statistics
  • My Routes \u2014 Maps of areas you've canvassed

Dashboard Cards:

  • Upcoming Shifts: Next 3 shifts you're signed up for
  • Your Statistics: Total visits, doors knocked, support found
  • Recent Activity: Last 10 visits you recorded
  • Quick Start: Button to start canvassing if you have an active shift

Screenshot placeholder: Volunteer dashboard showing statistics cards and upcoming shifts list

"},{"location":"v2/user-guides/volunteer-guide/#viewing-your-shifts","title":"Viewing Your Shifts","text":""},{"location":"v2/user-guides/volunteer-guide/#my-shifts-page","title":"My Shifts Page","text":"

To view all your shifts:

  1. Click \"My Shifts\" in the top navigation

The shifts page shows two tabs:

"},{"location":"v2/user-guides/volunteer-guide/#upcoming-shifts","title":"Upcoming Shifts","text":"

Shows shifts you're signed up for that haven't happened yet.

Each shift card shows:

  • Shift title: Name of the canvass
  • Date and time: When to arrive
  • Meeting location: Where to meet (address or description)
  • Cut assignment: Which area you'll be canvassing
  • Other volunteers: Who else signed up (if visible)
  • Actions: Cancel signup, view details, get directions

Screenshot placeholder: Upcoming shifts showing three shift cards with date, time, and location

"},{"location":"v2/user-guides/volunteer-guide/#past-shifts","title":"Past Shifts","text":"

Shows shifts you've completed or that have passed.

Each past shift shows:

  • Shift details
  • Your attendance (if tracked)
  • Number of visits you recorded
  • Session duration

Screenshot placeholder: Past shifts showing completed shift cards with visit counts

"},{"location":"v2/user-guides/volunteer-guide/#shift-details","title":"Shift Details","text":"

To view shift details:

  1. Click on a shift card
  2. View:
  3. Full description
  4. Map of the cut you'll canvass
  5. List of other volunteers (if visible)
  6. Instructions from organizer
  7. QR code to start canvassing (if you arrive early)

Screenshot placeholder: Shift detail modal showing map, description, and volunteer list

"},{"location":"v2/user-guides/volunteer-guide/#canceling-a-signup","title":"Canceling a Signup","text":"

To cancel a shift signup:

  1. Find the shift in My Shifts > Upcoming
  2. Click \"Cancel Signup\"
  3. Confirm cancellation

Cancel Early

Please cancel at least 24 hours before the shift if possible. Your organizer needs time to find a replacement.

You'll receive a confirmation email when you cancel.

"},{"location":"v2/user-guides/volunteer-guide/#canvassing","title":"Canvassing","text":""},{"location":"v2/user-guides/volunteer-guide/#starting-a-canvass-session","title":"Starting a Canvass Session","text":"

You can start canvassing in two ways:

"},{"location":"v2/user-guides/volunteer-guide/#method-1-from-dashboard-if-shift-is-today","title":"Method 1: From Dashboard (If Shift is Today)","text":"
  1. Go to Volunteer Dashboard
  2. If you have a shift today, you'll see a \"Start Canvassing\" button
  3. Click the button
  4. Select which shift you're canvassing for (if you have multiple)
  5. Click \"Start Session\"
"},{"location":"v2/user-guides/volunteer-guide/#method-2-from-my-shifts","title":"Method 2: From My Shifts","text":"
  1. Go to My Shifts
  2. Find today's shift
  3. Click \"Start Canvassing\"
"},{"location":"v2/user-guides/volunteer-guide/#method-3-scan-qr-code-walk-sheet","title":"Method 3: Scan QR Code (Walk Sheet)","text":"

If your organizer gave you a printed walk sheet:

  1. Open your phone's camera app
  2. Point at the QR code on the walk sheet
  3. Tap the notification that appears
  4. Your browser will open and start the session automatically

Screenshot placeholder: Start canvassing button on dashboard with shift selector dropdown

One Session at a Time

You can only have one active session. Finish your current session before starting a new one.

"},{"location":"v2/user-guides/volunteer-guide/#understanding-the-canvass-map","title":"Understanding the Canvass Map","text":"

When you start a session, you'll see a full-screen map with:

Map Elements:

  1. Your location (blue dot with accuracy circle)
  2. Updates as you move
  3. Accuracy circle shows GPS precision

  4. Locations to visit (house icons)

  5. Gray house: Not visited yet
  6. Yellow house: You visited, outcome recorded
  7. Red house: Refused to talk
  8. Green house: Supportive (LEVEL_1 or LEVEL_2)
  9. Blue house: Not home

  10. Walking route (purple line)

  11. Suggested path connecting unvisited locations
  12. Updates as you complete visits
  13. Follow the line for efficient canvassing

  14. Cut boundary (colored polygon)

  15. Your assigned territory
  16. Don't canvass outside this area

Screenshot placeholder: Canvass map showing blue location dot, house icons in different colors, and purple walking route

"},{"location":"v2/user-guides/volunteer-guide/#map-controls","title":"Map Controls","text":"

Top-left controls:

  • Menu (hamburger icon): Open navigation drawer
  • Center on me (target icon): Re-center map on your location
  • Fullscreen (expand icon): Enter fullscreen mode

Bottom toolbar:

  • Session timer: Shows how long you've been canvassing
  • Visit counter: Number of doors you've knocked
  • Next door button: Navigate to nearest unvisited location

Screenshot placeholder: Map controls showing timer, visit counter, and \"Next Door\" button

"},{"location":"v2/user-guides/volunteer-guide/#following-your-walking-route","title":"Following Your Walking Route","text":"

The purple line on the map is your suggested walking route.

How the route works:

  1. Starts at your current location
  2. Connects to nearest unvisited location
  3. Then to next nearest unvisited location
  4. And so on, minimizing backtracking

To follow the route:

  1. Look at the map
  2. Walk toward the first location on the purple line
  3. Your blue dot will move as you walk
  4. When you reach a location, tap the house icon
  5. Record your visit (see next section)
  6. The route automatically updates to skip that location

Use Turn-by-Turn Navigation

For long distances, tap a location and select \"Get Directions\" to open Google Maps for turn-by-turn navigation.

Screenshot placeholder: Walking route showing path from current location through several unvisited houses

"},{"location":"v2/user-guides/volunteer-guide/#recording-visits","title":"Recording Visits","text":"

To record a visit:

  1. Knock on the door (or ring doorbell)
  2. Wait 20-30 seconds
  3. If someone answers, have your conversation
  4. After the interaction (or non-interaction), tap the house icon on the map
  5. A bottom sheet slides up with the visit recording form

Screenshot placeholder: Bottom sheet showing visit recording form with outcome buttons

"},{"location":"v2/user-guides/volunteer-guide/#visit-outcomes","title":"Visit Outcomes","text":"

You must select one of seven outcomes:

"},{"location":"v2/user-guides/volunteer-guide/#1-not_home-nobody-answered","title":"1. NOT_HOME (Nobody Answered)","text":"

When to use:

  • Nobody answered the door
  • Waited 20-30 seconds
  • No signs of activity

What happens:

  • Location marked as \"not home\"
  • Could try again later
  • No other details needed
"},{"location":"v2/user-guides/volunteer-guide/#2-refused-refused-to-talk","title":"2. REFUSED (Refused to Talk)","text":"

When to use:

  • Someone answered but declined to talk
  • \"Not interested\"
  • Closed door immediately

What happens:

  • Location marked as \"refused\"
  • Don't visit again (respect their wishes)
  • Optional: Add notes about interaction
"},{"location":"v2/user-guides/volunteer-guide/#3-spoke_with-had-a-conversation","title":"3. SPOKE_WITH (Had a Conversation)","text":"

When to use:

  • Had a conversation (any length)
  • Discussed campaign issues
  • May or may not be supportive

What happens:

  • Prompts for support level (see below)
  • Can add notes about conversation
  • Can request sign placement

Most important outcome \u2014 this is your goal!

"},{"location":"v2/user-guides/volunteer-guide/#4-moved_away-resident-moved","title":"4. MOVED_AWAY (Resident Moved)","text":"

When to use:

  • Current resident says previous resident moved
  • For sale / for rent sign
  • Mailbox indicates new occupant

What happens:

  • Location marked as outdated
  • Helps organizer update database
"},{"location":"v2/user-guides/volunteer-guide/#5-wrong_address-location-doesnt-exist","title":"5. WRONG_ADDRESS (Location Doesn't Exist)","text":"

When to use:

  • Address doesn't exist (vacant lot, wrong number)
  • Building demolished
  • Address is commercial, not residential

What happens:

  • Flags location for removal from database
"},{"location":"v2/user-guides/volunteer-guide/#6-do_not_contact-asked-not-to-be-contacted","title":"6. DO_NOT_CONTACT (Asked Not to Be Contacted)","text":"

When to use:

  • Resident explicitly asks not to be contacted again
  • \"Please remove me from your list\"
  • Hostile response

What happens:

  • Location permanently marked \"do not contact\"
  • Will never appear on future walk sheets

Respect Privacy

Always honor \"do not contact\" requests immediately. It's legally required in many jurisdictions.

"},{"location":"v2/user-guides/volunteer-guide/#7-other-something-else","title":"7. OTHER (Something Else)","text":"

When to use:

  • Situation doesn't fit other categories
  • Special circumstances

What happens:

  • Prompts you to add notes explaining situation

Screenshot placeholder: Outcome buttons showing seven options with icons

"},{"location":"v2/user-guides/volunteer-guide/#support-levels","title":"Support Levels","text":"

When you select SPOKE_WITH, you'll be asked to rate the resident's support level.

Support Level Guide:

"},{"location":"v2/user-guides/volunteer-guide/#level_1-strong-support","title":"LEVEL_1: Strong Support","text":"
  • Definition: Enthusiastically supports your cause
  • Indicators:
  • \"Absolutely, I'm with you 100%\"
  • Asks how they can help
  • Already familiar with the issue
  • Wants to volunteer
  • Action: Ask if they want a yard sign, ask for volunteer signup
"},{"location":"v2/user-guides/volunteer-guide/#level_2-leaning-support","title":"LEVEL_2: Leaning Support","text":"
  • Definition: Generally supportive but not highly engaged
  • Indicators:
  • \"Yeah, I agree with that\"
  • Positive but brief response
  • Willing to listen
  • May have some questions
  • Action: Provide information, ask if they want updates
"},{"location":"v2/user-guides/volunteer-guide/#level_3-undecided-neutral","title":"LEVEL_3: Undecided / Neutral","text":"
  • Definition: Hasn't made up their mind
  • Indicators:
  • \"I need to think about it\"
  • Sees both sides of the issue
  • Doesn't have strong opinion
  • Wants more information
  • Action: Provide balanced information, offer to follow up
"},{"location":"v2/user-guides/volunteer-guide/#level_4-opposition","title":"LEVEL_4: Opposition","text":"
  • Definition: Opposed to your cause
  • Indicators:
  • \"I disagree with that\"
  • Supports opposing position
  • Strong opinions against
  • Action: Thank them for their time, respectfully end conversation

Be Honest

Record the support level as accurately as possible. This data helps your organizer understand the community and plan strategy.

Screenshot placeholder: Support level selector showing LEVEL_1 through LEVEL_4 with descriptions

"},{"location":"v2/user-guides/volunteer-guide/#requesting-signs","title":"Requesting Signs","text":"

If the resident is supportive (LEVEL_1 or LEVEL_2), you can mark that they want a yard sign.

To record a sign request:

  1. After selecting support level
  2. Toggle \"Wants Sign\" to ON
  3. Optionally add notes (e.g., \"Prefers small sign\", \"Needs post\")

Your organizer will see this request and arrange sign delivery.

Screenshot placeholder: Sign request toggle and notes field in visit form

"},{"location":"v2/user-guides/volunteer-guide/#taking-notes-and-photos","title":"Taking Notes and Photos","text":"

Notes field:

Use the notes field to record:

  • Key points from the conversation
  • Specific concerns the resident mentioned
  • Contact information (if they want follow-up)
  • Delivery instructions for signs
  • Any special circumstances

Example notes:

  • \"Very concerned about climate change. Has two kids. Wants to receive newsletter.\"
  • \"Undecided on issue. Worried about cost. Wants more info on funding.\"
  • \"Strong support. Already signed petition. Wants to volunteer. Email: john@example.com\"

Photo upload (optional):

Some organizations enable photo upload. You might take photos of:

  • Yard sign placements
  • Location identifiers (helps future canvassers)
  • Special notes left by resident

Privacy

Never take photos of people without permission. Only photograph property/signs if allowed by your organizer.

Screenshot placeholder: Notes textarea and photo upload button in visit form

"},{"location":"v2/user-guides/volunteer-guide/#saving-a-visit","title":"Saving a Visit","text":"

To save the visit:

  1. Select outcome
  2. Select support level (if spoke with resident)
  3. Add notes (optional)
  4. Toggle sign request (if applicable)
  5. Click \"Save Visit\"

The bottom sheet closes, the location icon changes color, and your visit counter increments.

Screenshot placeholder: Complete visit form with all fields filled and \"Save Visit\" button highlighted

"},{"location":"v2/user-guides/volunteer-guide/#skipping-a-location","title":"Skipping a Location","text":"

If you need to skip a location:

  1. Don't tap the house icon
  2. Walk to the next location on your route

Reasons to skip:

  • Dangerous dog
  • Unsafe approach (icy steps, etc.)
  • Location is inaccessible

You can come back to skipped locations later in the session.

"},{"location":"v2/user-guides/volunteer-guide/#using-gps-navigation","title":"Using GPS Navigation","text":""},{"location":"v2/user-guides/volunteer-guide/#enabling-location-permissions","title":"Enabling Location Permissions","text":"

To allow location access:

On iPhone:

  1. When app requests location, tap \"Allow While Using App\"
  2. Or go to Settings > Safari > Location > Allow

On Android:

  1. When prompted, tap \"Allow\"
  2. Or go to Settings > Apps > Chrome > Permissions > Location > Allow

Location Required

The canvassing map requires location access to show your position and update the walking route.

Screenshot placeholder: Location permission prompt on mobile browser

"},{"location":"v2/user-guides/volunteer-guide/#improving-gps-accuracy","title":"Improving GPS Accuracy","text":"

Tips for better GPS:

  1. Enable high accuracy mode
  2. iPhone: Settings > Privacy > Location Services > System Services > Improve Location
  3. Android: Settings > Location > Google Location Accuracy > ON

  4. Ensure clear sky view

  5. GPS works best outdoors
  6. Move away from tall buildings if possible
  7. Trees and structures reduce accuracy

  8. Wait for signal

  9. When you start session, GPS may take 30-60 seconds to lock
  10. Blue circle will shrink as accuracy improves

  11. Keep phone unlocked

  12. Some browsers pause location updates when screen is locked
  13. Consider increasing screen timeout

  14. Use Wi-Fi

  15. Even if not connected, enabling Wi-Fi improves location accuracy
  16. Wi-Fi scanning helps triangulate position

Screenshot placeholder: Map showing blue location dot with large accuracy circle (poor) vs small circle (good)

"},{"location":"v2/user-guides/volunteer-guide/#next-door-button","title":"\"Next Door\" Button","text":"

The \"Next Door\" button at the bottom of the map automatically:

  1. Finds the nearest unvisited location
  2. Centers map on that location
  3. Highlights the location (pulses)

When to use it:

  • You've finished a visit and want to know where to go next
  • You got turned around and need to reorient
  • You want to skip the current location and find the next one

Screenshot placeholder: \"Next Door\" button highlighted with arrow pointing to nearest unvisited location

"},{"location":"v2/user-guides/volunteer-guide/#gps-troubleshooting","title":"GPS Troubleshooting","text":"

If GPS isn't working:

  1. Refresh the page: Pull down to refresh
  2. Check permissions: Make sure location is allowed
  3. Toggle location off/on: In phone settings
  4. Restart browser: Close and reopen
  5. Try airplane mode toggle: Turn on/off to reset radios
  6. Check battery saver: Some battery saver modes disable GPS
  7. Contact your organizer: They can manually mark your visits
"},{"location":"v2/user-guides/volunteer-guide/#ending-your-session","title":"Ending Your Session","text":""},{"location":"v2/user-guides/volunteer-guide/#finishing-canvassing","title":"Finishing Canvassing","text":"

When you're done canvassing:

  1. Open the menu (hamburger icon, top-left)
  2. Tap \"End Session\"
  3. Review your session summary:
  4. Total visits
  5. Breakdown by outcome
  6. Session duration
  7. Support levels found
  8. Tap \"Confirm End Session\"

Screenshot placeholder: End session confirmation showing session statistics

"},{"location":"v2/user-guides/volunteer-guide/#session-summary","title":"Session Summary","text":"

After ending, you'll see a summary screen with:

Your results:

  • Total visits: Doors you knocked
  • Spoke with: Conversations had
  • Support found: LEVEL_1 and LEVEL_2 residents
  • Sign requests: Signs to deliver
  • Session time: How long you canvassed

What happens next:

  • Your visits are saved to the database
  • Your organizer can see your results
  • You can view your activity history in My Activity

Share Your Results

Take a screenshot of your summary to share on social media and encourage other volunteers!

Screenshot placeholder: Session summary screen showing statistics and \"Share Results\" button

"},{"location":"v2/user-guides/volunteer-guide/#abandoned-sessions","title":"Abandoned Sessions","text":"

If you forget to end your session, don't worry:

  • Sessions older than 12 hours are automatically closed
  • Your visit data is preserved
  • Next time you log in, you can start a new session
"},{"location":"v2/user-guides/volunteer-guide/#viewing-your-activity","title":"Viewing Your Activity","text":""},{"location":"v2/user-guides/volunteer-guide/#my-activity-page","title":"My Activity Page","text":"

To view your canvassing history:

  1. Click \"My Activity\" in the top navigation

The activity page shows:

Statistics cards:

  • Total visits: All-time visit count
  • Doors knocked: Total locations visited
  • Support found: LEVEL_1 and LEVEL_2 combined
  • Signs requested: Total sign requests

Outcome breakdown chart:

  • Pie chart showing % of each outcome
  • NOT_HOME, REFUSED, SPOKE_WITH, etc.
  • Helps you see patterns

Visit history table:

  • Date and time
  • Address visited
  • Outcome
  • Support level
  • Notes
  • Associated shift

Screenshot placeholder: My Activity page showing statistics, pie chart, and visit history table

"},{"location":"v2/user-guides/volunteer-guide/#filtering-your-activity","title":"Filtering Your Activity","text":"

Available filters:

  • Date range: Last 7 days, last 30 days, all time, custom
  • Outcome: Show only specific outcomes
  • Support level: Show only specific support levels
  • Shift: Show only specific shifts

Screenshot placeholder: Activity filters showing date range picker and outcome dropdown

"},{"location":"v2/user-guides/volunteer-guide/#exporting-your-data","title":"Exporting Your Data","text":"

To export your activity:

  1. Go to My Activity
  2. Apply filters (optional)
  3. Click \"Export CSV\"
  4. Open the file in Excel or Google Sheets

The export includes all visible visits with full details.

"},{"location":"v2/user-guides/volunteer-guide/#my-routes","title":"My Routes","text":""},{"location":"v2/user-guides/volunteer-guide/#viewing-past-routes","title":"Viewing Past Routes","text":"

To see where you've canvassed:

  1. Click \"My Routes\" in the top navigation

Each past session shows:

  • Map of the cut you canvassed
  • Your path (GPS track, if available)
  • Visited locations (colored by outcome)
  • Session details: Date, duration, visit count

Screenshot placeholder: My Routes showing map with GPS track and visited location markers

"},{"location":"v2/user-guides/volunteer-guide/#route-statistics","title":"Route Statistics","text":"

For each route, you can see:

  • Distance traveled: Estimated walking distance
  • Coverage: % of cut visited
  • Average time per visit: How long each interaction took
  • Efficiency: Visits per hour

This helps you improve your canvassing technique over time.

"},{"location":"v2/user-guides/volunteer-guide/#mobile-tips","title":"Mobile Tips","text":""},{"location":"v2/user-guides/volunteer-guide/#battery-saving","title":"Battery Saving","text":"

Canvassing uses GPS continuously, which drains battery. To conserve:

  1. Lower screen brightness: Adjust in quick settings
  2. Enable battery saver (after GPS locks): Reduces background activity
  3. Close other apps: Free up resources
  4. Bring portable charger: Essential for long sessions
  5. Use low power mode (cautiously): May reduce GPS accuracy

Expected battery life:

  • 2-3 hours of continuous canvassing
  • Bring charger for sessions longer than 2 hours

Screenshot placeholder: Phone battery settings showing low power mode and brightness slider

"},{"location":"v2/user-guides/volunteer-guide/#offline-considerations","title":"Offline Considerations","text":"

The canvassing app requires internet connection for:

  • Loading the map
  • Saving visits to the server
  • Updating the walking route

No Offline Mode

Currently, there's no offline mode. Ensure you have cellular data or Wi-Fi before starting.

If you lose connection:

  • Your current location still updates (GPS works offline)
  • You can still record visits (they're saved locally)
  • Visits will sync when connection returns
  • Map tiles may not load in new areas

Tips:

  • Check signal strength before starting session
  • Start session while connected (loads map data)
  • If rural area, load map of cut before leaving Wi-Fi
"},{"location":"v2/user-guides/volunteer-guide/#network-connectivity","title":"Network Connectivity","text":"

Minimum requirements:

  • 3G cellular data or better
  • Low latency (< 500ms ping)

Recommended:

  • 4G/LTE or better
  • Wi-Fi for starting session (loads initial data faster)

Data usage:

  • ~5-10 MB per hour of canvassing
  • Map tiles are the largest data use
  • Visit recording uses minimal data
"},{"location":"v2/user-guides/volunteer-guide/#safety-privacy","title":"Safety & Privacy","text":""},{"location":"v2/user-guides/volunteer-guide/#personal-safety-tips","title":"Personal Safety Tips","text":"

Before you go:

  1. Let someone know: Tell a friend/family where you'll be canvassing
  2. Bring a buddy: Canvass in pairs if possible
  3. Charge your phone: Essential for emergencies
  4. Wear comfortable shoes: You'll be walking a lot
  5. Check the weather: Dress appropriately

While canvassing:

  1. Stay in public view: Don't enter homes or yards
  2. Trust your instincts: Skip locations that feel unsafe
  3. Avoid aggressive dogs: Use the \"skip\" function
  4. Stay hydrated: Bring water, especially in summer
  5. Take breaks: Rest every hour
  6. Be aware of traffic: Look both ways before crossing streets

If you feel unsafe:

  1. Leave the area immediately
  2. Mark the location with outcome \"OTHER\" and note the safety concern
  3. Contact your organizer
  4. Call 911 if there's an emergency

Safety First

Never prioritize completing visits over your personal safety. It's always okay to skip a location or end your session early.

Screenshot placeholder: Safety checklist infographic

"},{"location":"v2/user-guides/volunteer-guide/#privacy-of-resident-information","title":"Privacy of Resident Information","text":"

What you can do with resident data:

  • Use it during your canvass session
  • Record visit outcomes and notes
  • Share relevant information with your organizer

What you cannot do:

  • Share resident information on social media
  • Use contact info for personal purposes
  • Sell or distribute the data
  • Contact residents outside official campaign activities

Legal obligations:

  • Respect \"do not contact\" requests immediately
  • Don't photograph residents without permission
  • Don't share personal details residents tell you (unless they explicitly allow)

Data you record is used for:

  • Campaign strategy and planning
  • Follow-up contact (official campaign only)
  • Sign delivery coordination
  • Voter outreach statistics

Confidentiality

Treat all resident information as confidential. Violating privacy can result in legal consequences and harm the campaign.

"},{"location":"v2/user-guides/volunteer-guide/#faqs","title":"FAQs","text":""},{"location":"v2/user-guides/volunteer-guide/#account-login","title":"Account & Login","text":"

Q: I forgot my password. How do I reset it?

A: Click \"Forgot Password\" on the login page, enter your email, and check your email for reset instructions.

Q: My email says I have a TEMP account. What does that mean?

A: TEMP accounts are created when you sign up for a shift publicly. After your first shift, an admin will upgrade you to a USER account with full access.

Q: Can I change my email address?

A: Contact your organizer to change your email. You cannot change it yourself.

"},{"location":"v2/user-guides/volunteer-guide/#shifts","title":"Shifts","text":"

Q: I signed up for a shift but didn't receive a confirmation email.

A: Check your spam folder. If still not there, contact your organizer to verify your signup.

Q: Can I sign up a friend for a shift?

A: Use the public signup form (one signup per person). Or ask your organizer to create accounts for multiple people.

Q: What if I'm running late to a shift?

A: Contact your organizer as soon as possible. You can still start canvassing when you arrive.

Q: I don't see any shifts. When will more be added?

A: Your organizer creates shifts as needed. Check back regularly or ask when the next shift will be scheduled.

"},{"location":"v2/user-guides/volunteer-guide/#canvassing_1","title":"Canvassing","text":"

Q: What should I say at the door?

A: Your organizer will provide a script or talking points. Generally: 1. Introduce yourself and your organization 2. Briefly explain why you're canvassing 3. Ask if they have time to talk 4. Respect their answer (yes or no)

Q: What if someone gets angry?

A: Stay calm, polite, and respectful. Say \"I understand, thank you for your time\" and leave. Mark as REFUSED. If threatened, leave immediately and report to your organizer.

Q: Can I canvass outside my assigned cut?

A: No, stick to your assigned territory. Other volunteers may be assigned to other cuts, and visiting outside your area creates duplication.

Q: What if I make a mistake recording a visit?

A: Contact your organizer. They can edit visit records in the admin panel.

Q: The walking route seems inefficient. Can I change it?

A: The route is generated automatically. You can visit locations in any order you prefer\u2014the route is just a suggestion.

Q: What if it starts raining?

A: Your safety comes first. End your session and seek shelter. You can resume canvassing later.

"},{"location":"v2/user-guides/volunteer-guide/#technical-issues","title":"Technical Issues","text":"

Q: The map won't load.

A: 1. Check your internet connection 2. Refresh the page (pull down) 3. Try logging out and back in 4. Try a different browser 5. Contact your organizer if still not working

Q: My location is wrong on the map.

A: 1. Make sure location permissions are enabled 2. Move to an area with clear sky view 3. Wait 1-2 minutes for GPS to improve 4. Toggle airplane mode off/on to reset GPS

Q: I can't save a visit.

A: 1. Check your internet connection (visit saves to server) 2. Make sure you selected an outcome 3. Try refreshing the page 4. If offline, visit will save when connection returns

Q: The app is slow.

A: 1. Close other apps (frees up memory) 2. Restart your browser 3. Clear browser cache (Settings > Safari/Chrome > Clear Cache) 4. Update your browser to latest version

Q: I accidentally ended my session. Can I resume?

A: No, sessions cannot be resumed. Start a new session to continue canvassing.

"},{"location":"v2/user-guides/volunteer-guide/#data-privacy","title":"Data & Privacy","text":"

Q: What data do you collect about me?

A: We collect: - Your name and email (account info) - GPS location (only during canvassing sessions) - Visit records (outcomes, notes you enter) - Session statistics (time, visit count)

Q: Is my location tracked when I'm not canvassing?

A: No, location is only accessed when you have an active canvassing session. Close your browser when done to ensure no tracking.

Q: Can other volunteers see my activity?

A: Other volunteers cannot see your activity. Only administrators can view visit records and statistics.

Q: Can I delete my account?

A: Contact your organizer to request account deletion. This will remove your personal information but preserve anonymized visit records for campaign statistics.

Q: What happens to the data I collect?

A: Visit data is used for: - Campaign strategy (identifying support levels) - Volunteer coordination (tracking coverage) - Sign delivery (fulfilling requests) - Follow-up outreach (contacting supportive residents)

Data is never sold or shared with third parties.

"},{"location":"v2/user-guides/volunteer-guide/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/user-guides/volunteer-guide/#common-issues","title":"Common Issues","text":""},{"location":"v2/user-guides/volunteer-guide/#cannot-start-canvass-session","title":"Cannot Start Canvass Session","text":"

Error: \"No active shift found\"

Solution: You need a shift assigned to you for today. Check My Shifts to see if you have any upcoming shifts. If not, sign up for a shift or contact your organizer.

Error: \"Shift has no cut assigned\"

Solution: The shift you signed up for doesn't have a territory assigned. Contact your organizer to assign a cut to the shift.

Error: \"You already have an active session\"

Solution: You have an abandoned session from a previous canvass. Contact your organizer to close the old session, or wait 12 hours for automatic cleanup.

"},{"location":"v2/user-guides/volunteer-guide/#gps-not-working","title":"GPS Not Working","text":"

Symptoms: Blue location dot doesn't appear or doesn't move

Solutions:

  1. Enable location permissions:
  2. iPhone: Settings > Safari > Location Services > While Using
  3. Android: Settings > Apps > Chrome > Permissions > Location > Allow
  4. Refresh the page: Pull down to refresh
  5. Check GPS signal: Move to an area with clear sky view
  6. Restart location services: Toggle location off/on in phone settings
  7. Try a different browser: Some browsers have better GPS support
"},{"location":"v2/user-guides/volunteer-guide/#walking-route-not-updating","title":"Walking Route Not Updating","text":"

Symptoms: Purple line doesn't change after completing visits

Solutions:

  1. Refresh the map: Pull down to refresh
  2. Check internet connection: Route updates require server communication
  3. Wait 30 seconds: Updates may be delayed
  4. Manually navigate: Use \"Next Door\" button instead of following line
"},{"location":"v2/user-guides/volunteer-guide/#visit-wont-save","title":"Visit Won't Save","text":"

Symptoms: \"Save Visit\" button doesn't work or shows error

Solutions:

  1. Check required fields: Make sure you selected an outcome
  2. Check internet connection: Visits save to server (requires connection)
  3. Try again: Close bottom sheet and tap location again
  4. Refresh page: Pull down to refresh
  5. Record offline: If persistently failing, write down visit details and report to organizer later
"},{"location":"v2/user-guides/volunteer-guide/#bottom-sheet-wont-close","title":"Bottom Sheet Won't Close","text":"

Symptoms: Visit recording form stays open after saving

Solutions:

  1. Swipe down: Swipe bottom sheet downward to close
  2. Tap outside: Tap on the map area
  3. Refresh page: Pull down to refresh
"},{"location":"v2/user-guides/volunteer-guide/#getting-help","title":"Getting Help","text":"

If you have technical issues during canvassing:

  1. Try basic troubleshooting: Refresh page, check connection
  2. Continue canvassing: Use \"Next Door\" button and visual map
  3. Take notes: Write down visit details if app fails
  4. Report to organizer: After session, explain what happened

If you have questions about canvassing technique:

  1. Ask your organizer: Before the shift
  2. Consult the script: Your organizer should provide talking points
  3. Watch experienced volunteers: Learn by observing

If you have account or scheduling issues:

  1. Contact your organizer: They have admin access to fix account problems
  2. Check your email: Look for notifications about shift changes
  3. Review this guide: Many common questions are answered here
"},{"location":"v2/user-guides/volunteer-guide/#related-documentation","title":"Related Documentation","text":"
  • Admin Guide: For organizers and administrators
  • Campaign Manager Guide: Guide to running advocacy campaigns
  • Map Organizer Guide: Guide to managing territories and canvassing operations
  • Map Module Features: Technical documentation on canvassing system
  • Canvassing System: Detailed technical documentation

Last updated: February 2026 (V2 complete)

Need help? Contact your organizer or visit the documentation at /docs.

"},{"location":"blog/archive/2025/","title":"2025","text":""}]} \ No newline at end of file diff --git a/mkdocs/site/sitemap.xml b/mkdocs/site/sitemap.xml new file mode 100644 index 00000000..4aaba79c --- /dev/null +++ b/mkdocs/site/sitemap.xml @@ -0,0 +1,835 @@ + + + + https://bnkserve.org/ + 2026-02-16 + + + https://bnkserve.org/lander/ + 2026-02-16 + + + https://bnkserve.org/blog/ + 2026-02-16 + + + https://bnkserve.org/blog/2025/07/03/blog-1/ + 2026-02-16 + + + https://bnkserve.org/blog/2025/07/10/2/ + 2026-02-16 + + + https://bnkserve.org/blog/2025/08/01/3/ + 2026-02-16 + + + https://bnkserve.org/blog/2025/09/24/4/ + 2026-02-16 + + + https://bnkserve.org/how%20to/canvass/ + 2026-02-16 + + + https://bnkserve.org/phil/ + 2026-02-16 + + + https://bnkserve.org/phil/cost-comparison/ + 2026-02-16 + + + https://bnkserve.org/v1/ + 2026-02-16 + + + https://bnkserve.org/v1/adv/ + 2026-02-16 + + + https://bnkserve.org/v1/adv/ansible/ + 2026-02-16 + + + https://bnkserve.org/v1/adv/vscode-ssh/ + 2026-02-16 + + + https://bnkserve.org/v1/build/ + 2026-02-16 + + + https://bnkserve.org/v1/build/influence/ + 2026-02-16 + + + https://bnkserve.org/v1/build/map/ + 2026-02-16 + + + https://bnkserve.org/v1/build/server/ + 2026-02-16 + + + https://bnkserve.org/v1/build/site/ + 2026-02-16 + + + https://bnkserve.org/v1/config/ + 2026-02-16 + + + https://bnkserve.org/v1/config/cloudflare-config/ + 2026-02-16 + + + https://bnkserve.org/v1/config/coder/ + 2026-02-16 + + + https://bnkserve.org/v1/config/map/ + 2026-02-16 + + + https://bnkserve.org/v1/config/mkdocs/ + 2026-02-16 + + + https://bnkserve.org/v1/manual/ + 2026-02-16 + + + https://bnkserve.org/v1/manual/map/ + 2026-02-16 + + + https://bnkserve.org/v1/services/ + 2026-02-16 + + + https://bnkserve.org/v1/services/code-server/ + 2026-02-16 + + + https://bnkserve.org/v1/services/gitea/ + 2026-02-16 + + + https://bnkserve.org/v1/services/homepage/ + 2026-02-16 + + + https://bnkserve.org/v1/services/listmonk/ + 2026-02-16 + + + https://bnkserve.org/v1/services/map/ + 2026-02-16 + + + https://bnkserve.org/v1/services/mini-qr/ + 2026-02-16 + + + https://bnkserve.org/v1/services/mkdocs/ + 2026-02-16 + + + https://bnkserve.org/v1/services/n8n/ + 2026-02-16 + + + https://bnkserve.org/v1/services/nocodb/ + 2026-02-16 + + + https://bnkserve.org/v1/services/postgresql/ + 2026-02-16 + + + https://bnkserve.org/v1/services/static-server/ + 2026-02-16 + + + https://bnkserve.org/v2/ + 2026-02-16 + + + https://bnkserve.org/v2/api-reference/ + 2026-02-16 + + + https://bnkserve.org/v2/architecture/ + 2026-02-16 + + + https://bnkserve.org/v2/architecture/authentication/ + 2026-02-16 + + + https://bnkserve.org/v2/architecture/dual-api/ + 2026-02-16 + + + https://bnkserve.org/v2/backend/ + 2026-02-16 + + + https://bnkserve.org/v2/backend/middleware/ + 2026-02-16 + + + https://bnkserve.org/v2/backend/modules/ + 2026-02-16 + + + https://bnkserve.org/v2/backend/modules/auth/ + 2026-02-16 + + + https://bnkserve.org/v2/backend/modules/campaigns/ + 2026-02-16 + + + https://bnkserve.org/v2/backend/modules/canvass/ + 2026-02-16 + + + https://bnkserve.org/v2/backend/modules/locations/ + 2026-02-16 + + + https://bnkserve.org/v2/backend/modules/media/ + 2026-02-16 + + + https://bnkserve.org/v2/backend/modules/pages/ + 2026-02-16 + + + https://bnkserve.org/v2/backend/modules/representatives/ + 2026-02-16 + + + https://bnkserve.org/v2/backend/modules/responses/ + 2026-02-16 + + + https://bnkserve.org/v2/backend/modules/settings/ + 2026-02-16 + + + https://bnkserve.org/v2/backend/modules/shifts/ + 2026-02-16 + + + https://bnkserve.org/v2/backend/modules/users/ + 2026-02-16 + + + https://bnkserve.org/v2/backend/services/ + 2026-02-16 + + + https://bnkserve.org/v2/backend/utilities/ + 2026-02-16 + + + https://bnkserve.org/v2/contributing/ + 2026-02-16 + + + https://bnkserve.org/v2/contributing/code-of-conduct/ + 2026-02-16 + + + https://bnkserve.org/v2/contributing/development-setup/ + 2026-02-16 + + + https://bnkserve.org/v2/contributing/pull-requests/ + 2026-02-16 + + + https://bnkserve.org/v2/contributing/roadmap/ + 2026-02-16 + + + https://bnkserve.org/v2/database/ + 2026-02-16 + + + https://bnkserve.org/v2/database/indexes/ + 2026-02-16 + + + https://bnkserve.org/v2/database/migrations/ + 2026-02-16 + + + https://bnkserve.org/v2/database/schema/ + 2026-02-16 + + + https://bnkserve.org/v2/database/seeding/ + 2026-02-16 + + + https://bnkserve.org/v2/database/models/ + 2026-02-16 + + + https://bnkserve.org/v2/database/models/auth/ + 2026-02-16 + + + https://bnkserve.org/v2/database/models/canvass/ + 2026-02-16 + + + https://bnkserve.org/v2/database/models/email-templates/ + 2026-02-16 + + + https://bnkserve.org/v2/database/models/influence/ + 2026-02-16 + + + https://bnkserve.org/v2/database/models/map/ + 2026-02-16 + + + https://bnkserve.org/v2/database/models/media/ + 2026-02-16 + + + https://bnkserve.org/v2/database/models/pages/ + 2026-02-16 + + + https://bnkserve.org/v2/database/models/settings/ + 2026-02-16 + + + https://bnkserve.org/v2/deployment/ + 2026-02-16 + + + https://bnkserve.org/v2/deployment/backup-restore/ + 2026-02-16 + + + https://bnkserve.org/v2/deployment/docker-compose/ + 2026-02-16 + + + https://bnkserve.org/v2/deployment/environment-variables/ + 2026-02-16 + + + https://bnkserve.org/v2/deployment/healthchecks/ + 2026-02-16 + + + https://bnkserve.org/v2/deployment/monitoring-stack/ + 2026-02-16 + + + https://bnkserve.org/v2/deployment/nginx/ + 2026-02-16 + + + https://bnkserve.org/v2/deployment/scaling/ + 2026-02-16 + + + https://bnkserve.org/v2/deployment/ssl-tls/ + 2026-02-16 + + + https://bnkserve.org/v2/deployment/tunneling/ + 2026-02-16 + + + https://bnkserve.org/v2/development/ + 2026-02-16 + + + https://bnkserve.org/v2/development/code-style/ + 2026-02-16 + + + https://bnkserve.org/v2/development/debugging/ + 2026-02-16 + + + https://bnkserve.org/v2/development/docker-workflow/ + 2026-02-16 + + + https://bnkserve.org/v2/development/git-workflow/ + 2026-02-16 + + + https://bnkserve.org/v2/development/local-setup/ + 2026-02-16 + + + https://bnkserve.org/v2/development/migrations/ + 2026-02-16 + + + https://bnkserve.org/v2/development/npm-commands/ + 2026-02-16 + + + https://bnkserve.org/v2/development/testing/ + 2026-02-16 + + + https://bnkserve.org/v2/development/typescript/ + 2026-02-16 + + + https://bnkserve.org/v2/features/ + 2026-02-16 + + + https://bnkserve.org/v2/features/COMPLETION_STATUS/ + 2026-02-16 + + + https://bnkserve.org/v2/features/email-templates/ + 2026-02-16 + + + https://bnkserve.org/v2/features/email-templates/editor/ + 2026-02-16 + + + https://bnkserve.org/v2/features/email-templates/template-system/ + 2026-02-16 + + + https://bnkserve.org/v2/features/email-templates/variables/ + 2026-02-16 + + + https://bnkserve.org/v2/features/email-templates/versioning/ + 2026-02-16 + + + https://bnkserve.org/v2/features/influence/ + 2026-02-16 + + + https://bnkserve.org/v2/features/influence/campaigns/ + 2026-02-16 + + + https://bnkserve.org/v2/features/influence/email-queue/ + 2026-02-16 + + + https://bnkserve.org/v2/features/influence/postal-codes/ + 2026-02-16 + + + https://bnkserve.org/v2/features/influence/representatives/ + 2026-02-16 + + + https://bnkserve.org/v2/features/influence/responses/ + 2026-02-16 + + + https://bnkserve.org/v2/features/landing-pages/ + 2026-02-16 + + + https://bnkserve.org/v2/features/map/ + 2026-02-16 + + + https://bnkserve.org/v2/features/map/MAP_FEATURES_STATUS/ + 2026-02-16 + + + https://bnkserve.org/v2/features/map/canvassing/ + 2026-02-16 + + + https://bnkserve.org/v2/features/map/cuts/ + 2026-02-16 + + + https://bnkserve.org/v2/features/map/data-quality/ + 2026-02-16 + + + https://bnkserve.org/v2/features/map/geocoding/ + 2026-02-16 + + + https://bnkserve.org/v2/features/map/locations/ + 2026-02-16 + + + https://bnkserve.org/v2/features/map/nar-import/ + 2026-02-16 + + + https://bnkserve.org/v2/features/map/shifts/ + 2026-02-16 + + + https://bnkserve.org/v2/features/map/tracking/ + 2026-02-16 + + + https://bnkserve.org/v2/features/map/walk-sheets/ + 2026-02-16 + + + https://bnkserve.org/v2/features/media/ + 2026-02-16 + + + https://bnkserve.org/v2/features/media/jobs/ + 2026-02-16 + + + https://bnkserve.org/v2/features/media/public-gallery/ + 2026-02-16 + + + https://bnkserve.org/v2/features/media/upload/ + 2026-02-16 + + + https://bnkserve.org/v2/features/media/video-library/ + 2026-02-16 + + + https://bnkserve.org/v2/features/newsletter/ + 2026-02-16 + + + https://bnkserve.org/v2/features/observability/ + 2026-02-16 + + + https://bnkserve.org/v2/features/pages/block-library/ + 2026-02-16 + + + https://bnkserve.org/v2/features/pages/grapes-editor/ + 2026-02-16 + + + https://bnkserve.org/v2/features/pages/mkdocs-export/ + 2026-02-16 + + + https://bnkserve.org/v2/features/pages/page-builder/ + 2026-02-16 + + + https://bnkserve.org/v2/features/tunnel/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/components/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/layouts/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/campaigns-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/canvass-dashboard-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/code-editor-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/cut-export-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/cuts-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/dashboard-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/data-quality-dashboard-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/docs-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/email-queue-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/email-template-editor-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/email-templates-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/gitea-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/landing-pages-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/listmonk-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/locations-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/mailhog-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/map-settings-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/mini-qr-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/mkdocs-settings-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/n8n-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/nocodb-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/observability-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/page-editor-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/pangolin-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/representatives-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/responses-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/settings-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/shifts-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/users-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/admin/walk-sheet-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/public/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/public/campaign-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/public/campaigns-list-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/public/landing-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/public/map-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/public/media-gallery-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/public/media-viewer-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/public/response-wall-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/public/shifts-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/volunteer/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/volunteer/my-activity-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/volunteer/my-routes-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/volunteer/volunteer-map-page/ + 2026-02-16 + + + https://bnkserve.org/v2/frontend/pages/volunteer/volunteer-shifts-page/ + 2026-02-16 + + + https://bnkserve.org/v2/getting-started/ + 2026-02-16 + + + https://bnkserve.org/v2/getting-started/quick-start/ + 2026-02-16 + + + https://bnkserve.org/v2/migration/ + 2026-02-16 + + + https://bnkserve.org/v2/migration/api-changes/ + 2026-02-16 + + + https://bnkserve.org/v2/migration/breaking-changes/ + 2026-02-16 + + + https://bnkserve.org/v2/migration/data-migration/ + 2026-02-16 + + + https://bnkserve.org/v2/migration/feature-parity/ + 2026-02-16 + + + https://bnkserve.org/v2/troubleshooting/ + 2026-02-16 + + + https://bnkserve.org/v2/troubleshooting/auth-issues/ + 2026-02-16 + + + https://bnkserve.org/v2/troubleshooting/common-errors/ + 2026-02-16 + + + https://bnkserve.org/v2/troubleshooting/database-issues/ + 2026-02-16 + + + https://bnkserve.org/v2/troubleshooting/docker-issues/ + 2026-02-16 + + + https://bnkserve.org/v2/troubleshooting/email-issues/ + 2026-02-16 + + + https://bnkserve.org/v2/troubleshooting/faq/ + 2026-02-16 + + + https://bnkserve.org/v2/troubleshooting/geocoding-issues/ + 2026-02-16 + + + https://bnkserve.org/v2/troubleshooting/monitoring-issues/ + 2026-02-16 + + + https://bnkserve.org/v2/troubleshooting/performance-optimization/ + 2026-02-16 + + + https://bnkserve.org/v2/user-guides/ + 2026-02-16 + + + https://bnkserve.org/v2/user-guides/admin-guide/ + 2026-02-16 + + + https://bnkserve.org/v2/user-guides/campaign-manager-guide/ + 2026-02-16 + + + https://bnkserve.org/v2/user-guides/content-editor-guide/ + 2026-02-16 + + + https://bnkserve.org/v2/user-guides/map-organizer-guide/ + 2026-02-16 + + + https://bnkserve.org/v2/user-guides/volunteer-guide/ + 2026-02-16 + + + https://bnkserve.org/blog/archive/2025/ + 2026-02-16 + + \ No newline at end of file diff --git a/mkdocs/site/sitemap.xml.gz b/mkdocs/site/sitemap.xml.gz new file mode 100644 index 00000000..d7b194fa Binary files /dev/null and b/mkdocs/site/sitemap.xml.gz differ diff --git a/mkdocs/site/stylesheets/extra.css b/mkdocs/site/stylesheets/extra.css new file mode 100644 index 00000000..e45d30c7 --- /dev/null +++ b/mkdocs/site/stylesheets/extra.css @@ -0,0 +1,597 @@ +.login-button { + display: inline-block; + padding: 2px 10px; + margin-left: auto; /* Push the button to the right */ + margin-right: 10px; + background-color: hsl(315, 80%, 42%); /* Use a solid, high-contrast color */ + color: #fff; /* Ensure text is white */ + text-decoration: none; + border-radius: 4px; + font-weight: bold; + transition: background-color 0.2s ease; + font-size: 0.9em; + vertical-align: middle; +} + +.login-button:hover { + background-color: #003c8f; /* Darker shade for hover */ + text-decoration: none; +} + +.git-code-button { + display: inline-block; + background: #30363d; + color: white !important; + padding: 0.6rem 1.2rem; + border-radius: 20px; + text-decoration: none; + font-size: 0.95rem; + font-weight: bold; + margin-left: 1rem; + transition: all 0.3s ease; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); +} + +.git-code-button:hover { + background: #444d56; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0,0,0,0.3); + text-decoration: none; +} + +.git-code-button .material-icons { + font-size: 1rem; + vertical-align: middle; + margin-right: 4px; +} + +/* Force code blocks to wrap text instead of horizontal scroll */ +.highlight pre, +.codehilite pre { + white-space: pre-wrap !important; + word-wrap: break-word !important; + overflow-wrap: break-word !important; + overflow-x: auto !important; +} + +/* Ensure code block containers maintain proper positioning */ +.highlight, +.codehilite { + position: relative !important; + overflow: visible !important; +} + +/* For inline code elements only */ +p code, +li code, +td code, +h1 code, +h2 code, +h3 code, +h4 code, +h5 code, +h6 code { + white-space: pre-wrap !important; + word-break: break-word !important; +} + +/* Ensure tables with code don't break layout */ +table { + table-layout: auto; + width: 100%; +} + +table td { + word-wrap: break-word; + overflow-wrap: break-word; +} + + + +/* GitHub Widget Styles */ +.github-widget { + margin: 1.5rem 0; + display: block; +} + +.github-widget-container { + border: 1px solid rgba(var(--md-primary-fg-color--rgb), 0.15); + border-radius: 12px; + padding: 20px; + background: linear-gradient(135deg, var(--md-code-bg-color) 0%, rgba(var(--md-primary-fg-color--rgb), 0.03) 100%); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.6; + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + position: relative; + overflow: hidden; +} + +.github-widget-container::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--md-primary-fg-color), var(--md-accent-fg-color)); + border-radius: 12px 12px 0 0; +} + +.github-widget-container:hover { + border-color: var(--md-accent-fg-color); + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); +} + +.github-widget-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 12px; +} + +.github-widget-title { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; +} + +.github-icon { + color: var(--md-default-fg-color--light); + flex-shrink: 0; + width: 20px; + height: 20px; +} + +.github-widget .repo-link { + color: var(--md-accent-fg-color); + text-decoration: none; + font-weight: 600; + font-size: 16px; + transition: color 0.2s ease; + word-break: break-word; +} + +.github-widget .repo-link:hover { + color: var(--md-primary-fg-color); + text-decoration: none; +} + +.github-widget-stats { + display: flex; + gap: 20px; + align-items: center; + flex-wrap: wrap; +} + +.stat-item { + display: flex; + align-items: center; + gap: 6px; + color: var(--md-default-fg-color); + font-size: 13px; + font-weight: 600; + background: rgba(var(--md-primary-fg-color--rgb), 0.08); + padding: 4px 8px; + border-radius: 16px; + transition: all 0.2s ease; +} + +.stat-item:hover { + background: rgba(var(--md-accent-fg-color--rgb), 0.15); + transform: scale(1.05); +} + +.stat-item svg { + color: var(--md-accent-fg-color); + width: 14px; + height: 14px; +} + +.github-widget-description { + color: var(--md-default-fg-color--light); + margin-bottom: 16px; + line-height: 1.5; + font-size: 14px; + font-style: italic; + padding: 12px; + background: rgba(var(--md-default-fg-color--rgb), 0.03); + border-radius: 8px; + border-left: 3px solid var(--md-accent-fg-color); +} + +.github-widget-footer { + display: flex; + gap: 20px; + align-items: center; + font-size: 12px; + color: var(--md-default-fg-color--lighter); + border-top: 1px solid rgba(var(--md-default-fg-color--rgb), 0.1); + padding-top: 16px; + margin-top: 16px; + flex-wrap: wrap; +} + +.language-info { + display: flex; + align-items: center; + gap: 6px; +} + +.language-dot { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; +} + +.last-update, +.license-info { + color: var(--md-default-fg-color--lighter); +} + +/* Loading State */ +.github-widget-loading { + display: flex; + align-items: center; + gap: 12px; + padding: 20px; + color: var(--md-default-fg-color--light); + justify-content: center; +} + +.loading-spinner { + width: 20px; + height: 20px; + border: 2px solid var(--md-default-fg-color--lightest); + border-top: 2px solid var(--md-accent-fg-color); + border-radius: 50%; + animation: github-widget-spin 1s linear infinite; +} + +@keyframes github-widget-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Error State */ +.github-widget-error { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 20px; + color: var(--md-typeset-color); + text-align: center; + background: var(--md-code-bg-color); + border: 1px solid #f85149; + border-radius: 6px; +} + +.github-widget-error svg { + color: #f85149; +} + +.github-widget-error small { + color: var(--md-default-fg-color--lighter); + font-size: 11px; +} + +/* Dark mode specific adjustments */ +[data-md-color-scheme="slate"] .github-widget-container { + background: var(--md-code-bg-color); + border-color: #30363d; +} + +[data-md-color-scheme="slate"] .github-widget-container:hover { + border-color: var(--md-accent-fg-color); +} + +/* Multiple widgets in a row */ +.github-widgets-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1rem; + margin: 1rem 0; +} + +.github-widgets-row .github-widget { + margin: 0; +} + +/* Compact widget variant */ +.github-widget.compact .github-widget-container { + padding: 12px; +} + +.github-widget.compact .github-widget-header { + margin-bottom: 8px; +} + +.github-widget.compact .github-widget-description { + display: none; +} + +.github-widget.compact .github-widget-footer { + margin-top: 8px; + padding-top: 8px; +} + +/* GitHub Widget Responsive - placed after existing mobile styles */ +@media (max-width: 768px) { + .github-widget-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .github-widget-stats { + gap: 12px; + } + + .github-widget-footer { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } +} + +/* Gitea Widget Styles */ +.gitea-widget { + margin: 1.5rem 0; + display: block; +} + +.gitea-widget-container { + border: 1px solid rgba(var(--md-primary-fg-color--rgb), 0.15); + border-radius: 12px; + padding: 20px; + background: linear-gradient(135deg, var(--md-code-bg-color) 0%, rgba(var(--md-primary-fg-color--rgb), 0.03) 100%); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.6; + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + position: relative; + overflow: hidden; +} + +.gitea-widget-container::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #609926, #89c442); + border-radius: 12px 12px 0 0; +} + +.gitea-widget-container:hover { + border-color: #89c442; + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); +} + +.gitea-widget-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 12px; +} + +.gitea-widget-title { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; +} + +.gitea-icon { + color: #89c442; + flex-shrink: 0; + width: 20px; + height: 20px; +} + +.gitea-widget .repo-link { + color: #89c442; + text-decoration: none; + font-weight: 600; + font-size: 16px; + transition: color 0.2s ease; + word-break: break-word; +} + +.gitea-widget .repo-link:hover { + color: #609926; + text-decoration: none; +} + +.gitea-widget-stats { + display: flex; + gap: 20px; + align-items: center; + flex-wrap: wrap; +} + +.gitea-widget .stat-item { + display: flex; + align-items: center; + gap: 6px; + color: var(--md-default-fg-color); + font-size: 13px; + font-weight: 600; + background: rgba(137, 196, 66, 0.1); + padding: 4px 8px; + border-radius: 16px; + transition: all 0.2s ease; +} + +.gitea-widget .stat-item:hover { + background: rgba(137, 196, 66, 0.2); + transform: scale(1.05); +} + +.gitea-widget .stat-item svg { + color: #89c442; + width: 14px; + height: 14px; +} + +.gitea-widget-description { + color: var(--md-default-fg-color--light); + margin-bottom: 16px; + line-height: 1.5; + font-size: 14px; + font-style: italic; + padding: 12px; + background: rgba(var(--md-default-fg-color--rgb), 0.03); + border-radius: 8px; + border-left: 3px solid #89c442; +} + +.gitea-widget-footer { + display: flex; + gap: 20px; + align-items: center; + font-size: 12px; + color: var(--md-default-fg-color--lighter); + border-top: 1px solid rgba(var(--md-default-fg-color--rgb), 0.1); + padding-top: 16px; + margin-top: 16px; + flex-wrap: wrap; +} + +.gitea-widget .language-info { + display: flex; + align-items: center; + gap: 6px; +} + +.gitea-widget .language-dot { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; +} + +.gitea-widget .last-update, +.gitea-widget .license-info { + color: var(--md-default-fg-color--lighter); +} + +/* Gitea Loading State */ +.gitea-widget-loading { + display: flex; + align-items: center; + gap: 12px; + padding: 20px; + color: var(--md-default-fg-color--light); + justify-content: center; +} + +.gitea-widget-loading .loading-spinner { + width: 20px; + height: 20px; + border: 2px solid var(--md-default-fg-color--lightest); + border-top: 2px solid #89c442; + border-radius: 50%; + animation: gitea-widget-spin 1s linear infinite; +} + +@keyframes gitea-widget-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Gitea Error State */ +.gitea-widget-error { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 20px; + color: var(--md-typeset-color); + text-align: center; + background: var(--md-code-bg-color); + border: 1px solid #f85149; + border-radius: 6px; +} + +.gitea-widget-error svg { + color: #f85149; +} + +.gitea-widget-error small { + color: var(--md-default-fg-color--lighter); + font-size: 11px; +} + +/* Dark mode specific adjustments for Gitea */ +[data-md-color-scheme="slate"] .gitea-widget-container { + background: var(--md-code-bg-color); + border-color: #30363d; +} + +[data-md-color-scheme="slate"] .gitea-widget-container:hover { + border-color: #89c442; +} + +/* Multiple Gitea widgets in a row */ +.gitea-widgets-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1rem; + margin: 1rem 0; +} + +.gitea-widgets-row .gitea-widget { + margin: 0; +} + +/* Compact Gitea widget variant */ +.gitea-widget.compact .gitea-widget-container { + padding: 12px; +} + +.gitea-widget.compact .gitea-widget-header { + margin-bottom: 8px; +} + +.gitea-widget.compact .gitea-widget-description { + display: none; +} + +.gitea-widget.compact .gitea-widget-footer { + margin-top: 8px; + padding-top: 8px; +} + +/* Gitea Widget Responsive */ +@media (max-width: 768px) { + .gitea-widget-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .gitea-widget-stats { + gap: 12px; + } + + .gitea-widget-footer { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } +} diff --git a/mkdocs/site/stylesheets/home.css b/mkdocs/site/stylesheets/home.css new file mode 100644 index 00000000..fbde54bf --- /dev/null +++ b/mkdocs/site/stylesheets/home.css @@ -0,0 +1,1073 @@ +/* Changemaker Lite - Ultra-Tight Grid System */ + +/* Homepage-specific variables */ +.md-content--home { + /* Trans flag colors with neon glow variants */ + --mkdocs-purple: #6f42c1; + --mkdocs-purple-dark: #3d2064; + --trans-blue: var(--mkdocs-purple); /* override for main accent */ + --trans-pink: #F5A9B8; + --trans-white: #FFFFFF; + --trans-white-dim: #E0E0E0; + + /* Dark theme colors - updated for consistent slate */ + --home-dark-bg: #0a0a0a; + --home-dark-surface: #1e293b; /* slate-800 */ + --home-dark-card: #334155; /* slate-700 */ + --home-dark-border: #475569; /* slate-600 */ + --home-dark-text: #e2e8f0; /* slate-200 */ + --home-dark-text-muted: #94a3b8; /* slate-400 */ + + /* Grid colors */ + --grid-primary: var(--trans-blue); + --grid-secondary: var(--trans-pink); + --grid-accent: var(--trans-white); + --grid-border: rgba(91, 206, 250, 0.2); + + /* Neon effects - optimized for grid density */ + --neon-blue-shadow: 0 0 8px rgba(91, 206, 250, 0.6); + --neon-pink-shadow: 0 0 8px rgba(245, 169, 184, 0.6); + --neon-white-shadow: 0 0 6px rgba(255, 255, 255, 0.4); + + /* Tight spacing for maximum content density */ + --space-xs: 0.25rem; + --space-sm: 0.5rem; + --space-md: 0.75rem; + --space-lg: 1rem; + --space-xl: 1.5rem; + --space-2xl: 2rem; + + /* Compact typography */ + --text-xs: 0.7rem; + --text-sm: 0.8rem; + --text-base: 0.9rem; + --text-lg: 1rem; + --text-xl: 1.1rem; + --text-2xl: 1.3rem; + --text-3xl: 1.8rem; + --text-4xl: 2.2rem; + + /* Layout */ + --home-radius: 8px; + --home-max-width: 1400px; + --grid-gap: var(--space-sm); + --card-padding: var(--space-md); + padding-top: 0rem; /* Reduced from 3.5rem */ +} + +/* Homepage body setup */ +body[data-md-template="home"] { + margin: 0; + padding: 0; + overflow-x: hidden; +} + +/* Hide MkDocs chrome completely */ +body[data-md-template="home"] .md-header, +body[data-md-template="home"] .md-tabs, +body[data-md-template="home"] .md-sidebar, +body[data-md-template="home"] .md-footer, +body[data-md-template="home"] .md-footer-meta { + display: none !important; +} + +body[data-md-template="home"] .md-content { + padding: 0 !important; + margin: 0 !important; +} + +body[data-md-template="home"] .md-main__inner { + margin: 0 !important; + max-width: none !important; +} + +/* Homepage content wrapper */ +.md-content--home { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + color: var(--home-dark-text); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + min-height: 100vh; + line-height: 1.4; +} + +/* ================================= + ULTRA-TIGHT GRID SYSTEM + ================================= */ + +.grid-container { + max-width: 1200px; + margin: 0 auto; + padding: var(--space-sm); + display: grid; + gap: var(--space-xs); +} + +.grid-card { + background: var(--home-dark-card); + border: 1px solid var(--home-dark-border); + border-radius: 4px; + padding: var(--space-md); + transition: all 0.2s ease; + position: relative; + overflow: hidden; + color: var(--home-dark-text); +} + +.grid-card::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 2px; + background: linear-gradient(90deg, transparent, var(--trans-blue), transparent); + transition: left 0.5s ease; +} + +.grid-card:hover::before { + left: 100%; +} + +.grid-card:hover { + border-color: var(--trans-blue); + box-shadow: 0 0 15px rgba(91, 206, 250, 0.3); + transform: translateY(-1px); +} + +/* ================================= + HERO GRID - ULTRA COMPACT + ================================= */ + +.hero-grid { + padding: var(--space-md) 0; +} + +.hero-grid .grid-container { + grid-template-columns: 2fr 1fr; + grid-template-rows: auto auto; + grid-template-areas: + "hero-main hero-problem" + "hero-stats hero-trust"; + gap: var(--space-sm); +} + +.hero-main { + grid-area: hero-main; +} + +.hero-problem { + grid-area: hero-problem; +} + +.hero-stats { + grid-area: hero-stats; +} + +.hero-trust { + grid-area: hero-trust; +} + +/* Neon badge animation */ +.meta-badge { + background: linear-gradient(135deg, var(--trans-blue), var(--trans-pink)); + color: #000; + font-size: 0.7rem; + font-weight: 700; + padding: 0.2rem 0.8rem; + border-radius: 20px; + display: inline-block; + margin-bottom: var(--space-sm); + animation: neon-pulse 2s ease-in-out infinite; +} + +@keyframes neon-pulse { + 0%, 100% { + box-shadow: 0 0 5px rgba(91, 206, 250, 0.8), + 0 0 10px rgba(91, 206, 250, 0.6), + 0 0 15px rgba(91, 206, 250, 0.4); + } + 50% { + box-shadow: 0 0 10px rgba(245, 169, 184, 0.8), + 0 0 20px rgba(245, 169, 184, 0.6), + 0 0 30px rgba(245, 169, 184, 0.4); + } +} + +.hero-main h1 { + font-size: 2rem; + font-weight: 800; + margin: 0 0 var(--space-sm) 0; + line-height: 1.1; + background: linear-gradient(135deg, var(--trans-white), var(--trans-blue)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + animation: text-glow 3s ease-in-out infinite; +} + +@keyframes text-glow { + 0%, 100% { filter: brightness(1); } + 50% { filter: brightness(1.2); } +} + +.hero-main p { + font-size: 0.9rem; + color: var(--home-dark-text-muted); + margin: 0 0 var(--space-lg) 0; + line-height: 1.4; +} + +.hero-ctas { + display: flex; + gap: var(--space-sm); +} + +/* Problem/Solution blocks */ +.hero-problem h3, +.hero-stats h3, +.hero-trust h3 { + font-size: 0.9rem; + margin: 0 0 var(--space-sm) 0; + color: var(--trans-blue); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.problem-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.problem-item { + font-size: 0.8rem; + color: var(--home-dark-text-muted); + padding: var(--space-xs); + background: var(--home-dark-surface); + border-radius: 2px; +} + +.stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-xs); +} + +.stat-item { + text-align: center; + padding: var(--space-xs); + background: var(--home-dark-surface); + border-radius: 2px; +} + +.stat-number { + font-size: 1.2rem; + font-weight: 700; + color: var(--trans-pink); + margin: 0; + animation: number-glow 4s ease-in-out infinite; +} + +@keyframes number-glow { + 0%, 100% { text-shadow: 0 0 5px rgba(91, 206, 250, 0.5); } + 50% { text-shadow: 0 0 10px rgba(91, 206, 250, 0.8); } +} + +.stat-label { + font-size: 0.65rem; + color: var(--home-dark-text-muted); + margin: 0; +} + +.trust-items { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-xs); +} + +.trust-item { + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: 0.7rem; + padding: var(--space-xs); + background: var(--home-dark-surface); + border-radius: 2px; +} + +/* ================================= + SERVICES GRID - ULTRA COMPACT + ================================= */ + +.solution-grid { + padding: var(--space-lg) 0; + border-top: 1px solid var(--grid-border); +} + +.section-header { + text-align: center; + margin-bottom: var(--space-lg); + padding: var(--space-lg) var(--space-md); + background: #2d1b69; /* Deep purple solid background */ + border-radius: var(--home-radius); + border: 1px solid var(--mkdocs-purple); + box-shadow: 0 0 20px rgba(111, 66, 193, 0.3); + position: relative; + z-index: 10; /* Ensure it stays above other content */ + overflow: hidden; /* Prevent any content bleeding */ +} + +.section-header h2 { + font-size: 1.8rem; + font-weight: 700; + margin: 0 0 var(--space-xs) 0; + color: var(--trans-white); + position: relative; + display: inline-block; +} + +.section-header h2::after { + content: ''; + position: absolute; + bottom: -5px; + left: 0; + width: 100%; + height: 2px; + background: linear-gradient(90deg, transparent, var(--trans-pink), transparent); +} + +.section-header p { + font-size: 0.9rem; + color: var(--trans-white-dim); + margin: 0; +} + +.services-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--space-sm); + padding-top: var(--space-md); /* Add padding to accommodate service badges too */ +} + +.service-card { + text-decoration: none; + color: inherit; + text-align: center; + transition: all 0.3s ease; + position: relative; + cursor: pointer; + min-height: 120px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: var(--space-md); /* Reset to normal padding */ + margin-top: 0; /* Remove margin, use grid padding instead */ +} + +.service-card:hover .service-icon { + animation: icon-bounce 0.5s ease; +} + +@keyframes icon-bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-5px); } +} + +.service-icon { + font-size: 2rem; + margin-bottom: var(--space-xs); +} + +.service-card h3 { + font-size: 0.9rem; + margin: 0 0 var(--space-xs) 0; + color: var(--home-dark-text); +} + +.service-replaces { + font-size: 0.65rem; + color: var(--trans-pink); + margin-bottom: var(--space-sm); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.service-features { + list-style: none; + padding: 0; + margin: 0 0 var(--space-sm) 0; + font-size: 0.7rem; + color: var(--home-dark-text-muted); +} + +.service-features li { + padding: 0.1rem 0; +} + +.service-tool { + font-size: 0.75rem; + font-weight: 600; + color: var(--trans-blue); + padding: 0.2rem 0.5rem; + background: var(--home-dark-surface); + border-radius: 2px; + display: inline-block; +} + +/* ================================= + PROOFS SECTION - NEW + ================================= */ + +.proofs-grid { + padding: var(--space-lg) 0; + border-top: 1px solid var(--grid-border); +} + +/* Example Sites */ +.proof-sites { + margin-bottom: var(--space-xl); +} + +.proof-sites h3 { + font-size: 1.2rem; + margin: 0 0 var(--space-lg) 0; + color: var(--trans-blue); + text-align: center; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.sites-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-sm); + margin-bottom: var(--space-xl); + max-width: 900px; + margin-left: auto; + margin-right: auto; + padding-top: var(--space-md); /* Add padding to accommodate badges */ +} + +.site-card { + text-decoration: none; + color: inherit; + text-align: center; + transition: all 0.3s ease; + position: relative; + cursor: pointer; + min-height: 120px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: var(--space-md); + margin-top: 0; /* Remove margin, use grid padding instead */ +} + +.site-card:hover { + transform: translateY(-3px) scale(1.02); + box-shadow: 0 0 20px rgba(91, 206, 250, 0.4); +} + +.site-card.featured { + border-color: var(--trans-blue); + box-shadow: 0 0 15px rgba(91, 206, 250, 0.3); +} + +.site-badge { + position: absolute; + top: -4px; /* Bring badge even closer to card */ + left: 50%; + transform: translateX(-50%); + background: var(--trans-blue); + color: #000; + padding: 0.1rem 0.6rem; + border-radius: 10px; + font-size: 0.65rem; + font-weight: 700; + z-index: 5; + white-space: nowrap; /* Prevent text wrapping */ +} + +.site-icon { + font-size: 2.5rem; + margin-bottom: var(--space-sm); + animation: site-float 3s ease-in-out infinite; +} + +@keyframes site-float { + 0%, 100% { transform: translateY(0px); } + 50% { transform: translateY(-5px); } +} + +.site-name { + font-size: 1rem; + font-weight: 600; + margin-bottom: var(--space-xs); + color: var(--home-dark-text); +} + +.site-desc { + font-size: 0.8rem; + color: var(--home-dark-text-muted); +} + +/* Live Stats */ +.proof-stats h3 { + font-size: 1.2rem; + margin: 0 0 var(--space-lg) 0; + color: var(--trans-pink); + text-align: center; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-sm); + max-width: 1000px; + margin: 0 auto; +} + +.stat-card { + text-align: center; + position: relative; + overflow: hidden; + min-height: 160px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: var(--space-md); +} + +.stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, + var(--trans-blue), + var(--trans-pink), + var(--trans-blue)); + animation: stat-glow 2s ease-in-out infinite; +} + +@keyframes stat-glow { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 1; } +} + +.stat-icon { + font-size: 2rem; + margin-bottom: var(--space-xs); + animation: stat-pulse 2s ease-in-out infinite; +} + +@keyframes stat-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); } +} + +.stat-counter { + font-size: 1.5rem; + font-weight: 700; + color: var(--trans-pink); /* Changed to pink to match section header */ + margin-bottom: var(--space-xs); + /* Removed text-shadow and animation for better readability */ +} + +.stat-label { + font-size: 0.9rem; + font-weight: 600; + color: var(--home-dark-text); + margin-bottom: var(--space-xs); +} + +.stat-detail { + font-size: 0.7rem; + color: var(--home-dark-text-muted); +} + +/* Animated counter effect */ +.stat-counter.counting { + animation: counter-count 1.2s ease-out; +} + +@keyframes counter-count { + 0% { + transform: scale(0.5) rotateY(-90deg); + opacity: 0; + } + 50% { + transform: scale(1.2) rotateY(0deg); + opacity: 0.8; + } + 100% { + transform: scale(1) rotateY(0deg); + opacity: 1; + } +} + +/* ================================= + COMPARISON TABLE - GRID STYLE + ================================= */ + +.comparison-grid { + padding: var(--space-lg) 0; +} + +.comparison-table { + display: grid; + gap: var(--space-xs); +} + +.comparison-header, +.comparison-row { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + align-items: center; +} + +.comparison-header { + font-weight: 700; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.compare-col { + text-align: center; + padding: var(--space-sm); +} + +.compare-col.highlight { + color: var(--trans-blue); +} + +.comparison-row { + font-size: 0.8rem; +} + +.compare-label { + padding: var(--space-sm); + font-weight: 600; +} + +.compare-value { + text-align: center; + padding: var(--space-sm); +} + +.compare-value.bad { + color: #ff6b6b; +} + +.compare-value.good { + color: #4ecdc4; + font-weight: 600; +} + +/* ================================= + OPTIONS GRID - ULTRA COMPACT + ================================= */ + +.get-started-grid { + padding: var(--space-lg) 0; +} + +.options-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: var(--space-sm); + margin-bottom: var(--space-lg); + padding-top: var(--space-md); /* Add padding to accommodate badges */ +} + +.option-card { + text-align: center; + position: relative; + padding-top: var(--space-md); /* Reduced padding since grid now has padding */ + margin-top: 0; /* Remove margin, use grid padding instead */ +} + +.option-card.featured { + border-color: var(--trans-blue); + animation: featured-glow 3s ease-in-out infinite; +} + +@keyframes featured-glow { + 0%, 100% { + box-shadow: 0 0 10px rgba(91, 206, 250, 0.4), + 0 0 20px rgba(91, 206, 250, 0.2); + } + 50% { + box-shadow: 0 0 20px rgba(91, 206, 250, 0.6), + 0 0 30px rgba(91, 206, 250, 0.3); + } +} + +.option-badge { + position: absolute; + top: -4px; /* Bring badge even closer to card */ + left: 50%; + transform: translateX(-50%); + background: var(--trans-blue); + color: #000; + padding: 0.1rem 0.6rem; + border-radius: 10px; + font-size: 0.65rem; + font-weight: 700; + z-index: 5; + white-space: nowrap; /* Prevent text wrapping */ +} + +.option-icon { + font-size: 2.5rem; + margin-bottom: var(--space-sm); +} + +.option-card h3 { + font-size: 1.2rem; + margin: 0 0 var(--space-xs) 0; + color: var(--home-dark-text); +} + +.option-price { + font-size: 2rem; + font-weight: 700; + color: var(--trans-blue); + margin-bottom: var(--space-sm); +} + +.option-features { + margin-bottom: var(--space-lg); + text-align: left; +} + +.feature { + padding: 0.2rem 0; + color: var(--home-dark-text-muted); + font-size: 0.75rem; +} + +/* Final CTA */ +.final-cta { + text-align: center; + padding: var(--space-xl); +} + +.final-cta h3 { + font-size: 1.5rem; + margin: 0 0 var(--space-sm) 0; + color: var(--home-dark-text); +} + +.final-cta p { + font-size: 0.9rem; + color: var(--home-dark-text-muted); + margin: 0 0 var(--space-lg) 0; +} + +.cta-buttons { + display: flex; + gap: var(--space-sm); + justify-content: center; +} + +/* ================================= + BUTTONS - NEON STYLE + ================================= */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.4rem 1rem; + font-size: 0.8rem; + font-weight: 600; + text-decoration: none; + border-radius: 4px; + transition: all 0.2s ease; + border: 1px solid transparent; + cursor: pointer; + min-width: 100px; + position: relative; + overflow: hidden; + color: var(--trans-white) !important; /* Force white text with higher specificity */ +} + +.btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + transform: translate(-50%, -50%); + transition: width 0.6s, height 0.6s; +} + +.btn:hover::before { + width: 300px; + height: 300px; +} + +.btn-primary { + background: var(--mkdocs-purple); + color: var(--trans-white) !important; /* Force white text */ + border-color: var(--mkdocs-purple); +} + +.btn-primary:hover { + background: var(--mkdocs-purple-dark); + color: var(--trans-white) !important; /* Force white text on hover */ + box-shadow: 0 0 15px var(--mkdocs-purple); + transform: translateY(-1px); +} + +.btn-secondary { + background: transparent; + color: var(--trans-white) !important; /* Force white text */ + border-color: var(--mkdocs-purple); +} + +.btn-secondary:hover { + background: var(--mkdocs-purple-dark); + color: var(--trans-white) !important; /* Force white text on hover */ + box-shadow: 0 0 15px var(--mkdocs-purple); +} + +/* Ensure all button variations have white text */ +a.btn, +a.btn:visited, +a.btn:link, +a.btn:active { + color: var(--trans-white) !important; + text-decoration: none !important; +} + +a.btn-primary, +a.btn-primary:visited, +a.btn-primary:link, +a.btn-primary:active { + color: var(--trans-white) !important; +} + +a.btn-secondary, +a.btn-secondary:visited, +a.btn-secondary:link, +a.btn-secondary:active { + color: var(--trans-white) !important; +} + +/* ================================= + RESPONSIVE - ULTRA TIGHT + ================================= */ + +@media (max-width: 768px) { + .grid-container { + gap: var(--space-xs); + padding: var(--space-xs); + } + + .hero-grid .grid-container { + grid-template-columns: 1fr; + grid-template-areas: + "hero-main" + "hero-problem" + "hero-stats" + "hero-trust"; + } + + .services-grid { + grid-template-columns: 1fr; + } + + .sites-grid { + grid-template-columns: repeat(2, 1fr); + max-width: 500px; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + max-width: 500px; + } + + .comparison-header, + .comparison-row { + font-size: 0.7rem; + } + + .options-grid { + grid-template-columns: 1fr; + } + + .cta-buttons { + flex-direction: column; + } + + .hero-main h1 { + font-size: 1.5rem; + } +} + +@media (max-width: 480px) { + .grid-card { + padding: var(--space-sm); + } + + .trust-items, + .stat-grid { + grid-template-columns: 1fr; + } + + .sites-grid { + grid-template-columns: 1fr; + max-width: 350px; + } + + .stats-grid { + grid-template-columns: 1fr; + max-width: 350px; + } + + .service-card { + padding: var(--space-sm); + } + + .hero-main h1 { + font-size: 1.3rem; + } + + .stat-counter { + font-size: 1.2rem; + } + + .site-icon { + font-size: 2rem; + } +} + +/* Row cards at bottom: two cards in a row on desktop, stack on mobile */ +.row-cards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-sm); + margin-top: var(--space-lg); +} + +@media (max-width: 768px) { + .row-cards { + grid-template-columns: 1fr; + gap: var(--space-xs); + } +} + +/* ================================= + PERFORMANCE + ================================= */ + +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + transition: none !important; + } +} + +/* GPU acceleration */ +.grid-card, +.btn { + will-change: transform; + backface-visibility: hidden; +} + +/* Badge for FOSS First */ +.badge-new { + display: inline-block; + background: var(--mkdocs-purple); + color: #fff; + font-size: 0.65em; + font-weight: 700; + border-radius: 8px; + padding: 0.1em 0.5em; + margin-left: 0.3em; + letter-spacing: 0.03em; + vertical-align: middle; + box-shadow: 0 0 6px var(--mkdocs-purple-dark); +} + +/* Ensure all cards have consistent slate background */ +.grid-card, +.site-card, +.stat-card, +.service-card, +.option-card, +.final-cta, +.subscribe-card { + background: var(--home-dark-card) !important; + color: var(--home-dark-text) !important; +} + +/* Fix form elements in subscribe card */ +.subscribe-card input[type="email"], +.subscribe-card input[type="text"] { + background: var(--home-dark-surface); + color: var(--home-dark-text); + border: 1px solid var(--home-dark-border); + border-radius: 4px; + padding: 0.5rem; + width: 100%; + box-sizing: border-box; +} + +.subscribe-card input[type="email"]:focus, +.subscribe-card input[type="text"]:focus { + outline: none; + border-color: var(--trans-blue); + box-shadow: 0 0 0 2px rgba(91, 206, 250, 0.2); +} + +.subscribe-card label { + color: var(--home-dark-text-muted); + font-size: 0.9rem; +} + +.subscribe-card .action-btn { + background: var(--mkdocs-purple); + color: #000; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.subscribe-card .action-btn:hover { + background: var(--mkdocs-purple-dark); + transform: translateY(-1px); + box-shadow: 0 0 10px rgba(111, 66, 193, 0.4); +} + +/* Ensure all section content respects the header layering */ +.solution-grid, +.proofs-grid, +.comparison-grid, +.get-started-grid { + position: relative; + z-index: 1; /* Lower than section headers */ +} + +/* Make sure grid cards don't interfere with section headers */ +.grid-card { + position: relative; + z-index: 2; /* Higher than section content but lower than headers */ +} \ No newline at end of file diff --git a/mkdocs/site/tags.json b/mkdocs/site/tags.json new file mode 100644 index 00000000..db61b0bf --- /dev/null +++ b/mkdocs/site/tags.json @@ -0,0 +1 @@ +{"mappings": []} \ No newline at end of file diff --git a/mkdocs/site/v1/adv/ansible/index.html b/mkdocs/site/v1/adv/ansible/index.html new file mode 100644 index 00000000..8937b400 --- /dev/null +++ b/mkdocs/site/v1/adv/ansible/index.html @@ -0,0 +1,3583 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SSH + Tailscale + Ansible - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Setting Up Ansible with Tailscale for Remote Server Management

+

Overview

+

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

+

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

+

What You'll Learn

+
    +
  • How to set up Ansible for infrastructure automation
  • +
  • How to configure secure remote access using Tailscale
  • +
  • How to troubleshoot common SSH and networking issues
  • +
  • Why this approach is better than alternatives like Cloudflare Tunnels for simple SSH access
  • +
+

Prerequisites

+
    +
  • Master Node: Your main computer running Ubuntu/Linux (control machine)
  • +
  • Target Nodes: Remote servers/ThinkCentres running Ubuntu/Linux
  • +
  • Both machines: Must have internet access
  • +
  • User Account: Same username on all machines (recommended)
  • +
+

Part 1: Initial Setup on Master Node

+

1. Create Ansible Directory Structure

+
# Create project directory
+mkdir ~/ansible_quickstart
+cd ~/ansible_quickstart
+
+# Create directory structure
+mkdir -p group_vars host_vars roles playbooks
+
+

2. Install Ansible

+
sudo apt update
+sudo apt install ansible
+
+

3. Generate SSH Keys (if not already done)

+
# Generate SSH key pair
+ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa
+
+# Display public key (save this for later)
+cat ~/.ssh/id_rsa.pub
+
+

Part 2: Target Node Setup (Physical Access Required Initially)

+

1. Enable SSH on Target Node

+

Access each target node physically (monitor + keyboard):

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

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

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

You should see SSH listening on port 22.

+

2. Configure SSH Key Authentication

+
# Create .ssh directory
+mkdir -p ~/.ssh
+chmod 700 ~/.ssh
+
+# Create authorized_keys file
+nano ~/.ssh/authorized_keys
+
+

Paste your public key from the master node, then:

+
# Set proper permissions
+chmod 600 ~/.ssh/authorized_keys
+
+

3. Configure SSH Security

+
# Edit SSH config
+sudo nano /etc/ssh/sshd_config
+
+

Ensure these lines are uncommented:

+
PubkeyAuthentication yes
+AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2
+
+
# Restart SSH service
+sudo systemctl restart ssh
+
+

4. Configure Firewall

+
# Check firewall status
+sudo ufw status
+
+# Allow SSH through firewall
+sudo ufw allow ssh
+
+# Fix home directory permissions (required for SSH keys)
+chmod 755 ~/
+
+

Part 3: Test Local SSH Connection

+

Before proceeding with remote access, test SSH connectivity locally:

+
# From master node, test SSH to target
+ssh username@<target-local-ip>
+
+

Common Issues and Solutions:

+
    +
  • Connection hangs: Check firewall rules (sudo ufw allow ssh)
  • +
  • Permission denied: Verify SSH keys and file permissions
  • +
  • SSH config errors: Ensure PubkeyAuthentication yes is set
  • +
+

Part 4: Set Up Tailscale for Remote Access

+

Why Tailscale Over Alternatives

+

We initially tried Cloudflare Tunnels but encountered complexity with:

+
    +
  • DNS routing issues
  • +
  • Complex configuration for SSH
  • +
  • Same-network testing problems
  • +
  • Multiple configuration approaches with varying success
  • +
+

Tailscale is superior because:

+
    +
  • Zero configuration mesh networking
  • +
  • Works from any network
  • +
  • Persistent IP addresses
  • +
  • No port forwarding needed
  • +
  • Free for personal use
  • +
+

1. Install Tailscale on Master Node

+
# Install Tailscale
+curl -fsSL https://tailscale.com/install.sh | sh
+
+# Connect to Tailscale network
+sudo tailscale up
+
+

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

+

2. Install Tailscale on Target Nodes

+

On each target node:

+
# Install Tailscale
+curl -fsSL https://tailscale.com/install.sh | sh
+
+# Connect to Tailscale network
+sudo tailscale up
+
+

Authenticate each device through the provided URL.

+

3. Get Tailscale IP Addresses

+

On each machine:

+
# Get your Tailscale IP
+tailscale ip -4
+
+

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

+

Part 5: Configure Ansible

+

1. Create Inventory File

+
# Create inventory.ini
+cd ~/ansible_quickstart
+nano inventory.ini
+
+

Content:

+
[thinkcenter]
+tc-node1 ansible_host=100.x.x.x ansible_user=your-username
+tc-node2 ansible_host=100.x.x.x ansible_user=your-username
+
+[all:vars]
+ansible_ssh_private_key_file=~/.ssh/id_rsa
+ansible_host_key_checking=False
+
+

Replace:

+
    +
  • 100.x.x.x with actual Tailscale IPs
  • +
  • your-username with your actual username
  • +
+

2. Test Ansible Connectivity

+
# Test connection to all nodes
+ansible all -i inventory.ini -m ping
+
+

Expected output:

+
tc-node1 | SUCCESS => {
+    "changed": false,
+    "ping": "pong"
+}
+
+

Part 6: Create and Run Playbooks

+

1. Simple Information Gathering Playbook

+
mkdir -p playbooks
+nano playbooks/info-playbook.yml
+
+

Content:

+
---
+- name: Gather Node Information
+  hosts: all
+  tasks:
+    - name: Get system information
+      setup:
+
+    - name: Display basic system info
+      debug:
+        msg: |
+          Hostname: {{ ansible_hostname }}
+          Operating System: {{ ansible_distribution }} {{ ansible_distribution_version }}
+          Architecture: {{ ansible_architecture }}
+          Memory: {{ ansible_memtotal_mb }}MB
+          CPU Cores: {{ ansible_processor_vcpus }}
+
+    - name: Show disk usage
+      command: df -h /
+      register: disk_info
+
+    - name: Display disk usage
+      debug:
+        msg: "Root filesystem usage: {{ disk_info.stdout_lines[1] }}"
+
+    - name: Check uptime
+      command: uptime
+      register: uptime_info
+
+    - name: Display uptime
+      debug:
+        msg: "System uptime: {{ uptime_info.stdout }}"
+
+

2. Run the Playbook

+
ansible-playbook -i inventory.ini playbooks/info-playbook.yml
+
+

Part 7: Advanced Playbook Example

+

System Setup Playbook

+
nano playbooks/setup-node.yml
+
+

Content:

+
---
+- name: Setup ThinkCentre Node
+  hosts: all
+  become: yes
+  tasks:
+    - name: Update package cache
+      apt:
+        update_cache: yes
+
+    - name: Install essential packages
+      package:
+        name:
+          - htop
+          - vim
+          - curl
+          - git
+          - docker.io
+        state: present
+
+    - name: Add user to docker group
+      user:
+        name: "{{ ansible_user }}"
+        groups: docker
+        append: yes
+
+    - name: Create management directory
+      file:
+        path: /opt/management
+        state: directory
+        owner: "{{ ansible_user }}"
+        group: "{{ ansible_user }}"
+
+

Troubleshooting Guide

+

SSH Issues

+

Problem: SSH connection hangs

+
    +
  • Check firewall: sudo ufw status and sudo ufw allow ssh
  • +
  • Verify SSH service: sudo systemctl status ssh
  • +
  • Test local connectivity first
  • +
+

Problem: Permission denied (publickey)

+
    +
  • Check SSH key permissions: chmod 600 ~/.ssh/authorized_keys
  • +
  • Verify home directory permissions: chmod 755 ~/
  • +
  • Ensure SSH config allows key auth: PubkeyAuthentication yes
  • +
+

Problem: Bad owner or permissions on SSH config

+
chmod 600 ~/.ssh/config
+
+

Ansible Issues

+

Problem: Host key verification failed

+
    +
  • Add to inventory: ansible_host_key_checking=False
  • +
+

Problem: Ansible command not found

+
sudo apt install ansible
+
+

Problem: Connection timeouts

+
    +
  • Verify Tailscale connectivity: ping <tailscale-ip>
  • +
  • Check if both nodes are connected: tailscale status
  • +
+

Tailscale Issues

+

Problem: Can't connect to Tailscale IP

+
    +
  • Verify both devices are authenticated: tailscale status
  • +
  • Check Tailscale is running: sudo systemctl status tailscaled
  • +
  • Restart Tailscale: sudo tailscale up
  • +
+

Scaling to Multiple Nodes

+

Adding New Nodes

+
    +
  1. Install Tailscale on new node
  2. +
  3. Set up SSH access (repeat Part 2)
  4. +
  5. Add to inventory.ini:
  6. +
+
[thinkcenter]
+tc-node1 ansible_host=100.125.148.60 ansible_user=bunker-admin
+tc-node2 ansible_host=100.x.x.x ansible_user=bunker-admin
+tc-node3 ansible_host=100.x.x.x ansible_user=bunker-admin
+
+

Group Management

+
[webservers]
+tc-node1 ansible_host=100.x.x.x ansible_user=bunker-admin
+tc-node2 ansible_host=100.x.x.x ansible_user=bunker-admin
+
+[databases]
+tc-node3 ansible_host=100.x.x.x ansible_user=bunker-admin
+
+[all:vars]
+ansible_ssh_private_key_file=~/.ssh/id_rsa
+ansible_host_key_checking=False
+
+

Run playbooks on specific groups:

+
ansible-playbook -i inventory.ini -l webservers playbook.yml
+
+

Best Practices

+

Security

+
    +
  • Use SSH keys, not passwords
  • +
  • Keep Tailscale client updated
  • +
  • Regular security updates via Ansible
  • +
  • Use become: yes only when necessary
  • +
+

Organization

+
ansible_quickstart/
+├── inventory.ini
+├── group_vars/
+├── host_vars/
+├── roles/
+└── playbooks/
+    ├── info-playbook.yml
+    ├── setup-node.yml
+    └── maintenance.yml
+
+

Monitoring and Maintenance

+

Create regular maintenance playbooks:

+
- name: System maintenance
+  hosts: all
+  become: yes
+  tasks:
+    - name: Update all packages
+      apt:
+        upgrade: dist
+        update_cache: yes
+
+    - name: Clean package cache
+      apt:
+        autoclean: yes
+        autoremove: yes
+
+

Alternative Approaches We Considered

+

Cloudflare Tunnels

+
    +
  • Pros: Good for web services, handles NAT traversal
  • +
  • Cons: Complex SSH setup, DNS routing issues, same-network problems
  • +
  • Use case: Better for web applications than SSH access
  • +
+

Traditional VPN

+
    +
  • Pros: Full network access
  • +
  • Cons: Complex setup, port forwarding required, router configuration
  • +
  • Use case: When you control the network infrastructure
  • +
+

SSH Reverse Tunnels

+
    +
  • Pros: Simple concept
  • +
  • Cons: Requires VPS, single point of failure, manual setup
  • +
  • Use case: Temporary access or when other methods fail
  • +
+

Conclusion

+

This setup provides:

+
    +
  • Reliable remote access from anywhere
  • +
  • Secure mesh networking with Tailscale
  • +
  • Infrastructure automation with Ansible
  • +
  • Easy scaling to multiple nodes
  • +
  • No complex networking required
  • +
+

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.

+

Quick Reference Commands

+
# Check Tailscale status
+tailscale status
+
+# Test Ansible connectivity
+ansible all -i inventory.ini -m ping
+
+# Run playbook on all hosts
+ansible-playbook -i inventory.ini playbook.yml
+
+# Run playbook on specific group
+ansible-playbook -i inventory.ini -l groupname playbook.yml
+
+# Run single command on all hosts
+ansible all -i inventory.ini -m command -a "uptime"
+
+# SSH to node via Tailscale
+ssh username@100.x.x.x
+
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/adv/index.html b/mkdocs/site/v1/adv/index.html new file mode 100644 index 00000000..087fdf09 --- /dev/null +++ b/mkdocs/site/v1/adv/index.html @@ -0,0 +1,2059 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Advanced Configurations - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Advanced Configurations

+

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.

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/adv/vscode-ssh/index.html b/mkdocs/site/v1/adv/vscode-ssh/index.html new file mode 100644 index 00000000..ddeb2116 --- /dev/null +++ b/mkdocs/site/v1/adv/vscode-ssh/index.html @@ -0,0 +1,3916 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SSH + VScode - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Remote Development with VSCode over Tailscale

+

Overview

+

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.

+

What You'll Learn

+
    +
  • How to configure VSCode for remote SSH connections
  • +
  • How to set up remote development environments
  • +
  • How to manage multiple remote servers efficiently
  • +
  • How to troubleshoot common remote development issues
  • +
  • Best practices for remote development workflows
  • +
+

Prerequisites

+
    +
  • Ansible + Tailscale setup completed (see previous guide)
  • +
  • VSCode installed on the local machine (master node)
  • +
  • Working SSH access to remote servers via Tailscale
  • +
  • Tailscale running on both local and remote machines
  • +
+

Verify Prerequisites

+

Before starting, verify the setup:

+
# Check Tailscale connectivity
+tailscale status
+
+# Test SSH access
+ssh <username>@<tailscale-ip>
+
+# Check VSCode is installed
+code --version
+
+

Part 1: Install and Configure Remote-SSH Extension

+

1. Install the Remote Development Extensions

+

Option A: Install Remote Development Pack (Recommended)

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

This pack includes:

+
    +
  • Remote - SSH
  • +
  • Remote - SSH: Editing Configuration Files
  • +
  • Remote - Containers
  • +
  • Remote - WSL (Windows only)
  • +
+

Option B: Install Individual Extension

+
    +
  1. Search for "Remote - SSH"
  2. +
  3. Install Remote - SSH by Microsoft
  4. +
+

2. Verify Installation

+

After installation, the following should be visible:

+
    +
  • Remote Explorer icon in the Activity Bar (left sidebar)
  • +
  • "Remote-SSH" commands in Command Palette (Ctrl+Shift+P)
  • +
+

Part 2: Configure SSH Connections

+

1. Access SSH Configuration

+

Method A: Through VSCode

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

Method B: Direct File Editing +

# Edit SSH config file directly
+nano ~/.ssh/config
+

+

2. Add Server Configurations

+

Add servers to the SSH config file:

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

Configuration Options Explained:

+
    +
  • Host: Friendly name for the connection
  • +
  • HostName: Tailscale IP address
  • +
  • User: Username on the remote server
  • +
  • IdentityFile: Path to the SSH private key
  • +
  • ForwardAgent: Enables SSH agent forwarding for Git operations
  • +
  • ServerAliveInterval: Keeps connection alive (prevents timeouts)
  • +
  • ServerAliveCountMax: Number of keepalive attempts
  • +
+

3. Set Proper SSH Key Permissions

+
# Ensure SSH config has correct permissions
+chmod 600 ~/.ssh/config
+
+# Verify SSH key permissions
+chmod 600 ~/.ssh/id_rsa
+chmod 644 ~/.ssh/id_rsa.pub
+
+

Part 3: Connect to Remote Servers

+

1. Connect via Command Palette

+
    +
  1. Press Ctrl+Shift+P
  2. +
  3. Type "Remote-SSH: Connect to Host..."
  4. +
  5. Select the server (e.g., node1)
  6. +
  7. VSCode will open a new window connected to the remote server
  8. +
+

2. Connect via Remote Explorer

+
    +
  1. Click the Remote Explorer icon in Activity Bar
  2. +
  3. Expand SSH Targets
  4. +
  5. Click the connect icon next to the server name
  6. +
+

3. Connect via Quick Menu

+
    +
  1. Click the remote indicator in bottom-left corner (looks like ><)
  2. +
  3. Select "Connect to Host..."
  4. +
  5. Choose the server from the list
  6. +
+

4. First Connection Process

+

On first connection, VSCode will:

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

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

+

Part 4: Remote Development Environment Setup

+

1. Open Remote Workspace

+

Once connected:

+
# In the VSCode terminal (now running on remote server)
+# Navigate to the project directory
+cd /home/<username>/projects
+
+# Open current directory in VSCode
+code .
+
+# Or open a specific project
+code /opt/myproject
+
+

2. Install Extensions on Remote Server

+

Extensions must be installed separately on the remote server:

+

Essential Development Extensions:

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

To Install:

+
    +
  1. Go to Extensions (Ctrl+Shift+X)
  2. +
  3. Find the desired extension
  4. +
  5. Click "Install in SSH: node1" (not local install)
  6. +
+

3. Configure Git on Remote Server

+
# In VSCode terminal (remote)
+git config --global user.name "<Full Name>"
+git config --global user.email "<email@example.com>"
+
+# Test Git connectivity
+git clone https://github.com/<username>/<repo>.git
+
+

Part 5: Remote Development Workflows

+

1. File Management

+

File Explorer:

+
    +
  • Shows remote server's file system
  • +
  • Create, edit, delete files directly
  • +
  • Drag and drop between local and remote (limited)
  • +
+

File Transfer: +

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

+

2. Terminal Usage

+

Integrated Terminal:

+
    +
  • Press Ctrl+` to open terminal
  • +
  • Runs directly on remote server
  • +
  • Multiple terminals supported
  • +
  • Full shell access (bash, zsh, etc.)
  • +
+

Common Remote Terminal Commands: +

# Check system resources
+htop
+df -h
+free -h
+
+# Install packages
+sudo apt update
+sudo apt install nodejs npm
+
+# Start services
+sudo systemctl start nginx
+sudo docker-compose up -d
+

+

3. Port Forwarding

+

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

+

Manual Port Forwarding:

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

Example: Web Development +

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

+

4. Debugging Remote Applications

+

Python Debugging: +

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

+

Node.js Debugging: +

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

+

Part 6: Advanced Configuration

+

1. Workspace Settings

+

Create remote-specific settings:

+
// .vscode/settings.json (on remote server)
+{
+    "python.defaultInterpreterPath": "/usr/bin/python3",
+    "terminal.integrated.shell.linux": "/bin/bash",
+    "files.autoSave": "afterDelay",
+    "editor.formatOnSave": true,
+    "remote.SSH.remotePlatform": {
+        "node1": "linux"
+    }
+}
+
+

2. Multi-Server Management

+

Switch Between Servers:

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

Compare Files Across Servers:

+
    +
  1. Open file from server A
  2. +
  3. Connect to server B in new window
  4. +
  5. Open corresponding file
  6. +
  7. Use "Compare with..." command
  8. +
+

3. Sync Configuration

+

Settings Sync:

+
    +
  1. Enable Settings Sync in VSCode
  2. +
  3. Settings, extensions, and keybindings sync to remote
  4. +
  5. Consistent experience across all servers
  6. +
+

Part 7: Project-Specific Setups

+

1. Python Development

+
# On remote server
+# Create virtual environment
+python3 -m venv venv
+source venv/bin/activate
+
+# Install packages
+pip install flask django requests
+
+# VSCode automatically detects Python interpreter
+
+

VSCode Python Configuration: +

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

+

2. Node.js Development

+
# On remote server
+# Install Node.js
+curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
+sudo apt-get install -y nodejs
+
+# Create project
+mkdir myapp && cd myapp
+npm init -y
+npm install express
+
+

3. Docker Development

+
# On remote server
+# Install Docker (if not already done via Ansible)
+sudo apt install docker.io docker-compose
+sudo usermod -aG docker $USER
+
+# Create Dockerfile
+cat > Dockerfile << EOF
+FROM node:18
+WORKDIR /app
+COPY package*.json ./
+RUN npm install
+COPY . .
+EXPOSE 3000
+CMD ["npm", "start"]
+EOF
+
+

VSCode Docker Integration:

+
    +
  • Install Docker extension on remote
  • +
  • Right-click Dockerfile → "Build Image"
  • +
  • Manage containers from VSCode interface
  • +
+

Part 8: Troubleshooting Guide

+

Common Connection Issues

+

Problem: "Could not establish connection to remote host"

+

Solutions: +

# Check Tailscale connectivity
+tailscale status
+ping <tailscale-ip>
+
+# Test SSH manually
+ssh <username>@<tailscale-ip>
+
+# Check SSH config syntax
+ssh -T node1
+

+

Problem: "Permission denied (publickey)"

+

Solutions: +

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

+

Problem: "Host key verification failed"

+

Solutions: +

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

+

VSCode-Specific Issues

+

Problem: Extensions not working on remote

+

Solutions:

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

Problem: Slow performance

+

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

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

+

Problem: Terminal not starting

+

Solutions: +

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

+

Network and Performance Issues

+

Problem: Connection timeouts

+

Solutions: +Add to SSH config: +

ServerAliveInterval 60
+ServerAliveCountMax 3
+TCPKeepAlive yes
+

+

Problem: File transfer slow

+

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

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

+

Part 9: Best Practices

+

Security Best Practices

+
    +
  1. Use SSH keys, never passwords
  2. +
  3. Keep SSH agent secure
  4. +
  5. Regular security updates on remote servers
  6. +
  7. Use VSCode's secure connection verification
  8. +
+

Performance Optimization

+
    +
  1. +

    Exclude unnecessary files: +

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

    +
  2. +
  3. +

    Use remote workspace for large projects

    +
  4. +
  5. Close unnecessary windows and extensions
  6. +
  7. Use efficient development workflows
  8. +
+

Development Workflow

+
    +
  1. +

    Use version control effectively: +

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

    +
  2. +
  3. +

    Environment separation: +

    # Development
    +ssh node1
    +cd /home/<username>/dev-projects
    +
    +# Production
    +ssh node2
    +cd /opt/production-apps
    +

    +
  4. +
  5. +

    Backup important work: +

    # Regular backups via Git
    +git push origin main
    +
    +# Or manual backup
    +scp -r <username>@<tailscale-ip>:/important/project ./backup/
    +

    +
  6. +
+

Part 10: Team Collaboration

+

Shared Development Servers

+

SSH Config for Team: +

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

+

Project Structure

+
/opt/projects/
+├── project-a/
+│   ├── dev/          # Development branch
+│   ├── staging/      # Staging environment
+│   └── docs/         # Documentation
+├── project-b/
+└── shared-tools/     # Common utilities
+
+

Access Management

+
# Create shared project directory
+sudo mkdir -p /opt/projects
+sudo chown -R :developers /opt/projects
+sudo chmod -R g+w /opt/projects
+
+# Add users to developers group
+sudo usermod -a -G developers <username>
+
+

Quick Reference

+

Essential VSCode Remote Commands

+
# Command Palette shortcuts
+Ctrl+Shift+P  "Remote-SSH: Connect to Host..."
+Ctrl+Shift+P  "Remote-SSH: Open SSH Configuration File..."
+Ctrl+Shift+P  "Remote-SSH: Kill VS Code Server on Host..."
+
+# Terminal
+Ctrl+`  Open integrated terminal
+Ctrl+Shift+`  Create new terminal
+
+# File operations
+Ctrl+O  Open file
+Ctrl+S  Save file
+Ctrl+Shift+E  Focus file explorer
+
+

SSH Connection Quick Test

+
# Test connectivity
+ssh -T node1
+
+# Connect with verbose output
+ssh -v <username>@<tailscale-ip>
+
+# Check SSH config
+ssh -F ~/.ssh/config node1
+
+

Port Forwarding Commands

+
# Manual port forwarding
+ssh -L 3000:localhost:3000 <username>@<tailscale-ip>
+
+# Background tunnel
+ssh -f -N -L 8080:localhost:80 <username>@<tailscale-ip>
+
+

Conclusion

+

This remote development setup provides:

+
    +
  • Full development environment on remote servers
  • +
  • Seamless file access and editing capabilities
  • +
  • Integrated debugging and terminal access
  • +
  • Port forwarding for web development
  • +
  • Extension ecosystem available remotely
  • +
  • Secure connections through Tailscale network
  • +
+

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

+

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

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/build/index.html b/mkdocs/site/v1/build/index.html new file mode 100644 index 00000000..ff3d6d71 --- /dev/null +++ b/mkdocs/site/v1/build/index.html @@ -0,0 +1,3028 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Getting Started - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Getting Started

+

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

+

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

+
    +
  • Build: Instructions on how to build the cm-lite on your own hardware
  • +
  • Services: Overview of all the services that are installed when you install cm-lite
  • +
  • Configuration: Information on how to configure all the services that you install in cm-lite
  • +
  • Manuals: Manuals on how to use the applications inside cm-lite (with videos!)
  • +
+

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

+

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

+

Quick Start

+

Build Changemaker-Lite

+
# Clone the repository
+git clone https://gitea.bnkops.com/admin/changemaker.lite
+cd changemaker.lite
+
+
+

Cloudflare Credentials

+

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

+
+
# Configure environment (creates .env file)
+./config.sh
+
+
# Start all services
+docker compose up -d
+
+

Optional - Site Builld

+

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.

+

Deploy

+
+

Cloudflare

+

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

+
+

For secure public access, use the production deployment script:

+
./start-production.sh
+
+

Map

+

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

+

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

+

Quick Start for Map

+

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

+

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

+
cd map
+docker compose up -d
+
+

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

+

Why Changemaker Lite?

+

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

+
+

The Reality

+

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

+
+

What You're Getting

+
    +
  • Data Sovereignty: Your data stays on your servers
  • +
  • Cost Savings: $50/month instead of $2,000+/month for corporate solutions
  • +
  • Community Control: Technology that serves movements, not shareholders
  • +
  • Trans Liberation: Tools built with radical politics and care
  • +
+

What You're Leaving Behind

+
    +
  • ❌ Corporate surveillance and data extraction
  • +
  • ❌ Escalating subscription fees and vendor lock-in
  • +
  • ❌ Algorithmic manipulation of your community
  • +
  • ❌ Terms of service that can silence you anytime
  • +
+
+

System Requirements

+

Operating System

+
    +
  • Ubuntu 24.04 LTS (Noble Numbat) - Recommended and tested
  • +
+
+

Getting Started on Ubuntu

+

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

+
+
    +
  • Other Linux distributions with systemd support
  • +
  • WSL2 on Windows (limited functionality)
  • +
  • Mac OS
  • +
+
+

New to Linux?

+

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

+
+

Hardware Requirements

+
    +
  • CPU: 2+ cores (4+ recommended)
  • +
  • RAM: 4GB minimum (8GB recommended)
  • +
  • Storage: 20GB+ available disk space
  • +
  • Network: Stable internet connection
  • +
+
+

Cloud Hosting

+

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

+
+

Software Prerequisites

+

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

+
    +
  1. Docker Engine (24.0+)
  2. +
+
# Install Docker
+curl -fsSL https://get.docker.com | sudo sh
+
+# Add your user to docker group
+sudo usermod -aG docker $USER
+
+# Log out and back in for group changes to take effect
+
+
    +
  1. Docker Compose (v2.20+)
  2. +
+
# Verify Docker Compose v2 is installed
+docker compose version
+
+
    +
  1. Essential Tools
  2. +
+
# Install required packages
+sudo apt update
+sudo apt install -y git curl jq openssl
+
+

Installation

+

1. Clone Repository

+
git clone https://gitea.bnkops.com/admin/changemaker.lite
+cd changemaker.lite
+
+

2. Run Configuration Wizard

+

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

+
./config.sh
+
+

This wizard will:

+
    +
  • ✅ Create a .env file with secure defaults
  • +
  • ✅ Scan for available ports to avoid conflicts
  • +
  • ✅ Set up your domain configuration
  • +
  • ✅ Generate secure passwords for databases
  • +
  • ✅ Configure Cloudflare credentials (optional)
  • +
  • ✅ Update all configuration files with your settings
  • +
+

Configuration Options

+

During setup, you'll be prompted for:

+
    +
  1. Domain Name: Your primary domain (e.g., example.com)
  2. +
  3. Cloudflare Settings (optional):
  4. +
  5. API Token
  6. +
  7. Zone ID
  8. +
  9. Account ID
  10. +
  11. Admin Credentials:
  12. +
  13. Listmonk admin email and password
  14. +
  15. n8n admin email and password
  16. +
+

3. Start Services

+

Launch all services with Docker Compose:

+
docker compose up -d
+
+

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

+
# Watch container status
+docker compose ps
+
+# View logs
+docker compose logs -f
+
+

4. Verify Installation

+

Check that all services are running:

+
docker compose ps
+
+

Expected output should show all services as "Up":

+
    +
  • code-server-changemaker
  • +
  • listmonk_app
  • +
  • listmonk_db
  • +
  • mkdocs-changemaker
  • +
  • mkdocs-site-server-changemaker
  • +
  • n8n-changemaker
  • +
  • nocodb
  • +
  • root_db
  • +
  • homepage-changemaker
  • +
  • gitea_changemaker
  • +
  • gitea_mysql_changemaker
  • +
  • mini-qr
  • +
+

Local Access

+

Once services are running, access them locally:

+

🏠 Homepage Dashboard

+
    +
  • URL: http://localhost:3010
  • +
  • Purpose: Central hub for all services
  • +
  • Features: Service status, quick links, monitoring
  • +
+

💻 Development Tools

+ +

📧 Communication

+ +

🔄 Automation & Data

+ +

🛠️ Interactive Tools

+ +

Map

+
+

Map

+

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

+
+

Map Manual

+

Production Deployment

+

Deploy with Cloudflare Tunnels

+

For secure public access, use the production deployment script:

+
./start-production.sh
+
+

This script will:

+
    +
  1. Install and configure cloudflared
  2. +
  3. Create a Cloudflare tunnel
  4. +
  5. Set up DNS records automatically
  6. +
  7. Configure access policies
  8. +
  9. Create a systemd service for persistence
  10. +
+

What Happens During Production Setup

+
    +
  1. Cloudflare Authentication: Browser-based login to Cloudflare
  2. +
  3. Tunnel Creation: Secure tunnel named changemaker-lite
  4. +
  5. DNS Configuration: Automatic CNAME records for all services
  6. +
  7. Access Policies: Email-based authentication for sensitive services
  8. +
  9. Service Installation: Systemd service for automatic startup
  10. +
+

Production URLs

+

After successful deployment, services will be available at:

+

Public Services:

+
    +
  • https://yourdomain.com - Main documentation site
  • +
  • https://listmonk.yourdomain.com - Email campaigns
  • +
  • https://docs.yourdomain.com - Documentation preview
  • +
  • https://n8n.yourdomain.com - Automation platform
  • +
  • https://db.yourdomain.com - NocoDB
  • +
  • https://git.yourdomain.com - Gitea
  • +
  • https://map.yourdomain.com - Map viewer
  • +
  • https://qr.yourdomain.com - QR generator
  • +
+

Protected Services (require authentication):

+
    +
  • https://homepage.yourdomain.com - Dashboard
  • +
  • https://code.yourdomain.com - Code Server
  • +
+

Configuration Management

+

Environment Variables

+

Key settings in .env file:

+
# Domain Configuration
+DOMAIN=yourdomain.com
+BASE_DOMAIN=https://yourdomain.com
+
+# Service Ports (automatically assigned to avoid conflicts)
+HOMEPAGE_PORT=3010
+CODE_SERVER_PORT=8888
+LISTMONK_PORT=9000
+MKDOCS_PORT=4000
+MKDOCS_SITE_SERVER_PORT=4001
+N8N_PORT=5678
+NOCODB_PORT=8090
+GITEA_WEB_PORT=3030
+GITEA_SSH_PORT=2222
+MAP_PORT=3000
+MINI_QR_PORT=8089
+
+# Cloudflare (for production)
+CF_API_TOKEN=your_token
+CF_ZONE_ID=your_zone_id
+CF_ACCOUNT_ID=your_account_id
+
+

Reconfigure Services

+

To update configuration:

+
# Re-run configuration wizard
+./config.sh
+
+# Restart services
+docker compose down && docker compose up -d
+
+

Common Tasks

+

Service Management

+
# View all services
+docker compose ps
+
+# View logs for specific service
+docker compose logs -f [service-name]
+
+# Restart a service
+docker compose restart [service-name]
+
+# Stop all services
+docker compose down
+
+# Stop and remove all data (CAUTION!)
+docker compose down -v
+
+

Backup Data

+
# Backup all volumes
+docker run --rm -v changemaker_listmonk-data:/data -v $(pwd):/backup alpine tar czf /backup/listmonk-backup.tar.gz -C /data .
+
+# Backup configuration
+tar czf configs-backup.tar.gz configs/
+
+# Backup documentation
+tar czf docs-backup.tar.gz mkdocs/docs/
+
+

Update Services

+
# Pull latest images
+docker compose pull
+
+# Recreate containers with new images
+docker compose up -d
+
+

Troubleshooting

+

Port Conflicts

+

If services fail to start due to port conflicts:

+
    +
  1. Check which ports are in use:
  2. +
+
sudo ss -tulpn | grep LISTEN
+
+
    +
  1. Re-run configuration to get new ports:
  2. +
+
./config.sh
+
+
    +
  1. Or manually edit .env file and change conflicting ports
  2. +
+

Permission Issues

+

Fix permission problems:

+
# Get your user and group IDs
+id -u  # User ID
+id -g  # Group ID
+
+# Update .env file with correct IDs
+USER_ID=1000
+GROUP_ID=1000
+
+# Restart services
+docker compose down && docker compose up -d
+
+

Service Won't Start

+

Debug service issues:

+
# Check detailed logs
+docker compose logs [service-name] --tail 50
+
+# Check container status
+docker ps -a
+
+# Inspect container
+docker inspect [container-name]
+
+

Cloudflare Tunnel Issues

+
# Check tunnel service status
+sudo systemctl status cloudflared-changemaker
+
+# View tunnel logs
+sudo journalctl -u cloudflared-changemaker -f
+
+# Restart tunnel
+sudo systemctl restart cloudflared-changemaker
+
+

Next Steps

+

Now that your Changemaker Lite instance is running:

+
    +
  1. Set up Listmonk - Configure SMTP and create your first campaign
  2. +
  3. Create workflows - Build automations in n8n
  4. +
  5. Import data - Set up your NocoDB databases
  6. +
  7. Configure map - Add location data for the map viewer
  8. +
  9. Write documentation - Start creating content in MkDocs
  10. +
  11. Set up Git - Initialize repositories in Gitea
  12. +
+

Getting Help

+
    +
  • Check the Services documentation for detailed guides
  • +
  • Review container logs for specific error messages
  • +
  • Ensure all prerequisites are properly installed
  • +
  • Verify your domain DNS settings for production deployment
  • +
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/build/influence/index.html b/mkdocs/site/v1/build/influence/index.html new file mode 100644 index 00000000..6b763cff --- /dev/null +++ b/mkdocs/site/v1/build/influence/index.html @@ -0,0 +1,3394 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Build Influence - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Influence Build Guide

+

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

+
+

Complete Configuration

+

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

+
+
+

Email Testing

+

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

+
+

Prerequisites

+
    +
  • Docker and Docker Compose installed
  • +
  • NocoDB instance with API access
  • +
  • SMTP email configuration (or use MailHog for testing)
  • +
  • Domain name (optional but recommended for production)
  • +
+

Quick Build Process

+

1. Get NocoDB API Token

+
    +
  1. Login to your NocoDB instance
  2. +
  3. Click user icon → Account SettingsAPI Tokens
  4. +
  5. Create new token with read/write permissions
  6. +
  7. Copy the token for the next step
  8. +
+

2. Configure Environment

+

Navigate to the influence directory and create your environment file:

+
cd influence
+cp example.env .env
+
+

Edit the .env file with your configuration:

+

Development Mode Configuration

+

For development and testing, use MailHog to catch emails:

+
# Development Mode
+NODE_ENV=development
+EMAIL_TEST_MODE=true
+
+# MailHog SMTP (for development)
+SMTP_HOST=mailhog
+SMTP_PORT=1025
+SMTP_SECURE=false
+SMTP_USER=test
+SMTP_PASS=test
+SMTP_FROM_EMAIL=dev@albertainfluence.local
+SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"
+
+# Email Testing
+TEST_EMAIL_RECIPIENT=developer@example.com
+
+

3. Auto-Create Database Structure

+

Run the build script to create required NocoDB tables:

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

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

+

4. Build and Deploy

+

Build the Docker image and start the application:

+
# Build the Docker image
+docker compose build
+
+# Start the application (includes MailHog in development)
+docker compose up -d
+
+

Verify Installation

+
    +
  1. +

    Check container status: +

    docker compose ps
    +

    +
  2. +
  3. +

    View logs: +

    docker compose logs -f app
    +

    +
  4. +
  5. +

    Access the application:

    +
  6. +
  7. Main App: http://localhost:3333
  8. +
  9. Admin Panel: http://localhost:3333/admin.html
  10. +
  11. Email Testing (dev): http://localhost:3333/email-test.html
  12. +
  13. MailHog UI (dev): http://localhost:8025
  14. +
+

Initial Setup

+

1. Create Admin User

+

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

+

2. Create Your First Campaign

+
    +
  1. Login to the admin panel
  2. +
  3. Click "Create Campaign"
  4. +
  5. Configure basic settings:
  6. +
  7. Campaign title and description
  8. +
  9. Email subject and body template
  10. +
  11. Upload cover photo (optional)
  12. +
  13. Set campaign options:
  14. +
  15. ✅ Allow SMTP Email - Enable server-side sending
  16. +
  17. ✅ Allow Mailto Link - Enable browser-based mailto
  18. +
  19. ✅ Collect User Info - Request name and email
  20. +
  21. ✅ Show Email Count - Display engagement metrics
  22. +
  23. ✅ Allow Email Editing - Let users customize message
  24. +
  25. Select target government levels (Federal, Provincial, Municipal, School Board)
  26. +
  27. Set status to Active to make campaign public
  28. +
  29. Click "Create Campaign"
  30. +
+

3. Test Representative Lookup

+
    +
  1. Visit the homepage
  2. +
  3. Enter an Alberta postal code (e.g., T5N4B8)
  4. +
  5. View representatives at all government levels
  6. +
  7. Test email sending functionality
  8. +
+

Development Workflow

+

Email Testing Interface

+

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

+

Features: +- 📧 Quick Test - Send test email with one click +- 👁️ Email Preview - Preview email formatting before sending +- ✏️ Custom Composition - Test with custom subject and message +- 📊 Email Logs - View all sent emails with filtering +- 🔧 SMTP Diagnostics - Test connection and troubleshoot

+

MailHog Web Interface

+

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

+

Switching to Production

+

When ready to deploy to production:

+
    +
  1. +

    Update .env with production SMTP settings: +

    EMAIL_TEST_MODE=false
    +NODE_ENV=production
    +SMTP_HOST=smtp.your-provider.com
    +SMTP_USER=your-real-email@domain.com
    +SMTP_PASS=your-real-password
    +

    +
  2. +
  3. +

    Restart the application: +

    docker compose restart
    +

    +
  4. +
+

Key Features

+

Representative Lookup

+
    +
  • Search by Alberta postal code (T prefix)
  • +
  • Display federal MPs, provincial MLAs, municipal representatives
  • +
  • Smart caching with NocoDB for fast performance
  • +
  • Graceful fallback to Represent API when cache unavailable
  • +
+

Campaign System

+
    +
  • Create unlimited advocacy campaigns
  • +
  • Upload cover photos for campaign pages
  • +
  • Customizable email templates
  • +
  • Optional user information collection
  • +
  • Toggle email count display for engagement metrics
  • +
  • Multi-level government targeting
  • +
+

Email Integration

+
    +
  • SMTP email sending with delivery confirmation
  • +
  • Mailto link support for browser-based email
  • +
  • Comprehensive email logging
  • +
  • Rate limiting for API protection
  • +
  • Test mode for safe development
  • +
+

API Endpoints

+

Public Endpoints

+
    +
  • GET / - Homepage with representative lookup
  • +
  • GET /campaign/:slug - Individual campaign page
  • +
  • GET /api/public/campaigns - List active campaigns
  • +
  • GET /api/representatives/by-postal/:postalCode - Find representatives
  • +
  • POST /api/emails/send - Send campaign email
  • +
+

Admin Endpoints (Authentication Required)

+
    +
  • GET /admin.html - Campaign management dashboard
  • +
  • GET /email-test.html - Email testing interface
  • +
  • POST /api/emails/preview - Preview email without sending
  • +
  • POST /api/emails/test - Send test email
  • +
  • GET /api/test-smtp - Test SMTP connection
  • +
+

Maintenance Commands

+

Update Application

+
docker compose down
+git pull origin main
+docker compose build
+docker compose up -d
+
+

Development Mode

+
cd app
+npm install
+npm run dev
+
+

View Logs

+
# Follow application logs
+docker compose logs -f app
+
+# View MailHog logs (development)
+docker compose logs -f mailhog
+
+

Database Backup

+
# Backup is handled through NocoDB
+# Access NocoDB admin panel to export tables
+
+

Health Check

+
curl http://localhost:3333/api/health
+
+

Troubleshooting

+

NocoDB Connection Issues

+
    +
  • Verify NOCODB_API_URL and NOCODB_API_TOKEN in .env
  • +
  • Run ./scripts/build-nocodb.sh to ensure tables exist
  • +
  • Application works without NocoDB (API fallback mode)
  • +
+

Email Not Sending

+
    +
  • In development: Check MailHog UI at http://localhost:8025
  • +
  • Verify SMTP credentials in .env
  • +
  • Use /email-test.html interface for diagnostics
  • +
  • Check email logs via admin panel
  • +
  • Review docker compose logs -f app for errors
  • +
+

No Representatives Found

+
    +
  • Ensure postal code starts with 'T' (Alberta only)
  • +
  • Try different postal code format (remove spaces)
  • +
  • Check Represent API status: curl http://localhost:3333/api/test-represent
  • +
  • Review application logs for API errors
  • +
+

Campaign Not Appearing

+
    +
  • Verify campaign status is set to "Active"
  • +
  • Check campaign configuration in admin panel
  • +
  • Clear browser cache and reload homepage
  • +
  • Review console for JavaScript errors
  • +
+

Production Deployment

+

Environment Configuration

+
NODE_ENV=production
+EMAIL_TEST_MODE=false
+PORT=3333
+
+# Use production SMTP settings
+SMTP_HOST=smtp.your-provider.com
+SMTP_PORT=587
+SMTP_SECURE=false
+SMTP_USER=your-production-email@domain.com
+SMTP_PASS=your-production-password
+
+

Docker Production

+
# Build and start in production mode
+docker compose -f docker-compose.yml up -d --build
+
+# View logs
+docker compose logs -f app
+
+# Monitor health
+watch curl http://localhost:3333/api/health
+
+

Monitoring

+
    +
  • Health check endpoint: /api/health
  • +
  • Email logs via admin panel
  • +
  • NocoDB integration status in logs
  • +
  • Rate limiting metrics in application logs
  • +
+

Security Considerations

+
    +
  • 🔒 Always use strong passwords for admin accounts
  • +
  • 🔒 Enable HTTPS in production (use reverse proxy)
  • +
  • 🔒 Rotate SMTP credentials regularly
  • +
  • 🔒 Monitor email logs for suspicious activity
  • +
  • 🔒 Set appropriate rate limits based on expected traffic
  • +
  • 🔒 Keep NocoDB API tokens secure and rotate periodically
  • +
  • 🔒 Use EMAIL_TEST_MODE=false only in production
  • +
+

Support

+

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

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/build/map/index.html b/mkdocs/site/v1/build/map/index.html new file mode 100644 index 00000000..516ee7ca --- /dev/null +++ b/mkdocs/site/v1/build/map/index.html @@ -0,0 +1,2657 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Build Map - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Map Build Guide

+

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

+
+

Complete Configuration

+

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

+
+
+

Clean NocoDB

+

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

+
+

Prerequisites

+
    +
  • Docker and Docker Compose installed
  • +
  • NocoDB instance with API access
  • +
  • Domain name (optional but recommended for production)
  • +
+

Quick Build Process

+

1. Get NocoDB API Token

+
    +
  1. Login to your NocoDB instance
  2. +
  3. Click user icon → Account SettingsAPI Tokens
  4. +
  5. Create new token with read/write permissions
  6. +
  7. Copy the token for the next step
  8. +
+

2. Configure Environment

+

Edit the .env file in the map/ directory:

+
cd map
+
+

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

+
NOCODB_API_URL=[change me]
+NOCODB_API_TOKEN=[change me]
+
+# NocoDB View URL is the URL to your NocoDB view where the map data is stored.
+NOCODB_VIEW_URL=[change me]
+
+# NOCODB_LOGIN_SHEET is the URL to your NocoDB login sheet.
+NOCODB_LOGIN_SHEET=[change me]
+
+# NOCODB_SETTINGS_SHEET is the URL to your NocoDB settings sheet.
+NOCODB_SETTINGS_SHEET=[change me]
+
+# NOCODB_SHIFTS_SHEET is the URL to your shifts sheet.
+NOCODB_SHIFTS_SHEET=[change me]
+
+# NOCODB_SHIFT_SIGNUPS_SHEET is the URL to your NocoDB shift signups sheet where users can add their own shifts.
+NOCODB_SHIFT_SIGNUPS_SHEET=[change me]
+
+# NOCODB_CUTS_SHEET is the URL to your NocoDB Cuts sheet.
+NOCODB_CUTS_SHEET=[change me]
+
+DOMAIN=[change me]
+
+# MkDocs Integration
+MKDOCS_URL=[change me]
+MKDOCS_SEARCH_URL=[change me]
+MKDOCS_SITE_SERVER_PORT=4002
+
+# Server Configuration
+PORT=3000
+NODE_ENV=production
+
+# Session Secret (IMPORTANT: Generate a secure random string for production)
+SESSION_SECRET=[change me]
+
+# Map Defaults (Edmonton, Alberta, Canada)
+DEFAULT_LAT=53.5461
+DEFAULT_LNG=-113.4938
+DEFAULT_ZOOM=11
+
+# Optional: Map Boundaries (prevents users from adding points outside area)
+# BOUND_NORTH=53.7
+# BOUND_SOUTH=53.4
+# BOUND_EAST=-113.3
+# BOUND_WEST=-113.7
+
+# Cloudflare Settings
+TRUST_PROXY=true
+COOKIE_DOMAIN=[change me]
+
+# Update NODE_ENV to production for HTTPS
+NODE_ENV=production
+
+# Add allowed origin
+ALLOWED_ORIGINS=[change me]
+
+# SMTP Configuration
+SMTP_HOST=[change me]
+SMTP_PORT=587   
+SMTP_SECURE=false
+SMTP_USER=[change me]
+SMTP_PASS=[change me]
+EMAIL_FROM_NAME="[change me]"
+EMAIL_FROM_ADDRESS=[change me]
+
+# App Configuration
+APP_NAME="[change me]"
+
+# Listmonk Configuration
+LISTMONK_API_URL=[change me]
+LISTMONK_USERNAME=[change me]
+LISTMONK_PASSWORD=[change me]
+LISTMONK_SYNC_ENABLED=true
+LISTMONK_INITIAL_SYNC=false  # Set to true only for first run to sync existing data
+
+

3. Auto-Create Database Structure

+

Run the build script to create required tables:

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

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

+

4. Get Table URLs

+

After the script completes:

+
    +
  1. Login to your NocoDB instance
  2. +
  3. Navigate to your project ("Map Viewer Project")
  4. +
  5. Copy the view URLs for each table from your browser address bar
  6. +
  7. URLs should look like: https://your-nocodb.com/dashboard/#/nc/project-id/table-id
  8. +
+

5. Update Environment with URLs

+

Edit your .env file and add the table URLs:

+
# NocoDB View URL is the URL to your NocoDB view where the map data is stored.
+NOCODB_VIEW_URL=[change me]
+
+# NOCODB_LOGIN_SHEET is the URL to your NocoDB login sheet.
+NOCODB_LOGIN_SHEET=[change me]
+
+# NOCODB_SETTINGS_SHEET is the URL to your NocoDB settings sheet.
+NOCODB_SETTINGS_SHEET=[change me]
+
+# NOCODB_SHIFTS_SHEET is the URL to your shifts sheet.
+NOCODB_SHIFTS_SHEET=[change me]
+
+# NOCODB_SHIFT_SIGNUPS_SHEET is the URL to your NocoDB shift signups sheet where users can add their own shifts.
+NOCODB_SHIFT_SIGNUPS_SHEET=[change me]
+
+# NOCODB_CUTS_SHEET is the URL to your NocoDB Cuts sheet.
+NOCODB_CUTS_SHEET=[change me]
+
+

6. Build and Deploy

+

Build the Docker image and start the application:

+
# Build the Docker image
+docker-compose build
+
+# Start the application
+docker-compose up -d
+
+

Verify Installation

+
    +
  1. +

    Check container status: +

    docker-compose ps
    +

    +
  2. +
  3. +

    View logs: +

    docker-compose logs -f map-viewer
    +

    +
  4. +
  5. +

    Access the application at http://localhost:3000

    +
  6. +
+

Quick Start

+
    +
  1. Login: Use an email from your Login table
  2. +
  3. Add Locations: Click on the map to add new locations
  4. +
  5. Admin Panel: Admin users can access /admin.html for configuration
  6. +
  7. Walk Sheets: Generate printable canvassing forms with QR codes
  8. +
+

Maintenance Commands

+

Update Application

+
docker-compose down
+git pull origin main
+docker-compose build
+docker-compose up -d
+
+

Development Mode

+
cd app
+npm install
+npm run dev
+
+

Health Check

+
curl http://localhost:3000/health
+
+

Support

+

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

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/build/server/index.html b/mkdocs/site/v1/build/server/index.html new file mode 100644 index 00000000..1555350b --- /dev/null +++ b/mkdocs/site/v1/build/server/index.html @@ -0,0 +1,2799 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Build Server - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

BNKops Server Build

+

Purpose: a Ubuntu server build-out for general application

+
+

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

+

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

+

Ubuntu OS

+

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

+

Install Ubuntu

+

Post Install

+

Post installation, run update: +

sudo apt update
+

+
sudo apt upgrade
+
+

Configuration

+

Further configurations:

+
    +
  • User profile was updated to Automatically Login
  • +
  • Remote Desktop, Sharing, and Login have all been enabled.
  • +
  • Default system settings have been set to dark mode.
  • +
+

VSCode Insiders

+

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.

+

Install Using App Centre

+

Obsidian

+

The free and flexible app for your private thoughts.

+

Install Using App Center

+

Curl

+

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

+

Install

+
sudo apt install curl 
+
+

Glances

+

Glances an Eye on your system. A top/htop alternative for GNU/Linux, BSD, Mac OS and Windows operating systems.

+

Install

+
sudo snap install glances 
+
+

Syncthing

+

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’s transmitted over the internet.

+

Install

+
# Add the release PGP keys:
+sudo mkdir -p /etc/apt/keyrings
+sudo curl -L -o /etc/apt/keyrings/syncthing-archive-keyring.gpg https://syncthing.net/release-key.gpg
+
+
# Add the "stable" channel to your APT sources:
+echo "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
+
+
# Update and install syncthing:
+sudo apt-get update
+sudo apt-get install syncthing
+
+

Post Install

+

Run syncthing as a system service. +

sudo systemctl start syncthing@yourusername
+

+
sudo systemctl enable syncthing@yourusername
+
+

Docker

+

Docker helps developers build, share, run, and verify applications anywhere — without tedious environment configuration or management. +

# Add Docker's official GPG key:
+sudo apt-get update
+sudo apt-get install ca-certificates curl
+sudo install -m 0755 -d /etc/apt/keyrings
+sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
+sudo chmod a+r /etc/apt/keyrings/docker.asc
+
+# Add the repository to Apt sources:
+echo \
+  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
+  $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
+  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
+sudo apt-get update
+

+
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
+
+

Update Users

+
sudo groupadd docker
+
+
sudo usermod -aG docker $USER
+
+
newgrp docker
+
+

Enable on Boot

+
sudo systemctl enable docker.service
+sudo systemctl enable containerd.service
+
+

Cloudflared

+

Connect, protect, and build everywhere. We make websites, apps, and networks faster and more secure. Our developer platform is the best place to build modern apps and deliver AI initiatives.

+
sudo mkdir -p --mode=0755 /usr/share/keyrings
+curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
+
+
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
+
+
sudo apt-get update && sudo apt-get install cloudflared
+
+

Post Install

+

Login to Cloudflare +

cloudflared login
+

+

Configuration

+

The ./config.sh and ./start-production.sh scripts will properly configure a Cloudflare tunnel and service to put your system online. More info in the Cloudflare Configuration.

+

Pandoc

+

If you need to convert files from one markup format into another, pandoc is your swiss-army knife.

+
sudo apt install pandoc
+
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/build/site/index.html b/mkdocs/site/v1/build/site/index.html new file mode 100644 index 00000000..1290b91f --- /dev/null +++ b/mkdocs/site/v1/build/site/index.html @@ -0,0 +1,2342 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Build Site - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Building the Site with MkDocs Material

+

Welcome! This guide will help you get started building and customizing your site using MkDocs Material.

+
+

Reset Site

+

You can read through all the BNKops cmlite documentation already in your docs folder or you can reset your docs folder to a baseline to start and read more manuals here. To reset docs folder to baseline, run the following:

+
./reset-site.sh
+
+

🚀 How to Build Your Site (Step by Step)

+
    +
  1. Open your Coder instance. + For example: coder.yourdomain.com
  2. +
  3. Go to the mkdocs folder:
    + In the terminal (for a new terminal press Crtl - Shift - ~), type: +
    cd mkdocs
    +
  4. +
  5. Build the site:
    + Type: +
    mkdocs build
    +
    + This creates the static website from your documents and places them in the mkdocs/site directory.
  6. +
+

Preview your site locally:
+ Visit localhost:4000 for local development or live.youdomain.com to see a public live load.

+
    +
  • All documentation in the mkdocs/docs folder is included automatically.
  • +
  • The site uses the beautiful and easy-to-use Material for MkDocs theme.
  • +
+

Material for MkDocs Documentation

+
+

Build vs Serve

+

Your website is built in stages. Any edits to documents in the mkdocs directory are instantly served and visible at localhost:4000 or if in production mode live.yourdomain.com. The live site is not meant as a public access point and will crash if too many requests are made to it.

+

Running mkdocs build pushes any changes to the site directory, which then a ngnix server pushes them to the production server for public access at your root domain (yourdomain.com).

+

You can think of it as serve/live = draft for personal review and build = save/push to production for the public.

+

This combination allows for rapid development of documentation while ensuring your live site does not get updated until your content is ready.

+
+
+

🧹 Resetting the Site

+

If you want to start fresh:

+
    +
  1. +

    Delete all folders EXCEPT these folders:

    +
      +
    • /blog
    • +
    • /javascripts
    • +
    • /hooks
    • +
    • /assets
    • +
    • /stylesheets
    • +
    • /overrides
    • +
    +
  2. +
  3. +

    Reset the landing page:

    +
      +
    • Open the main index.md file and remove everything at the very top (the "front matter").
    • +
    • Or edit /overrides/home.html to change the landing page.
    • +
    +
  4. +
  5. +

    Reset the mkdocs.yml

    +
      +
    • Open mkdocs.yml and delete the nav section entirely.
    • +
    • This action will enable mkdocs to build your site navigation based on file names in the root directory.
    • +
    +
  6. +
+
+

🤖 Using AI to Help Build Your Site

+
    +
  • If you have a claude.ai subscription, you can use powerful AI in your Coder terminal to write or rewrite pages, including a new home.html.
  • +
  • All you need to do is open the terminal and type: +
    claude
    +
  • +
  • You can also try local AI tools like Ollama for on-demand help.
  • +
+
+

🛠️ First-Time Setup Tips

+
    +
  • Navigation:
    + Open mkdocs.yml and remove the nav section to start with a blank menu. Add your own pages as you go.
  • +
  • Customize the look:
    + Check out the Material for MkDocs customization guide.
  • +
  • Live preview:
    + Use mkdocs serve (see above) to see changes instantly as you edit.
  • +
  • Custom files:
    + Put your own CSS, JavaScript, or HTML in /assets, /stylesheets, /javascripts, or /overrides.
  • +
+

Quick Start Guide

+
+

📚 More Resources

+ +
+

Happy building!

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/config/cloudflare-config/index.html b/mkdocs/site/v1/config/cloudflare-config/index.html new file mode 100644 index 00000000..ef50e3a5 --- /dev/null +++ b/mkdocs/site/v1/config/cloudflare-config/index.html @@ -0,0 +1,2346 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cloudflare - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Configure Cloudflare

+

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)

+

Credentials

+

The config.sh and start-production.sh scripts require the following Cloudflare credentials to function properly:

+

1. Cloudflare API Token

+
    +
  • Purpose: Used to authenticate API requests to Cloudflare for managing DNS records, tunnels, and access policies.
  • +
  • Required Permissions:
      +
    • Zone.DNS (Read/Write)
    • +
    • Account.Cloudflare Tunnel (Read/Write)
    • +
    • Access (Read/Write)
    • +
    +
  • +
  • How to Obtain:
      +
    • Log in to your Cloudflare account.
    • +
    • Go to My Profile > API Tokens > Create Token.
    • +
    • Use the Edit zone DNS template and add Cloudflare Tunnel permissions.
    • +
    +
  • +
+

2. Cloudflare Zone ID

+
    +
  • Purpose: Identifies the specific DNS zone (domain) in Cloudflare where DNS records will be created.
  • +
  • How to Obtain:
      +
    • Log in to your Cloudflare account.
    • +
    • Select the domain you want to use.
    • +
    • The Zone ID is displayed in the Overview section under API.
    • +
    +
  • +
+

3. Cloudflare Account ID

+
    +
  • Purpose: Identifies your Cloudflare account for tunnel creation and management.
  • +
  • How to Obtain:
      +
    • Log in to your Cloudflare account.
    • +
    • Go to My Profile > API Tokens.
    • +
    • The Account ID is displayed at the top of the page.
    • +
    +
  • +
+

4. Cloudflare Tunnel ID (Optional in config.sh, Required in start-production.sh)

+
+

Automatic Configuration of Tunnel

+

The start-production.sh script will automatically create a tunnel and system service for Cloudflare.

+
+
    +
  • Purpose: Identifies the specific Cloudflare Tunnel that will be used to route traffic to your services.
  • +
  • How to Obtain:
      +
    • This is automatically generated when you create a tunnel using cloudflared tunnel create or via the Cloudflare dashboard.
    • +
    +
  • +
  • The start-production.sh script will create this for you if it doesn't exist.
  • +
+

Summary of Required Credentials:

+
# In .env file
+CF_API_TOKEN=your_cloudflare_api_token
+CF_ZONE_ID=your_cloudflare_zone_id
+CF_ACCOUNT_ID=your_cloudflare_account_id
+CF_TUNNEL_ID=will_be_set_by_start_production  # This will be set by start-production.sh
+
+

Notes:

+
    +
  • The config.sh script will prompt you for these credentials during setup.
  • +
  • The start-production.sh script will verify these credentials and use them to configure DNS records, create tunnels, and set up access policies.
  • +
  • Ensure that the API token has the correct permissions, or the scripts will fail to configure Cloudflare services.
  • +
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/config/coder/index.html b/mkdocs/site/v1/config/coder/index.html new file mode 100644 index 00000000..714adec4 --- /dev/null +++ b/mkdocs/site/v1/config/coder/index.html @@ -0,0 +1,2889 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Code Server - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Coder Server Configuration

+

This section describes the configuration and features of the code-server environment.

+

Accessing Code Server

+
    +
  • URL: http://localhost:8080
  • +
  • Authentication: Password-based (see below for password retrieval)
  • +
+

Retrieving the Code Server Password

+

After the first build, the code-server password is stored in:

+
configs/code-server/.config/code-server/config.yaml
+
+

Look for the password: field in that file. For example:

+
password: 0c0dca951a2d12eff1665817
+
+
+

Note: It is recommended not to change this password manually, as it is securely generated.

+
+

Main Configuration Options

+
    +
  • bind-addr: The address and port code-server listens on (default: 127.0.0.1:8080)
  • +
  • auth: Authentication method (default: password)
  • +
  • password: The login password (see above)
  • +
  • cert: Whether to use HTTPS (default: false)
  • +
+

Installed Tools and Features

+

The code-server environment includes:

+
    +
  • Node.js 18+ and npm
  • +
  • Claude Code (@anthropic-ai/claude-code) globally installed
  • +
  • Python 3 and tools:
  • +
  • python3-pip, python3-venv, python3-full, pipx
  • +
  • Image and PDF processing libraries:
  • +
  • CairoSVG, Pillow, libcairo2-dev, libfreetype6-dev, libjpeg-dev, libpng-dev, libwebp-dev, libtiff5-dev, libopenjp2-7-dev, liblcms2-dev
  • +
  • weasyprint, fonts-roboto
  • +
  • Git for version control and plugin management
  • +
  • Build tools: build-essential, pkg-config, python3-dev, zlib1g-dev
  • +
  • MkDocs Material and a wide range of MkDocs plugins, installed in a dedicated Python virtual environment at /home/coder/.venv/mkdocs
  • +
  • Convenience script: run-mkdocs for running MkDocs commands easily
  • +
+

Using MkDocs

+

The virtual environment for MkDocs is automatically added to your PATH. You can run MkDocs commands directly, or use the provided script. For example, to build the site, from a clean terminal we would rung:

+
cd mkdocs 
+mkdocs build
+
+

Claude Code Integration

+
+ +

The code-server environment comes with Claude Code (@anthropic-ai/claude-code) globally installed via npm.

+

What is Claude Code?

+

Claude Code is an AI-powered coding assistant by Anthropic, designed to help you write, refactor, and understand code directly within your development environment.

+

Usage

+
    +
  • Access Claude Code features through the command palette or sidebar in code-server.
  • +
  • Use Claude Code to generate code, explain code snippets, or assist with documentation and refactoring tasks.
  • +
  • For more information, refer to the Claude Code documentation.
  • +
+
+

Note: Claude Code requires an API key or account with Anthropic for full functionality. Refer to the extension settings for configuration.

+
+

Call Claude

+

To use claude simply type claude into the terminal and follow instructions.

+
claude
+
+

Shell Environment

+

The .bashrc is configured to include the MkDocs virtual environment and user-local binaries in your PATH for convenience.

+

Code Navigation and Editing Features

+

The code-server environment provides robust code navigation and editing features, including:

+
    +
  • IntelliSense: Smart code completions based on variable types, function definitions, and imported modules.
  • +
  • Code Navigation: Easily navigate to definitions, references, and symbol searches within your codebase.
  • +
  • Debugging Support: Integrated debugging support for Node.js and Python, with breakpoints, call stacks, and interactive consoles.
  • +
  • Terminal Access: Built-in terminal access to run commands, scripts, and version control operations.
  • +
+

Collaboration Features

+

Code-server includes features to support collaboration:

+
    +
  • Live Share: Collaborate in real-time with others, sharing your code and terminal sessions.
  • +
  • ChatGPT Integration: AI-powered code assistance and chat-based collaboration.
  • +
+

Security Considerations

+

When using code-server, consider the following security aspects:

+
    +
  • Password Management: The default password is securely generated. Do not share it or expose it in public repositories.
  • +
  • Network Security: Ensure that your firewall settings allow access to the code-server port (default: 8080) only from trusted networks.
  • +
  • Data Privacy: Be cautious when uploading sensitive data or code to the server. Use environment variables or secure vaults for sensitive information.
  • +
+

Ollama Integration

+
+ +

The code-server environment includes Ollama, a tool for running large language models locally on your machine.

+

What is Ollama?

+

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.

+

Getting Started with Ollama

+

Staring Ollama

+

For ollama to be available, you need to open a terminal and run:

+
ollama serve
+
+

This will start the ollama server and you can then proceed to pulling a model and chatting.

+

Pulling a Model

+

To get started, you'll need to pull a model. For development and testing, we recommend starting with a smaller model like Gemma 2B:

+
ollama pull gemma2:2b
+
+

For even lighter resource usage, you can use the 1B parameter version:

+
ollama pull gemma2:1b
+
+

Running a Model

+

Once you've pulled a model, you can start an interactive session:

+
ollama run gemma2:2b
+
+

Available Models

+

Popular models available through Ollama include:

+
    +
  • Gemma 2 (1B, 2B, 9B, 27B): Google's efficient language models
  • +
  • Llama 3.2 (1B, 3B, 11B, 90B): Meta's latest language models
  • +
  • Qwen 2.5 (0.5B, 1.5B, 3B, 7B, 14B, 32B, 72B): Alibaba's multilingual models
  • +
  • Phi 3.5 (3.8B): Microsoft's compact language model
  • +
  • Code Llama (7B, 13B, 34B): Specialized for code generation
  • +
+

Using Ollama in Your Development Workflow

+

API Access

+

Ollama provides a REST API that runs on http://localhost:11434 by default. You can integrate this into your applications:

+
curl http://localhost:11434/api/generate -d '{
+  "model": "gemma2:2b",
+  "prompt": "Write a Python function to calculate fibonacci numbers",
+  "stream": false
+}'
+
+

Model Management

+

List installed models: +

ollama list
+

+

Remove a model: +

ollama rm gemma2:2b
+

+

Show model information: +

ollama show gemma2:2b
+

+

Resource Considerations

+
    +
  • 1B models: Require ~1GB RAM, suitable for basic tasks and resource-constrained environments
  • +
  • 2B models: Require ~2GB RAM, good balance of capability and resource usage
  • +
  • Larger models: Provide better performance but require significantly more resources
  • +
+

Integration with Development Tools

+

Ollama can be integrated with various development tools and editors through its API, enabling features like:

+
    +
  • Code completion and generation
  • +
  • Documentation writing assistance
  • +
  • Code review and explanation
  • +
  • Automated testing suggestions
  • +
+

For more information, visit the Ollama documentation.

+

For more detailed information on configuring and using code-server, refer to the official code-server documentation.

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/config/index.html b/mkdocs/site/v1/config/index.html new file mode 100644 index 00000000..37ebb8a6 --- /dev/null +++ b/mkdocs/site/v1/config/index.html @@ -0,0 +1,2060 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Configuration - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Configuration

+

There are several configuration steps to building a production ready Changemaker-Lite.

+

In the order we suggest doing them:

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/config/map/index.html b/mkdocs/site/v1/config/map/index.html new file mode 100644 index 00000000..ad13f712 --- /dev/null +++ b/mkdocs/site/v1/config/map/index.html @@ -0,0 +1,3634 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Map - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Map Configuration

+

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.

+

Features

+
    +
  • 🗺️ Interactive map visualization with OpenStreetMap
  • +
  • 📍 Real-time geolocation support for adding locations
  • +
  • ➕ Add new locations directly from the map interface
  • +
  • 🔄 Auto-refresh every 30 seconds
  • +
  • 📱 Responsive design for mobile devices
  • +
  • 🔒 Secure API proxy to protect NocoDB credentials
  • +
  • 👤 User authentication with login system
  • +
  • ⚙️ Admin panel for system configuration
  • +
  • 🎯 Configurable map start location
  • +
  • 📄 Walk Sheet generator for door-to-door canvassing
  • +
  • 🔗 QR code integration for digital resources
  • +
  • 🐳 Docker containerization for easy deployment
  • +
  • 🆓 100% open source (no proprietary dependencies)
  • +
+

Setup Process Overview

+

The setup process involves several steps that must be completed in order:

+
    +
  1. Get NocoDB API Token - Create an API token in your NocoDB instance
  2. +
  3. Configure Environment - Update the .env file with your NocoDB details
  4. +
  5. Auto-Create Database Structure - Run the build script to create required tables
  6. +
  7. Get Table URLs - Find and copy the URLs for the newly created tables
  8. +
  9. Update Environment with URLs - Add the table URLs to your .env file
  10. +
  11. Build and Deploy - Build the Docker image and start the application
  12. +
+

Prerequisites

+
    +
  • Docker and Docker Compose installed
  • +
  • NocoDB instance with API access
  • +
  • Domain name (optional but recommended for production)
  • +
+

Step 1: Get NocoDB API Token

+
    +
  1. Login to your NocoDB instance
  2. +
  3. Click your user icon → Account Settings
  4. +
  5. Go to the API Tokens tab
  6. +
  7. Click Create new token
  8. +
  9. Set the following permissions:
  10. +
  11. Read: Yes
  12. +
  13. Write: Yes
  14. +
  15. Delete: Yes (optional, for admin functions)
  16. +
  17. Copy the generated token - you'll need it for the next step
  18. +
+
+

Token Security

+

Keep your API token secure and never commit it to version control. The token provides full access to your NocoDB data.

+
+

Step 2: Configure Environment

+

Edit the .env file in the map/ directory:

+
# NocoDB API Configuration
+NOCODB_API_URL=https://your-nocodb-instance.com/api/v1
+NOCODB_API_TOKEN=your-api-token-here
+
+# These URLs will be populated after running build-nocodb.sh
+NOCODB_VIEW_URL=
+NOCODB_LOGIN_SHEET=
+NOCODB_SETTINGS_SHEET=
+
+# Server Configuration
+PORT=3000
+NODE_ENV=production
+
+# Session Secret (generate with: openssl rand -hex 32)
+SESSION_SECRET=your-secure-random-string
+
+# Map Defaults (Edmonton, Alberta, Canada)
+DEFAULT_LAT=53.5461
+DEFAULT_LNG=-113.4938
+DEFAULT_ZOOM=11
+
+# Optional: Map Boundaries (prevents users from adding points outside area)
+# BOUND_NORTH=53.7
+# BOUND_SOUTH=53.4
+# BOUND_EAST=-113.3
+# BOUND_WEST=-113.7
+
+# Production Settings
+TRUST_PROXY=true
+COOKIE_DOMAIN=.yourdomain.com
+ALLOWED_ORIGINS=https://map.yourdomain.com,http://localhost:3000
+
+

Required Configuration

+
    +
  • NOCODB_API_URL: Your NocoDB instance API URL (usually ends with /api/v1)
  • +
  • NOCODB_API_TOKEN: The token you created in Step 1
  • +
  • SESSION_SECRET: Generate a secure random string for session encryption
  • +
+

Optional Configuration

+
    +
  • DEFAULT_LAT/LNG/ZOOM: Default map center and zoom level
  • +
  • BOUND_*: Map boundaries to restrict where users can add points
  • +
  • COOKIE_DOMAIN: Your domain for cookie security
  • +
  • ALLOWED_ORIGINS: Comma-separated list of allowed origins for CORS
  • +
+

Step 3: Auto-Create Database Structure

+

The build-nocodb.sh script will automatically create the required tables in your NocoDB instance.

+
cd map
+chmod +x build-nocodb.sh
+./build-nocodb.sh
+
+

What the Script Creates

+

The script creates three tables with the following structure:

+

1. Locations Table

+

Main table for storing map data:

+
    +
  • Geo-Location (Geo-Data): Format "latitude;longitude"
  • +
  • latitude (Decimal): Precision 10, Scale 8
  • +
  • longitude (Decimal): Precision 11, Scale 8
  • +
  • First Name (Single Line Text): Person's first name
  • +
  • Last Name (Single Line Text): Person's last name
  • +
  • Email (Email): Email address
  • +
  • Phone (Single Line Text): Phone number
  • +
  • Unit Number (Single Line Text): Unit or apartment number
  • +
  • Address (Single Line Text): Street address
  • +
  • Support Level (Single Select): Options: "1", "2", "3", "4"
  • +
  • 1 = Strong Support (Green)
  • +
  • 2 = Moderate Support (Yellow)
  • +
  • 3 = Low Support (Orange)
  • +
  • 4 = No Support (Red)
  • +
  • Sign (Checkbox): Has campaign sign
  • +
  • Sign Size (Single Select): Options: "Regular", "Large", "Unsure"
  • +
  • Notes (Long Text): Additional details and comments
  • +
+

2. Login Table

+

User authentication table:

+
    +
  • Email (Email): User email address (Primary)
  • +
  • Name (Single Line Text): User display name
  • +
  • Admin (Checkbox): Admin privileges
  • +
+

3. Settings Table

+

Admin configuration table:

+
    +
  • key (Single Line Text): Setting identifier
  • +
  • title (Single Line Text): Display name
  • +
  • value (Long Text): Setting value
  • +
  • Geo-Location (Text): Format "latitude;longitude"
  • +
  • latitude (Decimal): Precision 10, Scale 8
  • +
  • longitude (Decimal): Precision 11, Scale 8
  • +
  • zoom (Number): Map zoom level
  • +
  • category (Single Select): Setting category
  • +
  • updated_by (Single Line Text): Last updater email
  • +
  • updated_at (DateTime): Last update time
  • +
  • qr_code_1_image (Attachment): QR code 1 image
  • +
  • qr_code_2_image (Attachment): QR code 2 image
  • +
  • qr_code_3_image (Attachment): QR code 3 image
  • +
+

Default Data

+

The script also creates: +- A default admin user (admin@example.com) +- A default start location setting

+

Step 4: Get Table URLs

+

After the script completes successfully:

+
    +
  1. Login to your NocoDB instance
  2. +
  3. Navigate to your project (should be named "Map Viewer Project")
  4. +
  5. For each table, get the view URL:
  6. +
  7. Click on the table name
  8. +
  9. Copy the URL from your browser's address bar
  10. +
  11. The URL should look like: https://your-nocodb.com/dashboard/#/nc/project-id/table-id
  12. +
+

You need URLs for: +- Locations tableNOCODB_VIEW_URL +- Login tableNOCODB_LOGIN_SHEET +- Settings tableNOCODB_SETTINGS_SHEET

+

Step 5: Update Environment with URLs

+

Edit your .env file and add the table URLs:

+
# Update these with the actual URLs from your NocoDB instance
+NOCODB_VIEW_URL=https://your-nocodb.com/dashboard/#/nc/project-id/locations-table-id
+NOCODB_LOGIN_SHEET=https://your-nocodb.com/dashboard/#/nc/project-id/login-table-id
+NOCODB_SETTINGS_SHEET=https://your-nocodb.com/dashboard/#/nc/project-id/settings-table-id
+
+
+

URL Format

+

Make sure to use the complete dashboard URLs, not the API URLs. The application will automatically extract the project and table IDs from these URLs.

+
+

Step 6: Build and Deploy

+

Build the Docker image and start the application:

+
# Build the Docker image
+docker-compose build
+
+# Start the application
+docker-compose up -d
+
+

Verify Deployment

+
    +
  1. +

    Check that the container is running: +

    docker-compose ps
    +

    +
  2. +
  3. +

    Check the logs: +

    docker-compose logs -f map-viewer
    +

    +
  4. +
  5. +

    Access the application at http://localhost:3000 (or your configured domain)

    +
  6. +
+

Using the Map System

+

User Interface

+

Main Map View

+
    +
  • Interactive Map: Click and drag to navigate
  • +
  • Add Location: Click on the map to add a new location
  • +
  • Search: Use the search bar to find addresses
  • +
  • Refresh: Data refreshes automatically every 30 seconds
  • +
+

Location Markers

+
    +
  • Green: Strong Support (Level 1)
  • +
  • Yellow: Moderate Support (Level 2)
  • +
  • Orange: Low Support (Level 3)
  • +
  • Red: No Support (Level 4)
  • +
+

Adding Locations

+
    +
  1. Click on the map where you want to add a location
  2. +
  3. Fill out the form with contact information
  4. +
  5. Select support level and sign information
  6. +
  7. Add any relevant notes
  8. +
  9. Click "Save Location"
  10. +
+

Authentication

+

User Login

+
    +
  • Users must be added to the Login table in NocoDB
  • +
  • Login with email address (no password required for simplified setup)
  • +
  • Admin users have additional privileges
  • +
+

Admin Access

+
    +
  • Admin users can access /admin.html
  • +
  • Configure map start location
  • +
  • Set up walk sheet generator
  • +
  • Manage QR codes and settings
  • +
+

Admin Panel Features

+

Start Location Configuration

+
    +
  • Interactive Map: Visual interface for selecting coordinates
  • +
  • Real-time Preview: See changes immediately
  • +
  • Validation: Built-in coordinate and zoom level validation
  • +
+

Walk Sheet Generator

+
    +
  • Printable Forms: Generate 8.5x11 walk sheets for door-to-door canvassing
  • +
  • QR Code Integration: Add up to 3 QR codes with custom URLs and labels
  • +
  • Form Field Matching: Automatically matches fields from the main location form
  • +
  • Live Preview: See changes as you type
  • +
  • Print Optimization: Proper formatting for printing or PDF export
  • +
+

API Endpoints

+

Public Endpoints

+
    +
  • GET /api/locations - Fetch all locations (requires auth)
  • +
  • POST /api/locations - Create new location (requires auth)
  • +
  • GET /api/locations/:id - Get single location (requires auth)
  • +
  • PUT /api/locations/:id - Update location (requires auth)
  • +
  • DELETE /api/locations/:id - Delete location (requires auth)
  • +
  • GET /api/config/start-location - Get map start location
  • +
  • GET /health - Health check
  • +
+

Authentication Endpoints

+
    +
  • POST /api/auth/login - User login
  • +
  • GET /api/auth/check - Check authentication status
  • +
  • POST /api/auth/logout - User logout
  • +
+

Admin Endpoints (requires admin privileges)

+
    +
  • GET /api/admin/start-location - Get start location with source info
  • +
  • POST /api/admin/start-location - Update map start location
  • +
  • GET /api/admin/walk-sheet-config - Get walk sheet configuration
  • +
  • POST /api/admin/walk-sheet-config - Save walk sheet configuration
  • +
+

Troubleshooting

+

Common Issues

+

Locations not showing

+
    +
  • Verify table has required columns (Geo-Location, latitude, longitude)
  • +
  • Check that coordinates are valid numbers
  • +
  • Ensure API token has read permissions
  • +
  • Verify NOCODB_VIEW_URL is correct
  • +
+

Cannot add locations

+
    +
  • Verify API token has write permissions
  • +
  • Check browser console for errors
  • +
  • Ensure coordinates are within valid ranges
  • +
  • Verify user is authenticated
  • +
+

Authentication issues

+
    +
  • Verify login table is properly configured
  • +
  • Check that user email exists in Login table
  • +
  • Ensure NOCODB_LOGIN_SHEET URL is correct
  • +
+

Build script failures

+
    +
  • Check that NOCODB_API_URL and NOCODB_API_TOKEN are correct
  • +
  • Verify NocoDB instance is accessible
  • +
  • Check network connectivity
  • +
  • Review script output for specific error messages
  • +
+

Development Mode

+

For development and debugging:

+
cd map/app
+npm install
+npm run dev
+
+

This will start the application with hot reload and detailed logging.

+

Logs and Monitoring

+

View application logs: +

docker-compose logs -f map-viewer
+

+

Check health status: +

curl http://localhost:3000/health
+

+

Security Considerations

+
    +
  1. API Token Security: Keep tokens secure and rotate regularly
  2. +
  3. HTTPS: Use HTTPS in production
  4. +
  5. CORS Configuration: Set appropriate ALLOWED_ORIGINS
  6. +
  7. Cookie Security: Configure COOKIE_DOMAIN properly
  8. +
  9. Input Validation: All inputs are validated server-side
  10. +
  11. Rate Limiting: API endpoints have rate limiting
  12. +
  13. Session Security: Use a strong SESSION_SECRET
  14. +
+

Maintenance

+

Regular Updates

+
# Stop the application
+docker-compose down
+
+# Pull updates (if using git)
+git pull origin main
+
+# Rebuild and restart
+docker-compose build
+docker-compose up -d
+
+

Backup Considerations

+
    +
  • NocoDB data is stored in your NocoDB instance
  • +
  • Back up your .env file securely
  • +
  • Consider backing up QR code images from the Settings table
  • +
+

Performance Tips

+
    +
  • Monitor NocoDB performance and scaling
  • +
  • Consider enabling caching for high-traffic deployments
  • +
  • Use CDN for static assets if needed
  • +
  • Monitor Docker container resource usage
  • +
+

Support

+

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

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/config/mkdocs/index.html b/mkdocs/site/v1/config/mkdocs/index.html new file mode 100644 index 00000000..e343b6d0 --- /dev/null +++ b/mkdocs/site/v1/config/mkdocs/index.html @@ -0,0 +1,2448 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MKdocs - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

MkDocs Customization & Features Overview

+

BNKops has been building our own features, widgets, and css styles for MKdocs material theme.

+

This document explains the custom styling, repository widgets, and key features enabled in this MkDocs site.

+

For more info on how to build your site see Site Build

+
+

Using the Repository Widget in Documentation

+

You can embed repository widgets directly in your Markdown documentation to display live repository stats and metadata.
+To do this, add a div with the appropriate class and data-repo attribute for the repository you want to display.

+

Example (for a Gitea repository): +

<div class="gitea-widget" data-repo="admin/changemaker.lite"></div>
+

+

This will render a styled card with information about the admin/changemaker.lite repository:

+
+ +

Options:
+You can control the widget display with additional data attributes: +- data-show-description="false" — Hide the description +- data-show-language="false" — Hide the language +- data-show-last-update="false" — Hide the last update date

+

Example with options: +

<div class="gitea-widget" data-repo="admin/changemaker.lite" data-show-description="false"></div>
+

+

For GitHub repositories, use the github-widget class: +

<div class="github-widget" data-repo="lyqht/mini-qr"></div>
+

+
+

Custom CSS Styling (stylesheets/extra.css)

+

The extra.css file provides extensive custom styling for the site, including:

+
    +
  • +

    Login and Git Code Buttons:
    + Custom styles for .login-button and .git-code-button to create visually distinct, modern buttons with hover effects.

    +
  • +
  • +

    Code Block Improvements:
    + Forces code blocks to wrap text (white-space: pre-wrap) and ensures inline code and tables with code display correctly on all devices.

    +
  • +
  • +

    GitHub Widget Styles:
    + Styles for .github-widget and its subcomponents, including:

    +
  • +
  • Card-like container with gradient backgrounds and subtle box-shadows.
  • +
  • Header with icon, repo link, and stats (stars, forks, issues).
  • +
  • Description area with accent border.
  • +
  • Footer with language, last update, and license info.
  • +
  • Loading and error states with spinners and error messages.
  • +
  • Responsive grid layout for multiple widgets.
  • +
  • Compact variant for smaller displays.
  • +
  • +

    Dark mode adjustments.

    +
  • +
  • +

    Gitea Widget Styles:
    + Similar to GitHub widget, but with Gitea branding (green accents).
    + Includes .gitea-widget, .gitea-widget-container, and related classes for header, stats, description, footer, loading, and error states.

    +
  • +
  • +

    Responsive Design:
    + Media queries ensure widgets and tables look good on mobile devices.

    +
  • +
+
+

Repository Widgets

+

Data Generation (hooks/repo_widget_hook.py)

+
    +
  • Purpose:
    + During the MkDocs build, this hook fetches metadata for a list of GitHub and Gitea repositories and writes JSON files to docs/assets/repo-data/.
  • +
  • How it works:
  • +
  • Runs before build (unless in serve mode).
  • +
  • Fetches repo data (stars, forks, issues, language, etc.) via GitHub/Gitea APIs.
  • +
  • Outputs a JSON file per repo (e.g., lyqht-mini-qr.json).
  • +
  • Used by frontend widgets for fast, client-side rendering.
  • +
+

GitHub Widget (javascripts/github-widget.js)

+
    +
  • Purpose:
    + Renders a card for each GitHub repository using the pre-generated JSON data.
  • +
  • Features:
  • +
  • Displays repo name, link, stars, forks, open issues, language, last update, and license.
  • +
  • Shows loading spinner while fetching data.
  • +
  • Handles errors gracefully.
  • +
  • Supports dynamic content (re-initializes on DOM changes).
  • +
  • Language color coding for popular languages.
  • +
+

Gitea Widget (javascripts/gitea-widget.js)

+
    +
  • Purpose:
    + Renders a card for each Gitea repository using the pre-generated JSON data.
  • +
  • Features:
  • +
  • Similar to GitHub widget, but styled for Gitea.
  • +
  • Shows repo name, link, stars, forks, open issues, language, last update.
  • +
  • Loading and error states.
  • +
  • Language color coding.
  • +
+
+

MkDocs Features (mkdocs.yml)

+

Key features and plugins enabled:

+
    +
  • +

    Material Theme:
    + Modern, responsive UI with dark/light mode toggle, custom fonts, and accent colors.

    +
  • +
  • +

    Navigation Enhancements:

    +
  • +
  • Tabs, sticky navigation, instant loading, breadcrumbs, and sectioned navigation.
  • +
  • +

    Table of contents with permalinks.

    +
  • +
  • +

    Content Features:

    +
  • +
  • Code annotation, copy buttons, tooltips, and improved code highlighting.
  • +
  • +

    Admonitions, tabbed content, task lists, and emoji support.

    +
  • +
  • +

    Plugins:

    +
  • +
  • Search: Advanced search with custom tokenization.
  • +
  • Social: OpenGraph/social card generation.
  • +
  • Blog: Blogging support with archives and categories.
  • +
  • +

    Tags: Tagging for content organization.

    +
  • +
  • +

    Custom Hooks:

    +
  • +
  • +

    repo_widget_hook.py for repository widget data.

    +
  • +
  • +

    Extra CSS/JS:

    +
  • +
  • +

    Custom styles and scripts for widgets and homepage.

    +
  • +
  • +

    Extra Configuration:

    +
  • +
  • Social links, copyright.
  • +
+
+

Summary

+

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.

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/index.html b/mkdocs/site/v1/index.html new file mode 100644 index 00000000..78207729 --- /dev/null +++ b/mkdocs/site/v1/index.html @@ -0,0 +1,2291 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + V1 Documentation (Deprecated) - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

V1 Documentation (Deprecated)

+
+

V1 is Legacy

+

Changemaker Lite V1 is deprecated and no longer actively maintained. These docs are preserved for reference only.

+
+

Migrating to V2

+

Changemaker Lite V2 is a complete architectural rebuild with significant improvements:

+

A quick test because why not.

+

Why Upgrade to V2?

+

Modern Stack +- TypeScript throughout (V1 was JavaScript) +- Prisma ORM (V1 used NocoDB REST API) +- JWT auth (V1 used session cookies) +- React admin (V1 used server-rendered HTML)

+

Better Performance +- Direct database access (no NocoDB middleware) +- Redis-backed caching and rate limiting +- BullMQ job queues for async operations +- Optimized queries with Prisma

+

Enhanced Security +- Security audit completed (Feb 2026) +- Password policy enforcement (12+ chars, complexity) +- Refresh token rotation in transactions +- XSS/injection prevention throughout +- Rate limiting on all sensitive endpoints

+

New Features +- Volunteer canvassing system with GPS tracking +- Landing page builder (GrapesJS) +- Email template management +- Media library (video management) +- Observability dashboard (Prometheus + Grafana) +- NAR 2025 data import (Canadian electoral data)

+

View complete V2 documentation →

+

Migration Guide

+

Ready to migrate? Follow our step-by-step guide:

+

→ V1 to V2 Migration Guide

+

The migration guide covers:

+
    +
  1. Breaking Changes - NocoDB → Prisma, API endpoint changes
  2. +
  3. Data Migration - Export V1 data, transform, import to V2
  4. +
  5. Configuration Changes - Environment variables, service names
  6. +
  7. Feature Parity - V1 vs V2 feature comparison
  8. +
+

V1 Documentation Archive

+

These docs are preserved for existing V1 installations:

+

Build Guides

+ +

Services

+ +

Configuration

+ +

Manuals

+ +

Advanced

+ +

V1 Architecture (Reference)

+

V1 used a two-app architecture with NocoDB as the data layer:

+

Influence App (port 3333) +- Express.js server with server-rendered HTML +- NocoDB REST API for database operations +- Session-based authentication (Redis) +- Bull job queues for emails

+

Map App (port 3000) +- Express.js server with Leaflet.js maps +- NocoDB REST API for database operations +- QR code generation +- Volunteer shift management

+

Shared Infrastructure +- NocoDB (data layer) +- Redis (sessions, cache, queues) +- PostgreSQL (via NocoDB) +- Cloudflare tunnels

+

Support for V1

+

V1 is no longer under active development. We recommend migrating to V2.

+

For critical V1 issues: +- Check existing V1 documentation +- Review V1 code in /influence and /map directories +- Consider migrating to V2

+
+

Ready to upgrade? Start with the V2 Quick Start Guide →

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/manual/index.html b/mkdocs/site/v1/manual/index.html new file mode 100644 index 00000000..22458dce --- /dev/null +++ b/mkdocs/site/v1/manual/index.html @@ -0,0 +1,2059 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Manuals - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Manuals

+

The following are manuals, some accompanied by videos, on the use of the system.

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/manual/map/index.html b/mkdocs/site/v1/manual/map/index.html new file mode 100644 index 00000000..734a3c92 --- /dev/null +++ b/mkdocs/site/v1/manual/map/index.html @@ -0,0 +1,4028 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Map - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Map System Manual

+

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)

+
+

1. Getting Started

+

Logging In

+
    +
  1. Go to your map site URL (e.g., https://yoursite.com or http://localhost:3000).
  2. +
  3. Enter your email and password on the login page.
  4. +
  5. Click Login.
  6. +
  7. If you forget your password, use the Reset Password link or contact an admin.
  8. +
  9. Password Recovery: Check your email for reset instructions if SMTP is configured. (Insert screenshot - login page)
  10. +
+

User Types & Permissions

+
    +
  • Admin: Full access to all features, user management, and system configuration
  • +
  • User: Access to map, shifts, profile management, and location data
  • +
  • Temp: Limited access (add/edit locations only, expires automatically after shift date)
  • +
+
+

2. Interactive Map Features

+

Basic Map Navigation

+
    +
  1. After login, you'll see the interactive map with location markers.
  2. +
  3. Use mouse or touch to pan and zoom around the map.
  4. +
  5. Your current location may appear as a blue dot (if location services enabled).
  6. +
  7. Use the zoom controls (±) or mouse wheel to adjust map scale. (Insert screenshot - main map view)
  8. +
+

Advanced Search (Ctrl+K)

+
    +
  1. Press Ctrl+K anywhere on the site to open the universal search.
  2. +
  3. Search for:
  4. +
  5. Addresses: Find and navigate to specific locations
  6. +
  7. Documentation: Search help articles and guides
  8. +
  9. Locations: Find existing data points by name or details
  10. +
  11. Click results to navigate directly to locations on the map.
  12. +
  13. QR Code Generation: Search results include QR codes for easy mobile sharing. (Insert screenshot - search interface)
  14. +
+

Map Overlays (Cuts)

+
    +
  1. Public Cuts: Geographic overlays (wards, neighborhoods, districts) are automatically displayed.
  2. +
  3. Cut Selector: Use the multi-select dropdown to show/hide different cuts.
  4. +
  5. Mobile Interface: On mobile, tap the 🗺️ button to manage overlays.
  6. +
  7. Legend: View active cuts with color coding and labels.
  8. +
  9. Cuts help organize and filter location data by geographic regions. (Insert screenshot - cuts interface)
  10. +
+
+

3. Location Management

+

Adding New Locations

+
    +
  1. Click the Add Location button (+ icon) on the map.
  2. +
  3. Click on the map where you want to place the new location.
  4. +
  5. Fill out the comprehensive form:
  6. +
  7. Personal: First Name, Last Name, Email, Phone, Unit Number
  8. +
  9. Political: Support Level (1-4 scale), Party Affiliation
  10. +
  11. Address: Street Address (auto-geocoded when possible)
  12. +
  13. Campaign: Lawn Sign (Yes/No/Maybe), Sign Size, Volunteer Interest
  14. +
  15. Notes: Additional information and comments
  16. +
  17. Address Confirmation: System validates and confirms addresses when possible.
  18. +
  19. Click Save to add the location marker. (Insert screenshot - add location form)
  20. +
+

Editing and Managing Locations

+
    +
  1. Click on any location marker to view details.
  2. +
  3. Popup Actions:
  4. +
  5. Edit: Modify all location details
  6. +
  7. Move: Drag marker to new position (admin/user only)
  8. +
  9. Delete: Remove location (admin/user only - hidden for temp users)
  10. +
  11. Quick Actions: Email, phone, or text contact directly from popup.
  12. +
  13. Support Level Color Coding: Markers change color based on support level.
  14. +
  15. Apartment View: Special clustering for apartment buildings. (Insert screenshot - location popup)
  16. +
+

Bulk Data Import

+
    +
  1. Admin PanelData ConverterUpload CSV
  2. +
  3. Supported Formats: CSV files with address data
  4. +
  5. Batch Geocoding: Automatically converts addresses to coordinates
  6. +
  7. Progress Tracking: Visual progress bar with success/failure reporting
  8. +
  9. Error Handling: Downloadable error reports for failed geocoding
  10. +
  11. Validation: Preview and verify data before final import
  12. +
  13. Edmonton Data: Pre-configured for City of Edmonton neighborhood data. (Insert screenshot - data import interface)
  14. +
+
+

4. Volunteer Shift Management

+

Public Shift Signup (No Login Required)

+
    +
  1. Visit the Public Shifts page (accessible without account).
  2. +
  3. Browse available volunteer opportunities with:
  4. +
  5. Date, time, and location information
  6. +
  7. Available spots and current signups
  8. +
  9. Detailed shift descriptions
  10. +
  11. One-Click Signup:
  12. +
  13. Enter name, email, and phone number
  14. +
  15. Automatic temporary account creation
  16. +
  17. Instant email confirmation with login details
  18. +
  19. Account Expiration: Temp accounts automatically expire after shift date. (Insert screenshot - public shifts page)
  20. +
+

Authenticated User Shift Management

+
    +
  1. Go to Shifts from the main navigation.
  2. +
  3. View Options:
  4. +
  5. Grid View: List format with detailed information
  6. +
  7. Calendar View: Monthly calendar with shift visualization
  8. +
  9. Filter Options: Date range, shift type, and availability status.
  10. +
  11. My Signups: View your confirmed shifts at the top of the page.
  12. +
+

Shift Actions

+
    +
  • Sign Up: Join available shifts (if spots remain)
  • +
  • Cancel: Remove yourself from shifts you've joined
  • +
  • Calendar Export: Add shifts to Google Calendar, Outlook, or Apple Calendar
  • +
  • Shift Details: View full descriptions, requirements, and coordinator info. (Insert screenshot - shifts interface)
  • +
+
+

5. Advanced Map Features

+

Geographic Cuts System

+

What are Cuts?: Polygon overlays that define geographic regions like wards, neighborhoods, or custom areas.

+

Viewing Cuts (All Users)

+
    +
  1. Auto-Display: Public cuts appear automatically when map loads.
  2. +
  3. Multi-Select Control: Desktop users see dropdown with checkboxes for each cut.
  4. +
  5. Mobile Modal: Touch the 🗺️ button for full-screen cut management.
  6. +
  7. Quick Actions: "Show All" / "Hide All" buttons for easy control.
  8. +
  9. Color Coding: Each cut has unique colors and opacity settings. (Insert screenshot - cuts display)
  10. +
+

Admin Cut Management

+
    +
  1. Admin PanelMap Cuts for full management interface.
  2. +
  3. Drawing Tools: Click-to-add-points polygon creation system.
  4. +
  5. Cut Properties:
  6. +
  7. Name, description, and category
  8. +
  9. Color and opacity customization
  10. +
  11. Public visibility settings
  12. +
  13. Official designation markers
  14. +
  15. Cut Operations:
  16. +
  17. Create, edit, duplicate, and delete cuts
  18. +
  19. Import/export cut data as JSON
  20. +
  21. Location filtering within cut boundaries
  22. +
  23. Statistics Dashboard: Analyze location data within cut boundaries.
  24. +
  25. Print Functionality: Generate professional reports with maps and data tables. (Insert screenshot - cut management)
  26. +
+

Location Filtering within Cuts

+
    +
  1. View Cut: Select a cut from the admin interface.
  2. +
  3. Filter Locations: Automatically shows only locations within cut boundaries.
  4. +
  5. Statistics Panel: Real-time counts of:
  6. +
  7. Total locations within cut
  8. +
  9. Support level breakdown (Strong/Lean/Undecided/Opposition)
  10. +
  11. Contact information availability (email/phone)
  12. +
  13. Lawn sign placements
  14. +
  15. Export Options: Download filtered location data as CSV.
  16. +
  17. Print Reports: Generate professional cut reports with statistics and location tables. (Insert screenshot - cut filtering)
  18. +
+
+

6. Communication Tools

+

Universal Search & Contact

+
    +
  1. Ctrl+K Search: Find and contact anyone in your database instantly.
  2. +
  3. Direct Contact Links: Email and phone links throughout the interface.
  4. +
  5. QR Code Generation: Share contact information via QR codes.
  6. +
+

Admin Communication Features

+
    +
  1. Bulk Email System:
  2. +
  3. Rich HTML email composer with formatting toolbar
  4. +
  5. Live email preview before sending
  6. +
  7. Broadcast to all users with progress tracking
  8. +
  9. Individual delivery status for each recipient
  10. +
  11. One-Click Communication Buttons:
  12. +
  13. 📧 Email: Launch email client with pre-filled recipient
  14. +
  15. 📞 Call: Open phone dialer with contact's number
  16. +
  17. 💬 SMS: Launch text messaging with contact's number
  18. +
  19. Shift Communication:
  20. +
  21. Email shift details to all volunteers
  22. +
  23. Individual volunteer contact from shift management
  24. +
  25. Automated signup confirmations and reminders. (Insert screenshot - communication tools)
  26. +
+
+

7. Walk Sheet Generator

+

Creating Walk Sheets

+
    +
  1. Admin PanelWalk Sheet Generator
  2. +
  3. Configuration Options:
  4. +
  5. Title, subtitle, and footer text
  6. +
  7. Contact information and instructions
  8. +
  9. QR codes for digital resources
  10. +
  11. Logo and branding elements
  12. +
  13. Location Selection: Choose specific areas or use cut boundaries.
  14. +
  15. Print Options: Multiple layout formats for different campaign needs.
  16. +
  17. QR Integration: Add QR codes linking to:
  18. +
  19. Digital surveys or forms
  20. +
  21. Contact information
  22. +
  23. Campaign websites or resources. (Insert screenshot - walk sheet generator)
  24. +
+

Mobile-Optimized Walk Sheets

+
    +
  1. Responsive Design: Optimized for viewing on phones and tablets.
  2. +
  3. QR Code Scanner Integration: Quick scanning for volunteer check-ins.
  4. +
  5. Offline Capability: Download for use without internet connection.
  6. +
+
+

8. User Profile Management

+

Personal Settings

+
    +
  1. User MenuProfile to access personal settings.
  2. +
  3. Account Information:
  4. +
  5. Update name, email, and phone number
  6. +
  7. Change password
  8. +
  9. Communication preferences
  10. +
  11. Activity History: View your shift signups and location contributions.
  12. +
  13. Privacy Settings: Control data sharing and communication preferences. (Insert screenshot - user profile)
  14. +
+

Password Recovery

+
    +
  1. Forgot Password link on login page.
  2. +
  3. Email Reset: Automated password reset via SMTP (if configured).
  4. +
  5. Admin Assistance: Contact administrators for manual password resets.
  6. +
+
+

9. Admin Panel Features

+

Dashboard Overview

+
    +
  1. System Statistics: User counts, recent activity, and system health.
  2. +
  3. Quick Actions: Direct access to common administrative tasks.
  4. +
  5. NocoDB Integration: Direct links to database management interface. (Insert screenshot - admin dashboard)
  6. +
+

User Management

+
    +
  1. Create Users: Add new accounts with role assignments:
  2. +
  3. Regular Users: Full access to mapping and shifts
  4. +
  5. Temporary Users: Limited access with automatic expiration
  6. +
  7. Admin Users: Full system administration privileges
  8. +
  9. User Communication:
  10. +
  11. Send login details to new users
  12. +
  13. Bulk email all users with rich HTML composer
  14. +
  15. Individual user contact (email, call, text)
  16. +
  17. User Types & Expiration:
  18. +
  19. Set expiration dates for temporary accounts
  20. +
  21. Visual indicators for user types and status
  22. +
  23. Automatic cleanup of expired accounts. (Insert screenshot - user management)
  24. +
+

Shift Administration

+
    +
  1. Create & Manage Shifts:
  2. +
  3. Set dates, times, locations, and volunteer limits
  4. +
  5. Public/private visibility settings
  6. +
  7. Detailed descriptions and requirements
  8. +
  9. Volunteer Management:
  10. +
  11. Add users directly to shifts
  12. +
  13. Remove volunteers when needed
  14. +
  15. Email shift details to all participants
  16. +
  17. Generate public signup links
  18. +
  19. Volunteer Communication:
  20. +
  21. Individual contact buttons (email, call, text) for each volunteer
  22. +
  23. Bulk shift detail emails with delivery tracking
  24. +
  25. Automated confirmation and reminder systems. (Insert screenshot - shift management)
  26. +
+

System Configuration

+
    +
  1. Map Settings:
  2. +
  3. Set default start location and zoom level
  4. +
  5. Configure map boundaries and restrictions
  6. +
  7. Customize marker styles and colors
  8. +
  9. Integration Management:
  10. +
  11. NocoDB database connections
  12. +
  13. Listmonk email list synchronization
  14. +
  15. SMTP configuration for automated emails
  16. +
  17. Security Settings:
  18. +
  19. User permissions and role management
  20. +
  21. API access controls
  22. +
  23. Session management. (Insert screenshot - system config)
  24. +
+
+

10. Data Management & Integration

+

NocoDB Database Integration

+
    +
  1. Direct Database Access: Admin links to NocoDB sheets for advanced data management.
  2. +
  3. Automated Sync: Real-time synchronization between map interface and database.
  4. +
  5. Backup & Migration: Built-in tools for data backup and system migration.
  6. +
  7. Custom Fields: Add custom data fields through NocoDB interface.
  8. +
+

Listmonk Email Marketing Integration

+
    +
  1. Automatic List Sync: Map data automatically syncs to Listmonk email lists.
  2. +
  3. Segmentation: Create targeted lists based on:
  4. +
  5. Geographic location (cuts/neighborhoods)
  6. +
  7. Support levels and volunteer interest
  8. +
  9. Contact preferences and activity
  10. +
  11. One-Direction Sync: Maintains data integrity while allowing email unsubscribes.
  12. +
  13. Compliance: Newsletter legislation compliance with opt-out capabilities. (Insert screenshot - integration settings)
  14. +
+

Data Export & Reporting

+
    +
  1. CSV Export: Download location data, user lists, and shift reports.
  2. +
  3. Cut Reports: Professional reports with statistics and location breakdowns.
  4. +
  5. Print-Ready Formats: Optimized layouts for physical distribution.
  6. +
  7. Analytics Dashboard: Track user engagement and system usage.
  8. +
+
+

11. Mobile & Accessibility Features

+

Mobile-Optimized Interface

+
    +
  1. Responsive Design: Fully functional on phones and tablets.
  2. +
  3. Touch Navigation: Optimized touch controls for map interaction.
  4. +
  5. Mobile-Specific Features:
  6. +
  7. Cut management modal for overlay control
  8. +
  9. Simplified navigation and larger touch targets
  10. +
  11. Offline capability for basic functions
  12. +
+

Accessibility

+
    +
  1. Keyboard Navigation: Full keyboard support throughout the interface.
  2. +
  3. Screen Reader Compatibility: ARIA labels and semantic markup.
  4. +
  5. High Contrast Support: Compatible with accessibility themes.
  6. +
  7. Text Scaling: Responsive to browser zoom and text size settings.
  8. +
+
+

12. Security & Privacy

+

Data Protection

+
    +
  1. Server-Side Security: All API tokens and credentials kept server-side only.
  2. +
  3. Input Validation: Comprehensive validation and sanitization of all user inputs.
  4. +
  5. CORS Protection: Cross-origin request security measures.
  6. +
  7. Rate Limiting: Protection against abuse and automated attacks.
  8. +
+

User Privacy

+
    +
  1. Role-Based Access: Users only see data appropriate to their permission level.
  2. +
  3. Temporary Account Expiration: Automatic cleanup of temporary user data.
  4. +
  5. Audit Trails: Logging of administrative actions and data changes.
  6. +
  7. Data Retention: Configurable retention policies for different data types. (Insert screenshot - security settings)
  8. +
+

Authentication

+
    +
  1. Secure Login: Password-based authentication with optional 2FA.
  2. +
  3. Session Management: Automatic logout for expired sessions.
  4. +
  5. Password Policies: Configurable password strength requirements.
  6. +
  7. Account Lockout: Protection against brute force attacks.
  8. +
+
+

13. Performance & System Requirements

+

System Performance

+
    +
  1. Optimized Database Queries: Reduced API calls by over 5000% for better performance.
  2. +
  3. Smart Caching: Intelligent caching of frequently accessed data.
  4. +
  5. Progressive Loading: Map data loads incrementally for faster initial page loads.
  6. +
  7. Background Sync: Automatic data synchronization without blocking user interface.
  8. +
+

Browser Requirements

+
    +
  1. Modern Browsers: Chrome, Firefox, Safari, Edge (recent versions).
  2. +
  3. JavaScript Required: Full functionality requires JavaScript enabled.
  4. +
  5. Local Storage: Uses browser storage for session management and caching.
  6. +
  7. Geolocation: Optional location services for enhanced functionality.
  8. +
+
+

14. Troubleshooting

+

Common Issues

+
    +
  • Locations not showing: Check database connectivity, verify coordinates are valid, ensure API permissions allow read access.
  • +
  • Cannot add locations: Verify API write permissions, check coordinate bounds, ensure all required fields completed.
  • +
  • Login problems: Verify email/password, check account expiration (for temp users), contact admin for password reset.
  • +
  • Map not loading: Check internet connection, verify site URL, clear browser cache and cookies.
  • +
  • Permission denied: Confirm user role and permissions, check account expiration status, contact administrator.
  • +
+

Performance Issues

+
    +
  • Slow loading: Check internet connection, try refreshing the page, contact admin if problems persist.
  • +
  • Database errors: Contact system administrator, check NocoDB service status.
  • +
  • Email not working: Verify SMTP configuration (admin), check spam/junk folders.
  • +
+

Mobile Issues

+
    +
  • Touch problems: Ensure touch targets are accessible, try refreshing page, check for browser compatibility.
  • +
  • Display issues: Try rotating device, check browser zoom level, update to latest browser version.
  • +
+
+

15. Advanced Features

+

API Access

+
    +
  1. RESTful API: Programmatic access to map data and functionality.
  2. +
  3. Authentication: Token-based API authentication for external integrations.
  4. +
  5. Rate Limiting: API usage limits to ensure system stability.
  6. +
  7. Documentation: Complete API documentation for developers.
  8. +
+

Customization Options

+
    +
  1. Theming: Customizable color schemes and branding.
  2. +
  3. Field Configuration: Add custom data fields through admin interface.
  4. +
  5. Workflow Customization: Configurable user workflows and permissions.
  6. +
  7. Integration Hooks: Webhook support for external system integration.
  8. +
+
+

16. Getting Help & Support

+

Built-in Help

+
    +
  1. Context Help: Tooltips and help text throughout the interface.
  2. +
  3. Search Documentation: Use Ctrl+K to search help articles and guides.
  4. +
  5. Status Messages: Clear feedback for all user actions and system status.
  6. +
+

Administrator Support

+
    +
  1. Contact Admin: Use the contact information provided during setup.
  2. +
  3. System Logs: Administrators have access to detailed system logs for troubleshooting.
  4. +
  5. Database Direct Access: Admins can access NocoDB directly for advanced data management.
  6. +
+

Community Resources

+
    +
  1. Documentation: Comprehensive online documentation and guides.
  2. +
  3. GitHub Repository: Access to source code and issue tracking.
  4. +
  5. Developer Community: Active community for advanced customization and development.
  6. +
+

For technical support, contact your system administrator or refer to the comprehensive documentation available through the help system. (Insert screenshot - help resources)

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/services/code-server/index.html b/mkdocs/site/v1/services/code-server/index.html new file mode 100644 index 00000000..ffd34923 --- /dev/null +++ b/mkdocs/site/v1/services/code-server/index.html @@ -0,0 +1,2378 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Code Server - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+ +
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Code Server

+

code

+
+ +

Overview

+

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.

+

Features

+
    +
  • Full VS Code experience in the browser
  • +
  • Extensions support
  • +
  • Terminal access
  • +
  • Git integration
  • +
  • File editing and management
  • +
  • Multi-language support
  • +
+

Access

+
    +
  • Default Port: 8888
  • +
  • URL: http://localhost:8888
  • +
  • Default Workspace: /home/coder/mkdocs/
  • +
+

Configuration

+

Environment Variables

+
    +
  • DOCKER_USER: The user to run code-server as (default: coder)
  • +
  • DEFAULT_WORKSPACE: Default workspace directory
  • +
  • USER_ID: User ID for file permissions
  • +
  • GROUP_ID: Group ID for file permissions
  • +
+

Volumes

+
    +
  • ./configs/code-server/.config: VS Code configuration
  • +
  • ./configs/code-server/.local: Local data
  • +
  • ./mkdocs: Main workspace directory
  • +
+

Usage

+
    +
  1. Access Code Server at http://localhost:8888
  2. +
  3. Open the /home/coder/mkdocs/ workspace
  4. +
  5. Start editing your documentation files
  6. +
  7. Install extensions as needed
  8. +
  9. Use the integrated terminal for commands
  10. +
+

Useful Extensions

+

Consider installing these extensions for better documentation work:

+
    +
  • Markdown All in One
  • +
  • Material Design Icons
  • +
  • GitLens
  • +
  • Docker
  • +
  • YAML
  • +
+

Official Documentation

+

For more detailed information, visit the official Code Server documentation.

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/services/code.png b/mkdocs/site/v1/services/code.png new file mode 100644 index 00000000..0b2b19cf Binary files /dev/null and b/mkdocs/site/v1/services/code.png differ diff --git a/mkdocs/site/v1/services/dashboard.png b/mkdocs/site/v1/services/dashboard.png new file mode 100644 index 00000000..2c1c4895 Binary files /dev/null and b/mkdocs/site/v1/services/dashboard.png differ diff --git a/mkdocs/site/v1/services/git.png b/mkdocs/site/v1/services/git.png new file mode 100644 index 00000000..0540a3a2 Binary files /dev/null and b/mkdocs/site/v1/services/git.png differ diff --git a/mkdocs/site/v1/services/gitea/index.html b/mkdocs/site/v1/services/gitea/index.html new file mode 100644 index 00000000..4731fa7e --- /dev/null +++ b/mkdocs/site/v1/services/gitea/index.html @@ -0,0 +1,2352 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Gitea - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Gitea

+

git

+
+ +

Self-hosted Git service for collaborative development.

+

Overview

+

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.

+

Features

+
    +
  • Git repository hosting
  • +
  • Web-based interface
  • +
  • Issue tracking
  • +
  • Pull requests
  • +
  • Wiki and code review
  • +
  • Lightweight and easy to deploy
  • +
+

Access

+
    +
  • Default Web Port: ${GITEA_WEB_PORT:-3030} (default: 3030)
  • +
  • Default SSH Port: ${GITEA_SSH_PORT:-2222} (default: 2222)
  • +
  • URL: http://localhost:${GITEA_WEB_PORT:-3030}
  • +
  • Default Data Directory: /data/gitea
  • +
+

Configuration

+

Environment Variables

+
    +
  • GITEA__database__DB_TYPE: Database type (e.g., sqlite3, mysql, postgres)
  • +
  • GITEA__database__HOST: Database host (default: ${GITEA_DB_HOST:-gitea-db:3306})
  • +
  • GITEA__database__NAME: Database name (default: ${GITEA_DB_NAME:-gitea})
  • +
  • GITEA__database__USER: Database user (default: ${GITEA_DB_USER:-gitea})
  • +
  • GITEA__database__PASSWD: Database password (from .env)
  • +
  • GITEA__server__ROOT_URL: Root URL (e.g., ${GITEA_ROOT_URL})
  • +
  • GITEA__server__HTTP_PORT: Web port (default: 3000 inside container)
  • +
  • GITEA__server__DOMAIN: Domain (e.g., ${GITEA_DOMAIN})
  • +
+

Volumes

+
    +
  • gitea_data:/data: Gitea configuration and data
  • +
  • /etc/timezone:/etc/timezone:ro
  • +
  • /etc/localtime:/etc/localtime:ro
  • +
+

Usage

+
    +
  1. Access Gitea at http://localhost:${GITEA_WEB_PORT:-3030}
  2. +
  3. Register or log in as an admin user
  4. +
  5. Create or import repositories
  6. +
  7. Collaborate with your team
  8. +
+

Official Documentation

+

For more details, visit the official Gitea documentation.

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/services/homepage/index.html b/mkdocs/site/v1/services/homepage/index.html new file mode 100644 index 00000000..c1e31c63 --- /dev/null +++ b/mkdocs/site/v1/services/homepage/index.html @@ -0,0 +1,2886 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Homepage - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Homepage

+

dashboard

+
+ +

Modern dashboard for accessing all your self-hosted services.

+

Overview

+

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.

+

Features

+
    +
  • Service Dashboard: Central hub for all your applications
  • +
  • Docker Integration: Automatic service discovery and monitoring
  • +
  • Customizable Layout: Flexible grid-based layout system
  • +
  • Service Widgets: Live status and metrics for services
  • +
  • Quick Search: Fast navigation with built-in search
  • +
  • Bookmarks: Organize frequently used links
  • +
  • Dark/Light Themes: Multiple color schemes available
  • +
  • Responsive Design: Works on desktop and mobile devices
  • +
+

Access

+
    +
  • Default Port: 3010
  • +
  • URL: http://localhost:3010
  • +
  • Configuration: YAML-based configuration files
  • +
+

Configuration

+

Environment Variables

+
    +
  • HOMEPAGE_PORT: External port mapping (default: 3010)
  • +
  • PUID: User ID for file permissions (default: 1000)
  • +
  • PGID: Group ID for file permissions (default: 1000)
  • +
  • TZ: Timezone setting (default: Etc/UTC)
  • +
  • HOMEPAGE_ALLOWED_HOSTS: Allowed hosts for the dashboard
  • +
+

Configuration Files

+

Homepage uses YAML configuration files located in ./configs/homepage/:

+
    +
  • settings.yaml: Global settings and theme configuration
  • +
  • services.yaml: Service definitions and widgets
  • +
  • bookmarks.yaml: Bookmark categories and links
  • +
  • widgets.yaml: Dashboard widgets configuration
  • +
  • docker.yaml: Docker integration settings
  • +
+

Volumes

+
    +
  • ./configs/homepage:/app/config: Configuration files
  • +
  • ./assets/icons:/app/public/icons: Custom service icons
  • +
  • ./assets/images:/app/public/images: Background images and assets
  • +
  • /var/run/docker.sock:/var/run/docker.sock: Docker socket for container monitoring
  • +
+

Changemaker Lite Services

+

Homepage is pre-configured with all Changemaker Lite services:

+

Essential Tools

+
    +
  • Code Server (Port 8888): VS Code in the browser
  • +
  • Listmonk (Port 9000): Newsletter & mailing list manager
  • +
  • NocoDB (Port 8090): No-code database platform
  • +
+

Content & Documentation

+
    +
  • MkDocs (Port 4000): Live documentation server
  • +
  • Static Site (Port 4001): Built documentation hosting
  • +
+

Automation & Data

+
    +
  • n8n (Port 5678): Workflow automation platform
  • +
  • PostgreSQL (Port 5432): Database backends
  • +
+

Customization

+

Adding Custom Services

+

Edit configs/homepage/services.yaml to add new services:

+
- Custom Category:
+    - My Service:
+        href: http://localhost:8080
+        description: Custom service description
+        icon: mdi-application
+        widget:
+          type: ping
+          url: http://localhost:8080
+
+

Custom Icons

+

Add custom icons to ./assets/icons/ directory and reference them in services.yaml:

+
icon: /icons/my-custom-icon.png
+
+

Themes and Styling

+

Modify configs/homepage/settings.yaml to customize appearance:

+
theme: dark  # or light
+color: purple  # slate, gray, zinc, neutral, stone, red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink, rose
+
+

Widgets

+

Enable live monitoring widgets in configs/homepage/services.yaml:

+
- Service Name:
+    widget:
+      type: docker
+      container: container-name
+      server: my-docker
+
+

Service Monitoring

+

Homepage can display real-time status information for your services:

+
    +
  • Docker Integration: Container status and resource usage
  • +
  • HTTP Ping: Service availability monitoring
  • +
  • Custom APIs: Integration with service-specific APIs
  • +
+

Docker Integration

+

Homepage monitors Docker containers automatically when configured:

+
    +
  1. Ensure Docker socket is mounted (/var/run/docker.sock)
  2. +
  3. Configure container mappings in docker.yaml
  4. +
  5. Add widget configurations to services.yaml
  6. +
+

Security Considerations

+
    +
  • Homepage runs with limited privileges
  • +
  • Configuration files should have appropriate permissions
  • +
  • Consider network isolation for production deployments
  • +
  • Use HTTPS for external access
  • +
  • Regularly update the Homepage image
  • +
+

Troubleshooting

+

Common Issues

+

Configuration not loading: Check YAML syntax in configuration files

+
docker logs homepage-changemaker
+
+

Icons not displaying: Verify icon paths and file permissions

+
ls -la ./assets/icons/
+
+

Services not reachable: Verify network connectivity between containers

+
docker exec homepage-changemaker ping service-name
+
+

Widget data not updating: Check Docker socket permissions and container access

+
docker exec homepage-changemaker ls -la /var/run/docker.sock
+
+

Configuration Examples

+

Basic Service Widget

+
- Code Server:
+    href: http://localhost:8888
+    description: VS Code in the browser
+    icon: code-server
+    widget:
+      type: docker
+      container: code-server-changemaker
+
+

Custom Dashboard Layout

+
# settings.yaml
+layout:
+  style: columns
+  columns: 3
+
+# Responsive breakpoints
+responsive:
+  mobile: 1
+  tablet: 2
+  desktop: 3
+
+

Official Documentation

+

For comprehensive configuration guides and advanced features:

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/services/index.html b/mkdocs/site/v1/services/index.html new file mode 100644 index 00000000..d277dd6d --- /dev/null +++ b/mkdocs/site/v1/services/index.html @@ -0,0 +1,2335 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Services - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Services

+

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.

+

Available Services

+

Code Server

+

Port: 8888 | Visual Studio Code in your browser for remote development

+
+
    +
  • Full IDE experience
  • +
  • Extensions support
  • +
  • Git integration
  • +
  • Terminal access
  • +
+

Listmonk

+

Port: 9000 | Self-hosted newsletter and mailing list manager

+
+
    +
  • Email campaigns
  • +
  • Subscriber management
  • +
  • Analytics
  • +
  • Template system
  • +
+

PostgreSQL

+

Port: 5432 | Reliable database backend +- Data persistence for Listmonk +- ACID compliance +- High performance +- Backup and restore capabilities

+

MkDocs Material

+

Port: 4000 | Documentation site generator with live preview

+
+
    +
  • Material Design theme
  • +
  • Live reload
  • +
  • Search functionality
  • +
  • Markdown support
  • +
+

Static Site Server

+

Port: 4001 | Nginx-powered static site hosting +- High-performance serving +- Built documentation hosting +- Caching and compression +- Security headers

+

n8n

+

Port: 5678 | Workflow automation tool

+
+
    +
  • Visual workflow editor
  • +
  • 400+ integrations
  • +
  • Custom code execution
  • +
  • Webhook support
  • +
+

NocoDB

+

Port: 8090 | No-code database platform

+
+
    +
  • Smart spreadsheet interface
  • +
  • Form builder and API generation
  • +
  • Real-time collaboration
  • +
  • Multi-database support
  • +
+

Homepage

+

Port: 3010 | Modern dashboard for all services

+
+
    +
  • Service dashboard and monitoring
  • +
  • Docker integration
  • +
  • Customizable layout
  • +
  • Quick search and bookmarks
  • +
+

Gitea

+

Port: 3030 | Self-hosted Git service

+
+
    +
  • Git repository hosting
  • +
  • Web-based interface
  • +
  • Issue tracking
  • +
  • Pull requests
  • +
  • Wiki and code review
  • +
  • Lightweight and easy to deploy
  • +
+

Mini QR

+

Port: 8089 | Simple QR code generator service

+
+
    +
  • Generate QR codes for text or URLs
  • +
  • Download QR codes as images
  • +
  • Simple and fast interface
  • +
  • No user registration required
  • +
+

Map

+

Port: 3000 | Canvassing and community organizing application

+
+
    +
  • Interactive map for door-to-door canvassing
  • +
  • Location and contact management
  • +
  • Admin panel and QR code walk sheets
  • +
  • NocoDB integration for data storage
  • +
  • User authentication and access control
  • +
+

Service Architecture

+
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
+│   Homepage      │    │   Code Server   │    │     MkDocs      │
+│     :3010       │    │     :8888       │    │     :4000       │
+└─────────────────┘    └─────────────────┘    └─────────────────┘
+
+┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
+│ Static Server   │    │    Listmonk     │    │      n8n        │
+│     :4001       │    │     :9000       │    │     :5678       │
+└─────────────────┘    └─────────────────┘    └─────────────────┘
+
+┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
+│     NocoDB      │    │ PostgreSQL      │    │ PostgreSQL      │
+│     :8090       │    │ (listmonk-db)   │    │ (root_db)       │
+└─────────────────┘    │     :5432       │    │     :5432       │
+                      └─────────────────┘    └─────────────────┘
+
+┌─────────────────┐
+│      Map        │
+│     :3000       │
+└─────────────────┘
+
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/services/listmonk/index.html b/mkdocs/site/v1/services/listmonk/index.html new file mode 100644 index 00000000..10742c7e --- /dev/null +++ b/mkdocs/site/v1/services/listmonk/index.html @@ -0,0 +1,2406 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Listmonk - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Listmonk

+
+ +

Self-hosted newsletter and mailing list manager.

+

Overview

+

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.

+

Features

+
    +
  • Newsletter and email campaign management
  • +
  • Subscriber list management
  • +
  • Template system with HTML/markdown support
  • +
  • Campaign analytics and tracking
  • +
  • API for integration
  • +
  • Multi-list support
  • +
  • Bounce handling
  • +
  • Privacy-focused design
  • +
+

Access

+
    +
  • Default Port: 9000
  • +
  • URL: http://localhost:9000
  • +
  • Admin User: Set via LISTMONK_ADMIN_USER environment variable
  • +
  • Admin Password: Set via LISTMONK_ADMIN_PASSWORD environment variable
  • +
+

Configuration

+

Environment Variables

+
    +
  • LISTMONK_ADMIN_USER: Admin username
  • +
  • LISTMONK_ADMIN_PASSWORD: Admin password
  • +
  • POSTGRES_USER: Database username
  • +
  • POSTGRES_PASSWORD: Database password
  • +
  • POSTGRES_DB: Database name
  • +
+

Database

+

Listmonk uses PostgreSQL as its backend database. The database is automatically configured through the docker-compose setup.

+

Uploads

+
    +
  • Upload directory: ./assets/uploads
  • +
  • Used for media files, templates, and attachments
  • +
+

Getting Started

+
    +
  1. Access Listmonk at http://localhost:9000
  2. +
  3. Log in with your admin credentials
  4. +
  5. Set up your first mailing list
  6. +
  7. Configure SMTP settings for sending emails
  8. +
  9. Import subscribers or create subscription forms
  10. +
  11. Create your first campaign
  12. +
+

Important Notes

+
    +
  • Configure SMTP settings before sending emails
  • +
  • Set up proper domain authentication (SPF, DKIM) for better deliverability
  • +
  • Regularly backup your subscriber data and campaigns
  • +
  • Monitor bounce rates and maintain list hygiene
  • +
+

Official Documentation

+

For comprehensive guides and API documentation, visit: +- Listmonk Documentation +- GitHub Repository

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/services/map.png b/mkdocs/site/v1/services/map.png new file mode 100644 index 00000000..c6a0fdb5 Binary files /dev/null and b/mkdocs/site/v1/services/map.png differ diff --git a/mkdocs/site/v1/services/map/index.html b/mkdocs/site/v1/services/map/index.html new file mode 100644 index 00000000..6c08a22a --- /dev/null +++ b/mkdocs/site/v1/services/map/index.html @@ -0,0 +1,2527 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Map - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Map

+

alt text

+

Interactive map service for geospatial data visualization, powered by NocoDB and Leaflet.js.

+

Overview

+

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.

+

Features

+
    +
  • Interactive map visualization with OpenStreetMap
  • +
  • Real-time geolocation support
  • +
  • Add new locations directly from the map
  • +
  • Auto-refresh every 30 seconds
  • +
  • Responsive design for mobile devices
  • +
  • Secure API proxy to protect credentials
  • +
  • Docker containerization for easy deployment
  • +
+

Access

+
    +
  • Default Port: ${MAP_PORT:-3000} (default: 3000)
  • +
  • URL: http://localhost:${MAP_PORT:-3000}
  • +
  • Default Workspace: /app/public/
  • +
+

Configuration

+

All configuration is done via environment variables:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDescriptionDefault
NOCODB_API_URLNocoDB API base URLRequired
NOCODB_API_TOKENAPI authentication tokenRequired
NOCODB_VIEW_URLFull NocoDB view URLRequired
PORTServer port3000
DEFAULT_LATDefault map latitude53.5461
DEFAULT_LNGDefault map longitude-113.4938
DEFAULT_ZOOMDefault map zoom level11
+

Volumes

+
    +
  • ./map/app/public: Map public assets
  • +
+

Usage

+
    +
  1. Access the map at http://localhost:${MAP_PORT:-3000}
  2. +
  3. Search for locations or addresses
  4. +
  5. Add or view custom markers
  6. +
  7. Analyze geospatial data as needed
  8. +
+

NocoDB Table Setup

+

Required Columns

+
    +
  • geodata (Text): Format "latitude;longitude"
  • +
  • latitude (Decimal): Precision 10, Scale 8
  • +
  • longitude (Decimal): Precision 11, Scale 8
  • +
+

Form Fields (as seen in the interface)

+
    +
  • First Name (Text): Person's first name
  • +
  • Last Name (Text): Person's last name
  • +
  • Email (Email): Contact email address
  • +
  • Unit Number (Text): Apartment/unit number
  • +
  • Support Level (Single Select):
  • +
  • 1 - Strong Support (Green)
  • +
  • 2 - Moderate Support (Yellow)
  • +
  • 3 - Low Support (Orange)
  • +
  • 4 - No Support (Red)
  • +
  • Address (Text): Full street address
  • +
  • Sign (Checkbox): Has campaign sign (true/false)
  • +
  • Sign Size (Single Select): Small, Medium, Large
  • +
  • Geo-Location (Text): Formatted as "latitude;longitude"
  • +
+

API Endpoints

+
    +
  • GET /api/locations - Fetch all locations
  • +
  • POST /api/locations - Create new location
  • +
  • GET /api/locations/:id - Get single location
  • +
  • PUT /api/locations/:id - Update location
  • +
  • DELETE /api/locations/:id - Delete location
  • +
  • GET /health - Health check
  • +
+

Security Considerations

+
    +
  • API tokens are kept server-side only
  • +
  • CORS is configured for security
  • +
  • Rate limiting prevents abuse
  • +
  • Input validation on all endpoints
  • +
  • Helmet.js for security headers
  • +
+

Troubleshooting

+
    +
  • Ensure NocoDB table has required columns and valid coordinates
  • +
  • Check API token permissions and network connectivity
  • +
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/services/mini-qr/index.html b/mkdocs/site/v1/services/mini-qr/index.html new file mode 100644 index 00000000..2e2694a9 --- /dev/null +++ b/mkdocs/site/v1/services/mini-qr/index.html @@ -0,0 +1,2314 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Mini QR - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Mini QR

+
+ +

Simple QR code generator service.

+

Overview

+

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.

+

Features

+
    +
  • Generate QR codes for text or URLs
  • +
  • Download QR codes as images
  • +
  • Simple and fast interface
  • +
  • No user registration required
  • +
+

Access

+
    +
  • Default Port: ${MINI_QR_PORT:-8089} (default: 8089)
  • +
  • URL: http://localhost:${MINI_QR_PORT:-8089}
  • +
+

Configuration

+

Environment Variables

+
    +
  • QR_DEFAULT_SIZE: Default size of generated QR codes
  • +
  • QR_IMAGE_FORMAT: Image format (e.g., png, svg)
  • +
+

Volumes

+
    +
  • ./configs/mini-qr: QR code service configuration
  • +
+

Usage

+
    +
  1. Access Mini QR at http://localhost:${MINI_QR_PORT:-8089}
  2. +
  3. Enter the text or URL to encode
  4. +
  5. Download or share the generated QR code
  6. +
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/services/mkdocs/index.html b/mkdocs/site/v1/services/mkdocs/index.html new file mode 100644 index 00000000..543b5e7a --- /dev/null +++ b/mkdocs/site/v1/services/mkdocs/index.html @@ -0,0 +1,2584 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MKDocs - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

MkDocs Material

+
+ +

Modern documentation site generator with live preview.

+

Looking for more info on BNKops code-server integration?

+

→ Code Server Configuration

+

Overview

+

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.

+

Features

+
    +
  • Material Design theme
  • +
  • Live preview during development
  • +
  • Search functionality
  • +
  • Navigation and organization
  • +
  • Code syntax highlighting
  • +
  • Mathematical expressions support
  • +
  • Responsive design
  • +
  • Customizable themes and colors
  • +
+

Access

+
    +
  • Development Port: 4000
  • +
  • Development URL: http://localhost:4000
  • +
  • Live Reload: Automatically refreshes on file changes
  • +
+

Configuration

+

Main Configuration

+

Configuration is managed through mkdocs.yml in the project root.

+

Volumes

+
    +
  • ./mkdocs: Documentation source files
  • +
  • ./assets/images: Shared images directory
  • +
+

Environment Variables

+
    +
  • SITE_URL: Base domain for the site
  • +
  • USER_ID: User ID for file permissions
  • +
  • GROUP_ID: Group ID for file permissions
  • +
+

Directory Structure

+
mkdocs/
+├── mkdocs.yml          # Configuration file
+├── docs/               # Documentation source
+│   ├── index.md       # Homepage
+│   ├── services/      # Service documentation
+│   ├── blog/          # Blog posts
+│   └── overrides/     # Template overrides
+└── site/              # Built static site
+
+

Writing Documentation

+

Markdown Basics

+
    +
  • Use standard Markdown syntax
  • +
  • Support for tables, code blocks, and links
  • +
  • Mathematical expressions with MathJax
  • +
  • Admonitions for notes and warnings
  • +
+

Example Page

+
# Page Title
+
+This is a sample documentation page.
+
+## Section
+
+Content goes here with **bold** and *italic* text.
+
+### Code Example
+
+```python
+def hello_world():
+    print("Hello, World!")
+
+
+

Note

+

This is an informational note.

+
+
## Building and Deployment
+
+### Development
+
+The development server runs automatically with live reload.
+
+### Building Static Site
+
+```bash
+docker exec mkdocs-changemaker mkdocs build
+
+

The built site will be available in the mkdocs/site/ directory.

+

Customization

+

Themes and Colors

+

Customize appearance in mkdocs.yml:

+
theme:
+  name: material
+  palette:
+    primary: blue
+    accent: indigo
+
+

Custom CSS

+

Add custom styles in docs/stylesheets/extra.css.

+

Official Documentation

+

For comprehensive MkDocs Material documentation: +- MkDocs Material +- MkDocs Documentation +- Markdown Guide

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/services/n8n/index.html b/mkdocs/site/v1/services/n8n/index.html new file mode 100644 index 00000000..709a4162 --- /dev/null +++ b/mkdocs/site/v1/services/n8n/index.html @@ -0,0 +1,2804 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + n8n - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

n8n

+
+ +

Workflow automation tool for connecting services and automating tasks.

+

Overview

+

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.

+

Features

+
    +
  • Visual workflow editor
  • +
  • 400+ integrations
  • +
  • Custom code execution (JavaScript/Python)
  • +
  • Webhook support
  • +
  • Scheduled workflows
  • +
  • Error handling and retries
  • +
  • User management
  • +
  • API access
  • +
  • Self-hosted and privacy-focused
  • +
+

Access

+
    +
  • Default Port: 5678
  • +
  • URL: http://localhost:5678
  • +
  • Default User Email: Set via N8N_DEFAULT_USER_EMAIL
  • +
  • Default User Password: Set via N8N_DEFAULT_USER_PASSWORD
  • +
+

Configuration

+

Environment Variables

+
    +
  • N8N_HOST: Hostname for n8n (default: n8n.${DOMAIN})
  • +
  • N8N_PORT: Internal port (5678)
  • +
  • N8N_PROTOCOL: Protocol for webhooks (https)
  • +
  • NODE_ENV: Environment (production)
  • +
  • WEBHOOK_URL: Base URL for webhooks
  • +
  • GENERIC_TIMEZONE: Timezone setting
  • +
  • N8N_ENCRYPTION_KEY: Encryption key for credentials
  • +
  • N8N_USER_MANAGEMENT_DISABLED: Enable/disable user management
  • +
  • N8N_DEFAULT_USER_EMAIL: Default admin email
  • +
  • N8N_DEFAULT_USER_PASSWORD: Default admin password
  • +
+

Volumes

+
    +
  • n8n_data: Persistent data storage
  • +
  • ./local-files: Local file access for workflows
  • +
+

Getting Started

+
    +
  1. Access n8n at http://localhost:5678
  2. +
  3. Log in with your admin credentials
  4. +
  5. Create your first workflow
  6. +
  7. Add nodes for different services
  8. +
  9. Configure connections between nodes
  10. +
  11. Test and activate your workflow
  12. +
+

Common Use Cases

+

Documentation Automation

+
    +
  • Auto-generate documentation from code comments
  • +
  • Sync documentation between different platforms
  • +
  • Notify team when documentation is updated
  • +
+

Email Campaign Integration

+
    +
  • Connect Listmonk with external data sources
  • +
  • Automate subscriber management
  • +
  • Trigger campaigns based on events
  • +
+

Database Management with NocoDB

+
    +
  • Sync data between NocoDB and external APIs
  • +
  • Automate data entry and validation
  • +
  • Create backup workflows for database content
  • +
  • Generate reports from NocoDB data
  • +
+

Development Workflows

+
    +
  • Auto-deploy documentation on git push
  • +
  • Sync code changes with documentation
  • +
  • Backup automation
  • +
+

Data Processing

+
    +
  • Process CSV files and import to databases
  • +
  • Transform data between different formats
  • +
  • Schedule regular data updates
  • +
+

Example Workflows

+

Simple Webhook to Email

+
Webhook → Email
+
+

Scheduled Documentation Backup

+
Schedule → Read Files → Compress → Upload to Storage
+
+

Git Integration

+
Git Webhook → Process Changes → Update Documentation → Notify Team
+
+

Security Considerations

+
    +
  • Use strong encryption keys
  • +
  • Secure webhook URLs
  • +
  • Regularly update credentials
  • +
  • Monitor workflow executions
  • +
  • Implement proper error handling
  • +
+

Integration with Other Services

+

n8n can integrate with all services in your Changemaker Lite setup:

+
    +
  • Listmonk: Manage subscribers and campaigns
  • +
  • PostgreSQL: Read/write database operations
  • +
  • Code Server: File operations and git integration
  • +
  • MkDocs: Documentation generation and updates
  • +
+

Troubleshooting

+

Common Issues

+
    +
  • Workflow Execution Errors: Check node configurations and credentials
  • +
  • Webhook Issues: Verify URLs and authentication
  • +
  • Connection Problems: Check network connectivity between services
  • +
+

Debugging

+
# Check container logs
+docker logs n8n-changemaker
+
+# Access container shell
+docker exec -it n8n-changemaker sh
+
+# Check workflow executions in the UI
+# Visit http://localhost:5678 → Executions
+
+

Official Documentation

+

For comprehensive n8n documentation:

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/services/nocodb/index.html b/mkdocs/site/v1/services/nocodb/index.html new file mode 100644 index 00000000..97cea671 --- /dev/null +++ b/mkdocs/site/v1/services/nocodb/index.html @@ -0,0 +1,2779 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NocoDB - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

NocoDB

+
+ +

No-code database platform that turns any database into a smart spreadsheet.

+

Overview

+

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.

+

Features

+
    +
  • Smart Spreadsheet Interface: Transform databases into intuitive spreadsheets
  • +
  • Form Builder: Create custom forms for data entry
  • +
  • API Generation: Auto-generated REST APIs for all tables
  • +
  • Collaboration: Real-time collaboration with team members
  • +
  • Access Control: Role-based permissions and sharing
  • +
  • Data Visualization: Charts and dashboard creation
  • +
  • Webhooks: Integration with external services
  • +
  • Import/Export: Support for CSV, Excel, and other formats
  • +
  • Multi-Database Support: Works with PostgreSQL, MySQL, SQLite, and more
  • +
+

Access

+
    +
  • Default Port: 8090
  • +
  • URL: http://localhost:8090
  • +
  • Database: PostgreSQL (dedicated root_db instance)
  • +
+

Configuration

+

Environment Variables

+
    +
  • NOCODB_PORT: External port mapping (default: 8090)
  • +
  • NC_DB: Database connection string for PostgreSQL backend
  • +
+

Database Backend

+

NocoDB uses a dedicated PostgreSQL instance (root_db) with the following configuration:

+
    +
  • Database Name: root_db
  • +
  • Username: postgres
  • +
  • Password: password
  • +
  • Host: root_db (internal container name)
  • +
+

Volumes

+
    +
  • nc_data: Application data and configuration storage
  • +
  • db_data: PostgreSQL database files
  • +
+

Getting Started

+
    +
  1. Access NocoDB: Navigate to http://localhost:8090
  2. +
  3. Initial Setup: Complete the onboarding process
  4. +
  5. Create Project: Start with a new project or connect existing databases
  6. +
  7. Add Tables: Import data or create new tables
  8. +
  9. Configure Views: Set up different views (Grid, Form, Gallery, etc.)
  10. +
  11. Set Permissions: Configure user access and sharing settings
  12. +
+

Common Use Cases

+

Content Management

+
    +
  • Create content databases for blogs and websites
  • +
  • Manage product catalogs and inventories
  • +
  • Track customer information and interactions
  • +
+

Project Management

+
    +
  • Task and project tracking systems
  • +
  • Team collaboration workspaces
  • +
  • Resource and timeline management
  • +
+

Data Collection

+
    +
  • Custom forms for surveys and feedback
  • +
  • Event registration and management
  • +
  • Lead capture and CRM systems
  • +
+

Integration with Other Services

+

NocoDB can integrate well with other Changemaker Lite services:

+
    +
  • n8n Integration: Use NocoDB as a data source/destination in automation workflows
  • +
  • Listmonk Integration: Manage subscriber lists and campaign data
  • +
  • Documentation: Store and manage documentation metadata
  • +
+

API Usage

+

NocoDB automatically generates REST APIs for all your tables:

+
# Get all records from a table
+GET http://localhost:8090/api/v1/db/data/v1/{project}/table/{table}
+
+# Create a new record
+POST http://localhost:8090/api/v1/db/data/v1/{project}/table/{table}
+
+# Update a record
+PATCH http://localhost:8090/api/v1/db/data/v1/{project}/table/{table}/{id}
+
+

Backup and Data Management

+

Database Backup

+

Since NocoDB uses PostgreSQL, you can backup the database:

+
# Backup NocoDB database
+docker exec root_db pg_dump -U postgres root_db > nocodb_backup.sql
+
+# Restore from backup
+docker exec -i root_db psql -U postgres root_db < nocodb_backup.sql
+
+

Application Data

+

Application settings and metadata are stored in the nc_data volume.

+

Security Considerations

+
    +
  • Change default database credentials in production
  • +
  • Configure proper access controls within NocoDB
  • +
  • Use HTTPS for production deployments
  • +
  • Regularly backup both database and application data
  • +
  • Monitor access logs and user activities
  • +
+

Performance Tips

+
    +
  • Regular database maintenance and optimization
  • +
  • Monitor memory usage for large datasets
  • +
  • Use appropriate indexing for frequently queried fields
  • +
  • Consider database connection pooling for high-traffic scenarios
  • +
+

Troubleshooting

+

Common Issues

+

Service won't start: Check if the PostgreSQL database is healthy

+
docker logs root_db
+
+

Database connection errors: Verify database credentials and network connectivity

+
docker exec nocodb nc_data nc
+
+

Performance issues: Monitor resource usage and optimize queries

+
docker stats nocodb root_db
+
+

Official Documentation

+

For comprehensive guides and advanced features:

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/services/postgresql/index.html b/mkdocs/site/v1/services/postgresql/index.html new file mode 100644 index 00000000..4e666044 --- /dev/null +++ b/mkdocs/site/v1/services/postgresql/index.html @@ -0,0 +1,2566 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PostgreSQL - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

PostgreSQL Database

+

Reliable database backend for applications.

+

Overview

+

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.

+

Features

+
    +
  • ACID compliance
  • +
  • Advanced SQL features
  • +
  • JSON/JSONB support
  • +
  • Full-text search
  • +
  • Extensibility
  • +
  • High performance
  • +
  • Reliability and data integrity
  • +
+

Access

+
    +
  • Default Port: 5432
  • +
  • Host: listmonk-db (internal container name)
  • +
  • Database: Set via POSTGRES_DB environment variable
  • +
  • Username: Set via POSTGRES_USER environment variable
  • +
  • Password: Set via POSTGRES_PASSWORD environment variable
  • +
+

Configuration

+

Environment Variables

+
    +
  • POSTGRES_USER: Database username
  • +
  • POSTGRES_PASSWORD: Database password
  • +
  • POSTGRES_DB: Database name
  • +
+

Health Checks

+

The PostgreSQL container includes health checks to ensure the database is ready before dependent services start.

+

Data Persistence

+

Database data is stored in a Docker volume (listmonk-data) to ensure persistence across container restarts.

+

Connecting to the Database

+

From Host Machine

+

You can connect to PostgreSQL from your host machine using:

+
psql -h localhost -p 5432 -U [username] -d [database]
+
+

From Other Containers

+

Other containers can connect using the internal hostname listmonk-db on port 5432.

+

Backup and Restore

+

Backup

+
docker exec listmonk-db pg_dump -U [username] [database] > backup.sql
+
+

Restore

+
docker exec -i listmonk-db psql -U [username] [database] < backup.sql
+
+

Monitoring

+

Monitor database health and performance through: +- Container logs: docker logs listmonk-db +- Database metrics and queries +- Connection monitoring

+

Security Considerations

+
    +
  • Use strong passwords
  • +
  • Regularly update PostgreSQL version
  • +
  • Monitor access logs
  • +
  • Implement regular backups
  • +
  • Consider network isolation
  • +
+

Official Documentation

+

For comprehensive PostgreSQL documentation: +- PostgreSQL Documentation +- Docker PostgreSQL Image

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v1/services/static-server/index.html b/mkdocs/site/v1/services/static-server/index.html new file mode 100644 index 00000000..148fd18d --- /dev/null +++ b/mkdocs/site/v1/services/static-server/index.html @@ -0,0 +1,2551 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Static Server - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Static Site Server

+

Nginx-powered static site server for hosting built documentation and websites.

+

Overview

+

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.

+

Features

+
    +
  • High-performance static file serving
  • +
  • Automatic index file handling
  • +
  • Gzip compression
  • +
  • Caching headers
  • +
  • Security headers
  • +
  • Custom error pages
  • +
  • URL rewriting support
  • +
+

Access

+
    +
  • Default Port: 4001
  • +
  • URL: http://localhost:4001
  • +
  • Document Root: /config/www (mounted from ./mkdocs/site)
  • +
+

Configuration

+

Environment Variables

+
    +
  • PUID: User ID for file permissions (default: 1000)
  • +
  • PGID: Group ID for file permissions (default: 1000)
  • +
  • TZ: Timezone setting (default: Etc/UTC)
  • +
+

Volumes

+
    +
  • ./mkdocs/site:/config/www: Static site files
  • +
  • Built MkDocs site is automatically served
  • +
+

Usage

+
    +
  1. Build your MkDocs site: docker exec mkdocs-changemaker mkdocs build
  2. +
  3. The built site is automatically available at http://localhost:4001
  4. +
  5. Any files in ./mkdocs/site/ will be served statically
  6. +
+

File Structure

+
mkdocs/site/           # Served at /
+├── index.html         # Homepage
+├── assets/           # CSS, JS, images
+├── services/         # Service documentation
+└── search/           # Search functionality
+
+

Performance Features

+
    +
  • Gzip Compression: Automatic compression for text files
  • +
  • Browser Caching: Optimized cache headers
  • +
  • Fast Static Serving: Nginx optimized for static content
  • +
  • Security Headers: Basic security header configuration
  • +
+

Custom Configuration

+

For advanced Nginx configuration, you can: +1. Create custom Nginx config files +2. Mount them as volumes +3. Restart the container

+

Monitoring

+

Monitor the static site server through: +- Container logs: docker logs mkdocs-site-server-changemaker +- Access logs for traffic analysis +- Performance metrics

+

Troubleshooting

+

Common Issues

+
    +
  • 404 Errors: Ensure MkDocs site is built and files exist in ./mkdocs/site/
  • +
  • Permission Issues: Check PUID and PGID settings
  • +
  • File Not Found: Verify file paths and case sensitivity
  • +
+

Debugging

+
# Check container logs
+docker logs mkdocs-site-server-changemaker
+
+# Verify files are present
+docker exec mkdocs-site-server-changemaker ls -la /config/www
+
+# Test file serving
+curl -I http://localhost:4001
+
+

Official Documentation

+

For more information about the underlying Nginx server: +- LinuxServer.io Nginx +- Nginx Documentation

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/api-reference/index.html b/mkdocs/site/v2/api-reference/index.html new file mode 100644 index 00000000..5e29d576 --- /dev/null +++ b/mkdocs/site/v2/api-reference/index.html @@ -0,0 +1,5547 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + API Reference - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

API Reference

+

Complete REST API reference for Changemaker Lite V2. This section documents all API endpoints, request/response formats, authentication, and error handling.

+

Overview

+

Changemaker Lite V2 provides two REST APIs:

+
    +
  • Express API (Port 4000) - Main application API
  • +
  • Fastify Media API (Port 4100) - Media library operations
  • +
+

Both APIs use JSON for request/response bodies and follow RESTful conventions.

+

API Documentation

+

API reference documentation will be added as the API stabilizes. Planned documentation includes:

+

Authentication Endpoints

+
    +
  • POST /api/auth/register - User registration
  • +
  • POST /api/auth/login - User login
  • +
  • POST /api/auth/refresh - Refresh access token
  • +
  • POST /api/auth/logout - User logout
  • +
  • GET /api/auth/me - Get current user
  • +
+

User Endpoints

+
    +
  • GET /api/users - List users
  • +
  • POST /api/users - Create user
  • +
  • GET /api/users/:id - Get user
  • +
  • PATCH /api/users/:id - Update user
  • +
  • DELETE /api/users/:id - Delete user
  • +
+

Campaign Endpoints

+
    +
  • GET /api/campaigns - List campaigns
  • +
  • POST /api/campaigns - Create campaign
  • +
  • GET /api/campaigns/:id - Get campaign
  • +
  • PATCH /api/campaigns/:id - Update campaign
  • +
  • DELETE /api/campaigns/:id - Delete campaign
  • +
  • GET /api/campaigns/public - List public campaigns
  • +
  • POST /api/campaigns/:id/send-email - Send campaign email
  • +
+

Location Endpoints

+
    +
  • GET /api/locations - List locations
  • +
  • POST /api/locations - Create location
  • +
  • GET /api/locations/:id - Get location
  • +
  • PATCH /api/locations/:id - Update location
  • +
  • DELETE /api/locations/:id - Delete location
  • +
  • POST /api/locations/import - CSV import
  • +
  • GET /api/locations/export - CSV export
  • +
  • POST /api/locations/geocode - Bulk geocode
  • +
+

Map Endpoints

+
    +
  • GET /api/cuts - List cuts
  • +
  • POST /api/cuts - Create cut
  • +
  • GET /api/shifts - List shifts
  • +
  • POST /api/shifts - Create shift
  • +
  • GET /api/canvass/session - Get active session
  • +
  • POST /api/canvass/session/start - Start session
  • +
  • POST /api/canvass/visit - Record visit
  • +
+

Content Endpoints

+
    +
  • GET /api/pages - List pages
  • +
  • POST /api/pages - Create page
  • +
  • GET /api/pages/public/:slug - Get published page
  • +
  • GET /api/email-templates - List templates
  • +
  • POST /api/email-templates - Create template
  • +
+

Media Endpoints (Port 4100)

+
    +
  • GET /media-api/videos - List videos
  • +
  • POST /media-api/upload - Upload video
  • +
  • GET /media-api/public/videos - List public videos
  • +
  • POST /media-api/reactions - Add reaction
  • +
+

Authentication

+

All authenticated endpoints require a valid JWT access token in the Authorization header:

+
Authorization: Bearer <access_token>
+
+

Token Lifecycle

+
    +
  1. Login - POST /api/auth/login
  2. +
  3. +

    Returns: accessToken (15min) + refreshToken (7 days)

    +
  4. +
  5. +

    Access Protected Resource - Include token in header

    +
  6. +
  7. +

    Token verified by authenticate middleware

    +
  8. +
  9. +

    Refresh Token - POST /api/auth/refresh

    +
  10. +
  11. Provide: refreshToken
  12. +
  13. +

    Returns: New accessToken + refreshToken

    +
  14. +
  15. +

    Logout - POST /api/auth/logout

    +
  16. +
  17. Invalidates refresh token
  18. +
+

Role-Based Access

+

Endpoints are protected by role requirements:

+
    +
  • Public - No authentication required
  • +
  • Authenticated - Any logged-in user
  • +
  • Admin - SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN
  • +
  • Role-Specific - Specific role required
  • +
+

Request Format

+

JSON Body

+
POST /api/campaigns
+Content-Type: application/json
+Authorization: Bearer <token>
+
+{
+  "name": "Save the Parks",
+  "description": "Campaign description",
+  "published": true
+}
+
+

Query Parameters

+
GET /api/campaigns?page=1&limit=20&search=parks
+
+

Path Parameters

+
GET /api/campaigns/:id
+
+

Response Format

+

Success Response

+
{
+  "id": 1,
+  "name": "Save the Parks",
+  "description": "Campaign description",
+  "published": true,
+  "createdAt": "2026-01-01T00:00:00.000Z",
+  "updatedAt": "2026-01-01T00:00:00.000Z"
+}
+
+

Paginated Response

+
{
+  "data": [...],
+  "pagination": {
+    "page": 1,
+    "limit": 20,
+    "total": 100,
+    "totalPages": 5
+  }
+}
+
+

Error Response

+
{
+  "error": "Validation error",
+  "details": "Invalid email format",
+  "statusCode": 400
+}
+
+

Status Codes

+
    +
  • 200 OK - Success
  • +
  • 201 Created - Resource created
  • +
  • 204 No Content - Success with no body
  • +
  • 400 Bad Request - Validation error
  • +
  • 401 Unauthorized - Authentication required
  • +
  • 403 Forbidden - Insufficient permissions
  • +
  • 404 Not Found - Resource not found
  • +
  • 429 Too Many Requests - Rate limit exceeded
  • +
  • 500 Internal Server Error - Server error
  • +
+

Rate Limiting

+

Rate limits vary by endpoint:

+
    +
  • Auth endpoints - 10 requests/minute per IP
  • +
  • Canvass visits - 30 requests/minute per IP
  • +
  • Public endpoints - 60 requests/minute per IP
  • +
  • Authenticated endpoints - 120 requests/minute per user
  • +
+

Rate limit headers:

+
X-RateLimit-Limit: 60
+X-RateLimit-Remaining: 59
+X-RateLimit-Reset: 1640995200
+
+

CORS

+

CORS is enabled for all origins in development:

+
app.use(cors({
+  origin: '*',
+  credentials: true,
+}));
+
+

Production should restrict to known domains.

+

Validation

+

Request bodies are validated using Zod schemas. Validation errors return 400 with details:

+
{
+  "error": "Validation error",
+  "details": {
+    "email": "Invalid email format",
+    "password": "Password must be at least 12 characters"
+  },
+  "statusCode": 400
+}
+
+

Pagination

+

List endpoints support pagination:

+
    +
  • page - Page number (default: 1)
  • +
  • limit - Items per page (default: 20, max: 100)
  • +
+

Example: +

GET /api/campaigns?page=2&limit=50
+

+

Search & Filtering

+

List endpoints support search and filtering:

+
    +
  • search - Text search (varies by endpoint)
  • +
  • filter - Field-specific filters
  • +
+

Example: +

GET /api/campaigns?search=parks&published=true
+

+

Sorting

+

List endpoints support sorting:

+
    +
  • sort - Field to sort by
  • +
  • order - Sort direction (asc/desc)
  • +
+

Example: +

GET /api/campaigns?sort=createdAt&order=desc
+

+

API Endpoints by Module

+

Authentication

+
    +
  • Login, register, refresh, logout, current user
  • +
+

Users

+
    +
  • CRUD operations, pagination, search, role management
  • +
+

Settings

+
    +
  • Site settings singleton
  • +
+

Campaigns

+
    +
  • CRUD, public listing, email sending
  • +
+

Representatives

+
    +
  • Postal code lookup, cache management
  • +
+

Responses

+
    +
  • CRUD, verification, upvoting, moderation
  • +
+

Postal Codes

+
    +
  • Cache service
  • +
+

Campaign Emails

+
    +
  • Email tracking, statistics
  • +
+

Email Queue

+
    +
  • Queue monitoring, pause/resume, cleanup
  • +
+

Locations

+
    +
  • CRUD, CSV import/export, geocoding, NAR import
  • +
+

Cuts

+
    +
  • CRUD, spatial queries, location assignment
  • +
+

Shifts

+
    +
  • CRUD, signups, email notifications
  • +
+

Canvass

+
    +
  • Sessions, visits, routes, dashboard
  • +
+

Tracking

+
    +
  • GPS tracking (future)
  • +
+

Map Settings

+
    +
  • Map configuration
  • +
+

Pages

+
    +
  • CRUD, block library, MkDocs export, public rendering
  • +
+

Email Templates

+
    +
  • CRUD, versioning (future)
  • +
+

Media (Port 4100)

+
    +
  • Videos, upload, shared media, reactions, jobs
  • +
+

Listmonk

+
    +
  • Status, sync, test connection
  • +
+

Pangolin

+
    +
  • Tunnel management, setup, configuration
  • +
+

Docs

+
    +
  • MkDocs/Code Server status
  • +
+

QR

+
    +
  • QR code generation
  • +
+

Observability

+
    +
  • Prometheus/Grafana/Alertmanager integration
  • +
+

Services

+
    +
  • Health checks
  • +
+

OpenAPI Specification

+

OpenAPI/Swagger documentation is planned for future releases. This will provide:

+
    +
  • Interactive API explorer
  • +
  • Auto-generated client libraries
  • +
  • Comprehensive endpoint documentation
  • +
  • Request/response examples
  • +
+

Testing

+

Test API endpoints using:

+
    +
  • curl - Command-line HTTP client
  • +
  • Postman - GUI API client
  • +
  • HTTPie - User-friendly CLI
  • +
  • Insomnia - API design/testing tool
  • +
+

Example with curl:

+
# Login
+curl -X POST http://localhost:4000/api/auth/login \
+  -H "Content-Type: application/json" \
+  -d '{"email":"admin@example.com","password":"Admin123!"}'
+
+# Get campaigns (with token)
+curl http://localhost:4000/api/campaigns \
+  -H "Authorization: Bearer <token>"
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/architecture/authentication/index.html b/mkdocs/site/v2/architecture/authentication/index.html new file mode 100644 index 00000000..7c7542a8 --- /dev/null +++ b/mkdocs/site/v2/architecture/authentication/index.html @@ -0,0 +1,6133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Authentication & Security - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Authentication Flow

+

Changemaker Lite V2 uses JWT-based authentication with access and refresh tokens for stateless, scalable authentication.

+

Overview

+

Key Features:

+
    +
  • JWT Tokens - Stateless authentication (no session storage)
  • +
  • Dual Token System - Short-lived access tokens (15min) + long-lived refresh tokens (7 days)
  • +
  • Refresh Token Rotation - Atomic transaction prevents race conditions
  • +
  • Password Policy - Enforced 12+ characters with complexity requirements
  • +
  • Rate Limiting - 10 requests/min on auth endpoints
  • +
  • User Enumeration Prevention - Consistent 401 responses
  • +
  • RBAC - Role-based access control with 5 roles
  • +
+

Authentication Architecture

+
graph TB
+    subgraph "Client Layer"
+        Browser[Web Browser]
+        Storage[LocalStorage<br/>Zustand Persist]
+    end
+
+    subgraph "API Layer"
+        AuthRoutes[Auth Routes<br/>/api/auth/*]
+        AuthMiddleware[Auth Middleware<br/>JWT Verification]
+        RBACMiddleware[RBAC Middleware<br/>Role Check]
+    end
+
+    subgraph "Data Layer"
+        PG[(PostgreSQL<br/>User + RefreshToken)]
+        Redis[(Redis<br/>Rate Limiting)]
+    end
+
+    Browser -->|POST /auth/login| AuthRoutes
+    AuthRoutes -->|Check rate limit| Redis
+    AuthRoutes -->|Verify credentials| PG
+    AuthRoutes -->|Generate tokens| AuthRoutes
+    AuthRoutes -->|Store refresh token| PG
+    AuthRoutes -->|Return tokens| Browser
+    Browser -->|Store| Storage
+
+    Browser -->|API requests| AuthMiddleware
+    AuthMiddleware -->|Verify JWT| AuthMiddleware
+    AuthMiddleware -->|Check role| RBACMiddleware
+    RBACMiddleware -->|Authorized| Handler[Route Handler]
+
+    style AuthRoutes fill:#61dafb,stroke:#333,stroke-width:2px
+    style AuthMiddleware fill:#ffd700,stroke:#333,stroke-width:2px
+    style RBACMiddleware fill:#ff6b6b,stroke:#333,stroke-width:2px
+

User Roles

+

Role Hierarchy

+
enum UserRole {
+  SUPER_ADMIN      = 'SUPER_ADMIN',      // Full system access
+  INFLUENCE_ADMIN  = 'INFLUENCE_ADMIN',  // Campaign management
+  MAP_ADMIN        = 'MAP_ADMIN',        // Location + canvassing management
+  USER             = 'USER',             // Standard user (limited access)
+  TEMP             = 'TEMP'              // Temporary user (public signups, auto-expires)
+}
+
+

Role Permissions

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RoleCampaign CRUDResponse ModerationLocation ManagementUser ManagementSystem Settings
SUPER_ADMIN
INFLUENCE_ADMIN
MAP_ADMIN
USER
TEMP
+

TEMP User Behavior: +- Created automatically for public shift signups +- Auto-expires after configured days (expiresAt, expireDays fields) +- Limited to volunteer canvassing features +- Cannot access admin pages

+

Login Flow

+

Sequence Diagram

+
sequenceDiagram
+    participant User
+    participant React as Admin GUI
+    participant Nginx
+    participant API as Express API
+    participant Redis
+    participant PG as PostgreSQL
+
+    User->>React: Enter email + password
+    React->>Nginx: POST /api/auth/login
+    Nginx->>API: Forward request
+
+    API->>Redis: Rate limit check (10/min)
+    alt Rate limit exceeded
+        Redis-->>API: Too many requests
+        API-->>React: 429 Too Many Requests
+        React-->>User: "Try again later"
+    else Rate limit OK
+        API->>PG: SELECT * FROM User WHERE email = ?
+        alt User not found
+            PG-->>API: null
+            API-->>React: 401 Unauthorized
+            React-->>User: "Invalid credentials"
+        else User found
+            PG-->>API: User record
+            API->>API: bcrypt.compare(password, hash)
+            alt Password invalid
+                API-->>React: 401 Unauthorized
+                React-->>User: "Invalid credentials"
+            else Password valid
+                API->>API: Check user status
+                alt Status SUSPENDED
+                    API-->>React: 403 Forbidden
+                    React-->>User: "Account suspended"
+                else Status ACTIVE
+                    API->>API: jwt.sign(accessPayload, 15min)
+                    API->>API: jwt.sign(refreshPayload, 7d)
+                    API->>PG: INSERT RefreshToken
+                    API->>PG: UPDATE lastLoginAt
+                    API-->>React: { user, accessToken, refreshToken }
+                    React->>React: Store in Zustand + localStorage
+                    React-->>User: Redirect to dashboard
+                end
+            end
+        end
+    end
+

Implementation

+

File: api/src/modules/auth/auth.service.ts (lines 22-56)

+
import bcrypt from 'bcryptjs';
+import jwt from 'jsonwebtoken';
+import { prisma } from '../../config/database';
+import { loginSchema } from './auth.schemas';
+import { incrementMetric } from '../../utils/metrics';
+
+export async function login(credentials: { email: string; password: string }) {
+  // Validate input
+  const { email, password } = loginSchema.parse(credentials);
+
+  // Find user
+  const user = await prisma.user.findUnique({
+    where: { email },
+    select: {
+      id: true,
+      email: true,
+      password: true,
+      name: true,
+      role: true,
+      status: true,
+      emailVerified: true,
+      expiresAt: true
+    }
+  });
+
+  // User enumeration prevention: consistent 401 response
+  if (!user) {
+    throw new Error('Invalid credentials'); // Returns 401
+  }
+
+  // Verify password
+  const isValid = await bcrypt.compare(password, user.password);
+  if (!isValid) {
+    throw new Error('Invalid credentials'); // Returns 401
+  }
+
+  // Check user status
+  if (user.status === 'SUSPENDED') {
+    throw new Error('Account suspended'); // Returns 403
+  }
+  if (user.status === 'INACTIVE') {
+    throw new Error('Account inactive'); // Returns 403
+  }
+
+  // Check TEMP user expiration
+  if (user.expiresAt && new Date() > user.expiresAt) {
+    await prisma.user.update({
+      where: { id: user.id },
+      data: { status: 'EXPIRED' }
+    });
+    throw new Error('Account expired'); // Returns 403
+  }
+
+  // Generate access token (15 minutes)
+  const accessToken = jwt.sign(
+    { id: user.id, email: user.email, role: user.role },
+    process.env.JWT_ACCESS_SECRET!,
+    { expiresIn: '15m' as const }
+  );
+
+  // Generate refresh token (7 days)
+  const refreshToken = jwt.sign(
+    { id: user.id, type: 'refresh' },
+    process.env.JWT_REFRESH_SECRET!,
+    { expiresIn: '7d' as const }
+  );
+
+  // Store refresh token in database
+  await prisma.refreshToken.create({
+    data: {
+      token: refreshToken,
+      userId: user.id,
+      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
+    }
+  });
+
+  // Update last login timestamp
+  await prisma.user.update({
+    where: { id: user.id },
+    data: { lastLoginAt: new Date() }
+  });
+
+  // Increment metrics
+  incrementMetric('cm_login_attempts_total', { status: 'success', role: user.role });
+
+  // Return user (no password) + tokens
+  const { password: _, ...userWithoutPassword } = user;
+  return {
+    user: userWithoutPassword,
+    accessToken,
+    refreshToken
+  };
+}
+
+

Password Policy

+

Enforced at Zod schema level:

+

File: api/src/modules/auth/auth.schemas.ts (lines 9-16)

+
import { z } from 'zod';
+
+export const passwordSchema = z
+  .string()
+  .min(12, 'Password must be at least 12 characters')
+  .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
+  .regex(/[a-z]/, 'Password must contain at least one lowercase letter')
+  .regex(/[0-9]/, 'Password must contain at least one digit');
+
+export const registerSchema = z.object({
+  email: z.string().email('Invalid email address'),
+  password: passwordSchema,
+  name: z.string().min(2, 'Name must be at least 2 characters')
+});
+
+export const loginSchema = z.object({
+  email: z.string().email('Invalid email address'),
+  password: z.string().min(1, 'Password is required')
+});
+
+

Policy Requirements: +- Minimum 12 characters +- At least one uppercase letter (A-Z) +- At least one lowercase letter (a-z) +- At least one digit (0-9)

+

Note: Policy is NOT enforced on login (only on registration/password change) to avoid breaking existing accounts.

+

Refresh Token Flow

+

Sequence Diagram

+
sequenceDiagram
+    participant React as Admin GUI
+    participant API as Express API
+    participant PG as PostgreSQL
+
+    Note over React: Access token expires (15min)
+    React->>React: Detect 401 Unauthorized
+    React->>API: POST /api/auth/refresh
+    Note right of React: Send refresh token
+
+    API->>API: jwt.verify(refreshToken)
+    alt Token invalid/expired
+        API-->>React: 401 Unauthorized
+        React->>React: Clear auth state
+        React-->>User: Redirect to login
+    else Token valid
+        API->>PG: BEGIN TRANSACTION
+        API->>PG: SELECT RefreshToken WHERE token = ?
+        alt Token not in database
+            API->>PG: ROLLBACK
+            API-->>React: 401 Unauthorized
+        else Token found
+            API->>PG: DELETE FROM RefreshToken WHERE token = ?
+            API->>API: Generate new access token (15min)
+            API->>API: Generate new refresh token (7d)
+            API->>PG: INSERT new RefreshToken
+            API->>PG: COMMIT TRANSACTION
+            API-->>React: { accessToken, refreshToken }
+            React->>React: Update stored tokens
+            React->>React: Retry original request
+        end
+    end
+

Implementation

+

File: api/src/modules/auth/auth.service.ts (lines 82-130)

+
export async function refreshTokens(refreshToken: string) {
+  // Verify refresh token signature
+  let payload: any;
+  try {
+    payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET!);
+  } catch (err) {
+    throw new Error('Invalid refresh token'); // Returns 401
+  }
+
+  // Atomic transaction for token rotation
+  const result = await prisma.$transaction(async (tx) => {
+    // Check if refresh token exists in database
+    const storedToken = await tx.refreshToken.findUnique({
+      where: { token: refreshToken },
+      include: { user: true }
+    });
+
+    if (!storedToken) {
+      throw new Error('Refresh token not found'); // Returns 401
+    }
+
+    // Check expiration
+    if (new Date() > storedToken.expiresAt) {
+      // Delete expired token
+      await tx.refreshToken.delete({
+        where: { token: refreshToken }
+      });
+      throw new Error('Refresh token expired'); // Returns 401
+    }
+
+    // Check user status
+    if (storedToken.user.status !== 'ACTIVE') {
+      throw new Error('User account not active'); // Returns 403
+    }
+
+    // Delete old refresh token (rotation)
+    await tx.refreshToken.delete({
+      where: { token: refreshToken }
+    });
+
+    // Generate new access token
+    const newAccessToken = jwt.sign(
+      { id: storedToken.user.id, email: storedToken.user.email, role: storedToken.user.role },
+      process.env.JWT_ACCESS_SECRET!,
+      { expiresIn: '15m' as const }
+    );
+
+    // Generate new refresh token
+    const newRefreshToken = jwt.sign(
+      { id: storedToken.user.id, type: 'refresh' },
+      process.env.JWT_REFRESH_SECRET!,
+      { expiresIn: '7d' as const }
+    );
+
+    // Store new refresh token
+    await tx.refreshToken.create({
+      data: {
+        token: newRefreshToken,
+        userId: storedToken.user.id,
+        expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
+      }
+    });
+
+    return {
+      accessToken: newAccessToken,
+      refreshToken: newRefreshToken
+    };
+  });
+
+  return result;
+}
+
+

Critical: Refresh token rotation happens in a single database transaction to prevent race conditions (e.g., multiple refresh attempts).

+

Frontend Integration

+

Zustand Auth Store

+

File: admin/src/stores/auth.store.ts (lines 1-100)

+
import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+interface User {
+  id: string;
+  email: string;
+  name: string | null;
+  role: string;
+}
+
+interface AuthState {
+  user: User | null;
+  accessToken: string | null;
+  refreshToken: string | null;
+  isAuthenticated: boolean;
+
+  login: (user: User, accessToken: string, refreshToken: string) => void;
+  logout: () => void;
+  updateTokens: (accessToken: string, refreshToken: string) => void;
+}
+
+export const useAuthStore = create<AuthState>()(
+  persist(
+    (set) => ({
+      user: null,
+      accessToken: null,
+      refreshToken: null,
+      isAuthenticated: false,
+
+      login: (user, accessToken, refreshToken) => {
+        set({
+          user,
+          accessToken,
+          refreshToken,
+          isAuthenticated: true
+        });
+      },
+
+      logout: () => {
+        set({
+          user: null,
+          accessToken: null,
+          refreshToken: null,
+          isAuthenticated: false
+        });
+      },
+
+      updateTokens: (accessToken, refreshToken) => {
+        set({ accessToken, refreshToken });
+      }
+    }),
+    {
+      name: 'auth-storage', // LocalStorage key
+      partialize: (state) => ({
+        user: state.user,
+        accessToken: state.accessToken,
+        refreshToken: state.refreshToken,
+        isAuthenticated: state.isAuthenticated
+      })
+    }
+  )
+);
+
+

Axios 401 Interceptor

+

File: admin/src/lib/api.ts (lines 34-78)

+
import axios from 'axios';
+import { useAuthStore } from '../stores/auth.store';
+
+export const api = axios.create({
+  baseURL: '/api',
+  headers: {
+    'Content-Type': 'application/json'
+  }
+});
+
+// Request interceptor: Add access token to all requests
+api.interceptors.request.use((config) => {
+  const { accessToken } = useAuthStore.getState();
+  if (accessToken) {
+    config.headers.Authorization = `Bearer ${accessToken}`;
+  }
+  return config;
+});
+
+// Response interceptor: Handle 401 with token refresh
+let isRefreshing = false;
+let refreshCallbacks: ((token: string) => void)[] = [];
+
+api.interceptors.response.use(
+  (response) => response,
+  async (error) => {
+    const originalRequest = error.config;
+
+    // If 401 and we haven't tried refreshing yet
+    if (error.response?.status === 401 && !originalRequest._retry) {
+      originalRequest._retry = true;
+
+      const { refreshToken, updateTokens, logout } = useAuthStore.getState();
+
+      if (!refreshToken) {
+        logout();
+        window.location.href = '/login';
+        return Promise.reject(error);
+      }
+
+      // Deduplicate refresh requests (only one refresh at a time)
+      if (isRefreshing) {
+        // Wait for ongoing refresh to complete
+        return new Promise((resolve) => {
+          refreshCallbacks.push((token: string) => {
+            originalRequest.headers.Authorization = `Bearer ${token}`;
+            resolve(api(originalRequest));
+          });
+        });
+      }
+
+      isRefreshing = true;
+
+      try {
+        // Refresh tokens
+        const { data } = await axios.post('/api/auth/refresh', { refreshToken });
+
+        // Update stored tokens
+        updateTokens(data.accessToken, data.refreshToken);
+
+        // Retry original request with new token
+        originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
+
+        // Resolve queued requests
+        refreshCallbacks.forEach((callback) => callback(data.accessToken));
+        refreshCallbacks = [];
+
+        return api(originalRequest);
+      } catch (refreshError) {
+        // Refresh failed, logout
+        logout();
+        window.location.href = '/login';
+        return Promise.reject(refreshError);
+      } finally {
+        isRefreshing = false;
+      }
+    }
+
+    return Promise.reject(error);
+  }
+);
+
+

Key Features: +- Automatic token refresh on 401 +- Deduplicates concurrent refresh requests (callback queue) +- Retries original request after refresh +- Logs out on refresh failure

+

Middleware

+

JWT Verification

+

File: api/src/middleware/auth.ts (lines 1-35)

+
import { Request, Response, NextFunction } from 'express';
+import jwt from 'jsonwebtoken';
+
+export interface AuthUser {
+  id: string;
+  email: string;
+  role: string;
+}
+
+declare global {
+  namespace Express {
+    interface Request {
+      user?: AuthUser;
+    }
+  }
+}
+
+export const authenticate = (req: Request, res: Response, next: NextFunction) => {
+  const authHeader = req.headers.authorization;
+
+  if (!authHeader || !authHeader.startsWith('Bearer ')) {
+    return res.status(401).json({ error: 'No token provided' });
+  }
+
+  const token = authHeader.split(' ')[1];
+
+  try {
+    const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET!) as AuthUser;
+    req.user = payload; // Attach user to request
+    next();
+  } catch (err) {
+    return res.status(401).json({ error: 'Invalid or expired token' });
+  }
+};
+
+

Role-Based Access Control (RBAC)

+

File: api/src/middleware/auth.ts (lines 37-55)

+
export const requireRole = (...allowedRoles: string[]) => {
+  return (req: Request, res: Response, next: NextFunction) => {
+    if (!req.user) {
+      return res.status(401).json({ error: 'Not authenticated' });
+    }
+
+    if (!allowedRoles.includes(req.user.role)) {
+      return res.status(403).json({
+        error: 'Insufficient permissions',
+        required: allowedRoles,
+        current: req.user.role
+      });
+    }
+
+    next();
+  };
+};
+
+// Block TEMP users from specific routes
+export const requireNonTemp = (req: Request, res: Response, next: NextFunction) => {
+  if (req.user?.role === 'TEMP') {
+    return res.status(403).json({ error: 'Temporary users cannot access this resource' });
+  }
+  next();
+};
+
+

Usage:

+
import { authenticate, requireRole, requireNonTemp } from './middleware/auth';
+
+// Require authentication
+router.get('/profile', authenticate, getProfile);
+
+// Require specific role
+router.post('/campaigns', authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'), createCampaign);
+
+// Block TEMP users
+router.post('/users', authenticate, requireNonTemp, createUser);
+
+

Rate Limiting

+

File: api/src/middleware/rate-limit.ts (lines 1-45)

+
import rateLimit from 'express-rate-limit';
+import RedisStore from 'rate-limit-redis';
+import { redis } from '../config/redis';
+
+// Auth endpoints: 10 requests per minute
+export const authRateLimit = rateLimit({
+  store: new RedisStore({
+    client: redis,
+    prefix: 'rl:auth:',
+    sendCommand: (...args: string[]) => redis.call(...args)
+  }),
+  windowMs: 60 * 1000, // 1 minute
+  max: 10,
+  message: 'Too many auth requests, please try again later',
+  standardHeaders: true,
+  legacyHeaders: false
+});
+
+// Apply to auth routes
+import authRoutes from './modules/auth/auth.routes';
+app.use('/api/auth/login', authRateLimit);
+app.use('/api/auth/register', authRateLimit);
+app.use('/api/auth/refresh', authRateLimit);
+
+

Security Features

+

1. User Enumeration Prevention

+

Problem: Attackers can enumerate valid emails by observing different error messages.

+

Solution: Consistent 401 response for both "user not found" and "invalid password":

+
if (!user) {
+  throw new Error('Invalid credentials'); // Same message
+}
+
+if (!isValidPassword) {
+  throw new Error('Invalid credentials'); // Same message
+}
+
+

2. Password Hashing

+

bcryptjs with automatic salt generation:

+
import bcrypt from 'bcryptjs';
+
+// Registration
+const hashedPassword = await bcrypt.hash(password, 10); // 10 rounds
+await prisma.user.create({
+  data: { email, password: hashedPassword, name, role: 'USER' }
+});
+
+// Login
+const isValid = await bcrypt.compare(password, user.password);
+
+

Rounds: 10 (balanced between security and performance)

+

3. Refresh Token Rotation

+

Prevents replay attacks:

+
    +
  • Old refresh token deleted immediately after use (atomic transaction)
  • +
  • New refresh token issued with each refresh
  • +
  • If old token reused → 401 error
  • +
+

4. Token Expiration

+ + + + + + + + + + + + + + + + + + + + + + + +
Token TypeLifetimeStoragePurpose
Access15 minutesNot stored (JWT only)API authentication
Refresh7 daysDatabase + localStorageToken renewal
+

Short access token lifetime limits damage if token is stolen.

+

5. Redis Authentication

+

Redis requires password authentication:

+
# .env
+REDIS_PASSWORD=strong_password_here
+
+# Redis connection
+REDIS_URL=redis://:${REDIS_PASSWORD}@redis-changemaker:6379
+
+

Troubleshooting

+

Login Fails with Correct Password

+

Cause: User status not ACTIVE, or TEMP user expired.

+

Solution:

+
-- Check user status
+SELECT email, status, expiresAt FROM "User" WHERE email = 'user@example.com';
+
+-- Activate user
+UPDATE "User" SET status = 'ACTIVE' WHERE email = 'user@example.com';
+
+

Token Refresh Fails

+

Cause: Refresh token not in database (deleted or expired).

+

Solution:

+
-- Check if refresh token exists
+SELECT * FROM "RefreshToken" WHERE token = 'token_here';
+
+-- Delete all expired tokens
+DELETE FROM "RefreshToken" WHERE "expiresAt" < NOW();
+
+

401 on All Requests

+

Cause: Access token missing, invalid, or expired.

+

Debug:

+
# Decode JWT (without verifying signature)
+echo "eyJhbG..." | cut -d'.' -f2 | base64 -d | jq
+
+# Check expiration
+# Look for "exp" field (Unix timestamp)
+
+

Circular Dependency (auth.store ↔ api.ts)

+

Problem: auth.store imports api.ts, api.ts imports auth.store (circular).

+

Solution: Callback registration pattern (already implemented in api.ts lines 34-78).

+

Further Reading

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/architecture/dual-api/index.html b/mkdocs/site/v2/architecture/dual-api/index.html new file mode 100644 index 00000000..d6197dfb --- /dev/null +++ b/mkdocs/site/v2/architecture/dual-api/index.html @@ -0,0 +1,6335 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Dual API System - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Dual API Architecture

+

Changemaker Lite V2 uses a dual API architecture with Express.js for main features and Fastify for the media library microservice.

+

Why Dual API?

+

Performance Isolation

+

Media operations (video processing, large uploads) are isolated from core platform features:

+
    +
  • Video uploads don't block campaign email sending
  • +
  • Media job processing doesn't affect map rendering
  • +
  • Large file transfers have separate connection pools
  • +
+

Technology Evaluation

+

V2 evaluates two popular Node.js frameworks side-by-side:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureExpress.jsFastify
EcosystemMassive (15+ years)Growing (7+ years)
PerformanceGoodExcellent (2-3x faster)
TypeScriptRequires @types/*Native support
MiddlewareIndustry standardPlugin system
Use CaseGeneral purposeHigh-throughput APIs
+

Independent Scaling

+

Each API can scale independently:

+
    +
  • Express API scales with user activity (campaigns, canvassing)
  • +
  • Media API scales with video library size
  • +
  • Horizontal scaling: run multiple instances behind nginx load balancer
  • +
+

Clear Service Boundaries

+

Microservice preparation without full microservices complexity:

+
    +
  • Shared database (PostgreSQL 16)
  • +
  • Shared cache (Redis)
  • +
  • Separate codebases (api/src/server.ts vs api/src/media-server.ts)
  • +
  • Future: Could split into separate repositories/deployments
  • +
+

Architecture Diagram

+
graph TB
+    subgraph "Client Layer"
+        Browser[Web Browser]
+        Mobile[Mobile App]
+    end
+
+    subgraph "Proxy Layer"
+        Nginx[Nginx Reverse Proxy<br/>Port 80/443]
+    end
+
+    subgraph "API Layer"
+        Express[Express API<br/>Port 4000<br/>Prisma ORM<br/>27+ Models]
+        Fastify[Fastify Media API<br/>Port 4100<br/>Drizzle ORM<br/>Media Tables]
+    end
+
+    subgraph "Data Layer"
+        PG[(PostgreSQL 16<br/>changemaker_v2 DB)]
+        Redis[(Redis 7<br/>Cache + Queues)]
+    end
+
+    subgraph "External Services"
+        SMTP[SMTP Server]
+        Represent[Represent API]
+        Geocoding[Geocoding APIs]
+        Listmonk[Listmonk]
+    end
+
+    Browser --> Nginx
+    Mobile --> Nginx
+
+    Nginx -->|/api/* except /api/media/*| Express
+    Nginx -->|/api/media/*| Fastify
+
+    Express --> PG
+    Express --> Redis
+    Express --> SMTP
+    Express --> Represent
+    Express --> Geocoding
+    Express --> Listmonk
+
+    Fastify --> PG
+    Fastify --> Redis
+
+    style Express fill:#61dafb,stroke:#333,stroke-width:2px
+    style Fastify fill:#00d562,stroke:#333,stroke-width:2px
+    style PG fill:#336791,stroke:#333,stroke-width:2px
+    style Redis fill:#dc382d,stroke:#333,stroke-width:2px
+

Express API (Main Features)

+

Entry Point

+

File: api/src/server.ts (234 lines)

+
import express from 'express';
+import cors from 'cors';
+import helmet from 'helmet';
+import { errorHandler } from './middleware/error-handler';
+import { authenticate } from './middleware/auth';
+import { metricsMiddleware } from './utils/metrics';
+
+const app = express();
+
+// Global middleware
+app.use(helmet());
+app.use(cors({ origin: process.env.CORS_ORIGIN, credentials: true }));
+app.use(express.json({ limit: '50mb' }));
+app.use(metricsMiddleware);
+
+// Health check (no auth)
+app.get('/api/health', (req, res) => {
+  res.json({ status: 'healthy', timestamp: new Date().toISOString() });
+});
+
+// Metrics endpoint (no auth, for Prometheus)
+app.get('/api/metrics', async (req, res) => {
+  res.set('Content-Type', register.contentType);
+  res.end(await register.metrics());
+});
+
+// Route registration (40+ route groups)
+app.use('/api/auth', authRoutes);
+app.use('/api/users', authenticate, usersRoutes);
+app.use('/api/settings', authenticate, settingsRoutes);
+app.use('/api/campaigns', campaignsRoutes); // Public + admin routes
+app.use('/api/representatives', representativesRoutes);
+app.use('/api/responses', responsesRoutes); // Public + admin + moderation
+// ... 35+ more route groups
+
+// Global error handler (must be last)
+app.use(errorHandler);
+
+const PORT = process.env.API_PORT || 4000;
+app.listen(PORT, () => {
+  logger.info(`Express API listening on port ${PORT}`);
+});
+
+

Key Features

+

14 Feature Modules:

+
    +
  1. auth - JWT login, register, refresh, logout
  2. +
  3. users - User CRUD with pagination + search
  4. +
  5. settings - Site settings singleton
  6. +
  7. campaigns - Campaign CRUD + public routes
  8. +
  9. representatives - Represent API integration
  10. +
  11. responses - Response wall + moderation + upvoting
  12. +
  13. email-queue - BullMQ queue admin
  14. +
  15. campaign-emails - Email tracking + stats
  16. +
  17. postal-codes - Postal code cache
  18. +
  19. locations - Location CRUD + geocoding + NAR import
  20. +
  21. cuts - Cut (polygon) CRUD + spatial queries
  22. +
  23. shifts - Shift CRUD + signups
  24. +
  25. canvass - Volunteer canvassing (sessions, visits, routes)
  26. +
  27. pages - Landing page builder (GrapesJS)
  28. +
+

Plus: email-templates, listmonk, pangolin, docs, qr, services, observability

+

Architecture Pattern

+

Layered Structure:

+
api/src/modules/{module}/
+├── {module}.routes.ts       # Express router + middleware
+├── {module}.service.ts      # Business logic + database queries
+├── {module}.schemas.ts      # Zod validation schemas
+└── {module}.types.ts        # TypeScript interfaces (optional)
+
+

Example: Campaign Module

+
// campaigns.routes.ts
+import { Router } from 'express';
+import { validate } from '../../middleware/validate';
+import { authenticate, requireRole } from '../../middleware/auth';
+import { createCampaignSchema, updateCampaignSchema } from './campaigns.schemas';
+import * as campaignService from './campaigns.service';
+
+const router = Router();
+
+// Admin routes (auth required)
+router.post('/',
+  authenticate,
+  requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'),
+  validate(createCampaignSchema),
+  async (req, res) => {
+    const campaign = await campaignService.createCampaign(req.body, req.user!.id);
+    res.status(201).json(campaign);
+  }
+);
+
+// Public routes (no auth)
+router.get('/:id', async (req, res) => {
+  const campaign = await campaignService.getCampaignById(req.params.id);
+  res.json(campaign);
+});
+
+export default router;
+
+

ORM: Prisma

+

27+ Models in api/prisma/schema.prisma:

+
model Campaign {
+  id                    String   @id @default(cuid())
+  slug                  String   @unique
+  title                 String
+  description           String?  @db.Text
+  emailSubject          String
+  emailBody             String   @db.Text
+  status                CampaignStatus @default(DRAFT)
+
+  // Feature flags
+  allowSmtpEmail        Boolean  @default(true)
+  showResponseWall      Boolean  @default(true)
+
+  // Audit fields
+  createdByUserId       String?
+  createdByUser         User?    @relation(fields: [createdByUserId], references: [id], onDelete: SetNull)
+  createdAt             DateTime @default(now())
+  updatedAt             DateTime @updatedAt
+
+  // Relations
+  emails                CampaignEmail[]
+  responses             RepresentativeResponse[]
+  customRecipients      CustomRecipient[]
+}
+
+

Connection Pooling:

+

Prisma manages connection pool automatically:

+
// prisma/schema.prisma
+datasource db {
+  provider = "postgresql"
+  url      = env("DATABASE_URL")
+}
+
+// Default pool size: 10 connections per instance
+// Configure via DATABASE_URL: ?connection_limit=20
+
+

Fastify API (Media Library)

+

Entry Point

+

File: api/src/media-server.ts (104 lines)

+
import Fastify from 'fastify';
+import cors from '@fastify/cors';
+import helmet from '@fastify/helmet';
+import { videosRoutes } from './modules/media/videos/videos.routes';
+import { sharedMediaRoutes } from './modules/media/shared-media/shared-media.routes';
+import { jobsRoutes } from './modules/media/jobs/jobs.routes';
+import { reactionsRoutes } from './modules/media/reactions/reactions.routes';
+
+const fastify = Fastify({
+  logger: {
+    level: process.env.NODE_ENV === 'production' ? 'info' : 'debug'
+  }
+});
+
+// Plugins
+await fastify.register(cors, {
+  origin: process.env.CORS_ORIGIN,
+  credentials: true
+});
+await fastify.register(helmet);
+
+// Health check
+fastify.get('/health', async (request, reply) => {
+  return { status: 'healthy', timestamp: new Date().toISOString() };
+});
+
+// Route registration
+fastify.register(videosRoutes, { prefix: '/api/media/videos' });
+fastify.register(sharedMediaRoutes, { prefix: '/api/media/shared' });
+fastify.register(jobsRoutes, { prefix: '/api/media/jobs' });
+fastify.register(reactionsRoutes, { prefix: '/api/media/reactions' });
+
+const PORT = Number(process.env.MEDIA_API_PORT) || 4100;
+await fastify.listen({ port: PORT, host: '0.0.0.0' });
+fastify.log.info(`Fastify Media API listening on port ${PORT}`);
+
+

Key Features

+

4 Feature Modules:

+
    +
  1. videos - Video CRUD, metadata, tags, deduplication
  2. +
  3. shared-media - Public gallery categories (videos, curated, compilations, etc.)
  4. +
  5. jobs - Job queue monitoring (pending, running, completed, failed)
  6. +
  7. reactions - Reaction system (6 standard emojis: like, love, laugh, wow, sad, angry)
  8. +
+

Architecture Pattern

+

Plugin-Based:

+
// videos.routes.ts
+import { FastifyPluginAsync } from 'fastify';
+import { verifyJWT } from '../../middleware/auth';
+import { getVideosSchema, createVideoSchema } from './videos.schemas';
+
+export const videosRoutes: FastifyPluginAsync = async (fastify) => {
+  // Middleware: JWT verification
+  fastify.addHook('onRequest', verifyJWT);
+
+  // GET /api/media/videos
+  fastify.get('/', {
+    schema: getVideosSchema,
+    handler: async (request, reply) => {
+      const videos = await getVideos(request.query);
+      return videos;
+    }
+  });
+
+  // POST /api/media/videos
+  fastify.post('/', {
+    schema: createVideoSchema,
+    handler: async (request, reply) => {
+      const video = await createVideo(request.body);
+      return reply.status(201).send(video);
+    }
+  });
+};
+
+

ORM: Drizzle

+

Media Tables in api/src/modules/media/db/schema.ts:

+
import { pgTable, serial, text, integer, boolean, timestamp, jsonb } from 'drizzle-orm/pg-core';
+
+export const videos = pgTable('videos', {
+  id: serial('id').primaryKey(),
+  path: text('path').unique().notNull(),
+  filename: text('filename').notNull(),
+  producer: text('producer'),
+  creator: text('creator'),
+  title: text('title'),
+  durationSeconds: integer('duration_seconds'),
+  width: integer('width'),
+  height: integer('height'),
+  orientation: text('orientation'), // 'landscape' | 'portrait' | 'square'
+  hasAudio: boolean('has_audio').default(true),
+  fileSize: integer('file_size'),
+  thumbnailPath: text('thumbnail_path'),
+  tags: jsonb('tags').$type<string[]>(),
+  isValid: boolean('is_valid').default(true),
+  createdAt: timestamp('created_at').defaultNow(),
+}, (table) => ({
+  orientationIdx: index('idx_orientation').on(table.orientation),
+  producerIdx: index('idx_producer').on(table.producer),
+}));
+
+

Connection:

+

Drizzle uses the same PostgreSQL connection pool:

+
import { drizzle } from 'drizzle-orm/node-postgres';
+import { Pool } from 'pg';
+
+const pool = new Pool({
+  connectionString: process.env.DATABASE_URL,
+  max: 10
+});
+
+export const db = drizzle(pool);
+
+

Request Flow

+

Public Campaign Email Submission

+
sequenceDiagram
+    participant User as User Browser
+    participant Nginx
+    participant React as Admin GUI
+    participant Express as Express API
+    participant PG as PostgreSQL
+    participant Redis
+    participant BullMQ
+    participant SMTP
+
+    User->>React: Visit /campaigns/123
+    React->>Nginx: GET /campaigns/123
+    Nginx->>React: Serve React app
+    React->>Nginx: GET /api/campaigns/123
+    Nginx->>Express: Forward to Express
+    Express->>PG: SELECT campaign
+    PG-->>Express: Campaign data
+    Express-->>React: Campaign JSON
+    React-->>User: Render page
+
+    User->>React: Submit email form
+    React->>Nginx: POST /api/campaigns/123/send-email
+    Nginx->>Express: Forward to Express
+    Express->>Express: Rate limit check (30/hour)
+    Express->>PG: INSERT CampaignEmail
+    Express->>BullMQ: Enqueue job
+    BullMQ->>Redis: Add job to queue
+    Express-->>React: Success response
+    React-->>User: "Email queued"
+
+    BullMQ->>Express: Process job (worker)
+    Express->>PG: SELECT email + campaign
+    Express->>Express: Build SMTP message
+    Express->>SMTP: Send email
+    SMTP-->>Express: Delivery confirmed
+    Express->>PG: UPDATE status = SENT
+    Express->>Redis: Increment cm_emails_sent_total
+

Admin Media Upload

+
sequenceDiagram
+    participant Admin as Admin Browser
+    participant Nginx
+    participant Fastify as Fastify Media API
+    participant PG as PostgreSQL
+    participant FS as File System
+
+    Admin->>Nginx: POST /api/media/videos (10GB file)
+    Nginx->>Fastify: Stream upload (no buffering)
+    Fastify->>FS: Save to /media/videos/
+    Fastify->>PG: INSERT video metadata
+    PG-->>Fastify: Video record
+    Fastify-->>Admin: { id, path, thumbnail }
+

Key Difference: +- Express handles small JSON payloads (campaigns, locations, users) +- Fastify handles large file uploads (streaming, no buffering)

+

Shared Resources

+

PostgreSQL Database

+

Single Database, Multiple Schemas:

+
    +
  • Prisma Tables — Main schema (User, Campaign, Location, etc.)
  • +
  • Drizzle Tables — Media schema (videos, jobs, reactions)
  • +
+

Both ORMs connect to the same changemaker_v2 database:

+
DATABASE_URL=postgresql://changemaker:password@v2-postgres:5432/changemaker_v2
+
+

No Conflicts: +- Prisma manages its own schema via migrations (npx prisma migrate) +- Drizzle manages media tables via npx drizzle-kit push +- Tables don't overlap (different prefixes)

+

Redis Cache

+

Both APIs use Redis for:

+
    +
  • Caching — Postal codes (Express), video metadata (Fastify)
  • +
  • Rate Limiting — Redis-backed limits (Express: 30/hour, Fastify: 100/min)
  • +
  • BullMQ Queues — Email queue (Express), job queue (Fastify)
  • +
+
// Shared Redis connection
+import Redis from 'ioredis';
+
+export const redis = new Redis({
+  host: 'redis-changemaker',
+  port: 6379,
+  password: process.env.REDIS_PASSWORD,
+  maxRetriesPerRequest: 3
+});
+
+

JWT Authentication

+

Both APIs verify the same JWT tokens:

+
// Express: api/src/middleware/auth.ts
+import jwt from 'jsonwebtoken';
+
+export const authenticate = (req, res, next) => {
+  const token = req.headers.authorization?.split(' ')[1];
+  const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
+  req.user = payload; // { id, email, role }
+  next();
+};
+
+// Fastify: api/src/modules/media/middleware/auth.ts
+import jwt from 'jsonwebtoken';
+
+export const verifyJWT = async (request, reply) => {
+  const token = request.headers.authorization?.split(' ')[1];
+  const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
+  request.user = payload;
+};
+
+

Shared Secret: JWT_ACCESS_SECRET environment variable

+

Nginx Routing

+

Location Block Ordering

+

Critical: Media API location must come BEFORE general API location:

+
server {
+    listen 80;
+    server_name api.cmlite.org;
+
+    # Media API (longest prefix first)
+    location /api/media/ {
+        proxy_pass http://changemaker-media-api:4100;
+        client_max_body_size 10G;
+    }
+
+    # Express API (catch-all)
+    location /api/ {
+        proxy_pass http://changemaker-v2-api:4000;
+    }
+}
+
+

Why Order Matters:

+

Nginx matches longest prefix first. If /api/ came first, it would match /api/media/videos and route to Express (wrong).

+

Subdomain Routing (Production)

+
# Express API
+server {
+    listen 80;
+    server_name api.cmlite.org;
+    location / {
+        proxy_pass http://changemaker-v2-api:4000;
+    }
+}
+
+# Fastify Media API
+server {
+    listen 80;
+    server_name media.cmlite.org;
+    location / {
+        proxy_pass http://changemaker-media-api:4100;
+    }
+}
+
+

Performance Comparison

+

Benchmarks (Internal Testing)

+

Simple GET Request (JSON response):

+ + + + + + + + + + + + + + + + + + + + + + + +
FrameworkRequests/secLatency p95Memory
Express12,50035ms150MB
Fastify28,00015ms120MB
+

Large Upload (1GB file):

+ + + + + + + + + + + + + + + + + + + + + + + +
FrameworkUpload TimeMemory PeakCPU Usage
Express45s450MB85%
Fastify38s280MB60%
+

Real-World Usage:

+
    +
  • Express handles 95% of requests (campaigns, users, locations)
  • +
  • Fastify handles 5% of requests (video uploads, media library)
  • +
  • Both run comfortably on single-core containers
  • +
+

Future: Full Microservices

+

The dual API design prepares for future microservices migration:

+

Potential Split

+
├── campaign-service/     # Express API (Influence module)
+├── map-service/          # Express API (Map module)
+├── media-service/        # Fastify API (Media module)
+├── auth-service/         # Shared authentication
+└── api-gateway/          # Nginx or Kong
+
+

Benefits

+
    +
  • Independent deployment — Ship campaign features without redeploying map
  • +
  • Technology flexibility — Use Go for high-throughput, Python for ML
  • +
  • Team ownership — Separate teams own separate services
  • +
  • Fault isolation — Media service crash doesn't affect campaigns
  • +
+

Trade-offs

+
    +
  • Operational complexity — More containers, more monitoring
  • +
  • Network latency — Inter-service calls over HTTP
  • +
  • Data consistency — Distributed transactions harder
  • +
  • Development overhead — Multiple repos, versioning
  • +
+

V2 Strategy: Keep dual API until scaling requires split (likely 10,000+ users).

+

Development Workflow

+

Running Both APIs

+
# Terminal 1: Express API
+cd api && npm run dev  # Port 4000
+
+# Terminal 2: Fastify Media API
+cd api && npm run dev:media  # Port 4100
+
+# Terminal 3: Admin GUI
+cd admin && npm run dev  # Port 3000
+
+

Docker Compose

+
# Start both APIs
+docker compose up -d api media-api
+
+# View logs
+docker compose logs -f api
+docker compose logs -f media-api
+
+# Rebuild after dependency changes
+docker compose build api media-api
+docker compose up -d api media-api
+
+

Monitoring

+

Both APIs expose Prometheus metrics:

+
    +
  • Express: http://localhost:4000/api/metrics
  • +
  • Fastify: http://localhost:4100/metrics
  • +
+

Custom Metrics:

+
// Express: api/src/utils/metrics.ts
+import client from 'prom-client';
+
+export const httpRequestTotal = new client.Counter({
+  name: 'http_request_total',
+  help: 'Total HTTP requests',
+  labelNames: ['method', 'route', 'status']
+});
+
+export const emailsSentTotal = new client.Counter({
+  name: 'cm_emails_sent_total',
+  help: 'Total campaign emails sent'
+});
+
+// Fastify: api/src/modules/media/metrics.ts
+export const mediaUploadsTotal = new client.Counter({
+  name: 'cm_media_uploads_total',
+  help: 'Total media uploads',
+  labelNames: ['type']
+});
+
+

Prometheus scrapes both endpoints every 15 seconds.

+

Troubleshooting

+

Media API Returns 404

+

Cause: Nginx routing issue (order of location blocks).

+

Fix: Ensure /api/media/ comes BEFORE /api/ in nginx config.

+

Large Upload Fails (413)

+

Cause: client_max_body_size too small.

+

Fix: Increase in nginx config:

+
location /api/media/ {
+    client_max_body_size 20G;  # Increase from default
+}
+
+

Connection Pool Exhausted

+

Cause: Too many concurrent requests, not enough DB connections.

+

Fix: Increase connection limit in DATABASE_URL:

+
DATABASE_URL=postgresql://user:pass@host:5432/db?connection_limit=20
+
+

Or reduce pool size per API instance (if running multiple):

+
// Prisma
+datasource db {
+  url = env("DATABASE_URL")  // Add ?connection_limit=5 for smaller pool
+}
+
+// Drizzle
+const pool = new Pool({ max: 5 });
+
+

JWT Verification Fails Across APIs

+

Cause: Different JWT_ACCESS_SECRET values.

+

Fix: Ensure both APIs use the same secret:

+
# .env
+JWT_ACCESS_SECRET=<same-value-for-both>
+
+

Further Reading

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/architecture/index.html b/mkdocs/site/v2/architecture/index.html new file mode 100644 index 00000000..e136beae --- /dev/null +++ b/mkdocs/site/v2/architecture/index.html @@ -0,0 +1,5683 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + V2 Architecture Overview - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

V2 Architecture Overview

+

Changemaker Lite V2 is built on a modern microservices architecture with a dual API design, React admin interface, and comprehensive observability.

+

System Architecture

+
graph TB
+    subgraph "User Access"
+        Browser[Web Browser]
+        VolunteerApp[Volunteer Mobile]
+    end
+
+    subgraph "Nginx Reverse Proxy"
+        Nginx[Nginx<br/>Subdomain Router]
+    end
+
+    subgraph "Frontend Layer"
+        AdminGUI[Admin GUI<br/>React + Vite + Ant Design<br/>Port 3000]
+        PublicPages[Public Pages<br/>Dark Theme]
+        VolunteerPortal[Volunteer Portal<br/>GPS Canvassing]
+    end
+
+    subgraph "Backend Layer - Dual API"
+        ExpressAPI[Express API<br/>Main Features<br/>Port 4000<br/>Prisma ORM]
+        FastifyAPI[Fastify API<br/>Media Library<br/>Port 4100<br/>Drizzle ORM]
+    end
+
+    subgraph "Data Layer"
+        Postgres[(PostgreSQL 16<br/>27+ Models)]
+        Redis[(Redis<br/>Cache + Queues)]
+    end
+
+    subgraph "Job Processing"
+        EmailQueue[BullMQ<br/>Email Queue]
+        GeocodeQueue[BullMQ<br/>Geocoding Queue]
+    end
+
+    subgraph "External Services"
+        SMTP[SMTP Server<br/>Email Delivery]
+        Represent[Represent API<br/>Canadian Reps]
+        Geocoding[Geocoding Providers<br/>6 Services]
+        Listmonk[Listmonk<br/>Newsletter Platform]
+    end
+
+    subgraph "Observability"
+        Prometheus[Prometheus<br/>Metrics]
+        Grafana[Grafana<br/>Dashboards]
+        Alertmanager[Alertmanager<br/>Notifications]
+    end
+
+    Browser --> Nginx
+    VolunteerApp --> Nginx
+
+    Nginx --> AdminGUI
+    Nginx --> PublicPages
+    Nginx --> VolunteerPortal
+
+    AdminGUI --> ExpressAPI
+    AdminGUI --> FastifyAPI
+    PublicPages --> ExpressAPI
+    VolunteerPortal --> ExpressAPI
+
+    ExpressAPI --> Postgres
+    ExpressAPI --> Redis
+    ExpressAPI --> EmailQueue
+    ExpressAPI --> GeocodeQueue
+    ExpressAPI --> Represent
+    ExpressAPI --> Geocoding
+    ExpressAPI --> Listmonk
+    ExpressAPI --> Prometheus
+
+    FastifyAPI --> Postgres
+    FastifyAPI --> Redis
+    FastifyAPI --> Prometheus
+
+    EmailQueue --> Redis
+    EmailQueue --> SMTP
+    GeocodeQueue --> Redis
+    GeocodeQueue --> Geocoding
+
+    Prometheus --> Grafana
+    Prometheus --> Alertmanager
+

Core Components

+

1. Nginx Reverse Proxy

+

Purpose: Routes HTTP requests to appropriate services based on subdomain

+

Subdomains: +- app.cmlite.org → Admin GUI (React) +- api.cmlite.org → Express API (main features) +- media.cmlite.org → Fastify API (video library) +- db.cmlite.org → NocoDB (data browser) +- docs.cmlite.org → MkDocs (documentation) +- listmonk.cmlite.org → Listmonk (newsletter) +- grafana.cmlite.org → Grafana (monitoring) +- And 8 more service subdomains...

+

Configuration: /nginx/conf.d/

+

Learn more →

+

2. Frontend Layer

+

Admin GUI (Port 3000)

+
    +
  • Framework: React 19 with Vite build tool
  • +
  • UI Library: Ant Design 5 (Table, Form, Modal, Drawer components)
  • +
  • State Management: Zustand stores (auth, canvass)
  • +
  • Routing: React Router v6
  • +
  • HTTP Client: Axios with 401 refresh interceptor
  • +
+

Structure: +- 32 admin pages (campaigns, locations, users, settings, etc.) +- 6 public pages (campaign view, response wall, map, shifts) +- 4 volunteer portal pages (canvassing, assignments, activity) +- Shared components (map, canvass, GrapesJS editor)

+

Learn more →

+

Public Pages

+
    +
  • Dark blue/teal theme (consistent with V1 branding)
  • +
  • No authentication required
  • +
  • Mobile-responsive layouts
  • +
  • Public campaign submission
  • +
  • Response wall with upvoting
  • +
  • Public map with location markers
  • +
  • Shift signup forms
  • +
+

Volunteer Portal

+
    +
  • Top navigation layout
  • +
  • Mobile-optimized (hamburger menu)
  • +
  • GPS-tracked canvassing
  • +
  • Full-screen map interface
  • +
  • Visit recording forms
  • +
  • Activity tracking
  • +
+

3. Backend Layer - Dual API Design

+

Express API (Port 4000)

+

Main application server handling core features:

+

14 Feature Modules: +1. auth - JWT login, register, refresh, logout +2. users - User CRUD with pagination +3. settings - Site settings singleton +4. campaigns - Campaign CRUD + public routes +5. representatives - Represent API integration +6. responses - Response wall + moderation +7. email-queue - BullMQ queue admin +8. campaign-emails - Email tracking + stats +9. postal-codes - Postal code cache +10. locations - Location CRUD + geocoding + NAR import +11. cuts - Cut (polygon) CRUD + spatial queries +12. shifts - Shift CRUD + signups +13. canvass - Volunteer canvassing (sessions, visits, routes) +14. pages - Landing page builder (GrapesJS)

+

Plus: email-templates, listmonk, pangolin, docs, qr, services, observability

+

ORM: Prisma (27+ models)

+

Architecture: +- Layered structure (routes → services → database) +- Zod schema validation +- Role-based access control (RBAC) +- Error handling middleware +- Winston logging

+

Learn more →

+

Fastify API (Port 4100)

+

Specialized microservice for media library:

+

Features: +- Video CRUD (title, duration, orientation, producer) +- Shared media (public gallery categories) +- Lock/unlock system (public visibility control) +- Reaction system (6 standard emojis) +- Job queue monitoring +- Bulk operations

+

ORM: Drizzle (lightweight schema-first)

+

Why Separate?: +- Performance isolation (video ops don't slow main API) +- Different ORM evaluation (Drizzle vs Prisma) +- Independent scaling +- Clear service boundaries

+

Shared Resources: +- Same PostgreSQL database (different schemas) +- Same Redis instance +- Reuses JWT_ACCESS_SECRET for auth

+

Learn more →

+

4. Data Layer

+

PostgreSQL 16

+

Primary database with two ORM schemas:

+

Prisma Schema (27+ models): +- User, RefreshToken (auth) +- Campaign, Representative, Response, CampaignEmail (influence) +- Location, Cut, Shift, ShiftSignup (map) +- CanvassSession, CanvassVisit, TrackingSession, TrackPoint (canvass) +- LandingPage, PageBlock, EmailTemplate (content) +- SiteSettings, MapSettings (config)

+

Drizzle Schema (media tables): +- videos +- shared_media +- reactions +- jobs

+

Indexes: Optimized for common queries (userId, campaignId, cutId, etc.)

+

Learn more →

+

Redis

+

Multi-purpose cache and queue backend:

+
    +
  • Caching: Postal codes (7-day TTL), representatives
  • +
  • Rate Limiting: Per-endpoint limits (Redis-backed)
  • +
  • BullMQ Queues: Email sending, bulk geocoding
  • +
  • Sessions: Future session storage (if needed)
  • +
+

Authentication: Required (REDIS_PASSWORD env var)

+

5. Job Processing

+

BullMQ Queues

+

Async job processing for long-running operations:

+

Email Queue: +- Campaign email sending (SMTP) +- Email verification (double opt-in) +- Confirmation emails (shift signups) +- Retry logic (exponential backoff) +- Rate limiting (avoid spam flagging)

+

Geocoding Queue: +- Bulk address geocoding +- Multi-provider fallback (6 services) +- Rate limit compliance (500 jobs/min) +- Result caching

+

Queue Management: +- Admin routes for pause/resume +- Job status monitoring +- Failed job inspection +- Queue metrics (Prometheus)

+

6. External Services

+

SMTP Server

+

Email delivery for: +- Campaign advocacy emails +- Email verification +- Password reset +- Shift confirmation +- Admin notifications

+

Dev Mode: MailHog captures emails (EMAIL_TEST_MODE=true)

+

Represent API

+

Canadian elected representative lookup: +- Postal code → MPs, MPPs, councillors +- Caching (7-day TTL per postal code) +- Fallback to cached data on API errors

+

Geocoding Providers

+

Multi-provider geocoding with fallback:

+
    +
  1. Nominatim (OpenStreetMap, free)
  2. +
  3. Mapbox (requires API key, best accuracy)
  4. +
  5. ArcGIS (free tier available)
  6. +
  7. Photon (OSM-based, no key required)
  8. +
  9. Google (requires API key, high cost)
  10. +
  11. LocationIQ (requires API key, generous free tier)
  12. +
+

Strategy: Try each provider in order until success

+

Listmonk Newsletter Platform

+

Email marketing integration: +- Sync participants/locations/users → subscriber lists +- Newsletter campaigns (separate from advocacy emails) +- API integration (basic auth) +- Health monitoring

+

7. Observability Stack

+

Prometheus

+

Metrics collection with custom instrumentation:

+

12 Custom Metrics (cm_* prefix): +- cm_api_uptime_seconds - API availability +- cm_email_queue_size - Queue depth +- cm_email_sent_total - Email delivery count +- cm_geocode_success_rate - Geocoding quality +- cm_active_canvass_sessions - Live canvassing +- And 7 more domain-specific metrics...

+

HTTP Metrics: +- http_request_total - Total requests +- http_request_duration_seconds - Latency histogram +- http_request_errors_total - Error count

+

Scrape Targets: +- Express API (:4000/metrics) +- Fastify API (:4100/metrics) +- Redis Exporter +- Node Exporter (host metrics) +- cAdvisor (container metrics)

+

Learn more →

+

Grafana

+

Visualization dashboards:

+
    +
  1. Application Overview - API metrics, queue stats, sessions
  2. +
  3. Infrastructure - Container metrics, host resources, Redis
  4. +
  5. Alerts & SLOs - Error budgets, SLI tracking
  6. +
+

Auto-provisioned: Dashboards in /configs/grafana/

+

Alertmanager

+

Alert routing and notifications:

+

12 Alert Rules: +- High error rate (>5% for 5 minutes) +- Email queue stuck (no jobs processed in 10 minutes) +- Service down (health check fails) +- Database connection pool exhausted +- Redis unavailable +- And 7 more critical conditions...

+

Notification Channels: +- Gotify (self-hosted push notifications) +- Email (SMTP) +- Webhook (custom integrations)

+

Request Lifecycle

+

Example: Public Campaign Email Submission

+
sequenceDiagram
+    participant User as User Browser
+    participant Nginx
+    participant Admin as Admin GUI
+    participant Express as Express API
+    participant DB as PostgreSQL
+    participant Redis
+    participant Queue as BullMQ
+    participant SMTP as SMTP Server
+    participant Rep as Represent API
+
+    User->>Nginx: Visit /campaigns/123
+    Nginx->>Admin: Route to React app
+    Admin->>Express: GET /api/campaigns/123 (public)
+    Express->>DB: Query campaign
+    DB-->>Express: Campaign data
+    Express-->>Admin: Campaign JSON
+    Admin-->>User: Render campaign page
+
+    User->>Admin: Enter postal code + submit
+    Admin->>Express: POST /api/postal-codes (lookup)
+    Express->>Redis: Check cache
+    Redis-->>Express: Cache miss
+    Express->>Rep: GET /representatives/postal-code
+    Rep-->>Express: Representative list
+    Express->>Redis: Cache for 7 days
+    Express-->>Admin: Representatives JSON
+    Admin-->>User: Show rep selection
+
+    User->>Admin: Select rep + write email + submit
+    Admin->>Express: POST /api/responses (create)
+    Express->>DB: Insert response
+    Express->>Queue: Enqueue verification email
+    Express->>DB: Insert campaign email record
+    DB-->>Express: Response created
+    Express-->>Admin: Success response
+    Admin-->>User: Show success message
+
+    Queue->>SMTP: Send verification email
+    SMTP-->>Queue: Delivery confirmed
+
+    User->>User: Click verification link (email)
+    User->>Nginx: GET /verify-response/:token
+    Nginx->>Admin: Route to React app
+    Admin->>Express: POST /api/responses/:id/verify
+    Express->>DB: Update response (verified=true)
+    Express->>Queue: Enqueue campaign email to rep
+    DB-->>Express: Response verified
+    Express-->>Admin: Success
+    Admin-->>User: Email sent confirmation
+
+    Queue->>SMTP: Send campaign email to rep
+    SMTP-->>Queue: Delivery confirmed
+

Technology Decisions

+

Why TypeScript?

+
    +
  • Type safety reduces runtime errors
  • +
  • Better IDE support and autocomplete
  • +
  • Easier refactoring
  • +
  • Self-documenting code
  • +
+

Why Prisma + Drizzle?

+
    +
  • Prisma: Great for complex models, migrations, auto-generated types
  • +
  • Drizzle: Lightweight, perfect for simple media tables
  • +
  • Evaluate both ORMs in production
  • +
+

Why Dual API?

+
    +
  • Separation of concerns: Media ops isolated from core features
  • +
  • Performance: Video processing doesn't block main API
  • +
  • Scalability: Independent horizontal scaling
  • +
  • Technology evaluation: Compare Express vs Fastify
  • +
+

Why JWT over Sessions?

+
    +
  • Stateless (scales horizontally)
  • +
  • No session storage overhead
  • +
  • Works across multiple API servers
  • +
  • Standard claims (iat, exp, sub)
  • +
+

Why BullMQ over Bull?

+
    +
  • Better TypeScript support
  • +
  • Improved performance
  • +
  • Active maintenance
  • +
  • Better documentation
  • +
+

Why PostgreSQL over NoSQL?

+
    +
  • Complex relational data (campaigns, locations, users)
  • +
  • ACID transactions (critical for email queue)
  • +
  • Full-text search
  • +
  • Spatial queries (PostGIS for future geo features)
  • +
+

Deployment Architecture

+

Docker Compose

+

All services orchestrated in docker-compose.yml:

+

Profiles: +- default: Core services (postgres, redis, api, admin, nginx) +- monitoring: Prometheus, Grafana, Alertmanager, exporters

+

Networks: +- changemaker-lite bridge network +- Service discovery via container names

+

Volumes: +- PostgreSQL data persistence +- Redis data persistence +- Uploads directory +- Logs directory

+

Learn more →

+

Nginx Routing

+

Subdomain-based routing:

+
# Admin GUI
+server {
+    server_name app.cmlite.org;
+    location / {
+        proxy_pass http://admin:3000;
+    }
+}
+
+# Express API
+server {
+    server_name api.cmlite.org;
+    location / {
+        proxy_pass http://api:4000;
+    }
+}
+
+# Fastify Media API
+server {
+    server_name media.cmlite.org;
+    location / {
+        proxy_pass http://media-api:4100;
+    }
+}
+
+

Learn more →

+

Security Architecture

+

Authentication Flow

+
sequenceDiagram
+    participant Client
+    participant API as Express API
+    participant DB as PostgreSQL
+    participant Redis
+
+    Client->>API: POST /api/auth/login
+    API->>DB: Verify credentials
+    DB-->>API: User record
+    API->>DB: Create refresh token (expires 7d)
+    API->>Redis: Rate limit check
+    API-->>Client: Access token (15min) + Refresh token (7d)
+
+    Note over Client: Access token expires
+
+    Client->>API: POST /api/auth/refresh
+    API->>DB: Validate refresh token
+    DB-->>API: Token valid
+    API->>DB: Rotate refresh token (transaction)
+    API-->>Client: New access token + New refresh token
+

Features: +- bcrypt password hashing (12+ chars, complexity requirements) +- JWT access tokens (15min expiry) +- Refresh tokens (7 days, stored in DB, rotated on use) +- Rate limiting (10 requests/min on auth endpoints) +- User enumeration prevention (401 not 404) +- RBAC middleware (requireRole, requireNonTemp)

+

Learn more →

+

Security Layers

+
    +
  1. Network: Nginx rate limiting, fail2ban
  2. +
  3. Application: Input validation (Zod schemas), RBAC
  4. +
  5. Data: Encrypted fields (ENCRYPTION_KEY), SQL injection prevention (Prisma)
  6. +
  7. Transport: HTTPS only (production), HSTS headers
  8. +
+

Learn more →

+

Scalability Considerations

+

Horizontal Scaling

+
    +
  • Stateless APIs: JWT auth allows multiple API instances
  • +
  • Redis-backed queues: Share job queues across workers
  • +
  • Database connection pooling: Prisma manages connections
  • +
  • Nginx load balancing: Distribute requests across API instances
  • +
+

Vertical Scaling

+
    +
  • Increase container resources (CPU, memory)
  • +
  • Optimize database queries (indexes, query planning)
  • +
  • Redis memory limits (LRU eviction policy)
  • +
+

Bottlenecks

+
    +
  • PostgreSQL: Single primary (future: read replicas)
  • +
  • Redis: Single instance (future: Redis Cluster)
  • +
  • File uploads: Local disk (future: S3-compatible storage)
  • +
+

Monitoring & Observability

+

Golden Signals

+
    +
  1. Latency: Request duration histograms
  2. +
  3. Traffic: Request rate by endpoint
  4. +
  5. Errors: Error rate (5xx responses)
  6. +
  7. Saturation: Database connections, Redis memory, queue depth
  8. +
+

SLOs (Service Level Objectives)

+
    +
  • Availability: 99.9% uptime (8.76 hours downtime/year)
  • +
  • Latency: p95 < 500ms, p99 < 1000ms
  • +
  • Error Rate: < 0.1% (1 error per 1000 requests)
  • +
+

Alerting Strategy

+
    +
  • Critical: Page on-call (service down, database unavailable)
  • +
  • Warning: Create ticket (queue growing, elevated errors)
  • +
  • Info: Log only (slow query, cache miss)
  • +
+

Learn more →

+

Further Reading

+ +
+

Next: Set up your development environment →

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/backend/index.html b/mkdocs/site/v2/backend/index.html new file mode 100644 index 00000000..6568c9e3 --- /dev/null +++ b/mkdocs/site/v2/backend/index.html @@ -0,0 +1,4734 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Backend Overview - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Backend Overview

+

The Changemaker Lite V2 backend is a dual-API architecture built with TypeScript, providing a robust foundation for campaign management, mapping, and media services.

+

Architecture

+

The backend consists of two complementary API servers:

+
    +
  • Express API (Port 4000) - Main V2 features with Prisma ORM + PostgreSQL
  • +
  • Fastify Media API (Port 4100) - Video library with Drizzle ORM (shared database)
  • +
+

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.

+

Key Components

+

Modules

+

Backend modules provide feature-specific functionality across authentication, campaigns, locations, media, and more. Each module follows a consistent pattern with schemas, services, and routes.

+

Services

+

Shared services provide cross-cutting concerns like email delivery, geocoding, queue management, and external API integrations.

+

Middleware

+

Middleware components handle authentication, authorization, rate limiting, validation, and error handling across all API endpoints.

+

Utilities

+

Utility modules provide common functionality for spatial calculations, logging, metrics collection, and data processing.

+

Technology Stack

+
    +
  • Runtime: Node.js 20+ with TypeScript 5.x
  • +
  • Main Framework: Express.js (TypeScript)
  • +
  • Media Framework: Fastify (TypeScript)
  • +
  • ORMs:
  • +
  • Prisma (main API)
  • +
  • Drizzle (media API)
  • +
  • Database: PostgreSQL 16
  • +
  • Cache/Queue: Redis 7 with BullMQ
  • +
  • Validation: Zod schemas
  • +
  • Authentication: JWT with bcrypt
  • +
+

API Structure

+
api/
+├── src/
+│   ├── server.ts              # Express API entry point (port 4000)
+│   ├── media-server.ts        # Fastify media API (port 4100)
+│   ├── config/
+│   │   └── env.ts             # Environment configuration
+│   ├── middleware/            # Auth, RBAC, validation, rate limiting
+│   ├── modules/               # Feature modules
+│   ├── services/              # Shared services
+│   ├── types/                 # TypeScript definitions
+│   └── utils/                 # Helper utilities
+├── prisma/
+│   ├── schema.prisma          # Main database schema (30+ models)
+│   └── migrations/            # Database migrations
+└── drizzle/                   # Media API schema
+
+ + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/backend/middleware/index.html b/mkdocs/site/v2/backend/middleware/index.html new file mode 100644 index 00000000..996c5d8c --- /dev/null +++ b/mkdocs/site/v2/backend/middleware/index.html @@ -0,0 +1,4962 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Backend Middleware - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Backend Middleware

+

Middleware components provide cross-cutting concerns for authentication, authorization, validation, rate limiting, and error handling across all API endpoints.

+

Middleware Architecture

+

Express middleware functions are composed in the request pipeline to:

+
    +
  • Authenticate users via JWT tokens
  • +
  • Authorize access based on user roles
  • +
  • Validate request bodies against Zod schemas
  • +
  • Apply rate limits to prevent abuse
  • +
  • Handle errors consistently
  • +
  • Log requests and responses
  • +
+

Core Middleware

+

Authentication

+

authenticate (middleware/auth.ts)

+
    +
  • Verifies JWT access tokens from Authorization header
  • +
  • Attaches req.user object with user ID, email, and role
  • +
  • Returns 401 Unauthorized for missing/invalid tokens
  • +
  • Supports both admin and public route protection
  • +
+
router.get('/profile', authenticate, async (req, res) => {
+  // req.user is guaranteed to exist
+  const userId = req.user.id;
+});
+
+

Authorization

+

requireRole (middleware/auth.ts)

+
    +
  • Checks if authenticated user has one of the required roles
  • +
  • Returns 403 Forbidden if role check fails
  • +
  • Supports multiple roles (OR logic)
  • +
+
router.post(
+  '/campaigns',
+  authenticate,
+  requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'),
+  async (req, res) => {
+    // Only admins can create campaigns
+  }
+);
+
+

requireNonTemp (middleware/auth.ts)

+
    +
  • Blocks TEMP users from accessing endpoints
  • +
  • Used for routes that require full user accounts
  • +
  • TEMP users are created during shift signups
  • +
+
router.post(
+  '/shifts/:id/signup',
+  authenticate,
+  requireNonTemp,
+  async (req, res) => {
+    // TEMP users cannot sign up for shifts
+  }
+);
+
+

Validation

+

validate (middleware/validate.ts)

+
    +
  • Validates request body against Zod schemas
  • +
  • Returns 400 Bad Request with sanitized error messages
  • +
  • Supports nested validation and type coercion
  • +
  • Prevents injection attacks by sanitizing output
  • +
+
import { validate } from '../middleware/validate';
+import { createCampaignSchema } from './campaigns.schemas';
+
+router.post(
+  '/campaigns',
+  authenticate,
+  validate(createCampaignSchema),
+  async (req, res) => {
+    // req.body is type-safe and validated
+  }
+);
+
+

Rate Limiting

+

rateLimit (middleware/rate-limit.ts)

+
    +
  • Uses Redis for distributed rate limiting
  • +
  • Configurable window size and max requests
  • +
  • Returns 429 Too Many Requests when limit exceeded
  • +
  • Per-IP tracking with X-RateLimit headers
  • +
+

Common Configurations:

+
// Auth endpoints: 10 requests per minute
+rateLimitRedis({
+  windowMs: 60 * 1000,
+  max: 10,
+  standardHeaders: true,
+  legacyHeaders: false,
+  keyPrefix: 'rl:auth:',
+})
+
+// Canvass visits: 30 requests per minute
+rateLimitRedis({
+  windowMs: 60 * 1000,
+  max: 30,
+  keyPrefix: 'rl:canvass-visit:',
+})
+
+

Error Handling

+

errorHandler (middleware/error-handler.ts)

+
    +
  • Catches all unhandled errors in routes
  • +
  • Formats errors consistently as JSON
  • +
  • Logs errors with Winston
  • +
  • Sanitizes error messages in production
  • +
  • Returns appropriate HTTP status codes
  • +
+

Request Logging

+

requestLogger (middleware/logger.ts)

+
    +
  • Logs all incoming requests with Morgan
  • +
  • Tracks response times
  • +
  • Formats logs with Winston
  • +
  • Separates error logs from access logs
  • +
+

Middleware Composition

+

Middleware is applied in order and can be composed:

+
// Global middleware (applies to all routes)
+app.use(helmet());
+app.use(cors());
+app.use(requestLogger);
+
+// Route-specific middleware
+router.post(
+  '/api/campaigns',
+  rateLimit({ max: 100 }),
+  authenticate,
+  requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'),
+  validate(createCampaignSchema),
+  campaignController.create
+);
+
+// Global error handler (last middleware)
+app.use(errorHandler);
+
+

Security Features

+

Password Policy

+
    +
  • Enforced at schema validation level
  • +
  • 12+ characters required
  • +
  • Must include uppercase, lowercase, and digit
  • +
  • Validated in auth.schemas.ts
  • +
+

User Enumeration Prevention

+
    +
  • Returns 401 instead of 404 for missing users
  • +
  • Consistent response times for invalid credentials
  • +
  • No detailed error messages about which field failed
  • +
+

Refresh Token Rotation

+
    +
  • Atomic transaction to prevent race conditions
  • +
  • Invalidates old refresh token on rotation
  • +
  • Tracks token family for security
  • +
  • Automatic cleanup of expired tokens
  • +
+

Redis Authentication

+
    +
  • All Redis connections require password
  • +
  • Configured via REDIS_PASSWORD environment variable
  • +
  • Prevents unauthorized cache/queue access
  • +
+

Middleware Chain

+

Typical middleware chain for protected routes:

+
    +
  1. CORS - Handle cross-origin requests
  2. +
  3. Helmet - Security headers
  4. +
  5. Request Logger - Log incoming request
  6. +
  7. Body Parser - Parse JSON body
  8. +
  9. Rate Limit - Check rate limits
  10. +
  11. Authenticate - Verify JWT token
  12. +
  13. Authorize - Check user role
  14. +
  15. Validate - Validate request schema
  16. +
  17. Route Handler - Execute business logic
  18. +
  19. Error Handler - Catch and format errors
  20. +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/backend/modules/auth/index.html b/mkdocs/site/v2/backend/modules/auth/index.html new file mode 100644 index 00000000..b89c7b91 --- /dev/null +++ b/mkdocs/site/v2/backend/modules/auth/index.html @@ -0,0 +1,6316 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Auth Module - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Auth Module

+

Overview

+

The Auth module provides JWT-based authentication with access and refresh tokens. It handles user registration, login, token refresh, and logout operations with comprehensive security features including password policy enforcement, rate limiting, and user enumeration prevention.

+

Key Features:

+
    +
  • JWT access tokens (15-minute expiry)
  • +
  • Refresh token rotation with atomic transactions
  • +
  • Password policy enforcement (12+ characters, complexity requirements)
  • +
  • Rate limiting (10 requests/minute per IP)
  • +
  • User enumeration prevention
  • +
  • Account status validation (ACTIVE, SUSPENDED, BANNED)
  • +
  • Temporary user expiration handling
  • +
  • Prometheus metrics integration
  • +
+

File Paths

+ + + + + + + + + + + + + + + + + + + + + +
FilePurpose
api/src/modules/auth/auth.routes.tsExpress router with 5 endpoints
api/src/modules/auth/auth.service.tsAuthentication business logic
api/src/modules/auth/auth.schemas.tsZod validation schemas
+

Database Models

+

User Model

+
model User {
+  id              String         @id @default(cuid())
+  email           String         @unique
+  password        String
+  name            String?
+  phone           String?
+  role            UserRole       @default(USER)
+  status          UserStatus     @default(ACTIVE)
+  permissions     Json?
+  createdVia      String?        @default("web")
+  emailVerified   Boolean        @default(false)
+  expiresAt       DateTime?      // For TEMP users
+  lastLoginAt     DateTime?
+  createdAt       DateTime       @default(now())
+  updatedAt       DateTime       @updatedAt
+  refreshTokens   RefreshToken[]
+}
+
+enum UserRole {
+  SUPER_ADMIN
+  INFLUENCE_ADMIN
+  MAP_ADMIN
+  USER
+  TEMP
+}
+
+enum UserStatus {
+  ACTIVE
+  SUSPENDED
+  BANNED
+}
+
+

RefreshToken Model

+
model RefreshToken {
+  id        String   @id @default(cuid())
+  token     String   @unique
+  userId    String
+  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
+  expiresAt DateTime
+  createdAt DateTime @default(now())
+}
+
+

API Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathAuthRate LimitDescription
POST/api/auth/loginNone10/minAuthenticate user with email/password
POST/api/auth/registerNone10/minCreate new user account
POST/api/auth/refreshNone10/minRefresh access token
POST/api/auth/logoutNone10/minInvalidate refresh token
GET/api/auth/meRequiredNoneGet current user profile
+

Endpoint Details

+

POST /api/auth/login

+

Authenticate user with email and password.

+

Request Body:

+
{
+  "email": "user@example.com",
+  "password": "SecurePass123"
+}
+
+

Response (200 OK):

+
{
+  "user": {
+    "id": "clx1234567890",
+    "email": "user@example.com",
+    "name": "John Doe",
+    "phone": null,
+    "role": "USER",
+    "status": "ACTIVE",
+    "permissions": null,
+    "createdVia": "web",
+    "emailVerified": false,
+    "expiresAt": null,
+    "lastLoginAt": "2026-02-11T12:00:00.000Z",
+    "createdAt": "2026-02-01T12:00:00.000Z",
+    "updatedAt": "2026-02-11T12:00:00.000Z"
+  },
+  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+}
+
+

Error Responses:

+
    +
  • 401 Unauthorized: Invalid email or password (prevents user enumeration)
  • +
  • 403 Forbidden: Account is suspended/banned or expired
  • +
  • 429 Too Many Requests: Rate limit exceeded
  • +
+

Implementation:

+
router.post(
+  '/login',
+  authRateLimit,
+  validate(loginSchema),
+  async (req: Request, res: Response, next: NextFunction) => {
+    try {
+      const result = await authService.login(req.body.email, req.body.password);
+      res.json(result);
+    } catch (err) {
+      next(err);
+    }
+  }
+);
+
+

Security Features:

+
    +
  1. User Enumeration Prevention: Same error message for invalid email or password
  2. +
  3. Account Status Validation: Checks ACTIVE, SUSPENDED, BANNED, expired states
  4. +
  5. Login Metrics: Records success/failure for monitoring
  6. +
  7. Last Login Tracking: Updates lastLoginAt timestamp
  8. +
  9. Password Comparison: Uses bcrypt with 12 salt rounds
  10. +
+
+

POST /api/auth/register

+

Create a new user account. Public endpoint restricted to USER role.

+

Request Body:

+
{
+  "email": "newuser@example.com",
+  "password": "SecurePass123",
+  "name": "Jane Smith",
+  "phone": "+1234567890"
+}
+
+

Response (201 Created):

+
{
+  "user": {
+    "id": "clx0987654321",
+    "email": "newuser@example.com",
+    "name": "Jane Smith",
+    "phone": "+1234567890",
+    "role": "USER",
+    "status": "ACTIVE",
+    "permissions": null,
+    "createdVia": "web",
+    "emailVerified": false,
+    "expiresAt": null,
+    "lastLoginAt": null,
+    "createdAt": "2026-02-11T12:00:00.000Z",
+    "updatedAt": "2026-02-11T12:00:00.000Z"
+  },
+  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+}
+
+

Error Responses:

+
    +
  • 409 Conflict: Email already registered
  • +
  • 400 Bad Request: Password doesn't meet complexity requirements
  • +
  • 429 Too Many Requests: Rate limit exceeded
  • +
+

Password Policy:

+
password: z.string()
+  .min(12, 'Password must be at least 12 characters')
+  .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
+  .regex(/[a-z]/, 'Password must contain at least one lowercase letter')
+  .regex(/[0-9]/, 'Password must contain at least one digit')
+
+

Security Notes:

+
    +
  • Role is always set to USER server-side (not user-controllable)
  • +
  • Password hashed with bcrypt (12 salt rounds)
  • +
  • Immediately issues access + refresh tokens (auto-login after registration)
  • +
+
+

POST /api/auth/refresh

+

Refresh access token using a valid refresh token. Implements token rotation for security.

+

Request Body:

+
{
+  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+}
+
+

Response (200 OK):

+
{
+  "user": {
+    "id": "clx1234567890",
+    "email": "user@example.com",
+    "name": "John Doe",
+    "role": "USER",
+    "status": "ACTIVE",
+    ...
+  },
+  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+}
+
+

Error Responses:

+
    +
  • 401 Unauthorized: Invalid, expired, or not found refresh token
  • +
+

Token Rotation Flow:

+
// Atomic transaction ensures no race condition
+const tokens = await prisma.$transaction(async (tx) => {
+  // 1. Delete old refresh token
+  await tx.refreshToken.delete({ where: { id: stored.id } });
+
+  // 2. Generate new token pair
+  const accessToken = this.generateAccessToken(stored.user);
+  const refreshToken = jwt.sign(refreshPayload, env.JWT_REFRESH_SECRET, {
+    expiresIn: env.JWT_REFRESH_EXPIRY,
+  });
+
+  // 3. Store new refresh token
+  await tx.refreshToken.create({
+    data: {
+      token: refreshToken,
+      userId: stored.user.id,
+      expiresAt: new Date(decoded.exp * 1000),
+    },
+  });
+
+  return { accessToken, refreshToken };
+});
+
+

Security Features:

+
    +
  1. Atomic Rotation: Old token deleted and new token created in single transaction
  2. +
  3. Expiration Check: Validates refresh token hasn't expired
  4. +
  5. Database Validation: Checks token exists in database (prevents replay attacks)
  6. +
  7. Automatic Cleanup: Expired tokens deleted on access attempt
  8. +
+
+

POST /api/auth/logout

+

Invalidate a refresh token.

+

Request Body:

+
{
+  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+}
+
+

Response (200 OK):

+
{
+  "message": "Logged out"
+}
+
+

Implementation:

+
async logout(refreshToken: string) {
+  await prisma.refreshToken.deleteMany({ where: { token: refreshToken } });
+}
+
+

Notes:

+
    +
  • Uses deleteMany (safe if token doesn't exist)
  • +
  • Client should discard access token immediately
  • +
  • Access tokens remain valid until expiry (15 minutes)
  • +
+
+

GET /api/auth/me

+

Get current authenticated user's profile.

+

Request Headers:

+
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+
+

Response (200 OK):

+
{
+  "id": "clx1234567890",
+  "email": "user@example.com",
+  "name": "John Doe",
+  "phone": null,
+  "role": "USER",
+  "status": "ACTIVE",
+  "permissions": null,
+  "createdVia": "web",
+  "emailVerified": false,
+  "lastLoginAt": "2026-02-11T12:00:00.000Z",
+  "createdAt": "2026-02-01T12:00:00.000Z",
+  "updatedAt": "2026-02-11T12:00:00.000Z"
+}
+
+

Error Responses:

+
    +
  • 401 Unauthorized: Missing, invalid, or expired access token
  • +
  • 401 Unauthorized: User not found (prevents user enumeration - same code as invalid token)
  • +
+

Security Note:

+

Returns 401 instead of 404 when user not found to prevent user enumeration.

+

Service Functions

+

authService.login(email, password)

+

Purpose: Authenticate user and generate token pair.

+

Flow:

+
    +
  1. Find user by email
  2. +
  3. Compare password with bcrypt
  4. +
  5. Validate account status (ACTIVE, not expired)
  6. +
  7. Record login metrics
  8. +
  9. Update lastLoginAt timestamp
  10. +
  11. Generate access + refresh token pair
  12. +
  13. Return user (without password) + tokens
  14. +
+

Error Handling:

+
if (!user) {
+  recordLoginAttempt('failure');
+  throw new AppError(401, 'Invalid email or password', 'INVALID_CREDENTIALS');
+}
+
+const valid = await bcrypt.compare(password, user.password);
+if (!valid) {
+  recordLoginAttempt('failure');
+  throw new AppError(401, 'Invalid email or password', 'INVALID_CREDENTIALS');
+}
+
+if (user.status !== UserStatus.ACTIVE) {
+  recordLoginAttempt('failure');
+  throw new AppError(403, `Account is ${user.status.toLowerCase()}`, 'ACCOUNT_INACTIVE');
+}
+
+
+

authService.register(data)

+

Purpose: Create new user account with hashed password.

+

Flow:

+
    +
  1. Check if email already exists
  2. +
  3. Hash password with bcrypt (12 salt rounds)
  4. +
  5. Create user with USER role
  6. +
  7. Generate token pair
  8. +
  9. Return user (without password) + tokens
  10. +
+

Implementation:

+
const hashedPassword = await bcrypt.hash(data.password, 12);
+
+const user = await prisma.user.create({
+  data: {
+    email: data.email,
+    password: hashedPassword,
+    name: data.name,
+    phone: data.phone,
+    role: UserRole.USER, // Always USER for public registration
+  },
+});
+
+
+

authService.refreshTokens(refreshToken)

+

Purpose: Rotate refresh token and issue new access token.

+

Security:

+
    +
  • Atomic transaction (delete old + create new)
  • +
  • Validates token signature with JWT_REFRESH_SECRET
  • +
  • Checks database for token existence
  • +
  • Validates expiration timestamp
  • +
  • Prevents replay attacks
  • +
+
+

authService.generateAccessToken(user)

+

Purpose: Create short-lived JWT for API authentication.

+

Token Payload:

+
interface TokenPayload {
+  id: string;
+  email: string;
+  role: UserRole;
+}
+
+

Configuration:

+
    +
  • Secret: JWT_ACCESS_SECRET environment variable
  • +
  • Expiry: JWT_ACCESS_EXPIRY (default: 15m)
  • +
  • Algorithm: HS256 (HMAC with SHA-256)
  • +
+

Usage:

+
const accessToken = authService.generateAccessToken(user);
+// Returns: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+
+
+

authService.generateRefreshToken(user)

+

Purpose: Create long-lived JWT and store in database.

+

Configuration:

+
    +
  • Secret: JWT_REFRESH_SECRET (must differ from access secret)
  • +
  • Expiry: JWT_REFRESH_EXPIRY (default: 7d)
  • +
  • Storage: Database (RefreshToken table)
  • +
+

Implementation:

+
const token = jwt.sign(payload, env.JWT_REFRESH_SECRET, {
+  expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'],
+});
+
+const decoded = jwt.decode(token) as { exp: number };
+const expiresAt = new Date(decoded.exp * 1000);
+
+await prisma.refreshToken.create({
+  data: {
+    token,
+    userId: user.id,
+    expiresAt,
+  },
+});
+
+

Code Examples

+

Complete Login Flow

+
// Client: Login request
+const response = await axios.post('/api/auth/login', {
+  email: 'user@example.com',
+  password: 'SecurePass123'
+});
+
+const { user, accessToken, refreshToken } = response.data;
+
+// Store tokens
+localStorage.setItem('accessToken', accessToken);
+localStorage.setItem('refreshToken', refreshToken);
+
+// Use access token for API requests
+axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
+
+

Token Refresh Flow

+
// Client: 401 interceptor for automatic token refresh
+axios.interceptors.response.use(
+  response => response,
+  async (error) => {
+    if (error.response?.status === 401 && !error.config._retry) {
+      error.config._retry = true;
+
+      const refreshToken = localStorage.getItem('refreshToken');
+      if (!refreshToken) {
+        // Redirect to login
+        window.location.href = '/login';
+        return Promise.reject(error);
+      }
+
+      try {
+        const { data } = await axios.post('/api/auth/refresh', { refreshToken });
+
+        // Update stored tokens
+        localStorage.setItem('accessToken', data.accessToken);
+        localStorage.setItem('refreshToken', data.refreshToken);
+
+        // Retry original request with new token
+        error.config.headers['Authorization'] = `Bearer ${data.accessToken}`;
+        return axios(error.config);
+      } catch (refreshError) {
+        // Refresh failed, redirect to login
+        localStorage.removeItem('accessToken');
+        localStorage.removeItem('refreshToken');
+        window.location.href = '/login';
+        return Promise.reject(refreshError);
+      }
+    }
+
+    return Promise.reject(error);
+  }
+);
+
+

Protected Route Middleware

+
// Server: Protect routes with authentication
+import { authenticate } from '../../middleware/auth.middleware';
+
+router.get('/protected', authenticate, async (req, res) => {
+  // req.user is populated by authenticate middleware
+  const userId = req.user!.id;
+  const userRole = req.user!.role;
+
+  res.json({ message: 'Authenticated!', userId, userRole });
+});
+
+

Role-Based Access Control

+
import { requireRole } from '../../middleware/rbac.middleware';
+import { UserRole } from '@prisma/client';
+
+// Only SUPER_ADMIN can access
+router.delete('/users/:id',
+  authenticate,
+  requireRole(UserRole.SUPER_ADMIN),
+  async (req, res) => {
+    // Delete user logic
+  }
+);
+
+// Multiple roles allowed
+router.post('/campaigns',
+  authenticate,
+  requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN),
+  async (req, res) => {
+    // Create campaign logic
+  }
+);
+
+

Environment Configuration

+

Required environment variables:

+
# JWT Access Token (15 minutes)
+JWT_ACCESS_SECRET=<random-32-byte-hex>
+JWT_ACCESS_EXPIRY=15m
+
+# JWT Refresh Token (7 days)
+JWT_REFRESH_SECRET=<different-random-32-byte-hex>
+JWT_REFRESH_EXPIRY=7d
+
+# Database
+DATABASE_URL=postgresql://user:password@localhost:5432/changemaker_v2
+
+# Redis (for rate limiting)
+REDIS_URL=redis://:password@localhost:6379
+REDIS_PASSWORD=<redis-password>
+
+

Generate secrets:

+
# Generate random secrets (macOS/Linux)
+openssl rand -hex 32  # For JWT_ACCESS_SECRET
+openssl rand -hex 32  # For JWT_REFRESH_SECRET (must differ!)
+
+

Security Considerations

+

Password Policy

+
    +
  • Minimum length: 12 characters
  • +
  • Complexity: Uppercase, lowercase, digit required
  • +
  • Hashing: bcrypt with 12 salt rounds
  • +
  • Enforcement: Schema-level validation (cannot be bypassed)
  • +
+

Rate Limiting

+
// 10 requests per minute per IP
+export const authRateLimit = rateLimit({
+  windowMs: 60 * 1000,
+  max: 10,
+  message: 'Too many requests, please try again later',
+  standardHeaders: true,
+  legacyHeaders: false,
+  keyGenerator: (req) => req.ip,
+  store: new RedisStore({
+    client: redis,
+    prefix: 'rl:auth:',
+  }),
+});
+
+

User Enumeration Prevention

+
    +
  • Login/register errors don't reveal if email exists
  • +
  • /api/auth/me returns 401 (not 404) when user not found
  • +
  • Consistent error messages and response times
  • +
+

Token Security

+
    +
  • Access tokens: Short-lived (15 min), stored in memory
  • +
  • Refresh tokens: Long-lived (7 days), stored in database + httpOnly cookie
  • +
  • Rotation: Refresh tokens rotated on each use (atomic transaction)
  • +
  • Secrets: Access and refresh use different secrets (prevents cross-contamination)
  • +
  • Expiration: Automatic cleanup of expired tokens
  • +
+

Database Security

+
    +
  • Passwords never returned in API responses (excluded via select or destructuring)
  • +
  • Refresh tokens cascade deleted when user deleted
  • +
  • Unique constraint on email prevents duplicates
  • +
  • Foreign key constraints ensure referential integrity
  • +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/backend/modules/campaigns/index.html b/mkdocs/site/v2/backend/modules/campaigns/index.html new file mode 100644 index 00000000..b38c25d4 --- /dev/null +++ b/mkdocs/site/v2/backend/modules/campaigns/index.html @@ -0,0 +1,6210 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Campaigns Module - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Campaigns Module

+

Overview

+

The Campaigns module manages advocacy email campaigns targeting elected representatives. It provides comprehensive CRUD operations with rich feature flags, automatic slug generation, and role-based visibility controls. Campaigns integrate with the representative lookup system, email sending queue, and public response wall.

+

Key Features:

+
    +
  • Full CRUD with pagination, search, and status filtering
  • +
  • Auto-generated slugs from campaign titles (collision-safe)
  • +
  • Feature flags (SMTP email, mailto links, response wall, highlighting, etc.)
  • +
  • Government level targeting (Federal, Provincial, Municipal, School Board)
  • +
  • Email count and call count tracking
  • +
  • Public vs admin visibility (non-admins see only their own campaigns)
  • +
  • Integration with email queue, representatives, and responses modules
  • +
  • Cover photo support (URL-based)
  • +
+

File Paths

+ + + + + + + + + + + + + + + + + + + + + + + + + +
FilePurpose
api/src/modules/influence/campaigns/campaigns.routes.tsAdmin router with 5 CRUD endpoints
api/src/modules/influence/campaigns/campaigns-public.routes.tsPublic router (2 endpoints, no auth)
api/src/modules/influence/campaigns/campaigns.service.tsCampaign business logic
api/src/modules/influence/campaigns/campaigns.schemas.tsZod validation schemas
+

Database Model

+
model Campaign {
+  id                      String              @id @default(cuid())
+  slug                    String              @unique
+  title                   String
+  description             String?
+  emailSubject            String
+  emailBody               String
+  callToAction            String?
+  coverPhoto              String?
+  status                  CampaignStatus      @default(DRAFT)
+  targetGovernmentLevels  GovernmentLevel[]
+
+  // Feature flags
+  allowSmtpEmail          Boolean             @default(true)
+  allowMailtoLink         Boolean             @default(true)
+  collectUserInfo         Boolean             @default(true)
+  showEmailCount          Boolean             @default(true)
+  showCallCount           Boolean             @default(true)
+  allowEmailEditing       Boolean             @default(false)
+  allowCustomRecipients   Boolean             @default(false)
+  showResponseWall        Boolean             @default(false)
+  highlightCampaign       Boolean             @default(false)
+
+  // Creator tracking
+  createdByUserId         String
+  createdByUserEmail      String
+  createdByUserName       String?
+
+  // Relations
+  emails                  CampaignEmail[]
+  responses               Response[]
+  customRecipients        CustomRecipient[]
+
+  createdAt               DateTime            @default(now())
+  updatedAt               DateTime            @updatedAt
+
+  @@index([status])
+  @@index([createdByUserId])
+}
+
+enum CampaignStatus {
+  DRAFT      // Not visible to public
+  ACTIVE     // Live on public site
+  PAUSED     // Temporarily hidden
+  ARCHIVED   // Completed/historical
+}
+
+enum GovernmentLevel {
+  FEDERAL        // MPs, Prime Minister
+  PROVINCIAL     // MPPs, MLAs, Premier
+  MUNICIPAL      // Councillors, Mayor
+  SCHOOL_BOARD   // School board trustees
+}
+
+

API Endpoints

+

Admin Endpoints (Authentication Required)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathAuthDescription
GET/api/campaignsAdmin rolesList campaigns with pagination/filters
GET/api/campaigns/:idAdmin rolesGet single campaign by ID
POST/api/campaignsAdmin rolesCreate new campaign
PUT/api/campaigns/:idAdmin rolesUpdate campaign
DELETE/api/campaigns/:idAdmin rolesDelete campaign
+

Admin Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

+

Public Endpoints (No Authentication)

+ + + + + + + + + + + + + + + + + + + + + + + +
MethodPathAuthDescription
GET/api/public/campaignsNoneList active/highlighted campaigns
GET/api/public/campaigns/:slugNoneGet campaign by slug
+

Admin Endpoint Details

+

GET /api/campaigns

+

List campaigns with pagination, search, and filtering. Non-admin users see only their own campaigns.

+

Query Parameters:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDefaultDescription
pagenumberNo1Page number
limitnumberNo20Results per page (max 100)
searchstringNo-Search title or description
statusCampaignStatusNo-Filter by status
+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/campaigns?page=1&limit=10&search=climate&status=ACTIVE"
+
+

Response (200 OK):

+
{
+  "campaigns": [
+    {
+      "id": "clx1234567890",
+      "slug": "climate-action-now",
+      "title": "Climate Action Now",
+      "description": "Demand bold climate policies from your representatives",
+      "emailSubject": "Pass the Climate Emergency Bill",
+      "emailBody": "Dear [Representative Name],\n\n...",
+      "callToAction": "Send your email now!",
+      "coverPhoto": "https://example.com/climate.jpg",
+      "status": "ACTIVE",
+      "targetGovernmentLevels": ["FEDERAL", "PROVINCIAL"],
+      "allowSmtpEmail": true,
+      "allowMailtoLink": true,
+      "collectUserInfo": true,
+      "showEmailCount": true,
+      "showCallCount": false,
+      "allowEmailEditing": false,
+      "allowCustomRecipients": false,
+      "showResponseWall": true,
+      "highlightCampaign": true,
+      "createdByUserId": "clx0987654321",
+      "createdByUserEmail": "admin@example.com",
+      "createdByUserName": "Admin User",
+      "createdAt": "2026-02-01T12:00:00.000Z",
+      "updatedAt": "2026-02-11T12:00:00.000Z",
+      "_count": {
+        "emails": 342,
+        "responses": 89
+      }
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 10,
+    "total": 15,
+    "totalPages": 2
+  }
+}
+
+

Visibility Rules:

+
// Non-admin users only see their own campaigns
+const adminRoles: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
+if (user && !adminRoles.includes(user.role)) {
+  where.createdByUserId = user.id;
+}
+
+
+

POST /api/campaigns

+

Create new campaign with auto-generated slug.

+

Request Body:

+
{
+  "title": "Climate Action Now",
+  "description": "Demand bold climate policies",
+  "emailSubject": "Pass the Climate Emergency Bill",
+  "emailBody": "Dear [Representative Name],\n\nI urge you to...",
+  "callToAction": "Send your email now!",
+  "coverPhoto": "https://example.com/climate.jpg",
+  "status": "DRAFT",
+  "targetGovernmentLevels": ["FEDERAL", "PROVINCIAL"],
+  "allowSmtpEmail": true,
+  "allowMailtoLink": true,
+  "showResponseWall": true,
+  "highlightCampaign": true
+}
+
+

Response (201 Created):

+

Returns created campaign object (same format as GET).

+

Slug Generation:

+
function generateSlug(title: string): string {
+  return title
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, '-')  // Replace non-alphanumeric with -
+    .replace(/^-+|-+$/g, '')      // Remove leading/trailing -
+    .slice(0, 80);                // Max 80 chars
+}
+
+// Collision detection
+async function resolveSlugCollision(slug: string, excludeId?: string): Promise<string> {
+  let candidate = slug;
+  let suffix = 2;
+
+  while (true) {
+    const existing = await prisma.campaign.findUnique({ where: { slug: candidate } });
+    if (!existing || (excludeId && existing.id === excludeId)) {
+      return candidate;
+    }
+    candidate = `${slug}-${suffix}`;  // climate-action-now-2
+    suffix++;
+  }
+}
+
+

Example Slug Transformations:

+
    +
  • "Climate Action NOW!"climate-action-now
  • +
  • "Email Your MP: Support Bill C-12"email-your-mp-support-bill-c-12
  • +
  • "Climate Action Now" (2nd with same title) → climate-action-now-2
  • +
+
+

PUT /api/campaigns/:id

+

Update campaign. Partial updates supported. Slug regenerated if title changes.

+

Request Body (Partial):

+
{
+  "status": "ACTIVE",
+  "highlightCampaign": true,
+  "showResponseWall": true
+}
+
+

Response (200 OK):

+

Returns updated campaign object.

+
+

DELETE /api/campaigns/:id

+

Delete campaign and cascade to related records.

+

Response (204 No Content):

+

No response body.

+

Cascading Deletes:

+
    +
  • Campaign emails (all email send records)
  • +
  • Responses (all user responses)
  • +
  • Custom recipients
  • +
+
+

Public Endpoint Details

+

GET /api/public/campaigns

+

List active and highlighted campaigns (no auth required).

+

Query Parameters:

+ + + + + + + + + + + + + + + + + + + + +
ParameterTypeDescription
highlightedbooleanFilter to highlighted campaigns only
limitnumberResults per page (max 50, default 20)
+

Example Request:

+
curl http://api.cmlite.org/api/public/campaigns?highlighted=true&limit=10
+
+

Response (200 OK):

+
{
+  "campaigns": [
+    {
+      "id": "clx1234567890",
+      "slug": "climate-action-now",
+      "title": "Climate Action Now",
+      "description": "Demand bold climate policies",
+      "callToAction": "Send your email now!",
+      "coverPhoto": "https://example.com/climate.jpg",
+      "status": "ACTIVE",
+      "highlightCampaign": true,
+      "showEmailCount": true,
+      "showCallCount": false,
+      "_count": {
+        "emails": 342,
+        "responses": 89
+      }
+    }
+  ]
+}
+
+

Filtering:

+
const where: Prisma.CampaignWhereInput = {
+  status: CampaignStatus.ACTIVE,  // Only active campaigns
+};
+
+if (highlighted === 'true') {
+  where.highlightCampaign = true;
+}
+
+
+

GET /api/public/campaigns/:slug

+

Get campaign by slug (no auth required).

+

Path Parameters:

+
    +
  • slug (string): Campaign slug
  • +
+

Example Request:

+
curl http://api.cmlite.org/api/public/campaigns/climate-action-now
+
+

Response (200 OK):

+

Returns full campaign object (same as admin GET).

+

Error Responses:

+
    +
  • 404 Not Found: Campaign not found or not ACTIVE
  • +
+
+

Service Functions

+

campaignsService.findAll(filters, user)

+

List campaigns with role-based visibility.

+

Visibility Logic:

+
// Admin users see all campaigns
+// Non-admin users see only their own campaigns
+const adminRoles: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
+if (user && !adminRoles.includes(user.role)) {
+  where.createdByUserId = user.id;
+}
+
+
+

campaignsService.create(data, user)

+

Create campaign with auto-generated slug and creator tracking.

+

Creator Tracking:

+
const campaign = await prisma.campaign.create({
+  data: {
+    ...data,
+    slug: await resolveSlugCollision(generateSlug(data.title)),
+    createdByUserId: user.id,
+    createdByUserEmail: user.email,
+    createdByUserName: user.name || null,
+  },
+  select: campaignSelect,
+});
+
+
+

campaignsService.update(id, data)

+

Update campaign. Regenerates slug if title changes.

+

Slug Regeneration:

+
if (data.title) {
+  const newSlug = generateSlug(data.title);
+  updateData.slug = await resolveSlugCollision(newSlug, id);
+}
+
+
+

Validation Schemas

+

Create Campaign Schema

+
export const createCampaignSchema = z.object({
+  title: z.string().min(1, 'Title is required'),
+  description: z.string().optional(),
+  emailSubject: z.string().min(1, 'Email subject is required'),
+  emailBody: z.string().min(1, 'Email body is required'),
+  callToAction: z.string().optional(),
+  status: z.nativeEnum(CampaignStatus).optional().default(CampaignStatus.DRAFT),
+  targetGovernmentLevels: z.array(z.nativeEnum(GovernmentLevel)).optional().default([]),
+  allowSmtpEmail: z.boolean().optional().default(true),
+  allowMailtoLink: z.boolean().optional().default(true),
+  collectUserInfo: z.boolean().optional().default(true),
+  showEmailCount: z.boolean().optional().default(true),
+  showCallCount: z.boolean().optional().default(true),
+  allowEmailEditing: z.boolean().optional().default(false),
+  allowCustomRecipients: z.boolean().optional().default(false),
+  showResponseWall: z.boolean().optional().default(false),
+  highlightCampaign: z.boolean().optional().default(false),
+  coverPhoto: z.string().optional(),
+});
+
+

Feature Flags

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FlagDefaultDescription
allowSmtpEmailtrueEnable direct SMTP email sending via queue
allowMailtoLinktrueShow mailto: link option (opens default email client)
collectUserInfotrueCollect sender name, email, postal code
showEmailCounttrueDisplay email send count on public page
showCallCounttrueDisplay call count (future feature)
allowEmailEditingfalseLet users edit email template before sending
allowCustomRecipientsfalseAllow manual recipient selection (overrides postal code lookup)
showResponseWallfalseEnable public response submission + display
highlightCampaignfalseFeatured campaign (shown on homepage)
+

Code Examples

+

Admin: Create Campaign

+
import { api } from '@/lib/api';
+
+const createCampaign = async () => {
+  const { data } = await api.post('/api/campaigns', {
+    title: 'Climate Action Now',
+    emailSubject: 'Pass the Climate Emergency Bill',
+    emailBody: 'Dear [Representative Name],\n\nI urge you to support immediate climate action...',
+    targetGovernmentLevels: ['FEDERAL', 'PROVINCIAL'],
+    status: 'DRAFT',
+    showResponseWall: true,
+    highlightCampaign: true,
+  });
+
+  console.log(`Campaign created: ${data.slug}`);
+  return data;
+};
+
+

Public: List Active Campaigns

+
import axios from 'axios';
+
+const fetchActiveCampaigns = async () => {
+  const { data } = await axios.get('/api/public/campaigns?highlighted=true');
+  return data.campaigns;
+};
+
+

Admin: Update Campaign Status

+
import { api } from '@/lib/api';
+
+const publishCampaign = async (id: string) => {
+  const { data } = await api.put(`/api/campaigns/${id}`, {
+    status: 'ACTIVE',
+  });
+
+  message.success('Campaign published!');
+  return data;
+};
+
+

Frontend Integration

+

The CampaignsPage component (admin/src/pages/CampaignsPage.tsx) provides:

+
    +
  • Paginated table with search and status filter
  • +
  • Feature flag badges (SMTP, Response Wall, Highlighted, etc.)
  • +
  • Create campaign modal with rich text editor (TinyMCE/Quill)
  • +
  • Edit campaign modal (pre-populated form)
  • +
  • Delete confirmation modal
  • +
  • Email count drawer (shows campaign email stats)
  • +
  • Publish/archive actions (status toggle)
  • +
+

State Management:

+
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
+const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
+const [filters, setFilters] = useState({ search: '', status: null });
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/backend/modules/canvass/index.html b/mkdocs/site/v2/backend/modules/canvass/index.html new file mode 100644 index 00000000..5efe94ce --- /dev/null +++ b/mkdocs/site/v2/backend/modules/canvass/index.html @@ -0,0 +1,7098 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Canvass Module - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Canvass Module

+

Overview

+

The Canvass module powers the volunteer canvassing system, enabling door-to-door outreach with GPS tracking, visit recording, walking route optimization, and real-time progress monitoring. It features role-based permissions, automated session management, and comprehensive analytics for campaign organizers.

+

Key Features:

+
    +
  • Canvass session management (start, end, abandon detection)
  • +
  • Visit recording with outcomes (CONTACTED, SUPPORTER, NOT_HOME, REFUSED, etc.)
  • +
  • Bulk visit recording (mark entire building as NOT_HOME)
  • +
  • Walking route optimization (nearest-neighbor algorithm)
  • +
  • GPS-enabled location tracking
  • +
  • Role-gated field editing (volunteers update support data, admins update PII)
  • +
  • Real-time cut completion percentage calculation
  • +
  • Admin dashboard (stats, activity feed, volunteer leaderboard)
  • +
  • Shift-based assignments (volunteers assigned to cuts via shifts)
  • +
  • Rate limiting (30 visits/min per IP, 10 bulk visits/min)
  • +
  • Abandoned session cleanup (ACTIVE > 12h → ABANDONED)
  • +
+

File Paths

+ + + + + + + + + + + + + + + + + + + + + + + + + +
FilePurpose
api/src/modules/map/canvass/canvass.routes.ts2 routers (volunteer + admin) with 22 endpoints
api/src/modules/map/canvass/canvass.service.tsCanvass business logic + session management
api/src/modules/map/canvass/canvass.schemas.tsZod validation schemas
api/src/modules/map/canvass/canvass-route.service.tsWalking route optimization algorithm
+

Database Models

+

CanvassSession

+
model CanvassSession {
+  id             String                @id @default(cuid())
+  userId         String
+  user           User                  @relation(fields: [userId], references: [id], onDelete: Cascade)
+  cutId          String
+  cut            Cut                   @relation(fields: [cutId], references: [id], onDelete: Cascade)
+  shiftId        String?
+  shift          Shift?                @relation(fields: [shiftId], references: [id], onDelete: SetNull)
+  status         CanvassSessionStatus  @default(ACTIVE)
+  startLatitude  Float?
+  startLongitude Float?
+  startedAt      DateTime              @default(now())
+  endedAt        DateTime?
+  visits         CanvassVisit[]
+
+  @@index([userId])
+  @@index([cutId])
+  @@index([status])
+  @@map("canvass_sessions")
+}
+
+enum CanvassSessionStatus {
+  ACTIVE     // Currently canvassing
+  COMPLETED  // Ended by volunteer
+  ABANDONED  // Auto-closed after 12h
+}
+
+

CanvassVisit

+
model CanvassVisit {
+  id              String         @id @default(cuid())
+  addressId       String         // Changed from locationId to support multi-unit buildings
+  address         Address        @relation(fields: [addressId], references: [id], onDelete: Cascade)
+  userId          String
+  user            User           @relation(fields: [userId], references: [id], onDelete: Cascade)
+  sessionId       String?
+  session         CanvassSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
+  shiftId         String?
+  shift           Shift?         @relation(fields: [shiftId], references: [id], onDelete: SetNull)
+  outcome         VisitOutcome
+  supportLevel    SupportLevel?
+  signRequested   Boolean        @default(false)
+  signSize        String?
+  notes           String?        @db.Text
+  durationSeconds Int?
+  visitedAt       DateTime       @default(now())
+
+  @@index([addressId])
+  @@index([userId])
+  @@index([sessionId])
+  @@index([outcome])
+  @@map("canvass_visits")
+}
+
+enum VisitOutcome {
+  CONTACTED      // Successful conversation
+  SUPPORTER      // Supporter identified
+  NOT_HOME       // No answer
+  REFUSED        // Declined conversation
+  MOVED          // No longer at address
+  WRONG_ADDRESS  // Address doesn't exist
+  CALLBACK       // Requested follow-up
+  INACCESSIBLE   // Cannot access (locked building, no entry)
+}
+
+

Address Model (Multi-Unit Support)

+
model Address {
+  id           String        @id @default(cuid())
+  locationId   String
+  location     Location      @relation(fields: [locationId], references: [id], onDelete: Cascade)
+  unitNumber   String?
+  firstName    String?
+  lastName     String?
+  email        String?
+  phone        String?
+  supportLevel SupportLevel?
+  sign         Boolean       @default(false)
+  signSize     String?
+  notes        String?       @db.Text
+  visits       CanvassVisit[]
+
+  @@index([locationId])
+  @@map("addresses")
+}
+
+

Multi-Unit Building Support:

+
    +
  • Location — Physical building (lat/lng, address, buildingNotes)
  • +
  • Address — Individual unit within building (unitNumber, firstName, lastName, supportLevel, etc.)
  • +
  • CanvassVisit — Links to Address (not Location) for per-unit tracking
  • +
+
+

API Endpoints

+

Volunteer Endpoints (Authentication Required, Any Role)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathDescription
GET/api/map/canvass/my/assignmentsGet assigned shifts with cuts
GET/api/map/canvass/my/statsGet volunteer statistics
GET/api/map/canvass/my/visitsList my visit history (paginated)
GET/api/map/canvass/my/sessionGet active canvass session
POST/api/map/canvass/sessionsStart new canvass session
POST/api/map/canvass/sessions/:id/endEnd canvass session
GET/api/map/canvass/cuts/:cutId/locationsGet locations in cut for canvassing
GET/api/map/canvass/cuts/:cutId/routeGet optimized walking route
GET/api/map/canvass/locationsGet all locations with visit annotations
PUT/api/map/canvass/locations/:idUpdate location (role-gated fields)
POST/api/map/canvass/locationsCreate location (role-gated fields)
POST/api/map/canvass/reverse-geocodeReverse geocode lat/lng
POST/api/map/canvass/geocode-searchGeocode address for map search
POST/api/map/canvass/visitsRecord visit (rate-limited: 30/min)
POST/api/map/canvass/visits/bulkBulk record visits for building (rate-limited: 10/min)
+

Admin Endpoints (Authentication Required, MAP_ADMIN Roles)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathDescription
GET/api/map/canvass/statsGet admin statistics
GET/api/map/canvass/stats/cuts/:cutIdGet cut-specific statistics
GET/api/map/canvass/activityGet recent activity feed (paginated)
GET/api/map/canvass/volunteersList volunteers with visit counts
GET/api/map/canvass/volunteers/:userIdGet volunteer statistics
GET/api/map/canvass/visitsList all visits (paginated, filtered)
+

Admin Roles: SUPER_ADMIN, MAP_ADMIN

+
+

Volunteer Endpoint Details

+

POST /api/map/canvass/sessions

+

Start new canvass session for a cut.

+

Request Body:

+
{
+  "cutId": "clxCut123",
+  "shiftId": "clxShift456",
+  "startLatitude": 43.6532,
+  "startLongitude": -79.3832
+}
+
+

Response (201 Created):

+
{
+  "id": "clxSession789",
+  "userId": "clxUser123",
+  "cutId": "clxCut123",
+  "shiftId": "clxShift456",
+  "status": "ACTIVE",
+  "startLatitude": 43.6532,
+  "startLongitude": -79.3832,
+  "startedAt": "2026-02-11T14:00:00.000Z",
+  "endedAt": null,
+  "cut": {
+    "id": "clxCut123",
+    "name": "Downtown Ward 5"
+  },
+  "shift": {
+    "id": "clxShift456",
+    "title": "Saturday Canvass"
+  }
+}
+
+

Validation:

+
    +
  • Only one active session per user allowed
  • +
  • Cut must exist
  • +
  • Shift is optional (can canvass outside scheduled shifts)
  • +
+

Error Responses:

+
    +
  • 409 Conflict: User already has active session
  • +
  • 404 Not Found: Cut not found
  • +
+
+

POST /api/map/canvass/sessions/:id/end

+

End active canvass session.

+

Path Parameters:

+
    +
  • id (string): Session ID
  • +
+

Response (200 OK):

+

Returns updated session with status: COMPLETED and endedAt timestamp.

+

Post-Processing:

+
    +
  • Recalculates cut completion percentage
  • +
  • Updates Prometheus metrics (active sessions gauge)
  • +
+

Validation:

+
    +
  • Session must belong to authenticated user
  • +
  • Session must be ACTIVE (not already completed/abandoned)
  • +
+
+

GET /api/map/canvass/my/assignments

+

Get volunteer's assigned shifts with associated cuts.

+

Example Response (200 OK):

+
[
+  {
+    "shiftId": "clxShift456",
+    "shiftTitle": "Saturday Canvass",
+    "shiftDate": "2026-02-15",
+    "startTime": "10:00",
+    "endTime": "14:00",
+    "location": "Community Center, 123 Main St",
+    "cutId": "clxCut123",
+    "cutName": "Downtown Ward 5",
+    "completionPercentage": 42
+  }
+]
+
+

Filtering:

+
    +
  • Only returns confirmed signups (status: CONFIRMED)
  • +
  • Only returns shifts with associated cuts (cutId not null)
  • +
  • Ordered by shift date ascending (upcoming shifts first)
  • +
+
+

GET /api/map/canvass/cuts/:cutId/locations

+

Get locations within cut for canvassing with visit annotations.

+

Path Parameters:

+
    +
  • cutId (string): Cut ID
  • +
+

Query Parameters:

+
    +
  • minLat, maxLat, minLng, maxLng (optional): Bounding box for visible map area
  • +
+

Example Response (200 OK):

+
[
+  {
+    "id": "clxAddress123",
+    "unitNumber": "Apt 4",
+    "firstName": "John",
+    "lastName": "Doe",
+    "email": "john@example.com",
+    "phone": "416-555-1234",
+    "supportLevel": "LEVEL_1",
+    "sign": true,
+    "signSize": "Large",
+    "notes": "Willing to volunteer",
+    "location": {
+      "id": "clxLocation456",
+      "latitude": 43.6532,
+      "longitude": -79.3832,
+      "address": "123 Main St, Toronto, ON",
+      "buildingNotes": "Intercom code: 1234"
+    },
+    "lastVisit": {
+      "outcome": "CONTACTED",
+      "visitedAt": "2026-02-10T14:30:00.000Z",
+      "visitorName": "Jane Smith",
+      "isMyVisit": false
+    }
+  }
+]
+
+

Two-Stage Filtering:

+
    +
  1. Database bounds filter — Fast WHERE clause on lat/lng
  2. +
  3. Polygon filter — In-memory point-in-polygon check
  4. +
+

Visit Annotations:

+
    +
  • lastVisit — Most recent visit to this address (any volunteer)
  • +
  • isMyVisit — True if authenticated user made last visit
  • +
  • Null if address never visited
  • +
+
+

GET /api/map/canvass/cuts/:cutId/route

+

Get optimized walking route for cut.

+

Path Parameters:

+
    +
  • cutId (string): Cut ID
  • +
+

Query Parameters:

+
    +
  • excludeVisited (boolean, default: false): Exclude already-visited addresses
  • +
  • startLatitude (number, optional): Starting position latitude
  • +
  • startLongitude (number, optional): Starting position longitude
  • +
+

Example Response (200 OK):

+
{
+  "route": [
+    {
+      "id": "clxAddress123",
+      "latitude": 43.6532,
+      "longitude": -79.3832,
+      "address": "123 Main St",
+      "unitNumber": "Apt 4",
+      "distanceFromPrevious": 0
+    },
+    {
+      "id": "clxAddress124",
+      "latitude": 43.6540,
+      "longitude": -79.3825,
+      "address": "125 Main St",
+      "unitNumber": null,
+      "distanceFromPrevious": 92.3
+    }
+  ],
+  "totalDistance": 1847.6,
+  "estimatedDuration": 1680
+}
+
+

Walking Route Algorithm:

+

Nearest-neighbor greedy algorithm:

+
// Start at provided coordinates or first location
+let current = startCoords || locations[0];
+const route: RouteStop[] = [];
+
+while (unvisited.length > 0) {
+  // Find nearest unvisited location
+  const nearest = findNearest(current, unvisited);
+  const distance = haversineDistance(current, nearest);
+
+  route.push({
+    ...nearest,
+    distanceFromPrevious: distance,
+  });
+
+  current = nearest;
+  unvisited = unvisited.filter(loc => loc.id !== nearest.id);
+}
+
+// Calculate total distance and duration
+const totalDistance = route.reduce((sum, stop) => sum + stop.distanceFromPrevious, 0);
+const estimatedDuration = Math.ceil(totalDistance / WALKING_SPEED_MPS); // 1.4 m/s
+
+

Performance:

+
    +
  • O(n²) complexity (acceptable for typical cut sizes <500 locations)
  • +
  • Uses haversine distance (meters) for accurate walking distances
  • +
  • Assumes walking speed: 1.4 m/s (5 km/h)
  • +
+
+

POST /api/map/canvass/visits

+

Record visit to an address.

+

Rate Limiting: 30 requests per minute per IP

+

Request Body:

+
{
+  "addressId": "clxAddress123",
+  "outcome": "CONTACTED",
+  "supportLevel": "LEVEL_2",
+  "signRequested": true,
+  "signSize": "Large",
+  "notes": "Interested in volunteering for phone banks",
+  "durationSeconds": 180,
+  "sessionId": "clxSession789",
+  "shiftId": "clxShift456",
+  "updateLocation": true
+}
+
+

Field Descriptions:

+
    +
  • addressId (required): Address ID (unit within building)
  • +
  • outcome (required): Visit outcome enum
  • +
  • supportLevel (optional): Support level identified during visit
  • +
  • signRequested (optional, default: false): Lawn sign requested
  • +
  • signSize (optional): Sign size if requested
  • +
  • notes (optional): Visit notes
  • +
  • durationSeconds (optional): Time spent at door
  • +
  • sessionId (optional): Active canvass session ID
  • +
  • shiftId (optional): Associated shift ID
  • +
  • updateLocation (optional, default: true): Update address record with visit data
  • +
+

Response (201 Created):

+

Returns created visit object.

+

Address Update Logic:

+

If updateLocation=true and outcome is CONTACTED or SUPPORTER:

+
await prisma.address.update({
+  where: { id: addressId },
+  data: {
+    supportLevel: data.supportLevel || undefined,
+    sign: data.signRequested || undefined,
+    signSize: data.signRequested ? data.signSize : undefined,
+  },
+});
+
+

Metrics:

+
    +
  • Increments cm_canvass_visits_total counter with outcome label
  • +
  • Updates cut completion percentage
  • +
+
+

POST /api/map/canvass/visits/bulk

+

Record visit to all unvisited units in a building.

+

Rate Limiting: 10 requests per minute per IP (stricter than single visits)

+

Request Body:

+
{
+  "locationId": "clxLocation456",
+  "outcome": "NOT_HOME",
+  "notes": "Building-wide: No answer at any unit",
+  "sessionId": "clxSession789",
+  "shiftId": "clxShift456"
+}
+
+

Allowed Outcomes:

+

Only non-contact outcomes: +- NOT_HOME +- REFUSED +- MOVED

+

Logic:

+
    +
  1. Find all addresses at location (building)
  2. +
  3. Filter to unvisited addresses (no existing visit records)
  4. +
  5. Create visit records for all unvisited addresses in bulk
  6. +
+

Response (201 Created):

+
{
+  "created": 8,
+  "skipped": 2,
+  "locationId": "clxLocation456"
+}
+
+

Use Cases:

+
    +
  • Large apartment buildings where no one answers buzzer
  • +
  • Entire building marked as MOVED (demolished/vacant)
  • +
  • Save time: record 10+ units with single action
  • +
+
+

PUT /api/map/canvass/locations/:id

+

Update location with role-gated field restrictions.

+

Path Parameters:

+
    +
  • id (string): Address ID
  • +
+

Request Body (Volunteer):

+
{
+  "supportLevel": "LEVEL_2",
+  "sign": true,
+  "signSize": "Large",
+  "notes": "Willing to volunteer"
+}
+
+

Request Body (Admin):

+
{
+  "firstName": "John",
+  "lastName": "Doe",
+  "address": "123 Main St, Unit 4",
+  "unitNumber": "4",
+  "email": "john@example.com",
+  "phone": "416-555-1234",
+  "supportLevel": "LEVEL_2",
+  "sign": true
+}
+
+

Role-Gated Fields:

+

All Authenticated Users: +- supportLevel +- sign +- signSize +- notes

+

Admins Only (SUPER_ADMIN, MAP_ADMIN): +- firstName +- lastName +- address +- unitNumber +- email +- phone

+

TEMP Users:

+
    +
  • Cannot update any fields (read-only canvassing)
  • +
+

Service-Level Field Stripping:

+
const isAdmin = role === UserRole.SUPER_ADMIN || role === UserRole.MAP_ADMIN;
+const isTemp = role === UserRole.TEMP;
+
+if (isTemp) {
+  throw new AppError(403, 'TEMP users cannot edit locations', 'FORBIDDEN');
+}
+
+const updateData: Prisma.AddressUpdateInput = {};
+
+// Volunteer fields (all authenticated users)
+if (data.supportLevel !== undefined) updateData.supportLevel = data.supportLevel;
+if (data.sign !== undefined) updateData.sign = data.sign;
+if (data.signSize !== undefined) updateData.signSize = data.signSize;
+if (data.notes !== undefined) updateData.notes = data.notes;
+
+// Admin-only PII fields
+if (isAdmin) {
+  if (data.firstName !== undefined) updateData.firstName = data.firstName;
+  if (data.lastName !== undefined) updateData.lastName = data.lastName;
+  if (data.email !== undefined) updateData.email = data.email;
+  if (data.phone !== undefined) updateData.phone = data.phone;
+}
+
+
+

Admin Endpoint Details

+

GET /api/map/canvass/stats

+

Get aggregate canvassing statistics.

+

Example Response (200 OK):

+
{
+  "totalVisits": 3847,
+  "totalVolunteers": 42,
+  "activeSessions": 7,
+  "byOutcome": {
+    "CONTACTED": 1892,
+    "SUPPORTER": 542,
+    "NOT_HOME": 987,
+    "REFUSED": 234,
+    "MOVED": 89,
+    "WRONG_ADDRESS": 43,
+    "CALLBACK": 34,
+    "INACCESSIBLE": 26
+  },
+  "topVolunteers": [
+    {
+      "userId": "clxUser123",
+      "name": "Jane Smith",
+      "visitCount": 247
+    }
+  ],
+  "cutProgress": [
+    {
+      "cutId": "clxCut123",
+      "cutName": "Downtown Ward 5",
+      "completionPercentage": 68,
+      "visitCount": 342,
+      "totalAddresses": 503
+    }
+  ]
+}
+
+
+

GET /api/map/canvass/activity

+

Get recent canvass activity feed.

+

Query Parameters:

+
    +
  • page (default: 1): Page number
  • +
  • limit (default: 20, max: 100): Results per page
  • +
  • cutId (optional): Filter by cut
  • +
  • userId (optional): Filter by volunteer
  • +
  • outcome (optional): Filter by outcome
  • +
+

Example Response (200 OK):

+
{
+  "activities": [
+    {
+      "id": "clxVisit789",
+      "userId": "clxUser123",
+      "user": {
+        "name": "Jane Smith",
+        "email": "jane@example.com"
+      },
+      "addressId": "clxAddress456",
+      "address": {
+        "address": "123 Main St",
+        "unitNumber": "Apt 4"
+      },
+      "outcome": "CONTACTED",
+      "supportLevel": "LEVEL_2",
+      "visitedAt": "2026-02-11T14:30:00.000Z",
+      "durationSeconds": 180
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 20,
+    "total": 3847,
+    "totalPages": 193
+  }
+}
+
+
+

GET /api/map/canvass/volunteers

+

List volunteers with visit counts.

+

Example Response (200 OK):

+
[
+  {
+    "userId": "clxUser123",
+    "name": "Jane Smith",
+    "email": "jane@example.com",
+    "totalVisits": 247,
+    "todayVisits": 18,
+    "activeSessions": 1
+  }
+]
+
+
+

Service Functions

+

canvassService.startSession(userId, data)

+

Start new canvass session.

+

Validation:

+
// Check for existing active session
+const existing = await prisma.canvassSession.findFirst({
+  where: { userId, status: CanvassSessionStatus.ACTIVE },
+});
+if (existing) {
+  throw new AppError(409, 'You already have an active canvass session', 'SESSION_ACTIVE');
+}
+
+// Verify cut exists
+const cut = await prisma.cut.findUnique({ where: { id: data.cutId } });
+if (!cut) {
+  throw new AppError(404, 'Cut not found', 'CUT_NOT_FOUND');
+}
+
+
+

canvassService.endSession(sessionId, userId)

+

End canvass session and recalculate cut completion.

+

Post-Processing:

+
// End session
+await prisma.canvassSession.update({
+  where: { id: sessionId },
+  data: { status: CanvassSessionStatus.COMPLETED, endedAt: new Date() },
+});
+
+// Recalculate cut completion percentage
+await this.recalculateCutCompletion(session.cutId);
+
+

Cut Completion Calculation:

+
async recalculateCutCompletion(cutId: string) {
+  // Get all addresses in cut
+  const totalAddresses = await this.countAddressesInCut(cutId);
+
+  // Get visited addresses (distinct addressId from visits)
+  const visitedCount = await prisma.canvassVisit.findMany({
+    where: { address: { location: { cuts: { some: { id: cutId } } } } },
+    distinct: ['addressId'],
+  }).then(visits => visits.length);
+
+  const completionPercentage = totalAddresses > 0
+    ? Math.round((visitedCount / totalAddresses) * 100)
+    : 0;
+
+  await prisma.cut.update({
+    where: { id: cutId },
+    data: { completionPercentage },
+  });
+}
+
+
+

canvassService.recordVisit(userId, data)

+

Record visit to address with optional location update.

+

Address Update Logic:

+
if (data.updateLocation && (data.outcome === VisitOutcome.CONTACTED || data.outcome === VisitOutcome.SUPPORTER)) {
+  await prisma.address.update({
+    where: { id: data.addressId },
+    data: {
+      supportLevel: data.supportLevel || undefined,
+      sign: data.signRequested || undefined,
+      signSize: data.signRequested ? data.signSize : undefined,
+    },
+  });
+}
+
+

Metrics:

+
recordCanvassVisit(data.outcome); // Prometheus counter
+
+
+

canvassService.getWalkingRoute(cutId, userId, options)

+

Get optimized walking route for cut.

+

Algorithm:

+
import { calculateWalkingRoute } from './canvass-route.service';
+
+const addresses = await this.getCutLocationsForCanvass(cutId, userId);
+
+// Filter to unvisited if requested
+const unvisited = options.excludeVisited
+  ? addresses.filter(addr => !addr.lastVisit)
+  : addresses;
+
+// Calculate route using nearest-neighbor algorithm
+const route = calculateWalkingRoute(
+  unvisited,
+  options.startLatitude,
+  options.startLongitude,
+);
+
+return route;
+
+
+

Abandoned Session Cleanup

+

Scheduled Task:

+

Runs on API startup and every hour:

+
// api/src/server.ts
+async function closeAbandonedSessions() {
+  const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
+
+  const result = await prisma.canvassSession.updateMany({
+    where: {
+      status: CanvassSessionStatus.ACTIVE,
+      startedAt: { lt: twelveHoursAgo },
+    },
+    data: {
+      status: CanvassSessionStatus.ABANDONED,
+      endedAt: new Date(),
+    },
+  });
+
+  if (result.count > 0) {
+    logger.info(`Closed ${result.count} abandoned canvass sessions`);
+  }
+}
+
+// Run on startup
+closeAbandonedSessions();
+
+// Run every hour
+setInterval(closeAbandonedSessions, 60 * 60 * 1000);
+
+
+

Validation Schemas

+

Record Visit Schema

+
export const recordVisitSchema = z.object({
+  addressId: z.string().min(1),
+  outcome: z.nativeEnum(VisitOutcome),
+  supportLevel: z.nativeEnum(SupportLevel).optional(),
+  signRequested: z.boolean().optional().default(false),
+  signSize: z.string().optional(),
+  notes: z.string().optional(),
+  durationSeconds: z.number().int().optional(),
+  sessionId: z.string().optional(),
+  shiftId: z.string().optional(),
+  updateLocation: z.boolean().optional().default(true),
+});
+
+

Bulk Record Visit Schema

+
export const bulkRecordVisitSchema = z.object({
+  locationId: z.string().min(1), // Building ID
+  outcome: z.enum(['NOT_HOME', 'REFUSED', 'MOVED']), // Only non-contact outcomes
+  notes: z.string().optional(),
+  sessionId: z.string().optional(),
+  shiftId: z.string().optional(),
+});
+
+
+

Code Examples

+

Volunteer: Start Canvass Session

+
import { api } from '@/lib/api';
+import { message } from 'antd';
+
+const startSession = async (cutId: string, shiftId?: string) => {
+  // Get current GPS position
+  navigator.geolocation.getCurrentPosition(async (position) => {
+    try {
+      const { data } = await api.post('/api/map/canvass/sessions', {
+        cutId,
+        shiftId,
+        startLatitude: position.coords.latitude,
+        startLongitude: position.coords.longitude,
+      });
+
+      message.success('Canvass session started');
+      console.log(`Session ID: ${data.id}`);
+    } catch (error: any) {
+      if (error.response?.status === 409) {
+        message.error('You already have an active session');
+      } else {
+        message.error('Failed to start session');
+      }
+    }
+  });
+};
+
+

Volunteer: Record Visit

+
import { api } from '@/lib/api';
+import { message } from 'antd';
+
+const recordVisit = async (addressId: string, outcome: string, sessionId: string) => {
+  try {
+    const { data } = await api.post('/api/map/canvass/visits', {
+      addressId,
+      outcome,
+      supportLevel: 'LEVEL_2',
+      signRequested: true,
+      signSize: 'Large',
+      notes: 'Interested in volunteering',
+      durationSeconds: 180,
+      sessionId,
+      updateLocation: true,
+    });
+
+    message.success('Visit recorded');
+    return data;
+  } catch (error: any) {
+    if (error.response?.status === 429) {
+      message.error('Rate limit exceeded. Please wait a moment.');
+    } else {
+      message.error('Failed to record visit');
+    }
+  }
+};
+
+

Admin: Get Canvass Statistics

+
import { api } from '@/lib/api';
+
+const getStats = async () => {
+  const { data } = await api.get('/api/map/canvass/stats');
+
+  console.log(`Total Visits: ${data.totalVisits}`);
+  console.log(`Active Sessions: ${data.activeSessions}`);
+  console.log(`Top Volunteer: ${data.topVolunteers[0]?.name} (${data.topVolunteers[0]?.visitCount} visits)`);
+
+  return data;
+};
+
+
+

Frontend Integration

+

Volunteer Portal

+

VolunteerMapPage (admin/src/pages/volunteer/VolunteerMapPage.tsx):

+
    +
  • Full-screen Leaflet map (no AppLayout)
  • +
  • GPS tracking (blue dot follows volunteer)
  • +
  • Location markers (color-coded by visit status)
  • +
  • Walking route visualization (dashed blue line)
  • +
  • Bottom sheet toolbar (floating panel)
  • +
  • Visit recording form (outcome, notes, duration)
  • +
  • Optimized route toggle (exclude visited addresses)
  • +
  • Session timer (displays elapsed time)
  • +
+

MyAssignmentsPage (admin/src/pages/volunteer/MyAssignmentsPage.tsx):

+
    +
  • Assigned shifts table
  • +
  • Cut names + completion percentage
  • +
  • "Start Canvassing" button (opens map, starts session)
  • +
+

MyActivityPage (admin/src/pages/volunteer/MyActivityPage.tsx):

+
    +
  • Visit history table (paginated)
  • +
  • Outcome breakdown (pie chart)
  • +
  • Today's visit count vs. total
  • +
+

State Management:

+
// admin/src/stores/canvass.store.ts
+interface CanvassState {
+  session: CanvassSession | null;
+  locations: CanvassLocation[];
+  route: WalkingRoute | null;
+  gpsPosition: { lat: number; lng: number } | null;
+  selectedAddress: string | null;
+  showVisitRecording: boolean;
+}
+
+

Admin Dashboard

+

CanvassDashboardPage (admin/src/pages/CanvassDashboardPage.tsx):

+
    +
  • Statistics cards (total visits, active sessions, volunteers, completion %)
  • +
  • Recent activity feed (realtime visit stream)
  • +
  • Cut progress table (completionPercentage, visitCount)
  • +
  • Volunteer leaderboard (sorted by visit count)
  • +
+
+

Performance Considerations

+

Rate Limiting:

+
    +
  • Single visits: 30/min per IP (prevents spam)
  • +
  • Bulk visits: 10/min per IP (stricter for building-wide operations)
  • +
  • Geocoding: 10/min per IP (prevents geocoding API abuse)
  • +
+

Abandoned Session Cleanup:

+
    +
  • Runs hourly (low overhead)
  • +
  • Only updates sessions older than 12 hours
  • +
  • Prevents stale ACTIVE sessions blocking new sessions
  • +
+

Walking Route Algorithm:

+
    +
  • O(n²) complexity acceptable for typical cuts (<500 locations)
  • +
  • Uses haversine distance (meters) for accuracy
  • +
  • Pre-filters visited addresses when excludeVisited=true
  • +
+

Cut Completion Calculation:

+
    +
  • Triggered on session end (not every visit)
  • +
  • Uses distinct: ['addressId'] to count unique addresses
  • +
  • Caches result in Cut.completionPercentage field
  • +
+
+

Troubleshooting

+

Issue: "You already have an active canvass session"

+

Cause: Volunteer forgot to end previous session

+

Solution:

+
    +
  • Admin: Find session in CanvassDashboardPage, manually mark as COMPLETED
  • +
  • Wait for automatic cleanup (12h timeout)
  • +
  • Volunteer: Navigate to session end screen and click "End Session"
  • +
+

Issue: Rate limit exceeded (429) when recording visits

+

Cause: Recording visits too quickly (>30/min)

+

Solution:

+
    +
  • Slow down visit recording (realistic door-knocking speed: ~10-15/hr)
  • +
  • Use bulk visit endpoint for buildings (NOT_HOME for entire building)
  • +
+

Issue: Walking route skips some addresses

+

Cause: excludeVisited=true filters out already-visited addresses

+

Solution:

+
    +
  • Set excludeVisited=false to see all addresses
  • +
  • Verify addresses have visits recorded (check lastVisit field)
  • +
+

Issue: Cut completion percentage not updating

+

Cause: Completion calculated on session end, not per-visit

+

Solution:

+
    +
  • End canvass session to trigger recalculation
  • +
  • Admin: View cut stats to verify visitCount vs. totalAddresses
  • +
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/backend/modules/index.html b/mkdocs/site/v2/backend/modules/index.html new file mode 100644 index 00000000..fa0066da --- /dev/null +++ b/mkdocs/site/v2/backend/modules/index.html @@ -0,0 +1,5196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Backend Modules - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Backend Modules

+

Backend modules provide feature-specific functionality for the Changemaker Lite platform. Each module follows a consistent architecture pattern with schemas, services, and routes.

+

Module Architecture

+

Each module typically contains:

+
    +
  • Schemas (*.schemas.ts) - Zod validation schemas for requests/responses
  • +
  • Service (*.service.ts) - Business logic and database operations
  • +
  • Routes (*.routes.ts) - Express router definitions with middleware
  • +
  • Types - TypeScript interfaces (when needed beyond Prisma types)
  • +
+

Modules may split routes into admin and public variants (e.g., campaigns.routes.ts and campaigns-public.routes.ts).

+

Core Modules

+

Authentication & User Management

+
    +
  • Auth Module - JWT authentication, login, register, refresh tokens, logout
  • +
  • Users Module - User CRUD, pagination, search, role management
  • +
  • Settings Module - Global site settings singleton
  • +
+

Influence (Advocacy Campaigns)

+ +

Map & Location Services

+
    +
  • Locations Module - Location CRUD, geocoding, NAR import, CSV operations
  • +
  • Cuts Module - Polygon CRUD, spatial queries, point-in-polygon
  • +
  • Shifts Module - Shift CRUD, volunteer signups, email notifications
  • +
  • Canvass Module - Canvassing sessions, visit tracking, walking routes
  • +
+

Content Management

+ +

Supporting Modules

+

Infrastructure

+
    +
  • Services Module - Service health checks and monitoring
  • +
  • QR Module - QR code PNG generation
  • +
  • Docs Module - MkDocs and Code Server integration
  • +
+

Integrations

+
    +
  • Listmonk Module - Newsletter sync, list management
  • +
  • Pangolin Module - Tunnel management, resource configuration
  • +
  • Observability Module - Prometheus/Grafana integration
  • +
+

Email & Queuing

+
    +
  • Campaign Emails Module - Email tracking, statistics
  • +
  • Email Queue Module - BullMQ queue administration
  • +
  • Postal Codes Module - Postal code caching service
  • +
+

Geocoding & Spatial

+
    +
  • Geocoding Module - Multi-provider geocoding (6 providers)
  • +
  • Tracking Module - GPS tracking sessions (volunteer + admin)
  • +
  • Map Settings Module - Map configuration singleton
  • +
+

Module List

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ModulePurposeRoutes
authAuthentication & sessions/api/auth/*
usersUser management/api/users/*
settingsSite settings/api/settings/*
campaignsAdvocacy campaigns/api/campaigns/*
representativesRepresentative lookup/api/representatives/*
responsesResponse wall/api/responses/*
locationsLocation database/api/locations/*
cutsGeographic cuts/api/cuts/*
shiftsVolunteer shifts/api/shifts/*
canvassCanvassing system/api/canvass/*
pagesLanding pages/api/pages/*
mediaVideo library/media-api/* (port 4100)
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/backend/modules/locations/index.html b/mkdocs/site/v2/backend/modules/locations/index.html new file mode 100644 index 00000000..2218822f --- /dev/null +++ b/mkdocs/site/v2/backend/modules/locations/index.html @@ -0,0 +1,7323 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Locations Module - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Locations Module

+

Overview

+

The Locations module manages geographic locations for organizing campaigns, mapping volunteers, and tracking supporter data. It features multi-provider geocoding, NAR (National Address Register) bulk import with 2025 format support, CSV import/export, location history tracking, and comprehensive filtering with spatial queries.

+

Key Features:

+
    +
  • Location CRUD with automatic geocoding
  • +
  • Multi-provider geocoding (Nominatim, Mapbox, ArcGIS, Photon, Google, LocationIQ)
  • +
  • Batch geocoding with BullMQ queue integration
  • +
  • NAR 2025 bulk import (Canadian electoral data with Lambert projection support)
  • +
  • CSV import/export with flexible column mapping
  • +
  • Location history tracking (audit trail for all changes)
  • +
  • Reverse geocoding (lat/lng → address)
  • +
  • Spatial filtering (cut polygons, bounding boxes, postal codes)
  • +
  • Deduplication (coordinate-based with configurable radius)
  • +
  • Support level tracking (LEVEL_1 through LEVEL_4)
  • +
  • Sign tracking (lawn signs, sizes)
  • +
  • Public map API (PII-filtered)
  • +
  • Statistics dashboard (geocoding quality, provider distribution, confidence levels)
  • +
+

File Paths

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FilePurpose
api/src/modules/map/locations/locations.routes.ts2 routers (admin + public) with 20 endpoints
api/src/modules/map/locations/locations.service.tsLocation business logic + geocoding + NAR import (1,100 lines)
api/src/modules/map/locations/locations.schemas.tsZod validation schemas
api/src/modules/map/locations/nar-import.service.tsNAR import service (server-side streaming, legacy support)
api/src/modules/map/locations/nar-import.routes.tsNAR import admin routes
api/src/modules/map/locations/bulk-geocode.routes.tsBulk geocoding queue routes
api/src/modules/map/locations/bulk-geocode.schemas.tsBulk geocoding schemas
+

Database Models

+

Location

+
model Location {
+  id                  String         @id @default(cuid())
+  address             String
+  unitNumber          String?
+  firstName           String?
+  lastName            String?
+  email               String?
+  phone               String?
+  supportLevel        SupportLevel?
+  sign                Boolean        @default(false)
+  signSize            String?
+  notes               String?        @db.Text
+  buildingNotes       String?        @db.Text
+
+  // Geocoding
+  latitude            Float?
+  longitude           Float?
+  geocodeConfidence   Int?
+  geocodeProvider     GeocodeProvider?
+
+  // NAR fields (2025 format support)
+  postalCode          String?
+  province            String?
+  federalDistrict     String?
+  buildingUse         Int?           // 1=Residential, 2=Commercial, 3=Mixed
+
+  // Audit
+  createdByUserId     String?
+  updatedByUserId     String?
+  createdAt           DateTime       @default(now())
+  updatedAt           DateTime       @updatedAt
+
+  // Relations
+  createdByUser       User?          @relation("LocationCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
+  updatedByUser       User?          @relation("LocationUpdater", fields: [updatedByUserId], references: [id], onDelete: SetNull)
+  history             LocationHistory[]
+
+  @@index([latitude, longitude])
+  @@index([supportLevel])
+  @@index([sign])
+  @@index([geocodeConfidence])
+  @@map("locations")
+}
+
+enum SupportLevel {
+  LEVEL_1  // Strong support
+  LEVEL_2  // Moderate support
+  LEVEL_3  // Undecided
+  LEVEL_4  // Opposed
+}
+
+enum GeocodeProvider {
+  NOMINATIM
+  MAPBOX
+  ARCGIS
+  PHOTON
+  GOOGLE
+  LOCATIONIQ
+  UNKNOWN
+}
+
+

LocationHistory

+
model LocationHistory {
+  id         String                @id @default(cuid())
+  locationId String
+  location   Location              @relation(fields: [locationId], references: [id], onDelete: Cascade)
+  userId     String?
+  user       User?                 @relation(fields: [userId], references: [id], onDelete: SetNull)
+  action     LocationHistoryAction
+  field      String?
+  oldValue   String?
+  newValue   String?
+  metadata   Json?
+  createdAt  DateTime              @default(now())
+
+  @@index([locationId])
+  @@index([userId])
+  @@index([action])
+  @@map("location_history")
+}
+
+enum LocationHistoryAction {
+  CREATED
+  UPDATED
+  GEOCODED
+  MOVED_ON_MAP
+  DELETED
+}
+
+

History Tracking:

+
    +
  • All location changes recorded with before/after values
  • +
  • CREATED — Location created (manual or import)
  • +
  • UPDATED — Field changed
  • +
  • GEOCODED — Address geocoded (auto or bulk geocoding)
  • +
  • MOVED_ON_MAP — Lat/lng changed via map drag
  • +
  • DELETED — Location deleted
  • +
+
+

API Endpoints

+

Admin Endpoints (Authentication Required)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathDescription
GET/api/map/locationsList locations (paginated, filtered)
GET/api/map/locations/statsLocation statistics
GET/api/map/locations/export-csvExport CSV download
GET/api/map/locations/allAll geocoded locations for map (admin, 5000 limit)
GET/api/map/locations/:idGet single location
GET/api/map/locations/:id/historyGet location edit history
POST/api/map/locationsCreate location (auto-geocodes if no lat/lng)
POST/api/map/locations/geocodeGeocode single address
POST/api/map/locations/geocode-missingGeocode all ungeocoded locations
POST/api/map/locations/import-csvUpload + import CSV (10MB limit)
POST/api/map/locations/import-bulkBulk import NAR or CSV (100MB limit, 5min timeout)
POST/api/map/locations/reverse-geocodeReverse geocode lat/lng to address
POST/api/map/locations/bulk-deleteDelete multiple locations
PUT/api/map/locations/:idUpdate location
DELETE/api/map/locations/:idDelete location
+

Admin Roles: SUPER_ADMIN, MAP_ADMIN

+

Public Endpoints (No Authentication)

+ + + + + + + + + + + + + + + +
MethodPathDescription
GET/api/map/locations/publicPublic locations for map (PII-filtered, 5000 limit)
+
+

Admin Endpoint Details

+

GET /api/map/locations

+

List locations with pagination, search, and filtering.

+

Query Parameters:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDefaultDescription
pagenumberNo1Page number
limitnumberNo20Results per page (max 100)
searchstringNo-Search address, first/last name, email
supportLevelSupportLevelNo-Filter by support level
hasSignbooleanNo-Filter by sign presence
confidenceLevelstringNo-Filter by geocode confidence: high (85+), medium (60-84), low (<60), none (0 or null)
sortBystringNocreatedAtSort field: createdAt, address, supportLevel
sortOrderstringNodescSort order: asc, desc
+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/map/locations?page=1&limit=20&supportLevel=LEVEL_1&hasSign=true&confidenceLevel=high"
+
+

Response (200 OK):

+
{
+  "locations": [
+    {
+      "id": "clx1234567890",
+      "address": "123 Main St, Toronto, ON",
+      "unitNumber": "Apt 4",
+      "firstName": "John",
+      "lastName": "Doe",
+      "email": "john@example.com",
+      "phone": "416-555-1234",
+      "supportLevel": "LEVEL_1",
+      "sign": true,
+      "signSize": "Large",
+      "notes": "Willing to volunteer",
+      "buildingNotes": "Apartment building, intercom required",
+      "latitude": 43.6532,
+      "longitude": -79.3832,
+      "geocodeConfidence": 95,
+      "geocodeProvider": "NOMINATIM",
+      "postalCode": "M5H 2N2",
+      "province": "ON",
+      "federalDistrict": "Toronto Centre",
+      "buildingUse": 1,
+      "createdByUserId": "clxUser123",
+      "updatedByUserId": null,
+      "createdAt": "2026-02-08T12:00:00.000Z",
+      "updatedAt": "2026-02-08T12:00:00.000Z"
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 20,
+    "total": 342,
+    "totalPages": 18
+  }
+}
+
+

Search Logic:

+
if (search) {
+  where.OR = [
+    { address: { contains: search, mode: 'insensitive' } },
+    { firstName: { contains: search, mode: 'insensitive' } },
+    { lastName: { contains: search, mode: 'insensitive' } },
+    { email: { contains: search, mode: 'insensitive' } },
+  ];
+}
+
+

Confidence Level Filtering:

+
if (confidenceLevel === 'high') {
+  where.geocodeConfidence = { gte: 85 };
+} else if (confidenceLevel === 'medium') {
+  where.geocodeConfidence = { gte: 60, lt: 85 };
+} else if (confidenceLevel === 'low') {
+  where.geocodeConfidence = { lt: 60, gt: 0 };
+} else if (confidenceLevel === 'none') {
+  where.OR = [{ geocodeConfidence: null }, { geocodeConfidence: 0 }];
+}
+
+
+

GET /api/map/locations/stats

+

Get aggregate statistics for locations.

+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/map/locations/stats"
+
+

Response (200 OK):

+
{
+  "total": 1247,
+  "supportLevels": {
+    "LEVEL_1": 342,
+    "LEVEL_2": 189,
+    "LEVEL_3": 276,
+    "LEVEL_4": 98,
+    "NONE": 342
+  },
+  "signs": 142,
+  "geocoded": 1189,
+  "ungeocoded": 58,
+  "confidence": {
+    "high": 892,
+    "medium": 213,
+    "low": 84,
+    "none": 58,
+    "average": 87
+  },
+  "providers": {
+    "nominatim": 654,
+    "mapbox": 312,
+    "arcgis": 98,
+    "photon": 76,
+    "google": 34,
+    "locationiq": 15,
+    "manual": 58
+  }
+}
+
+

Field Descriptions:

+
    +
  • total — Total location count
  • +
  • supportLevels — Breakdown by support level
  • +
  • signs — Locations with sign=true
  • +
  • geocoded — Locations with lat/lng
  • +
  • ungeocoded — Locations without lat/lng
  • +
  • confidence.high — Geocode confidence ≥ 85
  • +
  • confidence.medium — Geocode confidence 60-84
  • +
  • confidence.low — Geocode confidence < 60
  • +
  • confidence.none — No geocode confidence (0 or null)
  • +
  • confidence.average — Average geocode confidence (excludes 0/null)
  • +
  • providers — Breakdown by geocode provider
  • +
+
+

POST /api/map/locations

+

Create new location with automatic geocoding.

+

Request Body:

+
{
+  "address": "123 Main St, Toronto, ON",
+  "unitNumber": "Apt 4",
+  "firstName": "John",
+  "lastName": "Doe",
+  "email": "john@example.com",
+  "phone": "416-555-1234",
+  "supportLevel": "LEVEL_1",
+  "sign": true,
+  "signSize": "Large",
+  "notes": "Willing to volunteer",
+  "buildingNotes": "Apartment building, intercom required"
+}
+
+

Response (201 Created):

+

Returns created location object.

+

Auto-Geocoding:

+

If address provided and no latitude/longitude, automatically geocodes:

+
if (data.address && data.latitude == null && data.longitude == null) {
+  const result = await geocodingService.geocode(data.address);
+  if (result) {
+    createData.latitude = result.latitude;
+    createData.longitude = result.longitude;
+    createData.geocodeConfidence = result.confidence;
+    createData.geocodeProvider = result.provider;
+  }
+}
+
+

History Tracking:

+

Creates LocationHistory record with action GEOCODED (if geocoded) or CREATED (if manual coordinates).

+
+

PUT /api/map/locations/:id

+

Update location. Re-geocodes if address changes without explicit lat/lng.

+

Request Body (Partial):

+
{
+  "address": "456 Oak St, Toronto, ON",
+  "supportLevel": "LEVEL_2"
+}
+
+

Response (200 OK):

+

Returns updated location object.

+

Smart Geocoding:

+
    +
  • If address changes and no explicit lat/lng provided: re-geocode automatically
  • +
  • If lat/lng provided: use provided coordinates (manual override)
  • +
+

History Tracking:

+

Records field changes with before/after values:

+
// Track changes
+const changes: { field: string; oldValue: unknown; newValue: unknown }[] = [];
+
+if (data.address && data.address !== existing.address) {
+  changes.push({ field: 'address', oldValue: existing.address, newValue: data.address });
+}
+
+// Determine action based on changes
+let action: LocationHistoryAction = LocationHistoryAction.UPDATED;
+
+if (data.latitude !== undefined && data.latitude !== existing.latitude) {
+  action = LocationHistoryAction.MOVED_ON_MAP; // Explicit coordinate change (map drag)
+}
+
+if (address changed && auto-geocoded) {
+  action = LocationHistoryAction.GEOCODED;
+}
+
+
+

POST /api/map/locations/import-csv

+

Upload and import CSV file with flexible column mapping.

+

Multipart Form Data:

+
    +
  • file (required): CSV file (max 10MB)
  • +
+

Supported Column Names (Case-Insensitive):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldColumn Names
addressaddress, street, street address
firstNamefirst name, firstname, first
lastNamelast name, lastname, last
emailemail, e-mail
phonephone, telephone, tel, phone number
unitNumberunit, unit number, apt, apartment, suite
supportLevelsupport level, supportlevel, support, level
signsign, lawn sign
signSizesign size, signsize
notesnotes, note, comments
latitudelatitude, lat
longitudelongitude, lng, lon
+

Example CSV:

+
address,first name,last name,email,phone,support level,sign
+"123 Main St, Toronto, ON",John,Doe,john@example.com,416-555-1234,LEVEL_1,true
+"456 Oak St, Toronto, ON",Jane,Smith,jane@example.com,416-555-5678,LEVEL_2,false
+
+

Example Request:

+
curl -X POST -H "Authorization: Bearer <token>" \
+  -F "file=@locations.csv" \
+  "http://api.cmlite.org/api/map/locations/import-csv"
+
+

Response (200 OK):

+
{
+  "total": 1000,
+  "success": 942,
+  "warnings": 34,
+  "failed": 24,
+  "errors": [
+    "Row 12: Missing address",
+    "Row 45: Invalid email format",
+    "Row 89: Geocoding failed"
+  ]
+}
+
+

Field Descriptions:

+
    +
  • total — Total rows in CSV
  • +
  • success — Successfully created locations
  • +
  • warnings — Created but geocoding failed (no lat/lng)
  • +
  • failed — Failed to create (validation errors)
  • +
  • errors — First 50 error messages (row numbers 1-indexed)
  • +
+

Geocoding:

+
    +
  • If CSV has latitude/longitude columns: uses provided coordinates
  • +
  • Otherwise: auto-geocodes each address (slow for large files, consider NAR import for bulk)
  • +
+
+

POST /api/map/locations/import-bulk

+

Bulk import NAR (National Address Register) or standard CSV with advanced filtering.

+

Multipart Form Data:

+
    +
  • file (required): CSV file (max 100MB)
  • +
  • format (required): nar or standard
  • +
  • filterType (optional): none, cut, mapArea, city, province
  • +
  • cutId (optional): Cut ID for filterType=cut
  • +
  • filterCity (optional): City name for filterType=city
  • +
  • filterProvince (optional): Province code for filterType=province (e.g., ON, BC)
  • +
  • residentialOnly (optional, default: false): Skip non-residential buildings (NAR only)
  • +
  • deduplicateRadius (optional, default: 5): Coordinate deduplication radius in meters
  • +
  • skipGeocoding (optional, default: true): Skip geocoding (NAR files have coordinates)
  • +
  • batchSize (optional, default: 1000): Database batch insert size
  • +
+

Request Timeout: 5 minutes (extended for large files)

+

Example Request (NAR Import with Cut Filter):

+
curl -X POST -H "Authorization: Bearer <token>" \
+  -F "file=@Address_24_part_1.csv" \
+  -F "format=nar" \
+  -F "filterType=cut" \
+  -F "cutId=clxCut123" \
+  -F "residentialOnly=true" \
+  -F "deduplicateRadius=5" \
+  "http://api.cmlite.org/api/map/locations/import-bulk"
+
+

Response (200 OK):

+
{
+  "total": 50000,
+  "created": 12847,
+  "skippedDuplicate": 1243,
+  "skippedOutOfBounds": 34892,
+  "skippedInvalid": 1018,
+  "errors": [
+    "Row 234: Invalid coordinates",
+    "Row 1892: Missing civic number"
+  ]
+}
+
+

NAR Format Support:

+

2025 NAR Format (Recommended):

+
    +
  • Address File Columns: CIVIC_NO, CIVIC_NO_SUFFIX, OFFICIAL_STREET_NAME, OFFICIAL_STREET_TYPE, OFFICIAL_STREET_DIR, APT_NO_LABEL, BG_X, BG_Y, MAIL_MUN_NAME, MAIL_PROV_ABVN, MAIL_POSTAL_CODE, FED_ENG_NAME, BU_USE
  • +
  • Location File Columns: BG_LATITUDE, BG_LONGITUDE (WGS84), LOC_GUID
  • +
  • Coordinate Systems:
  • +
  • BG_X/BG_Y — EPSG:3347 Lambert Conformal Conic (converted to WGS84)
  • +
  • BG_LATITUDE/BG_LONGITUDE — WGS84 (used directly)
  • +
+

Legacy NAR Format (Backward Compatible):

+
    +
  • Columns: STR_NBR, STR_NME, STR_TYP, STR_DIR, LAT, LNG, MUN_NME, PRV_NME
  • +
+

Auto-Detection:

+

If 3+ NAR-specific columns detected, automatically treats as NAR format.

+

Lambert Projection Conversion:

+
import proj4 from 'proj4';
+
+// Define EPSG:3347 (Statistics Canada Lambert Conformal Conic)
+proj4.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');
+
+function lambertToLatLng(bgX: number, bgY: number): [number, number] {
+  const [lng, lat] = proj4('EPSG:3347', 'EPSG:4326', [bgX, bgY]);
+  return [lat, lng];
+}
+
+

Filtering Options:

+
    +
  1. Cut Filter (filterType=cut):
  2. +
  3. Only imports locations inside specified cut polygon
  4. +
  5. +

    Uses point-in-polygon ray-casting algorithm

    +
  6. +
  7. +

    Map Area Filter (filterType=mapArea):

    +
  8. +
  9. Imports locations visible on current map view
  10. +
  11. +

    Calculates bounding box from MapSettings (center, zoom)

    +
  12. +
  13. +

    City Filter (filterType=city):

    +
  14. +
  15. +

    Imports locations matching city name (case-insensitive)

    +
  16. +
  17. +

    Province Filter (filterType=province):

    +
  18. +
  19. Imports locations matching province code (e.g., ON, BC)
  20. +
+

Deduplication:

+

Prevents duplicate locations at same coordinates:

+
const coordKey = `${roundCoord(lat, 5)}:${roundCoord(lng, 5)}`; // 5 decimal places = ~1.1m precision
+
+if (existingCoords.has(coordKey) || inFileCoords.has(coordKey)) {
+  skippedDuplicate++;
+  continue;
+}
+
+

Batch Processing:

+

Inserts locations in batches (default 1000) for performance:

+
const batch: Prisma.LocationCreateManyInput[] = [];
+
+// ... collect locations ...
+
+if (batch.length >= options.batchSize) {
+  await prisma.location.createMany({ data: batch, skipDuplicates: true });
+  batch.length = 0;
+}
+
+
+

GET /api/map/locations/export-csv

+

Export locations as CSV download.

+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/map/locations/export-csv" \
+  -o locations.csv
+
+

Response (200 OK):

+

CSV file with headers:

+
address,firstName,lastName,email,phone,unitNumber,supportLevel,sign,signSize,notes,latitude,longitude,geocodeConfidence,geocodeProvider,createdAt
+"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
+
+
+

POST /api/map/locations/reverse-geocode

+

Reverse geocode coordinates to address.

+

Request Body:

+
{
+  "latitude": 43.6532,
+  "longitude": -79.3832
+}
+
+

Response (200 OK):

+
{
+  "address": "123 Main St, Toronto, ON M5H 2N2, Canada",
+  "provider": "NOMINATIM",
+  "confidence": 85
+}
+
+

Use Cases:

+
    +
  • Click-to-add location on map (get address from coordinates)
  • +
  • Move location on map (update address after drag)
  • +
  • Verify coordinates match expected address
  • +
+
+

GET /api/map/locations/all

+

Get all geocoded locations for admin map view.

+

Query Parameters:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeDescription
minLatnumberMinimum latitude (bounding box)
maxLatnumberMaximum latitude
minLngnumberMinimum longitude
maxLngnumberMaximum longitude
+

Example Request:

+
# All locations
+curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/map/locations/all"
+
+# Bounding box (visible map area)
+curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/map/locations/all?minLat=43.6&maxLat=43.7&minLng=-79.4&maxLng=-79.3"
+
+

Response (200 OK):

+

Returns array of location objects (max 5000).

+

Safety Limit:

+

If result hits 5000 locations, adds header X-Location-Limit-Hit: true to warn client.

+
+

GET /api/map/locations/:id/history

+

Get location edit history with audit trail.

+

Query Parameters:

+
    +
  • page (optional, default: 1): Page number
  • +
  • limit (optional, default: 20): Results per page
  • +
+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/map/locations/clx1234567890/history?page=1&limit=20"
+
+

Response (200 OK):

+
{
+  "history": [
+    {
+      "id": "clxHistory123",
+      "locationId": "clx1234567890",
+      "userId": "clxUser123",
+      "user": {
+        "id": "clxUser123",
+        "email": "admin@example.com",
+        "name": "Admin User",
+        "role": "SUPER_ADMIN"
+      },
+      "action": "MOVED_ON_MAP",
+      "field": "latitude",
+      "oldValue": "43.6532",
+      "newValue": "43.6540",
+      "metadata": null,
+      "createdAt": "2026-02-11T12:00:00.000Z"
+    },
+    {
+      "id": "clxHistory124",
+      "locationId": "clx1234567890",
+      "userId": "clxUser123",
+      "user": {...},
+      "action": "GEOCODED",
+      "field": "latitude",
+      "oldValue": null,
+      "newValue": "43.6532",
+      "metadata": {
+        "provider": "NOMINATIM",
+        "confidence": 95,
+        "geocoded": true
+      },
+      "createdAt": "2026-02-08T12:00:00.000Z"
+    },
+    {
+      "id": "clxHistory125",
+      "locationId": "clx1234567890",
+      "userId": "clxUser123",
+      "user": {...},
+      "action": "CREATED",
+      "field": null,
+      "oldValue": null,
+      "newValue": null,
+      "metadata": null,
+      "createdAt": "2026-02-08T12:00:00.000Z"
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 20,
+    "total": 7,
+    "totalPages": 1
+  }
+}
+
+

History Actions:

+
    +
  • CREATED — Location created
  • +
  • UPDATED — Field changed (address, name, email, etc.)
  • +
  • GEOCODED — Auto-geocoded (address → lat/lng)
  • +
  • MOVED_ON_MAP — Coordinates changed via map drag
  • +
  • DELETED — Location deleted (orphaned history records)
  • +
+
+

Public Endpoint Details

+

GET /api/map/locations/public

+

Get locations for public map (PII-filtered).

+

Query Parameters:

+
    +
  • minLat, maxLat, minLng, maxLng (optional): Bounding box
  • +
+

Example Request:

+
curl "http://api.cmlite.org/api/public/map/locations?minLat=43.6&maxLat=43.7&minLng=-79.4&maxLng=-79.3"
+
+

Response (200 OK):

+
[
+  {
+    "id": "clx1234567890",
+    "latitude": 43.6532,
+    "longitude": -79.3832,
+    "supportLevel": "LEVEL_1",
+    "sign": true,
+    "signSize": "Large",
+    "unitNumber": "Apt 4",
+    "address": "123 Main St, Toronto, ON"
+  }
+]
+
+

PII Filtering:

+

Only returns non-sensitive fields:

+
    +
  • Included: id, latitude, longitude, supportLevel, sign, signSize, unitNumber, address
  • +
  • Excluded: firstName, lastName, email, phone, notes, buildingNotes, geocodeConfidence, geocodeProvider, createdByUserId, postalCode, province, federalDistrict, buildingUse
  • +
+
+

Service Functions

+

locationsService.create(data, userId)

+

Create location with auto-geocoding.

+

Auto-Geocoding Logic:

+
if (data.address && data.latitude == null && data.longitude == null) {
+  const result = await geocodingService.geocode(data.address);
+  if (result) {
+    createData.latitude = result.latitude;
+    createData.longitude = result.longitude;
+    createData.geocodeConfidence = result.confidence;
+    createData.geocodeProvider = result.provider;
+  }
+}
+
+

History Recording:

+

Creates history record in transaction:

+
const location = await prisma.$transaction(async (tx) => {
+  const newLocation = await tx.location.create({ data: createData });
+
+  await tx.locationHistory.create({
+    data: {
+      locationId: newLocation.id,
+      userId,
+      action: geocodeMetadata ? LocationHistoryAction.GEOCODED : LocationHistoryAction.CREATED,
+      metadata: geocodeMetadata,
+    },
+  });
+
+  return newLocation;
+});
+
+
+

locationsService.update(id, data, userId)

+

Update location with smart geocoding and history tracking.

+

Smart Geocoding:

+
    +
  • If address changes and no explicit lat/lng: re-geocode
  • +
  • If lat/lng provided: use provided coordinates (manual override)
  • +
+

Action Detection:

+
let action: LocationHistoryAction = LocationHistoryAction.UPDATED;
+
+// Explicit coordinate change (map drag)
+if (data.latitude !== undefined && data.latitude !== existing.latitude) {
+  action = LocationHistoryAction.MOVED_ON_MAP;
+}
+
+// Auto-geocode on address change
+if (data.address && data.address !== existing.address && !data.latitude && !data.longitude) {
+  const result = await geocodingService.geocode(data.address);
+  if (result) {
+    updateData.latitude = result.latitude;
+    updateData.longitude = result.longitude;
+    action = LocationHistoryAction.GEOCODED;
+  }
+}
+
+

Change Tracking:

+
const changes: { field: string; oldValue: unknown; newValue: unknown }[] = [];
+
+const fieldsToTrack = ['address', 'firstName', 'lastName', 'email', 'phone', 'unitNumber', 'supportLevel', 'sign', 'signSize', 'notes'];
+
+for (const field of fieldsToTrack) {
+  if (data[field] !== undefined && data[field] !== existing[field]) {
+    changes.push({ field, oldValue: existing[field], newValue: data[field] });
+  }
+}
+
+// Record all changes in transaction
+await tx.locationHistory.createMany({ data: historyRecords });
+
+
+

locationsService.importFromCsv(buffer, userId)

+

Import CSV with flexible column mapping.

+

Column Mapping:

+
const CSV_HEADER_MAP: Record<string, keyof CsvRow> = {
+  'address': 'address',
+  'street': 'address',
+  'street address': 'address',
+  'first name': 'firstName',
+  'firstname': 'firstName',
+  // ... 50+ mappings
+};
+
+

Processing:

+
    +
  1. Parse CSV with csv-parse library
  2. +
  3. Detect column mapping from headers
  4. +
  5. For each row:
  6. +
  7. Validate required fields (address)
  8. +
  9. Parse support level, sign boolean
  10. +
  11. Use provided lat/lng or geocode address
  12. +
  13. Create location in database
  14. +
  15. Return summary statistics
  16. +
+
+

locationsService.importBulk(buffer, userId, options, filters)

+

Bulk import NAR or standard CSV with advanced filtering.

+

NAR Format Detection:

+
function detectNarFormat(headers: string[]): boolean {
+  const NAR_DETECT_COLUMNS = [
+    'CIVIC_NO', 'OFFICIAL_STREET_NAME', 'BG_X', 'BG_Y', // 2025 format
+    'STR_NBR', 'STR_NME', 'LAT', 'LNG',                 // Legacy format
+  ];
+
+  const normalizedHeaders = headers.map((h) => h.trim().toUpperCase());
+  let matchCount = 0;
+
+  for (const col of NAR_DETECT_COLUMNS) {
+    if (normalizedHeaders.includes(col)) matchCount++;
+  }
+
+  return matchCount >= 3; // At least 3 NAR columns
+}
+
+

3-Phase Processing:

+

Phase 1: Parse & Filter

+
// Parse all records
+for (const record of records) {
+  // Build address from NAR fields
+  const civicNo = getValue('CIVIC_NO');
+  const streetName = getValue('STREET_NAME');
+  const address = [civicNo, streetName, ...].join(' ');
+
+  // Apply filters
+  if (filters?.city && !matchesCity(address, filters.city)) {
+    skippedOutOfBounds++;
+    continue;
+  }
+
+  // Residential filter
+  if (options.residentialOnly && buildingUse === 3) {
+    skippedOutOfBounds++;
+    continue;
+  }
+
+  parsedRecords.push({ address, lat, lng, needsGeocoding });
+}
+
+

Phase 2: Batch Geocode

+
// Collect addresses needing geocoding
+const addressesToGeocode: string[] = parsedRecords
+  .filter(r => r.needsGeocoding)
+  .map(r => r.address);
+
+// Batch geocode (parallel)
+const geocodeResults = await geocodingService.geocodeBatch(addressesToGeocode);
+
+

Phase 3: Create Records

+
const batch: Prisma.LocationCreateManyInput[] = [];
+
+for (const parsed of parsedRecords) {
+  // Apply geocoding result
+  if (parsed.needsGeocoding) {
+    const result = geocodeResults[geocodeIndex];
+    if (result) {
+      lat = result.latitude;
+      lng = result.longitude;
+    }
+  }
+
+  // Cut polygon filter
+  if (filters?.cutPolygon) {
+    if (!isPointInPolygon(lat, lng, cutPolygon)) {
+      skippedOutOfBounds++;
+      continue;
+    }
+  }
+
+  // Deduplication
+  if (existingCoords.has(coordKey)) {
+    skippedDuplicate++;
+    continue;
+  }
+
+  batch.push({ address, lat, lng, ... });
+
+  // Flush batch
+  if (batch.length >= options.batchSize) {
+    await prisma.location.createMany({ data: batch });
+    batch.length = 0;
+  }
+}
+
+
+

locationsService.exportToCsv(filters?)

+

Export locations as CSV.

+

CSV Generation:

+
import { stringify } from 'csv-stringify/sync';
+
+const rows = locations.map((loc) => ({
+  address: loc.address || '',
+  firstName: loc.firstName || '',
+  lastName: loc.lastName || '',
+  email: loc.email || '',
+  phone: loc.phone || '',
+  unitNumber: loc.unitNumber || '',
+  supportLevel: loc.supportLevel || '',
+  sign: loc.sign ? 'Yes' : 'No',
+  signSize: loc.signSize || '',
+  notes: loc.notes || '',
+  latitude: loc.latitude?.toString() || '',
+  longitude: loc.longitude?.toString() || '',
+  geocodeConfidence: loc.geocodeConfidence?.toString() || '',
+  geocodeProvider: loc.geocodeProvider || '',
+  createdAt: loc.createdAt.toISOString(),
+}));
+
+return stringify(rows, { header: true });
+
+
+

Validation Schemas

+

Create Location Schema

+
export const createLocationSchema = z.object({
+  address: z.string().min(1, 'Address is required'),
+  firstName: z.string().optional(),
+  lastName: z.string().optional(),
+  email: z.string().email().optional().or(z.literal('')),
+  phone: z.string().optional(),
+  unitNumber: z.string().optional(),
+  supportLevel: z.nativeEnum(SupportLevel).optional(),
+  sign: z.boolean().optional().default(false),
+  signSize: z.string().optional(),
+  notes: z.string().optional(),
+  buildingNotes: z.string().max(2000).optional(),
+  latitude: z.number().min(-90).max(90).optional(),
+  longitude: z.number().min(-180).max(180).optional(),
+});
+
+

Bulk Import Schema

+
export const bulkImportSchema = z.object({
+  format: z.enum(['standard', 'nar']).default('standard'),
+  filterType: z.enum(['none', 'cut', 'mapArea', 'city', 'province']).default('none'),
+  cutId: z.string().optional(),
+  filterCity: z.string().optional(),
+  filterProvince: z.string().optional(),
+  residentialOnly: z.coerce.boolean().default(false),
+  deduplicateRadius: z.coerce.number().min(0).max(100).default(5),
+  skipGeocoding: z.coerce.boolean().default(true),
+  batchSize: z.coerce.number().int().min(100).max(5000).default(1000),
+});
+
+
+

Code Examples

+

Admin: Create Location with Auto-Geocoding

+
import { api } from '@/lib/api';
+import { message } from 'antd';
+
+const createLocation = async () => {
+  try {
+    const { data } = await api.post('/api/map/locations', {
+      address: '123 Main St, Toronto, ON',
+      firstName: 'John',
+      lastName: 'Doe',
+      email: 'john@example.com',
+      supportLevel: 'LEVEL_1',
+      sign: true,
+    });
+
+    message.success('Location created and geocoded');
+    console.log(`Created at: ${data.latitude}, ${data.longitude}`);
+    console.log(`Confidence: ${data.geocodeConfidence}%`);
+  } catch (error) {
+    message.error('Failed to create location');
+  }
+};
+
+

Admin: Import NAR File with Cut Filter

+
import { api } from '@/lib/api';
+import { message } from 'antd';
+
+const importNAR = async (file: File, cutId: string) => {
+  const formData = new FormData();
+  formData.append('file', file);
+  formData.append('format', 'nar');
+  formData.append('filterType', 'cut');
+  formData.append('cutId', cutId);
+  formData.append('residentialOnly', 'true');
+  formData.append('deduplicateRadius', '5');
+
+  try {
+    const { data } = await api.post('/api/map/locations/import-bulk', formData, {
+      headers: { 'Content-Type': 'multipart/form-data' },
+      timeout: 300000, // 5 minutes
+    });
+
+    message.success(`Created ${data.created} locations`);
+    console.log(`Skipped ${data.skippedDuplicate} duplicates`);
+    console.log(`Skipped ${data.skippedOutOfBounds} out of bounds`);
+  } catch (error) {
+    message.error('NAR import failed');
+  }
+};
+
+

Admin: Export Locations

+
import { api } from '@/lib/api';
+import { message } from 'antd';
+
+const exportLocations = async () => {
+  try {
+    const { data } = await api.get('/api/map/locations/export-csv', {
+      responseType: 'blob',
+    });
+
+    const url = window.URL.createObjectURL(new Blob([data]));
+    const link = document.createElement('a');
+    link.href = url;
+    link.setAttribute('download', 'locations.csv');
+    document.body.appendChild(link);
+    link.click();
+    link.remove();
+
+    message.success('Locations exported');
+  } catch (error) {
+    message.error('Export failed');
+  }
+};
+
+
+

Frontend Integration

+

The LocationsPage component (admin/src/pages/LocationsPage.tsx) provides:

+
    +
  • Location table with pagination (20 results/page)
  • +
  • Search (address, name, email)
  • +
  • Filters (support level, sign, confidence level)
  • +
  • Sorting (createdAt, address, supportLevel)
  • +
  • Statistics dashboard (total, support levels, signs, geocoded, confidence breakdown, provider distribution)
  • +
  • Create location modal (form with auto-geocoding preview)
  • +
  • Edit location modal (pre-populated form)
  • +
  • Delete location action
  • +
  • Bulk delete (select multiple rows)
  • +
  • CSV import (10MB limit)
  • +
  • NAR bulk import (100MB limit, cut/city/province filters)
  • +
  • CSV export (download button)
  • +
  • Geocode missing button (batch geocodes all ungeocoded)
  • +
  • Location history drawer (audit trail with user, action, field changes)
  • +
  • Map integration (shows all geocoded locations, click-to-add, drag-to-move)
  • +
+

State Management:

+
const [locations, setLocations] = useState<Location[]>([]);
+const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
+const [filters, setFilters] = useState({ search: '', supportLevel: null, hasSign: null, confidenceLevel: null });
+const [stats, setStats] = useState({ total: 0, supportLevels: {}, signs: 0, geocoded: 0, ungeocoded: 0, confidence: {}, providers: {} });
+
+
+

Performance Considerations

+

Batch Processing:

+
    +
  • NAR import uses 1000-record batches (configurable)
  • +
  • Reduces transaction overhead
  • +
  • Improves import speed (10,000+ locations/minute)
  • +
+

Deduplication:

+
    +
  • Coordinate-based (5 decimal places = ~1.1m precision)
  • +
  • In-memory Set for fast lookups
  • +
  • Prevents duplicate imports within same file
  • +
+

Indexing:

+
    +
  • @@index([latitude, longitude]) — Fast map bounds queries
  • +
  • @@index([supportLevel]) — Fast filtering by support level
  • +
  • @@index([sign]) — Fast sign filtering
  • +
  • @@index([geocodeConfidence]) — Fast confidence filtering
  • +
+

Safety Limits:

+
    +
  • Map queries limited to 5000 locations
  • +
  • CSV import limited to 10MB
  • +
  • Bulk import limited to 100MB (5-minute timeout)
  • +
  • Bulk import warning header when limit hit
  • +
+

Geocoding:

+
    +
  • Auto-geocodes on create/update (individual addresses)
  • +
  • Batch geocoding for bulk imports (parallel processing)
  • +
  • Uses BullMQ queue for background geocoding (separate service)
  • +
+
+

Troubleshooting

+

Issue: CSV import fails with "Invalid CSV file format"

+

Cause: CSV not UTF-8 encoded or has malformed rows

+

Solution:

+
    +
  • Save CSV as UTF-8 in Excel/LibreOffice
  • +
  • Ensure no missing quote delimiters
  • +
  • Remove empty rows at end of file
  • +
+

Issue: NAR import skips all records (skippedOutOfBounds = total)

+

Cause: Cut/city/province filter doesn't match any records

+

Solution:

+
    +
  • Verify cut ID is correct
  • +
  • Check city/province spelling matches NAR data (case-insensitive)
  • +
  • Try without filters first to verify file format
  • +
+

Issue: Geocoding confidence is low (<60) for many locations

+

Cause: Incomplete addresses or geocoding provider limitations

+

Solution:

+
    +
  • Use NAR import (has pre-geocoded coordinates)
  • +
  • Add city/province to addresses
  • +
  • Try different geocoding provider (see settings)
  • +
  • Use "Geocode Missing" button to retry with fallback providers
  • +
+

Issue: Bulk import times out after 5 minutes

+

Cause: File too large or too many locations to geocode

+

Solution:

+
    +
  • Set skipGeocoding=true for NAR imports (coordinates included)
  • +
  • Split large files into smaller batches
  • +
  • Use cut filter to reduce import size
  • +
  • Increase batchSize parameter (1000 → 2000)
  • +
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/backend/modules/media/index.html b/mkdocs/site/v2/backend/modules/media/index.html new file mode 100644 index 00000000..cb1c8888 --- /dev/null +++ b/mkdocs/site/v2/backend/modules/media/index.html @@ -0,0 +1,7158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Media Module - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Media Module (Fastify Video Library API)

+

Overview

+

The Media module is a separate Fastify microservice running on port 4100 (separate from the main Express API on port 4000). It provides a complete video library management system with public gallery features, reaction tracking, and job queue for video processing. The module uses Drizzle ORM (unlike the main API's Prisma ORM) and shares the same PostgreSQL database.

+

Key Features:

+
    +
  • Dual API architecture:
  • +
  • Main Express API (port 4000) — Prisma ORM
  • +
  • Media Fastify API (port 4100) — Drizzle ORM
  • +
  • Shared PostgreSQL 16 database
  • +
  • Video library management:
  • +
  • Directory-based organization (studios, gifs, private, inbox, curated, etc.)
  • +
  • Metadata tracking (duration, quality, orientation, file size, dimensions)
  • +
  • Thumbnail generation and storage
  • +
  • File hash-based deduplication
  • +
  • Public gallery system:
  • +
  • Category-based organization
  • +
  • Engagement tracking (views, upvotes, comments, watch time)
  • +
  • Lock/unlock system for controlling public visibility
  • +
  • Session-based upvoting (no auth required)
  • +
  • Reaction system:
  • +
  • 6 emoji reactions (👍 like, ❤️ love, 😂 laugh, 😮 wow, 😢 sad, 😠 angry)
  • +
  • Timestamped reactions (mark specific moments in videos)
  • +
  • User-based tracking (authenticated users)
  • +
  • Job queue:
  • +
  • Video processing job management
  • +
  • Resource category allocation (GPU AI, GPU encode, CPU)
  • +
  • Queue position tracking with VRAM requirements
  • +
  • Pipeline integration for multi-step processing
  • +
  • Compilation management:
  • +
  • Multi-video compilation tracking
  • +
  • Settings preservation
  • +
  • Feature flag: ENABLE_MEDIA_FEATURES=true (opt-in)
  • +
+

File Paths

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FilePurpose
api/src/media-server.tsFastify server entry point (port 4100)
api/src/modules/media/db/schema.tsDrizzle schema (15+ tables, 1,400+ lines)
api/src/modules/media/routes/videos.routes.tsVideo CRUD routes (99 lines)
api/src/modules/media/routes/public-media.routes.tsPublic gallery routes (12,852 lines)
api/src/modules/media/routes/reactions.routes.tsReaction routes (135 lines)
api/src/modules/media/routes/comments.routes.tsComment routes (4,827 lines)
api/src/modules/media/middleware/auth.tsFastify auth middleware (JWT verification)
api/src/modules/media/types/enums.tsShared enums
+

Database Models (Drizzle ORM)

+

Videos Table

+
export const videos = pgTable('videos', {
+  id: serial('id').primaryKey(),
+  path: text('path').notNull().unique(),
+  filename: text('filename').notNull(),
+  producer: text('producer'),
+  creator: text('creator'),
+  title: text('title'),
+  durationSeconds: integer('duration_seconds'),
+  quality: text('quality'),
+  orientation: text('orientation'),
+  hasAudio: boolean('has_audio').default(true),
+  fileSize: bigint('file_size', { mode: 'number' }),
+  fileHash: text('file_hash'),
+  width: integer('width'),
+  height: integer('height'),
+  lastValidated: timestamp('last_validated', { withTimezone: true }),
+  isValid: boolean('is_valid').default(true),
+  thumbnailPath: text('thumbnail_path'),
+  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
+  tags: jsonb('tags').$type<string[]>(),
+
+  // Directory type for efficient filtering
+  directoryType: text('directory_type').$type<DirectoryType>(),
+
+  // Historical engagement stats (preserved when moved from public media)
+  publicViewCount: integer('public_view_count'),
+  publicUpvoteCount: integer('public_upvote_count'),
+  publicCommentCount: integer('public_comment_count'),
+  publicCompletionCount: integer('public_completion_count'),
+  publicTotalWatchTime: integer('public_total_watch_time'),
+  movedFromPublicAt: timestamp('moved_from_public_at', { withTimezone: true }),
+
+  // Name standardization tracking
+  originalFilename: text('original_filename'),
+  originalPath: text('original_path'),
+  standardizedAt: timestamp('standardized_at', { withTimezone: true }),
+}, (table) => ({
+  orientationIdx: index('idx_orientation').on(table.orientation),
+  producerIdx: index('idx_producer').on(table.producer),
+  isValidIdx: index('idx_is_valid').on(table.isValid),
+  directoryTypeIdx: index('idx_directory_type').on(table.directoryType),
+  fingerprintIdx: index('idx_videos_fingerprint').on(
+    table.durationSeconds, table.fileSize, table.width, table.height
+  ),
+  directoryValidOrientationIdx: index('idx_videos_directory_valid_orientation').on(
+    table.directoryType, table.isValid, table.orientation
+  ),
+}));
+
+// Directory types
+export const DIRECTORY_TYPES = [
+  'studios', 'gifs', 'private', 'inbox', 'curated',
+  'playback', 'compilations', 'videos', 'highlights'
+] as const;
+export type DirectoryType = typeof DIRECTORY_TYPES[number];
+
+

Key Features:

+
    +
  • Unique path constraint — Prevents duplicate entries
  • +
  • File hash — Enables deduplication based on content
  • +
  • Fingerprint index — Fast duplicate detection (duration + fileSize + width + height)
  • +
  • Directory type — Efficient filtering by category
  • +
  • Historical stats — Preserves engagement metrics when moving from public gallery
  • +
  • Standardization tracking — Tracks original filename before renaming
  • +
+
+

Public Media Table

+
export const publicMedia = pgTable('public_media', {
+  id: serial('id').primaryKey(),
+  path: text('path').notNull().unique(),
+  filename: text('filename').notNull(),
+  category: text('category').notNull(),
+  durationSeconds: integer('duration_seconds'),
+  quality: text('quality'),
+  orientation: text('orientation'),
+  thumbnailPath: text('thumbnail_path'),
+  fileSize: bigint('file_size', { mode: 'number' }),
+
+  // Denormalized counters for performance
+  viewCount: integer('view_count').default(0),
+  upvoteCount: integer('upvote_count').default(0),
+  commentCount: integer('comment_count').default(0),
+  finishCount: integer('finish_count').default(0),
+  totalWatchTime: integer('total_watch_time').default(0),
+
+  // Lock system
+  isLocked: boolean('is_locked').default(false),
+  lockedAt: timestamp('locked_at', { withTimezone: true }),
+  lockedReason: text('locked_reason'),
+
+  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
+  updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
+}, (table) => ({
+  categoryIdx: index('idx_public_media_category').on(table.category),
+  orientationIdx: index('idx_public_media_orientation').on(table.orientation),
+  viewCountIdx: index('idx_public_media_views').on(table.viewCount),
+  upvoteCountIdx: index('idx_public_media_upvotes').on(table.upvoteCount),
+  isLockedIdx: index('idx_public_media_locked').on(table.isLocked),
+}));
+
+

Key Features:

+
    +
  • Denormalized counters — Fast sorting by popularity (no joins)
  • +
  • Lock system — Admin can lock videos to prevent public access
  • +
  • Category organization — Flexible categorization system
  • +
  • Performance indexes — Optimized for sorting by views/upvotes
  • +
+
+

Upvotes Table

+
export const upvotes = pgTable('upvotes', {
+  id: serial('id').primaryKey(),
+  mediaId: integer('media_id').notNull(),
+  sessionId: text('session_id').notNull(),
+  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
+}, (table) => ({
+  uniqueVoteIdx: index('idx_upvotes_unique').on(table.mediaId, table.sessionId),
+  mediaIdx: index('idx_upvotes_media').on(table.mediaId),
+}));
+
+

Key Features:

+
    +
  • Session-based — No authentication required (anonymous upvoting)
  • +
  • Unique constraint — One upvote per session per media item
  • +
  • Denormalized — upvoteCount in publicMedia table updated via trigger or application logic
  • +
+
+

Video Reactions Table

+
export const REACTION_TYPES = ['like', 'love', 'laugh', 'wow', 'sad', 'angry'] as const;
+export type ReactionType = typeof REACTION_TYPES[number];
+
+export const videoReactions = pgTable('video_reactions', {
+  id: serial('id').primaryKey(),
+  userId: integer('user_id').notNull(),
+  mediaId: integer('media_id').notNull(),
+  reactionType: text('reaction_type').notNull(),
+  videoTimestamp: integer('video_timestamp').notNull(), // seconds into video
+  createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
+}, (table) => ({
+  userMediaTypeIdx: index('idx_video_reactions_user_media_type').on(
+    table.userId, table.mediaId, table.reactionType
+  ),
+  mediaTimestampIdx: index('idx_video_reactions_media_timestamp').on(
+    table.mediaId, table.videoTimestamp
+  ),
+  mediaIdx: index('idx_video_reactions_media').on(table.mediaId),
+  createdAtIdx: index('idx_video_reactions_created').on(table.createdAt),
+}));
+
+

Reaction Emojis:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeEmojiLabel
like👍Like
love❤️Love
laugh😂Laugh
wow😮Wow
sad😢Sad
angry😠Angry
+

Key Features:

+
    +
  • Timestamped reactions — Mark specific moments in videos
  • +
  • User-based — Requires authentication
  • +
  • Timeline visualization — Can show reaction heatmap across video timeline
  • +
+
+

Jobs Table

+
export type ResourceCategory = 'gpu_ai' | 'gpu_encode' | 'cpu';
+export type JobStatus = 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
+
+export const jobs = pgTable('jobs', {
+  id: serial('id').primaryKey(),
+  type: text('type').notNull(),
+  status: text('status').default('pending').$type<JobStatus>(),
+  progress: integer('progress').default(0),
+  log: text('log'),
+  params: jsonb('params').$type<Record<string, unknown>>(),
+  startedAt: timestamp('started_at', { withTimezone: true }),
+  completedAt: timestamp('completed_at', { withTimezone: true }),
+  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
+
+  // Queue management
+  resourceCategory: text('resource_category').default('cpu').$type<ResourceCategory>(),
+  vramRequired: integer('vram_required').default(0),
+  queuePosition: integer('queue_position'),
+  waitingReason: text('waiting_reason'),
+  priority: integer('priority').default(5),
+
+  // Pipeline integration
+  pipelineId: integer('pipeline_id'),
+  pipelineStepId: integer('pipeline_step_id'),
+}, (table) => ({
+  queueIdx: index('idx_jobs_queue').on(table.status, table.priority, table.createdAt),
+  resourceIdx: index('idx_jobs_resource').on(table.resourceCategory, table.status),
+  pipelineIdx: index('idx_jobs_pipeline').on(table.pipelineId),
+}));
+
+

Job Types:

+
    +
  • compilation — Multi-video compilation
  • +
  • scan, public_scan — Video library scanning
  • +
  • organize, organize_studio — Automatic organization
  • +
  • reencode_streaming — Transcode for web streaming
  • +
  • compile_random, compile_quad, compile_quad_horizontal, etc. — Compilation variants
  • +
  • generate_gif, fetch, digest, clip_generate, highlight_generate — Content generation
  • +
  • tag_generation, scene_extract, clip_extract_only, auto_organize_publish — AI-powered tasks
  • +
+

Resource Categories:

+
    +
  • gpu_ai — AI/ML tasks (scene detection, tagging, etc.) — High VRAM
  • +
  • gpu_encode — Video encoding/transcoding — Medium VRAM
  • +
  • cpu — General processing — No GPU required
  • +
+
+

Compilations Table

+
export const compilations = pgTable('compilations', {
+  id: serial('id').primaryKey(),
+  filename: text('filename').notNull(),
+  path: text('path'),
+  durationSeconds: integer('duration_seconds'),
+  videoIds: jsonb('video_ids').$type<number[]>(),
+  settings: jsonb('settings').$type<Record<string, unknown>>(),
+  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
+});
+
+

Key Features:

+
    +
  • Multi-video tracking — Stores array of source video IDs
  • +
  • Settings preservation — Stores compilation parameters (layout, transitions, etc.)
  • +
+
+

API Endpoints

+

Admin Endpoints (Videos)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathAuthDescription
GET/api/videosAdmin rolesList videos with pagination
GET/api/videos/:idAdmin rolesGet single video
GET/api/videos/healthNoneHealth check
+

Admin Roles: Requires admin role via Fastify auth middleware

+

Public Media Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathAuthDescription
GET/api/media/publicNoneList shared media (paginated, filterable, sorted)
GET/api/media/public/:idNoneGet single media + increment view count
POST/api/media/public/:id/upvoteNoneUpvote media (session-based)
DELETE/api/media/public/:id/upvoteNoneRemove upvote
POST/api/media/public/:id/finishNoneMark video as finished
POST/api/media/public/:id/watch-timeNoneTrack watch time
+

Reaction Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathAuthDescription
POST/api/reactionsRequiredAdd reaction to video
GET/api/reactionsNoneGet reactions (filterable by mediaId/userId)
GET/api/reactions/configNoneGet available reaction types
+

Comment Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + +
MethodPathAuthDescription
POST/api/media/commentsOptionalAdd comment (auth optional, session-based)
GET/api/media/commentsNoneList comments for media
+
+

Endpoint Details

+

GET /api/videos

+

List videos with pagination and search (admin only).

+

Query Parameters:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeDefaultDescription
limitnumber50Results per page (max 100)
offsetnumber0Skip N results
searchstring-Search title (case-insensitive)
+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://localhost:4100/api/videos?limit=20&offset=0&search=demo"
+
+

Response (200 OK):

+
{
+  "videos": [
+    {
+      "id": 123,
+      "title": "Demo Video",
+      "filename": "demo-video.mp4",
+      "duration": 300,
+      "fileSize": 52428800,
+      "width": 1920,
+      "height": 1080,
+      "createdAt": "2026-02-01T12:00:00.000Z",
+      "updatedAt": "2026-02-11T14:30:00.000Z"
+    }
+  ],
+  "total": 45,
+  "limit": 20,
+  "offset": 0
+}
+
+
+

GET /api/media/public

+

List shared media with pagination, filtering, and sorting (no auth required).

+

Query Parameters:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeDefaultDescription
categorystring-Filter by category
searchstring-Search filename/path
sortenumrecentSort: recent, popular, most_viewed
orientationstring-Filter by orientation
limitnumber24Results per page (max 100)
offsetnumber0Skip N results
+

Example Request:

+
curl "http://localhost:4100/api/media/public?category=highlights&sort=popular&limit=12"
+
+

Response (200 OK):

+
{
+  "videos": [
+    {
+      "id": 456,
+      "filename": "highlight-2024-01-15.mp4",
+      "category": "highlights",
+      "durationSeconds": 45,
+      "quality": "1080p",
+      "orientation": "landscape",
+      "thumbnailPath": "/thumbnails/highlight-2024-01-15.jpg",
+      "viewCount": 1250,
+      "upvoteCount": 89,
+      "commentCount": 12,
+      "isLocked": false,
+      "createdAt": "2026-01-15T10:00:00.000Z"
+    }
+  ],
+  "pagination": {
+    "total": 145,
+    "limit": 12,
+    "offset": 0,
+    "hasMore": true
+  }
+}
+
+

Sort Modes:

+
switch (sort) {
+  case 'popular':
+    orderBy = [desc(publicMedia.upvoteCount), desc(publicMedia.createdAt)];
+    break;
+  case 'most_viewed':
+    orderBy = [desc(publicMedia.viewCount), desc(publicMedia.createdAt)];
+    break;
+  case 'recent':
+  default:
+    orderBy = [desc(publicMedia.createdAt)];
+    break;
+}
+
+
+

GET /api/media/public/:id

+

Get single media details and increment view count (no auth required).

+

Path Parameters:

+
    +
  • id (number): Media ID
  • +
+

Example Request:

+
curl "http://localhost:4100/api/media/public/456"
+
+

Response (200 OK):

+
{
+  "id": 456,
+  "path": "/public/highlights/highlight-2024-01-15.mp4",
+  "filename": "highlight-2024-01-15.mp4",
+  "category": "highlights",
+  "durationSeconds": 45,
+  "quality": "1080p",
+  "orientation": "landscape",
+  "thumbnailPath": "/thumbnails/highlight-2024-01-15.jpg",
+  "fileSize": 15728640,
+  "viewCount": 1251,
+  "upvoteCount": 89,
+  "commentCount": 12,
+  "finishCount": 420,
+  "totalWatchTime": 48600,
+  "isLocked": false,
+  "createdAt": "2026-01-15T10:00:00.000Z",
+  "updatedAt": "2026-02-11T15:45:00.000Z"
+}
+
+

Side Effect:

+

View count is incremented fire-and-forget (does not block response):

+
// Increment view count (fire and forget)
+db.update(publicMedia)
+  .set({ viewCount: sql`${publicMedia.viewCount} + 1` })
+  .where(eq(publicMedia.id, mediaId))
+  .execute()
+  .catch(err => logger.error({ err }, 'Failed to increment view count'));
+
+
+

POST /api/media/public/:id/upvote

+

Upvote media (session-based, no auth required).

+

Path Parameters:

+
    +
  • id (number): Media ID
  • +
+

Request Body:

+
{
+  "sessionId": "sess_abc123def456"
+}
+
+

Response (200 OK):

+
{
+  "success": true,
+  "upvoted": true,
+  "upvoteCount": 90
+}
+
+

Behavior:

+
    +
  • Idempotent — If already upvoted, returns existing upvote
  • +
  • Denormalized counter — Updates publicMedia.upvoteCount atomically
  • +
  • Session-based — No authentication required
  • +
+

Duplicate Prevention:

+
// Check if already upvoted
+const [existingVote] = await db
+  .select()
+  .from(upvotes)
+  .where(and(
+    eq(upvotes.mediaId, mediaId),
+    eq(upvotes.sessionId, sessionId)
+  ));
+
+if (existingVote) {
+  return reply.send({ success: true, upvoted: true, upvoteCount: media.upvoteCount });
+}
+
+
+

DELETE /api/media/public/:id/upvote

+

Remove upvote (session-based).

+

Path Parameters:

+
    +
  • id (number): Media ID
  • +
+

Query Parameters:

+
    +
  • sessionId (string): Session ID
  • +
+

Response (200 OK):

+
{
+  "success": true,
+  "upvoted": false,
+  "upvoteCount": 89
+}
+
+
+

POST /api/reactions

+

Add reaction to video (authenticated users only).

+

Request Body:

+
{
+  "mediaId": 456,
+  "reactionType": "love",
+  "videoTimestamp": 27
+}
+
+

Response (200 OK):

+
{
+  "success": true,
+  "reaction": {
+    "id": 789,
+    "mediaId": 456,
+    "userId": 123,
+    "reactionType": "love",
+    "videoTimestamp": 27,
+    "emoji": "❤️",
+    "formattedTime": "0:27",
+    "createdAt": "2026-02-11T15:50:00.000Z"
+  }
+}
+
+

Validation:

+
const REACTION_EMOJIS: Record<string, string> = {
+  like: '👍',
+  love: '❤️',
+  laugh: '😂',
+  wow: '😮',
+  sad: '😢',
+  angry: '😠',
+};
+
+if (!REACTION_EMOJIS[reactionType]) {
+  return fastify.httpErrors.badRequest('Invalid reaction type');
+}
+
+

Time Formatting:

+
function formatVideoTime(seconds: number): string {
+  const h = Math.floor(seconds / 3600);
+  const m = Math.floor((seconds % 3600) / 60);
+  const s = seconds % 60;
+
+  if (h > 0) {
+    return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
+  }
+  return `${m}:${s.toString().padStart(2, '0')}`;
+}
+
+// Examples:
+// 27 → "0:27"
+// 90 → "1:30"
+// 3661 → "1:01:01"
+
+
+

GET /api/reactions

+

Get reactions (filterable by mediaId/userId).

+

Query Parameters:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeDescription
mediaIdnumberFilter by media ID
userIdstringFilter by user ID
limitnumberResults per page (default 50)
+

Example Request:

+
curl "http://localhost:4100/api/reactions?mediaId=456&limit=20"
+
+

Response (200 OK):

+
{
+  "reactions": [
+    {
+      "id": 789,
+      "mediaId": 456,
+      "userId": 123,
+      "reactionType": "love",
+      "videoTimestamp": 27,
+      "emoji": "❤️",
+      "formattedTime": "0:27",
+      "createdAt": "2026-02-11T15:50:00.000Z"
+    },
+    {
+      "id": 790,
+      "mediaId": 456,
+      "userId": 124,
+      "reactionType": "laugh",
+      "videoTimestamp": 42,
+      "emoji": "😂",
+      "formattedTime": "0:42",
+      "createdAt": "2026-02-11T15:51:00.000Z"
+    }
+  ]
+}
+
+
+

GET /api/reactions/config

+

Get available reaction types.

+

Example Request:

+
curl "http://localhost:4100/api/reactions/config"
+
+

Response (200 OK):

+
{
+  "reactions": [
+    { "type": "like", "emoji": "👍", "label": "Like" },
+    { "type": "love", "emoji": "❤️", "label": "Love" },
+    { "type": "laugh", "emoji": "😂", "label": "Laugh" },
+    { "type": "wow", "emoji": "😮", "label": "Wow" },
+    { "type": "sad", "emoji": "😢", "label": "Sad" },
+    { "type": "angry", "emoji": "😠", "label": "Angry" }
+  ]
+}
+
+
+

Fastify vs Express Differences

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureExpress API (port 4000)Fastify Media API (port 4100)
FrameworkExpress 5Fastify
ORMPrismaDrizzle
Schema ValidationZod + middlewareFastify built-in
Auth Middlewareauthenticate, requireRoleauthenticate, requireAdminRole, optionalAuth
Error HandlingAppError class + error handler middlewarefastify.httpErrors + decorators
Route Registrationrouter.get(...)fastify.register(routes, { prefix })
Request Handler(req, res, next) => {}async (request, reply) => {}
Database Clientimport { prisma }import { db }
Query BuilderPrisma fluent APIDrizzle query builder
+

Code Pattern Comparison

+

Express (Prisma):

+
import { Router } from 'express';
+import { prisma } from '../../config/database';
+import { authenticate, requireRole } from '../../middleware/auth.middleware';
+
+const router = Router();
+
+router.get('/', authenticate, requireRole('ADMIN'), async (req, res, next) => {
+  try {
+    const users = await prisma.user.findMany({
+      where: { role: 'ADMIN' },
+      select: { id: true, email: true },
+    });
+    res.json(users);
+  } catch (err) {
+    next(err);
+  }
+});
+
+export default router;
+
+

Fastify (Drizzle):

+
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
+import { db } from '../db';
+import { users } from '../db/schema';
+import { eq } from 'drizzle-orm';
+import { requireAdminRole } from '../middleware/auth';
+
+export async function usersRoutes(fastify: FastifyInstance) {
+  fastify.get(
+    '/',
+    { preHandler: requireAdminRole },
+    async (request: FastifyRequest, reply: FastifyReply) => {
+      const results = await db
+        .select({ id: users.id, email: users.email })
+        .from(users)
+        .where(eq(users.role, 'ADMIN'));
+
+      return reply.send(results);
+    }
+  );
+}
+
+
+

Frontend Integration

+

The Media module integrates with multiple frontend pages:

+

Admin Pages

+
    +
  • LibraryPage (admin/src/pages/media/LibraryPage.tsx)
  • +
  • Video grid with thumbnails
  • +
  • Filter by directory type
  • +
  • Search by filename
  • +
  • +

    Bulk operations (lock, unlock, delete)

    +
  • +
  • +

    SharedMediaPage (admin/src/pages/media/SharedMediaPage.tsx)

    +
  • +
  • Public gallery admin
  • +
  • Category management
  • +
  • Lock/unlock controls
  • +
  • +

    Engagement metrics display

    +
  • +
  • +

    MediaJobsPage (admin/src/pages/media/MediaJobsPage.tsx)

    +
  • +
  • Job queue monitoring
  • +
  • Job status tracking (pending, queued, running, completed, failed)
  • +
  • Progress visualization
  • +
  • Resource category filtering
  • +
+

Public Pages

+
    +
  • MediaGalleryPage (admin/src/pages/public/MediaGalleryPage.tsx)
  • +
  • Public video gallery
  • +
  • Category filtering
  • +
  • Sort by recent/popular/most viewed
  • +
  • Upvote functionality (session-based)
  • +
  • +

    View count display

    +
  • +
  • +

    MediaViewerPage (admin/src/pages/public/MediaViewerPage.tsx)

    +
  • +
  • Video player with reactions
  • +
  • Timestamped reactions overlay
  • +
  • Comment section
  • +
  • Related videos
  • +
  • Share functionality
  • +
+

State Management:

+
// Admin: useMediaApi hook
+const { videos, loading, error } = useMediaApi('/api/videos', {
+  limit: 24,
+  offset: 0,
+  search: '',
+});
+
+// Public: Direct axios calls to media API
+const { data } = await axios.get('http://localhost:4100/api/media/public', {
+  params: { category: 'highlights', sort: 'popular', limit: 12 },
+});
+
+
+

Performance Considerations

+

Denormalized Counters

+

The publicMedia table uses denormalized counters for engagement metrics:

+
viewCount: integer('view_count').default(0),
+upvoteCount: integer('upvote_count').default(0),
+commentCount: integer('comment_count').default(0),
+finishCount: integer('finish_count').default(0),
+totalWatchTime: integer('total_watch_time').default(0),
+
+

Pros:

+
    +
  • Fast sorting — No joins or aggregations needed
  • +
  • Instant popularity ranking — Direct sorting on indexed columns
  • +
  • Simple queries — No complex GROUP BY clauses
  • +
+

Cons:

+
    +
  • Consistency risk — Counters can drift if transactions fail
  • +
  • Update overhead — Must update counter on every upvote/view
  • +
+

Mitigation:

+
    +
  • Use atomic updates: sql\${publicMedia.viewCount} + 1``
  • +
  • Run periodic reconciliation job to fix drift
  • +
+
+

Fire-and-Forget View Tracking

+

View count increments are fire-and-forget to avoid blocking response:

+
// Increment view count (fire and forget)
+db.update(publicMedia)
+  .set({ viewCount: sql`${publicMedia.viewCount} + 1` })
+  .where(eq(publicMedia.id, mediaId))
+  .execute()
+  .catch(err => logger.error({ err }, 'Failed to increment view count'));
+
+// Return immediately (don't await)
+return reply.send(media);
+
+

Trade-off:

+
    +
  • Faster response — User doesn't wait for view count update
  • +
  • Eventual consistency — View count may be slightly behind
  • +
+
+

Fingerprint-Based Deduplication

+

The videos table includes a composite index for fast duplicate detection:

+
fingerprintIdx: index('idx_videos_fingerprint').on(
+  table.durationSeconds, table.fileSize, table.width, table.height
+),
+
+

Usage:

+
const duplicates = await db
+  .select()
+  .from(videos)
+  .where(and(
+    eq(videos.durationSeconds, newVideo.durationSeconds),
+    eq(videos.fileSize, newVideo.fileSize),
+    eq(videos.width, newVideo.width),
+    eq(videos.height, newVideo.height),
+  ));
+
+if (duplicates.length > 0 && duplicates[0].fileHash === newVideo.fileHash) {
+  throw new Error('Duplicate video detected');
+}
+
+

Why Fingerprint Index:

+
    +
  • Fast pre-filter — Index lookup narrows candidates
  • +
  • File hash check — Confirms exact duplicate (expensive, only on candidates)
  • +
  • Two-stage approach — Balances speed and accuracy
  • +
+
+

Troubleshooting

+

Media API Not Starting

+

Problem:

+

Docker logs show "Media API server closed" immediately.

+

Diagnosis:

+

Check env vars:

+
docker compose exec api printenv | grep MEDIA
+
+

Required vars:

+
MEDIA_API_PORT=4100
+ENABLE_MEDIA_FEATURES=true
+MAX_UPLOAD_SIZE_GB=10
+
+

Solution:

+
    +
  • Verify ENABLE_MEDIA_FEATURES=true in .env
  • +
  • Check port conflicts: lsof -i :4100
  • +
  • Check database connection (shares same DATABASE_URL)
  • +
+
+

CORS Errors on Media API

+

Problem:

+

Frontend gets CORS errors when calling media API endpoints.

+

Diagnosis:

+

Check CORS origins:

+
CORS_ORIGINS=http://localhost:3000,http://localhost:3010
+
+

Behavior:

+
await fastify.register(cors, {
+  origin: (origin, cb) => {
+    if (!origin) {
+      cb(null, true);  // Allow no origin (mobile, curl)
+      return;
+    }
+
+    if (allowedOrigins.includes(origin)) {
+      cb(null, true);
+    } else {
+      cb(new Error('CORS not allowed'), false);
+    }
+  },
+  credentials: true,
+});
+
+

Solution:

+

Add missing origins to CORS_ORIGINS in .env:

+
CORS_ORIGINS=http://localhost:3000,http://localhost:3010,http://localhost:3100
+
+
+

Upvote Not Working

+

Problem:

+

Upvote button doesn't work, returns 400 error.

+

Diagnosis:

+

Check request body:

+
curl -X POST \
+  -H "Content-Type: application/json" \
+  -d '{"sessionId":"sess_abc123"}' \
+  http://localhost:4100/api/media/public/456/upvote
+
+

Common Issues:

+
    +
  1. +

    Missing sessionId: +

    { "error": "sessionId is required" }
    +

    +
  2. +
  3. +

    Media not found: +

    { "error": "Media not found" }
    +

    +
  4. +
  5. +

    Locked media: +

    { "error": "Media is locked" }
    +

    +
  6. +
+

Solution:

+
    +
  • Generate session ID in frontend: crypto.randomUUID() or nanoid()
  • +
  • Verify media exists in public_media table
  • +
  • Check isLocked status
  • +
+
+

Reactions Not Appearing

+

Problem:

+

Reactions submitted but not appearing in frontend.

+

Diagnosis:

+

Check reaction data:

+
SELECT * FROM video_reactions WHERE "mediaId" = 456 ORDER BY "createdAt" DESC LIMIT 10;
+
+

Verify:

+
    +
  • userId matches authenticated user
  • +
  • mediaId matches video ID
  • +
  • reactionType is valid emoji type
  • +
+

Common Issues:

+
    +
  1. Authentication failed:
  2. +
  3. Reaction requires auth
  4. +
  5. +

    Check JWT token in Authorization header

    +
  6. +
  7. +

    Invalid reaction type: +

    { "error": "Invalid reaction type" }
    +

    +
  8. +
  9. +

    Video not found: +

    { "error": "Video not found" }
    +

    +
  10. +
+

Solution:

+
    +
  • Verify JWT token is valid and not expired
  • +
  • Use valid reaction types: like, love, laugh, wow, sad, angry
  • +
  • Check video exists in videos table (not just public_media)
  • +
+
+

Job Queue Not Processing

+

Problem:

+

Jobs stuck in pending status, never transition to running.

+

Diagnosis:

+

Check job queue:

+
SELECT id, type, status, "resourceCategory", "queuePosition", "waitingReason"
+FROM jobs
+WHERE status IN ('pending', 'queued')
+ORDER BY priority DESC, "createdAt" ASC;
+
+

Common Issues:

+
    +
  1. No worker running:
  2. +
  3. Check if job worker process is running
  4. +
  5. +

    Verify ENABLE_MEDIA_FEATURES=true

    +
  6. +
  7. +

    Resource exhaustion:

    +
  8. +
  9. GPU jobs waiting for VRAM
  10. +
  11. +

    Check vramRequired vs available VRAM

    +
  12. +
  13. +

    Pipeline blocking:

    +
  14. +
  15. Pipeline step depends on previous step completion
  16. +
+

Solution:

+
    +
  • Start job worker: npm run worker:media or check Docker Compose
  • +
  • Adjust resource limits or priority
  • +
  • Check pipeline configuration for blocking issues
  • +
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/backend/modules/pages/index.html b/mkdocs/site/v2/backend/modules/pages/index.html new file mode 100644 index 00000000..f1b60b33 --- /dev/null +++ b/mkdocs/site/v2/backend/modules/pages/index.html @@ -0,0 +1,7490 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pages Module - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Pages Module (Landing Page Builder)

+

Overview

+

The Pages module provides a complete landing page builder with dual editing modes (WYSIWYG GrapesJS + direct HTML), automatic MkDocs export, and reusable block library. It enables admins to create custom landing pages visually or with code, publish them to public URLs (/p/:slug), and optionally export them to the MkDocs documentation site as Material theme overrides.

+

Key Features:

+
    +
  • Dual editor modes:
  • +
  • VISUAL — GrapesJS drag-and-drop WYSIWYG editor with custom blocks
  • +
  • CODE — Direct HTML editing for advanced users
  • +
  • Automatic slug generation from titles (collision-safe)
  • +
  • MkDocs export system:
  • +
  • Exports pages to mkdocs/overrides/ directory
  • +
  • Creates .md stub files with front matter for MkDocs Material
  • +
  • Two export modes: THEMED (Jinja2 extends main.html) or STANDALONE (full HTML document)
  • +
  • Configurable nav/TOC hiding via Material theme front matter
  • +
  • Reusable block library (hero, text, image, CTA, features, testimonials, form)
  • +
  • SEO metadata (title, description, image)
  • +
  • Public rendering at /p/:slug route
  • +
  • Sync & validation tools for managing MkDocs exports
  • +
  • Path traversal protection (null bytes, .., encoded sequences)
  • +
  • Published/draft workflow
  • +
+

File Paths

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FilePurpose
api/src/modules/pages/pages-admin.routes.tsAdmin router with 7 endpoints (114 lines)
api/src/modules/pages/pages-public.routes.tsPublic router (1 endpoint, 21 lines)
api/src/modules/pages/blocks.routes.tsBlock library router (5 endpoints, 88 lines)
api/src/modules/pages/pages.service.tsLanding page business logic + MkDocs export (637 lines)
api/src/modules/pages/blocks.service.tsBlock CRUD service (89 lines)
api/src/modules/pages/pages.schemas.tsZod validation schemas (83 lines)
+

Database Models

+
model LandingPage {
+  id                String            @id @default(cuid())
+  slug              String            @unique
+  title             String
+  description       String?           @db.Text
+  blocks            Json              // JSON from GrapesJS editor
+  htmlOutput        String?           @db.Text
+  cssOutput         String?           @db.Text
+  editorMode        EditorMode        @default(VISUAL)
+  mkdocsPath        String?           // Path in mkdocs/overrides/
+  mkdocsStubPath    String?           // Path to .md stub in mkdocs/docs/
+  mkdocsExportMode  MkdocsExportMode  @default(THEMED)
+  mkdocsHideNav     Boolean           @default(true)
+  mkdocsHideToc     Boolean           @default(true)
+  mkdocsSkipExport  Boolean           @default(false)
+  published         Boolean           @default(false)
+  seoTitle          String?
+  seoDescription    String?           @db.Text
+  seoImage          String?
+  createdAt         DateTime          @default(now())
+  updatedAt         DateTime          @updatedAt
+
+  @@map("landing_pages")
+}
+
+enum EditorMode {
+  VISUAL      // GrapesJS drag-and-drop editor
+  CODE        // Direct HTML editing
+}
+
+enum MkdocsExportMode {
+  THEMED      // Jinja2 extends main.html (Material theme integration)
+  STANDALONE  // Full HTML document (no Jinja2 inheritance)
+}
+
+model PageBlock {
+  id           String   @id @default(cuid())
+  type         String   // hero, text, image, cta, features, testimonials, form
+  label        String
+  schema       Json     // Block configuration schema (GrapesJS component definition)
+  defaults     Json     // Default values for new instances
+  thumbnail    String?
+  category     String?
+  sortOrder    Int      @default(0)
+  createdAt    DateTime @default(now())
+  updatedAt    DateTime @updatedAt
+
+  @@map("page_blocks")
+}
+
+

Key Fields:

+
    +
  • blocks — GrapesJS JSON state (saved on Ctrl+S in editor)
  • +
  • htmlOutput — Rendered HTML (generated by GrapesJS or manually entered in CODE mode)
  • +
  • cssOutput — Extracted CSS (from GrapesJS styles or manual entry)
  • +
  • mkdocsPath — Relative path in mkdocs/overrides/ (e.g., landing-page.html)
  • +
  • mkdocsStubPath — Relative path to .md stub (e.g., landing-page.md)
  • +
  • mkdocsExportMode — THEMED (Jinja2) or STANDALONE (full HTML)
  • +
  • mkdocsSkipExport — Skip MkDocs export (for internal pages only accessible via /p/:slug)
  • +
+

Slug Generation:

+
function generateSlug(title: string): string {
+  return title
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, '-')  // Replace non-alphanumeric with -
+    .replace(/^-+|-+$/g, '')      // Remove leading/trailing -
+    .slice(0, 80);                // Max 80 chars
+}
+
+

Example Transformations:

+
    +
  • "Landing Page"landing-page
  • +
  • "About Us — Contact Info"about-us-contact-info
  • +
  • "Landing Page" (duplicate) → landing-page-2
  • +
+

API Endpoints

+

Admin Endpoints (Authentication Required)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathAuthDescription
GET/api/pagesAdmin rolesList landing pages with pagination/filters
GET/api/pages/:idAdmin rolesGet single landing page
POST/api/pagesAdmin rolesCreate landing page
PUT/api/pages/:idAdmin rolesUpdate landing page (triggers MkDocs export)
DELETE/api/pages/:idAdmin rolesDelete landing page (removes MkDocs export)
POST/api/pages/syncAdmin rolesSync MkDocs overrides to database
POST/api/pages/validateAdmin rolesValidate and repair MkDocs exports
+

Admin Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

+

Block Library Endpoints (Admin Only)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathAuthDescription
GET/api/page-blocksAdmin rolesList blocks with category filter
GET/api/page-blocks/:idAdmin rolesGet single block
POST/api/page-blocksAdmin rolesCreate block
PUT/api/page-blocks/:idAdmin rolesUpdate block
DELETE/api/page-blocks/:idAdmin rolesDelete block
+

Public Endpoints (No Authentication)

+ + + + + + + + + + + + + + + + + +
MethodPathAuthDescription
GET/api/pages/:slug/viewNoneGet published page by slug
+

Admin Endpoint Details

+

GET /api/pages

+

List landing pages with pagination, search, and filtering.

+

Query Parameters:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDefaultDescription
pagenumberNo1Page number
limitnumberNo20Results per page (max 100)
searchstringNo-Search title, description, or slug
publishedenumNo-Filter by status: 'true', 'false'
+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/pages?page=1&limit=10&published=true&search=about"
+
+

Response (200 OK):

+
{
+  "pages": [
+    {
+      "id": "clx1234567890",
+      "slug": "about-us",
+      "title": "About Us",
+      "description": "Learn about our organization",
+      "editorMode": "VISUAL",
+      "blocks": {
+        "assets": [],
+        "pages": [/* GrapesJS page structure */],
+        "styles": [/* GrapesJS styles */]
+      },
+      "htmlOutput": "<div class=\"hero\">...</div>",
+      "cssOutput": ".hero { background: #3498db; }",
+      "mkdocsPath": "about-us.html",
+      "mkdocsStubPath": "about-us.md",
+      "mkdocsExportMode": "THEMED",
+      "mkdocsHideNav": true,
+      "mkdocsHideToc": true,
+      "mkdocsSkipExport": false,
+      "published": true,
+      "seoTitle": "About Us — Changemaker Lite",
+      "seoDescription": "Learn about our mission and values",
+      "seoImage": "https://example.com/og-image.jpg",
+      "createdAt": "2026-02-01T12:00:00.000Z",
+      "updatedAt": "2026-02-11T14:30:00.000Z"
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 10,
+    "total": 5,
+    "totalPages": 1
+  }
+}
+
+

Search Behavior:

+
if (search) {
+  where.OR = [
+    { title: { contains: search, mode: 'insensitive' } },
+    { description: { contains: search, mode: 'insensitive' } },
+    { slug: { contains: search, mode: 'insensitive' } },
+  ];
+}
+
+
+

GET /api/pages/:id

+

Get single landing page with full editor state.

+

Path Parameters:

+
    +
  • id (string): Landing page ID
  • +
+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/pages/clx1234567890"
+
+

Response (200 OK):

+

Returns full landing page object (same format as GET list).

+

Error Responses:

+
    +
  • 404 Not Found: Page not found
  • +
+
+

POST /api/pages

+

Create landing page with auto-generated slug.

+

Request Body:

+
{
+  "title": "About Us",
+  "description": "Learn about our organization",
+  "editorMode": "VISUAL",
+  "blocks": {},
+  "htmlOutput": null,
+  "cssOutput": null,
+  "mkdocsExportMode": "THEMED",
+  "mkdocsHideNav": true,
+  "mkdocsHideToc": true,
+  "published": false,
+  "seoTitle": "About Us — Changemaker Lite",
+  "seoDescription": "Learn about our mission and values",
+  "seoImage": "https://example.com/og-image.jpg"
+}
+
+

Response (201 Created):

+

Returns created landing page object.

+

Auto-Generated Fields:

+
    +
  • slug — Generated from title (collision-safe)
  • +
  • mkdocsPath — Defaults to ${slug}.html if not provided
  • +
+

Validation:

+
    +
  • title is required
  • +
  • mkdocsPath must end with .html
  • +
  • mkdocsPath must not contain path traversal sequences (.., null bytes, encoded traversal)
  • +
+
+

PUT /api/pages/:id

+

Update landing page. Triggers MkDocs export if published.

+

Request Body (Partial):

+
{
+  "htmlOutput": "<div class=\"hero\">Updated content</div>",
+  "cssOutput": ".hero { background: #e74c3c; }",
+  "published": true
+}
+
+

Response (200 OK):

+

Returns updated landing page object.

+

Side Effects:

+
    +
  1. +

    Slug regeneration if title changes (preserves old slug if collision): +

    if (data.title && data.title !== existing.title) {
    +  const baseSlug = generateSlug(data.title);
    +  const newSlug = await resolveSlugCollision(baseSlug, id);
    +  updateData.slug = newSlug;
    +
    +  // Update mkdocsPath if auto-generated
    +  if (existing.mkdocsPath === `${existing.slug}.html`) {
    +    updateData.mkdocsPath = `${newSlug}.html`;
    +    await removeFromMkDocs(existing.mkdocsPath, existing.mkdocsStubPath);
    +  }
    +}
    +

    +
  2. +
  3. +

    MkDocs export if published === true && mkdocsSkipExport === false: +

    if (page.published && !page.mkdocsSkipExport && page.mkdocsPath && page.htmlOutput) {
    +  const stubPath = await exportToMkDocs({
    +    mkdocsPath: page.mkdocsPath,
    +    html: page.htmlOutput,
    +    css: page.cssOutput,
    +    editorMode: page.editorMode,
    +    exportMode: page.mkdocsExportMode,
    +    title: page.title,
    +    seoTitle: page.seoTitle,
    +    seoDescription: page.seoDescription,
    +    hideNav: page.mkdocsHideNav,
    +    hideToc: page.mkdocsHideToc,
    +  });
    +}
    +

    +
  4. +
  5. +

    MkDocs cleanup if published === false || mkdocsSkipExport === true: +

    await removeFromMkDocs(existing.mkdocsPath, existing.mkdocsStubPath);
    +

    +
  6. +
+

Export Workflow:

+
graph TD
+    A[Update Landing Page] --> B{Published?}
+    B -->|No| C[Remove MkDocs Export]
+    B -->|Yes| D{Skip Export?}
+    D -->|Yes| C
+    D -->|No| E{Has HTML Output?}
+    E -->|No| F[No Action]
+    E -->|Yes| G[Export to MkDocs]
+    G --> H[Write Override HTML]
+    H --> I[Write .md Stub]
+    I --> J[Update stubPath in DB]
+
+

DELETE /api/pages/:id

+

Delete landing page and remove MkDocs export.

+

Path Parameters:

+
    +
  • id (string): Landing page ID
  • +
+

Response (204 No Content):

+

No response body.

+

Side Effects:

+
    +
  • Removes MkDocs override HTML file (mkdocs/overrides/{mkdocsPath})
  • +
  • Removes .md stub file (mkdocs/docs/{mkdocsStubPath})
  • +
+
+

POST /api/pages/sync

+

Sync MkDocs override files to database (import untracked files, update CODE pages).

+

Example Request:

+
curl -X POST \
+  -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/pages/sync"
+
+

Response (200 OK):

+
{
+  "imported": 2,
+  "updated": 1,
+  "stubs": 3
+}
+
+

Behavior:

+
    +
  1. +

    Scan mkdocs/overrides/ directory for .html files: +

    const files = await scanOverrideFiles(MKDOCS_OVERRIDES);
    +// Returns: [{ relativePath: 'foo.html', fullPath: '/full/path/foo.html' }, ...]
    +

    +
  2. +
  3. +

    Import untracked files as CODE pages: +

    if (!tracked) {
    +  // New file not in database
    +  const title = path.basename(file.relativePath, '.html');
    +  const baseSlug = generateSlug(title);
    +  const slug = await resolveSlugCollision(baseSlug);
    +
    +  await prisma.landingPage.create({
    +    data: {
    +      slug,
    +      title,
    +      editorMode: 'CODE',
    +      htmlOutput: content,
    +      mkdocsPath: file.relativePath,
    +      published: true,
    +      blocks: {},
    +    },
    +  });
    +
    +  imported++;
    +}
    +

    +
  4. +
  5. +

    Update CODE pages from disk (disk wins): +

    else if (tracked.editorMode === 'CODE') {
    +  // Tracked CODE page — sync from disk
    +  await prisma.landingPage.update({
    +    where: { id: tracked.id },
    +    data: { htmlOutput: content },
    +  });
    +
    +  updated++;
    +}
    +// VISUAL pages: don't overwrite from disk (managed by GrapesJS)
    +

    +
  6. +
  7. +

    Backfill missing .md stubs for published pages: +

    for (const page of existingPages) {
    +  if (!page.published || !page.mkdocsPath) continue;
    +
    +  const expectedStubPath = page.mkdocsStubPath || deriveStubPath(page.mkdocsPath);
    +  const exists = await stubExistsOnDisk(expectedStubPath);
    +  if (!exists) {
    +    await writeStubFile(expectedStubPath, stubContent);
    +    stubs++;
    +  }
    +}
    +

    +
  8. +
+

Use Cases:

+
    +
  • Manual file creation — Admin creates .html file directly in mkdocs/overrides/, then syncs to database
  • +
  • Git pull — After pulling changes that add override files, sync to database
  • +
  • Stub recovery — Re-create missing .md stub files
  • +
+
+

POST /api/pages/validate

+

Validate MkDocs exports and repair missing files.

+

Example Request:

+
curl -X POST \
+  -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/pages/validate"
+
+

Response (200 OK):

+
{
+  "validated": 10,
+  "repaired": 2,
+  "errors": [
+    {
+      "pageId": "clx1234567890",
+      "slug": "broken-page",
+      "error": "ENOENT: no such file or directory"
+    }
+  ]
+}
+
+

Behavior:

+
    +
  1. +

    Query all published pages with mkdocsSkipExport === false: +

    const pages = await prisma.landingPage.findMany({
    +  where: {
    +    published: true,
    +    mkdocsSkipExport: false,
    +    mkdocsPath: { not: null },
    +    htmlOutput: { not: null },
    +  },
    +});
    +

    +
  2. +
  3. +

    Check override HTML exists: +

    const overridePath = path.join(MKDOCS_OVERRIDES, page.mkdocsPath);
    +await fs.access(overridePath);  // Throws if missing
    +

    +
  4. +
  5. +

    Check .md stub exists: +

    const expectedStubPath = page.mkdocsStubPath || deriveStubPath(page.mkdocsPath);
    +const stubExists = await stubExistsOnDisk(expectedStubPath);
    +

    +
  6. +
  7. +

    Repair if either missing: +

    if (!overrideExists || !stubExists) {
    +  await exportToMkDocs({
    +    mkdocsPath: page.mkdocsPath,
    +    html: page.htmlOutput,
    +    css: page.cssOutput,
    +    // ...
    +  });
    +
    +  repaired++;
    +}
    +

    +
  8. +
+

Use Cases:

+
    +
  • Missing exports after deploy — MkDocs volume lost, re-export all pages
  • +
  • Manual deletion — Admin accidentally deleted override file, repair from database
  • +
  • Health check — Verify all published pages have correct exports
  • +
+
+

Block Library Endpoint Details

+

GET /api/page-blocks

+

List blocks with optional category filter.

+

Query Parameters:

+ + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDescription
categorystringNoFilter by category
+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/page-blocks?category=hero"
+
+

Response (200 OK):

+
[
+  {
+    "id": "clx1234567890",
+    "type": "hero",
+    "label": "Hero Section",
+    "schema": {
+      "type": "div",
+      "classes": ["hero"],
+      "attributes": { "data-gjs-type": "hero" },
+      "components": [/* ... */],
+      "traits": [
+        { "type": "text", "name": "heading", "label": "Heading" },
+        { "type": "text", "name": "subheading", "label": "Subheading" }
+      ]
+    },
+    "defaults": {
+      "heading": "Welcome to our site",
+      "subheading": "Your journey starts here"
+    },
+    "thumbnail": "https://example.com/hero-thumb.jpg",
+    "category": "hero",
+    "sortOrder": 1,
+    "createdAt": "2026-01-15T12:00:00.000Z",
+    "updatedAt": "2026-01-20T10:00:00.000Z"
+  }
+]
+
+

Sort Order:

+

Blocks are sorted by sortOrder ASC. Lower numbers appear first in block library panel.

+
+

POST /api/page-blocks

+

Create block.

+

Request Body:

+
{
+  "type": "hero",
+  "label": "Hero Section",
+  "schema": {
+    "type": "div",
+    "classes": ["hero"],
+    "attributes": { "data-gjs-type": "hero" }
+  },
+  "defaults": {
+    "heading": "Welcome",
+    "subheading": "Your journey starts here"
+  },
+  "thumbnail": "https://example.com/hero-thumb.jpg",
+  "category": "hero",
+  "sortOrder": 1
+}
+
+

Response (201 Created):

+

Returns created block object.

+
+

Public Endpoint Details

+

GET /api/pages/:slug/view

+

Get published landing page by slug (no auth required).

+

Path Parameters:

+
    +
  • slug (string): Landing page slug
  • +
+

Example Request:

+
curl http://api.cmlite.org/api/pages/about-us/view
+
+

Response (200 OK):

+

Returns full landing page object (same format as admin GET).

+

Filtering:

+
    +
  • Only returns pages with published === true
  • +
  • Throws 404 if page not found or not published
  • +
+

Error Responses:

+
    +
  • 404 Not Found: Page not found or not published
  • +
+
+

Service Functions

+

pagesService.findAll(filters)

+

List landing pages with pagination, search, and filtering.

+

Usage:

+
import { pagesService } from './pages.service';
+
+const result = await pagesService.findAll({
+  page: 1,
+  limit: 20,
+  search: 'about',
+  published: 'true',
+});
+
+console.log(result.pages.length);    // Array of pages
+console.log(result.pagination);      // { page, limit, total, totalPages }
+
+
+

pagesService.create(data)

+

Create landing page with auto-generated slug and mkdocsPath.

+

Usage:

+
const page = await pagesService.create({
+  title: 'About Us',
+  description: 'Learn about our organization',
+  editorMode: 'VISUAL',
+  blocks: {},
+  published: false,
+});
+
+console.log(page.slug);          // 'about-us'
+console.log(page.mkdocsPath);    // 'about-us.html'
+
+
+

pagesService.update(id, data)

+

Update landing page with MkDocs export/cleanup side effects.

+

Usage:

+
const page = await pagesService.update('clx1234567890', {
+  htmlOutput: '<div class="hero">Updated</div>',
+  cssOutput: '.hero { background: #e74c3c; }',
+  published: true,
+});
+
+// Side effect: Exports to mkdocs/overrides/{mkdocsPath}
+// Side effect: Creates .md stub in mkdocs/docs/{mkdocsStubPath}
+
+

Export Trigger:

+
    +
  • Export happens if published === true && mkdocsSkipExport === false && mkdocsPath && htmlOutput
  • +
  • Cleanup happens if published === false || mkdocsSkipExport === true
  • +
+
+

pagesService.syncOverrides()

+

Sync MkDocs override files to database.

+

Usage:

+
const result = await pagesService.syncOverrides();
+
+console.log(`Imported: ${result.imported}`);  // New CODE pages imported
+console.log(`Updated: ${result.updated}`);    // CODE pages synced from disk
+console.log(`Stubs: ${result.stubs}`);        // Missing stubs created
+
+

Workflow:

+
    +
  1. Scan mkdocs/overrides/ for .html files
  2. +
  3. Import untracked files as CODE pages
  4. +
  5. Update tracked CODE pages from disk (disk wins)
  6. +
  7. Don't overwrite VISUAL pages (managed by GrapesJS)
  8. +
  9. Backfill missing .md stubs
  10. +
+
+

pagesService.validateExports()

+

Validate and repair MkDocs exports.

+

Usage:

+
const result = await pagesService.validateExports();
+
+console.log(`Validated: ${result.validated}`);  // Pages checked
+console.log(`Repaired: ${result.repaired}`);    // Missing exports repaired
+console.log(`Errors: ${result.errors.length}`);  // Failed repairs
+
+

Repair Logic:

+
// Check override HTML exists
+const overridePath = path.join(MKDOCS_OVERRIDES, page.mkdocsPath);
+const overrideExists = await fs.access(overridePath).then(() => true, () => false);
+
+// Check stub exists
+const stubExists = await stubExistsOnDisk(expectedStubPath);
+
+// Repair if either missing
+if (!overrideExists || !stubExists) {
+  await exportToMkDocs({/* ... */});
+  repaired++;
+}
+
+
+

MkDocs Export System

+

Export Modes

+

1. THEMED (Default)

+

Wraps HTML in Jinja2 template extending MkDocs Material theme:

+
{% extends "main.html" %}
+{% block content %}
+<style>
+{{ css }}
+</style>
+{{ html }}
+{% endblock %}
+
+

Pros:

+
    +
  • Inherits Material theme navigation, footer, search
  • +
  • Consistent branding with main docs
  • +
  • Responsive out of the box
  • +
+

Cons:

+
    +
  • Limited control over layout
  • +
  • Must work within Material theme constraints
  • +
+

2. STANDALONE

+

Full HTML document without Jinja2 inheritance:

+
<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>{{ seoTitle || title }}</title>
+    <meta name="description" content="{{ seoDescription }}">
+    <style>
+{{ css }}
+    </style>
+</head>
+<body>
+{{ html }}
+</body>
+</html>
+
+

Pros:

+
    +
  • Full control over layout
  • +
  • No Material theme constraints
  • +
  • Custom navigation/footer
  • +
+

Cons:

+
    +
  • No Material theme features (search, nav, etc.)
  • +
  • Must implement responsive design
  • +
  • Separate branding
  • +
+
+

.md Stub File Format

+

The .md stub file is required for MkDocs to recognize the override template. It uses Material theme front matter to configure page appearance.

+

Example:

+
---
+template: about-us.html
+hide:
+  - navigation
+  - toc
+title: "About Us — Changemaker Lite"
+description: "Learn about our mission and values"
+---
+
+

Front Matter Fields:

+
    +
  • template — Override filename (relative to custom_dir/overrides)
  • +
  • hide — Hide Material theme elements (navigation, toc)
  • +
  • title — Page title (SEO)
  • +
  • description — Page description (SEO)
  • +
+

Generation:

+
function generateMdStub(opts: StubOptions): string {
+  const hideItems: string[] = [];
+  if (opts.hideNav) hideItems.push('  - navigation');
+  if (opts.hideToc) hideItems.push('  - toc');
+
+  const hideBlock = hideItems.length > 0 ? `hide:\n${hideItems.join('\n')}\n` : '';
+  const descLine = opts.description ? `description: "${opts.description.replace(/"/g, '\\"')}"\n` : '';
+
+  return `---
+template: ${opts.overrideFilename}
+${hideBlock}title: "${opts.title.replace(/"/g, '\\"')}"
+${descLine}---
+`;
+}
+
+
+

Path Validation

+

All mkdocsPath values are validated to prevent path traversal attacks:

+
function validateMkdocsPath(mkdocsPath: string): void {
+  // Check for null bytes
+  if (mkdocsPath.includes('\0')) {
+    throw new AppError(400, 'Invalid path: null byte detected', 'INVALID_MKDOCS_PATH');
+  }
+
+  // Normalize and check for traversal
+  const normalized = path.normalize(mkdocsPath);
+  if (normalized.includes('..') || path.isAbsolute(normalized)) {
+    throw new AppError(400, 'Path traversal not allowed', 'INVALID_MKDOCS_PATH');
+  }
+
+  // Check for encoded traversal sequences
+  if (mkdocsPath.includes('%2e') || mkdocsPath.includes('%2E')) {
+    throw new AppError(400, 'Encoded path traversal not allowed', 'INVALID_MKDOCS_PATH');
+  }
+
+  if (!mkdocsPath.endsWith('.html')) {
+    throw new AppError(400, 'Path must end with .html', 'INVALID_MKDOCS_PATH');
+  }
+}
+
+

Blocked Patterns:

+
    +
  • Null bytes (\0)
  • +
  • Path traversal (..)
  • +
  • Absolute paths (/etc/passwd)
  • +
  • Encoded traversal (%2e%2e/, %2E%2E/)
  • +
  • Non-HTML files (must end with .html)
  • +
+
+

Validation Schemas

+

Create Landing Page Schema

+
export const createLandingPageSchema = z.object({
+  title: z.string().min(1, 'Title is required'),
+  description: z.string().optional(),
+  editorMode: z.enum(['VISUAL', 'CODE']).optional().default('VISUAL'),
+  blocks: z.any().optional().default({}),
+  htmlOutput: z.string().optional(),
+  cssOutput: z.string().optional(),
+  mkdocsPath: z.string().optional(),
+  mkdocsExportMode: z.enum(['THEMED', 'STANDALONE']).optional().default('THEMED'),
+  mkdocsHideNav: z.boolean().optional().default(true),
+  mkdocsHideToc: z.boolean().optional().default(true),
+  mkdocsSkipExport: z.boolean().optional().default(false),
+  published: z.boolean().optional().default(false),
+  seoTitle: z.string().optional(),
+  seoDescription: z.string().optional(),
+  seoImage: z.string().optional(),
+});
+
+

Defaults:

+
    +
  • editorMode: VISUAL
  • +
  • blocks: {}
  • +
  • mkdocsExportMode: THEMED
  • +
  • mkdocsHideNav: true
  • +
  • mkdocsHideToc: true
  • +
  • mkdocsSkipExport: false
  • +
  • published: false
  • +
+
+

Create Page Block Schema

+
export const createPageBlockSchema = z.object({
+  type: z.string().min(1, 'Type is required'),
+  label: z.string().min(1, 'Label is required'),
+  schema: z.any().optional().default({}),
+  defaults: z.any().optional().default({}),
+  thumbnail: z.string().optional(),
+  category: z.string().optional(),
+  sortOrder: z.number().int().optional().default(0),
+});
+
+

Example Valid Input:

+
{
+  "type": "hero",
+  "label": "Hero Section",
+  "schema": {
+    "type": "div",
+    "classes": ["hero"]
+  },
+  "defaults": {
+    "heading": "Welcome"
+  },
+  "category": "hero",
+  "sortOrder": 1
+}
+
+
+

Code Examples

+

Admin: Create Landing Page

+
import { api } from '@/lib/api';
+import { message } from 'antd';
+
+const createPage = async () => {
+  try {
+    const { data } = await api.post('/api/pages', {
+      title: 'About Us',
+      description: 'Learn about our organization',
+      editorMode: 'VISUAL',
+      mkdocsExportMode: 'THEMED',
+      mkdocsHideNav: true,
+      mkdocsHideToc: true,
+      published: false,
+      seoTitle: 'About Us — Changemaker Lite',
+      seoDescription: 'Learn about our mission and values',
+    });
+
+    message.success(`Page created: ${data.slug}`);
+    return data;
+  } catch (error) {
+    message.error('Failed to create page');
+    throw error;
+  }
+};
+
+
+

Admin: Publish Page (Triggers MkDocs Export)

+
import { api } from '@/lib/api';
+import { message } from 'antd';
+
+const publishPage = async (pageId: string, htmlOutput: string, cssOutput: string) => {
+  try {
+    const { data } = await api.put(`/api/pages/${pageId}`, {
+      htmlOutput,
+      cssOutput,
+      published: true,
+    });
+
+    message.success(`Page published and exported to MkDocs!`);
+    return data;
+  } catch (error) {
+    message.error('Failed to publish page');
+    throw error;
+  }
+};
+
+
+

Admin: Sync MkDocs Overrides

+
import { api } from '@/lib/api';
+import { message } from 'antd';
+
+const syncOverrides = async () => {
+  try {
+    const { data } = await api.post('/api/pages/sync');
+
+    message.success(
+      `Sync complete: ${data.imported} imported, ${data.updated} updated, ${data.stubs} stubs created`
+    );
+    return data;
+  } catch (error) {
+    message.error('Failed to sync overrides');
+    throw error;
+  }
+};
+
+
+

Admin: Validate and Repair Exports

+
import { api } from '@/lib/api';
+import { message } from 'antd';
+
+const validateExports = async () => {
+  try {
+    const { data } = await api.post('/api/pages/validate');
+
+    if (data.errors.length > 0) {
+      message.warning(`Validation complete: ${data.repaired} repaired, ${data.errors.length} errors`);
+    } else {
+      message.success(`Validation complete: ${data.validated} validated, ${data.repaired} repaired`);
+    }
+
+    return data;
+  } catch (error) {
+    message.error('Failed to validate exports');
+    throw error;
+  }
+};
+
+
+

Public: Render Landing Page

+
import axios from 'axios';
+import { useParams } from 'react-router-dom';
+import { useEffect, useState } from 'react';
+
+interface LandingPage {
+  id: string;
+  slug: string;
+  title: string;
+  htmlOutput: string;
+  cssOutput: string | null;
+  seoTitle: string | null;
+  seoDescription: string | null;
+}
+
+const LandingPageRenderer = () => {
+  const { slug } = useParams<{ slug: string }>();
+  const [page, setPage] = useState<LandingPage | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    const fetchPage = async () => {
+      try {
+        const { data } = await axios.get(`/api/pages/${slug}/view`);
+        setPage(data);
+      } catch (error) {
+        console.error('Page not found:', error);
+      } finally {
+        setLoading(false);
+      }
+    };
+
+    fetchPage();
+  }, [slug]);
+
+  if (loading) return <div>Loading...</div>;
+  if (!page) return <div>Page not found</div>;
+
+  return (
+    <>
+      {/* Inject CSS */}
+      {page.cssOutput && <style dangerouslySetInnerHTML={{ __html: page.cssOutput }} />}
+
+      {/* Render HTML */}
+      <div dangerouslySetInnerHTML={{ __html: page.htmlOutput }} />
+    </>
+  );
+};
+
+
+

Frontend Integration

+

The LandingPagesPage component (admin/src/pages/LandingPagesPage.tsx) provides:

+
    +
  • Paginated pages table with search and published filter
  • +
  • Create page button (opens modal with title input)
  • +
  • Edit button (navigates to full-screen GrapesJS editor)
  • +
  • Publish/unpublish toggle (triggers MkDocs export)
  • +
  • Delete confirmation modal
  • +
  • Sync button (syncs MkDocs overrides to database)
  • +
  • Validate button (repairs missing exports)
  • +
  • Settings modal (configure MkDocs export options)
  • +
+

State Management:

+
const [pages, setPages] = useState<LandingPage[]>([]);
+const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
+const [filters, setFilters] = useState({ search: '', published: null });
+const [settingsModalOpen, setSettingsModalOpen] = useState(false);
+
+

Page Editor:

+

The PageEditorPage component (admin/src/pages/PageEditorPage.tsx) provides:

+
    +
  • Full-screen GrapesJS editor (no AppLayout)
  • +
  • Custom block library (hero, text, image, CTA, features, testimonials, form)
  • +
  • Ctrl+S save (forwardRef to GrapesJS instance)
  • +
  • Mobile warning (GrapesJS is desktop-only)
  • +
  • Visual/Code mode toggle
  • +
  • Auto-save on blur (optional)
  • +
+

Public Renderer:

+

The LandingPage component (admin/src/pages/public/LandingPage.tsx) provides:

+
    +
  • Public route at /p/:slug
  • +
  • Renders htmlOutput with cssOutput
  • +
  • SEO metadata from seoTitle, seoDescription, seoImage
  • +
  • 404 handling for unpublished or missing pages
  • +
+
+

Performance Considerations

+

MkDocs Export Caching

+

MkDocs exports are triggered on update, not on every GET request. This avoids I/O overhead.

+

Export Trigger:

+
if (page.published && !page.mkdocsSkipExport && page.mkdocsPath && page.htmlOutput) {
+  await exportToMkDocs({/* ... */});
+}
+
+

No Export:

+
    +
  • Draft pages (published === false)
  • +
  • Skipped pages (mkdocsSkipExport === true)
  • +
  • Pages without HTML output
  • +
+
+

Slug Collision Handling

+

The slug collision resolver loops until unique slug found. To avoid infinite loops, it uses suffix counter:

+
async function resolveSlugCollision(slug: string, excludeId?: string): Promise<string> {
+  let candidate = slug;
+  let suffix = 2;
+
+  while (true) {
+    const existing = await prisma.landingPage.findUnique({ where: { slug: candidate } });
+    if (!existing || (excludeId && existing.id === excludeId)) {
+      return candidate;
+    }
+    candidate = `${slug}-${suffix}`;  // about-us-2, about-us-3, ...
+    suffix++;
+  }
+}
+
+

Worst-case:

+
    +
  • O(n) queries where n = number of pages with same base slug
  • +
  • In practice, n is very small (< 10)
  • +
+
+

Troubleshooting

+

MkDocs Override Not Appearing

+

Problem:

+

Page is published but doesn't appear on MkDocs site.

+

Diagnosis:

+
    +
  1. +

    Check override file exists: +

    ls mkdocs/overrides/about-us.html
    +

    +
  2. +
  3. +

    Check stub file exists: +

    ls mkdocs/docs/about-us.md
    +

    +
  4. +
  5. +

    Check stub front matter: +

    cat mkdocs/docs/about-us.md
    +

    +
  6. +
+

Verify template: points to override filename (not path): +

template: about-us.html  # Correct
+template: overrides/about-us.html  # WRONG — causes TemplateNotFound
+

+
    +
  1. Check MkDocs logs: +
    docker compose logs -f mkdocs
    +
  2. +
+

Solutions:

+
    +
  • +

    Missing files: Run validate endpoint to repair: +

    curl -X POST -H "Authorization: Bearer <token>" \
    +  http://api.cmlite.org/api/pages/validate
    +

    +
  • +
  • +

    Wrong template path: Front matter template: value is relative to template search paths. Use filename only.

    +
  • +
  • +

    MkDocs rebuild: Restart MkDocs container: +

    docker compose restart mkdocs
    +

    +
  • +
+
+

Path Traversal Validation Error

+

Problem:

+

Creating page fails with "Path traversal not allowed" error.

+

Diagnosis:

+

Check mkdocsPath value for blocked patterns:

+
// Blocked:
+mkdocsPath: '../etc/passwd.html'       // Path traversal
+mkdocsPath: '/etc/passwd.html'         // Absolute path
+mkdocsPath: '%2e%2e/etc/passwd.html'   // Encoded traversal
+mkdocsPath: 'foo\0bar.html'            // Null byte
+
+// Allowed:
+mkdocsPath: 'about-us.html'            // Simple filename
+mkdocsPath: 'subfolder/about-us.html'  // Subdirectory (no traversal)
+
+

Solution:

+

Use safe filenames without path traversal sequences. Subfolders are allowed but must not contain ...

+
+

CODE Page Overwritten by Disk

+

Problem:

+

Manual edits to CODE page in database are lost after sync.

+

Diagnosis:

+

Check editorMode:

+
SELECT id, slug, "editorMode" FROM landing_pages WHERE slug = 'my-page';
+
+

Behavior:

+
    +
  • CODE pages: Disk wins. Sync overwrites database htmlOutput from disk.
  • +
  • VISUAL pages: Database wins. Sync does not overwrite GrapesJS-managed pages.
  • +
+

Solution:

+
    +
  • +

    Option 1: Edit file on disk directly: +

    vim mkdocs/overrides/my-page.html
    +# Then sync
    +curl -X POST -H "Authorization: Bearer <token>" http://api.cmlite.org/api/pages/sync
    +

    +
  • +
  • +

    Option 2: Change editorMode to VISUAL if you want database to be source of truth: +

    UPDATE landing_pages SET "editorMode" = 'VISUAL' WHERE slug = 'my-page';
    +

    +
  • +
+
+

Stub Template Not Found

+

Problem:

+

MkDocs build fails with TemplateNotFound error.

+

Diagnosis:

+

Check stub front matter:

+
cat mkdocs/docs/about-us.md
+
+

Common Mistakes:

+
# WRONG — includes directory path
+template: overrides/about-us.html
+
+# CORRECT — filename only
+template: about-us.html
+
+

Why:

+

MkDocs Material template: searches in custom_dir (which includes /overrides). Using overrides/ in the template value causes it to look for overrides/overrides/about-us.html.

+

Solution:

+

Re-export page to fix stub:

+
curl -X POST -H "Authorization: Bearer <token>" \
+  http://api.cmlite.org/api/pages/validate
+
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/backend/modules/representatives/index.html b/mkdocs/site/v2/backend/modules/representatives/index.html new file mode 100644 index 00000000..9d95420b --- /dev/null +++ b/mkdocs/site/v2/backend/modules/representatives/index.html @@ -0,0 +1,6886 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Representatives Module - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Representatives Module

+

Overview

+

The Representatives module integrates with the Canadian Represent API to provide elected official lookup by postal code. It features intelligent caching, rate limiting, deduplication, and both public and admin endpoints for managing representative data.

+

Key Features:

+
    +
  • Canadian representative lookup via Represent API (MPs, MPPs, councillors)
  • +
  • Intelligent cache-first strategy with fire-and-forget cache writes
  • +
  • Rate limiting (55 requests/minute, under Represent API's 60/min limit)
  • +
  • Representative deduplication (centroid + concordance results)
  • +
  • Public postal code lookup (no auth required)
  • +
  • Admin cache management (view, clear, stats)
  • +
  • Integration with postal codes module for location metadata
  • +
  • Health check endpoint for API connectivity testing
  • +
+

File Paths

+ + + + + + + + + + + + + + + + + + + + + + + + + +
FilePurpose
api/src/modules/influence/representatives/representatives.routes.tsRouter with 8 endpoints (2 public, 6 admin)
api/src/modules/influence/representatives/representatives.service.tsRepresentative business logic + Represent API integration
api/src/modules/influence/representatives/representatives.schemas.tsZod validation schemas
api/src/modules/influence/representatives/represent-api.client.tsRepresent API HTTP client with rate limiting
+

Database Model

+
model Representative {
+  id                    String    @id @default(cuid())
+  postalCode            String
+  name                  String?
+  email                 String?
+  districtName          String?
+  electedOffice         String?
+  partyName             String?
+  representativeSetName String?
+  url                   String?
+  photoUrl              String?
+  offices               Json?     // JSON array of office contact info
+  cachedAt              DateTime  @default(now())
+
+  @@index([postalCode])
+  @@map("representatives")
+}
+
+

Field Descriptions:

+
    +
  • postalCode — Canadian postal code (e.g., "M5H 2N2")
  • +
  • name — Representative's full name
  • +
  • email — Contact email address
  • +
  • districtName — Electoral district name (e.g., "Toronto Centre")
  • +
  • electedOffice — Position (e.g., "MP", "MPP", "Councillor")
  • +
  • partyName — Political party affiliation
  • +
  • representativeSetName — Data source identifier (e.g., "House of Commons")
  • +
  • url — Representative's official website
  • +
  • photoUrl — Profile photo URL
  • +
  • offices — JSON array of office locations with contact info
  • +
  • cachedAt — Timestamp when cached from Represent API
  • +
+

API Endpoints

+

Public Endpoints (No Authentication)

+ + + + + + + + + + + + + + + + + + + + +
MethodPathDescription
GET/api/representatives/by-postal/:postalCodeLookup representatives by postal code (cache-first)
GET/api/representatives/test-connectionTest Represent API connectivity
+

Admin Endpoints (Authentication Required)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathAuthDescription
GET/api/representatives/cache-statsAdmin rolesGet cache statistics
GET/api/representativesAdmin rolesList all cached representatives (paginated)
GET/api/representatives/:idAdmin rolesGet single cached representative
DELETE/api/representatives/by-postal/:postalCodeAdmin rolesClear cache for postal code
DELETE/api/representatives/:idAdmin rolesDelete single cached representative
+

Admin Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

+
+

Public Endpoint Details

+

GET /api/representatives/by-postal/:postalCode

+

Lookup representatives by Canadian postal code. Uses cache-first strategy: returns cached results if available, otherwise calls Represent API and caches results asynchronously.

+

Path Parameters:

+
    +
  • postalCode (string): Canadian postal code (e.g., "M5H2N2" or "M5H 2N2")
  • +
+

Query Parameters:

+ + + + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDefaultDescription
refreshbooleanNofalseForce API call even if cached data exists
+

Example Request:

+
# Cache-first lookup
+curl "http://api.cmlite.org/api/representatives/by-postal/M5H2N2"
+
+# Force refresh from API
+curl "http://api.cmlite.org/api/representatives/by-postal/M5H2N2?refresh=true"
+
+

Response (200 OK):

+
{
+  "source": "cache",
+  "postalCode": "M5H2N2",
+  "location": {
+    "city": "Toronto",
+    "province": "ON"
+  },
+  "representatives": [
+    {
+      "id": "clx1234567890",
+      "postalCode": "M5H2N2",
+      "name": "Chrystia Freeland",
+      "email": "chrystia.freeland@parl.gc.ca",
+      "districtName": "University—Rosedale",
+      "electedOffice": "MP",
+      "partyName": "Liberal",
+      "representativeSetName": "House of Commons",
+      "url": "https://www.ourcommons.ca/members/en/chrystia-freeland(71619)",
+      "photoUrl": "https://www.ourcommons.ca/Content/Parliamentarians/Images/OfficialMPPhotos/44/FreelendeC_Lib.jpg",
+      "offices": [
+        {
+          "type": "constituency",
+          "tel": "416-656-2424",
+          "fax": "416-656-2425",
+          "postal": "703-2005 Sheppard Ave E, Toronto ON M2J 5B4"
+        }
+      ],
+      "cachedAt": "2026-02-11T12:00:00.000Z"
+    },
+    {
+      "id": "clx0987654321",
+      "postalCode": "M5H2N2",
+      "name": "Suze Morrison",
+      "email": "smorrisons@ola.org",
+      "districtName": "Toronto Centre",
+      "electedOffice": "MPP",
+      "partyName": "NDP",
+      "representativeSetName": "Legislative Assembly of Ontario",
+      "url": "https://www.ola.org/en/members/all/suze-morrison",
+      "photoUrl": null,
+      "offices": [],
+      "cachedAt": "2026-02-11T12:00:00.000Z"
+    }
+  ]
+}
+
+

Response Fields:

+
    +
  • source — Data source: "cache" (from database) or "api" (fresh from Represent API)
  • +
  • postalCode — Normalized postal code
  • +
  • location — City and province from PostalCodeCache table
  • +
  • representatives — Array of representative objects
  • +
+

Error Responses:

+
    +
  • 400 Bad Request: Invalid postal code format
  • +
  • 404 Not Found: Postal code not found in Represent API
  • +
  • 429 Too Many Requests: Rate limit exceeded (55/min)
  • +
  • 500 Internal Server Error: Represent API unreachable or other error
  • +
+

Caching Strategy:

+
// 1. Check cache first (unless forceRefresh)
+const cached = await prisma.representative.findMany({ where: { postalCode: code } });
+if (cached.length > 0 && !forceRefresh) {
+  return { source: 'cache', representatives: cached };
+}
+
+// 2. Call Represent API
+const apiResponse = await representApiClient.getByPostalCode(code);
+
+// 3. Fire-and-forget cache write (don't await)
+cacheWrite(); // Deletes old cache, creates new entries
+
+// 4. Return API results immediately (don't wait for cache)
+return { source: 'api', representatives: uniqueReps };
+
+

Deduplication:

+

Representatives from both representatives_centroid and representatives_concordance are merged and deduplicated by name|elected_office key to avoid duplicate entries.

+
function deduplicateReps(reps: RepresentRepresentative[]): RepresentRepresentative[] {
+  const seen = new Set<string>();
+  return reps.filter((rep) => {
+    const key = `${rep.name}|${rep.elected_office}`;
+    if (seen.has(key)) return false;
+    seen.add(key);
+    return true;
+  });
+}
+
+
+

GET /api/representatives/test-connection

+

Test connectivity to the Represent API.

+

Example Request:

+
curl "http://api.cmlite.org/api/representatives/test-connection"
+
+

Response (200 OK):

+
{
+  "ok": true,
+  "message": "Represent API is reachable"
+}
+
+

Response (200 OK, API Down):

+
{
+  "ok": false,
+  "message": "HTTP 503"
+}
+
+

Use Cases:

+
    +
  • Health checks for monitoring dashboards
  • +
  • Troubleshooting representative lookup issues
  • +
  • Verifying API configuration in admin settings
  • +
+
+

Admin Endpoint Details

+

GET /api/representatives/cache-stats

+

Get cache statistics for the representatives cache.

+

Authentication: Required (Admin roles)

+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/representatives/cache-stats"
+
+

Response (200 OK):

+
{
+  "totalRepresentatives": 1247,
+  "postalCodesWithRepresentatives": 412,
+  "totalPostalCodes": 450
+}
+
+

Field Descriptions:

+
    +
  • totalRepresentatives — Total cached representative records
  • +
  • postalCodesWithRepresentatives — Unique postal codes with cached representatives
  • +
  • totalPostalCodes — Total postal codes in PostalCodeCache table (includes codes without representatives)
  • +
+
+

GET /api/representatives

+

List all cached representatives with pagination and search.

+

Authentication: Required (Admin roles)

+

Query Parameters:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDefaultDescription
pagenumberNo1Page number
limitnumberNo20Results per page (max 100)
searchstringNo-Search name, email, district, or office
postalCodestringNo-Filter by postal code
+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/representatives?page=1&limit=10&search=Toronto&postalCode=M5H2N2"
+
+

Response (200 OK):

+
{
+  "representatives": [
+    {
+      "id": "clx1234567890",
+      "postalCode": "M5H2N2",
+      "name": "Chrystia Freeland",
+      "email": "chrystia.freeland@parl.gc.ca",
+      "districtName": "University—Rosedale",
+      "electedOffice": "MP",
+      "partyName": "Liberal",
+      "representativeSetName": "House of Commons",
+      "url": "https://www.ourcommons.ca/members/en/chrystia-freeland(71619)",
+      "photoUrl": "https://www.ourcommons.ca/Content/Parliamentarians/Images/OfficialMPPhotos/44/FreelendeC_Lib.jpg",
+      "offices": [...],
+      "cachedAt": "2026-02-11T12:00:00.000Z"
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 10,
+    "total": 15,
+    "totalPages": 2
+  }
+}
+
+

Search Logic:

+

Search term is matched against name, email, district name, or elected office (case-insensitive):

+
if (search) {
+  where.OR = [
+    { name: { contains: search, mode: 'insensitive' } },
+    { email: { contains: search, mode: 'insensitive' } },
+    { districtName: { contains: search, mode: 'insensitive' } },
+    { electedOffice: { contains: search, mode: 'insensitive' } },
+  ];
+}
+
+
+

GET /api/representatives/:id

+

Get single cached representative by ID.

+

Authentication: Required (Admin roles)

+

Path Parameters:

+
    +
  • id (string): Representative ID (cuid)
  • +
+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/representatives/clx1234567890"
+
+

Response (200 OK):

+

Returns single representative object (same format as list).

+

Error Responses:

+
    +
  • 404 Not Found: Representative not found
  • +
+
+

DELETE /api/representatives/by-postal/:postalCode

+

Clear all cached representatives for a specific postal code.

+

Authentication: Required (Admin roles)

+

Path Parameters:

+
    +
  • postalCode (string): Canadian postal code
  • +
+

Example Request:

+
curl -X DELETE -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/representatives/by-postal/M5H2N2"
+
+

Response (200 OK):

+
{
+  "deleted": 3,
+  "postalCode": "M5H2N2"
+}
+
+

Use Cases:

+
    +
  • Force cache refresh for specific postal code
  • +
  • Remove stale data after election
  • +
  • Troubleshoot incorrect representative data
  • +
+
+

DELETE /api/representatives/:id

+

Delete single cached representative by ID.

+

Authentication: Required (Admin roles)

+

Path Parameters:

+
    +
  • id (string): Representative ID (cuid)
  • +
+

Example Request:

+
curl -X DELETE -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/representatives/clx1234567890"
+
+

Response (204 No Content):

+

No response body.

+

Error Responses:

+
    +
  • 404 Not Found: Representative not found
  • +
+
+

Represent API Integration

+

API Client

+

The represent-api.client.ts file provides a typed HTTP client for the Represent API.

+

Base URL:

+
const REPRESENT_API_URL = 'https://represent.opennorth.ca';
+
+

Configuration:

+

Set REPRESENT_API_URL in .env to override (default: https://represent.opennorth.ca).

+

Methods:

+
class RepresentApiClient {
+  // Lookup by postal code
+  async getByPostalCode(code: string): Promise<RepresentPostalCodeResponse>;
+
+  // Health check
+  async testConnection(): Promise<{ ok: boolean; message: string }>;
+}
+
+

Rate Limiting

+

Limits:

+
    +
  • Represent API: 60 requests/minute
  • +
  • Changemaker Lite: 55 requests/minute (safety margin)
  • +
+

Implementation:

+

In-memory sliding window rate limiter:

+
const RATE_LIMIT = 55;
+const RATE_WINDOW_MS = 60_000;
+const requestTimestamps: number[] = [];
+
+function checkRateLimit(): boolean {
+  const now = Date.now();
+  // Remove timestamps outside the window
+  while (requestTimestamps.length > 0 && requestTimestamps[0] < now - RATE_WINDOW_MS) {
+    requestTimestamps.shift();
+  }
+  return requestTimestamps.length < RATE_LIMIT;
+}
+
+function recordRequest(): void {
+  requestTimestamps.push(Date.now());
+}
+
+

Behavior:

+
    +
  • If rate limit exceeded: throws Error('Represent API rate limit reached. Please try again in a minute.')
  • +
  • Returns 429 status to client
  • +
  • Resets after 1 minute
  • +
+

Response Schema

+
interface RepresentPostalCodeResponse {
+  city: string | null;
+  province: string | null;
+  centroid: { type: string; coordinates: [number, number] } | null;
+  representatives_centroid: RepresentRepresentative[];
+  representatives_concordance: RepresentRepresentative[];
+}
+
+interface RepresentRepresentative {
+  name: string;
+  email: string | null;
+  elected_office: string;
+  district_name: string;
+  party_name: string | null;
+  representative_set_name: string;
+  url: string;
+  photo_url: string | null;
+  offices: RepresentOffice[];
+}
+
+interface RepresentOffice {
+  type?: string;       // "constituency" or "legislature"
+  tel?: string;        // Phone number
+  fax?: string;        // Fax number
+  postal?: string;     // Mailing address
+}
+
+

Centroid vs. Concordance:

+
    +
  • representatives_centroid — Representatives found using the postal code's geographic centroid
  • +
  • representatives_concordance — Representatives found using postal code concordance tables (may be more accurate for boundary-edge postal codes)
  • +
  • Both arrays are merged and deduplicated by Changemaker Lite
  • +
+
+

Service Functions

+

representativesService.lookupByPostalCode(code, forceRefresh)

+

Cache-first representative lookup.

+

Parameters:

+
    +
  • code (string): Canadian postal code
  • +
  • forceRefresh (boolean, default: false): Skip cache and force API call
  • +
+

Returns:

+
{
+  source: 'cache' | 'api';
+  postalCode: string;
+  location: { city: string | null; province: string | null };
+  representatives: Representative[];
+}
+
+

Logic Flow:

+
    +
  1. Check cache unless forceRefresh=true
  2. +
  3. If cached data found, return immediately with source: 'cache'
  4. +
  5. If no cache or forceRefresh, call Represent API
  6. +
  7. Merge centroid + concordance representatives and deduplicate
  8. +
  9. Fire-and-forget cache write (delete old, insert new, upsert postal code)
  10. +
  11. Return API results with source: 'api' (don't wait for cache)
  12. +
+

Fire-and-Forget Caching:

+
const cacheWrite = async () => {
+  try {
+    // Delete old cached reps for this postal code
+    await prisma.representative.deleteMany({ where: { postalCode: code } });
+
+    // Cache new reps
+    await prisma.representative.createMany({
+      data: uniqueReps.map((rep) => ({
+        postalCode: code,
+        name: rep.name || null,
+        email: rep.email || null,
+        districtName: rep.district_name || null,
+        electedOffice: rep.elected_office || null,
+        partyName: rep.party_name || null,
+        representativeSetName: rep.representative_set_name || null,
+        url: rep.url || null,
+        photoUrl: rep.photo_url || null,
+        offices: rep.offices ? (rep.offices as unknown as Prisma.InputJsonValue) : Prisma.JsonNull,
+      })),
+    });
+
+    // Upsert postal code cache (city, province, centroid)
+    await postalCodesService.upsert({
+      postalCode: code,
+      city: apiResponse.city,
+      province: apiResponse.province,
+      centroidLat: coords ? coords[1] : null,
+      centroidLng: coords ? coords[0] : null,
+    });
+  } catch (err) {
+    logger.error('Failed to cache representatives', { postalCode: code, error: err });
+  }
+};
+
+// Don't await — fire and forget
+cacheWrite();
+
+

Why Fire-and-Forget?

+
    +
  • Returns API results to user immediately (faster response)
  • +
  • Cache failures don't block user requests
  • +
  • Next lookup will use cached data if write succeeds
  • +
  • Errors logged for monitoring but don't propagate to user
  • +
+
+

representativesService.findAll(filters)

+

List cached representatives with pagination and search.

+

Parameters:

+
{
+  page: number;      // Page number (default: 1)
+  limit: number;     // Results per page (max 100, default: 20)
+  search?: string;   // Search term (optional)
+  postalCode?: string; // Filter by postal code (optional)
+}
+
+

Returns:

+
{
+  representatives: Representative[];
+  pagination: {
+    page: number;
+    limit: number;
+    total: number;
+    totalPages: number;
+  };
+}
+
+
+

representativesService.findById(id)

+

Get single cached representative by ID.

+

Throws: AppError(404) if not found

+
+

representativesService.clearByPostalCode(code)

+

Delete all cached representatives for a postal code.

+

Returns:

+
{
+  deleted: number;      // Count of deleted records
+  postalCode: string;
+}
+
+
+

representativesService.deleteById(id)

+

Delete single cached representative by ID.

+

Throws: AppError(404) if not found

+
+

representativesService.testApiConnection()

+

Test connectivity to Represent API.

+

Returns:

+
{
+  ok: boolean;
+  message: string;
+}
+
+

Implementation:

+

Calls Represent API's /boundary-sets/?limit=1 endpoint (lightweight health check).

+
+

representativesService.getCacheStats()

+

Get cache statistics.

+

Returns:

+
{
+  totalRepresentatives: number;         // Total cached representative records
+  postalCodesWithRepresentatives: number; // Unique postal codes with reps
+  totalPostalCodes: number;              // Total postal codes in cache
+}
+
+

Implementation:

+
const [totalReps, postalCodesWithReps, totalPostalCodes] = await Promise.all([
+  prisma.representative.count(),
+  prisma.representative.groupBy({ by: ['postalCode'] }).then((g) => g.length),
+  prisma.postalCodeCache.count(),
+]);
+
+return {
+  totalRepresentatives: totalReps,
+  postalCodesWithRepresentatives: postalCodesWithReps,
+  totalPostalCodes,
+};
+
+
+

Validation Schemas

+

List Representatives Schema

+
export const listRepresentativesSchema = z.object({
+  page: z.coerce.number().int().positive().default(1),
+  limit: z.coerce.number().int().positive().max(100).default(20),
+  search: z.string().optional(),
+  postalCode: z.string().optional(),
+});
+
+export type ListRepresentativesInput = z.infer<typeof listRepresentativesSchema>;
+
+

Coercion:

+
    +
  • page and limit coerced from query string to number
  • +
  • Invalid values fallback to defaults
  • +
+
+

Integration with Postal Codes Module

+

The representatives module integrates with the postal codes module (api/src/modules/influence/postal-codes/) for location metadata.

+

PostalCodeCache Model:

+
model PostalCodeCache {
+  id          String    @id @default(cuid())
+  postalCode  String    @unique
+  city        String?
+  province    String?
+  centroidLat Float?
+  centroidLng Float?
+  cachedAt    DateTime  @default(now())
+}
+
+

Integration Points:

+
    +
  1. Lookup: When returning cached representatives, fetch city/province from PostalCodeCache:
  2. +
+
const postalInfo = await postalCodesService.findByPostalCode(code);
+return {
+  source: 'cache',
+  location: {
+    city: postalInfo?.city ?? null,
+    province: postalInfo?.province ?? null,
+  },
+  representatives: cached,
+};
+
+
    +
  1. Cache Write: After calling Represent API, upsert postal code with location data:
  2. +
+
await postalCodesService.upsert({
+  postalCode: code,
+  city: apiResponse.city,
+  province: apiResponse.province,
+  centroidLat: coords ? coords[1] : null,
+  centroidLng: coords ? coords[0] : null,
+});
+
+
+

Code Examples

+

Public: Lookup Representatives by Postal Code

+
import axios from 'axios';
+
+const lookupRepresentatives = async (postalCode: string) => {
+  const { data } = await axios.get(
+    `/api/representatives/by-postal/${postalCode}`
+  );
+
+  console.log(`Source: ${data.source}`); // "cache" or "api"
+  console.log(`Location: ${data.location.city}, ${data.location.province}`);
+
+  data.representatives.forEach((rep) => {
+    console.log(`${rep.name} (${rep.electedOffice}) - ${rep.email}`);
+  });
+
+  return data;
+};
+
+// Cache-first lookup
+await lookupRepresentatives('M5H2N2');
+
+// Force refresh from API
+const { data } = await axios.get('/api/representatives/by-postal/M5H2N2?refresh=true');
+
+

Admin: Get Cache Statistics

+
import { api } from '@/lib/api';
+
+const getCacheStats = async () => {
+  const { data } = await api.get('/api/representatives/cache-stats');
+
+  console.log(`Total Representatives: ${data.totalRepresentatives}`);
+  console.log(`Postal Codes with Reps: ${data.postalCodesWithRepresentatives}`);
+  console.log(`Total Postal Codes: ${data.totalPostalCodes}`);
+
+  return data;
+};
+
+

Admin: Clear Cache for Postal Code

+
import { api } from '@/lib/api';
+import { message } from 'antd';
+
+const clearPostalCodeCache = async (postalCode: string) => {
+  try {
+    const { data } = await api.delete(`/api/representatives/by-postal/${postalCode}`);
+    message.success(`Cleared ${data.deleted} representatives for ${postalCode}`);
+  } catch (error) {
+    message.error('Failed to clear cache');
+  }
+};
+
+

Admin: Search Cached Representatives

+
import { api } from '@/lib/api';
+
+const searchRepresentatives = async (search: string, page: number = 1) => {
+  const { data } = await api.get('/api/representatives', {
+    params: { search, page, limit: 20 },
+  });
+
+  return {
+    representatives: data.representatives,
+    pagination: data.pagination,
+  };
+};
+
+
+

Frontend Integration

+

The RepresentativesPage component (admin/src/pages/RepresentativesPage.tsx) provides:

+
    +
  • Cache statistics dashboard (total reps, postal codes, coverage)
  • +
  • Representative cache table with pagination
  • +
  • Search by name, email, district, or office
  • +
  • Filter by postal code
  • +
  • Clear cache by postal code (bulk action)
  • +
  • Delete individual cached representatives
  • +
  • Postal code lookup tool (test Represent API)
  • +
  • Connection test (verify API reachability)
  • +
  • Refresh button (force API call for postal code)
  • +
+

State Management:

+
const [representatives, setRepresentatives] = useState<Representative[]>([]);
+const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
+const [filters, setFilters] = useState({ search: '', postalCode: '' });
+const [stats, setStats] = useState({ totalRepresentatives: 0, postalCodesWithRepresentatives: 0, totalPostalCodes: 0 });
+
+
+

Performance Considerations

+

Cache-First Strategy:

+
    +
  • Cached lookups: <10ms (database query)
  • +
  • API lookups: 200-500ms (external API call)
  • +
  • Fire-and-forget writes don't block user response
  • +
+

Rate Limiting:

+
    +
  • 55 requests/minute limit prevents Represent API 429 errors
  • +
  • In-memory sliding window (no Redis overhead)
  • +
  • Returns 429 status to client when limit exceeded
  • +
+

Database Indexing:

+
    +
  • @@index([postalCode]) — Fast lookup by postal code
  • +
  • Ordered by cachedAt DESC — Recent lookups first
  • +
+

Deduplication:

+
    +
  • Prevents duplicate representatives from centroid + concordance results
  • +
  • Reduces database storage and frontend rendering load
  • +
+
+

Troubleshooting

+

Issue: "Represent API rate limit reached"

+

Cause: More than 55 requests in 60-second window

+

Solution:

+
    +
  • Wait 1 minute and retry
  • +
  • Use cached data (don't force refresh)
  • +
  • Batch postal code lookups instead of sequential
  • +
+

Issue: Cached data is stale

+

Cause: Representative changed after election

+

Solution:

+
    +
  • Force refresh: GET /api/representatives/by-postal/:postalCode?refresh=true
  • +
  • Admin clear cache: DELETE /api/representatives/by-postal/:postalCode
  • +
  • Cache will be refreshed on next lookup
  • +
+

Issue: Postal code returns no representatives

+

Cause: Invalid postal code or Represent API doesn't have data

+

Solution:

+
    +
  • Verify postal code format (e.g., "M5H2N2" or "M5H 2N2")
  • +
  • Check Represent API directly: https://represent.opennorth.ca/postcodes/M5H2N2/
  • +
  • Ensure postal code is Canadian (Represent API is Canada-only)
  • +
+

Issue: Duplicate representatives in cache

+

Cause: Deduplication bug or manual database insertion

+

Solution:

+
    +
  • Clear cache: DELETE /api/representatives/by-postal/:postalCode
  • +
  • Next lookup will re-deduplicate from API
  • +
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/backend/modules/responses/index.html b/mkdocs/site/v2/backend/modules/responses/index.html new file mode 100644 index 00000000..741a542a --- /dev/null +++ b/mkdocs/site/v2/backend/modules/responses/index.html @@ -0,0 +1,7432 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Responses Module - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Responses Module

+

Overview

+

The Responses module manages the public response wall for advocacy campaigns, allowing users to share representative responses (emails, letters, phone calls, etc.) with email verification, upvoting, and admin moderation. It features a dual verification system (verify or report links), IP-based and user-based upvoting, and comprehensive moderation tools.

+

Key Features:

+
    +
  • Public response submission with representative verification emails
  • +
  • Email verification flow (30-day expiry, verify or report links)
  • +
  • Upvoting system (IP-based for anonymous, user-based for logged-in users)
  • +
  • Admin moderation (PENDING → APPROVED/REJECTED workflow)
  • +
  • Response statistics (total, verified, upvotes, level breakdown)
  • +
  • Public response listing with sorting (recent, upvotes, verified)
  • +
  • Rate limiting (prevents spam submissions)
  • +
  • Anonymous submissions (submitter name/email hidden)
  • +
  • Response types (email, letter, phone call, meeting, social media, other)
  • +
  • HTML result pages for email verification links
  • +
+

File Paths

+ + + + + + + + + + + + + + + + + + + + + +
FilePurpose
api/src/modules/influence/responses/responses.routes.ts3 routers (campaign public, responses public, admin) with 12 endpoints
api/src/modules/influence/responses/responses.service.tsResponse business logic + email verification
api/src/modules/influence/responses/responses.schemas.tsZod validation schemas
+

Database Models

+

RepresentativeResponse

+
model RepresentativeResponse {
+  id                   String           @id @default(cuid())
+  campaignId           String
+  campaign             Campaign         @relation(fields: [campaignId], references: [id], onDelete: Cascade)
+  campaignSlug         String
+
+  representativeName   String
+  representativeTitle  String?
+  representativeLevel  GovernmentLevel
+  representativeEmail  String?
+
+  responseType         ResponseType
+  responseText         String           @db.Text
+  userComment          String?          @db.Text
+  screenshotUrl        String?
+
+  // Submitter info
+  submittedByUserId    String?
+  submittedByUser      User?            @relation("ResponseSubmitter", fields: [submittedByUserId], references: [id], onDelete: SetNull)
+  submittedByName      String?
+  submittedByEmail     String?
+  isAnonymous          Boolean          @default(false)
+  submittedIp          String?
+
+  // Moderation
+  status               ResponseStatus   @default(PENDING)
+
+  // Verification
+  isVerified           Boolean          @default(false)
+  verificationToken    String?
+  verificationSentAt   DateTime?
+  verifiedAt           DateTime?
+  verifiedBy           String?
+
+  // Upvoting
+  upvoteCount          Int              @default(0)
+  upvotes              ResponseUpvote[]
+
+  createdAt            DateTime         @default(now())
+  updatedAt            DateTime         @updatedAt
+
+  @@index([campaignId])
+  @@index([campaignSlug])
+  @@index([status])
+  @@map("representative_responses")
+}
+
+enum ResponseType {
+  EMAIL
+  LETTER
+  PHONE_CALL
+  MEETING
+  SOCIAL_MEDIA
+  OTHER
+}
+
+enum ResponseStatus {
+  PENDING   // Awaiting moderation
+  APPROVED  // Visible on public wall
+  REJECTED  // Removed/disputed
+}
+
+

ResponseUpvote

+
model ResponseUpvote {
+  id         String  @id @default(cuid())
+  responseId String
+  response   RepresentativeResponse @relation(fields: [responseId], references: [id], onDelete: Cascade)
+  userId     String?
+  user       User?   @relation(fields: [userId], references: [id], onDelete: SetNull)
+  userEmail  String?
+  upvotedIp  String?
+
+  @@unique([responseId, userId])      // Logged-in users: one upvote per response
+  @@unique([responseId, upvotedIp])   // Anonymous users: one upvote per IP per response
+  @@map("response_upvotes")
+}
+
+

Upvoting Logic:

+
    +
  • Logged-in users: tracked by userId (allows upvoting from multiple devices)
  • +
  • Anonymous users: tracked by upvotedIp (prevents duplicate upvotes from same IP)
  • +
  • Unique constraints ensure users can't upvote same response multiple times
  • +
+
+

API Endpoints

+

Campaign-Scoped Public Endpoints (No Authentication)

+ + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathDescription
GET/api/campaigns/:slug/responsesList approved responses for campaign
GET/api/campaigns/:slug/response-statsGet response statistics for campaign
POST/api/campaigns/:slug/responsesSubmit new response (rate-limited)
+

Response-Scoped Public Endpoints (Optional Authentication)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathDescription
POST/api/responses/:id/upvoteUpvote a response
DELETE/api/responses/:id/upvoteRemove upvote from response
GET/api/responses/:id/verify/:tokenVerify response (returns HTML page)
GET/api/responses/:id/report/:tokenReport response as invalid (returns HTML page)
+

Admin Endpoints (Authentication Required)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathAuthDescription
GET/api/responsesAdmin rolesList all responses (paginated, filtered)
PATCH/api/responses/:id/statusAdmin rolesUpdate response status
POST/api/responses/:id/resend-verificationAdmin rolesResend verification email
DELETE/api/responses/:idAdmin rolesDelete response
+

Admin Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

+
+

Public Endpoint Details

+

POST /api/campaigns/:slug/responses

+

Submit a new representative response to a campaign.

+

Rate Limiting: 10 requests per minute per IP

+

Path Parameters:

+
    +
  • slug (string): Campaign slug
  • +
+

Request Body:

+
{
+  "representativeName": "Chrystia Freeland",
+  "representativeTitle": "Deputy Prime Minister",
+  "representativeLevel": "FEDERAL",
+  "representativeEmail": "chrystia.freeland@parl.gc.ca",
+  "responseType": "EMAIL",
+  "responseText": "Thank you for writing. I appreciate your concerns regarding climate change and am committed to...",
+  "userComment": "Received this response 2 days after sending my email!",
+  "submittedByName": "Jane Doe",
+  "submittedByEmail": "jane@example.com",
+  "isAnonymous": false,
+  "sendVerification": true
+}
+
+

Field Descriptions:

+
    +
  • representativeName (required): Representative's full name
  • +
  • representativeLevel (required): Government level (FEDERAL, PROVINCIAL, MUNICIPAL, SCHOOL_BOARD)
  • +
  • responseType (required): Response type (EMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHER)
  • +
  • responseText (required): Full text of representative's response
  • +
  • representativeTitle (optional): Representative's title/position
  • +
  • representativeEmail (optional): Representative's email (required if sendVerification=true)
  • +
  • userComment (optional): Submitter's comment about the response
  • +
  • submittedByName (optional): Submitter's name (not shown if isAnonymous=true)
  • +
  • submittedByEmail (optional): Submitter's email (not shown publicly)
  • +
  • isAnonymous (optional, default: false): Hide submitter name on public wall
  • +
  • sendVerification (optional, default: false): Send verification email to representative
  • +
+

Response (201 Created):

+
{
+  "id": "clx1234567890",
+  "status": "PENDING",
+  "verificationSent": true
+}
+
+

Verification Email Flow:

+

If sendVerification=true and representativeEmail is provided, an email is sent to the representative with:

+
    +
  • Verify Link: Marks response as APPROVED and verified
  • +
  • Report Link: Marks response as REJECTED (representative disputes it)
  • +
  • 30-day expiry: Verification token expires after 30 days
  • +
+

Example Verification Email:

+
Subject: Please verify this response submission for "Climate Action Now" campaign
+
+Dear Representative,
+
+A constituent has submitted a response from you for the "Climate Action Now" campaign on Changemaker Lite.
+
+Response Type: Email
+Response Text: "Thank you for writing. I appreciate your concerns regarding..."
+Submitted By: Jane Doe
+
+If this is a genuine response from you, please verify it:
+https://api.cmlite.org/api/responses/clx1234567890/verify/abc123...
+
+If you did not send this response, or it is inaccurate, please report it:
+https://api.cmlite.org/api/responses/clx1234567890/report/abc123...
+
+This link expires in 30 days.
+
+

Error Responses:

+
    +
  • 400 Bad Request: Campaign not active, response wall disabled, or validation error
  • +
  • 404 Not Found: Campaign not found
  • +
  • 429 Too Many Requests: Rate limit exceeded (10/min)
  • +
+

Campaign Requirements:

+
    +
  • Campaign must have status=ACTIVE
  • +
  • Campaign must have showResponseWall=true
  • +
+
+

GET /api/campaigns/:slug/responses

+

List approved responses for a campaign.

+

Path Parameters:

+
    +
  • slug (string): Campaign slug
  • +
+

Query Parameters:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDefaultDescription
pagenumberNo1Page number
limitnumberNo20Results per page (max 100)
sortstringNorecentSort order: recent, upvotes, verified
levelGovernmentLevelNo-Filter by government level
+

Example Request:

+
# Recent responses
+curl "http://api.cmlite.org/api/campaigns/climate-action-now/responses?page=1&limit=10"
+
+# Sort by upvotes
+curl "http://api.cmlite.org/api/campaigns/climate-action-now/responses?sort=upvotes"
+
+# Filter by federal only
+curl "http://api.cmlite.org/api/campaigns/climate-action-now/responses?level=FEDERAL"
+
+

Response (200 OK):

+
{
+  "responses": [
+    {
+      "id": "clx1234567890",
+      "representativeName": "Chrystia Freeland",
+      "representativeTitle": "Deputy Prime Minister",
+      "representativeLevel": "FEDERAL",
+      "responseType": "EMAIL",
+      "responseText": "Thank you for writing. I appreciate your concerns...",
+      "userComment": "Received this response 2 days after sending!",
+      "submittedByName": "Jane Doe",
+      "isAnonymous": false,
+      "isVerified": true,
+      "verifiedAt": "2026-02-10T12:00:00.000Z",
+      "upvoteCount": 42,
+      "createdAt": "2026-02-08T12:00:00.000Z"
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 10,
+    "total": 89,
+    "totalPages": 9
+  }
+}
+
+

Response Fields:

+
    +
  • Only APPROVED responses are returned
  • +
  • submittedByName is null if isAnonymous=true
  • +
  • submittedByEmail never exposed on public routes
  • +
  • representativeEmail never exposed on public routes
  • +
+

Sorting:

+
switch (sort) {
+  case 'upvotes':
+    orderBy = { upvoteCount: 'desc' };
+    break;
+  case 'verified':
+    orderBy = { isVerified: 'desc' };
+    break;
+  default: // 'recent'
+    orderBy = { createdAt: 'desc' };
+}
+
+
+

GET /api/campaigns/:slug/response-stats

+

Get aggregate statistics for campaign responses.

+

Path Parameters:

+
    +
  • slug (string): Campaign slug
  • +
+

Example Request:

+
curl "http://api.cmlite.org/api/campaigns/climate-action-now/response-stats"
+
+

Response (200 OK):

+
{
+  "total": 89,
+  "verified": 42,
+  "totalUpvotes": 347,
+  "byLevel": {
+    "FEDERAL": 32,
+    "PROVINCIAL": 28,
+    "MUNICIPAL": 21,
+    "SCHOOL_BOARD": 8
+  }
+}
+
+

Field Descriptions:

+
    +
  • total: Total APPROVED responses for campaign
  • +
  • verified: Count of APPROVED responses with isVerified=true
  • +
  • totalUpvotes: Sum of all upvoteCount values
  • +
  • byLevel: Breakdown by government level
  • +
+
+

POST /api/responses/:id/upvote

+

Upvote a response.

+

Authentication: Optional (supports both logged-in and anonymous users)

+

Path Parameters:

+
    +
  • id (string): Response ID
  • +
+

Example Request:

+
# Anonymous upvote (tracked by IP)
+curl -X POST "http://api.cmlite.org/api/responses/clx1234567890/upvote"
+
+# Logged-in upvote (tracked by user ID)
+curl -X POST -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/responses/clx1234567890/upvote"
+
+

Response (200 OK):

+
{
+  "success": true
+}
+
+

Response (200 OK, Already Upvoted):

+
{
+  "success": false,
+  "alreadyUpvoted": true
+}
+
+

Upvoting Logic:

+
    +
  1. Verify response exists and is APPROVED
  2. +
  3. Create ResponseUpvote record:
  4. +
  5. Logged-in: userId + responseId (allows upvoting from multiple IPs)
  6. +
  7. Anonymous: upvotedIp + responseId (prevents duplicate upvotes from same IP)
  8. +
  9. Increment upvoteCount on response
  10. +
  11. If duplicate (Prisma P2002 error), return alreadyUpvoted: true
  12. +
+

Error Responses:

+
    +
  • 400 Bad Request: Response is not approved
  • +
  • 404 Not Found: Response not found
  • +
+
+

DELETE /api/responses/:id/upvote

+

Remove upvote from a response.

+

Authentication: Optional (supports both logged-in and anonymous users)

+

Path Parameters:

+
    +
  • id (string): Response ID
  • +
+

Example Request:

+
# Remove anonymous upvote
+curl -X DELETE "http://api.cmlite.org/api/responses/clx1234567890/upvote"
+
+# Remove logged-in upvote
+curl -X DELETE -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/responses/clx1234567890/upvote"
+
+

Response (200 OK):

+
{
+  "success": true
+}
+
+

Response (200 OK, Not Upvoted):

+
{
+  "success": false
+}
+
+

Logic:

+
    +
  1. Delete ResponseUpvote record matching responseId + userId (or upvotedIp if anonymous)
  2. +
  3. Decrement upvoteCount if deleted
  4. +
  5. Return success: false if no upvote record found
  6. +
+
+

GET /api/responses/:id/verify/:token

+

Verify a response (representative confirms authenticity). Returns HTML result page.

+

Path Parameters:

+
    +
  • id (string): Response ID
  • +
  • token (string): Verification token (64-char hex)
  • +
+

Example URL:

+
https://api.cmlite.org/api/responses/clx1234567890/verify/abc123...
+
+

Response (200 OK, Success):

+

Returns HTML page with success message:

+
<!DOCTYPE html>
+<html>
+<head>
+  <title>Response Verified - Changemaker Lite</title>
+  ...
+</head>
+<body>
+  <div class="container">
+    <div class="card">
+      <div class="icon"></div>
+      <h1 style="color: #16a34a">Response Verified</h1>
+      <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>
+    </div>
+    <div class="brand">Powered by <strong>Changemaker Lite</strong></div>
+  </div>
+</body>
+</html>
+
+

Response (200 OK, Failed):

+

Returns HTML page with error message:

+
    +
  • reason: "Invalid verification link" — Token doesn't match
  • +
  • reason: "Verification link has expired" — More than 30 days since sent
  • +
+

Database Changes on Success:

+
await prisma.representativeResponse.update({
+  where: { id: responseId },
+  data: {
+    isVerified: true,
+    verifiedAt: new Date(),
+    verifiedBy: response.representativeEmail || 'Representative',
+    status: ResponseStatus.APPROVED,
+  },
+});
+
+

Expiry Logic:

+
const VERIFICATION_EXPIRY_DAYS = 30;
+
+if (response.verificationSentAt) {
+  const daysSinceSent = (Date.now() - response.verificationSentAt.getTime()) / (1000 * 60 * 60 * 24);
+  if (daysSinceSent > VERIFICATION_EXPIRY_DAYS) {
+    return { success: false, reason: 'Verification link has expired' };
+  }
+}
+
+
+

GET /api/responses/:id/report/:token

+

Report a response as invalid (representative disputes it). Returns HTML result page.

+

Path Parameters:

+
    +
  • id (string): Response ID
  • +
  • token (string): Verification token (same token as verify link)
  • +
+

Example URL:

+
https://api.cmlite.org/api/responses/clx1234567890/report/abc123...
+
+

Response (200 OK, Success):

+

Returns HTML page with confirmation:

+
<h1 style="color: #dc2626">Response Reported</h1>
+<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>
+
+

Database Changes on Success:

+
await prisma.representativeResponse.update({
+  where: { id: responseId },
+  data: {
+    status: ResponseStatus.REJECTED,
+    isVerified: false,
+    verifiedBy: `Disputed by ${response.representativeEmail || 'representative'}`,
+  },
+});
+
+

Use Cases:

+
    +
  • Representative never sent the response (fake submission)
  • +
  • Response text is inaccurate or fabricated
  • +
  • Response was sent by someone else impersonating the representative
  • +
+
+

Admin Endpoint Details

+

GET /api/responses

+

List all responses with admin filters.

+

Authentication: Required (Admin roles)

+

Query Parameters:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDefaultDescription
pagenumberNo1Page number
limitnumberNo20Results per page (max 100)
statusResponseStatusNo-Filter by status (PENDING, APPROVED, REJECTED)
campaignIdstringNo-Filter by campaign ID
searchstringNo-Search name, response text, or submitter
+

Example Request:

+
# Pending responses
+curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/responses?status=PENDING&page=1&limit=10"
+
+# Search
+curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/responses?search=climate"
+
+# Campaign-specific
+curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/responses?campaignId=clxCampaign123"
+
+

Response (200 OK):

+
{
+  "responses": [
+    {
+      "id": "clx1234567890",
+      "representativeName": "Chrystia Freeland",
+      "representativeTitle": "Deputy Prime Minister",
+      "representativeLevel": "FEDERAL",
+      "representativeEmail": "chrystia.freeland@parl.gc.ca",
+      "responseType": "EMAIL",
+      "responseText": "Thank you for writing...",
+      "userComment": "Received this response 2 days after sending!",
+      "submittedByName": "Jane Doe",
+      "submittedByEmail": "jane@example.com",
+      "isAnonymous": false,
+      "status": "PENDING",
+      "isVerified": false,
+      "verifiedAt": null,
+      "verifiedBy": null,
+      "upvoteCount": 0,
+      "createdAt": "2026-02-08T12:00:00.000Z",
+      "campaign": {
+        "id": "clxCampaign123",
+        "title": "Climate Action Now",
+        "slug": "climate-action-now"
+      }
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 10,
+    "total": 23,
+    "totalPages": 3
+  }
+}
+
+

Differences from Public Route:

+
    +
  • Includes representativeEmail, submittedByEmail (sensitive fields)
  • +
  • Returns all statuses (not just APPROVED)
  • +
  • Includes campaign relation
  • +
  • Search across name, response text, submitter name
  • +
+

Search Logic:

+
if (search) {
+  where.OR = [
+    { representativeName: { contains: search, mode: 'insensitive' } },
+    { responseText: { contains: search, mode: 'insensitive' } },
+    { submittedByName: { contains: search, mode: 'insensitive' } },
+  ];
+}
+
+
+

PATCH /api/responses/:id/status

+

Update response status (approve or reject).

+

Authentication: Required (Admin roles)

+

Path Parameters:

+
    +
  • id (string): Response ID
  • +
+

Request Body:

+
{
+  "status": "APPROVED"
+}
+
+

Valid Statuses: PENDING, APPROVED, REJECTED

+

Example Request:

+
# Approve response
+curl -X PATCH -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d '{"status":"APPROVED"}' \
+  "http://api.cmlite.org/api/responses/clx1234567890/status"
+
+# Reject response
+curl -X PATCH -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d '{"status":"REJECTED"}' \
+  "http://api.cmlite.org/api/responses/clx1234567890/status"
+
+

Response (200 OK):

+

Returns updated response object (same format as GET).

+

Error Responses:

+
    +
  • 404 Not Found: Response not found
  • +
+

Use Cases:

+
    +
  • Manual moderation: approve legitimate responses, reject spam
  • +
  • Bulk approval after reviewing pending queue
  • +
  • Reject disputed responses without representative verification
  • +
+
+

POST /api/responses/:id/resend-verification

+

Resend verification email to representative.

+

Authentication: Required (Admin roles)

+

Path Parameters:

+
    +
  • id (string): Response ID
  • +
+

Example Request:

+
curl -X POST -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/responses/clx1234567890/resend-verification"
+
+

Response (200 OK):

+
{
+  "success": true
+}
+
+

Error Responses:

+
    +
  • 400 Bad Request: No representative email on record
  • +
  • 404 Not Found: Response not found
  • +
+

Logic:

+
    +
  1. Retrieve existing response
  2. +
  3. Regenerate verification token (or reuse existing)
  4. +
  5. Update verificationToken and verificationSentAt in database
  6. +
  7. Send verification email to representativeEmail
  8. +
+

Use Cases:

+
    +
  • Verification email wasn't delivered
  • +
  • Representative lost the original email
  • +
  • Token expired (more than 30 days old)
  • +
+
+

DELETE /api/responses/:id

+

Delete a response permanently.

+

Authentication: Required (Admin roles)

+

Path Parameters:

+
    +
  • id (string): Response ID
  • +
+

Example Request:

+
curl -X DELETE -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/responses/clx1234567890"
+
+

Response (204 No Content):

+

No response body.

+

Error Responses:

+
    +
  • 404 Not Found: Response not found
  • +
+

Cascading Deletes:

+
    +
  • All ResponseUpvote records for this response (via Prisma cascade)
  • +
+
+

Service Functions

+

responsesService.submitResponse(slug, data, senderIp)

+

Submit new response to campaign.

+

Parameters:

+
    +
  • slug (string): Campaign slug
  • +
  • data (SubmitResponseInput): Response data
  • +
  • senderIp (string, optional): Submitter's IP address
  • +
+

Returns:

+
{
+  id: string;
+  status: ResponseStatus;
+  verificationSent: boolean;
+}
+
+

Validation:

+
    +
  • Campaign must exist and be ACTIVE
  • +
  • Campaign must have showResponseWall=true
  • +
  • If sendVerification=true, representativeEmail is required
  • +
+

Verification Token:

+
let verificationToken: string | null = null;
+
+if (data.sendVerification && data.representativeEmail) {
+  verificationToken = randomBytes(32).toString('hex'); // 64-char hex string
+}
+
+

Metrics:

+

Calls recordResponseSubmission() to increment Prometheus counter.

+
+

responsesService.listApproved(slug, filters)

+

List approved responses for campaign with sorting.

+

Parameters:

+
{
+  page: number;
+  limit: number;
+  sort: 'recent' | 'upvotes' | 'verified';
+  level?: GovernmentLevel;
+}
+
+

Returns:

+
{
+  responses: Response[];
+  pagination: Pagination;
+}
+
+
+

responsesService.getStats(slug)

+

Get aggregate statistics for campaign responses.

+

Returns:

+
{
+  total: number;
+  verified: number;
+  totalUpvotes: number;
+  byLevel: Record<string, number>;
+}
+
+
+

responsesService.upvote(responseId, userIp, userId)

+

Upvote a response.

+

Parameters:

+
    +
  • responseId (string): Response ID
  • +
  • userIp (string, optional): User's IP address
  • +
  • userId (string, optional): User ID (if logged in)
  • +
+

Returns:

+
{
+  success: boolean;
+  alreadyUpvoted?: boolean; // True if duplicate upvote attempt
+}
+
+

Logic:

+
try {
+  await prisma.responseUpvote.create({
+    data: {
+      responseId,
+      userId: userId || null,
+      upvotedIp: !userId ? (userIp || null) : null,
+    },
+  });
+
+  await prisma.representativeResponse.update({
+    where: { id: responseId },
+    data: { upvoteCount: { increment: 1 } },
+  });
+
+  return { success: true };
+} catch (err: any) {
+  if (err.code === 'P2002') {  // Prisma unique constraint violation
+    return { success: false, alreadyUpvoted: true };
+  }
+  throw err;
+}
+
+
+

responsesService.removeUpvote(responseId, userIp, userId)

+

Remove upvote from response.

+

Parameters:

+
    +
  • responseId (string): Response ID
  • +
  • userIp (string, optional): User's IP address
  • +
  • userId (string, optional): User ID (if logged in)
  • +
+

Returns:

+
{
+  success: boolean; // True if upvote was found and removed
+}
+
+
+

responsesService.verify(responseId, token)

+

Verify a response via email link.

+

Parameters:

+
    +
  • responseId (string): Response ID
  • +
  • token (string): Verification token
  • +
+

Returns:

+
{
+  success: boolean;
+  campaignTitle?: string; // On success
+  reason?: string;        // On failure
+}
+
+

Failure Reasons:

+
    +
  • "Invalid verification link" — Token doesn't match
  • +
  • "Verification link has expired" — More than 30 days old
  • +
+
+

responsesService.report(responseId, token)

+

Report a response as invalid via email link.

+

Parameters:

+
    +
  • responseId (string): Response ID
  • +
  • token (string): Verification token (same as verify link)
  • +
+

Returns:

+
{
+  success: boolean;
+  campaignTitle?: string;
+  reason?: string;
+}
+
+

Database Changes:

+
    +
  • Sets status=REJECTED
  • +
  • Sets isVerified=false
  • +
  • Sets verifiedBy to "Disputed by {email}"
  • +
+
+

responsesService.findAll(filters) (Admin)

+

List all responses with admin filters.

+

Parameters:

+
{
+  page: number;
+  limit: number;
+  status?: ResponseStatus;
+  campaignId?: string;
+  search?: string;
+}
+
+

Returns:

+
{
+  responses: Response[];
+  pagination: Pagination;
+}
+
+
+

responsesService.updateStatus(id, data) (Admin)

+

Update response status.

+

Throws: AppError(404) if not found

+
+

responsesService.deleteResponse(id) (Admin)

+

Delete response permanently.

+

Throws: AppError(404) if not found

+
+

responsesService.resendVerification(id) (Admin)

+

Resend verification email to representative.

+

Throws:

+
    +
  • AppError(404) if response not found
  • +
  • AppError(400) if no representative email on record
  • +
+
+

Validation Schemas

+

Submit Response Schema

+
export const submitResponseSchema = z.object({
+  representativeName: z.string().min(1, 'Representative name is required'),
+  representativeLevel: z.nativeEnum(GovernmentLevel),
+  responseType: z.nativeEnum(ResponseType),
+  responseText: z.string().min(1, 'Response text is required'),
+  representativeTitle: z.string().optional(),
+  representativeEmail: z.string().email().optional(),
+  userComment: z.string().optional(),
+  submittedByName: z.string().optional(),
+  submittedByEmail: z.string().email().optional(),
+  isAnonymous: z.boolean().optional().default(false),
+  sendVerification: z.boolean().optional().default(false),
+});
+
+

List Public Responses Schema

+
export const listPublicResponsesSchema = z.object({
+  page: z.coerce.number().int().positive().default(1),
+  limit: z.coerce.number().int().positive().max(100).default(20),
+  sort: z.enum(['recent', 'upvotes', 'verified']).optional().default('recent'),
+  level: z.nativeEnum(GovernmentLevel).optional(),
+});
+
+

List Admin Responses Schema

+
export const listAdminResponsesSchema = z.object({
+  page: z.coerce.number().int().positive().default(1),
+  limit: z.coerce.number().int().positive().max(100).default(20),
+  status: z.nativeEnum(ResponseStatus).optional(),
+  campaignId: z.string().optional(),
+  search: z.string().optional(),
+});
+
+
+

Code Examples

+

Public: Submit Response with Verification

+
import axios from 'axios';
+
+const submitResponse = async (campaignSlug: string) => {
+  const { data } = await axios.post(
+    `/api/campaigns/${campaignSlug}/responses`,
+    {
+      representativeName: 'Chrystia Freeland',
+      representativeTitle: 'Deputy Prime Minister',
+      representativeLevel: 'FEDERAL',
+      representativeEmail: 'chrystia.freeland@parl.gc.ca',
+      responseType: 'EMAIL',
+      responseText: 'Thank you for writing. I appreciate your concerns regarding...',
+      userComment: 'Received this response 2 days after sending!',
+      submittedByName: 'Jane Doe',
+      submittedByEmail: 'jane@example.com',
+      isAnonymous: false,
+      sendVerification: true,
+    }
+  );
+
+  console.log(`Response submitted: ${data.id}`);
+  console.log(`Verification sent: ${data.verificationSent}`);
+
+  return data;
+};
+
+

Public: Upvote Response

+
import axios from 'axios';
+import { message } from 'antd';
+
+const upvoteResponse = async (responseId: string) => {
+  try {
+    const { data } = await axios.post(`/api/responses/${responseId}/upvote`);
+
+    if (data.success) {
+      message.success('Upvoted!');
+    } else if (data.alreadyUpvoted) {
+      message.info('You already upvoted this response');
+    }
+  } catch (error) {
+    message.error('Failed to upvote');
+  }
+};
+
+

Admin: Approve Response

+
import { api } from '@/lib/api';
+import { message } from 'antd';
+
+const approveResponse = async (responseId: string) => {
+  try {
+    await api.patch(`/api/responses/${responseId}/status`, {
+      status: 'APPROVED',
+    });
+
+    message.success('Response approved');
+  } catch (error) {
+    message.error('Failed to approve response');
+  }
+};
+
+

Admin: Resend Verification

+
import { api } from '@/lib/api';
+import { message } from 'antd';
+
+const resendVerification = async (responseId: string) => {
+  try {
+    await api.post(`/api/responses/${responseId}/resend-verification`);
+    message.success('Verification email resent');
+  } catch (error: any) {
+    if (error.response?.status === 400) {
+      message.error('No representative email on record');
+    } else {
+      message.error('Failed to resend verification');
+    }
+  }
+};
+
+
+

Frontend Integration

+

ResponsesPage (Admin)

+

The ResponsesPage component (admin/src/pages/ResponsesPage.tsx) provides:

+
    +
  • Response table with pagination
  • +
  • Status filter (PENDING, APPROVED, REJECTED)
  • +
  • Campaign filter
  • +
  • Search by name, response text, or submitter
  • +
  • Response detail drawer (shows full response + verification status)
  • +
  • Status update actions (approve, reject)
  • +
  • Resend verification button
  • +
  • Delete response action
  • +
  • Verification status badges (verified/unverified)
  • +
+

ResponseWallPage (Public)

+

The ResponseWallPage component (admin/src/pages/public/ResponseWallPage.tsx) provides:

+
    +
  • Response card grid layout
  • +
  • Sort controls (recent, upvotes, verified)
  • +
  • Government level filter
  • +
  • Upvote buttons (IP-based for anonymous)
  • +
  • Verified badges
  • +
  • Submit response modal (opens from campaign page)
  • +
  • Response statistics (total, verified, upvotes)
  • +
  • Anonymous submission toggle
  • +
+
+

Performance Considerations

+

Upvote Constraints:

+
    +
  • Unique constraints prevent duplicate upvotes at database level
  • +
  • No need for application-level deduplication logic
  • +
  • Concurrent upvote attempts return alreadyUpvoted: true
  • +
+

Indexing:

+
    +
  • @@index([campaignId]) — Fast filtering by campaign
  • +
  • @@index([campaignSlug]) — Fast public lookup
  • +
  • @@index([status]) — Fast admin filtering
  • +
+

Pagination:

+
    +
  • Max 100 results per page prevents excessive data transfer
  • +
  • Default 20 results balances performance and UX
  • +
+
+

Troubleshooting

+

Issue: Verification email not delivered

+

Cause: SMTP configuration issue or email blocked by spam filter

+

Solution:

+
    +
  • Check EMAIL_TEST_MODE=true in .env (emails go to MailHog)
  • +
  • Verify SMTP credentials in site settings
  • +
  • Check spam folder on representative's email
  • +
  • Admin: Use "Resend Verification" button
  • +
+ +

Cause: More than 30 days since verification email sent

+

Solution:

+
    +
  • Admin: Use "Resend Verification" to generate new token
  • +
  • New verification email sent with fresh 30-day expiry
  • +
+

Issue: Can't upvote response

+

Cause: Already upvoted, or response not approved

+

Solution:

+
    +
  • Check alreadyUpvoted: true in response
  • +
  • Remove existing upvote first (DELETE endpoint)
  • +
  • Verify response has status=APPROVED
  • +
+

Issue: Response not appearing on public wall

+

Cause: Status is PENDING or REJECTED

+

Solution:

+
    +
  • Admin: Check response status in ResponsesPage
  • +
  • Admin: Approve response manually if legitimate
  • +
  • If verification email sent, representative must click verify link
  • +
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/backend/modules/settings/index.html b/mkdocs/site/v2/backend/modules/settings/index.html new file mode 100644 index 00000000..27047ad0 --- /dev/null +++ b/mkdocs/site/v2/backend/modules/settings/index.html @@ -0,0 +1,6309 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Settings Module - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Settings Module

+

Overview

+

The Settings module provides site-wide configuration management with a singleton pattern. It handles organization branding, theme customization, SMTP configuration, and feature toggles. The module implements field-level encryption for sensitive data (SMTP passwords) and provides separate endpoints for public and admin access.

+

Key Features:

+
    +
  • Singleton pattern (one settings record per installation)
  • +
  • Field-level encryption (SMTP passwords encrypted at rest)
  • +
  • Public vs. admin endpoints (strips credentials from public responses)
  • +
  • SMTP configuration with test connection/send
  • +
  • Email service integration (auto-rebuild transporter on changes)
  • +
  • Organization branding (name, logo, favicon)
  • +
  • Theme customization (admin + public color schemes)
  • +
  • Feature toggles (Influence, Map, Newsletter, Landing Pages)
  • +
+

File Paths

+ + + + + + + + + + + + + + + + + + + + + + + + + +
FilePurpose
api/src/modules/settings/settings.routes.tsExpress router with 5 endpoints
api/src/modules/settings/settings.service.tsSettings business logic with encryption
api/src/modules/settings/settings.schemas.tsZod validation schema
api/src/utils/crypto.tsAES-256-GCM encryption/decryption
+

Database Model

+
model SiteSettings {
+  id  String @id @default(cuid())
+
+  // Organization
+  organizationName         String   @default("Changemaker Lite")
+  organizationShortName    String   @default("CM")
+  organizationLogoUrl      String?
+  organizationFaviconUrl   String?
+
+  // Admin theme
+  adminColorPrimary        String   @default("#1890ff")
+  adminColorBgBase         String   @default("#ffffff")
+
+  // Public theme
+  publicColorPrimary       String   @default("#3498db")
+  publicColorBgBase        String   @default("#0d1b2a")
+  publicColorBgContainer   String   @default("#1b2838")
+  publicHeaderGradient     String?
+
+  // Text
+  footerText               String   @default("© 2026 Changemaker Lite")
+  loginSubtitle            String   @default("Political Infrastructure Platform")
+
+  // Email branding
+  emailFromName            String   @default("Changemaker Lite")
+
+  // SMTP configuration (encrypted at rest)
+  smtpHost                 String   @default("mailhog")
+  smtpPort                 Int      @default(1025)
+  smtpUser                 String   @default("")
+  smtpPass                 String   @default("")  // Encrypted with ENCRYPTION_KEY
+  smtpFromAddress          String   @default("noreply@cmlite.org")
+  smtpActiveProvider       String   @default("mailhog")
+  emailTestMode            Boolean  @default(true)
+  testEmailRecipient       String?
+
+  // Feature toggles
+  enableInfluence          Boolean  @default(true)
+  enableMap                Boolean  @default(true)
+  enableNewsletter         Boolean  @default(false)
+  enableLandingPages       Boolean  @default(true)
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+}
+
+

Singleton Pattern:

+
    +
  • Only one SiteSettings record exists in the database
  • +
  • Auto-created with defaults on first access if missing
  • +
  • All updates modify the existing record
  • +
+

Encryption:

+
    +
  • smtpPass encrypted at rest with AES-256-GCM
  • +
  • Uses ENCRYPTION_KEY environment variable (must NOT reuse JWT secrets)
  • +
  • Decrypted on read, re-encrypted on write
  • +
+

API Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathAuthDescription
GET/api/settingsNoneGet public settings (strips SMTP credentials)
GET/api/settings/adminSUPER_ADMINGet full settings (includes SMTP credentials)
PUT/api/settingsSUPER_ADMINUpdate settings
POST/api/settings/email/test-connectionSUPER_ADMINTest SMTP connection
POST/api/settings/email/test-sendSUPER_ADMINSend test email
+

Endpoint Details

+

GET /api/settings

+

Get public-safe settings (no authentication required). Used by login page and public pages.

+

Security: Strips SMTP credentials before returning: +- smtpHost +- smtpPort +- smtpUser +- smtpPass +- smtpFromAddress +- testEmailRecipient

+

Example Request:

+
curl http://api.cmlite.org/api/settings
+
+

Response (200 OK):

+
{
+  "id": "clx1234567890",
+  "organizationName": "Changemaker Lite",
+  "organizationShortName": "CM",
+  "organizationLogoUrl": "https://example.com/logo.png",
+  "organizationFaviconUrl": "https://example.com/favicon.ico",
+  "adminColorPrimary": "#1890ff",
+  "adminColorBgBase": "#ffffff",
+  "publicColorPrimary": "#3498db",
+  "publicColorBgBase": "#0d1b2a",
+  "publicColorBgContainer": "#1b2838",
+  "publicHeaderGradient": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
+  "footerText": "© 2026 Changemaker Lite",
+  "loginSubtitle": "Political Infrastructure Platform",
+  "emailFromName": "Changemaker Lite",
+  "enableInfluence": true,
+  "enableMap": true,
+  "enableNewsletter": false,
+  "enableLandingPages": true,
+  "createdAt": "2026-02-01T12:00:00.000Z",
+  "updatedAt": "2026-02-11T12:00:00.000Z"
+}
+
+

Implementation:

+
router.get(
+  '/',
+  async (_req: Request, res: Response, next: NextFunction) => {
+    try {
+      const settings = await siteSettingsService.getPublic();
+      res.json(settings);
+    } catch (err) {
+      next(err);
+    }
+  }
+);
+
+

Service Logic:

+
async getPublic(): Promise<Omit<SiteSettings, typeof SENSITIVE_FIELDS[number]>> {
+  const settings = await this.get();
+  const result = { ...settings } as Record<string, unknown>;
+  for (const field of SENSITIVE_FIELDS) {
+    delete result[field];
+  }
+  return result as Omit<SiteSettings, typeof SENSITIVE_FIELDS[number]>;
+}
+
+
+

GET /api/settings/admin

+

Get full settings including SMTP credentials (SUPER_ADMIN only).

+

Request Headers:

+
Authorization: Bearer <access_token>
+
+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  http://api.cmlite.org/api/settings/admin
+
+

Response (200 OK):

+
{
+  "id": "clx1234567890",
+  "organizationName": "Changemaker Lite",
+  "organizationShortName": "CM",
+  "organizationLogoUrl": "https://example.com/logo.png",
+  "organizationFaviconUrl": "https://example.com/favicon.ico",
+  "adminColorPrimary": "#1890ff",
+  "adminColorBgBase": "#ffffff",
+  "publicColorPrimary": "#3498db",
+  "publicColorBgBase": "#0d1b2a",
+  "publicColorBgContainer": "#1b2838",
+  "publicHeaderGradient": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
+  "footerText": "© 2026 Changemaker Lite",
+  "loginSubtitle": "Political Infrastructure Platform",
+  "emailFromName": "Changemaker Lite",
+  "smtpHost": "smtp.sendgrid.net",
+  "smtpPort": 587,
+  "smtpUser": "apikey",
+  "smtpPass": "SG.xxxxxxxxxxxx",
+  "smtpFromAddress": "noreply@cmlite.org",
+  "smtpActiveProvider": "production",
+  "emailTestMode": false,
+  "testEmailRecipient": "admin@example.com",
+  "enableInfluence": true,
+  "enableMap": true,
+  "enableNewsletter": false,
+  "enableLandingPages": true,
+  "createdAt": "2026-02-01T12:00:00.000Z",
+  "updatedAt": "2026-02-11T12:00:00.000Z"
+}
+
+

Error Responses:

+
    +
  • 401 Unauthorized: Missing or invalid access token
  • +
  • 403 Forbidden: Non-SUPER_ADMIN user
  • +
+

Implementation:

+
router.get(
+  '/admin',
+  authenticate,
+  requireRole(UserRole.SUPER_ADMIN),
+  async (_req: Request, res: Response, next: NextFunction) => {
+    try {
+      const settings = await siteSettingsService.get();
+      res.json(settings);
+    } catch (err) {
+      next(err);
+    }
+  }
+);
+
+

Decryption:

+
/** Full settings with encrypted fields decrypted (admin use) */
+async get() {
+  let settings = await prisma.siteSettings.findFirst();
+  if (!settings) {
+    settings = await prisma.siteSettings.create({ data: {} });
+  }
+  return decryptSettings(settings);
+}
+
+function decryptSettings(settings: SiteSettings): SiteSettings {
+  for (const field of ENCRYPTED_FIELDS) {
+    const value = settings[field];
+    if (typeof value === 'string' && value) {
+      (settings as Record<string, unknown>)[field] = decrypt(value);
+    }
+  }
+  return settings;
+}
+
+
+

PUT /api/settings

+

Update site settings (SUPER_ADMIN only). Automatically rebuilds email transporter if SMTP fields change.

+

Request Headers:

+
Authorization: Bearer <access_token>
+Content-Type: application/json
+
+

Request Body (Partial Update):

+
{
+  "organizationName": "My Campaign",
+  "organizationShortName": "MC",
+  "organizationLogoUrl": "https://example.com/new-logo.png",
+  "publicColorPrimary": "#ff6b6b",
+  "smtpHost": "smtp.sendgrid.net",
+  "smtpPort": 587,
+  "smtpUser": "apikey",
+  "smtpPass": "SG.new_api_key",
+  "smtpFromAddress": "hello@mycampaign.org",
+  "smtpActiveProvider": "production",
+  "emailTestMode": false,
+  "enableNewsletter": true
+}
+
+

All fields are optional (partial updates supported).

+

Response (200 OK):

+

Returns updated settings (same format as GET /api/settings/admin).

+

Error Responses:

+
    +
  • 401 Unauthorized: Missing or invalid access token
  • +
  • 403 Forbidden: Non-SUPER_ADMIN user
  • +
  • 400 Bad Request: Invalid field values
  • +
+

Implementation:

+
router.put(
+  '/',
+  authenticate,
+  requireRole(UserRole.SUPER_ADMIN),
+  validate(updateSiteSettingsSchema),
+  async (req: Request, res: Response, next: NextFunction) => {
+    try {
+      const settings = await siteSettingsService.update(req.body);
+
+      // If SMTP-related fields were updated, rebuild the transporter
+      const smtpFields = ['smtpHost', 'smtpPort', 'smtpUser', 'smtpPass', 'smtpFromAddress', 'smtpActiveProvider', 'emailTestMode', 'testEmailRecipient'];
+      const hasSmtpChanges = smtpFields.some((f) => f in req.body);
+      if (hasSmtpChanges) {
+        await emailService.rebuildTransporter();
+      }
+
+      res.json(settings);
+    } catch (err) {
+      next(err);
+    }
+  }
+);
+
+

Encryption on Write:

+
async update(data: UpdateSiteSettingsInput) {
+  // Encrypt sensitive fields before writing to DB
+  const toWrite = { ...data };
+  for (const field of ENCRYPTED_FIELDS) {
+    if (field in toWrite && typeof toWrite[field] === 'string' && toWrite[field]) {
+      (toWrite as Record<string, unknown>)[field] = encrypt(toWrite[field] as string);
+    }
+  }
+
+  const existing = await prisma.siteSettings.findFirst();
+  let settings: SiteSettings;
+  if (existing) {
+    settings = await prisma.siteSettings.update({
+      where: { id: existing.id },
+      data: toWrite,
+    });
+  } else {
+    settings = await prisma.siteSettings.create({ data: toWrite });
+  }
+  return decryptSettings(settings);
+}
+
+

Email Transporter Rebuild:

+

When SMTP settings change, the email service transporter is automatically rebuilt:

+
const smtpFields = ['smtpHost', 'smtpPort', 'smtpUser', 'smtpPass', 'smtpFromAddress', 'smtpActiveProvider', 'emailTestMode', 'testEmailRecipient'];
+const hasSmtpChanges = smtpFields.some((f) => f in req.body);
+if (hasSmtpChanges) {
+  await emailService.rebuildTransporter();
+}
+
+
+

POST /api/settings/email/test-connection

+

Test SMTP connection (SUPER_ADMIN only).

+

Request Headers:

+
Authorization: Bearer <access_token>
+
+

Example Request:

+
curl -X POST \
+  -H "Authorization: Bearer <token>" \
+  http://api.cmlite.org/api/settings/email/test-connection
+
+

Response (200 OK):

+
{
+  "success": true,
+  "message": "SMTP connection verified"
+}
+
+

Response (Failure):

+
{
+  "success": false,
+  "message": "SMTP connection failed"
+}
+
+

Implementation:

+
router.post(
+  '/email/test-connection',
+  authenticate,
+  requireRole(UserRole.SUPER_ADMIN),
+  async (_req: Request, res: Response, next: NextFunction) => {
+    try {
+      const success = await emailService.testConnection();
+      res.json({ success, message: success ? 'SMTP connection verified' : 'SMTP connection failed' });
+    } catch (err) {
+      next(err);
+    }
+  }
+);
+
+
+

POST /api/settings/email/test-send

+

Send test email to verify SMTP configuration (SUPER_ADMIN only).

+

Request Headers:

+
Authorization: Bearer <access_token>
+Content-Type: application/json
+
+

Request Body (Optional):

+
{
+  "to": "test@example.com"
+}
+
+

If to is not provided, uses testEmailRecipient from settings or defaults to admin@cmlite.org.

+

Example Request:

+
curl -X POST \
+  -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d '{"to":"test@example.com"}' \
+  http://api.cmlite.org/api/settings/email/test-send
+
+

Response (200 OK):

+
{
+  "success": true,
+  "messageId": "<20260211120000.1.abcd1234@cmlite.org>",
+  "testMode": false,
+  "recipient": "test@example.com"
+}
+
+

Response (Test Mode):

+
{
+  "success": true,
+  "messageId": "test-mode-1234567890",
+  "testMode": true,
+  "recipient": "test@example.com"
+}
+
+

Implementation:

+
router.post(
+  '/email/test-send',
+  authenticate,
+  requireRole(UserRole.SUPER_ADMIN),
+  async (req: Request, res: Response, next: NextFunction) => {
+    try {
+      const { to } = req.body as { to?: string };
+      const settings = await siteSettingsService.get();
+      const recipient = to || settings.testEmailRecipient || 'admin@cmlite.org';
+
+      const result = await emailService.sendEmail({
+        to: recipient,
+        subject: 'Changemaker Lite — Test Email',
+        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>`,
+        text: `SMTP Test Successful\n\nThis email confirms that your SMTP configuration is working correctly.\n\nSent at: ${new Date().toISOString()}`,
+      });
+
+      res.json({
+        success: result.success,
+        messageId: result.messageId,
+        testMode: result.testMode,
+        recipient,
+      });
+    } catch (err) {
+      next(err);
+    }
+  }
+);
+
+

Test Mode:

+

If emailTestMode is true, emails are sent to MailHog instead of actual SMTP server:

+
    +
  • Development: MailHog captures emails at http://localhost:8025
  • +
  • Production: Should set emailTestMode: false to use real SMTP
  • +
+

Service Functions

+

siteSettingsService.get()

+

Purpose: Get full settings with decrypted SMTP password (admin use).

+

Auto-Creation:

+
let settings = await prisma.siteSettings.findFirst();
+if (!settings) {
+  settings = await prisma.siteSettings.create({ data: {} });
+}
+return decryptSettings(settings);
+
+
+

siteSettingsService.getPublic()

+

Purpose: Get settings without sensitive SMTP fields (public use).

+

Stripped Fields:

+
    +
  • smtpHost
  • +
  • smtpPort
  • +
  • smtpUser
  • +
  • smtpPass
  • +
  • smtpFromAddress
  • +
  • testEmailRecipient
  • +
+
+

siteSettingsService.update(data)

+

Purpose: Update settings with encryption for sensitive fields.

+

Encryption:

+
const toWrite = { ...data };
+for (const field of ENCRYPTED_FIELDS) {
+  if (field in toWrite && typeof toWrite[field] === 'string' && toWrite[field]) {
+    (toWrite as Record<string, unknown>)[field] = encrypt(toWrite[field] as string);
+  }
+}
+
+

Upsert Logic:

+
const existing = await prisma.siteSettings.findFirst();
+let settings: SiteSettings;
+if (existing) {
+  settings = await prisma.siteSettings.update({
+    where: { id: existing.id },
+    data: toWrite,
+  });
+} else {
+  settings = await prisma.siteSettings.create({ data: toWrite });
+}
+return decryptSettings(settings);
+
+

Code Examples

+

Frontend: Load Public Settings

+
import axios from 'axios';
+
+const loadSettings = async () => {
+  const { data } = await axios.get('/api/settings');
+
+  // Apply theme to Ant Design ConfigProvider
+  document.title = data.organizationName;
+  if (data.organizationFaviconUrl) {
+    const link = document.querySelector("link[rel='icon']") as HTMLLinkElement;
+    if (link) link.href = data.organizationFaviconUrl;
+  }
+
+  return data;
+};
+
+

Admin: Update Settings

+
import { api } from '@/lib/api';
+
+const updateSettings = async (updates: Partial<SiteSettings>) => {
+  const { data } = await api.put('/api/settings', updates);
+
+  message.success('Settings updated successfully');
+  return data;
+};
+
+// Usage
+await updateSettings({
+  organizationName: 'My Campaign',
+  publicColorPrimary: '#ff6b6b',
+  enableNewsletter: true,
+});
+
+

Admin: Test SMTP Connection

+
import { api } from '@/lib/api';
+
+const testSmtpConnection = async () => {
+  try {
+    const { data } = await api.post('/api/settings/email/test-connection');
+
+    if (data.success) {
+      message.success('SMTP connection verified');
+    } else {
+      message.error('SMTP connection failed');
+    }
+
+    return data.success;
+  } catch (error) {
+    message.error('Failed to test SMTP connection');
+    return false;
+  }
+};
+
+

Admin: Send Test Email

+
import { api } from '@/lib/api';
+
+const sendTestEmail = async (recipient?: string) => {
+  try {
+    const { data } = await api.post('/api/settings/email/test-send', {
+      to: recipient,
+    });
+
+    if (data.success) {
+      if (data.testMode) {
+        message.success(`Test email sent (MailHog mode) to ${data.recipient}`);
+      } else {
+        message.success(`Test email sent to ${data.recipient}`);
+      }
+    }
+
+    return data;
+  } catch (error) {
+    message.error('Failed to send test email');
+    throw error;
+  }
+};
+
+

Validation Schema

+
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Must be a hex color (e.g. #ff00ff)');
+
+export const updateSiteSettingsSchema = z.object({
+  // Organization
+  organizationName: z.string().min(1).max(100).optional(),
+  organizationShortName: z.string().min(1).max(10).optional(),
+  organizationLogoUrl: z.string().url().nullable().optional().or(z.literal('')),
+  organizationFaviconUrl: z.string().url().nullable().optional().or(z.literal('')),
+
+  // Admin theme
+  adminColorPrimary: hexColor.optional(),
+  adminColorBgBase: hexColor.optional(),
+
+  // Public theme
+  publicColorPrimary: hexColor.optional(),
+  publicColorBgBase: hexColor.optional(),
+  publicColorBgContainer: hexColor.optional(),
+  publicHeaderGradient: z.string().max(500).optional(),
+
+  // Text
+  footerText: z.string().max(200).optional(),
+  loginSubtitle: z.string().max(50).optional(),
+
+  // Email branding
+  emailFromName: z.string().min(1).max(100).optional(),
+
+  // SMTP configuration
+  smtpHost: z.string().max(255).optional(),
+  smtpPort: z.number().int().min(0).max(65535).optional(),
+  smtpUser: z.string().max(255).optional(),
+  smtpPass: z.string().max(500).optional(),
+  smtpFromAddress: z.string().max(255).optional(),
+  smtpActiveProvider: z.enum(['mailhog', 'production']).optional(),
+  emailTestMode: z.boolean().optional(),
+  testEmailRecipient: z.string().max(255).optional(),
+
+  // Feature toggles
+  enableInfluence: z.boolean().optional(),
+  enableMap: z.boolean().optional(),
+  enableNewsletter: z.boolean().optional(),
+  enableLandingPages: z.boolean().optional(),
+});
+
+

Encryption

+

AES-256-GCM Encryption

+

The smtpPass field is encrypted at rest using AES-256-GCM (authenticated encryption).

+

Environment Configuration:

+
ENCRYPTION_KEY=<32-byte-hex>  # Must NOT reuse JWT secrets
+
+

Generate Encryption Key:

+
openssl rand -hex 32
+
+

Encryption Utility:

+
import crypto from 'crypto';
+
+const ALGORITHM = 'aes-256-gcm';
+const IV_LENGTH = 12;
+const TAG_LENGTH = 16;
+const SALT_LENGTH = 32;
+const KEY_LENGTH = 32;
+
+export function encrypt(plaintext: string): string {
+  const iv = crypto.randomBytes(IV_LENGTH);
+  const salt = crypto.randomBytes(SALT_LENGTH);
+
+  const key = crypto.pbkdf2Sync(env.ENCRYPTION_KEY, salt, 100000, KEY_LENGTH, 'sha256');
+  const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
+
+  let encrypted = cipher.update(plaintext, 'utf8', 'base64');
+  encrypted += cipher.final('base64');
+
+  const tag = cipher.getAuthTag();
+
+  // Format: iv:salt:tag:ciphertext
+  return `${iv.toString('base64')}:${salt.toString('base64')}:${tag.toString('base64')}:${encrypted}`;
+}
+
+export function decrypt(ciphertext: string): string {
+  const parts = ciphertext.split(':');
+  if (parts.length !== 4) throw new Error('Invalid ciphertext format');
+
+  const [ivB64, saltB64, tagB64, encrypted] = parts;
+  const iv = Buffer.from(ivB64, 'base64');
+  const salt = Buffer.from(saltB64, 'base64');
+  const tag = Buffer.from(tagB64, 'base64');
+
+  const key = crypto.pbkdf2Sync(env.ENCRYPTION_KEY, salt, 100000, KEY_LENGTH, 'sha256');
+  const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
+
+  decipher.setAuthTag(tag);
+
+  let decrypted = decipher.update(encrypted, 'base64', 'utf8');
+  decrypted += decipher.final('utf8');
+
+  return decrypted;
+}
+
+

Feature Toggles

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ToggleDefaultDescription
enableInfluencetrueAdvocacy campaigns + response wall
enableMaptrueLocation mapping + canvassing
enableNewsletterfalseListmonk integration
enableLandingPagestrueGrapesJS page builder
+

Frontend Usage:

+
const settings = await loadSettings();
+
+if (settings.enableInfluence) {
+  // Show Influence menu items
+}
+
+if (settings.enableMap) {
+  // Show Map menu items
+}
+
+

Environment Configuration

+

Required environment variables:

+
# Encryption (for smtpPass field)
+ENCRYPTION_KEY=<32-byte-hex>  # Must differ from JWT secrets
+
+# Database
+DATABASE_URL=postgresql://user:password@localhost:5432/changemaker_v2
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/backend/modules/shifts/index.html b/mkdocs/site/v2/backend/modules/shifts/index.html new file mode 100644 index 00000000..ce2921d4 --- /dev/null +++ b/mkdocs/site/v2/backend/modules/shifts/index.html @@ -0,0 +1,7754 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Shifts Module - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Shifts Module

+

Overview

+

The Shifts module manages volunteer shift scheduling with public signup capabilities. It provides comprehensive CRUD operations for shift management, volunteer signup tracking, and automatic status updates based on capacity. The module includes three separate routers for admin management, authenticated volunteer portal access, and public signup flows.

+

Key Features:

+
    +
  • Full shift CRUD with pagination, search, and filtering
  • +
  • Automatic status management (OPEN → FULL based on capacity)
  • +
  • Cut association for canvassing shifts (optional)
  • +
  • Three signup sources: admin-added, authenticated user, public
  • +
  • Temporary user creation for public signups (auto-expires after shift date)
  • +
  • Email confirmation system with readable passwords for new users
  • +
  • Capacity tracking (currentVolunteers / maxVolunteers)
  • +
  • Cancellation system with capacity recalculation
  • +
  • Email all volunteers functionality
  • +
  • Rate limiting on signup endpoints (5/min per IP)
  • +
  • Prometheus metrics tracking (cm_shift_signups_total)
  • +
+

File Paths

+ + + + + + + + + + + + + + + + + + + + + +
FilePurpose
api/src/modules/map/shifts/shifts.routes.ts3 routers: admin, volunteer, public (242 lines)
api/src/modules/map/shifts/shifts.service.tsShift business logic with signup flows (754 lines)
api/src/modules/map/shifts/shifts.schemas.tsZod validation schemas (55 lines)
+

Database Models

+
model Shift {
+  id                String      @id @default(cuid())
+  title             String
+  description       String?     @db.Text
+  date              DateTime    @db.Date
+  startTime         String      // HH:MM format
+  endTime           String      // HH:MM format
+  location          String?
+  maxVolunteers     Int
+  currentVolunteers Int         @default(0)
+  status            ShiftStatus @default(OPEN)
+  isPublic          Boolean     @default(false)
+  cutId             String?
+  cut               Cut?        @relation(fields: [cutId], references: [id], onDelete: SetNull)
+  createdBy         String?
+  createdAt         DateTime    @default(now())
+  updatedAt         DateTime    @updatedAt
+
+  signups           ShiftSignup[]
+  canvassVisits     CanvassVisit[]
+  canvassSessions   CanvassSession[]
+
+  @@index([cutId])
+  @@map("shifts")
+}
+
+enum ShiftStatus {
+  OPEN       // Accepting signups
+  FULL       // Max capacity reached
+  CANCELLED  // Shift cancelled
+}
+
+model ShiftSignup {
+  id           String       @id @default(cuid())
+  shiftId      String
+  shift        Shift        @relation(fields: [shiftId], references: [id], onDelete: Cascade)
+  shiftTitle   String?
+  userId       String?
+  user         User?        @relation(fields: [userId], references: [id], onDelete: SetNull)
+  userEmail    String
+  userName     String?
+  userPhone    String?
+  signupDate   DateTime     @default(now())
+  status       SignupStatus @default(CONFIRMED)
+  signupSource SignupSource @default(AUTHENTICATED)
+
+  @@unique([shiftId, userEmail])
+  @@index([shiftId])
+  @@map("shift_signups")
+}
+
+enum SignupStatus {
+  CONFIRMED  // Active signup
+  CANCELLED  // Cancelled (can be re-activated)
+}
+
+enum SignupSource {
+  AUTHENTICATED  // Logged-in user signup
+  PUBLIC         // Anonymous public signup
+  ADMIN          // Added by admin
+}
+
+

Key Relationships:

+
    +
  • Shift → ShiftSignup: One-to-many (cascade delete)
  • +
  • Shift → Cut: Optional many-to-one (cut assignment for canvassing, set null on delete)
  • +
  • Shift → CanvassSession/CanvassVisit: One-to-many (canvassing data linked to shifts)
  • +
  • ShiftSignup → User: Optional many-to-one (set null on user delete, preserves signup record)
  • +
+

Unique Constraints:

+
    +
  • [shiftId, userEmail] — One signup per email per shift (allows re-activation of cancelled signups)
  • +
+

API Endpoints

+

Admin Endpoints (Authentication Required)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathAuthDescription
GET/api/map/shiftsMAP_ADMINList paginated shifts
GET/api/map/shifts/statsMAP_ADMINShift statistics
GET/api/map/shifts/:idMAP_ADMINGet shift with signups
POST/api/map/shiftsMAP_ADMINCreate shift
PUT/api/map/shifts/:idMAP_ADMINUpdate shift
DELETE/api/map/shifts/:idMAP_ADMINDelete shift
POST/api/map/shifts/:id/signupsMAP_ADMINAdd volunteer signup
DELETE/api/map/shifts/:id/signups/:signupIdMAP_ADMINRemove signup
POST/api/map/shifts/:id/email-detailsMAP_ADMINEmail all volunteers
+

Admin Roles: SUPER_ADMIN, MAP_ADMIN

+

Volunteer Endpoints (Authentication Required)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathAuthDescription
GET/api/map/shifts/volunteer/upcomingAny logged-inUpcoming shifts with signup status
GET/api/map/shifts/volunteer/my-signupsAny logged-inOwn confirmed signups
POST/api/map/shifts/volunteer/:id/signupAny logged-inSign up for shift
DELETE/api/map/shifts/volunteer/:id/signupAny logged-inCancel own signup
+

Public Endpoints (No Authentication)

+ + + + + + + + + + + + + + + + + + + + + + + +
MethodPathAuthDescription
GET/api/map/shifts/publicNoneList public upcoming shifts
POST/api/map/shifts/public/:id/signupNonePublic signup (creates temp user if needed)
+

Admin Endpoint Details

+

GET /api/map/shifts

+

List shifts with pagination, search, and filtering.

+

Query Parameters:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDefaultDescription
pagenumberNo1Page number
limitnumberNo20Results per page (max 100)
searchstringNo-Search title or location
statusShiftStatusNo-Filter by status
upcomingbooleanNo-Filter to shifts with date >= today
sortByenumNodateSort field: date, createdAt, title
sortOrderenumNodescSort direction: asc, desc
+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/map/shifts?upcoming=true&status=OPEN&page=1&limit=10"
+
+

Response (200 OK):

+
{
+  "shifts": [
+    {
+      "id": "clx1234567890",
+      "title": "Door Knocking — Ward 5",
+      "description": "Canvassing residential areas in Ward 5. Meet at campaign office.",
+      "date": "2026-02-15T00:00:00.000Z",
+      "startTime": "10:00",
+      "endTime": "14:00",
+      "location": "123 Main St (Campaign Office)",
+      "maxVolunteers": 15,
+      "currentVolunteers": 8,
+      "status": "OPEN",
+      "isPublic": true,
+      "cutId": "clx0987654321",
+      "cut": {
+        "id": "clx0987654321",
+        "name": "Ward 5 Residential"
+      },
+      "createdBy": "clx1111111111",
+      "createdAt": "2026-02-01T12:00:00.000Z",
+      "updatedAt": "2026-02-11T14:30:00.000Z",
+      "_count": {
+        "signups": 8
+      }
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 10,
+    "total": 23,
+    "totalPages": 3
+  }
+}
+
+
+

GET /api/map/shifts/stats

+

Get shift statistics.

+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/map/shifts/stats"
+
+

Response (200 OK):

+
{
+  "total": 45,
+  "open": 12,
+  "full": 3,
+  "cancelled": 2,
+  "upcoming": 15,
+  "totalSignups": 287
+}
+
+
+

GET /api/map/shifts/:id

+

Get single shift with signups list.

+

Path Parameters:

+
    +
  • id (string): Shift ID
  • +
+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/map/shifts/clx1234567890"
+
+

Response (200 OK):

+
{
+  "id": "clx1234567890",
+  "title": "Door Knocking — Ward 5",
+  "description": "Canvassing residential areas in Ward 5",
+  "date": "2026-02-15T00:00:00.000Z",
+  "startTime": "10:00",
+  "endTime": "14:00",
+  "location": "123 Main St",
+  "maxVolunteers": 15,
+  "currentVolunteers": 8,
+  "status": "OPEN",
+  "isPublic": true,
+  "cutId": "clx0987654321",
+  "cut": {
+    "id": "clx0987654321",
+    "name": "Ward 5 Residential"
+  },
+  "signups": [
+    {
+      "id": "clx2222222222",
+      "shiftId": "clx1234567890",
+      "shiftTitle": "Door Knocking — Ward 5",
+      "userId": "clx3333333333",
+      "user": {
+        "id": "clx3333333333",
+        "email": "volunteer@example.com",
+        "name": "Jane Volunteer",
+        "phone": "+1234567890"
+      },
+      "userEmail": "volunteer@example.com",
+      "userName": "Jane Volunteer",
+      "userPhone": "+1234567890",
+      "signupDate": "2026-02-05T10:30:00.000Z",
+      "status": "CONFIRMED",
+      "signupSource": "PUBLIC"
+    }
+  ],
+  "_count": {
+    "signups": 8
+  }
+}
+
+

Error Responses:

+
    +
  • 404 Not Found: Shift not found
  • +
+
+

POST /api/map/shifts

+

Create new shift.

+

Request Body:

+
{
+  "title": "Door Knocking — Ward 5",
+  "description": "Canvassing residential areas in Ward 5. Meet at campaign office.",
+  "date": "2026-02-15",
+  "startTime": "10:00",
+  "endTime": "14:00",
+  "location": "123 Main St (Campaign Office)",
+  "maxVolunteers": 15,
+  "isPublic": true,
+  "cutId": "clx0987654321"
+}
+
+

Response (201 Created):

+

Returns created shift object (same format as GET).

+

Validation:

+
    +
  • date must be YYYY-MM-DD format
  • +
  • startTime, endTime must be HH:MM format
  • +
  • maxVolunteers must be >= 1
  • +
  • cutId is optional (for non-canvassing shifts)
  • +
+
+

PUT /api/map/shifts/:id

+

Update shift. Auto-updates status if capacity changes.

+

Request Body (Partial):

+
{
+  "maxVolunteers": 20,
+  "status": "OPEN"
+}
+
+

Response (200 OK):

+

Returns updated shift object.

+

Auto-Status Logic:

+
// When maxVolunteers is updated:
+if (currentVolunteers >= newMaxVolunteers && status === OPEN) {
+  status = FULL;
+} else if (currentVolunteers < newMaxVolunteers && status === FULL) {
+  status = OPEN;
+}
+
+
+

DELETE /api/map/shifts/:id

+

Delete shift. Cascade deletes all signups.

+

Response (204 No Content):

+

No response body.

+
+

POST /api/map/shifts/:id/signups

+

Admin add volunteer signup.

+

Request Body:

+
{
+  "userEmail": "volunteer@example.com",
+  "userName": "Jane Volunteer"
+}
+
+

Response (201 Created):

+
{
+  "id": "clx2222222222",
+  "shiftId": "clx1234567890",
+  "shiftTitle": "Door Knocking — Ward 5",
+  "userId": "clx3333333333",
+  "userEmail": "volunteer@example.com",
+  "userName": "Jane Volunteer",
+  "userPhone": null,
+  "signupDate": "2026-02-11T15:00:00.000Z",
+  "status": "CONFIRMED",
+  "signupSource": "ADMIN"
+}
+
+

Behavior:

+
    +
  • Looks up user by email (if exists, links via userId)
  • +
  • If signup was previously cancelled, re-activates it
  • +
  • Increments currentVolunteers
  • +
  • Auto-updates shift status to FULL if capacity reached
  • +
  • Transaction ensures atomicity
  • +
+

Error Responses:

+
    +
  • 400 Bad Request: Shift is full
  • +
  • 404 Not Found: Shift not found
  • +
  • 409 Conflict: Volunteer already signed up
  • +
+
+

DELETE /api/map/shifts/:id/signups/:signupId

+

Admin remove volunteer signup.

+

Path Parameters:

+
    +
  • id (string): Shift ID
  • +
  • signupId (string): Signup ID
  • +
+

Response (204 No Content):

+

No response body.

+

Behavior:

+
    +
  • Updates signup status to CANCELLED (does not delete record)
  • +
  • Decrements currentVolunteers
  • +
  • Auto-updates shift status to OPEN
  • +
  • Transaction ensures atomicity
  • +
+

Error Responses:

+
    +
  • 400 Bad Request: Signup already cancelled
  • +
  • 404 Not Found: Signup not found
  • +
+
+

POST /api/map/shifts/:id/email-details

+

Email shift details to all confirmed volunteers.

+

Example Request:

+
curl -X POST \
+  -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/map/shifts/clx1234567890/email-details"
+
+

Response (200 OK):

+
{
+  "sent": 8,
+  "failed": 0
+}
+
+

Email Template:

+

Uses shift-details.html and shift-details.txt templates with variables:

+
    +
  • USER_NAME — Volunteer name
  • +
  • SHIFT_TITLE — Shift title
  • +
  • SHIFT_DATE — Formatted date
  • +
  • SHIFT_START_TIME — Start time
  • +
  • SHIFT_END_TIME — End time
  • +
  • SHIFT_LOCATION — Location
  • +
  • SHIFT_DESCRIPTION — Description
  • +
  • CURRENT_VOLUNTEERS — Current signup count
  • +
  • MAX_VOLUNTEERS — Max capacity
  • +
  • SHIFT_STATUS — Status
  • +
  • ORGANIZATION_NAME — Site settings org name
  • +
+
+

Volunteer Endpoint Details

+

GET /api/map/shifts/volunteer/upcoming

+

Get upcoming public shifts with signup status for current user.

+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/map/shifts/volunteer/upcoming"
+
+

Response (200 OK):

+
[
+  {
+    "id": "clx1234567890",
+    "title": "Door Knocking — Ward 5",
+    "description": "Canvassing residential areas",
+    "date": "2026-02-15T00:00:00.000Z",
+    "startTime": "10:00",
+    "endTime": "14:00",
+    "location": "123 Main St",
+    "maxVolunteers": 15,
+    "currentVolunteers": 8,
+    "status": "OPEN",
+    "isSignedUp": true
+  },
+  {
+    "id": "clx9876543210",
+    "title": "Phone Banking",
+    "description": "Call voters for GOTV",
+    "date": "2026-02-16T00:00:00.000Z",
+    "startTime": "18:00",
+    "endTime": "20:00",
+    "location": "Virtual (Zoom)",
+    "maxVolunteers": 25,
+    "currentVolunteers": 12,
+    "status": "OPEN",
+    "isSignedUp": false
+  }
+]
+
+

Filtering:

+
    +
  • Only public shifts (isPublic: true)
  • +
  • Only non-cancelled shifts
  • +
  • Only shifts with date >= today
  • +
  • Sorted by date ASC, then startTime ASC
  • +
+
+

GET /api/map/shifts/volunteer/my-signups

+

Get current user's confirmed signups for upcoming shifts.

+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/map/shifts/volunteer/my-signups"
+
+

Response (200 OK):

+
[
+  {
+    "id": "clx2222222222",
+    "shiftId": "clx1234567890",
+    "shiftTitle": "Door Knocking — Ward 5",
+    "userId": "clx3333333333",
+    "userEmail": "volunteer@example.com",
+    "userName": "Jane Volunteer",
+    "userPhone": "+1234567890",
+    "signupDate": "2026-02-05T10:30:00.000Z",
+    "status": "CONFIRMED",
+    "signupSource": "PUBLIC",
+    "shift": {
+      "id": "clx1234567890",
+      "title": "Door Knocking — Ward 5",
+      "description": "Canvassing residential areas",
+      "date": "2026-02-15T00:00:00.000Z",
+      "startTime": "10:00",
+      "endTime": "14:00",
+      "location": "123 Main St",
+      "maxVolunteers": 15,
+      "currentVolunteers": 8,
+      "status": "OPEN"
+    }
+  }
+]
+
+

Filtering:

+
    +
  • Signups by current user's email
  • +
  • Only confirmed signups
  • +
  • Only shifts with date >= today
  • +
  • Only non-cancelled shifts
  • +
  • Sorted by shift date ASC
  • +
+
+

POST /api/map/shifts/volunteer/:id/signup

+

Authenticated user signs up for shift.

+

Path Parameters:

+
    +
  • id (string): Shift ID
  • +
+

Rate Limiting:

+

5 requests/min per IP (shiftSignupRateLimit middleware)

+

Example Request:

+
curl -X POST \
+  -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/map/shifts/volunteer/clx1234567890/signup"
+
+

Response (201 Created):

+
{
+  "id": "clx2222222222",
+  "shiftId": "clx1234567890",
+  "shiftTitle": "Door Knocking — Ward 5",
+  "userId": "clx3333333333",
+  "userEmail": "volunteer@example.com",
+  "userName": "Jane Volunteer",
+  "userPhone": "+1234567890",
+  "signupDate": "2026-02-11T15:00:00.000Z",
+  "status": "CONFIRMED",
+  "signupSource": "AUTHENTICATED"
+}
+
+

Validation:

+
    +
  • Shift must be public (isPublic: true)
  • +
  • Shift must not be cancelled
  • +
  • Shift must not have passed (date >= today)
  • +
  • Shift must not be full
  • +
  • User must not already be signed up
  • +
+

Behavior:

+
    +
  • If previously cancelled signup exists, re-activates it
  • +
  • Sends confirmation email (no temp password)
  • +
  • Increments currentVolunteers
  • +
  • Auto-updates shift status to FULL if capacity reached
  • +
  • Records Prometheus metric cm_shift_signups_total
  • +
+

Error Responses:

+
    +
  • 400 Bad Request: Shift is full, cancelled, or past
  • +
  • 403 Forbidden: Shift is not public
  • +
  • 404 Not Found: Shift not found
  • +
  • 409 Conflict: Already signed up
  • +
  • 429 Too Many Requests: Rate limit exceeded
  • +
+
+

DELETE /api/map/shifts/volunteer/:id/signup

+

Cancel own signup.

+

Path Parameters:

+
    +
  • id (string): Shift ID
  • +
+

Example Request:

+
curl -X DELETE \
+  -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/map/shifts/volunteer/clx1234567890/signup"
+
+

Response (204 No Content):

+

No response body.

+

Behavior:

+
    +
  • Updates signup status to CANCELLED
  • +
  • Decrements currentVolunteers
  • +
  • Auto-updates shift status to OPEN
  • +
+

Error Responses:

+
    +
  • 400 Bad Request: Already cancelled
  • +
  • 404 Not Found: Signup not found
  • +
+
+

Public Endpoint Details

+

GET /api/map/shifts/public

+

List public upcoming shifts (no auth required).

+

Example Request:

+
curl http://api.cmlite.org/api/map/shifts/public
+
+

Response (200 OK):

+
[
+  {
+    "id": "clx1234567890",
+    "title": "Door Knocking — Ward 5",
+    "description": "Canvassing residential areas in Ward 5",
+    "date": "2026-02-15T00:00:00.000Z",
+    "startTime": "10:00",
+    "endTime": "14:00",
+    "location": "123 Main St",
+    "maxVolunteers": 15,
+    "currentVolunteers": 8,
+    "status": "OPEN"
+  }
+]
+
+

Filtering:

+
    +
  • Only public shifts (isPublic: true)
  • +
  • Only non-cancelled shifts
  • +
  • Only shifts with date >= today
  • +
  • Sorted by date ASC, then startTime ASC
  • +
+
+

POST /api/map/shifts/public/:id/signup

+

Public signup with temporary user creation.

+

Path Parameters:

+
    +
  • id (string): Shift ID
  • +
+

Rate Limiting:

+

5 requests/min per IP (shiftSignupRateLimit middleware)

+

Request Body:

+
{
+  "email": "newvolunteer@example.com",
+  "name": "John Doe",
+  "phone": "+1234567890"
+}
+
+

Response (201 Created):

+
{
+  "signup": {
+    "id": "clx2222222222",
+    "shiftId": "clx1234567890",
+    "shiftTitle": "Door Knocking — Ward 5",
+    "userId": "clx4444444444",
+    "userEmail": "newvolunteer@example.com",
+    "userName": "John Doe",
+    "userPhone": "+1234567890",
+    "signupDate": "2026-02-11T15:00:00.000Z",
+    "status": "CONFIRMED",
+    "signupSource": "PUBLIC"
+  },
+  "isNewUser": true
+}
+
+

Validation:

+
    +
  • Shift must be public
  • +
  • Shift must be OPEN status
  • +
  • Shift date must not have passed
  • +
  • Shift must not be full
  • +
  • Email must not already be signed up
  • +
+

Behavior — New User:

+

If email does not exist in database:

+
    +
  1. +

    Generate readable password: +

    const adjectives = ['Blue', 'Red', 'Green', 'Swift', 'Bright', 'Bold', 'Calm', 'Fair'];
    +const nouns = ['Eagle', 'River', 'Mountain', 'Star', 'Forest', 'Lake', 'Wolf', 'Hawk'];
    +
    +function generateReadablePassword(): string {
    +  const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
    +  const noun = nouns[Math.floor(Math.random() * nouns.length)];
    +  const num = Math.floor(Math.random() * 90) + 10;
    +  return `${adj}${noun}${num}`;  // e.g., "BlueEagle42"
    +}
    +

    +
  2. +
  3. +

    Create TEMP user: +

    const hashedPassword = await bcrypt.hash(tempPassword, 12);
    +const shiftDate = new Date(shift.date);
    +shiftDate.setDate(shiftDate.getDate() + 1);  // Expires day after shift
    +
    +const user = await prisma.user.create({
    +  data: {
    +    email: data.email,
    +    password: hashedPassword,
    +    name: data.name,
    +    phone: data.phone,
    +    role: 'TEMP',
    +    createdVia: 'PUBLIC_SHIFT_SIGNUP',
    +    expiresAt: shiftDate,
    +  },
    +});
    +

    +
  4. +
  5. +

    Send confirmation email with temp password: +

    const vars = {
    +  USER_NAME: data.name,
    +  USER_EMAIL: data.email,
    +  SHIFT_TITLE: shift.title,
    +  SHIFT_DATE: '...',
    +  SHIFT_TIME: '...',
    +  SHIFT_LOCATION: shift.location || 'TBD',
    +  IS_NEW_USER: 'true',
    +  TEMP_PASSWORD: tempPassword,  // Only included for new users
    +  LOGIN_URL: `${siteUrl}/login`,
    +  ORGANIZATION_NAME: orgName,
    +};
    +

    +
  6. +
+

Behavior — Existing User:

+

If email exists in database:

+
    +
  • Links signup to existing user via userId
  • +
  • No password generated or sent
  • +
  • Sets signupSource to AUTHENTICATED
  • +
+

Behavior — Re-activation:

+

If cancelled signup exists:

+
    +
  • Re-activates existing signup record (status → CONFIRMED)
  • +
  • Does not create duplicate signup
  • +
+

Transaction:

+
    +
  • Signup creation + currentVolunteers increment + status update are atomic
  • +
+

Error Responses:

+
    +
  • 400 Bad Request: Shift full, not open, or past
  • +
  • 403 Forbidden: Shift not public
  • +
  • 404 Not Found: Shift not found
  • +
  • 409 Conflict: Already signed up
  • +
  • 429 Too Many Requests: Rate limit exceeded
  • +
+
+

Service Functions

+

shiftsService.findAll(filters)

+

List shifts with pagination, search, and filtering.

+

Usage:

+
import { shiftsService } from './shifts.service';
+
+const result = await shiftsService.findAll({
+  page: 1,
+  limit: 20,
+  search: 'ward 5',
+  status: ShiftStatus.OPEN,
+  upcoming: true,
+  sortBy: 'date',
+  sortOrder: 'asc',
+});
+
+console.log(result.shifts.length);  // Array of shifts
+console.log(result.pagination);     // { page, limit, total, totalPages }
+
+

Search Behavior:

+
if (search) {
+  where.OR = [
+    { title: { contains: search, mode: 'insensitive' } },
+    { location: { contains: search, mode: 'insensitive' } },
+  ];
+}
+
+
+

shiftsService.findById(id)

+

Get single shift with signups list.

+

Usage:

+
const shift = await shiftsService.findById('clx1234567890');
+console.log(shift.signups.length);  // Confirmed signups only
+console.log(shift.cut?.name);       // Cut name if associated
+
+

Throws:

+
    +
  • AppError(404) if shift not found
  • +
+
+

shiftsService.create(data, userId)

+

Create shift.

+

Usage:

+
const shift = await shiftsService.create({
+  title: 'Door Knocking — Ward 5',
+  description: 'Canvassing residential areas',
+  date: '2026-02-15',
+  startTime: '10:00',
+  endTime: '14:00',
+  location: '123 Main St',
+  maxVolunteers: 15,
+  isPublic: true,
+  cutId: 'clx0987654321',
+}, req.user.id);
+
+
+

shiftsService.update(id, data)

+

Update shift with auto-status management.

+

Usage:

+
const shift = await shiftsService.update('clx1234567890', {
+  maxVolunteers: 20,
+});
+
+// If currentVolunteers was 15 and maxVolunteers was 15:
+// - Old status: FULL
+// - New status: OPEN (because 15 < 20)
+
+

Auto-Status Logic:

+
if (data.maxVolunteers !== undefined) {
+  if (existing.currentVolunteers >= data.maxVolunteers && existing.status === ShiftStatus.OPEN) {
+    updateData.status = ShiftStatus.FULL;
+  } else if (existing.currentVolunteers < data.maxVolunteers && existing.status === ShiftStatus.FULL) {
+    updateData.status = ShiftStatus.OPEN;
+  }
+}
+
+
+

shiftsService.addSignup(shiftId, data)

+

Admin add volunteer signup.

+

Usage:

+
const signup = await shiftsService.addSignup('clx1234567890', {
+  userEmail: 'volunteer@example.com',
+  userName: 'Jane Volunteer',
+});
+
+console.log(signup.signupSource);  // 'ADMIN'
+
+

Behavior:

+
    +
  • Looks up user by email
  • +
  • Re-activates cancelled signup if exists
  • +
  • Atomic transaction (signup + capacity + status)
  • +
+

Throws:

+
    +
  • AppError(400) if shift full
  • +
  • AppError(404) if shift not found
  • +
  • AppError(409) if already signed up
  • +
+
+

shiftsService.publicSignup(shiftId, data)

+

Public signup with temp user creation.

+

Usage:

+
const result = await shiftsService.publicSignup('clx1234567890', {
+  email: 'newuser@example.com',
+  name: 'John Doe',
+  phone: '+1234567890',
+});
+
+if (result.isNewUser) {
+  console.log('Created TEMP user with readable password');
+  console.log('Confirmation email sent with credentials');
+}
+
+

Temp User Expiry:

+
const shiftDate = new Date(shift.date);
+shiftDate.setDate(shiftDate.getDate() + 1);  // Expires day after shift
+
+

Email Template Variables:

+
const vars: Record<string, string> = {
+  USER_NAME: data.name,
+  USER_EMAIL: data.email,
+  SHIFT_TITLE: shift.title,
+  SHIFT_DATE: dateStr,
+  SHIFT_TIME: `${shift.startTime}${shift.endTime}`,
+  SHIFT_LOCATION: shift.location || 'TBD',
+  IS_NEW_USER: isNewUser ? 'true' : '',  // Conditional content in template
+  TEMP_PASSWORD: tempPassword || '',     // Only included for new users
+  LOGIN_URL: `${baseUrl}/login`,
+  ORGANIZATION_NAME: orgName,
+};
+
+

Metrics:

+

Records cm_shift_signups_total Prometheus counter.

+

Throws:

+
    +
  • AppError(400) if shift full, not open, or past
  • +
  • AppError(403) if not public
  • +
  • AppError(404) if shift not found
  • +
  • AppError(409) if duplicate signup
  • +
+
+

shiftsService.removeSignup(signupId)

+

Cancel signup (admin).

+

Usage:

+
await shiftsService.removeSignup('clx2222222222');
+
+// Signup status → CANCELLED
+// currentVolunteers decremented
+// Shift status → OPEN
+
+

Atomic Transaction:

+
await prisma.$transaction([
+  prisma.shiftSignup.update({
+    where: { id: signupId },
+    data: { status: SignupStatus.CANCELLED },
+  }),
+  prisma.shift.update({
+    where: { id: signup.shiftId },
+    data: {
+      currentVolunteers: { decrement: 1 },
+      status: ShiftStatus.OPEN,
+    },
+  }),
+]);
+
+
+

shiftsService.emailShiftDetails(shiftId)

+

Email shift details to all confirmed volunteers.

+

Usage:

+
const result = await shiftsService.emailShiftDetails('clx1234567890');
+console.log(`Sent: ${result.sent}, Failed: ${result.failed}`);
+
+

Email Template:

+

Uses shift-details.html and shift-details.txt with variables:

+
    +
  • USER_NAME
  • +
  • SHIFT_TITLE
  • +
  • SHIFT_DATE
  • +
  • SHIFT_START_TIME
  • +
  • SHIFT_END_TIME
  • +
  • SHIFT_LOCATION
  • +
  • SHIFT_DESCRIPTION
  • +
  • CURRENT_VOLUNTEERS
  • +
  • MAX_VOLUNTEERS
  • +
  • SHIFT_STATUS
  • +
  • ORGANIZATION_NAME
  • +
+

Error Handling:

+

Individual email failures are logged but do not stop batch processing.

+
+

Validation Schemas

+

Create Shift Schema

+
export const createShiftSchema = z.object({
+  title: z.string().min(1, 'Title is required'),
+  description: z.string().optional(),
+  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
+  startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
+  endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'),
+  location: z.string().optional(),
+  maxVolunteers: z.number().int().min(1, 'Must have at least 1 volunteer spot'),
+  isPublic: z.boolean().optional().default(false),
+  cutId: z.string().optional(),
+});
+
+

Example Valid Input:

+
{
+  "title": "Door Knocking — Ward 5",
+  "description": "Canvassing residential areas",
+  "date": "2026-02-15",
+  "startTime": "10:00",
+  "endTime": "14:00",
+  "location": "123 Main St",
+  "maxVolunteers": 15,
+  "isPublic": true,
+  "cutId": "clx0987654321"
+}
+
+
+

Update Shift Schema

+
export const updateShiftSchema = z.object({
+  title: z.string().min(1).optional(),
+  description: z.string().nullable().optional(),
+  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
+  startTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
+  endTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
+  location: z.string().nullable().optional(),
+  maxVolunteers: z.number().int().min(1).optional(),
+  isPublic: z.boolean().optional(),
+  status: z.nativeEnum(ShiftStatus).optional(),
+  cutId: z.string().nullable().optional(),
+});
+
+

Partial Updates:

+

All fields optional. Only provided fields are updated.

+
+

Public Signup Schema

+
export const publicSignupSchema = z.object({
+  email: z.string().email('Valid email is required'),
+  name: z.string().min(1, 'Name is required'),
+  phone: z.string().optional(),
+});
+
+

Example Valid Input:

+
{
+  "email": "volunteer@example.com",
+  "name": "Jane Volunteer",
+  "phone": "+1234567890"
+}
+
+
+

Code Examples

+

Admin: Create Shift with Cut Association

+
import { api } from '@/lib/api';
+import { message } from 'antd';
+
+const createShift = async () => {
+  try {
+    const { data } = await api.post('/api/map/shifts', {
+      title: 'Door Knocking — Ward 5',
+      description: 'Canvassing residential areas in Ward 5. Meet at campaign office.',
+      date: '2026-02-15',
+      startTime: '10:00',
+      endTime: '14:00',
+      location: '123 Main St (Campaign Office)',
+      maxVolunteers: 15,
+      isPublic: true,
+      cutId: 'clx0987654321',  // Associate with cut
+    });
+
+    message.success(`Shift created: ${data.title}`);
+    return data;
+  } catch (error) {
+    message.error('Failed to create shift');
+    throw error;
+  }
+};
+
+
+

Volunteer: Sign Up for Shift

+
import { api } from '@/lib/api';
+import { message } from 'antd';
+
+const signUpForShift = async (shiftId: string) => {
+  try {
+    const { data } = await api.post(`/api/map/shifts/volunteer/${shiftId}/signup`);
+
+    message.success('Signed up successfully! Check your email for confirmation.');
+    return data;
+  } catch (error: any) {
+    if (error.response?.data?.code === 'SHIFT_FULL') {
+      message.error('This shift is full');
+    } else if (error.response?.data?.code === 'DUPLICATE_SIGNUP') {
+      message.warning('You are already signed up for this shift');
+    } else {
+      message.error('Failed to sign up');
+    }
+    throw error;
+  }
+};
+
+
+

Public: Sign Up Without Account

+
import axios from 'axios';
+
+const publicSignup = async (shiftId: string, formData: { email: string; name: string; phone?: string }) => {
+  try {
+    const { data } = await axios.post(
+      `/api/map/shifts/public/${shiftId}/signup`,
+      formData
+    );
+
+    if (data.isNewUser) {
+      alert(`Account created! Check your email for your temporary password.`);
+    } else {
+      alert('Signed up successfully!');
+    }
+
+    return data;
+  } catch (error: any) {
+    if (error.response?.status === 429) {
+      alert('Too many signups. Please try again in a minute.');
+    } else if (error.response?.data?.code === 'SHIFT_FULL') {
+      alert('This shift is full');
+    } else {
+      alert('Failed to sign up');
+    }
+    throw error;
+  }
+};
+
+
+

Admin: Email All Volunteers

+
import { api } from '@/lib/api';
+import { message } from 'antd';
+
+const emailAllVolunteers = async (shiftId: string) => {
+  try {
+    const { data } = await api.post(`/api/map/shifts/${shiftId}/email-details`);
+
+    message.success(`Sent ${data.sent} emails successfully. ${data.failed} failed.`);
+    return data;
+  } catch (error) {
+    message.error('Failed to send emails');
+    throw error;
+  }
+};
+
+
+

Frontend Integration

+

The ShiftsPage component (admin/src/pages/ShiftsPage.tsx) provides:

+
    +
  • Paginated shifts table with search and status filter
  • +
  • Cut association dropdown (optional, for canvassing shifts)
  • +
  • Capacity badges (8/15 with OPEN/FULL status)
  • +
  • Create shift modal with date/time pickers
  • +
  • Edit shift modal (pre-populated form)
  • +
  • Delete confirmation modal
  • +
  • Signups drawer (shows volunteers, email all button, remove signup)
  • +
  • Public/private toggle (controls isPublic flag)
  • +
  • Status badges (OPEN=green, FULL=orange, CANCELLED=red)
  • +
+

State Management:

+
const [shifts, setShifts] = useState<Shift[]>([]);
+const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
+const [filters, setFilters] = useState({ search: '', status: null, upcoming: true });
+const [signupsDrawerOpen, setSignupsDrawerOpen] = useState(false);
+const [selectedShift, setSelectedShift] = useState<Shift | null>(null);
+
+

Volunteer Portal:

+

The VolunteerShiftsPage component (admin/src/pages/volunteer/VolunteerShiftsPage.tsx) provides:

+
    +
  • Upcoming shifts cards with shift details
  • +
  • Signup status badges ("Signed Up" vs "Join Now")
  • +
  • Capacity indicators (8/15 volunteers)
  • +
  • Signup confirmation modal
  • +
  • Cancel signup functionality
  • +
  • My signups tab (shows user's confirmed signups)
  • +
+

Public Page:

+

The ShiftsPage component (admin/src/pages/public/ShiftsPage.tsx) provides:

+
    +
  • Public shift cards with date/time/location
  • +
  • Signup modal (collects email, name, phone)
  • +
  • Capacity indicators
  • +
  • Status badges
  • +
  • Responsive grid layout
  • +
+
+

Performance Considerations

+

Capacity Tracking

+

The currentVolunteers field is denormalized for performance:

+
// Instead of counting signups on every query:
+const count = await prisma.shiftSignup.count({ where: { shiftId, status: 'CONFIRMED' } });
+
+// We maintain a counter:
+data: {
+  currentVolunteers: { increment: 1 },  // On signup
+  currentVolunteers: { decrement: 1 },  // On cancel
+}
+
+

Pros:

+
    +
  • No joins or aggregations needed for shift listings
  • +
  • Instant capacity checks
  • +
  • Fast status filtering
  • +
+

Cons:

+
    +
  • Must maintain consistency in transactions
  • +
  • Risk of drift if transactions fail
  • +
+

Consistency Checks:

+

Run periodic reconciliation:

+
UPDATE shifts
+SET "currentVolunteers" = (
+  SELECT COUNT(*) FROM shift_signups
+  WHERE "shiftId" = shifts.id AND status = 'CONFIRMED'
+)
+WHERE "currentVolunteers" != (
+  SELECT COUNT(*) FROM shift_signups
+  WHERE "shiftId" = shifts.id AND status = 'CONFIRMED'
+);
+
+
+

Unique Constraint Performance

+

The [shiftId, userEmail] unique constraint enables fast duplicate checks:

+
const existing = await prisma.shiftSignup.findUnique({
+  where: { shiftId_userEmail: { shiftId, userEmail: data.email } },
+});
+
+

Index Usage:

+
    +
  • PostgreSQL uses the unique index for lookups
  • +
  • O(log n) lookup time
  • +
  • No full table scan
  • +
+
+

Rate Limiting

+

The shiftSignupRateLimit middleware protects against signup spam:

+
// From api/src/middleware/rate-limit.ts
+export const shiftSignupRateLimit = createRateLimiter({
+  windowMs: 60 * 1000,  // 1 minute
+  max: 5,               // 5 signups per minute
+  message: 'Too many signup requests. Please try again later.',
+});
+
+

Why 5/min?

+
    +
  • Allows legitimate users to sign up for multiple shifts quickly
  • +
  • Prevents automated abuse
  • +
  • Balances UX with security
  • +
+
+

Troubleshooting

+

Shift Status Not Updating Automatically

+

Problem:

+

Shift status stays FULL even after volunteer cancels.

+

Diagnosis:

+

Check transaction logic in removeSignup:

+
await prisma.$transaction([
+  prisma.shiftSignup.update({ /* ... */ }),
+  prisma.shift.update({
+    where: { id: signup.shiftId },
+    data: {
+      currentVolunteers: { decrement: 1 },
+      status: ShiftStatus.OPEN,  // Always set to OPEN on cancel
+    },
+  }),
+]);
+
+

Solution:

+

Status is always set to OPEN on cancel. If shift should remain FULL (e.g., still at capacity), check if another transaction occurred simultaneously.

+
+

Duplicate Signups

+

Problem:

+

User signed up twice for same shift.

+

Diagnosis:

+

Check unique constraint enforcement:

+
SELECT * FROM shift_signups
+WHERE "shiftId" = 'clx1234567890' AND "userEmail" = 'volunteer@example.com';
+
+

Possible Causes:

+
    +
  • Constraint disabled
  • +
  • Race condition (two requests hit database before first commit)
  • +
  • Manual database insertion bypassing constraint
  • +
+

Solution:

+
    +
  • Verify constraint exists: \d shift_signups in psql
  • +
  • Add application-level locking if race conditions persist: +
    await prisma.$transaction([
    +  prisma.shiftSignup.findUnique({ /* check */ }),
    +  prisma.shiftSignup.create({ /* create */ }),
    +], { isolationLevel: 'Serializable' });
    +
  • +
+
+

Confirmation Emails Not Sending

+

Problem:

+

Volunteers sign up but don't receive confirmation emails.

+

Diagnosis:

+

Check email service logs:

+
docker compose logs -f api | grep "shift signup confirmation"
+
+

Common Causes:

+
    +
  1. +

    MailHog mode enabled: +

    EMAIL_TEST_MODE=true  # Emails go to MailHog, not SMTP
    +

    +
  2. +
  3. +

    SMTP misconfiguration: +

    SMTP_HOST=smtp.gmail.com
    +SMTP_PORT=587
    +SMTP_USER=your-email@gmail.com
    +SMTP_PASSWORD=your-app-password  # Must be app password, not account password
    +

    +
  4. +
  5. +

    Template missing: +

    # Check template exists
    +ls api/src/templates/shift-signup-confirmation.html
    +ls api/src/templates/shift-signup-confirmation.txt
    +

    +
  6. +
  7. +

    Email service crash: +

    try {
    +  await emailService.sendEmail({ /* ... */ });
    +} catch (err) {
    +  logger.error('Failed to send shift signup confirmation email:', err);
    +  // Signup succeeds even if email fails
    +}
    +

    +
  8. +
+

Solution:

+
    +
  • Set EMAIL_TEST_MODE=false for production
  • +
  • Verify SMTP credentials
  • +
  • Ensure templates exist
  • +
  • Check email logs for detailed errors
  • +
+
+

Temp Users Not Expiring

+

Problem:

+

TEMP users created via public signup still active long after shift.

+

Diagnosis:

+

Check expiresAt value:

+
SELECT id, email, role, "expiresAt", "createdAt"
+FROM users
+WHERE role = 'TEMP' AND "expiresAt" < NOW() AND status = 'ACTIVE';
+
+

Expected Behavior:

+
    +
  • expiresAt is set to shift date + 1 day
  • +
  • Expired users should be marked EXPIRED by auth middleware
  • +
+

Solution:

+

Run cleanup script or add cron job:

+
// Expire temp users
+await prisma.user.updateMany({
+  where: {
+    role: UserRole.TEMP,
+    expiresAt: { lte: new Date() },
+    status: { not: UserStatus.EXPIRED },
+  },
+  data: { status: UserStatus.EXPIRED },
+});
+
+
+

Rate Limit Too Strict

+

Problem:

+

Users get rate-limited when legitimately signing up for multiple shifts.

+

Diagnosis:

+

Check rate limit config:

+
export const shiftSignupRateLimit = createRateLimiter({
+  windowMs: 60 * 1000,  // 1 minute
+  max: 5,               // 5 signups per minute
+});
+
+

Solution:

+

Increase limit if legitimate use case:

+
max: 10,  // Allow 10 signups per minute
+
+

Alternative:

+

Whitelist admin IPs:

+
skip: (req) => {
+  const ip = req.ip || req.connection.remoteAddress;
+  return ['127.0.0.1', '::1', ADMIN_IP].includes(ip);
+},
+
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/backend/modules/users/index.html b/mkdocs/site/v2/backend/modules/users/index.html new file mode 100644 index 00000000..93f76ab8 --- /dev/null +++ b/mkdocs/site/v2/backend/modules/users/index.html @@ -0,0 +1,6613 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Users Module - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Users Module

+

Overview

+

The Users module provides comprehensive user management with role-based access control, pagination, search, and filtering. It supports CRUD operations with granular permissions allowing admins to manage all users while regular users can only view/update their own profile.

+

Key Features:

+
    +
  • Full CRUD operations with role-based permissions
  • +
  • Paginated list with search (email, name) and filters (role, status)
  • +
  • Self-service profile updates for regular users
  • +
  • Admin-only role and status changes
  • +
  • Password hashing with bcrypt (12 salt rounds)
  • +
  • Temporary user expiration handling
  • +
  • Email uniqueness validation
  • +
  • Cascading delete for related records
  • +
+

File Paths

+ + + + + + + + + + + + + + + + + + + + + +
FilePurpose
api/src/modules/users/users.routes.tsExpress router with 5 CRUD endpoints
api/src/modules/users/users.service.tsUser management business logic
api/src/modules/users/users.schemas.tsZod validation schemas
+

Database Model

+

The Users module uses the User model from the Auth module:

+
model User {
+  id              String         @id @default(cuid())
+  email           String         @unique
+  password        String
+  name            String?
+  phone           String?
+  role            UserRole       @default(USER)
+  status          UserStatus     @default(ACTIVE)
+  permissions     Json?
+  createdVia      String?        @default("web")
+  emailVerified   Boolean        @default(false)
+  expiresAt       DateTime?      // For TEMP users
+  expireDays      Int?           // Days until expiration
+  lastLoginAt     DateTime?
+  createdAt       DateTime       @default(now())
+  updatedAt       DateTime       @updatedAt
+
+  // Relations
+  refreshTokens   RefreshToken[]
+  createdCampaigns Campaign[]    @relation("CreatedBy")
+  createdLocations Location[]    @relation("CreatedBy")
+  // ... other relations
+}
+
+enum UserRole {
+  SUPER_ADMIN      // Full system access
+  INFLUENCE_ADMIN  // Campaign management
+  MAP_ADMIN        // Location/canvass management
+  USER             // Standard authenticated user
+  TEMP             // Temporary user (e.g., shift signups)
+}
+
+enum UserStatus {
+  ACTIVE      // Normal operation
+  SUSPENDED   // Temporarily disabled
+  BANNED      // Permanently disabled
+}
+
+

API Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathAuthPermissionsDescription
GET/api/usersRequiredAdmin rolesList users with pagination/filters
GET/api/users/:idRequiredAdmin or selfGet user by ID
POST/api/usersRequiredAdmin rolesCreate new user
PUT/api/users/:idRequiredAdmin or selfUpdate user
DELETE/api/users/:idRequiredAdmin rolesDelete user
+

Admin Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

+

Endpoint Details

+

GET /api/users

+

List users with pagination, search, and filtering (admin only).

+

Request Headers:

+
Authorization: Bearer <access_token>
+
+

Query Parameters:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDefaultDescription
pagenumberNo1Page number (1-indexed)
limitnumberNo20Results per page (max 100)
searchstringNo-Search email or name (case-insensitive)
roleUserRoleNo-Filter by role
statusUserStatusNo-Filter by status
+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/users?page=1&limit=20&search=john&role=USER&status=ACTIVE"
+
+

Response (200 OK):

+
{
+  "users": [
+    {
+      "id": "clx1234567890",
+      "email": "john.doe@example.com",
+      "name": "John Doe",
+      "phone": "+1234567890",
+      "role": "USER",
+      "status": "ACTIVE",
+      "permissions": null,
+      "createdVia": "web",
+      "expiresAt": null,
+      "expireDays": null,
+      "lastLoginAt": "2026-02-11T12:00:00.000Z",
+      "emailVerified": true,
+      "createdAt": "2026-02-01T12:00:00.000Z",
+      "updatedAt": "2026-02-11T12:00:00.000Z"
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 20,
+    "total": 150,
+    "totalPages": 8
+  }
+}
+
+

Implementation:

+
router.get(
+  '/',
+  requireRole(...ADMIN_ROLES),
+  validate(listUsersSchema, 'query'),
+  async (req: Request, res: Response, next: NextFunction) => {
+    try {
+      const result = await usersService.findAll(req.query as any);
+      res.json(result);
+    } catch (err) {
+      next(err);
+    }
+  }
+);
+
+

Search Logic:

+
if (search) {
+  where.OR = [
+    { email: { contains: search, mode: 'insensitive' } },
+    { name: { contains: search, mode: 'insensitive' } },
+  ];
+}
+
+
+

GET /api/users/:id

+

Get user by ID. Admins can view any user, regular users can only view themselves.

+

Request Headers:

+
Authorization: Bearer <access_token>
+
+

Path Parameters:

+
    +
  • id (string): User ID
  • +
+

Example Request:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/users/clx1234567890"
+
+

Response (200 OK):

+
{
+  "id": "clx1234567890",
+  "email": "john.doe@example.com",
+  "name": "John Doe",
+  "phone": "+1234567890",
+  "role": "USER",
+  "status": "ACTIVE",
+  "permissions": null,
+  "createdVia": "web",
+  "expiresAt": null,
+  "expireDays": null,
+  "lastLoginAt": "2026-02-11T12:00:00.000Z",
+  "emailVerified": true,
+  "createdAt": "2026-02-01T12:00:00.000Z",
+  "updatedAt": "2026-02-11T12:00:00.000Z"
+}
+
+

Error Responses:

+
    +
  • 403 Forbidden: Non-admin trying to view another user
  • +
  • 404 Not Found: User ID does not exist
  • +
+

Permission Logic:

+
const isAdmin = ADMIN_ROLES.includes(req.user!.role);
+const isSelf = req.user!.id === id;
+
+if (!isAdmin && !isSelf) {
+  res.status(403).json({ error: { message: 'Insufficient permissions', code: 'FORBIDDEN' } });
+  return;
+}
+
+
+

POST /api/users

+

Create new user account (admin only). Unlike public registration, admins can set any role.

+

Request Headers:

+
Authorization: Bearer <access_token>
+Content-Type: application/json
+
+

Request Body:

+
{
+  "email": "newuser@example.com",
+  "password": "TempPass123",
+  "name": "New User",
+  "phone": "+1234567890",
+  "role": "MAP_ADMIN",
+  "status": "ACTIVE",
+  "expiresAt": "2026-12-31T23:59:59Z",
+  "expireDays": 365
+}
+
+

Field Details:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
emailstringYesUnique email address
passwordstringYesMinimum 8 characters (admin creation has relaxed policy)
namestringNoFull name
phonestringNoPhone number
roleUserRoleNoDefault: USER
statusUserStatusNoDefault: ACTIVE
expiresAtISO 8601NoExpiration timestamp (for TEMP users)
expireDaysnumberNoDays until expiration
+

Response (201 Created):

+
{
+  "id": "clx0987654321",
+  "email": "newuser@example.com",
+  "name": "New User",
+  "phone": "+1234567890",
+  "role": "MAP_ADMIN",
+  "status": "ACTIVE",
+  "permissions": null,
+  "createdVia": "web",
+  "expiresAt": "2026-12-31T23:59:59.000Z",
+  "expireDays": 365,
+  "lastLoginAt": null,
+  "emailVerified": false,
+  "createdAt": "2026-02-11T12:00:00.000Z",
+  "updatedAt": "2026-02-11T12:00:00.000Z"
+}
+
+

Error Responses:

+
    +
  • 409 Conflict: Email already registered
  • +
  • 403 Forbidden: Non-admin trying to create user
  • +
+
+

PUT /api/users/:id

+

Update user. Admins can update any user and change role/status. Regular users can update their own profile (except role/status).

+

Request Headers:

+
Authorization: Bearer <access_token>
+Content-Type: application/json
+
+

Path Parameters:

+
    +
  • id (string): User ID to update
  • +
+

Request Body (Partial Update):

+
{
+  "name": "Updated Name",
+  "phone": "+0987654321",
+  "email": "newemail@example.com",
+  "password": "NewPass123",
+  "role": "INFLUENCE_ADMIN",
+  "status": "SUSPENDED"
+}
+
+

All fields are optional (partial updates supported).

+

Response (200 OK):

+
{
+  "id": "clx1234567890",
+  "email": "newemail@example.com",
+  "name": "Updated Name",
+  "phone": "+0987654321",
+  "role": "INFLUENCE_ADMIN",
+  "status": "SUSPENDED",
+  ...
+}
+
+

Error Responses:

+
    +
  • 403 Forbidden: Non-admin trying to update another user or change role/status
  • +
  • 404 Not Found: User ID does not exist
  • +
  • 409 Conflict: Email already in use by another user
  • +
+

Permission Logic:

+
const isAdmin = ADMIN_ROLES.includes(req.user!.role);
+const isSelf = req.user!.id === id;
+
+if (!isAdmin && !isSelf) {
+  return res.status(403).json({ error: { message: 'Insufficient permissions', code: 'FORBIDDEN' } });
+}
+
+// Non-admins cannot change role or status
+if (!isAdmin) {
+  delete req.body.role;
+  delete req.body.status;
+}
+
+

Password Handling:

+
if (data.password) {
+  updateData.password = await bcrypt.hash(data.password, 12);
+}
+
+
+

DELETE /api/users/:id

+

Delete user (admin only). Cascades to related records (refresh tokens, created campaigns, etc.).

+

Request Headers:

+
Authorization: Bearer <access_token>
+
+

Path Parameters:

+
    +
  • id (string): User ID to delete
  • +
+

Example Request:

+
curl -X DELETE \
+  -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/users/clx1234567890"
+
+

Response (204 No Content):

+

No response body.

+

Error Responses:

+
    +
  • 403 Forbidden: Non-admin trying to delete user
  • +
  • 404 Not Found: User ID does not exist
  • +
+

Cascading Deletes:

+

Deleting a user automatically deletes: +- Refresh tokens +- Created campaigns (if createdByUserId relation) +- Created locations (if createdByUserId relation) +- Campaign emails +- Responses +- Shift signups

+

Service Functions

+

usersService.findAll(filters)

+

Purpose: Paginated user listing with search and filters.

+

Parameters:

+
interface ListUsersInput {
+  page: number;         // Default: 1
+  limit: number;        // Default: 20, max: 100
+  search?: string;      // Search email or name
+  role?: UserRole;      // Filter by role
+  status?: UserStatus;  // Filter by status
+}
+
+

Returns:

+
{
+  users: User[];
+  pagination: {
+    page: number;
+    limit: number;
+    total: number;
+    totalPages: number;
+  };
+}
+
+

Implementation:

+
const { page, limit, search, role, status } = filters;
+const skip = (page - 1) * limit;
+
+const where: Prisma.UserWhereInput = {};
+
+if (search) {
+  where.OR = [
+    { email: { contains: search, mode: 'insensitive' } },
+    { name: { contains: search, mode: 'insensitive' } },
+  ];
+}
+
+if (role) where.role = role;
+if (status) where.status = status;
+
+const [users, total] = await Promise.all([
+  prisma.user.findMany({
+    where,
+    select: userSelect,
+    skip,
+    take: limit,
+    orderBy: { createdAt: 'desc' },
+  }),
+  prisma.user.count({ where }),
+]);
+
+return {
+  users,
+  pagination: {
+    page,
+    limit,
+    total,
+    totalPages: Math.ceil(total / limit),
+  },
+};
+
+
+

usersService.findById(id)

+

Purpose: Get single user by ID.

+

Returns: User object or throws 404 error.

+

Security: Password excluded via select (never returned in API responses).

+
+

usersService.create(data)

+

Purpose: Create new user with hashed password.

+

Flow:

+
    +
  1. Check if email already exists (409 if duplicate)
  2. +
  3. Hash password with bcrypt (12 salt rounds)
  4. +
  5. Create user in database
  6. +
  7. Return user (password excluded)
  8. +
+

Expiration Handling:

+
const user = await prisma.user.create({
+  data: {
+    ...data,
+    password: hashedPassword,
+    expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined,
+  },
+  select: userSelect,
+});
+
+
+

usersService.update(id, data)

+

Purpose: Update existing user (partial updates supported).

+

Validation:

+
    +
  • Check user exists (404 if not found)
  • +
  • Check email uniqueness if changing email (409 if taken)
  • +
  • Hash password if provided
  • +
  • Convert expiresAt string to Date
  • +
+

Email Change:

+
if (data.email && data.email !== existing.email) {
+  const emailTaken = await prisma.user.findUnique({ where: { email: data.email } });
+  if (emailTaken) {
+    throw new AppError(409, 'Email already in use', 'EMAIL_EXISTS');
+  }
+}
+
+
+

usersService.delete(id)

+

Purpose: Delete user and cascade to related records.

+

Error Handling:

+
const existing = await prisma.user.findUnique({ where: { id } });
+if (!existing) {
+  throw new AppError(404, 'User not found', 'USER_NOT_FOUND');
+}
+
+await prisma.user.delete({ where: { id } });
+
+

Code Examples

+

Admin: List Users with Filters

+
import { api } from '@/lib/api';
+
+const fetchUsers = async (page = 1, search = '', role = null, status = null) => {
+  const params = new URLSearchParams({
+    page: page.toString(),
+    limit: '20',
+  });
+
+  if (search) params.append('search', search);
+  if (role) params.append('role', role);
+  if (status) params.append('status', status);
+
+  const { data } = await api.get(`/api/users?${params}`);
+  return data;
+};
+
+// Usage
+const result = await fetchUsers(1, 'john', 'USER', 'ACTIVE');
+console.log(`Total users: ${result.pagination.total}`);
+console.log(`Users on page 1:`, result.users);
+
+

Admin: Create User

+
import { api } from '@/lib/api';
+
+const createUser = async (userData) => {
+  const { data } = await api.post('/api/users', {
+    email: userData.email,
+    password: userData.password,
+    name: userData.name,
+    phone: userData.phone,
+    role: userData.role || 'USER',
+    status: userData.status || 'ACTIVE',
+  });
+
+  return data;
+};
+
+// Usage
+const newUser = await createUser({
+  email: 'volunteer@example.com',
+  password: 'TempPass123',
+  name: 'Jane Volunteer',
+  role: 'USER',
+});
+
+console.log(`Created user: ${newUser.id}`);
+
+

Admin: Update User Role

+
import { api } from '@/lib/api';
+
+const promoteToAdmin = async (userId: string, adminRole: string) => {
+  const { data } = await api.put(`/api/users/${userId}`, {
+    role: adminRole,
+  });
+
+  return data;
+};
+
+// Usage
+const updatedUser = await promoteToAdmin('clx1234567890', 'MAP_ADMIN');
+console.log(`User promoted to ${updatedUser.role}`);
+
+

User: Update Own Profile

+
import { api } from '@/lib/api';
+
+const updateProfile = async (name: string, phone: string) => {
+  const { data } = await api.put(`/api/users/${currentUser.id}`, {
+    name,
+    phone,
+  });
+
+  return data;
+};
+
+// Usage (non-admin user updating self)
+const updated = await updateProfile('Updated Name', '+1234567890');
+console.log('Profile updated:', updated);
+
+

Admin: Suspend User

+
import { api } from '@/lib/api';
+
+const suspendUser = async (userId: string) => {
+  const { data } = await api.put(`/api/users/${userId}`, {
+    status: 'SUSPENDED',
+  });
+
+  return data;
+};
+
+// Usage
+const suspended = await suspendUser('clx1234567890');
+console.log(`User ${suspended.email} suspended`);
+
+

Admin: Delete User

+
import { api } from '@/lib/api';
+
+const deleteUser = async (userId: string) => {
+  await api.delete(`/api/users/${userId}`);
+  console.log(`User ${userId} deleted`);
+};
+
+// Usage
+await deleteUser('clx1234567890');
+
+

Frontend Integration

+

The UsersPage component (admin/src/pages/UsersPage.tsx) provides a comprehensive UI for user management:

+

Features:

+
    +
  • Paginated table with role/status badges
  • +
  • Search by email or name (300ms debounce)
  • +
  • Filter dropdowns (role, status)
  • +
  • Create user modal with form validation
  • +
  • Edit user modal (pre-populated form)
  • +
  • Delete confirmation modal
  • +
  • Responsive design (mobile-friendly)
  • +
+

State Management:

+
const [users, setUsers] = useState<User[]>([]);
+const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
+const [filters, setFilters] = useState({ search: '', role: null, status: null });
+
+

API Integration:

+
const fetchUsers = async () => {
+  setLoading(true);
+  try {
+    const params = new URLSearchParams({
+      page: pagination.page.toString(),
+      limit: pagination.limit.toString(),
+    });
+
+    if (filters.search) params.append('search', filters.search);
+    if (filters.role) params.append('role', filters.role);
+    if (filters.status) params.append('status', filters.status);
+
+    const { data } = await api.get(`/api/users?${params}`);
+    setUsers(data.users);
+    setPagination(data.pagination);
+  } catch (error) {
+    message.error('Failed to fetch users');
+  } finally {
+    setLoading(false);
+  }
+};
+
+

Validation Schemas

+

Create User Schema

+
export const createUserSchema = z.object({
+  email: z.string().email(),
+  password: z.string().min(8, 'Password must be at least 8 characters'),
+  name: z.string().optional(),
+  phone: z.string().optional(),
+  role: z.nativeEnum(UserRole).optional(),
+  status: z.nativeEnum(UserStatus).optional(),
+  expiresAt: z.string().datetime().optional(),
+  expireDays: z.number().int().positive().optional(),
+});
+
+

Note: Admin user creation has relaxed password requirements (8 chars vs. 12 for public registration).

+

Update User Schema

+
export const updateUserSchema = z.object({
+  email: z.string().email().optional(),
+  password: z.string().min(8).optional(),
+  name: z.string().optional(),
+  phone: z.string().optional(),
+  role: z.nativeEnum(UserRole).optional(),
+  status: z.nativeEnum(UserStatus).optional(),
+  expiresAt: z.string().datetime().nullable().optional(),
+  expireDays: z.number().int().positive().nullable().optional(),
+});
+
+

List Users Schema

+
export const listUsersSchema = z.object({
+  page: z.coerce.number().int().positive().default(1),
+  limit: z.coerce.number().int().positive().max(100).default(20),
+  search: z.string().optional(),
+  role: z.nativeEnum(UserRole).optional(),
+  status: z.nativeEnum(UserStatus).optional(),
+});
+
+

Security Considerations

+

Password Security

+
    +
  • Hashing: bcrypt with 12 salt rounds (admin creation) or enforced by registration schema
  • +
  • Never Returned: Password excluded from all API responses via select clause
  • +
  • Updates: Re-hashed when changed
  • +
+

Permission Model

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ActionSUPER_ADMININFLUENCE_ADMINMAP_ADMINUSER
List users
View any userOwn profile only
Create user
Update any userOwn profile only
Change role/status
Delete user
+

Email Uniqueness

+
    +
  • Enforced at database level (@unique constraint)
  • +
  • Checked before creation (409 Conflict)
  • +
  • Checked before email change (409 Conflict)
  • +
+

Cascading Deletes

+

Deleting a user automatically deletes related records via Prisma onDelete: Cascade:

+
    +
  • RefreshTokens
  • +
  • Created campaigns
  • +
  • Created locations
  • +
  • Campaign emails
  • +
  • Responses
  • +
  • Shift signups
  • +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/backend/services/index.html b/mkdocs/site/v2/backend/services/index.html new file mode 100644 index 00000000..d2fbc7da --- /dev/null +++ b/mkdocs/site/v2/backend/services/index.html @@ -0,0 +1,4940 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Backend Services - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Backend Services

+

Shared services provide cross-cutting functionality across the Changemaker Lite platform. These services handle external integrations, background processing, and common operations.

+

Service Architecture

+

Services are singleton instances that provide:

+
    +
  • External API integrations (email, geocoding, newsletters)
  • +
  • Background job processing (email queues, geocoding queues)
  • +
  • Caching and data management
  • +
  • Infrastructure operations (Docker, tunneling)
  • +
+

Core Services

+

Email Services

+

Email Service (email.service.ts)

+
    +
  • Nodemailer SMTP wrapper
  • +
  • Template processing with variable substitution
  • +
  • Test mode support (MailHog integration)
  • +
  • HTML email generation
  • +
  • Attachment handling
  • +
+

Email Queue Service (email-queue.service.ts)

+
    +
  • BullMQ queue management
  • +
  • Worker process for async email sending
  • +
  • Job retry logic with exponential backoff
  • +
  • Queue monitoring and statistics
  • +
  • Batch email processing
  • +
+

Geocoding Services

+

Geocoding Service (geocoding.service.ts)

+
    +
  • Multi-provider geocoding (6 providers)
  • +
  • Nominatim (OpenStreetMap)
  • +
  • ArcGIS
  • +
  • Photon
  • +
  • Mapbox
  • +
  • Google Geocoding API
  • +
  • Pelias
  • +
  • Provider fallback chain
  • +
  • Rate limiting per provider
  • +
  • Result caching
  • +
  • Batch geocoding support
  • +
+

Geocode Queue Service (geocode-queue.service.ts)

+
    +
  • BullMQ queue for async geocoding
  • +
  • Worker process with provider rotation
  • +
  • Progress tracking
  • +
  • Error handling and retry logic
  • +
  • Batch processing optimization
  • +
+

Integration Services

+

Listmonk Client (listmonk.client.ts)

+
    +
  • Typed HTTP client for Listmonk REST API
  • +
  • Basic auth integration
  • +
  • List management operations
  • +
  • Subscriber CRUD
  • +
  • Campaign operations
  • +
+

Listmonk Sync Service (listmonk-sync.service.ts)

+
    +
  • Opt-in sync (controlled by LISTMONK_SYNC_ENABLED)
  • +
  • Participant → subscriber sync
  • +
  • Location → list management
  • +
  • User role → list assignment
  • +
  • Automated sync on campaign actions
  • +
+

Pangolin Client (pangolin.client.ts)

+
    +
  • Typed HTTP client for Pangolin Integration API
  • +
  • API key authentication
  • +
  • Tunnel management
  • +
  • Site configuration
  • +
  • Resource operations
  • +
+

Infrastructure Services

+

Docker Service (docker.service.ts)

+
    +
  • Container lifecycle management
  • +
  • Health check monitoring
  • +
  • Service status queries
  • +
  • Container operations (start, stop, restart)
  • +
  • Resource monitoring
  • +
+

Service List

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ServicePurposeDependencies
Email ServiceSMTP email deliveryNodemailer
Email QueueAsync email processingBullMQ, Redis
GeocodingAddress → coordinatesMultiple providers
Geocode QueueAsync geocodingBullMQ, Redis
Listmonk ClientNewsletter APINative fetch
Listmonk SyncAutomated list syncListmonk Client
Pangolin ClientTunnel APINative fetch
Docker ServiceContainer opsDocker API
+

Configuration

+

Services are configured via environment variables in api/src/config/env.ts:

+
// Email
+EMAIL_TEST_MODE=true          // Use MailHog instead of SMTP
+SMTP_HOST=smtp.example.com
+SMTP_PORT=587
+
+// Geocoding
+MAPBOX_ACCESS_TOKEN=pk_...
+GOOGLE_GEOCODE_API_KEY=...
+
+// Listmonk
+LISTMONK_SYNC_ENABLED=true
+LISTMONK_API_URL=http://listmonk:9000
+LISTMONK_API_USER=api_user
+LISTMONK_API_TOKEN=secret
+
+// Pangolin
+PANGOLIN_API_URL=https://api.bnkserve.org/v1
+PANGOLIN_API_KEY=...
+
+

Usage Patterns

+

Email Service

+
import { emailService } from '../services/email.service';
+
+await emailService.sendEmail({
+  to: 'user@example.com',
+  subject: 'Welcome',
+  html: '<p>Welcome to our platform</p>',
+});
+
+

Email Queue

+
import { emailQueueService } from '../services/email-queue.service';
+
+await emailQueueService.addEmailJob({
+  to: 'user@example.com',
+  subject: 'Campaign Update',
+  template: 'campaign-email',
+  variables: { campaignName: 'Save the Parks' },
+});
+
+

Geocoding Service

+
import { geocodingService } from '../services/geocoding.service';
+
+const result = await geocodingService.geocode({
+  address: '123 Main St, Toronto, ON',
+  provider: 'nominatim',
+});
+
+if (result.success) {
+  console.log(result.coordinates); // { lat: 43.65, lng: -79.38 }
+}
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/backend/utilities/index.html b/mkdocs/site/v2/backend/utilities/index.html new file mode 100644 index 00000000..fbde4a40 --- /dev/null +++ b/mkdocs/site/v2/backend/utilities/index.html @@ -0,0 +1,4938 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Backend Utilities - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Backend Utilities

+

Utility modules provide common functionality for spatial calculations, logging, metrics collection, and data processing across the Changemaker Lite platform.

+

Utility Modules

+

Spatial Utilities

+

spatial.ts (utils/spatial.ts)

+

Provides geospatial calculations and polygon operations:

+

Point-in-Polygon +- Ray-casting algorithm for polygon containment +- Supports GeoJSON polygon format +- Handles holes in polygons +- Used for cut assignment

+
import { isPointInPolygon } from '../utils/spatial';
+
+const inside = isPointInPolygon(
+  { lat: 43.65, lng: -79.38 },
+  geoJsonPolygon
+);
+
+

Haversine Distance +- Calculate distance between two coordinates +- Returns distance in kilometers +- Great-circle distance calculation

+
import { haversineDistance } from '../utils/spatial';
+
+const distance = haversineDistance(
+  { lat: 43.65, lng: -79.38 },
+  { lat: 43.66, lng: -79.39 }
+);
+// Returns: 1.23 (km)
+
+

Bounds Calculation +- Calculate bounding box for set of locations +- Returns min/max lat/lng +- Used for map centering

+
import { calculateBounds } from '../utils/spatial';
+
+const bounds = calculateBounds(locations);
+// Returns: { minLat, maxLat, minLng, maxLng }
+
+

Centroid Calculation +- Calculate center point of locations +- Geographic mean of coordinates +- Used for map initial center

+
import { calculateCentroid } from '../utils/spatial';
+
+const center = calculateCentroid(locations);
+// Returns: { lat, lng }
+
+

GeoJSON Parsing +- Parse GeoJSON geometry to coordinate arrays +- Support for Polygon and MultiPolygon +- Coordinate validation

+

Logging Utilities

+

logger.ts (utils/logger.ts)

+

Winston-based logging with multiple transports:

+

Log Levels +- error - Error conditions +- warn - Warning messages +- info - Informational messages +- http - HTTP request logs +- debug - Debug-level messages

+

Usage

+
import logger from '../utils/logger';
+
+logger.info('Campaign created', { campaignId: 123 });
+logger.error('Failed to send email', { error: err.message });
+logger.debug('Geocoding result', { lat, lng });
+
+

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

+

Metrics Utilities

+

metrics.ts (utils/metrics.ts)

+

Prometheus metrics collection with 12 custom cm_* metrics:

+

Counter Metrics +- cm_api_uptime_seconds - API uptime counter +- cm_canvass_visits_total - Total canvass visits +- cm_campaign_emails_sent_total - Total campaign emails +- cm_geocode_requests_total - Total geocode requests

+

Gauge Metrics +- cm_canvass_sessions_active - Active canvass sessions +- cm_email_queue_size - Email queue depth +- cm_geocode_queue_size - Geocode queue depth +- cm_external_service_health - Service health status (0/1)

+

Histogram Metrics +- cm_geocode_duration_seconds - Geocoding request duration +- http_request_duration_ms - HTTP request duration

+

Usage

+
import { metrics } from '../utils/metrics';
+
+// Increment counter
+metrics.campaignEmailsSent.inc();
+
+// Set gauge
+metrics.emailQueueSize.set(42);
+
+// Observe histogram
+const end = metrics.geocodeDuration.startTimer();
+await geocode(address);
+end();
+
+

HTTP Metrics

+

Automatic tracking of: +- Request count by method, route, status +- Request duration percentiles +- Active requests gauge

+

Path Validation

+

path-validator.ts (utils/path-validator.ts)

+

Security utilities for path validation:

+

Features +- Null byte detection +- Path traversal prevention (../ patterns) +- Encoded traversal detection (%2e%2e) +- Path normalization

+
import { validatePath } from '../utils/path-validator';
+
+const safe = validatePath(userInput);
+if (!safe) {
+  throw new Error('Invalid path');
+}
+
+

HTML Sanitization

+

sanitize.ts (utils/sanitize.ts)

+

XSS prevention utilities:

+
import { escapeHtml } from '../utils/sanitize';
+
+const safe = escapeHtml(userInput);
+// Escapes: < > & " ' to HTML entities
+
+

Utility Functions Summary

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
UtilityFunctionPurpose
Spatial
isPointInPolygon()Check if point is inside polygon
haversineDistance()Calculate distance between points
calculateBounds()Calculate bounding box
calculateCentroid()Calculate center point
parseGeoJSON()Parse GeoJSON to coordinates
Logging
logger.info()Log informational message
logger.error()Log error message
logger.debug()Log debug message
Metrics
metrics.*.inc()Increment counter
metrics.*.set()Set gauge value
metrics.*.startTimer()Start histogram timer
Security
validatePath()Validate file path safety
escapeHtml()Sanitize HTML content
+

Configuration

+

Utilities are configured via environment variables:

+
# Logging
+LOG_LEVEL=info              # Minimum log level
+NODE_ENV=production         # Environment mode
+
+# Metrics
+METRICS_ENABLED=true        # Enable Prometheus metrics
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/contributing/code-of-conduct/index.html b/mkdocs/site/v2/contributing/code-of-conduct/index.html new file mode 100644 index 00000000..32404060 --- /dev/null +++ b/mkdocs/site/v2/contributing/code-of-conduct/index.html @@ -0,0 +1,5193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Code of Conduct - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Code of Conduct

+

Our Pledge

+

We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.

+

We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.

+

Our Standards

+

Examples of Positive Behavior

+

Behavior that contributes to a positive environment includes:

+
    +
  • Demonstrating empathy and kindness toward other people
  • +
  • Being respectful of differing opinions, viewpoints, and experiences
  • +
  • Giving and gracefully accepting constructive feedback
  • +
  • Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
  • +
  • Focusing on what is best not just for us as individuals, but for the overall community
  • +
  • Using welcoming and inclusive language
  • +
  • Being patient and helpful with newcomers
  • +
  • Showing appreciation for contributions, no matter how small
  • +
+

Examples of Unacceptable Behavior

+

Behavior that will not be tolerated includes:

+
    +
  • Harassment: The use of sexualized language or imagery, and sexual attention or advances of any kind
  • +
  • Trolling: Inflammatory, insulting, or derogatory comments, and personal or political attacks
  • +
  • Discrimination: Discriminatory jokes, slurs, or language targeting any group
  • +
  • Privacy violations: Publishing others' private information (addresses, phone numbers, email) without explicit permission
  • +
  • Spam: Unsolicited promotion of products, services, or websites
  • +
  • Doxxing: Publishing someone's personal information with malicious intent
  • +
  • Intimidation: Threats of violence or deliberate intimidation
  • +
  • Disruption: Deliberately disrupting discussions, meetings, or events
  • +
  • Other conduct which could reasonably be considered inappropriate in a professional setting
  • +
+

Enforcement Responsibilities

+

Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.

+

Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.

+

Scope

+

This Code of Conduct applies within all community spaces, including but not limited to:

+
    +
  • GitHub repositories: Issues, pull requests, discussions, wikis
  • +
  • Communication channels: Email, community calls, chat platforms
  • +
  • Events: Meetups, conferences, online gatherings
  • +
  • Public spaces: When representing the project (social media, forums, etc.)
  • +
+

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.

+

Enforcement

+

Reporting Violations

+

Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at conduct@cmlite.org.

+

All complaints will be reviewed and investigated promptly and fairly.

+

What to include in a report:

+
    +
  1. Your contact information (for follow-up)
  2. +
  3. Names of people involved (or pseudonyms)
  4. +
  5. Description of behavior (what happened)
  6. +
  7. When and where it occurred
  8. +
  9. Links or screenshots (if applicable)
  10. +
  11. Any witnesses
  12. +
  13. Whether you've reported elsewhere (e.g., to GitHub)
  14. +
+

Confidentiality: All community leaders are obligated to respect the privacy and security of the reporter of any incident.

+

Investigation Process

+

Upon receiving a report:

+
    +
  1. Acknowledgment: We will acknowledge receipt within 24 hours
  2. +
  3. Investigation: We will review the report and gather additional information
  4. +
  5. Decision: Community leaders will determine appropriate action
  6. +
  7. Communication: We will inform the reporter of the outcome
  8. +
  9. Action: We will enforce the decision
  10. +
+

Timeline: Most investigations complete within 7 days.

+

Enforcement Guidelines

+

Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:

+

1. Correction

+

Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.

+

Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.

+

Example: +- Using mildly offensive language +- Being dismissive of others' contributions +- Minor disruptions in discussions

+

2. Warning

+

Community Impact: A violation through a single incident or series of actions.

+

Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.

+

Example: +- Repeated inappropriate language after correction +- Personal attacks or insults +- Sustained disruption of discussions

+

Duration: 7-30 days, depending on severity.

+

3. Temporary Ban

+

Community Impact: A serious violation of community standards, including sustained inappropriate behavior.

+

Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.

+

Example: +- Harassment or discrimination +- Publishing private information +- Threats or intimidation +- Pattern of violations after warning

+

Duration: 30 days to 6 months.

+

4. Permanent Ban

+

Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.

+

Consequence: A permanent ban from any sort of public interaction within the community.

+

Example: +- Severe harassment or threats +- Doxxing or privacy violations +- Repeated violations after temporary ban +- Violent or discriminatory content

+

Duration: Permanent.

+

Appeals

+

If you believe an enforcement decision was made in error, you may appeal by:

+
    +
  1. Emailing conduct@cmlite.org within 14 days of the decision
  2. +
  3. Providing your reasoning for why the decision was incorrect
  4. +
  5. Suggesting alternative resolution (if applicable)
  6. +
+

Appeals will be reviewed by a different community leader when possible. The appeal decision is final.

+

Note: Appeals are not guaranteed to result in a changed decision.

+

Attribution

+

This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.

+

Community Impact Guidelines were inspired by Mozilla's code of conduct enforcement ladder.

+

For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.

+

Contact

+

Enforcement Team: conduct@cmlite.org

+

Project Maintainers: +- Lead Maintainer: [Name] (email@cmlite.org) +- Community Manager: [Name] (email@cmlite.org)

+

Response Time: We aim to respond to all reports within 24 hours.

+

Acknowledgments

+

We thank all contributors who help maintain a welcoming and inclusive community. Special thanks to:

+
    +
  • The Contributor Covenant team for the foundational code of conduct
  • +
  • The Mozilla community for enforcement guidelines
  • +
  • All community members who report violations to help keep our space safe
  • +
+

Version History

+
    +
  • v2.1 (2026-02-13): Adopted from Contributor Covenant 2.1
  • +
  • Future updates will be announced via GitHub Discussions
  • +
+
+

Last updated: February 13, 2026

+

By participating in this community, you agree to abide by this Code of Conduct.

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/contributing/development-setup/index.html b/mkdocs/site/v2/contributing/development-setup/index.html new file mode 100644 index 00000000..af652e79 --- /dev/null +++ b/mkdocs/site/v2/contributing/development-setup/index.html @@ -0,0 +1,6051 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Development Setup - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Development Setup

+

This guide will help you set up a complete development environment for contributing to Changemaker Lite V2.

+

Prerequisites

+

Before beginning, ensure you have the following installed:

+

Required Software

+
    +
  • +

    Node.js 20+ (download) +

    node --version  # Should be v20.x.x or higher
    +

    +
  • +
  • +

    npm 10+ (comes with Node.js) +

    npm --version  # Should be 10.x.x or higher
    +

    +
  • +
  • +

    Docker Desktop (download) +

    docker --version        # Should be 20.10.x or higher
    +docker compose version  # Should be 2.0.x or higher
    +

    +
  • +
  • +

    Git (download) +

    git --version  # Should be 2.x.x or higher
    +

    +
  • +
+ +
    +
  • VSCode (download) - Recommended code editor
  • +
  • GitHub CLI (download) - Simplifies GitHub operations
  • +
  • Postman or Thunder Client - API testing (Thunder Client is VSCode extension)
  • +
+

System Requirements

+
    +
  • Operating System: Linux, macOS, or Windows (with WSL2)
  • +
  • RAM: 8GB minimum (16GB recommended)
  • +
  • Disk Space: 20GB free space
  • +
  • Internet: Required for npm packages and Docker images
  • +
+

Fork and Clone

+

1. Fork the Repository

+
    +
  1. Visit https://github.com/changemaker-lite/v2
  2. +
  3. Click Fork button (top right)
  4. +
  5. Select your GitHub account as the destination
  6. +
+

2. Clone Your Fork

+
# Clone your fork (replace YOUR-USERNAME)
+git clone https://github.com/YOUR-USERNAME/changemaker-lite.git
+cd changemaker-lite
+
+# Add upstream remote (original repository)
+git remote add upstream https://github.com/changemaker-lite/v2.git
+
+# Verify remotes
+git remote -v
+# Should show:
+# origin    https://github.com/YOUR-USERNAME/changemaker-lite.git (fetch)
+# origin    https://github.com/YOUR-USERNAME/changemaker-lite.git (push)
+# upstream  https://github.com/changemaker-lite/v2.git (fetch)
+# upstream  https://github.com/changemaker-lite/v2.git (push)
+
+

3. Checkout V2 Branch

+
# Switch to v2 branch
+git checkout v2
+
+# Verify you're on v2
+git branch
+# Should show: * v2
+
+

Environment Setup

+

1. Create Environment File

+
# Copy example environment file
+cp .env.example .env
+
+# Edit .env with your preferred editor
+nano .env  # or: code .env (VSCode)
+
+

2. Configure Environment Variables

+

Minimal development configuration:

+
# Database
+DATABASE_URL=postgresql://changemaker:devpassword@localhost:5433/changemaker_v2?schema=public
+V2_POSTGRES_USER=changemaker
+V2_POSTGRES_PASSWORD=devpassword
+V2_POSTGRES_DB=changemaker_v2
+
+# Redis
+REDIS_URL=redis://:devpassword@localhost:6379
+REDIS_PASSWORD=devpassword
+
+# JWT Secrets (generate with: openssl rand -hex 32)
+JWT_ACCESS_SECRET=your_access_secret_here_32_chars_min
+JWT_REFRESH_SECRET=your_refresh_secret_here_32_chars_min
+ENCRYPTION_KEY=your_encryption_key_here_32_chars_min
+
+# API
+API_PORT=4000
+MEDIA_API_PORT=4100
+NODE_ENV=development
+
+# Email (test mode - uses MailHog)
+EMAIL_TEST_MODE=true
+SMTP_HOST=localhost
+SMTP_PORT=1025
+SMTP_FROM=dev@localhost
+
+# Feature Flags
+ENABLE_MEDIA_FEATURES=true
+LISTMONK_SYNC_ENABLED=false
+
+

Generate secrets: +

# Generate JWT secrets (run 3 times for each secret)
+openssl rand -hex 32
+
+# On Windows (PowerShell):
+[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 }))
+

+
+

Security

+

Use different secrets for JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, and ENCRYPTION_KEY. Never commit .env to Git!

+
+

Install Dependencies

+

API Dependencies

+
cd api
+
+# Install npm packages
+npm install
+
+# Verify installation
+npm list prisma  # Should show @prisma/client and prisma packages
+
+

Admin Dependencies

+
cd ../admin
+
+# Install npm packages
+npm install
+
+# Verify installation
+npm list react  # Should show react 19.x.x
+
+

Database Setup

+ +

Start PostgreSQL and Redis in Docker:

+
cd /home/bunker-admin/changemaker.lite
+
+# Start database services
+docker compose up -d v2-postgres redis
+
+# Wait for PostgreSQL to be ready (about 10 seconds)
+sleep 10
+
+# Verify services running
+docker compose ps
+# Should show v2-postgres and redis as "running"
+
+

Run Prisma migrations:

+
cd api
+
+# Run all migrations
+npx prisma migrate deploy
+
+# Seed initial data (admin user, settings, blocks)
+npx prisma db seed
+
+# Verify database
+npx prisma studio
+# Opens browser at http://localhost:5555
+
+

Default admin credentials (from seed): +- Email: admin@example.com +- Password: Admin123!

+
+

Change Default Password

+

Change the default admin password immediately after first login in development.

+
+

Option 2: Local PostgreSQL (Advanced)

+

If you have PostgreSQL 16 installed locally:

+
# Create database and user
+psql -U postgres
+CREATE USER changemaker WITH PASSWORD 'devpassword';
+CREATE DATABASE changemaker_v2 OWNER changemaker;
+\q
+
+# Update .env DATABASE_URL
+DATABASE_URL=postgresql://changemaker:devpassword@localhost:5432/changemaker_v2?schema=public
+
+# Run migrations
+cd api
+npx prisma migrate deploy
+npx prisma db seed
+
+

Running Development Servers

+

Method 1: Docker Compose (Full Stack)

+

Run all services in Docker:

+
# Start all services
+docker compose up -d
+
+# View logs
+docker compose logs -f api admin
+
+# Stop services
+docker compose down
+
+

Access points: +- Admin: http://localhost:3000 +- API: http://localhost:4000 +- Media API: http://localhost:4100 +- Prisma Studio: cd api && npx prisma studio +- MailHog: http://localhost:8025

+

Method 2: Local Development (Hot Reload)

+

Run services locally for faster development:

+

Terminal 1 - API: +

cd api
+npm run dev
+
+# Runs on http://localhost:4000
+# Hot reload enabled (nodemon)
+

+

Terminal 2 - Admin: +

cd admin
+npm run dev
+
+# Runs on http://localhost:3000
+# Hot reload enabled (Vite HMR)
+

+

Terminal 3 - Media API (optional): +

cd api
+npm run dev:media
+
+# Runs on http://localhost:4100
+# Hot reload enabled (nodemon)
+

+

Terminal 4 - Database (Docker): +

# Keep PostgreSQL and Redis running
+docker compose up -d v2-postgres redis
+

+
+

Recommended Workflow

+

Use Method 2 (local) for frontend/backend development (faster hot reload). Use Method 1 (Docker) for testing full stack integration.

+
+

VSCode Setup

+ +

Install these VSCode extensions for better development experience:

+
{
+  "recommendations": [
+    "dbaeumer.vscode-eslint",           // ESLint
+    "esbenp.prettier-vscode",           // Prettier
+    "prisma.prisma",                    // Prisma syntax
+    "bradlc.vscode-tailwindcss",        // Tailwind (if using)
+    "ms-azuretools.vscode-docker",      // Docker
+    "rangav.vscode-thunder-client",     // API testing
+    "editorconfig.editorconfig",        // EditorConfig
+    "streetsidesoftware.code-spell-checker" // Spell check
+  ]
+}
+
+

Workspace Settings

+

Create .vscode/settings.json:

+
{
+  "editor.defaultFormatter": "esbenp.prettier-vscode",
+  "editor.formatOnSave": true,
+  "editor.codeActionsOnSave": {
+    "source.fixAll.eslint": true
+  },
+  "typescript.tsdk": "node_modules/typescript/lib",
+  "typescript.enablePromptUseWorkspaceTsdk": true,
+  "files.exclude": {
+    "**/.git": true,
+    "**/.DS_Store": true,
+    "**/node_modules": true,
+    "**/dist": true
+  }
+}
+
+

Debug Configuration

+

Create .vscode/launch.json for debugging:

+
{
+  "version": "0.2.0",
+  "configurations": [
+    {
+      "name": "API (Node)",
+      "type": "node",
+      "request": "launch",
+      "runtimeExecutable": "npm",
+      "runtimeArgs": ["run", "dev"],
+      "cwd": "${workspaceFolder}/api",
+      "console": "integratedTerminal",
+      "internalConsoleOptions": "neverOpen",
+      "skipFiles": ["<node_internals>/**"]
+    },
+    {
+      "name": "Admin (Chrome)",
+      "type": "chrome",
+      "request": "launch",
+      "url": "http://localhost:3000",
+      "webRoot": "${workspaceFolder}/admin/src",
+      "sourceMapPathOverrides": {
+        "webpack:///src/*": "${webRoot}/*"
+      }
+    }
+  ]
+}
+
+

Making Changes

+

1. Create Feature Branch

+
# Fetch latest changes
+git fetch upstream
+git checkout v2
+git merge upstream/v2
+
+# Create feature branch
+git checkout -b feature/your-feature-name
+
+# Or for bug fix
+git checkout -b fix/bug-description
+
+

2. Code Style

+

Follow project conventions:

+

Run linter: +

cd api && npm run lint        # Backend
+cd admin && npm run lint      # Frontend
+

+

Auto-fix: +

cd api && npm run lint:fix    # Backend
+cd admin && npm run lint:fix  # Frontend
+

+

Format code: +

cd api && npm run format      # Backend
+cd admin && npm run format    # Frontend
+

+

Type check: +

cd api && npx tsc --noEmit    # Backend
+cd admin && npx tsc --noEmit  # Frontend
+

+

3. Run Tests

+
# API unit tests
+cd api && npm test
+
+# API integration tests
+cd api && npm run test:integration
+
+# Frontend tests
+cd admin && npm test
+
+# End-to-end tests
+npm run test:e2e
+
+

4. Test Your Changes

+

Manual testing checklist:

+
    +
  • API endpoints work (use Postman/Thunder Client)
  • +
  • Frontend renders correctly
  • +
  • No console errors
  • +
  • Works in Chrome, Firefox, Safari
  • +
  • Responsive design (mobile, tablet, desktop)
  • +
  • Accessibility (keyboard navigation, screen reader)
  • +
  • Error handling (try invalid inputs)
  • +
  • Loading states (try slow network)
  • +
+

Integration testing: +

# Start full stack
+docker compose up -d
+
+# Run integration tests
+./scripts/test-integration.sh
+
+# Check logs for errors
+docker compose logs -f
+

+

Staying Synced with Upstream

+

Regularly sync your fork with the upstream repository:

+
# Fetch upstream changes
+git fetch upstream
+
+# Merge into your local v2 branch
+git checkout v2
+git merge upstream/v2
+
+# Push to your fork
+git push origin v2
+
+# Rebase your feature branch (optional, cleaner history)
+git checkout feature/your-feature-name
+git rebase v2
+
+
+

Rebase vs Merge

+

Use git rebase v2 for cleaner commit history. Use git merge v2 if you're unsure about rebasing.

+
+

Troubleshooting

+

Port Conflicts

+

Error: Port 3000 already in use

+

Solution: +

# Find process using port
+lsof -i :3000  # macOS/Linux
+netstat -ano | findstr :3000  # Windows
+
+# Kill process or change port
+# Edit .env: ADMIN_PORT=3001
+

+

Database Connection Errors

+

Error: Can't reach database server at localhost:5433

+

Solution: +

# Check if PostgreSQL is running
+docker compose ps v2-postgres
+
+# Restart PostgreSQL
+docker compose restart v2-postgres
+
+# Check logs
+docker compose logs v2-postgres
+
+# Verify DATABASE_URL in .env
+

+

Migration Errors

+

Error: Migration failed

+

Solution: +

# Reset database (WARNING: deletes all data)
+cd api
+npx prisma migrate reset
+
+# Or manually drop and recreate
+docker compose exec v2-postgres psql -U changemaker -d postgres -c "DROP DATABASE changemaker_v2;"
+docker compose exec v2-postgres psql -U changemaker -d postgres -c "CREATE DATABASE changemaker_v2 OWNER changemaker;"
+npx prisma migrate deploy
+npx prisma db seed
+

+

Dependency Installation Errors

+

Error: npm install fails

+

Solution: +

# Clear npm cache
+npm cache clean --force
+
+# Remove node_modules and package-lock.json
+rm -rf node_modules package-lock.json
+
+# Reinstall
+npm install
+
+# If still fails, try older Node version
+nvm install 20.11.0
+nvm use 20.11.0
+npm install
+

+

Docker Issues

+

Error: Docker daemon not running

+

Solution: +- macOS/Windows: Start Docker Desktop +- Linux: sudo systemctl start docker

+

Error: Permission denied (Docker)

+

Solution (Linux): +

# Add user to docker group
+sudo usermod -aG docker $USER
+
+# Log out and back in, or:
+newgrp docker
+

+

Next Steps

+

Now that your environment is set up:

+
    +
  1. Find an issue to work on
  2. +
  3. Review code style guidelines
  4. +
  5. Create your first PR
  6. +
  7. Join the community
  8. +
+ + +

Getting Help

+

Stuck on setup? Ask for help:

+ +

Happy coding! 🚀

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/contributing/index.html b/mkdocs/site/v2/contributing/index.html new file mode 100644 index 00000000..399acdca --- /dev/null +++ b/mkdocs/site/v2/contributing/index.html @@ -0,0 +1,5376 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Contributing to Changemaker Lite - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Contributing to Changemaker Lite

+

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.

+

Welcome!

+

Changemaker Lite is an open-source political campaign platform built by volunteers for organizers. We welcome contributions from developers, designers, writers, and community organizers of all experience levels.

+

Our mission: Provide free, self-hosted tools for grassroots political campaigns to compete with well-funded opponents.

+

Ways to Contribute

+

1. Code Contributions

+

Help build new features or fix bugs in:

+
    +
  • Backend API (TypeScript + Express + Prisma)
  • +
  • Admin Frontend (React + Vite + Ant Design)
  • +
  • Media API (TypeScript + Fastify + Drizzle)
  • +
  • Infrastructure (Docker, Nginx, PostgreSQL, Redis)
  • +
+

→ Development Setup Guide

+

2. Documentation

+

Improve guides, tutorials, and API documentation:

+
    +
  • User guides - Help organizers use the platform
  • +
  • Developer docs - API reference, architecture guides
  • +
  • Tutorials - Step-by-step walkthroughs
  • +
  • Translations - Localize docs for other languages
  • +
+

→ Documentation Guide

+

3. Bug Reports

+

Found a bug? Help us fix it:

+
    +
  • Search existing issues first
  • +
  • Provide clear reproduction steps
  • +
  • Include error messages and logs
  • +
  • Test on latest version
  • +
+

→ Report a Bug

+

4. Feature Requests

+

Suggest new features or enhancements:

+
    +
  • Check roadmap first
  • +
  • Describe the use case
  • +
  • Explain why it's valuable
  • +
  • Consider implementation complexity
  • +
+

→ Request a Feature

+

5. Testing

+

Help test new features and releases:

+
    +
  • Test beta releases on staging
  • +
  • Verify bug fixes
  • +
  • Test migration procedures
  • +
  • Report edge cases
  • +
+

6. Community Support

+

Help other users:

+
    +
  • Answer questions in Discussions
  • +
  • Share your setup experiences
  • +
  • Write blog posts or tutorials
  • +
  • Present at community calls
  • +
+

7. Design

+

Improve user experience:

+
    +
  • UI/UX design mockups
  • +
  • User flow improvements
  • +
  • Accessibility enhancements
  • +
  • Mobile responsiveness
  • +
+

Code of Conduct

+

Changemaker Lite is committed to providing a welcoming and inclusive environment for all contributors.

+

Our values:

+
    +
  • Respect: Treat everyone with kindness and professionalism
  • +
  • Inclusivity: Welcome contributors from all backgrounds
  • +
  • Collaboration: Work together constructively
  • +
  • Constructive feedback: Focus on improvement, not criticism
  • +
+

→ Full Code of Conduct

+

Unacceptable behavior:

+
    +
  • Harassment, discrimination, or hate speech
  • +
  • Personal attacks or trolling
  • +
  • Publishing private information
  • +
  • Spam or self-promotion
  • +
+

Enforcement: Violations will result in warnings, temporary bans, or permanent bans depending on severity.

+

Reporting: Email conduct@cmlite.org to report violations confidentially.

+

Getting Started

+

Prerequisites

+

Before contributing code, ensure you have:

+
    +
  • Node.js 20+ installed
  • +
  • Docker Desktop (or Docker + Docker Compose)
  • +
  • Git for version control
  • +
  • Code editor (VSCode recommended)
  • +
  • GitHub account for pull requests
  • +
+

Quick Start

+
    +
  1. Fork the repository on GitHub
  2. +
  3. Clone your fork locally
  4. +
  5. Set up development environment (guide)
  6. +
  7. Find an issue to work on
  8. +
  9. Create a branch for your changes
  10. +
  11. Make your changes with tests
  12. +
  13. Submit a pull request (guide)
  14. +
+

Finding Issues to Work On

+

Good first issues: Look for issues tagged good-first-issue in GitHub Issues.

+

Help wanted: Issues tagged help-wanted need contributors.

+

By skill level: +- beginner - Simple fixes, documentation +- intermediate - Feature enhancements, refactoring +- advanced - Architecture changes, performance optimization

+

By area: +- backend - API, database, services +- frontend - React components, UI/UX +- infrastructure - Docker, Nginx, deployment +- documentation - Guides, tutorials, API docs

+

→ Browse Issues

+

Contribution Workflow

+

1. Claim an Issue

+

Before starting work:

+
    +
  1. Comment on the issue: "I'd like to work on this"
  2. +
  3. Wait for assignment: Maintainer will assign you
  4. +
  5. Ask questions: Clarify requirements before coding
  6. +
+
+

Avoid Duplicate Work

+

Always check if someone is already assigned before starting work.

+
+

2. Create a Branch

+
# Update main branch
+git checkout main
+git pull upstream main
+
+# Create feature branch
+git checkout -b feature/campaign-export
+
+# Or for bug fixes
+git checkout -b fix/geocoding-error
+
+

Branch naming: +- feature/description - New features +- fix/description - Bug fixes +- docs/description - Documentation +- refactor/description - Code refactoring +- test/description - Test additions

+

3. Make Changes

+

Follow our coding standards:

+
    +
  • TypeScript: Strict mode, type all functions
  • +
  • ESLint: Run npm run lint before committing
  • +
  • Prettier: Auto-format with npm run format
  • +
  • Tests: Add tests for new features
  • +
  • Comments: Document complex logic
  • +
+
// Good: Type-safe function with comments
+/**
+ * Geocodes an address using the specified provider.
+ * Falls back to next provider if the first fails.
+ *
+ * @param address - Full address string
+ * @param provider - Geocoding provider (default: nominatim)
+ * @returns Promise resolving to { lat, lng, quality }
+ */
+async function geocodeAddress(
+  address: string,
+  provider: GeocodingProvider = 'nominatim'
+): Promise<GeocodingResult> {
+  // Implementation
+}
+
+

4. Test Your Changes

+
# Backend tests
+cd api && npm test
+
+# Frontend tests
+cd admin && npm test
+
+# Type checking
+cd api && npx tsc --noEmit
+cd admin && npx tsc --noEmit
+
+# Linting
+cd api && npm run lint
+cd admin && npm run lint
+
+# Integration tests
+docker compose up -d
+./scripts/test-integration.sh
+
+

5. Commit Your Changes

+

Commit message format (Conventional Commits):

+
type(scope): short description
+
+Longer description (optional)
+
+Fixes #123
+
+

Types: +- feat - New feature +- fix - Bug fix +- docs - Documentation +- style - Formatting, whitespace +- refactor - Code restructuring +- test - Test additions +- chore - Build, tooling

+

Examples: +

feat(campaigns): add campaign export to CSV
+
+Adds a new export button to the campaigns page that downloads
+all campaigns as a CSV file.
+
+Fixes #456
+
+---
+
+fix(geocoding): handle null responses from Nominatim
+
+Prevents crash when Nominatim returns empty result for
+invalid addresses.
+
+Fixes #789
+
+---
+
+docs(api): document campaign endpoints
+
+Adds comprehensive API documentation for all campaign endpoints
+including request/response examples.
+

+

6. Push and Create Pull Request

+
# Push to your fork
+git push origin feature/campaign-export
+
+# Create pull request on GitHub
+# Fill out the PR template
+
+

→ Pull Request Guidelines

+

7. Code Review

+

After submitting your PR:

+
    +
  1. Automated checks run (lint, tests, build)
  2. +
  3. Maintainer review provides feedback
  4. +
  5. Address feedback with new commits
  6. +
  7. Request re-review after changes
  8. +
  9. Merge after approval
  10. +
+

Be patient: Reviews may take 1-3 business days. If no response after 5 days, politely ping the maintainer.

+

Development Guidelines

+

Code Style

+

TypeScript: +

// Use interfaces for object shapes
+interface Campaign {
+  id: string;
+  title: string;
+  slug: string;
+  active: boolean;
+}
+
+// Use types for unions/aliases
+type SupportLevel = 'STRONG_SUPPORT' | 'SUPPORT' | 'UNDECIDED' | 'OPPOSED' | 'STRONG_OPPOSED';
+
+// Prefer async/await over promises
+async function getCampaigns(): Promise<Campaign[]> {
+  const campaigns = await prisma.campaign.findMany();
+  return campaigns;
+}
+

+

React: +

// Use functional components
+const CampaignsPage: React.FC = () => {
+  const [campaigns, setCampaigns] = useState<Campaign[]>([]);
+
+  useEffect(() => {
+    fetchCampaigns();
+  }, []);
+
+  return <Table dataSource={campaigns} />;
+};
+
+// Extract reusable components
+const CampaignCard: React.FC<{ campaign: Campaign }> = ({ campaign }) => {
+  return <Card title={campaign.title} />;
+};
+

+

Prisma: +

// Use type-safe queries
+const campaigns = await prisma.campaign.findMany({
+  where: { active: true },
+  include: { createdBy: true },
+  orderBy: { createdAt: 'desc' }
+});
+
+// Use transactions for multi-step operations
+await prisma.$transaction(async (tx) => {
+  await tx.campaign.update({ where: { id }, data: { active: false } });
+  await tx.campaignEmail.updateMany({ where: { campaignId: id }, data: { status: 'CANCELLED' } });
+});
+

+

Testing Guidelines

+

Unit tests: +

// api/src/modules/campaigns/campaigns.service.test.ts
+describe('CampaignService', () => {
+  it('should create campaign with valid data', async () => {
+    const campaign = await campaignService.create({
+      title: 'Test Campaign',
+      slug: 'test-campaign',
+      createdByUserId: 'user-id'
+    });
+
+    expect(campaign).toHaveProperty('id');
+    expect(campaign.title).toBe('Test Campaign');
+  });
+
+  it('should throw error for duplicate slug', async () => {
+    await expect(
+      campaignService.create({ title: 'Test', slug: 'existing', createdByUserId: 'user-id' })
+    ).rejects.toThrow('Slug already exists');
+  });
+});
+

+

Integration tests: +

// api/tests/campaigns.integration.test.ts
+describe('Campaigns API', () => {
+  it('GET /api/influence/campaigns returns campaigns', async () => {
+    const response = await request(app)
+      .get('/api/influence/campaigns')
+      .set('Authorization', `Bearer ${adminToken}`);
+
+    expect(response.status).toBe(200);
+    expect(response.body.success).toBe(true);
+    expect(Array.isArray(response.body.data)).toBe(true);
+  });
+});
+

+

Communication Channels

+

GitHub

+
    +
  • Issues: Bug reports, feature requests
  • +
  • Discussions: General questions, ideas
  • +
  • Pull Requests: Code contributions
  • +
+

Email

+
    +
  • General: hello@cmlite.org
  • +
  • Security: security@cmlite.org
  • +
  • Code of Conduct: conduct@cmlite.org
  • +
+

Community Calls

+
    +
  • Monthly Contributors Call: First Tuesday of month, 7pm UTC
  • +
  • Quarterly Community Call: Last Friday of quarter, 6pm UTC
  • +
+

→ Join Calls

+

Recognition

+

We appreciate all contributors! Your name will be:

+
    +
  • Added to CONTRIBUTORS.md after first merged PR
  • +
  • Listed in release notes for significant contributions
  • +
  • Featured on website for major features
  • +
  • Invited to community calls as a contributor
  • +
+

Hall of Fame

+

Top Contributors (all time):

+
    +
  1. @contributor1 - 234 commits
  2. +
  3. @contributor2 - 189 commits
  4. +
  5. @contributor3 - 156 commits
  6. +
+

→ Full Contributors List

+

License

+

By contributing to Changemaker Lite, you agree that your contributions will be licensed under the MIT License.

+

This means: +- Your code can be used by anyone +- Attribution is required (copyright notice) +- No warranty is provided

+

See LICENSE for full terms.

+

Questions?

+
    +
  • Need help getting started? Ask in Discussions
  • +
  • Have a question about an issue? Comment on the issue
  • +
  • Stuck on development setup? Check Development Setup Guide
  • +
  • Want to chat? Join our monthly contributors call
  • +
+ + +

Next Steps

+

Ready to contribute?

+
    +
  1. Read the Code of Conduct - Understand community standards
  2. +
  3. Set up your environment - Install dependencies
  4. +
  5. Find an issue - Pick something to work on
  6. +
  7. Submit your first PR - Make your contribution
  8. +
+

Thank you for contributing to Changemaker Lite! Together, we're building tools for democratic change.

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/contributing/pull-requests/index.html b/mkdocs/site/v2/contributing/pull-requests/index.html new file mode 100644 index 00000000..1e8f49aa --- /dev/null +++ b/mkdocs/site/v2/contributing/pull-requests/index.html @@ -0,0 +1,6270 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pull Requests - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Pull Request Guidelines

+

This guide covers the complete pull request (PR) process for contributing code to Changemaker Lite V2, from creation to merge.

+

Before Submitting a PR

+

1. Create or Find an Issue

+

For features: +- Search existing issues first +- If none exists, create a feature request +- Wait for maintainer approval before implementing +- Discuss implementation approach in the issue

+

For bugs: +- Search existing bug reports first +- If none exists, create a bug report +- Include reproduction steps and expected vs actual behavior +- Verify bug still exists on latest v2 branch

+
+

Avoid Wasted Effort

+

Always create an issue and get approval before spending time on a large feature. Maintainers may have alternative approaches or priorities.

+
+

2. Test Your Changes

+

Run all checks locally before submitting:

+
# Type checking
+cd api && npx tsc --noEmit
+cd admin && npx tsc --noEmit
+
+# Linting
+cd api && npm run lint
+cd admin && npm run lint
+
+# Unit tests
+cd api && npm test
+cd admin && npm test
+
+# Integration tests (if applicable)
+cd api && npm run test:integration
+
+# Build
+cd api && npm run build
+cd admin && npm run build
+
+

All checks must pass before submitting PR.

+

3. Update Documentation

+

If your changes affect:

+ +
+

Documentation is Required

+

PRs with new features will not be merged without corresponding documentation updates.

+
+

PR Title Format

+

Use Conventional Commits format:

+
type(scope): short description
+
+

Types

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeWhen to Use
featNew feature for users
fixBug fix
docsDocumentation changes
styleCode formatting (no behavior change)
refactorCode restructuring (no behavior change)
perfPerformance improvements
testTest additions or fixes
choreBuild process, tooling, dependencies
ciCI/CD configuration
revertReverting a previous commit
+

Scopes

+

Common scopes by area:

+

Backend: +- api - General API changes +- auth - Authentication/authorization +- campaigns - Campaign module +- locations - Location module +- shifts - Shift module +- canvass - Canvassing module +- email - Email sending +- database - Database schema/migrations

+

Frontend: +- admin - Admin pages +- public - Public pages +- volunteer - Volunteer portal +- components - React components +- store - Zustand stores +- ui - UI/UX changes

+

Infrastructure: +- docker - Docker/Docker Compose +- nginx - Nginx configuration +- monitoring - Prometheus/Grafana +- deployment - Deployment scripts/config

+

Examples

+

Good titles: +- ✅ feat(campaigns): add campaign export to CSV +- ✅ fix(geocoding): handle null responses from Nominatim +- ✅ docs(api): document campaign endpoints +- ✅ refactor(auth): extract JWT middleware to separate file +- ✅ perf(locations): add database index on postalCode

+

Bad titles: +- ❌ Update campaigns.tsx (too vague) +- ❌ Bug fix (no scope or description) +- ❌ WIP: New feature (don't submit WIP PRs) +- ❌ Fixed everything (not descriptive)

+

PR Description Template

+

Use this template for your PR description:

+
## What
+
+[Clear description of what this PR does]
+
+## Why
+
+[Why this change is needed, link to issue]
+
+## How
+
+[Brief explanation of implementation approach]
+
+## Testing
+
+[How to test these changes]
+
+## Screenshots
+
+[For UI changes, include before/after screenshots]
+
+## Checklist
+
+- [ ] Tests added/updated
+- [ ] Documentation updated
+- [ ] No console errors
+- [ ] All CI checks pass
+- [ ] Follows code style guidelines
+
+Fixes #[issue-number]
+
+

Example PR Description

+
## What
+
+Adds a CSV export button to the campaigns page that allows admins to download all campaigns with their metadata.
+
+## Why
+
+Users need to export campaign data for reporting and analysis in external tools like Excel.
+
+Fixes #456
+
+## How
+
+- Added export button to CampaignsPage header
+- Created `/api/influence/campaigns/export` endpoint
+- Implemented CSV generation using `csv-stringify` library
+- Added SUPER_ADMIN/INFLUENCE_ADMIN role check
+
+## Testing
+
+1. Login as admin user
+2. Navigate to Campaigns page (/app/influence/campaigns)
+3. Click "Export CSV" button in page header
+4. Verify CSV file downloads with correct data
+5. Open CSV in Excel/Google Sheets to verify formatting
+
+## Screenshots
+
+![Export button in campaigns page](https://user-images.githubusercontent.com/export-button.png)
+
+## Checklist
+
+- [x] Tests added (export endpoint integration test)
+- [x] Documentation updated (API reference, admin guide)
+- [x] No console errors
+- [x] All CI checks pass
+- [x] Follows code style guidelines
+
+Fixes #456
+
+

Creating the PR

+

1. Push Your Branch

+
# Ensure your branch is up to date
+git fetch upstream
+git rebase upstream/v2  # or: git merge upstream/v2
+
+# Push to your fork
+git push origin feature/your-feature-name
+
+# If you rebased, force push (with care!)
+git push --force-with-lease origin feature/your-feature-name
+
+

2. Open PR on GitHub

+
    +
  1. Go to your fork on GitHub
  2. +
  3. Click "Pull requests" tab
  4. +
  5. Click "New pull request"
  6. +
  7. Base repository: changemaker-lite/v2 base: v2
  8. +
  9. Head repository: YOUR-USERNAME/changemaker-lite compare: feature/your-feature-name
  10. +
  11. Click "Create pull request"
  12. +
  13. Fill out the PR template (see above)
  14. +
  15. Click "Create pull request"
  16. +
+

3. Request Reviewers

+
    +
  • PRs are automatically assigned to maintainers
  • +
  • You can request specific reviewers if you know who to ask
  • +
  • For urgent PRs, mention @changemaker-lite/maintainers
  • +
+

Code Review Process

+

Automated Checks

+

After submitting, CI/CD runs these checks:

+
    +
  1. Lint: ESLint rules
  2. +
  3. Type Check: TypeScript compilation
  4. +
  5. Tests: Unit + integration tests
  6. +
  7. Build: Production build
  8. +
  9. Security: Dependency vulnerability scan
  10. +
+

Status badges appear on your PR:

+
    +
  • Green checkmark: All checks passed
  • +
  • Red X: Some checks failed
  • +
  • 🟡 Yellow dot: Checks in progress
  • +
+

Fix failing checks before requesting review.

+

Maintainer Review

+

A maintainer will review your code and provide feedback:

+

Review categories:

+
    +
  1. Code quality:
  2. +
  3. Follows code style guidelines
  4. +
  5. No unnecessary complexity
  6. +
  7. Proper error handling
  8. +
  9. +

    No security vulnerabilities

    +
  10. +
  11. +

    Functionality:

    +
  12. +
  13. Solves the problem correctly
  14. +
  15. Edge cases handled
  16. +
  17. +

    No regressions

    +
  18. +
  19. +

    Tests:

    +
  20. +
  21. Adequate test coverage (>80%)
  22. +
  23. Tests are meaningful
  24. +
  25. +

    Tests pass consistently

    +
  26. +
  27. +

    Documentation:

    +
  28. +
  29. Code comments for complex logic
  30. +
  31. API documentation updated
  32. +
  33. User guide updated (if needed)
  34. +
+

Review Outcomes

+

Approved ✅: +- Maintainer approves PR +- Ready to merge (after squash)

+

Request Changes 🔄: +- Maintainer requests modifications +- Address feedback and push new commits +- Re-request review after changes

+

Comment 💬: +- Feedback without blocking merge +- Optional to address

+

Addressing Feedback

+

1. Read Feedback Carefully

+
    +
  • Understand the requested change
  • +
  • Ask clarifying questions if unclear
  • +
  • Don't take criticism personally (it's about code, not you)
  • +
+

2. Make Changes

+
# Make requested changes
+# Edit files...
+
+# Commit changes
+git add .
+git commit -m "refactor: address review feedback
+
+- Extracted duplicate logic into helper function
+- Added error handling for edge case
+- Updated tests to cover new scenario"
+
+# Push to same branch
+git push origin feature/your-feature-name
+
+

Commits are added to existing PR automatically.

+

3. Respond to Comments

+
    +
  • Acknowledge feedback: "Good catch, fixed in abc1234"
  • +
  • Explain changes: "Refactored this to use a switch statement instead"
  • +
  • Ask questions: "I'm not sure how to handle X, suggestions?"
  • +
  • Mark resolved: Click "Resolve conversation" after addressing
  • +
+

4. Re-Request Review

+

After addressing all feedback:

+
    +
  1. Click "Reviewers" section
  2. +
  3. Click circular arrow next to reviewer's name
  4. +
  5. Or comment @reviewer Ready for re-review
  6. +
+

Common Review Feedback

+

Code Quality Issues

+

Issue: Large function with too many responsibilities

+

Feedback:

+
+

This function is doing too much. Can you extract the geocoding logic into a separate function?

+
+

Fix: +

// Before
+async function createLocation(data) {
+  // 50 lines of validation, geocoding, database insert...
+}
+
+// After
+async function createLocation(data) {
+  const validated = validateLocationData(data);
+  const geocoded = await geocodeAddress(validated.address);
+  return insertLocation({ ...validated, ...geocoded });
+}
+

+

Issue: Magic numbers or strings

+

Feedback:

+
+

What does 30 represent here? Use a named constant.

+
+

Fix: +

// Before
+if (visits.length >= 30) { }
+
+// After
+const VISIT_RATE_LIMIT = 30;
+if (visits.length >= VISIT_RATE_LIMIT) { }
+

+

Issue: Missing error handling

+

Feedback:

+
+

What happens if the API call fails? Add error handling.

+
+

Fix: +

// Before
+const reps = await fetch(url).then(r => r.json());
+
+// After
+try {
+  const response = await fetch(url);
+  if (!response.ok) {
+    throw new Error(`API returned ${response.status}`);
+  }
+  const reps = await response.json();
+} catch (error) {
+  logger.error('Failed to fetch representatives', error);
+  throw new Error('Unable to lookup representatives');
+}
+

+

Test Coverage Issues

+

Issue: Missing test for edge case

+

Feedback:

+
+

Add a test for when postal code is invalid.

+
+

Fix: +

it('should return 400 for invalid postal code', async () => {
+  const response = await request(app)
+    .post('/api/influence/representatives/lookup')
+    .send({ postalCode: 'INVALID' });
+
+  expect(response.status).toBe(400);
+  expect(response.body.success).toBe(false);
+});
+

+

Documentation Issues

+

Issue: Missing API documentation

+

Feedback:

+
+

Add this endpoint to the API reference docs.

+
+

Fix: Update docs/v2/api-reference/campaigns.md with new endpoint.

+

Performance Issues

+

Issue: N+1 query problem

+

Feedback:

+
+

This causes N+1 queries. Use Prisma include to join.

+
+

Fix: +

// Before (N+1)
+const campaigns = await prisma.campaign.findMany();
+for (const campaign of campaigns) {
+  campaign.createdBy = await prisma.user.findUnique({ where: { id: campaign.createdByUserId } });
+}
+
+// After (single query)
+const campaigns = await prisma.campaign.findMany({
+  include: { createdBy: true }
+});
+

+

Merge Process

+

Squash and Merge

+

Changemaker Lite uses squash and merge for all PRs:

+
    +
  1. Maintainer clicks "Squash and merge"
  2. +
  3. All commits in PR are squashed into one commit
  4. +
  5. Commit message = PR title + description summary
  6. +
  7. Merged to v2 branch
  8. +
+

Why squash? +- Clean linear history +- Easier to revert if needed +- No messy "WIP" or "fix typo" commits

+

After Merge

+

Once your PR is merged:

+
    +
  1. Celebrate! 🎉 You've contributed to Changemaker Lite
  2. +
  3. Update your fork: +
    git checkout v2
    +git pull upstream v2
    +git push origin v2
    +
  4. +
  5. Delete feature branch (optional): +
    git branch -d feature/your-feature-name
    +git push origin --delete feature/your-feature-name
    +
  6. +
  7. Update issue: GitHub auto-closes issue with Fixes #N
  8. +
  9. Check release notes: Your contribution will be mentioned in next release
  10. +
+

PR Checklist

+

Use this before submitting:

+

Pre-Submission

+
    +
  • Issue created and approved by maintainer
  • +
  • Branch created from latest v2
  • +
  • Changes implemented following code style
  • +
  • Self-review - read your own code critically
  • +
  • Manual testing - verify changes work as expected
  • +
+

Code Quality

+
    +
  • TypeScript: No type errors (npx tsc --noEmit)
  • +
  • Linting: No lint errors (npm run lint)
  • +
  • Formatting: Code formatted (npm run format)
  • +
  • No console logs: Remove debug statements
  • +
  • No commented code: Remove old code
  • +
  • Error handling: All errors caught and logged
  • +
+

Tests

+
    +
  • Unit tests: Added/updated tests
  • +
  • Tests pass: npm test succeeds
  • +
  • Coverage: Maintained or improved (>80%)
  • +
  • Integration tests: Added if needed
  • +
  • Edge cases: Tested invalid inputs
  • +
+

Documentation

+
    +
  • Code comments: Complex logic documented
  • +
  • API docs: New endpoints documented
  • +
  • User docs: User guide updated (if user-facing)
  • +
  • README: Updated if needed
  • +
  • .env.example: New env vars added
  • +
+

UI (if applicable)

+
    +
  • Responsive: Works on mobile/tablet/desktop
  • +
  • Accessibility: Keyboard navigation works
  • +
  • Browser testing: Works in Chrome, Firefox, Safari
  • +
  • Loading states: Spinners for async operations
  • +
  • Error states: Error messages shown to user
  • +
  • Screenshots: Included in PR description
  • +
+

Final Checks

+
    +
  • CI passing: All automated checks green
  • +
  • PR template: Description complete
  • +
  • Commit messages: Follow conventional commits
  • +
  • No merge conflicts: Branch rebased/merged with v2
  • +
  • Reviewers requested: Maintainers notified
  • +
+

Troubleshooting PRs

+

CI Checks Failing

+

Lint failures: +

cd api && npm run lint:fix
+cd admin && npm run lint:fix
+git add . && git commit -m "chore: fix lint errors" && git push
+

+

Type errors: +

cd api && npx tsc --noEmit  # Shows errors
+# Fix type errors in code
+git add . && git commit -m "fix: resolve type errors" && git push
+

+

Test failures: +

cd api && npm test  # Run locally to see errors
+# Fix failing tests
+git add . && git commit -m "test: fix failing tests" && git push
+

+

Merge Conflicts

+

Resolving conflicts: +

# Fetch latest upstream
+git fetch upstream
+
+# Rebase onto v2
+git rebase upstream/v2
+
+# If conflicts, resolve them
+# Edit conflicted files, then:
+git add .
+git rebase --continue
+
+# Force push (since history changed)
+git push --force-with-lease origin feature/your-feature-name
+

+

PR Not Getting Reviewed

+

If no review after 5 business days:

+
    +
  1. Check CI: Ensure all checks pass
  2. +
  3. Ping maintainer: Comment "@changemaker-lite/maintainers Friendly ping for review"
  4. +
  5. Join Discord: Ask in #contributors channel
  6. +
  7. Email: dev@cmlite.org for urgent PRs
  8. +
+

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

+ + +

Questions?

+ +

Thank you for contributing! Every PR helps make Changemaker Lite better. 🚀

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/contributing/roadmap/index.html b/mkdocs/site/v2/contributing/roadmap/index.html new file mode 100644 index 00000000..de8a26bc --- /dev/null +++ b/mkdocs/site/v2/contributing/roadmap/index.html @@ -0,0 +1,6185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Roadmap - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Changemaker Lite V2 Roadmap

+

This roadmap outlines the development journey of Changemaker Lite V2, including completed phases, current work, and future plans.

+

Overview

+

V2 is a complete rebuild of Changemaker Lite, transitioning from two separate Express apps to a unified modern TypeScript stack. The rebuild began in January 2025 and Phase 14 completed in February 2026.

+

Current Status: ✅ Phase 1-14 Complete | 🚧 Phase 15 In Progress

+

Completed Phases (1-14)

+

Phase 1: Foundation ✅ COMPLETE

+

Timeline: January 2025

+

Deliverables: +- Initialized api/ with TypeScript, Express, Prisma +- Created comprehensive Prisma schema (30+ models) +- Set up environment configuration (Zod validation) +- Implemented middleware (error handling, validation, rate limiting) +- Built utility modules (logger, metrics) +- Initialized admin/ with Vite + React + Ant Design +- Created Docker Compose orchestration +- Wrote .env.example with 100+ variables +- Backed up V1 to docker-compose.v1.yml

+

Key Achievements: +- Clean-room architecture established +- Type-safe foundation with TypeScript +- Scalable project structure

+
+

Phase 2: Auth + User Management ✅ COMPLETE

+

Timeline: January 2025

+

Deliverables: +- Express Request type augmentation +- Zod auth schemas +- JWT auth service (login, register, refresh, logout) +- Auth middleware (JWT verification) +- RBAC middleware (role-based access) +- User CRUD service + routes +- Integration tested (Postman)

+

Key Achievements: +- JWT refresh token rotation (atomic transaction) +- 5 user roles (SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP) +- Secure bcrypt password hashing +- User enumeration prevention (401 for invalid credentials)

+
+

Phase 3: Admin GUI Foundation ✅ COMPLETE

+

Timeline: January 2025

+

Deliverables: +- Zustand auth store with token management +- Login page with form validation +- Protected route wrapper +- AppLayout with sidebar navigation +- UsersPage with CRUD operations +- Axios client with 401 refresh interceptor (callback pattern)

+

Key Achievements: +- Automatic token refresh (seamless UX) +- Role-based sidebar navigation +- Responsive Ant Design components

+
+

Phase 4: Influence — Campaigns ✅ COMPLETE

+

Timeline: January 2025

+

Deliverables: +- Campaign Zod schemas +- Campaign service (CRUD, slug generation, toggle highlighting) +- Campaign admin routes +- CampaignsPage (table, filters, CRUD modals) +- Feature flag integration

+

Key Achievements: +- Unique slug generation +- Highlighted campaign toggle +- Response wall enable/disable per campaign

+
+

Phase 5: Influence — Representatives + Postal Codes ✅ COMPLETE

+

Timeline: January 2025

+

Deliverables: +- Postal code validation schemas (Canadian format) +- Postal code cache service (Prisma) +- Represent API client (typed, rate-limited 55/min) +- Representative service (cache-first lookup, fire-and-forget writes) +- Representative admin routes (list, stats, detail, delete) +- RepresentativesPage (lookup, stats cards, table, detail modal)

+

Key Achievements: +- Redis cache (60min TTL, ~20ms lookup) +- In-memory rate limiter (Represent API limit) +- Cache stats dashboard (total, by level, by party)

+
+

Phase 6: Influence — Email Sending ✅ COMPLETE

+

Timeline: January 2025

+

Deliverables: +- BullMQ email queue setup +- Email worker (SMTP via nodemailer) +- Campaign email service (compose, queue, track) +- Campaign email routes (send, track mailto, list, stats) +- Email queue admin routes (stats, pause, resume, clean) +- EmailQueuePage (monitoring, controls) +- CampaignEmailsDrawer (stats + list from CampaignsPage)

+

Key Achievements: +- Async email processing (BullMQ) +- Email test mode (MailHog) +- Rate limiting (30 req/hour per IP) +- Job retry with exponential backoff

+
+

Phase 7: Influence — Response Wall + Public Campaign View ✅ COMPLETE

+

Timeline: January-February 2025

+

Deliverables: +- Response service (submit, moderate, verify) +- Response routes (3 routers: campaign-public, response-public, admin) +- Email verification (HTML templates, verify/report endpoints) +- ResponsesPage (filters, approve/reject/delete, detail drawer) +- ResponseWallPage (sort, filter, submit modal, upvote) +- Upvoting system (IP + user dedup, optimistic UI) +- CampaignPage (postal code lookup, email sending) +- CampaignsListPage (hero, featured, grid) +- PublicLayout (dark theme for public pages)

+

Key Achievements: +- Moderation workflow (PENDING → APPROVED/REJECTED) +- Upvote deduplication (IP address + user ID) +- Public campaign discovery

+
+

Phase 8: Map — Locations ✅ COMPLETE

+

Timeline: February 2025

+

Deliverables: +- Multi-provider geocoding service (Nominatim, ArcGIS, Photon, Mapbox, Google, OpenCage) +- Location service (CRUD, geocoding, stats, bulk operations) +- Location routes (admin + public) +- MapSettings service + routes (singleton config) +- LocationsPage (table, stats, CRUD, geocode button, CSV import/export) +- MapSettingsPage (center/zoom, walk sheet config) +- Public MapPage (Leaflet, circle markers, color-coded, multi-unit grouping, cut overlays, geolocate, fullscreen) +- MapLegend component +- MapControls (click-to-add, move, geolocate, fullscreen) +- CutDrawingMode (polygon drawing with close detection) +- CutOverlays + CutOverlayControls

+

Key Achievements: +- 6 geocoding providers with automatic fallback +- Geocoding quality tracking (provider, timestamp, quality score) +- CSV import with flexible column mapping +- Admin map enhancements (click-to-add, drag-to-move) +- Point-in-polygon spatial queries (ray-casting algorithm)

+
+

Phase 9: Map — Shifts ✅ COMPLETE

+

Timeline: February 2025

+

Deliverables: +- Shift service (CRUD, signup management) +- Shift routes (admin + public) +- ShiftsPage (CRUD, signups drawer, email all signups) +- Public ShiftsPage (calendar view, signup cards, signup modal) +- Temp user creation (30-day expiry) +- Confirmation emails

+

Key Achievements: +- Cut assignment (link shift to territory) +- Signup status tracking (PENDING, CONFIRMED, CANCELLED, COMPLETED, NO_SHOW) +- Public signup flow with temp user auto-creation +- Email all shift signups (broadcast feature)

+
+

Phase 10: Walk Sheets & QR Codes ✅ COMPLETE

+

Timeline: February 2025

+

Deliverables: +- QR code generation endpoint (GET /api/qr, public, no auth) +- WalkSheetPage (printable form with QR codes, browser print) +- CutExportPage (printable location report with stats + table) +- Sidebar navigation + route wiring

+

Key Achievements: +- QR codes encode location data (address, coordinates, notes) +- Print-optimized CSS (page breaks, hide buttons) +- Cut-specific walk sheets (filter by cut)

+
+

Phase 11: Listmonk Integration ✅ COMPLETE

+

Timeline: February 2025

+

Deliverables: +- Listmonk API client (typed HTTP, basic auth, native fetch) +- Sync service (campaign participants, locations, users → subscriber lists) +- Admin routes (status, stats, sync triggers, test connection, reinitialize) +- ListmonkPage (status dashboard, sync buttons, list stats) +- Opt-in sync flag (LISTMONK_SYNC_ENABLED)

+

Key Achievements: +- Newsletter integration (advocacy campaigns → subscriber lists) +- Automatic list creation/sync +- Proton Mail SMTP configuration (listmonk-init auto-configures)

+
+

Phase 12: Landing Page Builder ✅ COMPLETE

+

Timeline: February 2025

+

Deliverables: +- Landing page service (CRUD, slug generation, MkDocs export) +- Page block service (seed blocks, CRUD, library API) +- GrapesJS editor integration (custom blocks, Ctrl+S save, error boundary) +- LandingPagesPage (table, search, settings modal) +- PageEditorPage (full-screen GrapesJS, desktop-only, forwardRef) +- Public LandingPage renderer (/p/:slug) +- MkDocs export (Jinja2 Material override template, themed + standalone modes) +- DocsPage (management, status cards, export table)

+

Key Achievements: +- Visual page builder (drag-and-drop) +- Custom block library (Hero, Features, CTA, Testimonials, etc.) +- MkDocs integration (static site generation) +- Jinja2 template export for Material theme

+
+

Phase 13: Volunteer Canvassing System ✅ COMPLETE

+

Timeline: February 2025

+

Deliverables: +- Prisma models (CanvassSession, CanvassVisit, TrackingSession, TrackPoint) +- Canvass API (volunteer routes: start/end session, record visits, walking route) +- Canvass API (admin routes: dashboard stats, activity feed, cut progress, leaderboard) +- Walking route algorithm (nearest-neighbor with haversine distance) +- GPS tracking routes (volunteer + admin) +- Abandoned session cleanup (startup + hourly, ACTIVE > 12h → 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 → /app, USER/TEMP → /volunteer)

+

Key Achievements: +- Complete field canvassing workflow +- Real-time GPS tracking with trail visualization +- Optimized walking routes (nearest-neighbor algorithm) +- Visit outcome tracking (8 outcomes: CONTACT_MADE, NOT_HOME, REFUSED, etc.) +- Volunteer leaderboard (by visits, filterable by period) +- Rate limiting (30 visits/min per IP)

+
+

Phase 14: Monitoring + DevOps ✅ COMPLETE

+

Timeline: February 2026

+

Pangolin Tunnel: +- Pangolin Integration API client (typescript) +- Admin pangolin routes (status, config, sites, resources, setup, sync, delete) +- PangolinPage (setup wizard + resource dashboard) +- Newt container in docker-compose.yml +- Env vars (PANGOLIN_API_URL, API_KEY, ORG_ID, SITE_ID, ENDPOINT, NEWT_ID, NEWT_SECRET) +- Retired Cloudflare scripts → scripts/legacy/

+

Prometheus Metrics: +- 12 domain-specific cm_* metrics (emails, auth, canvass, services, etc.) +- Instrumented modules (email-queue, auth, campaigns, responses, canvass, shifts, services) +- HTTP request metrics (duration, count, errors)

+

Monitoring Configs: +- Prometheus V2 API scrape job (removed V1 influence-app) +- Alert rules (rewritten for V2 metric names) +- Alertmanager Gotify webhook (commented, ready to enable) +- Grafana dashboards (3 dashboards: system-health, application-overview, api-performance)

+

Docker Healthchecks: +- 7 services with healthchecks (API, admin, nginx, NocoDB, n8n, Gitea, Listmonk)

+

Backup: +- scripts/backup.sh (V2 PostgreSQL + Listmonk + uploads archive) +- Manifest with timestamps, sizes, SHA256 checksums +- Configurable retention (default 30 days) +- Optional S3 upload (--s3 flag)

+

Key Achievements: +- Self-hosted tunnel alternative (Pangolin replaces Cloudflare) +- Comprehensive observability (Prometheus + Grafana) +- Production-ready monitoring stack +- Automated backup procedures

+
+

Current Phase (15)

+

Phase 15: Testing + Polish 🚧 IN PROGRESS

+

Timeline: February-March 2026

+

Goals: +- Comprehensive testing (unit, integration, E2E) +- Performance optimization +- Security hardening +- Documentation polish +- Bug fixes

+

Planned Deliverables:

+

Testing: +- [ ] API integration tests (Jest/Vitest) + - Auth flow tests (login, refresh, logout) + - Campaign CRUD tests + - Location CRUD + geocoding tests + - Canvass workflow tests +- [ ] Admin E2E tests (Playwright/Cypress) + - Login flow + - Campaign creation flow + - Location management flow + - Canvass session flow +- [ ] Test coverage reports (>80% target) +- [ ] Load testing (k6 or Artillery) + - API endpoint stress tests + - Database query performance + - Email queue throughput

+

Performance: +- [ ] Database query optimization + - Review Prisma queries for N+1 issues + - Add missing indexes + - Optimize spatial queries +- [ ] Frontend bundle size reduction + - Code splitting + - Lazy loading + - Tree shaking optimization +- [ ] Redis cache tuning + - Cache hit rate analysis + - TTL optimization + - Memory usage monitoring +- [ ] Image optimization + - WebP conversion + - Lazy loading + - Responsive images

+

Security: +- [ ] Dependency audit (npm audit, Snyk) +- [ ] OWASP Top 10 review +- [ ] Security headers verification +- [ ] Rate limiting verification +- [ ] Input validation audit +- [ ] SQL injection prevention check +- [ ] XSS protection verification

+

Documentation: +- [ ] API reference completion (all endpoints documented) +- [ ] User guide polish (screenshots, videos) +- [ ] Developer docs review (architecture, database) +- [ ] Migration guide testing (V1→V2 procedure verification) +- [ ] Troubleshooting guide expansion (common issues)

+

Bug Fixes: +- [ ] Review and fix open GitHub issues +- [ ] Fix reported bugs (priority: critical > high > medium > low) +- [ ] Address edge cases +- [ ] Improve error messages

+

Polish: +- [ ] UI/UX refinements (spacing, alignment, colors) +- [ ] Accessibility improvements (keyboard nav, screen reader) +- [ ] Mobile responsiveness fixes +- [ ] Loading states improvements +- [ ] Error state improvements

+

Progress: 20% (security audit complete, NAR import complete, media upload complete)

+
+

Future Roadmap (Phase 16+)

+

Phase 16: Multi-Tenancy (Planned)

+

Goal: Support multiple organizations on single instance

+

Features: +- [ ] Tenant isolation (database row-level security) +- [ ] Subdomain routing (org1.cmlite.org, org2.cmlite.org) +- [ ] Tenant-specific settings +- [ ] Billing integration (optional) +- [ ] Admin cross-tenant management +- [ ] Tenant signup flow

+

Technical Challenges: +- Database schema changes (add tenantId to all tables) +- Prisma middleware for automatic tenant filtering +- JWT token tenant claim +- File upload isolation (per-tenant directories)

+

Timeline: 2-3 months (tentative Q2 2026)

+
+

Phase 17: Mobile Apps (Planned)

+

Goal: Native iOS and Android apps for volunteers

+

Features: +- [ ] React Native app (iOS + Android) +- [ ] Volunteer canvassing optimized for mobile +- [ ] Offline mode (sync when online) +- [ ] Push notifications (shift reminders, campaign updates) +- [ ] Location services integration +- [ ] QR code scanning (walk sheets) +- [ ] Photo upload (location photos)

+

Technical Stack: +- React Native + Expo +- AsyncStorage for offline data +- React Query for sync +- Expo Notifications +- Expo Camera

+

Timeline: 3-4 months (tentative Q3 2026)

+
+

Phase 18: Advanced Analytics (Planned)

+

Goal: Campaign performance and volunteer metrics

+

Features: +- [ ] Campaign analytics dashboard + - Email open rates + - Response submission trends + - Geographic distribution +- [ ] Volunteer analytics + - Canvassing efficiency metrics + - Top volunteers leaderboard + - Activity heatmaps +- [ ] Location analytics + - Support level trends over time + - Geocoding quality reports + - Coverage maps +- [ ] Export to BI tools (Metabase, Superset)

+

Technical Stack: +- Prisma aggregations +- Chart.js or Recharts +- CSV/Excel export +- Optional: Metabase integration

+

Timeline: 2 months (tentative Q4 2026)

+
+

Phase 19: AI Integration (Exploratory)

+

Goal: AI-powered features for campaign optimization

+

Potential Features: +- [ ] Campaign email drafting (GPT-4 integration) +- [ ] Response sentiment analysis +- [ ] Canvassing route optimization (ML algorithm) +- [ ] Volunteer assignment suggestions +- [ ] Predictive support level classification +- [ ] Automated data quality checks

+

Technical Considerations: +- OpenAI API integration (cost considerations) +- Privacy concerns (user data in AI models) +- Ethical AI usage guidelines +- Opt-in for AI features

+

Timeline: TBD (community feedback needed)

+
+

Phase 20: Additional Integrations (Planned)

+

Goal: Connect to other campaign tools

+

Potential Integrations: +- [ ] Social media: Facebook, Twitter, Instagram posting +- [ ] SMS campaigns: Twilio integration for text banking +- [ ] Phone banking: VoIP integration for call tracking +- [ ] Donation tracking: ActBlue, Stripe integration +- [ ] Event management: Rally, town hall scheduling +- [ ] Voter files: VAN/Votebuilder import +- [ ] Peer-to-peer texting: Spoke, Relay integration

+

Timeline: Ongoing (community-driven priorities)

+
+

Feature Requests

+

Have an idea for a new feature? We'd love to hear it!

+

How to Request

+
    +
  1. Search existing requests: Check Discussions
  2. +
  3. Create new discussion: Start a discussion
  4. +
  5. Provide details:
  6. +
  7. Problem: What problem does this solve?
  8. +
  9. Use case: Who would use this feature?
  10. +
  11. Implementation ideas: How might it work?
  12. +
  13. Alternatives: What workarounds exist today?
  14. +
+

Prioritization Process

+

Features are prioritized based on:

+
    +
  1. Impact: How many users benefit?
  2. +
  3. Effort: How complex to implement?
  4. +
  5. Strategic fit: Aligns with mission?
  6. +
  7. Community votes: Upvote discussions
  8. +
  9. Funding: Sponsored development
  10. +
+

High-priority features: +- Requested by many users +- Low implementation effort +- Core to mission (campaign advocacy, volunteer management)

+

Low-priority features: +- Niche use cases +- High complexity +- Available via integrations

+

Community Voting

+

Upvote feature requests in GitHub Discussions:

+
    +
  1. Go to Ideas category
  2. +
  3. Click 👍 on discussions you want
  4. +
  5. Comment with your use case
  6. +
+

Most-upvoted features are considered for roadmap.

+
+

Contribution Opportunities

+

Want to contribute to the roadmap?

+

Code Contributions

+
    +
  • Phase 15 (Testing): Write integration tests, E2E tests
  • +
  • Phase 15 (Performance): Optimize queries, reduce bundle size
  • +
  • Phase 15 (Documentation): Improve guides, add tutorials
  • +
+

→ Find Issues

+

Design Contributions

+
    +
  • UI/UX mockups: Design future features
  • +
  • User research: Interview campaign organizers
  • +
  • Accessibility audit: Test with screen readers
  • +
+

Documentation Contributions

+
    +
  • User guides: Write how-to guides
  • +
  • Video tutorials: Create walkthrough videos
  • +
  • Translations: Translate docs to other languages
  • +
+

Sponsorship

+

Support development of specific features:

+
    +
  • Individual sponsors: $10/month (GitHub Sponsors)
  • +
  • Organization sponsors: $500+/month (custom features, priority support)
  • +
  • One-time donations: Sponsor specific features
  • +
+

→ Sponsor on GitHub

+
+

Release Schedule

+

Version Numbering

+

Changemaker Lite uses Semantic Versioning:

+
    +
  • Major (1.0.0): Breaking changes
  • +
  • Minor (1.1.0): New features (backward compatible)
  • +
  • Patch (1.1.1): Bug fixes
  • +
+

Current version: 2.0.0-beta.1 (Phase 15 in progress)

+

Release Cycle

+

Major releases: 6-12 months (major new features, breaking changes)

+

Minor releases: 1-2 months (new features, no breaking changes)

+

Patch releases: 1-2 weeks (bug fixes, security patches)

+

Upcoming Releases

+

v2.0.0 (stable release): +- Target: March 2026 +- Requires: Phase 15 complete (testing, polish) +- Breaking changes from beta: TBD

+

v2.1.0: +- Target: May 2026 +- Features: TBD based on community feedback

+

v2.2.0: +- Target: July 2026 +- Features: Possibly multi-tenancy (Phase 16)

+
+

Long-Term Vision

+

Mission: Provide free, self-hosted tools for grassroots political campaigns.

+

5-Year Vision (2026-2031):

+
    +
  1. Year 1 (2026): V2 stable, 100+ organizations using Changemaker Lite
  2. +
  3. Year 2 (2027): Multi-tenancy, mobile apps, 500+ organizations
  4. +
  5. Year 3 (2028): Advanced analytics, AI features, 1000+ organizations
  6. +
  7. Year 4 (2029): Ecosystem of integrations, international campaigns
  8. +
  9. Year 5 (2030): Changemaker Lite as standard platform for grassroots advocacy
  10. +
+

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)

+
+

Breaking Changes Policy

+

Commitment

+

We strive to minimize breaking changes in V2 minor releases. When breaking changes are necessary:

+
    +
  1. Advance notice: Announced 2 releases prior (e.g., deprecation in v2.1.0, removal in v2.3.0)
  2. +
  3. Migration guide: Detailed upgrade guide provided
  4. +
  5. Deprecation warnings: Console warnings in code
  6. +
  7. Major version bumps: Breaking changes only in major releases (v2→v3)
  8. +
+

Deprecation Process

+
    +
  1. Deprecate: Mark feature as deprecated (console warnings)
  2. +
  3. Announce: Publish deprecation notice in release notes
  4. +
  5. Wait: Keep deprecated feature for 2 releases minimum
  6. +
  7. Remove: Remove in next major version
  8. +
+

Example: +- v2.1.0: Deprecate /api/old-endpoint (with warnings) +- v2.2.0: Still supported, warnings continue +- v2.3.0: Still supported, migration guide published +- v3.0.0: Removed (breaking change)

+
+ + +

Feedback

+

Have feedback on the roadmap?

+
    +
  • Discuss features: GitHub Discussions
  • +
  • Report priorities: Email roadmap@cmlite.org
  • +
  • Vote on features: Upvote discussions
  • +
+

Together, we're building the future of grassroots political campaigns! 🚀

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/database/index.html b/mkdocs/site/v2/database/index.html new file mode 100644 index 00000000..32087c55 --- /dev/null +++ b/mkdocs/site/v2/database/index.html @@ -0,0 +1,5838 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Database Documentation - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Database Documentation

+

Overview

+

Changemaker Lite V2 uses a dual ORM architecture with PostgreSQL 16 as the backing database:

+
    +
  • Prisma ORM (Express API, port 4000) — 30 models for auth, influence, map, canvassing, email templates, landing pages, and tracking
  • +
  • Drizzle ORM (Fastify Media API, port 4100) — 3 models for video library, compilations, and job queue
  • +
+

Both ORMs share the same PostgreSQL database but maintain separate schemas and migration workflows.

+

Database Architecture

+

Database: PostgreSQL 16 +Connection: DATABASE_URL environment variable +Total Models: 33 models organized into 9 groups +Migration Tools: Prisma Migrate (main API), Drizzle Kit (media API)

+

Key Design Patterns

+
    +
  1. Audit Fields — Most models include:
  2. +
  3. createdAt / updatedAt timestamps
  4. +
  5. createdByUserId / updatedByUserId user references
  6. +
  7. +

    Automatic tracking via Prisma middleware

    +
  8. +
  9. +

    Soft Deletes — Some models use status fields instead of hard deletes:

    +
  10. +
  11. User: status (ACTIVE/INACTIVE/SUSPENDED/EXPIRED)
  12. +
  13. Campaign: status (DRAFT/ACTIVE/PAUSED/ARCHIVED)
  14. +
  15. +

    Shift: status (OPEN/FULL/CANCELLED)

    +
  16. +
  17. +

    JSON Fields — Used for flexible schema:

    +
  18. +
  19. permissions (User) — granular per-app permissions
  20. +
  21. offices (Representative) — array of office contact info
  22. +
  23. tags (videos) — array of tag strings
  24. +
  25. geojson (Cut) — GeoJSON polygon coordinates
  26. +
  27. +

    blocks (LandingPage) — GrapesJS editor output

    +
  28. +
  29. +

    Enums — 18 enums for type safety:

    +
  30. +
  31. +

    UserRole, UserStatus, CampaignStatus, GovernmentLevel, EmailMethod, ResponseType, ResponseStatus, SupportLevel, GeocodeProvider, BuildingType, LocationHistoryAction, ShiftStatus, SignupStatus, SignupSource, CutCategory, VisitOutcome, CanvassSessionStatus, TrackPointEvent, EmailTemplateCategory, EditorMode, MkdocsExportMode

    +
  32. +
  33. +

    Cascade Deletes — Foreign keys with onDelete: Cascade:

    +
  34. +
  35. Deleting a Campaign deletes all CampaignEmail, RepresentativeResponse, CustomRecipient, Call records
  36. +
  37. Deleting a Location deletes all Address and LocationHistory records
  38. +
  39. Deleting a Shift deletes all ShiftSignup records
  40. +
  41. +

    Deleting a CanvassSession deletes all CanvassVisit records

    +
  42. +
  43. +

    Indexes — Strategic indexing for performance:

    +
  44. +
  45. All foreign keys indexed (userId, campaignId, locationId, etc.)
  46. +
  47. Composite indexes for common queries (latitude+longitude, locationId+unitNumber, etc.)
  48. +
  49. Unique constraints (email, slug, postalCode, token, etc.)
  50. +
+

Complete Entity Relationship Diagram

+
erDiagram
+    %% ============================================================================
+    %% AUTH & USERS
+    %% ============================================================================
+
+    User ||--o{ RefreshToken : has
+    User ||--o{ Campaign : creates
+    User ||--o{ CampaignEmail : sends
+    User ||--o{ RepresentativeResponse : submits
+    User ||--o{ ResponseUpvote : upvotes
+    User ||--o{ ShiftSignup : "signs up for"
+    User ||--o{ Location : creates
+    User ||--o{ Location : updates
+    User ||--o{ Address : "creates (addresses)"
+    User ||--o{ Address : "updates (addresses)"
+    User ||--o{ LocationHistory : edits
+    User ||--o{ Cut : "creates (cuts)"
+    User ||--o{ CanvassVisit : visits
+    User ||--o{ CanvassSession : "has (sessions)"
+    User ||--o{ TrackingSession : "tracks (gps)"
+    User ||--o{ EmailTemplate : "creates (templates)"
+    User ||--o{ EmailTemplate : "updates (templates)"
+    User ||--o{ EmailTemplateVersion : "versions (templates)"
+    User ||--o{ EmailTemplateTestLog : "tests (templates)"
+
+    User {
+        String id PK
+        String email UK "bcrypt hashed"
+        String password "bcrypt"
+        String name
+        String phone
+        UserRole role "SUPER_ADMIN | INFLUENCE_ADMIN | MAP_ADMIN | USER | TEMP"
+        UserStatus status "ACTIVE | INACTIVE | SUSPENDED | EXPIRED"
+        Json permissions "granular per-app"
+        UserCreatedVia createdVia "ADMIN | PUBLIC_SHIFT_SIGNUP | STANDARD"
+        DateTime expiresAt "for TEMP users"
+        Int expireDays
+        DateTime lastLoginAt
+        Boolean emailVerified
+        DateTime createdAt
+        DateTime updatedAt
+    }
+
+    RefreshToken {
+        String id PK
+        String token UK "JWT refresh token"
+        String userId FK
+        DateTime expiresAt
+        DateTime createdAt
+    }
+
+    %% ============================================================================
+    %% INFLUENCE — CAMPAIGNS
+    %% ============================================================================
+
+    Campaign ||--o{ CampaignEmail : sends
+    Campaign ||--o{ RepresentativeResponse : receives
+    Campaign ||--o{ CustomRecipient : targets
+    Campaign ||--o{ Call : tracks
+
+    Campaign {
+        String id PK
+        String slug UK
+        String title
+        String description
+        String emailSubject
+        String emailBody
+        String callToAction
+        String coverPhoto
+        CampaignStatus status "DRAFT | ACTIVE | PAUSED | ARCHIVED"
+        Boolean allowSmtpEmail "default: true"
+        Boolean allowMailtoLink "default: true"
+        Boolean collectUserInfo "default: true"
+        Boolean showEmailCount "default: true"
+        Boolean showCallCount "default: true"
+        Boolean allowEmailEditing "default: false"
+        Boolean allowCustomRecipients "default: false"
+        Boolean showResponseWall "default: false"
+        Boolean highlightCampaign "default: false"
+        GovernmentLevel[] targetGovernmentLevels
+        String createdByUserId FK
+        String createdByUserEmail
+        String createdByUserName
+        DateTime createdAt
+        DateTime updatedAt
+    }
+
+    CampaignEmail {
+        String id PK
+        String campaignId FK
+        String campaignSlug
+        String userId FK
+        String userEmail
+        String userName
+        String userPostalCode
+        String recipientEmail
+        String recipientName
+        String recipientTitle
+        GovernmentLevel recipientLevel
+        EmailMethod emailMethod "SMTP | MAILTO"
+        String subject
+        String message
+        CampaignEmailStatus status "QUEUED | SENT | FAILED | CLICKED | USER_INFO_CAPTURED"
+        String senderIp
+        DateTime sentAt
+    }
+
+    Representative {
+        String id PK
+        String postalCode IDX
+        String name
+        String email
+        String districtName
+        String electedOffice
+        String partyName
+        String representativeSetName
+        String url
+        String photoUrl
+        Json offices "array of office contact info"
+        DateTime cachedAt
+    }
+
+    CustomRecipient {
+        String id PK
+        String campaignId FK
+        String campaignSlug
+        String recipientName
+        String recipientEmail
+        String recipientTitle
+        String recipientOrganization
+        String notes
+        Boolean isActive
+        DateTime createdAt
+        DateTime updatedAt
+    }
+
+    PostalCodeCache {
+        String id PK
+        String postalCode UK
+        String city
+        String province
+        Decimal centroidLat
+        Decimal centroidLng
+        DateTime lastUpdated
+    }
+
+    Call {
+        String id PK
+        String representativeName
+        String representativeTitle
+        String phoneNumber
+        String officeType
+        String callerName
+        String callerEmail
+        String postalCode
+        String campaignId FK
+        String campaignSlug
+        String callerIp
+        DateTime calledAt
+    }
+
+    %% ============================================================================
+    %% INFLUENCE — RESPONSE WALL
+    %% ============================================================================
+
+    RepresentativeResponse ||--o{ ResponseUpvote : gets
+
+    RepresentativeResponse {
+        String id PK
+        String campaignId FK
+        String campaignSlug
+        String representativeName
+        String representativeTitle
+        GovernmentLevel representativeLevel
+        String representativeEmail
+        ResponseType responseType "EMAIL | LETTER | PHONE_CALL | MEETING | SOCIAL_MEDIA | OTHER"
+        String responseText
+        String userComment
+        String screenshotUrl
+        String submittedByUserId FK
+        String submittedByName
+        String submittedByEmail
+        Boolean isAnonymous
+        ResponseStatus status "PENDING | APPROVED | REJECTED"
+        Boolean isVerified
+        String verificationToken
+        DateTime verificationSentAt
+        DateTime verifiedAt
+        String verifiedBy
+        Int upvoteCount
+        String submittedIp
+        DateTime createdAt
+        DateTime updatedAt
+    }
+
+    ResponseUpvote {
+        String id PK
+        String responseId FK
+        String userId FK
+        String userEmail
+        String upvotedIp
+    }
+
+    EmailLog {
+        String id PK
+        String recipientEmail
+        String senderName
+        String senderEmail
+        String subject
+        String message
+        String postalCode
+        String status "sent | failed | previewed"
+        String senderIp
+        DateTime sentAt
+    }
+
+    EmailVerification {
+        String id PK
+        String token UK
+        String email
+        String tempCampaignData "JSON"
+        DateTime createdAt
+        DateTime expiresAt
+        Boolean used
+    }
+
+    %% ============================================================================
+    %% MAP — LOCATIONS
+    %% ============================================================================
+
+    Location ||--o{ Address : contains
+    Location ||--o{ LocationHistory : logs
+
+    Location {
+        String id PK
+        Decimal latitude "required, precision: 10,8"
+        Decimal longitude "required, precision: 11,8"
+        String address "base street address, no unit"
+        String postalCode
+        String province
+        String federalDistrict
+        Int buildingUse "NAR BU_USE: 1=Residential, 2=Partial, 3=Non-Residential, 4=Unknown"
+        String locGuid UK "NAR LOC_GUID"
+        BuildingType buildingType "SINGLE_FAMILY | MULTI_UNIT | MIXED_USE | COMMERCIAL"
+        Int totalUnits
+        String buildingNotes "access codes, manager contact"
+        Int geocodeConfidence "0-100"
+        GeocodeProvider geocodeProvider
+        String createdByUserId FK
+        String updatedByUserId FK
+        DateTime createdAt
+        DateTime updatedAt
+    }
+
+    Address {
+        String id PK
+        String locationId FK
+        String unitNumber
+        String addrGuid UK "NAR ADDR_GUID"
+        String firstName
+        String lastName
+        String email
+        String phone
+        SupportLevel supportLevel "1 | 2 | 3 | 4"
+        Boolean sign
+        String signSize
+        String notes
+        String createdByUserId FK
+        String updatedByUserId FK
+        DateTime createdAt
+        DateTime updatedAt
+    }
+
+    LocationHistory {
+        String id PK
+        String locationId FK
+        String userId FK
+        LocationHistoryAction action "CREATED | UPDATED | GEOCODED | BULK_GEOCODED | MOVED_ON_MAP | IMPORTED_CSV | IMPORTED_NAR"
+        String field "which field changed"
+        String oldValue
+        String newValue
+        Json metadata "provider, confidence, etc"
+        DateTime createdAt
+    }
+
+    %% ============================================================================
+    %% MAP — SHIFTS & CUTS
+    %% ============================================================================
+
+    Cut ||--o{ Shift : schedules
+    Shift ||--o{ ShiftSignup : has
+    Shift ||--o{ CanvassVisit : "visits (shift)"
+    Shift ||--o{ CanvassSession : "sessions (shift)"
+
+    Shift {
+        String id PK
+        String title
+        String description
+        DateTime date
+        String startTime "HH:MM"
+        String endTime "HH:MM"
+        String location
+        Int maxVolunteers
+        Int currentVolunteers
+        ShiftStatus status "OPEN | FULL | CANCELLED"
+        Boolean isPublic
+        String cutId FK
+        String createdBy
+        DateTime createdAt
+        DateTime updatedAt
+    }
+
+    ShiftSignup {
+        String id PK
+        String shiftId FK
+        String shiftTitle
+        String userId FK
+        String userEmail
+        String userName
+        String userPhone
+        DateTime signupDate
+        SignupStatus status "CONFIRMED | CANCELLED"
+        SignupSource signupSource "AUTHENTICATED | PUBLIC | ADMIN"
+    }
+
+    Cut {
+        String id PK
+        String name
+        String description
+        String color
+        Decimal opacity
+        CutCategory category "CUSTOM | WARD | NEIGHBORHOOD | DISTRICT"
+        Boolean isPublic
+        Boolean isOfficial
+        String geojson "GeoJSON polygon data"
+        String bounds "bounding box JSON"
+        Boolean showLocations
+        Boolean exportEnabled
+        String assignedTo
+        Json filterSettings
+        DateTime lastCanvassed
+        Int completionPercentage
+        String createdByUserId FK
+        DateTime createdAt
+        DateTime updatedAt
+    }
+
+    MapSettings {
+        String id PK
+        Decimal latitude
+        Decimal longitude
+        Int zoom
+        String walkSheetTitle
+        String walkSheetSubtitle
+        String walkSheetFooter
+        String qrCode1Url
+        String qrCode1Label
+        String qrCode2Url
+        String qrCode2Label
+        String qrCode3Url
+        String qrCode3Label
+        String createdBy
+        DateTime createdAt
+        DateTime updatedAt
+    }
+
+    %% ============================================================================
+    %% CANVASSING
+    %% ============================================================================
+
+    Cut ||--o{ CanvassSession : "sessions (cut)"
+    CanvassSession ||--o{ CanvassVisit : records
+    CanvassSession ||--|| TrackingSession : tracks
+    Address ||--o{ CanvassVisit : "visited (address)"
+
+    CanvassSession {
+        String id PK
+        String userId FK
+        String cutId FK
+        String shiftId FK
+        CanvassSessionStatus status "ACTIVE | COMPLETED | ABANDONED"
+        DateTime startedAt
+        DateTime endedAt
+        Decimal startLatitude
+        Decimal startLongitude
+    }
+
+    CanvassVisit {
+        String id PK
+        String addressId FK
+        String userId FK
+        String shiftId FK
+        String sessionId FK
+        VisitOutcome outcome "NOT_HOME | REFUSED | MOVED | ALREADY_VOTED | SPOKE_WITH | LEFT_LITERATURE | COME_BACK_LATER"
+        SupportLevel supportLevel
+        Boolean signRequested
+        String signSize
+        String notes
+        Int durationSeconds
+        DateTime visitedAt
+    }
+
+    TrackingSession {
+        String id PK
+        String userId FK
+        String canvassSessionId UK
+        DateTime startedAt
+        DateTime endedAt
+        Boolean isActive
+        Int totalPoints
+        Float totalDistanceM
+        Decimal lastLatitude
+        Decimal lastLongitude
+        DateTime lastRecordedAt
+    }
+
+    TrackingSession ||--o{ TrackPoint : logs
+
+    TrackPoint {
+        String id PK
+        String trackingSessionId FK
+        Decimal latitude
+        Decimal longitude
+        Float accuracy
+        DateTime recordedAt
+        TrackPointEvent eventType "LOCATION_ADDED | VISIT_RECORDED | SESSION_STARTED | SESSION_ENDED"
+    }
+
+    %% ============================================================================
+    %% EMAIL TEMPLATES
+    %% ============================================================================
+
+    EmailTemplate ||--o{ EmailTemplateVariable : defines
+    EmailTemplate ||--o{ EmailTemplateVersion : versions
+    EmailTemplate ||--o{ EmailTemplateTestLog : tests
+
+    EmailTemplate {
+        String id PK
+        String key UK "e.g., campaign-email"
+        String name "display name"
+        String description
+        EmailTemplateCategory category "INFLUENCE | MAP | SYSTEM"
+        String subjectLine "with {{VAR}} support"
+        String htmlContent
+        String textContent
+        Boolean isSystem "prevent deletion"
+        Boolean isActive
+        String createdByUserId FK
+        String updatedByUserId FK
+        DateTime createdAt
+        DateTime updatedAt
+    }
+
+    EmailTemplateVariable {
+        String id PK
+        String templateId FK
+        String key "e.g., USER_NAME"
+        String label "e.g., User Name"
+        String description
+        Boolean isRequired
+        Boolean isConditional "used in {{#if}} blocks"
+        String sampleValue
+        Int sortOrder
+    }
+
+    EmailTemplateVersion {
+        String id PK
+        String templateId FK
+        Int versionNumber "auto-increment per template"
+        String subjectLine
+        String htmlContent
+        String textContent
+        String changeNotes
+        String createdByUserId FK
+        DateTime createdAt
+    }
+
+    EmailTemplateTestLog {
+        String id PK
+        String templateId FK
+        String recipientEmail
+        Json testData "sample variable values"
+        Boolean success
+        String errorMessage
+        String messageId "nodemailer message ID"
+        String sentByUserId FK
+        DateTime sentAt
+    }
+
+    %% ============================================================================
+    %% LANDING PAGES
+    %% ============================================================================
+
+    LandingPage {
+        String id PK
+        String slug UK
+        String title
+        String description
+        Json blocks "GrapesJS editor JSON"
+        String htmlOutput
+        String cssOutput
+        EditorMode editorMode "VISUAL | CODE"
+        String mkdocsPath "path in mkdocs/overrides/"
+        String mkdocsStubPath "path to .md stub"
+        MkdocsExportMode mkdocsExportMode "THEMED | STANDALONE"
+        Boolean mkdocsHideNav
+        Boolean mkdocsHideToc
+        Boolean mkdocsSkipExport
+        Boolean published
+        String seoTitle
+        String seoDescription
+        String seoImage
+        DateTime createdAt
+        DateTime updatedAt
+    }
+
+    PageBlock {
+        String id PK
+        String type "hero | text | image | cta | features | testimonials | form"
+        String label
+        Json schema "block configuration schema"
+        Json defaults "default values"
+        String thumbnail
+        String category
+        Int sortOrder
+        DateTime createdAt
+        DateTime updatedAt
+    }
+
+    %% ============================================================================
+    %% SITE SETTINGS
+    %% ============================================================================
+
+    SiteSettings {
+        String id PK
+        String organizationName
+        String organizationShortName
+        String organizationLogoUrl
+        String organizationFaviconUrl
+        String adminColorPrimary
+        String adminColorBgBase
+        String publicColorPrimary
+        String publicColorBgBase
+        String publicColorBgContainer
+        String publicHeaderGradient
+        String footerText
+        String loginSubtitle
+        String emailFromName
+        String smtpHost
+        Int smtpPort
+        String smtpUser
+        String smtpPass
+        String smtpFromAddress
+        String smtpActiveProvider "mailhog | production"
+        Boolean emailTestMode
+        String testEmailRecipient
+        Boolean enableInfluence
+        Boolean enableMap
+        Boolean enableNewsletter
+        Boolean enableLandingPages
+        DateTime createdAt
+        DateTime updatedAt
+    }
+

Model Groups

+

The database is organized into 9 logical groups:

+

1. Auth & Users

+
    +
  • User — User accounts with roles (SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP)
  • +
  • RefreshToken — JWT refresh token storage with rotation
  • +
+

Key Features: bcrypt passwords (12+ chars policy), role-based access control, temp user expiration, email verification

+

2. Influence

+
    +
  • Campaign — Advocacy campaigns with 12 feature flags
  • +
  • Representative — Cached representative data from Represent API
  • +
  • CampaignEmail — Email tracking (SMTP vs MAILTO)
  • +
  • RepresentativeResponse — Response wall with moderation
  • +
  • ResponseUpvote — Upvote tracking with IP + user uniqueness
  • +
  • CustomRecipient — Custom email targets
  • +
  • PostalCodeCache — Postal code geocoding cache
  • +
  • EmailLog — Email audit trail
  • +
  • EmailVerification — Verification token storage
  • +
  • Call — Phone call tracking
  • +
+

Key Features: Multi-government-level targeting, response moderation workflow (PENDING → APPROVED/REJECTED), BullMQ integration for email queue, upvote deduplication

+

3. Map — Locations

+
    +
  • Location — Building-level data with lat/lng, NAR integration
  • +
  • Address — Unit-level data with support levels
  • +
  • LocationHistory — Audit trail with 7 action types
  • +
  • Shift — Volunteer shifts with cut relation
  • +
  • ShiftSignup — Signup tracking
  • +
  • Cut — GeoJSON polygon overlays
  • +
  • MapSettings — Singleton for map center/zoom + walk sheet config
  • +
+

Key Features: Building vs unit architecture, multi-provider geocoding (6 providers), NAR 2025 import support, spatial indexing, GeoJSON storage

+

4. Canvassing

+
    +
  • CanvassSession — Session lifecycle (ACTIVE → COMPLETED/ABANDONED)
  • +
  • CanvassVisit — Visit recording with 7 outcome types
  • +
  • TrackingSession — GPS tracking integration
  • +
  • TrackPoint — GPS breadcrumb trail
  • +
+

Key Features: Walking route algorithm, session abandonment logic (12h timeout), distance calculation, support level tracking

+

5. Email Templates

+
    +
  • EmailTemplate — Template master with categories
  • +
  • EmailTemplateVariable — Variable definitions with validation
  • +
  • EmailTemplateVersion — Version history
  • +
  • EmailTemplateTestLog — Test email audit
  • +
+

Key Features: Handlebars-style variable interpolation ({{VAR}}), conditional variables, system template protection, version auto-increment

+

6. Landing Pages

+
    +
  • LandingPage — GrapesJS editor output with MkDocs export
  • +
  • PageBlock — Reusable block library
  • +
+

Key Features: GrapesJS JSON storage, MkDocs export modes (THEMED vs STANDALONE), SEO metadata, slug-based routing

+

7. Settings

+
    +
  • SiteSettings — Org branding + theme + SMTP + feature toggles
  • +
  • MapSettings — Map center/zoom + walk sheet config
  • +
+

Key Features: Singleton pattern, SMTP override hierarchy (SiteSettings → .env), feature flags

+

8. Media (Drizzle ORM)

+
    +
  • videos — Video library with metadata, directory types, engagement stats
  • +
  • compilations — Video compilation tracking
  • +
  • jobs — Job queue with resource categories
  • +
+

Key Features: Dual ORM architecture, FFprobe metadata extraction, directory type enum (9 types), job queue with GPU/CPU resource tracking

+

9. Shared/Standalone Models

+
    +
  • Representative — Shared across campaigns
  • +
  • PostalCodeCache — Shared geocoding cache
  • +
  • EmailLog — Audit trail (no relations)
  • +
  • EmailVerification — Standalone verification tokens
  • +
+

Field Types Reference

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Prisma TypePostgreSQL TypeDescriptionExample
StringtextVariable-length text"admin@cmlite.org"
String @db.TexttextLong-form text (no char limit)Campaign descriptions
Intinteger32-bit integer42
BigIntbigint64-bit integer (Node: number mode)File sizes
BooleanbooleanTrue/falsetrue
DecimalnumericArbitrary precision decimalLat/lng coordinates
Decimal @db.Decimal(10, 8)numeric(10, 8)10 digits, 8 after decimal53.54612345
DateTimetimestamp with time zoneTimestamp2025-02-11T10:30:00Z
DateTime @db.DatedateDate only (no time)Shift dates
JsonjsonbJSON data (binary storage)Arrays, objects
EnumenumEnumerated typeUserRole.SUPER_ADMIN
+

Enum Definitions

+

Auth & Users

+
    +
  • UserRole: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP
  • +
  • UserStatus: ACTIVE, INACTIVE, SUSPENDED, EXPIRED
  • +
  • UserCreatedVia: ADMIN, PUBLIC_SHIFT_SIGNUP, STANDARD
  • +
+

Influence

+
    +
  • CampaignStatus: DRAFT, ACTIVE, PAUSED, ARCHIVED
  • +
  • GovernmentLevel: FEDERAL, PROVINCIAL, MUNICIPAL, SCHOOL_BOARD
  • +
  • EmailMethod: SMTP, MAILTO
  • +
  • CampaignEmailStatus: QUEUED, SENT, FAILED, CLICKED, USER_INFO_CAPTURED
  • +
  • ResponseType: EMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHER
  • +
  • ResponseStatus: PENDING, APPROVED, REJECTED
  • +
+

Map

+
    +
  • SupportLevel: LEVEL_1 (mapped to "1"), LEVEL_2, LEVEL_3, LEVEL_4
  • +
  • GeocodeProvider: GOOGLE, MAPBOX, NOMINATIM, PHOTON, LOCATIONIQ, ARCGIS, UNKNOWN
  • +
  • BuildingType: SINGLE_FAMILY, MULTI_UNIT, MIXED_USE, COMMERCIAL
  • +
  • LocationHistoryAction: CREATED, UPDATED, GEOCODED, BULK_GEOCODED, MOVED_ON_MAP, IMPORTED_CSV, IMPORTED_NAR
  • +
  • ShiftStatus: OPEN, FULL, CANCELLED
  • +
  • SignupStatus: CONFIRMED, CANCELLED
  • +
  • SignupSource: AUTHENTICATED, PUBLIC, ADMIN
  • +
  • CutCategory: CUSTOM, WARD, NEIGHBORHOOD, DISTRICT
  • +
+

Canvassing

+
    +
  • VisitOutcome: NOT_HOME, REFUSED, MOVED, ALREADY_VOTED, SPOKE_WITH, LEFT_LITERATURE, COME_BACK_LATER
  • +
  • CanvassSessionStatus: ACTIVE, COMPLETED, ABANDONED
  • +
  • TrackPointEvent: LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED
  • +
+

Email Templates

+
    +
  • EmailTemplateCategory: INFLUENCE, MAP, SYSTEM
  • +
+

Landing Pages

+
    +
  • EditorMode: VISUAL, CODE
  • +
  • MkdocsExportMode: THEMED, STANDALONE
  • +
+

Media (Drizzle)

+
    +
  • DirectoryType (TypeScript literal): 'studios', 'gifs', 'private', 'inbox', 'curated', 'playback', 'compilations', 'videos', 'highlights'
  • +
  • ResourceCategory (TypeScript literal): 'gpu_ai', 'gpu_encode', 'cpu'
  • +
  • JobStatus (TypeScript literal): 'pending', 'queued', 'running', 'completed', 'failed', 'cancelled'
  • +
+

Index Strategy Overview

+

Foreign Key Indexes

+

All foreign key fields are indexed for join performance: +- userId, campaignId, locationId, addressId, shiftId, cutId, sessionId, templateId, trackingSessionId

+

Composite Indexes

+

Strategic multi-column indexes for common query patterns: +- [latitude, longitude] (Location) — spatial queries +- [locationId, unitNumber] (Address) — unit lookups +- [campaignId, status] (RepresentativeResponse) — filtered response lists +- [isActive, lastRecordedAt] (TrackingSession) — active session cleanup +- [templateId, createdAt(sort: Desc)] (EmailTemplateVersion) — version history +- [directoryType, isValid, orientation] (videos) — media library filtering

+

Unique Constraints

+

Enforce data integrity: +- email (User) +- slug (Campaign, LandingPage) +- postalCode (PostalCodeCache) +- token (RefreshToken, EmailVerification) +- key (EmailTemplate) +- [responseId, userId] (ResponseUpvote) — prevent duplicate upvotes from logged-in users +- [responseId, upvotedIp] (ResponseUpvote) — prevent duplicate upvotes from same IP +- [shiftId, userEmail] (ShiftSignup) — prevent duplicate shift signups +- [templateId, key] (EmailTemplateVariable) — unique variable keys per template +- [templateId, versionNumber] (EmailTemplateVersion) — sequential version numbers

+

Foreign Key Conventions

+

Cascade Deletes

+

onDelete: Cascade
+
+Used when child records should be deleted with parent: +- RefreshToken → User +- CampaignEmail → Campaign +- RepresentativeResponse → Campaign +- CustomRecipient → Campaign +- Call → Campaign (SetNull) +- Address → Location +- LocationHistory → Location +- ShiftSignup → Shift +- CanvassVisit → Address, CanvassSession +- TrackPoint → TrackingSession +- EmailTemplateVariable → EmailTemplate +- EmailTemplateVersion → EmailTemplate +- EmailTemplateTestLog → EmailTemplate

+

Set Null

+

onDelete: SetNull
+
+Used when child records should remain but orphan the reference: +- Campaign.createdByUserId → User +- CampaignEmail.userId → User +- RepresentativeResponse.submittedByUserId → User +- Location.createdByUserId/updatedByUserId → User +- Shift.cutId → Cut +- CanvassSession.shiftId → Shift +- TrackingSession.canvassSessionId → CanvassSession

+ + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/database/indexes/index.html b/mkdocs/site/v2/database/indexes/index.html new file mode 100644 index 00000000..598ea02c --- /dev/null +++ b/mkdocs/site/v2/database/indexes/index.html @@ -0,0 +1,6523 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Indexes - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Index Strategy & Performance

+

Overview

+

Changemaker Lite V2 uses strategic indexing across 33 models to optimize query performance. This document catalogs all indexes, explains their purpose, and provides query optimization guidance.

+

Total Indexes: 60+ (Prisma: 50+, Drizzle: 10+)

+

Index Types: +- Unique indexes — Enforce uniqueness constraints (email, slug, token, etc.) +- Foreign key indexes — Optimize JOIN operations (userId, campaignId, locationId, etc.) +- Composite indexes — Multi-column indexes for complex queries +- Spatial indexes — Latitude/longitude for geographic queries

+
+

Index Catalog

+

Auth & Users

+

User

+
    +
  • Unique: email — Login lookups (WHERE email = ?)
  • +
+

RefreshToken

+
    +
  • Unique: token — Refresh endpoint lookups (WHERE token = ?)
  • +
  • Foreign Key: userId — User deletion cascades
  • +
+
+

Influence

+

Campaign

+
    +
  • Unique: slug — Public campaign page lookups (WHERE slug = ?)
  • +
+

Representative

+
    +
  • Non-unique: postalCode — Postal code lookups (WHERE postalCode = ?)
  • +
+

CampaignEmail

+
    +
  • Foreign Key: campaignId — Campaign email stats (JOIN campaign_emails ON campaign_id = ?)
  • +
  • Non-unique: campaignSlug — Slug-based queries
  • +
+

RepresentativeResponse

+
    +
  • Foreign Key: campaignId — Campaign response wall (JOIN representative_responses ON campaign_id = ?)
  • +
  • Non-unique: campaignSlug — Slug-based queries
  • +
+

ResponseUpvote

+
    +
  • Unique: [responseId, userId] — Prevent duplicate upvotes from logged-in users
  • +
  • Unique: [responseId, upvotedIp] — Prevent duplicate upvotes from same IP
  • +
+

CustomRecipient

+
    +
  • Foreign Key: campaignId — Campaign custom recipients (JOIN custom_recipients ON campaign_id = ?)
  • +
+

PostalCodeCache

+
    +
  • Unique: postalCode — Postal code cache lookups (WHERE postal_code = ?)
  • +
+

Call

+
    +
  • Foreign Key: campaignId — Campaign call tracking (JOIN calls ON campaign_id = ?)
  • +
+
+

Map — Locations

+

Location

+
    +
  • Unique: locGuid — NAR location GUID lookups
  • +
  • Composite: [latitude, longitude]Spatial queries (nearby locations, bounding box searches)
  • +
  • Non-unique: postalCode — Postal code filtering
  • +
+

Query Optimization: +

-- Uses composite index for bounding box queries
+SELECT * FROM locations
+WHERE latitude BETWEEN ? AND ?
+  AND longitude BETWEEN ? AND ?;
+

+

Address

+
    +
  • Unique: addrGuid — NAR address GUID lookups
  • +
  • Foreign Key: locationId — Location addresses (JOIN addresses ON location_id = ?)
  • +
  • Composite: [locationId, unitNumber]Unit lookups within building
  • +
+

Query Optimization: +

-- Uses composite index for unit-specific queries
+SELECT * FROM addresses
+WHERE location_id = ? AND unit_number = ?;
+

+

LocationHistory

+
    +
  • Foreign Key: locationId — Location history (JOIN location_history ON location_id = ?)
  • +
  • Foreign Key: userId — User edit history (JOIN location_history ON user_id = ?)
  • +
  • Non-unique: createdAtTemporal queries (recent edits, audit trails)
  • +
+
+

Map — Shifts & Cuts

+

Shift

+
    +
  • Foreign Key: cutId — Cut shifts (JOIN shifts ON cut_id = ?)
  • +
+

ShiftSignup

+
    +
  • Unique: [shiftId, userEmail]Prevent duplicate shift signups
  • +
  • Foreign Key: shiftId — Shift signups (JOIN shift_signups ON shift_id = ?)
  • +
+
+

Canvassing

+

CanvassSession

+
    +
  • Foreign Key: userId — User canvass sessions (JOIN canvass_sessions ON user_id = ?)
  • +
  • Foreign Key: cutId — Cut canvass sessions (JOIN canvass_sessions ON cut_id = ?)
  • +
  • Foreign Key: shiftId — Shift canvass sessions (JOIN canvass_sessions ON shift_id = ?)
  • +
+

CanvassVisit

+
    +
  • Foreign Key: addressId — Address visit history (JOIN canvass_visits ON address_id = ?)
  • +
  • Foreign Key: userId — User visit history (JOIN canvass_visits ON user_id = ?)
  • +
  • Foreign Key: shiftId — Shift visits (JOIN canvass_visits ON shift_id = ?)
  • +
  • Foreign Key: sessionId — Session visits (JOIN canvass_visits ON session_id = ?)
  • +
  • Non-unique: visitedAtTemporal queries (recent visits, activity feeds)
  • +
+

TrackingSession

+
    +
  • Unique: canvassSessionIdOne-to-one relationship with CanvassSession
  • +
  • Foreign Key: userId — User GPS sessions (JOIN tracking_sessions ON user_id = ?)
  • +
  • Non-unique: isActive — Active session filtering (WHERE is_active = true)
  • +
  • Composite: [isActive, lastRecordedAt]Session cleanup queries (abandoned sessions)
  • +
+

Query Optimization: +

-- Uses composite index for abandoned session cleanup
+SELECT * FROM tracking_sessions
+WHERE is_active = true
+  AND last_recorded_at < NOW() - INTERVAL '12 hours';
+

+

TrackPoint

+
    +
  • Composite: [trackingSessionId, recordedAt]Temporal GPS queries (session breadcrumb trail)
  • +
  • Non-unique: recordedAt — Cross-session temporal queries
  • +
+
+

Email Templates

+

EmailTemplate

+
    +
  • Unique: key — Template key lookups (WHERE key = 'campaign-email')
  • +
  • Non-unique: category — Category filtering (WHERE category = 'INFLUENCE')
  • +
  • Non-unique: isActive — Active template filtering (WHERE is_active = true)
  • +
+

EmailTemplateVariable

+
    +
  • Unique: [templateId, key]Unique variable keys per template
  • +
  • Foreign Key: templateId — Template variables (JOIN email_template_variables ON template_id = ?)
  • +
+

EmailTemplateVersion

+
    +
  • Unique: [templateId, versionNumber]Sequential version numbers per template
  • +
  • Composite: [templateId, createdAt(sort: Desc)]Recent version history
  • +
+

Query Optimization: +

-- Uses composite index for recent version queries
+SELECT * FROM email_template_versions
+WHERE template_id = ?
+ORDER BY created_at DESC
+LIMIT 10;
+

+

EmailTemplateTestLog

+
    +
  • Composite: [templateId, sentAt(sort: Desc)]Recent test logs
  • +
+
+

Landing Pages

+

LandingPage

+
    +
  • Unique: slug — Public page lookups (WHERE slug = 'about')
  • +
+
+

Media (Drizzle ORM)

+

videos

+
    +
  • Unique: path — File path lookups (WHERE path = '/media/local/videos/file.mp4')
  • +
  • Non-unique: orientation — Orientation filtering (WHERE orientation = 'landscape')
  • +
  • Non-unique: producer — Producer filtering (WHERE producer = 'Studio A')
  • +
  • Non-unique: isValid — Valid video filtering (WHERE is_valid = true)
  • +
  • Non-unique: directoryType — Directory type filtering (WHERE directory_type = 'studios')
  • +
  • Composite: [durationSeconds, fileSize, width, height]Fingerprint matching (duplicate detection)
  • +
  • Composite: [directoryType, isValid, orientation]Common filtering pattern
  • +
+

Query Optimization: +

-- Uses composite index for common video library queries
+SELECT * FROM videos
+WHERE directory_type = 'studios'
+  AND is_valid = true
+  AND orientation = 'landscape';
+

+

jobs

+
    +
  • Composite: [status, priority, createdAt]Job queue processing
  • +
  • Composite: [resourceCategory, status]Resource-based filtering
  • +
  • Non-unique: pipelineId — Pipeline job filtering
  • +
+

Query Optimization: +

-- Uses composite index for job queue queries
+SELECT * FROM jobs
+WHERE status = 'pending'
+ORDER BY priority ASC, created_at ASC
+LIMIT 10;
+

+
+

Query Optimization Patterns

+

1. Use Indexes for WHERE Clauses

+
// ✅ Uses email unique index
+await prisma.user.findUnique({ where: { email: 'user@example.com' } });
+
+// ❌ Full table scan (no index on name)
+await prisma.user.findMany({ where: { name: 'John' } });
+
+

2. Use Composite Indexes for Multi-Column Filters

+
// ✅ Uses [latitude, longitude] composite index
+await prisma.location.findMany({
+  where: {
+    latitude: { gte: 53.5, lte: 53.6 },
+    longitude: { gte: -113.5, lte: -113.4 },
+  },
+});
+
+// ❌ Less efficient (only uses latitude index)
+await prisma.location.findMany({
+  where: {
+    latitude: { gte: 53.5, lte: 53.6 },
+    // longitude filter applied after index scan
+  },
+});
+
+

3. Use Foreign Key Indexes for JOINs

+
// ✅ Uses campaignId foreign key index
+await prisma.campaign.findUnique({
+  where: { id: campaignId },
+  include: { emails: true }, // JOIN uses index
+});
+
+// ❌ N+1 query (loads emails one-by-one)
+const campaign = await prisma.campaign.findUnique({ where: { id: campaignId } });
+const emails = await prisma.campaignEmail.findMany({ where: { campaignId: campaign.id } });
+
+

4. Use Unique Indexes for Deduplication

+
// ✅ Uses [responseId, userId] unique index
+await prisma.responseUpvote.create({
+  data: { responseId, userId, upvotedIp },
+});
+// Throws error if user already upvoted (database-level check)
+
+// ❌ Application-level check (race condition)
+const existing = await prisma.responseUpvote.findFirst({
+  where: { responseId, userId },
+});
+if (existing) throw new Error('Already upvoted');
+await prisma.responseUpvote.create({ data: { responseId, userId } });
+
+

5. Use Temporal Indexes for Date Filtering

+
// ✅ Uses createdAt index
+await prisma.locationHistory.findMany({
+  where: {
+    createdAt: { gte: new Date('2025-01-01') },
+  },
+  orderBy: { createdAt: 'desc' },
+  take: 100,
+});
+
+// ❌ Full table scan (no index on field)
+await prisma.locationHistory.findMany({
+  where: {
+    oldValue: { contains: 'Calgary' }, // No index
+  },
+});
+
+
+

Index Selectivity

+

Selectivity = Percentage of unique values in indexed column. Higher selectivity = better index performance.

+

High Selectivity (Good)

+
    +
  • email (User) — 100% unique (1 user per email)
  • +
  • token (RefreshToken) — 100% unique (1 token per record)
  • +
  • slug (Campaign, LandingPage) — 100% unique (1 record per slug)
  • +
  • [responseId, userId] (ResponseUpvote) — High uniqueness (1 upvote per user per response)
  • +
+

Medium Selectivity (Okay)

+
    +
  • postalCode (Location) — ~50% unique (multiple locations per postal code)
  • +
  • campaignId (CampaignEmail) — ~10% unique (100s of emails per campaign)
  • +
  • directoryType (videos) — ~11% unique (9 directory types)
  • +
+

Low Selectivity (Poor for filtering, good for covering index)

+
    +
  • isActive (TrackingSession) — ~50% unique (active vs inactive)
  • +
  • status (Campaign) — ~25% unique (4 statuses: DRAFT, ACTIVE, PAUSED, ARCHIVED)
  • +
  • role (User) — ~20% unique (5 roles)
  • +
+

Optimization: +- Use low-selectivity indexes as first column in composite index only +- Example: [isActive, lastRecordedAt] uses isActive to narrow search, then lastRecordedAt for ordering

+
+

Index Maintenance

+

Prisma Indexes (Automatic)

+

Prisma migrations automatically create indexes defined in schema.prisma: +

model Location {
+  latitude  Decimal
+  longitude Decimal
+
+  @@index([latitude, longitude])  // Composite index
+}
+

+

Drizzle Indexes (Manual in Schema)

+

Drizzle indexes defined in schema.ts: +

export const videos = pgTable('videos', {
+  directoryType: text('directory_type'),
+  isValid: boolean('is_valid'),
+  orientation: text('orientation'),
+}, (table) => ({
+  directoryValidOrientationIdx: index('idx_videos_directory_valid_orientation')
+    .on(table.directoryType, table.isValid, table.orientation),
+}));
+

+

Index Size Monitoring

+
-- Check index sizes
+SELECT
+  tablename,
+  indexname,
+  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
+FROM pg_stat_user_indexes
+WHERE schemaname = 'public'
+ORDER BY pg_relation_size(indexrelid) DESC;
+
+

Unused Index Detection

+
-- Find indexes with zero scans (unused)
+SELECT
+  schemaname,
+  tablename,
+  indexname,
+  idx_scan,
+  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
+FROM pg_stat_user_indexes
+WHERE schemaname = 'public'
+  AND idx_scan = 0
+  AND indexrelid NOT IN (
+    SELECT conindid FROM pg_constraint WHERE contype IN ('p', 'u')
+  )
+ORDER BY pg_relation_size(indexrelid) DESC;
+
+
+

Performance Considerations

+

Index Trade-offs

+
    +
  • Pros: Faster SELECT queries, enforces uniqueness, prevents N+1
  • +
  • Cons: Slower INSERT/UPDATE/DELETE (index must be updated), increased storage
  • +
+

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)

+

Query Planning

+

Use EXPLAIN ANALYZE to verify index usage: +

EXPLAIN ANALYZE
+SELECT * FROM locations
+WHERE latitude BETWEEN 53.5 AND 53.6
+  AND longitude BETWEEN -113.5 AND -113.4;
+
+-- Output should show "Index Scan using locations_latitude_longitude_idx"
+

+

Index Bloat

+

Over time, indexes can become bloated (unused space). Monitor with: +

SELECT
+  schemaname,
+  tablename,
+  indexname,
+  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
+  idx_scan,
+  idx_tup_read,
+  idx_tup_fetch
+FROM pg_stat_user_indexes
+WHERE schemaname = 'public'
+ORDER BY pg_relation_size(indexrelid) DESC;
+

+

Fix bloat: REINDEX INDEX index_name; (requires table lock)

+
+

Common Performance Issues

+

Issue: Slow campaign email stats query

+

Query: +

SELECT COUNT(*) FROM campaign_emails WHERE campaign_id = ?;
+

+

Solution: Already optimized (uses campaignId foreign key index)

+

Issue: Slow location bounding box queries

+

Query: +

SELECT * FROM locations WHERE latitude > ? AND latitude < ? AND longitude > ? AND longitude < ?;
+

+

Solution: Already optimized (uses [latitude, longitude] composite index)

+

Issue: Slow active session cleanup

+

Query: +

SELECT * FROM tracking_sessions WHERE is_active = true AND last_recorded_at < ?;
+

+

Solution: Already optimized (uses [isActive, lastRecordedAt] composite index)

+

Issue: Slow template version history

+

Query: +

SELECT * FROM email_template_versions WHERE template_id = ? ORDER BY created_at DESC LIMIT 10;
+

+

Solution: Already optimized (uses [templateId, createdAt(sort: Desc)] composite index)

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/database/migrations/index.html b/mkdocs/site/v2/database/migrations/index.html new file mode 100644 index 00000000..7fadb8f8 --- /dev/null +++ b/mkdocs/site/v2/database/migrations/index.html @@ -0,0 +1,6220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Migrations - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Migration Workflow

+

Overview

+

Changemaker Lite V2 uses a dual ORM architecture with separate migration workflows:

+
    +
  • Prisma Migrate — Main API (Express, 30 models)
  • +
  • Drizzle Kit — Media API (Fastify, 3 models)
  • +
+

Both ORMs share the same PostgreSQL database but maintain independent migration histories.

+
+

Prisma Migration Workflow

+

Development Workflow

+

1. Modify Schema

+

Edit api/prisma/schema.prisma: +

model Location {
+  id       String  @id @default(cuid())
+  address  String
+  // Add new field:
+  province String?
+  // ...
+}
+

+

2. Create Migration

+
cd api
+npx prisma migrate dev --name add_province_to_location
+
+

This command: +- Generates SQL migration file in prisma/migrations/ +- Applies migration to database +- Regenerates Prisma Client +- Updates _prisma_migrations table

+

Output: +

Prisma schema loaded from prisma/schema.prisma
+Datasource "db": PostgreSQL database "changemaker_v2", schema "public"
+
+Applying migration `20260213120000_add_province_to_location`
+
+The following migration(s) have been created and applied from new schema changes:
+
+migrations/
+  └─ 20260213120000_add_province_to_location/
+      └─ migration.sql
+
+Your database is now in sync with your schema.
+

+

3. Review Migration SQL

+
-- migrations/20260213120000_add_province_to_location/migration.sql
+-- AlterTable
+ALTER TABLE "locations" ADD COLUMN "province" TEXT;
+
+

4. Commit Migration

+
git add prisma/migrations/
+git commit -m "Add province field to Location model"
+
+

Production Workflow

+

1. Deploy Migration

+
docker compose exec api npx prisma migrate deploy
+
+

This command: +- Applies pending migrations from prisma/migrations/ +- Does NOT create new migrations +- Does NOT prompt for confirmations +- Safe for production/CI pipelines

+

2. Verify Migration Status

+
docker compose exec api npx prisma migrate status
+
+

Output: +

1 migration found in prisma/migrations
+
+Following migration have been applied:
+
+20260213120000_add_province_to_location
+
+Database schema is up to date!
+

+

Common Migration Scenarios

+

Add Field (Nullable)

+

model Location {
+  federalDistrict String?  // Add nullable field
+}
+
+Migration: +
ALTER TABLE "locations" ADD COLUMN "federal_district" TEXT;
+

+

Add Field (Required with Default)

+

model Location {
+  buildingType BuildingType @default(SINGLE_FAMILY)
+}
+
+Migration: +
ALTER TABLE "locations" ADD COLUMN "building_type" TEXT NOT NULL DEFAULT 'SINGLE_FAMILY';
+

+

Add Relation

+

model Shift {
+  cutId String?
+  cut   Cut?    @relation(fields: [cutId], references: [id], onDelete: SetNull)
+}
+
+Migration: +
ALTER TABLE "shifts" ADD COLUMN "cut_id" TEXT;
+CREATE INDEX "shifts_cut_id_idx" ON "shifts"("cut_id");
+ALTER TABLE "shifts" ADD CONSTRAINT "shifts_cut_id_fkey" FOREIGN KEY ("cut_id") REFERENCES "cuts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+

+

Change Field Type

+

model Location {
+  geocodeConfidence Int?  // Changed from String? to Int?
+}
+
+Migration (requires data migration): +
-- Step 1: Add new column
+ALTER TABLE "locations" ADD COLUMN "geocode_confidence_new" INTEGER;
+
+-- Step 2: Migrate data (custom logic)
+UPDATE "locations" SET "geocode_confidence_new" = CAST("geocode_confidence" AS INTEGER)
+WHERE "geocode_confidence" ~ '^[0-9]+$';
+
+-- Step 3: Drop old column
+ALTER TABLE "locations" DROP COLUMN "geocode_confidence";
+
+-- Step 4: Rename new column
+ALTER TABLE "locations" RENAME COLUMN "geocode_confidence_new" TO "geocode_confidence";
+

+

Add Enum

+

enum BuildingType {
+  SINGLE_FAMILY
+  MULTI_UNIT
+  MIXED_USE
+  COMMERCIAL
+}
+
+Migration: +
CREATE TYPE "BuildingType" AS ENUM ('SINGLE_FAMILY', 'MULTI_UNIT', 'MIXED_USE', 'COMMERCIAL');
+

+

Add Index

+

model Location {
+  latitude  Decimal
+  longitude Decimal
+
+  @@index([latitude, longitude])
+}
+
+Migration: +
CREATE INDEX "locations_latitude_longitude_idx" ON "locations"("latitude", "longitude");
+

+

Migration Commands Reference

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CommandDescriptionEnvironment
npx prisma migrate devCreate + apply migrationDevelopment
npx prisma migrate deployApply pending migrationsProduction/CI
npx prisma migrate statusCheck migration statusAll
npx prisma migrate resetReset DB + apply all migrationsDevelopment only
npx prisma db pushPush schema without migrationsPrototyping only
npx prisma studioOpen Prisma Studio (DB GUI)Development
+

Safe Migration Practices

+

✅ DO

+
    +
  • Always review generated SQL before committing
  • +
  • Test migrations on dev database first
  • +
  • Back up production database before deploying migrations
  • +
  • Use nullable fields for new columns on existing tables
  • +
  • Use @default() for new required fields
  • +
  • Commit migration files to version control
  • +
+

❌ DON'T

+
    +
  • Use prisma db push in production (skips migrations)
  • +
  • Use prisma migrate reset in production (deletes data)
  • +
  • Manually edit migration files after applying
  • +
  • Delete old migration files (breaks history)
  • +
  • Change field names without data migration plan
  • +
+
+

Drizzle Migration Workflow

+

Development Workflow

+

1. Modify Schema

+

Edit api/src/modules/media/db/schema.ts: +

export const videos = pgTable('videos', {
+  id: serial('id').primaryKey(),
+  path: text('path').notNull().unique(),
+  // Add new field:
+  description: text('description'),
+  // ...
+});
+

+

2. Push Schema Changes

+
cd api
+npx drizzle-kit push
+
+

This command: +- Generates SQL diff from schema +- Applies changes directly to database +- Does NOT create migration files (Drizzle push mode) +- Updates database schema immediately

+

Output: +

Reading config file '/home/bunker-admin/changemaker.lite/api/drizzle.config.ts'
+Pulling schema from database...✓
+Applying changes...
+
+[✓] Applying: ALTER TABLE "videos" ADD COLUMN "description" text;
+
+Schema applied successfully!
+

+

3. Verify Schema

+

npx drizzle-kit studio
+
+Opens Drizzle Studio at https://local.drizzle.studio/ for database inspection.

+

Production Workflow

+

Same as development: +

docker compose exec media-api npx drizzle-kit push
+

+

Drizzle vs Prisma Migrate

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeaturePrisma MigrateDrizzle Kit Push
Migration files✓ Generated✗ Not generated
Migration history✓ Tracked in _prisma_migrations✗ No history table
Rollback support✓ Via migration files✗ Manual only
Production safety✓ Explicit deploy step⚠️ Direct push
Best forMain API (schema stability)Media API (rapid iteration)
+

Why Drizzle for Media API? +- Smaller schema (3 tables vs 30) +- Faster iteration during development +- Simpler deployment (no migration history to manage) +- Media API is newer (less risk of breaking changes)

+

Drizzle Commands Reference

+ + + + + + + + + + + + + + + + + + + + + +
CommandDescription
npx drizzle-kit pushPush schema changes to DB
npx drizzle-kit studioOpen Drizzle Studio
npx drizzle-kit generateGenerate migrations (not used)
+
+

Migration File Structure

+

Prisma Migrations

+
api/prisma/migrations/
+├── 20260211120000_initial/
+│   └── migration.sql
+├── 20260211125000_add_refresh_tokens/
+│   └── migration.sql
+├── 20260212100000_add_canvass_system/
+│   └── migration.sql
+└── migration_lock.toml
+
+

File naming: YYYYMMDDHHMMSS_description/migration.sql

+

migration_lock.toml: +

# Please do not edit this file manually
+provider = "postgresql"
+

+

Drizzle Schema (No Migrations)

+
api/src/modules/media/db/
+├── schema.ts          # Source of truth
+└── drizzle.config.ts  # Drizzle config
+
+
+

Rollback Strategies

+

Prisma Rollback (Manual)

+

Scenario: Migration 20260213120000_add_province caused issues.

+

Step 1: Identify last good migration +

npx prisma migrate status
+

+

Step 2: Manually revert migration SQL +

-- Reverse of migration.sql
+ALTER TABLE "locations" DROP COLUMN "province";
+

+

Step 3: Mark migration as rolled back +

DELETE FROM "_prisma_migrations" WHERE migration_name = '20260213120000_add_province';
+

+

Step 4: Remove migration file +

rm -rf prisma/migrations/20260213120000_add_province/
+

+

Step 5: Fix schema +Edit prisma/schema.prisma to remove province field.

+

Step 6: Create new migration +

npx prisma migrate dev --name remove_province_from_location
+

+

Drizzle Rollback (Manual)

+

Step 1: Revert schema changes in schema.ts

+

Step 2: Push reverted schema +

npx drizzle-kit push
+

+

Step 3: If data loss occurred, restore from backup

+
+

Common Migration Errors

+

Error: "Migration failed to apply cleanly"

+

Cause: Database state doesn't match expected state +Solution: +

npx prisma migrate resolve --applied <migration-name>  # Mark as applied
+# OR
+npx prisma migrate resolve --rolled-back <migration-name>  # Mark as rolled back
+

+

Error: "Unique constraint violation"

+

Cause: Trying to add unique constraint on column with duplicate values +Solution: +1. Clean up duplicate data first +2. Run migration

+

Error: "Column cannot be NOT NULL"

+

Cause: Trying to add required field to table with existing rows +Solution: Use @default() or make field nullable

+

Error: "Foreign key constraint failed"

+

Cause: Referencing non-existent records +Solution: Ensure related records exist before adding FK

+
+

Database Backup Before Migration

+

Development

+
docker compose exec v2-postgres pg_dump -U changemaker_v2 changemaker_v2 > backup.sql
+
+

Production

+
# Via docker-compose
+docker compose exec v2-postgres pg_dump -U changemaker_v2 changemaker_v2 | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
+
+# Via backup script
+./scripts/backup.sh
+
+

Restore from Backup

+
# Stop API services
+docker compose stop api media-api
+
+# Restore database
+docker compose exec -T v2-postgres psql -U changemaker_v2 changemaker_v2 < backup.sql
+
+# Restart services
+docker compose up -d api media-api
+
+
+

CI/CD Integration

+

GitHub Actions Example

+
name: Deploy V2
+
+on:
+  push:
+    branches: [main]
+
+jobs:
+  migrate:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-node@v3
+        with:
+          node-version: 20
+
+      - name: Install dependencies
+        run: cd api && npm ci
+
+      - name: Run Prisma migrations
+        run: cd api && npx prisma migrate deploy
+        env:
+          DATABASE_URL: ${{ secrets.DATABASE_URL }}
+
+      - name: Run Drizzle push
+        run: cd api && npx drizzle-kit push
+        env:
+          DATABASE_URL: ${{ secrets.DATABASE_URL }}
+
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/database/models/auth/index.html b/mkdocs/site/v2/database/models/auth/index.html new file mode 100644 index 00000000..d664b2d3 --- /dev/null +++ b/mkdocs/site/v2/database/models/auth/index.html @@ -0,0 +1,6690 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Auth Models - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Auth & Users Models

+

Overview

+

The Auth & Users module provides JWT-based authentication with role-based access control (RBAC), temporary user support for public shift signups, and refresh token rotation for enhanced security.

+

Models: +- User — User accounts with roles and permissions +- RefreshToken — JWT refresh token storage with expiration

+

Key Features: +- bcrypt password hashing (12+ character policy enforced at schema level) +- JWT access tokens (15min) + refresh tokens (7 days) +- Refresh token rotation with atomic transactions +- Role hierarchy: SUPER_ADMIN > INFLUENCE_ADMIN > MAP_ADMIN > USER > TEMP +- Temporary user support with auto-expiration +- Email verification workflow +- User enumeration prevention (401 not 404)

+
+

Models Summary

+ + + + + + + + + + + + + + + + + + + + +
ModelTableDescription
UserusersUser accounts with RBAC, permissions, temp user support
RefreshTokenrefresh_tokensJWT refresh tokens with expiration tracking
+
+

User Model

+

Purpose

+

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.

+

Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
Identity
idStringcuid()Primary key
emailStringUnique email address (lowercase)
passwordStringbcrypt hashed (12+ chars, 1 uppercase, 1 lowercase, 1 digit)
nameStringnullUser display name
phoneStringnullPhone number
Authorization
roleUserRoleUSERUser role (see enum below)
statusUserStatusACTIVEAccount status (see enum below)
permissionsJsonnullGranular per-app permissions object
User Lifecycle
createdViaUserCreatedViaSTANDARDCreation source (see enum below)
expiresAtDateTimenullExpiration date for TEMP users
expireDaysIntnullDays until expiration (for TEMP users)
lastLoginAtDateTimenullLast login timestamp
emailVerifiedBooleanfalseEmail verification status
Audit
createdAtDateTimenow()Creation timestamp
updatedAtDateTimeAutoLast update timestamp
+

Enums

+

UserRole

+

Role hierarchy (descending):

+
enum UserRole {
+  SUPER_ADMIN       // Full system access, can manage all users
+  INFLUENCE_ADMIN   // Manage influence module (campaigns, responses)
+  MAP_ADMIN         // Manage map module (locations, shifts, cuts)
+  USER              // Standard volunteer with assigned permissions
+  TEMP              // Temporary user (auto-expires, restricted access)
+}
+
+

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

+

UserStatus

+
enum UserStatus {
+  ACTIVE     // Normal active user
+  INACTIVE   // Manually deactivated (login blocked)
+  SUSPENDED  // Temporarily suspended (login blocked)
+  EXPIRED    // Auto-expired temp user (login blocked)
+}
+
+

UserCreatedVia

+
enum UserCreatedVia {
+  ADMIN                 // Created by admin in user management
+  PUBLIC_SHIFT_SIGNUP   // Auto-created via public shift signup
+  STANDARD              // Self-registered (if enabled)
+}
+
+

Relations (33 total)

+

Authentication: +- refreshTokens → RefreshToken[] (onDelete: Cascade)

+

Influence Module (6): +- campaignsCreated → Campaign[] (creator, onDelete: SetNull) +- campaignEmails → CampaignEmail[] (sender, onDelete: SetNull) +- responses → RepresentativeResponse[] (submitter, onDelete: SetNull) +- responseUpvotes → ResponseUpvote[] (onDelete: SetNull)

+

Map Module (8): +- locationsCreated → Location[] (creator, onDelete: SetNull) +- locationsUpdated → Location[] (updater, onDelete: SetNull) +- addressesCreated → Address[] (creator, onDelete: SetNull) +- addressesUpdated → Address[] (updater, onDelete: SetNull) +- locationEdits → LocationHistory[] (editor, onDelete: SetNull) +- cutsCreated → Cut[] (creator, onDelete: SetNull) +- shiftSignups → ShiftSignup[] (onDelete: SetNull)

+

Canvassing Module (4): +- canvassVisits → CanvassVisit[] (visitor, onDelete: Cascade) +- canvassSessions → CanvassSession[] (onDelete: Cascade) +- trackingSessions → TrackingSession[] (onDelete: Cascade)

+

Email Templates Module (4): +- templatesCreated → EmailTemplate[] (creator) +- templatesUpdated → EmailTemplate[] (updater) +- templateVersionsCreated → EmailTemplateVersion[] +- templateTestsSent → EmailTemplateTestLog[]

+

Indexes

+
    +
  • Unique: email (case-insensitive via Prisma transform)
  • +
+

Constraints

+
    +
  • Email must be unique across all users
  • +
  • Password must meet policy: 12+ chars, 1 uppercase, 1 lowercase, 1 digit (enforced by Zod schema)
  • +
  • TEMP users must have expiresAt set
  • +
  • EXPIRED status auto-applied when expiresAt < now()
  • +
+
+

RefreshToken Model

+

Purpose

+

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.

+

Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
tokenStringJWT refresh token string (unique, 512 chars)
userIdStringForeign key to User
expiresAtDateTimeToken expiration timestamp (7 days from issue)
createdAtDateTimenow()Token creation timestamp
+

Relations

+
    +
  • user → User (onDelete: Cascade) — deleting user deletes all refresh tokens
  • +
+

Indexes

+
    +
  • Unique: token (fast lookup for refresh endpoint)
  • +
  • Foreign Key: userId (join to User)
  • +
+

Constraints

+
    +
  • Token must be unique (prevents replay attacks)
  • +
  • ExpiresAt must be > now() for valid tokens
  • +
  • Expired tokens cleaned up via cron job (daily)
  • +
+
+

Relationships Diagram

+
erDiagram
+    User ||--o{ RefreshToken : has
+    User ||--o{ Campaign : creates
+    User ||--o{ CampaignEmail : sends
+    User ||--o{ RepresentativeResponse : submits
+    User ||--o{ ResponseUpvote : upvotes
+    User ||--o{ ShiftSignup : "signs up for"
+    User ||--o{ Location : creates
+    User ||--o{ Location : updates
+    User ||--o{ Address : "creates (addresses)"
+    User ||--o{ Address : "updates (addresses)"
+    User ||--o{ LocationHistory : edits
+    User ||--o{ Cut : "creates (cuts)"
+    User ||--o{ CanvassVisit : visits
+    User ||--o{ CanvassSession : "has (sessions)"
+    User ||--o{ TrackingSession : "tracks (gps)"
+    User ||--o{ EmailTemplate : "creates (templates)"
+    User ||--o{ EmailTemplate : "updates (templates)"
+    User ||--o{ EmailTemplateVersion : "versions (templates)"
+    User ||--o{ EmailTemplateTestLog : "tests (templates)"
+
+    User {
+        String id PK
+        String email UK "unique, lowercase"
+        String password "bcrypt hashed"
+        String name
+        String phone
+        UserRole role "SUPER_ADMIN | INFLUENCE_ADMIN | MAP_ADMIN | USER | TEMP"
+        UserStatus status "ACTIVE | INACTIVE | SUSPENDED | EXPIRED"
+        Json permissions "granular per-app"
+        UserCreatedVia createdVia
+        DateTime expiresAt "TEMP user expiration"
+        Int expireDays
+        DateTime lastLoginAt
+        Boolean emailVerified
+        DateTime createdAt
+        DateTime updatedAt
+    }
+
+    RefreshToken {
+        String id PK
+        String token UK
+        String userId FK
+        DateTime expiresAt
+        DateTime createdAt
+    }
+
+

Common Queries

+

Create User (Admin)

+
const user = await prisma.user.create({
+  data: {
+    email: 'volunteer@example.com',
+    password: await bcrypt.hash('SecurePass123!', 10),
+    name: 'Jane Volunteer',
+    phone: '555-0100',
+    role: UserRole.USER,
+    status: UserStatus.ACTIVE,
+    createdVia: UserCreatedVia.ADMIN,
+    emailVerified: true,
+  },
+});
+
+

Create Temp User (Public Shift Signup)

+
const tempUser = await prisma.user.create({
+  data: {
+    email: 'temp@example.com',
+    password: await bcrypt.hash(randomPassword, 10), // Generated password
+    name: 'Temp Volunteer',
+    role: UserRole.TEMP,
+    status: UserStatus.ACTIVE,
+    createdVia: UserCreatedVia.PUBLIC_SHIFT_SIGNUP,
+    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
+    expireDays: 30,
+  },
+});
+
+

Find User with Relations

+
const user = await prisma.user.findUnique({
+  where: { email: 'admin@example.com' },
+  include: {
+    campaignsCreated: { take: 5, orderBy: { createdAt: 'desc' } },
+    canvassSessions: { take: 10, orderBy: { startedAt: 'desc' } },
+    shiftSignups: { include: { shift: true } },
+  },
+});
+
+

Update Last Login

+
await prisma.user.update({
+  where: { id: userId },
+  data: { lastLoginAt: new Date() },
+});
+
+

Store Refresh Token

+
const refreshToken = await prisma.refreshToken.create({
+  data: {
+    token: jwtRefreshToken,
+    userId: user.id,
+    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
+  },
+});
+
+

Refresh Token Rotation (Atomic)

+
const newTokens = await prisma.$transaction(async (tx) => {
+  // 1. Verify old token exists and is valid
+  const oldToken = await tx.refreshToken.findUnique({
+    where: { token: oldRefreshToken },
+    include: { user: true },
+  });
+
+  if (!oldToken || oldToken.expiresAt < new Date()) {
+    throw new Error('Invalid or expired refresh token');
+  }
+
+  // 2. Delete old token
+  await tx.refreshToken.delete({
+    where: { id: oldToken.id },
+  });
+
+  // 3. Generate new access + refresh tokens
+  const newAccessToken = jwt.sign({ userId: oldToken.userId }, ACCESS_SECRET, { expiresIn: '15m' });
+  const newRefreshToken = jwt.sign({ userId: oldToken.userId }, REFRESH_SECRET, { expiresIn: '7d' });
+
+  // 4. Store new refresh token
+  await tx.refreshToken.create({
+    data: {
+      token: newRefreshToken,
+      userId: oldToken.userId,
+      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
+    },
+  });
+
+  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
+});
+
+

Expire Temp Users (Cron)

+
await prisma.user.updateMany({
+  where: {
+    role: UserRole.TEMP,
+    expiresAt: { lt: new Date() },
+    status: { not: UserStatus.EXPIRED },
+  },
+  data: {
+    status: UserStatus.EXPIRED,
+  },
+});
+
+

Clean Expired Refresh Tokens (Cron)

+
await prisma.refreshToken.deleteMany({
+  where: {
+    expiresAt: { lt: new Date() },
+  },
+});
+
+
+

Data Flow

+

User Registration Flow

+
sequenceDiagram
+    participant Client
+    participant API
+    participant Prisma
+    participant bcrypt
+    participant JWT
+
+    Client->>API: POST /api/auth/register (email, password, name)
+    API->>bcrypt: hash(password)
+    bcrypt-->>API: hashedPassword
+    API->>Prisma: user.create({ email, password: hashed, role: USER })
+    Prisma-->>API: user
+    API->>JWT: sign(accessToken, { userId, email, role })
+    API->>JWT: sign(refreshToken, { userId })
+    JWT-->>API: tokens
+    API->>Prisma: refreshToken.create({ token, userId, expiresAt })
+    Prisma-->>API: refreshToken
+    API-->>Client: { user, accessToken, refreshToken }
+

Login Flow

+
sequenceDiagram
+    participant Client
+    participant API
+    participant Prisma
+    participant bcrypt
+    participant JWT
+
+    Client->>API: POST /api/auth/login (email, password)
+    API->>Prisma: user.findUnique({ where: { email } })
+    Prisma-->>API: user | null
+    API->>bcrypt: compare(password, user.password)
+    bcrypt-->>API: isValid
+    alt Invalid credentials
+        API-->>Client: 401 Unauthorized
+    else Valid credentials
+        API->>Prisma: user.update({ lastLoginAt: now() })
+        API->>JWT: sign(accessToken, { userId, email, role })
+        API->>JWT: sign(refreshToken, { userId })
+        JWT-->>API: tokens
+        API->>Prisma: refreshToken.create({ token, userId, expiresAt })
+        Prisma-->>API: refreshToken
+        API-->>Client: { user, accessToken, refreshToken }
+    end
+

Token Refresh Flow

+
sequenceDiagram
+    participant Client
+    participant API
+    participant Prisma
+    participant JWT
+
+    Client->>API: POST /api/auth/refresh (refreshToken)
+    API->>JWT: verify(refreshToken)
+    JWT-->>API: payload | error
+    alt Invalid token
+        API-->>Client: 401 Unauthorized
+    else Valid token
+        API->>Prisma: $transaction start
+        API->>Prisma: refreshToken.findUnique({ where: { token } })
+        Prisma-->>API: oldToken | null
+        alt Token not found or expired
+            API->>Prisma: $transaction rollback
+            API-->>Client: 401 Unauthorized
+        else Token valid
+            API->>Prisma: refreshToken.delete({ where: { id: oldToken.id } })
+            API->>JWT: sign(newAccessToken, { userId, email, role })
+            API->>JWT: sign(newRefreshToken, { userId })
+            JWT-->>API: newTokens
+            API->>Prisma: refreshToken.create({ token: newRefreshToken, userId, expiresAt })
+            API->>Prisma: $transaction commit
+            Prisma-->>API: success
+            API-->>Client: { accessToken, refreshToken }
+        end
+    end
+
+

Performance Notes

+

Index Usage

+
    +
  • email unique index: Used for login lookups (WHERE email = ?)
  • +
  • refreshToken.token unique index: Used for refresh endpoint (WHERE token = ?)
  • +
  • refreshToken.userId index: Used for user deletion cascades
  • +
+

Query Optimization

+
    +
  • Avoid loading all 33 user relations by default — use selective include or select
  • +
  • Use findFirst instead of findMany().take(1) for single record queries
  • +
  • Paginate user lists with skip + take + cursor-based pagination for large datasets
  • +
+

N+1 Prevention

+
// ❌ N+1 query (loads campaigns one-by-one)
+const users = await prisma.user.findMany();
+for (const user of users) {
+  const campaigns = await prisma.campaign.findMany({ where: { createdByUserId: user.id } });
+}
+
+// ✅ Single query with include
+const users = await prisma.user.findMany({
+  include: {
+    campaignsCreated: true,
+  },
+});
+
+
+

Security Considerations

+

Password Policy

+

Enforced at API schema level (auth.schemas.ts): +

password: z.string()
+  .min(12, 'Password must be at least 12 characters')
+  .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
+  .regex(/[a-z]/, 'Password must contain at least one lowercase letter')
+  .regex(/[0-9]/, 'Password must contain at least one digit')
+

+

User Enumeration Prevention

+
    +
  • /api/auth/me returns 401 (not 404) for missing users
  • +
  • Login endpoint returns generic "Invalid credentials" (not "Email not found")
  • +
  • Registration endpoint returns generic "Email already exists" (no user details)
  • +
+

Refresh Token Security

+
    +
  • Tokens stored in database (not just signed JWTs)
  • +
  • Rotation on every refresh (old token deleted)
  • +
  • Atomic transaction prevents race conditions
  • +
  • 7-day expiration with daily cleanup cron
  • +
+

Role-Based Access Control

+

Middleware enforces role requirements: +

// Requires SUPER_ADMIN or MAP_ADMIN
+router.get('/api/locations', requireRole(UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN), ...)
+
+// Requires any non-TEMP user
+router.get('/api/campaigns', requireNonTemp, ...)
+
+// Requires any authenticated user
+router.get('/api/profile', authenticate, ...)
+

+

TEMP User Restrictions

+
    +
  • Cannot access admin routes (blocked by requireNonTemp middleware)
  • +
  • Cannot create campaigns, locations, or templates
  • +
  • Can only canvass within assigned cut (verified by canvass service)
  • +
  • Auto-expire after expireDays (default 30)
  • +
+
+

Troubleshooting

+

"Email already exists" on registration

+

Cause: Email uniqueness constraint violated +Solution: Check for existing user: prisma.user.findUnique({ where: { email } })

+

"Invalid refresh token" on refresh

+

Cause: Token already used (rotation), expired, or manually deleted +Solution: User must re-login to obtain new token pair

+

"Password does not meet policy" on update

+

Cause: Password validation regex mismatch +Solution: Ensure new password has 12+ chars, 1 uppercase, 1 lowercase, 1 digit

+

TEMP user cannot access route

+

Cause: Route uses requireNonTemp middleware +Solution: Upgrade user to USER role via admin panel

+

Circular dependency: auth store ↔ api client

+

Cause: Both modules import each other +Solution: Use callback registration pattern (see admin/src/lib/api.ts + admin/src/stores/auth.store.ts)

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/database/models/canvass/index.html b/mkdocs/site/v2/database/models/canvass/index.html new file mode 100644 index 00000000..2424d6c0 --- /dev/null +++ b/mkdocs/site/v2/database/models/canvass/index.html @@ -0,0 +1,5369 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Canvass Models - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Canvassing Models

+

Overview

+

The Canvassing module provides GPS-tracked volunteer canvassing with session management, visit recording, walking route algorithms, and automatic session abandonment.

+

Models (4): +- CanvassSession — Session lifecycle (ACTIVE → COMPLETED/ABANDONED) +- CanvassVisit — Visit recording with 7 outcome types +- TrackingSession — GPS tracking integration +- TrackPoint — GPS breadcrumb trail

+

Key Features: +- Session lifecycle management (ACTIVE → COMPLETED/ABANDONED) +- 7 visit outcomes (NOT_HOME, REFUSED, MOVED, ALREADY_VOTED, SPOKE_WITH, LEFT_LITERATURE, COME_BACK_LATER) +- Walking route algorithm (nearest-neighbor with haversine distance) +- GPS breadcrumb trail with event markers +- Support level tracking (1-4) +- Sign request tracking +- Session abandonment (12h timeout, auto-ABANDONED status) +- Distance calculation (meters)

+

See Schema Reference for complete field listings.

+
+

Session Lifecycle

+
stateDiagram-v2
+    [*] --> ACTIVE : Start session
+    ACTIVE --> COMPLETED : End session (user action)
+    ACTIVE --> ABANDONED : 12h timeout (cron)
+    COMPLETED --> [*]
+    ABANDONED --> [*]
+

Status: CanvassSessionStatus +- ACTIVE — Session in progress +- COMPLETED — Session ended by user +- ABANDONED — Session inactive > 12h (auto-expired by cron)

+
+

Visit Outcomes

+
enum VisitOutcome {
+  NOT_HOME          // No one home
+  REFUSED           // Refused to talk
+  MOVED             // Resident moved away
+  ALREADY_VOTED     // Already voted (early voting)
+  SPOKE_WITH        // Successful conversation
+  LEFT_LITERATURE   // Left campaign literature
+  COME_BACK_LATER   // Asked to come back later
+}
+
+

Support Level Mapping: +- Outcome: SPOKE_WITH → Record support level (1-4) +- Outcome: REFUSED → Support level defaults to null or 1 +- Outcome: NOT_HOME → No support level

+
+

Walking Route Algorithm

+

Algorithm: Nearest-neighbor with haversine distance calculation

+

Steps: +1. Get all unvisited addresses in cut +2. Start from session start coordinates (or cut centroid) +3. Find nearest unvisited address (haversine distance) +4. Add to route, mark as visited +5. Repeat from new position until all addresses visited

+

Implementation: api/src/modules/map/canvass/walking-route.service.ts

+
function calculateWalkingRoute(
+  addresses: Address[],
+  startLat: number,
+  startLng: number,
+  visitedAddressIds: string[]
+): WalkingRoute {
+  const unvisited = addresses.filter(a => !visitedAddressIds.includes(a.id));
+  const route: Address[] = [];
+  let currentLat = startLat;
+  let currentLng = startLng;
+
+  while (unvisited.length > 0) {
+    // Find nearest unvisited address
+    const nearest = findNearestAddress(currentLat, currentLng, unvisited);
+    route.push(nearest);
+    currentLat = nearest.location.latitude;
+    currentLng = nearest.location.longitude;
+    unvisited.splice(unvisited.indexOf(nearest), 1);
+  }
+
+  return {
+    addresses: route,
+    totalDistanceM: calculateTotalDistance(route),
+  };
+}
+
+
+

GPS Tracking

+

TrackingSession = One-to-one with CanvassSession +- Stores total points, distance, last position +- isActive flag for active tracking

+

TrackPoint = GPS breadcrumb +- Latitude, longitude, accuracy +- Event type markers (LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED)

+

Event Flow: +

sequenceDiagram
+    participant Volunteer
+    participant API
+    participant GPS
+
+    Volunteer->>API: POST /api/canvass/sessions (start session)
+    API-->>Volunteer: sessionId
+    loop Every 30 seconds
+        GPS->>API: POST /api/tracking/:sessionId/points (lat, lng)
+        API-->>GPS: 200 OK
+    end
+    Volunteer->>API: POST /api/canvass/visits (record visit)
+    API->>GPS: POST /api/tracking/:sessionId/points (eventType: VISIT_RECORDED)
+    Volunteer->>API: POST /api/canvass/sessions/:id/end
+    API->>GPS: POST /api/tracking/:sessionId/points (eventType: SESSION_ENDED)
+    API-->>Volunteer: session (status: COMPLETED)

+
+

Session Abandonment

+

Cron Job: Runs hourly via api/src/server.ts startup + interval

+
async function abandonStaleSessions() {
+  const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
+
+  await prisma.canvassSession.updateMany({
+    where: {
+      status: CanvassSessionStatus.ACTIVE,
+      startedAt: { lt: twelveHoursAgo },
+    },
+    data: {
+      status: CanvassSessionStatus.ABANDONED,
+      endedAt: new Date(),
+    },
+  });
+}
+
+

Trigger Conditions: +- Status = ACTIVE +- StartedAt < 12 hours ago +- No explicit end by user

+
+

Common Queries

+

Start Canvass Session

+
const session = await prisma.canvassSession.create({
+  data: {
+    userId: user.id,
+    cutId: cut.id,
+    shiftId: shift?.id,
+    status: CanvassSessionStatus.ACTIVE,
+    startLatitude: startLat,
+    startLongitude: startLng,
+    trackingSession: {
+      create: {
+        userId: user.id,
+        isActive: true,
+      },
+    },
+  },
+});
+
+

Record Visit

+
const visit = await prisma.canvassVisit.create({
+  data: {
+    addressId: address.id,
+    userId: user.id,
+    sessionId: session.id,
+    shiftId: shift?.id,
+    outcome: VisitOutcome.SPOKE_WITH,
+    supportLevel: SupportLevel.LEVEL_4,
+    signRequested: true,
+    signSize: 'Large',
+    notes: 'Very supportive, wants to volunteer',
+    durationSeconds: 180,
+  },
+});
+
+// Update address support level
+await prisma.address.update({
+  where: { id: address.id },
+  data: {
+    supportLevel: SupportLevel.LEVEL_4,
+    sign: true,
+    signSize: 'Large',
+    notes: 'Very supportive, wants to volunteer',
+    updatedByUserId: user.id,
+  },
+});
+
+

End Session

+
await prisma.canvassSession.update({
+  where: { id: sessionId },
+  data: {
+    status: CanvassSessionStatus.COMPLETED,
+    endedAt: new Date(),
+    trackingSession: {
+      update: {
+        isActive: false,
+        endedAt: new Date(),
+      },
+    },
+  },
+});
+
+

Get Walking Route

+
const session = await prisma.canvassSession.findUnique({
+  where: { id: sessionId },
+  include: {
+    visits: { include: { address: true } },
+  },
+});
+
+const visitedAddressIds = session.visits.map(v => v.addressId);
+
+const addresses = await prisma.address.findMany({
+  where: {
+    location: {
+      // Point-in-polygon check for cut
+      latitude: { gte: cutBounds.south, lte: cutBounds.north },
+      longitude: { gte: cutBounds.west, lte: cutBounds.east },
+    },
+  },
+  include: { location: true },
+});
+
+const route = calculateWalkingRoute(
+  addresses,
+  session.startLatitude,
+  session.startLongitude,
+  visitedAddressIds
+);
+
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/database/models/email-templates/index.html b/mkdocs/site/v2/database/models/email-templates/index.html new file mode 100644 index 00000000..df7ff40c --- /dev/null +++ b/mkdocs/site/v2/database/models/email-templates/index.html @@ -0,0 +1,5088 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Email Templates Models - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Email Template Models

+

Overview

+

The Email Template module provides a reusable template system with Handlebars-style variable interpolation, version history, and test email functionality.

+

Models (4): +- EmailTemplate — Template master with categories +- EmailTemplateVariable — Variable definitions +- EmailTemplateVersion — Version history +- EmailTemplateTestLog — Test email audit

+

Key Features: +- Handlebars-style {{VAR}} interpolation +- 3 categories: INFLUENCE, MAP, SYSTEM +- System template protection (isSystem flag prevents deletion) +- Version history with auto-increment version numbers +- Conditional variables for {{#if}} blocks +- Test email sending with sample data +- HTML + plain text content

+

See Schema Reference for complete field listings.

+
+

Template Categories

+
enum EmailTemplateCategory {
+  INFLUENCE  // Campaign emails, response verification
+  MAP        // Shift confirmations, reminders
+  SYSTEM     // Password resets, welcome emails
+}
+
+
+

Variable Interpolation

+

Syntax: Handlebars-style {{VARIABLE_NAME}}

+

Example Template: +

<p>Hello {{USER_NAME}},</p>
+<p>Thank you for signing up for the shift:</p>
+<ul>
+  <li>Title: {{SHIFT_TITLE}}</li>
+  <li>Date: {{SHIFT_DATE}}</li>
+  <li>Time: {{SHIFT_TIME}}</li>
+</ul>
+{{#if IS_NEW_USER}}
+<p>Your temporary password is: {{TEMP_PASSWORD}}</p>
+{{/if}}
+

+

Variable Record: +

{
+  key: 'USER_NAME',
+  label: 'User Name',
+  description: 'Name of the volunteer',
+  isRequired: true,
+  isConditional: false,
+  sampleValue: 'Jane Doe',
+  sortOrder: 0,
+}
+

+
+

Version History

+

Auto-Increment Version Numbers: +

const latestVersion = await prisma.emailTemplateVersion.findFirst({
+  where: { templateId },
+  orderBy: { versionNumber: 'desc' },
+});
+
+const newVersion = await prisma.emailTemplateVersion.create({
+  data: {
+    templateId,
+    versionNumber: (latestVersion?.versionNumber || 0) + 1,
+    subjectLine,
+    htmlContent,
+    textContent,
+    changeNotes: 'Updated call-to-action wording',
+    createdByUserId: user.id,
+  },
+});
+

+
+

System Templates (4 seeded)

+

1. campaign-email (INFLUENCE) +- Variables: CAMPAIGN_TITLE, MESSAGE, USER_NAME, USER_EMAIL, POSTAL_CODE, RECIPIENT_NAME, RECIPIENT_LEVEL, ORGANIZATION_NAME, TIMESTAMP +- Used by: Campaign email sending

+

2. response-verification (INFLUENCE) +- Variables: CAMPAIGN_TITLE, RESPONSE_TYPE, RESPONSE_TEXT, SUBMITTER_NAME, SUBMITTED_DATE, VERIFICATION_URL, REPORT_URL, ORGANIZATION_NAME, TIMESTAMP +- Used by: Response wall submission verification

+

3. shift-signup-confirmation (MAP) +- Variables: ORGANIZATION_NAME, USER_NAME, USER_EMAIL, SHIFT_TITLE, SHIFT_DATE, SHIFT_TIME, SHIFT_LOCATION, IS_NEW_USER, TEMP_PASSWORD, LOGIN_URL +- Used by: Public shift signup

+

4. shift-details (MAP) +- Variables: ORGANIZATION_NAME, USER_NAME, SHIFT_TITLE, SHIFT_DATE, SHIFT_START_TIME, SHIFT_END_TIME, SHIFT_LOCATION, SHIFT_DESCRIPTION, CURRENT_VOLUNTEERS, MAX_VOLUNTEERS, SHIFT_STATUS +- Used by: Shift reminder emails

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/database/models/index.html b/mkdocs/site/v2/database/models/index.html new file mode 100644 index 00000000..66f2d227 --- /dev/null +++ b/mkdocs/site/v2/database/models/index.html @@ -0,0 +1,5605 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Database Models - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Database Models

+

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).

+

Model Organization

+

Models are organized by feature area:

+

Authentication & Users

+

Core authentication and user management:

+
    +
  • User - User accounts with roles and authentication
  • +
  • RefreshToken - JWT refresh token tracking
  • +
  • Session - User session management (future)
  • +
+

Influence Module

+

Advocacy campaign models:

+
    +
  • Campaign - Campaign definitions and settings
  • +
  • CampaignEmail - Sent email tracking
  • +
  • Response - Public response wall submissions
  • +
  • PostalCodeCache - Representative lookup cache
  • +
+

Map Module

+

Location and geographic models:

+
    +
  • Location - Address database with geocoding
  • +
  • Cut - Geographic polygon organization
  • +
  • Shift - Volunteer shift scheduling
  • +
  • MapSettings - Map configuration singleton
  • +
+

Canvassing

+

Door-to-door canvassing models:

+
    +
  • CanvassSession - Canvassing session tracking
  • +
  • CanvassVisit - Visit outcome recording
  • +
  • TrackingSession - GPS tracking (future)
  • +
+

Content Management

+

Landing pages and content:

+
    +
  • Page - Landing page definitions
  • +
  • PageBlock - Reusable content blocks
  • +
+

Email Templates

+

Email template system:

+
    +
  • EmailTemplate - Template definitions
  • +
  • EmailTemplateVersion - Version history (future)
  • +
+

Media

+

Video library (Drizzle ORM):

+
    +
  • videos - Video metadata and files
  • +
  • shared_media - Public gallery assignments
  • +
  • media_reactions - Emoji reactions
  • +
  • media_jobs - Background job queue
  • +
+

Settings

+

Global configuration:

+
    +
  • Settings - Site-wide settings singleton
  • +
+

ORM Architecture

+

Prisma (Main API)

+

Used for 95% of models:

+
    +
  • Schema: api/prisma/schema.prisma
  • +
  • Migrations: api/prisma/migrations/
  • +
  • Client: Auto-generated TypeScript types
  • +
  • Database: PostgreSQL 16
  • +
+

Drizzle (Media API)

+

Used for media models only:

+
    +
  • Schema: api/src/modules/media/db/schema.ts
  • +
  • Migrations: None (push-based)
  • +
  • Client: Manual schema definition
  • +
  • Database: Same PostgreSQL 16
  • +
+

Common Patterns

+

Timestamps

+

Most models include:

+
createdAt DateTime @default(now())
+updatedAt DateTime @updatedAt
+
+

Foreign Keys

+

Relations use explicit foreign key fields:

+
model Campaign {
+  id              Int     @id @default(autoincrement())
+  createdByUserId Int
+  createdBy       User    @relation(fields: [createdByUserId], references: [id])
+}
+
+

JSON Fields

+

Flexible data stored as JSON:

+
model Campaign {
+  emailTemplate Json?
+  settings      Json?
+}
+
+

TypeScript types:

+
import { Prisma } from '@prisma/client';
+
+const template: Prisma.InputJsonValue = {
+  subject: 'Email subject',
+  body: 'Email body',
+};
+
+

Enums

+

Type-safe enumerations:

+
enum Role {
+  SUPER_ADMIN
+  INFLUENCE_ADMIN
+  MAP_ADMIN
+  USER
+  TEMP
+}
+
+enum VisitOutcome {
+  SUCCESS
+  NOT_HOME
+  MOVED
+  REFUSED
+  WRONG_ADDRESS
+  INACCESSIBLE
+  OTHER
+}
+
+

Model Count by Category

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CategoryModelsORM
Authentication3Prisma
Influence4Prisma
Map4Prisma
Canvassing3Prisma
Content2Prisma
Email Templates2Prisma
Media4Drizzle
Settings2Prisma
Total24Mixed
+

Database Operations

+

Migrations (Prisma)

+
# Create migration
+cd api && npx prisma migrate dev --name add_field
+
+# Deploy migrations
+cd api && npx prisma migrate deploy
+
+# Reset database (dev only)
+cd api && npx prisma migrate reset
+
+

Schema Push (Drizzle)

+
# Push schema changes (media API)
+cd api && npx drizzle-kit push
+
+

Database Browser

+

View data via:

+
    +
  • Prisma Studio: npx prisma studio
  • +
  • NocoDB: http://localhost:8091 (read-only)
  • +
+

Indexes

+

Key indexes for performance:

+
model Location {
+  @@index([cutId])
+  @@index([lastVisitedAt])
+}
+
+model Campaign {
+  @@index([published])
+  @@index([createdByUserId])
+}
+
+model CanvassSession {
+  @@index([userId])
+  @@index([status])
+}
+
+

Constraints

+

Unique Constraints

+
model User {
+  email String @unique
+}
+
+model Page {
+  slug String @unique
+}
+
+model Cut {
+  name String @unique
+}
+
+

Check Constraints

+

Enforced at application level:

+
    +
  • Email format validation
  • +
  • Password complexity (12+ chars)
  • +
  • Coordinate bounds (-90 to 90 lat, -180 to 180 lng)
  • +
+

Relations

+

One-to-Many

+
model User {
+  id        Int        @id @default(autoincrement())
+  campaigns Campaign[]
+}
+
+model Campaign {
+  id              Int  @id @default(autoincrement())
+  createdByUserId Int
+  createdBy       User @relation(fields: [createdByUserId], references: [id])
+}
+
+

Many-to-Many

+

Via junction tables:

+
model Shift {
+  id      Int            @id @default(autoincrement())
+  signups ShiftSignup[]
+}
+
+model User {
+  id      Int            @id @default(autoincrement())
+  signups ShiftSignup[]
+}
+
+model ShiftSignup {
+  id      Int   @id @default(autoincrement())
+  shiftId Int
+  userId  Int
+  shift   Shift @relation(fields: [shiftId], references: [id])
+  user    User  @relation(fields: [userId], references: [id])
+
+  @@unique([shiftId, userId])
+}
+
+

Seeding

+

Initial data in api/prisma/seed.ts:

+
    +
  • Admin user (admin@example.com)
  • +
  • Default settings
  • +
  • Sample page blocks
  • +
  • System email templates
  • +
+
# Run seed
+cd api && npx prisma db seed
+
+

Data Types

+

Common Types

+
    +
  • ID: Int @id @default(autoincrement())
  • +
  • String: String or String @db.Text (long text)
  • +
  • Number: Int or Float
  • +
  • Boolean: Boolean @default(false)
  • +
  • Date: DateTime @default(now())
  • +
  • JSON: Json or Json?
  • +
  • Enum: Role, VisitOutcome, etc.
  • +
+

Spatial Data

+

GeoJSON stored as JSON:

+
model Cut {
+  geometry Json  // GeoJSON Polygon
+}
+
+

Coordinates as separate fields:

+
model Location {
+  latitude  Float
+  longitude Float
+}
+
+

Database Configuration

+

Connection String

+
DATABASE_URL="postgresql://user:password@localhost:5432/changemaker_v2?schema=public"
+
+

Connection Pool

+

Prisma connection pool:

+
// api/src/server.ts
+const prisma = new PrismaClient({
+  log: ['error', 'warn'],
+});
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/database/models/influence/index.html b/mkdocs/site/v2/database/models/influence/index.html new file mode 100644 index 00000000..6ac4647a --- /dev/null +++ b/mkdocs/site/v2/database/models/influence/index.html @@ -0,0 +1,5456 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Influence Models - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Influence Models

+

Overview

+

The Influence module provides advocacy campaign management with multi-government-level targeting, email/call tracking, response wall with moderation, and representative caching.

+

Models (10): +- Campaign — Advocacy campaigns with 12 feature flags +- Representative — Cached rep data from Represent API +- CampaignEmail — Email tracking (SMTP vs MAILTO) +- RepresentativeResponse — Response wall submissions +- ResponseUpvote — Upvote tracking with deduplication +- CustomRecipient — Custom email targets +- PostalCodeCache — Postal code geocoding cache +- EmailLog — Email audit trail +- EmailVerification — Email verification tokens +- Call — Phone call tracking

+

Key Features: +- Multi-government-level targeting (Federal, Provincial, Municipal, School Board) +- Dual email methods: SMTP (async BullMQ queue) + mailto: links +- Response moderation workflow (PENDING → APPROVED/REJECTED) +- Email verification for response wall submissions +- Upvote deduplication (user ID + IP address) +- Represent API integration for Canadian representatives +- Postal code → representative lookup

+

See Schema Reference for complete field listings.

+
+

Campaign Feature Flags (12 total)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FlagDefaultDescription
allowSmtpEmailtrueEnable SMTP email sending via BullMQ
allowMailtoLinktrueEnable mailto: links for client-side email
collectUserInfotrueCollect sender name/email/postal code
showEmailCounttrueDisplay email sent count on public page
showCallCounttrueDisplay call made count on public page
allowEmailEditingfalseAllow users to edit email subject/body
allowCustomRecipientsfalseEnable custom recipient management
showResponseWallfalseEnable public response wall
highlightCampaignfalseHighlight on campaigns list page
+
+

Government Level Targeting

+
enum GovernmentLevel {
+  FEDERAL
+  PROVINCIAL
+  MUNICIPAL
+  SCHOOL_BOARD
+}
+
+

Campaigns can target multiple levels: +

const campaign = await prisma.campaign.create({
+  data: {
+    title: 'Support Climate Action',
+    targetGovernmentLevels: [GovernmentLevel.FEDERAL, GovernmentLevel.PROVINCIAL],
+    // ...
+  },
+});
+

+

Representative lookup filters by targeted levels: +

const reps = await representativeService.lookup(postalCode, campaign.targetGovernmentLevels);
+

+
+

Email Methods

+

SMTP (Async Queue)

+
    +
  • Queued via BullMQ (Redis backend)
  • +
  • Worker sends via Nodemailer
  • +
  • Supports templates with variable interpolation
  • +
  • Tracks delivery status (QUEUED → SENT/FAILED)
  • +
  • Rate limiting (10 emails/min per IP)
  • +
+

MAILTO (Client-Side)

+
    +
  • Generates mailto: link with pre-filled subject/body
  • +
  • Tracked when link clicked (status: CLICKED)
  • +
  • No server-side email sending
  • +
  • User's default email client used
  • +
+
+

Response Moderation Workflow

+
stateDiagram-v2
+    [*] --> PENDING : Submit response
+    PENDING --> APPROVED : Admin approves
+    PENDING --> REJECTED : Admin rejects
+    APPROVED --> [*]
+    REJECTED --> [*]
+

Status: PENDING (default) → APPROVED | REJECTED

+

Admin moderation via /app/influence/responses: +- Filter by status, campaign, date range +- Bulk approve/reject +- View submitter details +- Screenshot attachments

+
+

Upvote Deduplication

+

Two unique constraints prevent duplicate upvotes:

+
model ResponseUpvote {
+  @@unique([responseId, userId])      // Logged-in users
+  @@unique([responseId, upvotedIp])  // Guest users
+}
+
+

Logic: +- Logged-in user: Check [responseId, userId] +- Guest user: Check [responseId, upvotedIp] +- Database-level enforcement (no race conditions)

+
+

Represent API Integration

+

Representative Cache: +- Cached in representatives table +- TTL: 30 days (check cachedAt field) +- Re-fetched if cache miss or stale

+

Lookup Flow: +

sequenceDiagram
+    participant Client
+    participant API
+    participant Cache
+    participant Represent
+
+    Client->>API: GET /api/representatives/lookup?postalCode=K1A0B1
+    API->>Cache: findMany({ where: { postalCode } })
+    alt Cache hit (cachedAt < 30 days ago)
+        Cache-->>API: representatives[]
+        API-->>Client: representatives[]
+    else Cache miss or stale
+        API->>Represent: GET /representatives/?point=K1A0B1
+        Represent-->>API: representatives[]
+        API->>Cache: upsert({ postalCode, ... })
+        API-->>Client: representatives[]
+    end

+
+

Common Queries

+

Create Campaign

+
const campaign = await prisma.campaign.create({
+  data: {
+    slug: 'climate-action',
+    title: 'Support Climate Action Bill C-12',
+    emailSubject: 'Support Climate Action',
+    emailBody: 'I urge you to support...',
+    status: CampaignStatus.ACTIVE,
+    targetGovernmentLevels: [GovernmentLevel.FEDERAL],
+    allowSmtpEmail: true,
+    showResponseWall: true,
+    createdByUserId: user.id,
+  },
+});
+
+

Queue Campaign Email (SMTP)

+
await emailQueueService.addCampaignEmail({
+  campaignId: campaign.id,
+  recipientEmail: 'rep@example.com',
+  recipientName: 'Hon. Jane Smith',
+  subject: 'Support Climate Action',
+  message: 'I urge you to...',
+  userEmail: 'voter@example.com',
+  userName: 'John Voter',
+  userPostalCode: 'K1A0B1',
+});
+
+

Submit Response

+
const response = await prisma.representativeResponse.create({
+  data: {
+    campaignId: campaign.id,
+    campaignSlug: campaign.slug,
+    representativeName: 'Hon. Jane Smith',
+    representativeLevel: GovernmentLevel.FEDERAL,
+    responseType: ResponseType.EMAIL,
+    responseText: 'Thank you for your letter...',
+    submittedByUserId: user.id,
+    submittedByEmail: 'voter@example.com',
+    status: ResponseStatus.PENDING,
+  },
+});
+
+

Upvote Response

+
await prisma.responseUpvote.create({
+  data: {
+    responseId: response.id,
+    userId: user?.id,  // Null for guests
+    userEmail: user?.email,
+    upvotedIp: req.ip,
+  },
+});
+
+// Increment upvote count (denormalized)
+await prisma.representativeResponse.update({
+  where: { id: response.id },
+  data: { upvoteCount: { increment: 1 } },
+});
+
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/database/models/map/index.html b/mkdocs/site/v2/database/models/map/index.html new file mode 100644 index 00000000..8d3897c5 --- /dev/null +++ b/mkdocs/site/v2/database/models/map/index.html @@ -0,0 +1,5396 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Map Models - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Map Models

+

Overview

+

The Map module provides building-level and unit-level location management with multi-provider geocoding, volunteer shift scheduling, GeoJSON polygon cuts for map filtering, and comprehensive audit trails.

+

Models (7): +- Location — Building-level with lat/lng, NAR integration +- Address — Unit-level with support levels +- LocationHistory — Audit trail (7 action types) +- Shift — Volunteer shifts with cut relation +- ShiftSignup — Signup tracking +- Cut — GeoJSON polygon overlays +- MapSettings — Singleton configuration

+

Key Features: +- Building vs unit architecture (1 Location → many Addresses) +- Multi-provider geocoding (6 providers: Google, Mapbox, Nominatim, Photon, LocationIQ, ArcGIS) +- NAR 2025 Canadian electoral data import +- Spatial indexing (latitude/longitude composite index) +- GeoJSON polygon storage for cuts +- Walk sheet generation with QR codes +- CSV import/export

+

See Schema Reference for complete field listings.

+
+

Building vs Unit Architecture

+

Location = Building-level data: +- Single lat/lng coordinate +- Street address (no unit number) +- Building type (SINGLE_FAMILY, MULTI_UNIT, MIXED_USE, COMMERCIAL) +- Total units count +- Building notes (access codes, manager contact)

+

Address = Unit-level data: +- Unit number (apartment #, suite #) +- Occupant name/email/phone +- Support level (1-4) +- Sign request flag +- Canvassing notes

+

Relationship: Location ||--o{ Address (one-to-many)

+

Example: +

// 1 Location: 123 Main St (4-unit apartment building)
+const location = {
+  address: '123 Main St',
+  latitude: 53.5461,
+  longitude: -113.4938,
+  buildingType: 'MULTI_UNIT',
+  totalUnits: 4,
+};
+
+// 4 Addresses: Units 101-104
+const addresses = [
+  { locationId, unitNumber: '101', firstName: 'Alice', supportLevel: '4' },
+  { locationId, unitNumber: '102', firstName: 'Bob', supportLevel: '3' },
+  { locationId, unitNumber: '103', firstName: 'Carol', supportLevel: '2' },
+  { locationId, unitNumber: '104', firstName: 'Dave', supportLevel: '1' },
+];
+

+
+

Geocoding Providers

+
enum GeocodeProvider {
+  GOOGLE        // Google Maps Geocoding API
+  MAPBOX        // Mapbox Geocoding API
+  NOMINATIM     // OpenStreetMap Nominatim
+  PHOTON        // Photon (OSM-based)
+  LOCATIONIQ    // LocationIQ (OSM-based)
+  ARCGIS        // ArcGIS Geocoding Service
+  UNKNOWN       // Manually entered or unknown source
+}
+
+

Provider Priority: +1. Google (highest accuracy, paid) +2. Mapbox (high accuracy, paid) +3. ArcGIS (high accuracy, free tier) +4. Nominatim (medium accuracy, free) +5. Photon (medium accuracy, free) +6. LocationIQ (medium accuracy, free tier)

+

Confidence Score: 0-100 (stored in geocodeConfidence field)

+
+

NAR 2025 Import

+

NAR = National Address Register (Canadian electoral data)

+

Import Features: +- Streams large CSV files (no memory limit) +- Joins Location + Address files on LOC_GUID +- Converts BG_X/BG_Y (EPSG:3347 Lambert projection) → lat/lng +- Province selector (codes 10-62) +- City/postal/cut filters +- Residential-only toggle (buildingUse = 1)

+

New Location Fields: +- postalCode — Canadian postal code +- province — Province code (e.g., "AB") +- federalDistrict — Federal electoral district +- buildingUse — NAR BU_USE (1=Residential, 2=Partial, 3=Non-Residential, 4=Unknown) +- locGuid — NAR LOC_GUID (unique)

+

New Address Fields: +- addrGuid — NAR ADDR_GUID (unique)

+
+

LocationHistory Actions

+
enum LocationHistoryAction {
+  CREATED          // Location created
+  UPDATED          // Location updated
+  GEOCODED         // Single location geocoded
+  BULK_GEOCODED    // Batch geocode operation
+  MOVED_ON_MAP     // Dragged on admin map
+  IMPORTED_CSV     // CSV import
+  IMPORTED_NAR     // NAR import
+}
+
+

Audit Fields: +- field — Which field changed (e.g., "latitude") +- oldValue — Previous value +- newValue — New value +- metadata — JSON with provider, confidence, etc.

+
+

Cut GeoJSON Storage

+

Cut stores GeoJSON polygon coordinates:

+
{
+  "type": "Polygon",
+  "coordinates": [
+    [
+      [-113.5, 53.5],
+      [-113.4, 53.5],
+      [-113.4, 53.6],
+      [-113.5, 53.6],
+      [-113.5, 53.5]
+    ]
+  ]
+}
+
+

Bounds: Calculated bounding box for quick filtering: +

{
+  "north": 53.6,
+  "south": 53.5,
+  "east": -113.4,
+  "west": -113.5
+}
+

+
+

Shift Status Workflow

+
stateDiagram-v2
+    [*] --> OPEN : Create shift
+    OPEN --> FULL : currentVolunteers >= maxVolunteers
+    OPEN --> CANCELLED : Admin cancels
+    FULL --> OPEN : Volunteer cancels (currentVolunteers < maxVolunteers)
+    FULL --> CANCELLED : Admin cancels
+    CANCELLED --> [*]
+
+

Common Queries

+

Create Location with Geocoding

+
const location = await prisma.location.create({
+  data: {
+    address: '123 Main St, Edmonton, AB',
+    latitude: 53.5461,
+    longitude: -113.4938,
+    geocodeProvider: GeocodeProvider.GOOGLE,
+    geocodeConfidence: 95,
+    buildingType: BuildingType.SINGLE_FAMILY,
+    totalUnits: 1,
+    createdByUserId: user.id,
+    history: {
+      create: {
+        userId: user.id,
+        action: LocationHistoryAction.GEOCODED,
+        metadata: { provider: 'google', confidence: 95 },
+      },
+    },
+  },
+});
+
+

Find Locations in Bounding Box

+
const locations = await prisma.location.findMany({
+  where: {
+    latitude: { gte: 53.5, lte: 53.6 },
+    longitude: { gte: -113.5, lte: -113.4 },
+  },
+  include: { addresses: true },
+});
+
+

Create Shift with Cut

+
const shift = await prisma.shift.create({
+  data: {
+    title: 'Weekend Canvassing - Downtown',
+    date: new Date('2025-02-15'),
+    startTime: '10:00',
+    endTime: '14:00',
+    maxVolunteers: 10,
+    isPublic: true,
+    cutId: cut.id,  // Assign to cut
+  },
+});
+
+

Public Shift Signup (Creates TEMP User)

+
// 1. Create TEMP user with random password
+const tempPassword = generatePassword(); // "SwiftEagle42"
+const tempUser = await prisma.user.create({
+  data: {
+    email: 'volunteer@example.com',
+    password: await bcrypt.hash(tempPassword, 10),
+    name: 'Jane Volunteer',
+    role: UserRole.TEMP,
+    createdVia: UserCreatedVia.PUBLIC_SHIFT_SIGNUP,
+    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
+    expireDays: 30,
+  },
+});
+
+// 2. Create shift signup
+await prisma.shiftSignup.create({
+  data: {
+    shiftId: shift.id,
+    userId: tempUser.id,
+    userEmail: 'volunteer@example.com',
+    userName: 'Jane Volunteer',
+    signupSource: SignupSource.PUBLIC,
+  },
+});
+
+// 3. Send confirmation email with temp password
+await emailService.send({
+  template: 'shift-signup-confirmation',
+  variables: {
+    USER_NAME: 'Jane Volunteer',
+    SHIFT_TITLE: shift.title,
+    IS_NEW_USER: 'true',
+    TEMP_PASSWORD: tempPassword,
+    // ...
+  },
+});
+
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/database/models/media/index.html b/mkdocs/site/v2/database/models/media/index.html new file mode 100644 index 00000000..3399fe80 --- /dev/null +++ b/mkdocs/site/v2/database/models/media/index.html @@ -0,0 +1,5130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Media Models - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Media Models (Drizzle ORM)

+

Overview

+

The Media module uses Drizzle ORM (separate from Prisma) to manage video library, compilations, and job queue.

+

Models (3): +- videos — Video library with metadata +- compilations — Video compilation tracking +- jobs — Job queue with resource management

+

ORM: Drizzle (not Prisma) +API: Fastify (port 4100, separate from Express main API) +Migration: npx drizzle-kit push (no migration files)

+

Key Features: +- FFprobe metadata extraction (duration, dimensions, orientation, quality, audio) +- Directory type enum (9 types: studios, gifs, private, inbox, curated, playback, compilations, videos, highlights) +- Public media engagement stats (historical) +- Job queue with GPU/CPU resource categories +- Video upload with automatic metadata extraction

+

See Schema Reference for complete field listings.

+
+

Directory Types

+
export const DIRECTORY_TYPES = [
+  'studios',
+  'gifs',
+  'private',
+  'inbox',
+  'curated',
+  'playback',
+  'compilations',
+  'videos',
+  'highlights'
+] as const;
+
+

Usage: +- Efficient filtering (indexed) +- Replaces LIKE patterns (e.g., path LIKE '%/studios/%')

+
+

Video Metadata (FFprobe)

+

Extracted Fields: +- durationSeconds — Video duration in seconds +- width / height — Video dimensions (pixels) +- orientation — portrait, landscape, square +- quality — 1080p, 720p, 480p, etc. +- hasAudio — Audio track present flag

+

Extraction Service: api/src/modules/media/services/ffprobe.service.ts +Timeout: 30 seconds for metadata extraction +Validation: Decodes 5 frames with 60s timeout

+
+

Job Queue

+

Resource Categories: +

export type ResourceCategory = 'gpu_ai' | 'gpu_encode' | 'cpu';
+

+

Job Status: +

export type JobStatus = 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
+

+

Job Types (21 total): +- compilation, scan, public_scan, organize, organize_studio +- reencode_streaming, compile_random, compile_quad, compile_quad_horizontal +- compile_triple_vertical, compile_mega, compile_gif, generate_gif +- fetch, digest, digest_generate, clip_generate, highlight_generate +- tag_generation, scene_extract, clip_extract_only, auto_organize_publish

+

Queue Processing: +- Ordered by: status (pending first), priority (lower = higher), createdAt (FIFO) +- Uses composite index: [status, priority, createdAt]

+
+

Video Upload Flow

+
sequenceDiagram
+    participant Client
+    participant API
+    participant FFprobe
+    participant DB
+
+    Client->>API: POST /api/media/upload (multipart/form-data)
+    API->>API: Stream file to /media/local/inbox
+    API->>FFprobe: Extract metadata (duration, width, height, etc.)
+    FFprobe-->>API: metadata
+    API->>DB: INSERT INTO videos (path, filename, durationSeconds, ...)
+    DB-->>API: video record
+    API-->>Client: { id, path, metadata }
+

Volume Mount: /media/local/inbox:rw (read-write), library remains :ro

+
+

Drizzle Schema Example

+
export const videos = pgTable('videos', {
+  id: serial('id').primaryKey(),
+  path: text('path').notNull().unique(),
+  filename: text('filename').notNull(),
+  durationSeconds: integer('duration_seconds'),
+  quality: text('quality'),
+  orientation: text('orientation'),
+  hasAudio: boolean('has_audio').default(true),
+  width: integer('width'),
+  height: integer('height'),
+  directoryType: text('directory_type').$type<DirectoryType>(),
+  tags: jsonb('tags').$type<string[]>(),
+  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
+}, (table) => ({
+  directoryTypeIdx: index('idx_directory_type').on(table.directoryType),
+  fingerprintIdx: index('idx_videos_fingerprint').on(
+    table.durationSeconds,
+    table.fileSize,
+    table.width,
+    table.height
+  ),
+}));
+
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/database/models/pages/index.html b/mkdocs/site/v2/database/models/pages/index.html new file mode 100644 index 00000000..d3b5a277 --- /dev/null +++ b/mkdocs/site/v2/database/models/pages/index.html @@ -0,0 +1,5100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pages Models - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Landing Page Models

+

Overview

+

The Landing Page module provides a WYSIWYG page builder with GrapesJS editor integration, reusable block library, and MkDocs export functionality.

+

Models (2): +- LandingPage — GrapesJS editor output with MkDocs export +- PageBlock — Reusable block library

+

Key Features: +- GrapesJS WYSIWYG editor (desktop only) +- Visual + code editor modes +- MkDocs export (THEMED vs STANDALONE modes) +- SEO metadata (title, description, image) +- Reusable block library (6 default blocks) +- Jinja2 Material theme integration

+

See Schema Reference for complete field listings.

+
+

Editor Modes

+
enum EditorMode {
+  VISUAL  // GrapesJS visual editor (default)
+  CODE    // Raw HTML/CSS code editor
+}
+
+
+

MkDocs Export Modes

+
enum MkdocsExportMode {
+  THEMED      // Extends main.html, content block only (default)
+  STANDALONE  // Full HTML document, no Jinja2 inheritance
+}
+
+

THEMED Mode: +

{% extends "main.html" %}
+{% block content %}
+  <div class="landing-page">
+    <!-- Page HTML here -->
+  </div>
+{% endblock %}
+

+

STANDALONE Mode: +

<!DOCTYPE html>
+<html>
+<head>
+  <title>Page Title</title>
+  <!-- Full HTML document -->
+</head>
+<body>
+  <!-- Page HTML here -->
+</body>
+</html>
+

+
+

Page Blocks (6 default)

+

1. Hero Section (Headers) +- Schema: title, subtitle, backgroundImage, ctaText, ctaUrl +- Defaults: "Welcome to Our Campaign", "Get Involved"

+

2. Text Block (Content) +- Schema: heading, body +- Defaults: "About Us", "Tell your story here..."

+

3. Features Grid (Content) +- Schema: features[] (title, description, icon) +- Defaults: 3 features (Community Action, Advocacy, Volunteer)

+

4. Call to Action (Actions) +- Schema: heading, description, buttonText, buttonUrl +- Defaults: "Ready to Take Action?", "Join Now"

+

5. Testimonials (Content) +- Schema: quotes[] (text, author, role) +- Defaults: 2 quotes

+

6. Contact Form (Actions) +- Schema: heading, fields[] (name, type, required) +- Defaults: Name, Email, Message fields

+
+

GrapesJS JSON Format

+

blocks Field: +

{
+  "pages": [
+    {
+      "id": "page-main",
+      "component": {
+        "type": "wrapper",
+        "components": [
+          {
+            "tagName": "section",
+            "classes": ["hero"],
+            "components": [
+              {
+                "tagName": "h1",
+                "content": "Welcome to Our Campaign"
+              }
+            ]
+          }
+        ]
+      }
+    }
+  ]
+}
+

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/database/models/settings/index.html b/mkdocs/site/v2/database/models/settings/index.html new file mode 100644 index 00000000..01f6632c --- /dev/null +++ b/mkdocs/site/v2/database/models/settings/index.html @@ -0,0 +1,4998 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Settings Models - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Settings Models

+

Overview

+

The Settings module provides two singleton configuration models for global site settings and map-specific settings.

+

Models (2): +- SiteSettings — Org branding + theme + SMTP + feature toggles +- MapSettings — Map center/zoom + walk sheet config

+

Key Features: +- Singleton pattern (always ID "default") +- SMTP override hierarchy (SiteSettings → env vars) +- Feature flags (enableInfluence, enableMap, enableNewsletter, enableLandingPages) +- Theme color customization (admin + public) +- Walk sheet customization (title, subtitle, footer, QR codes)

+

See Schema Reference for complete field listings.

+
+

SiteSettings (Singleton)

+

ID: Always "default" (enforced by seed + UI)

+

Sections: +1. Organization — Name, logo, favicon +2. Admin Theme — Primary color, background color +3. Public Theme — Primary color, background color, container color, header gradient +4. Email Branding — From name, footer text, login subtitle +5. SMTP Configuration — Host, port, user, pass, from address, active provider, test mode +6. Feature Toggles — Enable/disable modules

+

SMTP Hierarchy: +- If SiteSettings.smtpHost is set → use SiteSettings SMTP +- Else → fallback to env vars (SMTP_HOST, SMTP_PORT, etc.)

+
+

MapSettings (Singleton)

+

ID: Always "default" (enforced by seed + UI)

+

Sections: +1. Map Center — Latitude, longitude, zoom (default: Edmonton, AB) +2. Walk Sheet — Title, subtitle, footer text +3. QR Codes — 3 QR code slots (URL + label each)

+

QR Code Usage: +- Rendered on printable walk sheets +- Typically links to volunteer portal, shift signup, campaign page +- Generated via Mini QR service (GET /api/qr?url=...)

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/database/schema/index.html b/mkdocs/site/v2/database/schema/index.html new file mode 100644 index 00000000..9d7dd216 --- /dev/null +++ b/mkdocs/site/v2/database/schema/index.html @@ -0,0 +1,9792 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Schema Overview - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Complete Schema Reference

+

This page provides a comprehensive listing of all 33 models across both Prisma and Drizzle ORMs.

+

Models Summary

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
GroupModelTable NameDescriptionORM
Auth & UsersUserusersUser accounts with role-based access controlPrisma
RefreshTokenrefresh_tokensJWT refresh token storagePrisma
InfluenceCampaigncampaignsAdvocacy campaigns with feature flagsPrisma
RepresentativerepresentativesCached representative data from Represent APIPrisma
CampaignEmailcampaign_emailsEmail tracking and delivery logsPrisma
RepresentativeResponserepresentative_responsesResponse wall submissions with moderationPrisma
ResponseUpvoteresponse_upvotesUpvote tracking with deduplicationPrisma
CustomRecipientcustom_recipientsCustom email targets for campaignsPrisma
PostalCodeCachepostal_code_cachePostal code geocoding cachePrisma
EmailLogemail_logsGlobal email audit trailPrisma
EmailVerificationemail_verificationsEmail verification tokensPrisma
CallcallsPhone call trackingPrisma
Map — LocationsLocationlocationsBuilding-level address data with geocodingPrisma
AddressaddressesUnit-level data with support levelsPrisma
LocationHistorylocation_historyAudit trail for location changesPrisma
Map — Shifts & CutsShiftshiftsVolunteer shifts with schedulingPrisma
ShiftSignupshift_signupsShift signup trackingPrisma
CutcutsGeoJSON polygon overlays for map filteringPrisma
MapSettingsmap_settingsSingleton for map configurationPrisma
CanvassingCanvassSessioncanvass_sessionsCanvassing session lifecyclePrisma
CanvassVisitcanvass_visitsVisit recording with outcomesPrisma
TrackingSessiontracking_sessionsGPS tracking sessionsPrisma
TrackPointtrack_pointsGPS breadcrumb trailPrisma
Email TemplatesEmailTemplateemail_templatesEmail template master recordsPrisma
EmailTemplateVariableemail_template_variablesTemplate variable definitionsPrisma
EmailTemplateVersionemail_template_versionsTemplate version historyPrisma
EmailTemplateTestLogemail_template_test_logsTest email audit logsPrisma
Landing PagesLandingPagelanding_pagesGrapesJS editor output with MkDocs exportPrisma
PageBlockpage_blocksReusable block libraryPrisma
Site SettingsSiteSettingssite_settingsGlobal site configuration singletonPrisma
MediavideosvideosVideo library with metadataDrizzle
compilationscompilationsVideo compilation trackingDrizzle
jobsjobsJob queue with resource managementDrizzle
+

Total: 33 models (30 Prisma + 3 Drizzle)

+
+

Auth & Users

+

User

+

Table: users +Description: User accounts with role-based access control, temporary user support, and audit tracking.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
emailStringUnique email address
passwordStringbcrypt hashed password (12+ chars policy)
nameStringnullUser display name
phoneStringnullPhone number
roleUserRoleUSERRole: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP
statusUserStatusACTIVEStatus: ACTIVE, INACTIVE, SUSPENDED, EXPIRED
permissionsJsonnullGranular per-app permissions object
createdViaUserCreatedViaSTANDARDCreation source: ADMIN, PUBLIC_SHIFT_SIGNUP, STANDARD
expiresAtDateTimenullExpiration date for TEMP users
expireDaysIntnullDays until expiration for TEMP users
lastLoginAtDateTimenullLast login timestamp
emailVerifiedBooleanfalseEmail verification status
createdAtDateTimenow()Creation timestamp
updatedAtDateTimeAutoLast update timestamp
+

Indexes: +- Unique: email

+

Relations (33 total): +- refreshTokens → RefreshToken[] +- campaignsCreated → Campaign[] +- campaignEmails → CampaignEmail[] +- responses → RepresentativeResponse[] +- responseUpvotes → ResponseUpvote[] +- shiftSignups → ShiftSignup[] +- locationsCreated → Location[] +- locationsUpdated → Location[] +- addressesCreated → Address[] +- addressesUpdated → Address[] +- locationEdits → LocationHistory[] +- cutsCreated → Cut[] +- canvassVisits → CanvassVisit[] +- canvassSessions → CanvassSession[] +- trackingSessions → TrackingSession[] +- templatesCreated → EmailTemplate[] +- templatesUpdated → EmailTemplate[] +- templateVersionsCreated → EmailTemplateVersion[] +- templateTestsSent → EmailTemplateTestLog[]

+

RefreshToken

+

Table: refresh_tokens +Description: JWT refresh token storage with expiration tracking.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
tokenStringJWT refresh token (unique)
userIdStringForeign key to User
expiresAtDateTimeToken expiration timestamp
createdAtDateTimenow()Creation timestamp
+

Indexes: +- Unique: token +- Foreign key: userId

+

Relations: +- user → User (onDelete: Cascade)

+
+

Influence

+

Campaign

+

Table: campaigns +Description: Advocacy campaigns with 12 feature flags and government-level targeting.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
slugStringURL-friendly slug (unique)
titleStringCampaign title
descriptionStringnullCampaign description (long text)
emailSubjectStringDefault email subject line
emailBodyStringDefault email body (long text)
callToActionStringnullCall-to-action text (long text)
coverPhotoStringnullCover photo URL
statusCampaignStatusDRAFTStatus: DRAFT, ACTIVE, PAUSED, ARCHIVED
allowSmtpEmailBooleantrueAllow SMTP email sending
allowMailtoLinkBooleantrueAllow mailto: links
collectUserInfoBooleantrueCollect user information
showEmailCountBooleantrueShow email sent count
showCallCountBooleantrueShow call made count
allowEmailEditingBooleanfalseAllow users to edit email content
allowCustomRecipientsBooleanfalseAllow custom email recipients
showResponseWallBooleanfalseShow public response wall
highlightCampaignBooleanfalseHighlight on campaign list page
targetGovernmentLevelsGovernmentLevel[][]Target levels: FEDERAL, PROVINCIAL, MUNICIPAL, SCHOOL_BOARD
createdByUserIdStringnullForeign key to User (creator)
createdByUserEmailStringnullCreator email (denormalized)
createdByUserNameStringnullCreator name (denormalized)
createdAtDateTimenow()Creation timestamp
updatedAtDateTimeAutoLast update timestamp
+

Indexes: +- Unique: slug

+

Relations: +- createdByUser → User (onDelete: SetNull) +- emails → CampaignEmail[] +- responses → RepresentativeResponse[] +- customRecipients → CustomRecipient[] +- calls → Call[]

+

Representative

+

Table: representatives +Description: Cached representative data from Represent API.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
postalCodeStringCanadian postal code (indexed)
nameStringnullRepresentative name
emailStringnullRepresentative email
districtNameStringnullElectoral district name
electedOfficeStringnullOffice title
partyNameStringnullPolitical party
representativeSetNameStringnullRepresentative set from Represent API
urlStringnullOfficial website URL
photoUrlStringnullPhoto URL
officesJsonnullArray of office contact info objects
cachedAtDateTimenow()Cache timestamp
+

Indexes: +- Non-unique: postalCode

+

Relations: None (standalone cache)

+

CampaignEmail

+

Table: campaign_emails +Description: Email tracking and delivery logs for campaign emails.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
campaignIdStringForeign key to Campaign
campaignSlugStringDenormalized campaign slug
userIdStringnullForeign key to User (sender)
userEmailStringnullSender email
userNameStringnullSender name
userPostalCodeStringnullSender postal code
recipientEmailStringRecipient email address
recipientNameStringnullRecipient name
recipientTitleStringnullRecipient title
recipientLevelGovernmentLevelnullGovernment level
emailMethodEmailMethodMethod: SMTP, MAILTO
subjectStringEmail subject line
messageStringEmail message body (long text)
statusCampaignEmailStatusSENTStatus: QUEUED, SENT, FAILED, CLICKED, USER_INFO_CAPTURED
senderIpStringnullSender IP address
sentAtDateTimenow()Send timestamp
+

Indexes: +- Foreign key: campaignId +- Non-unique: campaignSlug

+

Relations: +- campaign → Campaign (onDelete: Cascade) +- user → User (onDelete: SetNull)

+

RepresentativeResponse

+

Table: representative_responses +Description: Response wall submissions with moderation workflow.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
campaignIdStringForeign key to Campaign
campaignSlugStringDenormalized campaign slug
representativeNameStringRepresentative name
representativeTitleStringnullRepresentative title
representativeLevelGovernmentLevelGovernment level
representativeEmailStringnullRepresentative email
responseTypeResponseTypeType: EMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHER
responseTextStringResponse text (long text)
userCommentStringnullUser comment (long text)
screenshotUrlStringnullScreenshot URL
submittedByUserIdStringnullForeign key to User
submittedByNameStringnullSubmitter name
submittedByEmailStringnullSubmitter email
isAnonymousBooleanfalseAnonymous submission flag
statusResponseStatusPENDINGStatus: PENDING, APPROVED, REJECTED
isVerifiedBooleanfalseEmail verification status
verificationTokenStringnullVerification token
verificationSentAtDateTimenullVerification email timestamp
verifiedAtDateTimenullVerification timestamp
verifiedByStringnullEmail address that verified
upvoteCountInt0Upvote count (denormalized)
submittedIpStringnullSubmitter IP address
createdAtDateTimenow()Creation timestamp
updatedAtDateTimeAutoLast update timestamp
+

Indexes: +- Foreign key: campaignId +- Non-unique: campaignSlug

+

Relations: +- campaign → Campaign (onDelete: Cascade) +- submittedByUser → User (onDelete: SetNull) +- upvotes → ResponseUpvote[]

+

ResponseUpvote

+

Table: response_upvotes +Description: Upvote tracking with deduplication by user ID and IP address.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
responseIdStringForeign key to RepresentativeResponse
userIdStringnullForeign key to User
userEmailStringnullUser email (for guest upvotes)
upvotedIpStringnullUpvoter IP address
+

Indexes: +- Unique: [responseId, userId] (prevent duplicate upvotes from logged-in users) +- Unique: [responseId, upvotedIp] (prevent duplicate upvotes from same IP)

+

Relations: +- response → RepresentativeResponse (onDelete: Cascade) +- user → User (onDelete: SetNull)

+

CustomRecipient

+

Table: custom_recipients +Description: Custom email targets for campaigns (when allowCustomRecipients enabled).

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
campaignIdStringForeign key to Campaign
campaignSlugStringDenormalized campaign slug
recipientNameStringRecipient name
recipientEmailStringRecipient email address
recipientTitleStringnullRecipient title
recipientOrganizationStringnullRecipient organization
notesStringnullAdmin notes (long text)
isActiveBooleantrueActive status
createdAtDateTimenow()Creation timestamp
updatedAtDateTimeAutoLast update timestamp
+

Indexes: +- Foreign key: campaignId

+

Relations: +- campaign → Campaign (onDelete: Cascade)

+

PostalCodeCache

+

Table: postal_code_cache +Description: Postal code geocoding cache for centroid lookups.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
postalCodeStringCanadian postal code (unique)
cityStringnullCity name
provinceStringnullProvince code (e.g., "AB")
centroidLatDecimal(10,8)nullCentroid latitude
centroidLngDecimal(11,8)nullCentroid longitude
lastUpdatedDateTimenow()Last cache update
+

Indexes: +- Unique: postalCode

+

Relations: None (standalone cache)

+

EmailLog

+

Table: email_logs +Description: Global email audit trail (all email types).

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
recipientEmailStringRecipient email address
senderNameStringSender name
senderEmailStringSender email address
subjectStringnullEmail subject line
messageStringnullEmail message body (long text)
postalCodeStringnullSender postal code
statusString"sent"Status: sent, failed, previewed
senderIpStringnullSender IP address
sentAtDateTimenow()Send timestamp
+

Indexes: None

+

Relations: None (audit log only)

+

EmailVerification

+

Table: email_verifications +Description: Email verification tokens for response wall submissions.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
tokenStringVerification token (unique)
emailStringEmail address to verify
tempCampaignDataStringnullTemporary campaign data JSON (long text)
createdAtDateTimenow()Creation timestamp
expiresAtDateTimeToken expiration timestamp
usedBooleanfalseToken used flag
+

Indexes: +- Unique: token

+

Relations: None (standalone)

+

Call

+

Table: calls +Description: Phone call tracking for advocacy campaigns.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
representativeNameStringRepresentative name
representativeTitleStringnullRepresentative title
phoneNumberStringPhone number called
officeTypeStringnullOffice type (constituency, legislative, etc.)
callerNameStringnullCaller name
callerEmailStringnullCaller email
postalCodeStringnullCaller postal code
campaignIdStringnullForeign key to Campaign
campaignSlugStringnullDenormalized campaign slug
callerIpStringnullCaller IP address
calledAtDateTimenow()Call timestamp
+

Indexes: +- Foreign key: campaignId

+

Relations: +- campaign → Campaign (onDelete: SetNull)

+
+

Map — Locations

+

Location

+

Table: locations +Description: Building-level address data with geocoding and NAR integration.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
latitudeDecimal(10,8)Latitude coordinate (required)
longitudeDecimal(11,8)Longitude coordinate (required)
addressStringBase street address (no unit number)
postalCodeStringnullCanadian postal code
provinceStringnullProvince code (e.g., "AB")
federalDistrictStringnullFederal electoral district name
buildingUseIntnullNAR BU_USE: 1=Residential, 2=Partial, 3=Non-Residential, 4=Unknown
locGuidStringnullNAR LOC_GUID (unique)
buildingTypeBuildingTypeSINGLE_FAMILYType: SINGLE_FAMILY, MULTI_UNIT, MIXED_USE, COMMERCIAL
totalUnitsInt1Total units in building
buildingNotesStringnullAccess codes, manager contact (long text)
geocodeConfidenceIntnullGeocoding confidence (0-100)
geocodeProviderGeocodeProvidernullProvider: GOOGLE, MAPBOX, NOMINATIM, PHOTON, LOCATIONIQ, ARCGIS, UNKNOWN
createdByUserIdStringnullForeign key to User (creator)
updatedByUserIdStringnullForeign key to User (last updater)
createdAtDateTimenow()Creation timestamp
updatedAtDateTimeAutoLast update timestamp
+

Indexes: +- Unique: locGuid +- Composite: [latitude, longitude] (spatial queries) +- Non-unique: postalCode

+

Relations: +- createdByUser → User (onDelete: SetNull) +- updatedByUser → User (onDelete: SetNull) +- addresses → Address[] +- history → LocationHistory[]

+

Address

+

Table: addresses +Description: Unit-level data with support levels and canvassing information.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
locationIdStringForeign key to Location
unitNumberStringnullUnit/apartment number
addrGuidStringnullNAR ADDR_GUID (unique)
firstNameStringnullOccupant first name
lastNameStringnullOccupant last name
emailStringnullOccupant email
phoneStringnullOccupant phone
supportLevelSupportLevelnullSupport level: 1, 2, 3, 4
signBooleanfalseSign requested flag
signSizeStringnullSign size
notesStringnullCanvassing notes (long text)
createdByUserIdStringnullForeign key to User (creator)
updatedByUserIdStringnullForeign key to User (last updater)
createdAtDateTimenow()Creation timestamp
updatedAtDateTimeAutoLast update timestamp
+

Indexes: +- Unique: addrGuid +- Foreign key: locationId +- Composite: [locationId, unitNumber] (unit lookups)

+

Relations: +- location → Location (onDelete: Cascade) +- createdByUser → User (onDelete: SetNull) +- updatedByUser → User (onDelete: SetNull) +- canvassVisits → CanvassVisit[]

+

LocationHistory

+

Table: location_history +Description: Audit trail for location changes with action types.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
locationIdStringForeign key to Location
userIdStringnullForeign key to User
actionLocationHistoryActionAction: CREATED, UPDATED, GEOCODED, BULK_GEOCODED, MOVED_ON_MAP, IMPORTED_CSV, IMPORTED_NAR
fieldStringnullField name that changed
oldValueStringnullOld value (long text)
newValueStringnullNew value (long text)
metadataJsonnullProvider, confidence, etc.
createdAtDateTimenow()Timestamp
+

Indexes: +- Foreign key: locationId +- Foreign key: userId +- Non-unique: createdAt (temporal queries)

+

Relations: +- location → Location (onDelete: Cascade) +- user → User (onDelete: SetNull)

+
+

Map — Shifts & Cuts

+

Shift

+

Table: shifts +Description: Volunteer shifts with scheduling and capacity tracking.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
titleStringShift title
descriptionStringnullShift description (long text)
dateDateTimeShift date (date only, no time)
startTimeStringStart time (HH:MM format)
endTimeStringEnd time (HH:MM format)
locationStringnullShift location description
maxVolunteersIntMaximum volunteer capacity
currentVolunteersInt0Current signup count
statusShiftStatusOPENStatus: OPEN, FULL, CANCELLED
isPublicBooleanfalsePublic signup allowed
cutIdStringnullForeign key to Cut
createdByStringnullCreator user ID (string, not FK)
createdAtDateTimenow()Creation timestamp
updatedAtDateTimeAutoLast update timestamp
+

Indexes: +- Foreign key: cutId

+

Relations: +- cut → Cut (onDelete: SetNull) +- signups → ShiftSignup[] +- canvassVisits → CanvassVisit[] +- canvassSessions → CanvassSession[]

+

ShiftSignup

+

Table: shift_signups +Description: Shift signup tracking with source attribution.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
shiftIdStringForeign key to Shift
shiftTitleStringnullDenormalized shift title
userIdStringnullForeign key to User
userEmailStringUser email (for guest signups)
userNameStringnullUser name
userPhoneStringnullUser phone
signupDateDateTimenow()Signup timestamp
statusSignupStatusCONFIRMEDStatus: CONFIRMED, CANCELLED
signupSourceSignupSourceAUTHENTICATEDSource: AUTHENTICATED, PUBLIC, ADMIN
+

Indexes: +- Unique: [shiftId, userEmail] (prevent duplicate signups) +- Foreign key: shiftId

+

Relations: +- shift → Shift (onDelete: Cascade) +- user → User (onDelete: SetNull)

+

Cut

+

Table: cuts +Description: GeoJSON polygon overlays for map filtering and canvassing.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
nameStringCut name
descriptionStringnullCut description (long text)
colorString"#3388ff"Polygon fill color (hex)
opacityDecimal(3,2)0.3Polygon opacity (0.00-1.00)
categoryCutCategorynullCategory: CUSTOM, WARD, NEIGHBORHOOD, DISTRICT
isPublicBooleanfalsePublic visibility flag
isOfficialBooleanfalseOfficial boundary flag
geojsonStringGeoJSON polygon data (long text)
boundsStringnullBounding box JSON (long text)
showLocationsBooleantrueShow locations on map
exportEnabledBooleantrueExport enabled flag
assignedToStringnullAssigned user ID (string, not FK)
filterSettingsJsonnullFilter configuration object
lastCanvassedDateTimenullLast canvass timestamp
completionPercentageInt0Canvass completion percentage
createdByUserIdStringnullForeign key to User (creator)
createdAtDateTimenow()Creation timestamp
updatedAtDateTimeAutoLast update timestamp
+

Indexes: None

+

Relations: +- createdByUser → User (onDelete: SetNull) +- shifts → Shift[] +- canvassSessions → CanvassSession[]

+

MapSettings

+

Table: map_settings +Description: Singleton for map center/zoom and walk sheet configuration.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key (always "default")
latitudeDecimal(10,8)nullMap center latitude
longitudeDecimal(11,8)nullMap center longitude
zoomIntnullDefault map zoom level
walkSheetTitleStringnullWalk sheet header title
walkSheetSubtitleStringnullWalk sheet header subtitle
walkSheetFooterStringnullWalk sheet footer text (long text)
qrCode1UrlStringnullQR code 1 URL
qrCode1LabelStringnullQR code 1 label
qrCode2UrlStringnullQR code 2 URL
qrCode2LabelStringnullQR code 2 label
qrCode3UrlStringnullQR code 3 URL
qrCode3LabelStringnullQR code 3 label
createdByStringnullCreator user ID (string, not FK)
createdAtDateTimenow()Creation timestamp
updatedAtDateTimeAutoLast update timestamp
+

Indexes: None

+

Relations: None (singleton)

+
+

Canvassing

+

CanvassSession

+

Table: canvass_sessions +Description: Canvassing session lifecycle with status tracking.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
userIdStringForeign key to User
cutIdStringForeign key to Cut
shiftIdStringnullForeign key to Shift
statusCanvassSessionStatusACTIVEStatus: ACTIVE, COMPLETED, ABANDONED
startedAtDateTimenow()Session start timestamp
endedAtDateTimenullSession end timestamp
startLatitudeDecimal(10,8)nullStarting latitude
startLongitudeDecimal(11,8)nullStarting longitude
+

Indexes: +- Foreign key: userId +- Foreign key: cutId +- Foreign key: shiftId

+

Relations: +- user → User (onDelete: Cascade) +- cut → Cut (onDelete: Cascade) +- shift → Shift (onDelete: SetNull) +- visits → CanvassVisit[] +- trackingSession → TrackingSession (one-to-one)

+

CanvassVisit

+

Table: canvass_visits +Description: Visit recording with outcome tracking.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
addressIdStringForeign key to Address
userIdStringForeign key to User
shiftIdStringnullForeign key to Shift
sessionIdStringnullForeign key to CanvassSession
outcomeVisitOutcomeOutcome: NOT_HOME, REFUSED, MOVED, ALREADY_VOTED, SPOKE_WITH, LEFT_LITERATURE, COME_BACK_LATER
supportLevelSupportLevelnullSupport level: 1, 2, 3, 4
signRequestedBooleanfalseSign requested flag
signSizeStringnullSign size
notesStringnullVisit notes (long text)
durationSecondsIntnullVisit duration in seconds
visitedAtDateTimenow()Visit timestamp
+

Indexes: +- Foreign key: addressId +- Foreign key: userId +- Foreign key: shiftId +- Foreign key: sessionId +- Non-unique: visitedAt (temporal queries)

+

Relations: +- address → Address (onDelete: Cascade) +- user → User (onDelete: Cascade) +- shift → Shift (onDelete: SetNull) +- session → CanvassSession (onDelete: SetNull)

+

TrackingSession

+

Table: tracking_sessions +Description: GPS tracking sessions with distance calculation.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
userIdStringForeign key to User
canvassSessionIdStringnullForeign key to CanvassSession (unique, one-to-one)
startedAtDateTimenow()Tracking start timestamp
endedAtDateTimenullTracking end timestamp
isActiveBooleantrueActive tracking flag
totalPointsInt0Total GPS points recorded
totalDistanceMFloat0Total distance in meters
lastLatitudeDecimal(10,8)nullLast recorded latitude
lastLongitudeDecimal(11,8)nullLast recorded longitude
lastRecordedAtDateTimenullLast GPS point timestamp
+

Indexes: +- Unique: canvassSessionId +- Foreign key: userId +- Non-unique: isActive +- Composite: [isActive, lastRecordedAt] (cleanup queries)

+

Relations: +- user → User (onDelete: Cascade) +- canvassSession → CanvassSession (onDelete: SetNull) +- trackPoints → TrackPoint[]

+

TrackPoint

+

Table: track_points +Description: GPS breadcrumb trail with event types.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
trackingSessionIdStringForeign key to TrackingSession
latitudeDecimal(10,8)GPS latitude
longitudeDecimal(11,8)GPS longitude
accuracyFloatnullGPS accuracy in meters
recordedAtDateTimenow()GPS point timestamp
eventTypeTrackPointEventnullEvent: LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED
+

Indexes: +- Composite: [trackingSessionId, recordedAt] (temporal queries) +- Non-unique: recordedAt

+

Relations: +- trackingSession → TrackingSession (onDelete: Cascade)

+
+

Email Templates

+

EmailTemplate

+

Table: email_templates +Description: Email template master records with category organization.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
keyStringTemplate key (unique, e.g., "campaign-email")
nameStringDisplay name
descriptionStringnullTemplate description (long text)
categoryEmailTemplateCategoryCategory: INFLUENCE, MAP, SYSTEM
subjectLineStringSubject line with {{VAR}} support
htmlContentStringHTML template (long text)
textContentStringPlain text template (long text)
isSystemBooleanfalseSystem template (prevent deletion)
isActiveBooleantrueActive status
createdByUserIdStringForeign key to User (creator)
updatedByUserIdStringnullForeign key to User (last updater)
createdAtDateTimenow()Creation timestamp
updatedAtDateTimeAutoLast update timestamp
+

Indexes: +- Unique: key +- Non-unique: category +- Non-unique: isActive

+

Relations: +- createdBy → User +- updatedBy → User +- variables → EmailTemplateVariable[] +- versions → EmailTemplateVersion[] +- testLogs → EmailTemplateTestLog[]

+

EmailTemplateVariable

+

Table: email_template_variables +Description: Template variable definitions with validation.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
templateIdStringForeign key to EmailTemplate
keyStringVariable key (e.g., "USER_NAME")
labelStringVariable label (e.g., "User Name")
descriptionStringnullVariable description (long text)
isRequiredBooleantrueRequired flag
isConditionalBooleanfalseConditional variable (used in {{#if}})
sampleValueStringnullSample value for testing (long text)
sortOrderInt0Display order
+

Indexes: +- Unique: [templateId, key] (unique variable keys per template) +- Foreign key: templateId

+

Relations: +- template → EmailTemplate (onDelete: Cascade)

+

EmailTemplateVersion

+

Table: email_template_versions +Description: Template version history with auto-increment version numbers.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
templateIdStringForeign key to EmailTemplate
versionNumberIntAuto-increment version number per template
subjectLineStringSubject line snapshot
htmlContentStringHTML content snapshot (long text)
textContentStringPlain text snapshot (long text)
changeNotesStringnullVersion notes (long text)
createdByUserIdStringForeign key to User
createdAtDateTimenow()Version timestamp
+

Indexes: +- Unique: [templateId, versionNumber] (sequential version numbers) +- Composite: [templateId, createdAt(sort: Desc)] (recent versions)

+

Relations: +- template → EmailTemplate (onDelete: Cascade) +- createdBy → User

+

EmailTemplateTestLog

+

Table: email_template_test_logs +Description: Test email audit logs.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
templateIdStringForeign key to EmailTemplate
recipientEmailStringTest recipient email
testDataJsonSample variable values JSON
successBooleanTest success flag
errorMessageStringnullError message (long text)
messageIdStringnullNodemailer message ID
sentByUserIdStringForeign key to User
sentAtDateTimenow()Send timestamp
+

Indexes: +- Composite: [templateId, sentAt(sort: Desc)] (recent tests)

+

Relations: +- template → EmailTemplate (onDelete: Cascade) +- sentBy → User

+
+

Landing Pages

+

LandingPage

+

Table: landing_pages +Description: GrapesJS editor output with MkDocs export support.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
slugStringURL slug (unique)
titleStringPage title
descriptionStringnullPage description (long text)
blocksJsonGrapesJS editor JSON
htmlOutputStringnullRendered HTML (long text)
cssOutputStringnullRendered CSS (long text)
editorModeEditorModeVISUALEditor mode: VISUAL, CODE
mkdocsPathStringnullPath in mkdocs/overrides/
mkdocsStubPathStringnullPath to .md stub in mkdocs/docs/
mkdocsExportModeMkdocsExportModeTHEMEDExport mode: THEMED, STANDALONE
mkdocsHideNavBooleantrueHide navigation in MkDocs
mkdocsHideTocBooleantrueHide table of contents in MkDocs
mkdocsSkipExportBooleanfalseSkip MkDocs export flag
publishedBooleanfalsePublished status
seoTitleStringnullSEO title override
seoDescriptionStringnullSEO description (long text)
seoImageStringnullSEO image URL
createdAtDateTimenow()Creation timestamp
updatedAtDateTimeAutoLast update timestamp
+

Indexes: +- Unique: slug

+

Relations: None

+

PageBlock

+

Table: page_blocks +Description: Reusable block library for GrapesJS editor.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key
typeStringBlock type (hero, text, image, cta, features, testimonials, form)
labelStringBlock label
schemaJsonBlock configuration schema JSON
defaultsJsonDefault values JSON
thumbnailStringnullThumbnail URL
categoryStringnullBlock category
sortOrderInt0Display order
createdAtDateTimenow()Creation timestamp
updatedAtDateTimeAutoLast update timestamp
+

Indexes: None

+

Relations: None

+
+

Site Settings

+

SiteSettings

+

Table: site_settings +Description: Global site configuration singleton for branding, theme, SMTP, and feature toggles.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idStringcuid()Primary key (always "default")
organizationNameString"Changemaker Lite"Organization name
organizationShortNameString"CML"Short name/acronym
organizationLogoUrlStringnullLogo URL
organizationFaviconUrlStringnullFavicon URL
adminColorPrimaryString"#9d4edd"Admin primary color (hex)
adminColorBgBaseString"#1a1025"Admin background color (hex)
publicColorPrimaryString"#3498db"Public primary color (hex)
publicColorBgBaseString"#0d1b2a"Public background color (hex)
publicColorBgContainerString"#1b2838"Public container color (hex)
publicHeaderGradientString"linear-gradient(135deg, #005a9c 0%, #007acc 100%)"Public header gradient (CSS)
footerTextString"Powered by Changemaker Lite"Footer text
loginSubtitleString"Admin"Login page subtitle
emailFromNameString"Changemaker Lite"Email from name
smtpHostString""SMTP host (empty = use env)
smtpPortInt0SMTP port (0 = use env)
smtpUserString""SMTP username (empty = use env)
smtpPassString""SMTP password (empty = use env)
smtpFromAddressString""SMTP from address (empty = use env)
smtpActiveProviderString"mailhog"Active provider: "mailhog", "production"
emailTestModeBooleantrueEmail test mode flag
testEmailRecipientString""Test email recipient
enableInfluenceBooleantrueEnable Influence module
enableMapBooleantrueEnable Map module
enableNewsletterBooleantrueEnable Newsletter module
enableLandingPagesBooleantrueEnable Landing Pages module
createdAtDateTimenow()Creation timestamp
updatedAtDateTimeAutoLast update timestamp
+

Indexes: None

+

Relations: None (singleton)

+
+

Media (Drizzle ORM)

+

videos

+

Table: videos +Description: Video library with metadata extraction and engagement tracking.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idserialAutoPrimary key (auto-increment)
pathtextFile path (unique)
filenametextFile name
producertextnullProducer name
creatortextnullCreator name
titletextnullVideo title
durationSecondsintegernullDuration in seconds (FFprobe)
qualitytextnullQuality string (e.g., "1080p")
orientationtextnullOrientation: portrait, landscape, square
hasAudiobooleantrueAudio track present flag
fileSizebigintnullFile size in bytes
fileHashtextnullMD5 hash
widthintegernullVideo width (FFprobe)
heightintegernullVideo height (FFprobe)
lastValidatedtimestampnullLast validation timestamp
isValidbooleantrueValid file flag
thumbnailPathtextnullThumbnail file path
createdAttimestampnow()Creation timestamp
tagsjsonbnullArray of tag strings
directoryTypetextnullDirectory type: studios, gifs, private, inbox, curated, playback, compilations, videos, highlights
publicViewCountintegernullPublic view count (historical)
publicUpvoteCountintegernullPublic upvote count (historical)
publicCommentCountintegernullPublic comment count (historical)
publicCompletionCountintegernullPublic completion count (historical)
publicTotalWatchTimeintegernullPublic total watch time (historical)
movedFromPublicAttimestampnullTimestamp when moved from public media
originalFilenametextnullOriginal filename before standardization
originalPathtextnullOriginal path before standardization
standardizedAttimestampnullStandardization timestamp
+

Indexes: +- Unique: path +- Non-unique: orientation +- Non-unique: producer +- Non-unique: isValid +- Non-unique: directoryType +- Composite: [durationSeconds, fileSize, width, height] (fingerprint) +- Composite: [directoryType, isValid, orientation] (common filtering)

+

Relations: None (standalone)

+

compilations

+

Table: compilations +Description: Video compilation tracking.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idserialAutoPrimary key (auto-increment)
filenametextCompilation filename
pathtextnullCompilation file path
durationSecondsintegernullTotal duration in seconds
videoIdsjsonbnullArray of video IDs included
settingsjsonbnullCompilation settings object
createdAttimestampnow()Creation timestamp
+

Indexes: None

+

Relations: None (video IDs stored as JSON array)

+

jobs

+

Table: jobs +Description: Job queue with resource category management.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDefaultDescription
idserialAutoPrimary key (auto-increment)
typetextJob type (compilation, scan, organize, etc.)
statustext"pending"Status: pending, queued, running, completed, failed, cancelled
progressinteger0Progress percentage (0-100)
logtextnullJob log output
paramsjsonbnullJob parameters object
startedAttimestampnullJob start timestamp
completedAttimestampnullJob completion timestamp
createdAttimestampnow()Creation timestamp
resourceCategorytext"cpu"Resource category: gpu_ai, gpu_encode, cpu
vramRequiredinteger0VRAM required in MB
queuePositionintegernullQueue position
waitingReasontextnullReason for waiting
priorityinteger5Job priority (lower = higher priority)
pipelineIdintegernullPipeline ID (for pipeline jobs)
pipelineStepIdintegernullPipeline step ID
+

Indexes: +- Composite: [status, priority, createdAt] (queue processing) +- Composite: [resourceCategory, status] (resource filtering) +- Non-unique: pipelineId

+

Relations: None (pipeline relations are external)

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/database/seeding/index.html b/mkdocs/site/v2/database/seeding/index.html new file mode 100644 index 00000000..4eb24cd5 --- /dev/null +++ b/mkdocs/site/v2/database/seeding/index.html @@ -0,0 +1,6034 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Seeding - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Database Seeding

+

Overview

+

The database seeding process populates initial data required for the application to function. Seeding runs automatically after migrations in development but must be run manually in production.

+

Seed Script: api/prisma/seed.ts

+

Seed Data: +- Default super admin user +- Default map settings (Edmonton coordinates) +- 6 page blocks for landing page builder +- 4 email templates (campaign email, response verification, shift signup confirmation, shift details reminder)

+
+

Running Seed

+

Development

+
cd api
+npm run seed
+# OR
+npx prisma db seed
+
+

Production (Docker)

+
docker compose exec api npx prisma db seed
+
+

CI/CD

+

Seed runs automatically after prisma migrate deploy if configured in package.json: +

{
+  "prisma": {
+    "seed": "ts-node prisma/seed.ts"
+  }
+}
+

+
+

Seed Data Details

+

1. Default Admin User

+

Email: admin@cmlite.org +Password: ChangeMe2025! +Role: SUPER_ADMIN +Status: ACTIVE +Email Verified: true

+

Code: +

const hashedPassword = await bcrypt.hash('ChangeMe2025!', 10);
+
+const admin = await prisma.user.upsert({
+  where: { email: 'admin@cmlite.org' },
+  update: {
+    password: hashedPassword,
+    emailVerified: true,
+    status: 'ACTIVE',
+  },
+  create: {
+    email: 'admin@cmlite.org',
+    password: hashedPassword,
+    name: 'Admin',
+    role: UserRole.SUPER_ADMIN,
+    emailVerified: true,
+  },
+});
+

+

Security Note: Change default password immediately after first login!

+

2. Default Map Settings

+

ID: default (singleton) +Coordinates: Edmonton, AB (53.5461°N, 113.4938°W) +Zoom: 11 +Walk Sheet: Blank titles/footers

+

Code: +

await prisma.mapSettings.upsert({
+  where: { id: 'default' },
+  update: {},
+  create: {
+    id: 'default',
+    latitude: 53.5461,
+    longitude: -113.4938,
+    zoom: 11,
+    walkSheetTitle: 'Walk Sheet',
+    walkSheetSubtitle: '',
+    walkSheetFooter: '',
+  },
+});
+

+

3. Page Blocks (6 blocks)

+

Hero Section

+
{
+  id: 'default-hero',
+  type: 'hero',
+  label: 'Hero Section',
+  category: 'Headers',
+  sortOrder: 1,
+  schema: {
+    title: { type: 'string', label: 'Title' },
+    subtitle: { type: 'string', label: 'Subtitle' },
+    backgroundImage: { type: 'string', label: 'Background Image URL' },
+    ctaText: { type: 'string', label: 'Button Text' },
+    ctaUrl: { type: 'string', label: 'Button URL' },
+  },
+  defaults: {
+    title: 'Welcome to Our Campaign',
+    subtitle: 'Join us in making a difference in your community.',
+    backgroundImage: '',
+    ctaText: 'Get Involved',
+    ctaUrl: '#',
+  },
+}
+
+

Text Block

+
{
+  id: 'default-text',
+  type: 'text',
+  label: 'Text Block',
+  category: 'Content',
+  sortOrder: 2,
+  schema: {
+    heading: { type: 'string', label: 'Heading' },
+    body: { type: 'text', label: 'Body Text' },
+  },
+  defaults: {
+    heading: 'About Us',
+    body: 'Tell your story here...',
+  },
+}
+
+

Features Grid

+
{
+  id: 'default-features',
+  type: 'features',
+  label: 'Features Grid',
+  category: 'Content',
+  sortOrder: 3,
+  schema: {
+    features: {
+      type: 'array',
+      label: 'Features',
+      items: { title: 'string', description: 'string', icon: 'string' }
+    },
+  },
+  defaults: {
+    features: [
+      { title: 'Community Action', description: 'Organize local events...', icon: '' },
+      { title: 'Advocacy', description: 'Email your representatives...', icon: '' },
+      { title: 'Volunteer', description: 'Sign up for shifts...', icon: '' },
+    ],
+  },
+}
+
+

Call to Action

+
{
+  id: 'default-cta',
+  type: 'cta',
+  label: 'Call to Action',
+  category: 'Actions',
+  sortOrder: 4,
+  // ... (see seed.ts for full schema)
+}
+
+

Testimonials

+
{
+  id: 'default-testimonials',
+  type: 'testimonials',
+  label: 'Testimonials',
+  category: 'Content',
+  sortOrder: 5,
+  // ... (see seed.ts for full schema)
+}
+
+

Contact Form

+
{
+  id: 'default-contact-form',
+  type: 'contact-form',
+  label: 'Contact Form',
+  category: 'Actions',
+  sortOrder: 6,
+  // ... (see seed.ts for full schema)
+}
+
+

4. Email Templates (4 templates)

+

Campaign Email to Representative

+

Key: campaign-email +Category: INFLUENCE +Variables: CAMPAIGN_TITLE, MESSAGE, USER_NAME, USER_EMAIL, POSTAL_CODE, RECIPIENT_NAME, RECIPIENT_LEVEL, ORGANIZATION_NAME, TIMESTAMP

+

File Locations: +- HTML: api/src/templates/email/campaign-email.html +- Text: api/src/templates/email/campaign-email.txt

+

Seeding Logic: +

const templateDef = {
+  key: 'campaign-email',
+  name: 'Campaign Email to Representative',
+  description: 'Email sent when a constituent contacts their elected representative through a campaign',
+  category: EmailTemplateCategory.INFLUENCE,
+  subjectLine: '{{CAMPAIGN_TITLE}} - Message from {{USER_NAME}}',
+  isSystem: true,
+  variables: [
+    { key: 'CAMPAIGN_TITLE', label: 'Campaign Title', isRequired: true, sampleValue: 'Support Climate Action Bill C-12' },
+    { key: 'MESSAGE', label: 'Message Body', isRequired: true, sampleValue: 'I urge you to support...' },
+    // ... 7 more variables
+  ],
+};
+
+const htmlContent = fs.readFileSync(path.join(templatesDir, `${templateDef.key}.html`), 'utf-8');
+const textContent = fs.readFileSync(path.join(templatesDir, `${templateDef.key}.txt`), 'utf-8');
+
+const template = await prisma.emailTemplate.create({
+  data: {
+    ...templateDef,
+    htmlContent,
+    textContent,
+    createdByUserId: admin.id,
+    variables: {
+      create: templateDef.variables,
+    },
+  },
+});
+

+

Response Verification

+

Key: response-verification +Category: INFLUENCE +Variables: CAMPAIGN_TITLE, RESPONSE_TYPE, RESPONSE_TEXT, SUBMITTER_NAME, SUBMITTED_DATE, VERIFICATION_URL, REPORT_URL, ORGANIZATION_NAME, TIMESTAMP

+

Shift Signup Confirmation

+

Key: shift-signup-confirmation +Category: MAP +Variables: ORGANIZATION_NAME, USER_NAME, USER_EMAIL, SHIFT_TITLE, SHIFT_DATE, SHIFT_TIME, SHIFT_LOCATION, IS_NEW_USER, TEMP_PASSWORD, LOGIN_URL

+

Shift Details Reminder

+

Key: shift-details +Category: MAP +Variables: ORGANIZATION_NAME, USER_NAME, SHIFT_TITLE, SHIFT_DATE, SHIFT_START_TIME, SHIFT_END_TIME, SHIFT_LOCATION, SHIFT_DESCRIPTION, CURRENT_VOLUNTEERS, MAX_VOLUNTEERS, SHIFT_STATUS

+
+

Seed Script Structure

+

Main Function

+
async function main() {
+  console.log('Seeding database...');
+
+  // 1. Create admin user
+  const admin = await createAdminUser();
+
+  // 2. Create map settings
+  await createMapSettings();
+
+  // 3. Create page blocks
+  await createPageBlocks();
+
+  // 4. Seed email templates
+  await seedEmailTemplates(admin);
+
+  console.log('Seed complete.');
+}
+
+

Upsert Pattern

+

All seed operations use upsert to be idempotent: +

await prisma.pageBlock.upsert({
+  where: { id: block.id },
+  update: {},  // Don't update if exists
+  create: block,  // Create if doesn't exist
+});
+

+

Benefits: +- Safe to run multiple times +- Won't duplicate data +- Won't overwrite user changes (empty update clause)

+

Error Handling

+
main()
+  .catch((e) => {
+    console.error('Seed error:', e);
+    process.exit(1);
+  })
+  .finally(async () => {
+    await prisma.$disconnect();
+  });
+
+
+

Customizing Seed Data

+

Change Admin Credentials

+

Edit api/prisma/seed.ts: +

const hashedPassword = await bcrypt.hash('YourSecurePassword123!', 10);
+
+const admin = await prisma.user.upsert({
+  where: { email: 'your-email@example.com' },  // Change email
+  update: {
+    password: hashedPassword,
+    emailVerified: true,
+    status: 'ACTIVE',
+  },
+  create: {
+    email: 'your-email@example.com',  // Change email
+    password: hashedPassword,
+    name: 'Your Name',  // Change name
+    role: UserRole.SUPER_ADMIN,
+    emailVerified: true,
+  },
+});
+

+

Change Map Default Location

+

Edit api/prisma/seed.ts: +

await prisma.mapSettings.upsert({
+  where: { id: 'default' },
+  update: {},
+  create: {
+    id: 'default',
+    latitude: 51.0447,    // Calgary, AB
+    longitude: -114.0719,
+    zoom: 11,
+    walkSheetTitle: 'Calgary Canvass Walk Sheet',
+    walkSheetSubtitle: 'District Canvassing',
+    walkSheetFooter: 'Thank you for volunteering!',
+  },
+});
+

+

Add Custom Page Blocks

+
const customBlocks = [
+  {
+    id: 'custom-video',
+    type: 'video',
+    label: 'Video Embed',
+    category: 'Media',
+    sortOrder: 7,
+    schema: {
+      videoUrl: { type: 'string', label: 'Video URL' },
+      caption: { type: 'string', label: 'Caption' },
+    },
+    defaults: {
+      videoUrl: 'https://www.youtube.com/embed/...',
+      caption: 'Watch our video',
+    },
+  },
+];
+
+for (const block of customBlocks) {
+  await prisma.pageBlock.upsert({
+    where: { id: block.id },
+    update: {},
+    create: block,
+  });
+}
+
+
+

Verifying Seed Data

+

Check Admin User

+
docker compose exec api npx prisma studio
+# Navigate to users table, filter by role = "SUPER_ADMIN"
+
+

Check Map Settings

+
docker compose exec v2-postgres psql -U changemaker_v2 changemaker_v2 -c "SELECT * FROM map_settings;"
+
+

Check Page Blocks

+
docker compose exec v2-postgres psql -U changemaker_v2 changemaker_v2 -c "SELECT id, type, label FROM page_blocks ORDER BY sort_order;"
+
+

Check Email Templates

+
docker compose exec v2-postgres psql -U changemaker_v2 changemaker_v2 -c "SELECT key, name, category FROM email_templates;"
+
+
+

Troubleshooting

+

Error: "Unique constraint failed on email"

+

Cause: Admin user already exists +Solution: Seed uses upsert, so this shouldn't happen. Check seed script for typos.

+

Error: "Template files not found"

+

Cause: Email template .html/.txt files missing +Solution: Ensure api/src/templates/email/ directory contains: +- campaign-email.html +- campaign-email.txt +- response-verification.html +- response-verification.txt +- shift-signup-confirmation.html +- shift-signup-confirmation.txt +- shift-details.html +- shift-details.txt

+

Error: "Cannot find module 'bcryptjs'"

+

Cause: Dependencies not installed +Solution: +

cd api && npm install
+

+

Seed doesn't run after migration

+

Cause: package.json missing prisma.seed config +Solution: Add to api/package.json: +

{
+  "prisma": {
+    "seed": "ts-node prisma/seed.ts"
+  }
+}
+

+
+

Production Seeding

+

Initial Deployment

+
# 1. Deploy migrations
+docker compose exec api npx prisma migrate deploy
+
+# 2. Run seed
+docker compose exec api npx prisma db seed
+
+# 3. Change admin password immediately
+# Login at https://app.cmlite.org with admin@cmlite.org / ChangeMe2025!
+# Navigate to /app/profile, update password
+
+

Subsequent Deployments

+

Don't re-run seed unless adding new seed data (new page blocks, email templates, etc.). Existing seed data uses upsert with empty update clause, so it won't overwrite user changes.

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/deployment/backup-restore/index.html b/mkdocs/site/v2/deployment/backup-restore/index.html new file mode 100644 index 00000000..1a9ce6b3 --- /dev/null +++ b/mkdocs/site/v2/deployment/backup-restore/index.html @@ -0,0 +1,6332 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Backup & Restore - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Backup & Restore Procedures

+

Overview

+

The scripts/backup.sh script provides automated backups of: +- V2 PostgreSQL database (pg_dump) +- Listmonk PostgreSQL database (pg_dump) +- Uploads directory (tar.gz) +- Backup manifest (SHA256 checksums)

+

Optional S3 upload for offsite storage.

+
+

Quick Start

+

Manual Backup

+
# Basic backup (local only)
+./scripts/backup.sh
+
+# With S3 upload
+./scripts/backup.sh --s3
+
+# Custom retention (60 days)
+./scripts/backup.sh --retention 60
+
+

Output: backups/changemaker-v2-backup-YYYYMMDD_HHMMSS.tar.gz

+
+

Automated Backups (Cron)

+
# Edit crontab
+crontab -e
+
+# Daily backup at 2 AM + S3 upload
+0 2 * * * /home/user/changemaker.lite/scripts/backup.sh --s3 >> /var/log/changemaker-backup.log 2>&1
+
+# Weekly backup on Sundays at 3 AM
+0 3 * * 0 /home/user/changemaker.lite/scripts/backup.sh --s3 --retention 90
+
+
+

Backup Script Walkthrough

+

Configuration

+

Location: scripts/backup.sh

+

Variables: +

BACKUP_DIR="${BACKUP_DIR:-./backups}"     # Backup output directory
+RETENTION_DAYS="${RETENTION_DAYS:-30}"    # Delete backups older than N days
+TIMESTAMP="$(date +%Y%m%d_%H%M%S)"        # Backup timestamp
+

+

Environment: Loads .env automatically (safe parsing handles quotes/special chars).

+
+

Backup Steps

+

1. V2 PostgreSQL Dump

+
docker exec changemaker-v2-postgres \
+  pg_dump -U changemaker -d changemaker_v2 --no-owner --no-acl \
+  | gzip > v2-postgres.sql.gz
+
+

Options: +- --no-owner: Skip ownership commands (easier restore) +- --no-acl: Skip permissions (easier restore) +- gzip: Compress (70-80% reduction)

+

Size estimate: 100MB-2GB (depends on data volume).

+
+

2. Listmonk PostgreSQL Dump

+
docker exec listmonk-db \
+  pg_dump -U listmonk -d listmonk --no-owner --no-acl \
+  | gzip > listmonk-postgres.sql.gz
+
+

Optional: Skipped if Listmonk container not running.

+

Size estimate: 10MB-500MB (depends on subscriber count + campaigns).

+
+

3. Uploads Archive

+
tar -czf uploads.tar.gz -C assets/ uploads/
+
+

Includes: +- Campaign email attachments +- Response wall images +- Listmonk campaign uploads

+

Size estimate: 100MB-10GB (depends on file uploads).

+
+

4. Backup Manifest

+

Format: JSON with file list + SHA256 checksums.

+
{
+  "timestamp": "20260213_140530",
+  "backup_name": "changemaker-v2-backup-20260213_140530",
+  "files": [
+    {
+      "file": "v2-postgres.sql.gz",
+      "size_bytes": 123456789,
+      "sha256": "abc123..."
+    },
+    {
+      "file": "listmonk-postgres.sql.gz",
+      "size_bytes": 987654,
+      "sha256": "def456..."
+    },
+    {
+      "file": "uploads.tar.gz",
+      "size_bytes": 555666777,
+      "sha256": "ghi789..."
+    }
+  ],
+  "v2_database": "changemaker_v2",
+  "listmonk_database": "listmonk",
+  "retention_days": 30
+}
+
+

Purpose: Verify backup integrity + metadata.

+
+

Final Archive

+

Creates single tar.gz: +

tar -czf changemaker-v2-backup-20260213_140530.tar.gz \
+  changemaker-v2-backup-20260213_140530/
+

+

Removes temp directory after archiving.

+
+

Optional S3 Upload

+

Requires: +- AWS CLI installed (apt install awscli) +- Credentials configured (aws configure) +- S3_BUCKET env var set

+

Command: +

aws s3 cp changemaker-v2-backup-20260213_140530.tar.gz \
+  s3://${S3_BUCKET}/${S3_PREFIX}/
+

+

S3 prefix: ${S3_PREFIX:-changemaker-backups} (customizable).

+
+

Retention Cleanup

+

Deletes backups older than RETENTION_DAYS: +

find backups/ -name "changemaker-v2-backup-*.tar.gz" -mtime +30 -delete
+

+

Local only (S3 has its own lifecycle policies).

+
+

Restore Procedures

+

Full Restore (New Server)

+

1. Extract Backup

+
# Download from S3 (if needed)
+aws s3 cp s3://my-bucket/changemaker-backups/changemaker-v2-backup-20260213_140530.tar.gz ./
+
+# Extract archive
+tar -xzf changemaker-v2-backup-20260213_140530.tar.gz
+cd changemaker-v2-backup-20260213_140530/
+
+
+

2. Restore V2 Database

+
# Start PostgreSQL container
+docker compose up -d v2-postgres
+
+# Wait for healthy
+docker compose ps v2-postgres
+
+# Restore dump
+gunzip -c v2-postgres.sql.gz | \
+  docker exec -i changemaker-v2-postgres \
+  psql -U changemaker -d changemaker_v2
+
+# Verify
+docker compose exec v2-postgres \
+  psql -U changemaker -d changemaker_v2 -c "\dt"
+
+
+

3. Restore Listmonk Database

+
# Start Listmonk DB
+docker compose up -d listmonk-db
+
+# Restore dump
+gunzip -c listmonk-postgres.sql.gz | \
+  docker exec -i listmonk-db \
+  psql -U listmonk -d listmonk
+
+# Verify
+docker compose exec listmonk-db \
+  psql -U listmonk -d listmonk -c "SELECT COUNT(*) FROM subscribers"
+
+
+

4. Restore Uploads

+
# Extract uploads
+tar -xzf uploads.tar.gz -C ./assets/
+
+# Verify
+ls -lh assets/uploads/
+
+
+

5. Start Services

+
# Start all services
+docker compose up -d
+
+# Run migrations (if needed)
+docker compose exec api npx prisma migrate deploy
+
+# Check health
+docker compose ps
+curl http://localhost:4000/api/health
+
+
+

Partial Restore (Specific Data)

+

Restore Single Table

+
# Extract table from dump
+pg_restore -U changemaker -d changemaker_v2 \
+  --table=campaigns \
+  v2-postgres.sql.gz
+
+# Or: restore from SQL dump
+gunzip -c v2-postgres.sql.gz | \
+  grep -A9999 "CREATE TABLE campaigns" | \
+  grep -B9999 "CREATE TABLE " | \
+  docker exec -i changemaker-v2-postgres \
+  psql -U changemaker -d changemaker_v2
+
+
+

Restore Specific Files

+
# List files in upload archive
+tar -tzf uploads.tar.gz
+
+# Extract specific file
+tar -xzf uploads.tar.gz uploads/campaigns/logo.png
+
+# Copy to container
+docker cp uploads/campaigns/logo.png \
+  changemaker-v2-api:/app/uploads/campaigns/
+
+
+

Backup Verification

+

Integrity Check

+
# Verify checksums from manifest
+cd changemaker-v2-backup-20260213_140530/
+
+# Check v2-postgres.sql.gz
+echo "abc123...  v2-postgres.sql.gz" | sha256sum -c
+
+# Check all files
+jq -r '.files[] | "\(.sha256)  \(.file)"' manifest.json | sha256sum -c
+
+

Expected output: OK for each file.

+
+

Test Restore (Dry Run)

+

Best practice: Periodically test restores.

+
# Restore to test database
+docker compose up -d v2-postgres
+
+# Create test DB
+docker compose exec v2-postgres \
+  psql -U changemaker -c "CREATE DATABASE changemaker_v2_test"
+
+# Restore to test DB
+gunzip -c v2-postgres.sql.gz | \
+  docker exec -i changemaker-v2-postgres \
+  psql -U changemaker -d changemaker_v2_test
+
+# Verify data
+docker compose exec v2-postgres \
+  psql -U changemaker -d changemaker_v2_test -c "SELECT COUNT(*) FROM users"
+
+# Drop test DB
+docker compose exec v2-postgres \
+  psql -U changemaker -c "DROP DATABASE changemaker_v2_test"
+
+
+

S3 Configuration

+

Setup AWS CLI

+
# Install
+sudo apt install awscli
+
+# Configure credentials
+aws configure
+# AWS Access Key ID: <your-key>
+# AWS Secret Access Key: <your-secret>
+# Default region: us-east-1
+# Default output format: json
+
+
+

Create S3 Bucket

+
# Create bucket
+aws s3 mb s3://changemaker-backups
+
+# Set lifecycle policy (auto-delete old backups)
+cat > lifecycle.json <<EOF
+{
+  "Rules": [
+    {
+      "Id": "DeleteOldBackups",
+      "Status": "Enabled",
+      "Prefix": "changemaker-backups/",
+      "Expiration": {
+        "Days": 90
+      }
+    }
+  ]
+}
+EOF
+
+aws s3api put-bucket-lifecycle-configuration \
+  --bucket changemaker-backups \
+  --lifecycle-configuration file://lifecycle.json
+
+
+

Environment Variables

+
# Add to .env
+S3_BUCKET=changemaker-backups
+S3_PREFIX=changemaker-backups
+AWS_ACCESS_KEY_ID=<your-key>
+AWS_SECRET_ACCESS_KEY=<your-secret>
+AWS_DEFAULT_REGION=us-east-1
+
+
+

Retention Policies

+ +

Daily backups: Keep 7 days
+Weekly backups: Keep 4 weeks
+Monthly backups: Keep 12 months

+

Implementation (via cron): +

# Daily (keep 7 days)
+0 2 * * * /path/to/backup.sh --retention 7
+
+# Weekly (Sundays, keep 28 days)
+0 3 * * 0 /path/to/backup.sh --retention 28 --s3
+
+# Monthly (1st of month, keep 365 days)
+0 4 1 * * /path/to/backup.sh --retention 365 --s3
+

+
+

S3 Lifecycle

+

Glacier transition (archive old backups): +

{
+  "Rules": [
+    {
+      "Id": "ArchiveOldBackups",
+      "Status": "Enabled",
+      "Transitions": [
+        {
+          "Days": 30,
+          "StorageClass": "GLACIER"
+        }
+      ],
+      "Expiration": {
+        "Days": 365
+      }
+    }
+  ]
+}
+

+

Apply: +

aws s3api put-bucket-lifecycle-configuration \
+  --bucket changemaker-backups \
+  --lifecycle-configuration file://lifecycle.json
+

+
+

Disaster Recovery

+

Complete Server Loss

+

Scenario: Server crashes, all data lost.

+

Recovery Steps:

+
    +
  1. Provision new server (same OS, Docker installed)
  2. +
  3. Clone repository: +
    git clone <repo> changemaker.lite
    +cd changemaker.lite
    +git checkout v2
    +
  4. +
  5. Restore .env file (from secure backup location)
  6. +
  7. Download latest backup from S3: +
    aws s3 cp s3://changemaker-backups/changemaker-backups/latest.tar.gz ./
    +
  8. +
  9. Extract + restore (see Full Restore above)
  10. +
  11. Start services: +
    docker compose up -d
    +
  12. +
  13. Verify: +
    docker compose ps
    +curl http://localhost:4000/api/health
    +
  14. +
+

RTO (Recovery Time Objective): 30-60 minutes
+RPO (Recovery Point Objective): Last backup (e.g., 24h for daily backups)

+
+

Database Corruption

+

Scenario: PostgreSQL data corruption detected.

+

Recovery: +

# Stop services
+docker compose stop api admin
+
+# Drop corrupted database
+docker compose exec v2-postgres \
+  psql -U changemaker -c "DROP DATABASE changemaker_v2"
+
+# Recreate database
+docker compose exec v2-postgres \
+  psql -U changemaker -c "CREATE DATABASE changemaker_v2"
+
+# Restore from backup
+gunzip -c backups/latest/v2-postgres.sql.gz | \
+  docker exec -i changemaker-v2-postgres \
+  psql -U changemaker -d changemaker_v2
+
+# Restart services
+docker compose up -d api admin
+

+
+

Monitoring Backup Success

+

Log Files

+

Cron output: +

# View last backup log
+tail -f /var/log/changemaker-backup.log
+
+# Check for errors
+grep -i error /var/log/changemaker-backup.log
+

+
+

Prometheus Metrics (Custom)

+

Add to api/src/utils/metrics.ts: +

export const lastBackupTimestamp = new client.Gauge({
+  name: 'cm_last_backup_timestamp',
+  help: 'Unix timestamp of last successful backup',
+});
+
+export const backupSizeBytes = new client.Gauge({
+  name: 'cm_backup_size_bytes',
+  help: 'Size of last backup in bytes',
+});
+

+

Alert rule: +

- alert: BackupTooOld
+  expr: time() - cm_last_backup_timestamp > 86400 * 2  # 2 days
+  for: 1h
+  labels:
+    severity: warning
+  annotations:
+    summary: "Backup older than 2 days"
+

+
+

Troubleshooting

+

pg_dump: permission denied

+

Symptoms: Backup fails with "permission denied for database"

+

Cause: PostgreSQL user lacks dump privileges.

+

Solution: +

# Grant privileges
+docker compose exec v2-postgres \
+  psql -U changemaker -c "GRANT ALL ON DATABASE changemaker_v2 TO changemaker"
+
+# Retry backup
+./scripts/backup.sh
+

+
+

S3 upload fails: InvalidAccessKeyId

+

Symptoms: AWS CLI authentication error

+

Solution: +

# Verify credentials
+aws sts get-caller-identity
+
+# Reconfigure
+aws configure
+
+# Test S3 access
+aws s3 ls s3://changemaker-backups/
+

+
+

Restore fails: relation already exists

+

Symptoms: psql: ERROR: relation "users" already exists

+

Cause: Restoring to non-empty database.

+

Solution: +

# Drop and recreate database
+docker compose exec v2-postgres \
+  psql -U changemaker <<SQL
+DROP DATABASE changemaker_v2;
+CREATE DATABASE changemaker_v2;
+SQL
+
+# Retry restore
+gunzip -c v2-postgres.sql.gz | \
+  docker exec -i changemaker-v2-postgres \
+  psql -U changemaker -d changemaker_v2
+

+
+

Best Practices

+

Security

+
    +
  • Encrypt backups at rest (S3 encryption enabled)
  • +
  • Restrict .env file access (chmod 600 .env)
  • +
  • Store S3 credentials securely (not in .env committed to Git)
  • +
  • Test restore procedures monthly
  • +
  • Document recovery procedures (this guide!)
  • +
+

Automation

+
    +
  • Schedule daily backups via cron
  • +
  • Monitor backup success (log files + metrics)
  • +
  • Alert on backup failures
  • +
  • Rotate local backups (retention policy)
  • +
  • Offsite storage (S3 or alternative)
  • +
+

Documentation

+
    +
  • Document .env restoration procedure
  • +
  • Keep list of critical files to backup
  • +
  • Document service dependencies
  • +
  • Test disaster recovery plan annually
  • +
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/deployment/docker-compose/index.html b/mkdocs/site/v2/deployment/docker-compose/index.html new file mode 100644 index 00000000..e02eef58 --- /dev/null +++ b/mkdocs/site/v2/deployment/docker-compose/index.html @@ -0,0 +1,7334 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Docker Compose - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Docker Compose Orchestration

+

Overview

+

Changemaker Lite V2 uses Docker Compose to orchestrate 20+ microservices in a single unified stack. This approach simplifies deployment, provides service isolation, and ensures consistent environments across development and production.

+

Key Benefits:

+
    +
  • Single Configuration File: All services defined in docker-compose.yml
  • +
  • Automatic Networking: All containers communicate via a shared bridge network
  • +
  • Health Checks: 7 critical services have automated health monitoring
  • +
  • Volume Persistence: Database, uploads, and configuration data persisted across restarts
  • +
  • Profile Support: Optional monitoring stack behind --profile monitoring flag
  • +
  • Container Dependencies: Services start in correct order via depends_on relationships
  • +
+

Architecture:

+

The V2 stack consolidates all services into a single Docker Compose file, replacing the fragmented V1 approach. Services are organized into logical groups: Core (API, database, admin), Supporting (NocoDB, Listmonk, Gitea), Media (media-api, public-media), and Monitoring (Prometheus, Grafana, exporters).

+
+

Service Architecture

+
graph TB
+    subgraph "Core Services"
+        NGINX[nginx<br/>:80, :443]
+        API[api<br/>Express :4000]
+        MEDIA[media-api<br/>Fastify :4100]
+        ADMIN[admin<br/>Vite :3000]
+        PG[v2-postgres<br/>PostgreSQL 16]
+        REDIS[redis<br/>:6379]
+    end
+
+    subgraph "Supporting Services"
+        NOCODB[nocodb-v2<br/>:8091]
+        LISTMONK[listmonk-app<br/>:9000]
+        LISTMONK_DB[listmonk-db<br/>PostgreSQL 17]
+        MAILHOG[mailhog<br/>:8025]
+        GITEA[gitea-app<br/>:3000]
+        GITEA_DB[gitea-db<br/>MySQL 8]
+        N8N[n8n<br/>:5678]
+        MKDOCS[mkdocs<br/>:8000]
+        CODE[code-server<br/>:8080]
+        HOMEPAGE[homepage<br/>:3000]
+        MINIQR[mini-qr<br/>:8080]
+    end
+
+    subgraph "Media Services"
+        PUBLIC_MEDIA[public-media<br/>:80]
+    end
+
+    subgraph "Tunnel Services"
+        NEWT[newt<br/>Pangolin connector]
+    end
+
+    subgraph "Monitoring Services (profile: monitoring)"
+        PROMETHEUS[prometheus<br/>:9090]
+        GRAFANA[grafana<br/>:3000]
+        CADVISOR[cadvisor<br/>:8080]
+        NODE_EXPORTER[node-exporter<br/>:9100]
+        REDIS_EXPORTER[redis-exporter<br/>:9121]
+        ALERTMANAGER[alertmanager<br/>:9093]
+        GOTIFY[gotify<br/>:80]
+    end
+
+    NGINX --> API
+    NGINX --> MEDIA
+    NGINX --> ADMIN
+    NGINX --> NOCODB
+    NGINX --> LISTMONK
+    NGINX --> GITEA
+    NGINX --> N8N
+    NGINX --> MKDOCS
+    NGINX --> CODE
+    NGINX --> HOMEPAGE
+    NGINX --> MINIQR
+    NGINX --> MAILHOG
+    NGINX --> PUBLIC_MEDIA
+
+    API --> PG
+    API --> REDIS
+    MEDIA --> PG
+    ADMIN --> API
+    ADMIN --> MEDIA
+    NOCODB --> PG
+    LISTMONK --> LISTMONK_DB
+    GITEA --> GITEA_DB
+    NEWT --> NGINX
+
+    PROMETHEUS --> API
+    PROMETHEUS --> REDIS_EXPORTER
+    PROMETHEUS --> CADVISOR
+    PROMETHEUS --> NODE_EXPORTER
+    GRAFANA --> PROMETHEUS
+    ALERTMANAGER --> PROMETHEUS
+
+

Core Services

+

v2-postgres

+

Purpose: PostgreSQL 16 database for V2 platform (main app + NocoDB metadata)

+

Configuration: +

v2-postgres:
+  image: postgres:16-alpine
+  container_name: changemaker-v2-postgres
+  restart: unless-stopped
+  ports:
+    - "127.0.0.1:5433:5432"  # Localhost only
+  environment:
+    POSTGRES_USER: ${V2_POSTGRES_USER:-changemaker}
+    POSTGRES_PASSWORD: ${V2_POSTGRES_PASSWORD}
+    POSTGRES_DB: ${V2_POSTGRES_DB:-changemaker_v2}
+  volumes:
+    - v2-postgres-data:/var/lib/postgresql/data
+    - ./api/prisma/init-nocodb-db.sh:/docker-entrypoint-initdb.d/init-nocodb-db.sh:ro
+  healthcheck:
+    test: ["CMD-SHELL", "pg_isready -U changemaker"]
+    interval: 10s
+    timeout: 5s
+    retries: 5
+

+

Key Features: +- Alpine image for minimal footprint +- init-nocodb-db.sh creates separate nocodb_meta database on first startup +- Health check uses pg_isready for fast readiness detection +- Port bound to 127.0.0.1 to prevent external access

+

Volumes: +- v2-postgres-data: Persistent PostgreSQL data directory

+

Dependencies: None (starts first)

+
+

redis

+

Purpose: Shared Redis instance for sessions, BullMQ job queues, rate limiting, and geocoding cache

+

Configuration: +

redis:
+  image: redis:7-alpine
+  container_name: redis-changemaker
+  command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru --requirepass "${REDIS_PASSWORD}"
+  ports:
+    - "6379:6379"
+  volumes:
+    - redis-data:/data
+  healthcheck:
+    test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
+    interval: 10s
+    timeout: 5s
+    retries: 5
+  deploy:
+    resources:
+      limits:
+        cpus: '1'
+        memory: 512M
+      reservations:
+        cpus: '0.25'
+        memory: 256M
+

+

Key Features: +- Authentication required: --requirepass flag enforces password on all connections +- AOF persistence: --appendonly yes writes every command to disk +- Memory limits: 512MB max with LRU eviction policy +- Resource constraints: Prevents Redis from consuming excessive host resources

+

Volumes: +- redis-data: Persistent AOF log and RDB snapshots

+

Security Note: As of Security Audit 2025-02-11, Redis authentication is REQUIRED in production. Set a strong REDIS_PASSWORD in .env.

+
+

api

+

Purpose: Unified Express.js API (TypeScript, Prisma ORM)

+

Configuration: +

api:
+  build:
+    context: ./api
+    target: development
+  container_name: changemaker-v2-api
+  restart: unless-stopped
+  ports:
+    - "${API_PORT:-4000}:4000"
+    - "${LISTMONK_PROXY_PORT:-9002}:9002"
+  healthcheck:
+    test: ["CMD", "wget", "-q", "--spider", "http://localhost:4000/api/health"]
+    interval: 15s
+    timeout: 5s
+    retries: 3
+    start_period: 30s
+  environment:
+    - NODE_ENV=${NODE_ENV:-development}
+    - PORT=4000
+    - DATABASE_URL=postgresql://${V2_POSTGRES_USER}:${V2_POSTGRES_PASSWORD}@changemaker-v2-postgres:5432/${V2_POSTGRES_DB}
+    - REDIS_URL=redis://:${REDIS_PASSWORD}@redis-changemaker:6379
+    - JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
+    - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
+    # ... 30+ additional env vars (see .env.example)
+  volumes:
+    - ./api:/app
+    - /app/node_modules
+    - ./assets/uploads:/app/uploads
+    - ./mkdocs:/mkdocs:rw
+    - ./data:/data:ro
+    - /var/run/docker.sock:/var/run/docker.sock  # For Docker service management
+  depends_on:
+    v2-postgres:
+      condition: service_healthy
+    redis:
+      condition: service_healthy
+

+

Key Features: +- Waits for PostgreSQL + Redis to be healthy before starting +- Mounts source code for live reloading in development +- Docker socket access for managing MkDocs/Code Server containers +- Health check on /api/health endpoint with 30s startup grace period +- Exposes Listmonk proxy on port 9002 (OAuth integration)

+

Volumes: +- ./api:/app: Live code reloading +- /app/node_modules: Prevents host node_modules conflicts +- ./assets/uploads:/app/uploads: Shared upload directory +- ./mkdocs:/mkdocs:rw: MkDocs export target +- ./data:/data:ro: NAR import data (read-only) +- /var/run/docker.sock: Docker API access

+

Environment Variables: See Environment Variables for complete reference.

+
+

media-api

+

Purpose: Fastify microservice for video library management (Drizzle ORM)

+

Configuration: +

media-api:
+  build:
+    context: ./api
+    dockerfile: Dockerfile.media
+    target: development
+  container_name: changemaker-media-api
+  restart: unless-stopped
+  ports:
+    - "${MEDIA_API_PORT:-4100}:4100"
+  healthcheck:
+    test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:4100/health"]
+    interval: 15s
+    timeout: 5s
+    retries: 3
+    start_period: 30s
+  environment:
+    - NODE_ENV=${NODE_ENV:-development}
+    - MEDIA_API_PORT=4100
+    - DATABASE_URL=postgresql://...  # Same DB as main API
+    - ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true}
+    - MAX_UPLOAD_SIZE_GB=${MAX_UPLOAD_SIZE_GB:-10}
+  volumes:
+    - ./api:/app
+    - /app/node_modules
+    - ${MEDIA_ROOT:-./media}:/media:ro
+    - ${MEDIA_ROOT:-./media}/local/inbox:/media/local/inbox:rw  # Upload inbox
+  depends_on:
+    v2-postgres:
+      condition: service_healthy
+

+

Key Features: +- Separate Dockerfile (Dockerfile.media) with FFmpeg/FFprobe installed +- Shares PostgreSQL database with main API (different ORM) +- Media library mounted read-only, inbox writable for uploads +- 10GB upload size limit (configurable)

+

Volumes: +- ${MEDIA_ROOT}:/media:ro: Read-only media library +- ${MEDIA_ROOT}/local/inbox:/media/local/inbox:rw: RW mount required for video uploads

+

Important: The inbox directory must have :rw flag; main library stays :ro for security.

+
+

admin

+

Purpose: React admin GUI (Vite dev server in development, Nginx in production)

+

Configuration: +

admin:
+  build:
+    context: ./admin
+    target: development
+  container_name: changemaker-v2-admin
+  restart: unless-stopped
+  ports:
+    - "${ADMIN_PORT:-3000}:3000"
+  healthcheck:
+    test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/"]
+    interval: 30s
+    timeout: 5s
+    retries: 3
+    start_period: 20s
+  environment:
+    - VITE_API_URL=http://changemaker-v2-api:4000
+    - VITE_MEDIA_API_URL=http://changemaker-media-api:4100
+    - VITE_MKDOCS_URL=http://mkdocs-changemaker:8000
+  volumes:
+    - ./admin:/app
+    - /app/node_modules
+  depends_on:
+    - api
+

+

Key Features: +- Vite environment variables use container hostnames (not localhost) +- Health check on root path (Vite dev server responds with HTML) +- Live reloading via mounted source code

+

Environment Variables: +- VITE_API_URL: Points to API container (not localhost) +- VITE_MEDIA_API_URL: Points to media-api container +- VITE_MKDOCS_URL: Points to MkDocs container for iframe embed

+

Production Build: Swap target: development to target: production and serve static files via Nginx.

+
+

nginx

+

Purpose: Reverse proxy with subdomain routing, SSL termination, and iframe embedding support

+

Configuration: +

nginx:
+  build:
+    context: ./nginx
+  container_name: changemaker-v2-nginx
+  restart: unless-stopped
+  ports:
+    - "${NGINX_HTTP_PORT:-80}:80"
+    - "${NGINX_HTTPS_PORT:-443}:443"
+    - "8881:8881"  # NocoDB embed proxy
+    - "8882:8882"  # n8n embed proxy
+    - "8883:8883"  # Gitea embed proxy
+    - "8884:8884"  # MailHog embed proxy
+    - "8885:8885"  # Mini QR embed proxy
+  healthcheck:
+    test: ["CMD", "sh", "-c", "wget -q --spider http://127.0.0.1:80/ && pgrep crond"]
+    interval: 30s
+    timeout: 5s
+    retries: 3
+  environment:
+    - PANGOLIN_SITE_ID=${PANGOLIN_SITE_ID:-}
+  volumes:
+    - ./nginx/conf.d:/etc/nginx/conf.d:ro
+    - ./public-web:/usr/share/nginx/public-web:ro
+    - ./configs/pangolin:/etc/pangolin:ro
+  depends_on:
+    - api
+    - admin
+

+

Key Features: +- Subdomain routing: api.cmlite.org, app.cmlite.org, db.cmlite.org, etc. +- Embed proxy ports: 888x ports strip X-Frame-Options for iframe embedding +- Health check: Validates both HTTP server + cron daemon (for cert renewal) +- Read-only configs: Prevents accidental modification

+

Configuration Files: +- nginx.conf: Global settings, gzip, security headers +- conf.d/default.conf: Localhost fallback + path-based routing +- conf.d/api.conf: API subdomain routing (media endpoints must come before /api/) +- conf.d/services.conf: All supporting services + CSP headers

+

See Nginx Configuration for complete routing details.

+
+

nocodb-v2

+

Purpose: Read-only database browser for V2 schema

+

Configuration: +

nocodb-v2:
+  image: nocodb/nocodb:latest
+  container_name: changemaker-v2-nocodb
+  restart: unless-stopped
+  ports:
+    - "${NOCODB_V2_PORT:-8091}:8080"
+  healthcheck:
+    test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/api/v1/health"]
+    interval: 30s
+    timeout: 5s
+    retries: 3
+    start_period: 30s
+  environment:
+    NC_DB: "pg://changemaker-v2-postgres:5432?u=${V2_POSTGRES_USER}&p=${V2_POSTGRES_PASSWORD}&d=nocodb_meta"
+    NC_ADMIN_EMAIL: ${NC_ADMIN_EMAIL:-admin@cmlite.org}
+    NC_ADMIN_PASSWORD: ${NC_ADMIN_PASSWORD}
+    NC_PUBLIC_URL: ${NC_PUBLIC_URL:-http://localhost:8091}
+  volumes:
+    - nocodb-v2-data:/usr/app/data
+  depends_on:
+    v2-postgres:
+      condition: service_healthy
+

+

Key Features: +- Uses separate nocodb_meta database (auto-created by init-nocodb-db.sh) +- Health check via NocoDB API endpoint +- Read-only access recommended (grant SELECT only in production)

+

Volumes: +- nocodb-v2-data: NocoDB's internal file storage

+

Access: http://localhost:8091 or http://db.cmlite.org (via subdomain routing)

+
+

Supporting Services

+

listmonk-app

+

Purpose: Email marketing platform for newsletters (V2 syncs subscribers via REST API)

+

Configuration: +

listmonk-app:
+  image: listmonk/listmonk:latest
+  container_name: listmonk-app
+  restart: unless-stopped
+  ports:
+    - "${LISTMONK_PORT:-9001}:9000"
+  healthcheck:
+    test: ["CMD", "wget", "-q", "--spider", "http://localhost:9000/"]
+    interval: 30s
+    timeout: 5s
+    retries: 3
+    start_period: 30s
+  depends_on:
+    - listmonk-db
+  command: [sh, -c, "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''"]
+  environment:
+    LISTMONK_app__address: 0.0.0.0:9000
+    LISTMONK_db__host: listmonk-db
+    LISTMONK_db__user: ${LISTMONK_DB_USER:-listmonk}
+    LISTMONK_db__password: ${LISTMONK_DB_PASSWORD}
+    LISTMONK_ADMIN_USER: ${LISTMONK_WEB_ADMIN_USER:-admin}
+    LISTMONK_ADMIN_PASSWORD: ${LISTMONK_WEB_ADMIN_PASSWORD}
+  volumes:
+    - ./assets/uploads:/listmonk/uploads:rw
+

+

Key Features: +- Idempotent init: --install --idempotent runs migrations on every start (safe) +- Auto-upgrade: --upgrade --yes applies schema upgrades +- Shared uploads: Uses same upload directory as main API

+

Database: Uses separate PostgreSQL 17 instance (listmonk-db)

+

API Integration: V2 API syncs participants/locations to Listmonk lists via REST API (opt-in via LISTMONK_SYNC_ENABLED=true)

+
+

listmonk-db

+

Purpose: PostgreSQL 17 database for Listmonk

+

Configuration: +

listmonk-db:
+  image: postgres:17-alpine
+  container_name: listmonk-db
+  restart: unless-stopped
+  ports:
+    - "127.0.0.1:5432:5432"  # Localhost only
+  environment:
+    POSTGRES_USER: ${LISTMONK_DB_USER:-listmonk}
+    POSTGRES_PASSWORD: ${LISTMONK_DB_PASSWORD}
+    POSTGRES_DB: ${LISTMONK_DB_NAME:-listmonk}
+  healthcheck:
+    test: ["CMD-SHELL", "pg_isready -U listmonk"]
+    interval: 10s
+    timeout: 5s
+    retries: 6
+  volumes:
+    - listmonk-data:/var/lib/postgresql/data
+

+

Key Features: +- Separate PostgreSQL instance (not shared with V2 database) +- Port bound to 127.0.0.1 for security

+

Volumes: +- listmonk-data: Persistent Listmonk database

+
+

listmonk-init

+

Purpose: One-shot container to create Listmonk API user for V2 integration

+

Configuration: +

listmonk-init:
+  image: postgres:17-alpine
+  container_name: listmonk-init
+  depends_on:
+    listmonk-app:
+      condition: service_started
+  restart: "no"  # Runs once and exits
+  environment:
+    PGPASSWORD: ${LISTMONK_DB_PASSWORD}
+    LISTMONK_API_USER: ${LISTMONK_API_USER:-v2-api}
+    LISTMONK_API_TOKEN: ${LISTMONK_API_TOKEN}
+  entrypoint: ["/bin/sh", "-c"]
+  command:
+    - |
+      # Wait for Listmonk to create tables
+      for i in $(seq 1 30); do
+        if psql -h listmonk-db -U listmonk -d listmonk -c "SELECT 1 FROM users LIMIT 1" >/dev/null 2>&1; then
+          break
+        fi
+        sleep 2
+      done
+
+      # Upsert API user
+      psql -h listmonk-db -U listmonk -d listmonk -q <<SQL
+      INSERT INTO users (username, password, password_login, email, name, type, user_role_id, status)
+      VALUES ('$LISTMONK_API_USER', '$LISTMONK_API_TOKEN', true, '$LISTMONK_API_USER@api.internal', '$LISTMONK_API_USER', 'api', 1, 'enabled')
+      ON CONFLICT (username) DO UPDATE SET password = EXCLUDED.password, status = 'enabled';
+      SQL
+

+

Key Features: +- Idempotent: Safe to run multiple times (upserts API user) +- Auto-configuration: Also configures SMTP providers (MailHog + production) +- Exit on completion: restart: "no" prevents restart after success

+

Important: Listmonk API users store tokens as plaintext (not bcrypt), so direct SQL upsert works.

+
+

gitea-app

+

Purpose: Self-hosted Git repository hosting

+

Configuration: +

gitea-app:
+  image: gitea/gitea:1.23.7
+  container_name: gitea-changemaker
+  healthcheck:
+    test: ["CMD", "curl", "-f", "http://localhost:3000/"]
+    interval: 30s
+    timeout: 5s
+    retries: 3
+    start_period: 30s
+  environment:
+    - GITEA__database__DB_TYPE=mysql
+    - GITEA__database__HOST=gitea-db:3306
+    - GITEA__server__ROOT_URL=${GITEA_ROOT_URL}
+    - GITEA__server__X_FRAME_OPTIONS=  # Allow iframe embedding
+    - GITEA__server__LFS_MAX_FILE_SIZE=1024  # 1GB LFS
+  ports:
+    - "${GITEA_WEB_PORT:-3030}:3000"
+    - "${GITEA_SSH_PORT:-2222}:22"
+  volumes:
+    - gitea-data:/data
+  depends_on:
+    - gitea-db
+

+

Key Features: +- MySQL backend: Uses separate MySQL 8 container +- LFS support: 1GB max file size for large binaries +- SSH access: Port 2222 for Git push/pull +- Iframe embedding: X_FRAME_OPTIONS disabled for admin iframe

+

Health Check: Uses curl (Debian-based image) not wget

+

Volumes: +- gitea-data: Git repositories + attachments

+
+

n8n

+

Purpose: Workflow automation platform

+

Configuration: +

n8n:
+  image: docker.n8n.io/n8nio/n8n
+  container_name: n8n-changemaker
+  restart: unless-stopped
+  ports:
+    - "${N8N_PORT:-5678}:5678"
+  healthcheck:
+    test: ["CMD", "wget", "-q", "--spider", "http://localhost:5678/healthz"]
+    interval: 30s
+    timeout: 5s
+    retries: 3
+    start_period: 30s
+  environment:
+    - N8N_HOST=${N8N_HOST:-n8n.cmlite.org}
+    - N8N_PROTOCOL=https
+    - WEBHOOK_URL=https://${N8N_HOST}/
+    - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
+    - N8N_DEFAULT_USER_EMAIL=${N8N_USER_EMAIL}
+    - N8N_DEFAULT_USER_PASSWORD=${N8N_USER_PASSWORD}
+  volumes:
+    - n8n-data:/home/node/.n8n
+    - ./local-files:/files
+

+

Key Features: +- HTTPS required: N8N_PROTOCOL=https for webhook security +- User management: Creates default admin user on first start +- File access: /files directory for workflow file operations

+

Health Check: /healthz endpoint (Alpine image uses wget)

+

Volumes: +- n8n-data: Workflow definitions + credentials +- ./local-files:/files: Shared file directory for workflows

+
+

mkdocs

+

Purpose: Live documentation preview server (Material theme)

+

Configuration: +

mkdocs:
+  image: squidfunk/mkdocs-material
+  container_name: mkdocs-changemaker
+  volumes:
+    - ./mkdocs:/docs:rw
+    - ./assets/images:/docs/assets/images:rw
+  user: "${USER_ID:-1000}:${GROUP_ID:-1000}"
+  ports:
+    - "${MKDOCS_PORT:-4003}:8000"
+  environment:
+    - SITE_URL=${BASE_DOMAIN:-https://cmlite.org}
+  command: serve --dev-addr=0.0.0.0:8000 --watch-theme --livereload
+  restart: unless-stopped
+

+

Key Features: +- Live reloading: --livereload watches for file changes +- User mapping: Runs as host user to prevent permission issues +- Port 4003: Changed from 4000 (conflicted with API in V1)

+

Volumes: +- ./mkdocs:/docs:rw: Documentation source (writable for MkDocs export) +- ./assets/images:/docs/assets/images:rw: Shared image directory

+

Access: http://localhost:4003 or http://docs.cmlite.org (via subdomain routing)

+
+

code-server

+

Purpose: VS Code in the browser for documentation editing

+

Configuration: +

code-server:
+  build:
+    context: .
+    dockerfile: Dockerfile.code-server
+  container_name: code-server-changemaker
+  command: /home/coder/project
+  environment:
+    - DOCKER_USER=${USER_NAME:-coder}
+  user: "${USER_ID:-1000}:${GROUP_ID:-1000}"
+  volumes:
+    - ./configs/code-server/.config:/home/coder/.config
+    - ./configs/code-server/.local:/home/coder/.local
+    - .:/home/coder/project
+  ports:
+    - "${CODE_SERVER_PORT:-8888}:8080"
+  restart: unless-stopped
+

+

Key Features: +- User mapping: Runs as host user (prevents permission conflicts) +- Project mount: Entire repository mounted at /home/coder/project +- Persistent config: .config and .local directories preserved

+

Access: http://localhost:8888 or http://code.cmlite.org (via subdomain routing)

+
+

mailhog

+

Purpose: Email capture for development/testing

+

Configuration: +

mailhog:
+  image: mailhog/mailhog:latest
+  container_name: mailhog-changemaker
+  ports:
+    - "${MAILHOG_WEB_PORT:-8025}:8025"
+    # SMTP port 1025 only exposed on Docker network
+  restart: unless-stopped
+  logging:
+    driver: "json-file"
+    options:
+      max-size: "5m"
+      max-file: "2"
+

+

Key Features: +- SMTP on port 1025: Accessible only from Docker network (not exposed to host) +- Web UI on port 8025: View captured emails +- Log rotation: 5MB max size, 2 files

+

Usage: Set EMAIL_TEST_MODE=true in .env to route all emails to MailHog

+

Access: http://localhost:8025 or http://mail.cmlite.org (via subdomain routing)

+
+

mini-qr

+

Purpose: QR code generation service (used by walk sheets + cut exports)

+

Configuration: +

mini-qr:
+  image: ghcr.io/lyqht/mini-qr:latest
+  container_name: mini-qr
+  ports:
+    - "${MINI_QR_PORT:-8089}:8080"
+  restart: unless-stopped
+

+

Key Features: +- Stateless: No volumes or persistent data +- Lightweight: Alpine-based image

+

API Integration: V2 API has dedicated /api/qr routes for direct PNG generation; mini-qr used for admin iframe

+

Access: http://localhost:8089 or http://qr.cmlite.org (via subdomain routing)

+
+

homepage

+

Purpose: Service dashboard with container status

+

Configuration: +

homepage:
+  image: ghcr.io/gethomepage/homepage:latest
+  container_name: homepage-changemaker
+  ports:
+    - "${HOMEPAGE_PORT:-3010}:3000"
+  volumes:
+    - ./configs/homepage:/app/config
+    - ./assets/icons:/app/public/icons
+    - ./assets/images:/app/public/images
+    - /var/run/docker.sock:/var/run/docker.sock
+  environment:
+    - PUID=${USER_ID:-1000}
+    - PGID=${DOCKER_GROUP_ID:-984}
+    - HOMEPAGE_ALLOWED_HOSTS=*
+  restart: unless-stopped
+

+

Key Features: +- Docker socket access: Reads container status +- User mapping: Runs as host user with Docker group +- Custom dashboard: Configure in configs/homepage/

+

Access: http://localhost:3010 or http://home.cmlite.org (via subdomain routing)

+
+

Media Services

+

public-media

+

Purpose: Public video gallery frontend (React production build)

+

Configuration: +

public-media:
+  build:
+    context: ./public-media
+  container_name: changemaker-public-media
+  restart: unless-stopped
+  ports:
+    - "${PUBLIC_MEDIA_PORT:-3100}:80"
+  healthcheck:
+    test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/"]
+    interval: 30s
+    timeout: 5s
+    retries: 3
+    start_period: 10s
+  depends_on:
+    - api
+    - media-api
+

+

Key Features: +- Static build: React app served by Nginx (not Vite dev server) +- Fast startup: 10s start period (static files load quickly)

+

Access: http://localhost:3100 or /gallery/ path via main Nginx

+
+

Tunnel Services

+

newt

+

Purpose: Pangolin tunnel connector (replaces Cloudflare Tunnel)

+

Configuration: +

newt:
+  image: fosrl/newt
+  container_name: newt-changemaker
+  restart: unless-stopped
+  environment:
+    - PANGOLIN_ENDPOINT=${PANGOLIN_ENDPOINT}
+    - NEWT_ID=${PANGOLIN_NEWT_ID}
+    - NEWT_SECRET=${PANGOLIN_NEWT_SECRET}
+  depends_on:
+    - nginx
+

+

Key Features: +- Self-hosted: Connects to Pangolin server at api.bnkserve.org +- Nginx dependency: All traffic routes through nginx:80 +- Auto-reconnect: restart: unless-stopped handles connection drops

+

Setup: Use admin PangolinPage.tsx wizard to configure org → site → endpoint → resource

+

See Tunneling for complete setup guide.

+
+

Monitoring Services (profile: monitoring)

+

prometheus

+

Purpose: Metrics collection and alerting

+

Configuration: +

prometheus:
+  image: prom/prometheus:latest
+  container_name: prometheus-changemaker
+  command:
+    - '--config.file=/etc/prometheus/prometheus.yml'
+    - '--storage.tsdb.path=/prometheus'
+    - '--storage.tsdb.retention.time=30d'
+  ports:
+    - "${PROMETHEUS_PORT:-9090}:9090"
+  volumes:
+    - ./configs/prometheus:/etc/prometheus
+    - prometheus-data:/prometheus
+  restart: always
+  profiles:
+    - monitoring
+

+

Key Features: +- 30-day retention: --storage.tsdb.retention.time=30d +- Custom metrics: 12 cm_* metrics from API +- Alert rules: alerts.yml defines 12+ alert conditions

+

Scrape Targets: +- changemaker-v2-api:4000/api/metrics (10s interval) +- redis-exporter:9121 (15s interval) +- cadvisor:8080 (15s interval) +- node-exporter:9100 (15s interval)

+

Access: http://localhost:9090

+

See Monitoring Stack for complete configuration.

+
+

grafana

+

Purpose: Metrics visualization

+

Configuration: +

grafana:
+  image: grafana/grafana:latest
+  container_name: grafana-changemaker
+  ports:
+    - "${GRAFANA_PORT:-3001}:3000"
+  environment:
+    - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
+    - GF_USERS_ALLOW_SIGN_UP=false
+    - GF_SECURITY_ALLOW_EMBEDDING=true  # For admin iframe
+  volumes:
+    - grafana-data:/var/lib/grafana
+    - ./configs/grafana:/etc/grafana/provisioning
+  restart: always
+  depends_on:
+    - prometheus
+  profiles:
+    - monitoring
+

+

Key Features: +- Auto-provisioning: Dashboards from configs/grafana/ auto-load on startup +- 3 dashboards: Application Overview, API Performance, System Health +- Prometheus datasource: Auto-configured via datasources.yml

+

Access: http://localhost:3001 (admin/admin default)

+
+

cadvisor

+

Purpose: Container resource metrics

+

Configuration: +

cadvisor:
+  image: gcr.io/cadvisor/cadvisor:latest
+  container_name: cadvisor-changemaker
+  ports:
+    - "${CADVISOR_PORT:-8080}:8080"
+  volumes:
+    - /:/rootfs:ro
+    - /var/run:/var/run:ro
+    - /sys:/sys:ro
+    - /var/lib/docker/:/var/lib/docker:ro
+    - /dev/disk/:/dev/disk:ro
+  privileged: true
+  devices:
+    - /dev/kmsg
+  restart: always
+  profiles:
+    - monitoring
+

+

Key Features: +- Privileged mode: Required for full system access +- Host filesystem: Read-only mounts for metrics collection

+

Access: http://localhost:8080

+
+

node-exporter

+

Purpose: Host system metrics (CPU, memory, disk, network)

+

Configuration: +

node-exporter:
+  image: prom/node-exporter:latest
+  container_name: node-exporter-changemaker
+  ports:
+    - "${NODE_EXPORTER_PORT:-9100}:9100"
+  command:
+    - '--path.rootfs=/host'
+    - '--path.procfs=/host/proc'
+    - '--path.sysfs=/host/sys'
+    - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
+  volumes:
+    - /proc:/host/proc:ro
+    - /sys:/host/sys:ro
+    - /:/rootfs:ro
+  restart: always
+  profiles:
+    - monitoring
+

+

Key Features: +- Host metrics: CPU, memory, disk, network from host (not container) +- Filesystem filters: Excludes virtual filesystems

+

Access: http://localhost:9100/metrics

+
+

redis-exporter

+

Purpose: Redis metrics (memory, commands, connections)

+

Configuration: +

redis-exporter:
+  image: oliver006/redis_exporter:latest
+  container_name: redis-exporter-changemaker
+  ports:
+    - "${REDIS_EXPORTER_PORT:-9121}:9121"
+  environment:
+    - REDIS_ADDR=redis:6379
+    - REDIS_PASSWORD=${REDIS_PASSWORD}  # Required for authenticated Redis
+  restart: always
+  depends_on:
+    - redis
+  profiles:
+    - monitoring
+

+

Key Features: +- Authenticated connection: Uses REDIS_PASSWORD env var +- Memory metrics: Tracks Redis memory usage

+

Access: http://localhost:9121/metrics

+
+

alertmanager

+

Purpose: Alert routing and notification

+

Configuration: +

alertmanager:
+  image: prom/alertmanager:latest
+  container_name: alertmanager-changemaker
+  ports:
+    - "${ALERTMANAGER_PORT:-9093}:9093"
+  volumes:
+    - ./configs/alertmanager:/etc/alertmanager
+    - alertmanager-data:/alertmanager
+  command:
+    - '--config.file=/etc/alertmanager/alertmanager.yml'
+    - '--storage.path=/alertmanager'
+  restart: always
+  profiles:
+    - monitoring
+

+

Key Features: +- Alert grouping: Prevents notification spam +- Multiple receivers: Email, Slack, webhook, Gotify

+

Configuration: Edit configs/alertmanager/alertmanager.yml

+

Access: http://localhost:9093

+
+

gotify

+

Purpose: Push notification server (optional alert receiver)

+

Configuration: +

gotify:
+  image: gotify/server:latest
+  container_name: gotify-changemaker
+  ports:
+    - "${GOTIFY_PORT:-8889}:80"
+  environment:
+    - GOTIFY_DEFAULTUSER_NAME=${GOTIFY_ADMIN_USER:-admin}
+    - GOTIFY_DEFAULTUSER_PASS=${GOTIFY_ADMIN_PASSWORD:-admin}
+  volumes:
+    - gotify-data:/app/data
+  restart: always
+  profiles:
+    - monitoring
+

+

Key Features: +- Push notifications: Mobile app support (iOS/Android) +- Webhook receiver: Integrates with Alertmanager

+

Access: http://localhost:8889

+
+

Networks & Volumes

+

Networks

+

changemaker-lite: Bridge network shared by all services

+
networks:
+  changemaker-lite:
+    driver: bridge
+
+

Features: +- Automatic DNS: Containers resolve each other by name (e.g., changemaker-v2-api:4000) +- Isolation: No external network access unless ports explicitly exposed +- Service discovery: Docker's internal DNS server (127.0.0.11)

+
+

Volumes

+

Named volumes (Docker-managed, persistent across container recreation):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VolumePurposeSize Estimate
v2-postgres-dataV2 PostgreSQL database1-10GB (depends on data)
nocodb-v2-dataNocoDB metadata + uploads100MB-1GB
redis-dataRedis AOF log + RDB snapshots50-500MB
listmonk-dataListmonk PostgreSQL database100MB-5GB
n8n-datan8n workflows + credentials10-100MB
gitea-dataGit repositories + attachments1-50GB
mysql-dataGitea MySQL database100MB-2GB
prometheus-dataPrometheus TSDB (30 days)1-5GB
grafana-dataGrafana dashboards + config10-100MB
alertmanager-dataAlert state + silences1-10MB
gotify-dataGotify messages + apps10-100MB
+

Bind mounts (host directories):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Bind MountContainer PathPurposePermissions
./api/appAPI source coderw
./admin/appAdmin source coderw
./assets/uploads/app/uploads, /listmonk/uploadsShared uploadsrw
./mkdocs/docs, /mkdocsDocumentation sourcerw
./data/dataNAR import dataro
./nginx/conf.d/etc/nginx/conf.dNginx configro
./configs/prometheus/etc/prometheusPrometheus configro
./configs/grafana/etc/grafana/provisioningGrafana configro
/var/run/docker.sock/var/run/docker.sockDocker APIrw
+

Important: Media library requires special mount: +

- ${MEDIA_ROOT:-./media}:/media:ro              # Main library (read-only)
+- ${MEDIA_ROOT:-./media}/local/inbox:/media/local/inbox:rw  # Upload inbox (writable)
+

+
+

Starting Services

+

Basic Commands

+

Start all core services: +

docker compose up -d
+

+

Start with monitoring stack: +

docker compose --profile monitoring up -d
+

+

Start specific service: +

docker compose up -d api
+

+

Start with rebuild: +

docker compose up -d --build api admin
+

+

Stop all services: +

docker compose down
+

+

Stop and remove volumes (⚠️ destroys all data): +

docker compose down -v
+

+
+

Development Workflow

+

1. Initial setup (first time only): +

# Start core services
+docker compose up -d v2-postgres redis api admin
+
+# Wait for API to be healthy
+docker compose ps api  # Check status
+
+# Run migrations
+docker compose exec api npx prisma migrate deploy
+
+# Seed database
+docker compose exec api npx prisma db seed
+

+

2. Daily development: +

# Start services
+docker compose up -d v2-postgres redis api admin
+
+# View logs (live tail)
+docker compose logs -f api
+
+# Restart single service
+docker compose restart api
+
+# Check health status
+docker compose ps
+

+

3. Full stack with monitoring: +

# Start everything
+docker compose --profile monitoring up -d
+
+# Check Prometheus targets
+curl http://localhost:9090/api/v1/targets
+
+# View Grafana dashboards
+open http://localhost:3001
+

+
+

Log Management

+

View logs: +

# All services (last 50 lines)
+docker compose logs --tail=50
+
+# Specific service (live tail)
+docker compose logs -f api
+
+# Multiple services
+docker compose logs -f api media-api
+
+# With timestamps
+docker compose logs -f --timestamps api
+
+# Since timestamp
+docker compose logs --since 2024-01-01T00:00:00 api
+

+

Log rotation: Configured in docker-compose.yml for Redis + MailHog: +

logging:
+  driver: "json-file"
+  options:
+    max-size: "5m"
+    max-file: "2"
+

+
+

Health Checks

+

Check service health: +

# All services (shows health status)
+docker compose ps
+
+# Filter unhealthy services
+docker compose ps | grep unhealthy
+
+# Inspect health check details
+docker inspect changemaker-v2-api | jq '.[0].State.Health'
+

+

Services with health checks: +- api: wget http://localhost:4000/api/health (30s start period) +- media-api: wget http://127.0.0.1:4100/health (30s start period) +- admin: wget http://127.0.0.1:3000/ (20s start period) +- v2-postgres: pg_isready -U changemaker (5 retries) +- redis: redis-cli -a ${REDIS_PASSWORD} ping (5 retries) +- gitea-app: curl http://localhost:3000/ (30s start period) +- n8n: wget http://localhost:5678/healthz (30s start period)

+

Dependency chains (via depends_on with condition: service_healthy): +- api waits for v2-postgres + redis +- media-api waits for v2-postgres +- nocodb-v2 waits for v2-postgres

+

See Health Checks for detailed configuration.

+
+

Troubleshooting

+

Port Conflicts

+

Problem: Error: bind: address already in use

+

Solution: +

# Find process using port
+sudo lsof -i :4000
+sudo netstat -tulpn | grep :4000
+
+# Change port in .env
+echo "API_PORT=4002" >> .env
+
+# Restart service
+docker compose up -d api
+

+

Common conflicts: +- Port 3000: Homepage, Grafana, admin (set ADMIN_PORT=3005) +- Port 4000: API, MkDocs v1 (set MKDOCS_PORT=4003) +- Port 5432: Listmonk DB, system PostgreSQL (bind to 127.0.0.1 in compose file)

+
+

Volume Permission Issues

+

Problem: EACCES: permission denied or mkdir: cannot create directory

+

Cause: Container user mismatch with host filesystem

+

Solution: +

# Fix ownership (run on host)
+sudo chown -R $USER:$USER ./api ./admin ./mkdocs ./assets
+
+# Set USER_ID/GROUP_ID in .env
+id -u  # Get your UID
+id -g  # Get your GID
+echo "USER_ID=$(id -u)" >> .env
+echo "GROUP_ID=$(id -g)" >> .env
+
+# Recreate containers
+docker compose up -d --force-recreate
+

+

Services using user mapping: +- mkdocs: user: "${USER_ID}:${GROUP_ID}" +- code-server: user: "${USER_ID}:${GROUP_ID}" +- homepage: PUID=${USER_ID}, PGID=${DOCKER_GROUP_ID}

+
+

Network Issues

+

Problem: Containers can't communicate (e.g., API can't reach Redis)

+

Solution: +

# Verify network exists
+docker network ls | grep changemaker-lite
+
+# Inspect network
+docker network inspect changemaker-lite
+
+# Check container connectivity
+docker compose exec api ping redis-changemaker
+
+# Recreate network
+docker compose down
+docker compose up -d
+

+

DNS resolution: Containers use Docker's internal DNS (127.0.0.11). Reference services by container name: +- ✅ redis-changemaker:6379 +- ❌ localhost:6379 (only works if port exposed to host)

+
+

Database Migration Failures

+

Problem: prisma migrate deploy fails with "relation already exists"

+

Solution: +

# Reset database (⚠️ destroys data)
+docker compose exec api npx prisma migrate reset --force
+
+# Or: Fix manually
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2
+
+# Check migration status
+docker compose exec api npx prisma migrate status
+
+# Force resolve migration
+docker compose exec api npx prisma migrate resolve --applied "20240101000000_init"
+

+
+

Container Crashes / Restart Loops

+

Problem: Container repeatedly restarting

+

Diagnosis: +

# Check logs for crash reason
+docker compose logs --tail=100 api
+
+# Check exit code
+docker inspect changemaker-v2-api | jq '.[0].State'
+
+# Check resource limits
+docker stats changemaker-v2-api
+

+

Common causes: +- Missing env vars: Check .env file for required secrets +- Health check failing: Inspect health check logs +- Out of memory: Increase Docker memory limit or add resource constraints +- Port binding failure: Check for port conflicts

+

Fix: +

# Restart with fresh logs
+docker compose up -d --force-recreate api
+
+# Check health
+docker compose ps api
+

+
+

Monitoring Stack Not Starting

+

Problem: Prometheus/Grafana containers missing

+

Cause: Monitoring services behind profiles: [monitoring]

+

Solution: +

# Start with monitoring profile
+docker compose --profile monitoring up -d
+
+# Or: Explicitly start monitoring services
+docker compose up -d prometheus grafana
+

+
+

Media Upload Failures

+

Problem: Video uploads fail with EACCES or timeout

+

Diagnosis: +

# Check media-api logs
+docker compose logs -f media-api
+
+# Verify inbox permissions
+ls -la ./media/local/inbox
+
+# Check disk space
+df -h
+

+

Solution: +

# Ensure inbox is writable
+chmod 755 ./media/local/inbox
+
+# Verify RW mount in docker-compose.yml
+grep "inbox:rw" docker-compose.yml
+
+# Recreate container
+docker compose up -d --force-recreate media-api
+

+

Important: Inbox must have :rw flag; main library stays :ro.

+
+

Production Deployment

+

Resource Limits

+

Production recommendations:

+
# Add to services in docker-compose.yml
+deploy:
+  resources:
+    limits:
+      cpus: '2'
+      memory: 2G
+    reservations:
+      cpus: '0.5'
+      memory: 512M
+
+

Recommended limits: +- api: 2 CPU, 2GB RAM +- media-api: 2 CPU, 2GB RAM (for FFprobe) +- v2-postgres: 2 CPU, 4GB RAM +- redis: 1 CPU, 512MB RAM (already set) +- listmonk-app: 1 CPU, 1GB RAM +- grafana: 1 CPU, 512MB RAM

+
+

Healthcheck Tuning

+

Production healthcheck configuration:

+
healthcheck:
+  interval: 30s      # Check every 30s (default: 15s)
+  timeout: 10s       # Allow 10s for response (default: 5s)
+  retries: 5         # 5 failures before unhealthy (default: 3)
+  start_period: 60s  # 60s grace period on startup (default: 30s)
+
+

Rationale: +- Longer intervals reduce overhead +- Higher retries prevent false positives +- Longer start periods for slow database migrations

+
+

Log Management

+

Production logging configuration:

+
# Add to all services
+logging:
+  driver: "json-file"
+  options:
+    max-size: "10m"
+    max-file: "5"
+
+

Alternative: Use centralized logging (e.g., Loki + Promtail): +

logging:
+  driver: "loki"
+  options:
+    loki-url: "http://loki:3100/loki/api/v1/push"
+

+
+

Restart Policies

+

Production restart policies: +- restart: always — For critical services (db, redis, api) +- restart: unless-stopped — For most services (respects manual stops) +- restart: on-failure — For optional services (monitoring)

+

Current configuration: Most services use unless-stopped (allows manual shutdown).

+
+

Backup Strategy

+

Automated backups (via cron): +

# Add to crontab
+0 2 * * * /home/user/changemaker.lite/scripts/backup.sh --s3 >> /var/log/changemaker-backup.log 2>&1
+

+

What gets backed up: +- V2 PostgreSQL database (pg_dump) +- Listmonk PostgreSQL database (pg_dump) +- Uploads directory (tar.gz)

+

See Backup & Restore for complete procedures.

+
+

Security Hardening

+

Production checklist: +- [ ] Change all default passwords in .env +- [ ] Set strong REDIS_PASSWORD (required since Security Audit 2025-02-11) +- [ ] Bind PostgreSQL ports to 127.0.0.1 (not 0.0.0.0) +- [ ] Enable SSL/TLS via Nginx (see SSL/TLS) +- [ ] Set ENCRYPTION_KEY (must differ from JWT secrets) +- [ ] Disable EMAIL_TEST_MODE (use real SMTP) +- [ ] Set NODE_ENV=production +- [ ] Review Nginx security headers (CSP, HSTS, Permissions-Policy) +- [ ] Restrict NocoDB to read-only access (revoke INSERT/UPDATE/DELETE) +- [ ] Enable Prometheus scraping authentication (basic auth)

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/deployment/environment-variables/index.html b/mkdocs/site/v2/deployment/environment-variables/index.html new file mode 100644 index 00000000..e86cc957 --- /dev/null +++ b/mkdocs/site/v2/deployment/environment-variables/index.html @@ -0,0 +1,7144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Environment Variables - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Environment Variables Reference

+

Overview

+

Changemaker Lite V2 uses over 100 environment variables to configure services, credentials, and feature flags. This document provides a complete reference organized by functional area.

+

Configuration File: .env (never committed to Git)

+

Template: .env.example (committed, safe to share)

+

Validation: api/src/config/env.ts (Zod schema validates all variables on startup)

+
+

Quick Start

+

Initial Setup

+
# Copy template
+cp .env.example .env
+
+# Generate secrets
+openssl rand -hex 32  # For JWT_ACCESS_SECRET
+openssl rand -hex 32  # For JWT_REFRESH_SECRET
+openssl rand -hex 32  # For ENCRYPTION_KEY (must differ from JWT secrets!)
+openssl rand -hex 16  # For LISTMONK_API_TOKEN
+
+# Edit .env
+nano .env
+
+

Minimal Required Variables

+

Must set before first start: +

V2_POSTGRES_PASSWORD=<strong-password>
+REDIS_PASSWORD=<strong-password>
+JWT_ACCESS_SECRET=<openssl-rand-hex-32>
+JWT_REFRESH_SECRET=<openssl-rand-hex-32>
+ENCRYPTION_KEY=<openssl-rand-hex-32>  # Production only
+

+

All other variables have safe defaults for development.

+
+

General Configuration

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
NODE_ENVdevelopmentNoEnvironment mode (development | production)
DOMAINcmlite.orgNoBase domain for subdomain routing
USER_ID1000NoHost user ID for volume permissions
GROUP_ID1000NoHost group ID for volume permissions
DOCKER_GROUP_ID984NoDocker group ID (for homepage container)
+

Usage: +

NODE_ENV=production docker compose up -d
+

+
+

V2 PostgreSQL

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
V2_POSTGRES_USERchangemakerNoPostgreSQL username
V2_POSTGRES_PASSWORDCHANGE_ME_STRONG_PASSWORDYesPostgreSQL password
V2_POSTGRES_DBchangemaker_v2NoDatabase name
V2_POSTGRES_PORT5433NoHost port (container always 5432)
+

Connection String (auto-generated in docker-compose.yml): +

postgresql://changemaker:PASSWORD@changemaker-v2-postgres:5432/changemaker_v2
+

+

Port Binding: 127.0.0.1:5433:5432 (localhost only for security)

+

Important: Change V2_POSTGRES_PASSWORD before production deployment.

+
+

JWT Authentication

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
JWT_ACCESS_SECRETGENERATE_WITH_openssl_rand_hex_32YesAccess token secret (15min lifespan)
JWT_REFRESH_SECRETGENERATE_WITH_openssl_rand_hex_32YesRefresh token secret (7 day lifespan)
JWT_ACCESS_EXPIRY15mNoAccess token expiration (15m, 1h, etc.)
JWT_REFRESH_EXPIRY7dNoRefresh token expiration (7d, 30d, etc.)
ENCRYPTION_KEYGENERATE_WITH_openssl_rand_hex_32Yes (prod)DB encryption key for SMTP passwords, etc.
+

Security Requirements (enforced by Zod schema): +- JWT_ACCESS_SECRET must be 32+ characters +- JWT_REFRESH_SECRET must be 32+ characters +- ENCRYPTION_KEY must be 32+ characters and differ from JWT secrets

+

Generation: +

export JWT_ACCESS_SECRET=$(openssl rand -hex 32)
+export JWT_REFRESH_SECRET=$(openssl rand -hex 32)
+export ENCRYPTION_KEY=$(openssl rand -hex 32)
+echo "JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}" >> .env
+echo "JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}" >> .env
+echo "ENCRYPTION_KEY=${ENCRYPTION_KEY}" >> .env
+

+

Production Note: ENCRYPTION_KEY required in production (dev mode allows empty for testing).

+
+

Redis

+ + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
REDIS_PASSWORDCHANGE_ME_REDIS_PASSWORDYesRedis authentication password
REDIS_URLredis://:PASSWORD@redis-changemaker:6379NoFull connection URL (auto-generated)
+

Format: redis://[:<password>@]<host>:<port>[/<db>]

+

Example: +

REDIS_PASSWORD=mySecurePassword123
+REDIS_URL=redis://:mySecurePassword123@redis-changemaker:6379
+

+

Security Note: As of Security Audit 2025-02-11, Redis requires authentication in production.

+

Docker Command (in docker-compose.yml): +

command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru --requirepass "${REDIS_PASSWORD}"
+

+
+

API Configuration

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
API_PORT4000NoExpress API port (host)
API_URLhttp://localhost:4000NoPublic API URL (for emails, OAuth redirects)
CORS_ORIGINShttp://localhost:3000,http://localhostNoAllowed CORS origins (comma-separated)
+

Production Example: +

API_PORT=4000
+API_URL=https://api.cmlite.org
+CORS_ORIGINS=https://app.cmlite.org,https://cmlite.org
+

+

CORS Note: List all frontend origins (admin, public site, media gallery).

+
+

Admin GUI

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
ADMIN_PORT3000NoAdmin GUI port (host)
ADMIN_URLhttp://localhost:3000NoPublic admin URL
VITE_API_URLhttp://changemaker-v2-api:4000NoAPI URL for Vite proxy (Docker internal)
VITE_MEDIA_API_URLhttp://changemaker-media-api:4100NoMedia API URL for Vite proxy
VITE_MKDOCS_URLhttp://mkdocs-changemaker:8000NoMkDocs URL for iframe embed
+

Development vs Production:

+

Development (Docker): +

VITE_API_URL=http://changemaker-v2-api:4000  # Container name
+VITE_MEDIA_API_URL=http://changemaker-media-api:4100
+

+

Development (local): +

VITE_API_URL=http://localhost:4000  # Localhost
+VITE_MEDIA_API_URL=http://localhost:4100
+

+

Production: Vite build embeds these URLs at build time.

+
+

Nginx

+ + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
NGINX_HTTP_PORT80NoHTTP port
NGINX_HTTPS_PORT443NoHTTPS port
+

Port Mapping (docker-compose.yml): +

nginx:
+  ports:
+    - "80:80"
+    - "443:443"
+    - "8881:8881"  # NocoDB embed proxy
+    - "8882:8882"  # n8n embed proxy
+    - "8883:8883"  # Gitea embed proxy
+    - "8884:8884"  # MailHog embed proxy
+    - "8885:8885"  # Mini QR embed proxy
+

+

Custom Ports (if 80/443 occupied): +

NGINX_HTTP_PORT=8080
+NGINX_HTTPS_PORT=8443
+

+
+

SMTP / Email

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
SMTP_HOSTmailhog-changemakerNoSMTP server hostname
SMTP_PORT1025NoSMTP server port
SMTP_USER``NoSMTP username (empty for MailHog)
SMTP_PASS``NoSMTP password
SMTP_FROMnoreply@cmlite.orgNoDefault sender email
SMTP_FROM_NAMEChangemaker LiteNoDefault sender name
EMAIL_TEST_MODEtrueNoRoute all emails to MailHog (dev mode)
TEST_EMAIL_RECIPIENTadmin@cmlite.orgNoOverride recipient in test mode
+

Development (MailHog): +

SMTP_HOST=mailhog-changemaker
+SMTP_PORT=1025
+SMTP_USER=
+SMTP_PASS=
+EMAIL_TEST_MODE=true
+

+

Production (e.g., ProtonMail): +

SMTP_HOST=smtp.protonmail.ch
+SMTP_PORT=587
+SMTP_USER=your@email.com
+SMTP_PASS=your-app-password
+EMAIL_TEST_MODE=false
+

+

Test Mode Behavior: +- true: All emails sent to MailHog (visible at http://localhost:8025) +- false: Emails sent to real recipients via SMTP

+

SiteSettings Override: Admins can override SMTP config via /app/settings (stored encrypted in DB).

+
+

Listmonk

+

Database

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
LISTMONK_DB_PORT5432NoListmonk PostgreSQL port
LISTMONK_DB_USERlistmonkNoDatabase username
LISTMONK_DB_PASSWORDCHANGE_ME_LISTMONK_PASSWORDYesDatabase password
LISTMONK_DB_NAMElistmonkNoDatabase name
+

Web Admin

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
LISTMONK_PORT9001NoListmonk web UI port
LISTMONK_WEB_ADMIN_USERadminNoWeb UI username
LISTMONK_WEB_ADMIN_PASSWORDCHANGE_ME_LISTMONK_ADMINYesWeb UI password
+

API Integration

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
LISTMONK_API_USERv2-apiNoAPI user (auto-created by listmonk-init)
LISTMONK_API_TOKENGENERATE_WITH_openssl_rand_hex_16YesAPI token (plaintext, not bcrypt)
LISTMONK_ADMIN_USERv2-apiNoAlias for API user (V2 uses this)
LISTMONK_ADMIN_PASSWORDSAME_AS_LISTMONK_API_TOKENYesAlias for API token
LISTMONK_SYNC_ENABLEDfalseNoEnable participant/location sync
LISTMONK_PROXY_PORT9002NoOAuth proxy port (for future integrations)
+

API User Setup: The listmonk-init container auto-creates the API user by directly inserting into PostgreSQL.

+

Token Generation: +

export LISTMONK_API_TOKEN=$(openssl rand -hex 16)
+echo "LISTMONK_API_TOKEN=${LISTMONK_API_TOKEN}" >> .env
+echo "LISTMONK_ADMIN_PASSWORD=${LISTMONK_API_TOKEN}" >> .env
+

+

Sync Behavior: +- false: Manual sync only (default) +- true: Auto-sync participants/locations to Listmonk lists on signup/create

+

SMTP Configuration

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
LISTMONK_SMTP_HOSTmailhog-changemakerNoSMTP server for newsletters
LISTMONK_SMTP_PORT1025NoSMTP port
LISTMONK_SMTP_USER``NoSMTP username
LISTMONK_SMTP_PASSWORD``NoSMTP password
LISTMONK_SMTP_TLS_TYPEnoneNoTLS mode (none | STARTTLS | TLS)
LISTMONK_SMTP_FROMChangemaker Lite <noreply@cmlite.org>NoNewsletter sender
+

listmonk-init Behavior: Configures dual SMTP providers (MailHog + production if credentials set).

+
+

Represent API

+ + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
REPRESENT_API_URLhttps://represent.opennorth.caNoRepresent API endpoint (Canadian electoral data)
+

Free Public API: No authentication required.

+

Usage: Postal code → representative lookup for Influence campaigns.

+
+

NocoDB

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
NOCODB_V2_PORT8091NoNocoDB web UI port
NOCODB_URLhttp://changemaker-v2-nocodb:8080NoInternal NocoDB URL
NC_ADMIN_EMAILadmin@cmlite.orgNoAdmin email
NC_ADMIN_PASSWORDCHANGE_ME_NOCODB_PASSWORDYesAdmin password
NC_PUBLIC_URLhttp://localhost:8091NoPublic NocoDB URL
+

Database Connection: Uses separate nocodb_meta database (auto-created by init-nocodb-db.sh).

+

Connection String: +

pg://changemaker-v2-postgres:5432?u=changemaker&p=PASSWORD&d=nocodb_meta
+

+
+

Media Management

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
ENABLE_MEDIA_FEATURESfalseNoEnable media manager features
MEDIA_API_PORT4100NoFastify media API port
MEDIA_API_PUBLIC_URLhttp://media-api:4100NoPublic media API URL
MEDIA_ROOT/media/libraryNoMedia library root path
MEDIA_UPLOADS/media/uploadsNoUpload staging directory
MAX_UPLOAD_SIZE_GB10NoMax video upload size (GB)
PUBLIC_MEDIA_PORT3100NoPublic media gallery port
VIDEO_PLAYER_DEBUGfalseNoEnable video.js debug logging
+

Feature Flag: Set ENABLE_MEDIA_FEATURES=true to activate media routes.

+

Volume Mounts (in docker-compose.yml): +

volumes:
+  - ${MEDIA_ROOT:-./media}:/media:ro              # Library (read-only)
+  - ${MEDIA_ROOT:-./media}/local/inbox:/media/local/inbox:rw  # Inbox (writable)
+

+

Supported Formats: MP4, MOV, AVI, MKV, WebM, M4V, FLV

+
+

Gitea

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
GITEA_URLhttp://gitea-changemaker:3000NoInternal Gitea URL
GITEA_WEB_PORT3030NoGitea web UI port
GITEA_SSH_PORT2222NoGitea SSH port (for git push/pull)
GITEA_DB_TYPEmysqlNoDatabase type
GITEA_DB_HOSTgitea-db:3306NoMySQL hostname
GITEA_DB_NAMEgiteaNoDatabase name
GITEA_DB_USERgiteaNoDatabase username
GITEA_DB_PASSWDCHANGE_ME_GITEA_DBYesDatabase password
GITEA_DB_ROOT_PASSWORDCHANGE_ME_GITEA_ROOTYesMySQL root password
GITEA_ROOT_URLhttps://git.cmlite.orgNoPublic Gitea URL
GITEA_DOMAINgit.cmlite.orgNoGitea domain
+

First-Time Setup: Visit http://localhost:3030 to create admin account.

+

Git Commands: +

# Clone via HTTP
+git clone http://localhost:3030/user/repo.git
+
+# Clone via SSH
+git clone ssh://git@localhost:2222/user/repo.git
+

+
+

n8n

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
N8N_URLhttp://n8n-changemaker:5678NoInternal n8n URL
N8N_PORT5678Non8n port
N8N_HOSTn8n.cmlite.orgNoPublic n8n hostname
N8N_ENCRYPTION_KEYCHANGE_ME_N8N_KEYYesWorkflow encryption key
N8N_USER_EMAILadmin@example.comNoDefault admin email
N8N_USER_PASSWORDCHANGE_ME_N8N_PASSWORDYesDefault admin password
GENERIC_TIMEZONEUTCNoWorkflow timezone
+

First Start: n8n creates admin user with N8N_USER_EMAIL/N8N_USER_PASSWORD automatically.

+

Encryption Key: Used to encrypt credentials in workflows.

+
+

MkDocs

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
MKDOCS_PORT4003NoMkDocs live preview port
MKDOCS_SITE_SERVER_PORT4001NoMkDocs static site port
BASE_DOMAINhttps://cmlite.orgNoSite URL for sitemap/canonical
MKDOCS_PREVIEW_URLhttp://mkdocs:8000NoInternal preview URL
MKDOCS_DOCS_PATH/mkdocs/docsNoDocumentation source path
+

Port Change: Was 4000 in V1, changed to 4003 to avoid conflict with API.

+

Live Reload: http://localhost:4003 (updates on file save)

+

Static Build: http://localhost:4001 (Nginx-served production build)

+
+

Code Server

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
CODE_SERVER_PORT8888NoCode Server port
CODE_SERVER_URLhttp://code-server:8080NoInternal Code Server URL
USER_NAMEcoderNoCode Server username
+

Access: http://localhost:8888

+

Password: Set in configs/code-server/.config/code-server/config.yaml

+
+

Homepage

+ + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
HOMEPAGE_PORT3010NoHomepage dashboard port
HOMEPAGE_VAR_BASE_URLhttp://localhostNoBase URL for service links
+

Configuration: Edit configs/homepage/services.yaml to customize dashboard.

+
+

Mini QR

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
MINI_QR_PORT8089NoMini QR service port
MINI_QR_URLhttp://mini-qr:8080NoInternal Mini QR URL
MINI_QR_EMBED_PORT8885NoNginx embed proxy port
+

Usage: Walk sheets + cut exports embed QR codes via API or iframe.

+
+

MailHog

+ + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
MAILHOG_SMTP_PORT1025NoSMTP port (internal only)
MAILHOG_WEB_PORT8025NoWeb UI port
+

Web UI: http://localhost:8025

+

SMTP: Only accessible from Docker network (not exposed to host).

+
+

NAR Import

+ + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
NAR_DATA_DIR/dataNoPath to NAR data directory (in container)
+

Host Mount (in docker-compose.yml): +

volumes:
+  - ./data:/data:ro  # Read-only NAR data
+

+

Data Structure: +

./data/
+└─ 202501/  (YYYYMM)
+   ├─ Addresses/
+   │  ├─ Address_10.txt  (PEI)
+   │  ├─ Address_24_part_1.txt  (Quebec part 1)
+   │  └─ ...
+   └─ Locations/
+      ├─ Location_10.txt
+      └─ ...
+

+

Download: https://www150.statcan.gc.ca/n1/pub/46-26-0002/462600022022001-eng.htm

+
+

Geocoding

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
MAPBOX_API_KEY``NoMapbox API key (optional, 100k free/month)
GEOCODING_RATE_LIMIT_MS1100NoDelay between provider requests (ms)
GEOCODING_CACHE_ENABLEDtrueNoEnable Redis caching
GEOCODING_CACHE_TTL_HOURS24NoCache TTL in hours
GOOGLE_MAPS_API_KEY``NoGoogle Maps API key (optional, paid)
GOOGLE_MAPS_ENABLEDfalseNoEnable Google geocoding provider
GEOCODING_PARALLEL_ENABLEDtrueNoParallel geocoding for bulk imports
GEOCODING_BATCH_SIZE10NoBatch size for parallel geocoding
BULK_GEOCODE_ENABLEDtrueNoEnable bulk re-geocode feature
BULK_GEOCODE_MAX_BATCH5000NoMax locations per bulk geocode batch
+

Providers (in fallback order): +1. Nominatim (OpenStreetMap, free) +2. ArcGIS (free tier) +3. Photon (free) +4. Mapbox (100k free/month, requires API key) +5. LocationIQ (free tier) +6. Google (paid, most accurate)

+

Recommendation: Add MAPBOX_API_KEY for better accuracy without cost.

+
+

Pangolin Tunnel

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
PANGOLIN_API_URLhttps://api.bnkserve.org/v1NoPangolin API endpoint
PANGOLIN_API_KEY``NoPangolin API key
PANGOLIN_ORG_ID``NoOrganization ID (from setup wizard)
PANGOLIN_SITE_ID``NoSite ID (from setup wizard)
PANGOLIN_ENDPOINThttps://pangolin.bnkserve.orgNoTunnel endpoint URL
PANGOLIN_NEWT_ID``NoNewt connector ID
PANGOLIN_NEWT_SECRET``NoNewt connector secret
+

Setup Workflow: +1. Visit /app/pangolin in admin GUI +2. Enter PANGOLIN_API_KEY +3. Create org → site → endpoint → resource +4. Copy NEWT_ID/NEWT_SECRET to .env +5. Restart Newt container

+

Manual Setup: +

# Set API key
+export PANGOLIN_API_KEY=your-api-key
+
+# Create org (returns ORG_ID)
+curl -H "Authorization: Bearer $PANGOLIN_API_KEY" \
+  https://api.bnkserve.org/v1/orgs \
+  -d '{"name":"My Organization"}'
+
+# Create site (returns SITE_ID)
+curl -H "Authorization: Bearer $PANGOLIN_API_KEY" \
+  https://api.bnkserve.org/v1/sites \
+  -d '{"org_id":"ORG_ID","name":"Production Site"}'
+
+# Continue setup...
+

+

See Tunneling for complete guide.

+
+

Monitoring

+

Prometheus

+ + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
PROMETHEUS_PORT9090NoPrometheus port
+

Scrape Targets (configured in configs/prometheus/prometheus.yml): +- changemaker-v2-api:4000/api/metrics (10s interval) +- redis-exporter:9121 (15s interval) +- cadvisor:8080 (15s interval) +- node-exporter:9100 (15s interval)

+

Retention: 30 days (configured in docker-compose.yml command).

+

Grafana

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
GRAFANA_PORT3001NoGrafana port
GRAFANA_ADMIN_PASSWORDadminNoAdmin password
GRAFANA_ROOT_URLhttp://localhost:3001NoPublic Grafana URL
+

Default Login: admin / admin (change on first login)

+

Dashboards: 3 pre-configured dashboards auto-provisioned from configs/grafana/

+

Exporters

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
CADVISOR_PORT8080NocAdvisor container metrics port
NODE_EXPORTER_PORT9100NoNode exporter system metrics port
REDIS_EXPORTER_PORT9121NoRedis exporter port
+

Alertmanager

+ + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
ALERTMANAGER_PORT9093NoAlertmanager port
+

Configuration: Edit configs/alertmanager/alertmanager.yml for notification receivers.

+

Gotify

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultRequiredDescription
GOTIFY_PORT8889NoGotify push notification server port
GOTIFY_ADMIN_USERadminNoGotify admin username
GOTIFY_ADMIN_PASSWORDadminNoGotify admin password
+

Usage: Create apps in Gotify UI, add webhook URL to Alertmanager.

+
+

Security Checklist

+

Before production deployment:

+
    +
  • Change all CHANGE_ME_* passwords
  • +
  • Generate strong JWT_ACCESS_SECRET (32+ chars)
  • +
  • Generate strong JWT_REFRESH_SECRET (32+ chars)
  • +
  • Generate strong ENCRYPTION_KEY (32+ chars, different from JWT secrets)
  • +
  • Set strong REDIS_PASSWORD
  • +
  • Set strong V2_POSTGRES_PASSWORD
  • +
  • Set strong LISTMONK_DB_PASSWORD
  • +
  • Set strong LISTMONK_API_TOKEN
  • +
  • Set strong GITEA_DB_PASSWD + GITEA_DB_ROOT_PASSWORD
  • +
  • Set strong N8N_ENCRYPTION_KEY + N8N_USER_PASSWORD
  • +
  • Set strong NC_ADMIN_PASSWORD (NocoDB)
  • +
  • Set strong GRAFANA_ADMIN_PASSWORD
  • +
  • Disable EMAIL_TEST_MODE (set to false)
  • +
  • Configure real SMTP credentials
  • +
  • Set NODE_ENV=production
  • +
  • Review CORS_ORIGINS (whitelist only trusted domains)
  • +
+

Validation: +

# Check for remaining placeholders
+grep -r "CHANGE_ME" .env
+
+# Verify secrets are different
+echo "JWT_ACCESS_SECRET: $(grep JWT_ACCESS_SECRET .env)"
+echo "JWT_REFRESH_SECRET: $(grep JWT_REFRESH_SECRET .env)"
+echo "ENCRYPTION_KEY: $(grep ENCRYPTION_KEY .env)"
+

+
+

Troubleshooting

+

Missing .env File

+

Symptoms: Containers fail to start with "missing environment variable" errors

+

Solution: +

# Create from template
+cp .env.example .env
+
+# Verify file exists
+ls -la .env
+

+
+

Invalid Environment Variables

+

Symptoms: API fails to start with Zod validation errors

+

Diagnosis: +

# View API startup logs
+docker compose logs api | grep -A10 "Environment validation"
+

+

Common errors: +- JWT_ACCESS_SECRET too short (must be 32+ chars) +- ENCRYPTION_KEY same as JWT_ACCESS_SECRET (must differ) +- Invalid URL format (API_URL must start with http:// or https://)

+

Solution: +

# Regenerate secrets
+export JWT_ACCESS_SECRET=$(openssl rand -hex 32)
+export ENCRYPTION_KEY=$(openssl rand -hex 32)
+
+# Update .env
+sed -i "s/^JWT_ACCESS_SECRET=.*/JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}/" .env
+sed -i "s/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${ENCRYPTION_KEY}/" .env
+
+# Restart API
+docker compose restart api
+

+
+

PostgreSQL Connection Failures

+

Symptoms: API logs show ECONNREFUSED or authentication failed

+

Diagnosis: +

# Check PostgreSQL is running
+docker compose ps v2-postgres
+
+# Test connection
+docker compose exec api npx prisma db pull
+
+# Verify DATABASE_URL
+docker compose exec api printenv | grep DATABASE_URL
+

+

Solution: +

# Verify password matches in .env
+grep V2_POSTGRES_PASSWORD .env
+
+# Restart PostgreSQL
+docker compose restart v2-postgres
+
+# Wait for healthcheck
+docker compose ps v2-postgres  # Should show (healthy)
+

+
+

Redis Connection Failures

+

Symptoms: API logs show ECONNREFUSED or WRONGPASS invalid password

+

Diagnosis: +

# Check Redis is running
+docker compose ps redis
+
+# Test connection
+docker compose exec redis redis-cli -a "${REDIS_PASSWORD}" ping
+

+

Solution: +

# Verify password in .env
+grep REDIS_PASSWORD .env
+
+# Ensure REDIS_URL includes password
+grep REDIS_URL .env  # Should be redis://:PASSWORD@redis-changemaker:6379
+
+# Restart Redis
+docker compose restart redis
+

+
+

Environment Variables Not Updating

+

Symptoms: Changed .env but service still uses old value

+

Cause: Docker Compose reads .env at startup, not runtime

+

Solution: +

# Recreate container (picks up new env vars)
+docker compose up -d --force-recreate api
+
+# Or: stop and start
+docker compose down
+docker compose up -d
+

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/deployment/healthchecks/index.html b/mkdocs/site/v2/deployment/healthchecks/index.html new file mode 100644 index 00000000..c086f393 --- /dev/null +++ b/mkdocs/site/v2/deployment/healthchecks/index.html @@ -0,0 +1,5972 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Health Checks - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Docker Health Check Configuration

+

Overview

+

Docker health checks provide automatic service monitoring and restart capabilities. Changemaker Lite V2 includes health checks for 7 critical services.

+

Benefits: +- Automatic restart of unhealthy containers +- Dependency management (depends_on with service_healthy) +- Monitoring integration (Prometheus can scrape health status)

+
+

Services with Health Checks

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ServiceHealthcheck CommandIntervalTimeoutRetriesStart Period
apiwget http://localhost:4000/api/health15s5s330s
media-apiwget http://127.0.0.1:4100/health15s5s330s
adminwget http://127.0.0.1:3000/30s5s320s
v2-postgrespg_isready -U changemaker10s5s5-
redisredis-cli -a $REDIS_PASSWORD ping10s5s5-
gitea-appcurl http://localhost:3000/30s5s330s
n8nwget http://localhost:5678/healthz30s5s330s
+
+

Health Check Configuration

+

API (Express)

+

docker-compose.yml: +

api:
+  healthcheck:
+    test: ["CMD", "wget", "-q", "--spider", "http://localhost:4000/api/health"]
+    interval: 15s
+    timeout: 5s
+    retries: 3
+    start_period: 30s
+

+

Explanation: +- test: Runs wget (Alpine image standard) to check /api/health endpoint +- interval: Check every 15 seconds +- timeout: Fail if no response in 5 seconds +- retries: Mark unhealthy after 3 consecutive failures +- start_period: 30s grace period on startup (allows migrations to run)

+

Health endpoint (api/src/server.ts): +

app.get('/api/health', (req, res) => {
+  res.json({ status: 'ok', timestamp: new Date().toISOString() });
+});
+

+

Health states: +- starting: Within start_period (30s) +- healthy: Check passed +- unhealthy: 3 consecutive failures

+
+

Media API (Fastify)

+

docker-compose.yml: +

media-api:
+  healthcheck:
+    test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:4100/health"]
+    interval: 15s
+    timeout: 5s
+    retries: 3
+    start_period: 30s
+

+

Health endpoint (api/src/media-server.ts): +

app.get('/health', async (req, reply) => {
+  return { status: 'ok' };
+});
+

+

Note: Uses 127.0.0.1 instead of localhost (Alpine's wget prefers IP).

+
+

Admin (Vite Dev Server)

+

docker-compose.yml: +

admin:
+  healthcheck:
+    test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/"]
+    interval: 30s
+    timeout: 5s
+    retries: 3
+    start_period: 20s
+

+

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)

+
+

V2 PostgreSQL

+

docker-compose.yml: +

v2-postgres:
+  healthcheck:
+    test: ["CMD-SHELL", "pg_isready -U changemaker"]
+    interval: 10s
+    timeout: 5s
+    retries: 5
+

+

Explanation: +- pg_isready: Built-in PostgreSQL health check utility +- 10s interval: Fast detection of database issues +- 5 retries: More tolerant (database startup can be slow) +- No start_period: PostgreSQL has its own startup delay

+

pg_isready output: +

# Healthy
+/var/run/postgresql:5432 - accepting connections
+
+# Unhealthy
+/var/run/postgresql:5432 - rejecting connections
+

+
+

Redis

+

docker-compose.yml: +

redis:
+  healthcheck:
+    test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
+    interval: 10s
+    timeout: 5s
+    retries: 5
+

+

Explanation: +- redis-cli ping: Returns PONG if healthy +- -a ${REDIS_PASSWORD}: Authenticates with password (required since Security Audit) +- 10s interval: Fast detection for critical cache service

+

PING output: +

# Healthy
+PONG
+
+# Unhealthy
+(error) NOAUTH Authentication required
+

+
+

Gitea

+

docker-compose.yml: +

gitea-app:
+  healthcheck:
+    test: ["CMD", "curl", "-f", "http://localhost:3000/"]
+    interval: 30s
+    timeout: 5s
+    retries: 3
+    start_period: 30s
+

+

Explanation: +- curl: Debian-based image (no wget) +- -f: Fail on HTTP errors (non-200 response) +- 30s interval: Supporting service (less critical)

+

Important: Gitea uses curl (not wget) because it's a Debian image, not Alpine.

+
+

n8n

+

docker-compose.yml: +

n8n:
+  healthcheck:
+    test: ["CMD", "wget", "-q", "--spider", "http://localhost:5678/healthz"]
+    interval: 30s
+    timeout: 5s
+    retries: 3
+    start_period: 30s
+

+

Explanation: +- /healthz: n8n's built-in health endpoint +- 30s interval: Workflow automation (not user-facing)

+
+

Dependency Chains

+

API Depends on Database + Redis

+

docker-compose.yml: +

api:
+  depends_on:
+    v2-postgres:
+      condition: service_healthy
+    redis:
+      condition: service_healthy
+

+

Effect: API container waits for PostgreSQL + Redis to be healthy before starting.

+

Startup sequence: +1. PostgreSQL starts → health checks begin +2. After 5 successful checks → marked healthy +3. Redis starts → health checks begin +4. After 5 successful checks → marked healthy +5. API starts (both dependencies healthy)

+
+

Media API Depends on Database

+

docker-compose.yml: +

media-api:
+  depends_on:
+    v2-postgres:
+      condition: service_healthy
+

+

Effect: Media API waits for PostgreSQL to be healthy.

+
+

NocoDB Depends on Database

+

docker-compose.yml: +

nocodb-v2:
+  depends_on:
+    v2-postgres:
+      condition: service_healthy
+

+

Effect: NocoDB waits for its metadata database to be ready.

+
+

Monitoring Healthcheck Status

+

View Health Status

+
# All services (shows health in STATUS column)
+docker compose ps
+
+# Example output:
+# NAME                    STATUS
+# changemaker-v2-api      Up 2 hours (healthy)
+# changemaker-v2-postgres Up 2 hours (healthy)
+# redis-changemaker       Up 2 hours (healthy)
+
+

Health states: +- (healthy): All checks passing +- (unhealthy): Multiple checks failed +- (health: starting): Within start_period

+
+

Filter Unhealthy Services

+
# Show only unhealthy
+docker compose ps | grep unhealthy
+
+# Count unhealthy
+docker compose ps -q --status unhealthy | wc -l
+
+
+

Inspect Health Check Details

+
# Full health info for API
+docker inspect changemaker-v2-api | jq '.[0].State.Health'
+
+# Example output:
+{
+  "Status": "healthy",
+  "FailingStreak": 0,
+  "Log": [
+    {
+      "Start": "2026-02-13T14:30:00Z",
+      "End": "2026-02-13T14:30:01Z",
+      "ExitCode": 0,
+      "Output": ""
+    }
+  ]
+}
+
+

Key fields: +- Status: healthy, unhealthy, or starting +- FailingStreak: Consecutive failed checks +- Log: Last 5 health check results

+
+

Health Check Logs

+
# View health check output
+docker inspect changemaker-v2-api | jq '.[0].State.Health.Log[-1]'
+
+# Example (success):
+{
+  "Start": "2026-02-13T14:30:00Z",
+  "End": "2026-02-13T14:30:01Z",
+  "ExitCode": 0,
+  "Output": ""
+}
+
+# Example (failure):
+{
+  "Start": "2026-02-13T14:35:00Z",
+  "End": "2026-02-13T14:35:05Z",
+  "ExitCode": 1,
+  "Output": "wget: can't connect to remote host (127.0.0.1): Connection refused"
+}
+
+
+

Custom Health Checks

+

Advanced API Health Check

+

Check database + Redis connectivity:

+

api/src/server.ts: +

app.get('/api/health', async (req, res) => {
+  const checks = {
+    database: false,
+    redis: false,
+  };
+
+  try {
+    await prisma.$queryRaw`SELECT 1`;
+    checks.database = true;
+  } catch (err) {
+    console.error('DB health check failed:', err);
+  }
+
+  try {
+    await redis.ping();
+    checks.redis = true;
+  } catch (err) {
+    console.error('Redis health check failed:', err);
+  }
+
+  const healthy = checks.database && checks.redis;
+  res.status(healthy ? 200 : 503).json({
+    status: healthy ? 'ok' : 'degraded',
+    checks,
+    timestamp: new Date().toISOString(),
+  });
+});
+

+

docker-compose.yml (no change needed — still checks /api/health): +

healthcheck:
+  test: ["CMD", "wget", "-q", "--spider", "http://localhost:4000/api/health"]
+

+
+

Readiness vs Liveness

+

Readiness: Service is ready to accept traffic (used by Kubernetes)
+Liveness: Service is running (Docker health checks)

+

Example (separate endpoints): +

// Liveness (minimal check)
+app.get('/api/health', (req, res) => {
+  res.json({ status: 'ok' });
+});
+
+// Readiness (comprehensive check)
+app.get('/api/ready', async (req, res) => {
+  const dbReady = await checkDatabase();
+  const redisReady = await checkRedis();
+  const ready = dbReady && redisReady;
+  res.status(ready ? 200 : 503).json({ ready, dbReady, redisReady });
+});
+

+

Docker uses liveness (/api/health).
+Load balancer uses readiness (/api/ready).

+
+

Troubleshooting

+

Service Marked Unhealthy

+

Diagnosis: +

# Check logs
+docker compose logs --tail=50 api
+
+# Check health check output
+docker inspect changemaker-v2-api | jq '.[0].State.Health.Log[-1].Output'
+
+# Manually run health check
+docker compose exec api wget -O- http://localhost:4000/api/health
+

+

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)

+
+

Container Restarting Loop

+

Symptoms: Container repeatedly marked unhealthy → restart → unhealthy

+

Diagnosis: +

# Check restart count
+docker inspect changemaker-v2-api | jq '.[0].RestartCount'
+
+# Check logs for errors
+docker compose logs api | grep -i error
+

+

Common causes: +- Health check too aggressive (increase retries/interval) +- Service genuinely broken (fix code issue) +- Resource limits too low (increase memory/CPU)

+

Solution: +

# Temporarily disable health check
+healthcheck:
+  disable: true
+
+# Or increase tolerance
+healthcheck:
+  retries: 10
+  start_period: 60s
+

+
+

Health Check Command Not Found

+

Symptoms: Health check fails with "wget: not found" or "curl: not found"

+

Cause: Using wrong command for image type (Alpine vs Debian)

+

Solution:

+

Alpine images (api, media-api, redis, v2-postgres): +

test: ["CMD", "wget", "-q", "--spider", "http://..."]
+

+

Debian images (gitea-app): +

test: ["CMD", "curl", "-f", "http://..."]
+

+
+

Start Period Too Short

+

Symptoms: Service marked unhealthy immediately on startup

+

Cause: Database migrations or slow startup exceed start_period

+

Solution: +

# Increase start_period
+healthcheck:
+  start_period: 60s  # Was 30s
+

+

Monitor startup time: +

# Measure time to first healthy
+docker compose up -d api && \
+  while ! docker compose ps api | grep -q healthy; do sleep 1; done && \
+  echo "Startup took $SECONDS seconds"
+

+
+

Production Recommendations

+

Timeout Configuration

+

Critical services (database, redis, api): +- interval: 10-15s +- timeout: 5s +- retries: 3-5 +- start_period: 30-60s

+

Supporting services (n8n, gitea, mailhog): +- interval: 30-60s +- timeout: 10s +- retries: 3 +- start_period: 30s

+
+

Restart Policies

+

Combine with restart policies: +

api:
+  restart: unless-stopped  # Auto-restart on failure
+  healthcheck:
+    test: ["CMD", "wget", "-q", "--spider", "http://localhost:4000/api/health"]
+

+

Effect: Unhealthy container → restart → health checks resume.

+
+

Monitoring Integration

+

Prometheus exporter (future): +

# Expose health check status as metrics
+docker_healthcheck_status{container="changemaker-v2-api"} 1
+

+

Alert on unhealthy: +

- alert: ContainerUnhealthy
+  expr: docker_healthcheck_status == 0
+  for: 5m
+  labels:
+    severity: warning
+  annotations:
+    summary: "Container {{ $labels.container }} unhealthy"
+

+
+

Testing Health Checks

+

Manual Test

+
# Start service
+docker compose up -d api
+
+# Watch health status
+watch -n2 'docker compose ps api'
+
+# Should see:
+# (health: starting) → (healthy)
+
+
+

Simulate Failure

+
# Stop backend service
+docker compose stop v2-postgres
+
+# Wait 15s (API health check interval)
+sleep 15
+
+# Check API status
+docker compose ps api
+# Should show (unhealthy) after 3 failures (45s)
+
+# Restart backend
+docker compose start v2-postgres
+
+# API should recover
+docker compose ps api
+# Should show (healthy) after successful check
+
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/deployment/index.html b/mkdocs/site/v2/deployment/index.html new file mode 100644 index 00000000..3e16074b --- /dev/null +++ b/mkdocs/site/v2/deployment/index.html @@ -0,0 +1,5370 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Deployment Overview - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Deployment Overview

+

This section covers deploying Changemaker Lite V2 to production, including Docker orchestration, environment configuration, SSL/TLS setup, monitoring, backups, and scaling strategies.

+

Deployment Guide

+

Docker Compose

+

Complete Docker orchestration for all services:

+
    +
  • 20+ containers
  • +
  • Service dependencies
  • +
  • Health checks
  • +
  • Restart policies
  • +
  • Network configuration
  • +
  • Volume management
  • +
+

Environment Variables

+

Comprehensive environment configuration:

+
    +
  • 100+ environment variables
  • +
  • Required vs optional
  • +
  • Security considerations
  • +
  • Service-specific config
  • +
  • Feature flags
  • +
+

Nginx Configuration

+

Reverse proxy and routing:

+
    +
  • Subdomain routing (12+ subdomains)
  • +
  • SSL/TLS termination
  • +
  • Security headers
  • +
  • Proxy settings
  • +
  • Static file serving
  • +
+

SSL/TLS Setup

+

HTTPS configuration:

+
    +
  • Let's Encrypt integration
  • +
  • Certificate management
  • +
  • Auto-renewal
  • +
  • Security best practices
  • +
  • HSTS configuration
  • +
+

Tunneling

+

Public access via tunneling:

+
    +
  • Pangolin tunnel setup
  • +
  • Newt container deployment
  • +
  • Resource configuration
  • +
  • Alternative to Cloudflare
  • +
  • DNS-free setup
  • +
+

Backup & Restore

+

Data protection:

+
    +
  • PostgreSQL backups
  • +
  • Listmonk backups
  • +
  • Media file backups
  • +
  • S3 upload (optional)
  • +
  • Restore procedures
  • +
  • Automated schedules
  • +
+

Monitoring Stack

+

Observability and alerting:

+
    +
  • Prometheus metrics
  • +
  • Grafana dashboards
  • +
  • Alertmanager alerts
  • +
  • Service health checks
  • +
  • Log aggregation
  • +
+

Healthchecks

+

Container health monitoring:

+
    +
  • Docker healthchecks
  • +
  • Service-specific checks
  • +
  • Restart on failure
  • +
  • Dependency management
  • +
+

Scaling

+

Horizontal and vertical scaling:

+
    +
  • Multi-instance deployment
  • +
  • Load balancing
  • +
  • Database replication
  • +
  • Cache scaling
  • +
  • Performance optimization
  • +
+

Quick Start

+

Initial Deployment

+
    +
  1. +

    Prepare Server +

    # Ubuntu/Debian server with Docker installed
    +apt update && apt install docker.io docker-compose git
    +

    +
  2. +
  3. +

    Clone Repository +

    git clone <repo-url> changemaker.lite
    +cd changemaker.lite
    +git checkout v2
    +

    +
  4. +
  5. +

    Configure Environment +

    cp .env.example .env
    +# Edit .env with your settings
    +

    +
  6. +
  7. +

    Start Services +

    docker compose up -d v2-postgres redis
    +docker compose up -d api admin
    +docker compose exec api npx prisma migrate deploy
    +docker compose exec api npx prisma db seed
    +

    +
  8. +
  9. +

    Access Application +

    http://server-ip:3000
    +Login: admin@example.com / Admin123!
    +

    +
  10. +
+

Production Deployment

+
    +
  1. Configure Tunneling (for public access)
  2. +
  3. Set up Pangolin account
  4. +
  5. Configure tunnel in admin UI
  6. +
  7. +

    Deploy Newt container

    +
  8. +
  9. +

    Enable Monitoring +

    docker compose --profile monitoring up -d
    +

    +
  10. +
  11. +

    Set Up Backups +

    # Configure backup.sh
    +./scripts/backup.sh
    +
    +# Add to crontab
    +0 2 * * * /path/to/backup.sh
    +

    +
  12. +
  13. +

    Secure Installation

    +
  14. +
  15. Change default passwords
  16. +
  17. Enable Redis auth
  18. +
  19. Configure firewall
  20. +
  21. Review security audit
  22. +
+

Architecture Overview

+

Service Topology

+
Internet
+  ↓
+Pangolin Tunnel / Cloudflare
+  ↓
+Newt Container / Tunnel Daemon
+  ↓
+Nginx (Reverse Proxy)
+  ↓
+  ├→ Admin GUI (React, port 3000)
+  ├→ Express API (TypeScript, port 4000)
+  ├→ Media API (Fastify, port 4100)
+  ├→ MkDocs (Documentation, port 4003)
+  ├→ Grafana (Monitoring, port 3001)
+  └→ Other Services...
+  ↓
+  ├→ PostgreSQL 16 (Database, port 5433)
+  ├→ Redis 7 (Cache/Queue, port 6379)
+  └→ Supporting Services...
+
+

Port Reference

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PortServiceAccess
3000Admin GUIPublic
4000Express APIPublic
4100Media APIPublic
5433PostgreSQLInternal
6379RedisInternal
3001GrafanaPublic
9090PrometheusInternal
8091NocoDBPublic
9001ListmonkPublic
+

Subdomain Routing

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SubdomainTargetPurpose
app.cmlite.orgAdmin:3000Admin interface
api.cmlite.orgAPI:4000Express API
media.cmlite.orgMedia:4100Media API
docs.cmlite.orgMkDocs:4003Documentation
grafana.cmlite.orgGrafana:3001Monitoring
db.cmlite.orgNocoDB:8091Data browser
listmonk.cmlite.orgListmonk:9001Newsletters
+

Production Checklist

+

Security

+
    +
  • Change default admin password
  • +
  • Set strong PostgreSQL password
  • +
  • Set strong Redis password
  • +
  • Generate unique JWT secrets
  • +
  • Generate unique encryption key
  • +
  • Enable Redis authentication
  • +
  • Configure firewall rules
  • +
  • Review security audit findings
  • +
+

Environment

+
    +
  • Set production NODE_ENV
  • +
  • Configure SMTP settings
  • +
  • Set up geocoding API keys
  • +
  • Configure Listmonk (if enabled)
  • +
  • Set media storage paths
  • +
  • Configure backup destinations
  • +
+

Services

+
    +
  • Start core services
  • +
  • Run database migrations
  • +
  • Seed initial data
  • +
  • Test admin login
  • +
  • Verify API connectivity
  • +
  • Check service health
  • +
+

Monitoring

+
    +
  • Enable monitoring stack
  • +
  • Configure Grafana dashboards
  • +
  • Set up Alertmanager
  • +
  • Test alert notifications
  • +
  • Review metrics collection
  • +
+

Backups

+
    +
  • Configure backup script
  • +
  • Test backup/restore
  • +
  • Set up automated schedule
  • +
  • Configure S3 (optional)
  • +
  • Document restore procedure
  • +
+

Public Access

+
    +
  • Configure tunnel (Pangolin/Cloudflare)
  • +
  • Test public URLs
  • +
  • Verify SSL/TLS
  • +
  • Check subdomain routing
  • +
  • Test from external network
  • +
+

Maintenance

+

Regular Tasks

+

Daily: +- Monitor service health +- Review error logs +- Check disk space

+

Weekly: +- Review backup success +- Check queue depths +- Update dependencies (if needed)

+

Monthly: +- Security updates +- Database optimization +- Log rotation +- Certificate renewal check

+

Updates

+
    +
  1. +

    Pull Latest Code +

    git pull origin v2
    +

    +
  2. +
  3. +

    Rebuild Containers +

    docker compose build
    +docker compose up -d
    +

    +
  4. +
  5. +

    Run Migrations +

    docker compose exec api npx prisma migrate deploy
    +

    +
  6. +
  7. +

    Verify Services +

    docker compose ps
    +curl http://localhost:4000/health
    +

    +
  8. +
+

Troubleshooting

+

Common deployment issues:

+
    +
  • Container fails to start - Check logs, environment variables
  • +
  • Database connection error - Verify PostgreSQL password, port
  • +
  • Redis connection error - Check Redis password, authentication
  • +
  • Nginx routing issues - Review nginx config, test upstream services
  • +
  • Tunnel connection fails - Verify Pangolin credentials, Newt config
  • +
  • SSL certificate errors - Check Let's Encrypt rate limits, renewal
  • +
+

See Troubleshooting Guide for detailed solutions.

+

Resource Requirements

+

Minimum

+
    +
  • CPU: 2 cores
  • +
  • RAM: 4 GB
  • +
  • Disk: 20 GB SSD
  • +
  • Network: 10 Mbps
  • +
+ +
    +
  • CPU: 4 cores
  • +
  • RAM: 8 GB
  • +
  • Disk: 50 GB SSD
  • +
  • Network: 100 Mbps
  • +
+

High Load

+
    +
  • CPU: 8+ cores
  • +
  • RAM: 16+ GB
  • +
  • Disk: 100+ GB SSD
  • +
  • Network: 1 Gbps
  • +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/deployment/monitoring-stack/index.html b/mkdocs/site/v2/deployment/monitoring-stack/index.html new file mode 100644 index 00000000..696e68fc --- /dev/null +++ b/mkdocs/site/v2/deployment/monitoring-stack/index.html @@ -0,0 +1,6125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Monitoring Stack - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Monitoring Stack (Prometheus + Grafana)

+

Overview

+

Changemaker Lite V2 includes a complete observability stack for production monitoring:

+
    +
  • Prometheus: Metrics collection + alerting rules
  • +
  • Grafana: Visualization + pre-configured dashboards
  • +
  • Alertmanager: Alert routing + notifications
  • +
  • cAdvisor: Docker container metrics
  • +
  • Node Exporter: Host system metrics
  • +
  • Redis Exporter: Redis-specific metrics
  • +
  • Gotify: Push notifications (optional)
  • +
+

All monitoring services behind Docker Compose profile flag (opt-in).

+
+

Architecture

+
graph LR
+    subgraph "Application Metrics"
+        API[API<br/>:4000/api/metrics]
+        MEDIA[Media API<br/>:4100/metrics]
+    end
+
+    subgraph "Infrastructure Metrics"
+        CADVISOR[cAdvisor<br/>Container Stats]
+        NODE[Node Exporter<br/>Host Stats]
+        REDIS_EXP[Redis Exporter<br/>Redis Stats]
+    end
+
+    subgraph "Monitoring Stack"
+        PROM[Prometheus<br/>:9090]
+        GRAFANA[Grafana<br/>:3001]
+        ALERT[Alertmanager<br/>:9093]
+        GOTIFY[Gotify<br/>:8889]
+    end
+
+    API --> PROM
+    MEDIA --> PROM
+    CADVISOR --> PROM
+    NODE --> PROM
+    REDIS_EXP --> PROM
+
+    PROM --> GRAFANA
+    PROM --> ALERT
+    ALERT --> GOTIFY
+
+

Quick Start

+

Enable Monitoring

+
# Start with monitoring profile
+docker compose --profile monitoring up -d
+
+# Check services
+docker compose ps | grep monitoring
+
+# Access dashboards
+open http://localhost:3001  # Grafana (admin/admin)
+open http://localhost:9090  # Prometheus
+open http://localhost:9093  # Alertmanager
+
+
+

Prometheus Configuration

+

Scrape Targets

+

File: configs/prometheus/prometheus.yml

+
scrape_configs:
+  # V2 Unified API Metrics (10s interval)
+  - job_name: 'changemaker-v2-api'
+    static_configs:
+      - targets: ['changemaker-v2-api:4000']
+    metrics_path: '/api/metrics'
+    scrape_interval: 10s
+    scrape_timeout: 5s
+
+  # Redis Metrics (15s interval)
+  - job_name: 'redis'
+    static_configs:
+      - targets: ['redis-exporter:9121']
+    scrape_interval: 15s
+
+  # cAdvisor - Docker container metrics
+  - job_name: 'cadvisor'
+    static_configs:
+      - targets: ['cadvisor:8080']
+    scrape_interval: 15s
+
+  # Node Exporter - System metrics
+  - job_name: 'node'
+    static_configs:
+      - targets: ['node-exporter:9100']
+    scrape_interval: 15s
+
+  # Prometheus self-monitoring
+  - job_name: 'prometheus'
+    static_configs:
+      - targets: ['localhost:9090']
+
+  # Alertmanager monitoring
+  - job_name: 'alertmanager'
+    static_configs:
+      - targets: ['alertmanager:9093']
+    scrape_interval: 30s
+
+

Intervals: +- 10s: API (real-time application metrics) +- 15s: Infrastructure (host + containers + Redis) +- 30s: Monitoring stack itself

+
+

Custom Metrics (cm_*)

+

File: api/src/utils/metrics.ts

+

12 custom metrics for domain-specific monitoring:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricTypeLabelsDescription
cm_emails_sent_totalCountercampaign_idCampaign emails sent successfully
cm_emails_failed_totalCountercampaign_id, error_typeFailed email sends
cm_email_queue_sizeGauge-Current email queue size
cm_email_send_duration_secondsHistogram-Email send latency
cm_login_attempts_totalCounterstatusLogin attempts (success/failure)
cm_active_sessionsGauge-Active refresh tokens
cm_campaign_emails_totalCountercampaign_idTotal campaign emails created
cm_response_submissions_totalCounter-Response wall submissions
cm_canvass_visits_totalCounteroutcomeCanvass visits by outcome
cm_active_canvass_sessionsGauge-Active canvass sessions
cm_shift_signups_totalCounter-Shift signups
cm_external_service_upGaugeserviceExternal service health (1=up, 0=down)
+

HTTP metrics (standard prom-client): +- http_requests_total +- http_request_duration_seconds

+

Geocoding metrics: +- cm_geocode_cache_hits_total +- cm_geocode_cache_misses_total +- cm_geocode_requests_total +- cm_geocode_duration_seconds

+

Email template metrics: +- cm_email_templates_updated_total +- cm_email_test_sent_total +- cm_email_template_rollback_total +- cm_email_template_cache_hit/miss_total

+

Location query metrics: +- cm_map_location_query_duration_seconds +- cm_map_location_query_count_total +- cm_map_location_result_count

+
+

Alert Rules

+

File: configs/prometheus/alerts.yml

+

12 alert rules across 4 groups:

+

Application Alerts

+
    +
  1. ApplicationDown: API unreachable for 2 minutes
  2. +
  3. HighErrorRate: >10% 5xx errors for 5 minutes
  4. +
  5. EmailQueueBacklog: Queue size >100 for 10 minutes
  6. +
  7. HighEmailFailureRate: >20% email failures for 10 minutes
  8. +
  9. SuspiciousLoginActivity: >5 failed logins/sec for 2 minutes
  10. +
  11. HighAPILatency: P95 latency >2s for 5 minutes
  12. +
  13. ExternalServiceDown: External service unreachable for 5 minutes
  14. +
+

System Alerts

+
    +
  1. RedisDown: Redis unreachable for 1 minute
  2. +
  3. DiskSpaceLow: <15% disk space for 5 minutes
  4. +
  5. DiskSpaceCritical: <10% disk space for 2 minutes
  6. +
  7. HighCPUUsage: >85% CPU for 10 minutes
  8. +
  9. HighMemoryUsage: >85% memory for 10 minutes
  10. +
+

Example Alert: +

- alert: ApplicationDown
+  expr: up{job="changemaker-v2-api"} == 0
+  for: 2m
+  labels:
+    severity: critical
+  annotations:
+    summary: "V2 API is down"
+    description: "The Changemaker V2 API has been down for more than 2 minutes."
+

+
+

Data Retention

+

docker-compose.yml: +

prometheus:
+  command:
+    - '--storage.tsdb.retention.time=30d'  # 30 days
+

+

Disk usage: ~1-5GB for 30 days (depends on scrape frequency + cardinality).

+

Increase retention: +

# Edit docker-compose.yml
+# Change to '--storage.tsdb.retention.time=90d'
+
+# Recreate container
+docker compose --profile monitoring up -d --force-recreate prometheus
+

+
+

Grafana Configuration

+

Datasource

+

File: configs/grafana/datasources.yml

+
apiVersion: 1
+
+datasources:
+  - name: Prometheus
+    type: prometheus
+    access: proxy
+    url: http://prometheus:9090
+    isDefault: true
+    editable: false
+
+

Auto-provisioned on Grafana startup.

+
+

Dashboards

+

File: configs/grafana/dashboards.yml

+
apiVersion: 1
+
+providers:
+  - name: 'Default'
+    folder: 'Changemaker Lite'
+    type: file
+    options:
+      path: /etc/grafana/provisioning/dashboards
+
+

3 pre-configured dashboards:

+

1. Application Overview

+

File: configs/grafana/application-overview.json

+

Panels: +- API uptime (last 24h) +- Request rate (req/sec) +- Error rate (%) +- Email queue size +- Active sessions +- Campaign emails sent

+

Refresh: 10s

+
+

2. API Performance

+

File: configs/grafana/api-performance.json

+

Panels: +- Request latency (P50, P95, P99) +- Requests by status code +- Top 10 slowest endpoints +- HTTP errors by route +- Geocoding cache hit rate +- Email send duration

+

Refresh: 30s

+
+

3. System Health

+

File: configs/grafana/system-health.json

+

Panels: +- CPU usage (%) +- Memory usage (%) +- Disk space (GB free) +- Network I/O (MB/s) +- Container CPU throttling +- Redis memory usage

+

Refresh: 1m

+
+

First Login

+
# Access Grafana
+open http://localhost:3001
+
+# Default credentials
+Username: admin
+Password: admin
+
+# Change password on first login
+
+

Navigate: Dashboards → Changemaker Lite folder → Select dashboard

+
+

Alertmanager Configuration

+

Notification Receivers

+

File: configs/alertmanager/alertmanager.yml

+
global:
+  resolve_timeout: 5m
+
+route:
+  receiver: 'default'
+  group_by: ['alertname', 'severity']
+  group_wait: 30s
+  group_interval: 5m
+  repeat_interval: 4h
+
+receivers:
+  - name: 'default'
+    # Email (example)
+    email_configs:
+      - to: 'admin@cmlite.org'
+        from: 'alerts@cmlite.org'
+        smarthost: 'smtp.example.com:587'
+        auth_username: 'alerts@cmlite.org'
+        auth_password: 'your-password'
+
+    # Slack (example)
+    slack_configs:
+      - api_url: 'https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK'
+        channel: '#alerts'
+        title: '{{ .GroupLabels.alertname }}'
+        text: '{{ range .Alerts }}{{ .Annotations.summary }}\n{{ end }}'
+
+    # Gotify (push notifications)
+    webhook_configs:
+      - url: 'http://gotify:80/message?token=YOUR_GOTIFY_TOKEN'
+
+

Grouping: Combines similar alerts (prevents spam).

+

Repeat: Re-sends unresolved alerts every 4 hours.

+
+

Testing Alerts

+

Manual test: +

# Trigger test alert
+curl -X POST http://localhost:9093/api/v1/alerts \
+  -d '[{
+    "labels": {"alertname":"TestAlert","severity":"warning"},
+    "annotations": {"summary":"Test alert from curl"}
+  }]'
+
+# Check Alertmanager UI
+open http://localhost:9093
+

+

Force alert (stop API): +

# Stop API (triggers ApplicationDown alert after 2m)
+docker compose stop api
+
+# Check Prometheus alerts
+open http://localhost:9090/alerts
+
+# Wait 2 minutes → Alert fires → Notification sent
+

+
+

Exporters

+

cAdvisor (Container Metrics)

+

Metrics: +- CPU usage per container +- Memory usage per container +- Network I/O +- Disk I/O

+

Access: http://localhost:8080

+

Configuration (docker-compose.yml): +

cadvisor:
+  image: gcr.io/cadvisor/cadvisor:latest
+  container_name: cadvisor-changemaker
+  privileged: true  # Required for full access
+  volumes:
+    - /:/rootfs:ro
+    - /var/run:/var/run:ro
+    - /sys:/sys:ro
+    - /var/lib/docker/:/var/lib/docker:ro
+    - /dev/disk/:/dev/disk:ro
+  devices:
+    - /dev/kmsg
+

+
+

Node Exporter (Host Metrics)

+

Metrics: +- CPU usage (all cores) +- Memory usage (total, free, cached) +- Disk usage (filesystem, mountpoints) +- Network I/O (bytes, packets)

+

Access: http://localhost:9100/metrics

+

Configuration: +

node-exporter:
+  command:
+    - '--path.rootfs=/host'
+    - '--path.procfs=/host/proc'
+    - '--path.sysfs=/host/sys'
+    - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
+  volumes:
+    - /proc:/host/proc:ro
+    - /sys:/host/sys:ro
+    - /:/rootfs:ro
+

+
+

Redis Exporter

+

Metrics: +- Memory usage +- Commands per second +- Connected clients +- Keyspace hits/misses +- Evicted keys

+

Access: http://localhost:9121/metrics

+

Configuration: +

redis-exporter:
+  environment:
+    - REDIS_ADDR=redis:6379
+    - REDIS_PASSWORD=${REDIS_PASSWORD}  # Authenticates with Redis
+

+
+

Gotify (Push Notifications)

+

Setup: +

# Access Gotify UI
+open http://localhost:8889
+
+# Login (default: admin/admin)
+
+# Create app → Copy token
+
+# Add to Alertmanager config:
+webhook_configs:
+  - url: 'http://gotify:80/message?token=YOUR_TOKEN'
+

+

Mobile apps: Available for iOS/Android (receive push notifications).

+
+

Accessing Services

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ServiceURLDefault Credentials
Prometheushttp://localhost:9090None
Grafanahttp://localhost:3001admin / admin
Alertmanagerhttp://localhost:9093None
cAdvisorhttp://localhost:8080None
Node Exporterhttp://localhost:9100/metricsNone
Redis Exporterhttp://localhost:9121/metricsNone
Gotifyhttp://localhost:8889admin / admin
+
+

Troubleshooting

+

Prometheus Not Scraping

+

Symptoms: Missing data in Grafana dashboards

+

Diagnosis: +

# Check Prometheus targets
+open http://localhost:9090/targets
+
+# Look for errors (red) vs success (green)
+
+# Check API metrics endpoint
+curl http://localhost:4000/api/metrics
+

+

Common causes: +- API container not running +- Wrong port in prometheus.yml +- Network connectivity issue

+

Solution: +

# Restart API
+docker compose restart api
+
+# Reload Prometheus config
+docker compose exec prometheus kill -HUP 1
+
+# Or restart Prometheus
+docker compose restart prometheus
+

+
+

Grafana Dashboards Not Loading

+

Symptoms: Blank dashboards or "No data" errors

+

Diagnosis: +

# Check Grafana logs
+docker compose logs grafana | tail -50
+
+# Check datasource
+open http://localhost:3001/datasources
+
+# Test Prometheus query
+curl http://prometheus:9090/api/v1/query?query=up
+

+

Solution: +

# Verify datasource URL
+# Should be http://prometheus:9090 (container name, not localhost)
+
+# Restart Grafana
+docker compose restart grafana
+

+
+

Alerts Not Firing

+

Symptoms: No notifications despite issues

+

Diagnosis: +

# Check Prometheus alerts
+open http://localhost:9090/alerts
+
+# Check Alertmanager
+open http://localhost:9093
+
+# Verify alert rules loaded
+curl http://localhost:9090/api/v1/rules
+

+

Solution: +

# Reload Prometheus config
+docker compose exec prometheus kill -HUP 1
+
+# Check alerts.yml syntax
+docker compose exec prometheus promtool check rules /etc/prometheus/alerts.yml
+
+# Test notification receiver
+curl -X POST http://localhost:9093/api/v1/alerts -d '[...]'
+

+
+

Production Best Practices

+

Secure Grafana

+

Change admin password: +

# Via UI: Admin → Profile → Change Password
+
+# Via env var (docker-compose.yml):
+environment:
+  - GF_SECURITY_ADMIN_PASSWORD=<strong-password>
+

+

Disable signup: +

environment:
+  - GF_USERS_ALLOW_SIGN_UP=false  # Already set
+

+
+

Alert Tuning

+

Avoid false positives: Increase for duration in critical alerts.

+

Example (before): +

- alert: DiskSpaceLow
+  expr: disk_free_percent < 15
+  for: 1m  # Too aggressive
+

+

Example (after): +

- alert: DiskSpaceLow
+  expr: disk_free_percent < 15
+  for: 10m  # More reasonable
+

+
+

External Storage (Long-Term)

+

Prometheus supports remote write to: +- Thanos: Long-term storage (S3/GCS) +- Cortex: Multi-tenant Prometheus +- VictoriaMetrics: High-performance storage

+

Example (Thanos): +

# prometheus.yml
+remote_write:
+  - url: "http://thanos-receive:19291/api/v1/receive"
+

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/deployment/nginx/index.html b/mkdocs/site/v2/deployment/nginx/index.html new file mode 100644 index 00000000..88d2c449 --- /dev/null +++ b/mkdocs/site/v2/deployment/nginx/index.html @@ -0,0 +1,7113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Nginx Configuration - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Nginx Reverse Proxy Configuration

+

Overview

+

Nginx serves as the central reverse proxy for Changemaker Lite V2, routing traffic to 15+ backend services via subdomain-based routing. It handles SSL termination, security headers, static file serving, and WebSocket upgrades.

+

Key Responsibilities:

+
    +
  • Subdomain Routing: api.cmlite.org, app.cmlite.org, db.cmlite.org, etc.
  • +
  • SSL/TLS Termination: Handles HTTPS certificates (Let's Encrypt, Cloudflare, or Pangolin)
  • +
  • Security Headers: CSP, HSTS, X-Frame-Options, Permissions-Policy
  • +
  • Proxy Pass: Forwards requests to backend Docker containers
  • +
  • Static File Serving: Serves admin GUI production builds + MkDocs site
  • +
  • WebSocket Support: Upgrades connections for n8n, MailHog, MkDocs live reload
  • +
  • Iframe Embedding: CSP policies allow admin to embed services (NocoDB, Gitea, etc.)
  • +
+

Architecture:

+
Internet → Nginx (:80, :443) → [Docker Internal Network]
+                                  ├─ api:4000 (Express)
+                                  ├─ media-api:4100 (Fastify)
+                                  ├─ admin:3000 (Vite / static)
+                                  ├─ nocodb:8080
+                                  ├─ listmonk:9000
+                                  ├─ gitea:3000
+                                  ├─ n8n:5678
+                                  ├─ mkdocs:8000
+                                  ├─ code-server:8080
+                                  ├─ mailhog:8025
+                                  ├─ mini-qr:8080
+                                  ├─ homepage:3000
+                                  ├─ grafana:3000
+                                  └─ public-media:80
+
+
+

Architecture

+
graph LR
+    subgraph "External Access"
+        USER[User Browser]
+        TUNNEL[Pangolin Tunnel]
+    end
+
+    subgraph "Nginx Proxy :80, :443"
+        NGINX{Nginx<br/>Subdomain Router}
+    end
+
+    subgraph "Backend Services (Docker Network)"
+        API[api:4000<br/>Express]
+        MEDIA[media-api:4100<br/>Fastify]
+        ADMIN[admin:3000<br/>Vite]
+        NOCODB[nocodb:8080]
+        LISTMONK[listmonk:9000]
+        GITEA[gitea:3000]
+        N8N[n8n:5678]
+        MKDOCS[mkdocs:8000]
+        CODE[code-server:8080]
+    end
+
+    USER -->|HTTP/HTTPS| NGINX
+    TUNNEL -->|HTTP| NGINX
+
+    NGINX -->|api.cmlite.org| API
+    NGINX -->|api.cmlite.org/media| MEDIA
+    NGINX -->|app.cmlite.org| ADMIN
+    NGINX -->|db.cmlite.org| NOCODB
+    NGINX -->|listmonk.cmlite.org| LISTMONK
+    NGINX -->|git.cmlite.org| GITEA
+    NGINX -->|n8n.cmlite.org| N8N
+    NGINX -->|docs.cmlite.org| MKDOCS
+    NGINX -->|code.cmlite.org| CODE
+
+

Configuration Files

+

Nginx configuration split across multiple files:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FilePurposeType
nginx/nginx.confGlobal settings, gzip, security headersMain config
nginx/conf.d/default.confLocalhost fallback, path-based routingServer block
nginx/conf.d/api.confAPI subdomain routing (Express + Fastify)Server block
nginx/conf.d/services.confSupporting service subdomainsServer blocks (12+)
+

Configuration hierarchy: +

nginx.conf
+├─ Global: worker_processes, events, http
+├─ Security headers (applied to all)
+├─ Gzip compression
+├─ Docker DNS resolver (127.0.0.11)
+└─ Include conf.d/*.conf
+    ├─ default.conf (localhost)
+    ├─ api.conf (api.cmlite.org)
+    └─ services.conf (all other subdomains)
+

+
+

Global Configuration (nginx.conf)

+

File: nginx/nginx.conf

+

Worker Configuration

+
worker_processes auto;
+error_log /var/log/nginx/error.log warn;
+pid /var/run/nginx.pid;
+
+events {
+    worker_connections 1024;
+}
+
+

Explanation: +- worker_processes auto: Detects CPU cores (1 worker per core) +- worker_connections 1024: Max 1024 concurrent connections per worker +- Total capacity: auto × 1024 (e.g., 4 cores = 4096 connections)

+
+

HTTP Block

+
http {
+    include /etc/nginx/mime.types;
+    default_type application/octet-stream;
+
+    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+                    '$status $body_bytes_sent "$http_referer" '
+                    '"$http_user_agent" "$http_x_forwarded_for"';
+
+    access_log /var/log/nginx/access.log main;
+
+    sendfile on;
+    tcp_nopush on;
+    tcp_nodelay on;
+    keepalive_timeout 65;
+    types_hash_max_size 2048;
+    client_max_body_size 50m;  # Default max upload size
+
+    # Include server blocks
+    include /etc/nginx/conf.d/*.conf;
+}
+
+

Key Settings: +- sendfile on: Optimized file serving (kernel-level copy) +- tcp_nopush on: Sends HTTP headers in single packet +- client_max_body_size 50m: Default upload limit (overridden per location)

+
+

Gzip Compression

+
# Gzip compression
+gzip on;
+gzip_vary on;
+gzip_proxied any;
+gzip_comp_level 6;
+gzip_types text/plain text/css application/json application/javascript
+           text/xml application/xml application/xml+rss text/javascript
+           image/svg+xml;
+
+

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)

+
+

Security Headers

+
# Security headers (applied globally)
+add_header X-Content-Type-Options "nosniff" always;
+add_header X-XSS-Protection "1; mode=block" always;
+add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+add_header Permissions-Policy "geolocation=(self), microphone=(), camera=()" always;
+
+

Header Explanation: +- X-Content-Type-Options: Prevents MIME sniffing attacks +- X-XSS-Protection: Enables browser XSS filter (legacy browsers) +- Referrer-Policy: Controls referer header sent to external sites +- HSTS: Forces HTTPS for 1 year (31536000 seconds) +- Permissions-Policy: Restricts geolocation/media access

+

Note: X-Frame-Options set per server block (not global).

+
+

Docker DNS Resolver

+
# Docker internal DNS — enables runtime resolution
+resolver 127.0.0.11 valid=30s;
+
+

Purpose: Docker's embedded DNS server at 127.0.0.11 resolves container names.

+

Why needed: Allows Nginx to start even when optional services are down. Without this, Nginx fails to start if any upstream is missing.

+

Usage pattern: +

location / {
+    set $upstream_api http://changemaker-v2-api:4000;
+    proxy_pass $upstream_api;  # Resolves at request time, not config parse
+}
+

+

Alternative (fails if container missing): +

proxy_pass http://changemaker-v2-api:4000;  # Resolved at config parse — fails if down
+

+
+

Subdomain Routing

+

Default Server (localhost)

+

File: nginx/conf.d/default.conf

+
server {
+    listen 80 default_server;
+    server_name localhost _;
+    add_header X-Frame-Options "SAMEORIGIN" always;
+
+    # Admin GUI (default)
+    location / {
+        set $upstream_admin http://changemaker-v2-admin:3000;
+        proxy_pass $upstream_admin;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+    }
+
+    # Media API (must come BEFORE /api/ for longest prefix match)
+    location /api/media/ {
+        set $upstream_media http://changemaker-media-api:4100;
+        proxy_pass $upstream_media;
+        # ... (proxy headers)
+
+        # Large upload support
+        client_max_body_size 10G;
+        proxy_read_timeout 3600s;
+        proxy_connect_timeout 75s;
+        proxy_request_buffering off;
+    }
+
+    # API (Express)
+    location /api/ {
+        set $upstream_api http://changemaker-v2-api:4000;
+        proxy_pass $upstream_api;
+        # ... (proxy headers)
+    }
+
+    # Public Media Gallery
+    location /gallery/ {
+        proxy_pass http://changemaker-public-media:80/;
+        # ... (proxy headers)
+    }
+}
+
+

Routing Logic: +1. Request to http://localhost/api/media/videos → media-api:4100 +2. Request to http://localhost/api/campaigns → api:4000 +3. Request to http://localhost/ → admin:3000 +4. Request to http://localhost/gallery/ → public-media:80

+

Important: /api/media/ location must come before /api/ in config file (longest prefix match).

+
+

API Subdomain (api.cmlite.org)

+

File: nginx/conf.d/api.conf

+
server {
+    listen 80;
+    server_name api.cmlite.org;
+    add_header X-Frame-Options "SAMEORIGIN" always;
+
+    # Media API endpoints (must come BEFORE / for longest prefix match)
+    location /media/ {
+        set $upstream_media http://changemaker-media-api:4100/api/;
+        proxy_pass $upstream_media;
+        # ... (proxy headers)
+
+        # Large upload support
+        client_max_body_size 10G;
+        proxy_read_timeout 3600s;
+        proxy_connect_timeout 75s;
+        proxy_request_buffering off;
+
+        # WebSocket support
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+    }
+
+    # Main API (Express)
+    location / {
+        set $upstream_api http://changemaker-v2-api:4000;
+        proxy_pass $upstream_api;
+        # ... (proxy headers)
+        proxy_read_timeout 300s;
+        proxy_connect_timeout 75s;
+    }
+}
+
+

URL Mapping: +- http://api.cmlite.org/media/videoshttp://changemaker-media-api:4100/api/videos +- http://api.cmlite.org/auth/loginhttp://changemaker-v2-api:4000/auth/login

+

Critical: Media API location includes /api/ in proxy_pass to rewrite path.

+
+

Service Subdomains

+

File: nginx/conf.d/services.conf

+

Gitea (git.cmlite.org)

+
server {
+    listen 80;
+    server_name git.cmlite.org;
+    add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
+
+    # Increase max body size for large git pushes (2GB)
+    client_max_body_size 2048M;
+
+    location / {
+        set $upstream_gitea http://gitea-changemaker:3000;
+        proxy_pass $upstream_gitea;
+        proxy_hide_header X-Frame-Options;  # Allow iframe embedding
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+}
+
+

Key Features: +- CSP frame-ancestors: Allows embedding in app.cmlite.org (admin GUI) +- proxy_hide_header X-Frame-Options: Strips Gitea's default DENY policy +- 2GB upload limit: For large repository pushes

+
+

n8n (n8n.cmlite.org)

+
server {
+    listen 80;
+    server_name n8n.cmlite.org;
+    add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
+
+    location / {
+        set $upstream_n8n http://n8n-changemaker:5678;
+        proxy_pass $upstream_n8n;
+        proxy_hide_header X-Frame-Options;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";  # WebSocket support
+    }
+}
+
+

WebSocket Headers: +- Upgrade: $http_upgrade: Passes WebSocket upgrade header +- Connection: "upgrade": Indicates protocol upgrade

+

Required for: n8n workflow editor, MailHog live updates, MkDocs live reload

+
+

NocoDB (db.cmlite.org)

+
server {
+    listen 80;
+    server_name db.cmlite.org;
+    add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
+
+    location / {
+        set $upstream_nocodb http://changemaker-v2-nocodb:8080;
+        proxy_pass $upstream_nocodb;
+        proxy_hide_header X-Frame-Options;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+}
+
+

Iframe Embedding: +- frame-ancestors 'self' app.cmlite.org: Allows admin GUI to embed NocoDB +- proxy_hide_header X-Frame-Options: Removes NocoDB's default SAMEORIGIN policy

+
+

MkDocs (docs.cmlite.org)

+
server {
+    listen 80;
+    server_name docs.cmlite.org;
+    add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
+
+    location / {
+        set $upstream_mkdocs http://mkdocs-changemaker:8000;
+        proxy_pass $upstream_mkdocs;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";  # Live reload WebSocket
+    }
+}
+
+

Live Reload: MkDocs Material theme uses WebSocket for live reload during development.

+
+

Code Server (code.cmlite.org)

+
server {
+    listen 80;
+    server_name code.cmlite.org;
+    add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
+
+    location / {
+        set $upstream_code http://code-server-changemaker:8080;
+        proxy_pass $upstream_code;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";  # VS Code WebSocket
+    }
+}
+
+

WebSocket Usage: Code Server uses WebSockets for terminal, file watching, language server.

+
+

MailHog (mail.cmlite.org)

+
server {
+    listen 80;
+    server_name mail.cmlite.org;
+    add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
+
+    location / {
+        set $upstream_mailhog http://mailhog-changemaker:8025;
+        proxy_pass $upstream_mailhog;
+        proxy_hide_header X-Frame-Options;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        # WebSocket support for MailHog live updates
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+    }
+}
+
+

Live Updates: MailHog uses WebSocket to push new emails to browser without polling.

+
+

Listmonk (listmonk.cmlite.org)

+
server {
+    listen 80;
+    server_name listmonk.cmlite.org;
+    add_header X-Frame-Options "SAMEORIGIN" always;
+
+    location / {
+        set $upstream_listmonk http://listmonk-app:9000;
+        proxy_pass $upstream_listmonk;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+}
+
+

No Iframe: Listmonk not embedded in admin (accessed directly), so SAMEORIGIN policy kept.

+
+

Grafana (grafana.cmlite.org)

+
server {
+    listen 80;
+    server_name grafana.cmlite.org;
+    add_header X-Frame-Options "SAMEORIGIN" always;
+
+    location / {
+        set $upstream_grafana http://grafana-changemaker:3000;
+        proxy_pass $upstream_grafana;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";  # Grafana live updates
+    }
+}
+
+

WebSocket: Grafana uses WebSocket for live dashboard updates.

+
+

Mini QR (qr.cmlite.org)

+
server {
+    listen 80;
+    server_name qr.cmlite.org;
+    add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
+
+    location / {
+        set $upstream_miniqr http://mini-qr:8080;
+        proxy_pass $upstream_miniqr;
+        proxy_hide_header X-Frame-Options;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+}
+
+

Iframe Embedding: Admin GUI embeds Mini QR for walk sheet previews.

+
+

Root Domain (cmlite.org)

+
server {
+    listen 80;
+    server_name cmlite.org;
+
+    location / {
+        set $upstream_site http://mkdocs-site-server-changemaker:80;
+        proxy_pass $upstream_site;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+}
+
+

Purpose: Serves MkDocs static site (production build) on root domain.

+
+

Homepage (home.cmlite.org)

+
server {
+    listen 80;
+    server_name home.cmlite.org;
+    add_header X-Frame-Options "SAMEORIGIN" always;
+
+    location / {
+        set $upstream_homepage http://homepage-changemaker:3000;
+        proxy_pass $upstream_homepage;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+}
+
+

Dashboard: Service status dashboard with Docker integration.

+
+

Embed Proxy Ports

+

Purpose: Allow admin GUI to iframe services via localhost ports (bypassing subdomain requirements).

+

Ports: 8881-8885 (NocoDB, n8n, Gitea, MailHog, Mini QR)

+

Configuration (in services.conf):

+
# NocoDB embed proxy (port 8881)
+server {
+    listen 8881;
+    location / {
+        set $upstream_nocodb http://changemaker-v2-nocodb:8080;
+        proxy_pass $upstream_nocodb;
+        proxy_hide_header X-Frame-Options;
+        proxy_hide_header Content-Security-Policy;  # Strip all frame restrictions
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+}
+
+# n8n embed proxy (port 8882)
+server {
+    listen 8882;
+    location / {
+        set $upstream_n8n http://n8n-changemaker:5678;
+        proxy_pass $upstream_n8n;
+        proxy_hide_header X-Frame-Options;
+        proxy_hide_header Content-Security-Policy;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+    }
+}
+
+# Gitea embed proxy (port 8883)
+server {
+    listen 8883;
+    client_max_body_size 2048M;  # Large git pushes
+    location / {
+        set $upstream_gitea http://gitea-changemaker:3000;
+        proxy_pass $upstream_gitea;
+        proxy_hide_header X-Frame-Options;
+        proxy_hide_header Content-Security-Policy;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+}
+
+# MailHog embed proxy (port 8884)
+server {
+    listen 8884;
+    location / {
+        set $upstream_mailhog http://mailhog-changemaker:8025;
+        proxy_pass $upstream_mailhog;
+        proxy_hide_header X-Frame-Options;
+        proxy_hide_header Content-Security-Policy;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+    }
+}
+
+# Mini QR embed proxy (port 8885)
+server {
+    listen 8885;
+    location / {
+        set $upstream_miniqr http://mini-qr:8080;
+        proxy_pass $upstream_miniqr;
+        proxy_hide_header X-Frame-Options;
+        proxy_hide_header Content-Security-Policy;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+}
+
+

Usage in Admin GUI: +

<iframe src="http://localhost:8881" />  {/* NocoDB */}
+<iframe src="http://localhost:8882" />  {/* n8n */}
+<iframe src="http://localhost:8883" />  {/* Gitea */}
+<iframe src="http://localhost:8884" />  {/* MailHog */}
+<iframe src="http://localhost:8885" />  {/* Mini QR */}
+

+

Exposed in docker-compose.yml: +

nginx:
+  ports:
+    - "80:80"
+    - "443:443"
+    - "8881:8881"  # NocoDB
+    - "8882:8882"  # n8n
+    - "8883:8883"  # Gitea
+    - "8884:8884"  # MailHog
+    - "8885:8885"  # Mini QR
+

+
+

Proxy Configuration

+

Standard Proxy Headers

+

All proxy locations should include:

+
proxy_set_header Host $host;
+proxy_set_header X-Real-IP $remote_addr;
+proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+proxy_set_header X-Forwarded-Proto $scheme;
+
+

Header Explanation: +- Host: Preserves original hostname (e.g., api.cmlite.org) +- X-Real-IP: Client's IP address +- X-Forwarded-For: Chain of proxy IPs (adds to existing list) +- X-Forwarded-Proto: HTTP or HTTPS (used by backend for redirect logic)

+
+

WebSocket Upgrade

+

Required for: n8n, MailHog, MkDocs, Code Server, Grafana

+
proxy_set_header Upgrade $http_upgrade;
+proxy_set_header Connection "upgrade";
+
+

Explanation: +- Upgrade: websocket: Browser requests protocol upgrade +- Connection: upgrade: Indicates connection will persist

+

Without these headers: WebSocket connections fail with 400 Bad Request.

+
+

Timeouts

+

Default timeouts: +

proxy_read_timeout 300s;     # 5 minutes
+proxy_connect_timeout 75s;   # 75 seconds
+

+

Media API timeouts (video uploads): +

proxy_read_timeout 3600s;    # 1 hour
+proxy_connect_timeout 75s;
+

+

Why longer: FFprobe video analysis + large file uploads take time.

+
+

Upload Size Limits

+

Global default (nginx.conf): +

client_max_body_size 50m;
+

+

Per-location overrides: +- Media API: client_max_body_size 10G; (video uploads) +- Gitea: client_max_body_size 2048M; (large git pushes)

+
+

Request Buffering

+

Media API (disable buffering for streaming uploads): +

proxy_request_buffering off;
+

+

Effect: Nginx streams request body directly to backend (no temp file).

+

Benefits: +- Lower disk I/O on Nginx server +- Faster upload start time +- Reduced memory usage

+

Trade-off: Backend must handle slow clients (Fastify multipart does this).

+
+

SSL/TLS Configuration

+

Certificate Paths

+

Recommended structure: +

/etc/letsencrypt/live/cmlite.org/
+├─ fullchain.pem  (certificate + intermediate)
+├─ privkey.pem    (private key)
+└─ chain.pem      (intermediate CA)
+

+

Nginx SSL block: +

server {
+    listen 443 ssl http2;
+    server_name api.cmlite.org;
+
+    ssl_certificate /etc/letsencrypt/live/cmlite.org/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/cmlite.org/privkey.pem;
+
+    # Strong TLS configuration
+    ssl_protocols TLSv1.2 TLSv1.3;
+    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
+    ssl_prefer_server_ciphers on;
+    ssl_session_cache shared:SSL:10m;
+    ssl_session_timeout 10m;
+
+    # ... location blocks
+}
+

+
+

HTTP to HTTPS Redirect

+
server {
+    listen 80;
+    server_name api.cmlite.org;
+
+    # Redirect all HTTP to HTTPS
+    return 301 https://$host$request_uri;
+}
+
+server {
+    listen 443 ssl http2;
+    server_name api.cmlite.org;
+    # ... SSL config + locations
+}
+
+
+

HSTS Header

+

Already applied globally (in nginx.conf): +

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+

+

Effect: Browser caches HTTPS requirement for 1 year.

+

Important: Only enable after verifying HTTPS works (can't easily undo).

+
+

Wildcard Certificates

+

For *.cmlite.org (Let's Encrypt DNS challenge): +

certbot certonly --dns-cloudflare \
+  --dns-cloudflare-credentials ~/.secrets/cloudflare.ini \
+  -d cmlite.org -d "*.cmlite.org"
+

+

Single cert covers all subdomains: +- api.cmlite.org +- app.cmlite.org +- db.cmlite.org +- etc.

+

See SSL/TLS for complete certificate management.

+
+

Static File Serving

+

Admin GUI Production Build

+

Dockerfile multi-stage build (admin/Dockerfile): +

# Build stage
+FROM node:20-alpine AS builder
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
+# Production stage
+FROM nginx:alpine
+COPY --from=builder /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/nginx.conf
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]
+

+

Nginx serves static files (no Node.js in production): +

server {
+    listen 80;
+    server_name app.cmlite.org;
+
+    root /usr/share/nginx/html;
+    index index.html;
+
+    # React Router support (all routes → index.html)
+    location / {
+        try_files $uri $uri/ /index.html;
+    }
+
+    # API proxy
+    location /api/ {
+        proxy_pass http://changemaker-v2-api:4000;
+        # ... proxy headers
+    }
+
+    # Cache static assets
+    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
+        expires 1y;
+        add_header Cache-Control "public, immutable";
+    }
+}
+

+
+

MkDocs Static Site

+

Build process (via admin GUI or CLI): +

docker compose exec mkdocs mkdocs build
+

+

Output: mkdocs/site/ directory with static HTML

+

Served by mkdocs-site-server (Nginx Alpine container): +

mkdocs-site-server:
+  image: lscr.io/linuxserver/nginx:latest
+  volumes:
+    - ./mkdocs/site:/config/www
+  ports:
+    - "4004:80"
+

+

Nginx config (in configs/mkdocs-site/default.conf): +

server {
+    listen 80;
+    root /config/www;
+    index index.html;
+
+    location / {
+        try_files $uri $uri/ =404;
+    }
+}
+

+
+

Performance Optimization

+

Gzip Compression

+

Already enabled globally (see nginx.conf above).

+

Compression ratio: +- JSON responses: ~75% reduction +- HTML/CSS/JS: ~60-70% reduction +- Images/video: No compression (already compressed)

+

Trade-off: Slight CPU increase (~5-10%) for bandwidth savings.

+
+

Caching Static Assets

+
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
+    expires 1y;
+    add_header Cache-Control "public, immutable";
+}
+
+

Effect: Browsers cache static assets for 1 year.

+

Caveat: Use content hashing in filenames (Vite does this automatically).

+
+

Proxy Caching

+

Optional (not enabled by default): +

# In http block
+proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m max_size=1g inactive=60m;
+
+# In location block
+location /api/campaigns {
+    proxy_cache api_cache;
+    proxy_cache_valid 200 10m;
+    proxy_cache_key "$scheme$request_method$host$request_uri";
+    proxy_pass http://changemaker-v2-api:4000;
+}
+

+

Use cases: +- Public campaign listing (10-minute cache) +- Public map data (5-minute cache) +- Representative lookup (1-hour cache)

+

Avoid caching: +- Authenticated endpoints +- POST/PUT/DELETE requests +- Real-time data (canvass sessions, email queue)

+
+

Connection Pooling

+

Keep-alive to backends: +

upstream api {
+    server changemaker-v2-api:4000;
+    keepalive 32;  # Maintain 32 idle connections
+}
+
+location /api/ {
+    proxy_pass http://api;
+    proxy_http_version 1.1;
+    proxy_set_header Connection "";  # Clear close header
+}
+

+

Benefits: +- Reduced latency (no TCP handshake) +- Lower CPU (fewer connection setups) +- Better throughput under load

+
+

Troubleshooting

+

502 Bad Gateway

+

Symptoms: 502 Bad Gateway error

+

Causes: +1. Backend container not running +2. Backend healthcheck failing +3. Backend listening on wrong port +4. Network connectivity issue

+

Diagnosis: +

# Check backend status
+docker compose ps api
+
+# Check backend logs
+docker compose logs --tail=50 api
+
+# Test backend directly
+docker compose exec nginx curl http://changemaker-v2-api:4000/api/health
+
+# Check Nginx error log
+docker compose exec nginx cat /var/log/nginx/error.log
+

+

Solution: +

# Restart backend
+docker compose restart api
+
+# Check healthcheck
+docker inspect changemaker-v2-api | jq '.[0].State.Health'
+
+# Verify port in docker-compose.yml
+grep -A5 "api:" docker-compose.yml
+

+
+

504 Gateway Timeout

+

Symptoms: Request times out after 60 seconds

+

Cause: Backend processing too slow, proxy timeout too short

+

Solution: +

# Increase timeout for slow endpoints
+location /api/locations/geocode {
+    proxy_read_timeout 300s;  # 5 minutes
+    proxy_pass http://changemaker-v2-api:4000;
+}
+

+
+

SSL Certificate Errors

+

Symptoms: SSL_ERROR_RX_RECORD_TOO_LONG or ERR_SSL_PROTOCOL_ERROR

+

Cause: Accessing HTTPS port via HTTP or vice versa

+

Diagnosis: +

# Test HTTPS
+curl -I https://api.cmlite.org
+
+# Check certificate
+openssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org
+
+# Verify Nginx config
+docker compose exec nginx nginx -t
+

+

Solution: +

# Reload Nginx after cert renewal
+docker compose exec nginx nginx -s reload
+
+# Check cert paths in config
+grep ssl_certificate /path/to/nginx/conf.d/*.conf
+

+
+

CORS Errors

+

Symptoms: Browser console shows CORS policy: No 'Access-Control-Allow-Origin' header

+

Cause: Backend not setting CORS headers

+

Diagnosis: +

# Test from browser console
+fetch('http://api.cmlite.org/api/campaigns')
+
+# Check response headers
+curl -H "Origin: http://example.com" -I http://api.cmlite.org/api/campaigns
+

+

Solution: CORS headers set by backend (not Nginx). Check api/src/server.ts: +

app.use(cors({
+  origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'],
+  credentials: true,
+}));
+

+

Nginx passthrough (don't modify CORS headers): +

# DO NOT add these in Nginx (backend handles CORS)
+# add_header Access-Control-Allow-Origin "*";  # ❌ WRONG
+

+
+

WebSocket Connection Failures

+

Symptoms: WebSocket upgrade fails with 400 Bad Request

+

Cause: Missing Upgrade/Connection headers

+

Diagnosis: +

# Check Nginx config
+grep -A5 "Upgrade" nginx/conf.d/services.conf
+
+# Test WebSocket
+wscat -c ws://localhost:5678
+

+

Solution: +

# Add to location block
+proxy_set_header Upgrade $http_upgrade;
+proxy_set_header Connection "upgrade";
+

+
+

Large Upload Failures

+

Symptoms: Upload fails with 413 Request Entity Too Large

+

Cause: client_max_body_size too small

+

Solution: +

# Increase limit for specific location
+location /api/media/videos {
+    client_max_body_size 10G;
+    proxy_pass http://changemaker-media-api:4100;
+}
+

+
+

Iframe Not Displaying

+

Symptoms: Service loads in new tab but not in iframe

+

Cause: X-Frame-Options: DENY or CSP frame-ancestors blocking

+

Diagnosis: +

# Check response headers
+curl -I http://db.cmlite.org
+
+# Look for X-Frame-Options or Content-Security-Policy
+

+

Solution: +

# Hide backend's X-Frame-Options
+proxy_hide_header X-Frame-Options;
+
+# Add CSP allowing admin
+add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
+

+
+

Nginx Won't Start

+

Symptoms: docker compose up fails with Nginx error

+

Diagnosis: +

# Test config syntax
+docker compose run --rm nginx nginx -t
+
+# Check for duplicate server_name
+grep server_name nginx/conf.d/*.conf | sort
+
+# Check for port conflicts
+docker compose config | grep -A2 "ports:"
+

+

Common mistakes: +- Missing semicolon +- Duplicate server_name (same subdomain in multiple files) +- Invalid regex in location +- Unclosed { bracket

+
+

Production Best Practices

+

Rate Limiting

+

Limit requests per IP (prevents abuse): +

# In http block
+limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
+
+# In location block
+location /api/ {
+    limit_req zone=api_limit burst=20 nodelay;
+    proxy_pass http://changemaker-v2-api:4000;
+}
+

+

Explanation: +- rate=10r/s: 10 requests per second average +- burst=20: Allow bursts up to 20 requests +- nodelay: Process burst immediately (don't queue)

+
+

Security Headers Review

+

Production checklist: +- [x] HSTS enabled (max-age=31536000) +- [x] X-Content-Type-Options: nosniff +- [x] X-XSS-Protection: 1; mode=block +- [x] CSP frame-ancestors for embeddable services +- [x] X-Frame-Options: SAMEORIGIN for non-embedded services +- [x] Referrer-Policy: strict-origin-when-cross-origin +- [x] Permissions-Policy restricts sensors

+

Optional enhancements: +

# Stricter CSP
+add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" always;
+
+# Expect-CT (certificate transparency)
+add_header Expect-CT "max-age=86400, enforce" always;
+

+
+

Access Logging

+

Production log format (JSON for parsing): +

log_format json_combined escape=json
+  '{'
+    '"time_local":"$time_local",'
+    '"remote_addr":"$remote_addr",'
+    '"request":"$request",'
+    '"status": $status,'
+    '"body_bytes_sent":$body_bytes_sent,'
+    '"request_time":$request_time,'
+    '"http_referrer":"$http_referer",'
+    '"http_user_agent":"$http_user_agent"'
+  '}';
+
+access_log /var/log/nginx/access.log json_combined;
+

+

Benefits: Easy parsing with tools like jq, Logstash, Loki.

+
+

Error Page Customization

+

Custom error pages: +

error_page 404 /404.html;
+error_page 500 502 503 504 /50x.html;
+
+location = /404.html {
+    root /usr/share/nginx/html;
+    internal;
+}
+
+location = /50x.html {
+    root /usr/share/nginx/html;
+    internal;
+}
+

+

Create files: +

cat > nginx/html/404.html <<EOF
+<!DOCTYPE html>
+<html>
+<head><title>404 Not Found</title></head>
+<body>
+<h1>404 - Page Not Found</h1>
+<p>Return to <a href="/">homepage</a></p>
+</body>
+</html>
+EOF
+

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/deployment/scaling/index.html b/mkdocs/site/v2/deployment/scaling/index.html new file mode 100644 index 00000000..7e140051 --- /dev/null +++ b/mkdocs/site/v2/deployment/scaling/index.html @@ -0,0 +1,5753 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Scaling - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Horizontal Scaling Strategies

+

Overview

+

Changemaker Lite V2 can scale horizontally to handle increased traffic and data volume. This guide covers strategies for scaling each component.

+

When to Scale: +- API response time >500ms (P95) +- CPU usage >70% sustained +- Memory usage >80% sustained +- Database connection pool exhausted +- Job queue backing up (>100 jobs waiting)

+
+

Database Scaling

+

Read Replicas

+

PostgreSQL streaming replication for read-heavy workloads.

+

Setup (docker-compose.yml): +

v2-postgres-replica:
+  image: postgres:16-alpine
+  container_name: changemaker-v2-postgres-replica
+  environment:
+    POSTGRES_USER: replicator
+    POSTGRES_PASSWORD: ${REPLICA_PASSWORD}
+  command: |
+    postgres -c wal_level=replica
+             -c hot_standby=on
+             -c max_wal_senders=3
+             -c hot_standby_feedback=on
+  volumes:
+    - v2-postgres-replica-data:/var/lib/postgresql/data
+

+

Primary config (postgresql.conf): +

wal_level = replica
+max_wal_senders = 3
+wal_keep_size = 64MB
+

+

Replication user: +

CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'replica-password';
+

+

Prisma read replica (planned feature): +

// Future: Prisma read replicas
+const prisma = new PrismaClient({
+  datasources: {
+    db: {
+      url: process.env.DATABASE_URL,           // Primary (writes)
+      replicaUrl: process.env.REPLICA_URL,     // Replica (reads)
+    },
+  },
+});
+

+
+

Connection Pooling

+

PgBouncer for connection pooling.

+

docker-compose.yml: +

pgbouncer:
+  image: pgbouncer/pgbouncer:latest
+  container_name: pgbouncer-changemaker
+  environment:
+    DATABASES_HOST: changemaker-v2-postgres
+    DATABASES_PORT: 5432
+    DATABASES_USER: changemaker
+    DATABASES_PASSWORD: ${V2_POSTGRES_PASSWORD}
+    DATABASES_DBNAME: changemaker_v2
+    POOL_MODE: transaction
+    MAX_CLIENT_CONN: 1000
+    DEFAULT_POOL_SIZE: 20
+  ports:
+    - "6432:6432"
+

+

Update DATABASE_URL: +

# Before (direct)
+DATABASE_URL=postgresql://changemaker:pass@changemaker-v2-postgres:5432/changemaker_v2
+
+# After (pooled)
+DATABASE_URL=postgresql://changemaker:pass@pgbouncer:6432/changemaker_v2
+

+

Benefits: +- Handles 1000+ client connections with only 20 PostgreSQL connections +- Reduces connection overhead +- Prevents "too many connections" errors

+
+

API Scaling

+

Multiple API Containers

+

docker-compose.yml: +

api:
+  # ... existing config
+  deploy:
+    replicas: 3  # Run 3 API containers
+

+

Or manual scaling: +

docker compose up -d --scale api=3
+

+

Load balancer (Nginx upstream): +

upstream api_backend {
+    least_conn;  # Load balancing algorithm
+    server changemaker-v2-api-1:4000;
+    server changemaker-v2-api-2:4000;
+    server changemaker-v2-api-3:4000;
+}
+
+server {
+    location /api/ {
+        proxy_pass http://api_backend;
+    }
+}
+

+

Session affinity (sticky sessions): +

upstream api_backend {
+    ip_hash;  # Route same IP to same backend
+    server changemaker-v2-api-1:4000;
+    server changemaker-v2-api-2:4000;
+}
+

+
+

Vertical Scaling (Resource Limits)

+

Increase container resources: +

api:
+  deploy:
+    resources:
+      limits:
+        cpus: '4'      # 4 CPU cores
+        memory: 4G     # 4GB RAM
+      reservations:
+        cpus: '1'
+        memory: 1G
+

+

Node.js memory limit: +

api:
+  environment:
+    - NODE_OPTIONS=--max-old-space-size=3072  # 3GB heap
+

+
+

Redis Scaling

+

Redis Cluster (Sharding)

+

For >100GB datasets or high throughput.

+

docker-compose.yml (6-node cluster): +

redis-cluster-1:
+  image: redis:7-alpine
+  command: redis-server --cluster-enabled yes --cluster-config-file nodes.conf
+
+# ... repeat for redis-cluster-2 through redis-cluster-6
+

+

Create cluster: +

docker compose exec redis-cluster-1 redis-cli --cluster create \
+  redis-cluster-1:6379 \
+  redis-cluster-2:6379 \
+  redis-cluster-3:6379 \
+  redis-cluster-4:6379 \
+  redis-cluster-5:6379 \
+  redis-cluster-6:6379 \
+  --cluster-replicas 1
+

+
+

Redis Sentinel (High Availability)

+

Automatic failover for Redis.

+

docker-compose.yml: +

redis-sentinel-1:
+  image: redis:7-alpine
+  command: redis-sentinel /etc/redis/sentinel.conf
+  volumes:
+    - ./configs/redis/sentinel.conf:/etc/redis/sentinel.conf
+
+# ... repeat for sentinel-2, sentinel-3
+

+

sentinel.conf: +

sentinel monitor mymaster redis-primary 6379 2
+sentinel down-after-milliseconds mymaster 5000
+sentinel parallel-syncs mymaster 1
+sentinel failover-timeout mymaster 10000
+

+
+

Media API Scaling

+

Separate Media Containers

+

docker-compose.yml: +

media-api:
+  deploy:
+    replicas: 2  # Run 2 media API containers
+

+

Nginx load balancer: +

upstream media_backend {
+    server changemaker-media-api-1:4100;
+    server changemaker-media-api-2:4100;
+}
+
+location /api/media/ {
+    proxy_pass http://media_backend;
+}
+

+

Shared volume (read-only): +

media-api:
+  volumes:
+    - ${MEDIA_ROOT}:/media:ro  # All replicas read same library
+

+
+

CDN for Static Media

+

Cloudflare CDN (or similar):

+

Setup: +1. Enable Cloudflare proxy (orange cloud) +2. Configure cache rules: + - Cache /media/library/*.mp4 for 30 days + - Bypass cache for /api/media/ (dynamic)

+

Benefits: +- Offload video bandwidth +- Global edge caching +- DDoS protection

+
+

Frontend Scaling

+

CDN for Static Assets

+

Vite production build → static files → CDN.

+

Build: +

cd admin && npm run build
+

+

Upload to CDN (S3 + CloudFront): +

aws s3 sync dist/ s3://changemaker-static/ --delete
+aws cloudfront create-invalidation --distribution-id XYZ --paths "/*"
+

+

Benefits: +- Global edge caching +- Reduced origin load +- Faster page loads

+
+

Nginx Caching

+

Proxy cache for API responses: +

proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m max_size=1g inactive=60m;
+
+location /api/campaigns {
+    proxy_cache api_cache;
+    proxy_cache_valid 200 10m;
+    proxy_cache_key "$scheme$request_method$host$request_uri";
+    proxy_pass http://changemaker-v2-api:4000;
+}
+

+

Cacheable endpoints: +- /api/campaigns (public listing, 10 minutes) +- /api/representatives (lookup cache, 1 hour) +- /api/locations/public (map data, 5 minutes)

+

Never cache: +- POST/PUT/DELETE requests +- Authenticated endpoints +- Real-time data (canvass sessions)

+
+

Job Queue Scaling

+

Multiple BullMQ Workers

+

API container scaling also scales workers (each container runs worker).

+

Alternative: Dedicated worker containers.

+

docker-compose.yml: +

email-worker:
+  build:
+    context: ./api
+  container_name: email-worker
+  command: node dist/workers/email-worker.js
+  environment:
+    - REDIS_URL=${REDIS_URL}
+    - SMTP_HOST=${SMTP_HOST}
+    # ... other env vars
+  depends_on:
+    - redis
+

+

Worker script (api/src/workers/email-worker.ts): +

import { emailQueue } from '../services/email-queue.service';
+
+emailQueue.process(10, async (job) => {
+  // Process email job
+});
+
+console.log('Email worker started');
+

+

Scale workers: +

docker compose up -d --scale email-worker=5
+

+
+

Monitoring Under Load

+

Load Testing

+

k6 script (load-test.js): +

import http from 'k6/http';
+import { check } from 'k6';
+
+export let options = {
+  stages: [
+    { duration: '1m', target: 50 },   // Ramp to 50 users
+    { duration: '3m', target: 50 },   // Stay at 50 users
+    { duration: '1m', target: 100 },  // Ramp to 100 users
+    { duration: '3m', target: 100 },  // Stay at 100 users
+    { duration: '1m', target: 0 },    // Ramp down
+  ],
+};
+
+export default function () {
+  let res = http.get('http://api.cmlite.org/api/campaigns');
+  check(res, {
+    'status 200': (r) => r.status === 200,
+    'response time < 500ms': (r) => r.timings.duration < 500,
+  });
+}
+

+

Run test: +

k6 run load-test.js
+

+
+

Prometheus Metrics

+

Monitor scaling indicators: +- rate(http_requests_total[5m]) — Request rate +- histogram_quantile(0.95, http_request_duration_seconds) — P95 latency +- container_cpu_usage_seconds_total — CPU usage per container +- container_memory_usage_bytes — Memory usage per container

+

Grafana alert: +

- alert: HighAPILatency
+  expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 0.5
+  for: 5m
+  labels:
+    severity: warning
+  annotations:
+    summary: "P95 latency >500ms, consider scaling"
+

+
+

Troubleshooting

+

High CPU Usage

+

Diagnosis: +

# Top processes
+docker stats
+
+# API CPU usage
+docker stats changemaker-v2-api
+
+# Profile Node.js
+docker compose exec api node --prof dist/server.js
+

+

Solutions: +- Scale API containers (3-5 replicas) +- Increase CPU limit (2-4 cores) +- Optimize slow queries (add indexes) +- Enable caching (Nginx proxy cache)

+
+

Memory Leaks

+

Diagnosis: +

# Memory usage over time
+docker stats --no-stream changemaker-v2-api
+
+# Heap snapshot (Node.js)
+docker compose exec api node --inspect dist/server.js
+# Chrome DevTools → Memory → Take snapshot
+

+

Solutions: +- Restart containers daily (cron job) +- Increase memory limit (4-8GB) +- Fix code leaks (event listeners, circular refs)

+
+

Database Connection Exhaustion

+

Symptoms: Error: too many connections for role "changemaker"

+

Diagnosis: +

# Check connection count
+docker compose exec v2-postgres psql -U changemaker -c \
+  "SELECT COUNT(*) FROM pg_stat_activity WHERE usename='changemaker'"
+
+# Check max connections
+docker compose exec v2-postgres psql -U changemaker -c \
+  "SHOW max_connections"
+

+

Solutions: +- Add PgBouncer (connection pooling) +- Increase max_connections (PostgreSQL config) +- Fix connection leaks (always close Prisma clients)

+
+

Cost Optimization

+

Resource Allocation

+

Right-sizing (don't over-provision): +- Start with 1 CPU, 1GB RAM per container +- Monitor actual usage (Prometheus) +- Scale based on metrics (not guesses)

+

Example (production workload): +- API: 2 CPUs, 2GB RAM (3 replicas) +- PostgreSQL: 2 CPUs, 4GB RAM +- Redis: 1 CPU, 512MB RAM +- Media API: 2 CPUs, 2GB RAM (2 replicas)

+
+

Autoscaling (Docker Swarm)

+

Docker Swarm mode (alternative to Compose): +

# Initialize swarm
+docker swarm init
+
+# Deploy stack
+docker stack deploy -c docker-compose.yml changemaker
+
+# Autoscale API
+docker service scale changemaker_api=3
+
+# Update with zero downtime
+docker service update --image api:v2.1 changemaker_api
+

+

Autoscaling: +

api:
+  deploy:
+    replicas: 3
+    update_config:
+      parallelism: 1
+      delay: 10s
+    restart_policy:
+      condition: on-failure
+

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/deployment/ssl-tls/index.html b/mkdocs/site/v2/deployment/ssl-tls/index.html new file mode 100644 index 00000000..263da362 --- /dev/null +++ b/mkdocs/site/v2/deployment/ssl-tls/index.html @@ -0,0 +1,5542 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SSL/TLS - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

SSL/TLS Certificate Management

+

Overview

+

Changemaker Lite V2 supports multiple SSL/TLS certificate sources for HTTPS deployment:

+
    +
  • Let's Encrypt: Free automated certificates (recommended for self-hosted)
  • +
  • Cloudflare Origin Certificates: Static 15-year certificates (if using Cloudflare)
  • +
  • Pangolin Tunnel SSL: Tunnel provider handles SSL termination
  • +
+

Recommendation: Use Pangolin tunnel for simplest setup (SSL handled by tunnel provider).

+
+

Certificate Sources

+

Let's Encrypt with Certbot

+

Best for: Self-hosted deployments with public DNS

+

Process: +1. Install Certbot +2. Generate certificate (DNS or HTTP challenge) +3. Configure Nginx +4. Auto-renewal via cron

+

Installation (Ubuntu/Debian): +

sudo apt update
+sudo apt install certbot python3-certbot-nginx
+

+

Generate Certificate (HTTP-01 challenge): +

# Stop Nginx temporarily
+docker compose stop nginx
+
+# Generate cert
+sudo certbot certonly --standalone \
+  -d cmlite.org \
+  -d "*.cmlite.org" \
+  --email admin@cmlite.org \
+  --agree-tos \
+  --non-interactive
+
+# Start Nginx
+docker compose start nginx
+

+

Certificate Location: +

/etc/letsencrypt/live/cmlite.org/
+├─ fullchain.pem  (certificate + intermediate)
+├─ privkey.pem    (private key)
+└─ chain.pem      (intermediate only)
+

+

Nginx Configuration: +

server {
+    listen 443 ssl http2;
+    server_name api.cmlite.org;
+
+    ssl_certificate /etc/letsencrypt/live/cmlite.org/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/cmlite.org/privkey.pem;
+
+    ssl_protocols TLSv1.2 TLSv1.3;
+    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';
+    ssl_prefer_server_ciphers on;
+
+    # ... locations
+}
+

+

HTTP Redirect: +

server {
+    listen 80;
+    server_name api.cmlite.org;
+    return 301 https://$host$request_uri;
+}
+

+
+

Cloudflare Origin Certificates

+

Best for: Sites using Cloudflare DNS + proxy

+

Process: +1. Generate certificate in Cloudflare dashboard +2. Download certificate + private key +3. Install in Nginx +4. Set SSL mode to "Full (strict)"

+

Generate Certificate: +1. Cloudflare dashboard → SSL/TLS → Origin Server +2. Click "Create Certificate" +3. Hostnames: cmlite.org, *.cmlite.org +4. Validity: 15 years +5. Download certificate + private key

+

Install Certificate: +

# Create directory
+sudo mkdir -p /etc/ssl/cloudflare
+
+# Save files
+sudo nano /etc/ssl/cloudflare/cmlite.org.pem      # Certificate
+sudo nano /etc/ssl/cloudflare/cmlite.org.key      # Private key
+
+# Set permissions
+sudo chmod 600 /etc/ssl/cloudflare/cmlite.org.key
+

+

Nginx Configuration: +

server {
+    listen 443 ssl http2;
+    server_name api.cmlite.org;
+
+    ssl_certificate /etc/ssl/cloudflare/cmlite.org.pem;
+    ssl_certificate_key /etc/ssl/cloudflare/cmlite.org.key;
+
+    # ... TLS config
+}
+

+

Cloudflare SSL Mode: Set to "Full (strict)" (not "Flexible").

+
+

Pangolin Tunnel SSL

+

Best for: Quick deployment without SSL management

+

How it works: +1. Pangolin tunnel terminates SSL at tunnel endpoint +2. Traffic forwarded to your Nginx as HTTP +3. No certificate management needed

+

Setup: +

# Configure tunnel (see Tunneling guide)
+PANGOLIN_ENDPOINT=https://pangolin.bnkserve.org
+NEWT_ID=<your-newt-id>
+NEWT_SECRET=<your-newt-secret>
+
+# Start Newt container
+docker compose up -d newt
+

+

Nginx Configuration: Keep HTTP-only (tunnel handles HTTPS): +

server {
+    listen 80;
+    server_name api.cmlite.org;
+
+    # No SSL config needed — tunnel terminates HTTPS
+    location / {
+        proxy_pass http://changemaker-v2-api:4000;
+    }
+}
+

+

DNS Setup: Point domain to tunnel endpoint (provided by Pangolin).

+

See Tunneling for complete guide.

+
+

Nginx SSL Configuration

+

Strong TLS Settings

+
server {
+    listen 443 ssl http2;
+    server_name api.cmlite.org;
+
+    # Certificates
+    ssl_certificate /etc/letsencrypt/live/cmlite.org/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/cmlite.org/privkey.pem;
+
+    # Protocols (TLS 1.2+ only)
+    ssl_protocols TLSv1.2 TLSv1.3;
+
+    # Ciphers (secure + fast)
+    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
+    ssl_prefer_server_ciphers on;
+
+    # Session caching (performance)
+    ssl_session_cache shared:SSL:10m;
+    ssl_session_timeout 10m;
+
+    # OCSP stapling (performance + privacy)
+    ssl_stapling on;
+    ssl_stapling_verify on;
+    ssl_trusted_certificate /etc/letsencrypt/live/cmlite.org/chain.pem;
+
+    # HSTS (already set globally in nginx.conf)
+    # add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+    # ... locations
+}
+
+

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

+
+

HTTP/2

+

Already enabled (:443 ssl http2): +- Multiplexes requests over single connection +- Server push support (optional) +- Faster page loads

+

No additional config needed — Nginx handles HTTP/2 automatically.

+
+

HSTS (HTTP Strict Transport Security)

+

Already set globally (in nginx/nginx.conf): +

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+

+

Effect: +- Browsers cache HTTPS requirement for 1 year +- Prevents downgrade attacks +- Applies to all subdomains

+

Warning: Only enable after verifying HTTPS works (can't easily undo).

+

Test before enabling: +

# Test HTTPS works
+curl -I https://api.cmlite.org
+
+# Check for redirects
+curl -L https://api.cmlite.org
+

+
+

Certificate Renewal

+

Automated Renewal (Certbot)

+

Setup cron job: +

# Edit crontab
+sudo crontab -e
+
+# Add renewal job (checks twice daily)
+0 0,12 * * * certbot renew --quiet --post-hook "docker compose -f /path/to/changemaker.lite/docker-compose.yml exec nginx nginx -s reload"
+

+

Manual renewal: +

# Dry run (test)
+sudo certbot renew --dry-run
+
+# Real renewal
+sudo certbot renew
+
+# Reload Nginx
+docker compose exec nginx nginx -s reload
+

+

Renewal conditions: +- Certificates expire in <30 days +- HTTP-01 challenge succeeds (port 80 must be open)

+
+

Manual Renewal (Cloudflare)

+

Cloudflare origin certificates valid for 15 years — no renewal needed.

+

If replacing certificate: +1. Generate new cert in Cloudflare dashboard +2. Download files +3. Replace files in /etc/ssl/cloudflare/ +4. Reload Nginx: docker compose exec nginx nginx -s reload

+
+

Monitoring Expiry

+

Check expiry date: +

# Via OpenSSL
+echo | openssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org 2>/dev/null | openssl x509 -noout -dates
+
+# Output:
+# notBefore=Jan  1 00:00:00 2024 GMT
+# notAfter=Apr  1 23:59:59 2024 GMT
+

+

Automated monitoring (via Prometheus + Alertmanager): +

# In alerts.yml
+- alert: SSLCertificateExpiringSoon
+  expr: probe_ssl_earliest_cert_expiry - time() < 86400 * 30  # 30 days
+  for: 1h
+  labels:
+    severity: warning
+  annotations:
+    summary: "SSL certificate expiring in <30 days"
+

+
+

Testing SSL

+

SSL Labs

+

Online test: https://www.ssllabs.com/ssltest/

+

Target grade: A or A+

+

Common issues: +- Missing intermediate certificate (use fullchain.pem not cert.pem) +- Weak ciphers (update ssl_ciphers list) +- Missing HSTS header (already set globally)

+
+

Command Line

+

Test TLS handshake: +

openssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org
+

+

Check certificate chain: +

openssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org -showcerts
+

+

Test specific protocol: +

# TLS 1.2
+openssl s_client -connect api.cmlite.org:443 -tls1_2
+
+# TLS 1.3
+openssl s_client -connect api.cmlite.org:443 -tls1_3
+
+# SSLv3 (should fail)
+openssl s_client -connect api.cmlite.org:443 -ssl3
+

+
+

Wildcard Certificates

+

For *.cmlite.org (covers all subdomains):

+

Let's Encrypt (DNS-01 Challenge)

+

Required: API access to DNS provider (Cloudflare, Route53, etc.)

+

Example (Cloudflare): +

# Install Cloudflare plugin
+sudo apt install python3-certbot-dns-cloudflare
+
+# Create credentials file
+cat > ~/.secrets/cloudflare.ini <<EOF
+dns_cloudflare_api_token = YOUR_API_TOKEN
+EOF
+chmod 600 ~/.secrets/cloudflare.ini
+
+# Generate wildcard cert
+sudo certbot certonly \
+  --dns-cloudflare \
+  --dns-cloudflare-credentials ~/.secrets/cloudflare.ini \
+  -d cmlite.org \
+  -d "*.cmlite.org" \
+  --email admin@cmlite.org \
+  --agree-tos
+

+

Advantage: Single certificate covers all subdomains (api, app, db, etc.).

+
+

Troubleshooting

+

Certificate Not Trusted

+

Symptoms: Browser shows "Not Secure" warning

+

Causes: +1. Missing intermediate certificate +2. Wrong certificate file +3. Certificate expired

+

Solution: +

# Use fullchain.pem (includes intermediate)
+ssl_certificate /etc/letsencrypt/live/cmlite.org/fullchain.pem;
+
+# NOT cert.pem (missing intermediate)
+# ssl_certificate /etc/letsencrypt/live/cmlite.org/cert.pem;  # ❌ WRONG
+
+# Reload Nginx
+docker compose exec nginx nginx -s reload
+

+
+

Mixed Content Warnings

+

Symptoms: Some assets load via HTTP on HTTPS page

+

Cause: Hard-coded http:// URLs in HTML/JS

+

Solution: +

// Change absolute URLs to protocol-relative
+// ❌ WRONG
+const apiUrl = 'http://api.cmlite.org';
+
+// ✅ CORRECT
+const apiUrl = 'https://api.cmlite.org';
+
+// ✅ BEST (protocol-relative)
+const apiUrl = location.protocol + '//api.cmlite.org';
+

+
+

Renewal Failures

+

Symptoms: Certbot renewal fails

+

Diagnosis: +

# Test renewal
+sudo certbot renew --dry-run
+
+# Check logs
+sudo tail -f /var/log/letsencrypt/letsencrypt.log
+

+

Common causes: +- Port 80 blocked (HTTP-01 challenge fails) +- DNS not pointing to server (domain validation fails) +- Rate limit hit (5 certs/week per domain)

+

Solution: +

# Check port 80 open
+sudo netstat -tulpn | grep :80
+
+# Test HTTP challenge
+curl http://cmlite.org/.well-known/acme-challenge/test
+
+# Use DNS-01 challenge instead (no port 80 needed)
+sudo certbot certonly --dns-cloudflare ...
+

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/deployment/tunneling/index.html b/mkdocs/site/v2/deployment/tunneling/index.html new file mode 100644 index 00000000..85d86e2c --- /dev/null +++ b/mkdocs/site/v2/deployment/tunneling/index.html @@ -0,0 +1,5570 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Tunneling - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Pangolin Tunnel Deployment

+

Overview

+

Pangolin is a self-hosted tunnel service (alternative to Cloudflare Tunnel) that provides public HTTPS access to your Changemaker Lite instance without port forwarding or firewall configuration.

+

Benefits: +- No port forwarding needed +- SSL/TLS handled by tunnel provider +- Static public URLs +- Self-hosted tunnel server (privacy/control) +- Free/open source

+

Architecture: +

Internet → Pangolin Tunnel (pangolin.bnkserve.org) → Newt Container → Nginx → Services
+

+

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

+
+

Setup Workflow

+

1. Prerequisites

+

Required: +- Pangolin API key (obtain from Pangolin admin) +- Docker Compose running +- Nginx container accessible from Newt

+

Environment Variables: +

PANGOLIN_API_URL=https://api.bnkserve.org/v1
+PANGOLIN_API_KEY=<your-api-key>
+PANGOLIN_ORG_ID=<set-after-org-creation>
+PANGOLIN_SITE_ID=<set-after-site-creation>
+PANGOLIN_ENDPOINT=https://pangolin.bnkserve.org
+PANGOLIN_NEWT_ID=<set-after-resource-creation>
+PANGOLIN_NEWT_SECRET=<set-after-resource-creation>
+

+
+

2. Setup via Admin GUI

+

Easiest method: Use /app/pangolin page in admin GUI.

+

Steps: +1. Navigate to http://localhost:3000/app/pangolin +2. Enter PANGOLIN_API_KEY (click "Test Connection") +3. Create Organization (or select existing) +4. Create Site (linked to org) +5. Create Endpoint (tunnel URL) +6. Create Resource (Newt connector credentials) +7. Copy NEWT_ID and NEWT_SECRET to .env +8. Restart Newt container: docker compose restart newt

+
+

3. Manual Setup (CLI)

+

Organization: +

curl -X POST https://api.bnkserve.org/v1/orgs \
+  -H "Authorization: Bearer $PANGOLIN_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "name": "My Organization",
+    "description": "Changemaker Lite Production"
+  }'
+
+# Returns:
+# {"id":"org_abc123","name":"My Organization",...}
+
+export PANGOLIN_ORG_ID=org_abc123
+

+

Site: +

curl -X POST https://api.bnkserve.org/v1/sites \
+  -H "Authorization: Bearer $PANGOLIN_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "org_id": "'"$PANGOLIN_ORG_ID"'",
+    "name": "Production Site",
+    "description": "Main deployment"
+  }'
+
+# Returns:
+# {"id":"site_xyz789","name":"Production Site",...}
+
+export PANGOLIN_SITE_ID=site_xyz789
+

+

Endpoint: +

curl -X POST https://api.bnkserve.org/v1/endpoints \
+  -H "Authorization: Bearer $PANGOLIN_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "site_id": "'"$PANGOLIN_SITE_ID"'",
+    "subdomain": "changemaker",
+    "domain": "pangolin.bnkserve.org"
+  }'
+
+# Returns:
+# {"id":"endpoint_def456","url":"https://changemaker.pangolin.bnkserve.org",...}
+
+export PANGOLIN_ENDPOINT=https://changemaker.pangolin.bnkserve.org
+

+

Resource (Newt Connector): +

curl -X POST https://api.bnkserve.org/v1/resources \
+  -H "Authorization: Bearer $PANGOLIN_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "endpoint_id": "<endpoint-id>",
+    "target": "http://nginx:80",
+    "name": "Changemaker Services"
+  }'
+
+# Returns:
+# {"id":"newt_abc123","secret":"secret_xyz789",...}
+
+export PANGOLIN_NEWT_ID=newt_abc123
+export PANGOLIN_NEWT_SECRET=secret_xyz789
+

+

Update .env: +

cat >> .env <<EOF
+PANGOLIN_ORG_ID=$PANGOLIN_ORG_ID
+PANGOLIN_SITE_ID=$PANGOLIN_SITE_ID
+PANGOLIN_ENDPOINT=$PANGOLIN_ENDPOINT
+PANGOLIN_NEWT_ID=$PANGOLIN_NEWT_ID
+PANGOLIN_NEWT_SECRET=$PANGOLIN_NEWT_SECRET
+EOF
+

+
+

4. Start Newt Container

+
# Restart to pick up new env vars
+docker compose up -d --force-recreate newt
+
+# Check logs
+docker compose logs -f newt
+
+# Should see:
+# [INFO] Connected to Pangolin tunnel
+# [INFO] Tunnel active: https://changemaker.pangolin.bnkserve.org
+
+
+

5. DNS Configuration

+

Option A: Direct Access (use tunnel URL): +- https://changemaker.pangolin.bnkserve.org

+

Option B: Custom Domain (CNAME to tunnel): +

app.cmlite.org.  CNAME  changemaker.pangolin.bnkserve.org.
+api.cmlite.org.  CNAME  changemaker.pangolin.bnkserve.org.
+

+
+

Newt Container Configuration

+

docker-compose.yml: +

newt:
+  image: fosrl/newt
+  container_name: newt-changemaker
+  restart: unless-stopped
+  environment:
+    - PANGOLIN_ENDPOINT=${PANGOLIN_ENDPOINT}
+    - NEWT_ID=${PANGOLIN_NEWT_ID}
+    - NEWT_SECRET=${PANGOLIN_NEWT_SECRET}
+  depends_on:
+    - nginx
+  networks:
+    - changemaker-lite
+

+

Key Features: +- Restart policy: unless-stopped (auto-reconnects) +- Nginx dependency: Ensures Nginx running before Newt starts +- No ports exposed: All traffic via tunnel

+

Target: Newt connects to http://nginx:80 (Docker internal network).

+
+

Tunnel Lifecycle

+

Start Tunnel

+
docker compose up -d newt
+
+

Stop Tunnel

+
docker compose stop newt
+
+

Check Status

+
# Container status
+docker compose ps newt
+
+# Logs
+docker compose logs --tail=50 newt
+
+# Test tunnel
+curl https://changemaker.pangolin.bnkserve.org/api/health
+
+

Restart (after .env changes)

+
docker compose up -d --force-recreate newt
+
+
+

Exit Nodes & Resource Routing

+

Resource: Defines how tunnel routes traffic to your services.

+

Target URL: http://nginx:80 (Nginx handles subdomain routing internally).

+

Example Flow: +1. User visits https://changemaker.pangolin.bnkserve.org/api/health +2. Pangolin tunnel receives HTTPS request +3. Tunnel forwards to Newt container +4. Newt proxies to http://nginx:80/api/health +5. Nginx routes to changemaker-v2-api:4000/api/health +6. Response flows back through tunnel

+

Multiple Resources: Create separate resources for different backends (advanced).

+
+

Troubleshooting

+

Tunnel Not Connecting

+

Symptoms: Newt logs show connection errors

+

Diagnosis: +

docker compose logs newt
+
+# Common errors:
+# - "Authentication failed" (wrong NEWT_ID/SECRET)
+# - "Endpoint not found" (wrong PANGOLIN_ENDPOINT)
+# - "Connection refused" (Nginx not running)
+

+

Solutions: +

# Verify credentials
+docker compose exec newt printenv | grep PANGOLIN
+
+# Test Nginx from Newt container
+docker compose exec newt wget -O- http://nginx:80/api/health
+
+# Restart Newt
+docker compose restart newt
+

+
+

Tunnel Connected But Site Unreachable

+

Symptoms: Newt connected, but HTTPS requests timeout/fail

+

Diagnosis: +

# Test tunnel endpoint
+curl -I https://changemaker.pangolin.bnkserve.org
+
+# Check Nginx logs
+docker compose logs nginx | tail -50
+
+# Verify resource target
+curl -X GET https://api.bnkserve.org/v1/resources/<resource-id> \
+  -H "Authorization: Bearer $PANGOLIN_API_KEY"
+

+

Common Causes: +- Resource target points to wrong service +- Nginx not listening on port 80 +- Firewall blocking Nginx → backend communication

+

Solution: +

# Verify Nginx config
+docker compose exec nginx nginx -t
+
+# Check Nginx listening
+docker compose exec nginx netstat -tulpn | grep :80
+
+# Test backend from Nginx
+docker compose exec nginx curl http://changemaker-v2-api:4000/api/health
+

+
+

SSL Certificate Errors

+

Symptoms: Browser shows "Certificate invalid" warning

+

Cause: Tunnel endpoint SSL certificate not trusted (rare).

+

Solution: Contact Pangolin support — tunnel provider manages SSL certificates.

+
+

Frequent Disconnects

+

Symptoms: Newt reconnects every few minutes

+

Diagnosis: +

# Check for network issues
+docker compose logs newt | grep -i disconnect
+
+# Monitor connection
+watch -n5 'docker compose logs --tail=1 newt'
+

+

Possible Causes: +- Network instability +- Container restarts (check docker compose ps) +- Resource limits (check docker stats newt-changemaker)

+

Solution: +

# Increase restart backoff (if needed)
+# Edit docker-compose.yml:
+newt:
+  restart_policy:
+    condition: on-failure
+    delay: 5s
+    max_attempts: 3
+

+
+

Migration from Cloudflare Tunnel

+

Retired Scripts (in scripts/legacy/): +- start-production.sh +- config.sh +- tunnel-config.sh

+

Migration Steps: +1. Stop Cloudflare tunnel: cloudflared service uninstall +2. Remove Cloudflare credentials: rm ~/.cloudflared/*.json +3. Setup Pangolin tunnel (see above) +4. Update DNS: Change CNAME from cloudflared.com to pangolin.bnkserve.org +5. Test new tunnel: curl https://changemaker.pangolin.bnkserve.org/api/health +6. Remove old scripts: rm scripts/legacy/*

+

Why Pangolin? +- Self-hosted (privacy/control) +- No Cloudflare dependency +- Free/open source +- API-driven management

+
+

Advanced Configuration

+

Custom Tunnel Domain

+

Requirement: Own domain with DNS control.

+

Steps: +1. Create endpoint with custom domain +2. Add DNS record: tunnel.cmlite.org CNAME pangolin.bnkserve.org. +3. Update PANGOLIN_ENDPOINT=https://tunnel.cmlite.org +4. Restart Newt

+

Multiple Sites

+

Use case: Staging + production on same tunnel.

+

Setup: +

# Create second site
+curl -X POST https://api.bnkserve.org/v1/sites \
+  -d '{"org_id":"...","name":"Staging"}'
+
+# Create endpoint for staging
+curl -X POST https://api.bnkserve.org/v1/endpoints \
+  -d '{"site_id":"...","subdomain":"staging-changemaker"}'
+
+# Create resource pointing to staging Nginx
+curl -X POST https://api.bnkserve.org/v1/resources \
+  -d '{"endpoint_id":"...","target":"http://nginx-staging:80"}'
+

+
+

Monitoring

+

Health Checks

+

Tunnel status: +

# Container health
+docker compose ps newt
+
+# Connection logs
+docker compose logs --tail=50 newt | grep -i connected
+
+# Test public endpoint
+curl -I https://changemaker.pangolin.bnkserve.org
+

+

Prometheus metrics (if enabled): +

# API uptime through tunnel
+curl https://changemaker.pangolin.bnkserve.org/api/metrics | grep cm_api_uptime
+

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/development/code-style/index.html b/mkdocs/site/v2/development/code-style/index.html new file mode 100644 index 00000000..357bab04 --- /dev/null +++ b/mkdocs/site/v2/development/code-style/index.html @@ -0,0 +1,6716 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Code Style - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Code Style Guide

+

Coding standards and style conventions for Changemaker Lite V2.

+

Overview

+

Consistent code style improves: +- Readability: Easier to understand code +- Maintainability: Easier to modify code +- Collaboration: Reduces merge conflicts +- Quality: Catches common errors

+

This guide covers TypeScript, ESLint, Prettier, and naming conventions.

+

Tools

+

TypeScript

+

Version: 5.x +Config: tsconfig.json (api/ and admin/)

+

Strict Mode: Enabled

+
{
+  "compilerOptions": {
+    "strict": true,
+    "noImplicitAny": true,
+    "strictNullChecks": true,
+    "strictFunctionTypes": true,
+    "strictPropertyInitialization": true,
+    "noImplicitThis": true,
+    "alwaysStrict": true
+  }
+}
+
+

ESLint

+

Version: 8.x +Config: .eslintrc.js (api/ and admin/)

+

Plugins: +- @typescript-eslint/eslint-plugin +- eslint-plugin-react (admin only) +- eslint-plugin-react-hooks (admin only)

+

Prettier

+

Version: 3.x +Config: .prettierrc

+

Format on save: Enabled (VSCode)

+

TypeScript Configuration

+

API tsconfig.json

+
{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "commonjs",
+    "lib": ["ES2022"],
+    "outDir": "./dist",
+    "rootDir": "./src",
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "forceConsistentCasingInFileNames": true,
+    "resolveJsonModule": true,
+    "moduleResolution": "node",
+    "types": ["node"]
+  },
+  "include": ["src/**/*"],
+  "exclude": ["node_modules", "dist", "**/*.test.ts"]
+}
+
+

Admin tsconfig.json

+
{
+  "compilerOptions": {
+    "target": "ES2020",
+    "useDefineForClassFields": true,
+    "lib": ["ES2020", "DOM", "DOM.Iterable"],
+    "module": "ESNext",
+    "skipLibCheck": true,
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "react-jsx",
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "types": ["vite/client"]
+  },
+  "include": ["src"],
+  "references": [{ "path": "./tsconfig.node.json" }]
+}
+
+

ESLint Rules

+

API .eslintrc.js

+
module.exports = {
+  parser: '@typescript-eslint/parser',
+  parserOptions: {
+    ecmaVersion: 2022,
+    sourceType: 'module',
+    project: './tsconfig.json'
+  },
+  extends: [
+    'eslint:recommended',
+    'plugin:@typescript-eslint/recommended',
+    'plugin:@typescript-eslint/recommended-requiring-type-checking'
+  ],
+  plugins: ['@typescript-eslint'],
+  root: true,
+  env: {
+    node: true,
+    es2022: true
+  },
+  rules: {
+    // TypeScript
+    '@typescript-eslint/no-explicit-any': 'error',
+    '@typescript-eslint/explicit-function-return-type': 'off',
+    '@typescript-eslint/explicit-module-boundary-types': 'off',
+    '@typescript-eslint/no-unused-vars': [
+      'error',
+      { argsIgnorePattern: '^_' }
+    ],
+    '@typescript-eslint/no-floating-promises': 'error',
+    '@typescript-eslint/await-thenable': 'error',
+
+    // General
+    'no-console': ['warn', { allow: ['warn', 'error'] }],
+    'no-debugger': 'error',
+    'prefer-const': 'error',
+    'no-var': 'error',
+    'eqeqeq': ['error', 'always'],
+    'curly': ['error', 'all']
+  }
+};
+
+

Admin .eslintrc.js

+
module.exports = {
+  parser: '@typescript-eslint/parser',
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: 'module',
+    ecmaFeatures: {
+      jsx: true
+    },
+    project: './tsconfig.json'
+  },
+  extends: [
+    'eslint:recommended',
+    'plugin:react/recommended',
+    'plugin:react-hooks/recommended',
+    'plugin:@typescript-eslint/recommended'
+  ],
+  plugins: ['react', 'react-hooks', '@typescript-eslint'],
+  root: true,
+  env: {
+    browser: true,
+    es2020: true
+  },
+  settings: {
+    react: {
+      version: 'detect'
+    }
+  },
+  rules: {
+    // React
+    'react/react-in-jsx-scope': 'off', // React 17+
+    'react/prop-types': 'off', // Use TypeScript
+    'react-hooks/rules-of-hooks': 'error',
+    'react-hooks/exhaustive-deps': 'warn',
+
+    // TypeScript
+    '@typescript-eslint/no-explicit-any': 'error',
+    '@typescript-eslint/explicit-function-return-type': 'off',
+    '@typescript-eslint/no-unused-vars': [
+      'error',
+      { argsIgnorePattern: '^_' }
+    ],
+
+    // General
+    'no-console': ['warn', { allow: ['warn', 'error'] }],
+    'no-debugger': 'error',
+    'prefer-const': 'error',
+    'no-var': 'error',
+    'eqeqeq': ['error', 'always']
+  }
+};
+
+

Key Rules Explained

+

@typescript-eslint/no-explicit-any - Prevents any type +

// ❌ Bad
+function foo(data: any) {}
+
+// ✅ Good
+function foo(data: User) {}
+function foo(data: unknown) {} // Use unknown instead
+

+

@typescript-eslint/no-unused-vars - Prevents unused variables +

// ❌ Bad
+const foo = 1; // Never used
+
+// ✅ Good
+const _foo = 1; // Prefix with _ to ignore
+

+

@typescript-eslint/no-floating-promises - Requires await/catch +

// ❌ Bad
+asyncFunction(); // Promise not handled
+
+// ✅ Good
+await asyncFunction();
+asyncFunction().catch(console.error);
+void asyncFunction(); // Explicitly ignore
+

+

react-hooks/exhaustive-deps - Validates useEffect dependencies +

// ❌ Bad
+useEffect(() => {
+  fetchUser(userId);
+}, []); // Missing userId dependency
+
+// ✅ Good
+useEffect(() => {
+  fetchUser(userId);
+}, [userId]);
+

+

Prettier Configuration

+

.prettierrc

+
{
+  "semi": true,
+  "singleQuote": true,
+  "tabWidth": 2,
+  "useTabs": false,
+  "trailingComma": "es5",
+  "printWidth": 100,
+  "arrowParens": "avoid",
+  "endOfLine": "lf"
+}
+
+

.prettierignore

+
node_modules
+dist
+build
+coverage
+.vite
+.cache
+*.min.js
+*.min.css
+package-lock.json
+
+

Format Commands

+
# Format all files
+npm run format
+
+# Check formatting (CI)
+npm run format:check
+
+# Format specific file
+npx prettier --write src/modules/auth/auth.service.ts
+
+

Naming Conventions

+

Files and Directories

+

Files: kebab-case +

auth.service.ts
+user.controller.ts
+campaign.routes.ts
+locations-page.tsx
+

+

Components: PascalCase +

UserCard.tsx
+LoginForm.tsx
+MapView.tsx
+

+

Test files: Match source file with .test or .spec +

auth.service.test.ts
+UserCard.test.tsx
+

+

Directories: kebab-case +

src/modules/auth/
+src/components/map/
+src/pages/public/
+

+

Variables and Functions

+

Variables: camelCase +

const userName = 'John';
+const isActive = true;
+const totalCount = 100;
+

+

Constants: UPPER_SNAKE_CASE +

const API_URL = 'http://localhost:4000';
+const MAX_RETRIES = 3;
+const DEFAULT_PAGE_SIZE = 50;
+

+

Functions: camelCase +

function getUserById(id: number) {}
+async function fetchCampaigns() {}
+const handleClick = () => {};
+

+

Private methods: Prefix with underscore (optional) +

class UserService {
+  async getUser(id: number) {}
+
+  private async _hashPassword(password: string) {}
+}
+

+

Types and Interfaces

+

Types/Interfaces: PascalCase +

interface User {
+  id: number;
+  email: string;
+}
+
+type UserRole = 'USER' | 'ADMIN';
+
+interface CreateUserInput {
+  email: string;
+  password: string;
+}
+

+

Enums: PascalCase, members UPPER_SNAKE_CASE +

enum UserRole {
+  USER = 'USER',
+  ADMIN = 'ADMIN',
+  SUPER_ADMIN = 'SUPER_ADMIN'
+}
+

+

React Components

+

Components: PascalCase +

export function UserCard({ user }: { user: User }) {
+  return <div>{user.name}</div>;
+}
+

+

Props interfaces: ComponentNameProps +

interface UserCardProps {
+  user: User;
+  onEdit?: (user: User) => void;
+}
+
+export function UserCard({ user, onEdit }: UserCardProps) {
+  return <div>{user.name}</div>;
+}
+

+

Event handlers: handle[Event] or on[Event] +

function UserForm() {
+  const handleSubmit = () => {};
+  const onEmailChange = (email: string) => {};
+
+  return <form onSubmit={handleSubmit}>...</form>;
+}
+

+

Database Models

+

Prisma models: PascalCase (singular) +

model User {
+  id    Int    @id @default(autoincrement())
+  email String @unique
+}
+
+model Campaign {
+  id    Int    @id @default(autoincrement())
+  title String
+}
+

+

Table names: snake_case (plural) +

model User {
+  @@map("users")
+}
+
+model Campaign {
+  @@map("campaigns")
+}
+

+

Fields: camelCase in schema, snake_case in database +

model User {
+  createdAt DateTime @default(now()) @map("created_at")
+  updatedAt DateTime @updatedAt @map("updated_at")
+}
+

+

File Organization

+

Module Structure

+
src/modules/auth/
+├── auth.service.ts        # Business logic
+├── auth.routes.ts         # Express routes
+├── auth.schemas.ts        # Zod validation schemas
+└── auth.service.test.ts   # Tests
+
+

Import Order

+
    +
  1. External libraries
  2. +
  3. Internal modules (absolute imports)
  4. +
  5. Relative imports
  6. +
  7. Types
  8. +
  9. Styles (frontend)
  10. +
+
// 1. External libraries
+import express from 'express';
+import { z } from 'zod';
+
+// 2. Internal modules
+import { authenticate } from '@/middleware/auth';
+import { UserService } from '@/modules/users/user.service';
+
+// 3. Relative imports
+import { AuthService } from './auth.service';
+import { loginSchema } from './auth.schemas';
+
+// 4. Types
+import type { Request, Response } from 'express';
+import type { User } from '@prisma/client';
+
+// 5. Styles (frontend only)
+import './auth.css';
+
+

Export Patterns

+

Named exports (preferred) +

// auth.service.ts
+export class AuthService {
+  async login() {}
+}
+
+// usage
+import { AuthService } from './auth.service';
+

+

Default exports (React components) +

// UserCard.tsx
+export default function UserCard() {
+  return <div>...</div>;
+}
+
+// usage
+import UserCard from './UserCard';
+

+

Re-exports (index files) +

// modules/auth/index.ts
+export { AuthService } from './auth.service';
+export { authRoutes } from './auth.routes';
+export * from './auth.schemas';
+

+

Code Patterns

+

Async/Await

+

Always use async/await (not callbacks or .then()):

+

Good: +

async function getUser(id: number) {
+  const user = await prisma.user.findUnique({ where: { id } });
+  return user;
+}
+

+

Bad: +

function getUser(id: number) {
+  return prisma.user.findUnique({ where: { id } }).then(user => {
+    return user;
+  });
+}
+

+

Error Handling

+

Use try/catch for error handling:

+

Good: +

async function createUser(data: CreateUserInput) {
+  try {
+    const user = await prisma.user.create({ data });
+    return user;
+  } catch (error) {
+    logger.error('Failed to create user', error);
+    throw new Error('User creation failed');
+  }
+}
+

+

Bad: +

async function createUser(data: CreateUserInput) {
+  const user = await prisma.user.create({ data }); // Unhandled error
+  return user;
+}
+

+

Optional Chaining

+

Use optional chaining for nullable values:

+

Good: +

const email = user?.email;
+const city = user?.address?.city;
+

+

Bad: +

const email = user && user.email;
+const city = user && user.address && user.address.city;
+

+

Nullish Coalescing

+

Use ?? for default values (not ||):

+

Good: +

const limit = query.limit ?? 50;
+const name = user.name ?? 'Unknown';
+

+

Bad: +

const limit = query.limit || 50; // Fails for 0
+const name = user.name || 'Unknown'; // Fails for ''
+

+

Array Methods

+

Prefer functional array methods:

+

Good: +

const activeUsers = users.filter(u => u.isActive);
+const emails = users.map(u => u.email);
+const total = amounts.reduce((sum, amt) => sum + amt, 0);
+

+

Bad: +

const activeUsers = [];
+for (let i = 0; i < users.length; i++) {
+  if (users[i].isActive) {
+    activeUsers.push(users[i]);
+  }
+}
+

+

Object Destructuring

+

Use destructuring for object properties:

+

Good: +

const { email, name, role } = user;
+const { limit = 50, page = 1 } = query;
+

+

Bad: +

const email = user.email;
+const name = user.name;
+const role = user.role;
+

+

Template Literals

+

Use template literals for string interpolation:

+

Good: +

const message = `Hello, ${user.name}!`;
+const url = `/api/users/${userId}`;
+

+

Bad: +

const message = 'Hello, ' + user.name + '!';
+const url = '/api/users/' + userId;
+

+

Comments and Documentation

+

JSDoc for Functions

+

Document public functions with JSDoc:

+
/**
+ * Creates a new user with the given email and password.
+ *
+ * @param email - User's email address
+ * @param password - User's password (will be hashed)
+ * @returns Created user object
+ * @throws {Error} If user already exists
+ */
+async function createUser(email: string, password: string): Promise<User> {
+  // ...
+}
+
+

Inline Comments

+

Use inline comments for complex logic:

+
// Calculate pagination offset
+const offset = (page - 1) * limit;
+
+// Hash password with 10 salt rounds
+const hashedPassword = await bcrypt.hash(password, 10);
+
+// Point-in-polygon ray-casting algorithm
+let inside = false;
+for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
+  // ... complex logic
+}
+
+

Avoid Obvious Comments

+

Don't comment obvious code:

+

Good: +

const isValid = email.includes('@');
+

+

Bad: +

// Check if email is valid
+const isValid = email.includes('@');
+

+

TODO Comments

+

Use TODO for future work:

+
// TODO: Add pagination support
+async function getUsers() {
+  return prisma.user.findMany();
+}
+
+// FIXME: This doesn't handle edge case when user is null
+const userName = user.name;
+
+

Git Commit Messages

+

Conventional Commits

+

Use conventional commit format:

+
<type>(<scope>): <subject>
+
+<body>
+
+<footer>
+
+

Types: +- feat: New feature +- fix: Bug fix +- docs: Documentation +- style: Formatting +- refactor: Code restructuring +- test: Adding tests +- chore: Maintenance

+

Examples: +

feat(auth): add JWT refresh token rotation
+fix(map): correct point-in-polygon calculation
+docs(api): update authentication guide
+refactor(users): extract service layer
+test(campaigns): add unit tests for CRUD operations
+

+

With scope and body: +

git commit -m "feat(campaigns): add email sending
+
+Implements BullMQ queue for async email delivery.
+Adds retry logic and error handling.
+
+Closes #123"
+

+

Co-Authoring with Claude

+

When Claude assists with code:

+
git commit -m "$(cat <<'EOF'
+feat(auth): add JWT refresh token rotation
+
+Implemented atomic refresh token rotation to prevent
+race conditions during concurrent refresh requests.
+
+Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
+EOF
+)"
+
+

React Patterns

+

Functional Components

+

Always use functional components (not class components):

+

Good: +

export function UserCard({ user }: UserCardProps) {
+  return <div>{user.name}</div>;
+}
+

+

Bad: +

export class UserCard extends React.Component<UserCardProps> {
+  render() {
+    return <div>{this.props.user.name}</div>;
+  }
+}
+

+

Hooks

+

Use hooks for state and side effects:

+
function UserList() {
+  const [users, setUsers] = useState<User[]>([]);
+  const [loading, setLoading] = useState(false);
+
+  useEffect(() => {
+    async function fetchUsers() {
+      setLoading(true);
+      const data = await api.get('/users');
+      setUsers(data);
+      setLoading(false);
+    }
+    fetchUsers();
+  }, []);
+
+  if (loading) return <div>Loading...</div>;
+
+  return <div>{users.map(u => <UserCard key={u.id} user={u} />)}</div>;
+}
+
+

Props Destructuring

+

Destructure props in function signature:

+

Good: +

function UserCard({ user, onEdit }: UserCardProps) {
+  return <div onClick={() => onEdit?.(user)}>{user.name}</div>;
+}
+

+

Bad: +

function UserCard(props: UserCardProps) {
+  return <div onClick={() => props.onEdit?.(props.user)}>{props.user.name}</div>;
+}
+

+

Key Prop

+

Always provide key for list items:

+

Good: +

{users.map(user => (
+  <UserCard key={user.id} user={user} />
+))}
+

+

Bad: +

{users.map((user, index) => (
+  <UserCard key={index} user={user} />
+))}
+

+

Editor Integration

+

VSCode Settings

+

Create .vscode/settings.json:

+
{
+  "editor.formatOnSave": true,
+  "editor.defaultFormatter": "esbenp.prettier-vscode",
+  "editor.codeActionsOnSave": {
+    "source.fixAll.eslint": true
+  },
+  "typescript.tsdk": "node_modules/typescript/lib",
+  "typescript.enablePromptUseWorkspaceTsdk": true,
+  "[typescript]": {
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
+  },
+  "[typescriptreact]": {
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
+  }
+}
+
+

Pre-commit Hook

+

Install husky for pre-commit checks:

+
npm install --save-dev husky lint-staged
+npx husky install
+
+

package.json: +

{
+  "lint-staged": {
+    "*.{ts,tsx}": [
+      "eslint --fix",
+      "prettier --write"
+    ]
+  },
+  "scripts": {
+    "prepare": "husky install"
+  }
+}
+

+

.husky/pre-commit: +

#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+npx lint-staged
+

+

Quick Reference

+

Run Linting

+
# Lint
+npm run lint
+
+# Auto-fix
+npm run lint:fix
+
+# Format
+npm run format
+
+# Type-check
+npm run type-check
+
+

Common Fixes

+
# Fix all auto-fixable issues
+npm run lint:fix && npm run format
+
+# Type-check both projects
+cd api && npm run type-check && cd ../admin && npm run type-check
+
+ + +

Summary

+

You now know: +- ✅ TypeScript configuration (strict mode) +- ✅ ESLint rules and plugins +- ✅ Prettier configuration +- ✅ Naming conventions (files, variables, types) +- ✅ File organization patterns +- ✅ Code patterns (async/await, error handling) +- ✅ Comment and documentation standards +- ✅ Git commit message format +- ✅ React patterns and best practices +- ✅ Editor integration (VSCode, pre-commit hooks)

+

Quick Start: +

# Auto-fix and format
+npm run lint:fix && npm run format
+
+# Check types
+npm run type-check
+
+# Pre-commit
+npm run lint:fix && npm run format && npm run type-check
+

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/development/debugging/index.html b/mkdocs/site/v2/development/debugging/index.html new file mode 100644 index 00000000..1c760717 --- /dev/null +++ b/mkdocs/site/v2/development/debugging/index.html @@ -0,0 +1,6975 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Debugging - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Debugging Guide

+

Comprehensive guide to debugging Changemaker Lite V2 applications, covering API, frontend, database, and Docker debugging techniques.

+

Overview

+

Effective debugging requires: +- Understanding the tools (VSCode, Chrome DevTools, logs) +- Systematic approach (reproduce, isolate, fix, verify) +- Knowledge of common issues

+

This guide covers debugging strategies for all parts of V2.

+

API Debugging

+

VSCode Debugging

+

Launch Configuration

+

Create .vscode/launch.json:

+
{
+  "version": "0.2.0",
+  "configurations": [
+    {
+      "name": "Debug API",
+      "type": "node",
+      "request": "launch",
+      "runtimeExecutable": "npm",
+      "runtimeArgs": ["run", "dev"],
+      "cwd": "${workspaceFolder}/api",
+      "console": "integratedTerminal",
+      "skipFiles": ["<node_internals>/**"],
+      "envFile": "${workspaceFolder}/.env",
+      "sourceMaps": true,
+      "restart": true,
+      "protocol": "inspector"
+    },
+    {
+      "name": "Debug Media API",
+      "type": "node",
+      "request": "launch",
+      "runtimeExecutable": "npm",
+      "runtimeArgs": ["run", "dev:media"],
+      "cwd": "${workspaceFolder}/api",
+      "console": "integratedTerminal",
+      "skipFiles": ["<node_internals>/**"],
+      "envFile": "${workspaceFolder}/.env"
+    },
+    {
+      "name": "Attach to API (Docker)",
+      "type": "node",
+      "request": "attach",
+      "port": 9229,
+      "address": "localhost",
+      "restart": true,
+      "sourceMaps": true,
+      "localRoot": "${workspaceFolder}/api",
+      "remoteRoot": "/app",
+      "skipFiles": ["<node_internals>/**"]
+    }
+  ]
+}
+
+

Start Debugging

+
    +
  1. Open VSCode
  2. +
  3. Open Run and Debug panel (Cmd+Shift+D / Ctrl+Shift+D)
  4. +
  5. Select "Debug API" configuration
  6. +
  7. Press F5 to start debugging
  8. +
  9. API starts with debugger attached
  10. +
+

Set Breakpoints

+

Click line number gutter to set breakpoint:

+
// api/src/modules/auth/auth.service.ts
+async login(email: string, password: string) {
+  const user = await this.prisma.user.findUnique({ // ← Click here
+    where: { email }
+  });
+
+  if (!user) {
+    throw new Error('User not found'); // ← Or here
+  }
+
+  // Breakpoint pauses execution
+  const isValid = await bcrypt.compare(password, user.password);
+
+  return { user, tokens: this.generateTokens(user) };
+}
+
+

Debug Features

+

Step Controls: +- F10: Step over (next line) +- F11: Step into (enter function) +- Shift+F11: Step out (exit function) +- F5: Continue (run to next breakpoint)

+

Inspect Variables: +- Hover over variable to see value +- Use "Variables" panel to see all local variables +- Use "Watch" panel to monitor specific expressions

+

Debug Console: +- Evaluate expressions while paused +- Call functions with current scope

+
// In debug console (while paused)
+> user.email
+'john@example.com'
+
+> bcrypt.compare('test', user.password)
+Promise { <pending> }
+
+> await bcrypt.compare('test', user.password)
+false
+
+

Call Stack: +- See function call hierarchy +- Click stack frame to jump to code +- Useful for understanding execution flow

+

Logging (Winston)

+

Using Logger

+
import { logger } from '../../utils/logger';
+
+// Info level
+logger.info('User logged in', { userId: user.id, email: user.email });
+
+// Error level
+logger.error('Failed to create user', {
+  error: error.message,
+  stack: error.stack,
+  email
+});
+
+// Warn level
+logger.warn('Deprecated endpoint accessed', { endpoint: req.path });
+
+// Debug level (only in development)
+logger.debug('Processing request', {
+  method: req.method,
+  path: req.path,
+  query: req.query
+});
+
+

Log Output

+

Development (console): +

[2026-02-13 10:30:45] INFO: User logged in {"userId":1,"email":"john@example.com"}
+[2026-02-13 10:30:46] ERROR: Failed to create user {"error":"Email already exists","email":"john@example.com"}
+

+

Production (JSON): +

{"level":"info","message":"User logged in","userId":1,"email":"john@example.com","timestamp":"2026-02-13T10:30:45.123Z"}
+{"level":"error","message":"Failed to create user","error":"Email already exists","email":"john@example.com","timestamp":"2026-02-13T10:30:46.456Z"}
+

+

Log Levels

+

Set log level via environment:

+
# .env
+LOG_LEVEL=debug  # dev: debug, info, warn, error
+LOG_LEVEL=info   # prod: info, warn, error
+
+

Database Query Logging

+

Prisma Query Logging

+

Enable in Prisma Client:

+
// api/src/config/prisma.ts
+const prisma = new PrismaClient({
+  log: [
+    { emit: 'event', level: 'query' },
+    { emit: 'event', level: 'error' },
+    { emit: 'event', level: 'warn' }
+  ]
+});
+
+prisma.$on('query', (e) => {
+  logger.debug('Prisma query', {
+    query: e.query,
+    params: e.params,
+    duration: e.duration
+  });
+});
+
+prisma.$on('error', (e) => {
+  logger.error('Prisma error', { target: e.target, message: e.message });
+});
+
+

Output: +

[2026-02-13 10:30:45] DEBUG: Prisma query {
+  "query": "SELECT * FROM users WHERE id = $1",
+  "params": "[1]",
+  "duration": 5
+}
+

+

Slow Query Logging

+

Log slow queries:

+
prisma.$on('query', (e) => {
+  if (e.duration > 100) { // > 100ms
+    logger.warn('Slow query detected', {
+      query: e.query,
+      duration: e.duration,
+      params: e.params
+    });
+  }
+});
+
+

Network Debugging

+

Request Logging

+

Log all HTTP requests:

+
// api/src/middleware/logger.ts
+import { Request, Response, NextFunction } from 'express';
+import { logger } from '../utils/logger';
+
+export function requestLogger(req: Request, res: Response, next: NextFunction) {
+  const start = Date.now();
+
+  res.on('finish', () => {
+    const duration = Date.now() - start;
+
+    logger.info('HTTP request', {
+      method: req.method,
+      path: req.path,
+      status: res.statusCode,
+      duration,
+      ip: req.ip,
+      userAgent: req.get('user-agent')
+    });
+
+    if (duration > 1000) {
+      logger.warn('Slow request', {
+        method: req.method,
+        path: req.path,
+        duration
+      });
+    }
+  });
+
+  next();
+}
+
+// In server.ts
+app.use(requestLogger);
+
+

Testing with curl

+
# GET request
+curl http://localhost:4000/api/users
+
+# POST request with JSON
+curl -X POST http://localhost:4000/api/auth/login \
+  -H "Content-Type: application/json" \
+  -d '{"email":"admin@example.com","password":"Admin123!"}'
+
+# With authentication
+curl http://localhost:4000/api/users \
+  -H "Authorization: Bearer <token>"
+
+# Verbose output (see headers)
+curl -v http://localhost:4000/api/users
+
+# Save response to file
+curl http://localhost:4000/api/users > users.json
+
+

Testing with HTTPie

+
# Install httpie
+brew install httpie  # macOS
+sudo apt install httpie  # Linux
+
+# GET request
+http localhost:4000/api/users
+
+# POST request
+http POST localhost:4000/api/auth/login \
+  email=admin@example.com \
+  password=Admin123!
+
+# With authentication
+http localhost:4000/api/users \
+  Authorization:"Bearer <token>"
+
+# Pretty JSON output
+http --pretty=all localhost:4000/api/users
+
+

Frontend Debugging

+

Chrome DevTools

+

Opening DevTools

+
    +
  • F12 or Cmd+Option+I (Mac) / Ctrl+Shift+I (Windows/Linux)
  • +
+

Console Tab

+

View console logs and errors:

+
// admin/src/pages/UsersPage.tsx
+console.log('Users loaded', users);
+console.error('Failed to fetch users', error);
+console.warn('Deprecated API used');
+console.table(users); // Display as table
+
+// Conditional logging
+if (import.meta.env.DEV) {
+  console.log('Debug info', { users, loading });
+}
+
+

Output: +

Users loaded [{ id: 1, email: 'john@example.com' }, ...]
+

+

Sources Tab

+

Debug JavaScript/TypeScript:

+
    +
  1. Open Sources tab
  2. +
  3. Find file in file tree (webpack://./src/)
  4. +
  5. Click line number to set breakpoint
  6. +
  7. Interact with UI to trigger breakpoint
  8. +
  9. Use step controls (same as VSCode)
  10. +
+

Conditional Breakpoints: +- Right-click line number +- Select "Add conditional breakpoint" +- Enter condition: user.id === 1 +- Pauses only when condition is true

+

Network Tab

+

Debug API calls:

+
    +
  1. Open Network tab
  2. +
  3. Filter by "Fetch/XHR"
  4. +
  5. Interact with UI
  6. +
  7. Click request to see:
  8. +
  9. Headers (request/response)
  10. +
  11. Payload (request body)
  12. +
  13. Preview (formatted response)
  14. +
  15. Response (raw response)
  16. +
  17. Timing (request duration)
  18. +
+

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

+

Application Tab

+

Inspect storage:

+
    +
  • Local Storage: See persisted auth tokens
  • +
  • Session Storage: See session data
  • +
  • Cookies: See cookies
  • +
  • Cache Storage: See cached resources
  • +
+
// View in console
+localStorage.getItem('auth-token');
+sessionStorage.getItem('cart');
+
+

React DevTools

+

Installation

+

Install browser extension: +- Chrome +- Firefox

+

Components Tab

+

Inspect React component tree:

+
    +
  1. Open DevTools
  2. +
  3. Go to "Components" tab
  4. +
  5. Select component from tree
  6. +
  7. View:
  8. +
  9. Props
  10. +
  11. State (hooks)
  12. +
  13. Context
  14. +
  15. Owner (parent component)
  16. +
+

Edit Props/State: +- Click value to edit +- Change takes effect immediately +- Useful for testing edge cases

+

Profiler Tab

+

Profile component renders:

+
    +
  1. Go to "Profiler" tab
  2. +
  3. Click "Record"
  4. +
  5. Interact with UI
  6. +
  7. Click "Stop"
  8. +
  9. See:
  10. +
  11. Flame graph (render hierarchy)
  12. +
  13. Ranked chart (slowest components)
  14. +
  15. Component details (render duration)
  16. +
+

Identify Performance Issues: +- Components rendering too often +- Slow component renders +- Unnecessary re-renders

+

Zustand DevTools

+

Enable Redux DevTools

+

Already configured in stores:

+
// admin/src/stores/auth.store.ts
+import { create } from 'zustand';
+import { devtools } from 'zustand/middleware';
+
+export const useAuthStore = create<AuthState>()(
+  devtools(
+    (set, get) => ({
+      user: null,
+      isAuthenticated: false,
+      setUser: (user) => set({ user, isAuthenticated: !!user }),
+      logout: () => set({ user: null, isAuthenticated: false })
+    }),
+    { name: 'AuthStore' } // Name in DevTools
+  )
+);
+
+

Using Redux DevTools

+
    +
  1. Install Redux DevTools extension
  2. +
  3. Open DevTools
  4. +
  5. Go to "Redux" tab
  6. +
  7. Select store from dropdown (AuthStore, CanvassStore)
  8. +
  9. View:
  10. +
  11. State tree
  12. +
  13. Action history
  14. +
  15. State diff
  16. +
+

Features: +- Time-travel debugging (jump to previous state) +- Action replay +- State export/import

+

VSCode Debugging (Frontend)

+

Launch Configuration

+
{
+  "name": "Debug Admin (Chrome)",
+  "type": "chrome",
+  "request": "launch",
+  "url": "http://localhost:3000",
+  "webRoot": "${workspaceFolder}/admin/src",
+  "sourceMapPathOverrides": {
+    "webpack:///./*": "${webRoot}/*",
+    "webpack:///src/*": "${webRoot}/*",
+    "webpack:///*": "*"
+  },
+  "userDataDir": false
+}
+
+

Start Debugging: +1. Start Admin dev server: npm run dev +2. Select "Debug Admin (Chrome)" in VSCode +3. Press F5 +4. Chrome opens with debugger attached +5. Set breakpoints in VSCode +6. Breakpoints hit when code executes

+

Database Debugging

+

Prisma Studio

+

Visual database browser:

+
# Start Prisma Studio
+cd api
+npx prisma studio
+
+

Features: +- Browse all tables +- Filter and sort data +- Edit records directly +- Create new records +- Delete records

+

Use Cases: +- Inspect database state +- Manual data fixes +- Verify migrations +- Test queries

+

PostgreSQL Shell

+

Direct database access:

+
# Connect to database
+docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db
+
+# List tables
+\dt
+
+# Describe table
+\d users
+
+# Run query
+SELECT * FROM users WHERE role = 'SUPER_ADMIN';
+
+# Count records
+SELECT COUNT(*) FROM campaigns;
+
+# Exit
+\q
+
+

Common Queries:

+
-- Find user by email
+SELECT * FROM users WHERE email = 'admin@example.com';
+
+-- Count users by role
+SELECT role, COUNT(*) FROM users GROUP BY role;
+
+-- Recent campaigns
+SELECT * FROM campaigns ORDER BY created_at DESC LIMIT 10;
+
+-- Users without name
+SELECT * FROM users WHERE name IS NULL;
+
+-- Delete test data
+DELETE FROM users WHERE email LIKE '%test%';
+
+

Query Analysis

+

Explain Query Plan

+
EXPLAIN ANALYZE
+SELECT * FROM users WHERE email = 'admin@example.com';
+
+

Output: +

Index Scan using users_email_key on users (cost=0.28..8.29 rows=1 width=...)
+  Index Cond: (email = 'admin@example.com'::text)
+  Planning Time: 0.123 ms
+  Execution Time: 0.045 ms
+

+

Identify Issues: +- Sequential scans (slow on large tables) +- Missing indexes +- Expensive joins

+

Slow Query Log

+

Enable slow query logging:

+
-- Set log threshold (100ms)
+ALTER DATABASE changemaker_v2_db SET log_min_duration_statement = 100;
+
+-- View slow queries in logs
+docker compose logs v2-postgres | grep "duration:"
+
+

Docker Debugging

+

Container Logs

+

View container output:

+
# All services
+docker compose logs -f
+
+# Specific service
+docker compose logs -f api
+
+# Last 100 lines
+docker compose logs --tail=100 api
+
+# With timestamps
+docker compose logs -t -f api
+
+# Since specific time
+docker compose logs --since 2024-01-01T10:00:00 api
+
+

Execute Commands in Container

+
# Shell access
+docker compose exec api sh
+
+# Run command
+docker compose exec api npm run type-check
+
+# Run as specific user
+docker compose exec -u root api sh
+
+# Non-interactive command
+docker compose exec -T api npm run lint
+
+

Inspect Container

+
# Container details
+docker inspect api
+
+# Environment variables
+docker inspect api | grep -A 20 "Env"
+
+# Mounts
+docker inspect api | grep -A 50 "Mounts"
+
+# Network settings
+docker inspect api | grep -A 20 "Networks"
+
+# Resource limits
+docker inspect api | grep -A 10 "Memory"
+
+

Container Stats

+
# Real-time stats
+docker stats
+
+# Specific container
+docker stats api
+
+# Format output
+docker stats --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}"
+
+

Network Debugging

+
# Test connectivity between containers
+docker compose exec api ping v2-postgres
+docker compose exec api ping redis
+
+# Check listening ports
+docker compose exec api netstat -tuln
+
+# Test HTTP endpoint from inside container
+docker compose exec api wget -O- http://localhost:4000/health
+
+# DNS lookup
+docker compose exec api nslookup v2-postgres
+
+

Common Issues

+

401 Unauthorized

+

Symptoms: API returns 401 for authenticated requests.

+

Causes: +1. Token expired +2. Invalid token +3. Missing Authorization header +4. Token format incorrect

+

Debug:

+
# Check token in browser DevTools
+localStorage.getItem('auth-token')
+
+# Test token with curl
+curl http://localhost:4000/api/users \
+  -H "Authorization: Bearer <token>" \
+  -v
+
+# Decode JWT (jwt.io)
+# Check expiration (exp claim)
+
+

Fix: +- Refresh token +- Re-login +- Check token format (Bearer prefix)

+

500 Internal Server Error

+

Symptoms: API returns 500 error.

+

Causes: +1. Unhandled exception +2. Database error +3. External service failure

+

Debug:

+
# Check API logs
+docker compose logs -f api
+
+# Look for error stack trace
+docker compose logs api | grep -A 20 "Error:"
+
+# Check database connection
+docker compose exec api npx prisma db execute --stdin <<< "SELECT 1"
+
+

Fix: +- Check error message in logs +- Verify database is running +- Check external service (Redis, SMTP, etc.)

+

CORS Errors

+

Symptoms: Browser blocks request with CORS error.

+

Causes: +1. Incorrect CORS_ORIGIN setting +2. Missing CORS headers +3. Preflight OPTIONS request fails

+

Debug:

+
# Check CORS_ORIGIN in .env
+grep CORS_ORIGIN .env
+
+# Test with curl (bypasses CORS)
+curl http://localhost:4000/api/users
+
+# Check preflight request
+curl -X OPTIONS http://localhost:4000/api/users \
+  -H "Origin: http://localhost:3000" \
+  -H "Access-Control-Request-Method: GET" \
+  -v
+
+

Fix: +- Set CORS_ORIGIN=http://localhost:3000 in .env +- Restart API: docker compose restart api

+

Database Connection Errors

+

Symptoms: API fails to connect to database.

+

Causes: +1. PostgreSQL not running +2. Incorrect DATABASE_URL +3. Network issue

+

Debug:

+
# Check PostgreSQL is running
+docker compose ps v2-postgres
+
+# Check DATABASE_URL
+docker compose exec api sh -c 'echo $DATABASE_URL'
+
+# Test connection
+docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c "SELECT 1"
+
+# Check logs
+docker compose logs v2-postgres
+
+

Fix: +- Start PostgreSQL: docker compose up -d v2-postgres +- Verify DATABASE_URL matches docker-compose.yml +- Check password in .env

+

Redis Connection Errors

+

Symptoms: API fails to connect to Redis.

+

Causes: +1. Redis not running +2. Incorrect REDIS_URL +3. Missing REDIS_PASSWORD

+

Debug:

+
# Check Redis is running
+docker compose ps redis
+
+# Test connection
+docker compose exec redis redis-cli -a your_password ping
+
+# Check Redis logs
+docker compose logs redis
+
+

Fix: +- Start Redis: docker compose up -d redis +- Set REDIS_PASSWORD in .env +- Update REDIS_URL with password

+

Hot Reload Not Working

+

Symptoms: Code changes don't trigger reload.

+

Causes: +1. Volume mount missing +2. File watcher not detecting changes +3. Build cache issue

+

Debug:

+
# Check volume mounts
+docker inspect api | grep -A 20 "Mounts"
+
+# Test file sync
+docker compose exec api ls -la /app/src
+
+# Check for .dockerignore blocking sync
+cat api/.dockerignore
+
+

Fix: +- Verify volume mount in docker-compose.yml +- Restart container: docker compose restart api +- Clear cache: rm -rf api/dist && docker compose restart api

+

Debug Checklist

+

Systematic Debugging Approach

+
    +
  1. Reproduce:
  2. +
  3. Can you consistently reproduce the issue?
  4. +
  5. +

    What are the exact steps?

    +
  6. +
  7. +

    Isolate:

    +
  8. +
  9. Does it happen in all environments?
  10. +
  11. +

    Is it specific to one user/data/scenario?

    +
  12. +
  13. +

    Gather Information:

    +
  14. +
  15. Check logs (API, frontend, database)
  16. +
  17. Check network requests (DevTools)
  18. +
  19. +

    Check error messages

    +
  20. +
  21. +

    Form Hypothesis:

    +
  22. +
  23. What do you think is causing it?
  24. +
  25. +

    What evidence supports this?

    +
  26. +
  27. +

    Test Hypothesis:

    +
  28. +
  29. Set breakpoints
  30. +
  31. Add logging
  32. +
  33. +

    Test specific scenario

    +
  34. +
  35. +

    Fix:

    +
  36. +
  37. Make minimal change to fix issue
  38. +
  39. +

    Don't fix multiple issues at once

    +
  40. +
  41. +

    Verify:

    +
  42. +
  43. Re-test original scenario
  44. +
  45. Test related functionality
  46. +
  47. +

    Check for side effects

    +
  48. +
  49. +

    Prevent:

    +
  50. +
  51. Add tests to catch regression
  52. +
  53. Update documentation
  54. +
  55. Share learnings with team
  56. +
+

Performance Debugging

+

API Response Time

+
// Measure endpoint performance
+app.get('/users', async (req, res) => {
+  const start = Date.now();
+
+  const users = await prisma.user.findMany();
+
+  const duration = Date.now() - start;
+  logger.info('Users endpoint', { duration, count: users.length });
+
+  res.json({ users });
+});
+
+

Database Query Performance

+
// Log slow queries
+prisma.$on('query', (e) => {
+  if (e.duration > 100) {
+    logger.warn('Slow query', {
+      query: e.query,
+      duration: e.duration,
+      params: e.params
+    });
+  }
+});
+
+

Frontend Render Performance

+
// Measure component render time
+function UserList() {
+  const renderStart = performance.now();
+
+  useEffect(() => {
+    const renderTime = performance.now() - renderStart;
+    if (renderTime > 16) { // > 1 frame (60fps)
+      console.warn('Slow render', { component: 'UserList', renderTime });
+    }
+  });
+
+  return <div>...</div>;
+}
+
+ + +

Summary

+

You now know: +- ✅ How to debug API with VSCode +- ✅ How to use Winston logging effectively +- ✅ How to debug frontend with Chrome DevTools +- ✅ How to use React DevTools and Zustand DevTools +- ✅ How to debug database with Prisma Studio and psql +- ✅ How to debug Docker containers +- ✅ Common issues and their solutions +- ✅ Systematic debugging approach +- ✅ Performance debugging techniques

+

Quick Start: +

# API debugging
+cd api && npm run dev  # Start with debugger
+# Set breakpoints in VSCode, press F5
+
+# Frontend debugging
+# Open Chrome DevTools (F12)
+# Network tab for API calls, Console for logs
+
+# Database debugging
+npx prisma studio  # Visual browser
+docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db
+
+# Logs
+docker compose logs -f api admin
+

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/development/docker-workflow/index.html b/mkdocs/site/v2/development/docker-workflow/index.html new file mode 100644 index 00000000..78cbf09e --- /dev/null +++ b/mkdocs/site/v2/development/docker-workflow/index.html @@ -0,0 +1,7305 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Docker Workflow - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Docker Development Workflow

+

Guide to developing Changemaker Lite V2 using Docker containers for consistent, reproducible development environments.

+

Overview

+

Docker-based development provides:

+
    +
  • Consistency: Same environment across all developer machines
  • +
  • Isolation: Services don't interfere with host system
  • +
  • Production Parity: Dev environment matches production
  • +
  • Easy Reset: Rebuild containers for clean state
  • +
+

This guide covers Docker development workflows, from basic container operations to advanced debugging techniques.

+

Docker vs Local Development

+

When to Use Docker

+

Advantages: +- Consistent Node.js/PostgreSQL/Redis versions +- No need to install services on host machine +- Easy onboarding for new developers +- Production-like environment +- Volume mounts still support hot reload

+

Disadvantages: +- Slightly slower hot reload (especially macOS/Windows) +- More complex debugging setup +- Volume mount performance overhead +- Larger disk space usage

+

When to Use Local npm

+

Advantages: +- Faster hot reload (native file system) +- Direct access to Node.js processes +- Simpler debugging (VSCode attach) +- Better performance on macOS/Windows

+

Disadvantages: +- Must install Node.js, PostgreSQL, Redis locally +- Version inconsistencies between developers +- Host system configuration required

+ +

Run databases in Docker, API/Admin locally:

+
# Docker: Databases only
+docker compose up -d v2-postgres redis mailhog
+
+# Local: Development servers
+cd api && npm run dev
+cd admin && npm run dev
+
+

This combines benefits of both approaches.

+

Starting Development Services

+

Full Docker Development

+

Start all development services:

+
# Core services (API, Admin, Databases)
+docker compose up -d api admin v2-postgres redis
+
+# Optional: MailHog for email testing
+docker compose up -d mailhog
+
+# Optional: Media API
+docker compose up -d media-api
+
+

Verify services started:

+
docker compose ps
+
+

Expected output: +

NAME                  STATUS    PORTS
+api                   running   0.0.0.0:4000->4000/tcp
+admin                 running   0.0.0.0:3000->3000/tcp
+v2-postgres           running   0.0.0.0:5433->5432/tcp
+redis                 running   0.0.0.0:6379->6379/tcp
+mailhog               running   0.0.0.0:1025->1025/tcp, 0.0.0.0:8025->8025/tcp
+

+

Selective Service Start

+

Start only what you need:

+
# Just databases (for local npm development)
+docker compose up -d v2-postgres redis
+
+# Just API (admin running locally)
+docker compose up -d api v2-postgres redis
+
+# Just Admin (API running locally)
+docker compose up -d admin
+
+

Start with Monitoring Stack

+

Enable monitoring services:

+
# Start with monitoring profile
+docker compose --profile monitoring up -d
+
+# Or specific monitoring services
+docker compose up -d prometheus grafana
+
+

Watching Logs

+

View Service Logs

+

Real-time log streaming:

+
# All services
+docker compose logs -f
+
+# Specific service
+docker compose logs -f api
+
+# Multiple services
+docker compose logs -f api admin
+
+# Last 100 lines, then follow
+docker compose logs -f --tail=100 api
+
+

Log output example (API): +

api  | Server running on port 4000
+api  | Database connected
+api  | Redis connected
+api  | BullMQ worker started
+api  | GET /api/users 200 45ms
+

+

Log output example (Admin): +

admin  | VITE v5.x.x ready in 500 ms
+admin  | ➜  Local:   http://localhost:3000/
+admin  | ➜  Network: http://172.18.0.5:3000/
+

+

Filter Logs

+

Use grep to filter log output:

+
# Show only errors
+docker compose logs -f api | grep ERROR
+
+# Show only database queries
+docker compose logs -f api | grep "SELECT\|INSERT\|UPDATE\|DELETE"
+
+# Show only HTTP requests
+docker compose logs -f api | grep "GET\|POST\|PUT\|DELETE"
+
+

Export Logs

+

Save logs to file:

+
# All services
+docker compose logs > logs.txt
+
+# Specific service with timestamps
+docker compose logs -t api > api-logs.txt
+
+# Last 24 hours
+docker compose logs --since 24h > recent-logs.txt
+
+

Executing Commands in Containers

+

Using docker compose exec

+

Run commands inside running containers:

+
# General syntax
+docker compose exec <service> <command>
+
+# Examples:
+docker compose exec api npm run type-check
+docker compose exec admin npm run lint
+docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db
+
+

Common API Commands

+
# Type-check
+docker compose exec api npm run type-check
+
+# Prisma migrate
+docker compose exec api npx prisma migrate dev --name add_field
+
+# Prisma Studio
+docker compose exec api npx prisma studio
+
+# Seed database
+docker compose exec api npx prisma db seed
+
+# Drizzle push (Media API)
+docker compose exec api npx drizzle-kit push
+
+# Node REPL
+docker compose exec api node
+
+# Shell access
+docker compose exec api sh
+
+

Common Admin Commands

+
# Type-check
+docker compose exec admin npm run type-check
+
+# Build
+docker compose exec admin npm run build
+
+# Lint
+docker compose exec admin npm run lint
+
+# Shell access
+docker compose exec admin sh
+
+

Database Commands

+
# PostgreSQL shell
+docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db
+
+# Run SQL query
+docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c "SELECT COUNT(*) FROM users;"
+
+# Dump database
+docker compose exec v2-postgres pg_dump -U changemaker_v2 changemaker_v2_db > backup.sql
+
+# Restore database
+cat backup.sql | docker compose exec -T v2-postgres psql -U changemaker_v2 changemaker_v2_db
+
+

Redis Commands

+
# Redis CLI
+docker compose exec redis redis-cli -a your_redis_password
+
+# Ping
+docker compose exec redis redis-cli -a your_redis_password ping
+
+# Get all keys
+docker compose exec redis redis-cli -a your_redis_password KEYS '*'
+
+# Monitor commands
+docker compose exec redis redis-cli -a your_redis_password MONITOR
+
+

Hot Reload in Containers

+

How Volume Mounts Enable Hot Reload

+

Docker Compose volume mounts sync code between host and container:

+
# docker-compose.yml
+api:
+  volumes:
+    - ./api:/app                # Syncs code changes
+    - /app/node_modules         # Preserves container's node_modules
+    - /app/dist                 # Preserves build output
+
+

When you edit a file on host: +1. File change detected by host file system +2. Change synced to container via volume mount +3. tsx watch (API) or Vite (Admin) detects change +4. Service restarts (API) or HMR updates (Admin)

+

API Hot Reload

+

API uses tsx watch for auto-restart:

+
# Start API in Docker
+docker compose up -d api
+
+# Watch logs
+docker compose logs -f api
+
+# Edit file: api/src/modules/auth/auth.service.ts
+# Logs show:
+# api  | File changed: src/modules/auth/auth.service.ts
+# api  | Restarting server...
+# api  | Server running on port 4000
+
+

What triggers reload: +- .ts file changes in src/ +- Schema changes (after Prisma migrate)

+

What does NOT trigger reload: +- .env changes (restart container manually) +- package.json changes (rebuild container)

+

Admin Hot Reload (Vite HMR)

+

Admin uses Vite Hot Module Replacement:

+
# Start Admin in Docker
+docker compose up -d admin
+
+# Watch logs
+docker compose logs -f admin
+
+# Edit file: admin/src/pages/UsersPage.tsx
+# Logs show:
+# admin  | 10:30:45 AM [vite] hmr update /src/pages/UsersPage.tsx
+# Browser updates WITHOUT full reload
+
+

HMR behavior: +- Component changes: Updates component only +- CSS changes: Updates styles instantly +- Store changes: May require full reload

+

Performance Considerations

+

Linux: Volume mounts are native, excellent performance.

+

macOS/Windows: Volume mounts use virtualization layer, slower performance.

+

Optimization for macOS/Windows:

+
    +
  1. Use delegated volume mounts (docker-compose.yml):
  2. +
+
api:
+  volumes:
+    - ./api:/app:delegated  # Slightly better performance
+
+
    +
  1. Reduce watched files (.dockerignore):
  2. +
+
node_modules
+dist
+coverage
+.git
+*.log
+
+
    +
  1. Use local development for intensive work:
  2. +
+
# Stop Docker services
+docker compose stop api admin
+
+# Run locally
+cd api && npm run dev
+cd admin && npm run dev
+
+

Database Operations

+

Running Migrations in Docker

+
# Create migration
+docker compose exec api npx prisma migrate dev --name add_user_field
+
+# Apply migrations (production)
+docker compose exec api npx prisma migrate deploy
+
+# Check migration status
+docker compose exec api npx prisma migrate status
+
+

Seeding Database

+
# Run seed script
+docker compose exec api npx prisma db seed
+
+# Or run custom script
+docker compose exec api npx tsx prisma/custom-seed.ts
+
+

Resetting Database

+

WARNING: Deletes all data!

+
# Reset and re-seed
+docker compose exec api npx prisma migrate reset
+
+# Confirm when prompted:
+# ⚠️  All data will be lost. Continue? [y/N]: y
+
+

Prisma Studio in Docker

+
# Start Prisma Studio
+docker compose exec api npx prisma studio
+
+# Access at http://localhost:5555
+
+

Note: Port forwarding must be configured (already set in docker-compose.yml).

+

Manual Database Access

+
# Open PostgreSQL shell
+docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db
+
+# Run queries
+changemaker_v2_db=# SELECT * FROM users;
+changemaker_v2_db=# \dt  -- List tables
+changemaker_v2_db=# \q   -- Exit
+
+

Rebuilding Containers

+

When to Rebuild

+

Rebuild containers when: +- package.json dependencies change +- Dockerfile changes +- Base image needs update +- Container is in corrupted state

+

Rebuild Commands

+
# Rebuild all services
+docker compose build
+
+# Rebuild specific service
+docker compose build api
+
+# Rebuild without cache (clean build)
+docker compose build --no-cache api
+
+# Rebuild and restart
+docker compose up -d --build api
+
+

Full Rebuild Workflow

+
# 1. Stop services
+docker compose down
+
+# 2. Rebuild (no cache)
+docker compose build --no-cache
+
+# 3. Start services
+docker compose up -d
+
+# 4. Verify
+docker compose ps
+docker compose logs -f api admin
+
+

After Package Changes

+

When package.json changes (new dependencies):

+
# Option 1: Rebuild container
+docker compose build --no-cache api
+docker compose restart api
+
+# Option 2: Install in running container
+docker compose exec api npm install
+docker compose restart api
+
+# Option 3: Remove and recreate
+docker compose rm -sf api
+docker compose up -d api
+
+

Cleaning Up

+

Stop Services

+
# Stop all services
+docker compose stop
+
+# Stop specific service
+docker compose stop api
+
+# Stop and remove containers
+docker compose down
+
+

Remove Containers

+
# Remove containers (keeps volumes)
+docker compose down
+
+# Remove containers and volumes (DELETES DATA)
+docker compose down -v
+
+# Remove containers, volumes, and images
+docker compose down -v --rmi all
+
+

Clean Docker System

+
# Remove stopped containers
+docker container prune
+
+# Remove unused images
+docker image prune
+
+# Remove unused volumes
+docker volume prune
+
+# Remove everything (DANGEROUS)
+docker system prune -a --volumes
+
+

Clean Project Volumes

+
# List project volumes
+docker volume ls | grep changemaker
+
+# Remove specific volume
+docker volume rm changemaker-lite_v2-postgres-data
+
+# Remove all project volumes (DELETES DATABASE)
+docker compose down -v
+
+

Reset Development Environment

+

Complete reset (deletes all data):

+
# 1. Stop and remove everything
+docker compose down -v --rmi all
+
+# 2. Clean Docker system
+docker system prune -a --volumes -f
+
+# 3. Rebuild from scratch
+docker compose build --no-cache
+
+# 4. Start services
+docker compose up -d
+
+# 5. Run migrations
+docker compose exec api npx prisma migrate deploy
+docker compose exec api npx prisma db seed
+
+

Debugging in Docker

+

Attach to Running Container

+
# Get shell in running container
+docker compose exec api sh
+
+# Or bash (if available)
+docker compose exec api bash
+
+# Inside container:
+# - Explore file system
+# - Run commands
+# - Check environment variables
+
+

Inspect Container

+
# View container details
+docker inspect api
+
+# View container environment variables
+docker inspect api | grep -A 20 "Env"
+
+# View container mounts
+docker inspect api | grep -A 50 "Mounts"
+
+

VSCode Remote Containers

+

Install "Remote - Containers" extension, then:

+
    +
  1. Open Command Palette (Cmd+Shift+P / Ctrl+Shift+P)
  2. +
  3. Select "Remote-Containers: Attach to Running Container"
  4. +
  5. Choose api or admin container
  6. +
  7. VSCode opens new window attached to container
  8. +
  9. Open /app folder in container
  10. +
  11. Set breakpoints and debug normally
  12. +
+

Debug Logs

+

Enable verbose logging:

+
# API with debug logs
+docker compose exec api npm run dev -- --inspect
+
+# Watch logs with timestamp
+docker compose logs -f -t api
+
+# Filter errors only
+docker compose logs -f api 2>&1 | grep -i error
+
+

Network Debugging

+
# Test container connectivity
+docker compose exec api ping v2-postgres
+docker compose exec api ping redis
+
+# Check listening ports
+docker compose exec api netstat -tuln
+
+# Test API from inside container
+docker compose exec api wget -O- http://localhost:4000/health
+
+

Performance Debugging

+
# Container stats
+docker stats
+
+# Specific service stats
+docker stats api admin
+
+# Container resource limits
+docker inspect api | grep -A 10 "Memory\|Cpu"
+
+

Advanced Workflows

+

Multi-Stage Development

+

Run different service combinations:

+
# Frontend development (local Admin, Docker API)
+docker compose up -d api v2-postgres redis
+cd admin && npm run dev
+
+# Backend development (local API, Docker Admin)
+docker compose up -d admin v2-postgres redis
+cd api && npm run dev
+
+# Full-stack (everything in Docker)
+docker compose up -d api admin v2-postgres redis
+
+

Custom Docker Compose Files

+

Create docker-compose.dev.yml for dev overrides:

+
# docker-compose.dev.yml
+services:
+  api:
+    command: npm run dev -- --inspect=0.0.0.0:9229
+    ports:
+      - "9229:9229"  # Debug port
+    environment:
+      - LOG_LEVEL=debug
+
+

Usage: +

# Use both files
+docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
+
+# Or set COMPOSE_FILE env var
+export COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
+docker compose up -d
+

+

Docker Profiles for Optional Services

+

Start monitoring stack:

+
# With monitoring services
+docker compose --profile monitoring up -d
+
+# Without monitoring (default)
+docker compose up -d
+
+

Monitoring services: +- Prometheus (port 9090) +- Grafana (port 3001) +- Alertmanager (port 9093) +- cAdvisor (port 8080)

+

Build Arguments

+

Pass build-time arguments:

+
# Build with Node.js version argument
+docker compose build --build-arg NODE_VERSION=20.11.0 api
+
+# Set in docker-compose.yml
+services:
+  api:
+    build:
+      context: ./api
+      args:
+        - NODE_VERSION=${NODE_VERSION:-20}
+
+

Health Checks

+

Check service health:

+
# View health status
+docker compose ps
+
+# Inspect health check
+docker inspect --format='{{json .State.Health}}' api | jq
+
+# Wait for healthy
+docker compose up -d api
+docker compose exec api sh -c 'while ! wget -q -O- http://localhost:4000/health; do sleep 1; done'
+
+

Troubleshooting

+

Container Exits Immediately

+

Problem: Container starts then stops.

+

Solution:

+
# Check logs for errors
+docker compose logs api
+
+# Common causes:
+# 1. Missing .env file
+# 2. Database connection failed
+# 3. Syntax error in code
+# 4. Port already in use
+
+# Start with interactive mode to see error
+docker compose run --rm api npm run dev
+
+

Volume Mount Not Working

+

Problem: Code changes don't appear in container.

+

Solution:

+
# Check volume mounts
+docker inspect api | grep -A 20 "Mounts"
+
+# Verify volume path
+docker compose exec api ls -la /app
+
+# Recreate container
+docker compose rm -sf api
+docker compose up -d api
+
+

Permission Errors

+

Problem: Permission denied errors in container.

+

Solution:

+
# Check file ownership
+docker compose exec api ls -la /app
+
+# Fix permissions on host
+sudo chown -R $(whoami):$(whoami) ./api
+
+# Or run container as current user (docker-compose.yml)
+services:
+  api:
+    user: "${UID}:${GID}"
+
+

Port Conflicts

+

Problem: Port already in use.

+

Solution:

+
# Find process using port
+lsof -ti:4000 | xargs kill -9
+
+# Or change port in docker-compose.yml
+services:
+  api:
+    ports:
+      - "4002:4000"  # Host:Container
+
+# Or use .env
+API_PORT=4002
+
+

Database Connection Failed

+

Problem: API cannot connect to PostgreSQL.

+

Solution:

+
# Check database is running
+docker compose ps v2-postgres
+
+# Check database logs
+docker compose logs v2-postgres
+
+# Test connection
+docker compose exec api sh -c 'wget -qO- http://v2-postgres:5432 || echo "Not reachable"'
+
+# Verify DATABASE_URL
+docker compose exec api sh -c 'echo $DATABASE_URL'
+
+# Restart database
+docker compose restart v2-postgres
+
+

Out of Disk Space

+

Problem: No space left on device.

+

Solution:

+
# Check Docker disk usage
+docker system df
+
+# Remove unused images
+docker image prune -a
+
+# Remove unused volumes
+docker volume prune
+
+# Remove build cache
+docker builder prune
+
+# Full cleanup
+docker system prune -a --volumes
+
+

Container Running Out of Memory

+

Problem: Container crashes with OOM.

+

Solution:

+
# Check container stats
+docker stats api
+
+# Increase Docker memory limit (Docker Desktop → Preferences → Resources)
+
+# Or set memory limit in docker-compose.yml
+services:
+  api:
+    mem_limit: 2g
+    memswap_limit: 2g
+
+

Slow Performance on macOS/Windows

+

Problem: Slow hot reload, high CPU usage.

+

Solution:

+
    +
  1. Use delegated volume mounts:
  2. +
+
services:
+  api:
+    volumes:
+      - ./api:/app:delegated
+
+
    +
  1. Reduce file watching:
  2. +
+
// vite.config.ts
+export default {
+  server: {
+    watch: {
+      ignored: ['**/node_modules/**', '**/dist/**']
+    }
+  }
+}
+
+
    +
  1. Switch to local development:
  2. +
+
docker compose up -d v2-postgres redis
+cd api && npm run dev
+cd admin && npm run dev
+
+

Best Practices

+

Development Workflow

+
    +
  1. +

    Start services in background: +

    docker compose up -d api admin
    +

    +
  2. +
  3. +

    Watch logs in separate terminal: +

    docker compose logs -f api admin
    +

    +
  4. +
  5. +

    Make code changes:

    +
  6. +
  7. +

    Hot reload picks up changes automatically

    +
  8. +
  9. +

    Type-check before commit: +

    docker compose exec api npm run type-check
    +docker compose exec admin npm run type-check
    +

    +
  10. +
  11. +

    Stop services when done: +

    docker compose stop
    +

    +
  12. +
+

Container Naming

+

Use meaningful service names in docker-compose.yml:

+
services:
+  api:              # Not "backend" or "server"
+  admin:            # Not "frontend" or "ui"
+  v2-postgres:      # Not "db" (version-specific)
+  redis:            # Standard name
+
+

Environment Variables

+
    +
  1. +

    Use .env file (not docker-compose.yml): +

    # .env
    +API_PORT=4000
    +ADMIN_PORT=3000
    +

    +
  2. +
  3. +

    Reference in docker-compose.yml: +

    services:
    +  api:
    +    environment:
    +      - API_PORT=${API_PORT}
    +

    +
  4. +
  5. +

    Don't commit .env (use .env.example).

    +
  6. +
+

Volume Management

+
    +
  1. +

    Named volumes for data: +

    volumes:
    +  v2-postgres-data:  # Persistent database
    +

    +
  2. +
  3. +

    Bind mounts for code: +

    volumes:
    +  - ./api:/app  # Live code sync
    +

    +
  4. +
  5. +

    Anonymous volumes for dependencies: +

    volumes:
    +  - /app/node_modules  # Isolate from host
    +

    +
  6. +
+

Log Management

+
    +
  1. +

    Use log rotation: +

    services:
    +  api:
    +    logging:
    +      driver: "json-file"
    +      options:
    +        max-size: "10m"
    +        max-file: "3"
    +

    +
  2. +
  3. +

    Filter logs with grep: +

    docker compose logs -f api | grep ERROR
    +

    +
  4. +
  5. +

    Export logs for analysis: +

    docker compose logs > debug-logs.txt
    +

    +
  6. +
+

Quick Reference

+

Essential Commands

+
# Start
+docker compose up -d api admin
+
+# Stop
+docker compose stop
+
+# Restart
+docker compose restart api
+
+# Logs
+docker compose logs -f api
+
+# Execute command
+docker compose exec api npm run type-check
+
+# Shell access
+docker compose exec api sh
+
+# Rebuild
+docker compose build --no-cache api
+
+# Clean up
+docker compose down -v
+
+

Service Health Checks

+
# Check status
+docker compose ps
+
+# Test API
+curl http://localhost:4000/health
+
+# Test Admin
+curl http://localhost:3000
+
+# Test database
+docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c "SELECT 1"
+
+# Test Redis
+docker compose exec redis redis-cli -a password ping
+
+

Quick Reset

+
# Full reset (DELETES DATA)
+docker compose down -v
+docker compose build --no-cache
+docker compose up -d
+docker compose exec api npx prisma migrate deploy
+docker compose exec api npx prisma db seed
+
+ + +

Summary

+

You now know: +- ✅ When to use Docker vs local development +- ✅ How to start and stop services +- ✅ How to watch and filter logs +- ✅ How to execute commands in containers +- ✅ How hot reload works with volume mounts +- ✅ How to perform database operations in Docker +- ✅ How to rebuild and clean up containers +- ✅ How to debug containerized services +- ✅ Advanced workflows and best practices

+

Quick Start: +

docker compose up -d api admin v2-postgres redis
+docker compose logs -f api admin
+# Make changes → Hot reload!
+docker compose exec api npm run type-check
+docker compose stop
+

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/development/git-workflow/index.html b/mkdocs/site/v2/development/git-workflow/index.html new file mode 100644 index 00000000..be9e66b4 --- /dev/null +++ b/mkdocs/site/v2/development/git-workflow/index.html @@ -0,0 +1,6812 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Git Workflow - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Git Workflow

+

Git branching strategy, commit conventions, and version control best practices for Changemaker Lite V2.

+

Overview

+

Changemaker Lite V2 uses Git for version control with a structured branching strategy and conventional commit messages.

+

Key Principles: +- Main branch always deployable +- Feature branches for new work +- Descriptive commit messages +- Code review via pull requests +- No direct commits to main

+

Branch Structure

+

Main Branches

+

main - Production branch +- Always deployable +- Protected (no direct pushes) +- Merges only via pull request +- Tagged with version numbers

+

v2 - Development branch +- Active development happens here +- Features merge into v2 first +- Tested before merging to main +- Currently the primary development branch

+

Feature Branches

+

Naming: feature/<descriptive-name>

+
# Create feature branch from v2
+git checkout v2
+git pull origin v2
+git checkout -b feature/add-user-avatar
+
+# Make changes
+# ...
+
+# Push to remote
+git push -u origin feature/add-user-avatar
+
+

Examples: +- feature/add-user-avatar +- feature/email-queue-monitoring +- feature/map-clustering +- feature/campaign-analytics

+

Bugfix Branches

+

Naming: fix/<descriptive-name>

+
# Create bugfix branch
+git checkout v2
+git pull origin v2
+git checkout -b fix/login-redirect-loop
+
+# Fix bug
+# ...
+
+# Push to remote
+git push -u origin fix/login-redirect-loop
+
+

Examples: +- fix/login-redirect-loop +- fix/map-marker-position +- fix/email-template-rendering

+

Hotfix Branches

+

Naming: hotfix/<descriptive-name>

+

For urgent production fixes:

+
# Create from main (production)
+git checkout main
+git pull origin main
+git checkout -b hotfix/security-patch
+
+# Fix issue
+# ...
+
+# Merge to main AND v2
+git checkout main
+git merge hotfix/security-patch
+git push origin main
+
+git checkout v2
+git merge hotfix/security-patch
+git push origin v2
+
+

Examples: +- hotfix/security-patch +- hotfix/critical-database-error

+

Release Branches

+

Naming: release/vX.Y.Z

+

For preparing releases:

+
# Create release branch from v2
+git checkout v2
+git pull origin v2
+git checkout -b release/v2.1.0
+
+# Prepare release (update version, changelog)
+# Test thoroughly
+# ...
+
+# Merge to main (after approval)
+git checkout main
+git merge release/v2.1.0
+git tag v2.1.0
+git push origin main --tags
+
+# Merge back to v2
+git checkout v2
+git merge release/v2.1.0
+git push origin v2
+
+

Feature Development Workflow

+

Step 1: Create Branch

+
# Update v2 branch
+git checkout v2
+git pull origin v2
+
+# Create feature branch
+git checkout -b feature/add-user-avatar
+
+# Verify branch
+git branch --show-current
+# Output: feature/add-user-avatar
+
+

Step 2: Make Changes

+

Edit files, test locally:

+
# Make changes
+vi api/src/modules/users/users.service.ts
+vi admin/src/pages/UsersPage.tsx
+
+# Test locally
+npm run dev
+
+# Type-check
+npm run type-check
+
+# Lint
+npm run lint:fix
+
+

Step 3: Stage and Commit

+
# Check status
+git status
+
+# Stage specific files (NOT git add .)
+git add api/src/modules/users/users.service.ts
+git add admin/src/pages/UsersPage.tsx
+
+# Commit with conventional message
+git commit -m "feat(users): add avatar upload functionality
+
+Implements avatar upload with image validation and S3 storage.
+Adds avatar field to User model and updates UI.
+
+Closes #123"
+
+

Step 4: Push to Remote

+
# Push branch (first time)
+git push -u origin feature/add-user-avatar
+
+# Push subsequent commits
+git push
+
+

Step 5: Create Pull Request

+

On GitHub/GitLab:

+
    +
  1. Navigate to repository
  2. +
  3. Click "New Pull Request"
  4. +
  5. Select base: v2, compare: feature/add-user-avatar
  6. +
  7. Fill in PR template (title, description, testing steps)
  8. +
  9. Request reviewers
  10. +
  11. Link related issues
  12. +
+

Step 6: Address Review Feedback

+
# Make requested changes
+vi api/src/modules/users/users.service.ts
+
+# Stage and commit
+git add api/src/modules/users/users.service.ts
+git commit -m "fix(users): address review feedback
+
+- Add error handling for upload failures
+- Improve validation messages
+- Add JSDoc comments"
+
+# Push changes
+git push
+
+

Step 7: Merge (After Approval)

+

Squash and Merge (recommended): +- Combines all commits into one +- Clean history on v2 branch +- Preserves individual commits in branch

+

Merge Commit: +- Preserves all commits +- More detailed history +- Use for large features

+

Rebase and Merge: +- Linear history +- No merge commits +- Use when v2 has diverged

+

Step 8: Clean Up

+
# Delete local branch
+git checkout v2
+git branch -d feature/add-user-avatar
+
+# Delete remote branch (if not auto-deleted)
+git push origin --delete feature/add-user-avatar
+
+# Update v2
+git pull origin v2
+
+

Commit Messages

+

Conventional Commits Format

+
<type>(<scope>): <subject>
+
+<body>
+
+<footer>
+
+

Types

+
    +
  • feat: New feature
  • +
  • fix: Bug fix
  • +
  • docs: Documentation changes
  • +
  • style: Code formatting (no logic change)
  • +
  • refactor: Code restructuring (no behavior change)
  • +
  • perf: Performance improvement
  • +
  • test: Adding tests
  • +
  • chore: Maintenance (dependencies, config)
  • +
  • ci: CI/CD changes
  • +
  • build: Build system changes
  • +
+

Scopes

+

Use module/area name:

+
    +
  • auth - Authentication
  • +
  • users - User management
  • +
  • campaigns - Campaign module
  • +
  • map - Map features
  • +
  • email - Email system
  • +
  • db - Database changes
  • +
  • ui - UI components
  • +
  • api - API changes
  • +
+

Examples

+

Simple commit: +

git commit -m "feat(auth): add JWT refresh token rotation"
+

+

With body: +

git commit -m "feat(campaigns): add email queue monitoring
+
+Implements real-time queue stats dashboard with pause/resume controls.
+Shows pending, active, completed, and failed jobs.
+
+Closes #45"
+

+

Breaking change: +

git commit -m "feat(api)!: change user endpoint response format
+
+BREAKING CHANGE: User endpoint now returns paginated response.
+Update client code to handle new format.
+
+Migration guide: docs/migration/v2.1.md"
+

+

Multiple changes: +

git commit -m "feat(map): add location clustering and popup improvements
+
+- Implement marker clustering for better performance
+- Add custom popup with location details
+- Improve map controls layout
+
+Closes #67, #68"
+

+

Hotfix: +

git commit -m "fix(auth)!: patch critical security vulnerability
+
+Fixes CVE-2024-12345 in JWT token validation.
+All users must update tokens after deploy.
+
+Security advisory: docs/security/2024-02-13.md"
+

+

Git Safety Protocol

+

From CLAUDE.md - Critical Rules:

+

NEVER Do These (Unless User Explicitly Requests)

+
# ❌ NEVER without explicit user approval
+git push --force
+git push --force-with-lease
+git reset --hard
+git checkout .
+git restore .
+git clean -f
+git clean -fd
+git branch -D
+git rebase -i
+
+# ❌ NEVER skip hooks
+git commit --no-verify
+git push --no-verify
+
+# ❌ NEVER force push to main/master
+git push --force origin main  # DANGER!
+
+

ALWAYS Do These

+
# ✅ Stage specific files (not git add .)
+git add api/src/modules/auth/auth.service.ts
+git add admin/src/pages/LoginPage.tsx
+
+# ✅ Create NEW commits (not --amend after hook failure)
+# If pre-commit hook fails, commit did NOT happen
+# Fix issue, re-stage, create NEW commit (not amend)
+
+# ✅ Verify changes before commit
+git diff --staged
+
+# ✅ Pull before push
+git pull origin v2
+git push origin feature/my-feature
+
+

Commit Co-Authoring (Claude Code)

+

When Claude assists with code, add co-author:

+
git commit -m "$(cat <<'EOF'
+feat(auth): implement refresh token rotation
+
+Adds atomic refresh token rotation to prevent race conditions
+during concurrent refresh requests.
+
+Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
+EOF
+)"
+
+

Or use heredoc: +

git commit -m "feat(auth): implement refresh token rotation
+
+Adds atomic refresh token rotation to prevent race conditions.
+
+Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
+

+

Pull Request Process

+

PR Template

+

Create .github/pull_request_template.md:

+
## Description
+<!-- Brief description of changes -->
+
+## Type of Change
+- [ ] Bug fix (non-breaking change which fixes an issue)
+- [ ] New feature (non-breaking change which adds functionality)
+- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
+- [ ] Documentation update
+
+## Related Issues
+<!-- Link to issue(s): Closes #123, Fixes #456 -->
+
+## Changes Made
+<!-- Detailed list of changes -->
+-
+-
+-
+
+## Testing Done
+<!-- How were these changes tested? -->
+- [ ] Unit tests added/updated
+- [ ] Integration tests added/updated
+- [ ] Manual testing performed
+
+## Checklist
+- [ ] Code follows style guidelines
+- [ ] Self-review completed
+- [ ] Comments added for complex logic
+- [ ] Documentation updated
+- [ ] No new warnings generated
+- [ ] Tests pass locally
+- [ ] Database migrations included (if applicable)
+
+

Code Review Checklist

+

Reviewer checks:

+
    +
  • Code matches description
  • +
  • Logic is correct
  • +
  • Error handling present
  • +
  • Tests included
  • +
  • TypeScript types correct
  • +
  • No security issues
  • +
  • No performance issues
  • +
  • Documentation updated
  • +
  • Follows code style guide
  • +
  • No debugging code left
  • +
+

Review Process

+
    +
  1. Author submits PR
  2. +
  3. Fills out template
  4. +
  5. Self-reviews changes
  6. +
  7. +

    Requests reviewers

    +
  8. +
  9. +

    Reviewers review

    +
  10. +
  11. Read description
  12. +
  13. Review code changes
  14. +
  15. Test locally (if needed)
  16. +
  17. +

    Leave comments/suggestions

    +
  18. +
  19. +

    Author addresses feedback

    +
  20. +
  21. Makes requested changes
  22. +
  23. Responds to comments
  24. +
  25. +

    Re-requests review

    +
  26. +
  27. +

    Final approval

    +
  28. +
  29. Reviewers approve
  30. +
  31. CI/CD checks pass
  32. +
  33. Merge to base branch
  34. +
+

Merge Strategies

+ +

When to use: +- Feature branches with multiple commits +- Want clean history on main/v2 +- Individual commits not important

+

Result: +

v2:  A---B---C---D
+                  \
+feature:           E---F---G  (squashed into D)
+

+

How: +

# On GitHub: "Squash and Merge" button
+
+# Manual:
+git checkout v2
+git merge --squash feature/add-avatar
+git commit -m "feat(users): add avatar upload functionality"
+git push origin v2
+

+

Merge Commit

+

When to use: +- Want to preserve all commits +- Large features with meaningful commit history +- Release branches

+

Result: +

v2:  A---B-------D
+          \     /
+feature:   E---F
+

+

How: +

# On GitHub: "Create a merge commit" button
+
+# Manual:
+git checkout v2
+git merge feature/add-avatar
+git push origin v2
+

+

Rebase and Merge

+

When to use: +- Want linear history +- Few commits +- No merge conflicts

+

Result: +

v2:  A---B---E'---F'
+

+

How: +

# On GitHub: "Rebase and merge" button
+
+# Manual:
+git checkout feature/add-avatar
+git rebase v2
+git checkout v2
+git merge feature/add-avatar
+git push origin v2
+

+

Version Tags

+

Semantic Versioning

+

Format: vMAJOR.MINOR.PATCH

+
    +
  • MAJOR: Breaking changes
  • +
  • MINOR: New features (backward compatible)
  • +
  • PATCH: Bug fixes (backward compatible)
  • +
+

Examples: +- v2.0.0 - Major release (V2 launch) +- v2.1.0 - New features added +- v2.1.1 - Bug fixes +- v2.2.0 - More new features

+

Creating Tags

+
# Create annotated tag
+git tag -a v2.1.0 -m "Release v2.1.0: Email queue monitoring
+
+New Features:
+- Email queue dashboard
+- Pause/resume controls
+- Job statistics
+
+Bug Fixes:
+- Fixed map marker positioning
+- Fixed login redirect loop
+
+See CHANGELOG.md for full details"
+
+# Push tag to remote
+git push origin v2.1.0
+
+# Push all tags
+git push origin --tags
+
+

Viewing Tags

+
# List all tags
+git tag
+
+# List tags matching pattern
+git tag -l "v2.1.*"
+
+# Show tag details
+git show v2.1.0
+
+# Checkout specific tag
+git checkout v2.1.0
+
+

Common Operations

+

Update Branch with Latest v2

+
# While on feature branch
+git checkout feature/add-avatar
+git fetch origin
+git rebase origin/v2
+
+# Or merge (if rebase has conflicts)
+git merge origin/v2
+
+

Resolve Merge Conflicts

+
# Attempt merge/rebase
+git merge v2
+# CONFLICT (content): Merge conflict in api/src/modules/auth/auth.service.ts
+
+# View conflicted files
+git status
+
+# Edit conflicted file
+vi api/src/modules/auth/auth.service.ts
+
+# Look for conflict markers:
+# <<<<<<< HEAD
+# Your changes
+# =======
+# Their changes
+# >>>>>>> v2
+
+# Resolve conflict, remove markers
+
+# Stage resolved file
+git add api/src/modules/auth/auth.service.ts
+
+# Continue merge
+git commit
+# Or continue rebase
+git rebase --continue
+
+

Undo Changes

+
# Unstage file
+git restore --staged api/src/modules/auth/auth.service.ts
+
+# Discard local changes (CAREFUL!)
+git restore api/src/modules/auth/auth.service.ts
+
+# Undo last commit (keep changes)
+git reset --soft HEAD~1
+
+# Undo last commit (discard changes)
+git reset --hard HEAD~1  # ⚠️ DESTRUCTIVE!
+
+# Revert commit (creates new commit)
+git revert abc123  # Safer than reset
+
+

Stash Changes

+
# Stash uncommitted changes
+git stash
+
+# Stash with message
+git stash save "WIP: avatar upload"
+
+# List stashes
+git stash list
+
+# Apply stash
+git stash apply
+
+# Apply specific stash
+git stash apply stash@{1}
+
+# Pop stash (apply and delete)
+git stash pop
+
+# Delete stash
+git stash drop stash@{0}
+
+

View History

+
# View commit history
+git log
+
+# One-line format
+git log --oneline
+
+# Graph view
+git log --oneline --graph --all
+
+# Filter by author
+git log --author="John Doe"
+
+# Filter by date
+git log --since="2024-01-01" --until="2024-12-31"
+
+# File history
+git log --follow api/src/modules/auth/auth.service.ts
+
+# Search commits
+git log --grep="JWT"
+
+

Compare Changes

+
# Compare working directory to staging
+git diff
+
+# Compare staging to last commit
+git diff --staged
+
+# Compare two branches
+git diff v2..feature/add-avatar
+
+# Compare specific file
+git diff v2 api/src/modules/auth/auth.service.ts
+
+# Compare commits
+git diff abc123..def456
+
+

Git Hooks

+

Pre-commit Hook

+

Install husky:

+
npm install --save-dev husky lint-staged
+npx husky install
+
+

Create pre-commit hook:

+
# .husky/pre-commit
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+# Run lint-staged
+npx lint-staged
+
+

Configure lint-staged (package.json):

+
{
+  "lint-staged": {
+    "*.{ts,tsx}": [
+      "eslint --fix",
+      "prettier --write"
+    ],
+    "*.{json,md}": [
+      "prettier --write"
+    ]
+  }
+}
+
+

Hook runs automatically:

+
git commit -m "feat: add feature"
+# Runs ESLint, Prettier on staged files
+# Fails commit if errors found
+
+

Commit-msg Hook

+

Validate commit message format:

+
# .husky/commit-msg
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+# Validate conventional commit format
+npx commitlint --edit $1
+
+

Install commitlint:

+
npm install --save-dev @commitlint/cli @commitlint/config-conventional
+
+

Configure (.commitlintrc.json):

+
{
+  "extends": ["@commitlint/config-conventional"],
+  "rules": {
+    "type-enum": [
+      2,
+      "always",
+      ["feat", "fix", "docs", "style", "refactor", "perf", "test", "chore", "ci", "build"]
+    ],
+    "scope-enum": [
+      2,
+      "always",
+      ["auth", "users", "campaigns", "map", "email", "db", "ui", "api"]
+    ]
+  }
+}
+
+

.gitignore

+

Project .gitignore:

+
# Dependencies
+node_modules/
+*/node_modules/
+
+# Build outputs
+dist/
+build/
+*/dist/
+*/build/
+
+# Environment
+.env
+.env.local
+.env.*.local
+
+# Logs
+logs/
+*.log
+npm-debug.log*
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Testing
+coverage/
+.nyc_output/
+
+# Temporary
+tmp/
+temp/
+*.tmp
+
+# Database
+*.sqlite
+*.db
+
+# Prisma
+api/.prisma/
+api/prisma/.env
+
+# Vite
+admin/.vite/
+admin/tsconfig.tsbuildinfo
+
+# Docker volumes
+postgres-data/
+redis-data/
+
+

Collaboration

+

Forks

+

Fork workflow:

+
    +
  1. Fork repository on GitHub
  2. +
  3. +

    Clone your fork: +

    git clone https://github.com/your-username/changemaker.lite.git
    +

    +
  4. +
  5. +

    Add upstream remote: +

    git remote add upstream https://github.com/original/changemaker.lite.git
    +

    +
  6. +
  7. +

    Create feature branch: +

    git checkout -b feature/my-feature
    +

    +
  8. +
  9. +

    Make changes, commit, push to your fork: +

    git push origin feature/my-feature
    +

    +
  10. +
  11. +

    Create pull request from your fork to upstream

    +
  12. +
+

Sync Fork with Upstream

+
# Fetch upstream changes
+git fetch upstream
+
+# Merge upstream v2 into your v2
+git checkout v2
+git merge upstream/v2
+
+# Push to your fork
+git push origin v2
+
+

Best Practices

+

Do's

+
    +
  • ✅ Pull before push
  • +
  • ✅ Write descriptive commit messages
  • +
  • ✅ Stage specific files (not git add .)
  • +
  • ✅ Review changes before commit (git diff --staged)
  • +
  • ✅ Test locally before pushing
  • +
  • ✅ Keep commits focused (one logical change)
  • +
  • ✅ Use branches for all changes
  • +
  • ✅ Delete merged branches
  • +
+

Don'ts

+
    +
  • ❌ Commit directly to main
  • +
  • ❌ Force push without approval
  • +
  • ❌ Commit large binary files
  • +
  • ❌ Commit secrets (.env, API keys)
  • +
  • ❌ Use git add . (stage specific files)
  • +
  • ❌ Amend commits after pushing
  • +
  • ❌ Rebase public branches
  • +
  • ❌ Leave debugging code in commits
  • +
+ + +

Summary

+

You now know: +- ✅ Branch structure (main, v2, feature, fix, hotfix) +- ✅ Feature development workflow +- ✅ Conventional commit message format +- ✅ Git safety protocol (NEVER force push without approval) +- ✅ Pull request process +- ✅ Merge strategies (squash, merge commit, rebase) +- ✅ Version tagging (semantic versioning) +- ✅ Common Git operations +- ✅ Git hooks (pre-commit, commit-msg) +- ✅ Best practices

+

Quick Reference: +

# Create feature branch
+git checkout -b feature/my-feature
+
+# Make changes, stage, commit
+git add specific-file.ts
+git commit -m "feat(scope): description"
+
+# Push and create PR
+git push -u origin feature/my-feature
+
+# After merge, clean up
+git checkout v2
+git pull origin v2
+git branch -d feature/my-feature
+

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/development/index.html b/mkdocs/site/v2/development/index.html new file mode 100644 index 00000000..19e9a185 --- /dev/null +++ b/mkdocs/site/v2/development/index.html @@ -0,0 +1,5444 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Development Guide - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Development Guide

+

This section covers development workflows, local setup, coding standards, testing, and best practices for contributing to Changemaker Lite V2.

+

Development Workflow

+

Local Setup

+

Getting started with local development:

+
    +
  • Prerequisites (Node.js, Docker, Git)
  • +
  • Repository setup
  • +
  • Environment configuration
  • +
  • Database initialization
  • +
  • Running development servers
  • +
+

Docker Workflow

+

Docker-based development:

+
    +
  • Starting services with Docker Compose
  • +
  • Viewing logs
  • +
  • Rebuilding containers
  • +
  • Database operations
  • +
  • Volume management
  • +
+

Git Workflow

+

Version control best practices:

+
    +
  • Branch naming conventions
  • +
  • Commit message format
  • +
  • Pull request process
  • +
  • Code review guidelines
  • +
  • Merge strategies
  • +
+

NPM Commands

+

Common development commands:

+
    +
  • Development servers
  • +
  • Build commands
  • +
  • Testing
  • +
  • Type-checking
  • +
  • Linting and formatting
  • +
+

Database Migrations

+

Schema changes and migrations:

+
    +
  • Creating migrations (Prisma)
  • +
  • Applying migrations
  • +
  • Schema push (Drizzle)
  • +
  • Rolling back changes
  • +
  • Testing migrations
  • +
+

TypeScript

+

TypeScript best practices:

+
    +
  • Type definitions
  • +
  • Strict mode
  • +
  • Common patterns
  • +
  • Zod integration
  • +
  • Prisma types
  • +
+

Code Style

+

Coding standards and conventions:

+
    +
  • ESLint configuration
  • +
  • Prettier formatting
  • +
  • Naming conventions
  • +
  • File organization
  • +
  • Comment standards
  • +
+

Testing

+

Testing strategies:

+
    +
  • Unit tests
  • +
  • Integration tests
  • +
  • API testing
  • +
  • Component testing
  • +
  • E2E testing (future)
  • +
+

Debugging

+

Debugging techniques:

+
    +
  • VS Code debugging
  • +
  • Browser DevTools
  • +
  • API debugging
  • +
  • Database queries
  • +
  • Log analysis
  • +
+

Quick Start

+

Local Development (No Docker)

+

Terminal 1: API Server +

cd api
+npm install
+npm run dev
+# Runs on http://localhost:4000
+

+

Terminal 2: Admin GUI +

cd admin
+npm install
+npm run dev
+# Runs on http://localhost:3000
+

+

Terminal 3: Media API (Optional) +

cd api
+npm run dev:media
+# Runs on http://localhost:4100
+

+

Docker Development

+

Start Core Services +

docker compose up -d v2-postgres redis
+docker compose up -d api admin
+

+

View Logs +

docker compose logs -f api
+docker compose logs -f admin
+

+

Rebuild After Changes +

docker compose build api
+docker compose up -d api
+

+

Development Tools

+

Required

+
    +
  • Node.js 20+ - JavaScript runtime
  • +
  • npm 10+ - Package manager
  • +
  • Docker 24+ - Container runtime
  • +
  • Docker Compose 2+ - Multi-container orchestration
  • +
  • Git 2+ - Version control
  • +
+ +
    +
  • VS Code - IDE with TypeScript support
  • +
  • Prisma Studio - Database GUI
  • +
  • Postman - API testing
  • +
  • Redis Insight - Redis GUI (optional)
  • +
+

VS Code Extensions

+
    +
  • Prisma - Schema syntax highlighting
  • +
  • ESLint - JavaScript linting
  • +
  • Prettier - Code formatting
  • +
  • TypeScript - Language support
  • +
  • Docker - Container management
  • +
+

Project Structure

+
changemaker.lite/
+├── api/                    # Backend (Express + Fastify)
+│   ├── src/
+│   │   ├── server.ts       # Express entry point
+│   │   ├── media-server.ts # Fastify entry point
+│   │   ├── modules/        # Feature modules
+│   │   ├── services/       # Shared services
+│   │   ├── middleware/     # Express middleware
+│   │   └── utils/          # Utilities
+│   ├── prisma/
+│   │   ├── schema.prisma   # Main schema
+│   │   └── migrations/     # Migration history
+│   └── package.json
+│
+├── admin/                  # Frontend (React + Vite)
+│   ├── src/
+│   │   ├── App.tsx         # Main router
+│   │   ├── components/     # Shared components
+│   │   ├── pages/          # Page components
+│   │   ├── lib/            # API clients
+│   │   └── stores/         # Zustand stores
+│   └── package.json
+│
+├── docker-compose.yml      # V2 orchestration
+├── .env                    # Environment variables (not committed)
+└── .env.example            # Example environment
+
+

Development Patterns

+

Backend Module Structure

+
api/src/modules/feature/
+├── feature.routes.ts       # Express router
+├── feature.service.ts      # Business logic
+├── feature.schemas.ts      # Zod validation
+└── feature-public.routes.ts # Public routes (optional)
+
+

Frontend Page Structure

+
admin/src/pages/
+├── admin/                  # Admin pages (30)
+├── public/                 # Public pages (8)
+├── volunteer/              # Volunteer pages (4)
+└── auth/                   # Auth pages (1)
+
+

API Client Pattern

+
// admin/src/lib/api.ts
+import axios from 'axios';
+
+export const api = axios.create({
+  baseURL: import.meta.env.VITE_API_URL,
+});
+
+// Interceptor for auth
+api.interceptors.request.use((config) => {
+  const token = localStorage.getItem('accessToken');
+  if (token) {
+    config.headers.Authorization = `Bearer ${token}`;
+  }
+  return config;
+});
+
+

Service Pattern

+
// api/src/modules/feature/feature.service.ts
+import { prisma } from '../../lib/prisma';
+
+class FeatureService {
+  async create(data: CreateInput) {
+    return await prisma.feature.create({ data });
+  }
+
+  async findAll(filters: Filters) {
+    return await prisma.feature.findMany({ where: filters });
+  }
+}
+
+export const featureService = new FeatureService();
+
+

Common Tasks

+

Add New API Endpoint

+
    +
  1. Create schema in *.schemas.ts
  2. +
  3. Add service method in *.service.ts
  4. +
  5. Add route handler in *.routes.ts
  6. +
  7. Register router in server.ts
  8. +
  9. Test with Postman/curl
  10. +
+

Add New Page

+
    +
  1. Create page component in pages/
  2. +
  3. Add route in App.tsx
  4. +
  5. Add to sidebar menu (if admin page)
  6. +
  7. Create API client calls
  8. +
  9. Test in browser
  10. +
+

Add Database Field

+
    +
  1. Update prisma/schema.prisma
  2. +
  3. Run npx prisma migrate dev --name add_field
  4. +
  5. Update TypeScript types
  6. +
  7. Update API endpoints
  8. +
  9. Update frontend forms
  10. +
+

Add New Service Integration

+
    +
  1. Create client in services/
  2. +
  3. Add environment variables
  4. +
  5. Create admin routes
  6. +
  7. Add admin page
  8. +
  9. Test integration
  10. +
+

Testing

+

API Tests

+
# Run API tests
+cd api && npm test
+
+# Test specific endpoint
+curl http://localhost:4000/api/campaigns
+
+

Frontend Tests

+
# Run component tests
+cd admin && npm test
+
+# Test build
+cd admin && npm run build
+
+

Type Checking

+
# Check API types
+cd api && npx tsc --noEmit
+
+# Check admin types
+cd admin && npx tsc --noEmit
+
+

Debugging

+

API Debugging

+
# View API logs
+docker compose logs -f api
+
+# Access container shell
+docker compose exec api sh
+
+# Run Prisma Studio
+cd api && npx prisma studio
+
+

Frontend Debugging

+
# View admin logs
+docker compose logs -f admin
+
+# Access browser DevTools
+# Open http://localhost:3000
+# F12 for DevTools
+
+

Code Quality

+

Linting

+
# Lint API
+cd api && npm run lint
+
+# Lint admin
+cd admin && npm run lint
+
+

Formatting

+
# Format API
+cd api && npm run format
+
+# Format admin
+cd admin && npm run format
+
+

Type Safety

+

All code uses TypeScript with strict mode:

+
{
+  "compilerOptions": {
+    "strict": true,
+    "noImplicitAny": true,
+    "strictNullChecks": true
+  }
+}
+
+

Best Practices

+

Backend

+
    +
  • Use Zod for validation
  • +
  • Service layer for business logic
  • +
  • Middleware for cross-cutting concerns
  • +
  • Error handling with try/catch
  • +
  • Logging with Winston
  • +
+

Frontend

+
    +
  • Use React hooks
  • +
  • Zustand for state management
  • +
  • Ant Design components
  • +
  • Type-safe API calls
  • +
  • Error boundaries
  • +
+

Database

+
    +
  • Use migrations for schema changes
  • +
  • Index frequently queried fields
  • +
  • Use transactions for multi-step operations
  • +
  • Avoid N+1 queries
  • +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/development/local-setup/index.html b/mkdocs/site/v2/development/local-setup/index.html new file mode 100644 index 00000000..bfdf46cd --- /dev/null +++ b/mkdocs/site/v2/development/local-setup/index.html @@ -0,0 +1,7668 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Local Setup - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Local Development Setup

+

This guide walks you through setting up Changemaker Lite V2 for local development on your machine.

+

Overview

+

Changemaker Lite V2 supports two development approaches:

+
    +
  1. Docker-based development - Run API and Admin in containers (recommended for consistency)
  2. +
  3. Local npm development - Run services directly on your host machine (faster hot reload)
  4. +
+

This guide covers both approaches. Choose the one that fits your workflow.

+

Prerequisites

+

Required Software

+

Node.js and npm

+
    +
  • Node.js 20.x LTS or higher
  • +
  • npm 10.x or higher
  • +
+
# Check versions
+node --version  # Should be v20.x.x or higher
+npm --version   # Should be 10.x.x or higher
+
+

Installation: +- Download from nodejs.org +- Or use nvm for version management:

+
nvm install 20
+nvm use 20
+
+

Docker and Docker Compose

+
    +
  • Docker Engine 24.x or higher
  • +
  • Docker Compose 2.x or higher (included with Docker Desktop)
  • +
+
# Check versions
+docker --version         # Should be 24.x.x or higher
+docker compose version   # Should be 2.x.x or higher
+
+

Installation: +- Docker Desktop: docker.com/get-started +- Linux: docs.docker.com/engine/install

+

Git

+
    +
  • Git 2.30 or higher
  • +
+
# Check version
+git --version  # Should be 2.30.x or higher
+
+

Installation: +- Download from git-scm.com +- Or use package manager (apt, brew, etc.)

+

Optional Tools

+

PostgreSQL Client Tools

+

Useful for database inspection and debugging:

+
# Ubuntu/Debian
+sudo apt install postgresql-client
+
+# macOS
+brew install postgresql@16
+
+# Check installation
+psql --version
+
+

Redis CLI

+

For cache/queue debugging:

+
# Ubuntu/Debian
+sudo apt install redis-tools
+
+# macOS
+brew install redis
+
+# Check installation
+redis-cli --version
+
+

Visual Studio Code

+

Recommended IDE with excellent TypeScript support:

+ +

System Requirements

+

Minimum: +- 8 GB RAM +- 20 GB free disk space +- 2 CPU cores

+

Recommended: +- 16 GB RAM +- 50 GB free disk space +- 4+ CPU cores

+

Repository Setup

+

Clone Repository

+
# Clone the repository
+git clone <repo-url> changemaker.lite
+cd changemaker.lite
+
+# Checkout v2 branch
+git checkout v2
+
+# Verify branch
+git branch --show-current
+# Output: v2
+
+

Repository Structure

+

After cloning, your directory structure should look like:

+
changemaker.lite/
+├── api/                  # Express.js + Fastify backend
+├── admin/                # React frontend
+├── configs/              # Monitoring configs (Prometheus, Grafana)
+├── nginx/                # Reverse proxy configuration
+├── scripts/              # Utility scripts
+├── docker-compose.yml    # V2 orchestration
+├── .env.example          # Environment template
+└── V2_PLAN.md           # Development roadmap
+
+

Verify Files

+

Check that key files exist:

+
ls -la api/package.json admin/package.json docker-compose.yml .env.example
+
+

If any files are missing, ensure you're on the v2 branch.

+

Environment Configuration

+

Create .env File

+

Copy the example environment file:

+
cp .env.example .env
+
+

Configure Essential Variables

+

Open .env in your editor and set the following critical variables:

+

Database Passwords

+
# PostgreSQL password (use a strong password)
+V2_POSTGRES_PASSWORD=your_strong_password_here
+
+# Redis password (use a strong password)
+REDIS_PASSWORD=your_redis_password_here
+
+

JWT Secrets

+

Generate secure random secrets:

+
# Generate secrets (run these commands separately)
+openssl rand -hex 32  # For JWT_ACCESS_SECRET
+openssl rand -hex 32  # For JWT_REFRESH_SECRET
+openssl rand -hex 32  # For ENCRYPTION_KEY
+
+

Add to .env:

+
# JWT secrets (use different values for each!)
+JWT_ACCESS_SECRET=<output from first command>
+JWT_REFRESH_SECRET=<output from second command>
+ENCRYPTION_KEY=<output from third command>
+
+

IMPORTANT: All three secrets must be different values!

+

Email Configuration (Development)

+

For development, use MailHog to capture emails locally:

+
# Email test mode (sends to MailHog instead of real SMTP)
+EMAIL_TEST_MODE=true
+
+# MailHog SMTP settings
+EMAIL_SMTP_HOST=localhost
+EMAIL_SMTP_PORT=1025
+EMAIL_SMTP_SECURE=false
+EMAIL_FROM_ADDRESS=noreply@cmlite.org
+EMAIL_FROM_NAME=Changemaker Lite
+
+

Optional Features

+

Enable optional features as needed:

+
# Media Manager (video library)
+ENABLE_MEDIA_FEATURES=true
+
+# Listmonk newsletter sync
+LISTMONK_SYNC_ENABLED=false  # Enable later if needed
+
+# API ports (defaults work for most setups)
+API_PORT=4000
+ADMIN_PORT=3000
+MEDIA_API_PORT=4100
+
+

Complete .env Template

+

Here's a minimal .env for local development:

+
# Database
+V2_POSTGRES_PASSWORD=your_strong_password
+DATABASE_URL=postgresql://changemaker_v2:your_strong_password@localhost:5433/changemaker_v2_db
+
+# Redis
+REDIS_PASSWORD=your_redis_password
+REDIS_URL=redis://:your_redis_password@localhost:6379
+
+# JWT
+JWT_ACCESS_SECRET=<32-byte hex from openssl>
+JWT_REFRESH_SECRET=<32-byte hex from openssl>
+ENCRYPTION_KEY=<32-byte hex from openssl>
+
+# Email (MailHog for dev)
+EMAIL_TEST_MODE=true
+EMAIL_SMTP_HOST=localhost
+EMAIL_SMTP_PORT=1025
+EMAIL_SMTP_SECURE=false
+EMAIL_FROM_ADDRESS=noreply@cmlite.org
+EMAIL_FROM_NAME=Changemaker Lite
+
+# Ports
+API_PORT=4000
+ADMIN_PORT=3000
+MEDIA_API_PORT=4100
+
+# Features
+ENABLE_MEDIA_FEATURES=true
+LISTMONK_SYNC_ENABLED=false
+
+# Node environment
+NODE_ENV=development
+
+

Verify Configuration

+

Check that required variables are set:

+
grep -E '^(V2_POSTGRES_PASSWORD|REDIS_PASSWORD|JWT_ACCESS_SECRET|JWT_REFRESH_SECRET|ENCRYPTION_KEY)=' .env
+
+

You should see 5 lines with non-empty values.

+

Database Setup

+

Start Database Services

+

Start PostgreSQL and Redis containers:

+
docker compose up -d v2-postgres redis
+
+

Wait for databases to initialize (first run takes 30-60 seconds):

+
# Watch logs
+docker compose logs -f v2-postgres redis
+
+# Look for:
+# v2-postgres: "database system is ready to accept connections"
+# redis: "Ready to accept connections"
+
+# Press Ctrl+C to exit logs
+
+

Verify Database Connection

+

Test PostgreSQL connection:

+
docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c "SELECT version();"
+
+

You should see PostgreSQL version information.

+

Test Redis connection:

+
docker compose exec redis redis-cli -a your_redis_password ping
+# Output: PONG
+
+

Install API Dependencies

+
cd api
+npm install
+
+

Expected output: +- Installs ~300+ packages +- May show peer dependency warnings (safe to ignore) +- Should complete without errors

+

Run Database Migrations

+

Apply Prisma migrations to create database schema:

+
# From api/ directory
+npx prisma migrate deploy
+
+

Expected output: +

Environment variables loaded from .env
+Prisma schema loaded from prisma/schema.prisma
+Datasource "db": PostgreSQL database "changemaker_v2_db"
+
+20 migrations found in prisma/migrations
+
+Applying migration `20260101000000_init`
+Applying migration `20260105000000_add_campaigns`
+...
+All migrations have been successfully applied.
+

+

Seed Database

+

Populate database with initial data (admin user, settings, etc.):

+
# From api/ directory
+npx prisma db seed
+
+

Expected output: +

Running seed command `tsx prisma/seed.ts` ...
+Seeding database...
+Created default settings
+Created admin user: admin@example.com
+Created 10 sample blocks
+...
+Seed completed successfully
+

+

Default Admin Credentials: +- Email: admin@example.com +- Password: Admin123! +- Change this password immediately after first login!

+

Verify Database Schema

+

Open Prisma Studio to browse the database:

+
# From api/ directory
+npx prisma studio
+
+

This opens a browser at http://localhost:5555 showing: +- 30+ tables (User, Campaign, Location, Shift, etc.) +- Seeded data (1 admin user, settings, blocks)

+

Press Ctrl+C to close Prisma Studio when done.

+

Return to Project Root

+
cd ..  # Back to changemaker.lite/
+
+

Starting Services

+

You have two options for running the development servers:

+ +

Run API and Admin in Docker containers with volume mounts for hot reload:

+
# Start API and Admin containers
+docker compose up -d api admin
+
+# Optional: Start MailHog for email testing
+docker compose up -d mailhog
+
+# Optional: Start Media API
+docker compose up -d media-api
+
+

Watch logs:

+
# All services
+docker compose logs -f api admin
+
+# Just API
+docker compose logs -f api
+
+# Just Admin
+docker compose logs -f admin
+
+

Verify services started:

+
docker compose ps
+
+

You should see: +- api - running on port 4000 +- admin - running on port 3000 +- v2-postgres - running on port 5433 +- redis - running on port 6379 +- mailhog - running on port 8025 (if started)

+

Hot Reload in Docker:

+

Volume mounts automatically sync code changes: +- API: tsx watch restarts server on file changes +- Admin: Vite HMR updates browser without full reload

+

Option 2: Local npm Development

+

Run services directly on your host machine (faster hot reload):

+

Terminal 1: API Server

+
cd api
+npm run dev
+
+

Expected output: +

> api@2.0.0 dev
+> tsx watch src/server.ts
+
+Server running on port 4000
+Database connected
+Redis connected
+BullMQ worker started
+

+

Terminal 2: Admin Server

+
cd admin
+npm install  # First time only
+npm run dev
+
+

Expected output: +

> admin@2.0.0 dev
+> vite
+
+  VITE v5.x.x  ready in 500 ms
+
+  ➜  Local:   http://localhost:3000/
+  ➜  Network: use --host to expose
+

+

Terminal 3: Media API (Optional)

+
cd api
+npm run dev:media
+
+

Expected output: +

> api@2.0.0 dev:media
+> tsx watch src/media-server.ts
+
+Media API server running on port 4100
+Database connected
+

+

Background Services

+

You still need Docker for PostgreSQL, Redis, and MailHog:

+
docker compose up -d v2-postgres redis mailhog
+
+

Which Approach to Use?

+

Use Docker-based development if: +- You want consistent environment across team +- You're new to the project +- You prefer simpler setup

+

Use local npm development if: +- You want faster hot reload (especially for frontend) +- You're actively developing API changes +- You prefer direct access to Node.js processes

+

You can mix approaches: +- Run API in Docker, Admin locally +- Run databases in Docker, both API/Admin locally

+

Verifying Setup

+

Health Check Endpoints

+

Test that services are responding:

+
# API health check
+curl http://localhost:4000/health
+# Expected: {"status":"ok","timestamp":"2026-02-13T..."}
+
+# Admin (open in browser)
+open http://localhost:3000
+# Or visit manually: http://localhost:3000
+
+# Media API health check (if enabled)
+curl http://localhost:4100/health
+# Expected: {"status":"ok","timestamp":"2026-02-13T..."}
+
+

Test Authentication

+

Test login endpoint:

+
curl -X POST http://localhost:4000/api/auth/login \
+  -H "Content-Type: application/json" \
+  -d '{
+    "email": "admin@example.com",
+    "password": "Admin123!"
+  }'
+
+

Expected response: +

{
+  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+  "user": {
+    "id": 1,
+    "email": "admin@example.com",
+    "role": "SUPER_ADMIN"
+  }
+}
+

+

Login to Admin GUI

+
    +
  1. Open http://localhost:3000 in browser
  2. +
  3. Login with:
  4. +
  5. Email: admin@example.com
  6. +
  7. Password: Admin123!
  8. +
  9. You should be redirected to /app (admin dashboard)
  10. +
  11. Change password immediately:
  12. +
  13. Click user menu (top right)
  14. +
  15. Settings → Change Password
  16. +
  17. Set new password (12+ chars, uppercase, lowercase, digit)
  18. +
+

Verify Database Connection

+

Check that API can query database:

+
curl http://localhost:4000/api/users \
+  -H "Authorization: Bearer <access_token_from_login>"
+
+

Expected response: +

{
+  "users": [
+    {
+      "id": 1,
+      "email": "admin@example.com",
+      "role": "SUPER_ADMIN",
+      ...
+    }
+  ],
+  "total": 1,
+  "page": 1,
+  "limit": 50
+}
+

+

Test Email Capture (MailHog)

+
    +
  1. Open http://localhost:8025 in browser
  2. +
  3. You should see MailHog web UI
  4. +
  5. Trigger a test email (e.g., shift signup)
  6. +
  7. Email appears in MailHog inbox
  8. +
+

IDE Setup

+

Visual Studio Code

+

Recommended IDE with excellent TypeScript/React support.

+ +

Install these extensions for best developer experience:

+

Essential: +- ESLint (dbaeumer.vscode-eslint) - Linting +- Prettier (esbenp.prettier-vscode) - Code formatting +- Prisma (Prisma.prisma) - Prisma schema support +- TypeScript Vue Plugin (Volar) (Vue.volar) - Vue/JSX support

+

Highly Recommended: +- GitLens (eamodio.gitlens) - Git insights +- Docker (ms-azuretools.vscode-docker) - Docker management +- Thunder Client (rangav.vscode-thunder-client) - API testing +- Error Lens (usernamehw.errorlens) - Inline errors +- Auto Rename Tag (formulahendry.auto-rename-tag) - HTML/JSX tag pairs +- Path Intellisense (christian-kohler.path-intellisense) - Path autocomplete

+

Optional: +- Tailwind CSS IntelliSense (bradlc.vscode-tailwindcss) - Tailwind support +- DotENV (mikestead.dotenv) - .env syntax highlighting +- Import Cost (wix.vscode-import-cost) - Bundle size info

+

Workspace Settings

+

Create .vscode/settings.json in project root:

+
{
+  // Editor
+  "editor.formatOnSave": true,
+  "editor.defaultFormatter": "esbenp.prettier-vscode",
+  "editor.codeActionsOnSave": {
+    "source.fixAll.eslint": true
+  },
+  "editor.tabSize": 2,
+  "editor.insertSpaces": true,
+
+  // Files
+  "files.eol": "\n",
+  "files.trimTrailingWhitespace": true,
+  "files.insertFinalNewline": true,
+
+  // TypeScript
+  "typescript.tsdk": "node_modules/typescript/lib",
+  "typescript.enablePromptUseWorkspaceTsdk": true,
+  "typescript.preferences.importModuleSpecifier": "relative",
+
+  // Prisma
+  "[prisma]": {
+    "editor.defaultFormatter": "Prisma.prisma"
+  },
+
+  // ESLint
+  "eslint.validate": [
+    "javascript",
+    "javascriptreact",
+    "typescript",
+    "typescriptreact"
+  ],
+
+  // Search exclusions (performance)
+  "search.exclude": {
+    "**/node_modules": true,
+    "**/dist": true,
+    "**/build": true,
+    "**/.git": true,
+    "**/coverage": true
+  },
+
+  // File associations
+  "files.associations": {
+    "*.css": "css",
+    ".env*": "dotenv"
+  }
+}
+
+

Launch Configuration

+

Create .vscode/launch.json for debugging:

+
{
+  "version": "0.2.0",
+  "configurations": [
+    {
+      "name": "Debug API",
+      "type": "node",
+      "request": "launch",
+      "runtimeExecutable": "npm",
+      "runtimeArgs": ["run", "dev"],
+      "cwd": "${workspaceFolder}/api",
+      "console": "integratedTerminal",
+      "skipFiles": ["<node_internals>/**"],
+      "envFile": "${workspaceFolder}/.env"
+    },
+    {
+      "name": "Debug Admin (Chrome)",
+      "type": "chrome",
+      "request": "launch",
+      "url": "http://localhost:3000",
+      "webRoot": "${workspaceFolder}/admin/src",
+      "sourceMapPathOverrides": {
+        "webpack:///./src/*": "${webRoot}/*"
+      }
+    },
+    {
+      "name": "Debug Media API",
+      "type": "node",
+      "request": "launch",
+      "runtimeExecutable": "npm",
+      "runtimeArgs": ["run", "dev:media"],
+      "cwd": "${workspaceFolder}/api",
+      "console": "integratedTerminal",
+      "skipFiles": ["<node_internals>/**"],
+      "envFile": "${workspaceFolder}/.env"
+    }
+  ]
+}
+
+

Workspace File

+

Create changemaker-lite.code-workspace:

+
{
+  "folders": [
+    {
+      "name": "Root",
+      "path": "."
+    },
+    {
+      "name": "API",
+      "path": "api"
+    },
+    {
+      "name": "Admin",
+      "path": "admin"
+    }
+  ],
+  "settings": {
+    // Workspace-level settings (inherits from .vscode/settings.json)
+  }
+}
+
+

Open workspace: code changemaker-lite.code-workspace

+

Other IDEs

+

WebStorm / IntelliJ IDEA

+
    +
  • Built-in TypeScript support
  • +
  • Built-in Prisma plugin
  • +
  • Configure ESLint/Prettier in Preferences → Languages & Frameworks
  • +
+

Neovim / Vim

+
    +
  • Use LSP with typescript-language-server
  • +
  • Prisma LSP: @prisma/language-server
  • +
  • ESLint/Prettier via null-ls or ALE
  • +
+

Troubleshooting

+

Port Conflicts

+

Problem: Port already in use errors

+
Error: listen EADDRINUSE: address already in use :::4000
+
+

Solution 1: Find and kill the process using the port

+
# Linux/macOS
+lsof -ti:4000 | xargs kill -9
+
+# Or change port in .env
+API_PORT=4002
+
+

Solution 2: Use different ports in .env

+
API_PORT=4002
+ADMIN_PORT=3002
+MEDIA_API_PORT=4102
+
+

Database Connection Errors

+

Problem: API cannot connect to PostgreSQL

+
Error: connect ECONNREFUSED 127.0.0.1:5433
+
+

Solution 1: Verify PostgreSQL is running

+
docker compose ps v2-postgres
+# Should show "running"
+
+

Solution 2: Check DATABASE_URL in .env

+
# Should match your password and port
+DATABASE_URL=postgresql://changemaker_v2:your_password@localhost:5433/changemaker_v2_db
+
+

Solution 3: Restart PostgreSQL container

+
docker compose restart v2-postgres
+docker compose logs -f v2-postgres
+# Wait for "ready to accept connections"
+
+

Redis Connection Errors

+

Problem: API cannot connect to Redis

+
Error: Redis connection refused
+
+

Solution 1: Verify Redis is running

+
docker compose ps redis
+# Should show "running"
+
+

Solution 2: Check REDIS_URL and password

+
# Should match your password
+REDIS_URL=redis://:your_redis_password@localhost:6379
+REDIS_PASSWORD=your_redis_password
+
+

Solution 3: Test Redis connection directly

+
docker compose exec redis redis-cli -a your_redis_password ping
+# Should output: PONG
+
+

Migration Errors

+

Problem: Prisma migration fails

+
Error: P3005 Database schema is not empty
+
+

Solution 1: Reset database (DEVELOPMENT ONLY)

+
cd api
+npx prisma migrate reset
+# WARNING: This deletes all data!
+
+

Solution 2: Force deploy migrations

+
cd api
+npx prisma migrate deploy --force
+
+

Solution 3: Check migration history

+
cd api
+npx prisma migrate status
+
+

npm Install Failures

+

Problem: npm install fails with permission errors

+

Solution 1: Clear npm cache

+
npm cache clean --force
+rm -rf node_modules package-lock.json
+npm install
+
+

Solution 2: Use correct Node.js version

+
node --version  # Should be v20.x.x
+nvm use 20
+
+

Solution 3: Check disk space

+
df -h
+# Ensure sufficient space (10GB+ free)
+
+

Hot Reload Not Working

+

Problem: Code changes don't trigger reload

+

Solution 1 (Docker): Verify volume mounts in docker-compose.yml

+
api:
+  volumes:
+    - ./api:/app  # Must be present
+
+

Solution 2 (Local): Restart dev server

+
# Stop (Ctrl+C) and restart
+npm run dev
+
+

Solution 3 (Admin/Vite): Clear Vite cache

+
cd admin
+rm -rf node_modules/.vite
+npm run dev
+
+

Admin Build Errors

+

Problem: TypeScript errors on build

+
error TS2339: Property 'foo' does not exist on type 'Bar'
+
+

Solution 1: Type-check without emit

+
cd admin
+npx tsc --noEmit
+# Shows all type errors
+
+

Solution 2: Update type definitions

+
cd admin
+npm install --save-dev @types/react@latest @types/react-dom@latest
+
+

Solution 3: Check tsconfig.json

+
cd admin
+cat tsconfig.json
+# Ensure "strict": true and "skipLibCheck": false
+
+

Docker Container Crashes

+

Problem: API/Admin container exits immediately

+

Solution 1: Check logs

+
docker compose logs api
+# Look for error messages
+
+

Solution 2: Verify .env file exists

+
ls -la .env
+# Should exist in project root
+
+

Solution 3: Rebuild containers

+
docker compose down
+docker compose build --no-cache api admin
+docker compose up -d api admin
+
+

Browser CORS Errors

+

Problem: Admin cannot call API (CORS errors in browser console)

+

Solution 1: Check CORS_ORIGIN in .env

+
# For local development
+CORS_ORIGIN=http://localhost:3000
+
+

Solution 2: Verify API_URL in admin

+

For Docker-based API, admin vite.config.ts proxy should work automatically.

+

For local API, ensure VITE_API_URL is NOT set (defaults to localhost:4000).

+

Solution 3: Clear browser cache

+
    +
  • Open DevTools → Network tab → Disable cache
  • +
  • Hard reload (Cmd+Shift+R / Ctrl+Shift+R)
  • +
+

Hot Reload

+

API Hot Reload (tsx watch)

+

API uses tsx watch for automatic restart on file changes:

+
# Started automatically with npm run dev
+cd api
+npm run dev
+
+

What triggers reload: +- Changes to .ts files in src/ +- Changes to .prisma files (after running migrate)

+

What does NOT trigger reload: +- Changes to .env (restart manually) +- Changes to node_modules/ (reinstall packages)

+

Manual restart: +

# If using npm run dev, just Ctrl+C and restart
+npm run dev
+

+

Admin Hot Reload (Vite HMR)

+

Admin uses Vite's Hot Module Replacement (HMR):

+
cd admin
+npm run dev
+
+

What triggers HMR: +- Changes to .tsx / .ts files +- Changes to .css files +- Changes to imported assets

+

HMR Behavior: +- Component changes: Updates without full reload +- Hook changes: May require full reload +- Route changes: Full reload

+

Force full reload: +- Press r in terminal running Vite +- Or refresh browser (Cmd+R / Ctrl+R)

+

Docker Hot Reload

+

Docker volume mounts enable hot reload in containers:

+
# docker-compose.yml
+api:
+  volumes:
+    - ./api:/app      # Syncs code changes
+    - /app/node_modules  # Preserves container's node_modules
+
+

Same reload behavior as local: +- API: tsx watch restarts on .ts changes +- Admin: Vite HMR updates browser

+

Performance note: +- macOS/Windows: Volume mounts slightly slower than Linux +- For intensive development, consider running locally instead

+

Debugging

+

API Debugging (VSCode)

+
    +
  1. Open VSCode
  2. +
  3. Open Run and Debug panel (Cmd+Shift+D / Ctrl+Shift+D)
  4. +
  5. Select "Debug API" configuration
  6. +
  7. Press F5 to start debugging
  8. +
  9. Set breakpoints by clicking line numbers
  10. +
  11. Trigger API endpoint to hit breakpoint
  12. +
+

Debugging features: +- Step through code (F10, F11) +- Inspect variables +- Evaluate expressions in Debug Console +- Call stack navigation

+

Frontend Debugging (Chrome DevTools)

+
    +
  1. Open Admin in Chrome: http://localhost:3000
  2. +
  3. Open DevTools (F12 / Cmd+Option+I)
  4. +
  5. Go to Sources tab
  6. +
  7. Find your component in file tree (webpack://./src/)
  8. +
  9. Set breakpoints by clicking line numbers
  10. +
  11. Interact with UI to trigger breakpoint
  12. +
+

React DevTools: +- Install React DevTools browser extension +- Inspect component tree +- View/edit props and state +- Profile component renders

+

Zustand DevTools

+

Enable Redux DevTools for Zustand stores:

+
// Already configured in auth.store.ts and canvass.store.ts
+import { devtools } from 'zustand/middleware';
+
+export const useAuthStore = create<AuthState>()(
+  devtools(
+    (set, get) => ({
+      // ... store implementation
+    }),
+    { name: 'AuthStore' }
+  )
+);
+
+

Usage: +1. Install Redux DevTools browser extension +2. Open extension +3. Select "AuthStore" or "CanvassStore" +4. See action history and state changes

+

Common Workflows

+

Starting Fresh Development Day

+
# 1. Pull latest changes
+git pull origin v2
+
+# 2. Check for dependency updates
+cd api && npm install && cd ..
+cd admin && npm install && cd ..
+
+# 3. Apply any new migrations
+cd api && npx prisma migrate deploy && cd ..
+
+# 4. Start services
+docker compose up -d v2-postgres redis mailhog
+# Either:
+docker compose up -d api admin  # Docker approach
+# Or:
+cd api && npm run dev  # Terminal 1 (local approach)
+cd admin && npm run dev  # Terminal 2 (local approach)
+
+# 5. Open browser
+open http://localhost:3000
+
+

Feature Development Workflow

+
# 1. Create feature branch
+git checkout -b feature/my-new-feature
+
+# 2. Start development servers (see above)
+
+# 3. Make changes
+# - Edit code
+# - Test in browser
+# - Check API responses
+
+# 4. Type-check
+cd api && npx tsc --noEmit && cd ..
+cd admin && npx tsc --noEmit && cd ..
+
+# 5. Run tests (when available)
+# cd api && npm test && cd ..
+# cd admin && npm test && cd ..
+
+# 6. Commit changes
+git add .
+git commit -m "feat: add new feature"
+
+# 7. Push and create PR
+git push origin feature/my-new-feature
+# Open PR on GitHub/GitLab
+
+

Database Schema Changes

+
# 1. Edit Prisma schema
+cd api
+vi prisma/schema.prisma  # Add/modify models
+
+# 2. Create migration
+npx prisma migrate dev --name add_new_field
+
+# 3. Migration auto-applies to dev database
+# Check generated SQL in prisma/migrations/
+
+# 4. Update seed if needed
+vi prisma/seed.ts
+
+# 5. Test migration on clean database
+npx prisma migrate reset  # WARNING: Deletes data
+# Re-run migrations + seed
+
+# 6. Commit migration files
+git add prisma/migrations/ prisma/schema.prisma
+git commit -m "feat(db): add new field to User model"
+
+

Bug Fixing Workflow

+
# 1. Reproduce bug locally
+# - Follow steps from bug report
+# - Check browser console
+# - Check API logs (docker compose logs -f api)
+
+# 2. Add logging to isolate issue
+# api/src/modules/foo/foo.service.ts
+logger.error('Bug context', { data });
+
+# 3. Set breakpoints (VSCode debug)
+# - Run "Debug API" configuration
+# - Trigger bug
+# - Step through code
+
+# 4. Fix bug
+# - Make code changes
+# - Hot reload picks up changes
+# - Test fix
+
+# 5. Verify fix
+# - Re-test original bug steps
+# - Check related functionality
+# - Type-check: npx tsc --noEmit
+
+# 6. Commit fix
+git add .
+git commit -m "fix: resolve issue with user login"
+
+

Switching Between Docker and Local

+

From Docker to Local:

+
# 1. Stop Docker services
+docker compose stop api admin
+
+# 2. Keep databases running
+docker compose ps v2-postgres redis mailhog
+# Should show running
+
+# 3. Start local dev servers
+cd api && npm run dev  # Terminal 1
+cd admin && npm run dev  # Terminal 2
+
+

From Local to Docker:

+
# 1. Stop local dev servers
+# Press Ctrl+C in both terminals
+
+# 2. Start Docker services
+docker compose up -d api admin
+
+# 3. Watch logs
+docker compose logs -f api admin
+
+

Next Steps

+

After completing local setup:

+
    +
  1. Read Development Guides:
  2. +
  3. NPM Commands Reference - All package.json scripts
  4. +
  5. Docker Workflow - Advanced Docker development
  6. +
  7. +

    Database Migrations - Schema change workflow

    +
  8. +
  9. +

    Understand Architecture:

    +
  10. +
  11. API Architecture - Backend organization
  12. +
  13. Frontend Architecture - React app structure
  14. +
  15. +

    Database Schema - Data models

    +
  16. +
  17. +

    Learn Code Patterns:

    +
  18. +
  19. TypeScript Guide - TypeScript best practices
  20. +
  21. Code Style Guide - Coding standards
  22. +
  23. +

    Testing Guide - Test writing

    +
  24. +
  25. +

    Start Contributing:

    +
  26. +
  27. Git Workflow - Branching and commits
  28. +
  29. Contributing Guide - Contribution process
  30. +
  31. V2 Development Plan - Roadmap and phases
  32. +
+ + +

Getting Help

+

Documentation: +- This guide for setup issues +- Troubleshooting for common problems +- FAQ for quick answers

+

Community: +- GitHub Issues for bug reports +- GitHub Discussions for questions +- Project README for contact info

+

Logs: +- API logs: docker compose logs -f api +- Admin logs: docker compose logs -f admin +- Database logs: docker compose logs -f v2-postgres

+

Summary

+

You now have: +- ✅ Prerequisites installed (Node.js, Docker, Git) +- ✅ Repository cloned and on v2 branch +- ✅ Environment configured (.env file) +- ✅ Database initialized (migrations + seed) +- ✅ Services running (API + Admin + databases) +- ✅ IDE configured (VSCode with extensions) +- ✅ Admin GUI accessible (http://localhost:3000)

+

Test your setup: +1. Login to Admin GUI (admin@example.com / Admin123!) +2. Navigate to Users page (/app/users) +3. See yourself in the users table +4. Check MailHog (http://localhost:8025) for welcome email

+

Ready to develop! Choose a task from V2_PLAN.md Phase 15 or create a feature branch.

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/development/migrations/index.html b/mkdocs/site/v2/development/migrations/index.html new file mode 100644 index 00000000..beea5b20 --- /dev/null +++ b/mkdocs/site/v2/development/migrations/index.html @@ -0,0 +1,7190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Migrations - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Database Migrations Guide

+

Complete guide to managing database schema changes in Changemaker Lite V2 using Prisma Migrate and Drizzle Kit.

+

Overview

+

Changemaker Lite V2 uses two ORMs for different parts of the application:

+
    +
  • Prisma (Main API) - Full-featured ORM with migration tracking
  • +
  • Drizzle (Media API) - Lightweight ORM with schema push (no migrations)
  • +
+

This guide covers both workflows.

+

Prisma Migrations (Main API)

+

Migration Workflow Overview

+
1. Edit schema.prisma
+        ↓
+2. Create migration (npx prisma migrate dev)
+        ↓
+3. Review generated SQL
+        ↓
+4. Test migration locally
+        ↓
+5. Commit migration files
+        ↓
+6. Deploy to production (npx prisma migrate deploy)
+
+

Understanding Prisma Migrate

+

Prisma Migrate: +- Tracks schema changes as SQL migration files +- Stores migration history in _prisma_migrations table +- Ensures schema consistency across environments +- Supports rollback via version control

+

Migration Files: +- Located in api/prisma/migrations/ +- Named with timestamp: 20260213123456_description/ +- Contains migration.sql (SQL commands)

+

Migration States: +- Pending: Not yet applied +- Applied: Successfully executed +- Failed: Execution error (requires manual fix)

+

Creating Migrations

+

Step 1: Edit Prisma Schema

+

Edit api/prisma/schema.prisma:

+
// Before
+model User {
+  id        Int      @id @default(autoincrement())
+  email     String   @unique
+  password  String
+  role      Role     @default(USER)
+  createdAt DateTime @default(now())
+}
+
+// After (add name field)
+model User {
+  id        Int      @id @default(autoincrement())
+  email     String   @unique
+  password  String
+  name      String?  // New field (nullable)
+  role      Role     @default(USER)
+  createdAt DateTime @default(now())
+}
+
+

Step 2: Validate Schema

+
cd api
+npx prisma validate
+
+

Expected output: +

Environment variables loaded from .env
+Prisma schema loaded from prisma/schema.prisma
+The schema is valid ✔
+

+

If errors: +

Error validating model "User": Field "foo" references unknown model "Bar"
+

+

Fix errors before proceeding.

+

Step 3: Create Migration

+
cd api
+npx prisma migrate dev --name add_user_name
+
+

What happens: +1. Prisma detects schema changes +2. Generates SQL migration file +3. Prompts for migration name (or uses --name argument) +4. Applies migration to development database +5. Regenerates Prisma Client

+

Expected output: +

Environment variables loaded from .env
+Prisma schema loaded from prisma/schema.prisma
+Datasource "db": PostgreSQL database "changemaker_v2_db"
+
+Applying migration `20260213123456_add_user_name`
+Running seed command `tsx prisma/seed.ts` ...
+
+✔ Generated Prisma Client to ./node_modules/@prisma/client
+

+

Migration file created: +

api/prisma/migrations/
+└── 20260213123456_add_user_name/
+    └── migration.sql
+

+

Step 4: Review Generated SQL

+
cd api
+cat prisma/migrations/20260213123456_add_user_name/migration.sql
+
+

Example SQL: +

-- AlterTable
+ALTER TABLE "users" ADD COLUMN "name" TEXT;
+

+

Verify SQL is correct: +- Check table names match expectations +- Ensure data types are correct +- Look for unexpected DROP commands

+

Step 5: Test Migration

+

Migration already applied to development DB. Verify:

+
# Check schema with Prisma Studio
+cd api
+npx prisma studio
+
+

Or query directly:

+
# PostgreSQL shell
+docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db
+
+# Describe users table
+changemaker_v2_db=# \d users;
+
+

Expected output: +

Column    |  Type   | Nullable | Default
+----------+---------+----------+---------
+id        | integer | not null | nextval(...)
+email     | text    | not null |
+password  | text    | not null |
+name      | text    |          |  <-- New field
+role      | text    | not null | 'USER'
+created_at| timestamp| not null | now()
+

+

Step 6: Commit Migration

+
git add prisma/migrations/20260213123456_add_user_name/
+git add prisma/schema.prisma
+git commit -m "feat(db): add name field to User model"
+
+

Always commit: +- Migration directory (prisma/migrations/*/) +- Updated schema.prisma

+

Applying Migrations (Production)

+

In Production Environment

+
cd api
+npx prisma migrate deploy
+
+

What it does: +- Checks _prisma_migrations table for applied migrations +- Applies only pending migrations +- Does NOT create new migrations +- Safe for production

+

Expected output: +

Environment variables loaded from .env
+Prisma schema loaded from prisma/schema.prisma
+Datasource "db": PostgreSQL database "changemaker_v2_prod_db"
+
+2 migrations found in prisma/migrations
+
+Applying migration `20260213123456_add_user_name`
+Applying migration `20260214000000_add_user_avatar`
+
+All migrations have been successfully applied.
+

+

In Docker

+
# Apply migrations in Docker container
+docker compose exec api npx prisma migrate deploy
+
+# Or during container startup (Dockerfile)
+CMD npx prisma migrate deploy && npm start
+
+

CI/CD Deployment

+
# GitHub Actions example
+- name: Run migrations
+  run: |
+    cd api
+    npx prisma migrate deploy
+
+

Migration Best Practices

+

1. Incremental Changes

+

Make small, focused migrations:

+

Good: +

# Separate migrations
+npx prisma migrate dev --name add_user_name
+npx prisma migrate dev --name add_user_avatar
+npx prisma migrate dev --name add_user_bio
+

+

Bad: +

# One huge migration
+npx prisma migrate dev --name update_user_model
+# (adds 10 fields, 3 relations, 5 indexes)
+

+

2. Descriptive Names

+

Use clear migration names:

+

Good: +

npx prisma migrate dev --name add_user_name
+npx prisma migrate dev --name make_email_unique
+npx prisma migrate dev --name create_posts_table
+npx prisma migrate dev --name add_user_posts_relation
+

+

Bad: +

npx prisma migrate dev --name update
+npx prisma migrate dev --name fix
+npx prisma migrate dev --name changes
+

+

3. Review SQL Before Committing

+

Always review generated SQL:

+
cat prisma/migrations/*/migration.sql
+
+

Watch for: +- Unexpected DROP TABLE or DROP COLUMN +- Missing NOT NULL constraints +- Incorrect data types +- Missing indexes on foreign keys

+

4. Backup Before Migration (Production)

+
# Backup database before deploy
+docker compose exec v2-postgres pg_dump -U changemaker_v2 changemaker_v2_db > backup-$(date +%Y%m%d).sql
+
+# Apply migration
+npx prisma migrate deploy
+
+# If migration fails, restore:
+cat backup-20260213.sql | docker compose exec -T v2-postgres psql -U changemaker_v2 changemaker_v2_db
+
+

5. Test on Staging First

+

Never deploy migrations directly to production:

+
1. Create migration in development
+2. Test locally
+3. Commit to version control
+4. Deploy to staging environment
+5. Test on staging
+6. Deploy to production
+
+

Common Migration Scenarios

+

Add New Field

+
// schema.prisma
+model User {
+  id    Int    @id @default(autoincrement())
+  email String @unique
+  name  String? // New nullable field
+}
+
+
npx prisma migrate dev --name add_user_name
+
+

Generated SQL: +

ALTER TABLE "users" ADD COLUMN "name" TEXT;
+

+

Add Required Field (with Default)

+
model User {
+  id        Int      @id @default(autoincrement())
+  email     String   @unique
+  createdAt DateTime @default(now()) // New required field with default
+}
+
+
npx prisma migrate dev --name add_created_at
+
+

Generated SQL: +

ALTER TABLE "users" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
+

+

Add New Table

+
model Post {
+  id        Int      @id @default(autoincrement())
+  title     String
+  content   String?
+  published Boolean  @default(false)
+  authorId  Int
+  author    User     @relation(fields: [authorId], references: [id])
+  createdAt DateTime @default(now())
+}
+
+model User {
+  id    Int    @id @default(autoincrement())
+  email String @unique
+  posts Post[]
+}
+
+
npx prisma migrate dev --name create_posts_table
+
+

Generated SQL: +

CREATE TABLE "posts" (
+    "id" SERIAL NOT NULL,
+    "title" TEXT NOT NULL,
+    "content" TEXT,
+    "published" BOOLEAN NOT NULL DEFAULT false,
+    "author_id" INTEGER NOT NULL,
+    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "posts_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "posts_author_id_idx" ON "posts"("author_id");
+
+ALTER TABLE "posts" ADD CONSTRAINT "posts_author_id_fkey"
+    FOREIGN KEY ("author_id") REFERENCES "users"("id")
+    ON DELETE RESTRICT ON UPDATE CASCADE;
+

+

Add Relation

+
model Campaign {
+  id           Int    @id @default(autoincrement())
+  title        String
+  createdByUserId Int // New foreign key
+  createdBy    User   @relation(fields: [createdByUserId], references: [id])
+}
+
+model User {
+  id        Int        @id @default(autoincrement())
+  email     String     @unique
+  campaigns Campaign[]
+}
+
+
npx prisma migrate dev --name add_campaign_user_relation
+
+

Generated SQL: +

ALTER TABLE "campaigns" ADD COLUMN "created_by_user_id" INTEGER NOT NULL;
+
+CREATE INDEX "campaigns_created_by_user_id_idx" ON "campaigns"("created_by_user_id");
+
+ALTER TABLE "campaigns" ADD CONSTRAINT "campaigns_created_by_user_id_fkey"
+    FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id")
+    ON DELETE RESTRICT ON UPDATE CASCADE;
+

+

Change Field Type

+
// Before
+model User {
+  age Int
+}
+
+// After
+model User {
+  age String // Changed from Int to String
+}
+
+
npx prisma migrate dev --name change_user_age_to_string
+
+

Generated SQL: +

ALTER TABLE "users" ALTER COLUMN "age" SET DATA TYPE TEXT;
+

+

Warning: This may fail if data cannot be cast. Consider data migration first.

+

Add Unique Constraint

+
model User {
+  email String @unique // Add unique constraint
+}
+
+
npx prisma migrate dev --name make_email_unique
+
+

Generated SQL: +

CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
+

+

Add Index

+
model User {
+  email String
+
+  @@index([email]) // Add index
+}
+
+
npx prisma migrate dev --name add_email_index
+
+

Generated SQL: +

CREATE INDEX "users_email_idx" ON "users"("email");
+

+

Migration History and Status

+

Check Migration Status

+
cd api
+npx prisma migrate status
+
+

Expected output: +

Environment variables loaded from .env
+Prisma schema loaded from prisma/schema.prisma
+Datasource "db": PostgreSQL database "changemaker_v2_db"
+
+Database schema is up to date!
+
+Following migrations have been applied:
+
+20260101000000_init
+20260105000000_add_campaigns
+20260110000000_add_locations
+20260213123456_add_user_name
+

+

View Migration History

+
# List migration files
+ls -la api/prisma/migrations/
+
+# View specific migration
+cat api/prisma/migrations/20260213123456_add_user_name/migration.sql
+
+

Check Database Migration Table

+
docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c "SELECT * FROM _prisma_migrations;"
+
+

Output: +

id | checksum | finished_at | migration_name | logs
+---+----------+-------------+----------------+-----
+1  | abc123   | 2026-01-01  | 20260101000000_init | NULL
+2  | def456   | 2026-01-05  | 20260105000000_add_campaigns | NULL
+

+

Rollback Strategies

+

Prisma Migrate does NOT have automatic rollback. Use these strategies:

+

1. Version Control Rollback

+
# Revert schema changes
+git revert <commit-hash>
+
+# Create new migration to undo changes
+npx prisma migrate dev --name revert_user_name
+
+# This creates a new migration that undoes the previous one
+
+

2. Manual Rollback Migration

+

Create a new migration to reverse changes:

+
// If you added a field, remove it
+model User {
+  id    Int    @id @default(autoincrement())
+  email String @unique
+  // name  String? // Remove this
+}
+
+
npx prisma migrate dev --name remove_user_name
+
+

Generated SQL: +

ALTER TABLE "users" DROP COLUMN "name";
+

+

3. Database Restore (Last Resort)

+
# Restore from backup
+cat backup-20260213.sql | docker compose exec -T v2-postgres psql -U changemaker_v2 changemaker_v2_db
+
+# Mark migrations as rolled back
+docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c "
+  DELETE FROM _prisma_migrations
+  WHERE migration_name = '20260213123456_add_user_name';
+"
+
+

4. Reset Development Database

+

WARNING: Deletes all data!

+
cd api
+npx prisma migrate reset
+
+

This: +1. Drops all tables +2. Re-applies all migrations from scratch +3. Runs seed script

+

Handling Migration Conflicts

+

Schema Drift

+

Problem: Database schema doesn't match Prisma schema.

+

Symptoms: +

Error: Database schema is not in sync with the migration history
+

+

Solution:

+
# Check what's different
+npx prisma migrate diff \
+  --from-schema-datamodel prisma/schema.prisma \
+  --to-schema-datasource prisma/schema.prisma
+
+# Create migration to fix drift
+npx prisma migrate dev --name fix_schema_drift
+
+

Failed Migration

+

Problem: Migration fails during apply.

+

Symptoms: +

Error: Migration failed with error:
+  ALTER TABLE "users" ADD COLUMN "age" INTEGER NOT NULL;
+  ERROR: column "age" contains null values
+

+

Solution:

+
# 1. Mark migration as rolled back
+npx prisma migrate resolve --rolled-back 20260213123456_add_user_age
+
+# 2. Fix migration SQL manually
+vi prisma/migrations/20260213123456_add_user_age/migration.sql
+
+# Change to:
+ALTER TABLE "users" ADD COLUMN "age" INTEGER; -- Make nullable first
+UPDATE "users" SET "age" = 0 WHERE "age" IS NULL; -- Set default
+ALTER TABLE "users" ALTER COLUMN "age" SET NOT NULL; -- Then make required
+
+# 3. Apply migration again
+npx prisma migrate deploy
+
+

Conflicting Migrations (Team Environment)

+

Problem: Two developers create migrations simultaneously.

+

Solution:

+
# 1. Pull latest changes
+git pull origin v2
+
+# 2. Prisma detects conflict
+npx prisma migrate dev
+
+# 3. Resolve by creating merge migration
+# Prisma will prompt you to create a migration that includes both changes
+
+

Data Migrations

+

Prisma Migrate handles schema changes, not data changes. For data transformations:

+

Option 1: Custom SQL in Migration

+

Edit generated migration file:

+
-- Add column (Prisma-generated)
+ALTER TABLE "users" ADD COLUMN "full_name" TEXT;
+
+-- Populate from existing data (manual addition)
+UPDATE "users" SET "full_name" = "first_name" || ' ' || "last_name";
+
+-- Remove old columns (Prisma-generated)
+ALTER TABLE "users" DROP COLUMN "first_name";
+ALTER TABLE "users" DROP COLUMN "last_name";
+
+

Option 2: Separate Data Migration Script

+
// api/prisma/data-migrations/20260213-populate-full-name.ts
+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+async function main() {
+  const users = await prisma.user.findMany();
+
+  for (const user of users) {
+    await prisma.user.update({
+      where: { id: user.id },
+      data: {
+        fullName: `${user.firstName} ${user.lastName}`
+      }
+    });
+  }
+
+  console.log(`Updated ${users.length} users`);
+}
+
+main()
+  .catch(console.error)
+  .finally(() => prisma.$disconnect());
+
+

Run after migration:

+
npx tsx prisma/data-migrations/20260213-populate-full-name.ts
+
+

Drizzle Push (Media API)

+

Drizzle Overview

+

Drizzle Kit Push: +- Syncs schema directly to database +- No migration files generated +- Fast iteration for prototyping +- Used only for Media API tables

+

Schema Location: +- api/src/modules/media/db/schema.ts

+

When to Use: +- Rapid prototyping +- Development only +- Media API tables (videos, jobs, reactions)

+

When NOT to Use: +- Production deployments +- Main API tables (use Prisma) +- When migration history is needed

+

Drizzle Push Workflow

+

Step 1: Edit Schema

+

Edit api/src/modules/media/db/schema.ts:

+
// Before
+export const videos = pgTable('videos', {
+  id: serial('id').primaryKey(),
+  filename: text('filename').notNull(),
+  title: text('title'),
+  duration: integer('duration'),
+  createdAt: timestamp('created_at').defaultNow().notNull(),
+});
+
+// After (add description field)
+export const videos = pgTable('videos', {
+  id: serial('id').primaryKey(),
+  filename: text('filename').notNull(),
+  title: text('title'),
+  description: text('description'), // New field
+  duration: integer('duration'),
+  createdAt: timestamp('created_at').defaultNow().notNull(),
+});
+
+

Step 2: Push Schema

+
cd api
+npm run drizzle:push
+
+

Or directly:

+
cd api
+npx drizzle-kit push
+
+

What happens: +1. Drizzle compares schema to database +2. Generates SQL for changes +3. Applies changes immediately +4. No migration files created

+

Expected output: +

Reading config from drizzle.config.ts
+Using 'pg' driver for database querying
+
+Pulling schema from database...
+[✓] Schema pulled successfully
+
+Comparing schemas...
+[!] Changes detected:
+  - ALTER TABLE "videos" ADD COLUMN "description" TEXT;
+
+Do you want to execute these changes? [y/N]: y
+
+Applying changes...
+[✓] Schema pushed successfully
+

+

Step 3: Verify Changes

+
# Check with Drizzle Studio
+cd api
+npx drizzle-kit studio
+
+

Or query directly:

+
docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c "\d videos"
+
+

Drizzle Best Practices

+

1. Development Only

+

Use Drizzle Push only in development:

+

Good: +

# Development
+npm run drizzle:push
+

+

Bad: +

# Production (use Prisma migrate for production schema changes)
+npm run drizzle:push
+

+

2. Backup Before Push

+

Always backup before pushing schema:

+
# Backup database
+docker compose exec v2-postgres pg_dump -U changemaker_v2 changemaker_v2_db > backup.sql
+
+# Push schema
+npm run drizzle:push
+
+# If something breaks, restore:
+cat backup.sql | docker compose exec -T v2-postgres psql -U changemaker_v2 changemaker_v2_db
+
+

3. Test Changes Locally

+

Never push untested schema changes:

+
# 1. Edit schema
+vi src/modules/media/db/schema.ts
+
+# 2. Push to dev database
+npm run drizzle:push
+
+# 3. Test with Drizzle Studio
+npm run drizzle:studio
+
+# 4. Test API endpoints
+curl http://localhost:4100/api/media/videos
+
+

Drizzle vs Prisma

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeaturePrisma MigrateDrizzle Push
Migration files✅ Yes❌ No
Migration history✅ Tracked❌ Not tracked
Rollback✅ Via version control❌ Manual only
Production use✅ Recommended⚠️ Not recommended
Prototyping⚠️ Slower✅ Faster
Use caseMain API tablesMedia API tables
+

Seeding After Migration

+

Running Seed Script

+

After migrations, seed database:

+
cd api
+npx prisma db seed
+
+

What it does: +- Runs prisma/seed.ts +- Creates admin user +- Creates default settings +- Creates sample blocks

+

Expected output: +

Running seed command `tsx prisma/seed.ts` ...
+Seeding database...
+Created default settings
+Created admin user: admin@example.com
+Created 10 sample blocks
+Seed completed successfully
+

+

Custom Seed Data

+

Edit api/prisma/seed.ts:

+
import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+async function main() {
+  // Create admin user
+  await prisma.user.upsert({
+    where: { email: 'admin@example.com' },
+    update: {},
+    create: {
+      email: 'admin@example.com',
+      password: await hashPassword('Admin123!'),
+      role: 'SUPER_ADMIN',
+      name: 'Admin User'
+    }
+  });
+
+  // Create sample campaign
+  await prisma.campaign.create({
+    data: {
+      title: 'Sample Campaign',
+      description: 'This is a sample campaign',
+      active: true,
+      createdByUserId: 1
+    }
+  });
+
+  console.log('Seed completed');
+}
+
+main()
+  .catch(console.error)
+  .finally(() => prisma.$disconnect());
+
+

CI/CD Integration

+

GitHub Actions Example

+
name: Deploy
+
+on:
+  push:
+    branches: [main]
+
+jobs:
+  deploy:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+
+      - name: Setup Node.js
+        uses: actions/setup-node@v3
+        with:
+          node-version: '20'
+
+      - name: Install dependencies
+        working-directory: ./api
+        run: npm ci
+
+      - name: Run migrations
+        working-directory: ./api
+        env:
+          DATABASE_URL: ${{ secrets.DATABASE_URL }}
+        run: npx prisma migrate deploy
+
+      - name: Seed database
+        working-directory: ./api
+        env:
+          DATABASE_URL: ${{ secrets.DATABASE_URL }}
+        run: npx prisma db seed
+
+

Docker Deployment

+
# api/Dockerfile
+FROM node:20-alpine
+
+WORKDIR /app
+
+COPY package*.json ./
+RUN npm ci --production
+
+COPY . .
+
+# Generate Prisma Client
+RUN npx prisma generate
+
+# Run migrations on startup
+CMD npx prisma migrate deploy && npm start
+
+

Troubleshooting

+

Migration Fails with "Column Already Exists"

+

Problem: +

Error: column "name" of relation "users" already exists
+

+

Solution:

+
# Mark migration as applied
+npx prisma migrate resolve --applied 20260213123456_add_user_name
+
+# Or drop column manually and re-run
+docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c "ALTER TABLE users DROP COLUMN name;"
+npx prisma migrate deploy
+
+

Migration Fails with "Relation Does Not Exist"

+

Problem: +

Error: relation "posts" does not exist
+

+

Solution:

+
# Check migration history
+npx prisma migrate status
+
+# Apply missing migrations
+npx prisma migrate deploy
+
+# Or reset (development only)
+npx prisma migrate reset
+
+

Schema Out of Sync

+

Problem: +

Error: Database schema is not in sync
+

+

Solution:

+
# Generate migration to fix drift
+npx prisma migrate dev --name fix_drift
+
+# Or in production, create explicit migration
+npx prisma migrate diff \
+  --from-schema-datamodel prisma/schema.prisma \
+  --to-schema-datasource prisma/schema.prisma \
+  --script > fix-drift.sql
+
+# Review fix-drift.sql and apply manually
+
+

Drizzle Push Fails

+

Problem: +

Error: Could not push schema
+

+

Solution:

+
# Check Drizzle config
+cat api/drizzle.config.ts
+
+# Verify DATABASE_URL
+echo $DATABASE_URL
+
+# Test database connection
+docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c "SELECT 1"
+
+# Clear Drizzle cache and retry
+rm -rf api/.drizzle
+npm run drizzle:push
+
+ + +

Summary

+

You now know: +- ✅ How Prisma Migrate tracks schema changes +- ✅ How to create and apply migrations +- ✅ Common migration scenarios (add field, table, relation) +- ✅ Migration best practices +- ✅ How to handle migration conflicts +- ✅ How to perform data migrations +- ✅ How Drizzle Push works for Media API +- ✅ When to use Prisma vs Drizzle +- ✅ How to seed database after migrations +- ✅ How to integrate migrations in CI/CD

+

Quick Reference: +

# Prisma: Create migration
+npx prisma migrate dev --name description
+
+# Prisma: Apply migrations (production)
+npx prisma migrate deploy
+
+# Prisma: Check status
+npx prisma migrate status
+
+# Drizzle: Push schema (dev only)
+npx drizzle-kit push
+
+# Seed database
+npx prisma db seed
+
+# Reset (dev only, DELETES DATA)
+npx prisma migrate reset
+

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/development/npm-commands/index.html b/mkdocs/site/v2/development/npm-commands/index.html new file mode 100644 index 00000000..51d7036f --- /dev/null +++ b/mkdocs/site/v2/development/npm-commands/index.html @@ -0,0 +1,7828 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NPM Commands - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

NPM Commands Reference

+

Complete reference for all npm scripts in Changemaker Lite V2.

+

Overview

+

Changemaker Lite V2 uses npm scripts for development, building, testing, and database management. Scripts are defined in package.json files in two main directories:

+
    +
  • api/package.json - Backend API scripts (Express + Fastify)
  • +
  • admin/package.json - Frontend GUI scripts (React + Vite)
  • +
+

This guide documents all available scripts, their usage, and common combinations.

+

API Scripts

+

Location: api/package.json

+

Development Scripts

+

npm run dev

+

Starts the Express API server in development mode with hot reload.

+
cd api
+npm run dev
+
+

What it does: +- Runs tsx watch src/server.ts +- Auto-restarts on file changes (.ts files) +- Loads environment from .env +- Runs on port API_PORT (default: 4000)

+

Output: +

Server running on port 4000
+Database connected
+Redis connected
+BullMQ worker started
+

+

Use when: +- Developing API endpoints +- Testing backend changes +- Debugging server code

+

npm run dev:media

+

Starts the Fastify Media API server in development mode.

+
cd api
+npm run dev:media
+
+

What it does: +- Runs tsx watch src/media-server.ts +- Auto-restarts on file changes +- Runs on port MEDIA_API_PORT (default: 4100)

+

Output: +

Media API server running on port 4100
+Database connected
+

+

Use when: +- Developing media features (video upload, reactions) +- Testing Media API endpoints +- Working on FFprobe integration

+

Build Scripts

+

npm run build

+

Compiles TypeScript to JavaScript for production.

+
cd api
+npm run build
+
+

What it does: +- Runs tsc --build +- Outputs to dist/ directory +- Type-checks all code +- Fails on type errors

+

Output: +

dist/
+├── server.js
+├── media-server.js
+└── modules/
+    ├── auth/
+    ├── users/
+    └── ...
+

+

Use when: +- Preparing for production deployment +- Verifying build succeeds +- Creating Docker images

+

npm run clean

+

Removes compiled JavaScript and build artifacts.

+
cd api
+npm run clean
+
+

What it does: +- Deletes dist/ directory +- Removes *.tsbuildinfo files

+

Use when: +- Starting fresh build +- Fixing build cache issues +- Cleaning up after development

+

Production Scripts

+

npm start

+

Runs the compiled API server (production mode).

+
cd api
+npm start
+
+

What it does: +- Runs node dist/server.js +- Requires npm run build first +- Uses production environment (NODE_ENV=production)

+

Output: +

Server running on port 4000
+Database connected
+Redis connected
+

+

Use when: +- Running in production (Docker) +- Testing production build locally

+

npm run start:media

+

Runs the compiled Media API server (production mode).

+
cd api
+npm run start:media
+
+

What it does: +- Runs node dist/media-server.js +- Requires npm run build first

+

Use when: +- Running Media API in production +- Testing production Media API

+

Code Quality Scripts

+

npm run type-check

+

Type-checks TypeScript without emitting files.

+
cd api
+npm run type-check
+
+

What it does: +- Runs tsc --noEmit +- Reports type errors +- Does NOT generate files

+

Output: +

# Success (no output)
+
+# Errors
+src/modules/auth/auth.service.ts:45:12 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
+

+

Use when: +- Before committing code +- In CI/CD pipeline +- Debugging type errors

+

npm run lint

+

Runs ESLint to check code style.

+
cd api
+npm run lint
+
+

What it does: +- Runs eslint src/ --ext .ts +- Reports style violations +- Checks for common errors

+

Output: +

# Success
+✔ 150 files linted, 0 errors, 0 warnings
+
+# Errors
+src/modules/auth/auth.service.ts
+  45:12  error  'foo' is assigned a value but never used  @typescript-eslint/no-unused-vars
+

+

Use when: +- Before committing code +- Enforcing code style +- Finding potential bugs

+

npm run lint:fix

+

Automatically fixes ESLint errors where possible.

+
cd api
+npm run lint:fix
+
+

What it does: +- Runs eslint src/ --ext .ts --fix +- Auto-fixes style issues (formatting, imports, etc.) +- Reports unfixable errors

+

Use when: +- After writing new code +- Cleaning up formatting +- Before commit

+

npm run format

+

Formats code with Prettier.

+
cd api
+npm run format
+
+

What it does: +- Runs prettier --write "src/**/*.{ts,js,json}" +- Formats all TypeScript, JavaScript, and JSON files +- Overwrites files in place

+

Use when: +- Standardizing code format +- After merge conflicts +- Team-wide formatting

+

npm run format:check

+

Checks if code is formatted correctly (CI).

+
cd api
+npm run format:check
+
+

What it does: +- Runs prettier --check "src/**/*.{ts,js,json}" +- Reports unformatted files +- Does NOT modify files

+

Use when: +- In CI/CD pipeline +- Verifying format before commit

+

Database Scripts

+

npm run prisma:migrate

+

Creates and applies a new Prisma migration.

+
cd api
+npm run prisma:migrate
+# Or with name:
+npx prisma migrate dev --name add_user_field
+
+

What it does: +- Prompts for migration name +- Generates SQL migration in prisma/migrations/ +- Applies migration to development database +- Regenerates Prisma Client

+

Output: +

✔ Enter a name for the new migration: … add_user_field
+Applying migration `20260213000000_add_user_field`
+✔ Generated Prisma Client to ./node_modules/@prisma/client
+

+

Use when: +- Changing database schema +- Adding new models +- Modifying fields

+

npm run prisma:deploy

+

Applies pending migrations (production).

+
cd api
+npm run prisma:deploy
+
+

What it does: +- Runs prisma migrate deploy +- Applies unapplied migrations only +- Does NOT create new migrations +- Safe for production

+

Output: +

Environment variables loaded from .env
+Datasource "db": PostgreSQL database "changemaker_v2_db"
+
+2 migrations found in prisma/migrations
+
+Applying migration `20260213000000_add_user_field`
+All migrations have been successfully applied.
+

+

Use when: +- Deploying to production +- Applying migrations in Docker +- CI/CD deployment

+

npm run prisma:seed

+

Seeds database with initial data.

+
cd api
+npm run prisma:seed
+
+

What it does: +- Runs tsx prisma/seed.ts +- Creates admin user +- Creates default settings +- Creates sample blocks

+

Output: +

Running seed command `tsx prisma/seed.ts` ...
+Seeding database...
+Created default settings
+Created admin user: admin@example.com
+Created 10 sample blocks
+Seed completed successfully
+

+

Use when: +- First-time setup +- After reset +- Populating test data

+

npm run prisma:studio

+

Opens Prisma Studio (database GUI).

+
cd api
+npm run prisma:studio
+
+

What it does: +- Runs prisma studio +- Opens browser at http://localhost:5555 +- Shows all tables and data +- Allows CRUD operations

+

Use when: +- Inspecting database +- Manual data editing +- Debugging data issues

+

npm run prisma:reset

+

Resets database (DESTRUCTIVE).

+
cd api
+npm run prisma:reset
+
+

What it does: +- Drops all tables +- Re-applies all migrations +- Runs seed script +- DELETES ALL DATA

+

Output: +

⚠️  You are about to drop the database 'changemaker_v2_db'
+   All data will be lost.
+
+Do you want to continue? [y/N]: y
+
+Database reset successful
+Migrations applied
+Seed completed
+

+

Use when: +- Starting fresh in development +- Fixing migration conflicts +- NEVER in production

+

npm run prisma:validate

+

Validates Prisma schema.

+
cd api
+npm run prisma:validate
+
+

What it does: +- Runs prisma validate +- Checks schema syntax +- Verifies relations +- Does NOT touch database

+

Output: +

# Success
+The schema is valid ✔
+
+# Errors
+Error validating model "User": Field "foo" references unknown model "Bar"
+

+

Use when: +- After editing schema +- Before creating migration +- In CI/CD pipeline

+

npm run drizzle:push

+

Pushes Drizzle schema changes to database (Media API).

+
cd api
+npm run drizzle:push
+
+

What it does: +- Runs drizzle-kit push +- Syncs src/modules/media/db/schema.ts to database +- Does NOT create migration files +- Direct schema sync

+

Output: +

Reading config from drizzle.config.ts
+Pushing schema to database...
+✔ Schema pushed successfully
+

+

Use when: +- Changing Media API tables (videos, jobs, reactions) +- Rapid prototyping (no migrations) +- Development only

+

npm run drizzle:studio

+

Opens Drizzle Studio (database GUI for Media API).

+
cd api
+npm run drizzle:studio
+
+

What it does: +- Runs drizzle-kit studio +- Opens browser at http://localhost:4983 +- Shows Media API tables only

+

Use when: +- Inspecting media tables +- Debugging video data +- Manual media data editing

+

Testing Scripts

+

npm test

+

Runs all tests (when configured).

+
cd api
+npm test
+
+

What it does: +- Runs Jest test suite +- Executes *.test.ts files +- Reports pass/fail

+

Note: Tests are part of Phase 15 (in progress).

+

npm run test:watch

+

Runs tests in watch mode.

+
cd api
+npm run test:watch
+
+

What it does: +- Runs jest --watch +- Re-runs tests on file changes

+

npm run test:coverage

+

Runs tests with coverage report.

+
cd api
+npm run test:coverage
+
+

What it does: +- Runs jest --coverage +- Generates coverage report in coverage/

+

Utility Scripts

+

npm run env:validate

+

Validates required environment variables.

+
cd api
+npm run env:validate
+
+

What it does: +- Checks .env has required vars +- Uses Zod validation (from config/env.ts) +- Fails if vars missing/invalid

+

Output: +

# Success
+✔ Environment variables valid
+
+# Errors
+Error: Missing required environment variables:
+  - JWT_ACCESS_SECRET
+  - REDIS_PASSWORD
+

+

Use when: +- After editing .env +- Before deployment +- Debugging config issues

+

Admin Scripts

+

Location: admin/package.json

+

Development Scripts

+

npm run dev

+

Starts Vite development server with HMR.

+
cd admin
+npm run dev
+
+

What it does: +- Runs vite +- Starts dev server on port ADMIN_PORT (default: 3000) +- Enables Hot Module Replacement (HMR) +- Proxies API requests to VITE_API_URL

+

Output: +

  VITE v5.x.x  ready in 500 ms
+
+  ➜  Local:   http://localhost:3000/
+  ➜  Network: use --host to expose
+

+

Use when: +- Developing frontend components +- Testing UI changes +- Working on React code

+

Build Scripts

+

npm run build

+

Builds production-optimized bundle.

+
cd admin
+npm run build
+
+

What it does: +- Runs tsc --noEmit && vite build +- Type-checks TypeScript +- Bundles JavaScript/CSS +- Optimizes assets (minify, tree-shake) +- Outputs to dist/

+

Output: +

vite v5.x.x building for production...
+✓ 1245 modules transformed.
+dist/index.html                   0.45 kB
+dist/assets/index-a1b2c3d4.js   245.67 kB │ gzip: 78.23 kB
+dist/assets/index-e5f6g7h8.css   12.34 kB │ gzip:  3.45 kB
+✓ built in 15.23s
+

+

Use when: +- Preparing for production deployment +- Creating Docker image +- Verifying build size

+

npm run preview

+

Previews production build locally.

+
cd admin
+npm run preview
+
+

What it does: +- Runs vite preview +- Serves dist/ directory +- Runs on port 4173 (Vite default)

+

Output: +

  ➜  Local:   http://localhost:4173/
+  ➜  Network: use --host to expose
+

+

Use when: +- Testing production build +- Verifying optimizations +- Before deployment

+

Code Quality Scripts

+

npm run type-check

+

Type-checks TypeScript without emitting files.

+
cd admin
+npm run type-check
+
+

What it does: +- Runs tsc --noEmit +- Reports type errors +- Checks all .ts and .tsx files

+

Output: +

# Success (no output)
+
+# Errors
+src/pages/UsersPage.tsx:123:45 - error TS2339: Property 'foo' does not exist on type 'User'.
+

+

Use when: +- Before committing code +- In CI/CD pipeline +- Debugging type errors

+

npm run lint

+

Runs ESLint to check code style.

+
cd admin
+npm run lint
+
+

What it does: +- Runs eslint src/ --ext .ts,.tsx +- Reports style violations +- Checks React best practices

+

Output: +

# Success
+✔ 85 files linted, 0 errors, 0 warnings
+
+# Errors
+src/pages/UsersPage.tsx
+  123:45  error  'foo' is assigned a value but never used  @typescript-eslint/no-unused-vars
+  200:10  warning  Missing dependency in useEffect  react-hooks/exhaustive-deps
+

+

Use when: +- Before committing code +- Enforcing code style +- Finding potential bugs

+

npm run lint:fix

+

Automatically fixes ESLint errors where possible.

+
cd admin
+npm run lint:fix
+
+

What it does: +- Runs eslint src/ --ext .ts,.tsx --fix +- Auto-fixes style issues +- Reports unfixable errors

+

Use when: +- After writing new code +- Cleaning up formatting +- Before commit

+

npm run format

+

Formats code with Prettier.

+
cd admin
+npm run format
+
+

What it does: +- Runs prettier --write "src/**/*.{ts,tsx,css,json}" +- Formats all source files +- Overwrites files in place

+

Use when: +- Standardizing code format +- After merge conflicts +- Team-wide formatting

+

npm run format:check

+

Checks if code is formatted correctly (CI).

+
cd admin
+npm run format:check
+
+

What it does: +- Runs prettier --check "src/**/*.{ts,tsx,css,json}" +- Reports unformatted files +- Does NOT modify files

+

Use when: +- In CI/CD pipeline +- Verifying format before commit

+

Testing Scripts

+

npm test

+

Runs all tests (when configured).

+
cd admin
+npm test
+
+

What it does: +- Runs Vitest test suite +- Executes *.test.tsx and *.spec.tsx files +- Reports pass/fail

+

Note: Tests are part of Phase 15 (in progress).

+

npm run test:watch

+

Runs tests in watch mode.

+
cd admin
+npm run test:watch
+
+

What it does: +- Runs vitest +- Re-runs tests on file changes

+

npm run test:ui

+

Runs tests with UI (Vitest UI).

+
cd admin
+npm run test:ui
+
+

What it does: +- Runs vitest --ui +- Opens browser with test UI +- Shows test results visually

+

npm run test:coverage

+

Runs tests with coverage report.

+
cd admin
+npm run test:coverage
+
+

What it does: +- Runs vitest --coverage +- Generates coverage report in coverage/

+

Utility Scripts

+

npm run clean

+

Removes build artifacts and cache.

+
cd admin
+npm run clean
+
+

What it does: +- Deletes dist/ directory +- Removes node_modules/.vite/ cache +- Removes tsconfig.tsbuildinfo

+

Use when: +- Starting fresh build +- Fixing build cache issues +- Cleaning up after development

+

Docker Commands

+

When running services in Docker, use docker compose exec to run npm scripts:

+

API in Docker

+
# Development server (already running via docker compose up)
+docker compose logs -f api
+
+# Type-check
+docker compose exec api npm run type-check
+
+# Prisma migrate
+docker compose exec api npx prisma migrate dev --name add_field
+
+# Prisma Studio
+docker compose exec api npx prisma studio
+
+# Prisma seed
+docker compose exec api npx prisma db seed
+
+# Drizzle push (Media API)
+docker compose exec api npx drizzle-kit push
+
+# Lint
+docker compose exec api npm run lint
+
+# Format
+docker compose exec api npm run format
+
+

Admin in Docker

+
# Development server (already running via docker compose up)
+docker compose logs -f admin
+
+# Type-check
+docker compose exec admin npm run type-check
+
+# Lint
+docker compose exec admin npm run lint
+
+# Build
+docker compose exec admin npm run build
+
+

Rebuild Containers

+
# Rebuild after package.json changes
+docker compose build --no-cache api admin
+
+# Restart services
+docker compose restart api admin
+
+

Script Chaining

+

Sequential Execution (&&)

+

Run scripts in sequence, stop on first failure:

+
# Type-check, then build
+cd api
+npm run type-check && npm run build
+
+# Lint, format, type-check
+cd admin
+npm run lint && npm run format && npm run type-check
+
+# Full quality check before commit
+cd api
+npm run lint:fix && npm run format && npm run type-check && npm test
+
+

Parallel Execution (npm-run-all)

+

Install npm-run-all for parallel script execution:

+
# Install (project root)
+npm install --save-dev npm-run-all
+
+# Add to package.json
+{
+  "scripts": {
+    "check": "npm-run-all --parallel type-check lint test"
+  }
+}
+
+# Run all checks in parallel
+npm run check
+
+

Pre/Post Hooks

+

npm automatically runs pre* and post* scripts:

+
# package.json
+{
+  "scripts": {
+    "prebuild": "npm run clean",
+    "build": "tsc --build",
+    "postbuild": "npm run copy-assets"
+  }
+}
+
+# Running npm run build executes:
+# 1. npm run prebuild (clean)
+# 2. npm run build (tsc)
+# 3. npm run postbuild (copy-assets)
+
+

Common Script Combinations

+

Full Development Setup

+
# 1. Install dependencies
+cd api && npm install && cd ..
+cd admin && npm install && cd ..
+
+# 2. Setup database
+cd api
+npx prisma migrate deploy
+npx prisma db seed
+cd ..
+
+# 3. Start development servers
+# Option A: Docker
+docker compose up -d api admin
+
+# Option B: Local
+cd api && npm run dev  # Terminal 1
+cd admin && npm run dev  # Terminal 2
+
+

Pre-Commit Quality Check

+
# API quality check
+cd api
+npm run lint:fix
+npm run format
+npm run type-check
+# npm test  # When tests available
+cd ..
+
+# Admin quality check
+cd admin
+npm run lint:fix
+npm run format
+npm run type-check
+# npm test  # When tests available
+cd ..
+
+# Commit if all pass
+git add .
+git commit -m "feat: add new feature"
+
+

Production Build

+
# Build API
+cd api
+npm run clean
+npm run build
+cd ..
+
+# Build Admin
+cd admin
+npm run clean
+npm run build
+cd ..
+
+# Build Docker images
+docker compose build api admin
+
+# Start production services
+docker compose -f docker-compose.yml up -d api admin
+
+

Database Migration Workflow

+
# 1. Edit schema
+cd api
+vi prisma/schema.prisma
+
+# 2. Validate schema
+npx prisma validate
+
+# 3. Create migration
+npx prisma migrate dev --name add_user_field
+
+# 4. Verify migration SQL
+cat prisma/migrations/20260213000000_add_user_field/migration.sql
+
+# 5. Test on clean database
+npx prisma migrate reset  # WARNING: Deletes data
+npx prisma migrate deploy
+npx prisma db seed
+
+# 6. Commit migration
+git add prisma/migrations/ prisma/schema.prisma
+git commit -m "feat(db): add field to User model"
+
+

Database Inspection

+
# Prisma Studio (main API)
+cd api
+npx prisma studio
+# Open http://localhost:5555
+
+# Drizzle Studio (Media API)
+cd api
+npx drizzle-kit studio
+# Open http://localhost:4983
+
+# Direct PostgreSQL query
+docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db
+# Run SQL queries
+
+

Full Type Check

+
# Type-check both projects
+cd api && npx tsc --noEmit && cd ..
+cd admin && npx tsc --noEmit && cd ..
+
+# Or create root script (package.json in project root)
+{
+  "scripts": {
+    "type-check": "cd api && npm run type-check && cd ../admin && npm run type-check"
+  }
+}
+
+# Run from root
+npm run type-check
+
+

CI/CD Integration

+

GitHub Actions Example

+
name: CI
+
+on: [push, pull_request]
+
+jobs:
+  api:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-node@v3
+        with:
+          node-version: '20'
+
+      - name: Install dependencies
+        working-directory: ./api
+        run: npm ci
+
+      - name: Type check
+        working-directory: ./api
+        run: npm run type-check
+
+      - name: Lint
+        working-directory: ./api
+        run: npm run lint
+
+      - name: Format check
+        working-directory: ./api
+        run: npm run format:check
+
+      - name: Test
+        working-directory: ./api
+        run: npm test
+
+      - name: Build
+        working-directory: ./api
+        run: npm run build
+
+  admin:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-node@v3
+        with:
+          node-version: '20'
+
+      - name: Install dependencies
+        working-directory: ./admin
+        run: npm ci
+
+      - name: Type check
+        working-directory: ./admin
+        run: npm run type-check
+
+      - name: Lint
+        working-directory: ./admin
+        run: npm run lint
+
+      - name: Format check
+        working-directory: ./admin
+        run: npm run format:check
+
+      - name: Test
+        working-directory: ./admin
+        run: npm test
+
+      - name: Build
+        working-directory: ./admin
+        run: npm run build
+
+

Troubleshooting

+

Script Not Found

+

Problem: +

npm ERR! missing script: dev
+

+

Solution: +- Check package.json has the script defined +- Verify you're in correct directory (api/ or admin/) +- Run npm install to ensure dependencies installed

+

Permission Errors

+

Problem: +

Error: EACCES: permission denied
+

+

Solution: +- Don't use sudo npm (creates permission issues) +- Fix npm permissions: sudo chown -R $(whoami) ~/.npm +- Or use nvm for user-level Node.js installation

+

Port Already in Use

+

Problem: +

Error: listen EADDRINUSE: address already in use :::4000
+

+

Solution: +- Find and kill process using port: lsof -ti:4000 | xargs kill -9 +- Or change port in .env: API_PORT=4002 +- Or use Docker (isolated ports)

+

TypeScript Errors on Build

+

Problem: +

src/modules/auth/auth.service.ts:45:12 - error TS2339
+

+

Solution: +- Fix type errors in code +- Or check tsconfig.json is correct +- Or update type definitions: npm install --save-dev @types/node@latest

+

Prisma Migration Conflicts

+

Problem: +

Error: P3005 The database schema is not in sync with the migration history
+

+

Solution: +- Development: npx prisma migrate reset (DELETES DATA) +- Production: npx prisma migrate resolve --applied <migration_name> +- Or create new migration to fix state

+

npm install Failures

+

Problem: +

npm ERR! code ERESOLVE
+npm ERR! ERESOLVE unable to resolve dependency tree
+

+

Solution: +- Clear cache: npm cache clean --force +- Delete and reinstall: rm -rf node_modules package-lock.json && npm install +- Use --legacy-peer-deps flag: npm install --legacy-peer-deps

+

Vite Build Errors

+

Problem: +

Error: Could not resolve entry module (index.html)
+

+

Solution: +- Ensure index.html exists in admin/ +- Check vite.config.ts has correct root +- Clear cache: rm -rf node_modules/.vite && npm run dev

+

Best Practices

+

Script Naming Conventions

+
    +
  • dev - Development mode with hot reload
  • +
  • build - Production build
  • +
  • start - Run production build
  • +
  • test - Run tests
  • +
  • lint - Check code style
  • +
  • lint:fix - Auto-fix code style
  • +
  • format - Format code
  • +
  • type-check - TypeScript validation
  • +
  • clean - Remove build artifacts
  • +
+

Script Organization

+

Group related scripts:

+
{
+  "scripts": {
+    // Development
+    "dev": "tsx watch src/server.ts",
+    "dev:media": "tsx watch src/media-server.ts",
+
+    // Build
+    "build": "tsc --build",
+    "clean": "rm -rf dist",
+
+    // Quality
+    "type-check": "tsc --noEmit",
+    "lint": "eslint src/ --ext .ts",
+    "lint:fix": "eslint src/ --ext .ts --fix",
+    "format": "prettier --write \"src/**/*.ts\"",
+
+    // Database
+    "prisma:migrate": "prisma migrate dev",
+    "prisma:deploy": "prisma migrate deploy",
+    "prisma:seed": "tsx prisma/seed.ts"
+  }
+}
+
+

Environment-Specific Scripts

+

Use cross-env for environment variables:

+
npm install --save-dev cross-env
+
+
{
+  "scripts": {
+    "dev": "cross-env NODE_ENV=development tsx watch src/server.ts",
+    "build": "cross-env NODE_ENV=production tsc --build",
+    "test": "cross-env NODE_ENV=test jest"
+  }
+}
+
+

Script Documentation

+

Add comments in package.json:

+
{
+  "scripts": {
+    "// Development": "",
+    "dev": "tsx watch src/server.ts",
+
+    "// Build": "",
+    "build": "tsc --build",
+
+    "// Quality": "",
+    "type-check": "tsc --noEmit"
+  }
+}
+
+

Quick Reference

+

API Scripts

+
npm run dev              # Dev server (port 4000)
+npm run dev:media        # Media API dev (port 4100)
+npm run build            # Build for production
+npm start                # Run production server
+npm run type-check       # TypeScript validation
+npm run lint             # ESLint check
+npm run lint:fix         # ESLint auto-fix
+npm run format           # Prettier format
+npx prisma migrate dev   # Create migration
+npx prisma migrate deploy # Apply migrations
+npx prisma db seed       # Seed database
+npx prisma studio        # Database GUI
+npx drizzle-kit push     # Push Media schema
+
+

Admin Scripts

+
npm run dev              # Dev server (port 3000)
+npm run build            # Build for production
+npm run preview          # Preview production build
+npm run type-check       # TypeScript validation
+npm run lint             # ESLint check
+npm run lint:fix         # ESLint auto-fix
+npm run format           # Prettier format
+npm test                 # Run tests
+npm run test:ui          # Test UI
+
+

Docker Scripts

+
docker compose exec api npm run type-check
+docker compose exec api npx prisma migrate dev
+docker compose exec admin npm run lint
+docker compose build --no-cache api admin
+
+ + +

Summary

+

You now know: +- ✅ All available npm scripts in API and Admin +- ✅ What each script does and when to use it +- ✅ How to run scripts in Docker containers +- ✅ How to chain scripts together +- ✅ Common script combinations for workflows +- ✅ How to troubleshoot script errors +- ✅ Best practices for script organization

+

Quick Start: +

# Development
+cd api && npm run dev
+cd admin && npm run dev
+
+# Pre-commit
+cd api && npm run lint:fix && npm run type-check
+cd admin && npm run lint:fix && npm run type-check
+
+# Production build
+cd api && npm run build
+cd admin && npm run build
+

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/development/testing/index.html b/mkdocs/site/v2/development/testing/index.html new file mode 100644 index 00000000..c5ac2752 --- /dev/null +++ b/mkdocs/site/v2/development/testing/index.html @@ -0,0 +1,6604 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Testing - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Testing Strategy and Guide

+

Comprehensive guide to testing Changemaker Lite V2, covering unit tests, integration tests, and end-to-end testing strategies.

+

Overview

+

Current Status: Phase 15 (Testing + Polish) in progress. Test infrastructure is being implemented.

+

This guide covers: +- Testing philosophy and strategy +- Test frameworks (Jest, Vitest, React Testing Library) +- Writing tests for API and Frontend +- Running tests and generating coverage +- Testing best practices

+

Testing Philosophy

+

Test Pyramid

+
       /\
+      /E2E\         ← Few, high-value end-to-end tests
+     /------\
+    /Integration\   ← Moderate integration tests
+   /------------\
+  /   Unit Tests  \ ← Many, fast unit tests
+ /----------------\
+
+

Unit Tests (70%): +- Test individual functions/components +- Fast execution (milliseconds) +- No external dependencies +- Easy to write and maintain

+

Integration Tests (20%): +- Test multiple units working together +- Test API routes with database +- Test user flows in frontend +- Moderate execution time

+

End-to-End Tests (10%): +- Test complete user journeys +- Test across API and frontend +- Slow execution (seconds) +- Complex setup

+

Testing Principles

+
    +
  1. Test Behavior, Not Implementation
  2. +
  3. Test what the code does, not how it does it
  4. +
  5. +

    Allows refactoring without breaking tests

    +
  6. +
  7. +

    Arrange-Act-Assert (AAA) Pattern

    +
  8. +
  9. Arrange: Set up test data and mocks
  10. +
  11. Act: Execute the code under test
  12. +
  13. +

    Assert: Verify expected behavior

    +
  14. +
  15. +

    Independent Tests

    +
  16. +
  17. Each test runs in isolation
  18. +
  19. No shared state between tests
  20. +
  21. +

    Tests can run in any order

    +
  22. +
  23. +

    Fast Feedback

    +
  24. +
  25. Tests run quickly (< 1 second each)
  26. +
  27. Run tests in watch mode during development
  28. +
  29. +

    Run full suite in CI/CD

    +
  30. +
  31. +

    Readable Tests

    +
  32. +
  33. Clear test names describing what is tested
  34. +
  35. Simple setup and assertions
  36. +
  37. Good error messages when tests fail
  38. +
+

Test Frameworks

+

API Testing (Jest)

+

Framework: Jest +Location: api/src/**/*.test.ts +Config: api/jest.config.js

+

Installation: +

cd api
+npm install --save-dev jest @types/jest ts-jest
+npm install --save-dev @types/supertest supertest
+

+

Configuration (jest.config.js): +

module.exports = {
+  preset: 'ts-jest',
+  testEnvironment: 'node',
+  roots: ['<rootDir>/src'],
+  testMatch: ['**/*.test.ts'],
+  collectCoverageFrom: [
+    'src/**/*.{ts,tsx}',
+    '!src/**/*.d.ts',
+    '!src/**/*.test.ts'
+  ],
+  coverageThreshold: {
+    global: {
+      branches: 80,
+      functions: 80,
+      lines: 80,
+      statements: 80
+    }
+  }
+};
+

+

Frontend Testing (Vitest + React Testing Library)

+

Framework: Vitest (Vite-native test runner) +Component Testing: React Testing Library +Location: admin/src/**/*.test.tsx, admin/src/**/*.spec.tsx +Config: admin/vitest.config.ts

+

Installation: +

cd admin
+npm install --save-dev vitest @vitest/ui
+npm install --save-dev @testing-library/react @testing-library/jest-dom
+npm install --save-dev @testing-library/user-event
+

+

Configuration (vitest.config.ts): +

import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+  plugins: [react()],
+  test: {
+    environment: 'jsdom',
+    globals: true,
+    setupFiles: './src/test/setup.ts',
+    coverage: {
+      provider: 'v8',
+      reporter: ['text', 'json', 'html'],
+      exclude: [
+        'node_modules/',
+        'src/test/',
+        '**/*.d.ts',
+        '**/*.config.*',
+        '**/mockData'
+      ]
+    }
+  }
+});
+

+

Setup File (admin/src/test/setup.ts): +

import '@testing-library/jest-dom';
+import { expect, afterEach } from 'vitest';
+import { cleanup } from '@testing-library/react';
+
+// Cleanup after each test
+afterEach(() => {
+  cleanup();
+});
+

+

API Testing

+

Unit Tests (Service Layer)

+

Test business logic in service files:

+

Example: api/src/modules/auth/auth.service.test.ts

+
import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { AuthService } from './auth.service';
+import { PrismaClient } from '@prisma/client';
+import bcrypt from 'bcryptjs';
+
+// Mock Prisma
+vi.mock('@prisma/client');
+
+describe('AuthService', () => {
+  let authService: AuthService;
+  let mockPrisma: any;
+
+  beforeEach(() => {
+    mockPrisma = {
+      user: {
+        findUnique: vi.fn(),
+        create: vi.fn()
+      }
+    };
+    authService = new AuthService(mockPrisma);
+  });
+
+  describe('login', () => {
+    it('should return tokens for valid credentials', async () => {
+      // Arrange
+      const email = 'test@example.com';
+      const password = 'Password123!';
+      const hashedPassword = await bcrypt.hash(password, 10);
+
+      mockPrisma.user.findUnique.mockResolvedValue({
+        id: 1,
+        email,
+        password: hashedPassword,
+        role: 'USER'
+      });
+
+      // Act
+      const result = await authService.login(email, password);
+
+      // Assert
+      expect(result).toHaveProperty('accessToken');
+      expect(result).toHaveProperty('refreshToken');
+      expect(result.user.email).toBe(email);
+    });
+
+    it('should throw error for invalid password', async () => {
+      // Arrange
+      const email = 'test@example.com';
+      const hashedPassword = await bcrypt.hash('correctpass', 10);
+
+      mockPrisma.user.findUnique.mockResolvedValue({
+        id: 1,
+        email,
+        password: hashedPassword,
+        role: 'USER'
+      });
+
+      // Act & Assert
+      await expect(
+        authService.login(email, 'wrongpass')
+      ).rejects.toThrow('Invalid credentials');
+    });
+
+    it('should throw error for non-existent user', async () => {
+      // Arrange
+      mockPrisma.user.findUnique.mockResolvedValue(null);
+
+      // Act & Assert
+      await expect(
+        authService.login('nonexistent@example.com', 'password')
+      ).rejects.toThrow('Invalid credentials');
+    });
+  });
+
+  describe('register', () => {
+    it('should create new user with hashed password', async () => {
+      // Arrange
+      const email = 'new@example.com';
+      const password = 'Password123!';
+
+      mockPrisma.user.findUnique.mockResolvedValue(null);
+      mockPrisma.user.create.mockResolvedValue({
+        id: 1,
+        email,
+        role: 'USER'
+      });
+
+      // Act
+      const result = await authService.register(email, password);
+
+      // Assert
+      expect(mockPrisma.user.create).toHaveBeenCalledWith({
+        data: expect.objectContaining({
+          email,
+          password: expect.any(String),
+          role: 'USER'
+        })
+      });
+      expect(result.user.email).toBe(email);
+    });
+
+    it('should throw error if user already exists', async () => {
+      // Arrange
+      mockPrisma.user.findUnique.mockResolvedValue({
+        id: 1,
+        email: 'existing@example.com'
+      });
+
+      // Act & Assert
+      await expect(
+        authService.register('existing@example.com', 'Password123!')
+      ).rejects.toThrow('User already exists');
+    });
+  });
+});
+
+

Integration Tests (Routes)

+

Test API endpoints with database:

+

Example: api/src/modules/auth/auth.routes.test.ts

+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import request from 'supertest';
+import { app } from '../../server';
+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+describe('Auth Routes', () => {
+  beforeAll(async () => {
+    // Setup test database
+    await prisma.$connect();
+  });
+
+  afterAll(async () => {
+    // Cleanup
+    await prisma.user.deleteMany();
+    await prisma.$disconnect();
+  });
+
+  describe('POST /api/auth/register', () => {
+    it('should register new user', async () => {
+      const response = await request(app)
+        .post('/api/auth/register')
+        .send({
+          email: 'test@example.com',
+          password: 'Password123!'
+        })
+        .expect(201);
+
+      expect(response.body).toHaveProperty('accessToken');
+      expect(response.body).toHaveProperty('refreshToken');
+      expect(response.body.user.email).toBe('test@example.com');
+    });
+
+    it('should return 400 for invalid email', async () => {
+      const response = await request(app)
+        .post('/api/auth/register')
+        .send({
+          email: 'invalid-email',
+          password: 'Password123!'
+        })
+        .expect(400);
+
+      expect(response.body).toHaveProperty('error');
+    });
+
+    it('should return 409 for existing user', async () => {
+      // Create user first
+      await request(app)
+        .post('/api/auth/register')
+        .send({
+          email: 'existing@example.com',
+          password: 'Password123!'
+        });
+
+      // Try to create again
+      const response = await request(app)
+        .post('/api/auth/register')
+        .send({
+          email: 'existing@example.com',
+          password: 'Password123!'
+        })
+        .expect(409);
+
+      expect(response.body.error).toContain('already exists');
+    });
+  });
+
+  describe('POST /api/auth/login', () => {
+    it('should login with valid credentials', async () => {
+      // Register user first
+      await request(app)
+        .post('/api/auth/register')
+        .send({
+          email: 'login@example.com',
+          password: 'Password123!'
+        });
+
+      // Login
+      const response = await request(app)
+        .post('/api/auth/login')
+        .send({
+          email: 'login@example.com',
+          password: 'Password123!'
+        })
+        .expect(200);
+
+      expect(response.body).toHaveProperty('accessToken');
+      expect(response.body).toHaveProperty('refreshToken');
+    });
+
+    it('should return 401 for invalid password', async () => {
+      const response = await request(app)
+        .post('/api/auth/login')
+        .send({
+          email: 'login@example.com',
+          password: 'WrongPassword!'
+        })
+        .expect(401);
+
+      expect(response.body.error).toContain('Invalid credentials');
+    });
+  });
+});
+
+

Database Testing

+

Use separate test database:

+

Environment Variable (.env.test): +

DATABASE_URL=postgresql://changemaker_v2:password@localhost:5433/changemaker_v2_test_db
+

+

Setup Script (api/src/test/setup.ts): +

import { PrismaClient } from '@prisma/client';
+import { execSync } from 'child_process';
+
+const prisma = new PrismaClient();
+
+export async function setupTestDatabase() {
+  // Apply migrations
+  execSync('npx prisma migrate deploy', {
+    env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL }
+  });
+
+  // Clean data
+  await prisma.user.deleteMany();
+  await prisma.campaign.deleteMany();
+  // ... delete all tables
+}
+
+export async function teardownTestDatabase() {
+  await prisma.$disconnect();
+}
+

+

Frontend Testing

+

Component Unit Tests

+

Test individual React components:

+

Example: admin/src/components/UserCard.test.tsx

+
import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { UserCard } from './UserCard';
+
+describe('UserCard', () => {
+  it('renders user information', () => {
+    const user = {
+      id: 1,
+      email: 'test@example.com',
+      role: 'USER',
+      name: 'Test User'
+    };
+
+    render(<UserCard user={user} />);
+
+    expect(screen.getByText('Test User')).toBeInTheDocument();
+    expect(screen.getByText('test@example.com')).toBeInTheDocument();
+    expect(screen.getByText('USER')).toBeInTheDocument();
+  });
+
+  it('renders "No name" when name is null', () => {
+    const user = {
+      id: 1,
+      email: 'test@example.com',
+      role: 'USER',
+      name: null
+    };
+
+    render(<UserCard user={user} />);
+
+    expect(screen.getByText('No name')).toBeInTheDocument();
+  });
+});
+
+

Component Integration Tests

+

Test user interactions:

+

Example: admin/src/pages/LoginPage.test.tsx

+
import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { LoginPage } from './LoginPage';
+import { BrowserRouter } from 'react-router-dom';
+import * as api from '../lib/api';
+
+// Mock API
+vi.mock('../lib/api');
+
+describe('LoginPage', () => {
+  it('submits login form with valid credentials', async () => {
+    const user = userEvent.setup();
+    const mockLogin = vi.spyOn(api, 'login').mockResolvedValue({
+      accessToken: 'token',
+      refreshToken: 'refresh',
+      user: { id: 1, email: 'test@example.com', role: 'USER' }
+    });
+
+    render(
+      <BrowserRouter>
+        <LoginPage />
+      </BrowserRouter>
+    );
+
+    // Fill form
+    await user.type(screen.getByLabelText(/email/i), 'test@example.com');
+    await user.type(screen.getByLabelText(/password/i), 'Password123!');
+
+    // Submit
+    await user.click(screen.getByRole('button', { name: /login/i }));
+
+    // Verify API called
+    await waitFor(() => {
+      expect(mockLogin).toHaveBeenCalledWith({
+        email: 'test@example.com',
+        password: 'Password123!'
+      });
+    });
+  });
+
+  it('shows error for invalid credentials', async () => {
+    const user = userEvent.setup();
+    vi.spyOn(api, 'login').mockRejectedValue(
+      new Error('Invalid credentials')
+    );
+
+    render(
+      <BrowserRouter>
+        <LoginPage />
+      </BrowserRouter>
+    );
+
+    await user.type(screen.getByLabelText(/email/i), 'test@example.com');
+    await user.type(screen.getByLabelText(/password/i), 'wrong');
+    await user.click(screen.getByRole('button', { name: /login/i }));
+
+    await waitFor(() => {
+      expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
+    });
+  });
+
+  it('disables submit button while loading', async () => {
+    const user = userEvent.setup();
+    vi.spyOn(api, 'login').mockImplementation(
+      () => new Promise(resolve => setTimeout(resolve, 1000))
+    );
+
+    render(
+      <BrowserRouter>
+        <LoginPage />
+      </BrowserRouter>
+    );
+
+    const submitButton = screen.getByRole('button', { name: /login/i });
+
+    await user.type(screen.getByLabelText(/email/i), 'test@example.com');
+    await user.type(screen.getByLabelText(/password/i), 'Password123!');
+    await user.click(submitButton);
+
+    expect(submitButton).toBeDisabled();
+  });
+});
+
+

Testing Hooks

+

Test custom React hooks:

+

Example: admin/src/hooks/useDebounce.test.ts

+
import { describe, it, expect, vi } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { useDebounce } from './useDebounce';
+
+describe('useDebounce', () => {
+  it('debounces value changes', async () => {
+    const { result, rerender } = renderHook(
+      ({ value, delay }) => useDebounce(value, delay),
+      { initialProps: { value: 'initial', delay: 500 } }
+    );
+
+    expect(result.current).toBe('initial');
+
+    // Change value
+    rerender({ value: 'updated', delay: 500 });
+
+    // Value should not change immediately
+    expect(result.current).toBe('initial');
+
+    // Wait for debounce
+    await waitFor(() => {
+      expect(result.current).toBe('updated');
+    }, { timeout: 600 });
+  });
+});
+
+

Testing Zustand Stores

+

Test state management:

+

Example: admin/src/stores/auth.store.test.ts

+
import { describe, it, expect, beforeEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useAuthStore } from './auth.store';
+
+describe('Auth Store', () => {
+  beforeEach(() => {
+    // Reset store before each test
+    const { result } = renderHook(() => useAuthStore());
+    act(() => {
+      result.current.logout();
+    });
+  });
+
+  it('sets user on login', () => {
+    const { result } = renderHook(() => useAuthStore());
+
+    act(() => {
+      result.current.setUser({
+        id: 1,
+        email: 'test@example.com',
+        role: 'USER'
+      });
+    });
+
+    expect(result.current.user).toEqual({
+      id: 1,
+      email: 'test@example.com',
+      role: 'USER'
+    });
+    expect(result.current.isAuthenticated).toBe(true);
+  });
+
+  it('clears user on logout', () => {
+    const { result } = renderHook(() => useAuthStore());
+
+    act(() => {
+      result.current.setUser({
+        id: 1,
+        email: 'test@example.com',
+        role: 'USER'
+      });
+    });
+
+    expect(result.current.isAuthenticated).toBe(true);
+
+    act(() => {
+      result.current.logout();
+    });
+
+    expect(result.current.user).toBeNull();
+    expect(result.current.isAuthenticated).toBe(false);
+  });
+});
+
+

Running Tests

+

Run All Tests

+
# API tests
+cd api
+npm test
+
+# Frontend tests
+cd admin
+npm test
+
+

Watch Mode

+

Run tests automatically on file changes:

+
# API tests (Jest watch)
+cd api
+npm run test:watch
+
+# Frontend tests (Vitest watch)
+cd admin
+npm run test:watch
+
+

Run Specific Tests

+
# Run specific test file
+npm test -- auth.service.test.ts
+
+# Run tests matching pattern
+npm test -- --testNamePattern="login"
+
+# Run tests in specific directory
+npm test -- src/modules/auth/
+
+

Coverage Reports

+

Generate test coverage:

+
# API coverage
+cd api
+npm run test:coverage
+
+# Frontend coverage
+cd admin
+npm run test:coverage
+
+

Coverage output: +

File                | % Stmts | % Branch | % Funcs | % Lines |
+--------------------|---------|----------|---------|---------|
+All files           |   82.45 |    75.33 |   80.12 |   83.21 |
+ auth/              |   95.23 |    89.47 |   93.75 |   96.15 |
+  auth.service.ts   |   97.14 |    91.67 |   100   |   98.21 |
+  auth.routes.ts    |   93.33 |    87.50 |   87.50 |   94.12 |
+

+

HTML report: +- Located in coverage/ directory +- Open coverage/index.html in browser +- Shows line-by-line coverage

+

CI/CD Testing

+

GitHub Actions Example:

+
name: Tests
+
+on: [push, pull_request]
+
+jobs:
+  api-tests:
+    runs-on: ubuntu-latest
+    services:
+      postgres:
+        image: postgres:16
+        env:
+          POSTGRES_PASSWORD: test
+        options: >-
+          --health-cmd pg_isready
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-node@v3
+        with:
+          node-version: '20'
+
+      - name: Install dependencies
+        working-directory: ./api
+        run: npm ci
+
+      - name: Run migrations
+        working-directory: ./api
+        env:
+          DATABASE_URL: postgresql://postgres:test@localhost:5432/test
+        run: npx prisma migrate deploy
+
+      - name: Run tests
+        working-directory: ./api
+        run: npm test -- --coverage
+
+      - name: Upload coverage
+        uses: codecov/codecov-action@v3
+        with:
+          files: ./api/coverage/coverage-final.json
+
+  frontend-tests:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-node@v3
+        with:
+          node-version: '20'
+
+      - name: Install dependencies
+        working-directory: ./admin
+        run: npm ci
+
+      - name: Run tests
+        working-directory: ./admin
+        run: npm test -- --coverage
+
+      - name: Upload coverage
+        uses: codecov/codecov-action@v3
+        with:
+          files: ./admin/coverage/coverage-final.json
+
+

Mocking

+

Mocking API Calls (Frontend)

+
// Mock axios
+vi.mock('../lib/api', () => ({
+  api: {
+    get: vi.fn(),
+    post: vi.fn(),
+    put: vi.fn(),
+    delete: vi.fn()
+  }
+}));
+
+// Use in test
+import { api } from '../lib/api';
+
+vi.mocked(api.get).mockResolvedValue({
+  data: { users: [] }
+});
+
+

Mocking Database (Backend)

+
// Mock Prisma Client
+vi.mock('@prisma/client', () => ({
+  PrismaClient: vi.fn(() => ({
+    user: {
+      findUnique: vi.fn(),
+      findMany: vi.fn(),
+      create: vi.fn(),
+      update: vi.fn(),
+      delete: vi.fn()
+    }
+  }))
+}));
+
+

Mocking External Services

+
// Mock email service
+vi.mock('../../services/email.service', () => ({
+  EmailService: {
+    sendEmail: vi.fn().mockResolvedValue(true)
+  }
+}));
+
+

Mocking Environment Variables

+
// Set env var for test
+process.env.JWT_ACCESS_SECRET = 'test-secret';
+
+// Or use vi.stubEnv
+vi.stubEnv('API_URL', 'http://localhost:4000');
+
+

Best Practices

+

Test Naming

+

Use descriptive test names:

+

Good: +

it('should return 401 for expired token', async () => {});
+it('should create user with hashed password', async () => {});
+it('should render error message for invalid email', () => {});
+

+

Bad: +

it('works', async () => {});
+it('test login', async () => {});
+it('should work correctly', () => {});
+

+

Test Organization

+

Group related tests:

+
describe('AuthService', () => {
+  describe('login', () => {
+    it('should return tokens for valid credentials', () => {});
+    it('should throw error for invalid password', () => {});
+    it('should throw error for non-existent user', () => {});
+  });
+
+  describe('register', () => {
+    it('should create new user', () => {});
+    it('should hash password', () => {});
+    it('should throw error if user exists', () => {});
+  });
+});
+
+

Setup and Teardown

+

Use beforeEach/afterEach for common setup:

+
describe('UserService', () => {
+  let userService: UserService;
+  let mockPrisma: any;
+
+  beforeEach(() => {
+    mockPrisma = createMockPrisma();
+    userService = new UserService(mockPrisma);
+  });
+
+  afterEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('...', () => {});
+});
+
+

Avoid Test Interdependence

+

Each test should be independent:

+

Good: +

it('should create user', async () => {
+  const user = await createUser({ email: 'test@example.com' });
+  expect(user.email).toBe('test@example.com');
+});
+
+it('should update user', async () => {
+  const user = await createUser({ email: 'test@example.com' });
+  const updated = await updateUser(user.id, { name: 'New Name' });
+  expect(updated.name).toBe('New Name');
+});
+

+

Bad: +

let userId;
+
+it('should create user', async () => {
+  const user = await createUser({ email: 'test@example.com' });
+  userId = user.id; // ❌ Shared state
+});
+
+it('should update user', async () => {
+  const updated = await updateUser(userId, { name: 'New Name' });
+  // ❌ Depends on previous test
+});
+

+

Test Edge Cases

+

Test boundary conditions:

+
describe('pagination', () => {
+  it('should handle page 1', () => {});
+  it('should handle last page', () => {});
+  it('should handle empty results', () => {});
+  it('should handle invalid page number', () => {});
+  it('should handle page exceeding total', () => {});
+});
+
+

Async Testing

+

Always use async/await for async tests:

+

Good: +

it('should fetch users', async () => {
+  const users = await userService.getUsers();
+  expect(users).toHaveLength(10);
+});
+

+

Bad: +

it('should fetch users', () => {
+  userService.getUsers().then(users => {
+    expect(users).toHaveLength(10); // ❌ May not run
+  });
+});
+

+

Coverage Requirements

+

Target coverage thresholds:

+
// jest.config.js / vitest.config.ts
+coverageThreshold: {
+  global: {
+    branches: 80,
+    functions: 80,
+    lines: 80,
+    statements: 80
+  }
+}
+
+

What to test: +- ✅ Business logic (services) +- ✅ API routes +- ✅ UI components +- ✅ Custom hooks +- ✅ Utilities +- ❌ Type definitions +- ❌ Config files +- ❌ Test files themselves

+

Troubleshooting

+

Tests Timing Out

+

Problem: Tests exceed timeout.

+

Solution:

+
// Increase timeout for specific test
+it('slow operation', async () => {
+  // ...
+}, 10000); // 10 second timeout
+
+// Or globally (vitest.config.ts)
+export default defineConfig({
+  test: {
+    testTimeout: 10000
+  }
+});
+
+

Mocks Not Working

+

Problem: Mocks not being used.

+

Solution:

+
// Mock must be at top of file, before imports
+vi.mock('../lib/api');
+
+import { api } from '../lib/api';
+
+// Verify mock is being used
+console.log(vi.isMockFunction(api.get)); // Should be true
+
+

Database Connection Errors

+

Problem: Tests fail with DB connection errors.

+

Solution:

+
// Use separate test database
+process.env.DATABASE_URL = 'postgresql://localhost/test_db';
+
+// Or mock database entirely
+vi.mock('@prisma/client');
+
+

React Testing Library Queries Failing

+

Problem: screen.getByText() doesn't find element.

+

Solution:

+
// Use findBy for async elements
+const element = await screen.findByText('Loading...');
+
+// Use queryBy to check non-existence
+expect(screen.queryByText('Error')).not.toBeInTheDocument();
+
+// Debug rendered output
+screen.debug();
+
+ + +

Summary

+

You now know: +- ✅ Testing philosophy (test pyramid, AAA pattern) +- ✅ Test frameworks (Jest, Vitest, React Testing Library) +- ✅ How to write unit tests (services, components) +- ✅ How to write integration tests (routes, user flows) +- ✅ How to run tests and generate coverage +- ✅ How to mock dependencies +- ✅ Testing best practices +- ✅ How to integrate tests in CI/CD

+

Quick Start: +

# Install dependencies (when Phase 15 complete)
+cd api && npm install --save-dev jest @types/jest ts-jest
+cd admin && npm install --save-dev vitest @vitest/ui
+
+# Run tests
+npm test
+
+# Watch mode
+npm run test:watch
+
+# Coverage
+npm run test:coverage
+

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/development/typescript/index.html b/mkdocs/site/v2/development/typescript/index.html new file mode 100644 index 00000000..36dfc016 --- /dev/null +++ b/mkdocs/site/v2/development/typescript/index.html @@ -0,0 +1,6423 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TypeScript - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

TypeScript Best Practices

+

Comprehensive TypeScript guide for Changemaker Lite V2, covering type system fundamentals, common patterns, and V2-specific conventions.

+

Overview

+

Changemaker Lite V2 uses TypeScript 5.x with strict mode enabled for maximum type safety.

+

Benefits: +- Catch errors at compile time +- Better IDE autocomplete and refactoring +- Self-documenting code +- Safer refactoring

+

This guide covers TypeScript best practices specific to V2 development.

+

Type System Fundamentals

+

Primitives

+
// Basic types
+const name: string = 'John';
+const age: number = 30;
+const isActive: boolean = true;
+const data: null = null;
+const value: undefined = undefined;
+
+// Arrays
+const numbers: number[] = [1, 2, 3];
+const emails: Array<string> = ['a@example.com', 'b@example.com'];
+
+// Tuples
+const userTuple: [number, string] = [1, 'John'];
+const coordinate: [number, number] = [51.5074, -0.1278];
+
+

Objects

+
// Object literal
+const user: { id: number; email: string } = {
+  id: 1,
+  email: 'john@example.com'
+};
+
+// Interface (preferred for reusable types)
+interface User {
+  id: number;
+  email: string;
+  name?: string; // Optional property
+  readonly role: string; // Read-only property
+}
+
+// Type alias (for unions, intersections, utilities)
+type UserRole = 'USER' | 'ADMIN' | 'SUPER_ADMIN';
+
+

Functions

+
// Function declaration
+function greet(name: string): string {
+  return `Hello, ${name}`;
+}
+
+// Arrow function
+const add = (a: number, b: number): number => a + b;
+
+// Optional parameters
+function log(message: string, level?: string): void {
+  console.log(`[${level ?? 'INFO'}] ${message}`);
+}
+
+// Default parameters
+function paginate(page: number = 1, limit: number = 50) {
+  return { page, limit };
+}
+
+// Rest parameters
+function sum(...numbers: number[]): number {
+  return numbers.reduce((total, n) => total + n, 0);
+}
+
+// Async functions
+async function fetchUser(id: number): Promise<User> {
+  const response = await fetch(`/api/users/${id}`);
+  return response.json();
+}
+
+

Unions and Intersections

+
// Union (OR)
+type Status = 'pending' | 'active' | 'completed';
+type ID = number | string;
+
+function printId(id: ID) {
+  if (typeof id === 'string') {
+    console.log(id.toUpperCase());
+  } else {
+    console.log(id.toFixed(2));
+  }
+}
+
+// Intersection (AND)
+type Timestamped = {
+  createdAt: Date;
+  updatedAt: Date;
+};
+
+type User = {
+  id: number;
+  email: string;
+} & Timestamped;
+
+// User has: id, email, createdAt, updatedAt
+
+

Generics

+
// Generic function
+function identity<T>(value: T): T {
+  return value;
+}
+
+const num = identity<number>(42);
+const str = identity<string>('hello');
+
+// Generic interface
+interface Response<T> {
+  data: T;
+  error?: string;
+}
+
+const userResponse: Response<User> = {
+  data: { id: 1, email: 'john@example.com' }
+};
+
+// Generic constraints
+function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
+  return obj[key];
+}
+
+const user = { id: 1, email: 'john@example.com' };
+const email = getProperty(user, 'email'); // Type: string
+
+

Utility Types

+
// Partial - Makes all properties optional
+type UpdateUserInput = Partial<User>;
+
+// Pick - Select specific properties
+type UserPreview = Pick<User, 'id' | 'email'>;
+
+// Omit - Exclude specific properties
+type UserWithoutPassword = Omit<User, 'password'>;
+
+// Required - Makes all properties required
+type RequiredUser = Required<User>;
+
+// Readonly - Makes all properties read-only
+type ImmutableUser = Readonly<User>;
+
+// Record - Object with specific key/value types
+type UserMap = Record<number, User>;
+
+// ReturnType - Extract return type of function
+function getUser() {
+  return { id: 1, email: 'john@example.com' };
+}
+type User = ReturnType<typeof getUser>;
+
+// Parameters - Extract parameter types
+function createUser(email: string, password: string) {}
+type CreateUserParams = Parameters<typeof createUser>;
+// [string, string]
+
+

Common V2 Patterns

+

Request/Response Types

+

API Request:

+
// Express Request with typed params, query, body
+import { Request, Response } from 'express';
+
+interface GetUserParams {
+  id: string; // Params are always strings
+}
+
+interface GetUsersQuery {
+  page?: string;
+  limit?: string;
+  search?: string;
+}
+
+interface CreateUserBody {
+  email: string;
+  password: string;
+  name?: string;
+}
+
+// Route handler
+app.get('/users/:id', (req: Request<GetUserParams>, res: Response) => {
+  const id = parseInt(req.params.id as string); // Cast to string
+  // ...
+});
+
+app.get('/users', (req: Request<{}, {}, {}, GetUsersQuery>, res: Response) => {
+  const page = parseInt(req.query.page ?? '1');
+  const limit = parseInt(req.query.limit ?? '50');
+  // ...
+});
+
+app.post('/users', (req: Request<{}, {}, CreateUserBody>, res: Response) => {
+  const { email, password, name } = req.body;
+  // ...
+});
+
+

Augmented Request (with user from JWT):

+
// api/src/types/express.d.ts
+import { User } from '@prisma/client';
+
+declare global {
+  namespace Express {
+    interface Request {
+      user?: {
+        id: number;
+        email: string;
+        role: string;
+      };
+    }
+  }
+}
+
+// Usage in route
+app.get('/me', authenticate, (req: Request, res: Response) => {
+  const userId = req.user!.id; // Non-null assertion (safe after authenticate)
+  // ...
+});
+
+

Prisma Types

+

Generated Types:

+
import { User, Campaign, Location, Prisma } from '@prisma/client';
+
+// Model types
+const user: User = {
+  id: 1,
+  email: 'john@example.com',
+  password: 'hashed...',
+  role: 'USER',
+  createdAt: new Date(),
+  updatedAt: new Date()
+};
+
+// Create input
+const createData: Prisma.UserCreateInput = {
+  email: 'john@example.com',
+  password: 'hashed...',
+  role: 'USER'
+};
+
+// Unchecked create (with foreign keys)
+const createCampaign: Prisma.CampaignUncheckedCreateInput = {
+  title: 'New Campaign',
+  createdByUserId: 1 // Can set FK directly
+};
+
+// Update input
+const updateData: Prisma.UserUpdateInput = {
+  name: 'John Doe',
+  updatedAt: new Date()
+};
+
+// Where clause
+const whereClause: Prisma.UserWhereInput = {
+  email: { contains: '@example.com' },
+  role: { in: ['USER', 'ADMIN'] },
+  createdAt: { gte: new Date('2024-01-01') }
+};
+
+// Include relations
+const userWithCampaigns = await prisma.user.findUnique({
+  where: { id: 1 },
+  include: { campaigns: true }
+});
+// Type: User & { campaigns: Campaign[] }
+
+

JSON Fields:

+
// Prisma model with JSON field
+model Page {
+  id      Int   @id @default(autoincrement())
+  content Json  // JSON field
+}
+
+// Type-safe JSON usage
+import { Prisma } from '@prisma/client';
+
+interface BlockContent {
+  type: string;
+  data: Record<string, unknown>;
+}
+
+const blocks: BlockContent[] = [
+  { type: 'text', data: { content: 'Hello' } }
+];
+
+// Cast to Prisma.InputJsonValue
+await prisma.page.create({
+  data: {
+    content: blocks as unknown as Prisma.InputJsonValue
+  }
+});
+
+// Use Prisma.JsonNull for null
+await prisma.page.update({
+  where: { id: 1 },
+  data: {
+    content: Prisma.JsonNull
+  }
+});
+
+

Drizzle Types

+

Schema Types:

+
// api/src/modules/media/db/schema.ts
+import { pgTable, serial, text, integer, timestamp } from 'drizzle-orm/pg-core';
+
+export const videos = pgTable('videos', {
+  id: serial('id').primaryKey(),
+  filename: text('filename').notNull(),
+  title: text('title'),
+  duration: integer('duration'),
+  createdAt: timestamp('created_at').defaultNow().notNull()
+});
+
+// Infer types from schema
+export type Video = typeof videos.$inferSelect;
+export type NewVideo = typeof videos.$inferInsert;
+
+// Usage
+const video: Video = {
+  id: 1,
+  filename: 'video.mp4',
+  title: 'My Video',
+  duration: 120,
+  createdAt: new Date()
+};
+
+const newVideo: NewVideo = {
+  filename: 'video.mp4',
+  title: 'My Video',
+  duration: 120
+  // id and createdAt auto-generated
+};
+
+

Zod Schemas

+

Validation Schemas:

+
import { z } from 'zod';
+
+// Login schema
+export const loginSchema = z.object({
+  email: z.string().email(),
+  password: z.string().min(12)
+});
+
+// Infer TypeScript type from Zod schema
+export type LoginInput = z.infer<typeof loginSchema>;
+
+// Usage in route
+app.post('/login', validate(loginSchema), (req: Request, res: Response) => {
+  const { email, password } = req.body as LoginInput;
+  // TypeScript knows email is string and password is string
+});
+
+// Complex schema
+export const createCampaignSchema = z.object({
+  title: z.string().min(1).max(200),
+  description: z.string().optional(),
+  targetEmails: z.array(z.string().email()),
+  active: z.boolean().default(false),
+  settings: z.object({
+    allowResponses: z.boolean(),
+    moderateResponses: z.boolean()
+  }).optional()
+});
+
+export type CreateCampaignInput = z.infer<typeof createCampaignSchema>;
+
+

React Component Types

+

Component Props:

+
// Prop types
+interface UserCardProps {
+  user: User;
+  onEdit?: (user: User) => void;
+  className?: string;
+}
+
+export function UserCard({ user, onEdit, className }: UserCardProps) {
+  return (
+    <div className={className} onClick={() => onEdit?.(user)}>
+      {user.name}
+    </div>
+  );
+}
+
+// Children prop
+interface LayoutProps {
+  children: React.ReactNode;
+  title: string;
+}
+
+export function Layout({ children, title }: LayoutProps) {
+  return (
+    <div>
+      <h1>{title}</h1>
+      {children}
+    </div>
+  );
+}
+
+// Generic component
+interface ListProps<T> {
+  items: T[];
+  renderItem: (item: T) => React.ReactNode;
+}
+
+export function List<T>({ items, renderItem }: ListProps<T>) {
+  return <div>{items.map(renderItem)}</div>;
+}
+
+

Event Handlers:

+
// Form events
+function LoginForm() {
+  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+    // ...
+  };
+
+  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    console.log(e.target.value);
+  };
+
+  return (
+    <form onSubmit={handleSubmit}>
+      <input onChange={handleChange} />
+    </form>
+  );
+}
+
+// Button click
+const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
+  console.log(e.currentTarget);
+};
+
+

Hooks:

+
import { useState, useEffect, useRef } from 'react';
+
+// useState
+const [count, setCount] = useState<number>(0);
+const [user, setUser] = useState<User | null>(null);
+
+// useEffect
+useEffect(() => {
+  async function fetchUser() {
+    const data = await api.get<User>('/user');
+    setUser(data);
+  }
+  fetchUser();
+}, []);
+
+// useRef
+const inputRef = useRef<HTMLInputElement>(null);
+const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
+
+useEffect(() => {
+  if (inputRef.current) {
+    inputRef.current.focus();
+  }
+}, []);
+
+// useReducer
+type State = { count: number };
+type Action = { type: 'increment' } | { type: 'decrement' };
+
+const reducer = (state: State, action: Action): State => {
+  switch (action.type) {
+    case 'increment':
+      return { count: state.count + 1 };
+    case 'decrement':
+      return { count: state.count - 1 };
+  }
+};
+
+const [state, dispatch] = useReducer(reducer, { count: 0 });
+
+

Zustand Store:

+
import { create } from 'zustand';
+
+interface AuthState {
+  user: User | null;
+  isAuthenticated: boolean;
+  setUser: (user: User | null) => void;
+  logout: () => void;
+}
+
+export const useAuthStore = create<AuthState>((set) => ({
+  user: null,
+  isAuthenticated: false,
+
+  setUser: (user) => set({
+    user,
+    isAuthenticated: !!user
+  }),
+
+  logout: () => set({
+    user: null,
+    isAuthenticated: false
+  })
+}));
+
+// Usage
+const { user, setUser, logout } = useAuthStore();
+
+

Type Safety

+

Avoiding any

+

Never use any (ESLint rule enforced):

+

Bad: +

function processData(data: any) {
+  return data.foo.bar; // No type safety
+}
+

+

Good: +

// Use unknown if type is truly unknown
+function processData(data: unknown) {
+  if (isValidData(data)) {
+    return data.foo.bar; // Safe after type guard
+  }
+  throw new Error('Invalid data');
+}
+
+function isValidData(data: unknown): data is { foo: { bar: string } } {
+  return (
+    typeof data === 'object' &&
+    data !== null &&
+    'foo' in data &&
+    typeof data.foo === 'object' &&
+    data.foo !== null &&
+    'bar' in data.foo
+  );
+}
+
+// Or define proper interface
+interface ValidData {
+  foo: { bar: string };
+}
+
+function processData(data: ValidData) {
+  return data.foo.bar;
+}
+

+

Type Assertions

+

Use type assertions carefully:

+

Good: +

// When you know more than TypeScript
+const input = document.getElementById('email') as HTMLInputElement;
+
+// Safer: Use type guard
+if (input instanceof HTMLInputElement) {
+  input.value = 'test@example.com';
+}
+

+

Bad: +

// Dangerous: Could be wrong
+const data = response.data as User;
+
+// Better: Validate first
+const data = validateUser(response.data);
+

+

Non-null Assertion

+

Use ! only when TypeScript can't infer non-null:

+

Good: +

// After authentication middleware
+app.get('/me', authenticate, (req, res) => {
+  const userId = req.user!.id; // Safe: authenticate ensures user exists
+});
+
+// After null check
+const user = await prisma.user.findUnique({ where: { id: 1 } });
+if (!user) {
+  throw new Error('User not found');
+}
+console.log(user!.email); // Safe: null checked above
+

+

Bad: +

// Dangerous: Could be null
+const user = await prisma.user.findUnique({ where: { id: 1 } });
+console.log(user!.email); // Could crash if user is null
+

+

Type Guards

+

Create type guards for runtime validation:

+
// Type guard function
+function isUser(obj: unknown): obj is User {
+  return (
+    typeof obj === 'object' &&
+    obj !== null &&
+    'id' in obj &&
+    'email' in obj &&
+    typeof obj.id === 'number' &&
+    typeof obj.email === 'string'
+  );
+}
+
+// Usage
+function processUser(data: unknown) {
+  if (isUser(data)) {
+    console.log(data.email); // TypeScript knows data is User
+  }
+}
+
+// Discriminated union
+type Shape =
+  | { kind: 'circle'; radius: number }
+  | { kind: 'square'; size: number };
+
+function area(shape: Shape): number {
+  switch (shape.kind) {
+    case 'circle':
+      return Math.PI * shape.radius ** 2; // TS knows: radius exists
+    case 'square':
+      return shape.size ** 2; // TS knows: size exists
+  }
+}
+
+

Common V2 Gotchas

+

Express Params as String or String[]

+

Problem: req.params.id type is string | string[] in Express 5.

+

Solution:

+
// Cast to string (if you expect single value)
+const id = parseInt(req.params.id as string);
+
+// Or check type
+const rawId = req.params.id;
+const id = typeof rawId === 'string' ? parseInt(rawId) : undefined;
+
+

useRef with Undefined

+

Problem: useRef<T>() requires explicit undefined.

+

Solution:

+
// Good
+const ref = useRef<HTMLInputElement>(undefined);
+const ref = useRef<HTMLInputElement | null>(null);
+
+// Bad
+const ref = useRef<HTMLInputElement>(); // Type error
+
+

Prisma JSON Fields

+

Problem: JSON arrays need cast.

+

Solution:

+
import { Prisma } from '@prisma/client';
+
+// Cast array to Prisma.InputJsonValue
+const blocks: Block[] = [...];
+await prisma.page.create({
+  data: {
+    content: blocks as unknown as Prisma.InputJsonValue
+  }
+});
+
+// Use Prisma.JsonNull for null
+await prisma.page.update({
+  where: { id: 1 },
+  data: { content: Prisma.JsonNull }
+});
+
+

Mixing ?? and ||

+

Problem: Cannot mix ?? and || without parentheses.

+

Solution:

+
// Error
+const value = a ?? b || c;
+
+// Good
+const value = (a ?? b) || c;
+const value = a ?? (b || c);
+
+

Record Cast

+

Problem: Need to cast via unknown first.

+

Solution:

+
// Error
+const obj: Record<string, unknown> = someData;
+
+// Good
+const obj = someData as unknown as Record<string, unknown>;
+
+

dayjs via Ant Design

+

Problem: dayjs available transitively.

+

Solution:

+
// No need to install dayjs separately
+import dayjs from 'dayjs'; // Available via antd
+
+

req.user Name Field

+

Problem: JWT only has id, email, role (no name).

+

Solution:

+
// JWT payload
+interface JWTPayload {
+  id: number;
+  email: string;
+  role: string;
+}
+
+// Augmented request
+declare global {
+  namespace Express {
+    interface Request {
+      user?: JWTPayload; // Not full User
+    }
+  }
+}
+
+// If you need name, fetch from database
+const user = await prisma.user.findUnique({
+  where: { id: req.user!.id }
+});
+console.log(user?.name);
+
+

API Import Pattern

+

Problem: Named export, not default.

+

Solution:

+
// Good
+import { api } from '../lib/api';
+
+// Bad
+import api from '../lib/api'; // Error
+
+

Unchecked Create/Update

+

Problem: Setting foreign keys directly.

+

Solution:

+
// Use Unchecked variants
+const data: Prisma.CampaignUncheckedCreateInput = {
+  title: 'Campaign',
+  createdByUserId: 1 // Can set FK directly
+};
+
+// Regular CreateInput requires nested create
+const data: Prisma.CampaignCreateInput = {
+  title: 'Campaign',
+  createdBy: {
+    connect: { id: 1 }
+  }
+};
+
+

Type Utilities

+

Custom Utility Types

+
// Make specific fields optional
+type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
+
+type UpdateUserInput = PartialBy<User, 'name' | 'email'>;
+// id required, name and email optional
+
+// Make specific fields required
+type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
+
+type UserWithEmail = RequiredBy<User, 'email'>;
+// All fields optional except email
+
+// Deep partial
+type DeepPartial<T> = {
+  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
+};
+
+// Nullable
+type Nullable<T> = T | null;
+
+type NullableUser = Nullable<User>;
+// User | null
+
+

Type Extraction

+
// Extract specific keys
+type UserKeys = keyof User;
+// 'id' | 'email' | 'password' | 'role' | ...
+
+// Extract value types
+type UserEmail = User['email'];
+// string
+
+// Extract function return type
+function getUser() {
+  return { id: 1, email: 'john@example.com' };
+}
+
+type User = ReturnType<typeof getUser>;
+// { id: number; email: string }
+
+// Extract promise result type
+async function fetchUser(): Promise<User> {
+  // ...
+}
+
+type FetchUserResult = Awaited<ReturnType<typeof fetchUser>>;
+// User (not Promise<User>)
+
+

Performance

+

Build Times

+

Optimize tsconfig.json:

+
{
+  "compilerOptions": {
+    "skipLibCheck": true, // Skip type checking node_modules
+    "incremental": true,  // Enable incremental compilation
+    "tsBuildInfoFile": ".tsbuildinfo" // Cache file
+  }
+}
+
+

Type-check without emit:

+
# Faster than full build
+npx tsc --noEmit
+
+

Type Inference

+

Let TypeScript infer when possible:

+

Good: +

// TypeScript infers string[]
+const emails = users.map(u => u.email);
+
+// TypeScript infers number
+const total = amounts.reduce((sum, n) => sum + n, 0);
+

+

Bad: +

// Unnecessary explicit type
+const emails: string[] = users.map(u => u.email);
+

+

Migration from JavaScript

+

Gradual Typing

+

Add types incrementally:

+

Step 1: Allow implicit any

+
{
+  "compilerOptions": {
+    "noImplicitAny": false
+  }
+}
+
+

Step 2: Add types to new code

+
// New functions with types
+function createUser(email: string, password: string): User {
+  // ...
+}
+
+

Step 3: Add types to existing code

+
// Old function (before)
+function getUser(id) {
+  return prisma.user.findUnique({ where: { id } });
+}
+
+// Add types (after)
+function getUser(id: number): Promise<User | null> {
+  return prisma.user.findUnique({ where: { id } });
+}
+
+

Step 4: Enable strict mode

+
{
+  "compilerOptions": {
+    "strict": true
+  }
+}
+
+ + +

Summary

+

You now know: +- ✅ TypeScript type system fundamentals +- ✅ Common V2 patterns (Prisma, Drizzle, Zod, React) +- ✅ Type safety best practices (avoid any, type guards) +- ✅ V2-specific gotchas and solutions +- ✅ Custom utility types +- ✅ Performance optimization +- ✅ Gradual migration from JavaScript

+

Quick Reference: +

// Prisma types
+import { User, Prisma } from '@prisma/client';
+
+// Zod types
+const schema = z.object({ email: z.string().email() });
+type Input = z.infer<typeof schema>;
+
+// React types
+interface Props {
+  user: User;
+  onEdit?: (user: User) => void;
+}
+
+// Type guards
+function isUser(obj: unknown): obj is User {
+  return typeof obj === 'object' && obj !== null && 'id' in obj;
+}
+
+// Utility types
+type UpdateInput = Partial<User>;
+type UserPreview = Pick<User, 'id' | 'email'>;
+

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/COMPLETION_STATUS/index.html b/mkdocs/site/v2/features/COMPLETION_STATUS/index.html new file mode 100644 index 00000000..393b7879 --- /dev/null +++ b/mkdocs/site/v2/features/COMPLETION_STATUS/index.html @@ -0,0 +1,1190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Phase 6 Features Documentation - Completion Status - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Phase 6 Features Documentation - Completion Status

+

Overview

+

Phase 6 creates comprehensive end-to-end feature documentation showing how complete features work across backend + frontend + database layers.

+

Target: 26 feature documentation files +Created: 6 files (23%) +Remaining: 20 files (77%)

+

Completed Files (6/26)

+

Influence Features (⅚)

+

campaigns.md (1,118 lines) - Campaign management system with lifecycle, feature flags, admin/public workflows +✅ representatives.md (1,048 lines) - Represent API integration, caching, postal code lookup +✅ responses.md (1,064 lines) - Response wall submission, moderation, upvoting, email verification +✅ email-queue.md (994 lines) - BullMQ email processing, queue monitoring, retry logic +✅ postal-codes.md (151 lines) - Postal code geocoding cache

+

call-tracking.md - Phone call tracking (not yet implemented in codebase)

+

Core Features (1/1)

+

index.md (155 lines) - Features documentation index with navigation

+

Remaining Files (20/26)

+

Map Features (0/9)

+

map/locations.md - Location management (building + unit architecture, NAR integration, CSV import/export) +❌ map/geocoding.md - Multi-provider geocoding (6 providers, fallback chain, confidence scoring) +❌ map/cuts.md - Geographic polygon overlays (GeoJSON storage, point-in-polygon, drawing mode) +❌ map/shifts.md - Volunteer shift management (signup workflow, email notifications) +❌ map/canvassing.md - Canvassing session system (visit outcomes, walking routes, GPS tracking) +❌ map/tracking.md - GPS tracking (breadcrumb trails, route visualization, distance calculation) +❌ map/walk-sheets.md - Printable walk sheets (QR codes, browser print API) +❌ map/data-quality.md - Geocoding quality dashboard (confidence metrics, provider success rates) +❌ map/nar-import.md - NAR 2025 electoral data import (province selector, streaming import, EPSG:3347 projection)

+

Landing Pages Features (0/4)

+

pages/page-builder.md - GrapesJS landing page builder (dual-mode editing, block library) +❌ pages/grapes-editor.md - GrapesJS editor component (forwardRef pattern, error boundary) +❌ pages/block-library.md - Reusable page blocks (6 default blocks, JSON schema) +❌ pages/mkdocs-export.md - MkDocs Material theme export (Jinja2 templates, overrides)

+

Email Templates Features (0/4)

+

email-templates/template-system.md - Email template engine (categories, variable interpolation, Handlebars) +❌ email-templates/editor.md - Email template editor (HTML editing, variable insertion, preview) +❌ email-templates/variables.md - Template variable system (required/optional, conditional blocks) +❌ email-templates/versioning.md - Template version history (auto-increment, rollback, change notes)

+

Media Features (0/4)

+

media/video-library.md - Video library management (9 directory types, FFprobe metadata) +❌ media/upload.md - Video upload system (automatic metadata extraction, 10GB limit, 7 formats) +❌ media/jobs.md - Media job queue (job types, resource categories, status flow) +❌ media/public-gallery.md - Public video gallery (categories, lock/unlock, reactions, comments)

+

Newsletter Features (0/3)

+

newsletter/listmonk-integration.md - Listmonk REST API integration (native fetch client, basic auth) +❌ newsletter/sync.md - Data sync to Listmonk (participants/locations/users → lists) +❌ newsletter/lists.md - Newsletter list management (results pagination, subscriber attributes)

+

Tunnel Features (0/3)

+

tunnel/pangolin-setup.md - Pangolin tunnel configuration (self-hosted API, setup wizard) +❌ tunnel/newt-container.md - Newt Docker integration (nginx dependency, tunnel lifecycle) +❌ tunnel/exit-nodes.md - Tunnel exit node management (routing setup, performance monitoring)

+

Observability Features (0/4)

+

observability/prometheus-metrics.md - Custom metrics collection (12 cm_* metrics, HTTP metrics) +❌ observability/grafana-dashboards.md - Grafana visualization (3 pre-configured dashboards) +❌ observability/alertmanager.md - Alert routing (12 alert rules, notification channels) +❌ observability/data-quality.md - Data quality monitoring (geocoding confidence, validation)

+

File Structure Template

+

Each feature file should follow this 12-section structure:

+
    +
  1. Overview — Feature purpose, use cases, key capabilities
  2. +
  3. Architecture — Mermaid diagram showing frontend → API → service → database flow
  4. +
  5. Database Models — Related models with links to database docs
  6. +
  7. API Endpoints — List of endpoints with links to API reference docs
  8. +
  9. Configuration — Environment variables, settings, feature flags (table format)
  10. +
  11. Admin Workflow — Step-by-step guide for administrators
  12. +
  13. Public Workflow — Step-by-step guide for public users (if applicable)
  14. +
  15. Volunteer Workflow — Step-by-step guide for volunteers (if applicable)
  16. +
  17. Code Examples — Real code snippets from backend/frontend
  18. +
  19. Troubleshooting — Common issues + solutions
  20. +
  21. Performance Considerations — Optimization tips, scaling notes
  22. +
  23. Related Documentation — Links to backend modules, frontend pages, database models
  24. +
+

Source References

+

Completed Files Reference:

+
    +
  • api/src/modules/influence/campaigns/ → campaigns.md
  • +
  • api/src/modules/influence/representatives/ → representatives.md
  • +
  • api/src/modules/influence/responses/ → responses.md
  • +
  • api/src/services/email-queue.service.ts → email-queue.md
  • +
  • admin/src/pages/CampaignsPage.tsx → campaigns.md
  • +
  • admin/src/pages/ResponsesPage.tsx → responses.md
  • +
+

For Remaining Files:

+
    +
  • api/src/modules/map/locations/ → locations.md
  • +
  • api/src/modules/map/geocoding/ → geocoding.md
  • +
  • api/src/modules/map/cuts/ → cuts.md
  • +
  • api/src/modules/map/shifts/ → shifts.md
  • +
  • api/src/modules/map/canvass/ → canvassing.md
  • +
  • api/src/modules/map/tracking/ → tracking.md
  • +
  • api/src/modules/pages/ → page-builder.md, block-library.md
  • +
  • api/src/modules/email-templates/ → template-system.md, editor.md, variables.md, versioning.md
  • +
  • api/src/modules/media/ → video-library.md, upload.md, jobs.md, public-gallery.md
  • +
  • api/src/services/listmonk.client.ts → listmonk-integration.md
  • +
  • api/src/services/pangolin.client.ts → pangolin-setup.md
  • +
  • api/src/utils/metrics.ts → prometheus-metrics.md
  • +
+

Statistics

+

Total Lines Created: ~4,530 lines across 6 files +Average File Size: ~755 lines +Estimated Remaining: ~15,100 lines (20 files × 755 avg) +Total Target: ~19,630 lines across 26 files

+

Next Steps

+
    +
  1. Create map features (highest priority - core platform functionality)
  2. +
  3. Create landing pages features (GrapesJS integration)
  4. +
  5. Create media features (video library + upload)
  6. +
  7. Create email templates features
  8. +
  9. Create newsletter features
  10. +
  11. Create tunnel features
  12. +
  13. Create observability features
  14. +
+

Notes

+
    +
  • All completed files include comprehensive Mermaid architecture diagrams
  • +
  • Real code examples extracted from source files (not invented)
  • +
  • Cross-references to Phase 3 (backend modules), Phase 4 (frontend pages), Phase 5 (database models)
  • +
  • Configuration tables with all environment variables
  • +
  • Troubleshooting sections with common errors and solutions
  • +
  • Performance considerations with optimization tips
  • +
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/email-templates/editor/index.html b/mkdocs/site/v2/features/email-templates/editor/index.html new file mode 100644 index 00000000..44c6804b --- /dev/null +++ b/mkdocs/site/v2/features/email-templates/editor/index.html @@ -0,0 +1,7135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Editor - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Email Template Editor

+

Overview

+

The Email Template Editor provides a powerful interface for creating and modifying email templates with live preview, variable insertion, and test send functionality. It supports split-pane editing for HTML and plain text versions, visual variable insertion, and real-time rendering with sample data.

+

Key Features:

+
    +
  • Split-Pane Editor — Side-by-side HTML and text editing
  • +
  • Variable Insertion Buttons — Click to insert {{VARIABLES}} at cursor position
  • +
  • Live Preview Rendering — See rendered HTML with sample data in real-time
  • +
  • Test Send Functionality — Send test emails with custom sample data
  • +
  • Auto-Save Drafts — Prevent data loss with automatic draft saving
  • +
  • Version Creation — Every save creates a new version with change notes
  • +
  • Responsive Layout — Desktop-optimized (mobile warning for small screens)
  • +
  • Keyboard Shortcuts — Ctrl+S to save, Ctrl+P to preview, Esc to close
  • +
+

Access Control: +- Role Required: SUPER_ADMIN only +- Route: /app/email-templates/:id/edit +- Layout: Full-screen (no AppLayout sidebar)

+
+

Architecture

+
flowchart TB
+    subgraph "Editor UI Components"
+        Editor[EmailTemplateEditorPage]
+        Toolbar[Editor Toolbar]
+        HtmlEditor[HTML Editor Pane]
+        TextEditor[Text Editor Pane]
+        VarPanel[Variable Insertion Panel]
+        Preview[Live Preview Pane]
+        TestForm[Test Send Form]
+    end
+
+    subgraph "State Management"
+        State[Component State]
+        Draft[LocalStorage Draft]
+        AutoSave[Auto-Save Timer]
+    end
+
+    subgraph "API Layer"
+        GetTemplate[GET /api/email-templates/:id]
+        UpdateTemplate[PUT /api/email-templates/:id]
+        TestSend[POST /api/email-templates/:id/test]
+    end
+
+    subgraph "Backend Processing"
+        Template[(EmailTemplate)]
+        Variables[(EmailTemplateVariable)]
+        Handlebars[Handlebars Compiler]
+        EmailService[Email Service]
+        TestLog[(EmailTemplateTestLog)]
+    end
+
+    Editor --> Toolbar
+    Editor --> HtmlEditor
+    Editor --> TextEditor
+    Editor --> VarPanel
+    Editor --> Preview
+    Editor --> TestForm
+
+    Editor --> State
+    State --> Draft
+    State --> AutoSave
+
+    Editor -->|Load| GetTemplate
+    GetTemplate --> Template
+    GetTemplate --> Variables
+
+    VarPanel -->|Insert| HtmlEditor
+    VarPanel -->|Insert| TextEditor
+
+    HtmlEditor -->|Debounce 300ms| Preview
+    TextEditor --> State
+
+    Preview --> Handlebars
+    Handlebars -->|Render HTML| Preview
+
+    Toolbar -->|Save Click| UpdateTemplate
+    UpdateTemplate --> Template
+    UpdateTemplate -->|Create Version| Versions[(EmailTemplateVersion)]
+
+    TestForm --> TestSend
+    TestSend --> EmailService
+    EmailService -->|Send| SMTP[Nodemailer]
+    SMTP --> TestLog
+
+    AutoSave --> Draft
+
+    style Editor fill:#4a90e2,color:#fff
+    style Template fill:#50c878,color:#fff
+    style Preview fill:#ffb347,color:#333
+

Data Flow:

+
    +
  1. Load Template — Fetch template + variables via GET API
  2. +
  3. Restore Draft — Load from localStorage if exists (unsaved changes)
  4. +
  5. Edit Content — Type in HTML/text editors, updates component state
  6. +
  7. Insert Variable — Click variable button → inserts {{VAR}} at cursor
  8. +
  9. Preview Update — Debounced (300ms) Handlebars compilation + iframe render
  10. +
  11. Test Send — Enter recipient + sample data → POST to test endpoint → email sent
  12. +
  13. Save Template — Click save → PUT API → create version → clear draft → redirect
  14. +
  15. Auto-Save Draft — Blur event → save to localStorage (not database)
  16. +
+
+

Editor Components

+

Toolbar

+

Location: Top bar (sticky)

+

Elements: +- Template Name — Read-only display (left) +- Save Button — Saves changes and creates version (right) +- Preview Toggle — Show/hide live preview pane (right) +- Test Send Button — Opens test send modal (right) +- Back Button — Returns to EmailTemplatesPage (left)

+

Actions: +

const handleSave = async () => {
+  setSaving(true);
+  try {
+    await api.put(`/api/email-templates/${id}`, {
+      subjectLine,
+      htmlContent,
+      textContent,
+      changeNotes,
+    });
+
+    message.success('Template saved successfully');
+    localStorage.removeItem(`email-template-draft-${id}`);
+    navigate('/app/email-templates');
+  } catch (error) {
+    message.error('Failed to save template');
+  } finally {
+    setSaving(false);
+  }
+};
+

+
+

HTML Editor Pane

+

Location: Left side (50% width) or full width when preview hidden

+

Features: +- Textarea or Monaco Editor — Syntax highlighting (Monaco upgrade path) +- Line Numbers — Visual line number gutter +- Auto-Resize — Grows to fit content (max 80vh) +- Tab Support — Tab key inserts 2 spaces (not focus change)

+

Implementation: +

const [htmlContent, setHtmlContent] = useState('');
+const htmlEditorRef = useRef<HTMLTextAreaElement>(null);
+
+const handleHtmlChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+  setHtmlContent(e.target.value);
+  debouncedPreview(e.target.value, sampleData);
+};
+
+const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+  // Tab key support
+  if (e.key === 'Tab') {
+    e.preventDefault();
+    const textarea = e.currentTarget;
+    const start = textarea.selectionStart;
+    const end = textarea.selectionEnd;
+
+    setHtmlContent(
+      htmlContent.substring(0, start) + '  ' + htmlContent.substring(end)
+    );
+
+    setTimeout(() => {
+      textarea.selectionStart = textarea.selectionEnd = start + 2;
+    }, 0);
+  }
+};
+

+
+

Text Editor Pane

+

Location: Left side (50% width) or full width when preview hidden

+

Features: +- Plain Text Editing — No syntax highlighting needed +- Auto-Resize — Matches HTML editor height +- Variable Insertion — Same insertion panel as HTML editor

+

Implementation: +

const [textContent, setTextContent] = useState('');
+const textEditorRef = useRef<HTMLTextAreaElement>(null);
+
+const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+  setTextContent(e.target.value);
+};
+

+
+

Variable Insertion Panel

+

Location: Right sidebar (collapsible)

+

Features: +- Variable List — All template variables with labels +- Insert Buttons — Click to insert {{VAR}} at cursor +- Required Badge — Red badge for required variables +- Conditional Badge — Blue badge for conditional variables +- Sample Value Display — Shows example value below each variable +- Search/Filter — Filter variables by name (if many variables)

+

Implementation: +

const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {
+  const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;
+  if (!textarea) return;
+
+  const start = textarea.selectionStart;
+  const end = textarea.selectionEnd;
+  const content = editorType === 'html' ? htmlContent : textContent;
+
+  const before = content.substring(0, start);
+  const after = content.substring(end);
+  const newContent = before + `{{${variableKey}}}` + after;
+
+  if (editorType === 'html') {
+    setHtmlContent(newContent);
+  } else {
+    setTextContent(newContent);
+  }
+
+  // Move cursor after inserted variable
+  setTimeout(() => {
+    const newPos = start + variableKey.length + 4; // 4 = {{ + }}
+    textarea.selectionStart = newPos;
+    textarea.selectionEnd = newPos;
+    textarea.focus();
+  }, 0);
+};
+

+

Variable List UI: +

<Space direction="vertical" style={{ width: '100%' }}>
+  {variables
+    .sort((a, b) => a.sortOrder - b.sortOrder)
+    .map((variable) => (
+      <Card key={variable.id} size="small">
+        <Space direction="vertical" size={0} style={{ width: '100%' }}>
+          <Space>
+            <Text strong>{variable.label}</Text>
+            {variable.isRequired && <Tag color="red">Required</Tag>}
+            {variable.isConditional && <Tag color="blue">Conditional</Tag>}
+          </Space>
+
+          <Text type="secondary" style={{ fontSize: 12 }}>
+            {variable.description}
+          </Text>
+
+          {variable.sampleValue && (
+            <Text code style={{ fontSize: 11 }}>
+              Example: {variable.sampleValue}
+            </Text>
+          )}
+
+          <Space size="small">
+            <Button
+              size="small"
+              onClick={() => handleInsertVariable(variable.key, 'html')}
+            >
+              Insert to HTML
+            </Button>
+            <Button
+              size="small"
+              onClick={() => handleInsertVariable(variable.key, 'text')}
+            >
+              Insert to Text
+            </Button>
+          </Space>
+        </Space>
+      </Card>
+    ))}
+</Space>
+

+
+

Live Preview Pane

+

Location: Right side (50% width) when enabled

+

Features: +- Iframe Rendering — Isolated HTML preview +- Sample Data Form — Edit sample variable values +- Desktop/Mobile Toggle — Preview in different viewport sizes +- Debounced Updates — Renders 300ms after typing stops +- Error Display — Shows Handlebars compilation errors

+

Implementation: +

import Handlebars from 'handlebars';
+
+const [previewHtml, setPreviewHtml] = useState('');
+const [sampleData, setSampleData] = useState<Record<string, unknown>>({});
+const previewRef = useRef<HTMLIFrameElement>(null);
+
+const renderPreview = useCallback((html: string, data: Record<string, unknown>) => {
+  try {
+    const compiled = Handlebars.compile(html);
+    const rendered = compiled(data);
+
+    // Inject into iframe
+    if (previewRef.current?.contentDocument) {
+      const doc = previewRef.current.contentDocument;
+      doc.open();
+      doc.write(`
+        <!DOCTYPE html>
+        <html>
+          <head>
+            <meta charset="UTF-8">
+            <meta name="viewport" content="width=device-width, initial-scale=1.0">
+            <style>
+              body { font-family: Arial, sans-serif; padding: 20px; }
+            </style>
+          </head>
+          <body>${rendered}</body>
+        </html>
+      `);
+      doc.close();
+    }
+
+    setPreviewHtml(rendered);
+  } catch (error) {
+    console.error('Preview render error:', error);
+    setPreviewError(error.message);
+  }
+}, []);
+
+const debouncedPreview = useMemo(
+  () => debounce(renderPreview, 300),
+  [renderPreview]
+);
+
+// Update preview when HTML or sample data changes
+useEffect(() => {
+  debouncedPreview(htmlContent, sampleData);
+}, [htmlContent, sampleData, debouncedPreview]);
+

+

Sample Data Form: +

const handleSampleDataChange = (variableKey: string, value: unknown) => {
+  setSampleData((prev) => ({
+    ...prev,
+    [variableKey]: value,
+  }));
+};
+
+// Render form
+<Space direction="vertical" style={{ width: '100%', marginBottom: 16 }}>
+  <Title level={5}>Sample Data</Title>
+  {variables.map((variable) => (
+    <Form.Item key={variable.id} label={variable.label}>
+      <Input
+        value={sampleData[variable.key] as string || ''}
+        onChange={(e) => handleSampleDataChange(variable.key, e.target.value)}
+        placeholder={variable.sampleValue || ''}
+      />
+    </Form.Item>
+  ))}
+</Space>
+

+
+

Test Send Form

+

Location: Modal dialog

+

Features: +- Recipient Email Input — Where to send test email +- Sample Data Editor — JSON editor or form fields +- Send Button — Triggers test send API call +- Success/Failure Notification — Shows send result +- Test Log Link — Link to test send history

+

Implementation: +

const [testModalVisible, setTestModalVisible] = useState(false);
+const [testRecipient, setTestRecipient] = useState('');
+const [testData, setTestData] = useState<Record<string, unknown>>({});
+
+const handleTestSend = async () => {
+  if (!testRecipient) {
+    message.error('Please enter recipient email');
+    return;
+  }
+
+  setTestSending(true);
+  try {
+    await api.post(`/api/email-templates/${id}/test`, {
+      recipientEmail: testRecipient,
+      testData,
+    });
+
+    message.success('Test email sent successfully');
+    setTestModalVisible(false);
+  } catch (error) {
+    message.error('Failed to send test email');
+  } finally {
+    setTestSending(false);
+  }
+};
+
+// Modal UI
+<Modal
+  title="Send Test Email"
+  visible={testModalVisible}
+  onOk={handleTestSend}
+  onCancel={() => setTestModalVisible(false)}
+  confirmLoading={testSending}
+  okText="Send Test"
+>
+  <Form layout="vertical">
+    <Form.Item label="Recipient Email" required>
+      <Input
+        type="email"
+        value={testRecipient}
+        onChange={(e) => setTestRecipient(e.target.value)}
+        placeholder="your-email@example.com"
+      />
+    </Form.Item>
+
+    <Form.Item label="Sample Data">
+      <Space direction="vertical" style={{ width: '100%' }}>
+        {variables.map((variable) => (
+          <Input
+            key={variable.id}
+            addonBefore={variable.label}
+            value={testData[variable.key] as string || ''}
+            onChange={(e) =>
+              setTestData((prev) => ({ ...prev, [variable.key]: e.target.value }))
+            }
+            placeholder={variable.sampleValue || ''}
+          />
+        ))}
+      </Space>
+    </Form.Item>
+  </Form>
+</Modal>
+

+
+

Admin Workflow

+

Opening Editor

+

From EmailTemplatesPage:

+
    +
  1. Click template row in table
  2. +
  3. Opens template detail modal
  4. +
  5. Click "Edit" button in modal
  6. +
  7. Opens EmailTemplateEditorPage in same tab
  8. +
+

Direct URL: +

/app/email-templates/{id}/edit
+

+

Route Definition: +

// admin/src/App.tsx
+
+<Route
+  path="/app/email-templates/:id/edit"
+  element={
+    <ProtectedRoute allowedRoles={[SUPER_ADMIN]}>
+      <EmailTemplateEditorPage />
+    </ProtectedRoute>
+  }
+/>
+

+
+

Editing HTML Content

+

Step 1: Load Template +- Template data fetched via API on component mount +- HTML/text content populated in editors +- Variables loaded in insertion panel

+

Step 2: Edit HTML +- Type HTML with {{VARIABLES}} placeholders +- Use variable insertion buttons for convenience +- Preview updates automatically (300ms debounce)

+

Step 3: Insert Variables +- Click variable "Insert to HTML" button +- {{VARIABLE_KEY}} inserted at cursor position +- Cursor moves after inserted variable

+

Step 4: Preview Changes +- Live preview pane shows rendered HTML +- Edit sample data to test different values +- Check for formatting issues

+

Example Editing Session: +

<!-- Initial HTML -->
+<p>Dear {{USER_NAME}},</p>
+<p>Thank you for signing up.</p>
+
+<!-- Add shift details -->
+<p>Dear {{USER_NAME}},</p>
+<p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong>.</p>
+<ul>
+  <li>Date: {{SHIFT_DATE}}</li>
+  <li>Time: {{SHIFT_TIME}}</li>
+</ul>
+
+<!-- Add conditional phone -->
+<p>Dear {{USER_NAME}},</p>
+<p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong>.</p>
+<ul>
+  <li>Date: {{SHIFT_DATE}}</li>
+  <li>Time: {{SHIFT_TIME}}</li>
+</ul>
+
+{{#if HAS_PHONE}}
+<p>We'll call you at {{USER_PHONE}} if there are any changes.</p>
+{{/if}}
+

+
+

Using Variable Insertion

+

Keyboard Method: +1. Type {{ in HTML editor +2. Type variable name (e.g., USER_NAME) +3. Type }}

+

Button Method: +1. Place cursor where you want variable +2. Click variable "Insert to HTML" button +3. {{VARIABLE_KEY}} inserted at cursor +4. Cursor moves to end of insertion

+

Insertion Logic: +

const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {
+  const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;
+  if (!textarea) return;
+
+  const start = textarea.selectionStart;
+  const end = textarea.selectionEnd;
+  const content = editorType === 'html' ? htmlContent : textContent;
+
+  // Replace selection with variable
+  const before = content.substring(0, start);
+  const after = content.substring(end);
+  const variable = `{{${variableKey}}}`;
+  const newContent = before + variable + after;
+
+  // Update state
+  if (editorType === 'html') {
+    setHtmlContent(newContent);
+  } else {
+    setTextContent(newContent);
+  }
+
+  // Move cursor to end of inserted variable
+  setTimeout(() => {
+    const newPos = start + variable.length;
+    textarea.selectionStart = newPos;
+    textarea.selectionEnd = newPos;
+    textarea.focus();
+  }, 0);
+};
+

+
+

Live Preview

+

Preview Update Flow:

+
    +
  1. Type in HTML Editor
  2. +
  3. onChange event fires
  4. +
  5. Updates htmlContent state
  6. +
  7. +

    Triggers debounced preview render (300ms)

    +
  8. +
  9. +

    Debounced Render

    +
  10. +
  11. Waits 300ms after typing stops
  12. +
  13. Compiles Handlebars template
  14. +
  15. Interpolates with sample data
  16. +
  17. +

    Injects HTML into iframe

    +
  18. +
  19. +

    Sample Data Changes

    +
  20. +
  21. Edit sample data form fields
  22. +
  23. Updates sampleData state
  24. +
  25. Immediately triggers preview render (no debounce)
  26. +
+

Preview Error Handling: +

const renderPreview = (html: string, data: Record<string, unknown>) => {
+  try {
+    const compiled = Handlebars.compile(html);
+    const rendered = compiled(data);
+
+    // Inject into iframe...
+    setPreviewError(null);
+  } catch (error) {
+    // Show error in preview pane
+    setPreviewError(error.message);
+
+    if (previewRef.current?.contentDocument) {
+      const doc = previewRef.current.contentDocument;
+      doc.open();
+      doc.write(`
+        <div style="color: red; padding: 20px;">
+          <h3>Preview Error</h3>
+          <pre>${error.message}</pre>
+        </div>
+      `);
+      doc.close();
+    }
+  }
+};
+

+
+

Testing Template

+

Step 1: Click "Send Test" Button +- Opens test send modal

+

Step 2: Enter Recipient Email +- Your email address (or test account) +- Validates email format before sending

+

Step 3: Edit Sample Data +- Pre-filled with variable sample values +- Modify to test specific scenarios +- Example: Set HAS_PHONE to false to test conditional block

+

Step 4: Click "Send Test" +- POST request to /api/email-templates/:id/test +- Email sent via SMTP (or MailHog in test mode) +- Success notification displayed

+

Step 5: Check Email +- Open email client (or MailHog at http://localhost:8025) +- Verify rendering, variables, formatting +- Test links, images, layout

+

Step 6: Review Test Log +- Navigate to "Test Logs" tab in template detail modal +- See test send history (recipient, timestamp, success/failure) +- Debug errors if send failed

+
+

Saving Changes

+

Step 1: Click "Save" Button +- Toolbar save button (or Ctrl+S keyboard shortcut)

+

Step 2: Enter Change Notes +- Modal prompts for change description +- Used for version history audit trail +- Optional but recommended

+

Step 3: Confirm Save +- PUT request to /api/email-templates/:id +- Creates new version automatically +- Clears localStorage draft +- Redirects to EmailTemplatesPage

+

Save Implementation: +

const [saveModalVisible, setSaveModalVisible] = useState(false);
+const [changeNotes, setChangeNotes] = useState('');
+
+const handleSave = async () => {
+  setSaving(true);
+  try {
+    await api.put(`/api/email-templates/${id}`, {
+      subjectLine,
+      htmlContent,
+      textContent,
+      changeNotes: changeNotes || undefined,
+    });
+
+    message.success('Template saved successfully');
+
+    // Clear draft
+    localStorage.removeItem(`email-template-draft-${id}`);
+
+    // Redirect
+    navigate('/app/email-templates');
+  } catch (error) {
+    message.error('Failed to save template');
+  } finally {
+    setSaving(false);
+    setSaveModalVisible(false);
+  }
+};
+
+// Keyboard shortcut
+useEffect(() => {
+  const handleKeyDown = (e: KeyboardEvent) => {
+    if ((e.ctrlKey || e.metaKey) && e.key === 's') {
+      e.preventDefault();
+      setSaveModalVisible(true);
+    }
+  };
+
+  window.addEventListener('keydown', handleKeyDown);
+  return () => window.removeEventListener('keydown', handleKeyDown);
+}, []);
+

+
+

Code Examples

+

EmailTemplateEditorPage Component

+

Full Component Structure:

+
// admin/src/pages/EmailTemplateEditorPage.tsx
+
+import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { Button, Input, Space, Card, Tag, Typography, Modal, Form, message } from 'antd';
+import { SaveOutlined, SendOutlined, ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
+import Handlebars from 'handlebars';
+import { debounce } from 'lodash';
+import { api } from '@/lib/api';
+import type { EmailTemplate, EmailTemplateVariable } from '@/types/api';
+
+const { Title, Text } = Typography;
+const { TextArea } = Input;
+
+export default function EmailTemplateEditorPage() {
+  const { id } = useParams<{ id: string }>();
+  const navigate = useNavigate();
+
+  // State
+  const [loading, setLoading] = useState(true);
+  const [saving, setSaving] = useState(false);
+  const [template, setTemplate] = useState<EmailTemplate | null>(null);
+  const [variables, setVariables] = useState<EmailTemplateVariable[]>([]);
+
+  const [subjectLine, setSubjectLine] = useState('');
+  const [htmlContent, setHtmlContent] = useState('');
+  const [textContent, setTextContent] = useState('');
+
+  const [showPreview, setShowPreview] = useState(true);
+  const [sampleData, setSampleData] = useState<Record<string, unknown>>({});
+  const [previewError, setPreviewError] = useState<string | null>(null);
+
+  const [testModalVisible, setTestModalVisible] = useState(false);
+  const [testRecipient, setTestRecipient] = useState('');
+  const [testSending, setTestSending] = useState(false);
+
+  const [saveModalVisible, setSaveModalVisible] = useState(false);
+  const [changeNotes, setChangeNotes] = useState('');
+
+  // Refs
+  const htmlEditorRef = useRef<HTMLTextAreaElement>(null);
+  const textEditorRef = useRef<HTMLTextAreaElement>(null);
+  const previewRef = useRef<HTMLIFrameElement>(null);
+
+  // Load template
+  useEffect(() => {
+    const loadTemplate = async () => {
+      try {
+        const response = await api.get(`/api/email-templates/${id}`);
+        const { template: tmpl, variables: vars } = response.data;
+
+        setTemplate(tmpl);
+        setVariables(vars);
+
+        setSubjectLine(tmpl.subjectLine);
+        setHtmlContent(tmpl.htmlContent);
+        setTextContent(tmpl.textContent);
+
+        // Initialize sample data from variable sample values
+        const initialSampleData: Record<string, unknown> = {};
+        vars.forEach((v: EmailTemplateVariable) => {
+          if (v.sampleValue) {
+            initialSampleData[v.key] = v.sampleValue;
+          }
+        });
+        setSampleData(initialSampleData);
+
+        // Restore draft if exists
+        const draft = localStorage.getItem(`email-template-draft-${id}`);
+        if (draft) {
+          const { subjectLine: draftSubject, htmlContent: draftHtml, textContent: draftText } = JSON.parse(draft);
+          setSubjectLine(draftSubject);
+          setHtmlContent(draftHtml);
+          setTextContent(draftText);
+          message.info('Restored unsaved changes from draft');
+        }
+
+        setLoading(false);
+      } catch (error) {
+        message.error('Failed to load template');
+        navigate('/app/email-templates');
+      }
+    };
+
+    loadTemplate();
+  }, [id, navigate]);
+
+  // Auto-save draft to localStorage
+  useEffect(() => {
+    if (!loading && template) {
+      const draft = {
+        subjectLine,
+        htmlContent,
+        textContent,
+      };
+      localStorage.setItem(`email-template-draft-${id}`, JSON.stringify(draft));
+    }
+  }, [subjectLine, htmlContent, textContent, loading, template, id]);
+
+  // Preview rendering
+  const renderPreview = useCallback((html: string, data: Record<string, unknown>) => {
+    try {
+      const compiled = Handlebars.compile(html);
+      const rendered = compiled(data);
+
+      if (previewRef.current?.contentDocument) {
+        const doc = previewRef.current.contentDocument;
+        doc.open();
+        doc.write(`
+          <!DOCTYPE html>
+          <html>
+            <head>
+              <meta charset="UTF-8">
+              <meta name="viewport" content="width=device-width, initial-scale=1.0">
+              <style>
+                body {
+                  font-family: Arial, sans-serif;
+                  padding: 20px;
+                  line-height: 1.6;
+                }
+              </style>
+            </head>
+            <body>${rendered}</body>
+          </html>
+        `);
+        doc.close();
+      }
+
+      setPreviewError(null);
+    } catch (error: any) {
+      setPreviewError(error.message);
+
+      if (previewRef.current?.contentDocument) {
+        const doc = previewRef.current.contentDocument;
+        doc.open();
+        doc.write(`
+          <div style="color: red; padding: 20px;">
+            <h3>Preview Error</h3>
+            <pre>${error.message}</pre>
+          </div>
+        `);
+        doc.close();
+      }
+    }
+  }, []);
+
+  // Debounced preview
+  const debouncedPreview = useMemo(
+    () => debounce(renderPreview, 300),
+    [renderPreview]
+  );
+
+  // Update preview when HTML or sample data changes
+  useEffect(() => {
+    if (showPreview) {
+      debouncedPreview(htmlContent, sampleData);
+    }
+  }, [htmlContent, sampleData, showPreview, debouncedPreview]);
+
+  // Variable insertion
+  const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {
+    const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;
+    if (!textarea) return;
+
+    const start = textarea.selectionStart;
+    const end = textarea.selectionEnd;
+    const content = editorType === 'html' ? htmlContent : textContent;
+
+    const before = content.substring(0, start);
+    const after = content.substring(end);
+    const variable = `{{${variableKey}}}`;
+    const newContent = before + variable + after;
+
+    if (editorType === 'html') {
+      setHtmlContent(newContent);
+    } else {
+      setTextContent(newContent);
+    }
+
+    setTimeout(() => {
+      const newPos = start + variable.length;
+      textarea.selectionStart = newPos;
+      textarea.selectionEnd = newPos;
+      textarea.focus();
+    }, 0);
+  };
+
+  // Save template
+  const handleSave = async () => {
+    setSaving(true);
+    try {
+      await api.put(`/api/email-templates/${id}`, {
+        subjectLine,
+        htmlContent,
+        textContent,
+        changeNotes: changeNotes || undefined,
+      });
+
+      message.success('Template saved successfully');
+      localStorage.removeItem(`email-template-draft-${id}`);
+      navigate('/app/email-templates');
+    } catch (error) {
+      message.error('Failed to save template');
+    } finally {
+      setSaving(false);
+      setSaveModalVisible(false);
+    }
+  };
+
+  // Test send
+  const handleTestSend = async () => {
+    if (!testRecipient) {
+      message.error('Please enter recipient email');
+      return;
+    }
+
+    setTestSending(true);
+    try {
+      await api.post(`/api/email-templates/${id}/test`, {
+        recipientEmail: testRecipient,
+        testData: sampleData,
+      });
+
+      message.success('Test email sent successfully');
+      setTestModalVisible(false);
+    } catch (error) {
+      message.error('Failed to send test email');
+    } finally {
+      setTestSending(false);
+    }
+  };
+
+  // Keyboard shortcuts
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if ((e.ctrlKey || e.metaKey) && e.key === 's') {
+        e.preventDefault();
+        setSaveModalVisible(true);
+      }
+      if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
+        e.preventDefault();
+        setShowPreview(!showPreview);
+      }
+    };
+
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [showPreview]);
+
+  if (loading) {
+    return <div style={{ padding: 24 }}>Loading...</div>;
+  }
+
+  return (
+    <div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
+      {/* Toolbar */}
+      <div
+        style={{
+          padding: '12px 24px',
+          borderBottom: '1px solid #f0f0f0',
+          display: 'flex',
+          justifyContent: 'space-between',
+          alignItems: 'center',
+        }}
+      >
+        <Space>
+          <Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/app/email-templates')}>
+            Back
+          </Button>
+          <Title level={4} style={{ margin: 0 }}>
+            {template?.name}
+          </Title>
+        </Space>
+
+        <Space>
+          <Button icon={<EyeOutlined />} onClick={() => setShowPreview(!showPreview)}>
+            {showPreview ? 'Hide' : 'Show'} Preview
+          </Button>
+          <Button icon={<SendOutlined />} onClick={() => setTestModalVisible(true)}>
+            Send Test
+          </Button>
+          <Button type="primary" icon={<SaveOutlined />} onClick={() => setSaveModalVisible(true)}>
+            Save
+          </Button>
+        </Space>
+      </div>
+
+      {/* Editor Area */}
+      <div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
+        {/* Left: Editors */}
+        <div
+          style={{
+            flex: showPreview ? 1 : 2,
+            padding: 24,
+            overflowY: 'auto',
+            borderRight: '1px solid #f0f0f0',
+          }}
+        >
+          <Space direction="vertical" style={{ width: '100%' }} size="large">
+            {/* Subject Line */}
+            <div>
+              <Text strong>Subject Line</Text>
+              <Input
+                value={subjectLine}
+                onChange={(e) => setSubjectLine(e.target.value)}
+                placeholder="Enter subject line with {{VARIABLES}}"
+              />
+            </div>
+
+            {/* HTML Editor */}
+            <div>
+              <Text strong>HTML Content</Text>
+              <TextArea
+                ref={htmlEditorRef}
+                value={htmlContent}
+                onChange={(e) => setHtmlContent(e.target.value)}
+                placeholder="Enter HTML content with {{VARIABLES}}"
+                rows={20}
+                style={{ fontFamily: 'monospace', fontSize: 13 }}
+              />
+            </div>
+
+            {/* Text Editor */}
+            <div>
+              <Text strong>Plain Text Content</Text>
+              <TextArea
+                ref={textEditorRef}
+                value={textContent}
+                onChange={(e) => setTextContent(e.target.value)}
+                placeholder="Enter plain text version"
+                rows={15}
+                style={{ fontFamily: 'monospace', fontSize: 13 }}
+              />
+            </div>
+          </Space>
+        </div>
+
+        {/* Right: Preview + Variables */}
+        {showPreview && (
+          <div style={{ flex: 1, padding: 24, overflowY: 'auto' }}>
+            <Space direction="vertical" style={{ width: '100%' }} size="large">
+              {/* Variables Panel */}
+              <Card title="Variables" size="small">
+                <Space direction="vertical" style={{ width: '100%' }} size="small">
+                  {variables
+                    .sort((a, b) => a.sortOrder - b.sortOrder)
+                    .map((variable) => (
+                      <Card key={variable.id} size="small" style={{ marginBottom: 8 }}>
+                        <Space direction="vertical" size={4} style={{ width: '100%' }}>
+                          <Space>
+                            <Text strong>{variable.label}</Text>
+                            {variable.isRequired && <Tag color="red">Required</Tag>}
+                            {variable.isConditional && <Tag color="blue">Conditional</Tag>}
+                          </Space>
+
+                          {variable.description && (
+                            <Text type="secondary" style={{ fontSize: 12 }}>
+                              {variable.description}
+                            </Text>
+                          )}
+
+                          <Space size="small">
+                            <Button size="small" onClick={() => handleInsertVariable(variable.key, 'html')}>
+                              Insert to HTML
+                            </Button>
+                            <Button size="small" onClick={() => handleInsertVariable(variable.key, 'text')}>
+                              Insert to Text
+                            </Button>
+                          </Space>
+                        </Space>
+                      </Card>
+                    ))}
+                </Space>
+              </Card>
+
+              {/* Preview */}
+              <Card title="Live Preview" size="small">
+                {previewError && (
+                  <div style={{ color: 'red', marginBottom: 12 }}>
+                    <Text strong>Error:</Text> {previewError}
+                  </div>
+                )}
+
+                <iframe
+                  ref={previewRef}
+                  style={{
+                    width: '100%',
+                    height: 600,
+                    border: '1px solid #d9d9d9',
+                    borderRadius: 4,
+                  }}
+                  title="Email Preview"
+                />
+              </Card>
+            </Space>
+          </div>
+        )}
+      </div>
+
+      {/* Save Modal */}
+      <Modal
+        title="Save Template"
+        visible={saveModalVisible}
+        onOk={handleSave}
+        onCancel={() => setSaveModalVisible(false)}
+        confirmLoading={saving}
+        okText="Save"
+      >
+        <Form layout="vertical">
+          <Form.Item label="Change Notes (optional)">
+            <TextArea
+              value={changeNotes}
+              onChange={(e) => setChangeNotes(e.target.value)}
+              placeholder="Describe what changed in this version"
+              rows={4}
+            />
+          </Form.Item>
+        </Form>
+      </Modal>
+
+      {/* Test Send Modal */}
+      <Modal
+        title="Send Test Email"
+        visible={testModalVisible}
+        onOk={handleTestSend}
+        onCancel={() => setTestModalVisible(false)}
+        confirmLoading={testSending}
+        okText="Send Test"
+      >
+        <Form layout="vertical">
+          <Form.Item label="Recipient Email" required>
+            <Input
+              type="email"
+              value={testRecipient}
+              onChange={(e) => setTestRecipient(e.target.value)}
+              placeholder="your-email@example.com"
+            />
+          </Form.Item>
+
+          <Form.Item label="Sample Data">
+            <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
+              Using sample data from preview. Edit values in the preview panel to change test data.
+            </Text>
+            <pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4 }}>
+              {JSON.stringify(sampleData, null, 2)}
+            </pre>
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  );
+}
+
+
+

Troubleshooting

+

Problem: Preview not updating

+

Symptoms: +- Type in HTML editor but preview doesn't change +- Preview shows old content

+

Causes: +1. Debounce timer still running (300ms delay) +2. Handlebars compilation error (silent failure) +3. Iframe not re-rendering

+

Solutions:

+

Wait for debounce: +- Wait 300ms after typing stops +- Preview should update automatically

+

Check browser console: +

// Look for errors
+Handlebars.compile error: ...
+

+

Force preview update: +

// Add button to manually trigger preview
+<Button onClick={() => renderPreview(htmlContent, sampleData)}>
+  Refresh Preview
+</Button>
+

+

Check iframe contentDocument: +

console.log('Iframe doc:', previewRef.current?.contentDocument);
+// Should not be null
+

+
+

Problem: Test send fails

+

Symptoms: +- "Failed to send test email" error +- Email not received in inbox or MailHog

+

Causes: +1. SMTP configuration incorrect +2. Email test mode disabled (sending to real SMTP) +3. Recipient email invalid +4. Template has compilation errors

+

Solutions:

+

Check SMTP settings: +

# .env
+EMAIL_TEST_MODE=true  # Use MailHog
+

+

Verify MailHog running: +

docker compose ps mailhog
+# Should show "Up"
+

+

Check test logs: +

SELECT * FROM email_template_test_logs
+WHERE template_id = 'xxx'
+ORDER BY created_at DESC
+LIMIT 5;
+
+-- Look at error_message column
+

+

Test with minimal template: +

<p>Hello {{USER_NAME}}</p>
+

+

Validate email address: +

import validator from 'validator';
+
+if (!validator.isEmail(testRecipient)) {
+  message.error('Invalid email address');
+  return;
+}
+

+
+

Problem: Variable insertion doesn't work

+

Symptoms: +- Click "Insert to HTML" button but nothing happens +- Variable inserted in wrong location

+

Causes: +1. Textarea ref not set +2. Cursor position not captured correctly +3. State update timing issue

+

Solutions:

+

Check ref exists: +

console.log('HTML ref:', htmlEditorRef.current);
+// Should be <textarea> element
+

+

Debug cursor position: +

const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {
+  const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;
+  console.log('Cursor position:', textarea?.selectionStart, textarea?.selectionEnd);
+
+  // Rest of insertion logic...
+};
+

+

Manual workaround: +- Type {{VARIABLE_KEY}} manually instead of using button

+
+

Problem: Draft not restored on reload

+

Symptoms: +- Unsaved changes lost after browser refresh +- No "Restored draft" message

+

Causes: +1. localStorage not available (private browsing) +2. Draft key mismatch +3. localStorage quota exceeded

+

Solutions:

+

Check localStorage: +

// Browser console
+localStorage.getItem('email-template-draft-cuid123');
+// Should return JSON string
+

+

Verify draft key: +

console.log('Draft key:', `email-template-draft-${id}`);
+

+

Clear old drafts: +

// Browser console
+for (let i = 0; i < localStorage.length; i++) {
+  const key = localStorage.key(i);
+  if (key?.startsWith('email-template-draft-')) {
+    localStorage.removeItem(key);
+  }
+}
+

+
+

Future Enhancements

+

Monaco Editor Integration

+

Current: Basic HTML textarea +Future: Monaco Editor with syntax highlighting, IntelliSense, error detection

+

Benefits: +- Syntax highlighting for HTML +- Auto-completion for HTML tags and Handlebars syntax +- Error squiggles for invalid HTML +- Multi-cursor editing +- Code folding

+

Implementation: +

import Editor from '@monaco-editor/react';
+
+<Editor
+  height="600px"
+  language="html"
+  value={htmlContent}
+  onChange={(value) => setHtmlContent(value || '')}
+  options={{
+    minimap: { enabled: false },
+    lineNumbers: 'on',
+    wordWrap: 'on',
+  }}
+/>
+

+
+

Drag-Drop Block Builder

+

Current: Manual HTML editing +Future: Visual block builder (like GrapesJS)

+

Benefits: +- No HTML knowledge required +- Pre-built email blocks (header, footer, CTA button) +- Drag-drop interface +- Mobile-responsive by default

+

Implementation: +- Use GrapesJS (same as landing page editor) +- Custom blocks for email-safe components +- Export to HTML for template storage

+
+

Email Client Previews

+

Current: Single iframe preview +Future: Multi-client previews (Gmail, Outlook, Apple Mail)

+

Benefits: +- Test rendering across email clients +- Catch client-specific CSS issues +- Preview dark mode rendering

+

Services: +- Litmus API integration +- Email on Acid screenshots +- Self-hosted preview using email client CSS emulation

+
+

A/B Testing Support

+

Current: Single template version +Future: A/B testing with variant templates

+

Features: +- Create template variants (A, B, C) +- Split traffic across variants +- Track open rates, click rates +- Auto-promote winning variant

+

Implementation: +- EmailTemplateVariant model (templateId, variantName, weight, stats) +- Random variant selection on send +- Tracking pixel in email HTML +- Analytics dashboard

+
+

Performance

+

Auto-Save Timing

+

Current Implementation: +- Save to localStorage on blur (when focus leaves editor) +- No automatic interval-based saves

+

Performance Impact: +- Negligible (localStorage write is < 1ms) +- No network requests (local only)

+

Alternative: Interval-Based Auto-Save: +

useEffect(() => {
+  const interval = setInterval(() => {
+    if (htmlContent || textContent) {
+      localStorage.setItem(`email-template-draft-${id}`, JSON.stringify({
+        subjectLine,
+        htmlContent,
+        textContent,
+        savedAt: new Date().toISOString(),
+      }));
+    }
+  }, 10000); // Every 10 seconds
+
+  return () => clearInterval(interval);
+}, [id, subjectLine, htmlContent, textContent]);
+

+
+

Preview Rendering Performance

+

Debounce Delay: +- Current: 300ms +- Too short: Preview updates too frequently (distracting) +- Too long: Preview feels laggy

+

Handlebars Compilation: +- Fast (< 1ms for typical templates) +- May slow down for very large templates (> 100KB)

+

Iframe Rendering: +- Browser-native rendering (very fast) +- No performance concerns

+

Optimization for Large Templates: +

// Skip preview for very large HTML
+const renderPreview = (html: string, data: Record<string, unknown>) => {
+  if (html.length > 100000) { // 100KB
+    setPreviewError('Template too large for live preview. Use test send instead.');
+    return;
+  }
+
+  // Normal preview rendering...
+};
+

+
+

Accessibility

+

Keyboard Shortcuts

+

Implemented: +- Ctrl+S (or Cmd+S on Mac) — Save template +- Ctrl+P — Toggle preview pane +- Esc — Close modal

+

Implementation: +

useEffect(() => {
+  const handleKeyDown = (e: KeyboardEvent) => {
+    // Save
+    if ((e.ctrlKey || e.metaKey) && e.key === 's') {
+      e.preventDefault();
+      setSaveModalVisible(true);
+    }
+
+    // Preview toggle
+    if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
+      e.preventDefault();
+      setShowPreview(!showPreview);
+    }
+
+    // Close modal
+    if (e.key === 'Escape') {
+      setSaveModalVisible(false);
+      setTestModalVisible(false);
+    }
+  };
+
+  window.addEventListener('keydown', handleKeyDown);
+  return () => window.removeEventListener('keydown', handleKeyDown);
+}, [showPreview]);
+

+
+

Screen Reader Support

+

Form Labels: +

<Form.Item label="Recipient Email" required>
+  <Input
+    type="email"
+    aria-label="Test email recipient address"
+    aria-required="true"
+    value={testRecipient}
+    onChange={(e) => setTestRecipient(e.target.value)}
+  />
+</Form.Item>
+

+

Button Descriptions: +

<Button
+  icon={<SaveOutlined />}
+  onClick={() => setSaveModalVisible(true)}
+  aria-label="Save template and create new version"
+>
+  Save
+</Button>
+
+<Button
+  size="small"
+  onClick={() => handleInsertVariable(variable.key, 'html')}
+  aria-label={`Insert ${variable.label} variable into HTML editor`}
+>
+  Insert to HTML
+</Button>
+

+
+ +

Frontend Documentation

+ +

Backend Documentation

+
    +
  • Email Templates Module — API routes
  • +
  • GET /api/email-templates/:id — Load template + variables
  • +
  • PUT /api/email-templates/:id — Update template (creates version)
  • +
  • POST /api/email-templates/:id/test — Send test email
  • +
+

Feature Documentation

+ + + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/email-templates/index.html b/mkdocs/site/v2/features/email-templates/index.html new file mode 100644 index 00000000..2a5824c8 --- /dev/null +++ b/mkdocs/site/v2/features/email-templates/index.html @@ -0,0 +1,5433 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Email Templates - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Email Templates

+

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.

+

Overview

+

The Email Templates system consists of four integrated components:

+
    +
  1. Template System - Template CRUD and management
  2. +
  3. Editor - Rich text editor with variable insertion
  4. +
  5. Variables - Dynamic content placeholders
  6. +
  7. Versioning - Template version history
  8. +
+

Features

+

Template Management

+
    +
  • Create/edit/delete templates
  • +
  • Category organization
  • +
  • Template types (campaign, notification, system)
  • +
  • Published/draft status
  • +
  • Search and filtering
  • +
  • Clone templates
  • +
+

Rich Text Editor

+
    +
  • WYSIWYG HTML editor
  • +
  • Variable insertion menu
  • +
  • Preview mode
  • +
  • HTML source view
  • +
  • Image upload (future)
  • +
  • Link management
  • +
+

Variable System

+

Dynamic placeholders:

+
    +
  • User variables - {{user.name}}, {{user.email}}
  • +
  • Campaign variables - {{campaign.name}}, {{campaign.description}}
  • +
  • Representative variables - {{rep.name}}, {{rep.title}}, {{rep.email}}
  • +
  • Custom variables - Template-specific placeholders
  • +
  • System variables - {{site.name}}, {{current.date}}
  • +
+

Version History

+
    +
  • Auto-save on changes
  • +
  • Version diff viewer
  • +
  • Restore previous versions
  • +
  • Change log
  • +
+

User Flow

+

Admin Experience

+
    +
  1. Create Template (/app/email-templates)
  2. +
  3. Click "New Template"
  4. +
  5. Enter name and category
  6. +
  7. Set template type
  8. +
  9. +

    Save draft

    +
  10. +
  11. +

    Edit Template (/app/email-templates/:id/edit)

    +
  12. +
  13. Full-screen rich text editor
  14. +
  15. Insert variables from dropdown
  16. +
  17. Preview with sample data
  18. +
  19. +

    Save changes

    +
  20. +
  21. +

    Use Template

    +
  22. +
  23. Select template in campaign form
  24. +
  25. Variables auto-populated from context
  26. +
  27. +

    Send email with processed template

    +
  28. +
  29. +

    Manage Versions (/app/email-templates/:id/versions)

    +
  30. +
  31. View version history
  32. +
  33. Compare versions
  34. +
  35. Restore previous version
  36. +
+

Architecture

+

Backend Components

+

Module: +- api/src/modules/email-templates/email-templates.routes.ts - Template CRUD +- api/src/modules/email-templates/email-templates.service.ts - Business logic +- api/src/modules/email-templates/email-templates.schemas.ts - Zod validation

+

Database Models: +- EmailTemplate - Template definitions (name, content, variables) +- EmailTemplateVersion - Version history (future)

+

Email Processing: +- Variable substitution in email.service.ts +- Mustache-style templating: {{variable}} +- HTML escaping for security

+

Frontend Components

+

Admin Pages: +- admin/src/pages/EmailTemplatesPage.tsx - Template management table +- admin/src/pages/EmailTemplateEditorPage.tsx - Full-screen editor

+

Editor Components: +- admin/src/components/email-templates/TemplateEditor.tsx - Rich text editor +- admin/src/components/email-templates/VariableInserter.tsx - Variable dropdown

+

Configuration

+

Template Types

+
    +
  • campaign - Campaign email templates
  • +
  • notification - User notifications
  • +
  • system - System emails (verification, password reset)
  • +
  • custom - Custom templates
  • +
+

Template Categories

+
    +
  • Influence - Campaign-related templates
  • +
  • Map - Shift/canvass notifications
  • +
  • User - User account emails
  • +
  • System - Automated system emails
  • +
+

Variable System

+

Available Variables

+

User Context: +

{{user.id}}           # User ID
+{{user.email}}        # Email address
+{{user.name}}         # Full name
+{{user.role}}         # User role
+

+

Campaign Context: +

{{campaign.id}}       # Campaign ID
+{{campaign.name}}     # Campaign name
+{{campaign.description}} # Description
+{{campaign.emailTemplate}} # Email body
+

+

Representative Context: +

{{rep.name}}          # Representative name
+{{rep.title}}         # Title (MP, MLA, etc.)
+{{rep.email}}         # Email address
+{{rep.phone}}         # Phone number
+{{rep.district}}      # District name
+

+

System Context: +

{{site.name}}         # Site name
+{{site.url}}          # Site URL
+{{current.date}}      # Current date
+{{current.year}}      # Current year
+

+

Variable Insertion

+
// Insert variable at cursor position
+editor.insertContent('{{user.name}}');
+
+// Variable dropdown menu
+<Select>
+  <Option value="{{user.name}}">User Name</Option>
+  <Option value="{{user.email}}">User Email</Option>
+  <Option value="{{rep.name}}">Representative Name</Option>
+</Select>
+
+

Variable Processing

+

Server-side processing in email.service.ts:

+
function processTemplate(
+  template: string,
+  variables: Record<string, any>
+): string {
+  let processed = template;
+
+  for (const [key, value] of Object.entries(variables)) {
+    const placeholder = `{{${key}}}`;
+    processed = processed.replace(
+      new RegExp(placeholder, 'g'),
+      escapeHtml(String(value))
+    );
+  }
+
+  return processed;
+}
+
+

Database Schema

+

EmailTemplate Model

+
model EmailTemplate {
+  id          Int      @id @default(autoincrement())
+  name        String
+  subject     String
+  body        String   @db.Text
+  category    String?
+  type        String   @default("custom")
+  variables   Json?    # Available variables
+  published   Boolean  @default(false)
+  createdAt   DateTime @default(now())
+  updatedAt   DateTime @updatedAt
+}
+
+

API Endpoints

+

Admin Endpoints

+
GET    /api/email-templates            # List templates
+POST   /api/email-templates            # Create template
+GET    /api/email-templates/:id        # Get template
+PATCH  /api/email-templates/:id        # Update template
+DELETE /api/email-templates/:id        # Delete template
+POST   /api/email-templates/:id/clone  # Clone template
+GET    /api/email-templates/:id/preview # Preview with sample data
+
+

Security

+

HTML Escaping

+

All variable values are HTML-escaped to prevent XSS:

+
import { escapeHtml } from '../utils/sanitize';
+
+const safe = escapeHtml(userInput);
+// Converts: < > & " ' to HTML entities
+
+

Template Validation

+
    +
  • Subject line: 1-200 characters
  • +
  • Body: Required, max 50,000 characters
  • +
  • Variables: Valid JSON object
  • +
  • Category: Predefined list
  • +
+

Best Practices

+

Template Design

+
    +
  • Clear subject lines (50-60 chars)
  • +
  • Personalize with variables
  • +
  • Mobile-responsive HTML
  • +
  • Plain text alternative
  • +
  • Unsubscribe link
  • +
  • Branding consistency
  • +
+

Variable Usage

+
    +
  • Document available variables
  • +
  • Provide defaults for missing values
  • +
  • Test with sample data
  • +
  • Validate variable names
  • +
+

Version Management

+
    +
  • Meaningful version names
  • +
  • Document changes
  • +
  • Test before publishing
  • +
  • Keep version history
  • +
+

Desktop-Only Editor

+

Email template editor requires desktop browser:

+
const screens = Grid.useBreakpoint();
+const isMobile = !screens.md;
+
+if (isMobile) {
+  return (
+    <Alert
+      message="Desktop Required"
+      description="Email editor requires desktop browser"
+      type="warning"
+    />
+  );
+}
+
+

Integration Points

+

Campaign Emails

+

Campaign emails use templates:

+
// Select template in campaign form
+<Select>
+  {templates.map(t => (
+    <Option value={t.id}>{t.name}</Option>
+  ))}
+</Select>
+
+// Process template with campaign data
+const emailBody = processTemplate(template.body, {
+  'user.name': user.name,
+  'campaign.name': campaign.name,
+  'rep.name': representative.name,
+});
+
+

System Emails

+

System emails (verification, password reset):

+
// Load system template
+const template = await getTemplateByType('email-verification');
+
+// Process with user data
+const emailBody = processTemplate(template.body, {
+  'user.name': user.name,
+  'verify.link': verificationUrl,
+});
+
+// Send email
+await emailService.sendEmail({
+  to: user.email,
+  subject: template.subject,
+  html: emailBody,
+});
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/email-templates/template-system/index.html b/mkdocs/site/v2/features/email-templates/template-system/index.html new file mode 100644 index 00000000..7e13d596 --- /dev/null +++ b/mkdocs/site/v2/features/email-templates/template-system/index.html @@ -0,0 +1,7714 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Template System - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Email Template System

+

Overview

+

The Email Template System provides centralized management of all transactional and campaign emails sent by Changemaker Lite. It enables administrators to create, edit, and maintain email templates with variable interpolation, version control, and testing capabilities.

+

Key Features:

+
    +
  • Centralized Management — All email templates stored in database, editable via admin GUI
  • +
  • Variable Interpolation{{VAR}} syntax powered by Handlebars template engine
  • +
  • Three Categories — INFLUENCE (campaign emails), MAP (shift/canvass emails), SYSTEM (platform emails)
  • +
  • Dual Format Support — HTML + plain text versions for all templates
  • +
  • System Templates — Protected templates with deletion prevention for critical platform emails
  • +
  • Version Control — Automatic version history on every save with rollback capability
  • +
  • Test Send — Preview rendered emails before deploying to production
  • +
  • Variable Validation — Required vs optional variables with runtime validation
  • +
+

Use Cases:

+
    +
  • Advocacy Campaigns — Custom email templates for representative outreach
  • +
  • Shift Notifications — Confirmation and reminder emails for volunteer shifts
  • +
  • User Onboarding — Welcome emails, verification emails, password resets
  • +
  • Response Moderation — Notification emails when responses are approved/rejected
  • +
  • Canvass Summaries — End-of-session reports sent to volunteers
  • +
+
+

Architecture

+
flowchart TB
+    subgraph "Email Service Layer"
+        Service[EmailService<br/>email.service.ts]
+        Service --> Load[Load Template by Key]
+        Service --> Validate[Validate Required Variables]
+        Service --> Interpolate[Handlebars Interpolation]
+    end
+
+    subgraph "Database Models"
+        Template[(EmailTemplate)]
+        Variables[(EmailTemplateVariable)]
+        Versions[(EmailTemplateVersion)]
+        TestLogs[(EmailTemplateTestLog)]
+
+        Template -->|1:N| Variables
+        Template -->|1:N| Versions
+        Template -->|1:N| TestLogs
+    end
+
+    subgraph "Output Channels"
+        HTML[HTML Email]
+        Text[Plain Text Email]
+        Preview[Preview Rendering]
+    end
+
+    Load --> Template
+    Template --> Variables
+    Validate --> Variables
+
+    Interpolate --> HTML
+    Interpolate --> Text
+    Interpolate --> Preview
+
+    HTML --> SMTP[Nodemailer SMTP]
+    Text --> SMTP
+    Preview --> Admin[Admin GUI]
+
+    SMTP --> Sent[Email Sent]
+    Sent --> TestLogs
+
+    Service -.->|Test Mode| MailHog[MailHog<br/>Dev Capture]
+
+    style Service fill:#4a90e2,color:#fff
+    style Template fill:#50c878,color:#fff
+    style SMTP fill:#ff6b6b,color:#fff
+

Component Responsibilities:

+
    +
  • EmailService — Core email sending logic with template loading and interpolation
  • +
  • EmailTemplate — Template metadata (key, name, category, content, active status)
  • +
  • EmailTemplateVariable — Variable definitions (key, label, required/optional, sample values)
  • +
  • EmailTemplateVersion — Version history snapshots with change notes
  • +
  • EmailTemplateTestLog — Test send audit trail with success/failure logging
  • +
  • Handlebars Engine — Template compilation and variable interpolation
  • +
  • Nodemailer — SMTP transport for production email delivery
  • +
  • MailHog — Development email capture (when EMAIL_TEST_MODE=true)
  • +
+
+

Database Models

+

EmailTemplate

+

Core template storage with metadata and content.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString (CUID)Primary key
keyString (unique)Programmatic identifier (e.g., "shift-signup-confirmation")
nameStringDisplay name for admin GUI
descriptionString (optional)Template purpose and usage notes
categoryEnumINFLUENCE, MAP, or SYSTEM
subjectLineStringEmail subject (supports {{VARIABLES}})
htmlContentTextHTML email body with Handlebars syntax
textContentTextPlain text fallback version
isSystemBooleanIf true, cannot be deleted (critical platform emails)
isActiveBooleanIf false, template is disabled and won't send
createdAtDateTimeCreation timestamp
updatedAtDateTimeLast modification timestamp
createdByUserIdString (optional)User who created template
updatedByUserIdString (optional)User who last modified template
+

Relations: +- variables — EmailTemplateVariable[] (1:N) +- versions — EmailTemplateVersion[] (1:N) +- testLogs — EmailTemplateTestLog[] (1:N)

+

Indexes: +- Unique index on key for fast lookups +- Index on category for filtered queries +- Index on isActive for production template queries

+
+

EmailTemplateVariable

+

Variable definitions for template interpolation.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString (CUID)Primary key
templateIdStringForeign key to EmailTemplate
keyStringVariable name (e.g., "USER_NAME")
labelStringDisplay label for admin GUI
descriptionString (optional)Variable purpose and usage notes
isRequiredBooleanIf true, must be provided in data object
isConditionalBooleanIf true, used in {{#if}} blocks (truthy/falsy)
sampleValueString (optional)Example value for testing and preview
sortOrderIntDisplay order in editor variable panel
createdAtDateTimeCreation timestamp
+

Relations: +- template — EmailTemplate (N:1)

+

Constraints: +- Unique index on (templateId, key) to prevent duplicate variables

+
+

EmailTemplateVersion

+

Version history snapshots for audit trail and rollback.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString (CUID)Primary key
templateIdStringForeign key to EmailTemplate
versionNumberIntAuto-incremented version number (1, 2, 3...)
subjectLineStringSubject at time of version
htmlContentTextHTML content snapshot
textContentTextPlain text content snapshot
changeNotesString (optional)Admin-provided change description
createdByUserIdString (optional)User who created this version
createdAtDateTimeVersion creation timestamp
+

Relations: +- template — EmailTemplate (N:1) +- createdBy — User (N:1)

+

Constraints: +- Unique index on (templateId, versionNumber) for version lookup +- Auto-increment logic in service layer (finds max + 1)

+
+

EmailTemplateTestLog

+

Test send audit trail for debugging and compliance.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString (CUID)Primary key
templateIdStringForeign key to EmailTemplate
recipientEmailStringEmail address test was sent to
testDataJSONSample variable data used for interpolation
successBooleanWhether send succeeded
errorMessageString (optional)Error details if send failed
messageIdString (optional)SMTP message ID if send succeeded
sentByUserIdString (optional)User who triggered test send
createdAtDateTimeTest send timestamp
+

Relations: +- template — EmailTemplate (N:1) +- sentBy — User (N:1)

+

Indexes: +- Index on templateId for template-specific test history +- Index on createdAt for chronological queries

+
+

Template Categories

+

INFLUENCE Category

+

Purpose: Advocacy campaign emails sent to representatives or response notifications to participants.

+

System Templates:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyNameDescription
campaign-emailCampaign Email to RepresentativeMain advocacy email template sent on behalf of participants
response-verificationResponse Verification EmailEmail asking participants to verify their response submission
response-approvedResponse Approval NotificationEmail notifying participant their response is published on wall
response-rejectedResponse Rejection NotificationEmail notifying participant their response was rejected (with reason)
+

Common Variables: +- USER_NAME — Participant's full name +- USER_EMAIL — Participant's email address +- CAMPAIGN_TITLE — Campaign name +- CAMPAIGN_SLUG — URL-safe campaign identifier +- REPRESENTATIVE_NAME — Representative's full name +- REPRESENTATIVE_EMAIL — Representative's email address +- REPRESENTATIVE_TITLE — Representative's title (e.g., "MP for...") +- CUSTOM_MESSAGE — Participant's custom message to representative +- RESPONSE_TEXT — Participant's response wall submission +- VERIFICATION_LINK — Unique verification URL +- ADMIN_NOTES — Moderator's rejection reason

+
+

MAP Category

+

Purpose: Location-based emails for volunteer shifts, canvassing sessions, and shift management.

+

System Templates:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyNameDescription
shift-signup-confirmationShift Signup ConfirmationEmail confirming volunteer's shift registration
shift-reminderShift ReminderEmail sent 24 hours before shift starts
shift-cancellationShift Cancellation NoticeEmail notifying volunteer of shift cancellation
canvass-session-summaryCanvass Session SummaryEnd-of-session report with visit statistics
+

Common Variables: +- USER_NAME — Volunteer's full name +- USER_EMAIL — Volunteer's email address +- USER_PHONE — Volunteer's phone number (optional) +- SHIFT_TITLE — Shift name +- SHIFT_DATE — Shift date (formatted) +- SHIFT_TIME — Shift time range (e.g., "10:00 AM - 2:00 PM") +- SHIFT_LOCATION — Shift meeting location +- CUT_NAME — Canvass area name +- VISIT_COUNT — Number of doors knocked +- CONTACT_COUNT — Number of successful contacts +- SUPPORT_COUNT — Number of supporters identified +- CANCELLATION_REASON — Why shift was cancelled

+
+

SYSTEM Category

+

Purpose: Core platform emails for user management, authentication, and system notifications.

+

System Templates:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyNameDescription
user-welcomeWelcome EmailEmail sent to new user registrations
password-resetPassword Reset EmailEmail with password reset link
email-verificationEmail VerificationEmail address verification for new accounts
account-lockedAccount Locked NoticeSecurity notification for locked accounts
+

Common Variables: +- USER_NAME — User's full name +- USER_EMAIL — User's email address +- VERIFICATION_LINK — Unique verification URL (expires in 24h) +- RESET_LINK — Unique password reset URL (expires in 1h) +- SUPPORT_EMAIL — Platform support email address +- SITE_NAME — Platform name (from SiteSettings) +- SITE_URL — Platform base URL +- LOGIN_URL — Direct link to login page +- LOCKOUT_REASON — Why account was locked

+
+

Variable Interpolation

+

The template system uses Handlebars for powerful variable interpolation with support for basic variables, conditional blocks, loops, and helpers.

+

Basic Variables

+

Syntax: {{VARIABLE_NAME}}

+

Example Template: +

<p>Dear {{USER_NAME}},</p>
+
+<p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong> on {{SHIFT_DATE}}.</p>
+
+<p>We'll see you at {{SHIFT_LOCATION}} at {{SHIFT_TIME}}.</p>
+
+<p>If you have any questions, email us at {{SUPPORT_EMAIL}}.</p>
+

+

Sample Data: +

{
+  "USER_NAME": "Jane Smith",
+  "SHIFT_TITLE": "Door Knocking - Downtown",
+  "SHIFT_DATE": "Saturday, March 15, 2026",
+  "SHIFT_LOCATION": "Campaign Office (123 Main St)",
+  "SHIFT_TIME": "10:00 AM - 2:00 PM",
+  "SUPPORT_EMAIL": "volunteer@example.org"
+}
+

+

Rendered Output: +

<p>Dear Jane Smith,</p>
+
+<p>Thank you for signing up for <strong>Door Knocking - Downtown</strong> on Saturday, March 15, 2026.</p>
+
+<p>We'll see you at Campaign Office (123 Main St) at 10:00 AM - 2:00 PM.</p>
+
+<p>If you have any questions, email us at volunteer@example.org.</p>
+

+
+

Conditional Blocks

+

Syntax: {{#if CONDITION}} ... {{else}} ... {{/if}}

+

Example Template: +

<p>Dear {{USER_NAME}},</p>
+
+<p>Your shift confirmation for {{SHIFT_TITLE}} is below.</p>
+
+{{#if HAS_PHONE}}
+<p><strong>We'll call you at {{USER_PHONE}} if there are any changes.</strong></p>
+{{else}}
+<p>We recommend adding a phone number to your profile for shift updates.</p>
+{{/if}}
+
+{{#if IS_CUT_ASSIGNED}}
+<p>You've been assigned to canvass <strong>{{CUT_NAME}}</strong>.</p>
+{{/if}}
+

+

Sample Data: +

{
+  "USER_NAME": "John Doe",
+  "SHIFT_TITLE": "Canvassing - North District",
+  "HAS_PHONE": true,
+  "USER_PHONE": "(555) 123-4567",
+  "IS_CUT_ASSIGNED": true,
+  "CUT_NAME": "North District - Zone A"
+}
+

+

Rendered Output: +

<p>Dear John Doe,</p>
+
+<p>Your shift confirmation for Canvassing - North District is below.</p>
+
+<p><strong>We'll call you at (555) 123-4567 if there are any changes.</strong></p>
+
+<p>You've been assigned to canvass <strong>North District - Zone A</strong>.</p>
+

+

Truthy/Falsy Values: +- true, non-empty strings, non-zero numbers → truthy +- false, null, undefined, 0, "" → falsy

+
+

Loops (Each Blocks)

+

Syntax: {{#each ARRAY}} ... {{/each}}

+

Example Template: +

<p>Dear {{USER_NAME}},</p>
+
+<p>Your email will be sent to the following representatives:</p>
+
+<ul>
+{{#each REPRESENTATIVES}}
+  <li>
+    <strong>{{name}}</strong> ({{title}})<br>
+    Email: {{email}}
+  </li>
+{{/each}}
+</ul>
+
+<p>Your custom message:</p>
+<blockquote>{{CUSTOM_MESSAGE}}</blockquote>
+

+

Sample Data: +

{
+  "USER_NAME": "Alice Johnson",
+  "REPRESENTATIVES": [
+    {
+      "name": "Jane Doe",
+      "title": "MP for Downtown",
+      "email": "jane.doe@parliament.ca"
+    },
+    {
+      "name": "John Smith",
+      "title": "City Councillor Ward 3",
+      "email": "john.smith@city.ca"
+    }
+  ],
+  "CUSTOM_MESSAGE": "Please support Bill C-123 to address climate change."
+}
+

+

Rendered Output: +

<p>Dear Alice Johnson,</p>
+
+<p>Your email will be sent to the following representatives:</p>
+
+<ul>
+  <li>
+    <strong>Jane Doe</strong> (MP for Downtown)<br>
+    Email: jane.doe@parliament.ca
+  </li>
+  <li>
+    <strong>John Smith</strong> (City Councillor Ward 3)<br>
+    Email: john.smith@city.ca
+  </li>
+</ul>
+
+<p>Your custom message:</p>
+<blockquote>Please support Bill C-123 to address climate change.</blockquote>
+

+

Loop Variables: +- {{@index}} — 0-based index +- {{@first}} — true if first item +- {{@last}} — true if last item

+
+

Raw HTML (Unescaped)

+

Syntax: {{{VARIABLE_NAME}}} (triple braces)

+

By default, Handlebars escapes HTML to prevent XSS attacks. Use triple braces for trusted HTML content.

+

Example Template: +

<p>Dear {{USER_NAME}},</p>
+
+<div class="message-content">
+  {{{FORMATTED_MESSAGE}}}
+</div>
+

+

Sample Data: +

{
+  "USER_NAME": "Bob Wilson",
+  "FORMATTED_MESSAGE": "<p>This is <strong>bold</strong> and <em>italic</em> text.</p><ul><li>Item 1</li><li>Item 2</li></ul>"
+}
+

+

Rendered Output: +

<p>Dear Bob Wilson,</p>
+
+<div class="message-content">
+  <p>This is <strong>bold</strong> and <em>italic</em> text.</p><ul><li>Item 1</li><li>Item 2</li></ul>
+</div>
+

+

Security Warning: Only use {{{...}}} for content generated by the application, never for user-submitted content without sanitization.

+
+

Admin Workflow

+

Viewing Templates

+
    +
  1. Navigate to Email Templates Page
  2. +
  3. Admin sidebar → Email Templates
  4. +
  5. +

    Shows table with all templates grouped by category

    +
  6. +
  7. +

    Filter and Search

    +
  8. +
  9. Filter by category (INFLUENCE, MAP, SYSTEM)
  10. +
  11. Search by template name or key
  12. +
  13. +

    Toggle "Show Inactive" to view disabled templates

    +
  14. +
  15. +

    Template Details

    +
  16. +
  17. Click template row to view details modal
  18. +
  19. See subject line, category, active status, system flag
  20. +
  21. View variable list with required/optional labels
  22. +
  23. Access version history tab
  24. +
  25. Access test send tab
  26. +
+
+

Creating Template

+
    +
  1. Click "New Template" Button
  2. +
  3. +

    Opens template creation modal

    +
  4. +
  5. +

    Enter Template Metadata

    +
  6. +
  7. Key — Programmatic identifier (lowercase-with-dashes)
  8. +
  9. Name — Display name for admin GUI
  10. +
  11. Description — Template purpose and usage notes
  12. +
  13. Category — Select INFLUENCE, MAP, or SYSTEM
  14. +
  15. +

    System Flag — Check if template is critical (prevents deletion)

    +
  16. +
  17. +

    Define Variables

    +
  18. +
  19. Click "Add Variable" in variables section
  20. +
  21. Enter variable key (UPPERCASE_WITH_UNDERSCORES)
  22. +
  23. Enter label and description
  24. +
  25. Toggle required/conditional flags
  26. +
  27. Provide sample value for testing
  28. +
  29. +

    Set sort order (drag to reorder)

    +
  30. +
  31. +

    Write Template Content

    +
  32. +
  33. Subject Line — Enter subject with optional {{VARIABLES}}
  34. +
  35. HTML Content — Write HTML body with {{VARIABLES}}
  36. +
  37. +

    Text Content — Write plain text fallback

    +
  38. +
  39. +

    Save Template

    +
  40. +
  41. Click "Save" to create template
  42. +
  43. Creates version 1 automatically
  44. +
  45. Template is active by default
  46. +
+
+

Editing Template

+
    +
  1. Open Template
  2. +
  3. Email Templates page → click template
  4. +
  5. +

    Opens detail modal

    +
  6. +
  7. +

    Click "Edit" Button

    +
  8. +
  9. Opens EmailTemplateEditorPage in new tab
  10. +
  11. +

    Shows split-pane editor (HTML + Text)

    +
  12. +
  13. +

    Modify Content

    +
  14. +
  15. Edit subject line, HTML, or text content
  16. +
  17. Use variable insertion buttons to add {{VARIABLES}}
  18. +
  19. +

    Preview rendered output with sample data

    +
  20. +
  21. +

    Add Change Notes

    +
  22. +
  23. Enter description of changes in "Change Notes" field
  24. +
  25. +

    Used for version history audit trail

    +
  26. +
  27. +

    Save Changes

    +
  28. +
  29. Click "Save" button
  30. +
  31. Creates new version automatically
  32. +
  33. Redirects to Email Templates page
  34. +
+
+

Testing Template

+
    +
  1. Open Template Detail Modal
  2. +
  3. +

    Click template from list

    +
  4. +
  5. +

    Navigate to "Test Send" Tab

    +
  6. +
  7. +

    Enter Test Parameters

    +
  8. +
  9. Recipient Email — Your email address for test
  10. +
  11. Sample Data — JSON object with variable values
  12. +
  13. +

    Pre-filled with variable sample values

    +
  14. +
  15. +

    Click "Send Test Email"

    +
  16. +
  17. Template is rendered with sample data
  18. +
  19. Email sent via SMTP (or MailHog in test mode)
  20. +
  21. +

    Success/failure notification displayed

    +
  22. +
  23. +

    Check Test Log

    +
  24. +
  25. View test send history in "Test Logs" tab
  26. +
  27. See timestamp, recipient, success status, error messages
  28. +
  29. Review sample data used for each test
  30. +
+
+

Activating/Deactivating Template

+
    +
  1. +

    Open Template Detail Modal

    +
  2. +
  3. +

    Toggle "Active" Switch

    +
  4. +
  5. When inactive, template won't send emails
  6. +
  7. +

    Useful for disabling seasonal templates or broken templates

    +
  8. +
  9. +

    Confirm Action

    +
  10. +
  11. System templates require additional confirmation
  12. +
  13. Deactivating system template may break critical platform functions
  14. +
+
+

Developer Workflow (Adding New Template)

+

Step 1: Define Template Key

+

Choose a descriptive, unique key using lowercase with dashes:

+

Good Keys: +- shift-signup-confirmation +- canvass-session-summary +- response-verification

+

Bad Keys: +- template1 (not descriptive) +- ShiftSignup (wrong case) +- shift_signup (use dashes, not underscores)

+
+

Step 2: Create Template via Seed Script

+

Add to api/prisma/seed.ts:

+
await prisma.emailTemplate.upsert({
+  where: { key: 'shift-signup-confirmation' },
+  update: {},
+  create: {
+    key: 'shift-signup-confirmation',
+    name: 'Shift Signup Confirmation',
+    description: 'Email sent to volunteers when they sign up for a shift',
+    category: 'MAP',
+    isSystem: true,
+    isActive: true,
+    subjectLine: 'Confirmed: {{SHIFT_TITLE}} on {{SHIFT_DATE}}',
+    htmlContent: `
+      <p>Dear {{USER_NAME}},</p>
+
+      <p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong>!</p>
+
+      <p><strong>Details:</strong></p>
+      <ul>
+        <li><strong>Date:</strong> {{SHIFT_DATE}}</li>
+        <li><strong>Time:</strong> {{SHIFT_TIME}}</li>
+        <li><strong>Location:</strong> {{SHIFT_LOCATION}}</li>
+      </ul>
+
+      {{#if HAS_PHONE}}
+      <p>We'll call you at {{USER_PHONE}} if there are any changes.</p>
+      {{/if}}
+
+      <p>See you there!</p>
+    `,
+    textContent: `
+Dear {{USER_NAME}},
+
+Thank you for signing up for {{SHIFT_TITLE}}!
+
+Details:
+- Date: {{SHIFT_DATE}}
+- Time: {{SHIFT_TIME}}
+- Location: {{SHIFT_LOCATION}}
+
+{{#if HAS_PHONE}}
+We'll call you at {{USER_PHONE}} if there are any changes.
+{{/if}}
+
+See you there!
+    `,
+  },
+});
+
+

Run Seed: +

docker compose exec api npx prisma db seed
+

+
+

Step 3: Define Variables

+

Add variables in same seed script:

+
const template = await prisma.emailTemplate.findUnique({
+  where: { key: 'shift-signup-confirmation' },
+});
+
+const variables = [
+  {
+    key: 'USER_NAME',
+    label: 'User Name',
+    description: 'Full name of the volunteer',
+    isRequired: true,
+    isConditional: false,
+    sampleValue: 'John Doe',
+    sortOrder: 1,
+  },
+  {
+    key: 'SHIFT_TITLE',
+    label: 'Shift Title',
+    description: 'Name of the shift',
+    isRequired: true,
+    isConditional: false,
+    sampleValue: 'Door Knocking - Downtown',
+    sortOrder: 2,
+  },
+  {
+    key: 'SHIFT_DATE',
+    label: 'Shift Date',
+    description: 'Formatted shift date',
+    isRequired: true,
+    isConditional: false,
+    sampleValue: 'Saturday, March 15, 2026',
+    sortOrder: 3,
+  },
+  {
+    key: 'SHIFT_TIME',
+    label: 'Shift Time',
+    description: 'Shift time range',
+    isRequired: true,
+    isConditional: false,
+    sampleValue: '10:00 AM - 2:00 PM',
+    sortOrder: 4,
+  },
+  {
+    key: 'SHIFT_LOCATION',
+    label: 'Shift Location',
+    description: 'Meeting location for shift',
+    isRequired: true,
+    isConditional: false,
+    sampleValue: 'Campaign Office (123 Main St)',
+    sortOrder: 5,
+  },
+  {
+    key: 'HAS_PHONE',
+    label: 'Has Phone',
+    description: 'Whether user provided phone number',
+    isRequired: false,
+    isConditional: true,
+    sampleValue: 'true',
+    sortOrder: 6,
+  },
+  {
+    key: 'USER_PHONE',
+    label: 'User Phone',
+    description: 'User phone number (optional)',
+    isRequired: false,
+    isConditional: false,
+    sampleValue: '(555) 123-4567',
+    sortOrder: 7,
+  },
+];
+
+for (const variable of variables) {
+  await prisma.emailTemplateVariable.upsert({
+    where: {
+      templateId_key: {
+        templateId: template!.id,
+        key: variable.key,
+      },
+    },
+    update: {},
+    create: {
+      templateId: template!.id,
+      ...variable,
+    },
+  });
+}
+
+
+

Step 4: Use in Code

+

Send email from template:

+
import { emailService } from '@/services/email.service';
+
+await emailService.sendFromTemplate('shift-signup-confirmation', {
+  recipientEmail: volunteer.email,
+  data: {
+    USER_NAME: volunteer.name,
+    SHIFT_TITLE: shift.title,
+    SHIFT_DATE: dayjs(shift.startTime).format('dddd, MMMM D, YYYY'),
+    SHIFT_TIME: `${dayjs(shift.startTime).format('h:mm A')} - ${dayjs(shift.endTime).format('h:mm A')}`,
+    SHIFT_LOCATION: shift.location,
+    HAS_PHONE: !!volunteer.phone,
+    USER_PHONE: volunteer.phone || '',
+  },
+});
+
+
+

Step 5: Document Template

+

Add to API documentation:

+

Create entry in mkdocs/docs/v2/api/email-templates.md:

+
## shift-signup-confirmation
+
+**Category:** MAP
+**System:** Yes
+
+Sent when volunteer signs up for a shift.
+
+**Required Variables:**
+- USER_NAME, SHIFT_TITLE, SHIFT_DATE, SHIFT_TIME, SHIFT_LOCATION
+
+**Optional Variables:**
+- HAS_PHONE (conditional), USER_PHONE
+
+**Usage:**
+\```typescript
+await emailService.sendFromTemplate('shift-signup-confirmation', { ... });
+\```
+
+
+

Code Examples

+

Send Email from Template

+

Basic Usage:

+
import { emailService } from '@/services/email.service';
+
+await emailService.sendFromTemplate('user-welcome', {
+  recipientEmail: 'newuser@example.com',
+  data: {
+    USER_NAME: 'Alice Smith',
+    USER_EMAIL: 'newuser@example.com',
+    VERIFICATION_LINK: 'https://cmlite.org/verify/abc123',
+    SITE_NAME: 'Changemaker Lite',
+    SITE_URL: 'https://cmlite.org',
+  },
+});
+
+

With Conditional Variables:

+
await emailService.sendFromTemplate('response-verification', {
+  recipientEmail: participant.email,
+  data: {
+    USER_NAME: participant.name,
+    CAMPAIGN_TITLE: campaign.title,
+    RESPONSE_TEXT: response.content,
+    VERIFICATION_LINK: `https://cmlite.org/responses/verify/${response.verificationToken}`,
+    HAS_CUSTOM_MESSAGE: !!response.customMessage,
+    CUSTOM_MESSAGE: response.customMessage || '',
+  },
+});
+
+

With Loops (Array Variables):

+
await emailService.sendFromTemplate('campaign-email', {
+  recipientEmail: 'representative@parliament.ca',
+  data: {
+    USER_NAME: participant.name,
+    USER_EMAIL: participant.email,
+    CAMPAIGN_TITLE: campaign.title,
+    CUSTOM_MESSAGE: emailData.customMessage,
+    REPRESENTATIVES: emailData.representatives.map(rep => ({
+      name: rep.name,
+      title: rep.title,
+      email: rep.email,
+    })),
+  },
+});
+
+
+

Template Service Implementation

+

Core sendFromTemplate Method:

+
// api/src/services/email.service.ts
+
+import Handlebars from 'handlebars';
+import { prisma } from '@/config/database';
+import { EmailTemplateNotFoundError, MissingRequiredVariableError } from '@/utils/errors';
+
+class EmailService {
+  async sendFromTemplate(
+    templateKey: string,
+    options: {
+      recipientEmail: string;
+      data: Record<string, unknown>;
+      attachments?: Array<{ filename: string; path: string }>;
+    }
+  ) {
+    // 1. Load template with variables
+    const template = await prisma.emailTemplate.findUnique({
+      where: { key: templateKey, isActive: true },
+      include: { variables: true },
+    });
+
+    if (!template) {
+      throw new EmailTemplateNotFoundError(`Template not found or inactive: ${templateKey}`);
+    }
+
+    // 2. Validate required variables
+    const requiredVars = template.variables.filter(v => v.isRequired);
+    const missingVars: string[] = [];
+
+    for (const variable of requiredVars) {
+      if (options.data[variable.key] === undefined || options.data[variable.key] === null) {
+        missingVars.push(variable.key);
+      }
+    }
+
+    if (missingVars.length > 0) {
+      throw new MissingRequiredVariableError(
+        `Missing required variables for template ${templateKey}: ${missingVars.join(', ')}`
+      );
+    }
+
+    // 3. Compile Handlebars templates
+    const compiledSubject = Handlebars.compile(template.subjectLine);
+    const compiledHtml = Handlebars.compile(template.htmlContent);
+    const compiledText = Handlebars.compile(template.textContent);
+
+    // 4. Interpolate variables
+    const subject = compiledSubject(options.data);
+    const html = compiledHtml(options.data);
+    const text = compiledText(options.data);
+
+    // 5. Send via Nodemailer
+    const result = await this.send({
+      to: options.recipientEmail,
+      subject,
+      html,
+      text,
+      attachments: options.attachments,
+    });
+
+    return result;
+  }
+
+  private async send(options: {
+    to: string;
+    subject: string;
+    html: string;
+    text: string;
+    attachments?: Array<{ filename: string; path: string }>;
+  }) {
+    // Nodemailer implementation
+    // See api/src/services/email.service.ts for full implementation
+  }
+}
+
+export const emailService = new EmailService();
+
+
+

Handlebars Helper Registration

+

Register custom helpers for common formatting:

+
// api/src/services/email.service.ts
+
+import Handlebars from 'handlebars';
+import dayjs from 'dayjs';
+
+// Date formatting helper
+Handlebars.registerHelper('formatDate', (date: string | Date, format: string) => {
+  return dayjs(date).format(format);
+});
+
+// Currency formatting helper
+Handlebars.registerHelper('currency', (amount: number) => {
+  return new Intl.NumberFormat('en-CA', {
+    style: 'currency',
+    currency: 'CAD',
+  }).format(amount);
+});
+
+// Pluralize helper
+Handlebars.registerHelper('pluralize', (count: number, singular: string, plural: string) => {
+  return count === 1 ? singular : plural;
+});
+
+

Usage in Templates:

+
<p>Your shift is scheduled for {{formatDate SHIFT_DATE "MMMM D, YYYY"}}.</p>
+
+<p>You've knocked on {{DOOR_COUNT}} {{pluralize DOOR_COUNT "door" "doors"}}.</p>
+
+<p>Campaign budget: {{currency CAMPAIGN_BUDGET}}</p>
+
+
+

Error Handling

+

Custom Error Classes:

+
// api/src/utils/errors.ts
+
+export class EmailTemplateNotFoundError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = 'EmailTemplateNotFoundError';
+  }
+}
+
+export class MissingRequiredVariableError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = 'MissingRequiredVariableError';
+  }
+}
+
+export class TemplateCompilationError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = 'TemplateCompilationError';
+  }
+}
+
+

Service Error Handling:

+
try {
+  await emailService.sendFromTemplate('shift-reminder', {
+    recipientEmail: volunteer.email,
+    data: { ... },
+  });
+} catch (error) {
+  if (error instanceof EmailTemplateNotFoundError) {
+    logger.error('Template not found', { templateKey: 'shift-reminder' });
+    // Fallback to default email or skip send
+  } else if (error instanceof MissingRequiredVariableError) {
+    logger.error('Missing required variables', { error: error.message });
+    // Log to Sentry, notify admin
+  } else {
+    logger.error('Email send failed', { error });
+    throw error;
+  }
+}
+
+
+

Troubleshooting

+

Problem: Template not found

+

Symptoms: +- EmailTemplateNotFoundError: Template not found or inactive: shift-reminder +- Email not sent, exception thrown

+

Causes: +1. Template key typo (case-sensitive) +2. Template is inactive (isActive = false) +3. Template doesn't exist in database

+

Solutions:

+

Check template exists: +

SELECT * FROM email_templates WHERE key = 'shift-reminder';
+

+

Check active status: +

SELECT key, is_active FROM email_templates WHERE key = 'shift-reminder';
+

+

Activate template: +

UPDATE email_templates SET is_active = true WHERE key = 'shift-reminder';
+

+

Create template via admin GUI or seed script (see Developer Workflow above)

+
+

Problem: Variable not replaced (shows {{VAR}} in email)

+

Symptoms: +- Rendered email shows {{USER_NAME}} instead of "John Doe" +- Variables appear as raw text in subject or body

+

Causes: +1. Variable key typo in data object (case-sensitive) +2. Variable not provided in data object +3. Handlebars compilation failed silently +4. Using wrong interpolation syntax

+

Solutions:

+

Check variable key matches exactly: +

// Template uses {{USER_NAME}}
+// Data must have USER_NAME (not userName or user_name)
+data: {
+  USER_NAME: 'John Doe',  // ✓ Correct
+  userName: 'John Doe',   // ✗ Wrong case
+  user_name: 'John Doe',  // ✗ Wrong format
+}
+

+

Console log data object: +

console.log('Template data:', JSON.stringify(options.data, null, 2));
+

+

Test Handlebars compilation: +

const Handlebars = require('handlebars');
+const template = Handlebars.compile('Hello {{USER_NAME}}!');
+console.log(template({ USER_NAME: 'Test' })); // Should output: "Hello Test!"
+

+

Verify template content: +

SELECT subject_line, html_content FROM email_templates WHERE key = 'shift-reminder';
+

+
+

Problem: Missing required variable error

+

Symptoms: +- MissingRequiredVariableError: Missing required variables for template shift-reminder: SHIFT_DATE, SHIFT_TIME +- Email not sent, exception thrown

+

Causes: +1. Required variable not provided in data object +2. Variable value is null or undefined

+

Solutions:

+

Check EmailTemplateVariable.isRequired: +

SELECT key, label, is_required
+FROM email_template_variables
+WHERE template_id = (SELECT id FROM email_templates WHERE key = 'shift-reminder');
+

+

Provide all required variables: +

await emailService.sendFromTemplate('shift-reminder', {
+  recipientEmail: volunteer.email,
+  data: {
+    USER_NAME: volunteer.name,
+    SHIFT_DATE: dayjs(shift.startTime).format('MMMM D, YYYY'),  // ✓ Required
+    SHIFT_TIME: dayjs(shift.startTime).format('h:mm A'),         // ✓ Required
+  },
+});
+

+

Temporary fix (set isRequired = false): +

UPDATE email_template_variables
+SET is_required = false
+WHERE template_id = (SELECT id FROM email_templates WHERE key = 'shift-reminder')
+  AND key = 'SHIFT_TIME';
+

+

Long-term fix: Update code to always provide required variables

+
+

Problem: Email sent to wrong recipient

+

Symptoms: +- Test email sent to production recipient +- User receives email meant for another user

+

Causes: +1. Wrong recipientEmail parameter +2. Email test mode disabled (EMAIL_TEST_MODE=false) +3. Variable interpolation pulled wrong user data

+

Solutions:

+

Enable test mode in development: +

# .env
+EMAIL_TEST_MODE=true
+

+

Check recipient email: +

console.log('Sending email to:', options.recipientEmail);
+

+

Use MailHog in dev: +- All emails captured at http://localhost:8025 +- Never sent to real recipients

+

Verify user data query: +

const volunteer = await prisma.user.findUnique({ where: { id: volunteerId } });
+console.log('Volunteer email:', volunteer.email);
+

+
+

Problem: HTML rendering broken in email client

+

Symptoms: +- Email looks correct in preview but broken in Gmail/Outlook +- Images not loading +- Styles not applied

+

Causes: +1. Email client doesn't support modern CSS +2. External images blocked by email client +3. Invalid HTML structure

+

Solutions:

+

Use inline styles (not CSS classes): +

<!-- ✗ Won't work in many email clients -->
+<p class="highlight">Important message</p>
+
+<!-- ✓ Use inline styles -->
+<p style="background-color: #ffeb3b; padding: 10px; font-weight: bold;">Important message</p>
+

+

Use tables for layout (not flexbox/grid): +

<!-- ✓ Email-safe layout -->
+<table width="100%" cellpadding="0" cellspacing="0">
+  <tr>
+    <td style="padding: 20px;">
+      <p>Content here</p>
+    </td>
+  </tr>
+</table>
+

+

Embed images as data URIs or use absolute URLs: +

<!-- ✓ Absolute URL -->
+<img src="https://cmlite.org/logo.png" alt="Logo">
+
+<!-- ✓ Data URI (small images only) -->
+<img src="data:image/png;base64,iVBORw0KG..." alt="Icon">
+

+

Test in multiple email clients: +- Use Litmus or Email on Acid +- Test in Gmail, Outlook, Apple Mail, Yahoo Mail

+
+

Performance Considerations

+

Template Loading

+

Current Implementation: +- Templates fetched from database on every send +- Includes variable definitions in same query +- No caching layer

+

Performance Impact: +- Single database query per email send (~10ms) +- Acceptable for low-volume sends (< 100/min) +- May bottleneck for high-volume campaigns (> 1000/min)

+

Optimization Options:

+

1. In-Memory Caching: +

// api/src/services/email.service.ts
+
+private templateCache = new Map<string, EmailTemplate & { variables: EmailTemplateVariable[] }>();
+private cacheExpiry = new Map<string, number>();
+private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
+
+async loadTemplate(key: string) {
+  const now = Date.now();
+
+  // Check cache
+  if (this.templateCache.has(key) && this.cacheExpiry.get(key)! > now) {
+    return this.templateCache.get(key)!;
+  }
+
+  // Load from database
+  const template = await prisma.emailTemplate.findUnique({
+    where: { key, isActive: true },
+    include: { variables: true },
+  });
+
+  if (!template) throw new EmailTemplateNotFoundError(`Template not found: ${key}`);
+
+  // Cache template
+  this.templateCache.set(key, template);
+  this.cacheExpiry.set(key, now + this.CACHE_TTL);
+
+  return template;
+}
+

+

2. Redis Caching: +

import { redis } from '@/config/redis';
+
+async loadTemplate(key: string) {
+  // Try Redis cache
+  const cached = await redis.get(`email-template:${key}`);
+  if (cached) {
+    return JSON.parse(cached);
+  }
+
+  // Load from database
+  const template = await prisma.emailTemplate.findUnique({ ... });
+
+  // Cache in Redis (5 min TTL)
+  await redis.setex(`email-template:${key}`, 300, JSON.stringify(template));
+
+  return template;
+}
+

+

3. Cache Invalidation: +

// When template is updated
+await redis.del(`email-template:${template.key}`);
+this.templateCache.delete(template.key);
+

+
+

Handlebars Compilation

+

Performance: +- Handlebars compilation is fast (~1ms per template) +- No significant bottleneck for typical templates

+

Large Templates: +- Templates > 100KB may take 5-10ms to compile +- Solution: Pre-compile templates and cache compiled functions

+

Pre-Compilation: +

private compiledCache = new Map<string, {
+  subject: HandlebarsTemplateDelegate;
+  html: HandlebarsTemplateDelegate;
+  text: HandlebarsTemplateDelegate;
+}>();
+
+async sendFromTemplate(templateKey: string, options: { ... }) {
+  const template = await this.loadTemplate(templateKey);
+
+  // Check compiled cache
+  let compiled = this.compiledCache.get(templateKey);
+
+  if (!compiled) {
+    compiled = {
+      subject: Handlebars.compile(template.subjectLine),
+      html: Handlebars.compile(template.htmlContent),
+      text: Handlebars.compile(template.textContent),
+    };
+    this.compiledCache.set(templateKey, compiled);
+  }
+
+  // Interpolate
+  const subject = compiled.subject(options.data);
+  const html = compiled.html(options.data);
+  const text = compiled.text(options.data);
+
+  // Send...
+}
+

+
+

Bulk Email Sending

+

Problem: Sending 1000+ emails sequentially is slow (1-2 seconds per email)

+

Solution: Use BullMQ job queue for async batch processing

+

Queue Implementation: +

// api/src/services/email-queue.service.ts
+
+import { Queue, Worker } from 'bullmq';
+import { redis } from '@/config/redis';
+
+const emailQueue = new Queue('email-queue', {
+  connection: redis,
+});
+
+// Add email job
+export async function queueEmail(templateKey: string, options: { ... }) {
+  await emailQueue.add('send-template', {
+    templateKey,
+    recipientEmail: options.recipientEmail,
+    data: options.data,
+  });
+}
+
+// Process email jobs
+const emailWorker = new Worker('email-queue', async (job) => {
+  const { templateKey, recipientEmail, data } = job.data;
+  await emailService.sendFromTemplate(templateKey, { recipientEmail, data });
+}, {
+  connection: redis,
+  concurrency: 10, // Process 10 emails in parallel
+});
+

+

Usage: +

// Queue 1000 emails
+for (const volunteer of volunteers) {
+  await queueEmail('shift-reminder', {
+    recipientEmail: volunteer.email,
+    data: { ... },
+  });
+}
+

+
+

Security Considerations

+

XSS (Cross-Site Scripting) in Email Clients

+

Risk: Admin-authored templates may contain malicious JavaScript

+

Handlebars Auto-Escaping: +- By default, {{VAR}} escapes HTML entities +- &&amp;, <&lt;, >&gt;

+

Raw HTML (Unescaped): +- {{{VAR}}} (triple braces) renders raw HTML +- Use ONLY for trusted, application-generated content +- NEVER use for user-submitted content without sanitization

+

Example: +

<!-- Safe: auto-escaped -->
+<p>User message: {{USER_MESSAGE}}</p>
+
+<!-- Unsafe: unescaped (only use for trusted content) -->
+<div>{{{FORMATTED_CONTENT}}}</div>
+

+

Sanitization: +

import DOMPurify from 'isomorphic-dompurify';
+
+const sanitizedMessage = DOMPurify.sanitize(userInput, {
+  ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li'],
+  ALLOWED_ATTR: [],
+});
+
+await emailService.sendFromTemplate('response-notification', {
+  recipientEmail: admin.email,
+  data: {
+    USER_MESSAGE: sanitizedMessage, // Safe to use {{{...}}}
+  },
+});
+

+
+

Email Address Validation

+

Risk: Invalid email addresses cause SMTP errors or bounce emails

+

Validation Before Sending: +

import validator from 'validator';
+
+if (!validator.isEmail(options.recipientEmail)) {
+  throw new Error('Invalid recipient email address');
+}
+

+

Bounce Handling: +- Monitor bounce notifications from SMTP provider +- Mark bounced emails in database +- Disable sending to repeatedly bounced addresses

+
+

Rate Limiting Template Test Sends

+

Risk: Admin spamming test sends

+

Rate Limit Implementation: +

// api/src/modules/email-templates/email-templates.routes.ts
+
+import rateLimit from 'express-rate-limit';
+
+const testSendLimiter = rateLimit({
+  windowMs: 60 * 1000, // 1 minute
+  max: 10, // 10 requests per minute
+  message: 'Too many test sends. Please wait before trying again.',
+  standardHeaders: true,
+  legacyHeaders: false,
+});
+
+router.post('/:id/test', testSendLimiter, requireRole(SUPER_ADMIN), async (req, res) => {
+  // Test send implementation...
+});
+

+
+

Template Injection Attacks

+

Risk: Admin injects malicious Handlebars helpers or expressions

+

Handlebars Security: +- Handlebars does NOT execute JavaScript (unlike eval) +- Helpers are pre-registered by application (admin can't add custom helpers) +- No access to Node.js globals or require()

+

Safe: +

{{USER_NAME}}
+{{#if HAS_PHONE}}{{USER_PHONE}}{{/if}}
+{{#each ITEMS}}{{name}}{{/each}}
+

+

Already Prevented by Handlebars: +

<!-- These do NOT execute, render as literal text -->
+{{require('fs').readFileSync('/etc/passwd')}}
+{{process.env.DATABASE_URL}}
+

+

Best Practice: Still review templates before activating

+
+ +

Frontend Documentation

+ +

Backend Documentation

+
    +
  • Email Templates Module — API routes and schemas
  • +
  • GET /api/email-templates — List templates (with filters)
  • +
  • POST /api/email-templates — Create template
  • +
  • PUT /api/email-templates/:id — Update template
  • +
  • DELETE /api/email-templates/:id — Delete template (system templates protected)
  • +
  • POST /api/email-templates/:id/test — Send test email
  • +
  • GET /api/email-templates/:id/versions — Version history
  • +
  • POST /api/email-templates/:id/rollback/:versionNumber — Restore version
  • +
  • Email Service — Core email sending logic
  • +
  • sendFromTemplate() — Load, validate, interpolate, send
  • +
  • send() — Low-level Nodemailer wrapper
  • +
  • Handlebars helper registration
  • +
+

Database Documentation

+
    +
  • Email Templates Models — Schema definitions
  • +
  • EmailTemplate model
  • +
  • EmailTemplateVariable model
  • +
  • EmailTemplateVersion model
  • +
  • EmailTemplateTestLog model
  • +
  • Indexes and constraints
  • +
+

Feature Documentation

+ +

Configuration

+
    +
  • Environment Variables — Email-related env vars
  • +
  • EMAIL_TEST_MODE — Enable MailHog capture
  • +
  • SMTP settings (host, port, user, password)
  • +
  • Site Settings — Site-wide email settings
  • +
  • Default from name/email
  • +
  • SMTP override settings
  • +
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/email-templates/variables/index.html b/mkdocs/site/v2/features/email-templates/variables/index.html new file mode 100644 index 00000000..5a7306af --- /dev/null +++ b/mkdocs/site/v2/features/email-templates/variables/index.html @@ -0,0 +1,7264 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Variables - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Template Variables System

+

Overview

+

The Template Variables System defines reusable placeholders for email templates, enabling dynamic content interpolation with validation, documentation, and sample values. Variables are defined per template and provide metadata for variable insertion UI, validation logic, and testing workflows.

+

Key Features:

+
    +
  • Per-Template Variables — Each template has its own variable definitions
  • +
  • Required vs Optional — Enforce required variables at runtime
  • +
  • Conditional Variables — Boolean/truthy flags for {{#if}} blocks
  • +
  • Sample Values — Example data for testing and preview
  • +
  • Sort Order — Control display order in editor UI
  • +
  • Documentation — Labels and descriptions for self-documenting templates
  • +
  • Validation — Runtime checks prevent missing variable errors
  • +
  • Reusability — Common variables (USER_NAME, USER_EMAIL) across templates
  • +
+

Benefits:

+
    +
  • Type Safety — Know what data is expected before sending
  • +
  • Self-Documentation — Variables describe their purpose
  • +
  • Better Testing — Sample values pre-fill test send forms
  • +
  • Consistency — Standardized variable naming across templates
  • +
  • Error Prevention — Catch missing variables before SMTP send
  • +
+
+

Architecture

+
flowchart TB
+    subgraph "Database Layer"
+        Template[(EmailTemplate)]
+        Variables[(EmailTemplateVariable)]
+
+        Template -->|1:N| Variables
+    end
+
+    subgraph "Variable Definition"
+        VarKey[Variable Key<br/>USER_NAME]
+        VarMeta[Metadata<br/>label, description, isRequired]
+        VarSample[Sample Value<br/>'John Doe']
+        VarSort[Sort Order<br/>1, 2, 3...]
+
+        VarKey --> Variables
+        VarMeta --> Variables
+        VarSample --> Variables
+        VarSort --> Variables
+    end
+
+    subgraph "Template Service"
+        Load[Load Template + Variables]
+        Validate[Validate Required Variables]
+        Interpolate[Handlebars Interpolation]
+
+        Load --> Template
+        Load --> Variables
+        Validate --> Variables
+        Interpolate -->|{{VAR}}| Data[Data Object]
+    end
+
+    subgraph "Editor UI"
+        InsertPanel[Variable Insertion Panel]
+        PreviewForm[Sample Data Form]
+        TestSend[Test Send Form]
+
+        Variables --> InsertPanel
+        Variables --> PreviewForm
+        Variables --> TestSend
+    end
+
+    subgraph "Runtime Validation"
+        Send[Send Email]
+        Check{Required<br/>Variables<br/>Present?}
+        Error[Throw MissingVariableError]
+        Success[Send via SMTP]
+
+        Send --> Validate
+        Validate --> Check
+        Check -->|No| Error
+        Check -->|Yes| Interpolate
+        Interpolate --> Success
+    end
+
+    style Template fill:#50c878,color:#fff
+    style Variables fill:#4a90e2,color:#fff
+    style Validate fill:#ffb347,color:#333
+

Component Responsibilities:

+
    +
  • EmailTemplateVariable — Database model storing variable metadata
  • +
  • Variable Insertion Panel — Editor UI for inserting {{VARIABLES}}
  • +
  • Sample Data Form — Preview/test form pre-filled with sample values
  • +
  • Validation Service — Runtime checks before template interpolation
  • +
  • Handlebars Engine — Replaces {{VAR}} with data values
  • +
+
+

Database Model

+

EmailTemplateVariable Schema

+

Table: email_template_variables

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString (CUID)Primary key
templateIdStringForeign key to EmailTemplate
keyStringVariable name (UPPERCASE_WITH_UNDERSCORES)
labelStringDisplay label for UI ("User's Full Name")
descriptionString (optional)Variable purpose and usage notes
isRequiredBooleanIf true, must be provided in data object
isConditionalBooleanIf true, used in {{#if}} blocks (truthy/falsy)
sampleValueString (optional)Example value for testing/preview
sortOrderIntDisplay order in UI (1, 2, 3...)
createdAtDateTimeCreation timestamp
+

Relations: +- template — EmailTemplate (N:1)

+

Constraints: +- Unique index on (templateId, key) — prevents duplicate variables per template +- Index on sortOrder for ordered queries

+

Prisma Schema: +

model EmailTemplateVariable {
+  id            String   @id @default(cuid())
+  templateId    String
+  key           String
+  label         String
+  description   String?
+  isRequired    Boolean  @default(false)
+  isConditional Boolean  @default(false)
+  sampleValue   String?
+  sortOrder     Int      @default(0)
+  createdAt     DateTime @default(now())
+
+  template EmailTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
+
+  @@unique([templateId, key])
+  @@index([sortOrder])
+  @@map("email_template_variables")
+}
+

+
+

Variable Types

+

Required Variables

+

Purpose: Must be provided in data object for template to send.

+

Behavior: +- Validation checks for presence before interpolation +- Throws MissingRequiredVariableError if missing +- Marked with red "Required" badge in editor UI

+

When to Use: +- Variables that appear in ALL template renders +- Variables without fallback values +- Critical data (e.g., recipient name, event date)

+

Example: +

await prisma.emailTemplateVariable.create({
+  data: {
+    templateId: template.id,
+    key: 'USER_NAME',
+    label: 'User Name',
+    description: 'Full name of the email recipient',
+    isRequired: true,  // ← MUST be provided
+    isConditional: false,
+    sampleValue: 'John Doe',
+    sortOrder: 1,
+  },
+});
+

+

Template Usage: +

<p>Dear {{USER_NAME}},</p>
+<!-- USER_NAME is required, error if missing -->
+

+
+

Optional Variables

+

Purpose: May be omitted from data object (defaults to empty string).

+

Behavior: +- No validation error if missing +- Handlebars renders as empty string if undefined +- Useful for conditional content or nice-to-have data

+

When to Use: +- Variables that may not always be available (e.g., phone number) +- Variables with fallback text in template +- Conditional blocks that check presence

+

Example: +

await prisma.emailTemplateVariable.create({
+  data: {
+    templateId: template.id,
+    key: 'USER_PHONE',
+    label: 'User Phone',
+    description: 'User phone number (optional)',
+    isRequired: false,  // ← Can be omitted
+    isConditional: false,
+    sampleValue: '(555) 123-4567',
+    sortOrder: 5,
+  },
+});
+

+

Template Usage: +

{{#if USER_PHONE}}
+<p>We'll call you at {{USER_PHONE}}.</p>
+{{else}}
+<p>Add a phone number to receive SMS updates.</p>
+{{/if}}
+

+
+

Conditional Variables

+

Purpose: Boolean or truthy/falsy values for {{#if}} blocks.

+

Behavior: +- isConditional: true marks variable as boolean-like +- Editor UI shows blue "Conditional" badge +- Used in {{#if VAR}}...{{/if}} blocks +- Can also be required or optional

+

When to Use: +- Boolean flags (HAS_PHONE, IS_VERIFIED, IS_ADMIN) +- Existence checks (HAS_CUSTOM_MESSAGE, HAS_LOCATION) +- Feature flags (SHOW_DISCOUNT, SHOW_MAP_LINK)

+

Example: +

await prisma.emailTemplateVariable.create({
+  data: {
+    templateId: template.id,
+    key: 'HAS_PHONE',
+    label: 'Has Phone Number',
+    description: 'Whether user provided a phone number',
+    isRequired: false,
+    isConditional: true,  // ← Boolean/truthy variable
+    sampleValue: 'true',
+    sortOrder: 4,
+  },
+});
+

+

Template Usage: +

{{#if HAS_PHONE}}
+<p>Contact: {{USER_PHONE}}</p>
+{{/if}}
+

+

Truthy Values: +- true, 'true', 1, non-empty strings, non-empty arrays

+

Falsy Values: +- false, 'false', 0, '', null, undefined, []

+
+

Array Variables (Loops)

+

Purpose: Collections for {{#each}} blocks.

+

Behavior: +- Not explicitly marked (same as other variables) +- Sample value should be JSON array string +- Used in {{#each VAR}}...{{/each}} loops

+

When to Use: +- Lists of representatives, shift assignments, visit outcomes +- Dynamic content length (1-N items)

+

Example: +

await prisma.emailTemplateVariable.create({
+  data: {
+    templateId: template.id,
+    key: 'REPRESENTATIVES',
+    label: 'Representative List',
+    description: 'Array of representative objects',
+    isRequired: true,
+    isConditional: false,
+    sampleValue: JSON.stringify([
+      { name: 'Jane Doe', title: 'MP', email: 'jane@parliament.ca' },
+      { name: 'John Smith', title: 'Councillor', email: 'john@city.ca' },
+    ]),
+    sortOrder: 10,
+  },
+});
+

+

Template Usage: +

<ul>
+{{#each REPRESENTATIVES}}
+  <li>
+    <strong>{{name}}</strong> ({{title}})<br>
+    Email: {{email}}
+  </li>
+{{/each}}
+</ul>
+

+

Data Object: +

{
+  REPRESENTATIVES: [
+    { name: 'Jane Doe', title: 'MP', email: 'jane@parliament.ca' },
+    { name: 'John Smith', title: 'Councillor', email: 'john@city.ca' },
+  ],
+}
+

+
+

Admin Workflow

+

Viewing Variables

+

From EmailTemplatesPage:

+
    +
  1. Click Template Row
  2. +
  3. +

    Opens template detail modal

    +
  4. +
  5. +

    Navigate to "Variables" Tab

    +
  6. +
  7. Shows table of all variables
  8. +
  9. +

    Columns: Key, Label, Required, Conditional, Sample Value, Sort Order

    +
  10. +
  11. +

    Variable Details

    +
  12. +
  13. Click variable row for description
  14. +
  15. See where variable is used in template content
  16. +
  17. View sample value
  18. +
+

From EmailTemplateEditorPage:

+
    +
  1. Open Template Editor
  2. +
  3. +

    Variables shown in right sidebar

    +
  4. +
  5. +

    Variable Insertion Panel

    +
  6. +
  7. Variables listed with labels, badges, descriptions
  8. +
  9. Sorted by sortOrder ascending
  10. +
  11. Click "Insert to HTML/Text" buttons
  12. +
+
+

Adding Variable

+

Step 1: Open Variables Tab +- EmailTemplatesPage → click template → "Variables" tab

+

Step 2: Click "Add Variable" Button +- Opens variable creation modal

+

Step 3: Enter Variable Metadata

+

Key (required): +- Uppercase with underscores (e.g., USER_NAME) +- Must be unique within template +- Used in template as {{KEY}}

+

Label (required): +- Display name for UI (e.g., "User's Full Name") +- Human-readable description

+

Description (optional): +- Detailed explanation of variable purpose +- Usage notes (e.g., "Must be in YYYY-MM-DD format")

+

Is Required: +- Toggle on if variable must always be provided +- Validation will fail if missing

+

Is Conditional: +- Toggle on if variable is used in {{#if}} blocks +- UI shows blue "Conditional" badge

+

Sample Value (optional): +- Example value for testing/preview +- Pre-fills test send form +- Shows expected data format

+

Sort Order: +- Numeric order for UI display +- Lower numbers appear first (1, 2, 3...) +- Auto-assigned if not specified

+

Step 4: Save Variable +- Click "Save" button +- Variable added to template +- Available in editor insertion panel

+
+

Editing Variable

+

Step 1: Open Variables Tab +- EmailTemplatesPage → click template → "Variables" tab

+

Step 2: Click Variable Row +- Opens variable edit modal +- Shows current values

+

Step 3: Modify Fields +- Change label, description, flags, sample value +- Cannot change key (would break existing templates)

+

Step 4: Save Changes +- Click "Save" button +- Variable updated in database

+

Note: Changing variable key requires creating new variable and updating template content manually.

+
+

Deleting Variable

+

Step 1: Check Template Usage +- Search template content for {{VAR_KEY}} +- Ensure variable is not used in subject/HTML/text

+

Step 2: Click Delete Button +- Variables tab → click variable row → "Delete" button

+

Step 3: Confirm Deletion +- Warning modal: "Are you sure? This cannot be undone." +- Click "Confirm Delete"

+

Step 4: Verify Template Still Valid +- Open template editor +- Check preview renders without errors +- Send test email

+

Warning: Deleting a variable that's still used in template content will cause rendering errors ({{VAR}} will appear as literal text).

+
+

Reordering Variables

+

Step 1: Open Variables Tab +- EmailTemplatesPage → click template → "Variables" tab

+

Step 2: Drag to Reorder +- Drag variable rows up/down +- Drop to new position

+

Step 3: Save Sort Order +- Click "Save Order" button +- Updates sortOrder field for all variables

+

Alternative: Manual Sort Order +- Edit variable → change sortOrder number +- Variables re-sort automatically

+
+

Developer Workflow

+

Creating Variables Programmatically

+

Seed Script Example:

+
// api/prisma/seed.ts
+
+const template = await prisma.emailTemplate.findUnique({
+  where: { key: 'shift-signup-confirmation' },
+});
+
+if (!template) throw new Error('Template not found');
+
+const variables = [
+  {
+    key: 'USER_NAME',
+    label: 'User Name',
+    description: 'Full name of the volunteer',
+    isRequired: true,
+    isConditional: false,
+    sampleValue: 'John Doe',
+    sortOrder: 1,
+  },
+  {
+    key: 'USER_EMAIL',
+    label: 'User Email',
+    description: 'Email address of the volunteer',
+    isRequired: true,
+    isConditional: false,
+    sampleValue: 'john@example.com',
+    sortOrder: 2,
+  },
+  {
+    key: 'SHIFT_TITLE',
+    label: 'Shift Title',
+    description: 'Name of the shift',
+    isRequired: true,
+    isConditional: false,
+    sampleValue: 'Door Knocking - Downtown',
+    sortOrder: 3,
+  },
+  {
+    key: 'SHIFT_DATE',
+    label: 'Shift Date',
+    description: 'Formatted shift date (e.g., "Saturday, March 15, 2026")',
+    isRequired: true,
+    isConditional: false,
+    sampleValue: 'Saturday, March 15, 2026',
+    sortOrder: 4,
+  },
+  {
+    key: 'SHIFT_TIME',
+    label: 'Shift Time',
+    description: 'Shift time range (e.g., "10:00 AM - 2:00 PM")',
+    isRequired: true,
+    isConditional: false,
+    sampleValue: '10:00 AM - 2:00 PM',
+    sortOrder: 5,
+  },
+  {
+    key: 'SHIFT_LOCATION',
+    label: 'Shift Location',
+    description: 'Meeting location for shift',
+    isRequired: true,
+    isConditional: false,
+    sampleValue: 'Campaign Office (123 Main St)',
+    sortOrder: 6,
+  },
+  {
+    key: 'HAS_PHONE',
+    label: 'Has Phone Number',
+    description: 'Whether user provided a phone number',
+    isRequired: false,
+    isConditional: true,
+    sampleValue: 'true',
+    sortOrder: 7,
+  },
+  {
+    key: 'USER_PHONE',
+    label: 'User Phone',
+    description: 'User phone number (optional)',
+    isRequired: false,
+    isConditional: false,
+    sampleValue: '(555) 123-4567',
+    sortOrder: 8,
+  },
+];
+
+// Upsert variables
+for (const variable of variables) {
+  await prisma.emailTemplateVariable.upsert({
+    where: {
+      templateId_key: {
+        templateId: template.id,
+        key: variable.key,
+      },
+    },
+    update: {
+      label: variable.label,
+      description: variable.description,
+      isRequired: variable.isRequired,
+      isConditional: variable.isConditional,
+      sampleValue: variable.sampleValue,
+      sortOrder: variable.sortOrder,
+    },
+    create: {
+      templateId: template.id,
+      ...variable,
+    },
+  });
+}
+
+console.log(`✓ Created ${variables.length} variables for shift-signup-confirmation template`);
+
+
+

Loading Variables in Code

+

With Template: +

const template = await prisma.emailTemplate.findUnique({
+  where: { key: 'shift-signup-confirmation' },
+  include: { variables: true },
+});
+
+console.log('Template variables:', template?.variables);
+

+

Ordered by Sort: +

const template = await prisma.emailTemplate.findUnique({
+  where: { key: 'shift-signup-confirmation' },
+  include: {
+    variables: {
+      orderBy: { sortOrder: 'asc' },
+    },
+  },
+});
+

+

Required Variables Only: +

const requiredVars = await prisma.emailTemplateVariable.findMany({
+  where: {
+    templateId: template.id,
+    isRequired: true,
+  },
+});
+
+console.log('Required variables:', requiredVars.map(v => v.key));
+

+
+

Validating Variables

+

Validation Function:

+
// api/src/services/email.service.ts
+
+function validateVariables(
+  template: EmailTemplate & { variables: EmailTemplateVariable[] },
+  data: Record<string, unknown>
+) {
+  const missing: string[] = [];
+
+  for (const variable of template.variables) {
+    if (variable.isRequired && (data[variable.key] === undefined || data[variable.key] === null)) {
+      missing.push(variable.key);
+    }
+  }
+
+  if (missing.length > 0) {
+    throw new MissingRequiredVariableError(
+      `Missing required variables for template ${template.key}: ${missing.join(', ')}`
+    );
+  }
+}
+
+

Usage: +

const template = await prisma.emailTemplate.findUnique({
+  where: { key: 'shift-reminder' },
+  include: { variables: true },
+});
+
+try {
+  validateVariables(template, {
+    USER_NAME: 'John Doe',
+    SHIFT_DATE: '2026-03-15',
+    // Missing SHIFT_TITLE (required)
+  });
+} catch (error) {
+  console.error('Validation failed:', error.message);
+  // Error: Missing required variables for template shift-reminder: SHIFT_TITLE
+}
+

+
+

Code Examples

+

Creating Variable via API

+

Endpoint: POST /api/email-templates/:id/variables

+

Request Body: +

{
+  "key": "USER_NAME",
+  "label": "User Name",
+  "description": "Full name of the email recipient",
+  "isRequired": true,
+  "isConditional": false,
+  "sampleValue": "John Doe",
+  "sortOrder": 1
+}
+

+

Route Implementation: +

// api/src/modules/email-templates/email-templates.routes.ts
+
+router.post('/:id/variables', requireRole(SUPER_ADMIN), async (req, res) => {
+  const { id } = req.params;
+  const { key, label, description, isRequired, isConditional, sampleValue, sortOrder } = req.body;
+
+  try {
+    const variable = await prisma.emailTemplateVariable.create({
+      data: {
+        templateId: id,
+        key,
+        label,
+        description,
+        isRequired: isRequired || false,
+        isConditional: isConditional || false,
+        sampleValue,
+        sortOrder: sortOrder || 0,
+      },
+    });
+
+    res.json(variable);
+  } catch (error: any) {
+    if (error.code === 'P2002') {
+      // Unique constraint violation
+      return res.status(400).json({ error: 'Variable key already exists for this template' });
+    }
+    throw error;
+  }
+});
+

+
+

Auto-Generating Sample Data

+

Load Sample Data from Variables:

+
function generateSampleData(variables: EmailTemplateVariable[]): Record<string, unknown> {
+  const sampleData: Record<string, unknown> = {};
+
+  for (const variable of variables) {
+    if (variable.sampleValue) {
+      // Try to parse as JSON (for arrays/objects)
+      try {
+        sampleData[variable.key] = JSON.parse(variable.sampleValue);
+      } catch {
+        // Use as string
+        sampleData[variable.key] = variable.sampleValue;
+      }
+    } else if (variable.isConditional) {
+      // Default conditional variables to true
+      sampleData[variable.key] = true;
+    } else {
+      // Default to empty string
+      sampleData[variable.key] = '';
+    }
+  }
+
+  return sampleData;
+}
+
+

Usage in Editor: +

const template = await api.get(`/api/email-templates/${id}`);
+const sampleData = generateSampleData(template.variables);
+
+setSampleData(sampleData);
+

+
+

Variable Usage Detection

+

Find Variables Used in Template Content:

+
function findUsedVariables(content: string): string[] {
+  // Regex: matches {{VAR}} but not {{#if}}, {{/if}}, {{#each}}, etc.
+  const regex = /\{\{(?!#|\/|\^)([A-Z_]+)\}\}/g;
+  const matches = content.matchAll(regex);
+
+  const variables = new Set<string>();
+  for (const match of matches) {
+    variables.add(match[1]);
+  }
+
+  return Array.from(variables);
+}
+
+

Check for Unused Variables: +

const template = await prisma.emailTemplate.findUnique({
+  where: { id: templateId },
+  include: { variables: true },
+});
+
+const htmlVars = findUsedVariables(template.htmlContent);
+const textVars = findUsedVariables(template.textContent);
+const subjectVars = findUsedVariables(template.subjectLine);
+
+const usedVars = new Set([...htmlVars, ...textVars, ...subjectVars]);
+
+const unusedVars = template.variables.filter(v => !usedVars.has(v.key));
+
+console.log('Unused variables:', unusedVars.map(v => v.key));
+

+
+

Common Variables by Category

+

INFLUENCE Templates

+

Standard Variables:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyLabelRequiredConditionalDescription
USER_NAMEUser NameYesNoParticipant's full name
USER_EMAILUser EmailYesNoParticipant's email address
CAMPAIGN_TITLECampaign TitleYesNoCampaign name
CAMPAIGN_SLUGCampaign SlugYesNoURL-safe campaign identifier
CAMPAIGN_URLCampaign URLNoNoFull URL to campaign page
REPRESENTATIVE_NAMERepresentative NameYesNoRepresentative's full name
REPRESENTATIVE_TITLERepresentative TitleYesNoRepresentative's title (e.g., "MP for Downtown")
REPRESENTATIVE_EMAILRepresentative EmailYesNoRepresentative's email address
CUSTOM_MESSAGECustom MessageYesNoParticipant's custom message to representative
RESPONSE_TEXTResponse TextNoNoParticipant's response wall submission
VERIFICATION_LINKVerification LinkNoNoUnique verification URL
HAS_CUSTOM_MESSAGEHas Custom MessageNoYesWhether participant added custom message
+

Usage Example: +

await emailService.sendFromTemplate('campaign-email', {
+  recipientEmail: representative.email,
+  data: {
+    USER_NAME: participant.name,
+    USER_EMAIL: participant.email,
+    CAMPAIGN_TITLE: campaign.title,
+    CAMPAIGN_SLUG: campaign.slug,
+    REPRESENTATIVE_NAME: representative.name,
+    REPRESENTATIVE_TITLE: representative.title,
+    REPRESENTATIVE_EMAIL: representative.email,
+    CUSTOM_MESSAGE: emailData.customMessage,
+  },
+});
+

+
+

MAP Templates

+

Standard Variables:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyLabelRequiredConditionalDescription
USER_NAMEUser NameYesNoVolunteer's full name
USER_EMAILUser EmailYesNoVolunteer's email address
USER_PHONEUser PhoneNoNoVolunteer's phone number (optional)
HAS_PHONEHas PhoneNoYesWhether user provided phone number
SHIFT_TITLEShift TitleYesNoShift name
SHIFT_DATEShift DateYesNoFormatted shift date
SHIFT_TIMEShift TimeYesNoShift time range (e.g., "10:00 AM - 2:00 PM")
SHIFT_LOCATIONShift LocationYesNoMeeting location for shift
CUT_NAMECut NameNoNoCanvass area name
IS_CUT_ASSIGNEDIs Cut AssignedNoYesWhether volunteer is assigned to a cut
VISIT_COUNTVisit CountNoNoNumber of doors knocked (session summary)
CONTACT_COUNTContact CountNoNoNumber of successful contacts
SUPPORT_COUNTSupport CountNoNoNumber of supporters identified
+

Usage Example: +

await emailService.sendFromTemplate('shift-signup-confirmation', {
+  recipientEmail: volunteer.email,
+  data: {
+    USER_NAME: volunteer.name,
+    USER_EMAIL: volunteer.email,
+    USER_PHONE: volunteer.phone || '',
+    HAS_PHONE: !!volunteer.phone,
+    SHIFT_TITLE: shift.title,
+    SHIFT_DATE: dayjs(shift.startTime).format('dddd, MMMM D, YYYY'),
+    SHIFT_TIME: `${dayjs(shift.startTime).format('h:mm A')} - ${dayjs(shift.endTime).format('h:mm A')}`,
+    SHIFT_LOCATION: shift.location,
+    IS_CUT_ASSIGNED: !!shift.cutId,
+    CUT_NAME: shift.cut?.name || '',
+  },
+});
+

+
+

SYSTEM Templates

+

Standard Variables:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyLabelRequiredConditionalDescription
USER_NAMEUser NameYesNoUser's full name
USER_EMAILUser EmailYesNoUser's email address
VERIFICATION_LINKVerification LinkNoNoUnique verification URL (expires 24h)
RESET_LINKReset LinkNoNoUnique password reset URL (expires 1h)
SUPPORT_EMAILSupport EmailYesNoPlatform support email address
SITE_NAMESite NameYesNoPlatform name (from SiteSettings)
SITE_URLSite URLYesNoPlatform base URL
LOGIN_URLLogin URLNoNoDirect link to login page
LOCKOUT_REASONLockout ReasonNoNoWhy account was locked (security)
+

Usage Example: +

await emailService.sendFromTemplate('password-reset', {
+  recipientEmail: user.email,
+  data: {
+    USER_NAME: user.name,
+    USER_EMAIL: user.email,
+    RESET_LINK: `https://cmlite.org/reset-password/${token}`,
+    SUPPORT_EMAIL: siteSettings.supportEmail,
+    SITE_NAME: siteSettings.siteName,
+    SITE_URL: siteSettings.siteUrl,
+  },
+});
+

+
+

Troubleshooting

+

Problem: Variable not appearing in editor

+

Symptoms: +- Variable exists in database but not shown in editor insertion panel +- Variable missing from variables list

+

Causes: +1. Variable belongs to different template +2. Template not refreshed after adding variable +3. Sort order is null or very high (out of view)

+

Solutions:

+

Check variable exists: +

SELECT * FROM email_template_variables
+WHERE template_id = 'cuid123' AND key = 'USER_NAME';
+

+

Verify template ID: +

SELECT id, key FROM email_templates WHERE key = 'shift-reminder';
+-- Check ID matches variable.template_id
+

+

Refresh editor page: +- Hard refresh (Ctrl+Shift+R) +- Clear browser cache

+

Check sort order: +

SELECT key, sort_order FROM email_template_variables
+WHERE template_id = 'cuid123'
+ORDER BY sort_order;
+
+-- Update if needed
+UPDATE email_template_variables
+SET sort_order = 1
+WHERE id = 'variable-id';
+

+
+

Problem: Validation error for optional variable

+

Symptoms: +- MissingRequiredVariableError thrown for variable marked as optional +- Email send fails unexpectedly

+

Causes: +1. Variable incorrectly marked as required in database +2. Validation logic bug +3. Template uses variable in required context

+

Solutions:

+

Check isRequired flag: +

SELECT key, is_required FROM email_template_variables
+WHERE key = 'USER_PHONE' AND template_id = 'cuid123';
+

+

Update to optional: +

UPDATE email_template_variables
+SET is_required = false
+WHERE key = 'USER_PHONE' AND template_id = 'cuid123';
+

+

Provide variable anyway: +

// Temporary fix: always provide optional variables
+data: {
+  USER_PHONE: volunteer.phone || '',  // Empty string if missing
+}
+

+

Check validation logic: +

// Ensure validation checks for undefined AND null
+if (variable.isRequired && (data[variable.key] === undefined || data[variable.key] === null)) {
+  missing.push(variable.key);
+}
+

+
+

Problem: Sample value not used in preview

+

Symptoms: +- Preview shows empty values instead of sample values +- Test send form doesn't pre-fill

+

Causes: +1. Sample value is null in database +2. Sample data initialization bug +3. Variable added after editor loaded

+

Solutions:

+

Check sample value exists: +

SELECT key, sample_value FROM email_template_variables
+WHERE template_id = 'cuid123';
+

+

Update sample value: +

UPDATE email_template_variables
+SET sample_value = 'John Doe'
+WHERE key = 'USER_NAME';
+

+

Refresh editor: +- Close and reopen EmailTemplateEditorPage +- Sample data reloads from variables

+

Manual preview data: +

// Editor UI allows manual editing of sample data
+setSampleData({
+  ...sampleData,
+  USER_NAME: 'Test Name',
+});
+

+
+

Problem: Duplicate variable key error

+

Symptoms: +- P2002: Unique constraint failed error when creating variable +- Cannot add variable with same key

+

Causes: +1. Variable already exists for this template +2. Attempting to create duplicate

+

Solutions:

+

Check existing variables: +

SELECT * FROM email_template_variables
+WHERE template_id = 'cuid123' AND key = 'USER_NAME';
+

+

Update existing instead: +

await prisma.emailTemplateVariable.upsert({
+  where: {
+    templateId_key: {
+      templateId: template.id,
+      key: 'USER_NAME',
+    },
+  },
+  update: {
+    label: 'User Full Name',  // Updated label
+  },
+  create: {
+    templateId: template.id,
+    key: 'USER_NAME',
+    label: 'User Full Name',
+    // ...
+  },
+});
+

+

Use different key: +

// If truly need separate variable
+key: 'USER_FULL_NAME',  // Not USER_NAME
+

+
+

Problem: Variables not alphabetically sorted

+

Symptoms: +- Variables appear in random order in editor +- Want alphabetical order instead of custom sort

+

Causes: +- Sort order not set alphabetically +- Need to update sortOrder values

+

Solutions:

+

Sort alphabetically by key: +

-- Generate new sort order based on alphabetical order
+WITH sorted AS (
+  SELECT id, ROW_NUMBER() OVER (PARTITION BY template_id ORDER BY key) AS new_order
+  FROM email_template_variables
+  WHERE template_id = 'cuid123'
+)
+UPDATE email_template_variables
+SET sort_order = sorted.new_order
+FROM sorted
+WHERE email_template_variables.id = sorted.id;
+

+

Sort by label: +

WITH sorted AS (
+  SELECT id, ROW_NUMBER() OVER (PARTITION BY template_id ORDER BY label) AS new_order
+  FROM email_template_variables
+  WHERE template_id = 'cuid123'
+)
+UPDATE email_template_variables
+SET sort_order = sorted.new_order
+FROM sorted
+WHERE email_template_variables.id = sorted.id;
+

+

Manual custom order: +- Use admin UI to drag-drop reorder +- Saves custom sortOrder values

+
+

Performance Considerations

+

Variable Loading

+

Current Implementation: +- Variables loaded with template via include: { variables: true } +- Single database query (JOIN) +- Fast (< 10ms for typical templates)

+

Optimization for Many Variables: +

// If template has 100+ variables, consider pagination
+const variables = await prisma.emailTemplateVariable.findMany({
+  where: { templateId: template.id },
+  orderBy: { sortOrder: 'asc' },
+  take: 50,  // Load first 50
+  skip: 0,   // Offset for pagination
+});
+

+
+

Validation Performance

+

Required Variable Check: +- O(n) where n = number of required variables +- Fast for typical templates (< 10 required vars) +- No database queries (uses in-memory variable list)

+

Caching Variables: +

// Cache template + variables to avoid DB lookup per send
+const templateCache = new Map<string, EmailTemplate & { variables: EmailTemplateVariable[] }>();
+
+async function loadTemplate(key: string) {
+  if (templateCache.has(key)) {
+    return templateCache.get(key)!;
+  }
+
+  const template = await prisma.emailTemplate.findUnique({
+    where: { key, isActive: true },
+    include: { variables: true },
+  });
+
+  if (template) {
+    templateCache.set(key, template);
+  }
+
+  return template;
+}
+

+
+

Best Practices

+

Variable Naming Conventions

+

Use UPPERCASE_WITH_UNDERSCORES: +

// ✓ Good
+USER_NAME
+SHIFT_DATE
+HAS_PHONE
+REPRESENTATIVE_EMAIL
+
+// ✗ Bad
+userName      // Not uppercase
+user-name     // Dashes not underscores
+UserName      // PascalCase
+

+

Be Descriptive: +

// ✓ Good
+SHIFT_START_TIME
+CAMPAIGN_TITLE
+IS_EMAIL_VERIFIED
+
+// ✗ Bad
+TIME          // Too vague
+TITLE         // Ambiguous
+VERIFIED      // Missing context
+

+

Prefix Booleans with IS/HAS: +

// ✓ Good
+HAS_PHONE
+IS_VERIFIED
+IS_CUT_ASSIGNED
+
+// ✗ Bad
+PHONE         // Not clearly boolean
+VERIFIED      // Ambiguous (boolean or timestamp?)
+

+
+

Documentation

+

Always Provide Labels: +

// ✓ Good
+label: 'User\'s Full Name',
+description: 'Full name of the email recipient',
+
+// ✗ Bad
+label: 'Name',  // Too generic
+description: '',
+

+

Document Expected Format: +

// ✓ Good
+description: 'Shift date in format "Saturday, March 15, 2026"',
+sampleValue: 'Saturday, March 15, 2026',
+
+// ✗ Bad
+description: 'The date',
+sampleValue: '2026-03-15',  // Doesn't match expected format
+

+
+

Sample Values

+

Provide Realistic Examples: +

// ✓ Good
+sampleValue: 'John Doe',                      // USER_NAME
+sampleValue: 'Saturday, March 15, 2026',      // SHIFT_DATE
+sampleValue: '(555) 123-4567',                // USER_PHONE
+
+// ✗ Bad
+sampleValue: 'test',                          // Not realistic
+sampleValue: '123',                           // Not realistic phone
+

+

Use JSON for Arrays/Objects: +

// ✓ Good
+sampleValue: JSON.stringify([
+  { name: 'Jane Doe', email: 'jane@example.com' },
+  { name: 'John Smith', email: 'john@example.com' },
+]),
+
+// ✗ Bad
+sampleValue: 'array of representatives',  // Not parseable
+

+
+

Required vs Optional

+

Make Variables Required If: +- Used in subject line (always visible) +- Critical to email meaning (e.g., event date) +- No reasonable default value

+

Make Variables Optional If: +- Used in conditional blocks ({{#if}}) +- Nice-to-have but not critical +- Has fallback text in template

+
+ +

Frontend Documentation

+ +

Backend Documentation

+
    +
  • Email Templates Module — Variable CRUD API
  • +
  • GET /api/email-templates/:id/variables — List variables
  • +
  • POST /api/email-templates/:id/variables — Create variable
  • +
  • PUT /api/email-templates/:id/variables/:varId — Update variable
  • +
  • DELETE /api/email-templates/:id/variables/:varId — Delete variable
  • +
+

Database Documentation

+ +

Feature Documentation

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/email-templates/versioning/index.html b/mkdocs/site/v2/features/email-templates/versioning/index.html new file mode 100644 index 00000000..2e40d22d --- /dev/null +++ b/mkdocs/site/v2/features/email-templates/versioning/index.html @@ -0,0 +1,6765 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Versioning - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Template Version History

+

Overview

+

The Template Version History system provides comprehensive audit trails for email template changes with automatic version creation, rollback capability, and change tracking. Every template save creates a new version snapshot, preserving the complete history of modifications with metadata about who changed what and why.

+

Key Features:

+
    +
  • Automatic Version Creation — Every save creates a new version (no manual versioning)
  • +
  • Auto-Incrementing Version Numbers — Sequential numbering (1, 2, 3...) per template
  • +
  • Complete Snapshots — Stores subject line, HTML content, and text content
  • +
  • Change Notes — Optional admin-provided descriptions of changes
  • +
  • User Attribution — Tracks who created each version
  • +
  • Rollback Capability — Restore any previous version (non-destructive)
  • +
  • Version Comparison — Visual diff between any two versions
  • +
  • Audit Trail — Full history for compliance and debugging
  • +
+

Benefits:

+
    +
  • Accident Recovery — Undo mistakes by rolling back to previous version
  • +
  • Change Tracking — See what changed and when
  • +
  • Compliance — Audit trail for regulatory requirements
  • +
  • Collaboration — Multiple admins can see each other's changes
  • +
  • Experimentation — Safely test changes knowing you can rollback
  • +
  • Documentation — Change notes explain why changes were made
  • +
+
+

Architecture

+
flowchart TB
+    subgraph "Version Creation Flow"
+        Save[Admin Saves Template]
+        FindMax[Find Max Version Number]
+        Increment[Increment to Next Version]
+        CreateVersion[Create EmailTemplateVersion]
+        UpdateTemplate[Update EmailTemplate]
+
+        Save --> FindMax
+        FindMax --> Increment
+        Increment --> CreateVersion
+        CreateVersion --> UpdateTemplate
+    end
+
+    subgraph "Database Models"
+        Template[(EmailTemplate)]
+        Versions[(EmailTemplateVersion)]
+
+        Template -->|1:N| Versions
+    end
+
+    subgraph "Version Data"
+        Snapshot[Content Snapshot<br/>subject, HTML, text]
+        Meta[Metadata<br/>version number, change notes]
+        Attribution[Attribution<br/>created by user, timestamp]
+
+        Snapshot --> Versions
+        Meta --> Versions
+        Attribution --> Versions
+    end
+
+    subgraph "Version Operations"
+        List[List Version History]
+        Compare[Compare Two Versions]
+        Rollback[Rollback to Version]
+        View[View Version Details]
+
+        Versions --> List
+        Versions --> Compare
+        Versions --> Rollback
+        Versions --> View
+    end
+
+    subgraph "Rollback Flow"
+        SelectVersion[Select Old Version]
+        LoadContent[Load Old Content]
+        UpdateCurrent[Update Current Template]
+        CreateNewVersion[Create New Version<br/>'Rolled back to vX']
+
+        SelectVersion --> LoadContent
+        LoadContent --> UpdateCurrent
+        UpdateCurrent --> CreateNewVersion
+        CreateNewVersion --> Versions
+    end
+
+    Save --> Template
+    CreateVersion --> Versions
+    Rollback --> Template
+
+    style Save fill:#4a90e2,color:#fff
+    style CreateVersion fill:#50c878,color:#fff
+    style Rollback fill:#ff6b6b,color:#fff
+

Component Responsibilities:

+
    +
  • EmailTemplateVersion — Version snapshot storage with metadata
  • +
  • Version Service — Auto-increment logic, version creation
  • +
  • Rollback Service — Restore old version as new version (non-destructive)
  • +
  • Comparison Service — Diff generation between versions
  • +
  • Audit Log — User attribution and change notes
  • +
+
+

Database Model

+

EmailTemplateVersion Schema

+

Table: email_template_versions

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString (CUID)Primary key
templateIdStringForeign key to EmailTemplate
versionNumberIntAuto-incremented version (1, 2, 3...)
subjectLineStringSubject line snapshot
htmlContentTextHTML content snapshot
textContentTextPlain text content snapshot
changeNotesString (optional)Admin-provided change description
createdByUserIdString (optional)User who created this version
createdAtDateTimeVersion creation timestamp
+

Relations: +- template — EmailTemplate (N:1) +- createdBy — User (N:1)

+

Constraints: +- Unique index on (templateId, versionNumber) for version lookup +- Auto-increment logic in service layer (finds max + 1) +- No ON DELETE CASCADE (preserve versions even if template deleted)

+

Prisma Schema: +

model EmailTemplateVersion {
+  id              String   @id @default(cuid())
+  templateId      String
+  versionNumber   Int
+  subjectLine     String
+  htmlContent     String   @db.Text
+  textContent     String   @db.Text
+  changeNotes     String?
+  createdByUserId String?
+  createdAt       DateTime @default(now())
+
+  template  EmailTemplate @relation(fields: [templateId], references: [id])
+  createdBy User?         @relation(fields: [createdByUserId], references: [id])
+
+  @@unique([templateId, versionNumber])
+  @@index([templateId])
+  @@index([createdAt])
+  @@map("email_template_versions")
+}
+

+
+

Version Creation

+

Automatic Versioning on Save

+

When Versions Are Created: +- Admin saves template via EmailTemplateEditorPage +- API PUT /api/email-templates/:id endpoint called +- Version created BEFORE updating template (snapshot current state)

+

Auto-Increment Logic:

+
// api/src/modules/email-templates/email-templates.service.ts
+
+async function createVersion(
+  templateId: string,
+  options: {
+    changeNotes?: string;
+    createdByUserId?: string;
+  }
+) {
+  // 1. Find max version number for this template
+  const maxVersion = await prisma.emailTemplateVersion.findFirst({
+    where: { templateId },
+    orderBy: { versionNumber: 'desc' },
+    select: { versionNumber: true },
+  });
+
+  const nextVersion = (maxVersion?.versionNumber || 0) + 1;
+
+  // 2. Load current template content
+  const template = await prisma.emailTemplate.findUnique({
+    where: { id: templateId },
+  });
+
+  if (!template) {
+    throw new Error('Template not found');
+  }
+
+  // 3. Create version snapshot
+  const version = await prisma.emailTemplateVersion.create({
+    data: {
+      templateId,
+      versionNumber: nextVersion,
+      subjectLine: template.subjectLine,
+      htmlContent: template.htmlContent,
+      textContent: template.textContent,
+      changeNotes: options.changeNotes,
+      createdByUserId: options.createdByUserId,
+    },
+  });
+
+  return version;
+}
+
+

Save Template with Versioning:

+
// api/src/modules/email-templates/email-templates.routes.ts
+
+router.put('/:id', requireRole(SUPER_ADMIN), async (req, res) => {
+  const { id } = req.params;
+  const { subjectLine, htmlContent, textContent, changeNotes } = req.body;
+
+  try {
+    // 1. Create version BEFORE updating (snapshot current state)
+    await createVersion(id, {
+      changeNotes,
+      createdByUserId: req.user!.id,
+    });
+
+    // 2. Update template with new content
+    const updatedTemplate = await prisma.emailTemplate.update({
+      where: { id },
+      data: {
+        subjectLine,
+        htmlContent,
+        textContent,
+        updatedByUserId: req.user!.id,
+      },
+    });
+
+    res.json(updatedTemplate);
+  } catch (error) {
+    logger.error('Failed to save template', { error, templateId: id });
+    res.status(500).json({ error: 'Failed to save template' });
+  }
+});
+
+

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.

+
+

Version Number Sequence

+

Sequence Rules: +- Starts at 1 for first version +- Increments by 1 for each save +- Per-template sequence (not global) +- No gaps in sequence

+

Example Timeline:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ActionVersionSubjectHTMLChange Notes
Create template1"Welcome!"<p>Hello</p>(initial version)
Edit subject2"Welcome to Our Platform!"<p>Hello</p>"Made subject more descriptive"
Add content3"Welcome to Our Platform!"<p>Hello {{USER_NAME}}</p>"Added user name variable"
Rollback to v14"Welcome!"<p>Hello</p>"Rolled back to version 1"
+

Note: Rollback creates NEW version (v4 in example), doesn't delete v2 and v3. This preserves complete audit trail.

+
+

Change Notes

+

Purpose: Describe what changed and why (audit trail documentation)

+

When Prompted: +- EmailTemplateEditorPage shows "Change Notes" field on save +- Optional but recommended +- Stored in changeNotes field

+

Examples:

+

Good Change Notes: +

- "Added phone number conditional block"
+- "Fixed typo in subject line"
+- "Updated shift location variable to include address"
+- "Removed deprecated campaign URL variable"
+- "Rolled back to version 5 due to rendering issue"
+

+

Poor Change Notes: +

- "update" (not descriptive)
+- "changes" (too vague)
+- "" (empty, no context)
+

+

Implementation: +

// EmailTemplateEditorPage.tsx
+
+const [saveModalVisible, setSaveModalVisible] = useState(false);
+const [changeNotes, setChangeNotes] = useState('');
+
+const handleSave = async () => {
+  await api.put(`/api/email-templates/${id}`, {
+    subjectLine,
+    htmlContent,
+    textContent,
+    changeNotes: changeNotes || undefined,  // Optional
+  });
+
+  message.success('Template saved successfully');
+  navigate('/app/email-templates');
+};
+
+// Modal UI
+<Modal title="Save Template" visible={saveModalVisible} onOk={handleSave}>
+  <Form.Item label="Change Notes (optional)">
+    <TextArea
+      value={changeNotes}
+      onChange={(e) => setChangeNotes(e.target.value)}
+      placeholder="Describe what changed in this version"
+      rows={4}
+    />
+  </Form.Item>
+</Modal>
+

+
+

Admin Workflow

+

Viewing Version History

+

Step 1: Open Template Detail +- EmailTemplatesPage → click template row +- Opens template detail modal

+

Step 2: Navigate to "Version History" Tab +- Click "Version History" tab +- Shows table of all versions

+

Version History Table Columns: +- Version — Version number (e.g., "v3") +- Created — Timestamp (e.g., "2026-03-15 14:23") +- Created By — User name (e.g., "John Doe") +- Change Notes — Description of changes +- Actions — View, Compare, Restore buttons

+

Sorting: +- Default: Descending by version number (newest first) +- Can sort by created date or version number

+
+

Viewing Version Details

+

Step 1: Click "View" Button +- Version history table → click version row → "View" button

+

Step 2: Version Detail Modal +- Shows version metadata: + - Version number + - Created by user + - Created timestamp + - Change notes +- Shows content snapshot: + - Subject line + - HTML content (scrollable textarea) + - Text content (scrollable textarea)

+

Step 3: Preview Rendered Version +- Click "Preview" button +- Renders HTML with sample data +- Shows how email looked at that version

+
+

Comparing Versions

+

Step 1: Select Two Versions +- Version history table → checkbox on two version rows +- Click "Compare Selected" button

+

Step 2: Comparison Modal +- Side-by-side diff view: + - Left: Older version + - Right: Newer version +- Highlighting: + - Green: Added lines + - Red: Deleted lines + - Yellow: Modified lines

+

Comparison Sections: +- Subject Line Diff — Shows changes in subject +- HTML Content Diff — Line-by-line HTML diff +- Text Content Diff — Line-by-line text diff

+

Implementation: +

import { diffLines } from 'diff';
+
+function renderDiff(oldContent: string, newContent: string) {
+  const diff = diffLines(oldContent, newContent);
+
+  return diff.map((part, index) => {
+    let color = 'black';
+    let backgroundColor = 'transparent';
+
+    if (part.added) {
+      color = 'green';
+      backgroundColor = '#e6ffed';
+    } else if (part.removed) {
+      color = 'red';
+      backgroundColor = '#ffebe9';
+    }
+
+    return (
+      <pre
+        key={index}
+        style={{
+          color,
+          backgroundColor,
+          margin: 0,
+          padding: '2px 4px',
+          fontFamily: 'monospace',
+          fontSize: 12,
+        }}
+      >
+        {part.value}
+      </pre>
+    );
+  });
+}
+

+
+

Rolling Back to Previous Version

+

Step 1: Select Version to Restore +- Version history table → click version row

+

Step 2: Click "Restore" Button +- Opens confirmation modal

+

Step 3: Confirm Rollback +- Modal shows: + - Version being restored (e.g., "Version 5") + - Warning: "This will create a new version with this content" + - Change notes field (pre-filled: "Rolled back to version 5")

+

Step 4: Confirm and Save +- Click "Confirm Restore" +- Creates new version (e.g., v10) with content from v5 +- Current template updated to v5 content +- Redirects to EmailTemplatesPage

+

Rollback Process:

+
// api/src/modules/email-templates/email-templates.routes.ts
+
+router.post('/:id/rollback/:versionNumber', requireRole(SUPER_ADMIN), async (req, res) => {
+  const { id } = req.params;
+  const versionNumber = parseInt(req.params.versionNumber);
+
+  try {
+    // 1. Load version to restore
+    const versionToRestore = await prisma.emailTemplateVersion.findUnique({
+      where: {
+        templateId_versionNumber: {
+          templateId: id,
+          versionNumber,
+        },
+      },
+    });
+
+    if (!versionToRestore) {
+      return res.status(404).json({ error: 'Version not found' });
+    }
+
+    // 2. Create version snapshot BEFORE rollback (current state)
+    await createVersion(id, {
+      changeNotes: `Rolled back to version ${versionNumber}`,
+      createdByUserId: req.user!.id,
+    });
+
+    // 3. Update template with old version content
+    const updatedTemplate = await prisma.emailTemplate.update({
+      where: { id },
+      data: {
+        subjectLine: versionToRestore.subjectLine,
+        htmlContent: versionToRestore.htmlContent,
+        textContent: versionToRestore.textContent,
+        updatedByUserId: req.user!.id,
+      },
+    });
+
+    res.json(updatedTemplate);
+  } catch (error) {
+    logger.error('Failed to rollback template', { error, templateId: id, versionNumber });
+    res.status(500).json({ error: 'Failed to rollback template' });
+  }
+});
+
+

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.

+
+

Code Examples

+

Creating Version Manually

+

When to Use: +- Seed script initialization +- Programmatic template updates +- Testing version history

+

Example: +

// Create initial version for new template
+const template = await prisma.emailTemplate.create({
+  data: {
+    key: 'user-welcome',
+    name: 'Welcome Email',
+    category: 'SYSTEM',
+    subjectLine: 'Welcome!',
+    htmlContent: '<p>Hello {{USER_NAME}}</p>',
+    textContent: 'Hello {{USER_NAME}}',
+    isActive: true,
+  },
+});
+
+// Create version 1
+await prisma.emailTemplateVersion.create({
+  data: {
+    templateId: template.id,
+    versionNumber: 1,
+    subjectLine: template.subjectLine,
+    htmlContent: template.htmlContent,
+    textContent: template.textContent,
+    changeNotes: 'Initial template creation',
+    createdByUserId: adminUser.id,
+  },
+});
+

+
+

Loading Version History

+

Fetch All Versions: +

const versions = await prisma.emailTemplateVersion.findMany({
+  where: { templateId },
+  orderBy: { versionNumber: 'desc' },
+  include: {
+    createdBy: {
+      select: { name: true, email: true },
+    },
+  },
+});
+
+console.log('Version history:', versions);
+

+

Fetch Specific Version: +

const version = await prisma.emailTemplateVersion.findUnique({
+  where: {
+    templateId_versionNumber: {
+      templateId: 'cuid123',
+      versionNumber: 5,
+    },
+  },
+});
+

+

Fetch Latest Version: +

const latestVersion = await prisma.emailTemplateVersion.findFirst({
+  where: { templateId },
+  orderBy: { versionNumber: 'desc' },
+});
+
+console.log('Latest version:', latestVersion.versionNumber);
+

+
+

Version Diff Generation

+

Line-by-Line Diff:

+
import { diffLines, Change } from 'diff';
+
+interface VersionDiff {
+  subject: Change[];
+  html: Change[];
+  text: Change[];
+}
+
+function compareVersions(
+  oldVersion: EmailTemplateVersion,
+  newVersion: EmailTemplateVersion
+): VersionDiff {
+  return {
+    subject: diffLines(oldVersion.subjectLine, newVersion.subjectLine),
+    html: diffLines(oldVersion.htmlContent, newVersion.htmlContent),
+    text: diffLines(oldVersion.textContent, newVersion.textContent),
+  };
+}
+
+

Usage: +

const version5 = await prisma.emailTemplateVersion.findUnique({
+  where: { templateId_versionNumber: { templateId, versionNumber: 5 } },
+});
+
+const version6 = await prisma.emailTemplateVersion.findUnique({
+  where: { templateId_versionNumber: { templateId, versionNumber: 6 } },
+});
+
+const diff = compareVersions(version5, version6);
+
+console.log('Subject changes:', diff.subject);
+console.log('HTML changes:', diff.html);
+console.log('Text changes:', diff.text);
+

+

Render Diff in UI: +

// admin/src/components/VersionDiff.tsx
+
+import { diffLines } from 'diff';
+
+interface VersionDiffProps {
+  oldContent: string;
+  newContent: string;
+  title: string;
+}
+
+export function VersionDiff({ oldContent, newContent, title }: VersionDiffProps) {
+  const diff = diffLines(oldContent, newContent);
+
+  return (
+    <div>
+      <h4>{title}</h4>
+      <pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 12 }}>
+        {diff.map((part, index) => {
+          let style = {};
+
+          if (part.added) {
+            style = { color: 'green', backgroundColor: '#e6ffed' };
+          } else if (part.removed) {
+            style = { color: 'red', backgroundColor: '#ffebe9' };
+          }
+
+          return (
+            <span key={index} style={style}>
+              {part.value}
+            </span>
+          );
+        })}
+      </pre>
+    </div>
+  );
+}
+

+
+

Rollback API Implementation

+

Full Rollback Route:

+
// api/src/modules/email-templates/email-templates.routes.ts
+
+import { Router } from 'express';
+import { requireRole } from '@/middleware/auth';
+import { prisma } from '@/config/database';
+import { logger } from '@/utils/logger';
+
+const router = Router();
+
+router.post('/:id/rollback/:versionNumber', requireRole('SUPER_ADMIN'), async (req, res) => {
+  const { id } = req.params;
+  const versionNumber = parseInt(req.params.versionNumber, 10);
+
+  if (isNaN(versionNumber) || versionNumber < 1) {
+    return res.status(400).json({ error: 'Invalid version number' });
+  }
+
+  try {
+    // 1. Load version to restore
+    const versionToRestore = await prisma.emailTemplateVersion.findUnique({
+      where: {
+        templateId_versionNumber: {
+          templateId: id,
+          versionNumber,
+        },
+      },
+    });
+
+    if (!versionToRestore) {
+      return res.status(404).json({ error: 'Version not found' });
+    }
+
+    // 2. Load current template
+    const currentTemplate = await prisma.emailTemplate.findUnique({
+      where: { id },
+    });
+
+    if (!currentTemplate) {
+      return res.status(404).json({ error: 'Template not found' });
+    }
+
+    // 3. Check if already at this version (no-op)
+    if (
+      currentTemplate.subjectLine === versionToRestore.subjectLine &&
+      currentTemplate.htmlContent === versionToRestore.htmlContent &&
+      currentTemplate.textContent === versionToRestore.textContent
+    ) {
+      return res.status(400).json({ error: 'Template already matches this version' });
+    }
+
+    // 4. Use transaction for atomicity
+    await prisma.$transaction(async (tx) => {
+      // 4a. Create version snapshot of CURRENT state
+      const maxVersion = await tx.emailTemplateVersion.findFirst({
+        where: { templateId: id },
+        orderBy: { versionNumber: 'desc' },
+        select: { versionNumber: true },
+      });
+
+      const nextVersion = (maxVersion?.versionNumber || 0) + 1;
+
+      await tx.emailTemplateVersion.create({
+        data: {
+          templateId: id,
+          versionNumber: nextVersion,
+          subjectLine: currentTemplate.subjectLine,
+          htmlContent: currentTemplate.htmlContent,
+          textContent: currentTemplate.textContent,
+          changeNotes: `Rolled back to version ${versionNumber}`,
+          createdByUserId: req.user!.id,
+        },
+      });
+
+      // 4b. Update template with OLD version content
+      await tx.emailTemplate.update({
+        where: { id },
+        data: {
+          subjectLine: versionToRestore.subjectLine,
+          htmlContent: versionToRestore.htmlContent,
+          textContent: versionToRestore.textContent,
+          updatedByUserId: req.user!.id,
+        },
+      });
+    });
+
+    // 5. Load updated template
+    const updatedTemplate = await prisma.emailTemplate.findUnique({
+      where: { id },
+    });
+
+    logger.info('Template rolled back', {
+      templateId: id,
+      toVersion: versionNumber,
+      userId: req.user!.id,
+    });
+
+    res.json(updatedTemplate);
+  } catch (error: any) {
+    logger.error('Failed to rollback template', {
+      error: error.message,
+      templateId: id,
+      versionNumber,
+    });
+    res.status(500).json({ error: 'Failed to rollback template' });
+  }
+});
+
+export default router;
+
+
+

Troubleshooting

+

Problem: Version numbers not auto-incrementing

+

Symptoms: +- Duplicate version number error +- P2002: Unique constraint failed on templateId_versionNumber

+

Causes: +1. Race condition (two saves at same time) +2. Max version query returns wrong result +3. Database constraint violated

+

Solutions:

+

Check max version: +

SELECT MAX(version_number) FROM email_template_versions
+WHERE template_id = 'cuid123';
+

+

Use transaction for atomicity: +

await prisma.$transaction(async (tx) => {
+  // 1. Find max version
+  const maxVersion = await tx.emailTemplateVersion.findFirst({
+    where: { templateId },
+    orderBy: { versionNumber: 'desc' },
+  });
+
+  const nextVersion = (maxVersion?.versionNumber || 0) + 1;
+
+  // 2. Create version (within same transaction)
+  await tx.emailTemplateVersion.create({
+    data: {
+      templateId,
+      versionNumber: nextVersion,
+      // ...
+    },
+  });
+});
+

+

Reset sequence if needed: +

-- Check for gaps
+SELECT version_number FROM email_template_versions
+WHERE template_id = 'cuid123'
+ORDER BY version_number;
+
+-- If gaps exist, renumber (DANGEROUS, only in dev)
+UPDATE email_template_versions
+SET version_number = (
+  SELECT COUNT(*) FROM email_template_versions AS v2
+  WHERE v2.template_id = email_template_versions.template_id
+    AND v2.created_at <= email_template_versions.created_at
+)
+WHERE template_id = 'cuid123';
+

+
+

Problem: Rollback creates infinite versions

+

Symptoms: +- Rollback triggers another rollback +- Version numbers increment rapidly

+

Causes: +1. Rollback doesn't use transaction +2. Version creation triggers template update hook

+

Solutions:

+

Use atomic transaction: +

await prisma.$transaction(async (tx) => {
+  // Create version + update template in same transaction
+  await tx.emailTemplateVersion.create({ ... });
+  await tx.emailTemplate.update({ ... });
+});
+

+

Disable hooks during rollback: +

// If using Prisma middleware, skip version creation during rollback
+prisma.$use(async (params, next) => {
+  if (params.model === 'EmailTemplate' && params.action === 'update') {
+    // Check if this is a rollback operation (via context flag)
+    if (params.args.data._isRollback) {
+      delete params.args.data._isRollback;
+      return next(params);  // Skip version creation
+    }
+
+    // Normal update: create version
+    await createVersion(params.args.where.id);
+  }
+
+  return next(params);
+});
+

+
+

Problem: Version history shows duplicate content

+

Symptoms: +- Multiple versions with identical content +- Version numbers increment but content unchanged

+

Causes: +1. Save triggered multiple times (double-click) +2. No dirty check before saving

+

Solutions:

+

Add content comparison before save: +

router.put('/:id', requireRole(SUPER_ADMIN), async (req, res) => {
+  const { id } = req.params;
+  const { subjectLine, htmlContent, textContent, changeNotes } = req.body;
+
+  // 1. Load current template
+  const currentTemplate = await prisma.emailTemplate.findUnique({ where: { id } });
+
+  // 2. Check if content changed
+  if (
+    currentTemplate.subjectLine === subjectLine &&
+    currentTemplate.htmlContent === htmlContent &&
+    currentTemplate.textContent === textContent
+  ) {
+    return res.status(400).json({ error: 'No changes detected' });
+  }
+
+  // 3. Create version + update template
+  await createVersion(id, { changeNotes, createdByUserId: req.user!.id });
+  await prisma.emailTemplate.update({ where: { id }, data: { subjectLine, htmlContent, textContent } });
+
+  res.json({ success: true });
+});
+

+

Debounce save button: +

// EmailTemplateEditorPage.tsx
+
+const [saving, setSaving] = useState(false);
+
+const handleSave = async () => {
+  if (saving) return;  // Prevent double-click
+
+  setSaving(true);
+  try {
+    await api.put(`/api/email-templates/${id}`, { ... });
+  } finally {
+    setSaving(false);
+  }
+};
+

+
+

Problem: Version comparison shows no diff

+

Symptoms: +- Comparison modal shows identical content +- No green/red highlighting

+

Causes: +1. Comparing version with itself +2. Versions truly identical (duplicate save)

+

Solutions:

+

Prevent self-comparison: +

function handleCompare(version1: number, version2: number) {
+  if (version1 === version2) {
+    message.error('Cannot compare version with itself');
+    return;
+  }
+
+  // Load and compare versions...
+}
+

+

Check versions exist: +

SELECT version_number, LENGTH(html_content) AS html_length
+FROM email_template_versions
+WHERE template_id = 'cuid123'
+  AND version_number IN (5, 6);
+

+
+

Problem: Rollback doesn't restore variables

+

Symptoms: +- Template content rolled back +- Variables not restored (still showing new variables)

+

Causes: +- Variables stored separately (not in version snapshot)

+

Current Limitation: +- EmailTemplateVersion only stores content (subject, HTML, text) +- Does NOT store variable definitions +- Rolling back template doesn't affect variables

+

Workaround: +- Manually restore variables via admin UI +- Future enhancement: Version variable definitions too

+

Future Enhancement: +

// Add to EmailTemplateVersion model
+variablesSnapshot: Prisma.JsonValue  // JSON array of variables
+
+// When creating version, snapshot variables
+const variables = await prisma.emailTemplateVariable.findMany({
+  where: { templateId },
+});
+
+await prisma.emailTemplateVersion.create({
+  data: {
+    // ...
+    variablesSnapshot: variables as unknown as Prisma.InputJsonValue,
+  },
+});
+
+// When rolling back, restore variables
+const variablesSnapshot = versionToRestore.variablesSnapshot as EmailTemplateVariable[];
+
+for (const variable of variablesSnapshot) {
+  await prisma.emailTemplateVariable.upsert({
+    where: { templateId_key: { templateId, key: variable.key } },
+    update: variable,
+    create: { templateId, ...variable },
+  });
+}
+

+
+

Performance Considerations

+

Version Storage Growth

+

Storage Impact: +- Each version stores 3 text fields (subject, HTML, text) +- Typical template: 5-20KB per version +- 100 versions = 500KB - 2MB per template

+

Optimization Options:

+

1. Compress Old Versions: +

import zlib from 'zlib';
+
+// Compress HTML content before storing
+const compressedHtml = zlib.gzipSync(htmlContent).toString('base64');
+
+await prisma.emailTemplateVersion.create({
+  data: {
+    // ...
+    htmlContent: compressedHtml,
+    isCompressed: true,  // Add flag
+  },
+});
+
+// Decompress when loading
+if (version.isCompressed) {
+  const buffer = Buffer.from(version.htmlContent, 'base64');
+  const htmlContent = zlib.gunzipSync(buffer).toString('utf-8');
+}
+

+

2. Archive Old Versions: +

-- Move versions > 1 year old to archive table
+INSERT INTO email_template_versions_archive
+SELECT * FROM email_template_versions
+WHERE created_at < NOW() - INTERVAL '1 year';
+
+DELETE FROM email_template_versions
+WHERE created_at < NOW() - INTERVAL '1 year';
+

+

3. Limit Version History: +

// Keep only last 50 versions per template
+const oldVersions = await prisma.emailTemplateVersion.findMany({
+  where: { templateId },
+  orderBy: { versionNumber: 'desc' },
+  skip: 50,  // Skip first 50 (keep these)
+});
+
+// Delete versions beyond 50
+await prisma.emailTemplateVersion.deleteMany({
+  where: {
+    id: { in: oldVersions.map(v => v.id) },
+  },
+});
+

+
+

Version Diff Performance

+

Performance Impact: +- Diff generation is CPU-intensive for large templates +- diffLines algorithm is O(n*m) where n, m = line counts

+

Optimization:

+

1. Cache Diff Results: +

const diffCache = new Map<string, Change[]>();
+
+function getCachedDiff(oldContent: string, newContent: string): Change[] {
+  const cacheKey = `${hashString(oldContent)}-${hashString(newContent)}`;
+
+  if (diffCache.has(cacheKey)) {
+    return diffCache.get(cacheKey)!;
+  }
+
+  const diff = diffLines(oldContent, newContent);
+  diffCache.set(cacheKey, diff);
+
+  return diff;
+}
+

+

2. Limit Diff Size: +

// For very large templates, show summary instead of full diff
+if (oldContent.length > 100000 || newContent.length > 100000) {
+  return {
+    error: 'Template too large for diff. Use version preview instead.',
+  };
+}
+

+
+

Best Practices

+

Change Notes Guidelines

+

Always Provide Change Notes: +- Documents WHY changes were made (not just WHAT) +- Helps future admins understand context +- Useful for compliance audits

+

Be Specific: +

✓ Good:
+  - "Added USER_PHONE variable with conditional block"
+  - "Fixed typo in subject line (Welcome vs Welcom)"
+  - "Updated shift location to include full address"
+
+✗ Bad:
+  - "updates"
+  - "changes"
+  - "fix"
+

+

Reference Issues/Tickets: +

"Fixed rendering issue in Gmail (Ticket #123)"
+"Added new variable per Sarah's request"
+

+
+

Rollback Safety

+

Always Review Before Rollback: +- View version content before restoring +- Compare with current version +- Understand what will change

+

Use Change Notes: +

"Rolled back to version 5 - version 6 broke email rendering in Outlook"
+

+

Test After Rollback: +- Send test email after rollback +- Verify rendering correct +- Check all variables still work

+
+

Version Retention

+

Keep All Versions (Default): +- Complete audit trail +- Compliance requirements

+

Archive Old Versions (Optional): +- Templates with 100+ versions +- Versions older than 1 year +- Move to separate archive table

+

Never Delete Versions: +- Breaks audit trail +- May violate compliance requirements +- Disk space is cheap

+
+ +

Frontend Documentation

+ +

Backend Documentation

+
    +
  • Email Templates Module — Version API routes
  • +
  • GET /api/email-templates/:id/versions — List versions
  • +
  • GET /api/email-templates/:id/versions/:versionNumber — Get version details
  • +
  • POST /api/email-templates/:id/rollback/:versionNumber — Rollback to version
  • +
+

Database Documentation

+ +

Feature Documentation

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/index.html b/mkdocs/site/v2/features/index.html new file mode 100644 index 00000000..7a57b7ff --- /dev/null +++ b/mkdocs/site/v2/features/index.html @@ -0,0 +1,4854 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Feature Documentation - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Feature Documentation

+

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.

+

Documentation Structure

+

Each feature guide includes:

+
    +
  • Architecture diagrams showing data flow
  • +
  • Database models with relationships
  • +
  • API endpoints for admin and public access
  • +
  • Configuration options and environment variables
  • +
  • Workflows for admin, public, and volunteer users
  • +
  • Code examples from actual source files
  • +
  • Troubleshooting common issues
  • +
  • Performance optimization tips
  • +
+

Feature Categories

+

Influence Features

+

Email advocacy campaigns and representative outreach:

+ +

Map Features

+

Geographic location management and canvassing:

+ +

Landing Pages

+

Website page building and management:

+ +

Email Templates

+

Email template system for campaigns:

+ +

Media Features

+

Video library management:

+ +

Newsletter Integration

+

Listmonk newsletter platform integration:

+ +

Tunnel Management

+

Pangolin tunnel for public access:

+ +

Observability

+

Monitoring and metrics:

+ + + +

Quick Navigation

+

By User Role

+

Administrators: +- Campaign creation and management +- Response moderation +- User management +- Location management +- Shift scheduling +- Email queue monitoring +- Landing page editing

+

Public Users: +- Campaign participation +- Representative lookup +- Email sending +- Response submission +- Shift signup +- Media gallery browsing

+

Volunteers: +- Canvassing with GPS +- Visit recording +- Shift assignments +- Activity tracking +- Route history

+

By Use Case

+

Advocacy Campaigns: +1. Create campaign +2. Configure representatives +3. Monitor email queue +4. Moderate responses

+

Canvassing Operations: +1. Import locations +2. Create geographic cuts +3. Schedule shifts +4. Track canvassing +5. Print walk sheets

+

Website Management: +1. Build landing pages +2. Manage content blocks +3. Export to MkDocs

+

Public Access: +1. Setup Pangolin tunnel +2. Configure Newt container +3. Monitor with observability

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/influence/campaigns/index.html b/mkdocs/site/v2/features/influence/campaigns/index.html new file mode 100644 index 00000000..c1df649e --- /dev/null +++ b/mkdocs/site/v2/features/influence/campaigns/index.html @@ -0,0 +1,6613 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Campaigns - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Campaign Management System

+

Overview

+

The campaign management system is the core of Changemaker Lite's advocacy email platform. It enables organizations to create, configure, and manage advocacy campaigns that allow supporters to contact elected representatives via email. The system supports multiple campaign types, customizable features via feature flags, and a complete lifecycle from draft to archived status.

+

Key Capabilities:

+
    +
  • Multi-status lifecycle: Draft → Active → Paused → Archived workflow
  • +
  • 12 feature flags: Granular control over campaign behavior
  • +
  • Government level filtering: Target specific levels (federal, provincial, municipal)
  • +
  • Cover photo uploads: Visual campaign branding
  • +
  • Slug-based routing: SEO-friendly public URLs
  • +
  • Response wall integration: Public display of campaign responses
  • +
  • Email tracking: Monitor sent emails and campaign effectiveness
  • +
+

Use Cases:

+
    +
  • Advocacy campaigns targeting elected officials
  • +
  • Public awareness campaigns with response sharing
  • +
  • Email-your-MP initiatives
  • +
  • Multi-level government outreach
  • +
  • Time-limited advocacy actions
  • +
+

Architecture

+
graph TD
+    A[Admin User] -->|Creates Campaign| B[CampaignsPage]
+    B -->|POST /api/campaigns| C[Campaign Service]
+    C -->|Save| D[(Campaign Model)]
+
+    E[Public User] -->|Browses| F[CampaignsListPage]
+    F -->|GET /api/public/campaigns| C
+
+    E -->|Views Campaign| G[CampaignPage]
+    G -->|GET /api/public/campaigns/:slug| C
+    G -->|Lookup Reps| H[Representatives Service]
+    G -->|Send Email| I[Email Queue Service]
+    I -->|Add Job| J[(BullMQ Redis)]
+
+    K[Email Worker] -->|Process Jobs| J
+    K -->|Send SMTP| L[Email Recipients]
+    K -->|Track| M[(CampaignEmail Model)]
+
+    D -->|1:N| M
+    D -->|1:N| N[(Response Model)]
+
+    style D fill:#e1f5ff
+    style M fill:#e1f5ff
+    style N fill:#e1f5ff
+    style J fill:#fff4e1
+

Flow Description:

+
    +
  1. Admin creates campaign → Campaign service validates and saves to database
  2. +
  3. Public user browses → Campaign service returns active campaigns
  4. +
  5. User views campaign → Representatives service looks up postal code
  6. +
  7. User sends email → Email queue service adds job to BullMQ
  8. +
  9. Worker processes job → Email sent via SMTP, tracked in CampaignEmail model
  10. +
  11. User submits response → Response service creates response for moderation
  12. +
+

Database Models

+

Campaign Model

+

See Campaign Model Documentation for full schema.

+

Key Fields:

+
    +
  • status: DRAFT | ACTIVE | PAUSED | ARCHIVED
  • +
  • targetGovernmentLevels: Array of government levels (federal, provincial, municipal)
  • +
  • emailSubjectTemplate: Subject line with {{VAR}} placeholders
  • +
  • emailBodyTemplate: Email body with {{VAR}} placeholders
  • +
  • coverPhotoUrl: Campaign hero image URL
  • +
  • slug: URL-friendly identifier
  • +
+

Feature Flags (12 total):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FlagTypeDefaultDescription
allowSmtpEmailbooleantrueEnable email sending
allowCallTrackingbooleanfalseEnable phone call logging
showResponseWallbooleantrueDisplay response wall
requireEmailVerificationbooleantrueVerify response emails
allowAnonymousResponsesbooleanfalseAllow responses without login
highlightCampaignbooleanfalseFeature on homepage
showProgressBarbooleantrueDisplay response count progress
allowSharingbooleantrueEnable social sharing buttons
requirePostalCodebooleantrueRequire postal code for lookup
allowCustomMessagebooleantrueUsers can edit email text
trackEmailOpensbooleanfalseTrack email opens (future)
notifyOnResponsebooleantrueEmail admin on new responses
+

Related Models:

+ +

API Endpoints

+

Admin Endpoints

+

See Campaigns Module API Reference for full details.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/campaignsSUPER_ADMIN, INFLUENCE_ADMINList all campaigns (paginated)
GET/api/campaigns/:idSUPER_ADMIN, INFLUENCE_ADMINGet campaign details
POST/api/campaignsSUPER_ADMIN, INFLUENCE_ADMINCreate new campaign
PUT/api/campaigns/:idSUPER_ADMIN, INFLUENCE_ADMINUpdate campaign
PATCH/api/campaigns/:id/statusSUPER_ADMIN, INFLUENCE_ADMINUpdate campaign status
DELETE/api/campaigns/:idSUPER_ADMINDelete campaign
+

Public Endpoints

+

See Campaigns Public API Reference.

+ + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/public/campaignsNoneList active campaigns
GET/api/public/campaigns/:slugNoneGet campaign by slug
+

Configuration

+

Environment Variables

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableTypeDefaultDescription
EMAIL_TEST_MODEbooleanfalseSend emails to MailHog instead of SMTP
SMTP_HOSTstring-SMTP server hostname
SMTP_PORTnumber587SMTP server port
SMTP_USERstring-SMTP username
SMTP_PASSstring-SMTP password
SMTP_FROM_EMAILstring-Default sender email
SMTP_FROM_NAMEstring-Default sender name
+

Site Settings

+

SMTP settings can be configured via Site Settings (overrides env vars):

+
{
+  smtpHost: string | null,
+  smtpPort: number | null,
+  smtpUser: string | null,
+  smtpPass: string | null,
+  smtpFromEmail: string | null,
+  smtpFromName: string | null
+}
+
+

Upload Configuration

+

Cover photos uploaded to /uploads/campaigns/{campaignId}/{filename}.

+

Limits: +- Max file size: 10MB +- Allowed formats: jpg, jpeg, png, gif, webp

+

Admin Workflow

+

1. Create Campaign

+

[Screenshot: CampaignsPage with "Create Campaign" button]

+

Steps:

+
    +
  1. Navigate to Influence > Campaigns
  2. +
  3. Click Create Campaign button
  4. +
  5. Fill in campaign details:
  6. +
  7. Title (required)
  8. +
  9. Description (required)
  10. +
  11. Target government levels (select all that apply)
  12. +
  13. Email subject template (use {{VAR}} for dynamic content)
  14. +
  15. Email body template (HTML supported)
  16. +
  17. Upload cover photo (optional)
  18. +
  19. Click Save (saves as DRAFT)
  20. +
+

Code Example (CampaignsPage.tsx):

+
const handleCreate = async (values: any) => {
+  try {
+    const formData = new FormData();
+    formData.append('title', values.title);
+    formData.append('description', values.description);
+    formData.append('targetGovernmentLevels', JSON.stringify(values.targetGovernmentLevels));
+    formData.append('emailSubjectTemplate', values.emailSubjectTemplate);
+    formData.append('emailBodyTemplate', values.emailBodyTemplate);
+
+    if (values.coverPhoto?.[0]?.originFileObj) {
+      formData.append('coverPhoto', values.coverPhoto[0].originFileObj);
+    }
+
+    await api.post('/campaigns', formData, {
+      headers: { 'Content-Type': 'multipart/form-data' }
+    });
+
+    message.success('Campaign created successfully');
+    fetchCampaigns();
+  } catch (error) {
+    message.error('Failed to create campaign');
+  }
+};
+
+

2. Configure Feature Flags

+

[Screenshot: Campaign edit modal with feature flags section]

+

Steps:

+
    +
  1. Click Edit on campaign row
  2. +
  3. Scroll to Feature Flags section
  4. +
  5. Toggle flags as needed:
  6. +
  7. allowSmtpEmail: Enable email sending (required for email campaigns)
  8. +
  9. showResponseWall: Display public response wall
  10. +
  11. requireEmailVerification: Require email verification for responses
  12. +
  13. highlightCampaign: Feature on homepage
  14. +
  15. allowCustomMessage: Let users edit email text before sending
  16. +
  17. Click Save
  18. +
+

Best Practices:

+
    +
  • Enable requireEmailVerification for public response walls
  • +
  • Disable allowCustomMessage if you want consistent messaging
  • +
  • Use highlightCampaign sparingly (max 2-3 campaigns)
  • +
  • Enable showProgressBar to encourage participation
  • +
+

3. Test Campaign

+

[Screenshot: Campaign preview with test email form]

+

Steps:

+
    +
  1. Set campaign status to ACTIVE
  2. +
  3. Navigate to public campaign page: /campaigns/{slug}
  4. +
  5. Enter test postal code
  6. +
  7. Review representative lookup results
  8. +
  9. Send test email to your own email address
  10. +
  11. Verify email content and formatting
  12. +
+

Troubleshooting:

+
    +
  • If no representatives found → Check Represent API cache
  • +
  • If email not received → Check Email Queue page for job status
  • +
  • If email formatting broken → Review HTML template syntax
  • +
+

4. Publish Campaign

+

[Screenshot: Campaign status dropdown]

+

Steps:

+
    +
  1. Return to Campaigns page
  2. +
  3. Click Status dropdown on campaign row
  4. +
  5. Select ACTIVE
  6. +
  7. Campaign now visible on public campaigns page
  8. +
+

Status Lifecycle:

+
stateDiagram-v2
+    [*] --> DRAFT: Create
+    DRAFT --> ACTIVE: Publish
+    ACTIVE --> PAUSED: Pause
+    PAUSED --> ACTIVE: Resume
+    ACTIVE --> ARCHIVED: Archive
+    PAUSED --> ARCHIVED: Archive
+    ARCHIVED --> [*]
+

5. Monitor Campaign

+

[Screenshot: Campaign emails drawer with stats]

+

Steps:

+
    +
  1. Click View Emails on campaign row
  2. +
  3. Review email stats:
  4. +
  5. Total sent
  6. +
  7. Success rate
  8. +
  9. Failed emails
  10. +
  11. View individual email details (recipient, status, sent date)
  12. +
  13. Retry failed emails if needed
  14. +
+

Metrics to Track:

+
    +
  • Emails sent per day
  • +
  • Response wall submissions
  • +
  • Verification rate (if enabled)
  • +
  • Geographic distribution (via postal codes)
  • +
+

Public Workflow

+

1. Browse Campaigns

+

[Screenshot: Public campaigns list page with featured campaigns]

+

User Journey:

+
    +
  1. User visits /campaigns
  2. +
  3. Sees featured campaigns (if highlightCampaign enabled)
  4. +
  5. Browses active campaigns grid
  6. +
  7. Clicks campaign card to view details
  8. +
+

Code Example (CampaignsListPage.tsx):

+
const CampaignsListPage: React.FC = () => {
+  const [campaigns, setCampaigns] = useState<Campaign[]>([]);
+  const [featured, setFeatured] = useState<Campaign[]>([]);
+
+  useEffect(() => {
+    const fetchCampaigns = async () => {
+      const { data } = await axios.get('/api/public/campaigns');
+
+      const featuredCampaigns = data.filter((c: Campaign) =>
+        c.highlightCampaign && c.status === 'ACTIVE'
+      );
+      const regularCampaigns = data.filter((c: Campaign) =>
+        !c.highlightCampaign && c.status === 'ACTIVE'
+      );
+
+      setFeatured(featuredCampaigns);
+      setCampaigns(regularCampaigns);
+    };
+
+    fetchCampaigns();
+  }, []);
+
+  return (
+    <PublicLayout>
+      {featured.length > 0 && (
+        <FeaturedCampaigns campaigns={featured} />
+      )}
+      <CampaignGrid campaigns={campaigns} />
+    </PublicLayout>
+  );
+};
+
+

2. View Campaign Details

+

[Screenshot: Campaign detail page with postal code lookup form]

+

User Journey:

+
    +
  1. User clicks campaign card
  2. +
  3. Navigated to /campaigns/{slug}
  4. +
  5. Reads campaign description
  6. +
  7. Enters postal code in lookup form
  8. +
  9. System fetches representatives from Represent API
  10. +
  11. User selects representatives to email
  12. +
+

3. Send Email

+

[Screenshot: Email form with representative selection]

+

User Journey:

+
    +
  1. User reviews list of representatives
  2. +
  3. Selects representatives to email (checkboxes)
  4. +
  5. Reviews email subject and body
  6. +
  7. Edits message if allowCustomMessage enabled
  8. +
  9. Adds personal details (name, email)
  10. +
  11. Clicks Send Email
  12. +
  13. Email jobs added to BullMQ queue
  14. +
  15. User sees confirmation message
  16. +
+

Code Example (CampaignPage.tsx):

+
const handleSendEmails = async (values: any) => {
+  try {
+    const payload = {
+      campaignId: campaign.id,
+      senderName: values.senderName,
+      senderEmail: values.senderEmail,
+      postalCode: values.postalCode,
+      representativeIds: values.representativeIds,
+      customMessage: campaign.allowCustomMessage ? values.customMessage : null
+    };
+
+    await axios.post('/api/public/campaigns/send-email', payload);
+
+    message.success('Your emails have been sent!');
+
+    if (campaign.showResponseWall) {
+      message.info('Share your response on the Response Wall!');
+    }
+  } catch (error) {
+    message.error('Failed to send emails');
+  }
+};
+
+

4. Submit Response (Optional)

+

[Screenshot: Response submission form]

+

User Journey:

+
    +
  1. After sending email, user clicks Share Your Response
  2. +
  3. Navigated to /responses/{campaignId}/submit
  4. +
  5. Fills in response form:
  6. +
  7. Type (EMAIL, LETTER, PHONE_CALL, etc.)
  8. +
  9. Message
  10. +
  11. Screenshot (optional)
  12. +
  13. Submits response
  14. +
  15. If requireEmailVerification enabled → verification email sent
  16. +
  17. User clicks verification link in email
  18. +
  19. Response appears on public response wall (after admin approval if moderation enabled)
  20. +
+

Volunteer Workflow

+

Not applicable — campaigns are admin-managed and public-facing.

+

Code Examples

+

Backend: Create Campaign

+
// api/src/modules/influence/campaigns/campaigns.service.ts
+
+async createCampaign(
+  data: Prisma.CampaignUncheckedCreateInput,
+  createdByUserId: string
+): Promise<Campaign> {
+  // Generate slug from title
+  const baseSlug = data.title
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, '-')
+    .replace(/^-|-$/g, '');
+
+  let slug = baseSlug;
+  let counter = 1;
+
+  // Ensure unique slug
+  while (await this.prisma.campaign.findUnique({ where: { slug } })) {
+    slug = `${baseSlug}-${counter}`;
+    counter++;
+  }
+
+  return this.prisma.campaign.create({
+    data: {
+      ...data,
+      slug,
+      createdByUserId,
+      status: 'DRAFT',
+      // Default feature flags
+      allowSmtpEmail: data.allowSmtpEmail ?? true,
+      showResponseWall: data.showResponseWall ?? true,
+      requireEmailVerification: data.requireEmailVerification ?? true,
+      allowCustomMessage: data.allowCustomMessage ?? true,
+      showProgressBar: data.showProgressBar ?? true,
+      allowSharing: data.allowSharing ?? true,
+      requirePostalCode: data.requirePostalCode ?? true,
+      notifyOnResponse: data.notifyOnResponse ?? true
+    }
+  });
+}
+
+

Frontend: Campaign Card Component

+
// admin/src/pages/public/CampaignsListPage.tsx
+
+const CampaignCard: React.FC<{ campaign: Campaign }> = ({ campaign }) => {
+  const navigate = useNavigate();
+
+  return (
+    <Card
+      hoverable
+      cover={
+        campaign.coverPhotoUrl && (
+          <img
+            alt={campaign.title}
+            src={campaign.coverPhotoUrl}
+            style={{ height: 200, objectFit: 'cover' }}
+          />
+        )
+      }
+      onClick={() => navigate(`/campaigns/${campaign.slug}`)}
+    >
+      <Card.Meta
+        title={campaign.title}
+        description={
+          <Space direction="vertical" size="small">
+            <Typography.Paragraph ellipsis={{ rows: 3 }}>
+              {campaign.description}
+            </Typography.Paragraph>
+
+            {campaign.showProgressBar && (
+              <Progress
+                percent={Math.min(
+                  (campaign._count?.responses || 0) / (campaign.responseGoal || 100) * 100,
+                  100
+                )}
+                status="active"
+              />
+            )}
+
+            <Space>
+              {campaign.targetGovernmentLevels.map(level => (
+                <Tag key={level} color="blue">{level}</Tag>
+              ))}
+            </Space>
+          </Space>
+        }
+      />
+    </Card>
+  );
+};
+
+

Troubleshooting

+

Campaign Not Visible on Public Page

+

Symptoms: +- Campaign exists in admin but doesn't appear on /campaigns

+

Solutions:

+
    +
  1. Check campaign status → must be ACTIVE
  2. +
  3. Verify no draft campaigns leaked → filter by status in query
  4. +
  5. Check Nginx caching → clear cache or disable for /api/public/campaigns
  6. +
+

Debugging:

+
# Check campaign status
+docker compose exec v2-postgres psql -U changemaker -d changemaker_lite -c \
+  "SELECT id, title, status, slug FROM campaigns WHERE slug = 'your-slug';"
+
+# Check public endpoint response
+curl http://localhost:4000/api/public/campaigns | jq
+
+

Email Template Variables Not Replaced

+

Symptoms: +- Email sent with {{senderName}} instead of actual name

+

Solutions:

+
    +
  1. Verify variable syntax → must use double curly braces {{VAR}}
  2. +
  3. Check email service interpolation → ensure processTemplate() called
  4. +
  5. Verify variable names match → senderName, senderEmail, postalCode, recipientName, recipientEmail
  6. +
+

Code Fix (email.service.ts):

+
private processTemplate(template: string, variables: Record<string, string>): string {
+  let processed = template;
+
+  Object.entries(variables).forEach(([key, value]) => {
+    const regex = new RegExp(`{{${key}}}`, 'g');
+    processed = processed.replace(regex, value || '');
+  });
+
+  return processed;
+}
+
+

Cover Photo Upload Fails

+

Symptoms: +- Upload spinner never completes +- Error: "File too large"

+

Solutions:

+
    +
  1. Check file size → max 10MB
  2. +
  3. Verify file format → must be jpg/jpeg/png/gif/webp
  4. +
  5. Check upload directory permissions → /uploads/campaigns must be writable
  6. +
  7. Increase Nginx upload limit → client_max_body_size 20M;
  8. +
+

Docker Volume Fix:

+
# docker-compose.yml
+services:
+  api:
+    volumes:
+      - ./uploads:/app/uploads:rw  # Ensure :rw (read-write)
+
+

Representatives Not Loading

+

Symptoms: +- Postal code lookup returns empty array

+

Solutions:

+
    +
  1. Check Represent API status → visit https://represent.opennorth.ca/health
  2. +
  3. Verify postal code format → must be valid Canadian postal code (K1A 0A1)
  4. +
  5. Check representative cache → may need refresh
  6. +
  7. Review API rate limits → Represent API has rate limits
  8. +
+

Manual Cache Refresh:

+
# Via admin UI
+# Navigate to Influence > Representatives
+# Enter postal code in search box
+# Click "Lookup"
+
+# Via API
+curl -X POST http://localhost:4000/api/representatives/lookup \
+  -H "Content-Type: application/json" \
+  -d '{"postalCode": "K1A0A1"}'
+
+

Performance Considerations

+

Campaign Listing Optimization

+

Query Optimization:

+
// Include response count for progress bar
+const campaigns = await prisma.campaign.findMany({
+  where: { status: 'ACTIVE' },
+  include: {
+    _count: {
+      select: { responses: true }
+    }
+  },
+  orderBy: [
+    { highlightCampaign: 'desc' }, // Featured first
+    { createdAt: 'desc' }
+  ]
+});
+
+

Caching Strategy:

+
    +
  • Cache active campaigns list for 5 minutes (Redis)
  • +
  • Invalidate cache on campaign status change
  • +
  • Use ETags for HTTP caching
  • +
+

Email Queue Scaling

+

BullMQ Configuration:

+
// api/src/services/email-queue.service.ts
+
+const queue = new Queue('campaign-emails', {
+  connection: redisConnection,
+  defaultJobOptions: {
+    attempts: 3,
+    backoff: {
+      type: 'exponential',
+      delay: 5000 // 5s, 25s, 125s
+    },
+    removeOnComplete: {
+      age: 86400, // Keep completed jobs for 24h
+      count: 1000
+    },
+    removeOnFail: {
+      age: 604800 // Keep failed jobs for 7 days
+    }
+  }
+});
+
+// Worker concurrency
+const worker = new Worker('campaign-emails', processCampaignEmail, {
+  connection: redisConnection,
+  concurrency: 5 // Process 5 emails simultaneously
+});
+
+

Monitoring:

+
    +
  • Track queue size with Prometheus cm_email_queue_size metric
  • +
  • Alert if queue size > 1000
  • +
  • Monitor worker processing rate
  • +
+

Cover Photo Optimization

+

Image Processing:

+
// api/src/modules/influence/campaigns/campaigns.service.ts
+
+import sharp from 'sharp';
+
+async uploadCoverPhoto(file: Express.Multer.File, campaignId: string): Promise<string> {
+  const filename = `${Date.now()}-${file.originalname}`;
+  const uploadPath = `/uploads/campaigns/${campaignId}`;
+
+  // Create directory
+  await fs.mkdir(uploadPath, { recursive: true });
+
+  // Optimize image
+  await sharp(file.buffer)
+    .resize(1200, 630, { // Open Graph ratio
+      fit: 'cover',
+      position: 'center'
+    })
+    .jpeg({ quality: 85 })
+    .toFile(`${uploadPath}/${filename}`);
+
+  return `${uploadPath}/${filename}`;
+}
+
+

CDN Integration:

+
    +
  • Serve cover photos via CDN (Cloudflare, CloudFront)
  • +
  • Use responsive images with srcset
  • +
  • Lazy load images below fold
  • +
+ +

Backend Modules

+ +

Frontend Pages

+ +

Database Models

+ +

Configuration

+ +

Guides

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/influence/email-queue/index.html b/mkdocs/site/v2/features/influence/email-queue/index.html new file mode 100644 index 00000000..5cd72760 --- /dev/null +++ b/mkdocs/site/v2/features/influence/email-queue/index.html @@ -0,0 +1,6754 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Email Queue - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Email Queue System

+

Overview

+

The email queue system manages asynchronous email sending for advocacy campaigns using BullMQ and Redis. It provides reliable email delivery, retry logic, job monitoring, and comprehensive tracking of email campaign effectiveness.

+

Key Capabilities:

+
    +
  • BullMQ integration: Redis-backed job queue for email processing
  • +
  • Automatic retry logic: Failed emails retried with exponential backoff
  • +
  • Job status tracking: Monitor queued, active, completed, and failed jobs
  • +
  • Rate limiting: Prevent SMTP server overload
  • +
  • Email tracking: Track sent emails per campaign
  • +
  • Admin monitoring: Real-time queue statistics and job management
  • +
  • Test mode: Send to MailHog instead of SMTP for testing
  • +
+

Use Cases:

+
    +
  • Bulk email sending for advocacy campaigns
  • +
  • Reliable email delivery with retry
  • +
  • Email campaign effectiveness tracking
  • +
  • SMTP server load management
  • +
  • Development email testing
  • +
+

Architecture

+
graph TD
+    A[Public User] -->|Send Email| B[CampaignPage]
+    B -->|POST /api/public/campaigns/send-email| C[Campaign Service]
+    C -->|Add Job| D[Email Queue Service]
+    D -->|Create Job| E[(BullMQ Redis)]
+
+    F[Email Worker] -->|Poll Jobs| E
+    F -->|Process Job| G{Send Email}
+    G -->|Success| H[Email Service - SMTP]
+    G -->|Failure| I[Retry Logic]
+
+    H -->|Track| J[(CampaignEmail Model)]
+    I -->|Backoff| E
+
+    K[Admin User] -->|Monitor| L[EmailQueuePage]
+    L -->|GET /api/email-queue/stats| D
+    L -->|Pause/Resume| D
+    L -->|Clean Jobs| D
+
+    M[Prometheus] -->|Scrape| N[Metrics Endpoint]
+    N -->|cm_email_queue_size| E
+
+    style E fill:#fff4e1
+    style J fill:#e1f5ff
+

Flow Description:

+
    +
  1. User sends email → Campaign service adds job to BullMQ queue
  2. +
  3. Worker polls queue → Picks up job for processing
  4. +
  5. Email sent via SMTP → Nodemailer sends email
  6. +
  7. Success → Job marked completed, email tracked in database
  8. +
  9. Failure → Job retried with exponential backoff (3 attempts)
  10. +
  11. Admin monitors → View queue stats, pause/resume, clean old jobs
  12. +
+

Database Models

+

CampaignEmail Model

+

See CampaignEmail Model Documentation for full schema.

+

Key Fields:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString (UUID)Primary key
campaignIdStringAssociated campaign
recipientEmailStringEmail recipient
recipientNameString?Recipient name
senderEmailStringSender email address
senderNameStringSender name
subjectStringEmail subject line
bodyString (Text)Email body content
statusEnumQUEUED, SENT, FAILED
jobIdString?BullMQ job ID
sentAtDateTime?When email was sent
failureReasonString?Error message if failed
+

Indexes:

+
    +
  • campaignId, status — For campaign email stats
  • +
  • jobId — For job status lookups
  • +
  • sentAt — For time-based queries
  • +
+

Related Models:

+ +

API Endpoints

+

Admin Endpoints

+

See Email Queue Module API Reference for full details.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/email-queue/statsSUPER_ADMIN, INFLUENCE_ADMINGet queue statistics
POST/api/email-queue/pauseSUPER_ADMIN, INFLUENCE_ADMINPause queue processing
POST/api/email-queue/resumeSUPER_ADMIN, INFLUENCE_ADMINResume queue processing
POST/api/email-queue/cleanSUPER_ADMINClean completed/failed jobs
POST/api/email-queue/retry/:jobIdSUPER_ADMIN, INFLUENCE_ADMINRetry failed job
+

Public Endpoints

+

Email queue jobs are created via campaign email endpoints (no direct public access).

+

Configuration

+

Environment Variables

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableTypeDefaultDescription
REDIS_HOSTstringlocalhostRedis hostname
REDIS_PORTnumber6379Redis port
REDIS_PASSWORDstring-Redis password (required)
SMTP_HOSTstring-SMTP server hostname
SMTP_PORTnumber587SMTP server port
SMTP_USERstring-SMTP username
SMTP_PASSstring-SMTP password
SMTP_FROM_EMAILstring-Default sender email
SMTP_FROM_NAMEstring-Default sender name
EMAIL_TEST_MODEbooleanfalseSend to MailHog instead of SMTP
EMAIL_QUEUE_CONCURRENCYnumber5Max concurrent email workers
+

BullMQ Configuration

+
// api/src/services/email-queue.service.ts
+
+const queueOptions = {
+  connection: {
+    host: process.env.REDIS_HOST,
+    port: parseInt(process.env.REDIS_PORT || '6379'),
+    password: process.env.REDIS_PASSWORD
+  },
+  defaultJobOptions: {
+    attempts: 3,
+    backoff: {
+      type: 'exponential',
+      delay: 5000 // 5s, 25s, 125s
+    },
+    removeOnComplete: {
+      age: 86400, // Keep completed jobs for 24h
+      count: 1000
+    },
+    removeOnFail: {
+      age: 604800 // Keep failed jobs for 7 days
+    }
+  }
+};
+
+

Worker Configuration

+
const workerOptions = {
+  connection: queueOptions.connection,
+  concurrency: parseInt(process.env.EMAIL_QUEUE_CONCURRENCY || '5'),
+  limiter: {
+    max: 60, // Max 60 emails
+    duration: 60000 // per minute
+  }
+};
+
+

Admin Workflow

+

1. View Queue Statistics

+

[Screenshot: EmailQueuePage with queue stats cards]

+

Steps:

+
    +
  1. Navigate to Influence > Email Queue
  2. +
  3. View queue statistics:
  4. +
  5. Waiting: Jobs queued for processing
  6. +
  7. Active: Jobs currently being processed
  8. +
  9. Completed: Successfully sent emails
  10. +
  11. Failed: Failed emails requiring attention
  12. +
  13. Monitor queue health (green if waiting < 100)
  14. +
+

Code Example (EmailQueuePage.tsx):

+
const [stats, setStats] = useState({
+  waiting: 0,
+  active: 0,
+  completed: 0,
+  failed: 0,
+  paused: false
+});
+
+useEffect(() => {
+  const fetchStats = async () => {
+    const { data } = await api.get('/email-queue/stats');
+    setStats(data);
+  };
+
+  fetchStats();
+
+  // Refresh every 5 seconds
+  const interval = setInterval(fetchStats, 5000);
+  return () => clearInterval(interval);
+}, []);
+
+return (
+  <Row gutter={16}>
+    <Col span={6}>
+      <Card>
+        <Statistic
+          title="Waiting"
+          value={stats.waiting}
+          valueStyle={{ color: stats.waiting > 100 ? '#cf1322' : '#3f8600' }}
+        />
+      </Card>
+    </Col>
+    <Col span={6}>
+      <Card>
+        <Statistic title="Active" value={stats.active} />
+      </Card>
+    </Col>
+    <Col span={6}>
+      <Card>
+        <Statistic title="Completed" value={stats.completed} />
+      </Card>
+    </Col>
+    <Col span={6}>
+      <Card>
+        <Statistic
+          title="Failed"
+          value={stats.failed}
+          valueStyle={{ color: stats.failed > 0 ? '#cf1322' : undefined }}
+        />
+      </Card>
+    </Col>
+  </Row>
+);
+
+

2. Pause/Resume Queue

+

[Screenshot: EmailQueuePage with pause/resume buttons]

+

Steps:

+
    +
  1. Click Pause Queue button
  2. +
  3. Queue stops processing new jobs
  4. +
  5. Active jobs complete normally
  6. +
  7. Status indicator shows "Paused"
  8. +
  9. Click Resume Queue to restart processing
  10. +
+

Use Cases:

+
    +
  • Temporary SMTP server maintenance
  • +
  • Stop email sending during testing
  • +
  • Prevent email sending during off-hours
  • +
+

Code Example (email-queue.service.ts):

+
async pauseQueue(): Promise<void> {
+  await this.queue.pause();
+  logger.info('Email queue paused');
+}
+
+async resumeQueue(): Promise<void> {
+  await this.queue.resume();
+  logger.info('Email queue resumed');
+}
+
+async isPaused(): Promise<boolean> {
+  return this.queue.isPaused();
+}
+
+

3. Clean Completed Jobs

+

[Screenshot: EmailQueuePage with clean jobs button]

+

Steps:

+
    +
  1. Click Clean Jobs dropdown
  2. +
  3. Select cleanup type:
  4. +
  5. Completed (>24h): Remove old successful jobs
  6. +
  7. Failed (>7d): Remove old failed jobs
  8. +
  9. All Completed: Remove all successful jobs
  10. +
  11. Confirm cleanup
  12. +
  13. Jobs removed from queue, stats updated
  14. +
+

Code Example (email-queue.routes.ts):

+
router.post('/clean', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'), async (req, res) => {
+  try {
+    const { type } = req.body; // 'completed', 'failed', 'all-completed'
+
+    let count = 0;
+
+    if (type === 'completed') {
+      count = await queue.clean(86400000, 1000, 'completed'); // 24h
+    } else if (type === 'failed') {
+      count = await queue.clean(604800000, 1000, 'failed'); // 7d
+    } else if (type === 'all-completed') {
+      count = await queue.clean(0, 0, 'completed'); // All
+    }
+
+    logger.info(`Cleaned ${count} ${type} jobs`);
+
+    res.json({ count });
+  } catch (error) {
+    logger.error('Failed to clean jobs:', error);
+    res.status(500).json({ error: 'Failed to clean jobs' });
+  }
+});
+
+

4. Retry Failed Jobs

+

[Screenshot: Failed jobs table with retry buttons]

+

Steps:

+
    +
  1. Scroll to Failed Jobs section
  2. +
  3. View failed job details (error message, recipient)
  4. +
  5. Click Retry button on specific job
  6. +
  7. Job re-queued for processing
  8. +
  9. Monitor in Active tab
  10. +
+

Bulk Retry:

+
    +
  1. Select multiple failed jobs (checkboxes)
  2. +
  3. Click Retry Selected button
  4. +
  5. All selected jobs re-queued
  6. +
+

Code Example (email-queue.service.ts):

+
async retryFailedJob(jobId: string): Promise<void> {
+  const job = await this.queue.getJob(jobId);
+
+  if (!job) {
+    throw new Error('Job not found');
+  }
+
+  if (await job.isFailed()) {
+    await job.retry();
+    logger.info(`Retrying job ${jobId}`);
+  } else {
+    throw new Error('Job is not failed');
+  }
+}
+
+async retryAllFailed(): Promise<number> {
+  const failed = await this.queue.getFailed();
+  let count = 0;
+
+  for (const job of failed) {
+    await job.retry();
+    count++;
+  }
+
+  logger.info(`Retried ${count} failed jobs`);
+  return count;
+}
+
+

Public Workflow

+

1. Send Campaign Email

+

[Screenshot: CampaignPage with email sending form]

+

User Journey:

+
    +
  1. User selects representatives to email
  2. +
  3. Fills in sender details (name, email)
  4. +
  5. Reviews/edits email content (if allowed)
  6. +
  7. Clicks Send Email button
  8. +
  9. System creates email jobs (one per recipient)
  10. +
  11. Jobs added to BullMQ queue
  12. +
  13. User sees confirmation message
  14. +
+

Code Example (campaigns-public.routes.ts):

+
router.post('/send-email', async (req, res) => {
+  try {
+    const {
+      campaignId,
+      senderName,
+      senderEmail,
+      postalCode,
+      representativeIds,
+      customMessage
+    } = req.body;
+
+    const campaign = await prisma.campaign.findUnique({
+      where: { id: campaignId }
+    });
+
+    if (!campaign || campaign.status !== 'ACTIVE') {
+      return res.status(400).json({ error: 'Campaign not active' });
+    }
+
+    const representatives = await prisma.representative.findMany({
+      where: { id: { in: representativeIds } }
+    });
+
+    // Create email jobs
+    const emailJobs = [];
+
+    for (const rep of representatives) {
+      const emailData = {
+        campaignId,
+        recipientEmail: rep.email,
+        recipientName: rep.name,
+        senderEmail,
+        senderName,
+        subject: processTemplate(campaign.emailSubjectTemplate, {
+          senderName,
+          recipientName: rep.name,
+          postalCode
+        }),
+        body: customMessage || processTemplate(campaign.emailBodyTemplate, {
+          senderName,
+          senderEmail,
+          recipientName: rep.name,
+          recipientEmail: rep.email,
+          postalCode
+        })
+      };
+
+      // Add to queue
+      const job = await emailQueueService.addEmail(emailData);
+
+      emailJobs.push(job);
+    }
+
+    res.json({
+      success: true,
+      emailsQueued: emailJobs.length
+    });
+  } catch (error) {
+    logger.error('Failed to queue campaign emails:', error);
+    res.status(500).json({ error: 'Failed to send emails' });
+  }
+});
+
+

2. Job Processing

+

Worker Processing Logic:

+
// api/src/services/email-queue.service.ts
+
+import { Worker } from 'bullmq';
+import { emailService } from './email.service';
+
+const worker = new Worker('campaign-emails', async (job) => {
+  const {
+    campaignId,
+    recipientEmail,
+    recipientName,
+    senderEmail,
+    senderName,
+    subject,
+    body
+  } = job.data;
+
+  try {
+    // Send email via nodemailer
+    await emailService.send({
+      to: recipientEmail,
+      from: {
+        email: process.env.SMTP_FROM_EMAIL!,
+        name: process.env.SMTP_FROM_NAME!
+      },
+      replyTo: {
+        email: senderEmail,
+        name: senderName
+      },
+      subject,
+      html: body
+    });
+
+    // Update database record
+    await prisma.campaignEmail.update({
+      where: { jobId: job.id },
+      data: {
+        status: 'SENT',
+        sentAt: new Date()
+      }
+    });
+
+    logger.info(`Sent campaign email ${job.id} to ${recipientEmail}`);
+
+    // Update Prometheus metric
+    metrics.campaignEmailsSent.inc({ campaign_id: campaignId });
+
+    return { success: true };
+  } catch (error) {
+    logger.error(`Failed to send email ${job.id}:`, error);
+
+    // Update database record
+    await prisma.campaignEmail.update({
+      where: { jobId: job.id },
+      data: {
+        status: 'FAILED',
+        failureReason: error.message
+      }
+    });
+
+    throw error; // Let BullMQ handle retry
+  }
+}, workerOptions);
+
+worker.on('completed', (job) => {
+  logger.info(`Job ${job.id} completed`);
+});
+
+worker.on('failed', (job, err) => {
+  logger.error(`Job ${job?.id} failed:`, err);
+});
+
+

Volunteer Workflow

+

Not applicable — email queue is system-level.

+

Code Examples

+

Backend: Email Queue Service

+
// api/src/services/email-queue.service.ts
+
+import { Queue, QueueEvents } from 'bullmq';
+import { logger } from '../utils/logger';
+import { prisma } from '../config/database';
+
+export class EmailQueueService {
+  private queue: Queue;
+  private queueEvents: QueueEvents;
+
+  constructor() {
+    const connection = {
+      host: process.env.REDIS_HOST!,
+      port: parseInt(process.env.REDIS_PORT || '6379'),
+      password: process.env.REDIS_PASSWORD
+    };
+
+    this.queue = new Queue('campaign-emails', {
+      connection,
+      defaultJobOptions: {
+        attempts: 3,
+        backoff: {
+          type: 'exponential',
+          delay: 5000
+        },
+        removeOnComplete: {
+          age: 86400,
+          count: 1000
+        },
+        removeOnFail: {
+          age: 604800
+        }
+      }
+    });
+
+    this.queueEvents = new QueueEvents('campaign-emails', { connection });
+
+    this.setupEventHandlers();
+  }
+
+  private setupEventHandlers(): void {
+    this.queueEvents.on('completed', ({ jobId }) => {
+      logger.info(`Email job ${jobId} completed`);
+    });
+
+    this.queueEvents.on('failed', ({ jobId, failedReason }) => {
+      logger.error(`Email job ${jobId} failed: ${failedReason}`);
+    });
+  }
+
+  async addEmail(data: any): Promise<{ jobId: string }> {
+    // Create database record
+    const emailRecord = await prisma.campaignEmail.create({
+      data: {
+        ...data,
+        status: 'QUEUED'
+      }
+    });
+
+    // Add job to queue
+    const job = await this.queue.add('send-email', data, {
+      jobId: emailRecord.id
+    });
+
+    // Update database with job ID
+    await prisma.campaignEmail.update({
+      where: { id: emailRecord.id },
+      data: { jobId: job.id }
+    });
+
+    logger.info(`Queued email job ${job.id}`);
+
+    return { jobId: job.id! };
+  }
+
+  async getStats(): Promise<any> {
+    const counts = await this.queue.getJobCounts();
+
+    return {
+      waiting: counts.waiting || 0,
+      active: counts.active || 0,
+      completed: counts.completed || 0,
+      failed: counts.failed || 0,
+      paused: await this.queue.isPaused()
+    };
+  }
+
+  async pauseQueue(): Promise<void> {
+    await this.queue.pause();
+  }
+
+  async resumeQueue(): Promise<void> {
+    await this.queue.resume();
+  }
+
+  async clean(grace: number, limit: number, type: string): Promise<number> {
+    return this.queue.clean(grace, limit, type as any);
+  }
+}
+
+export const emailQueueService = new EmailQueueService();
+
+

Frontend: Queue Stats Dashboard

+
// admin/src/pages/EmailQueuePage.tsx
+
+import React, { useState, useEffect } from 'react';
+import { Card, Row, Col, Statistic, Button, Space, message } from 'antd';
+import { PlayCircleOutlined, PauseCircleOutlined, ClearOutlined } from '@ant-design/icons';
+import { api } from '../../lib/api';
+
+const EmailQueuePage: React.FC = () => {
+  const [stats, setStats] = useState<any>(null);
+  const [loading, setLoading] = useState(false);
+
+  const fetchStats = async () => {
+    const { data } = await api.get('/email-queue/stats');
+    setStats(data);
+  };
+
+  useEffect(() => {
+    fetchStats();
+    const interval = setInterval(fetchStats, 5000);
+    return () => clearInterval(interval);
+  }, []);
+
+  const handlePause = async () => {
+    setLoading(true);
+    try {
+      await api.post('/email-queue/pause');
+      message.success('Queue paused');
+      fetchStats();
+    } catch (error) {
+      message.error('Failed to pause queue');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleResume = async () => {
+    setLoading(true);
+    try {
+      await api.post('/email-queue/resume');
+      message.success('Queue resumed');
+      fetchStats();
+    } catch (error) {
+      message.error('Failed to resume queue');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleClean = async (type: string) => {
+    setLoading(true);
+    try {
+      const { data } = await api.post('/email-queue/clean', { type });
+      message.success(`Cleaned ${data.count} jobs`);
+      fetchStats();
+    } catch (error) {
+      message.error('Failed to clean jobs');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  if (!stats) return <Card loading />;
+
+  return (
+    <Space direction="vertical" size="large" style={{ width: '100%' }}>
+      <Card title="Queue Statistics">
+        <Row gutter={16}>
+          <Col span={6}>
+            <Statistic
+              title="Waiting"
+              value={stats.waiting}
+              valueStyle={{ color: stats.waiting > 100 ? '#cf1322' : '#3f8600' }}
+            />
+          </Col>
+          <Col span={6}>
+            <Statistic title="Active" value={stats.active} />
+          </Col>
+          <Col span={6}>
+            <Statistic title="Completed" value={stats.completed} />
+          </Col>
+          <Col span={6}>
+            <Statistic
+              title="Failed"
+              value={stats.failed}
+              valueStyle={{ color: stats.failed > 0 ? '#cf1322' : undefined }}
+            />
+          </Col>
+        </Row>
+      </Card>
+
+      <Card title="Queue Controls">
+        <Space>
+          {stats.paused ? (
+            <Button
+              type="primary"
+              icon={<PlayCircleOutlined />}
+              onClick={handleResume}
+              loading={loading}
+            >
+              Resume Queue
+            </Button>
+          ) : (
+            <Button
+              icon={<PauseCircleOutlined />}
+              onClick={handlePause}
+              loading={loading}
+            >
+              Pause Queue
+            </Button>
+          )}
+
+          <Button
+            icon={<ClearOutlined />}
+            onClick={() => handleClean('completed')}
+            loading={loading}
+          >
+            Clean Completed
+          </Button>
+
+          <Button
+            danger
+            icon={<ClearOutlined />}
+            onClick={() => handleClean('failed')}
+            loading={loading}
+          >
+            Clean Failed
+          </Button>
+        </Space>
+      </Card>
+    </Space>
+  );
+};
+
+export default EmailQueuePage;
+
+

Troubleshooting

+

Emails Stuck in Queue

+

Symptoms: +- Waiting count increases but active/completed don't +- Jobs not processing

+

Solutions:

+
    +
  1. Check worker status → docker compose logs api | grep "Worker"
  2. +
  3. Verify Redis connection → docker compose exec redis redis-cli ping
  4. +
  5. Check SMTP configuration → test with /api/auth/test-email
  6. +
  7. Restart worker → docker compose restart api
  8. +
+

Debugging:

+
# Check Redis keys
+docker compose exec redis redis-cli --pass $REDIS_PASSWORD
+> KEYS bull:campaign-emails:*
+
+# Check worker logs
+docker compose logs -f api | grep "Email worker"
+
+# Check queue status
+curl -H "Authorization: Bearer $TOKEN" http://localhost:4000/api/email-queue/stats
+
+

High Failure Rate

+

Symptoms: +- Many jobs failing +- Failed count increasing rapidly

+

Solutions:

+
    +
  1. Check SMTP credentials → verify username/password
  2. +
  3. Review failure reasons → check failureReason field in database
  4. +
  5. Check SMTP server status → verify server is reachable
  6. +
  7. Review rate limits → may be hitting SMTP server limits
  8. +
+

Common Failure Reasons:

+
    +
  • 535 Authentication failed → Invalid SMTP credentials
  • +
  • 550 Mailbox unavailable → Recipient email doesn't exist
  • +
  • 421 Too many connections → Reduce concurrency
  • +
  • Connection timeout → SMTP server unreachable
  • +
+

Code Fix (email.service.ts):

+
// Add better error handling
+async send(options: EmailOptions): Promise<void> {
+  try {
+    await this.transporter.sendMail(options);
+  } catch (error) {
+    if (error.responseCode === 535) {
+      throw new Error('SMTP authentication failed - check credentials');
+    } else if (error.responseCode === 550) {
+      throw new Error('Recipient mailbox unavailable');
+    } else if (error.code === 'ETIMEDOUT') {
+      throw new Error('SMTP server connection timeout');
+    } else {
+      throw error;
+    }
+  }
+}
+
+

Redis Connection Issues

+

Symptoms: +- Error: "ECONNREFUSED" or "NOAUTH" +- Queue operations fail

+

Solutions:

+
    +
  1. Verify Redis is running → docker compose ps redis
  2. +
  3. Check Redis password → ensure REDIS_PASSWORD matches docker-compose.yml
  4. +
  5. Check Redis port → default 6379
  6. +
  7. Verify Redis auth → docker compose exec redis redis-cli --pass $REDIS_PASSWORD ping
  8. +
+

Fix Redis Auth:

+
# docker-compose.yml
+services:
+  redis:
+    image: redis:7-alpine
+    command: redis-server --requirepass ${REDIS_PASSWORD}
+    ports:
+      - "6379:6379"
+
+

Performance Considerations

+

Concurrency Tuning

+

Worker Concurrency:

+
// Adjust based on SMTP server limits
+const workerOptions = {
+  concurrency: 5, // Process 5 emails simultaneously
+  limiter: {
+    max: 60, // Max 60 emails per minute
+    duration: 60000
+  }
+};
+
+

SMTP Server Limits:

+
    +
  • Gmail: 100 emails/day (consumer), 2000/day (Workspace)
  • +
  • SendGrid: Varies by plan (40k/day free tier)
  • +
  • AWS SES: 14 emails/second, 200 emails/day (sandbox)
  • +
+

Queue Monitoring

+

Prometheus Metrics:

+
import { Counter, Gauge } from 'prom-client';
+
+export const campaignEmailsQueued = new Counter({
+  name: 'cm_campaign_emails_queued_total',
+  help: 'Total campaign emails queued',
+  labelNames: ['campaign_id']
+});
+
+export const campaignEmailsSent = new Counter({
+  name: 'cm_campaign_emails_sent_total',
+  help: 'Total campaign emails sent',
+  labelNames: ['campaign_id']
+});
+
+export const emailQueueSize = new Gauge({
+  name: 'cm_email_queue_size',
+  help: 'Current email queue size',
+  labelNames: ['status']
+});
+
+// Update gauge every 30 seconds
+setInterval(async () => {
+  const stats = await emailQueueService.getStats();
+
+  emailQueueSize.set({ status: 'waiting' }, stats.waiting);
+  emailQueueSize.set({ status: 'active' }, stats.active);
+  emailQueueSize.set({ status: 'failed' }, stats.failed);
+}, 30000);
+
+

Database Optimization

+

Index Strategy:

+
CREATE INDEX idx_campaign_email_status ON campaign_emails (status);
+CREATE INDEX idx_campaign_email_campaign_id ON campaign_emails (campaign_id);
+CREATE INDEX idx_campaign_email_sent_at ON campaign_emails (sent_at);
+
+

Query Optimization:

+
// Paginated campaign email stats
+const emails = await prisma.campaignEmail.findMany({
+  where: { campaignId },
+  select: {
+    id: true,
+    recipientEmail: true,
+    status: true,
+    sentAt: true,
+    failureReason: true
+  },
+  orderBy: { createdAt: 'desc' },
+  take: 100,
+  skip: page * 100
+});
+
+ +

Backend Modules

+ +

Frontend Pages

+ +

Database Models

+ +

Configuration

+ +

Monitoring

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/influence/index.html b/mkdocs/site/v2/features/influence/index.html new file mode 100644 index 00000000..f039e409 --- /dev/null +++ b/mkdocs/site/v2/features/influence/index.html @@ -0,0 +1,5234 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Influence Module - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Influence Module

+

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.

+

Overview

+

The Influence module consists of five integrated components:

+
    +
  1. Campaigns - Create and manage advocacy email campaigns
  2. +
  3. Representatives - Lookup representatives by postal code
  4. +
  5. Postal Codes - Postal code caching service
  6. +
  7. Email Queue - Async email sending with BullMQ
  8. +
  9. Responses - Public response wall with moderation
  10. +
+

Features

+

Campaign Management

+
    +
  • Create campaigns with title, description, and email template
  • +
  • Target federal, provincial, or municipal representatives
  • +
  • Track campaign statistics (emails sent, responses)
  • +
  • Public/private campaign visibility
  • +
  • Featured campaign highlighting
  • +
+

Representative Lookup

+
    +
  • Represent API integration (federal/provincial)
  • +
  • Postal code → representative matching
  • +
  • Representative information caching
  • +
  • Multiple representative levels
  • +
  • District boundary support
  • +
+

Email Sending

+
    +
  • Async email queue with BullMQ
  • +
  • Template processing with variable substitution
  • +
  • SMTP delivery with retry logic
  • +
  • Email tracking and statistics
  • +
  • Test mode support (MailHog)
  • +
+

Response Wall

+
    +
  • Public response submissions
  • +
  • Email verification flow
  • +
  • Moderation dashboard
  • +
  • Upvoting system
  • +
  • Response filtering and export
  • +
+

User Flow

+

Public User Experience

+
    +
  1. Browse Campaigns (/campaigns)
  2. +
  3. View featured campaigns
  4. +
  5. Search and filter (future)
  6. +
  7. +

    Click campaign to learn more

    +
  8. +
  9. +

    Campaign Detail (/campaigns/:id)

    +
  10. +
  11. Read campaign description
  12. +
  13. Enter postal code
  14. +
  15. View matched representatives
  16. +
  17. Customize email message
  18. +
  19. +

    Send email

    +
  20. +
  21. +

    Response Wall (/responses/:campaignId)

    +
  22. +
  23. Submit public response
  24. +
  25. Verify email address
  26. +
  27. View verified responses
  28. +
  29. Upvote responses
  30. +
+

Admin Experience

+
    +
  1. Campaign Management (/app/influence/campaigns)
  2. +
  3. Create campaigns
  4. +
  5. Edit templates
  6. +
  7. Configure targeting
  8. +
  9. View statistics
  10. +
  11. +

    Manage visibility

    +
  12. +
  13. +

    Response Moderation (/app/influence/responses)

    +
  14. +
  15. Review submissions
  16. +
  17. Verify/reject responses
  18. +
  19. Export data
  20. +
  21. +

    Monitor engagement

    +
  22. +
  23. +

    Representative Cache (/app/influence/representatives)

    +
  24. +
  25. View cached representatives
  26. +
  27. Refresh cache
  28. +
  29. +

    Monitor lookup statistics

    +
  30. +
  31. +

    Email Queue (/app/influence/email-queue)

    +
  32. +
  33. Monitor queue status
  34. +
  35. View failed jobs
  36. +
  37. Retry failed emails
  38. +
  39. Pause/resume queue
  40. +
+

Architecture

+

Backend Components

+

Modules: +- api/src/modules/influence/campaigns/ - Campaign CRUD + public routes +- api/src/modules/influence/representatives/ - Represent API integration +- api/src/modules/influence/postal-codes/ - Postal code cache service +- api/src/modules/influence/responses/ - Response CRUD + verification +- api/src/modules/influence/campaign-emails/ - Email tracking +- api/src/modules/influence/email-queue/ - Queue admin routes

+

Services: +- api/src/services/email.service.ts - Nodemailer wrapper +- api/src/services/email-queue.service.ts - BullMQ queue + worker

+

Database Models: +- Campaign - Campaign definitions +- CampaignEmail - Sent email tracking +- Response - Public response submissions +- PostalCodeCache - Cached representative data

+

Frontend Components

+

Admin Pages: +- admin/src/pages/CampaignsPage.tsx - Campaign management +- admin/src/pages/ResponsesPage.tsx - Response moderation +- admin/src/pages/RepresentativesPage.tsx - Cache admin +- admin/src/pages/EmailQueuePage.tsx - Queue monitoring

+

Public Pages: +- admin/src/pages/public/CampaignsListPage.tsx - Campaign listing +- admin/src/pages/public/CampaignPage.tsx - Campaign detail + email form +- admin/src/pages/public/ResponseWallPage.tsx - Response submissions

+

Configuration

+

Environment Variables

+
# Email
+EMAIL_TEST_MODE=true          # Use MailHog instead of SMTP
+SMTP_HOST=smtp.example.com
+SMTP_PORT=587
+SMTP_USER=user@example.com
+SMTP_PASS=password
+
+# Represent API (optional)
+REPRESENT_API_KEY=your_api_key
+
+# Redis (required for BullMQ)
+REDIS_PASSWORD=your_password
+
+

Feature Flags

+

Email sending can be toggled via EMAIL_TEST_MODE: +- true - Emails sent to MailHog (localhost:8025) +- false - Emails sent via SMTP

+

Integration Points

+

Represent API

+

Represent API (https://represent.opennorth.ca/) provides: +- Federal MP lookup by postal code +- Provincial MLA/MPP lookup +- District boundaries +- Representative contact info

+

Rate Limits: 60 requests/minute

+

Caching Strategy: +- Cache postal code → representative mappings +- Refresh cache on 404 (postal code not found) +- Cache expiration: 30 days

+

Listmonk Newsletter Sync

+

Campaign participants can be synced to Listmonk: +- Email submissions → subscribers +- Campaign → list assignment +- Opt-in sync via LISTMONK_SYNC_ENABLED

+

Email Queue (BullMQ)

+

BullMQ provides: +- Async email processing +- Job retry with exponential backoff +- Queue monitoring and statistics +- Job persistence in Redis

+

API Endpoints

+

Public Endpoints

+
GET  /api/campaigns/public              # List public campaigns
+GET  /api/campaigns/public/:id          # Get campaign details
+POST /api/campaigns/:id/send-email      # Send campaign email
+GET  /api/representatives/:postalCode   # Lookup representatives
+POST /api/responses                     # Submit response
+GET  /api/responses/verify/:token       # Verify email
+GET  /api/responses/campaign/:id        # Get campaign responses
+POST /api/responses/:id/upvote          # Upvote response
+
+

Admin Endpoints

+
GET    /api/campaigns                   # List all campaigns
+POST   /api/campaigns                   # Create campaign
+GET    /api/campaigns/:id               # Get campaign
+PATCH  /api/campaigns/:id               # Update campaign
+DELETE /api/campaigns/:id               # Delete campaign
+GET    /api/campaigns/:id/emails        # Get campaign emails
+GET    /api/responses                   # List responses (admin)
+PATCH  /api/responses/:id               # Update response
+DELETE /api/responses/:id               # Delete response
+GET    /api/representatives/cache       # View cache
+POST   /api/representatives/cache/refresh # Refresh cache
+GET    /api/email-queue/stats           # Queue statistics
+POST   /api/email-queue/pause           # Pause queue
+POST   /api/email-queue/resume          # Resume queue
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/influence/postal-codes/index.html b/mkdocs/site/v2/features/influence/postal-codes/index.html new file mode 100644 index 00000000..90b84593 --- /dev/null +++ b/mkdocs/site/v2/features/influence/postal-codes/index.html @@ -0,0 +1,5363 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Postal Codes - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Postal Code Geocoding Cache

+

Overview

+

The postal code geocoding cache system stores geographic coordinates for Canadian postal codes, enabling faster representative lookups and reducing external API calls. It integrates with the multi-provider geocoding service to provide reliable centroid calculations for postal code-based geographic queries.

+

Key Capabilities:

+
    +
  • Postal code caching: Store lat/lng centroids for postal codes
  • +
  • Geocoding integration: Automatic geocoding via multi-provider service
  • +
  • Cache hit optimization: Reduce external API calls
  • +
  • Administrative data: City and province extraction
  • +
  • Representative lookup: Fast postal code → representative mapping
  • +
+

Use Cases:

+
    +
  • Campaign postal code lookups
  • +
  • Geographic representative mapping
  • +
  • Postal code validation
  • +
  • Centroid-based spatial queries
  • +
+

Architecture

+
graph TD
+    A[Campaign Service] -->|Lookup Postal Code| B[Postal Code Service]
+    B -->|Check Cache| C{Cache Hit?}
+    C -->|Yes| D[Return Cached Centroid]
+    C -->|No| E[Geocoding Service]
+
+    E -->|Geocode| F[Multi-Provider Geocoding]
+    F -->|Parse Result| G[Extract Centroid]
+    G -->|Save| H[(PostalCodeCache Model)]
+    H -->|Return| D
+
+    I[Admin] -->|View Stats| J[RepresentativesPage]
+    J -->|Display| K[Cache Statistics]
+
+    style H fill:#e1f5ff
+    style F fill:#fff4e1
+

Database Models

+

PostalCodeCache Model

+

See PostalCodeCache Model Documentation for full schema.

+

Key Fields:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
postalCodeStringNormalized postal code (primary key)
latitudeFloatCentroid latitude
longitudeFloatCentroid longitude
cityString?City name
provinceString?Province abbreviation
+

Indexes:

+
    +
  • postalCode — Primary key, unique constraint
  • +
+

Related Models:

+ +

API Endpoints

+

Admin Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/postal-codes/statsSUPER_ADMIN, INFLUENCE_ADMINGet cache statistics
POST/api/postal-codes/lookupSUPER_ADMIN, INFLUENCE_ADMINManual postal code lookup
+

Public Endpoints

+

Postal code lookups are performed automatically via representative lookup (no direct public access).

+

Configuration

+

Environment Variables

+ + + + + + + + + + + + + + + + + + + + + + + +
VariableTypeDefaultDescription
GEOCODING_PROVIDERstringnominatimDefault geocoding provider
GEOCODING_FALLBACK_PROVIDERSstring-Comma-separated fallback providers
+

Admin Workflow

+

1. View Cache Statistics

+

Steps:

+
    +
  1. Navigate to Influence > Representatives
  2. +
  3. View postal code cache statistics
  4. +
  5. Monitor cache hit rate
  6. +
+

Public Workflow

+

Postal code caching is automatic and transparent to public users.

+

Code Examples

+

Backend: Postal Code Caching

+
// api/src/modules/influence/postal-codes/postal-codes.service.ts
+
+export class PostalCodeService {
+  async getOrCreateCache(postalCode: string): Promise<PostalCodeCache> {
+    const normalized = postalCode.toUpperCase().replace(/\s/g, '');
+
+    // Check cache
+    const cached = await prisma.postalCodeCache.findUnique({
+      where: { postalCode: normalized }
+    });
+
+    if (cached) {
+      return cached;
+    }
+
+    // Geocode postal code
+    const result = await geocodingService.geocode({
+      query: postalCode,
+      country: 'CA'
+    });
+
+    if (!result) {
+      throw new Error('Failed to geocode postal code');
+    }
+
+    // Create cache entry
+    return prisma.postalCodeCache.create({
+      data: {
+        postalCode: normalized,
+        latitude: result.latitude,
+        longitude: result.longitude,
+        city: result.city,
+        province: result.province
+      }
+    });
+  }
+}
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/influence/representatives/index.html b/mkdocs/site/v2/features/influence/representatives/index.html new file mode 100644 index 00000000..db8f7b3b --- /dev/null +++ b/mkdocs/site/v2/features/influence/representatives/index.html @@ -0,0 +1,6665 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Representatives - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Representative Lookup System

+

Overview

+

The representative lookup system integrates with the Represent API (Open North) to provide real-time postal code-based representative lookups for advocacy campaigns. It includes intelligent caching to minimize API calls, support for all Canadian government levels, and admin tools for cache management.

+

Key Capabilities:

+
    +
  • Represent API integration: Real-time lookup of elected officials by postal code
  • +
  • Multi-level support: Federal, provincial, and municipal representatives
  • +
  • Intelligent caching: Reduce API calls and improve performance
  • +
  • Cache invalidation: Manual and automatic cache refresh
  • +
  • Admin tools: Cache statistics, manual lookup, bulk operations
  • +
  • Error handling: Graceful fallback for API failures
  • +
+

Use Cases:

+
    +
  • Email-your-MP campaigns
  • +
  • Multi-level government outreach
  • +
  • Representative contact information lookup
  • +
  • Geographic representation analysis
  • +
  • Campaign targeting by electoral district
  • +
+

Architecture

+
graph TD
+    A[Public User] -->|Enter Postal Code| B[CampaignPage]
+    B -->|POST /api/public/representatives/lookup| C[Representative Service]
+
+    C -->|Check Cache| D{Cache Hit?}
+    D -->|Yes| E[Return Cached Reps]
+    D -->|No| F[Represent API Client]
+
+    F -->|GET /postcodes/:code| G[Represent API]
+    G -->|Return Reps| F
+    F -->|Parse & Save| H[(Representative Model)]
+    H -->|Return| E
+
+    I[Admin User] -->|View Cache| J[RepresentativesPage]
+    J -->|GET /api/representatives| C
+    J -->|Manual Lookup| C
+    J -->|Clear Cache| K[Delete Service]
+    K -->|Delete| H
+
+    L[Cache Invalidation Job] -->|Check lastUpdated| H
+    L -->|Delete Stale| H
+
+    style H fill:#e1f5ff
+    style G fill:#fff4e1
+

Flow Description:

+
    +
  1. User enters postal code → Representative service checks cache
  2. +
  3. Cache miss → Represent API client fetches representatives
  4. +
  5. API response → Parse representatives, save to cache
  6. +
  7. Cache hit → Return cached representatives (skip API call)
  8. +
  9. Admin management → View cache stats, manual lookup, clear cache
  10. +
  11. Cache invalidation → Automatic cleanup of stale entries (>30 days)
  12. +
+

Database Models

+

Representative Model

+

See Representative Model Documentation for full schema.

+

Key Fields:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString (UUID)Primary key
representIdStringRepresent API unique identifier
nameStringFull name of representative
emailStringEmail address
districtNameStringElectoral district name
electedOfficeStringOffice held (MP, MPP, Mayor, etc.)
partyNameString?Political party affiliation
photoUrlString?Profile photo URL
postalCodeStringAssociated postal code (cache key)
levelStringGovernment level (federal, provincial, municipal)
lastUpdatedDateTimeCache timestamp
+

Indexes:

+
    +
  • postalCode, level — Composite index for fast lookups
  • +
  • representId — Unique constraint
  • +
  • lastUpdated — For cache invalidation queries
  • +
+

Related Models:

+ +

API Endpoints

+

Admin Endpoints

+

See Representatives Module API Reference for full details.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/representativesSUPER_ADMIN, INFLUENCE_ADMINList all cached representatives
GET/api/representatives/statsSUPER_ADMIN, INFLUENCE_ADMINGet cache statistics
POST/api/representatives/lookupSUPER_ADMIN, INFLUENCE_ADMINManual postal code lookup
DELETE/api/representatives/:idSUPER_ADMIN, INFLUENCE_ADMINDelete cached representative
DELETE/api/representatives/postal-code/:postalCodeSUPER_ADMIN, INFLUENCE_ADMINDelete all reps for postal code
+

Public Endpoints

+

See Representatives Module API Reference.

+ + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
POST/api/public/representatives/lookupNoneLookup representatives by postal code
+

Configuration

+

Environment Variables

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableTypeDefaultDescription
REPRESENT_API_URLstringhttps://represent.opennorth.caRepresent API base URL
REPRESENT_CACHE_TTLnumber2592000Cache TTL in seconds (30 days)
REPRESENT_RATE_LIMITnumber60Max requests per minute
+

Represent API

+

The Represent API is a public service provided by Open North. No API key required.

+

API Documentation: https://represent.opennorth.ca/api/

+

Endpoints Used:

+
    +
  • GET /postcodes/:postalCode/ — Lookup representatives by postal code
  • +
  • GET /representatives/ — List representatives (unused, direct lookups only)
  • +
+

Rate Limits:

+
    +
  • 60 requests per minute per IP address
  • +
  • Exceeding limit returns HTTP 429
  • +
+

Postal Code Format:

+
    +
  • Canadian postal codes only
  • +
  • Format: K1A 0A1 or K1A0A1 (space optional)
  • +
  • Normalized to uppercase without spaces for API calls
  • +
+

Admin Workflow

+

1. View Cache Statistics

+

[Screenshot: RepresentativesPage with cache stats cards]

+

Steps:

+
    +
  1. Navigate to Influence > Representatives
  2. +
  3. View cache statistics:
  4. +
  5. Total Cached: Total representatives in cache
  6. +
  7. Unique Postal Codes: Number of postal codes cached
  8. +
  9. Cache Hit Rate: Percentage of lookups served from cache
  10. +
  11. Stale Entries: Entries older than 30 days
  12. +
+

Code Example (RepresentativesPage.tsx):

+
const [stats, setStats] = useState({
+  totalCached: 0,
+  uniquePostalCodes: 0,
+  cacheHitRate: 0,
+  staleEntries: 0
+});
+
+useEffect(() => {
+  const fetchStats = async () => {
+    const { data } = await api.get('/representatives/stats');
+    setStats(data);
+  };
+
+  fetchStats();
+}, []);
+
+return (
+  <Row gutter={16}>
+    <Col span={6}>
+      <Card>
+        <Statistic title="Total Cached" value={stats.totalCached} />
+      </Card>
+    </Col>
+    <Col span={6}>
+      <Card>
+        <Statistic title="Unique Postal Codes" value={stats.uniquePostalCodes} />
+      </Card>
+    </Col>
+    <Col span={6}>
+      <Card>
+        <Statistic
+          title="Cache Hit Rate"
+          value={stats.cacheHitRate}
+          suffix="%"
+          precision={1}
+        />
+      </Card>
+    </Col>
+    <Col span={6}>
+      <Card>
+        <Statistic
+          title="Stale Entries"
+          value={stats.staleEntries}
+          valueStyle={{ color: stats.staleEntries > 0 ? '#cf1322' : undefined }}
+        />
+      </Card>
+    </Col>
+  </Row>
+);
+
+

2. Manual Postal Code Lookup

+

[Screenshot: RepresentativesPage with postal code search form]

+

Steps:

+
    +
  1. Enter postal code in search box (e.g., "K1A 0A1")
  2. +
  3. Click Lookup button
  4. +
  5. View results:
  6. +
  7. Representative name, office, party
  8. +
  9. Electoral district
  10. +
  11. Email address (if available)
  12. +
  13. Results automatically cached for future lookups
  14. +
+

Use Cases:

+
    +
  • Pre-populate cache for campaign areas
  • +
  • Verify representative information
  • +
  • Test postal code validation
  • +
  • Troubleshoot lookup issues
  • +
+

Code Example (representatives.service.ts):

+
async lookupByPostalCode(postalCode: string): Promise<Representative[]> {
+  // Normalize postal code
+  const normalized = postalCode.toUpperCase().replace(/\s/g, '');
+
+  // Check cache first (within last 30 days)
+  const cached = await this.prisma.representative.findMany({
+    where: {
+      postalCode: normalized,
+      lastUpdated: {
+        gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // 30 days
+      }
+    }
+  });
+
+  if (cached.length > 0) {
+    logger.info(`Cache hit for postal code ${normalized}`);
+    return cached;
+  }
+
+  // Cache miss - fetch from Represent API
+  logger.info(`Cache miss for postal code ${normalized}, fetching from API`);
+
+  const representatives = await this.representApiClient.getRepresentativesByPostalCode(
+    normalized
+  );
+
+  // Save to cache
+  const saved = await Promise.all(
+    representatives.map(rep =>
+      this.prisma.representative.upsert({
+        where: { representId: rep.representId },
+        update: {
+          ...rep,
+          postalCode: normalized,
+          lastUpdated: new Date()
+        },
+        create: {
+          ...rep,
+          postalCode: normalized,
+          lastUpdated: new Date()
+        }
+      })
+    )
+  );
+
+  return saved;
+}
+
+

3. Clear Stale Cache Entries

+

[Screenshot: RepresentativesPage with "Clear Stale Cache" button]

+

Steps:

+
    +
  1. Click Clear Stale Cache button
  2. +
  3. Confirm deletion in modal
  4. +
  5. System deletes all entries older than 30 days
  6. +
  7. View updated cache statistics
  8. +
+

Automatic Cleanup:

+

Cache invalidation also runs automatically via cron job (daily at 2 AM):

+
// api/src/server.ts
+
+import cron from 'node-cron';
+
+// Clean stale representative cache daily at 2 AM
+cron.schedule('0 2 * * *', async () => {
+  try {
+    const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
+
+    const result = await prisma.representative.deleteMany({
+      where: {
+        lastUpdated: {
+          lt: thirtyDaysAgo
+        }
+      }
+    });
+
+    logger.info(`Deleted ${result.count} stale representative cache entries`);
+  } catch (error) {
+    logger.error('Failed to clean representative cache:', error);
+  }
+});
+
+

4. Delete Specific Cache Entries

+

[Screenshot: RepresentativesPage table with delete buttons]

+

Steps:

+
    +
  1. Browse cached representatives table
  2. +
  3. Click Delete button on specific row
  4. +
  5. Confirm deletion
  6. +
  7. Representative removed from cache (will be re-fetched on next lookup)
  8. +
+

Bulk Delete by Postal Code:

+
    +
  1. Click Delete All button on postal code group
  2. +
  3. Confirm deletion
  4. +
  5. All representatives for that postal code removed from cache
  6. +
+

Public Workflow

+

1. Enter Postal Code

+

[Screenshot: CampaignPage with postal code input field]

+

User Journey:

+
    +
  1. User visits campaign page (/campaigns/{slug})
  2. +
  3. Enters postal code in lookup form
  4. +
  5. Clicks Find My Representatives
  6. +
  7. System performs lookup (cache or API)
  8. +
  9. Representatives displayed below form
  10. +
+

Code Example (CampaignPage.tsx):

+
const [representatives, setRepresentatives] = useState<Representative[]>([]);
+const [loading, setLoading] = useState(false);
+
+const handleLookup = async (values: { postalCode: string }) => {
+  setLoading(true);
+
+  try {
+    const { data } = await axios.post('/api/public/representatives/lookup', {
+      postalCode: values.postalCode
+    });
+
+    setRepresentatives(data);
+
+    if (data.length === 0) {
+      message.warning('No representatives found for this postal code');
+    }
+  } catch (error) {
+    message.error('Failed to lookup representatives');
+  } finally {
+    setLoading(false);
+  }
+};
+
+return (
+  <Form onFinish={handleLookup}>
+    <Form.Item
+      name="postalCode"
+      label="Postal Code"
+      rules={[
+        { required: true, message: 'Please enter your postal code' },
+        {
+          pattern: /^[A-Za-z]\d[A-Za-z][ -]?\d[A-Za-z]\d$/,
+          message: 'Please enter a valid Canadian postal code'
+        }
+      ]}
+    >
+      <Input placeholder="K1A 0A1" maxLength={7} />
+    </Form.Item>
+
+    <Form.Item>
+      <Button type="primary" htmlType="submit" loading={loading}>
+        Find My Representatives
+      </Button>
+    </Form.Item>
+  </Form>
+);
+
+

2. View Representatives

+

[Screenshot: Representative cards with contact information]

+

Display Fields:

+
    +
  • Representative name
  • +
  • Elected office (MP, MPP, Mayor, Councillor)
  • +
  • Political party (if applicable)
  • +
  • Electoral district name
  • +
  • Photo (if available)
  • +
  • Email button (if email available)
  • +
+

Filtering:

+

Representatives filtered by campaign's targetGovernmentLevels:

+
// Filter representatives by campaign levels
+const filteredRepresentatives = representatives.filter(rep =>
+  campaign.targetGovernmentLevels.includes(rep.level)
+);
+
+

3. Select Representatives to Email

+

[Screenshot: Representative list with checkboxes]

+

User Journey:

+
    +
  1. User reviews list of representatives
  2. +
  3. Selects representatives to email (checkboxes)
  4. +
  5. Clicks Continue to email form
  6. +
  7. System pre-populates recipient list
  8. +
+

Code Example:

+
const [selectedReps, setSelectedReps] = useState<string[]>([]);
+
+const handleSelectAll = () => {
+  setSelectedReps(representatives.map(r => r.id));
+};
+
+const handleSelectNone = () => {
+  setSelectedReps([]);
+};
+
+return (
+  <Space direction="vertical" style={{ width: '100%' }}>
+    <Space>
+      <Button onClick={handleSelectAll}>Select All</Button>
+      <Button onClick={handleSelectNone}>Select None</Button>
+    </Space>
+
+    <Checkbox.Group
+      value={selectedReps}
+      onChange={setSelectedReps}
+      style={{ width: '100%' }}
+    >
+      {representatives.map(rep => (
+        <Card key={rep.id} style={{ marginBottom: 16 }}>
+          <Checkbox value={rep.id}>
+            <Space>
+              {rep.photoUrl && (
+                <Avatar src={rep.photoUrl} size={64} />
+              )}
+              <Space direction="vertical" size={0}>
+                <Typography.Text strong>{rep.name}</Typography.Text>
+                <Typography.Text type="secondary">{rep.electedOffice}</Typography.Text>
+                <Typography.Text type="secondary">{rep.districtName}</Typography.Text>
+                {rep.partyName && <Tag>{rep.partyName}</Tag>}
+              </Space>
+            </Space>
+          </Checkbox>
+        </Card>
+      ))}
+    </Checkbox.Group>
+  </Space>
+);
+
+

Volunteer Workflow

+

Not applicable — representative lookup is public-facing and admin-managed.

+

Code Examples

+

Backend: Represent API Client

+
// api/src/modules/influence/representatives/represent-api.client.ts
+
+import axios from 'axios';
+import { logger } from '../../../utils/logger';
+
+const REPRESENT_API_URL = process.env.REPRESENT_API_URL || 'https://represent.opennorth.ca';
+
+interface RepresentApiResponse {
+  objects: Array<{
+    name: string;
+    email: string;
+    district_name: string;
+    elected_office: string;
+    party_name?: string;
+    photo_url?: string;
+    url: string;
+    representative_set_name: string;
+  }>;
+}
+
+export class RepresentApiClient {
+  async getRepresentativesByPostalCode(postalCode: string): Promise<any[]> {
+    try {
+      const { data } = await axios.get<RepresentApiResponse>(
+        `${REPRESENT_API_URL}/postcodes/${postalCode}/`,
+        {
+          headers: {
+            'Accept': 'application/json'
+          },
+          timeout: 10000
+        }
+      );
+
+      return data.objects.map(rep => ({
+        representId: this.extractRepresentId(rep.url),
+        name: rep.name,
+        email: rep.email || null,
+        districtName: rep.district_name,
+        electedOffice: rep.elected_office,
+        partyName: rep.party_name || null,
+        photoUrl: rep.photo_url || null,
+        level: this.mapGovernmentLevel(rep.representative_set_name)
+      }));
+    } catch (error) {
+      if (axios.isAxiosError(error)) {
+        if (error.response?.status === 404) {
+          logger.warn(`No representatives found for postal code: ${postalCode}`);
+          return [];
+        }
+
+        if (error.response?.status === 429) {
+          logger.error('Represent API rate limit exceeded');
+          throw new Error('Rate limit exceeded. Please try again later.');
+        }
+      }
+
+      logger.error('Represent API error:', error);
+      throw new Error('Failed to fetch representatives');
+    }
+  }
+
+  private extractRepresentId(url: string): string {
+    // Extract ID from URL: /representatives/house-of-commons/123/
+    const match = url.match(/\/representatives\/[^\/]+\/(\d+)\//);
+    return match ? match[1] : url;
+  }
+
+  private mapGovernmentLevel(setName: string): string {
+    // Map representative set names to standard levels
+    const lowerSetName = setName.toLowerCase();
+
+    if (lowerSetName.includes('house-of-commons')) return 'federal';
+    if (lowerSetName.includes('legislative-assembly')) return 'provincial';
+    if (lowerSetName.includes('council')) return 'municipal';
+
+    return 'other';
+  }
+}
+
+

Frontend: Representative Card Component

+
// admin/src/components/influence/RepresentativeCard.tsx
+
+import React from 'react';
+import { Card, Avatar, Space, Typography, Tag, Button } from 'antd';
+import { MailOutlined, UserOutlined } from '@ant-design/icons';
+import type { Representative } from '../../types/api';
+
+interface RepresentativeCardProps {
+  representative: Representative;
+  onSelect?: (id: string) => void;
+  selected?: boolean;
+}
+
+const RepresentativeCard: React.FC<RepresentativeCardProps> = ({
+  representative,
+  onSelect,
+  selected
+}) => {
+  const levelColors: Record<string, string> = {
+    federal: 'blue',
+    provincial: 'green',
+    municipal: 'orange'
+  };
+
+  return (
+    <Card
+      hoverable={!!onSelect}
+      onClick={() => onSelect?.(representative.id)}
+      style={{
+        borderColor: selected ? '#1890ff' : undefined,
+        borderWidth: selected ? 2 : 1
+      }}
+    >
+      <Space align="start" size="large">
+        <Avatar
+          src={representative.photoUrl}
+          icon={<UserOutlined />}
+          size={80}
+        />
+
+        <Space direction="vertical" size={0} style={{ flex: 1 }}>
+          <Typography.Title level={5} style={{ margin: 0 }}>
+            {representative.name}
+          </Typography.Title>
+
+          <Typography.Text type="secondary">
+            {representative.electedOffice}
+          </Typography.Text>
+
+          <Typography.Text type="secondary">
+            {representative.districtName}
+          </Typography.Text>
+
+          <Space size="small" style={{ marginTop: 8 }}>
+            <Tag color={levelColors[representative.level] || 'default'}>
+              {representative.level.toUpperCase()}
+            </Tag>
+
+            {representative.partyName && (
+              <Tag>{representative.partyName}</Tag>
+            )}
+          </Space>
+
+          {representative.email && (
+            <Button
+              type="link"
+              icon={<MailOutlined />}
+              href={`mailto:${representative.email}`}
+              style={{ padding: 0, marginTop: 8 }}
+            >
+              {representative.email}
+            </Button>
+          )}
+        </Space>
+      </Space>
+    </Card>
+  );
+};
+
+export default RepresentativeCard;
+
+

Troubleshooting

+

No Representatives Found

+

Symptoms: +- Lookup returns empty array +- Error: "No representatives found for this postal code"

+

Solutions:

+
    +
  1. Verify postal code format → Must be valid Canadian postal code
  2. +
  3. Check Represent API status → Visit https://represent.opennorth.ca/health
  4. +
  5. Test postal code manually → Try https://represent.opennorth.ca/postcodes/K1A0A1/
  6. +
  7. Review API logs → Check for rate limit errors
  8. +
+

Debugging:

+
# Test Represent API directly
+curl https://represent.opennorth.ca/postcodes/K1A0A1/ | jq
+
+# Check representative cache
+docker compose exec v2-postgres psql -U changemaker -d changemaker_lite -c \
+  "SELECT * FROM representatives WHERE postal_code = 'K1A0A1';"
+
+# Check API logs
+docker compose logs api | grep "Represent API"
+
+

Rate Limit Exceeded

+

Symptoms: +- HTTP 429 error +- Error: "Rate limit exceeded. Please try again later."

+

Solutions:

+
    +
  1. Implement exponential backoff → Retry with increasing delays
  2. +
  3. Use cache more aggressively → Increase cache TTL to 60 days
  4. +
  5. Batch lookups → Avoid rapid repeated lookups
  6. +
  7. Contact Open North → Request rate limit increase if needed
  8. +
+

Code Fix (represent-api.client.ts):

+
async getRepresentativesByPostalCodeWithRetry(
+  postalCode: string,
+  maxRetries = 3
+): Promise<any[]> {
+  for (let i = 0; i < maxRetries; i++) {
+    try {
+      return await this.getRepresentativesByPostalCode(postalCode);
+    } catch (error) {
+      if (error.message.includes('Rate limit exceeded')) {
+        const delay = Math.pow(2, i) * 1000; // Exponential backoff
+        logger.warn(`Rate limit hit, retrying in ${delay}ms...`);
+        await new Promise(resolve => setTimeout(resolve, delay));
+        continue;
+      }
+      throw error;
+    }
+  }
+
+  throw new Error('Max retries exceeded');
+}
+
+

Stale Representative Information

+

Symptoms: +- Representative email bounces +- Representative no longer in office

+

Solutions:

+
    +
  1. Clear cache for postal code → Delete and re-fetch
  2. +
  3. Reduce cache TTL → Set REPRESENT_CACHE_TTL to 7 days (604800)
  4. +
  5. Manual verification → Check official government websites
  6. +
  7. Report to Represent API → If data is incorrect, report to Open North
  8. +
+

Manual Cache Clear:

+
// Via admin UI
+// Navigate to Influence > Representatives
+// Find postal code in table
+// Click "Delete All" for that postal code
+
+// Via API
+await api.delete(`/representatives/postal-code/${postalCode}`);
+
+

Missing Email Addresses

+

Symptoms: +- Representative has no email address +- Cannot send campaign email

+

Solutions:

+
    +
  1. Check Represent API data → Some reps don't provide email publicly
  2. +
  3. Use manual email field → Allow admins to add email addresses
  4. +
  5. Fallback to constituency office → Use office email if available
  6. +
  7. Skip representative → Don't include in email recipients
  8. +
+

Code Fix (representative.service.ts):

+
async updateRepresentativeEmail(
+  representId: string,
+  email: string
+): Promise<Representative> {
+  return this.prisma.representative.update({
+    where: { representId },
+    data: {
+      email,
+      lastUpdated: new Date() // Reset cache timestamp
+    }
+  });
+}
+
+

Performance Considerations

+

Cache Strategy

+

TTL Configuration:

+
    +
  • Default: 30 days (2,592,000 seconds)
  • +
  • Aggressive: 60 days for stable electoral districts
  • +
  • Conservative: 7 days during election periods
  • +
+

Cache Warming:

+

Pre-populate cache for common postal codes:

+
// api/src/scripts/warm-representative-cache.ts
+
+import { RepresentativeService } from '../modules/influence/representatives/representatives.service';
+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+const representativeService = new RepresentativeService(prisma);
+
+// Common postal codes from campaign participation data
+const commonPostalCodes = [
+  'K1A0A1', 'M5H2N2', 'V6B1A1', // Federal capitals
+  'T2P2M5', 'H3B1A1', 'S7K0J5'  // Provincial capitals
+];
+
+async function warmCache() {
+  for (const postalCode of commonPostalCodes) {
+    try {
+      await representativeService.lookupByPostalCode(postalCode);
+      console.log(`Cached representatives for ${postalCode}`);
+    } catch (error) {
+      console.error(`Failed to cache ${postalCode}:`, error);
+    }
+
+    // Rate limit: 1 request per second
+    await new Promise(resolve => setTimeout(resolve, 1000));
+  }
+}
+
+warmCache();
+
+

Query Optimization

+

Index Usage:

+
-- Composite index for fast lookups
+CREATE INDEX idx_representative_postal_code_level
+  ON representatives (postal_code, level);
+
+-- Index for cache invalidation
+CREATE INDEX idx_representative_last_updated
+  ON representatives (last_updated);
+
+

Query Pattern:

+
// Optimized cache lookup with index
+const cached = await prisma.representative.findMany({
+  where: {
+    postalCode: normalized,
+    level: { in: targetLevels }, // Use index
+    lastUpdated: {
+      gte: new Date(Date.now() - CACHE_TTL * 1000)
+    }
+  }
+});
+
+

API Rate Limiting

+

Client-Side Rate Limiter:

+
import Bottleneck from 'bottleneck';
+
+const limiter = new Bottleneck({
+  maxConcurrent: 1,
+  minTime: 1000 // 1 request per second
+});
+
+const getRepresentativesRateLimited = limiter.wrap(
+  representApiClient.getRepresentativesByPostalCode.bind(representApiClient)
+);
+
+

Redis-Based Distributed Rate Limiting:

+
import { RateLimiterRedis } from 'rate-limiter-flexible';
+
+const rateLimiter = new RateLimiterRedis({
+  storeClient: redisClient,
+  keyPrefix: 'represent-api',
+  points: 60, // 60 requests
+  duration: 60 // per minute
+});
+
+await rateLimiter.consume('represent-api-key');
+
+ +

Backend Modules

+ +

Frontend Pages

+ +

Database Models

+ +

External APIs

+ +

Configuration

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/influence/responses/index.html b/mkdocs/site/v2/features/influence/responses/index.html new file mode 100644 index 00000000..9ba6fcfc --- /dev/null +++ b/mkdocs/site/v2/features/influence/responses/index.html @@ -0,0 +1,6857 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Responses - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Response Wall System

+

Overview

+

The response wall system allows campaign participants to share their advocacy actions publicly, creating social proof and encouraging further participation. It includes email verification, admin moderation, upvoting capabilities, and screenshot uploads to showcase genuine participation.

+

Key Capabilities:

+
    +
  • Multiple response types: EMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHER
  • +
  • Email verification: Prevent spam with email confirmation
  • +
  • Admin moderation: PENDING → APPROVED/REJECTED workflow
  • +
  • Upvoting system: Community engagement with IP + user tracking
  • +
  • Screenshot uploads: Visual proof of participation
  • +
  • Public response wall: SEO-friendly public display
  • +
  • Moderation dashboard: Admin tools for reviewing submissions
  • +
+

Use Cases:

+
    +
  • Public display of campaign participation
  • +
  • Social proof for advocacy campaigns
  • +
  • Community engagement and sharing
  • +
  • Response verification and moderation
  • +
  • Campaign effectiveness metrics
  • +
+

Architecture

+
graph TD
+    A[Public User] -->|Submit Response| B[ResponseWallPage]
+    B -->|POST /api/public/responses| C[Response Service]
+    C -->|Save| D[(Response Model)]
+    C -->|Send| E[Email Service]
+    E -->|Verification Email| F[User Inbox]
+
+    F -->|Click Link| G[Verify Endpoint]
+    G -->|Update| D
+
+    H[Admin User] -->|Review| I[ResponsesPage]
+    I -->|GET /api/responses| C
+    I -->|Approve/Reject| C
+    C -->|Update Status| D
+
+    J[Public User] -->|View Wall| K[ResponseWallPage]
+    K -->|GET /api/public/responses/:campaignId| C
+    C -->|Filter APPROVED| D
+
+    K -->|Upvote| L[Upvote Service]
+    L -->|Track| M[(ResponseUpvote Model)]
+    L -->|Increment| D
+
+    style D fill:#e1f5ff
+    style M fill:#e1f5ff
+    style E fill:#fff4e1
+

Flow Description:

+
    +
  1. User submits response → Response service saves with PENDING status
  2. +
  3. Verification email sent → User clicks link to verify email
  4. +
  5. Email verified → Response marked as email verified
  6. +
  7. Admin reviews → Moderates response (approve/reject)
  8. +
  9. Response approved → Appears on public response wall
  10. +
  11. Users upvote → Upvote service tracks votes, increments count
  12. +
  13. Public views wall → Only approved responses displayed
  14. +
+

Database Models

+

Response Model

+

See Response Model Documentation for full schema.

+

Key Fields:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString (UUID)Primary key
campaignIdStringAssociated campaign
responseTypeEnumEMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHER
messageStringUser's response message
screenshotUrlString?Uploaded screenshot URL
nameStringSubmitter's name
emailStringSubmitter's email
postalCodeString?Submitter's postal code
isEmailVerifiedBooleanEmail verification status
statusEnumPENDING, APPROVED, REJECTED
upvotesIntNumber of upvotes
moderatedByUserIdString?Admin who moderated
moderationNotesString?Admin notes
+

Indexes:

+
    +
  • campaignId, status — For public wall queries
  • +
  • email, campaignId — Prevent duplicate submissions
  • +
  • isEmailVerified — Filter unverified responses
  • +
+

ResponseUpvote Model

+

See ResponseUpvote Model Documentation for full schema.

+

Key Fields:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString (UUID)Primary key
responseIdStringAssociated response
ipAddressString?Voter IP address
userIdString?Voter user ID (if logged in)
+

Constraints:

+
    +
  • Unique constraint on responseId, ipAddress — Prevent duplicate upvotes by IP
  • +
  • Unique constraint on responseId, userId — Prevent duplicate upvotes by user
  • +
+

Related Models:

+
    +
  • Campaign — Campaign association
  • +
  • User — Moderation user
  • +
+

API Endpoints

+

Admin Endpoints

+

See Responses Module API Reference for full details.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/responsesSUPER_ADMIN, INFLUENCE_ADMINList all responses (paginated, filterable)
GET/api/responses/:idSUPER_ADMIN, INFLUENCE_ADMINGet response details
PATCH/api/responses/:id/moderateSUPER_ADMIN, INFLUENCE_ADMINApprove/reject response
DELETE/api/responses/:idSUPER_ADMINDelete response
+

Public Endpoints

+

See Responses Module API Reference.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/public/responses/:campaignIdNoneList approved responses for campaign
POST/api/public/responsesNoneSubmit new response
GET/api/public/responses/verify/:tokenNoneVerify email via token
POST/api/public/responses/:id/upvoteNoneUpvote response (IP/user tracked)
+

Configuration

+

Environment Variables

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableTypeDefaultDescription
EMAIL_TEST_MODEbooleanfalseSend verification emails to MailHog
SMTP_FROM_EMAILstring-Sender email for verification
SMTP_FROM_NAMEstring-Sender name for verification
RESPONSE_VERIFICATION_URLstring-Base URL for verification links
+

Campaign Feature Flags

+

Response wall behavior configured per campaign:

+ + + + + + + + + + + + + + + + + + + + + +
FlagDescription
showResponseWallEnable response wall for campaign
requireEmailVerificationRequire email verification before display
allowAnonymousResponsesAllow submissions without login
+

Upload Configuration

+

Screenshots uploaded to /uploads/responses/{responseId}/{filename}.

+

Limits: +- Max file size: 5MB +- Allowed formats: jpg, jpeg, png, gif, webp

+

Admin Workflow

+

1. View Pending Responses

+

[Screenshot: ResponsesPage with pending filter active]

+

Steps:

+
    +
  1. Navigate to Influence > Responses
  2. +
  3. Click Pending filter tab
  4. +
  5. View pending responses requiring moderation
  6. +
  7. Sort by submission date (newest first)
  8. +
+

Code Example (ResponsesPage.tsx):

+
const [responses, setResponses] = useState<Response[]>([]);
+const [filter, setFilter] = useState<'all' | 'pending' | 'approved' | 'rejected'>('pending');
+
+useEffect(() => {
+  const fetchResponses = async () => {
+    const params = new URLSearchParams();
+
+    if (filter !== 'all') {
+      params.set('status', filter.toUpperCase());
+    }
+
+    const { data } = await api.get(`/responses?${params.toString()}`);
+    setResponses(data.responses);
+  };
+
+  fetchResponses();
+}, [filter]);
+
+return (
+  <Card>
+    <Tabs activeKey={filter} onChange={setFilter}>
+      <TabPane tab="Pending" key="pending" />
+      <TabPane tab="Approved" key="approved" />
+      <TabPane tab="Rejected" key="rejected" />
+      <TabPane tab="All" key="all" />
+    </Tabs>
+
+    <Table dataSource={responses} columns={columns} />
+  </Card>
+);
+
+

2. Review Response Details

+

[Screenshot: Response detail drawer with full content]

+

Steps:

+
    +
  1. Click View on response row
  2. +
  3. Review response details:
  4. +
  5. Campaign name
  6. +
  7. Response type
  8. +
  9. Submitter name and email
  10. +
  11. Message content
  12. +
  13. Screenshot (if uploaded)
  14. +
  15. Email verification status
  16. +
  17. Submission date
  18. +
  19. Check for spam/inappropriate content
  20. +
+

Moderation Checklist:

+
    +
  • ✓ Message is genuine and relevant
  • +
  • ✓ Screenshot matches claimed action (if provided)
  • +
  • ✓ Email verified (if required by campaign)
  • +
  • ✓ No profanity or inappropriate content
  • +
  • ✓ Not duplicate submission
  • +
+

3. Approve or Reject Response

+

[Screenshot: Response detail drawer with approve/reject buttons]

+

Steps:

+
    +
  1. Click Approve or Reject button
  2. +
  3. Add moderation notes (optional but recommended)
  4. +
  5. Confirm action
  6. +
  7. Response status updated
  8. +
  9. If approved → appears on public response wall
  10. +
  11. If rejected → hidden from public, admin can view
  12. +
+

Code Example (responses.service.ts):

+
async moderateResponse(
+  responseId: string,
+  status: 'APPROVED' | 'REJECTED',
+  moderatorUserId: string,
+  notes?: string
+): Promise<Response> {
+  const response = await this.prisma.response.update({
+    where: { id: responseId },
+    data: {
+      status,
+      moderatedByUserId: moderatorUserId,
+      moderationNotes: notes,
+      moderatedAt: new Date()
+    },
+    include: {
+      campaign: true,
+      moderatedBy: {
+        select: { name: true, email: true }
+      }
+    }
+  });
+
+  // Send notification email if campaign has notifyOnResponse enabled
+  if (status === 'APPROVED' && response.campaign.notifyOnResponse) {
+    await this.emailService.send({
+      to: response.email,
+      subject: `Your response was approved`,
+      template: 'response-approved',
+      variables: {
+        name: response.name,
+        campaignTitle: response.campaign.title,
+        responseWallUrl: `${process.env.FRONTEND_URL}/responses/${response.campaignId}`
+      }
+    });
+  }
+
+  logger.info(`Response ${responseId} ${status} by user ${moderatorUserId}`);
+
+  return response;
+}
+
+

4. Bulk Moderation Actions

+

[Screenshot: ResponsesPage with bulk action toolbar]

+

Steps:

+
    +
  1. Select multiple responses (checkboxes)
  2. +
  3. Click Bulk Actions dropdown
  4. +
  5. Choose action:
  6. +
  7. Approve selected
  8. +
  9. Reject selected
  10. +
  11. Delete selected
  12. +
  13. Confirm bulk action
  14. +
  15. All selected responses updated
  16. +
+

Code Example (ResponsesPage.tsx):

+
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
+
+const handleBulkApprove = async () => {
+  try {
+    await Promise.all(
+      selectedRowKeys.map(id =>
+        api.patch(`/responses/${id}/moderate`, {
+          status: 'APPROVED',
+          notes: 'Bulk approved'
+        })
+      )
+    );
+
+    message.success(`Approved ${selectedRowKeys.length} responses`);
+    setSelectedRowKeys([]);
+    fetchResponses();
+  } catch (error) {
+    message.error('Failed to bulk approve responses');
+  }
+};
+
+const rowSelection = {
+  selectedRowKeys,
+  onChange: setSelectedRowKeys
+};
+
+return (
+  <>
+    {selectedRowKeys.length > 0 && (
+      <Space style={{ marginBottom: 16 }}>
+        <Button onClick={handleBulkApprove}>Approve Selected</Button>
+        <Button onClick={handleBulkReject}>Reject Selected</Button>
+        <Button danger onClick={handleBulkDelete}>Delete Selected</Button>
+      </Space>
+    )}
+
+    <Table rowSelection={rowSelection} dataSource={responses} columns={columns} />
+  </>
+);
+
+

Public Workflow

+

1. Submit Response

+

[Screenshot: Response submission form on ResponseWallPage]

+

User Journey:

+
    +
  1. User completes campaign action (sends email)
  2. +
  3. Clicks Share Your Response link
  4. +
  5. Navigated to /responses/{campaignId}/submit
  6. +
  7. Fills in response form:
  8. +
  9. Response type (dropdown)
  10. +
  11. Name
  12. +
  13. Email
  14. +
  15. Postal code (optional)
  16. +
  17. Message (what they did)
  18. +
  19. Screenshot (optional upload)
  20. +
  21. Clicks Submit Response
  22. +
  23. System saves response as PENDING
  24. +
  25. Verification email sent (if required)
  26. +
+

Code Example (ResponseWallPage.tsx):

+
const handleSubmit = async (values: any) => {
+  try {
+    const formData = new FormData();
+    formData.append('campaignId', campaignId);
+    formData.append('responseType', values.responseType);
+    formData.append('name', values.name);
+    formData.append('email', values.email);
+    formData.append('message', values.message);
+
+    if (values.postalCode) {
+      formData.append('postalCode', values.postalCode);
+    }
+
+    if (values.screenshot?.[0]?.originFileObj) {
+      formData.append('screenshot', values.screenshot[0].originFileObj);
+    }
+
+    await axios.post('/api/public/responses', formData, {
+      headers: { 'Content-Type': 'multipart/form-data' }
+    });
+
+    if (campaign.requireEmailVerification) {
+      message.success('Response submitted! Please check your email to verify.');
+    } else {
+      message.success('Response submitted! It will appear after admin approval.');
+    }
+
+    form.resetFields();
+  } catch (error) {
+    message.error('Failed to submit response');
+  }
+};
+
+return (
+  <Form form={form} onFinish={handleSubmit} layout="vertical">
+    <Form.Item
+      name="responseType"
+      label="What did you do?"
+      rules={[{ required: true }]}
+    >
+      <Select>
+        <Option value="EMAIL">Sent Email</Option>
+        <Option value="LETTER">Sent Letter</Option>
+        <Option value="PHONE_CALL">Made Phone Call</Option>
+        <Option value="MEETING">Attended Meeting</Option>
+        <Option value="SOCIAL_MEDIA">Posted on Social Media</Option>
+        <Option value="OTHER">Other</Option>
+      </Select>
+    </Form.Item>
+
+    <Form.Item
+      name="name"
+      label="Your Name"
+      rules={[{ required: true }]}
+    >
+      <Input />
+    </Form.Item>
+
+    <Form.Item
+      name="email"
+      label="Your Email"
+      rules={[
+        { required: true },
+        { type: 'email' }
+      ]}
+    >
+      <Input />
+    </Form.Item>
+
+    <Form.Item
+      name="message"
+      label="Tell us more"
+      rules={[{ required: true }]}
+    >
+      <Input.TextArea rows={4} placeholder="Describe what you did..." />
+    </Form.Item>
+
+    <Form.Item
+      name="screenshot"
+      label="Upload Screenshot (optional)"
+      valuePropName="fileList"
+      getValueFromEvent={e => Array.isArray(e) ? e : e?.fileList}
+    >
+      <Upload
+        listType="picture"
+        maxCount={1}
+        beforeUpload={() => false}
+      >
+        <Button icon={<UploadOutlined />}>Upload Screenshot</Button>
+      </Upload>
+    </Form.Item>
+
+    <Form.Item>
+      <Button type="primary" htmlType="submit">
+        Submit Response
+      </Button>
+    </Form.Item>
+  </Form>
+);
+
+

2. Verify Email

+

[Screenshot: Email verification success page]

+

User Journey:

+
    +
  1. User receives verification email
  2. +
  3. Clicks verification link
  4. +
  5. Navigated to /api/public/responses/verify/{token}
  6. +
  7. System verifies email
  8. +
  9. Response marked as email verified
  10. +
  11. User redirected to response wall
  12. +
  13. Message: "Email verified! Your response will appear after admin approval."
  14. +
+

Verification Email Template:

+
<h1>Verify Your Response</h1>
+
+<p>Hi {{name}},</p>
+
+<p>Thanks for sharing your response to <strong>{{campaignTitle}}</strong>!</p>
+
+<p>Please verify your email address by clicking the link below:</p>
+
+<p>
+  <a href="{{verificationUrl}}">Verify Email</a>
+</p>
+
+<p>This link will expire in 24 hours.</p>
+
+<p>If you didn't submit this response, you can safely ignore this email.</p>
+
+

3. View Response Wall

+

[Screenshot: Public response wall with approved responses]

+

User Journey:

+
    +
  1. User visits /responses/{campaignId}
  2. +
  3. Sees approved responses
  4. +
  5. Responses sorted by upvotes (most upvoted first)
  6. +
  7. Can upvote responses
  8. +
  9. Can filter by response type
  10. +
+

Code Example (ResponseWallPage.tsx):

+
const [responses, setResponses] = useState<Response[]>([]);
+const [filter, setFilter] = useState<string | null>(null);
+
+useEffect(() => {
+  const fetchResponses = async () => {
+    const params = new URLSearchParams();
+
+    if (filter) {
+      params.set('responseType', filter);
+    }
+
+    const { data } = await axios.get(
+      `/api/public/responses/${campaignId}?${params.toString()}`
+    );
+
+    setResponses(data);
+  };
+
+  fetchResponses();
+}, [campaignId, filter]);
+
+return (
+  <PublicLayout>
+    <Space direction="vertical" size="large" style={{ width: '100%' }}>
+      <Typography.Title level={2}>Response Wall</Typography.Title>
+
+      <Radio.Group value={filter} onChange={e => setFilter(e.target.value)}>
+        <Radio.Button value={null}>All</Radio.Button>
+        <Radio.Button value="EMAIL">Emails</Radio.Button>
+        <Radio.Button value="LETTER">Letters</Radio.Button>
+        <Radio.Button value="PHONE_CALL">Calls</Radio.Button>
+        <Radio.Button value="MEETING">Meetings</Radio.Button>
+        <Radio.Button value="SOCIAL_MEDIA">Social Media</Radio.Button>
+      </Radio.Group>
+
+      <List
+        dataSource={responses}
+        renderItem={response => (
+          <ResponseCard
+            response={response}
+            onUpvote={handleUpvote}
+          />
+        )}
+      />
+    </Space>
+  </PublicLayout>
+);
+
+

4. Upvote Response

+

[Screenshot: Response card with upvote button]

+

User Journey:

+
    +
  1. User clicks upvote button on response
  2. +
  3. System checks for existing upvote (IP + user)
  4. +
  5. If first upvote → increment count, save upvote record
  6. +
  7. If already upvoted → show message "You already upvoted this"
  8. +
  9. Upvote count updated in real-time
  10. +
+

Code Example (responses-public.routes.ts):

+
router.post('/:id/upvote', async (req, res) => {
+  try {
+    const { id } = req.params;
+    const ipAddress = req.ip;
+    const userId = req.user?.id; // If authenticated
+
+    // Check for existing upvote
+    const existingUpvote = await prisma.responseUpvote.findFirst({
+      where: {
+        responseId: id,
+        OR: [
+          { ipAddress },
+          userId ? { userId } : {}
+        ]
+      }
+    });
+
+    if (existingUpvote) {
+      return res.status(400).json({ error: 'You already upvoted this response' });
+    }
+
+    // Create upvote and increment count (transaction)
+    await prisma.$transaction([
+      prisma.responseUpvote.create({
+        data: {
+          responseId: id,
+          ipAddress,
+          userId
+        }
+      }),
+      prisma.response.update({
+        where: { id },
+        data: {
+          upvotes: { increment: 1 }
+        }
+      })
+    ]);
+
+    res.json({ success: true });
+  } catch (error) {
+    logger.error('Failed to upvote response:', error);
+    res.status(500).json({ error: 'Failed to upvote response' });
+  }
+});
+
+

Volunteer Workflow

+

Not applicable — response wall is public-facing.

+

Code Examples

+

Backend: Email Verification Token

+
// api/src/modules/influence/responses/responses.service.ts
+
+import crypto from 'crypto';
+
+async createResponse(data: any): Promise<Response> {
+  const verificationToken = crypto.randomBytes(32).toString('hex');
+
+  const response = await this.prisma.response.create({
+    data: {
+      ...data,
+      status: 'PENDING',
+      isEmailVerified: false,
+      verificationToken,
+      verificationTokenExpires: new Date(Date.now() + 24 * 60 * 60 * 1000) // 24h
+    }
+  });
+
+  // Send verification email
+  await this.emailService.send({
+    to: response.email,
+    subject: 'Verify your response',
+    template: 'response-verification',
+    variables: {
+      name: response.name,
+      campaignTitle: response.campaign.title,
+      verificationUrl: `${process.env.RESPONSE_VERIFICATION_URL}/api/public/responses/verify/${verificationToken}`
+    }
+  });
+
+  return response;
+}
+
+async verifyEmail(token: string): Promise<Response> {
+  const response = await this.prisma.response.findFirst({
+    where: {
+      verificationToken: token,
+      verificationTokenExpires: {
+        gt: new Date()
+      }
+    }
+  });
+
+  if (!response) {
+    throw new Error('Invalid or expired verification token');
+  }
+
+  return this.prisma.response.update({
+    where: { id: response.id },
+    data: {
+      isEmailVerified: true,
+      verificationToken: null,
+      verificationTokenExpires: null
+    }
+  });
+}
+
+

Frontend: Response Card Component

+
// admin/src/components/influence/ResponseCard.tsx
+
+import React from 'react';
+import { Card, Space, Typography, Tag, Button, Avatar } from 'antd';
+import { LikeOutlined, LikeFilled } from '@ant-design/icons';
+import type { Response } from '../../types/api';
+
+interface ResponseCardProps {
+  response: Response;
+  onUpvote: (id: string) => void;
+  hasUpvoted?: boolean;
+}
+
+const ResponseCard: React.FC<ResponseCardProps> = ({
+  response,
+  onUpvote,
+  hasUpvoted
+}) => {
+  const typeColors: Record<string, string> = {
+    EMAIL: 'blue',
+    LETTER: 'green',
+    PHONE_CALL: 'orange',
+    MEETING: 'purple',
+    SOCIAL_MEDIA: 'cyan',
+    OTHER: 'default'
+  };
+
+  const typeLabels: Record<string, string> = {
+    EMAIL: 'Sent Email',
+    LETTER: 'Sent Letter',
+    PHONE_CALL: 'Made Call',
+    MEETING: 'Attended Meeting',
+    SOCIAL_MEDIA: 'Posted on Social Media',
+    OTHER: 'Other Action'
+  };
+
+  return (
+    <Card>
+      <Space direction="vertical" size="small" style={{ width: '100%' }}>
+        <Space>
+          <Avatar>{response.name[0].toUpperCase()}</Avatar>
+          <Space direction="vertical" size={0}>
+            <Typography.Text strong>{response.name}</Typography.Text>
+            <Typography.Text type="secondary">
+              {new Date(response.createdAt).toLocaleDateString()}
+            </Typography.Text>
+          </Space>
+        </Space>
+
+        <Tag color={typeColors[response.responseType]}>
+          {typeLabels[response.responseType]}
+        </Tag>
+
+        <Typography.Paragraph>{response.message}</Typography.Paragraph>
+
+        {response.screenshotUrl && (
+          <img
+            src={response.screenshotUrl}
+            alt="Response screenshot"
+            style={{ maxWidth: '100%', borderRadius: 4 }}
+          />
+        )}
+
+        <Button
+          type="text"
+          icon={hasUpvoted ? <LikeFilled /> : <LikeOutlined />}
+          onClick={() => onUpvote(response.id)}
+          disabled={hasUpvoted}
+        >
+          {response.upvotes} {response.upvotes === 1 ? 'upvote' : 'upvotes'}
+        </Button>
+      </Space>
+    </Card>
+  );
+};
+
+export default ResponseCard;
+
+

Troubleshooting

+

Verification Email Not Received

+

Symptoms: +- User doesn't receive verification email +- Email not in spam folder

+

Solutions:

+
    +
  1. Check email service logs → docker compose logs api | grep "verification"
  2. +
  3. Verify SMTP configuration → test with /api/auth/test-email
  4. +
  5. Check EMAIL_TEST_MODE → if true, email sent to MailHog (localhost:8025)
  6. +
  7. Resend verification email → manual resend via admin UI
  8. +
+

Manual Resend:

+
// Admin UI: ResponsesPage
+const handleResendVerification = async (responseId: string) => {
+  await api.post(`/responses/${responseId}/resend-verification`);
+  message.success('Verification email resent');
+};
+
+

Duplicate Upvotes

+

Symptoms: +- User can upvote same response multiple times +- Upvote count inflated

+

Solutions:

+
    +
  1. Check database constraints → should have unique constraint on responseId, ipAddress
  2. +
  3. Verify transaction → upvote creation and count increment must be atomic
  4. +
  5. Check IP address extraction → ensure req.ip is correct (consider X-Forwarded-For)
  6. +
+

Database Fix:

+
-- Add unique constraint if missing
+ALTER TABLE response_upvotes
+ADD CONSTRAINT unique_response_ip
+UNIQUE (response_id, ip_address);
+
+ALTER TABLE response_upvotes
+ADD CONSTRAINT unique_response_user
+UNIQUE (response_id, user_id)
+WHERE user_id IS NOT NULL;
+
+

Screenshot Upload Fails

+

Symptoms: +- Upload spinner never completes +- Error: "File too large"

+

Solutions:

+
    +
  1. Check file size → max 5MB
  2. +
  3. Verify file format → must be image (jpg/jpeg/png/gif/webp)
  4. +
  5. Check upload directory permissions → /uploads/responses must be writable
  6. +
  7. Increase Nginx upload limit → client_max_body_size 10M;
  8. +
+

Code Fix (responses.service.ts):

+
import sharp from 'sharp';
+
+async uploadScreenshot(
+  file: Express.Multer.File,
+  responseId: string
+): Promise<string> {
+  const uploadDir = `/uploads/responses/${responseId}`;
+  await fs.mkdir(uploadDir, { recursive: true });
+
+  const filename = `${Date.now()}-${file.originalname}`;
+
+  // Optimize image (max 1200px width, 85% quality)
+  await sharp(file.buffer)
+    .resize(1200, null, { withoutEnlargement: true })
+    .jpeg({ quality: 85 })
+    .toFile(`${uploadDir}/${filename}`);
+
+  return `${uploadDir}/${filename}`;
+}
+
+

Performance Considerations

+

Query Optimization

+

Index Strategy:

+
-- Composite index for public wall queries
+CREATE INDEX idx_response_campaign_status
+  ON responses (campaign_id, status)
+  WHERE status = 'APPROVED';
+
+-- Index for sorting by upvotes
+CREATE INDEX idx_response_upvotes
+  ON responses (upvotes DESC);
+
+-- Index for email verification lookups
+CREATE INDEX idx_response_verification_token
+  ON responses (verification_token)
+  WHERE verification_token IS NOT NULL;
+
+

Optimized Public Query:

+
const responses = await prisma.response.findMany({
+  where: {
+    campaignId,
+    status: 'APPROVED',
+    isEmailVerified: true
+  },
+  orderBy: [
+    { upvotes: 'desc' },
+    { createdAt: 'desc' }
+  ],
+  take: 50,
+  skip: page * 50
+});
+
+

Caching Strategy

+

Redis Caching for Response Wall:

+
import { redisClient } from '../../../config/redis';
+
+async getApprovedResponses(campaignId: string): Promise<Response[]> {
+  const cacheKey = `responses:${campaignId}`;
+
+  // Check cache
+  const cached = await redisClient.get(cacheKey);
+  if (cached) {
+    return JSON.parse(cached);
+  }
+
+  // Query database
+  const responses = await prisma.response.findMany({
+    where: {
+      campaignId,
+      status: 'APPROVED',
+      isEmailVerified: true
+    },
+    orderBy: { upvotes: 'desc' }
+  });
+
+  // Cache for 5 minutes
+  await redisClient.setex(cacheKey, 300, JSON.stringify(responses));
+
+  return responses;
+}
+
+// Invalidate cache on moderation
+async moderateResponse(responseId: string, status: string) {
+  const response = await prisma.response.update({
+    where: { id: responseId },
+    data: { status }
+  });
+
+  // Invalidate cache
+  await redisClient.del(`responses:${response.campaignId}`);
+
+  return response;
+}
+
+

Screenshot Optimization

+

Image Processing Pipeline:

+
import sharp from 'sharp';
+
+async optimizeScreenshot(file: Express.Multer.File): Promise<Buffer> {
+  return sharp(file.buffer)
+    .resize(1200, null, {
+      withoutEnlargement: true,
+      fit: 'inside'
+    })
+    .jpeg({
+      quality: 85,
+      progressive: true
+    })
+    .toBuffer();
+}
+
+

CDN Integration:

+
// Upload optimized screenshots to CDN
+import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
+
+const s3 = new S3Client({ region: 'us-east-1' });
+
+async uploadToCDN(buffer: Buffer, key: string): Promise<string> {
+  await s3.send(new PutObjectCommand({
+    Bucket: process.env.S3_BUCKET,
+    Key: `responses/${key}`,
+    Body: buffer,
+    ContentType: 'image/jpeg',
+    CacheControl: 'max-age=31536000' // 1 year
+  }));
+
+  return `${process.env.CDN_URL}/responses/${key}`;
+}
+
+ +

Backend Modules

+ +

Frontend Pages

+ +

Database Models

+ +

Guides

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/landing-pages/index.html b/mkdocs/site/v2/features/landing-pages/index.html new file mode 100644 index 00000000..1256d3bf --- /dev/null +++ b/mkdocs/site/v2/features/landing-pages/index.html @@ -0,0 +1,5446 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Landing Pages (Page Builder) - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Landing Pages (Page Builder)

+

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.

+

Overview

+

The Landing Pages system consists of four integrated components:

+
    +
  1. Page Builder - Page CRUD and management
  2. +
  3. GrapesJS Editor - WYSIWYG editor
  4. +
  5. Block Library - Reusable content blocks
  6. +
  7. MkDocs Export - Export to Jinja2 templates
  8. +
+

Features

+

WYSIWYG Editor

+
    +
  • GrapesJS integration
  • +
  • Drag-and-drop interface
  • +
  • Visual editing
  • +
  • Component customization
  • +
  • CSS styling
  • +
  • Responsive preview
  • +
  • Desktop-only (mobile warning)
  • +
+

Block Library

+

Pre-built components:

+
    +
  • Hero sections - Large header with CTA
  • +
  • Feature grids - Multi-column features
  • +
  • Call-to-action - Button sections
  • +
  • Text blocks - Rich text content
  • +
  • Image galleries - Photo grids
  • +
  • Forms - Contact forms (future)
  • +
  • Custom HTML - Raw HTML blocks
  • +
+

Page Management

+
    +
  • Create/edit/delete pages
  • +
  • Slug management (URL-friendly)
  • +
  • Meta tags (title, description)
  • +
  • Published/draft status
  • +
  • Page settings (layout, scripts)
  • +
  • Search and filtering
  • +
+

MkDocs Export

+
    +
  • Export to Jinja2 Material theme templates
  • +
  • Custom overrides directory
  • +
  • Static page generation
  • +
  • SEO optimization
  • +
  • Template inheritance
  • +
+

User Flow

+

Admin Experience

+
    +
  1. Create Page (/app/pages)
  2. +
  3. Click "New Page"
  4. +
  5. Enter title and slug
  6. +
  7. Set meta description
  8. +
  9. +

    Save draft

    +
  10. +
  11. +

    Edit Page (/app/pages/:id/edit)

    +
  12. +
  13. Full-screen GrapesJS editor
  14. +
  15. Drag blocks from sidebar
  16. +
  17. Customize components
  18. +
  19. Ctrl+S to save
  20. +
  21. +

    Preview changes

    +
  22. +
  23. +

    Publish Page

    +
  24. +
  25. Set status to "Published"
  26. +
  27. Page appears at /p/:slug
  28. +
  29. +

    Listed in page table

    +
  30. +
  31. +

    Export to MkDocs (/app/services/docs)

    +
  32. +
  33. Select pages to export
  34. +
  35. Click "Export"
  36. +
  37. Pages saved to MkDocs overrides
  38. +
  39. Rebuild MkDocs site
  40. +
+

Public Experience

+
    +
  1. View Landing Page (/p/:slug)
  2. +
  3. Rendered HTML/CSS
  4. +
  5. Custom styling
  6. +
  7. Responsive design
  8. +
  9. SEO metadata
  10. +
+

Architecture

+

Backend Components

+

Module: +- api/src/modules/pages/pages-admin.routes.ts - Admin CRUD +- api/src/modules/pages/pages-public.routes.ts - Public renderer +- api/src/modules/pages/blocks.routes.ts - Block library API +- api/src/modules/pages/pages.service.ts - Business logic +- api/src/modules/pages/pages.schemas.ts - Zod validation

+

Database Models: +- Page - Page definitions (title, slug, html, css, settings) +- PageBlock - Reusable block library

+

Frontend Components

+

Admin Pages: +- admin/src/pages/LandingPagesPage.tsx - Page management table +- admin/src/pages/PageEditorPage.tsx - Full-screen editor

+

Public Pages: +- admin/src/pages/public/LandingPage.tsx - Page renderer

+

Editor Component: +- admin/src/components/GrapesJSEditor.tsx - GrapesJS wrapper

+

Configuration

+

Environment Variables

+
# MkDocs export directory (inside Docker)
+MKDOCS_EXPORT_DIR=/mkdocs/docs/overrides
+
+

Page Settings

+

Each page can configure:

+
    +
  • Meta title - Browser title tag
  • +
  • Meta description - SEO description
  • +
  • Custom CSS - Page-specific styles
  • +
  • Custom JS - Page-specific scripts
  • +
  • Layout - Template wrapper (future)
  • +
+

GrapesJS Integration

+

Editor Setup

+
import grapesjs from 'grapesjs';
+
+const editor = grapesjs.init({
+  container: '#gjs',
+  fromElement: true,
+  height: '100vh',
+  storageManager: false,  // Save via API
+  canvas: {
+    styles: [...],         // Custom styles
+    scripts: [...],        // Custom scripts
+  },
+  blockManager: {
+    blocks: customBlocks,  // Block library
+  },
+});
+
+

Custom Blocks

+

Blocks defined in GrapesJSEditor.tsx:

+
const customBlocks = [
+  {
+    id: 'hero-section',
+    label: 'Hero Section',
+    content: '<div class="hero">...</div>',
+    category: 'Basic',
+  },
+  {
+    id: 'feature-grid',
+    label: 'Feature Grid',
+    content: '<div class="features">...</div>',
+    category: 'Content',
+  },
+  // ... more blocks
+];
+
+

Save Handler

+

Ctrl+S keyboard shortcut:

+
editor.on('run:core:save', () => {
+  const html = editor.getHtml();
+  const css = editor.getCss();
+  onSave({ html, css });
+});
+
+

MkDocs Export

+

Export Process

+
    +
  1. Select Pages - Admin selects pages to export
  2. +
  3. Generate Jinja2 - Wrap HTML in Material theme template
  4. +
  5. Save to Overrides - Write to mkdocs/docs/overrides/
  6. +
  7. Configure Front Matter - Set template, title, description
  8. +
  9. Rebuild Site - MkDocs regenerates static site
  10. +
+

Jinja2 Template Wrapper

+
{% extends "main.html" %}
+
+{% block content %}
+<style>
+{{ page_css }}
+</style>
+
+{{ page_html }}
+{% endblock %}
+
+

Front Matter

+
---
+template: custom-page.html
+title: Page Title
+description: Page description for SEO
+---
+
+

Database Schema

+

Page Model

+
model Page {
+  id          Int      @id @default(autoincrement())
+  title       String
+  slug        String   @unique
+  html        String   @db.Text
+  css         String?  @db.Text
+  settings    Json?
+  published   Boolean  @default(false)
+  createdAt   DateTime @default(now())
+  updatedAt   DateTime @updatedAt
+}
+
+

PageBlock Model

+
model PageBlock {
+  id          Int      @id @default(autoincrement())
+  name        String
+  category    String
+  html        String   @db.Text
+  css         String?  @db.Text
+  thumbnail   String?
+  createdAt   DateTime @default(now())
+  updatedAt   DateTime @updatedAt
+}
+
+

API Endpoints

+

Admin Endpoints

+
GET    /api/pages                       # List pages
+POST   /api/pages                       # Create page
+GET    /api/pages/:id                   # Get page
+PATCH  /api/pages/:id                   # Update page
+DELETE /api/pages/:id                   # Delete page
+POST   /api/pages/export-mkdocs         # Export to MkDocs
+GET    /api/pages/blocks                # Get block library
+POST   /api/pages/blocks                # Create block
+
+

Public Endpoints

+
GET    /api/pages/public/:slug          # Get published page by slug
+
+

Desktop-Only Editor

+

GrapesJS editor requires desktop browser:

+
const screens = Grid.useBreakpoint();
+const isMobile = !screens.md;
+
+if (isMobile) {
+  return (
+    <Alert
+      message="Desktop Required"
+      description="Page editor requires desktop browser"
+      type="warning"
+    />
+  );
+}
+
+

Best Practices

+

Slug Management

+
    +
  • Auto-generate from title
  • +
  • URL-friendly (lowercase, hyphens)
  • +
  • Unique constraint
  • +
  • Update URL on slug change
  • +
+

SEO Optimization

+
    +
  • Meta title (50-60 chars)
  • +
  • Meta description (150-160 chars)
  • +
  • Semantic HTML structure
  • +
  • Alt text for images
  • +
  • Heading hierarchy
  • +
+

Performance

+
    +
  • Minify CSS
  • +
  • Lazy load images
  • +
  • Async scripts
  • +
  • Cache rendered pages
  • +
+

Responsive Design

+
    +
  • Mobile-first CSS
  • +
  • Flexible grids
  • +
  • Responsive images
  • +
  • Touch-friendly buttons
  • +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/map/MAP_FEATURES_STATUS/index.html b/mkdocs/site/v2/features/map/MAP_FEATURES_STATUS/index.html new file mode 100644 index 00000000..ae4ad6e7 --- /dev/null +++ b/mkdocs/site/v2/features/map/MAP_FEATURES_STATUS/index.html @@ -0,0 +1,1027 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Map Features Documentation Status - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Map Features Documentation Status

+

Completion Summary

+

Date: 2026-02-13 +Task: Create 9 comprehensive Map feature documentation files +Status: 4/9 COMPLETE (in progress)

+

Completed Files (4053 lines)

+
    +
  1. locations.md (1154 lines) — Location management system
  2. +
  3. Building + unit architecture
  4. +
  5. NAR integration
  6. +
  7. CSV import/export
  8. +
  9. Geocoding integration
  10. +
  11. +

    Multi-provider support

    +
  12. +
  13. +

    geocoding.md (1029 lines) — Multi-provider geocoding service

    +
  14. +
  15. 6 provider fallback chain
  16. +
  17. Confidence scoring
  18. +
  19. Redis caching
  20. +
  21. BullMQ bulk processing
  22. +
  23. +

    Provider health tracking

    +
  24. +
  25. +

    cuts.md (924 lines) — Geographic polygon overlays

    +
  26. +
  27. Polygon drawing workflow
  28. +
  29. GeoJSON storage
  30. +
  31. Point-in-polygon ray-casting
  32. +
  33. Cut categories
  34. +
  35. +

    Completion tracking

    +
  36. +
  37. +

    shifts.md (946 lines) — Volunteer shift management

    +
  38. +
  39. Shift scheduling
  40. +
  41. Capacity management
  42. +
  43. Public signup
  44. +
  45. TEMP user creation
  46. +
  47. Email confirmations
  48. +
+

Remaining Files (5)

+
    +
  1. 🚧 canvassing.md — Canvassing session system
  2. +
  3. Session lifecycle
  4. +
  5. Visit recording
  6. +
  7. Walking route algorithm
  8. +
  9. GPS integration
  10. +
  11. +

    Volunteer + admin workflows

    +
  12. +
  13. +

    🚧 tracking.md — GPS tracking system

    +
  14. +
  15. TrackingSession model
  16. +
  17. TrackPoint recording
  18. +
  19. Distance calculation
  20. +
  21. Route visualization
  22. +
  23. +

    Live volunteer tracking

    +
  24. +
  25. +

    🚧 walk-sheets.md — Printable walk sheets + QR codes

    +
  26. +
  27. MapSettings configuration
  28. +
  29. QR code generation
  30. +
  31. Walk sheet layout
  32. +
  33. Cut export
  34. +
  35. +

    Browser print API

    +
  36. +
  37. +

    🚧 data-quality.md — Geocoding quality dashboard

    +
  38. +
  39. Confidence metrics
  40. +
  41. Provider success rate
  42. +
  43. Ungeocoded locations
  44. +
  45. Low-confidence alerts
  46. +
  47. +

    Duplicate detection

    +
  48. +
  49. +

    🚧 nar-import.md — NAR 2025 electoral data import

    +
  50. +
  51. NAR format support
  52. +
  53. Server-side streaming
  54. +
  55. Address + Location join
  56. +
  57. Lambert coordinate conversion
  58. +
  59. Province code mapping
  60. +
+

Next Steps

+

Continue creating remaining 5 files following the established 12-section structure:

+
    +
  1. Overview
  2. +
  3. Architecture (Mermaid diagram)
  4. +
  5. Database Models
  6. +
  7. API Endpoints
  8. +
  9. Configuration
  10. +
  11. Admin Workflow
  12. +
  13. Public Workflow (if applicable)
  14. +
  15. Volunteer Workflow (if applicable)
  16. +
  17. Code Examples
  18. +
  19. Troubleshooting
  20. +
  21. Performance Considerations
  22. +
  23. Related Documentation
  24. +
+

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)

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/map/canvassing/index.html b/mkdocs/site/v2/features/map/canvassing/index.html new file mode 100644 index 00000000..0c996a09 --- /dev/null +++ b/mkdocs/site/v2/features/map/canvassing/index.html @@ -0,0 +1,6760 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Canvassing - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Canvassing Session System

+

Overview

+

The canvassing system provides a complete door-to-door organizing workflow with GPS tracking, walking route optimization, visit recording, and progress tracking. It enables volunteers to efficiently canvass assigned territories using mobile devices with real-time location updates.

+

Key Capabilities:

+
    +
  • Session Lifecycle: ACTIVE → COMPLETED → ABANDONED (auto-close after 12h)
  • +
  • Walking Route Algorithm: Nearest-neighbor optimization from volunteer GPS position
  • +
  • Visit Recording: 7 outcome types with support level updates
  • +
  • GPS Integration: Live tracking via TrackingSession (1:1 relationship)
  • +
  • Rate Limiting: 30 visits/min per IP to prevent abuse
  • +
  • Progress Tracking: Cut completion percentage auto-calculated
  • +
  • Admin Oversight: Active sessions dashboard, activity feed, leaderboard
  • +
  • Volunteer Portal: Full-screen map with bottom-sheet visit recording
  • +
+

Use Cases:

+
    +
  • Door-to-door canvassing for electoral campaigns
  • +
  • Voter ID (identifying supporter levels)
  • +
  • GOTV (Get Out The Vote) efforts
  • +
  • Sign placement tracking
  • +
  • Petition signature collection
  • +
  • Issue surveys
  • +
  • Volunteer coordination
  • +
+

Architecture

+
graph TD
+    A[Volunteer] -->|Start Session| B[VolunteerMapPage]
+    B -->|POST /api/map/canvass/sessions| C[Canvass Service]
+    C -->|Create| D[(CanvassSession)]
+    C -->|Start GPS| E[Tracking Service]
+    E -->|Create| F[(TrackingSession)]
+
+    B -->|Load Addresses| C
+    C -->|Filter by Cut| G[Spatial Utils]
+    G -->|Point-in-Polygon| H[(Location Model)]
+
+    B -->|Calculate Route| I[Walking Route Service]
+    I -->|Nearest Neighbor| J[Haversine Distance]
+    J -->|Return Route| B
+
+    K[Volunteer GPS] -->|Submit Points| E
+    E -->|Save| L[(TrackPoint)]
+    E -->|Calculate Distance| J
+
+    B -->|Record Visit| C
+    C -->|Create| M[(CanvassVisit)]
+    C -->|Update Address| N[(Address Model)]
+    C -->|Update Progress| D
+
+    O[Admin] -->|View Dashboard| P[CanvassDashboardPage]
+    P -->|GET /api/map/canvass/admin/activity| C
+    C -->|Aggregate Stats| D
+    C -->|Activity Feed| M
+
+    D -->|1:1| F
+    D -->|1:N| M
+    M -->|N:1| N
+
+    style D fill:#e1f5ff
+    style F fill:#e1f5ff
+    style M fill:#e1f5ff
+    style N fill:#e1f5ff
+    style H fill:#e1f5ff
+

Flow Description:

+
    +
  1. Volunteer starts session → Creates CanvassSession + TrackingSession, loads addresses within cut
  2. +
  3. Calculate route → Walking route service uses nearest-neighbor from volunteer GPS position
  4. +
  5. GPS tracking → Auto-submit points every 10s, calculate distance with haversine
  6. +
  7. Record visit → Create CanvassVisit with outcome, update Address support level, update session progress
  8. +
  9. End session → Mark session COMPLETED, end tracking session, calculate final stats
  10. +
  11. Admin oversight → View active sessions, activity feed, cut progress, volunteer leaderboard
  12. +
+

Database Models

+

CanvassSession Model

+

See CanvassSession Model Documentation for full schema.

+

Key Fields:

+
    +
  • userId: Foreign key to volunteer User
  • +
  • cutId: Foreign key to Cut (territory)
  • +
  • shiftId: Optional foreign key to Shift (if started from shift)
  • +
  • status: ACTIVE | COMPLETED | ABANDONED
  • +
  • startedAt: Session start timestamp
  • +
  • endedAt: Session end timestamp (null while active)
  • +
  • totalVisits: Count of CanvassVisit records
  • +
  • completionPercentage: Auto-calculated from cut progress
  • +
+

Status Lifecycle:

+
ACTIVE (session running)
+  ↓ (volunteer ends session)
+COMPLETED
+
+OR
+
+ACTIVE (session running > 12 hours)
+  ↓ (auto-cleanup cron)
+ABANDONED
+
+

CanvassVisit Model

+

See CanvassVisit Model Documentation for full schema.

+

Key Fields:

+
    +
  • sessionId: Foreign key to CanvassSession
  • +
  • userId: Foreign key to volunteer User
  • +
  • addressId: Foreign key to Address (specific unit visited)
  • +
  • outcome: Visit result (7 types)
  • +
  • supportLevel: Updated support level (LEVEL_1-4 or null)
  • +
  • signRequested: Boolean - resident wants lawn/window sign
  • +
  • notes: Free-text canvass notes
  • +
  • visitedAt: Visit timestamp
  • +
  • durationSeconds: Time spent at door (auto-calculated)
  • +
+

Visit Outcome Enum:

+
enum VisitOutcome {
+  NOT_HOME         // Nobody answered door
+  REFUSED          // Refused to speak
+  MOVED            // Resident moved away
+  ALREADY_VOTED    // Already voted (GOTV)
+  SPOKE_WITH       // Had conversation
+  LEFT_LITERATURE  // Left campaign material
+  COME_BACK_LATER  // Asked to return later
+}
+
+

Related Models:

+ +

API Endpoints

+

See Canvass Backend Module Documentation for full API reference.

+

Volunteer Endpoints:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/map/canvass/volunteer/assignmentsAny logged-in userGet shifts with cut assignments
GET/api/map/canvass/volunteer/statsAny logged-in userGet volunteer canvass statistics
GET/api/map/canvass/volunteer/visitsAny logged-in userList own canvass visits with pagination
POST/api/map/canvass/sessionsAny logged-in userStart new canvass session
PATCH/api/map/canvass/sessions/:idAny logged-in userUpdate session (end session)
GET/api/map/canvass/sessions/:id/addressesAny logged-in userGet addresses within session cut
POST/api/map/canvass/sessions/:id/routeAny logged-in userCalculate walking route
POST/api/map/canvass/visitsAny logged-in userRecord single visit
POST/api/map/canvass/visits/bulkAny logged-in userRecord multiple visits (batch)
PATCH/api/map/canvass/volunteer/locations/:idAny logged-in userUpdate location from canvass
+

Admin Endpoints:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/map/canvass/admin/activityMAP_ADMINGet recent canvass activity feed
GET/api/map/canvass/admin/sessionsMAP_ADMINList active canvass sessions
GET/api/map/canvass/admin/visitsMAP_ADMINList all canvass visits with filters
GET/api/map/canvass/admin/progressMAP_ADMINGet cut completion progress
GET/api/map/canvass/admin/leaderboardMAP_ADMINGet volunteer visit leaderboard
+

Configuration

+

Environment Variables

+ + + + + + + + + + + + + + + + + + + + + + + +
VariableTypeDefaultDescription
CANVASS_SESSION_TIMEOUT_HOURSnumber12Auto-abandon active sessions after N hours
CANVASS_VISIT_RATE_LIMITnumber30Max visits per minute per IP
+

Rate Limiting

+

Visit Recording Rate Limit:

+
    +
  • Limit: 30 visits/min per IP address
  • +
  • Window: 60 seconds
  • +
  • Redis Prefix: rl:canvass-visit:
  • +
  • Purpose: Prevent accidental bulk submissions from GPS auto-submit bugs
  • +
+

Session Auto-Cleanup

+

Abandoned Session Detection:

+

System automatically marks sessions as ABANDONED if:

+
    +
  • Status: ACTIVE
  • +
  • Started: >12 hours ago
  • +
  • No Activity: No visits in last hour
  • +
+

Cleanup Schedule:

+
    +
  • Startup: On API server startup (check all active sessions)
  • +
  • Cron: Every hour at :00 (setInterval)
  • +
+
// api/src/server.ts
+setInterval(async () => {
+  await canvassService.cleanupAbandonedSessions();
+}, 60 * 60 * 1000); // 1 hour
+
+

Admin Workflow

+

Viewing Active Sessions

+

Step 1: Navigate to Canvass Dashboard

+

Navigate to Map → Canvass Dashboard in the admin sidebar.

+

![CanvassDashboardPage Screenshot Placeholder]

+

Step 2: View Active Sessions

+

Active Sessions card displays:

+
    +
  • Volunteer Name: Who is canvassing
  • +
  • Cut Name: Territory being canvassed
  • +
  • Start Time: When session started
  • +
  • Duration: Time elapsed (live updating)
  • +
  • Visits: Number of visits recorded
  • +
  • Status: ACTIVE badge
  • +
+

Step 3: View Session Details

+

Click session row to view:

+
    +
  • Volunteer GPS Location: Last known position on map
  • +
  • Route: Walking route polyline
  • +
  • Visited Addresses: Green markers
  • +
  • Unvisited Addresses: Blue markers
  • +
  • Recent Visits: Last 10 visits with outcomes
  • +
+

Monitoring Canvass Activity

+

Step 1: View Activity Feed

+

Recent Activity section displays:

+
    +
  • Volunteer Name: Who recorded visit
  • +
  • Address: Location visited
  • +
  • Outcome: Visit result (icon + label)
  • +
  • Support Level: Updated support level (color-coded)
  • +
  • Time Ago: "5 minutes ago"
  • +
+

Step 2: Filter Activity

+

Use filters:

+
    +
  • Date Range: Last hour / day / week / month
  • +
  • Outcome: Filter by specific outcome type
  • +
  • Volunteer: Filter by volunteer name
  • +
  • Cut: Filter by territory
  • +
+

Step 3: Export Activity

+

Click Export CSV to download activity feed for reporting.

+

Tracking Cut Completion

+

Step 1: View Cut Progress

+

Cut Progress card displays:

+
    +
  • Cut Name: Territory name
  • +
  • Total Addresses: Count of addresses in cut
  • +
  • Visited: Count of addresses with CanvassVisit records
  • +
  • Completion: Percentage (progress bar)
  • +
  • Last Activity: Time since last visit
  • +
+

Step 2: View Detailed Progress

+

Click cut row to view:

+
    +
  • Address List: All addresses in cut with visit status
  • +
  • Visit Heatmap: Map showing visited (green) vs unvisited (blue)
  • +
  • Outcome Breakdown: Pie chart of visit outcomes
  • +
  • Volunteer Breakdown: Who visited which addresses
  • +
+

Volunteer Leaderboard

+

Step 1: View Leaderboard

+

Leaderboard card displays:

+
    +
  • Rank: 1st, 2nd, 3rd place
  • +
  • Volunteer Name: Volunteer name
  • +
  • Total Visits: Visit count
  • +
  • Doors/Hour: Efficiency metric
  • +
  • Top Outcome: Most common outcome
  • +
+

Step 2: Filter by Time Period

+

Toggle time period:

+
    +
  • Today: Visits since midnight
  • +
  • This Week: Visits since Monday
  • +
  • This Month: Visits since 1st of month
  • +
  • All Time: Total visits
  • +
+

Volunteer Workflow

+

Starting a Canvass Session

+

Step 1: Login

+

Login at /login with volunteer account (or use TEMP account from shift signup).

+

Step 2: View Assignments

+

Navigate to Volunteer → My Assignments.

+

Step 3: Select Shift

+

Click Start Canvass on a shift with cut assignment.

+

Step 4: Grant GPS Permission

+

Browser requests geolocation permission. Click Allow.

+

Step 5: Start Session

+

System redirects to /volunteer/canvass/:cutId (full-screen map).

+

System will:

+
    +
  1. Create CanvassSession (status=ACTIVE)
  2. +
  3. Create TrackingSession (linked 1:1)
  4. +
  5. Load addresses within cut polygon
  6. +
  7. Calculate walking route from current GPS position
  8. +
  9. Start GPS auto-tracking (submit points every 10s)
  10. +
+

Following Walking Route

+

Step 1: View Route on Map

+

Map displays:

+
    +
  • Blue Polyline: Optimized walking route
  • +
  • Blue Markers: Unvisited addresses (ordered by route)
  • +
  • Green Markers: Visited addresses
  • +
  • Red Marker: Current GPS position (live updating)
  • +
+

Step 2: Navigate to First Address

+

Follow route to nearest unvisited address. Route recalculates when you move.

+

Step 3: View Address Details

+

Tap marker to view:

+
    +
  • Address: Street address + unit number
  • +
  • Resident Name: First/Last name (if available)
  • +
  • Support Level: Previous support level (if available)
  • +
  • Last Visit: Previous visit outcome + date (if applicable)
  • +
+

Recording a Visit

+

Step 1: Knock on Door

+

Approach address and knock/ring doorbell.

+

Step 2: Open Visit Recording Form

+

Tap Record Visit button in bottom toolbar. Bottom sheet slides up.

+

Step 3: Select Outcome

+

Choose visit outcome:

+
    +
  • Not Home: Nobody answered
  • +
  • Refused: Refused to speak
  • +
  • Moved: Resident moved away
  • +
  • Already Voted: Already voted (GOTV campaigns)
  • +
  • Spoke With: Had conversation
  • +
  • Left Literature: Left campaign material
  • +
  • Come Back Later: Asked to return later
  • +
+

Step 4: Update Support Level (if applicable)

+

For "Spoke With" outcome, select support level:

+
    +
  • Level 1 (Strong): Green badge
  • +
  • Level 2 (Leaning): Yellow badge
  • +
  • Level 3 (Undecided): Gray badge
  • +
  • Level 4 (Opposed): Red badge
  • +
+

Step 5: Sign Request (optional)

+

Toggle Sign Requested if resident wants lawn/window sign.

+

Step 6: Add Notes (optional)

+

Enter free-text notes (e.g., "Asked about healthcare policy", "Concerned about taxes").

+

Step 7: Save Visit

+

Tap Save Visit. System will:

+
    +
  1. Create CanvassVisit record with outcome + timestamp
  2. +
  3. Update Address with new support level + sign status + notes
  4. +
  5. Increment session.totalVisits count
  6. +
  7. Update cut.completionPercentage
  8. +
  9. Create LocationHistory audit record
  10. +
  11. Submit GPS trackpoint with eventType=VISIT_RECORDED
  12. +
  13. Update marker to green (visited)
  14. +
  15. Recalculate walking route (exclude visited address)
  16. +
+

Ending a Canvass Session

+

Step 1: Finish Route

+

Complete visits for all addresses (or as many as possible).

+

Step 2: End Session

+

Tap End Session button in header.

+

Step 3: Confirm

+

Confirmation modal displays session summary:

+
    +
  • Duration: Total session time
  • +
  • Visits: Number of visits recorded
  • +
  • Distance: Total distance walked (from GPS tracking)
  • +
  • Doors/Hour: Efficiency metric
  • +
+

Step 4: Submit

+

Tap End Session. System will:

+
    +
  1. Update CanvassSession (status=COMPLETED, endedAt=now)
  2. +
  3. End TrackingSession (isActive=false, endedAt=now)
  4. +
  5. Calculate final stats (totalVisits, totalDistanceM)
  6. +
  7. Redirect to /volunteer/activity (visit history page)
  8. +
+

Viewing Visit History

+

Step 1: Navigate to My Activity

+

Navigate to Volunteer → My Activity.

+

Step 2: View Visit List

+

Table displays:

+
    +
  • Address: Location visited
  • +
  • Outcome: Visit result (icon + label)
  • +
  • Support Level: Updated support level
  • +
  • Visit Date: Formatted date/time
  • +
  • Notes: Canvassing notes (truncated)
  • +
+

Step 3: Filter Visits

+

Use filters:

+
    +
  • Date Range: Last week / month / all time
  • +
  • Outcome: Filter by specific outcome
  • +
  • Support Level: Filter by support level
  • +
+

Step 4: View Session History

+

Navigate to My Routes to view:

+
    +
  • Session List: Past canvass sessions
  • +
  • Session Map: GPS route polyline + visited markers
  • +
  • Session Stats: Duration, visits, distance, doors/hour
  • +
+

Code Examples

+

Start Canvass Session (Backend)

+
// api/src/modules/map/canvass/canvass.service.ts
+async startSession(userId: string, data: StartSessionInput) {
+  const { cutId, shiftId, startLat, startLng } = data;
+
+  // Check for existing active session
+  const existing = await prisma.canvassSession.findFirst({
+    where: { userId, status: CanvassSessionStatus.ACTIVE },
+  });
+
+  if (existing) {
+    throw new AppError(400, 'Already have an active session', 'SESSION_ACTIVE');
+  }
+
+  // Create session + tracking session in transaction
+  const session = await prisma.$transaction(async (tx) => {
+    const canvassSession = await tx.canvassSession.create({
+      data: {
+        userId,
+        cutId,
+        shiftId,
+        status: CanvassSessionStatus.ACTIVE,
+      },
+    });
+
+    if (startLat && startLng) {
+      await tx.trackingSession.create({
+        data: {
+          userId,
+          canvassSessionId: canvassSession.id,
+          lastLatitude: new Prisma.Decimal(startLat),
+          lastLongitude: new Prisma.Decimal(startLng),
+          lastRecordedAt: new Date(),
+        },
+      });
+    }
+
+    return canvassSession;
+  });
+
+  setActiveCanvassSessions(
+    await prisma.canvassSession.count({
+      where: { status: CanvassSessionStatus.ACTIVE },
+    })
+  );
+
+  return session;
+}
+
+

Calculate Walking Route (Backend)

+
// api/src/modules/map/canvass/canvass-route.service.ts
+export function calculateWalkingRoute(
+  locations: RouteLocation[],
+  startLat?: number,
+  startLng?: number,
+  cutGeojson?: string,
+): RouteResult {
+  if (locations.length === 0) {
+    return { orderedLocations: [], totalDistanceMeters: 0, estimatedMinutes: 0 };
+  }
+
+  // Determine starting point
+  let currentLat: number;
+  let currentLng: number;
+
+  if (startLat !== undefined && startLng !== undefined) {
+    currentLat = startLat;
+    currentLng = startLng;
+  } else if (cutGeojson) {
+    const polygons = parseGeoJsonPolygon(cutGeojson);
+    const centroid = calculateCentroid(polygons[0]!);
+    currentLat = centroid.lat;
+    currentLng = centroid.lng;
+  } else {
+    // Use first location as starting point
+    currentLat = locations[0]!.latitude;
+    currentLng = locations[0]!.longitude;
+  }
+
+  const remaining = [...locations];
+  const ordered: RouteLocation[] = [];
+  let totalDistance = 0;
+
+  // Nearest-neighbor algorithm
+  while (remaining.length > 0) {
+    let nearestIdx = 0;
+    let nearestDist = Infinity;
+
+    for (let i = 0; i < remaining.length; i++) {
+      const loc = remaining[i]!;
+      const dist = haversineDistance(currentLat, currentLng, loc.latitude, loc.longitude);
+      if (dist < nearestDist) {
+        nearestDist = dist;
+        nearestIdx = i;
+      }
+    }
+
+    const nearest = remaining.splice(nearestIdx, 1)[0]!;
+    ordered.push(nearest);
+    totalDistance += nearestDist;
+    currentLat = nearest.latitude;
+    currentLng = nearest.longitude;
+  }
+
+  const WALKING_SPEED_MPS = 5000 / 60; // 5 km/h in m/min
+  const MINUTES_PER_DOOR = 2;
+  const walkingMinutes = totalDistance / WALKING_SPEED_MPS;
+  const doorMinutes = ordered.length * MINUTES_PER_DOOR;
+  const estimatedMinutes = Math.round(walkingMinutes + doorMinutes);
+
+  return {
+    orderedLocations: ordered,
+    totalDistanceMeters: Math.round(totalDistance),
+    estimatedMinutes,
+  };
+}
+
+

Record Visit (Backend)

+
// api/src/modules/map/canvass/canvass.service.ts
+async recordVisit(userId: string, data: RecordVisitInput) {
+  const { sessionId, addressId, outcome, supportLevel, signRequested, notes } = data;
+
+  // Verify session ownership and active status
+  const session = await prisma.canvassSession.findFirst({
+    where: { id: sessionId, userId, status: CanvassSessionStatus.ACTIVE },
+  });
+
+  if (!session) {
+    throw new AppError(404, 'Active session not found', 'SESSION_NOT_FOUND');
+  }
+
+  // Create visit + update address in transaction
+  const visit = await prisma.$transaction(async (tx) => {
+    const canvassVisit = await tx.canvassVisit.create({
+      data: {
+        sessionId,
+        userId,
+        addressId,
+        outcome,
+        supportLevel,
+        signRequested: signRequested ?? false,
+        notes,
+      },
+    });
+
+    // Update address with new data
+    if (supportLevel || signRequested !== undefined || notes) {
+      await tx.address.update({
+        where: { id: addressId },
+        data: {
+          ...(supportLevel && { supportLevel }),
+          ...(signRequested !== undefined && { sign: signRequested }),
+          ...(notes && { notes }),
+        },
+      });
+    }
+
+    // Increment session visit count
+    await tx.canvassSession.update({
+      where: { id: sessionId },
+      data: { totalVisits: { increment: 1 } },
+    });
+
+    // Update cut completion percentage
+    if (session.cutId) {
+      const cutId = session.cutId;
+      const totalAddresses = await tx.address.count({
+        where: {
+          location: {
+            // Point-in-polygon query omitted for brevity
+          },
+        },
+      });
+
+      const visitedAddresses = await tx.canvassVisit.count({
+        where: {
+          session: { cutId },
+        },
+      });
+
+      const completionPercentage = Math.round((visitedAddresses / totalAddresses) * 100);
+
+      await tx.cut.update({
+        where: { id: cutId },
+        data: { completionPercentage },
+      });
+    }
+
+    return canvassVisit;
+  });
+
+  recordCanvassVisit(outcome);
+
+  return visit;
+}
+
+

GPS Auto-Tracking (Frontend)

+
// admin/src/components/canvass/GPSTracker.tsx
+useEffect(() => {
+  if (!session || !geolocationEnabled) return;
+
+  const watchId = navigator.geolocation.watchPosition(
+    (position) => {
+      const point = {
+        latitude: position.coords.latitude,
+        longitude: position.coords.longitude,
+        accuracy: position.coords.accuracy,
+        recordedAt: new Date().toISOString(),
+      };
+
+      // Add to local buffer
+      setPointsBuffer((prev) => [...prev, point]);
+
+      // Update current position
+      setCurrentPosition([point.latitude, point.longitude]);
+    },
+    (error) => {
+      console.error('GPS error:', error);
+      message.error('GPS tracking failed');
+    },
+    {
+      enableHighAccuracy: true,
+      maximumAge: 0,
+      timeout: 10000,
+    }
+  );
+
+  // Submit buffered points every 10 seconds
+  const interval = setInterval(async () => {
+    if (pointsBuffer.length === 0) return;
+
+    try {
+      await api.post(`/map/tracking/sessions/${trackingSessionId}/points`, {
+        points: pointsBuffer,
+      });
+
+      setPointsBuffer([]);
+    } catch (error) {
+      console.error('Failed to submit GPS points:', error);
+    }
+  }, 10000);
+
+  return () => {
+    navigator.geolocation.clearWatch(watchId);
+    clearInterval(interval);
+  };
+}, [session, geolocationEnabled, pointsBuffer, trackingSessionId]);
+
+

Visit Recording Form (Frontend)

+
// admin/src/components/canvass/VisitRecordingForm.tsx
+const handleSubmit = async (values: any) => {
+  try {
+    await api.post('/map/canvass/visits', {
+      sessionId: session.id,
+      addressId: selectedAddress.id,
+      outcome: values.outcome,
+      supportLevel: values.supportLevel,
+      signRequested: values.signRequested,
+      notes: values.notes,
+    });
+
+    message.success('Visit recorded');
+    form.resetFields();
+    onVisitRecorded();
+  } catch (error) {
+    message.error('Failed to record visit');
+  }
+};
+
+

Troubleshooting

+

Issue: Walking Route Not Optimal

+

Symptoms:

+
    +
  • Route backtracks frequently
  • +
  • Total distance much longer than expected
  • +
  • Route doesn't start from volunteer GPS position
  • +
+

Causes:

+
    +
  • Nearest-neighbor algorithm is greedy (not globally optimal)
  • +
  • Starting position not provided (defaults to cut centroid)
  • +
  • GPS accuracy poor (volunteer position inaccurate)
  • +
+

Solutions:

+
    +
  1. Use volunteer GPS position as start:
  2. +
+
// Always pass volunteer GPS position to route calculation
+const route = await calculateWalkingRoute(
+  locations,
+  currentLat,
+  currentLng,
+  cut.geojson
+);
+
+
    +
  1. Consider alternative algorithms:
  2. +
+

For better optimization, use 2-opt or genetic algorithms (computationally expensive):

+
// Install optimization library
+npm install routing-js
+
+// Use 2-opt algorithm
+import { twoOpt } from 'routing-js';
+const optimized = twoOpt(locations, distanceMatrix);
+
+
    +
  1. Pre-optimize routes for shifts:
  2. +
+

Admin can pre-calculate optimal routes and assign to volunteers:

+
// Calculate route once, store in Shift model
+const route = await calculateWalkingRoute(locations);
+await prisma.shift.update({
+  where: { id: shiftId },
+  data: { preCalculatedRoute: JSON.stringify(route) },
+});
+
+

Issue: Session Auto-Abandoned Prematurely

+

Symptoms:

+
    +
  • Active session marked ABANDONED while volunteer still canvassing
  • +
  • Session timeout after <12 hours
  • +
  • Volunteer can't record visits after timeout
  • +
+

Causes:

+
    +
  • CANVASS_SESSION_TIMEOUT_HOURS set too low
  • +
  • Volunteer paused for lunch/break (no activity for >1 hour)
  • +
  • System clock drift
  • +
+

Solutions:

+
    +
  1. Increase timeout:
  2. +
+
# In .env
+CANVASS_SESSION_TIMEOUT_HOURS=24  # Was 12, increase to 24
+
+
    +
  1. Record "heartbeat" visits:
  2. +
+

Add periodic "still active" ping to prevent timeout:

+
// Volunteer app sends heartbeat every 30 minutes
+setInterval(async () => {
+  await api.post(`/map/canvass/sessions/${sessionId}/heartbeat`);
+}, 30 * 60 * 1000);
+
+
    +
  1. Allow session resumption:
  2. +
+

Let volunteers resume ABANDONED sessions:

+
// Backend: Add resume endpoint
+async resumeSession(userId: string, sessionId: string) {
+  const session = await prisma.canvassSession.findFirst({
+    where: { id: sessionId, userId, status: CanvassSessionStatus.ABANDONED },
+  });
+
+  if (!session) {
+    throw new AppError(404, 'Abandoned session not found', 'SESSION_NOT_FOUND');
+  }
+
+  return prisma.canvassSession.update({
+    where: { id: sessionId },
+    data: { status: CanvassSessionStatus.ACTIVE },
+  });
+}
+
+

Issue: GPS Tracking Draining Battery

+

Symptoms:

+
    +
  • Volunteer phone battery drains rapidly
  • +
  • Phone overheats during canvassing
  • +
  • GPS tracking fails after 2-3 hours
  • +
+

Causes:

+
    +
  • enableHighAccuracy uses GPS + WiFi + cellular (power-hungry)
  • +
  • Watchposition submits too frequently (every second)
  • +
  • Screen stays on during entire session
  • +
+

Solutions:

+
    +
  1. Reduce GPS accuracy:
  2. +
+
navigator.geolocation.watchPosition(
+  callback,
+  errorCallback,
+  {
+    enableHighAccuracy: false, // Use WiFi/cellular only (less accurate but lower power)
+    maximumAge: 5000,          // Cache position for 5s
+    timeout: 30000,            // Longer timeout
+  }
+);
+
+
    +
  1. Reduce submission frequency:
  2. +
+
// Submit GPS points every 30s instead of 10s
+const SUBMIT_INTERVAL_MS = 30000; // Was 10000
+
+
    +
  1. Pause tracking during breaks:
  2. +
+

Add "Pause Tracking" button to stop GPS watchPosition:

+
const pauseTracking = () => {
+  navigator.geolocation.clearWatch(watchId);
+  setTrackingPaused(true);
+};
+
+const resumeTracking = () => {
+  // Start watchPosition again
+  setTrackingPaused(false);
+};
+
+

Performance Considerations

+

Visit Recording Rate Limiting

+

Prevent Abuse:

+

Rate limit prevents accidental bulk submissions:

+
// api/src/middleware/rate-limit.ts
+const canvassVisitLimiter = new RateLimiterRedis({
+  storeClient: redis,
+  keyPrefix: 'rl:canvass-visit:',
+  points: 30,      // 30 visits
+  duration: 60,    // per 60 seconds
+  blockDuration: 300, // block for 5 minutes if exceeded
+});
+
+

Legitimate Use Cases:

+
    +
  • Bulk data entry: Admin can bypass rate limit for importing historical data
  • +
  • Offline sync: Mobile app queues visits offline, submits when online (batch endpoint)
  • +
+

Session Cleanup Performance

+

Efficient Abandoned Session Query:

+
-- Index for abandoned session cleanup
+CREATE INDEX idx_canvass_sessions_abandoned ON "CanvassSession" ("status", "startedAt")
+WHERE status = 'ACTIVE';
+
+-- Efficient query
+SELECT id FROM "CanvassSession"
+WHERE status = 'ACTIVE'
+  AND "startedAt" < NOW() - INTERVAL '12 hours';
+
+

Cut Completion Calculation

+

Avoid N+1 Queries:

+
// Inefficient: query per cut
+for (const cut of cuts) {
+  const visited = await prisma.canvassVisit.count({
+    where: { session: { cutId: cut.id } },
+  });
+  const total = await getAddressesInCut(cut.id).length;
+  cut.completionPercentage = (visited / total) * 100;
+}
+
+// Efficient: single aggregation query
+const completionStats = await prisma.canvassSession.groupBy({
+  by: ['cutId'],
+  where: { status: CanvassSessionStatus.COMPLETED },
+  _count: { visits: true },
+});
+
+ +

Backend Modules:

+ +

Frontend Pages:

+ +

Database:

+ +

Features:

+
    +
  • Cuts — Territory boundaries for canvassing
  • +
  • Shifts — Shift-based canvass scheduling
  • +
  • Tracking — GPS tracking system
  • +
  • Locations — Address management
  • +
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/map/cuts/index.html b/mkdocs/site/v2/features/map/cuts/index.html new file mode 100644 index 00000000..87db77a7 --- /dev/null +++ b/mkdocs/site/v2/features/map/cuts/index.html @@ -0,0 +1,6602 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cuts - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Geographic Polygon Overlays (Cuts)

+

Overview

+

The cuts system provides polygon-based geographic organizing using customizable map overlays. Cuts enable campaigns to divide territories into canvassing zones, track completion progress, and assign volunteers to specific areas.

+

Key Capabilities:

+
    +
  • Polygon Drawing: Click-to-draw custom polygons on Leaflet maps
  • +
  • GeoJSON Storage: Store complex polygons with coordinate precision
  • +
  • Spatial Queries: Point-in-polygon filtering using ray-casting algorithm
  • +
  • Cut Categories: CUSTOM, WARD, NEIGHBORHOOD, DISTRICT classification
  • +
  • Visual Customization: Configurable colors and opacity for map overlays
  • +
  • Bounds Calculation: Auto-calculate bounding box from polygon coordinates
  • +
  • Completion Tracking: Track canvassing progress by cut
  • +
  • Shift Assignment: Link shifts to cuts for volunteer scheduling
  • +
  • Export Filtering: Generate walk sheets for specific cuts
  • +
+

Use Cases:

+
    +
  • Electoral district mapping (wards, polling divisions)
  • +
  • Canvassing zone organization
  • +
  • Neighborhood targeting
  • +
  • Volunteer territory assignment
  • +
  • Walk sheet generation by area
  • +
  • Progress tracking by geographic zone
  • +
  • Multi-volunteer coordination
  • +
+

Architecture

+
graph TD
+    A[Admin User] -->|Draws Polygon| B[CutDrawingMode]
+    B -->|Click Vertices| C[Leaflet Map]
+    C -->|Auto-Close Detection| D[GeoJSON Polygon]
+    D -->|POST /api/map/cuts| E[Cuts Service]
+    E -->|Calculate Bounds| F[Spatial Utils]
+    F -->|Save| G[(Cut Model)]
+
+    H[Public Map] -->|Load Cuts| I[GET /api/public/map/cuts]
+    I -->|Return GeoJSON| E
+    E -->|Query| G
+    I -->|Render| J[CutOverlays Component]
+
+    K[Canvass Session] -->|Start in Cut| L[Canvass Service]
+    L -->|Load Addresses| M[Locations Service]
+    M -->|Point-in-Polygon| F
+    F -->|Filter| N[(Location Model)]
+
+    O[Shift] -->|Assigned to Cut| G
+    G -->|1:N| O
+
+    P[Export Locations] -->|Filter by Cut| M
+    M -->|Query Polygon| F
+
+    style G fill:#e1f5ff
+    style N fill:#e1f5ff
+    style O fill:#e1f5ff
+

Flow Description:

+
    +
  1. Admin draws cut → Click vertices on map, auto-close detection, generate GeoJSON
  2. +
  3. Save cut → Calculate bounds from coordinates, store polygon in database
  4. +
  5. Public map loads → Query public cuts, render as colored overlays with opacity
  6. +
  7. Canvass session starts → Load addresses within cut polygon using ray-casting
  8. +
  9. Shift assignment → Link shift to cut for volunteer scheduling
  10. +
  11. Export locations → Filter by cut polygon to generate walk sheet
  12. +
+

Database Models

+

Cut Model

+

See Cut Model Documentation for full schema.

+

Key Fields:

+
    +
  • name: Cut display name (e.g., "Ward 5 - Downtown")
  • +
  • description: Free-text notes about the cut
  • +
  • geojson: Polygon coordinates in GeoJSON format (TEXT field)
  • +
  • bounds: Auto-calculated bounding box {minLat, maxLat, minLng, maxLng} (JSON)
  • +
  • color: Hex color for map overlay (default: #3498db)
  • +
  • opacity: Opacity 0.0-1.0 for map rendering (default: 0.3)
  • +
  • category: CUSTOM | WARD | NEIGHBORHOOD | DISTRICT
  • +
  • isPublic: Show on public map
  • +
  • isOfficial: Official electoral boundary (prevents accidental deletion)
  • +
  • showLocations: Show location markers within cut on map
  • +
  • exportEnabled: Allow walk sheet export for this cut
  • +
  • assignedTo: Free-text assigned volunteer/team name
  • +
  • completionPercentage: Auto-calculated canvassing progress (0-100)
  • +
+

GeoJSON Format:

+
{
+  "type": "Polygon",
+  "coordinates": [
+    [
+      [-75.6972, 45.4215],
+      [-75.6980, 45.4220],
+      [-75.6960, 45.4230],
+      [-75.6950, 45.4225],
+      [-75.6972, 45.4215]
+    ]
+  ]
+}
+
+

Bounds Format:

+
{
+  "minLat": 45.4215,
+  "maxLat": 45.4230,
+  "minLng": -75.6980,
+  "maxLng": -75.6950
+}
+
+

Related Models:

+ +

API Endpoints

+

See Cuts Backend Module Documentation for full API reference.

+

Admin Endpoints:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/map/cutsMAP_ADMINList cuts with pagination, search, category filter
GET/api/map/cuts/statsMAP_ADMINGet cut statistics (total, by category)
GET/api/map/cuts/:idMAP_ADMINGet cut details
POST/api/map/cutsMAP_ADMINCreate new cut with polygon
PATCH/api/map/cuts/:idMAP_ADMINUpdate cut
DELETE/api/map/cuts/:idMAP_ADMINDelete cut (blocked if isOfficial=true)
GET/api/map/cuts/:id/locationsMAP_ADMINGet locations within cut polygon
GET/api/map/cuts/:id/progressMAP_ADMINGet canvassing progress for cut
+

Public Endpoints:

+ + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/public/map/cutsNoneList public cuts (isPublic=true)
GET/api/public/map/cuts/:idNoneGet public cut details
+

Configuration

+

Environment Variables

+

No specific environment variables for cuts. Uses standard database and map settings.

+

Cut Category Enum

+
enum CutCategory {
+  CUSTOM       // User-defined boundary
+  WARD         // Municipal ward boundary
+  NEIGHBORHOOD // Neighborhood association boundary
+  DISTRICT     // Electoral district boundary
+}
+
+

Default Values

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDefaultDescription
color#3498dbBlue color for overlay
opacity0.330% opacity (transparent)
isPublicfalseHidden from public map
isOfficialfalseCan be deleted by admin
showLocationstrueShow location markers within cut
exportEnabledtrueAllow walk sheet export
completionPercentage0Auto-updated by canvass service
+

Admin Workflow

+

Creating a Cut

+

Step 1: Navigate to Cuts Page

+

Navigate to Map → Cuts in the admin sidebar.

+

![CutsPage Screenshot Placeholder]

+

Step 2: Open Drawing Tab

+

Click Drawing tab to switch to map drawing mode.

+

Step 3: Activate Drawing Mode

+

Click Draw Cut button in the map controls. Map cursor changes to crosshair.

+

Step 4: Click Vertices

+

Click on the map to place polygon vertices:

+
    +
  • First Click: Start polygon
  • +
  • Additional Clicks: Add vertices
  • +
  • Auto-Close: When cursor near start point (within 10px), polygon auto-closes
  • +
+

Step 5: Configure Cut

+

Fill in the cut form (right sidebar):

+
    +
  • Name: "Ward 5 - Downtown"
  • +
  • Description: "Central business district and residential blocks"
  • +
  • Category: WARD
  • +
  • Color: Choose from color picker (default: blue)
  • +
  • Opacity: Slider 0-100 (default: 30%)
  • +
  • Is Public: Toggle to show on public map
  • +
  • Is Official: Toggle to prevent accidental deletion
  • +
+

Step 6: Save Cut

+

Click Save Cut. The system will:

+
    +
  1. Generate GeoJSON from vertices
  2. +
  3. Calculate bounding box
  4. +
  5. Save to database
  6. +
  7. Render polygon on map with configured color/opacity
  8. +
+

Editing a Cut

+

Step 1: Select Cut

+

On Table tab, click Edit button for a cut.

+

Step 2: Update Fields

+

Modify cut properties:

+
    +
  • Name/Description: Update text fields
  • +
  • Color/Opacity: Adjust visual appearance
  • +
  • Category: Change classification
  • +
  • Public/Official: Toggle flags
  • +
+

Step 3: Re-Draw Polygon (Optional)

+

To change polygon shape:

+
    +
  1. Switch to Drawing tab
  2. +
  3. Click Edit Cut button
  4. +
  5. Delete old vertices (click vertices to remove)
  6. +
  7. Add new vertices
  8. +
  9. Auto-close polygon
  10. +
+

Step 4: Save Changes

+

Click Update to save changes. Bounds are auto-recalculated if polygon changed.

+

Viewing Locations in Cut

+

Step 1: Select Cut

+

Click cut row in table to select.

+

Step 2: Click "View Locations"

+

Click View Locations button.

+

Step 3: View Filtered Table

+

System displays locations within cut polygon:

+
    +
  • Point-in-Polygon: Uses ray-casting algorithm to filter
  • +
  • Count: Number of locations within cut
  • +
  • Support Breakdown: Count by support level
  • +
+

Step 4: Export Locations

+

Click Export CSV to download locations for walk sheet generation.

+

Assigning Cut to Shift

+

Step 1: Create/Edit Shift

+

On Map → Shifts page, create or edit a shift.

+

Step 2: Select Cut

+

In shift form, choose cut from Cut dropdown.

+

Step 3: Save Shift

+

Shift is now linked to cut. Volunteers will see cut name on shift details.

+

Tracking Cut Completion

+

Step 1: View Cut Progress

+

On CutsPage, click Progress button for a cut.

+

Step 2: View Metrics

+

System displays:

+
    +
  • Completion Percentage: Auto-calculated from canvass visits
  • +
  • Total Addresses: Count of addresses within cut
  • +
  • Visited: Count of addresses with CanvassVisit records
  • +
  • Outstanding: Remaining addresses to visit
  • +
+

Step 3: View Canvass Activity

+

Table shows recent canvass visits within cut:

+
    +
  • Volunteer Name: Who visited
  • +
  • Visit Date: When visited
  • +
  • Outcome: Visit result (SPOKE_WITH, NOT_HOME, etc.)
  • +
  • Support Level: Updated support level (if applicable)
  • +
+

Public Workflow

+

Public users can view cut overlays on the interactive map.

+

Step 1: Navigate to Public Map

+

Visit /map (no authentication required).

+

Step 2: Toggle Cut Overlays

+

Click Cuts button in map controls to open overlay panel.

+

Step 3: Select Cuts

+

Check/uncheck cuts to show/hide on map:

+
    +
  • Color Legend: Shows cut name and color
  • +
  • Opacity: Semi-transparent overlays don't obscure markers
  • +
  • Multiple Cuts: Show multiple cuts simultaneously
  • +
+

Step 4: View Cut Details

+

Click on a cut polygon to view:

+
    +
  • Cut Name: Displayed in popup
  • +
  • Category: Ward, Neighborhood, etc.
  • +
  • Assigned To: Volunteer/team name (if configured)
  • +
+

Volunteer Workflow

+

Volunteers interact with cuts via shift assignments.

+

Step 1: View Assigned Shifts

+

On Volunteer → My Assignments page, view shifts with cut assignments.

+

Step 2: Start Canvass Session

+

Click Start Canvass on a shift. Redirects to /volunteer/canvass/:cutId.

+

Step 3: View Cut on Map

+

Full-screen map shows:

+
    +
  • Cut Polygon: Highlighted boundary
  • +
  • Locations Within Cut: Filtered to cut polygon only
  • +
  • Walking Route: Optimal route through cut locations
  • +
+

See Canvassing Documentation for full volunteer workflow.

+

Code Examples

+

Cut Service Create (Backend)

+
// api/src/modules/map/cuts/cuts.service.ts
+import { parseGeoJsonPolygon, calculateBounds } from '../../../utils/spatial';
+
+async create(data: CreateCutInput, userId: string) {
+  // Auto-calculate bounds from geojson if not provided
+  let boundsStr = data.bounds;
+  if (!boundsStr) {
+    try {
+      const rings = parseGeoJsonPolygon(data.geojson);
+      const allCoords = rings.flat();
+      const bounds = calculateBounds(allCoords);
+      boundsStr = JSON.stringify(bounds);
+    } catch {
+      // Bounds calculation optional
+    }
+  }
+
+  const cut = await prisma.cut.create({
+    data: {
+      name: data.name,
+      description: data.description,
+      color: data.color,
+      opacity: data.opacity,
+      category: data.category,
+      isPublic: data.isPublic,
+      isOfficial: data.isOfficial,
+      geojson: data.geojson,
+      bounds: boundsStr,
+      showLocations: data.showLocations,
+      exportEnabled: data.exportEnabled,
+      assignedTo: data.assignedTo,
+      createdByUserId: userId,
+    },
+  });
+
+  return cut;
+}
+
+

Bounds Calculation (Backend)

+
// api/src/utils/spatial.ts
+export function calculateBounds(coordinates: number[][]): {
+  minLat: number;
+  maxLat: number;
+  minLng: number;
+  maxLng: number;
+} {
+  let minLat = Infinity;
+  let maxLat = -Infinity;
+  let minLng = Infinity;
+  let maxLng = -Infinity;
+
+  for (const coord of coordinates) {
+    const lng = coord[0]!;
+    const lat = coord[1]!;
+    if (lat < minLat) minLat = lat;
+    if (lat > maxLat) maxLat = lat;
+    if (lng < minLng) minLng = lng;
+    if (lng > maxLng) maxLng = lng;
+  }
+
+  return { minLat, maxLat, minLng, maxLng };
+}
+
+

Point-in-Polygon Filter (Backend)

+
// api/src/modules/map/cuts/cuts.service.ts
+import { isPointInPolygon, parseGeoJsonPolygon } from '../../../utils/spatial';
+
+async getLocationsInCut(cutId: string) {
+  const cut = await prisma.cut.findUnique({
+    where: { id: cutId },
+    select: { geojson: true },
+  });
+
+  if (!cut?.geojson) {
+    throw new AppError(404, 'Cut not found', 'CUT_NOT_FOUND');
+  }
+
+  // Get all locations (or use bounds for optimization)
+  const locations = await prisma.location.findMany({
+    select: {
+      id: true,
+      latitude: true,
+      longitude: true,
+      address: true,
+    },
+  });
+
+  // Parse polygon coordinates
+  const polygons = parseGeoJsonPolygon(cut.geojson);
+
+  // Filter locations using ray-casting algorithm
+  const filtered = locations.filter((loc) => {
+    const lat = Number(loc.latitude);
+    const lng = Number(loc.longitude);
+    return polygons.some((poly) => isPointInPolygon(lat, lng, poly));
+  });
+
+  return filtered;
+}
+
+

Ray-Casting Algorithm (Backend)

+
// api/src/utils/spatial.ts
+export function isPointInPolygon(
+  lat: number,
+  lng: number,
+  polygonCoords: number[][]
+): boolean {
+  let inside = false;
+  for (let i = 0, j = polygonCoords.length - 1; i < polygonCoords.length; j = i++) {
+    const xi = polygonCoords[i]![1]!; // lat
+    const yi = polygonCoords[i]![0]!; // lng
+    const xj = polygonCoords[j]![1]!;
+    const yj = polygonCoords[j]![0]!;
+
+    const intersect = ((yi > lng) !== (yj > lng)) &&
+      (lat < (xj - xi) * (lng - yi) / (yj - yi) + xi);
+    if (intersect) inside = !inside;
+  }
+  return inside;
+}
+
+

Cut Drawing Mode (Frontend)

+
// admin/src/components/map/CutDrawingMode.tsx
+import { useState, useEffect } from 'react';
+import { useMapEvents } from 'react-leaflet';
+import type { LatLng } from 'leaflet';
+
+interface CutDrawingModeProps {
+  onPolygonComplete: (vertices: LatLng[]) => void;
+}
+
+export default function CutDrawingMode({ onPolygonComplete }: CutDrawingModeProps) {
+  const [vertices, setVertices] = useState<LatLng[]>([]);
+  const [isDrawing, setIsDrawing] = useState(true);
+
+  useMapEvents({
+    click(e) {
+      if (!isDrawing) return;
+
+      const newVertex = e.latlng;
+
+      // Auto-close detection: if click near first vertex (within 10px)
+      if (vertices.length >= 3) {
+        const firstVertex = vertices[0]!;
+        const map = e.target;
+        const firstPoint = map.latLngToContainerPoint(firstVertex);
+        const newPoint = map.latLngToContainerPoint(newVertex);
+        const distance = Math.sqrt(
+          Math.pow(firstPoint.x - newPoint.x, 2) +
+          Math.pow(firstPoint.y - newPoint.y, 2)
+        );
+
+        if (distance < 10) {
+          // Auto-close polygon
+          setIsDrawing(false);
+          onPolygonComplete(vertices);
+          return;
+        }
+      }
+
+      // Add vertex
+      setVertices([...vertices, newVertex]);
+    },
+  });
+
+  return (
+    <>
+      {/* Render temporary polygon while drawing */}
+      {vertices.length >= 2 && (
+        <Polygon positions={vertices} pathOptions={{ color: '#3498db', opacity: 0.5 }} />
+      )}
+      {/* Render vertex markers */}
+      {vertices.map((v, i) => (
+        <CircleMarker
+          key={i}
+          center={v}
+          radius={5}
+          pathOptions={{ color: '#e74c3c', fillColor: '#e74c3c', fillOpacity: 1 }}
+        />
+      ))}
+    </>
+  );
+}
+
+

Cut Overlays Rendering (Frontend)

+
// admin/src/components/map/CutOverlays.tsx
+import { Polygon, Popup } from 'react-leaflet';
+import type { Cut } from '@/types/api';
+
+interface CutOverlaysProps {
+  cuts: Cut[];
+  visibleCutIds: string[];
+}
+
+export default function CutOverlays({ cuts, visibleCutIds }: CutOverlaysProps) {
+  return (
+    <>
+      {cuts
+        .filter((cut) => visibleCutIds.includes(cut.id))
+        .map((cut) => {
+          const geojson = JSON.parse(cut.geojson);
+          // GeoJSON uses [lng, lat], Leaflet uses [lat, lng]
+          const positions = geojson.coordinates[0].map(([lng, lat]: number[]) => [lat, lng]);
+
+          return (
+            <Polygon
+              key={cut.id}
+              positions={positions}
+              pathOptions={{
+                color: cut.color,
+                fillColor: cut.color,
+                fillOpacity: cut.opacity,
+                weight: 2,
+              }}
+            >
+              <Popup>
+                <div>
+                  <strong>{cut.name}</strong>
+                  <br />
+                  {cut.category}
+                  {cut.assignedTo && (
+                    <>
+                      <br />
+                      Assigned to: {cut.assignedTo}
+                    </>
+                  )}
+                </div>
+              </Popup>
+            </Polygon>
+          );
+        })}
+    </>
+  );
+}
+
+

Convert Leaflet Polygon to GeoJSON (Frontend)

+
// admin/src/pages/CutsPage.tsx
+const handleSaveCut = async (vertices: LatLng[]) => {
+  // Convert Leaflet [lat, lng] to GeoJSON [lng, lat]
+  const coordinates = vertices.map((v) => [v.lng, v.lat]);
+
+  // Close polygon (first vertex === last vertex)
+  coordinates.push(coordinates[0]!);
+
+  const geojson = {
+    type: 'Polygon',
+    coordinates: [coordinates],
+  };
+
+  try {
+    const { data } = await api.post<Cut>('/map/cuts', {
+      name: cutName,
+      description: cutDescription,
+      geojson: JSON.stringify(geojson),
+      color: cutColor,
+      opacity: cutOpacity,
+      category: cutCategory,
+      isPublic: isPublic,
+      isOfficial: isOfficial,
+    });
+
+    message.success('Cut created');
+    fetchCuts();
+  } catch (error) {
+    message.error('Failed to create cut');
+  }
+};
+
+

Troubleshooting

+

Issue: Polygon Not Closing

+

Symptoms:

+
    +
  • Clicking near start point doesn't auto-close polygon
  • +
  • Polygon remains open after many vertices
  • +
  • "Save Cut" button disabled
  • +
+

Causes:

+
    +
  • Auto-close distance threshold too small
  • +
  • Mouse click precision issues on mobile
  • +
  • Map zoom level affecting pixel distance calculation
  • +
+

Solutions:

+
    +
  1. Increase auto-close threshold:
  2. +
+
// admin/src/components/map/CutDrawingMode.tsx
+const AUTO_CLOSE_DISTANCE_PX = 15; // Was 10, increase to 15
+
+if (distance < AUTO_CLOSE_DISTANCE_PX) {
+  // Auto-close polygon
+}
+
+
    +
  1. Manual close button:
  2. +
+

Add explicit "Close Polygon" button for mobile users:

+
<Button onClick={() => {
+  if (vertices.length >= 3) {
+    onPolygonComplete(vertices);
+  }
+}}>
+  Close Polygon
+</Button>
+
+

Issue: Point-in-Polygon Returns Wrong Results

+

Symptoms:

+
    +
  • Locations outside cut polygon included in canvass session
  • +
  • Locations inside cut polygon excluded
  • +
  • Export CSV missing locations
  • +
+

Causes:

+
    +
  • Coordinate order mismatch (GeoJSON [lng, lat] vs Leaflet [lat, lng])
  • +
  • Polygon not properly closed (first vertex !== last vertex)
  • +
  • Ray-casting algorithm bug with edge cases
  • +
+

Solutions:

+
    +
  1. Verify coordinate order:
  2. +
+
// GeoJSON uses [lng, lat]
+const geojson = {
+  type: 'Polygon',
+  coordinates: [
+    [
+      [-75.6972, 45.4215], // [lng, lat]
+      [-75.6980, 45.4220],
+      // ...
+    ]
+  ]
+};
+
+// Leaflet uses [lat, lng]
+<Polygon positions={[[45.4215, -75.6972], [45.4220, -75.6980]]} />
+
+
    +
  1. Verify polygon closure:
  2. +
+
-- Check if polygon is properly closed
+SELECT id, name,
+  geojson::json->'coordinates'->0->0 as first_vertex,
+  geojson::json->'coordinates'->0->-1 as last_vertex
+FROM "Cut"
+WHERE id = 'YOUR_CUT_ID';
+
+-- First and last should be identical
+
+
    +
  1. Test with known points:
  2. +
+
# Test point-in-polygon directly
+curl -X POST http://localhost:4000/api/map/cuts/YOUR_CUT_ID/test-point \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"latitude":45.4220,"longitude":-75.6975}'
+
+

Issue: Cut Rendering Performance Slow

+

Symptoms:

+
    +
  • Map lags when rendering multiple cuts
  • +
  • Browser freezes with >10 cuts visible
  • +
  • Polygon rendering takes >2 seconds
  • +
+

Causes:

+
    +
  • Too many polygon vertices (complex boundaries)
  • +
  • Multiple cut overlays rendered simultaneously
  • +
  • No polygon simplification
  • +
+

Solutions:

+
    +
  1. Simplify complex polygons:
  2. +
+

Use Turf.js simplify algorithm to reduce vertices:

+
import * as turf from '@turf/turf';
+
+const simplified = turf.simplify(polygon, {
+  tolerance: 0.0001, // Adjust based on zoom level
+  highQuality: true
+});
+
+
    +
  1. Lazy render cuts:
  2. +
+

Only render cuts within current map bounds:

+
const visibleCuts = cuts.filter((cut) => {
+  const bounds = JSON.parse(cut.bounds);
+  const mapBounds = map.getBounds();
+  return mapBounds.intersects([
+    [bounds.minLat, bounds.minLng],
+    [bounds.maxLat, bounds.maxLng]
+  ]);
+});
+
+
    +
  1. Use Canvas renderer:
  2. +
+

For large polygons, use Leaflet Canvas renderer instead of SVG:

+
<Polygon
+  positions={positions}
+  renderer={L.canvas()}
+  pathOptions={{ color: cut.color }}
+/>
+
+

Performance Considerations

+

Spatial Query Optimization

+

Bounds Pre-Filter:

+

Always pre-filter by bounding box before point-in-polygon:

+
async getLocationsInCut(cutId: string) {
+  const cut = await prisma.cut.findUnique({ where: { id: cutId } });
+  const bounds = JSON.parse(cut.bounds);
+
+  // Pre-filter by bounds (fast, uses index)
+  const candidates = await prisma.location.findMany({
+    where: {
+      latitude: {
+        gte: new Prisma.Decimal(bounds.minLat),
+        lte: new Prisma.Decimal(bounds.maxLat),
+      },
+      longitude: {
+        gte: new Prisma.Decimal(bounds.minLng),
+        lte: new Prisma.Decimal(bounds.maxLng),
+      },
+    },
+  });
+
+  // Then apply point-in-polygon (slower, but fewer candidates)
+  const polygons = parseGeoJsonPolygon(cut.geojson);
+  return candidates.filter((loc) => {
+    const lat = Number(loc.latitude);
+    const lng = Number(loc.longitude);
+    return polygons.some((poly) => isPointInPolygon(lat, lng, poly));
+  });
+}
+
+

Performance Impact:

+
    +
  • Without bounds pre-filter: 10,000 locations → 10,000 point-in-polygon checks
  • +
  • With bounds pre-filter: 10,000 locations → 500 candidates → 500 point-in-polygon checks (20x faster)
  • +
+

Polygon Simplification

+

Reduce Vertices for Large Cuts:

+

Use Douglas-Peucker algorithm to simplify polygons while preserving shape:

+
import * as turf from '@turf/turf';
+
+function simplifyPolygon(geojson: string, tolerance: number = 0.0001): string {
+  const polygon = JSON.parse(geojson);
+  const simplified = turf.simplify(polygon, { tolerance, highQuality: true });
+  return JSON.stringify(simplified);
+}
+
+// Usage: simplify when importing official boundaries (e.g., electoral districts)
+const simplifiedGeojson = simplifyPolygon(officialBoundary, 0.0005);
+
+

Tolerance Guidelines:

+
    +
  • 0.00001: High precision (±1m), use for small neighborhoods
  • +
  • 0.0001: Medium precision (±10m), use for wards
  • +
  • 0.001: Low precision (±100m), use for large districts
  • +
+

Caching Cut Queries

+

Cache Frequently Used Cuts:

+
// Cache cut polygons in Redis for fast repeated queries
+const CACHE_KEY = `CUT_POLYGON:${cutId}`;
+const cached = await redis.get(CACHE_KEY);
+
+if (cached) {
+  return JSON.parse(cached);
+}
+
+const cut = await prisma.cut.findUnique({ where: { id: cutId } });
+await redis.setex(CACHE_KEY, 3600, JSON.stringify(cut)); // 1 hour TTL
+
+return cut;
+
+ +

Backend Modules:

+ +

Frontend Pages:

+ +

Database:

+ +

Features:

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/map/data-quality/index.html b/mkdocs/site/v2/features/map/data-quality/index.html new file mode 100644 index 00000000..5a903385 --- /dev/null +++ b/mkdocs/site/v2/features/map/data-quality/index.html @@ -0,0 +1,7906 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Data Quality - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Data Quality Dashboard

+

Overview

+

The Data Quality Dashboard provides comprehensive monitoring and management of geocoding accuracy and location data integrity. This feature enables campaign administrators to identify and resolve data quality issues, track geocoding provider performance, and ensure reliable map data for canvassing operations.

+

Key Features:

+
    +
  • Real-time geocoding quality metrics
  • +
  • Provider success rate tracking
  • +
  • Low-confidence location detection
  • +
  • Duplicate location identification
  • +
  • Bulk re-geocoding operations
  • +
  • Address validation reporting
  • +
  • Interactive quality charts
  • +
  • Export quality reports
  • +
+

Use Cases:

+
    +
  • Monthly data quality audits
  • +
  • NAR import validation
  • +
  • Geocoding provider evaluation
  • +
  • Pre-canvass data verification
  • +
  • Address database cleanup
  • +
  • Campaign planning accuracy checks
  • +
+

Architecture Highlights:

+
    +
  • Aggregate statistics via database queries
  • +
  • Confidence threshold filtering (0-100 scale)
  • +
  • Provider performance comparison
  • +
  • Duplicate detection via coordinate matching
  • +
  • Manual review workflows
  • +
  • Prometheus metrics integration
  • +
+

Architecture

+
flowchart TB
+    subgraph Admin Interface
+        Admin[Admin User]
+        Dashboard[DataQualityDashboardPage]
+        LocationsPage[LocationsPage]
+    end
+
+    subgraph API Layer
+        StatsAPI["/api/locations/geocode-stats"]
+        LocationsAPI["/api/locations"]
+        DuplicatesAPI["/api/locations/duplicates"]
+        RegeocodeAPI["/api/locations/:id/regeocode"]
+        BulkGeocodeAPI["/api/locations/bulk-geocode"]
+    end
+
+    subgraph Database
+        LocationsDB[(Locations)]
+        Indexes[(Indexes)]
+    end
+
+    subgraph Geocoding Service
+        GeocodingService[GeocodingService]
+        Providers[6 Providers]
+        Cache[Redis Cache]
+    end
+
+    subgraph Monitoring
+        Prometheus[Prometheus]
+        Metrics[cm_locations_low_confidence_count]
+    end
+
+    Admin --> Dashboard
+    Admin --> LocationsPage
+
+    Dashboard --> StatsAPI
+    Dashboard --> LocationsAPI
+    Dashboard --> DuplicatesAPI
+    LocationsPage --> RegeocodeAPI
+    LocationsPage --> BulkGeocodeAPI
+
+    StatsAPI --> LocationsDB
+    LocationsAPI --> LocationsDB
+    DuplicatesAPI --> LocationsDB
+    RegeocodeAPI --> GeocodingService
+    BulkGeocodeAPI --> GeocodingService
+
+    LocationsDB --> Indexes
+    GeocodingService --> Providers
+    GeocodingService --> Cache
+
+    StatsAPI --> Prometheus
+    Prometheus --> Metrics
+

Data Flow:

+
    +
  1. Statistics Aggregation:
  2. +
  3. Query all locations with geocoding metadata
  4. +
  5. Calculate aggregate metrics (total, geocoded %, avg confidence)
  6. +
  7. Group by provider for success rate comparison
  8. +
  9. Identify low-confidence locations (< 50)
  10. +
  11. +

    Detect duplicates via coordinate matching

    +
  12. +
  13. +

    Quality Review:

    +
  14. +
  15. Admin views dashboard statistics
  16. +
  17. Filters low-confidence locations
  18. +
  19. Reviews individual location details
  20. +
  21. +

    Identifies patterns (provider failures, address format issues)

    +
  22. +
  23. +

    Remediation:

    +
  24. +
  25. Manual address correction
  26. +
  27. Single location re-geocoding
  28. +
  29. Bulk re-geocoding with different provider
  30. +
  31. +

    Duplicate merging or marking

    +
  32. +
  33. +

    Monitoring:

    +
  34. +
  35. Prometheus metrics track quality trends
  36. +
  37. Alert rules trigger for quality degradation
  38. +
  39. Grafana dashboards visualize provider performance
  40. +
+

Database Models

+

Location Model

+
model Location {
+  id          Int      @id @default(autoincrement())
+  address     String
+  latitude    Float?
+  longitude   Float?
+  postalCode  String?
+  province    String?
+
+  // Geocoding metadata
+  geocodeConfidence Int?        // 0-100 quality score
+  geocodeProvider   String?     // Provider used for geocoding
+  geocodedAt        DateTime?   // Timestamp of last geocode
+
+  // NAR import fields
+  locGuid           String?  @unique
+  federalDistrict   String?
+  buildingUse       Int?     // 1 = Residential
+
+  addresses   Address[]
+
+  createdAt   DateTime @default(now())
+  updatedAt   DateTime @updatedAt
+
+  @@index([geocodeConfidence])
+  @@index([geocodeProvider])
+  @@index([latitude, longitude])
+  @@index([latitude, longitude], where: latitude IS NOT NULL AND longitude IS NOT NULL)
+}
+
+

Geocode Confidence Scale: +- 0-20: Very Low (manual review required) +- 21-40: Low (likely incorrect, re-geocode recommended) +- 41-60: Medium (acceptable but consider verification) +- 61-80: Good (likely accurate) +- 81-100: Excellent (high confidence)

+

Geocode Provider Enum: +

enum GeocodeProvider {
+  GOOGLE = 'GOOGLE',
+  MAPBOX = 'MAPBOX',
+  NOMINATIM = 'NOMINATIM',
+  PHOTON = 'PHOTON',
+  LOCATIONIQ = 'LOCATIONIQ',
+  ARCGIS = 'ARCGIS',
+  UNKNOWN = 'UNKNOWN'
+}
+

+

Address Model

+
model Address {
+  id         Int      @id @default(autoincrement())
+  locationId Int
+  location   Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
+
+  unitNumber   String?
+  firstName    String?
+  lastName     String?
+  supportLevel Int?
+  notes        String?
+
+  // Address validation
+  isValidated  Boolean  @default(false)
+  validatedAt  DateTime?
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@index([locationId])
+}
+
+

API Endpoints

+

GET /api/locations/geocode-stats

+

Fetch aggregate geocoding quality statistics.

+

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

+

Response: +

{
+  "total": 1500,
+  "geocoded": 1450,
+  "geocodedPercent": 96.67,
+  "avgConfidence": 78.5,
+  "providerBreakdown": {
+    "GOOGLE": 800,
+    "MAPBOX": 350,
+    "NOMINATIM": 200,
+    "PHOTON": 100,
+    "ARCGIS": 0,
+    "LOCATIONIQ": 0,
+    "UNKNOWN": 50
+  },
+  "confidenceDistribution": {
+    "0-20": 15,
+    "21-40": 35,
+    "41-60": 150,
+    "61-80": 450,
+    "81-100": 800
+  },
+  "lowConfidenceCount": 50,
+  "missingCoordinates": 50,
+  "duplicatesCount": 12
+}
+

+

Implementation:

+
// locations.service.ts
+async getGeocodeStats() {
+  const locations = await prisma.location.findMany({
+    select: {
+      latitude: true,
+      longitude: true,
+      geocodeConfidence: true,
+      geocodeProvider: true
+    }
+  });
+
+  const total = locations.length;
+  const geocoded = locations.filter(l => l.latitude && l.longitude).length;
+  const avgConfidence = locations.reduce((sum, l) =>
+    sum + (l.geocodeConfidence || 0), 0) / total;
+
+  const providerBreakdown = locations.reduce((acc, l) => {
+    const provider = l.geocodeProvider || 'UNKNOWN';
+    acc[provider] = (acc[provider] || 0) + 1;
+    return acc;
+  }, {} as Record<string, number>);
+
+  const confidenceDistribution = {
+    '0-20': 0,
+    '21-40': 0,
+    '41-60': 0,
+    '61-80': 0,
+    '81-100': 0
+  };
+
+  locations.forEach(l => {
+    const conf = l.geocodeConfidence || 0;
+    if (conf <= 20) confidenceDistribution['0-20']++;
+    else if (conf <= 40) confidenceDistribution['21-40']++;
+    else if (conf <= 60) confidenceDistribution['41-60']++;
+    else if (conf <= 80) confidenceDistribution['61-80']++;
+    else confidenceDistribution['81-100']++;
+  });
+
+  const lowConfidenceCount = locations.filter(l =>
+    (l.geocodeConfidence || 0) < 50).length;
+
+  return {
+    total,
+    geocoded,
+    geocodedPercent: (geocoded / total) * 100,
+    avgConfidence,
+    providerBreakdown,
+    confidenceDistribution,
+    lowConfidenceCount,
+    missingCoordinates: total - geocoded,
+    duplicatesCount: await this.countDuplicates()
+  };
+}
+
+

GET /api/locations?geocodeConfidence=lt:50

+

Fetch locations filtered by geocode confidence.

+

Authentication: Required

+

Query Parameters: +- geocodeConfidence (filter): lt:X, gt:X, eq:X, null +- geocodeProvider (filter): Provider name (GOOGLE, MAPBOX, etc.) +- page (optional): Page number (default: 1) +- limit (optional): Results per page (default: 50) +- sortBy (optional): Field to sort by (default: "geocodeConfidence") +- order (optional): "asc" or "desc" (default: "asc")

+

Examples:

+
GET /api/locations?geocodeConfidence=lt:50
+GET /api/locations?geocodeConfidence=null
+GET /api/locations?geocodeProvider=NOMINATIM&geocodeConfidence=lt:70
+GET /api/locations?geocodeConfidence=gt:80&sortBy=address
+
+

Response: +

{
+  "data": [
+    {
+      "id": 1001,
+      "address": "123 Main St",
+      "latitude": 43.6532,
+      "longitude": -79.3832,
+      "postalCode": "M5H 2N2",
+      "geocodeConfidence": 45,
+      "geocodeProvider": "NOMINATIM",
+      "geocodedAt": "2025-02-10T10:00:00Z",
+      "addresses": [...]
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 50,
+    "total": 150,
+    "pages": 3
+  }
+}
+

+

GET /api/locations/duplicates

+

Identify locations with identical coordinates.

+

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

+

Query Parameters: +- threshold (optional): Distance threshold in meters (default: 1, matches exact duplicates)

+

Response: +

{
+  "duplicates": [
+    {
+      "coordinates": {
+        "latitude": 43.6532,
+        "longitude": -79.3832
+      },
+      "count": 3,
+      "locations": [
+        {
+          "id": 1001,
+          "address": "123 Main St",
+          "postalCode": "M5H 2N2"
+        },
+        {
+          "id": 1002,
+          "address": "123 Main Street",
+          "postalCode": "M5H 2N2"
+        },
+        {
+          "id": 1003,
+          "address": "123 Main St, Unit 1",
+          "postalCode": "M5H 2N2"
+        }
+      ]
+    }
+  ],
+  "total": 12
+}
+

+

Implementation:

+
// locations.service.ts
+async findDuplicates(thresholdMeters: number = 1) {
+  const locations = await prisma.location.findMany({
+    where: {
+      AND: [
+        { latitude: { not: null } },
+        { longitude: { not: null } }
+      ]
+    },
+    select: {
+      id: true,
+      address: true,
+      latitude: true,
+      longitude: true,
+      postalCode: true
+    }
+  });
+
+  const coordMap = new Map<string, typeof locations>();
+
+  locations.forEach(loc => {
+    // Round to 6 decimal places (~0.1m precision)
+    const key = `${loc.latitude!.toFixed(6)},${loc.longitude!.toFixed(6)}`;
+    if (!coordMap.has(key)) {
+      coordMap.set(key, []);
+    }
+    coordMap.get(key)!.push(loc);
+  });
+
+  const duplicates = Array.from(coordMap.entries())
+    .filter(([_, locs]) => locs.length > 1)
+    .map(([coords, locs]) => {
+      const [lat, lng] = coords.split(',').map(Number);
+      return {
+        coordinates: { latitude: lat, longitude: lng },
+        count: locs.length,
+        locations: locs
+      };
+    });
+
+  return {
+    duplicates,
+    total: duplicates.reduce((sum, dup) => sum + dup.count, 0)
+  };
+}
+
+

POST /api/locations/:id/regeocode

+

Re-geocode a single location with specified provider.

+

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

+

Request Body: +

{
+  "provider": "GOOGLE",
+  "address": "123 Main St, Toronto ON M5H 2N2"
+}
+

+

Parameters: +- provider (optional): Specific provider to use (default: fallback chain) +- address (optional): Override address string (default: use existing)

+

Response: +

{
+  "id": 1001,
+  "address": "123 Main St",
+  "latitude": 43.6532,
+  "longitude": -79.3832,
+  "geocodeConfidence": 95,
+  "geocodeProvider": "GOOGLE",
+  "geocodedAt": "2025-02-13T10:30:00Z"
+}
+

+

POST /api/locations/bulk-geocode

+

Bulk re-geocode multiple locations.

+

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

+

Request Body: +

{
+  "locationIds": [1001, 1002, 1003],
+  "provider": "GOOGLE",
+  "confidenceThreshold": 50
+}
+

+

Parameters: +- locationIds (optional): Specific location IDs (default: all with confidence < threshold) +- provider (optional): Specific provider to use (default: fallback chain) +- confidenceThreshold (optional): Only re-geocode locations below this confidence (default: 50)

+

Response: +

{
+  "jobId": "bulk-geocode-20250213-103000",
+  "status": "queued",
+  "total": 150,
+  "message": "Bulk geocoding job started"
+}
+

+

Job Progress Endpoint: +

GET /api/locations/bulk-geocode/:jobId
+

+

Job Status Response: +

{
+  "jobId": "bulk-geocode-20250213-103000",
+  "status": "processing",
+  "progress": {
+    "total": 150,
+    "processed": 75,
+    "successful": 70,
+    "failed": 5,
+    "percent": 50
+  },
+  "startedAt": "2025-02-13T10:30:00Z",
+  "estimatedCompletion": "2025-02-13T10:35:00Z"
+}
+

+

Configuration

+

Environment Variables

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableTypeDefaultDescription
GEOCODE_CONFIDENCE_THRESHOLDnumber50Minimum confidence for acceptable geocoding
GEOCODE_PRIMARY_PROVIDERstringGOOGLEPrimary geocoding provider
GEOCODE_FALLBACK_PROVIDERSstringMAPBOX,NOMINATIMComma-separated fallback providers
GEOCODE_CACHE_TTLnumber2592000Cache TTL in seconds (30 days)
+

Quality Thresholds

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricWarningCriticalDescription
Geocoded %< 95%< 90%Percentage of locations with coordinates
Avg Confidence< 70< 60Average geocode confidence score
Low Confidence Count> 50> 100Locations with confidence < 50
Duplicates> 20> 50Locations with identical coordinates
Missing Coordinates> 5%> 10%Locations without lat/lng
+

Prometheus Metrics

+

Custom Metrics:

+
// api/src/utils/metrics.ts
+
+export const geocodingQualityGauge = new Gauge({
+  name: 'cm_geocoding_avg_confidence',
+  help: 'Average geocoding confidence score (0-100)',
+  async collect() {
+    const stats = await locationsService.getGeocodeStats();
+    this.set(stats.avgConfidence);
+  }
+});
+
+export const lowConfidenceLocationsGauge = new Gauge({
+  name: 'cm_locations_low_confidence_count',
+  help: 'Number of locations with geocode confidence < 50',
+  async collect() {
+    const stats = await locationsService.getGeocodeStats();
+    this.set(stats.lowConfidenceCount);
+  }
+});
+
+export const geocodedPercentGauge = new Gauge({
+  name: 'cm_locations_geocoded_percent',
+  help: 'Percentage of locations with coordinates',
+  async collect() {
+    const stats = await locationsService.getGeocodeStats();
+    this.set(stats.geocodedPercent);
+  }
+});
+
+export const duplicateLocationsGauge = new Gauge({
+  name: 'cm_locations_duplicates_count',
+  help: 'Number of duplicate location entries',
+  async collect() {
+    const duplicates = await locationsService.findDuplicates();
+    this.set(duplicates.total);
+  }
+});
+
+

Alert Rules:

+
# configs/prometheus/alerts.yml
+
+groups:
+  - name: data_quality
+    interval: 5m
+    rules:
+      - alert: LowGeocodingConfidence
+        expr: cm_geocoding_avg_confidence < 60
+        for: 10m
+        labels:
+          severity: warning
+        annotations:
+          summary: Low average geocoding confidence
+          description: "Average geocoding confidence is {{ $value }}, below threshold of 60"
+
+      - alert: HighLowConfidenceLocations
+        expr: cm_locations_low_confidence_count > 100
+        for: 5m
+        labels:
+          severity: critical
+        annotations:
+          summary: High number of low-confidence locations
+          description: "{{ $value }} locations have geocoding confidence < 50"
+
+      - alert: LowGeocodedPercent
+        expr: cm_locations_geocoded_percent < 90
+        for: 10m
+        labels:
+          severity: warning
+        annotations:
+          summary: Low percentage of geocoded locations
+          description: "Only {{ $value }}% of locations have coordinates"
+
+      - alert: HighDuplicateLocations
+        expr: cm_locations_duplicates_count > 50
+        for: 15m
+        labels:
+          severity: warning
+        annotations:
+          summary: High number of duplicate locations
+          description: "{{ $value }} duplicate location entries detected"
+
+

Quality Metrics

+

Geocoding Confidence

+

Calculation:

+

Geocoding confidence is calculated based on multiple factors:

+
interface GeocodeResult {
+  latitude: number;
+  longitude: number;
+  matchType: 'exact' | 'interpolated' | 'approximate' | 'fallback';
+  addressComponents: {
+    streetNumber?: string;
+    street?: string;
+    city?: string;
+    postalCode?: string;
+    province?: string;
+  };
+  providerConfidence?: number; // Provider-specific score
+}
+
+function calculateConfidence(result: GeocodeResult, inputAddress: string): number {
+  let confidence = 0;
+
+  // Match type (0-40 points)
+  switch (result.matchType) {
+    case 'exact': confidence += 40; break;
+    case 'interpolated': confidence += 30; break;
+    case 'approximate': confidence += 20; break;
+    case 'fallback': confidence += 10; break;
+  }
+
+  // Address component completeness (0-30 points)
+  const components = result.addressComponents;
+  if (components.streetNumber) confidence += 10;
+  if (components.street) confidence += 10;
+  if (components.postalCode) confidence += 10;
+
+  // Provider-specific confidence (0-30 points)
+  if (result.providerConfidence) {
+    confidence += (result.providerConfidence / 100) * 30;
+  }
+
+  return Math.min(Math.round(confidence), 100);
+}
+
+

Confidence Levels:

+
    +
  • 81-100 (Excellent): Exact match with full address components
  • +
  • 61-80 (Good): Interpolated match with most components
  • +
  • 41-60 (Medium): Approximate match, missing some components
  • +
  • 21-40 (Low): Fallback geocoding, significant uncertainty
  • +
  • 0-20 (Very Low): Minimal match, likely incorrect
  • +
+

Provider Success Rates

+

Metrics Tracked:

+
interface ProviderMetrics {
+  provider: GeocodeProvider;
+  totalAttempts: number;
+  successfulGeocodes: number;
+  successRate: number; // 0-100%
+  avgConfidence: number; // 0-100
+  avgResponseTime: number; // milliseconds
+  errorCount: number;
+  lastError?: string;
+}
+
+

Success Rate Calculation:

+
const calculateProviderMetrics = async (): Promise<ProviderMetrics[]> => {
+  const locations = await prisma.location.findMany({
+    select: {
+      geocodeProvider: true,
+      geocodeConfidence: true,
+      latitude: true,
+      longitude: true
+    }
+  });
+
+  const providerGroups = groupBy(locations, 'geocodeProvider');
+
+  return Object.entries(providerGroups).map(([provider, locs]) => {
+    const total = locs.length;
+    const successful = locs.filter(l => l.latitude && l.longitude).length;
+    const avgConf = locs.reduce((sum, l) => sum + (l.geocodeConfidence || 0), 0) / total;
+
+    return {
+      provider: provider as GeocodeProvider,
+      totalAttempts: total,
+      successfulGeocodes: successful,
+      successRate: (successful / total) * 100,
+      avgConfidence: avgConf,
+      avgResponseTime: 0, // Would need separate tracking
+      errorCount: total - successful
+    };
+  });
+};
+
+

Duplicate Detection

+

Detection Methods:

+
    +
  1. +

    Exact Coordinate Match: +

    // Round to 6 decimal places (~0.1m precision)
    +const isDuplicateExact = (loc1: Location, loc2: Location): boolean => {
    +  return loc1.latitude!.toFixed(6) === loc2.latitude!.toFixed(6) &&
    +         loc1.longitude!.toFixed(6) === loc2.longitude!.toFixed(6);
    +};
    +

    +
  2. +
  3. +

    Proximity Threshold: +

    // Haversine distance check
    +const isDuplicateProximity = (loc1: Location, loc2: Location, thresholdM: number): boolean => {
    +  const distance = haversineDistance(
    +    [loc1.latitude!, loc1.longitude!],
    +    [loc2.latitude!, loc2.longitude!]
    +  );
    +  return distance < thresholdM;
    +};
    +

    +
  4. +
  5. +

    Address Similarity: +

    import { distance as levenshteinDistance } from 'fastest-levenshtein';
    +
    +const isDuplicateAddress = (addr1: string, addr2: string): boolean => {
    +  const normalized1 = normalizeAddress(addr1);
    +  const normalized2 = normalizeAddress(addr2);
    +  const dist = levenshteinDistance(normalized1, normalized2);
    +  const similarity = 1 - (dist / Math.max(normalized1.length, normalized2.length));
    +  return similarity > 0.9; // 90% similar
    +};
    +
    +const normalizeAddress = (address: string): string => {
    +  return address
    +    .toLowerCase()
    +    .replace(/\bstreet\b/g, 'st')
    +    .replace(/\bavenue\b/g, 'ave')
    +    .replace(/\broad\b/g, 'rd')
    +    .replace(/\bdrive\b/g, 'dr')
    +    .replace(/[^a-z0-9]/g, '');
    +};
    +

    +
  6. +
+

Address Validation

+

Validation Checks:

+
interface AddressValidationResult {
+  isValid: boolean;
+  issues: string[];
+  suggestions?: string[];
+}
+
+const validateAddress = (address: string): AddressValidationResult => {
+  const issues: string[] = [];
+
+  // Check minimum length
+  if (address.length < 5) {
+    issues.push('Address too short');
+  }
+
+  // Check for street number
+  if (!/^\d+/.test(address)) {
+    issues.push('Missing street number');
+  }
+
+  // Check for street name
+  if (!/\d+\s+([A-Za-z]+\s*)+/.test(address)) {
+    issues.push('Missing street name');
+  }
+
+  // Check for postal code (Canadian format)
+  if (!/[A-Z]\d[A-Z]\s?\d[A-Z]\d/.test(address)) {
+    issues.push('Missing or invalid postal code');
+  }
+
+  // Check for unusual characters
+  if (/[^A-Za-z0-9\s,.-]/.test(address)) {
+    issues.push('Contains unusual characters');
+  }
+
+  return {
+    isValid: issues.length === 0,
+    issues
+  };
+};
+
+

Admin Workflow

+ +

Step 1: Access Dashboard

+
    +
  1. Log in as SUPER_ADMIN or MAP_ADMIN
  2. +
  3. Click Map in sidebar
  4. +
  5. Click Data Quality submenu
  6. +
  7. Dashboard loads with statistics
  8. +
+

Step 2: Review Overall Statistics

+

Dashboard displays 4 main statistic cards:

+
┌──────────────────┬──────────────────┬──────────────────┬──────────────────┐
+│ Total Locations  │ Geocoded         │ Avg Confidence   │ Low Confidence   │
+│ 1,500            │ 1,450 (96.7%)    │ 78.5             │ 50               │
+└──────────────────┴──────────────────┴──────────────────┴──────────────────┘
+
+

Step 3: Analyze Provider Performance

+

Provider breakdown table shows:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ProviderCountSuccess RateAvg Confidence
GOOGLE80099.2%85.3
MAPBOX35097.1%82.1
NOMINATIM20094.5%75.8
PHOTON10091.0%68.2
UNKNOWN50N/A0
+

Step 4: Review Confidence Distribution

+

Bar chart displays confidence distribution:

+
Confidence Distribution
+100 |              ┌──────┐
+ 80 |              │      │
+ 60 |        ┌──────┤      │
+ 40 |  ┌──────┤      │      │
+ 20 |  │      │      │      │
+  0 └──┴──────┴──────┴──────┴──────┘
+    0-20  21-40  41-60  61-80 81-100
+     15     35    150    450    800
+
+

Identify and Review Low-Confidence Locations

+

Step 1: Filter Low-Confidence Locations

+
    +
  1. Click Low Confidence tab on dashboard
  2. +
  3. Table loads with locations where confidence < 50
  4. +
  5. Sort by confidence (ascending) to prioritize worst
  6. +
+

Step 2: Review Location Details

+

Click row to open detail drawer:

+
┌─────────────────────────────────────────┐
+│ Location Details                        │
+├─────────────────────────────────────────┤
+│ Address: 123 Main St                    │
+│ Postal Code: M5H 2N2                    │
+│ Coordinates: 43.6532, -79.3832          │
+│                                         │
+│ Geocoding Info:                         │
+│   Confidence: 45 (Low)                  │
+│   Provider: NOMINATIM                   │
+│   Geocoded: Feb 10, 2025 10:00 AM      │
+│                                         │
+│ Issues:                                 │
+│   • Missing street number in response   │
+│   • Approximate match only              │
+│                                         │
+│ [Re-geocode] [Edit Address] [View Map] │
+└─────────────────────────────────────────┘
+
+

Step 3: Take Action

+

Options for remediation:

+
    +
  1. Re-geocode with different provider:
  2. +
  3. Click Re-geocode button
  4. +
  5. Select provider (GOOGLE recommended for low confidence)
  6. +
  7. Click Geocode Now
  8. +
  9. +

    New confidence displayed

    +
  10. +
  11. +

    Edit address:

    +
  12. +
  13. Click Edit Address
  14. +
  15. Correct typos or formatting issues
  16. +
  17. Save changes
  18. +
  19. +

    Auto-triggers re-geocoding

    +
  20. +
  21. +

    View on map:

    +
  22. +
  23. Click View Map
  24. +
  25. Verify location accuracy visually
  26. +
  27. Drag marker to correct position if needed
  28. +
+

Bulk Re-geocoding

+

Step 1: Select Locations

+
    +
  1. In Low Confidence tab, use table checkboxes to select locations
  2. +
  3. Or click Select All to select all visible
  4. +
  5. Selected count displays: "50 selected"
  6. +
+

Step 2: Choose Provider

+
    +
  1. Click Bulk Re-geocode button
  2. +
  3. Modal opens with provider selection: +
    ┌─────────────────────────────────────┐
    +│ Bulk Re-geocode                     │
    +├─────────────────────────────────────┤
    +│ Re-geocode 50 locations             │
    +│                                     │
    +│ Provider: [GOOGLE ▼]                │
    +│                                     │
    +│ Options:                            │
    +│ ☑ Only if confidence < 50           │
    +│ ☑ Cache results                     │
    +│ ☐ Overwrite existing coordinates    │
    +│                                     │
    +│ Estimated time: ~2 minutes          │
    +│                                     │
    +│ [Cancel] [Start Re-geocoding]       │
    +└─────────────────────────────────────┘
    +
  4. +
+

Step 3: Monitor Progress

+
    +
  1. +

    Job starts, progress bar appears: +

    Re-geocoding in progress... 25/50 (50%)
    +[████████████░░░░░░░░░░░░] 50%
    +

    +
  2. +
  3. +

    Real-time updates:

    +
  4. +
  5. Total processed
  6. +
  7. Successful geocodes
  8. +
  9. Failed geocodes
  10. +
  11. Average new confidence
  12. +
+

Step 4: Review Results

+

Job completion summary:

+
┌─────────────────────────────────────┐
+│ Bulk Re-geocode Complete            │
+├─────────────────────────────────────┤
+│ Processed: 50                       │
+│ Successful: 47 (94%)                │
+│ Failed: 3 (6%)                      │
+│                                     │
+│ Quality Improvement:                │
+│   Avg Confidence Before: 42.5       │
+│   Avg Confidence After: 81.3        │
+│   Improvement: +38.8                │
+│                                     │
+│ [View Failed] [Close]               │
+└─────────────────────────────────────┘
+
+

Handle Duplicates

+

Step 1: View Duplicates Tab

+
    +
  1. Click Duplicates tab on dashboard
  2. +
  3. Table groups locations by coordinates
  4. +
+

Step 2: Review Duplicate Groups

+

Table displays:

+ + + + + + + + + + + + + + + + + + + + + + + +
CoordinatesCountAddressesAction
43.6532, -79.38323123 Main St, 123 Main Street, 123 Main St Unit 1[Review]
43.6540, -79.38252456 Bay St, 456 Bay Street[Review]
+

Step 3: Resolve Duplicates

+

Click Review to open resolution modal:

+
┌─────────────────────────────────────┐
+│ Resolve Duplicates                  │
+├─────────────────────────────────────┤
+│ 3 locations at 43.6532, -79.3832    │
+│                                     │
+│ ○ Merge into single location        │
+│   Primary: 123 Main St              │
+│   Merge units from duplicates       │
+│                                     │
+│ ○ Keep as separate multi-unit       │
+│   Mark as validated multi-unit      │
+│                                     │
+│ ○ Re-geocode individually           │
+│   Try to get unique coordinates     │
+│                                     │
+│ [Cancel] [Resolve]                  │
+└─────────────────────────────────────┘
+
+

Resolution Options:

+
    +
  1. Merge: Combine into single Location with multiple Address records
  2. +
  3. Multi-unit: Mark as legitimate multi-unit building
  4. +
  5. Re-geocode: Attempt to get unique coordinates for each
  6. +
+

Quality Improvement Strategies

+

Multi-Provider Geocoding

+

Fallback Chain:

+
// geocoding.service.ts
+
+const PROVIDER_CHAIN: GeocodeProvider[] = [
+  'GOOGLE',    // Primary: Best accuracy, paid
+  'MAPBOX',    // Fallback 1: Good accuracy, paid
+  'NOMINATIM', // Fallback 2: Free, decent accuracy
+  'PHOTON',    // Fallback 3: Free, lower accuracy
+  'ARCGIS'     // Fallback 4: Free, basic accuracy
+];
+
+async geocode(address: string): Promise<GeocodeResult | null> {
+  for (const provider of PROVIDER_CHAIN) {
+    try {
+      const result = await this.geocodeWithProvider(address, provider);
+      if (result && result.confidence >= 50) {
+        return result; // Success, confidence acceptable
+      }
+    } catch (error) {
+      logger.warn(`Geocoding failed with ${provider}:`, error);
+      // Try next provider
+    }
+  }
+  return null; // All providers failed
+}
+
+

Benefits: +- Increases success rate (90% → 96%+) +- Reduces dependency on single provider +- Cost optimization (use free providers as fallback) +- Provider outage resilience

+

Address Normalization

+

Pre-Geocoding Normalization:

+
const normalizeAddressForGeocoding = (address: string): string => {
+  let normalized = address;
+
+  // Remove extra whitespace
+  normalized = normalized.replace(/\s+/g, ' ').trim();
+
+  // Standardize abbreviations
+  const replacements: Record<string, string> = {
+    'Street': 'St',
+    'Avenue': 'Ave',
+    'Road': 'Rd',
+    'Drive': 'Dr',
+    'Boulevard': 'Blvd',
+    'Apartment': 'Apt',
+    'Unit': 'Unit',
+    'Suite': 'Ste'
+  };
+
+  Object.entries(replacements).forEach(([long, short]) => {
+    const regex = new RegExp(`\\b${long}\\b`, 'gi');
+    normalized = normalized.replace(regex, short);
+  });
+
+  // Ensure postal code spacing (Canadian format)
+  normalized = normalized.replace(/([A-Z]\d[A-Z])(\d[A-Z]\d)/, '$1 $2');
+
+  // Remove periods from abbreviations
+  normalized = normalized.replace(/\./g, '');
+
+  return normalized;
+};
+
+

Improvements: +- Reduces geocoding errors by 10-15% +- Increases confidence scores +- Better cache hit rate

+

Geocoding Cache

+

Redis Cache Implementation:

+
// geocoding.service.ts
+
+private async geocodeWithCache(address: string): Promise<GeocodeResult | null> {
+  const cacheKey = `geocode:${normalizeAddress(address)}`;
+
+  // Check cache
+  const cached = await redis.get(cacheKey);
+  if (cached) {
+    logger.debug('Geocoding cache hit:', address);
+    return JSON.parse(cached);
+  }
+
+  // Cache miss, geocode
+  const result = await this.geocode(address);
+  if (result) {
+    // Cache for 30 days
+    await redis.setex(cacheKey, 2592000, JSON.stringify(result));
+  }
+
+  return result;
+}
+
+

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

+

Manual Verification

+

Critical Location Verification:

+

Manually verify high-priority locations:

+
    +
  1. Campaign offices: Ensure exact coordinates
  2. +
  3. Shift start points: Verify accessibility
  4. +
  5. Event venues: Confirm entrance location
  6. +
  7. Polling stations: Critical for voter info
  8. +
+

Verification Process:

+
// Mark location as manually verified
+await prisma.location.update({
+  where: { id: locationId },
+  data: {
+    geocodeConfidence: 100,
+    geocodeProvider: 'MANUAL',
+    geocodedAt: new Date()
+  }
+});
+
+

Regular Audits

+

Monthly Quality Audit Checklist:

+
    +
  1. +

    Run quality report: +

    curl http://localhost:4000/api/locations/geocode-stats
    +

    +
  2. +
  3. +

    Check metrics against thresholds:

    +
  4. +
  5. Geocoded % > 95%
  6. +
  7. Avg confidence > 70
  8. +
  9. Low confidence count < 50
  10. +
  11. +

    Duplicates < 20

    +
  12. +
  13. +

    Review low-confidence locations:

    +
  14. +
  15. Filter locations with confidence < 50
  16. +
  17. Review top 20 by address
  18. +
  19. +

    Identify patterns (specific streets, providers)

    +
  20. +
  21. +

    Bulk re-geocode low confidence:

    +
  22. +
  23. Use GOOGLE provider for accuracy
  24. +
  25. +

    Monitor improvement in avg confidence

    +
  26. +
  27. +

    Resolve duplicates:

    +
  28. +
  29. Review all duplicate groups
  30. +
  31. Merge or mark as multi-unit
  32. +
  33. +

    Update addresses as needed

    +
  34. +
  35. +

    Export quality report: +

    const report = await generateQualityReport();
    +fs.writeFileSync(`quality-report-${date}.json`, JSON.stringify(report, null, 2));
    +

    +
  36. +
+

Code Examples

+

DataQualityDashboardPage.tsx

+
import React, { useEffect, useState } from 'react';
+import { Card, Row, Col, Statistic, Table, Tabs, Button, message } from 'antd';
+import { WarningOutlined, CheckCircleOutlined } from '@ant-design/icons';
+import { api } from '@/lib/api';
+import { Bar } from 'react-chartjs-2';
+
+interface GeocodeStats {
+  total: number;
+  geocoded: number;
+  geocodedPercent: number;
+  avgConfidence: number;
+  providerBreakdown: Record<string, number>;
+  confidenceDistribution: Record<string, number>;
+  lowConfidenceCount: number;
+  missingCoordinates: number;
+  duplicatesCount: number;
+}
+
+const DataQualityDashboardPage: React.FC = () => {
+  const [stats, setStats] = useState<GeocodeStats | null>(null);
+  const [lowConfLocations, setLowConfLocations] = useState<any[]>([]);
+  const [duplicates, setDuplicates] = useState<any[]>([]);
+  const [loading, setLoading] = useState(false);
+
+  useEffect(() => {
+    fetchStats();
+    fetchLowConfidenceLocations();
+    fetchDuplicates();
+  }, []);
+
+  const fetchStats = async () => {
+    setLoading(true);
+    try {
+      const { data } = await api.get<GeocodeStats>('/locations/geocode-stats');
+      setStats(data);
+    } catch (error) {
+      message.error('Failed to load statistics');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const fetchLowConfidenceLocations = async () => {
+    try {
+      const { data } = await api.get('/locations?geocodeConfidence=lt:50&limit=100');
+      setLowConfLocations(data.data);
+    } catch (error) {
+      message.error('Failed to load low-confidence locations');
+    }
+  };
+
+  const fetchDuplicates = async () => {
+    try {
+      const { data } = await api.get('/locations/duplicates');
+      setDuplicates(data.duplicates);
+    } catch (error) {
+      message.error('Failed to load duplicates');
+    }
+  };
+
+  const handleRegeocodeLocation = async (locationId: number) => {
+    try {
+      await api.post(`/locations/${locationId}/regeocode`, { provider: 'GOOGLE' });
+      message.success('Location re-geocoded successfully');
+      fetchStats();
+      fetchLowConfidenceLocations();
+    } catch (error) {
+      message.error('Failed to re-geocode location');
+    }
+  };
+
+  const confidenceChartData = stats ? {
+    labels: Object.keys(stats.confidenceDistribution),
+    datasets: [{
+      label: 'Locations',
+      data: Object.values(stats.confidenceDistribution),
+      backgroundColor: [
+        '#e74c3c', // 0-20: Red
+        '#f39c12', // 21-40: Orange
+        '#f1c40f', // 41-60: Yellow
+        '#3498db', // 61-80: Blue
+        '#27ae60'  // 81-100: Green
+      ]
+    }]
+  } : null;
+
+  const lowConfColumns = [
+    { title: 'Address', dataIndex: 'address', key: 'address' },
+    { title: 'Confidence', dataIndex: 'geocodeConfidence', key: 'confidence', render: (val: number) => (
+      <span style={{ color: val < 30 ? '#e74c3c' : '#f39c12' }}>{val}</span>
+    )},
+    { title: 'Provider', dataIndex: 'geocodeProvider', key: 'provider' },
+    { title: 'Action', key: 'action', render: (_: any, record: any) => (
+      <Button size="small" onClick={() => handleRegeocodeLocation(record.id)}>
+        Re-geocode
+      </Button>
+    )}
+  ];
+
+  return (
+    <div>
+      <h1>Data Quality Dashboard</h1>
+
+      {/* Statistics Cards */}
+      <Row gutter={16} style={{ marginBottom: 24 }}>
+        <Col span={6}>
+          <Card>
+            <Statistic
+              title="Total Locations"
+              value={stats?.total || 0}
+              prefix={<CheckCircleOutlined />}
+            />
+          </Card>
+        </Col>
+        <Col span={6}>
+          <Card>
+            <Statistic
+              title="Geocoded"
+              value={stats?.geocoded || 0}
+              suffix={`(${stats?.geocodedPercent.toFixed(1) || 0}%)`}
+              valueStyle={{ color: (stats?.geocodedPercent || 0) > 95 ? '#27ae60' : '#f39c12' }}
+            />
+          </Card>
+        </Col>
+        <Col span={6}>
+          <Card>
+            <Statistic
+              title="Avg Confidence"
+              value={stats?.avgConfidence.toFixed(1) || 0}
+              valueStyle={{ color: (stats?.avgConfidence || 0) > 70 ? '#27ae60' : '#f39c12' }}
+            />
+          </Card>
+        </Col>
+        <Col span={6}>
+          <Card>
+            <Statistic
+              title="Low Confidence"
+              value={stats?.lowConfidenceCount || 0}
+              prefix={<WarningOutlined />}
+              valueStyle={{ color: (stats?.lowConfidenceCount || 0) > 50 ? '#e74c3c' : '#f39c12' }}
+            />
+          </Card>
+        </Col>
+      </Row>
+
+      {/* Charts and Tables */}
+      <Tabs
+        items={[
+          {
+            key: 'overview',
+            label: 'Overview',
+            children: (
+              <div>
+                <Card title="Confidence Distribution" style={{ marginBottom: 24 }}>
+                  {confidenceChartData && <Bar data={confidenceChartData} />}
+                </Card>
+                <Card title="Provider Performance">
+                  <Table
+                    dataSource={stats ? Object.entries(stats.providerBreakdown).map(([provider, count]) => ({
+                      provider,
+                      count
+                    })) : []}
+                    columns={[
+                      { title: 'Provider', dataIndex: 'provider', key: 'provider' },
+                      { title: 'Count', dataIndex: 'count', key: 'count' }
+                    ]}
+                    pagination={false}
+                  />
+                </Card>
+              </div>
+            )
+          },
+          {
+            key: 'low-confidence',
+            label: `Low Confidence (${lowConfLocations.length})`,
+            children: (
+              <Table
+                dataSource={lowConfLocations}
+                columns={lowConfColumns}
+                rowKey="id"
+                loading={loading}
+              />
+            )
+          },
+          {
+            key: 'duplicates',
+            label: `Duplicates (${duplicates.length})`,
+            children: (
+              <Table
+                dataSource={duplicates}
+                columns={[
+                  { title: 'Coordinates', key: 'coords', render: (_, record: any) =>
+                    `${record.coordinates.latitude.toFixed(6)}, ${record.coordinates.longitude.toFixed(6)}`
+                  },
+                  { title: 'Count', dataIndex: 'count', key: 'count' },
+                  { title: 'Addresses', key: 'addresses', render: (_, record: any) =>
+                    record.locations.map((l: any) => l.address).join(', ')
+                  }
+                ]}
+                rowKey={(record) => `${record.coordinates.latitude}-${record.coordinates.longitude}`}
+              />
+            )
+          }
+        ]}
+      />
+    </div>
+  );
+};
+
+export default DataQualityDashboardPage;
+
+

Geocode Statistics Service

+
// locations.service.ts
+
+import { prisma } from '@/config/database';
+import type { GeocodeProvider } from '@prisma/client';
+
+export class LocationsService {
+  async getGeocodeStats() {
+    const locations = await prisma.location.findMany({
+      select: {
+        id: true,
+        latitude: true,
+        longitude: true,
+        geocodeConfidence: true,
+        geocodeProvider: true
+      }
+    });
+
+    const total = locations.length;
+    const geocoded = locations.filter(l => l.latitude && l.longitude).length;
+
+    const sumConfidence = locations.reduce((sum, l) => sum + (l.geocodeConfidence || 0), 0);
+    const avgConfidence = total > 0 ? sumConfidence / total : 0;
+
+    // Provider breakdown
+    const providerBreakdown: Record<string, number> = {};
+    locations.forEach(l => {
+      const provider = l.geocodeProvider || 'UNKNOWN';
+      providerBreakdown[provider] = (providerBreakdown[provider] || 0) + 1;
+    });
+
+    // Confidence distribution
+    const confidenceDistribution = {
+      '0-20': 0,
+      '21-40': 0,
+      '41-60': 0,
+      '61-80': 0,
+      '81-100': 0
+    };
+
+    locations.forEach(l => {
+      const conf = l.geocodeConfidence || 0;
+      if (conf <= 20) confidenceDistribution['0-20']++;
+      else if (conf <= 40) confidenceDistribution['21-40']++;
+      else if (conf <= 60) confidenceDistribution['41-60']++;
+      else if (conf <= 80) confidenceDistribution['61-80']++;
+      else confidenceDistribution['81-100']++;
+    });
+
+    const lowConfidenceCount = locations.filter(l => (l.geocodeConfidence || 0) < 50).length;
+    const duplicatesCount = await this.countDuplicates();
+
+    return {
+      total,
+      geocoded,
+      geocodedPercent: total > 0 ? (geocoded / total) * 100 : 0,
+      avgConfidence,
+      providerBreakdown,
+      confidenceDistribution,
+      lowConfidenceCount,
+      missingCoordinates: total - geocoded,
+      duplicatesCount
+    };
+  }
+
+  async countDuplicates(): Promise<number> {
+    const locations = await prisma.location.findMany({
+      where: {
+        AND: [
+          { latitude: { not: null } },
+          { longitude: { not: null } }
+        ]
+      },
+      select: { latitude: true, longitude: true }
+    });
+
+    const coordMap = new Map<string, number>();
+    locations.forEach(l => {
+      const key = `${l.latitude!.toFixed(6)},${l.longitude!.toFixed(6)}`;
+      coordMap.set(key, (coordMap.get(key) || 0) + 1);
+    });
+
+    return Array.from(coordMap.values()).filter(count => count > 1).reduce((sum, count) => sum + count, 0);
+  }
+
+  async regeocode(locationId: number, provider?: GeocodeProvider) {
+    const location = await prisma.location.findUnique({
+      where: { id: locationId }
+    });
+
+    if (!location) {
+      throw new Error('Location not found');
+    }
+
+    const result = await geocodingService.geocode(location.address, provider);
+
+    if (!result) {
+      throw new Error('Geocoding failed');
+    }
+
+    return await prisma.location.update({
+      where: { id: locationId },
+      data: {
+        latitude: result.latitude,
+        longitude: result.longitude,
+        geocodeConfidence: result.confidence,
+        geocodeProvider: result.provider,
+        geocodedAt: new Date()
+      }
+    });
+  }
+}
+
+

Troubleshooting

+

Problem: Many low-confidence locations

+

Symptoms: +- > 100 locations with confidence < 50 +- Avg confidence < 60 +- Prometheus alert firing

+

Solutions:

+
    +
  1. +

    Check provider API keys: +

    # Test Google Geocoding API
    +curl "https://maps.googleapis.com/maps/api/geocode/json?address=123+Main+St+Toronto&key=YOUR_KEY"
    +
    +# Verify key in .env
    +echo $GEOCODE_GOOGLE_API_KEY
    +

    +
  2. +
  3. +

    Try different primary provider: +

    # In .env, change primary provider
    +GEOCODE_PRIMARY_PROVIDER=GOOGLE  # Most accurate
    +# Or try:
    +GEOCODE_PRIMARY_PROVIDER=MAPBOX  # Good alternative
    +

    +
  4. +
  5. +

    Verify address format: +

    // Bad: Missing city/postal
    +"123 Main St"
    +
    +// Good: Full address
    +"123 Main St, Toronto ON M5H 2N2"
    +

    +
  6. +
  7. +

    Use postal code for better accuracy: +

    // Append postal code if available
    +const fullAddress = location.postalCode
    +  ? `${location.address}, ${location.postalCode}`
    +  : location.address;
    +

    +
  8. +
  9. +

    Bulk re-geocode with Google: +

    # Via API
    +curl -X POST http://localhost:4000/api/locations/bulk-geocode \
    +  -H "Authorization: Bearer $TOKEN" \
    +  -d '{"provider":"GOOGLE","confidenceThreshold":50}'
    +

    +
  10. +
+

Problem: Duplicate locations detected

+

Symptoms: +- Multiple locations at same coordinates +- Duplicates tab shows many groups +- Inflated location counts in cuts

+

Solutions:

+
    +
  1. +

    Check if legitimately multi-unit: +

    -- Find buildings with multiple addresses
    +SELECT l.id, l.address, COUNT(a.id) as unit_count
    +FROM "Location" l
    +JOIN "Address" a ON a."locationId" = l.id
    +GROUP BY l.id
    +HAVING COUNT(a.id) > 1;
    +

    +
  2. +
  3. +

    Verify geocoding precision: +

    // Check if rounding issue
    +const isDuplicateRounding = (loc1, loc2) => {
    +  // Use 4 decimal places (~11m precision) instead of 6 (~0.1m)
    +  return loc1.latitude.toFixed(4) === loc2.latitude.toFixed(4) &&
    +         loc1.longitude.toFixed(4) === loc2.longitude.toFixed(4);
    +};
    +

    +
  4. +
  5. +

    Review NAR import process: +

    // Ensure LOC_GUID unique constraint
    +const location = await prisma.location.upsert({
    +  where: { locGuid: narRecord.LOC_GUID },
    +  update: { /* update fields */ },
    +  create: { /* create fields */ }
    +});
    +

    +
  6. +
  7. +

    Merge duplicates: +

    // Merge function
    +const mergeDuplicates = async (primaryId: number, duplicateIds: number[]) => {
    +  // Move addresses to primary location
    +  await prisma.address.updateMany({
    +    where: { locationId: { in: duplicateIds } },
    +    data: { locationId: primaryId }
    +  });
    +
    +  // Delete duplicates
    +  await prisma.location.deleteMany({
    +    where: { id: { in: duplicateIds } }
    +  });
    +};
    +

    +
  8. +
+

Problem: Geocoding stats slow to load

+

Symptoms: +- GET /api/locations/geocode-stats takes > 5 seconds +- Dashboard timeout errors +- High database CPU

+

Solutions:

+
    +
  1. +

    Add database indexes: +

    CREATE INDEX CONCURRENTLY idx_locations_geocode_confidence
    +  ON "Location"(geocodeConfidence);
    +
    +CREATE INDEX CONCURRENTLY idx_locations_geocode_provider
    +  ON "Location"(geocodeProvider);
    +
    +CREATE INDEX CONCURRENTLY idx_locations_coords
    +  ON "Location"(latitude, longitude)
    +  WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
    +

    +
  2. +
  3. +

    Cache stats in Redis: +

    // Cache for 5 minutes
    +const getCachedStats = async () => {
    +  const cached = await redis.get('geocode:stats');
    +  if (cached) return JSON.parse(cached);
    +
    +  const stats = await locationsService.getGeocodeStats();
    +  await redis.setex('geocode:stats', 300, JSON.stringify(stats));
    +  return stats;
    +};
    +

    +
  4. +
  5. +

    Use aggregation pipeline: +

    // Raw SQL for better performance
    +const stats = await prisma.$queryRaw`
    +  SELECT
    +    COUNT(*) as total,
    +    COUNT(latitude) as geocoded,
    +    AVG(COALESCE("geocodeConfidence", 0)) as avg_confidence,
    +    "geocodeProvider",
    +    COUNT(*) FILTER (WHERE "geocodeConfidence" < 50) as low_confidence
    +  FROM "Location"
    +  GROUP BY "geocodeProvider"
    +`;
    +

    +
  6. +
  7. +

    Materialize stats view: +

    -- Create materialized view
    +CREATE MATERIALIZED VIEW geocode_stats_mv AS
    +SELECT
    +  COUNT(*) as total,
    +  COUNT(latitude) FILTER (WHERE latitude IS NOT NULL) as geocoded,
    +  AVG(COALESCE("geocodeConfidence", 0)) as avg_confidence,
    +  COUNT(*) FILTER (WHERE "geocodeConfidence" < 50) as low_confidence
    +FROM "Location";
    +
    +-- Refresh hourly
    +REFRESH MATERIALIZED VIEW geocode_stats_mv;
    +

    +
  8. +
+

Performance Considerations

+

Database Query Optimization

+

Indexes: +- geocodeConfidence (filtering) +- geocodeProvider (grouping) +- (latitude, longitude) composite (duplicate detection) +- Partial index on non-null coordinates

+

Query Performance: +- geocode-stats: ~500ms (1500 locations) +- Low confidence filter: ~100ms (with index) +- Duplicate detection: ~200ms (coordinate grouping) +- Bulk re-geocode: ~2-5 min (150 locations, depends on provider)

+

API Rate Limits

+

Provider Limits: +- Google: 50 QPS, $5/1000 requests +- Mapbox: 100,000/month free, then $0.50/1000 +- Nominatim: 1 QPS (public), no commercial use +- Photon: No official limit, self-hosted recommended +- ArcGIS: 100,000/month free

+

Optimization: +- Use Redis cache (30-day TTL) +- Batch geocoding jobs (avoid rate limits) +- Fallback to free providers for non-critical +- Monitor usage via provider dashboards

+

Caching Strategy

+

Cache Layers:

+
    +
  1. +

    Application Cache (Redis): +

    // 30-day TTL for geocode results
    +const cacheKey = `geocode:${normalizeAddress(address)}`;
    +await redis.setex(cacheKey, 2592000, JSON.stringify(result));
    +

    +
  2. +
  3. +

    Statistics Cache: +

    // 5-minute TTL for stats
    +await redis.setex('geocode:stats', 300, JSON.stringify(stats));
    +

    +
  4. +
  5. +

    Provider Response Cache: +

    // Cache raw provider responses separately
    +await redis.setex(`provider:${provider}:${address}`, 604800, JSON.stringify(rawResponse));
    +

    +
  6. +
+

Cache Hit Rates: +- Geocoding: 90%+ (repeated addresses) +- Statistics: 95%+ (frequent dashboard views) +- Provider responses: 85%+ (re-geocoding attempts)

+ +

Backend Documentation

+
    +
  • Locations Service: api/src/modules/map/locations/locations.service.ts
  • +
  • Geocode stats aggregation
  • +
  • Duplicate detection
  • +
  • +

    Re-geocoding operations

    +
  • +
  • +

    Geocoding Service: api/src/modules/map/geocoding/geocoding.service.ts

    +
  • +
  • Multi-provider fallback
  • +
  • Confidence calculation
  • +
  • +

    Cache integration

    +
  • +
  • +

    Bulk Geocoding: api/src/modules/map/locations/bulk-geocode.routes.ts

    +
  • +
  • Job queue integration
  • +
  • Progress tracking
  • +
  • Error handling
  • +
+

Frontend Documentation

+
    +
  • Data Quality Dashboard: admin/src/pages/DataQualityDashboardPage.tsx
  • +
  • Statistics display
  • +
  • Charts and tables
  • +
  • +

    Bulk actions

    +
  • +
  • +

    Locations Page: admin/src/pages/LocationsPage.tsx

    +
  • +
  • CSV import/export
  • +
  • Inline geocoding
  • +
  • Address editing
  • +
+

Database Documentation

+
    +
  • Location Model: api/prisma/schema.prisma
  • +
  • Geocoding metadata fields
  • +
  • Indexes for performance
  • +
  • Relations to Address
  • +
+

Monitoring Documentation

+
    +
  • Prometheus Metrics: api/src/utils/metrics.ts
  • +
  • Custom geocoding metrics
  • +
  • Quality gauges
  • +
  • +

    Alert integration

    +
  • +
  • +

    Grafana Dashboard: configs/grafana/dashboards/data-quality.json

    +
  • +
  • Quality trend charts
  • +
  • Provider comparison
  • +
  • Alert visualization
  • +
+

External Resources

+
    +
  • Google Geocoding API: https://developers.google.com/maps/documentation/geocoding
  • +
  • Mapbox Geocoding API: https://docs.mapbox.com/api/search/geocoding
  • +
  • Nominatim API: https://nominatim.org/release-docs/latest/api/Search
  • +
  • Photon API: https://photon.komoot.io
  • +
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/map/geocoding/index.html b/mkdocs/site/v2/features/map/geocoding/index.html new file mode 100644 index 00000000..7c975ff0 --- /dev/null +++ b/mkdocs/site/v2/features/map/geocoding/index.html @@ -0,0 +1,6795 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Geocoding - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Multi-Provider Geocoding Service

+

Overview

+

The geocoding service provides automated address-to-coordinate conversion using a six-provider fallback chain. It enables campaigns to quickly convert voter addresses to map coordinates, with confidence scoring, Redis caching, and BullMQ queue integration for bulk operations.

+

Key Capabilities:

+
    +
  • 6 Geocoding Providers: Google, Mapbox, Nominatim, Photon, LocationIQ, ArcGIS
  • +
  • Provider Fallback Chain: Try providers in order until success
  • +
  • Confidence Scoring: 0-100 score based on match quality
  • +
  • Redis Caching: 7-day TTL to avoid redundant API calls
  • +
  • Bulk Queue Processing: BullMQ integration for large geocoding jobs
  • +
  • Address Normalization: Expand abbreviations, normalize postal codes
  • +
  • Reverse Geocoding: Convert coordinates to human-readable address
  • +
  • Provider Health Tracking: Prometheus metrics for success rates
  • +
+

Use Cases:

+
    +
  • Bulk geocoding of voter files
  • +
  • Real-time address validation during data entry
  • +
  • Map marker placement for locations
  • +
  • Address autocomplete (future)
  • +
  • Spatial filtering by coordinates
  • +
  • Walk sheet generation with accurate maps
  • +
+

Architecture

+
graph TD
+    A[Location Service] -->|Geocode Request| B[Geocoding Service]
+    B -->|Check Cache| C[(Redis Cache)]
+    C -->|Cache Hit| A
+    C -->|Cache Miss| D[Provider Chain]
+
+    D -->|Try Provider 1| E[Google Geocoding API]
+    E -->|Success| F[Confidence Scorer]
+    E -->|Fail| G[Try Provider 2]
+    G -->|Mapbox| H[Mapbox Geocoding API]
+    H -->|Success| F
+    H -->|Fail| I[Try Provider 3]
+    I -->|Nominatim| J[Nominatim API]
+    J -->|Success| F
+    J -->|Fail| K[Try Provider 4]
+    K -->|Photon| L[Photon API]
+    L -->|Success| F
+    L -->|Fail| M[Try Provider 5]
+    M -->|LocationIQ| N[LocationIQ API]
+    N -->|Success| F
+    N -->|Fail| O[Try Provider 6]
+    O -->|ArcGIS| P[ArcGIS API]
+    P -->|Success| F
+    P -->|Fail| Q[Geocoding Failed]
+
+    F -->|Store Result| C
+    F -->|Return| A
+
+    R[Bulk Geocode Job] -->|Queue| S[(BullMQ)]
+    S -->|Process Batch| B
+    B -->|Rate Limit| T[Rate Limiter]
+    T -->|Allow| D
+
+    style C fill:#fff4e1
+    style S fill:#fff4e1
+    style E fill:#e8f5e9
+    style H fill:#e8f5e9
+    style J fill:#e8f5e9
+    style L fill:#e8f5e9
+    style N fill:#e8f5e9
+    style P fill:#e8f5e9
+

Flow Description:

+
    +
  1. Location service requests geocode → Geocoding service checks Redis cache
  2. +
  3. Cache miss → Try providers in configured order (Google → Mapbox → Nominatim → Photon → LocationIQ → ArcGIS)
  4. +
  5. Provider success → Calculate confidence score (0-100) based on match type
  6. +
  7. Cache result → Store in Redis with 7-day TTL
  8. +
  9. Bulk geocoding → BullMQ worker processes batches with rate limiting
  10. +
  11. Metrics tracking → Prometheus gauges for provider health and cache hit rate
  12. +
+

Database Models

+

GeocodeProvider Enum

+

See Location Model Documentation for full schema.

+

Provider Enum Values:

+
enum GeocodeProvider {
+  GOOGLE
+  MAPBOX
+  NOMINATIM
+  PHOTON
+  LOCATIONIQ
+  ARCGIS
+  UNKNOWN
+}
+
+

Location Model Geocoding Fields:

+
    +
  • latitude / longitude: Decimal coordinates from geocoding
  • +
  • geocodeConfidence: Integer 0-100 (>90=high, 70-90=medium, <70=low)
  • +
  • geocodeProvider: Which provider successfully geocoded
  • +
  • geocodeAttempts: Number of failed attempts (for retry logic)
  • +
  • lastGeocodeAttempt: Timestamp of last geocoding attempt
  • +
+

Related Models:

+ +

API Endpoints

+

See Geocoding Backend Module Documentation for full API reference.

+

Geocoding Endpoints:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
POST/api/map/locations/geocodeMAP_ADMINGeocode single address
POST/api/map/locations/reverse-geocodeMAP_ADMINReverse geocode lat/lng to address
POST/api/map/locations/bulk-geocode/startMAP_ADMINStart bulk geocoding job (BullMQ)
GET/api/map/locations/bulk-geocode/statusMAP_ADMINCheck bulk geocoding job status
POST/api/map/locations/bulk-geocode/cancelMAP_ADMINCancel running bulk geocoding job
+

Request/Response Examples:

+

Single Geocode Request:

+
POST /api/map/locations/geocode
+{
+  "address": "123 Main Street, Ottawa, ON K1A 0B1"
+}
+
+// Response
+{
+  "latitude": 45.4215,
+  "longitude": -75.6972,
+  "confidence": 95,
+  "provider": "GOOGLE",
+  "formattedAddress": "123 Main St, Ottawa, ON K1A 0B1, Canada"
+}
+
+

Bulk Geocode Job:

+
POST /api/map/locations/bulk-geocode/start
+{
+  "confidenceThreshold": 70,
+  "provider": "GOOGLE",
+  "batchSize": 50
+}
+
+// Response
+{
+  "jobId": "bulk-geocode-uuid",
+  "status": "queued",
+  "totalLocations": 1234
+}
+
+

Configuration

+

Environment Variables

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableTypeDefaultDescription
GEOCODING_ENABLEDbooleantrueEnable geocoding services
GEOCODING_CACHE_ENABLEDbooleantrueCache results in Redis
GEOCODING_CACHE_TTL_HOURSnumber168Cache TTL (7 days)
GEOCODING_PROVIDERSstringGOOGLE,MAPBOX,NOMINATIM,PHOTON,LOCATIONIQ,ARCGISProvider order (comma-separated)
GOOGLE_MAPS_API_KEYstring-Google Geocoding API key (required if Google enabled)
MAPBOX_ACCESS_TOKENstring-Mapbox API token (required if Mapbox enabled)
LOCATIONIQ_API_KEYstring-LocationIQ API key (required if LocationIQ enabled)
NOMINATIM_BASE_URLstringhttps://nominatim.openstreetmap.orgNominatim API URL
PHOTON_BASE_URLstringhttps://photon.komoot.ioPhoton API URL
ARCGIS_BASE_URLstringhttps://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServerArcGIS API URL
+

Provider Configuration

+

Provider Selection Strategy:

+
    +
  1. Free tier exhausted? Remove provider from chain
  2. +
  3. Rate limit hit? Skip provider temporarily (5min cooldown)
  4. +
  5. Service down? Skip provider (exponential backoff)
  6. +
  7. Low confidence? Try next provider
  8. +
+

Provider Priority (Default):

+
    +
  1. Google — Best accuracy, paid API (free $200/month credit)
  2. +
  3. Mapbox — Good accuracy, generous free tier (100k/month)
  4. +
  5. Nominatim — Free, moderate accuracy, 1 req/sec limit
  6. +
  7. Photon — Free, fast, good for European addresses
  8. +
  9. LocationIQ — Free tier (5k/day), good international coverage
  10. +
  11. ArcGIS — Free tier (20k/month), good US coverage
  12. +
+

Confidence Scoring Rules

+

Confidence Score Calculation:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Match TypeGoogleMapboxNominatimPhotonLocationIQArcGIS
Rooftop (exact address)95-10095-10090-9590-9590-9595-100
Interpolated85-9485-9480-8980-8980-8985-94
Street-level70-8470-8465-7965-7965-7970-84
Postal code50-6950-6945-6445-6445-6450-69
City30-4930-4925-4425-4425-4430-49
Province/State10-2910-295-245-245-2410-29
Country0-90-90-40-40-40-9
+

Confidence Thresholds:

+
    +
  • High (90-100): Exact address match, suitable for door-knocking
  • +
  • Medium (70-89): Street-level or interpolated, suitable for mapping
  • +
  • Low (50-69): Postal code or city-level, needs manual verification
  • +
  • None (<50): Unreliable, should re-geocode or manually enter coordinates
  • +
+

Admin Workflow

+

Single Address Geocoding

+

Step 1: Enter Address

+

On LocationsPage create/edit form, enter address:

+
Address: 123 Main Street
+Postal Code: K1A 0B1
+
+

Step 2: Click Geocode Button

+

Click Geocode button below address field.

+

Step 3: View Results

+

System displays:

+
    +
  • Latitude/Longitude: Auto-populated
  • +
  • Confidence Score: 95% (High)
  • +
  • Provider: Google
  • +
  • Formatted Address: 123 Main St, Ottawa, ON K1A 0B1, Canada
  • +
+

Step 4: Save Location

+

Click Save to create/update location with geocoded coordinates.

+

Bulk Re-Geocoding

+

Use Case: Re-geocode locations with missing or low-confidence coordinates.

+

Step 1: Open Bulk Geocode Modal

+

On LocationsPage, click Bulk Re-Geocode button.

+

Step 2: Configure Job

+

Set parameters:

+
    +
  • Confidence Threshold: Only geocode locations below this score (e.g., 70)
  • +
  • Missing Only: Only geocode locations without coordinates
  • +
  • Provider: Choose preferred provider (or use default chain)
  • +
  • Batch Size: Locations per batch (default: 50)
  • +
+

Step 3: Start Job

+

Click Start Job to queue job in BullMQ.

+

Step 4: Monitor Progress

+

View real-time progress:

+
    +
  • Completed: 234 / 1000 locations
  • +
  • Failed: 12 locations
  • +
  • Progress: 23.4%
  • +
  • ETA: 8 minutes
  • +
+

Step 5: Review Results

+

After job completes:

+
    +
  • Success Rate: 98.8%
  • +
  • Average Confidence: 87.3
  • +
  • Failed Addresses: Download CSV of failures
  • +
+

Step 6: Retry Failures (Optional)

+

For failed addresses:

+
    +
  1. Download failure CSV
  2. +
  3. Manually verify addresses
  4. +
  5. Fix typos/formatting issues
  6. +
  7. Re-import CSV
  8. +
  9. Run bulk geocode again
  10. +
+

Reverse Geocoding

+

Use Case: Convert map click coordinates to address.

+

Step 1: Click Map

+

On AdminMapView, click location to get lat/lng.

+

Step 2: Reverse Geocode

+

Click Reverse Geocode button in popup.

+

Step 3: View Address

+

System displays:

+
Address: 123 Main St
+City: Ottawa
+Province: ON
+Country: Canada
+
+

Step 4: Create Location

+

Click Create Location to auto-fill address form.

+

Code Examples

+

Geocoding Service (Backend)

+
// api/src/modules/map/geocoding/geocoding.service.ts
+export interface GeocodeResult {
+  latitude: number;
+  longitude: number;
+  confidence: number;
+  provider: GeocodeProvider;
+  formattedAddress?: string;
+}
+
+async function geocode(address: string): Promise<GeocodeResult> {
+  // Check Redis cache first
+  const cached = await getCachedResult(address);
+  if (cached) {
+    logger.debug('Geocode cache hit', { address });
+    return cached;
+  }
+
+  // Normalize address (expand abbreviations, fix postal code)
+  const normalized = normalizeAddress(address);
+
+  // Try providers in order
+  const providers = env.GEOCODING_PROVIDERS.split(',');
+  let lastError: Error | null = null;
+
+  for (const providerName of providers) {
+    try {
+      const result = await tryProvider(providerName, normalized);
+
+      if (result.confidence >= 50) {
+        // Cache successful result
+        await setCachedResult(address, result);
+        logger.info('Geocoded address', {
+          address,
+          provider: result.provider,
+          confidence: result.confidence,
+        });
+        return result;
+      }
+    } catch (err) {
+      lastError = err as Error;
+      logger.warn(`Provider ${providerName} failed`, { address, error: err });
+      continue;
+    }
+  }
+
+  throw new AppError(
+    500,
+    'All geocoding providers failed',
+    'GEOCODING_FAILED',
+    { address, lastError: lastError?.message }
+  );
+}
+
+

Provider Chain Implementation

+
// api/src/modules/map/geocoding/geocoding.service.ts
+async function tryProvider(
+  providerName: string,
+  address: string
+): Promise<GeocodeResult> {
+  switch (providerName.toUpperCase()) {
+    case 'GOOGLE':
+      return await geocodeWithGoogle(address);
+    case 'MAPBOX':
+      return await geocodeWithMapbox(address);
+    case 'NOMINATIM':
+      return await geocodeWithNominatim(address);
+    case 'PHOTON':
+      return await geocodeWithPhoton(address);
+    case 'LOCATIONIQ':
+      return await geocodeWithLocationIQ(address);
+    case 'ARCGIS':
+      return await geocodeWithArcGIS(address);
+    default:
+      throw new Error(`Unknown provider: ${providerName}`);
+  }
+}
+
+

Google Geocoding Provider

+
// api/src/modules/map/geocoding/geocoding.service.ts
+async function geocodeWithGoogle(address: string): Promise<GeocodeResult> {
+  if (!env.GOOGLE_MAPS_API_KEY) {
+    throw new Error('Google Maps API key not configured');
+  }
+
+  const url = new URL('https://maps.googleapis.com/maps/api/geocode/json');
+  url.searchParams.set('address', address);
+  url.searchParams.set('key', env.GOOGLE_MAPS_API_KEY);
+
+  const response = await fetch(url.toString());
+  const data = await response.json();
+
+  if (data.status !== 'OK' || !data.results?.[0]) {
+    throw new Error(`Google geocoding failed: ${data.status}`);
+  }
+
+  const result = data.results[0];
+  const location = result.geometry.location;
+
+  // Calculate confidence based on location_type
+  let confidence = 50;
+  if (result.geometry.location_type === 'ROOFTOP') {
+    confidence = 95;
+  } else if (result.geometry.location_type === 'RANGE_INTERPOLATED') {
+    confidence = 85;
+  } else if (result.geometry.location_type === 'GEOMETRIC_CENTER') {
+    confidence = 70;
+  }
+
+  return {
+    latitude: location.lat,
+    longitude: location.lng,
+    confidence,
+    provider: GeocodeProvider.GOOGLE,
+    formattedAddress: result.formatted_address,
+  };
+}
+
+

Mapbox Geocoding Provider

+
// api/src/modules/map/geocoding/geocoding.service.ts
+async function geocodeWithMapbox(address: string): Promise<GeocodeResult> {
+  if (!env.MAPBOX_ACCESS_TOKEN) {
+    throw new Error('Mapbox access token not configured');
+  }
+
+  const encodedAddress = encodeURIComponent(address);
+  const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedAddress}.json?access_token=${env.MAPBOX_ACCESS_TOKEN}`;
+
+  const response = await fetch(url);
+  const data = await response.json();
+
+  if (!data.features?.[0]) {
+    throw new Error('Mapbox geocoding failed: no results');
+  }
+
+  const feature = data.features[0];
+  const [lng, lat] = feature.center;
+
+  // Calculate confidence based on place_type
+  let confidence = 50;
+  if (feature.place_type.includes('address')) {
+    confidence = 95;
+  } else if (feature.place_type.includes('place')) {
+    confidence = 60;
+  } else if (feature.place_type.includes('postcode')) {
+    confidence = 55;
+  }
+
+  // Boost confidence for exact match
+  if (feature.relevance >= 0.9) {
+    confidence = Math.min(100, confidence + 10);
+  }
+
+  return {
+    latitude: lat,
+    longitude: lng,
+    confidence,
+    provider: GeocodeProvider.MAPBOX,
+    formattedAddress: feature.place_name,
+  };
+}
+
+

Nominatim Geocoding Provider

+
// api/src/modules/map/geocoding/geocoding.service.ts
+async function geocodeWithNominatim(address: string): Promise<GeocodeResult> {
+  const baseUrl = env.NOMINATIM_BASE_URL || 'https://nominatim.openstreetmap.org';
+  const url = new URL(`${baseUrl}/search`);
+  url.searchParams.set('q', address);
+  url.searchParams.set('format', 'json');
+  url.searchParams.set('limit', '1');
+
+  const response = await fetch(url.toString(), {
+    headers: { 'User-Agent': 'Changemaker Lite/2.0' }, // Required by Nominatim
+  });
+
+  const data = await response.json();
+
+  if (!data?.[0]) {
+    throw new Error('Nominatim geocoding failed: no results');
+  }
+
+  const result = data[0];
+  const lat = parseFloat(result.lat);
+  const lng = parseFloat(result.lon);
+
+  // Calculate confidence based on osm_type and importance
+  let confidence = 50;
+  if (result.osm_type === 'node' && result.importance > 0.5) {
+    confidence = 90;
+  } else if (result.osm_type === 'way' && result.importance > 0.4) {
+    confidence = 80;
+  } else if (result.importance > 0.3) {
+    confidence = 70;
+  }
+
+  return {
+    latitude: lat,
+    longitude: lng,
+    confidence,
+    provider: GeocodeProvider.NOMINATIM,
+    formattedAddress: result.display_name,
+  };
+}
+
+

Address Normalization

+
// api/src/modules/map/geocoding/geocoding.service.ts
+const abbreviations: Record<string, string> = {
+  // Street types
+  'st': 'street',
+  'ave': 'avenue',
+  'blvd': 'boulevard',
+  'dr': 'drive',
+  'rd': 'road',
+  'ln': 'lane',
+  'ct': 'court',
+  // Directional suffixes
+  'n': 'north',
+  'ne': 'northeast',
+  'e': 'east',
+  'se': 'southeast',
+  's': 'south',
+  'sw': 'southwest',
+  'w': 'west',
+  'nw': 'northwest',
+};
+
+function normalizeAddress(address: string): string {
+  let normalized = address.trim().toLowerCase();
+
+  // Expand abbreviations
+  for (const [abbr, full] of Object.entries(abbreviations)) {
+    const regex = new RegExp(`\\b${abbr}\\b`, 'gi');
+    normalized = normalized.replace(regex, full);
+  }
+
+  // Normalize postal code (K1A0B1 → K1A 0B1)
+  normalized = normalized.replace(
+    /\b([A-Za-z]\d[A-Za-z])\s*(\d[A-Za-z]\d)\b/g,
+    (match, p1, p2) => `${p1.toUpperCase()} ${p2.toUpperCase()}`
+  );
+
+  // Remove extra whitespace
+  normalized = normalized.replace(/\s+/g, ' ').trim();
+
+  return normalized;
+}
+
+

Redis Caching

+
// api/src/modules/map/geocoding/geocoding.service.ts
+import crypto from 'crypto';
+
+const CACHE_KEY_PREFIX = 'GEOCODE_CACHE:';
+
+function hashAddress(address: string): string {
+  return crypto.createHash('sha256').update(address).digest('hex').substring(0, 16);
+}
+
+async function getCachedResult(address: string): Promise<GeocodeResult | null> {
+  if (env.GEOCODING_CACHE_ENABLED !== 'true') return null;
+
+  try {
+    const key = `${CACHE_KEY_PREFIX}${hashAddress(address)}`;
+    const cached = await redis.get(key);
+
+    if (!cached) {
+      cm_geocode_cache_misses.inc();
+      return null;
+    }
+
+    const parsed = JSON.parse(cached);
+    cm_geocode_cache_hits.inc();
+    return parsed;
+  } catch (err) {
+    logger.warn('Failed to get cached geocode result:', err);
+    return null;
+  }
+}
+
+async function setCachedResult(address: string, result: GeocodeResult): Promise<void> {
+  if (env.GEOCODING_CACHE_ENABLED !== 'true') return;
+
+  try {
+    const key = `${CACHE_KEY_PREFIX}${hashAddress(address)}`;
+    const ttlSeconds = env.GEOCODING_CACHE_TTL_HOURS * 60 * 60;
+
+    await redis.setex(key, ttlSeconds, JSON.stringify(result));
+  } catch (err) {
+    logger.warn('Failed to cache geocode result:', err);
+  }
+}
+
+

Bulk Geocoding Job (BullMQ)

+
// api/src/services/geocode-queue.service.ts
+import Bull from 'bull';
+
+export const geocodeQueue = new Bull('geocode-queue', env.REDIS_URL, {
+  defaultJobOptions: {
+    attempts: 3,
+    backoff: { type: 'exponential', delay: 5000 },
+    removeOnComplete: 100,
+    removeOnFail: false,
+  },
+});
+
+// Bulk geocode job processor
+geocodeQueue.process(async (job) => {
+  const { locationIds, provider, batchSize } = job.data;
+
+  logger.info('Processing bulk geocode job', {
+    jobId: job.id,
+    totalLocations: locationIds.length,
+  });
+
+  let completed = 0;
+  let failed = 0;
+
+  for (let i = 0; i < locationIds.length; i += batchSize) {
+    const batch = locationIds.slice(i, i + batchSize);
+
+    for (const locationId of batch) {
+      try {
+        const location = await prisma.location.findUnique({
+          where: { id: locationId },
+        });
+
+        if (!location?.address) {
+          failed++;
+          continue;
+        }
+
+        const result = await geocodingService.geocode(location.address);
+
+        await prisma.location.update({
+          where: { id: locationId },
+          data: {
+            latitude: result.latitude,
+            longitude: result.longitude,
+            geocodeConfidence: result.confidence,
+            geocodeProvider: result.provider,
+            lastGeocodeAttempt: new Date(),
+          },
+        });
+
+        completed++;
+      } catch (err) {
+        logger.warn('Failed to geocode location', { locationId, error: err });
+        failed++;
+      }
+    }
+
+    // Update job progress
+    const progress = ((i + batch.length) / locationIds.length) * 100;
+    await job.progress(progress);
+
+    // Rate limiting: wait 1s between batches
+    await new Promise((resolve) => setTimeout(resolve, 1000));
+  }
+
+  return { completed, failed, total: locationIds.length };
+});
+
+

Troubleshooting

+

Issue: All Providers Failing

+

Symptoms:

+
    +
  • "All geocoding providers failed" error
  • +
  • Geocode confidence always 0
  • +
  • No results from any provider
  • +
+

Causes:

+
    +
  • All API keys invalid or missing
  • +
  • Network connectivity issues
  • +
  • Rate limits exceeded on all providers
  • +
  • Address format not recognized
  • +
+

Solutions:

+
    +
  1. Verify API keys:
  2. +
+
# Check .env file
+grep "GOOGLE_MAPS_API_KEY\|MAPBOX_ACCESS_TOKEN\|LOCATIONIQ_API_KEY" .env
+
+# Test Google API key directly
+curl "https://maps.googleapis.com/maps/api/geocode/json?address=123+Main+St&key=YOUR_KEY"
+
+
    +
  1. Check provider health:
  2. +
+
# View Prometheus metrics
+curl http://localhost:4000/metrics | grep cm_geocode
+
+# View API logs
+docker compose logs -f api | grep geocode
+
+
    +
  1. Test with free provider (Nominatim):
  2. +
+
# Temporarily use only Nominatim
+GEOCODING_PROVIDERS=NOMINATIM
+
+# Test endpoint
+curl -X POST http://localhost:4000/api/map/locations/geocode \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"address":"123 Main Street, Ottawa, ON"}'
+
+

Issue: Low Confidence Scores

+

Symptoms:

+
    +
  • Geocode confidence consistently <70
  • +
  • Coordinates appear incorrect on map
  • +
  • Addresses geocoded to city-level instead of street-level
  • +
+

Causes:

+
    +
  • Address format ambiguous (missing street type, postal code)
  • +
  • Provider using city centroid instead of exact address
  • +
  • International address format not recognized
  • +
  • Address doesn't exist in provider database
  • +
+

Solutions:

+
    +
  1. Improve address format:
  2. +
+
// Bad: missing postal code, street type
+"123 Main, Ottawa"
+
+// Good: full Canadian address
+"123 Main Street, Ottawa, ON K1A 0B1"
+
+
    +
  1. Try different providers:
  2. +
+
# Google/Mapbox best for North American addresses
+GEOCODING_PROVIDERS=GOOGLE,MAPBOX,NOMINATIM
+
+# Nominatim/Photon better for European addresses
+GEOCODING_PROVIDERS=NOMINATIM,PHOTON,MAPBOX
+
+
    +
  1. Manual verification:
  2. +
+

For critical addresses, manually verify coordinates:

+
# Reverse geocode to check accuracy
+curl -X POST http://localhost:4000/api/map/locations/reverse-geocode \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"latitude":45.4215,"longitude":-75.6972}'
+
+

Issue: Bulk Geocoding Job Stuck

+

Symptoms:

+
    +
  • Bulk geocode progress stuck at X%
  • +
  • Job running for hours without completing
  • +
  • BullMQ job marked as "active" but not processing
  • +
+

Causes:

+
    +
  • Worker crashed mid-job
  • +
  • Rate limit hit (paused for cooldown)
  • +
  • Redis connection lost
  • +
  • Job timeout (default: 30min)
  • +
+

Solutions:

+
    +
  1. Check job status:
  2. +
+
# View BullMQ jobs in Redis
+docker compose exec redis redis-cli KEYS "bull:geocode-queue:*"
+
+# Get job details
+docker compose exec redis redis-cli GET "bull:geocode-queue:JOB_ID"
+
+
    +
  1. Restart worker:
  2. +
+
# Restart API service (worker runs in API container)
+docker compose restart api
+
+
    +
  1. Cancel stuck job:
  2. +
+
# Via API endpoint
+curl -X POST http://localhost:4000/api/map/locations/bulk-geocode/cancel \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Or manually in Redis
+docker compose exec redis redis-cli DEL "bull:geocode-queue:ACTIVE_JOB_ID"
+
+
    +
  1. Increase timeout:
  2. +
+
// api/src/services/geocode-queue.service.ts
+defaultJobOptions: {
+  timeout: 3600000, // 1 hour (was 30min)
+}
+
+

Issue: Cache Not Working

+

Symptoms:

+
    +
  • cm_geocode_cache_hits metric always 0
  • +
  • Same address geocoded multiple times
  • +
  • High API usage for repeated addresses
  • +
+

Causes:

+
    +
  • Redis not running
  • +
  • GEOCODING_CACHE_ENABLED=false
  • +
  • Cache keys expiring too quickly
  • +
  • Address normalization inconsistent (cache miss due to formatting)
  • +
+

Solutions:

+
    +
  1. Verify Redis connection:
  2. +
+
# Check Redis is running
+docker compose ps redis
+
+# Test Redis connection from API
+docker compose exec api node -e "const redis = require('./src/config/redis').redis; redis.ping().then(console.log);"
+
+
    +
  1. Check cache keys:
  2. +
+
# View cached geocode results
+docker compose exec redis redis-cli KEYS "GEOCODE_CACHE:*"
+
+# Get sample cached result
+docker compose exec redis redis-cli GET "GEOCODE_CACHE:abc123def456"
+
+
    +
  1. Enable caching:
  2. +
+
# Verify in .env
+GEOCODING_CACHE_ENABLED=true
+GEOCODING_CACHE_TTL_HOURS=168  # 7 days
+
+
    +
  1. Clear cache to test:
  2. +
+
# Delete all geocode cache keys
+docker compose exec redis redis-cli --scan --pattern "GEOCODE_CACHE:*" | xargs docker compose exec redis redis-cli DEL
+
+

Performance Considerations

+

Provider Rate Limits

+

Free Tier Limits:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ProviderFree TierRate LimitBest For
Google$200/month credit (~28k reqs)50 req/secNorth American addresses
Mapbox100,000/month600 req/minGlobal coverage
NominatimUnlimited1 req/secEurope, low-volume
PhotonUnlimitedNo limit*Europe, high-volume
LocationIQ5,000/day2 req/secTesting, low-volume
ArcGIS20,000/month50 req/secUS addresses
+

*Self-hosted Photon recommended for production high-volume use.

+

Best Practices:

+
    +
  1. Enable Redis caching (7-day TTL reduces API calls by ~80%)
  2. +
  3. Use bulk geocoding jobs (BullMQ queue with 1s delay between batches)
  4. +
  5. Prefer NAR imports (coordinates included, no geocoding needed)
  6. +
  7. Set up Photon self-hosted (for high-volume European campaigns)
  8. +
+

Caching Strategy

+

Cache Hit Rate Optimization:

+
// Normalize address before hashing to improve cache hits
+function hashAddress(address: string): string {
+  // Remove punctuation, lowercase, trim
+  const normalized = address
+    .toLowerCase()
+    .replace(/[.,]/g, '')
+    .replace(/\s+/g, ' ')
+    .trim();
+
+  return crypto.createHash('sha256').update(normalized).digest('hex').substring(0, 16);
+}
+
+

TTL Configuration:

+
    +
  • Development: 24 hours (test address changes)
  • +
  • Production: 7 days (balance freshness vs API quota)
  • +
  • NAR imports: 30 days (addresses rarely change)
  • +
+

Bulk Geocoding Performance

+

Batch Size Tuning:

+
// Small batches: better for rate limits, slower overall
+batchSize: 10, // 1 req/sec = 10 locations per 10s batch
+
+// Large batches: faster, but may hit rate limits
+batchSize: 100, // 50 req/sec = 100 locations per 2s batch
+
+

Optimal Settings:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ProviderBatch SizeDelay Between Batches
Google501s
Mapbox10010s
Nominatim11s (strict rate limit)
Photon500s (self-hosted)
+

Prometheus Metrics:

+
# Cache hit rate (target: >80%)
+rate(cm_geocode_cache_hits_total[5m]) /
+  (rate(cm_geocode_cache_hits_total[5m]) + rate(cm_geocode_cache_misses_total[5m]))
+
+# Provider success rate (target: >95%)
+sum by (provider) (rate(cm_geocode_success_total[5m]))
+
+ +

Backend Modules:

+ +

Frontend Pages:

+ +

Database:

+ +

Features:

+ +

Configuration:

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/map/index.html b/mkdocs/site/v2/features/map/index.html new file mode 100644 index 00000000..16cbb385 --- /dev/null +++ b/mkdocs/site/v2/features/map/index.html @@ -0,0 +1,5566 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Map Module - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Map Module

+

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.

+

Overview

+

The Map module consists of ten integrated components:

+
    +
  1. Locations - Location database with geocoding
  2. +
  3. Geocoding - Multi-provider address → coordinate conversion
  4. +
  5. NAR Import - Canadian electoral data import
  6. +
  7. Cuts - Geographic polygon organization
  8. +
  9. Shifts - Volunteer shift scheduling
  10. +
  11. Canvassing - Door-to-door canvassing system
  12. +
  13. Tracking - GPS tracking sessions
  14. +
  15. Walk Sheets - Printable canvass materials
  16. +
  17. Data Quality - Geocoding quality monitoring
  18. +
  19. Map Features Status - Feature completion tracking
  20. +
+

Features

+

Location Management

+
    +
  • Location CRUD with address, coordinates, metadata
  • +
  • CSV import/export (100,000+ records supported)
  • +
  • Multi-provider geocoding (6 providers)
  • +
  • Bulk geocoding with queue
  • +
  • NAR 2025 server-side import (Canadian electoral data)
  • +
  • Address standardization
  • +
  • Visit tracking integration
  • +
+

Geographic Organization

+
    +
  • Polygon-based geographic cuts
  • +
  • GeoJSON import/export
  • +
  • Point-in-polygon queries
  • +
  • Cut-based location assignment
  • +
  • Spatial bounds calculation
  • +
  • Map visualization
  • +
+

Volunteer Coordination

+
    +
  • Shift scheduling with cut assignment
  • +
  • Volunteer signup (authenticated + anonymous)
  • +
  • Email confirmations
  • +
  • Temp user creation for walk-ins
  • +
  • Shift capacity tracking
  • +
+

Canvassing System

+
    +
  • GPS-enabled mobile interface
  • +
  • Walking route algorithm (nearest-neighbor)
  • +
  • Visit outcome recording (7 outcomes)
  • +
  • Session management (start/end/abandon)
  • +
  • Real-time progress tracking
  • +
  • Admin monitoring dashboard
  • +
  • Printable walk sheets with QR codes
  • +
+

Map Display

+
    +
  • Public interactive Leaflet map
  • +
  • Color-coded markers by visit status
  • +
  • Polygon overlays for cuts
  • +
  • Geolocate button
  • +
  • Fullscreen mode
  • +
  • Legend and controls
  • +
+

User Flow

+

Admin Experience

+
    +
  1. Import Locations (/app/map/locations)
  2. +
  3. Upload CSV or NAR data
  4. +
  5. Geocode addresses
  6. +
  7. Review quality metrics
  8. +
  9. +

    Bulk operations

    +
  10. +
  11. +

    Create Cuts (/app/map/cuts)

    +
  12. +
  13. Draw polygons on map
  14. +
  15. Name and describe cut
  16. +
  17. Assign locations (automatic)
  18. +
  19. +

    Export for printing

    +
  20. +
  21. +

    Schedule Shifts (/app/map/shifts)

    +
  22. +
  23. Create shift with cut assignment
  24. +
  25. Set date/time/capacity
  26. +
  27. Email all volunteers
  28. +
  29. +

    Monitor signups

    +
  30. +
  31. +

    Monitor Canvassing (/app/canvass/dashboard)

    +
  32. +
  33. View active sessions
  34. +
  35. Track visit progress
  36. +
  37. Check leaderboard
  38. +
  39. +

    Review activity feed

    +
  40. +
  41. +

    Print Materials (/app/canvass/walk-sheet)

    +
  42. +
  43. Select cut
  44. +
  45. Generate walk sheet PDF
  46. +
  47. QR codes for quick access
  48. +
  49. Browser print
  50. +
+

Volunteer Experience

+
    +
  1. View Assignments (/volunteer/assignments)
  2. +
  3. See upcoming shifts
  4. +
  5. Cut information
  6. +
  7. +

    Start canvass button

    +
  8. +
  9. +

    Canvass (/volunteer/canvass/:cutId)

    +
  10. +
  11. Full-screen map with GPS
  12. +
  13. Follow walking route
  14. +
  15. Click markers to record visits
  16. +
  17. Select outcomes + notes
  18. +
  19. +

    Track progress

    +
  20. +
  21. +

    Review Activity (/volunteer/activity)

    +
  22. +
  23. Visit history
  24. +
  25. Outcome breakdown
  26. +
  27. Session statistics
  28. +
+

Public Experience

+
    +
  1. View Map (/map)
  2. +
  3. Browse locations
  4. +
  5. View cuts
  6. +
  7. See visit status (color-coded)
  8. +
  9. +

    Geolocate self

    +
  10. +
  11. +

    Sign Up for Shifts (/shifts)

    +
  12. +
  13. Browse available shifts
  14. +
  15. Signup with email
  16. +
  17. Receive confirmation
  18. +
+

Architecture

+

Backend Components

+

Modules: +- api/src/modules/map/locations/ - Location CRUD + geocoding + NAR import +- api/src/modules/map/geocoding/ - Multi-provider geocoding service +- api/src/modules/map/cuts/ - Polygon CRUD + spatial queries +- api/src/modules/map/shifts/ - Shift CRUD + signups +- api/src/modules/map/canvass/ - Session + visit tracking +- api/src/modules/map/tracking/ - GPS tracking (future) +- api/src/modules/map/settings/ - Map settings singleton

+

Services: +- api/src/services/geocoding.service.ts - Geocoding abstraction +- api/src/services/geocode-queue.service.ts - Async geocoding

+

Utilities: +- api/src/utils/spatial.ts - Point-in-polygon, haversine, bounds, centroid

+

Database Models: +- Location - Address, coordinates, metadata, visit tracking +- Cut - Name, GeoJSON polygon +- Shift - Date/time, cut, capacity, signups +- CanvassSession - Session tracking, start/end times +- CanvassVisit - Visit outcomes, notes, GPS +- MapSettings - Map center/zoom, walk sheet config

+

Frontend Components

+

Admin Pages: +- admin/src/pages/LocationsPage.tsx - Location management +- admin/src/pages/CutsPage.tsx - Cut management +- admin/src/pages/ShiftsPage.tsx - Shift management +- admin/src/pages/CanvassDashboardPage.tsx - Canvass monitoring +- admin/src/pages/WalkSheetPage.tsx - Printable materials +- admin/src/pages/DataQualityDashboardPage.tsx - Quality metrics

+

Public Pages: +- admin/src/pages/public/MapPage.tsx - Public map +- admin/src/pages/public/ShiftsPage.tsx - Shift signup

+

Volunteer Pages: +- admin/src/pages/volunteer/VolunteerMapPage.tsx - GPS canvass map +- admin/src/pages/volunteer/VolunteerShiftsPage.tsx - Assignments +- admin/src/pages/volunteer/MyActivityPage.tsx - Activity history

+

Map Components: +- admin/src/components/map/MapControls.tsx - Control buttons +- admin/src/components/map/AddLocationMode.tsx - Click-to-add +- admin/src/components/map/CutDrawingMode.tsx - Polygon drawing +- admin/src/components/map/CutOverlays.tsx - GeoJSON rendering

+

Canvass Components: +- admin/src/components/canvass/GPSTracker.tsx - GPS tracking +- admin/src/components/canvass/WalkingRouteLine.tsx - Route display +- admin/src/components/canvass/VisitRecordingForm.tsx - Outcome form

+

Configuration

+

Environment Variables

+
# Geocoding Providers
+MAPBOX_ACCESS_TOKEN=pk_...
+GOOGLE_GEOCODE_API_KEY=...
+PELIAS_API_URL=http://pelias:4000
+
+# NAR Import
+NAR_DATA_DIR=/data           # NAR file directory (Docker volume)
+
+# Map Settings
+MAP_DEFAULT_LAT=43.65        # Default map center
+MAP_DEFAULT_LNG=-79.38
+MAP_DEFAULT_ZOOM=12
+
+

Map Settings

+

Configurable via admin UI (/app/map/settings): +- Default map center (lat/lng) +- Default zoom level +- Walk sheet header/footer +- Display preferences

+

Geocoding

+

Supported Providers

+
    +
  1. Nominatim (OpenStreetMap) - Free, rate limited
  2. +
  3. ArcGIS - Free tier available
  4. +
  5. Photon - Free, self-hosted option
  6. +
  7. Mapbox - API key required
  8. +
  9. Google Geocoding - API key required
  10. +
  11. Pelias - Self-hosted option
  12. +
+

Geocoding Strategy

+
    +
  1. Try provider 1 (Nominatim)
  2. +
  3. If fails, try provider 2 (ArcGIS)
  4. +
  5. Continue through providers
  6. +
  7. Cache successful results
  8. +
  9. Track quality metrics
  10. +
+

Bulk Geocoding

+
    +
  • BullMQ queue for async processing
  • +
  • Batch processing (100 locations/batch)
  • +
  • Provider rotation to avoid rate limits
  • +
  • Progress tracking
  • +
  • Error handling and retry
  • +
+

NAR Import

+

Canadian electoral data (NAR 2025 format):

+
    +
  • Address files - Civic addresses with coordinates (EPSG:3347)
  • +
  • Location files - Building locations with lat/lng
  • +
  • Join on LOC_GUID - Combine address + coordinates
  • +
  • Server-side streaming - Memory-efficient for large files
  • +
  • Filters - Province, city, postal code, cut, residential-only
  • +
+

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

+

Spatial Algorithms

+

Point-in-Polygon

+

Ray-casting algorithm: +- Count ray intersections with polygon edges +- Odd count = inside, even count = outside +- Supports holes in polygons +- Used for cut assignment

+

Walking Route

+

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

+

Haversine Distance

+

Great-circle distance between coordinates: +- Returns distance in kilometers +- Used for proximity sorting +- Route optimization

+

API Endpoints

+

Locations

+
GET    /api/locations                   # List locations
+POST   /api/locations                   # Create location
+GET    /api/locations/:id               # Get location
+PATCH  /api/locations/:id               # Update location
+DELETE /api/locations/:id               # Delete location
+POST   /api/locations/import            # CSV import
+GET    /api/locations/export            # CSV export
+POST   /api/locations/geocode           # Bulk geocode
+
+

Cuts

+
GET    /api/cuts                        # List cuts
+POST   /api/cuts                        # Create cut
+GET    /api/cuts/:id                    # Get cut
+PATCH  /api/cuts/:id                    # Update cut
+DELETE /api/cuts/:id                    # Delete cut
+POST   /api/cuts/:id/assign-locations   # Assign locations
+
+

Shifts

+
GET    /api/shifts                      # List shifts
+POST   /api/shifts                      # Create shift
+GET    /api/shifts/:id                  # Get shift
+PATCH  /api/shifts/:id                  # Update shift
+DELETE /api/shifts/:id                  # Delete shift
+POST   /api/shifts/:id/signup           # Signup for shift
+
+

Canvassing

+
POST   /api/canvass/session/start       # Start session
+POST   /api/canvass/session/end         # End session
+GET    /api/canvass/session             # Get active session
+POST   /api/canvass/visit               # Record visit
+GET    /api/canvass/route/:cutId        # Get walking route
+GET    /api/canvass/dashboard           # Dashboard stats
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/map/locations/index.html b/mkdocs/site/v2/features/map/locations/index.html new file mode 100644 index 00000000..26999128 --- /dev/null +++ b/mkdocs/site/v2/features/map/locations/index.html @@ -0,0 +1,6944 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Locations - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Location Management System

+

Overview

+

The location management system is the foundation of Changemaker Lite's field organizing capabilities. It provides building-level and unit-level voter/supporter tracking with comprehensive address management, geocoding integration, and Canadian electoral data (NAR) import support.

+

Key Capabilities:

+
    +
  • Building + Unit Architecture: Location (building) has 1:N Address (units) for multi-unit buildings
  • +
  • NAR Integration: Import Canadian electoral data (LOC_GUID, ADDR_GUID from Elections Canada)
  • +
  • Multi-Provider Geocoding: Automatically geocode addresses with confidence scoring
  • +
  • CSV Import/Export: Bulk operations for campaign data management
  • +
  • Support Level Tracking: LEVEL_1 (Strong) → LEVEL_4 (Opposed) classification
  • +
  • Spatial Filtering: Filter locations by polygon cuts or bounding box
  • +
  • History Tracking: Complete audit trail of location changes
  • +
  • Field Data: Sign tracking, building notes, federal district assignment
  • +
+

Use Cases:

+
    +
  • Voter file management for electoral campaigns
  • +
  • Door-to-door canvassing organization
  • +
  • Sign placement tracking (lawn signs, window signs)
  • +
  • Multi-unit building canvassing (apartments, condos)
  • +
  • Federal electoral district mapping
  • +
  • NAR 2025 import for Canadian campaigns
  • +
  • Walk sheet generation for field teams
  • +
+

Architecture

+
graph TD
+    A[Admin User] -->|Manages Locations| B[LocationsPage]
+    B -->|CRUD Operations| C[Locations API]
+    C -->|Save/Query| D[(Location Model)]
+    C -->|Geocode Address| E[Geocoding Service]
+    E -->|Try Providers| F[Multi-Provider Chain]
+    F -->|Cache Result| G[(Redis Cache)]
+
+    H[CSV Import] -->|Parse File| C
+    C -->|Validate| I[Location Service]
+    I -->|Auto-Geocode| E
+    I -->|Create Records| D
+
+    J[NAR Import] -->|Server Stream| K[NAR Import Service]
+    K -->|Join Address+Location| L[Location Files]
+    K -->|Convert Coords| M[proj4 Lambert→WGS84]
+    K -->|Filter| N[Cut/City/Postal]
+    K -->|Bulk Insert| D
+
+    D -->|1:N| O[(Address Model)]
+    D -->|Assigned To| P[(Cut Model)]
+
+    Q[Public Map] -->|GET /api/public/map/locations| C
+    C -->|Filter by Bounds| D
+
+    R[Canvass Session] -->|Load Addresses| C
+    C -->|Point-in-Polygon| S[Spatial Utils]
+
+    style D fill:#e1f5ff
+    style O fill:#e1f5ff
+    style P fill:#e1f5ff
+    style G fill:#fff4e1
+

Flow Description:

+
    +
  1. Admin creates location → Location service validates address and optionally geocodes
  2. +
  3. CSV import → Service parses file, detects format (standard/NAR), geocodes if needed, creates records
  4. +
  5. NAR server import → Streams large files, joins Address+Location CSVs, converts Lambert coords, filters, bulk inserts
  6. +
  7. Public map loads → Location service queries by bounds, returns color-coded markers
  8. +
  9. Canvass session starts → Service loads addresses within cut polygon using ray-casting algorithm
  10. +
  11. Geocoding → Multi-provider chain tries providers in order, caches successful results
  12. +
+

Database Models

+

Location Model

+

See Location Model Documentation for full schema.

+

Key Fields:

+
    +
  • latitude / longitude: WGS84 coordinates (Decimal type for precision)
  • +
  • address: Street address (building level, not including unit numbers)
  • +
  • postalCode: Canadian postal code (A1A 1A1 format)
  • +
  • province: Province code (ON, QC, AB, etc.)
  • +
  • federalDistrict: Federal electoral district name
  • +
  • buildingType: SINGLE_FAMILY | MULTI_UNIT | MIXED_USE | COMMERCIAL
  • +
  • totalUnits: Number of units in building (for multi-unit buildings)
  • +
  • geocodeConfidence: Confidence score 0-100 from geocoding service
  • +
  • geocodeProvider: Google, Mapbox, Nominatim, Photon, LocationIQ, ArcGIS
  • +
  • narLocGuid: NAR LOC_GUID identifier (Canadian electoral data)
  • +
  • buildingNotes: Free-text notes about building access, parking, etc.
  • +
+

NAR-Specific Fields:

+
    +
  • narLocGuid: Location GUID from NAR dataset
  • +
  • buildingUse: Building use code (1=Residential, 2=Commercial, etc.)
  • +
  • postalCode: Extracted from NAR MAIL_POSTAL_CODE
  • +
  • province: Extracted from NAR PROV_CODE
  • +
  • federalDistrict: Extracted from NAR FED_ENG_NAME
  • +
+

Geocoding Fields:

+
    +
  • geocodeConfidence: 0-100 score (>90=high, 70-90=medium, <70=low)
  • +
  • geocodeProvider: Which provider successfully geocoded the address
  • +
  • geocodeAttempts: Number of failed geocoding attempts
  • +
  • lastGeocodeAttempt: Timestamp of last geocoding attempt
  • +
+

Address Model

+

See Address Model Documentation for full schema.

+

Key Fields:

+
    +
  • locationId: Foreign key to Location (building)
  • +
  • unitNumber: Unit/apartment/suite number (optional for single-family)
  • +
  • firstName / lastName: Resident name
  • +
  • email / phone: Contact information
  • +
  • supportLevel: LEVEL_1 (Strong) | LEVEL_2 (Leaning) | LEVEL_3 (Undecided) | LEVEL_4 (Opposed)
  • +
  • sign: Boolean - has lawn/window sign
  • +
  • signSize: Sign size description (e.g., "24x18 lawn", "window")
  • +
  • notes: Free-text notes from canvassing
  • +
  • narAddrGuid: NAR ADDR_GUID identifier
  • +
+

NAR-Specific Fields:

+
    +
  • narAddrGuid: Address GUID from NAR dataset
  • +
  • unitNumber: Extracted from NAR APT_NO_LABEL
  • +
+

Related Models:

+ +

API Endpoints

+

See Locations Backend Module Documentation for full API reference.

+

Admin Endpoints:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/map/locationsMAP_ADMINList locations with pagination, search, filters
GET/api/map/locations/statsMAP_ADMINGet location statistics (total, geocoded, by confidence)
GET/api/map/locations/:idMAP_ADMINGet location details with addresses
POST/api/map/locationsMAP_ADMINCreate new location
PATCH/api/map/locations/:idMAP_ADMINUpdate location
DELETE/api/map/locations/:idMAP_ADMINDelete location (and cascade addresses)
POST/api/map/locations/geocodeMAP_ADMINGeocode single address
POST/api/map/locations/reverse-geocodeMAP_ADMINReverse geocode lat/lng to address
POST/api/map/locations/importMAP_ADMINImport CSV file (standard or NAR format)
GET/api/map/locations/exportMAP_ADMINExport locations to CSV
GET/api/map/locations/:id/historyMAP_ADMINGet location change history
+

Bulk Operations:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
POST/api/map/locations/bulk-geocode/startMAP_ADMINStart bulk geocoding job (BullMQ)
GET/api/map/locations/bulk-geocode/statusMAP_ADMINCheck bulk geocoding job status
POST/api/map/locations/bulk-geocode/cancelMAP_ADMINCancel running bulk geocoding job
+

NAR Import Endpoints:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/map/locations/nar/datasetsMAP_ADMINList available NAR datasets from /data directory
POST/api/map/locations/nar/importMAP_ADMINServer-side streaming NAR import with filters
GET/api/map/locations/nar/import/progressMAP_ADMINGet NAR import progress (polling endpoint)
+

Public Endpoints:

+ + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/public/map/locationsNoneList locations by bounds (for public map)
+

Volunteer Endpoints:

+ + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
PATCH/api/map/canvass/volunteer/locations/:idAny logged-in userUpdate location from canvass session
+

Configuration

+

Environment Variables

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableTypeDefaultDescription
GEOCODING_ENABLEDbooleantrueEnable geocoding services
GEOCODING_CACHE_ENABLEDbooleantrueCache geocoding results in Redis
GEOCODING_CACHE_TTL_HOURSnumber168Cache TTL (7 days)
GEOCODING_PROVIDERSstring[]See geocoding.mdComma-separated provider list
GOOGLE_MAPS_API_KEYstring-Google Geocoding API key
MAPBOX_ACCESS_TOKENstring-Mapbox API token
LOCATIONIQ_API_KEYstring-LocationIQ API key
NAR_DATA_DIRstring/dataDirectory containing NAR CSV files
+

Database Indexes

+

Key indexes for performance:

+
-- Location queries
+CREATE INDEX idx_locations_lat_lng ON "Location" (latitude, longitude);
+CREATE INDEX idx_locations_postal_code ON "Location" ("postalCode");
+CREATE INDEX idx_locations_province ON "Location" (province);
+CREATE INDEX idx_locations_federal_district ON "Location" ("federalDistrict");
+CREATE INDEX idx_locations_geocode_confidence ON "Location" ("geocodeConfidence");
+CREATE INDEX idx_locations_nar_loc_guid ON "Location" ("narLocGuid");
+
+-- Address queries
+CREATE INDEX idx_addresses_location_id ON "Address" ("locationId");
+CREATE INDEX idx_addresses_support_level ON "Address" ("supportLevel");
+CREATE INDEX idx_addresses_nar_addr_guid ON "Address" ("narAddrGuid");
+
+-- Spatial queries (cut assignment)
+CREATE INDEX idx_locations_lat ON "Location" (latitude);
+CREATE INDEX idx_locations_lng ON "Location" (longitude);
+
+

Admin Workflow

+

Creating a Location

+

Step 1: Navigate to Locations Page

+

Navigate to Map → Locations in the admin sidebar.

+

![LocationsPage Screenshot Placeholder]

+

Step 2: Click "Add Location"

+

Click the + Add Location button in the top-right corner.

+

Step 3: Enter Address Information

+

Fill in the location form:

+
    +
  • Address: Street address (e.g., "123 Main Street")
  • +
  • Postal Code: Canadian postal code (e.g., "K1A 0B1")
  • +
  • Building Type: Single Family / Multi-Unit / Mixed Use / Commercial
  • +
  • Total Units: Number of units (for multi-unit buildings)
  • +
  • Building Notes: Access codes, parking info, etc.
  • +
+

Step 4: Auto-Geocode (Optional)

+

Click Geocode button to automatically fetch latitude/longitude coordinates. The system will:

+
    +
  1. Try geocoding providers in order (Google → Mapbox → Nominatim → Photon → LocationIQ → ArcGIS)
  2. +
  3. Return confidence score (0-100)
  4. +
  5. Display formatted address from provider
  6. +
  7. Cache result in Redis for 7 days
  8. +
+

Step 5: Add Addresses (Units)

+

For multi-unit buildings, click Add Address to create unit records:

+
    +
  • Unit Number: Apartment/suite number
  • +
  • First Name / Last Name: Resident name
  • +
  • Support Level: LEVEL_1 (Strong) → LEVEL_4 (Opposed)
  • +
  • Sign: Check if resident has lawn/window sign
  • +
  • Notes: Canvassing notes
  • +
+

Step 6: Save Location

+

Click Create to save the location and addresses.

+

CSV Import Workflow

+

Step 1: Prepare CSV File

+

Prepare a CSV file with the following columns (flexible header names):

+

Standard Format:

+
address,firstName,lastName,email,phone,unitNumber,supportLevel,sign,notes,latitude,longitude
+123 Main St,John,Doe,john@example.com,555-1234,101,LEVEL_1,true,Friendly contact,,
+124 Main St,Jane,Smith,jane@example.com,555-5678,,LEVEL_2,false,Ask about lawn sign,45.4215,-75.6972
+
+

NAR Format (auto-detected if 3+ NAR columns present):

+
CIVIC_NO,OFFICIAL_STREET_NAME,OFFICIAL_STREET_TYPE,APT_NO_LABEL,MAIL_POSTAL_CODE,BG_LATITUDE,BG_LONGITUDE,FED_ENG_NAME
+123,Main,Street,101,K1A 0B1,45.4215,-75.6972,Ottawa Centre
+124,Main,Street,,K1A 0B2,45.4220,-75.6975,Ottawa Centre
+
+

Step 2: Open Import Modal

+

Click Import CSV button on LocationsPage.

+

Step 3: Select Import Format

+

Choose format:

+
    +
  • Standard: General campaign CSV (address, firstName, lastName, supportLevel, etc.)
  • +
  • NAR: National Address Register format (auto-detected)
  • +
  • Server: Server-side NAR streaming import (for large files >100MB)
  • +
+

Step 4: Configure Filters (Optional)

+

Filter imported locations:

+
    +
  • Cut: Import only locations within a polygon
  • +
  • Map Area: Import only locations within current map bounds
  • +
  • City: Filter by city name
  • +
  • Province: Filter by province code (ON, QC, AB, etc.)
  • +
  • Residential Only: Exclude commercial buildings (BU_USE = 1)
  • +
+

Step 5: Upload File

+

Drag-and-drop or click to select CSV file.

+

Step 6: Configure Geocoding

+

Toggle Geocode Missing Coordinates:

+
    +
  • Enabled: Automatically geocode addresses without lat/lng (slower, uses geocoding API quota)
  • +
  • Disabled: Import only records with coordinates (faster, for NAR imports)
  • +
+

Step 7: Review Import Results

+

After import completes, view results:

+
    +
  • Created: Number of new locations created
  • +
  • Skipped: Number of duplicate addresses skipped
  • +
  • Failed: Number of errors (invalid addresses, geocoding failures)
  • +
  • Geocoded: Number of addresses successfully geocoded
  • +
+

NAR Server Import Workflow

+

For large NAR datasets (>100MB), use server-side streaming import:

+

Step 1: Upload NAR Files to Server

+

Copy NAR CSV files to server's /data directory:

+
# Example NAR files for Ontario (province code 35)
+/data/Address_35_part_1.csv
+/data/Address_35_part_2.csv
+/data/Location_35.csv
+
+

Step 2: Open NAR Import Tab

+

Click NAR Import tab on LocationsPage.

+

Step 3: Scan for Datasets

+

Click Scan NAR Directory to detect available datasets. The system will:

+
    +
  • Scan /data directory for Address_.csv and Location_.csv files
  • +
  • Group files by province code (10=NL, 24=QC, 35=ON, 48=AB, etc.)
  • +
  • Display file sizes and counts
  • +
+

Step 4: Select Province

+

Choose province from dropdown (e.g., "35 - Ontario (10.5 GB, 45 files)").

+

Step 5: Configure Filters

+

Apply optional filters:

+
    +
  • City: Filter by MAIL_MUN_NAME or CSD_ENG_NAME
  • +
  • Postal Code Prefix: Filter by first 3 characters (e.g., "K1A")
  • +
  • Cut: Import only addresses within polygon
  • +
  • Residential Only: Exclude commercial buildings (BU_USE != 1)
  • +
+

Step 6: Start Import

+

Click Start Import. The system will:

+
    +
  1. Stream Address CSV files (multi-part files processed sequentially)
  2. +
  3. Join with Location CSV on LOC_GUID
  4. +
  5. Convert BG_X/BG_Y (Lambert projection) to lat/lng (WGS84) using proj4
  6. +
  7. Apply filters (city, postal, cut, residential)
  8. +
  9. Bulk insert locations + addresses (transaction batches of 500)
  10. +
  11. Update progress every 5 seconds
  12. +
+

Step 7: Monitor Progress

+

View real-time progress:

+
    +
  • Records Processed: Current/total count
  • +
  • Progress Percentage: Visual progress bar
  • +
  • ETA: Estimated time remaining
  • +
  • Current File: Which multi-part file is being processed
  • +
+

Step 8: Review Results

+

After import completes:

+
    +
  • Total Created: Number of locations + addresses created
  • +
  • Duration: Total import time
  • +
  • Skipped: Duplicate or filtered records
  • +
+

Bulk Re-Geocoding

+

For locations with missing or low-confidence coordinates:

+

Step 1: Open Bulk Geocode Modal

+

Click Bulk Re-Geocode button on LocationsPage.

+

Step 2: Configure Job Parameters

+

Set parameters:

+
    +
  • Confidence Filter: Re-geocode locations below threshold (e.g., <70)
  • +
  • Missing Only: Only geocode locations without coordinates
  • +
  • Provider: Choose preferred geocoding provider
  • +
  • Batch Size: Number of locations per batch (default: 50)
  • +
+

Step 3: Start Job

+

Click Start Job to queue bulk geocoding job in BullMQ.

+

Step 4: Monitor Progress

+

Poll job status:

+
    +
  • Completed: Number of successfully geocoded locations
  • +
  • Failed: Number of geocoding failures
  • +
  • Progress: Percentage complete
  • +
  • ETA: Estimated time remaining
  • +
+

Step 5: Cancel Job (Optional)

+

Click Cancel Job to stop bulk geocoding.

+

Exporting Locations

+

Step 1: Configure Export Filters

+

Apply filters on LocationsPage:

+
    +
  • Search: Filter by address or notes
  • +
  • Confidence Level: High / Medium / Low / None
  • +
  • Cut: Export locations within specific polygon
  • +
+

Step 2: Click Export CSV

+

Click Export CSV button. The system will:

+
    +
  1. Export locations matching current filters
  2. +
  3. Include all address records (one row per address)
  4. +
  5. Download CSV file with timestamp
  6. +
+

Export Format:

+
locationId,address,latitude,longitude,postalCode,province,federalDistrict,buildingType,totalUnits,geocodeConfidence,geocodeProvider,unitNumber,firstName,lastName,email,phone,supportLevel,sign,signSize,notes
+uuid-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
+
+

Public Workflow

+

Public users can view locations on the interactive map.

+

Step 1: Navigate to Public Map

+

Visit /map (public route, no authentication required).

+

Step 2: Browse Map

+

Interact with Leaflet map:

+
    +
  • Zoom/Pan: Use mouse or touch gestures
  • +
  • Markers: Locations displayed as color-coded circle markers:
  • +
  • Green: LEVEL_1 (Strong support)
  • +
  • Yellow: LEVEL_2 (Leaning support)
  • +
  • Gray: LEVEL_3 (Undecided)
  • +
  • Red: LEVEL_4 (Opposed)
  • +
  • Blue: No support level assigned
  • +
+

Step 3: View Cut Overlays

+

Toggle cut overlays using Cuts control panel:

+
    +
  • Show/Hide: Toggle cut visibility
  • +
  • Opacity: Adjust polygon transparency
  • +
  • Legend: View cut color legend
  • +
+

Step 4: Geolocate

+

Click Geolocate button to center map on current location (requires browser geolocation permission).

+

Step 5: Fullscreen Mode

+

Click Fullscreen button to expand map to full screen.

+

Volunteer Workflow

+

Volunteers can update location data during canvassing sessions.

+

Step 1: Start Canvass Session

+

See Canvassing Documentation for full workflow.

+

Step 2: Record Visit

+

When visiting a location, update fields:

+
    +
  • Support Level: Update based on conversation
  • +
  • Sign: Check if resident wants lawn/window sign
  • +
  • Notes: Add canvassing notes
  • +
+

Step 3: Update Location

+

Click Save Visit to record changes. The system will:

+
    +
  1. Create CanvassVisit record with outcome
  2. +
  3. Update Address with new supportLevel/sign/notes
  4. +
  5. Update Location.lastUpdated timestamp
  6. +
  7. Create LocationHistory audit record
  8. +
+

Code Examples

+

Creating a Location (Frontend)

+
// admin/src/pages/LocationsPage.tsx
+const handleCreate = async (values: any) => {
+  try {
+    const { data } = await api.post<Location>('/map/locations', {
+      address: values.address,
+      postalCode: values.postalCode,
+      buildingType: values.buildingType,
+      totalUnits: values.totalUnits,
+      buildingNotes: values.buildingNotes,
+      latitude: values.latitude,
+      longitude: values.longitude,
+      geocodeConfidence: values.geocodeConfidence,
+      geocodeProvider: values.geocodeProvider,
+    });
+
+    message.success('Location created');
+    setCreateModalOpen(false);
+    createForm.resetFields();
+    fetchLocations();
+  } catch (error) {
+    message.error('Failed to create location');
+  }
+};
+
+

Geocoding an Address (Frontend)

+
// admin/src/pages/LocationsPage.tsx
+const handleGeocode = async () => {
+  const address = createForm.getFieldValue('address');
+  const postalCode = createForm.getFieldValue('postalCode');
+
+  if (!address) {
+    message.warning('Please enter an address first');
+    return;
+  }
+
+  setGeocoding(true);
+  try {
+    const fullAddress = postalCode ? `${address}, ${postalCode}` : address;
+    const { data } = await api.post<GeocodeResult>('/map/locations/geocode', {
+      address: fullAddress,
+    });
+
+    createForm.setFieldsValue({
+      latitude: data.latitude,
+      longitude: data.longitude,
+      geocodeConfidence: data.confidence,
+      geocodeProvider: data.provider,
+    });
+
+    message.success(
+      `Geocoded with ${data.provider} (confidence: ${data.confidence}%)`
+    );
+  } catch (error) {
+    message.error('Geocoding failed');
+  } finally {
+    setGeocoding(false);
+  }
+};
+
+

Location Service Create (Backend)

+
// api/src/modules/map/locations/locations.service.ts
+async create(data: CreateLocationInput, userId: string) {
+  // Auto-geocode if address provided but no coordinates
+  if (data.address && !data.latitude && !data.longitude) {
+    try {
+      const fullAddress = data.postalCode
+        ? `${data.address}, ${data.postalCode}`
+        : data.address;
+      const geocodeResult = await geocodingService.geocode(fullAddress);
+
+      data.latitude = geocodeResult.latitude;
+      data.longitude = geocodeResult.longitude;
+      data.geocodeConfidence = geocodeResult.confidence;
+      data.geocodeProvider = geocodeResult.provider;
+
+      logger.info('Auto-geocoded location', {
+        address: fullAddress,
+        provider: geocodeResult.provider,
+        confidence: geocodeResult.confidence,
+      });
+    } catch (err) {
+      logger.warn('Auto-geocoding failed, creating location without coordinates', err);
+    }
+  }
+
+  const location = await prisma.location.create({
+    data: {
+      address: data.address,
+      latitude: data.latitude,
+      longitude: data.longitude,
+      postalCode: data.postalCode,
+      province: data.province,
+      federalDistrict: data.federalDistrict,
+      buildingType: data.buildingType,
+      totalUnits: data.totalUnits,
+      buildingNotes: data.buildingNotes,
+      geocodeConfidence: data.geocodeConfidence,
+      geocodeProvider: data.geocodeProvider,
+      createdByUserId: userId,
+    },
+  });
+
+  // Create history record
+  await prisma.locationHistory.create({
+    data: {
+      locationId: location.id,
+      action: LocationHistoryAction.CREATED,
+      changedByUserId: userId,
+      changes: JSON.stringify({ created: true }),
+    },
+  });
+
+  recordLocationQuery('create');
+  return location;
+}
+
+

CSV Import Detection (Backend)

+
// api/src/modules/map/locations/locations.service.ts
+function detectNarFormat(headers: string[]): boolean {
+  const normalizedHeaders = headers.map((h) => h.trim().toUpperCase());
+  let matchCount = 0;
+  const matched = new Set<string>();
+
+  // NAR columns to detect (need 3+ matches)
+  const NAR_DETECT_COLUMNS = [
+    'CIVIC_NO', 'OFFICIAL_STREET_NAME', 'OFFICIAL_STREET_TYPE',
+    'BG_X', 'BG_Y', 'MAIL_POSTAL_CODE', 'MAIL_PROV_ABVN',
+    'BG_LATITUDE', 'BG_LONGITUDE',
+  ];
+
+  for (const col of NAR_DETECT_COLUMNS) {
+    if (normalizedHeaders.includes(col) && !matched.has(col)) {
+      matched.add(col);
+      matchCount++;
+    }
+  }
+
+  return matchCount >= 3;
+}
+
+

NAR Lambert Coordinate Conversion (Backend)

+
// api/src/modules/map/locations/locations.service.ts
+import proj4 from 'proj4';
+
+// Statistics Canada Lambert Conformal Conic (EPSG:3347) → WGS84 (EPSG:4326)
+proj4.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'
+);
+
+/** Convert BG_X/BG_Y (EPSG:3347 Lambert) to [lat, lng] (WGS84) */
+function lambertToLatLng(bgX: number, bgY: number): [number, number] {
+  const [lng, lat] = proj4('EPSG:3347', 'EPSG:4326', [bgX, bgY]);
+  return [lat, lng];
+}
+
+// Usage in NAR import
+const [lat, lng] = lambertToLatLng(row.BG_X, row.BG_Y);
+
+

Spatial Filtering by Cut (Backend)

+
// api/src/modules/map/locations/locations.service.ts
+async findByBounds(filters: BoundsQuery) {
+  const where: Prisma.LocationWhereInput = {
+    latitude: {
+      gte: new Prisma.Decimal(filters.minLat),
+      lte: new Prisma.Decimal(filters.maxLat),
+    },
+    longitude: {
+      gte: new Prisma.Decimal(filters.minLng),
+      lte: new Prisma.Decimal(filters.maxLng),
+    },
+  };
+
+  const locations = await prisma.location.findMany({
+    where,
+    select: {
+      id: true,
+      latitude: true,
+      longitude: true,
+      address: true,
+      addresses: {
+        select: {
+          supportLevel: true,
+        },
+      },
+    },
+  });
+
+  // If cut filter provided, apply point-in-polygon
+  if (filters.cutId) {
+    const cut = await prisma.cut.findUnique({
+      where: { id: filters.cutId },
+      select: { geojson: true },
+    });
+
+    if (cut?.geojson) {
+      const polygons = parseGeoJsonPolygon(cut.geojson);
+      return locations.filter((loc) => {
+        const lat = Number(loc.latitude);
+        const lng = Number(loc.longitude);
+        return polygons.some((poly) => isPointInPolygon(lat, lng, poly));
+      });
+    }
+  }
+
+  return locations;
+}
+
+

Troubleshooting

+

Issue: Geocoding Fails for Valid Address

+

Symptoms:

+
    +
  • "Geocoding failed" error message
  • +
  • Location created without coordinates
  • +
  • Low geocode confidence score (<50)
  • +
+

Causes:

+
    +
  • Invalid API key for geocoding provider
  • +
  • Provider quota exceeded
  • +
  • Address format not recognized by provider
  • +
  • Provider service down
  • +
+

Solutions:

+
    +
  1. Check API keys:
  2. +
+
# Verify API keys are set in .env
+grep "GOOGLE_MAPS_API_KEY\|MAPBOX_ACCESS_TOKEN\|LOCATIONIQ_API_KEY" .env
+
+
    +
  1. Test geocoding endpoint directly:
  2. +
+
curl -X POST http://localhost:4000/api/map/locations/geocode \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"address":"123 Main Street, Ottawa, ON K1A 0B1"}'
+
+
    +
  1. Check provider order in env:
  2. +
+
# Try different provider order
+GEOCODING_PROVIDERS=GOOGLE,NOMINATIM,PHOTON,MAPBOX,LOCATIONIQ,ARCGIS
+
+
    +
  1. View API logs:
  2. +
+
docker compose logs -f api | grep geocode
+
+

Issue: NAR Import Fails or Hangs

+

Symptoms:

+
    +
  • NAR import progress stuck at 0%
  • +
  • Import fails with "File not found" error
  • +
  • Import fails with "Invalid coordinates" error
  • +
  • Memory errors during large imports
  • +
+

Causes:

+
    +
  • NAR files not in /data directory
  • +
  • Multi-part files missing (e.g., Address_35_part_2.csv)
  • +
  • Incorrect province code
  • +
  • Invalid BG_X/BG_Y coordinates
  • +
  • Cut polygon filter too complex
  • +
+

Solutions:

+
    +
  1. Verify NAR files exist:
  2. +
+
# Check /data directory in container
+docker compose exec api ls -lh /data
+
+# Verify file naming matches NAR format
+# Address_{PROV_CODE}_part_{N}.csv
+# Location_{PROV_CODE}.csv
+
+
    +
  1. Check province code mapping:
  2. +
+
10 = Newfoundland and Labrador
+24 = Quebec
+35 = Ontario
+48 = Alberta
+59 = British Columbia
+62 = Nunavut
+
+
    +
  1. Test coordinate conversion:
  2. +
+
# Verify proj4 is installed
+docker compose exec api node -e "const proj4 = require('proj4'); console.log(proj4.version);"
+
+
    +
  1. Monitor import progress:
  2. +
+
# Watch API logs during import
+docker compose logs -f api | grep "NAR import"
+
+# Check Redis for progress key
+docker compose exec redis redis-cli GET "NAR_IMPORT_PROGRESS"
+
+
    +
  1. +

    Use smaller filters for testing:

    +
  2. +
  3. +

    Start with single postal code prefix (e.g., "K1A")

    +
  4. +
  5. Use small cut polygon
  6. +
  7. Enable residential-only filter (reduces records by ~50%)
  8. +
+

Issue: Duplicate Locations Created on Import

+

Symptoms:

+
    +
  • Same address appears multiple times in table
  • +
  • Export CSV has duplicate rows
  • +
  • Location count doesn't match expected NAR count
  • +
+

Causes:

+
    +
  • Re-importing same CSV file without checking for duplicates
  • +
  • NAR Address multi-part files have overlapping records
  • +
  • Different LOC_GUID for same physical address (NAR data issue)
  • +
+

Solutions:

+
    +
  1. Use NAR GUID fields for deduplication:
  2. +
+

The system deduplicates by narLocGuid and narAddrGuid:

+
// Check for existing location before creating
+const existing = await prisma.location.findFirst({
+  where: { narLocGuid: row.LOC_GUID },
+});
+
+if (existing) {
+  skipped++;
+  continue;
+}
+
+
    +
  1. Delete duplicates manually:
  2. +
+
-- Find duplicate locations by address
+SELECT address, COUNT(*) as count
+FROM "Location"
+GROUP BY address
+HAVING COUNT(*) > 1;
+
+-- Keep first, delete rest
+DELETE FROM "Location"
+WHERE id NOT IN (
+  SELECT MIN(id)
+  FROM "Location"
+  GROUP BY address
+);
+
+
    +
  1. Use server-side NAR import (better deduplication):
  2. +
+

Server-side import joins Address + Location files on LOC_GUID before inserting, preventing duplicates.

+

Issue: Low Geocode Confidence for NAR Data

+

Symptoms:

+
    +
  • NAR locations have geocodeConfidence < 70
  • +
  • Locations appear in wrong place on map
  • +
  • "Low confidence" warnings in admin
  • +
+

Causes:

+
    +
  • BG_X/BG_Y coordinates missing in NAR Location file
  • +
  • BG_LATITUDE/BG_LONGITUDE used instead of converted Lambert coords
  • +
  • proj4 conversion error
  • +
+

Solutions:

+
    +
  1. Verify coordinate source:
  2. +
+

NAR Location files have TWO coordinate fields:

+
    +
  • BG_LATITUDE / BG_LONGITUDE: Direct WGS84 (use these if available)
  • +
  • +

    BG_X / BG_Y: Lambert Conformal Conic EPSG:3347 (requires conversion)

    +
  • +
  • +

    Use BG_LATITUDE/BG_LONGITUDE if available:

    +
  • +
+
// Priority: use direct WGS84 coords if available
+const lat = row.BG_LATITUDE
+  ? parseFloat(row.BG_LATITUDE)
+  : (row.BG_X && row.BG_Y ? lambertToLatLng(row.BG_X, row.BG_Y)[0] : null);
+
+
    +
  1. Re-geocode low-confidence locations:
  2. +
+

Use bulk re-geocoding feature with confidence filter <70.

+

Performance Considerations

+

Query Optimization

+

Bounding Box Queries:

+

Always use indexed lat/lng queries for map bounds:

+
-- Efficient: uses idx_locations_lat_lng index
+SELECT * FROM "Location"
+WHERE latitude BETWEEN 45.0 AND 46.0
+  AND longitude BETWEEN -76.0 AND -75.0;
+
+-- Inefficient: no index
+SELECT * FROM "Location"
+WHERE ST_Contains(polygon, point); -- PostGIS not used
+
+

Point-in-Polygon:

+

For small result sets (<1000 locations), use application-level ray-casting:

+
// api/src/utils/spatial.ts
+export function isPointInPolygon(
+  lat: number,
+  lng: number,
+  polygonCoords: number[][]
+): boolean {
+  let inside = false;
+  for (let i = 0, j = polygonCoords.length - 1; i < polygonCoords.length; j = i++) {
+    const xi = polygonCoords[i]![1]!; // lat
+    const yi = polygonCoords[i]![0]!; // lng
+    const xj = polygonCoords[j]![1]!;
+    const yj = polygonCoords[j]![0]!;
+
+    const intersect = ((yi > lng) !== (yj > lng)) &&
+      (lat < (xj - xi) * (lng - yi) / (yj - yi) + xi);
+    if (intersect) inside = !inside;
+  }
+  return inside;
+}
+
+

For large result sets (>10,000 locations), consider PostGIS extension.

+

Geocoding Rate Limits

+

Provider Limits:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ProviderFree TierRate Limit
Google$200/month credit50 req/sec
Mapbox100,000/month600 req/min
NominatimUnlimited1 req/sec
PhotonUnlimitedNo limit (self-hosted recommended)
LocationIQ5,000/day2 req/sec
ArcGIS20,000/month50 req/sec
+

Best Practices:

+
    +
  1. Enable Redis caching (default: 7 days TTL)
  2. +
  3. Use bulk geocoding jobs (BullMQ queue with rate limiting)
  4. +
  5. Prefer NAR imports (coordinates included, no geocoding needed)
  6. +
  7. Batch geocoding requests (50 locations per batch)
  8. +
+

NAR Import Performance

+

Large File Streaming:

+

NAR Address files can be 10+ GB. Use server-side streaming to avoid memory issues:

+
// api/src/modules/map/locations/nar-import.service.ts
+import { createReadStream } from 'fs';
+import { parse } from 'csv-parse';
+
+async function streamNarFile(filePath: string) {
+  return new Promise((resolve, reject) => {
+    const stream = createReadStream(filePath)
+      .pipe(parse({ columns: true, skip_empty_lines: true }));
+
+    const batch: any[] = [];
+    const BATCH_SIZE = 500;
+
+    stream.on('data', async (row) => {
+      batch.push(row);
+
+      if (batch.length >= BATCH_SIZE) {
+        stream.pause(); // Backpressure
+        await insertBatch(batch);
+        batch.length = 0;
+        stream.resume();
+      }
+    });
+
+    stream.on('end', async () => {
+      if (batch.length > 0) await insertBatch(batch);
+      resolve(true);
+    });
+
+    stream.on('error', reject);
+  });
+}
+
+

Transaction Batching:

+

Insert locations in transaction batches to improve performance:

+
async function insertBatch(rows: any[]) {
+  await prisma.$transaction(
+    rows.map((row) =>
+      prisma.location.create({
+        data: {
+          address: row.address,
+          latitude: row.latitude,
+          longitude: row.longitude,
+          // ... other fields
+        },
+      })
+    ),
+    { timeout: 30000 } // 30s timeout for large batches
+  );
+}
+
+

Map Rendering Performance

+

Marker Clustering:

+

For maps with >1000 locations, use marker clustering to improve render performance:

+
// admin/src/components/map/AdminMapView.tsx
+import MarkerClusterGroup from 'react-leaflet-cluster';
+
+<MarkerClusterGroup>
+  {locations.map((loc) => (
+    <CircleMarker
+      key={loc.id}
+      center={[loc.latitude, loc.longitude]}
+      radius={8}
+      pathOptions={{ color: getSupportLevelColor(loc.supportLevel) }}
+    />
+  ))}
+</MarkerClusterGroup>
+
+

Viewport Filtering:

+

Only load locations within map bounds + buffer:

+
// admin/src/pages/public/MapPage.tsx
+const handleMapMove = useCallback(
+  debounce(() => {
+    if (!mapRef.current) return;
+
+    const bounds = mapRef.current.getBounds();
+    const buffer = 0.1; // 10% buffer
+
+    fetchLocations({
+      minLat: bounds.getSouth() - buffer,
+      maxLat: bounds.getNorth() + buffer,
+      minLng: bounds.getWest() - buffer,
+      maxLng: bounds.getEast() + buffer,
+    });
+  }, 500),
+  []
+);
+
+ +

Backend Modules:

+ +

Frontend Pages:

+ +

Database:

+ +

Features:

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/map/nar-import/index.html b/mkdocs/site/v2/features/map/nar-import/index.html new file mode 100644 index 00000000..ff030ebc --- /dev/null +++ b/mkdocs/site/v2/features/map/nar-import/index.html @@ -0,0 +1,7930 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NAR Import - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

NAR Import System

+

Overview

+

The National Address Register (NAR) import system enables bulk import of Canadian electoral data from Elections Canada. The system supports the 2025 NAR format with server-side streaming import, coordinate projection conversion, and comprehensive filtering options.

+

Key Features:

+
    +
  • Server-side streaming import (handles large datasets)
  • +
  • NAR 2025 format support (BG_X/BG_Y Lambert projection)
  • +
  • Address + Location file joining on LOC_GUID
  • +
  • Proj4 coordinate conversion (EPSG:3347 → WGS84)
  • +
  • Province selector (13 provinces/territories)
  • +
  • Filtering: city, postal code, cut boundary, residential-only
  • +
  • Multi-part file handling (large provinces)
  • +
  • Progress tracking and error reporting
  • +
  • Import statistics and validation
  • +
+

Use Cases:

+
    +
  • Initial campaign database setup
  • +
  • Electoral district targeting
  • +
  • NAR data updates (new redistribution)
  • +
  • Multi-region campaign expansion
  • +
  • Address database verification
  • +
+

Architecture Highlights:

+
    +
  • Streaming CSV parser (avoids memory limits)
  • +
  • File-based LOC_GUID join
  • +
  • Real-time coordinate projection
  • +
  • Point-in-polygon cut filtering
  • +
  • Transaction batching (500 records/commit)
  • +
  • Duplicate prevention via UPSERT
  • +
+

Architecture

+
flowchart TB
+    subgraph Admin Interface
+        Admin[Admin User]
+        LocationsPage[LocationsPage - NAR Tab]
+    end
+
+    subgraph API Layer
+        DatasetsAPI["/api/locations/nar/datasets"]
+        ImportAPI["/api/locations/nar/import"]
+    end
+
+    subgraph NAR Import Service
+        Scanner[File Scanner]
+        Reader[CSV Stream Reader]
+        Joiner[Address+Location Joiner]
+        Converter[Coordinate Converter]
+        Filter[Filter Pipeline]
+        Importer[Bulk Importer]
+    end
+
+    subgraph File System
+        DataDir[/data/NAR Files]
+        AddressFiles[Address_XX_part_*.csv]
+        LocationFiles[Location_XX.csv]
+    end
+
+    subgraph Database
+        LocationsDB[(Locations)]
+        AddressesDB[(Addresses)]
+    end
+
+    subgraph External Services
+        Proj4[Proj4 Library]
+        EPSG3347[EPSG:3347 Definition]
+    end
+
+    Admin --> LocationsPage
+    LocationsPage --> DatasetsAPI
+    LocationsPage --> ImportAPI
+
+    DatasetsAPI --> Scanner
+    Scanner --> DataDir
+
+    ImportAPI --> Reader
+    Reader --> AddressFiles
+    Reader --> LocationFiles
+
+    Reader --> Joiner
+    Joiner --> Converter
+    Converter --> Proj4
+    Proj4 --> EPSG3347
+
+    Converter --> Filter
+    Filter --> Importer
+    Importer --> LocationsDB
+    Importer --> AddressesDB
+

Data Flow:

+
    +
  1. Dataset Discovery:
  2. +
  3. Scan /data directory for NAR CSV files
  4. +
  5. Group by province code (10-62)
  6. +
  7. Identify multi-part Address files
  8. +
  9. +

    Return available datasets

    +
  10. +
  11. +

    Import Initiation:

    +
  12. +
  13. Admin selects province + filters
  14. +
  15. API creates import job
  16. +
  17. +

    Begins streaming CSV files

    +
  18. +
  19. +

    File Processing:

    +
  20. +
  21. Read Address files (all parts sequentially)
  22. +
  23. Read Location file (parallel)
  24. +
  25. +

    Join on LOC_GUID (in-memory map)

    +
  26. +
  27. +

    Coordinate Conversion:

    +
  28. +
  29. Extract BG_X/BG_Y from Location file
  30. +
  31. Convert EPSG:3347 → WGS84 using Proj4
  32. +
  33. +

    Fallback to BG_LATITUDE/BG_LONGITUDE if conversion fails

    +
  34. +
  35. +

    Filtering:

    +
  36. +
  37. City filter (exact match on MUNICIPALITY)
  38. +
  39. Postal code filter (prefix match)
  40. +
  41. Cut filter (point-in-polygon)
  42. +
  43. +

    Residential filter (BU_USE = 1)

    +
  44. +
  45. +

    Database Import:

    +
  46. +
  47. UPSERT Locations by locGuid (prevent duplicates)
  48. +
  49. INSERT Addresses with foreign key
  50. +
  51. Batch commits (500 records)
  52. +
  53. Track progress and errors
  54. +
+

NAR File Format

+

File Structure

+

Directory Layout: +

/data/
+├── Address_10.csv                  # Newfoundland
+├── Address_11.csv                  # PEI
+├── Address_12.csv                  # Nova Scotia
+├── Address_13.csv                  # New Brunswick
+├── Address_24_part_1.csv           # Quebec (multi-part)
+├── Address_24_part_2.csv
+├── Address_24_part_3.csv
+├── Address_24_part_4.csv
+├── Address_24_part_5.csv
+├── Address_24_part_6.csv
+├── Address_35_part_1.csv           # Ontario (multi-part)
+├── Address_35_part_2.csv
+├── ...
+├── Location_10.csv
+├── Location_11.csv
+├── Location_12.csv
+├── Location_13.csv
+├── Location_24.csv
+├── Location_35.csv
+└── ...
+

+

Address File Schema

+

File: Address_XX_part_Y.csv

+
ADDR_GUID,LOC_GUID,CIVIC_NO,OFFICIAL_STREET_NAME,POSTAL_CODE,MUNICIPALITY,PROVINCE_CODE
+{uuid},{uuid},123,MAIN ST,M5H2N2,TORONTO,35
+{uuid},{uuid},125,MAIN ST,M5H2N2,TORONTO,35
+{uuid},{uuid},127,MAIN ST,M5H2N2,TORONTO,35
+
+

Key Fields:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescriptionExample
ADDR_GUIDUUIDUnique address identifier{12345678-...}
LOC_GUIDUUIDLocation identifier (FK){87654321-...}
CIVIC_NOStringStreet number123, 123A, 123-125
OFFICIAL_STREET_NAMEStringStreet name (uppercase)MAIN ST, YONGE ST
POSTAL_CODEStringCanadian postal code (no space)M5H2N2, K1A0B1
MUNICIPALITYStringCity/town nameTORONTO, OTTAWA
PROVINCE_CODEIntegerProvince code (10-62)35 (Ontario)
+

Record Count: +- Small provinces: 10k-50k addresses +- Medium provinces: 50k-200k addresses +- Large provinces: 200k-1M+ addresses (multi-part files)

+

Location File Schema

+

File: Location_XX.csv

+
LOC_GUID,BG_LATITUDE,BG_LONGITUDE,BG_X,BG_Y,FED_NUM,BU_USE,MUNICIPALITY
+{uuid},43.6532,-79.3832,1234567.89,234567.89,35001,1,TORONTO
+{uuid},43.6540,-79.3825,1234600.00,234600.00,35001,1,TORONTO
+
+

Key Fields:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescriptionExample
LOC_GUIDUUIDUnique location identifier{87654321-...}
BG_LATITUDEFloatLatitude (WGS84)43.6532
BG_LONGITUDEFloatLongitude (WGS84)-79.3832
BG_XFloatX coord (EPSG:3347 Lambert)1234567.89
BG_YFloatY coord (EPSG:3347 Lambert)234567.89
FED_NUMStringFederal electoral district35001, 24050
BU_USEIntegerBuilding use code1 = Residential
MUNICIPALITYStringCity/town nameTORONTO
+

Coordinate Systems:

+
    +
  • BG_LATITUDE/BG_LONGITUDE: WGS84 decimal degrees (EPSG:4326)
  • +
  • BG_X/BG_Y: Statistics Canada Lambert Conformal Conic (EPSG:3347)
  • +
  • 2025 NAR Change: Primary coordinates shifted from lat/lng to BG_X/BG_Y
  • +
+

Building Use Codes:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CodeDescription
1Residential
2Commercial
3Industrial
4Institutional
5Parks/Recreation
9Other
+

Database Models

+

Location Model Extensions

+
model Location {
+  id          Int      @id @default(autoincrement())
+  address     String
+  latitude    Float?
+  longitude   Float?
+  postalCode  String?
+  province    String?
+
+  // NAR-specific fields
+  locGuid           String?  @unique  // NAR LOC_GUID (UUID)
+  federalDistrict   String?           // NAR FED_NUM
+  buildingUse       Int?              // NAR BU_USE code
+  municipality      String?           // NAR MUNICIPALITY
+
+  // Geocoding metadata (populated during import)
+  geocodeConfidence Int?     @default(100)  // NAR = high confidence
+  geocodeProvider   String?  @default("NAR")
+  geocodedAt        DateTime?
+
+  addresses   Address[]
+
+  createdAt   DateTime @default(now())
+  updatedAt   DateTime @updatedAt
+
+  @@index([locGuid])
+  @@index([federalDistrict])
+  @@index([buildingUse])
+  @@index([postalCode])
+}
+
+

Address Model Extensions

+
model Address {
+  id         Int      @id @default(autoincrement())
+  locationId Int
+  location   Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
+
+  // NAR-specific fields
+  addrGuid    String?  @unique  // NAR ADDR_GUID (UUID)
+  unitNumber  String?           // NAR CIVIC_NO (if multi-unit)
+
+  // Voter data (future)
+  firstName    String?
+  lastName     String?
+  supportLevel Int?
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@index([locationId])
+  @@index([addrGuid])
+}
+
+

UPSERT Strategy:

+
// Prevent duplicates on re-import
+const location = await prisma.location.upsert({
+  where: { locGuid: narRecord.LOC_GUID },
+  update: {
+    address: narRecord.addressString,
+    latitude: coords.latitude,
+    longitude: coords.longitude,
+    postalCode: narRecord.POSTAL_CODE,
+    province: provinceMap[narRecord.PROVINCE_CODE],
+    federalDistrict: narRecord.FED_NUM,
+    buildingUse: narRecord.BU_USE,
+    municipality: narRecord.MUNICIPALITY,
+    geocodeProvider: 'NAR',
+    geocodedAt: new Date()
+  },
+  create: {
+    locGuid: narRecord.LOC_GUID,
+    address: narRecord.addressString,
+    latitude: coords.latitude,
+    longitude: coords.longitude,
+    postalCode: narRecord.POSTAL_CODE,
+    province: provinceMap[narRecord.PROVINCE_CODE],
+    federalDistrict: narRecord.FED_NUM,
+    buildingUse: narRecord.BU_USE,
+    municipality: narRecord.MUNICIPALITY,
+    geocodeConfidence: 100,
+    geocodeProvider: 'NAR',
+    geocodedAt: new Date()
+  }
+});
+
+

API Endpoints

+

GET /api/locations/nar/datasets

+

Scan NAR data directory and return available province datasets.

+

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

+

Response: +

{
+  "datasets": [
+    {
+      "provinceCode": "10",
+      "provinceName": "Newfoundland and Labrador",
+      "addressFiles": ["Address_10.csv"],
+      "locationFile": "Location_10.csv",
+      "addressFileCount": 1,
+      "estimatedRecords": 15000,
+      "lastModified": "2025-01-15T00:00:00Z"
+    },
+    {
+      "provinceCode": "24",
+      "provinceName": "Quebec",
+      "addressFiles": [
+        "Address_24_part_1.csv",
+        "Address_24_part_2.csv",
+        "Address_24_part_3.csv",
+        "Address_24_part_4.csv",
+        "Address_24_part_5.csv",
+        "Address_24_part_6.csv"
+      ],
+      "locationFile": "Location_24.csv",
+      "addressFileCount": 6,
+      "estimatedRecords": 850000,
+      "lastModified": "2025-01-20T00:00:00Z"
+    },
+    {
+      "provinceCode": "35",
+      "provinceName": "Ontario",
+      "addressFiles": [
+        "Address_35_part_1.csv",
+        "Address_35_part_2.csv",
+        "Address_35_part_3.csv"
+      ],
+      "locationFile": "Location_35.csv",
+      "addressFileCount": 3,
+      "estimatedRecords": 1200000,
+      "lastModified": "2025-01-22T00:00:00Z"
+    }
+  ],
+  "dataDir": "/data",
+  "totalDatasets": 13
+}
+

+

Implementation:

+
// nar-import.service.ts
+
+async scanDatasets(): Promise<NARDataset[]> {
+  const files = await fs.readdir(NAR_DATA_DIR);
+
+  // Group files by province code
+  const provinceGroups: Record<string, { address: string[], location: string }> = {};
+
+  files.forEach(file => {
+    const addressMatch = file.match(/^Address_(\d+)(?:_part_\d+)?\.csv$/);
+    const locationMatch = file.match(/^Location_(\d+)\.csv$/);
+
+    if (addressMatch) {
+      const code = addressMatch[1];
+      if (!provinceGroups[code]) provinceGroups[code] = { address: [], location: '' };
+      provinceGroups[code].address.push(file);
+    } else if (locationMatch) {
+      const code = locationMatch[1];
+      if (!provinceGroups[code]) provinceGroups[code] = { address: [], location: '' };
+      provinceGroups[code].location = file;
+    }
+  });
+
+  // Build dataset objects
+  const datasets: NARDataset[] = [];
+
+  for (const [code, group] of Object.entries(provinceGroups)) {
+    if (group.address.length === 0 || !group.location) continue;
+
+    const stats = await fs.stat(path.join(NAR_DATA_DIR, group.location));
+
+    datasets.push({
+      provinceCode: code,
+      provinceName: PROVINCE_NAMES[code],
+      addressFiles: group.address.sort(),
+      locationFile: group.location,
+      addressFileCount: group.address.length,
+      estimatedRecords: await this.estimateRecordCount(group.address),
+      lastModified: stats.mtime.toISOString()
+    });
+  }
+
+  return datasets.sort((a, b) => a.provinceCode.localeCompare(b.provinceCode));
+}
+
+

POST /api/locations/nar/import

+

Start NAR import job with filters.

+

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

+

Request Body: +

{
+  "provinceCode": "35",
+  "city": "TORONTO",
+  "postalCodePrefix": "M5",
+  "cutId": 42,
+  "residentialOnly": true
+}
+

+

Parameters:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDescription
provinceCodestringYesProvince code (10-62)
citystringNoFilter by MUNICIPALITY (exact match, uppercase)
postalCodePrefixstringNoFilter by postal code prefix (e.g., "M5", "K1A")
cutIdnumberNoFilter by cut boundary (point-in-polygon)
residentialOnlybooleanNoOnly import BU_USE = 1 (default: false)
+

Response: +

{
+  "jobId": "nar-import-35-20250213-103000",
+  "status": "processing",
+  "provinceCode": "35",
+  "provinceName": "Ontario",
+  "filters": {
+    "city": "TORONTO",
+    "postalCodePrefix": "M5",
+    "cutId": 42,
+    "residentialOnly": true
+  },
+  "startedAt": "2025-02-13T10:30:00Z",
+  "estimatedCompletion": "2025-02-13T10:45:00Z"
+}
+

+

GET /api/locations/nar/import/:jobId

+

Check import job progress.

+

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

+

Response (In Progress): +

{
+  "jobId": "nar-import-35-20250213-103000",
+  "status": "processing",
+  "progress": {
+    "total": 1200000,
+    "processed": 600000,
+    "imported": 580000,
+    "skipped": 15000,
+    "errors": 5000,
+    "percent": 50.0
+  },
+  "currentFile": "Address_35_part_2.csv",
+  "startedAt": "2025-02-13T10:30:00Z",
+  "estimatedCompletion": "2025-02-13T10:45:00Z"
+}
+

+

Response (Complete): +

{
+  "jobId": "nar-import-35-20250213-103000",
+  "status": "completed",
+  "result": {
+    "total": 1200000,
+    "processed": 1200000,
+    "imported": 1150000,
+    "skipped": 45000,
+    "errors": 5000,
+    "percent": 100.0
+  },
+  "statistics": {
+    "locationsCreated": 800000,
+    "locationsUpdated": 350000,
+    "addressesCreated": 1150000,
+    "avgConfidence": 100,
+    "processingTime": "14m 32s"
+  },
+  "startedAt": "2025-02-13T10:30:00Z",
+  "completedAt": "2025-02-13T10:44:32Z"
+}
+

+

Status Values: +- queued: Job created, waiting to start +- processing: Import in progress +- completed: Import finished successfully +- failed: Import failed with errors +- cancelled: Import cancelled by user

+

Configuration

+

Environment Variables

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableTypeDefaultDescription
NAR_DATA_DIRstring/dataDirectory containing NAR CSV files
NAR_BATCH_SIZEnumber500Records per database transaction
NAR_IMPORT_TIMEOUTnumber3600000Import timeout in ms (1 hour)
+

Province Codes

+

Complete mapping of NAR province codes:

+
// nar-import.service.ts
+
+const PROVINCE_NAMES: Record<string, string> = {
+  '10': 'Newfoundland and Labrador',
+  '11': 'Prince Edward Island',
+  '12': 'Nova Scotia',
+  '13': 'New Brunswick',
+  '24': 'Quebec',
+  '35': 'Ontario',
+  '46': 'Manitoba',
+  '47': 'Saskatchewan',
+  '48': 'Alberta',
+  '59': 'British Columbia',
+  '60': 'Yukon',
+  '61': 'Northwest Territories',
+  '62': 'Nunavut'
+};
+
+const PROVINCE_ABBREVIATIONS: Record<string, string> = {
+  '10': 'NL',
+  '11': 'PE',
+  '12': 'NS',
+  '13': 'NB',
+  '24': 'QC',
+  '35': 'ON',
+  '46': 'MB',
+  '47': 'SK',
+  '48': 'AB',
+  '59': 'BC',
+  '60': 'YT',
+  '61': 'NT',
+  '62': 'NU'
+};
+
+

Coordinate Projection

+

EPSG:3347 Definition (Statistics Canada Lambert Conformal Conic):

+
import proj4 from 'proj4';
+
+// Define EPSG:3347 projection
+proj4.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');
+
+// Convert function
+const convertCoordinates = (bgX: number, bgY: number): [number, number] => {
+  // Input: [X, Y] in EPSG:3347 (meters)
+  // Output: [longitude, latitude] in WGS84 (degrees)
+  return proj4('EPSG:3347', 'WGS84', [bgX, bgY]);
+};
+
+

Projection Parameters:

+
    +
  • Type: Lambert Conformal Conic
  • +
  • Standard Parallels: 49°N, 77°N
  • +
  • Central Meridian: -91.866667°
  • +
  • Origin: 63.390675°N, -91.866667°W
  • +
  • False Easting: 6,200,000 m
  • +
  • False Northing: 3,000,000 m
  • +
  • Ellipsoid: GRS80
  • +
  • Units: Meters
  • +
+

Example Conversion:

+
// Toronto City Hall coordinates
+const bgX = 609091.8;  // EPSG:3347 X
+const bgY = 4834610.7; // EPSG:3347 Y
+
+const [lng, lat] = proj4('EPSG:3347', 'WGS84', [bgX, bgY]);
+// Result: lng = -79.3832, lat = 43.6532
+
+

Import Workflow

+

Prepare NAR Files

+

Step 1: Download NAR Data

+
    +
  1. Visit Elections Canada NAR portal: https://www.elections.ca/NAR
  2. +
  3. Select "2025 National Address Register"
  4. +
  5. Download province-specific CSV files
  6. +
  7. Extract ZIP archives
  8. +
+

Step 2: Upload Files to Server

+
# Create data directory if not exists
+mkdir -p /path/to/data
+
+# Upload files via SCP
+scp Address_35_*.csv user@server:/path/to/data/
+scp Location_35.csv user@server:/path/to/data/
+
+# Or mount volume in Docker
+# docker-compose.yml:
+volumes:
+  - ./data:/data:ro
+
+

Step 3: Verify File Integrity

+
# Check file count
+ls -l /path/to/data/Address_35_*.csv | wc -l
+
+# Check Location file exists
+ls -l /path/to/data/Location_35.csv
+
+# Sample first few rows
+head -5 /path/to/data/Address_35_part_1.csv
+head -5 /path/to/data/Location_35.csv
+
+

Run Import via Admin UI

+

Step 1: Navigate to NAR Import Tab

+
    +
  1. Log in as SUPER_ADMIN or MAP_ADMIN
  2. +
  3. Click MapLocations in sidebar
  4. +
  5. Click NAR Import tab
  6. +
  7. Available datasets load automatically
  8. +
+

Step 2: Select Province

+
┌─────────────────────────────────────────┐
+│ Available NAR Datasets                  │
+├─────────────────────────────────────────┤
+│ Province         │ Files │ Records      │
+├──────────────────┼───────┼──────────────┤
+│ Ontario (35)     │   3   │ 1,200,000    │
+│ Quebec (24)      │   6   │   850,000    │
+│ Alberta (48)     │   2   │   450,000    │
+└──────────────────┴───────┴──────────────┘
+
+[Select Province: Ontario ▼]
+
+

Step 3: Configure Filters (Optional)

+
Filters (Optional):
+
+City:                [TORONTO          ]
+  Filter by exact municipality name (uppercase)
+
+Postal Code Prefix:  [M5               ]
+  Filter by postal code prefix (2-3 chars)
+
+Cut Boundary:        [Downtown Core ▼  ]
+  Only import locations within cut polygon
+
+☑ Residential Only
+  Only import buildings with BU_USE = 1
+
+

Step 4: Review Import Summary

+
Import Summary:
+
+Province:      Ontario (35)
+Files:         Address_35_part_1.csv
+               Address_35_part_2.csv
+               Address_35_part_3.csv
+               Location_35.csv
+
+Filters:
+  City:               TORONTO
+  Postal Code:        M5
+  Cut:                Downtown Core
+  Residential Only:   Yes
+
+Estimated Records:  ~50,000 (after filters)
+Estimated Time:     ~3 minutes
+
+[Cancel] [Start Import]
+
+

Step 5: Monitor Progress

+
Import in Progress...
+
+Current File: Address_35_part_2.csv
+Progress: 600,000 / 1,200,000 (50%)
+
+[████████████░░░░░░░░░░░░] 50%
+
+Statistics:
+  Processed:  600,000
+  Imported:   580,000
+  Skipped:    15,000
+  Errors:     5,000
+
+[Cancel Import]
+
+

Step 6: Review Results

+
Import Complete!
+
+Final Statistics:
+  Total Processed:     1,200,000
+  Successfully Imported: 1,150,000
+  Skipped (Filters):      45,000
+  Errors:                  5,000
+
+Details:
+  Locations Created:    800,000
+  Locations Updated:    350,000
+  Addresses Created:  1,150,000
+
+  Processing Time:      14m 32s
+  Avg Records/Second:   1,375
+
+[View Import Log] [Import Another Province] [Close]
+
+

Import via API

+

Step 1: Get Available Datasets

+
curl -X GET http://localhost:4000/api/locations/nar/datasets \
+  -H "Authorization: Bearer $TOKEN"
+
+

Step 2: Start Import

+
curl -X POST http://localhost:4000/api/locations/nar/import \
+  -H "Authorization: Bearer $TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "provinceCode": "35",
+    "city": "TORONTO",
+    "postalCodePrefix": "M5",
+    "residentialOnly": true
+  }'
+
+

Step 3: Poll Job Status

+
JOB_ID="nar-import-35-20250213-103000"
+
+while true; do
+  STATUS=$(curl -s -X GET \
+    http://localhost:4000/api/locations/nar/import/$JOB_ID \
+    -H "Authorization: Bearer $TOKEN" \
+    | jq -r '.status')
+
+  if [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ]; then
+    break
+  fi
+
+  sleep 5
+done
+
+# Get final result
+curl -X GET http://localhost:4000/api/locations/nar/import/$JOB_ID \
+  -H "Authorization: Bearer $TOKEN" | jq
+
+

Coordinate Conversion

+

Proj4 Integration

+

Installation:

+
npm install proj4
+# TypeScript types included in package
+
+

Service Implementation:

+
// nar-import.service.ts
+
+import proj4 from 'proj4';
+
+// Define EPSG:3347 (Statistics Canada Lambert)
+proj4.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'
+);
+
+interface Coordinates {
+  latitude: number;
+  longitude: number;
+}
+
+class NARImportService {
+  /**
+   * Convert NAR BG_X/BG_Y (EPSG:3347) to WGS84 lat/lng
+   */
+  convertCoordinates(bgX: number, bgY: number): Coordinates | null {
+    try {
+      // Validate inputs
+      if (!bgX || !bgY || bgX < 0 || bgY < 0) {
+        logger.warn('Invalid BG_X/BG_Y coordinates:', { bgX, bgY });
+        return null;
+      }
+
+      // Convert: EPSG:3347 → WGS84
+      const [longitude, latitude] = proj4('EPSG:3347', 'WGS84', [bgX, bgY]);
+
+      // Validate output (Canada bounds)
+      if (
+        latitude < 41.0 || latitude > 84.0 ||   // Canada latitude range
+        longitude < -141.0 || longitude > -52.0 // Canada longitude range
+      ) {
+        logger.warn('Converted coordinates outside Canada:', { latitude, longitude });
+        return null;
+      }
+
+      return { latitude, longitude };
+    } catch (error) {
+      logger.error('Coordinate conversion failed:', error);
+      return null;
+    }
+  }
+
+  /**
+   * Get coordinates from NAR record (try BG_X/BG_Y, fallback to lat/lng)
+   */
+  getCoordinates(narLocation: NARLocationRecord): Coordinates | null {
+    // Primary: Convert BG_X/BG_Y
+    if (narLocation.BG_X && narLocation.BG_Y) {
+      const coords = this.convertCoordinates(narLocation.BG_X, narLocation.BG_Y);
+      if (coords) return coords;
+    }
+
+    // Fallback: Use BG_LATITUDE/BG_LONGITUDE directly
+    if (narLocation.BG_LATITUDE && narLocation.BG_LONGITUDE) {
+      return {
+        latitude: narLocation.BG_LATITUDE,
+        longitude: narLocation.BG_LONGITUDE
+      };
+    }
+
+    return null;
+  }
+}
+
+

Conversion Examples

+

Example 1: Toronto City Hall

+
const bgX = 609091.8;
+const bgY = 4834610.7;
+
+const coords = convertCoordinates(bgX, bgY);
+// Result: { latitude: 43.6532, longitude: -79.3832 }
+
+

Example 2: Parliament Hill, Ottawa

+
const bgX = 447384.4;
+const bgY = 5030660.5;
+
+const coords = convertCoordinates(bgX, bgY);
+// Result: { latitude: 45.4236, longitude: -75.7009 }
+
+

Example 3: Invalid Coordinates

+
const bgX = -1000;  // Negative (invalid)
+const bgY = 0;      // Zero (invalid)
+
+const coords = convertCoordinates(bgX, bgY);
+// Result: null
+
+

Validation

+

Canada Bounds Check:

+
const isWithinCanada = (lat: number, lng: number): boolean => {
+  return (
+    lat >= 41.0 && lat <= 84.0 &&     // Latitude: Pelee Island to Alert
+    lng >= -141.0 && lng <= -52.0     // Longitude: Yukon to Newfoundland
+  );
+};
+
+

Precision Check:

+
// NAR coordinates should have 2-6 decimal places
+const hasValidPrecision = (value: number): boolean => {
+  const str = value.toString();
+  const decimals = str.split('.')[1]?.length || 0;
+  return decimals >= 2 && decimals <= 6;
+};
+
+

Multi-Part File Handling

+

Large Province Processing

+

Quebec (Province Code 24): +- 6 Address files: Address_24_part_1.csv through Address_24_part_6.csv +- 1 Location file: Location_24.csv +- Total records: ~850,000

+

Ontario (Province Code 35): +- 3 Address files: Address_35_part_1.csv through Address_35_part_3.csv +- 1 Location file: Location_35.csv +- Total records: ~1,200,000

+

Sequential File Reading

+
// nar-import.service.ts
+
+async processAddressFiles(provinceCode: string): Promise<Map<string, AddressRecord[]>> {
+  const addressMap = new Map<string, AddressRecord[]>();
+
+  // Find all Address files for province
+  const files = await fs.readdir(NAR_DATA_DIR);
+  const addressFiles = files
+    .filter(f => f.match(new RegExp(`^Address_${provinceCode}(?:_part_\\d+)?\\.csv$`)))
+    .sort(); // Ensure part_1, part_2, ... order
+
+  logger.info(`Processing ${addressFiles.length} address files for province ${provinceCode}`);
+
+  // Process each file sequentially
+  for (const file of addressFiles) {
+    logger.info(`Reading ${file}...`);
+
+    const filePath = path.join(NAR_DATA_DIR, file);
+    const stream = fs.createReadStream(filePath);
+    const parser = stream.pipe(csvParser());
+
+    let rowCount = 0;
+
+    for await (const row of parser) {
+      const locGuid = row.LOC_GUID;
+
+      if (!addressMap.has(locGuid)) {
+        addressMap.set(locGuid, []);
+      }
+
+      addressMap.get(locGuid)!.push({
+        addrGuid: row.ADDR_GUID,
+        civicNo: row.CIVIC_NO,
+        streetName: row.OFFICIAL_STREET_NAME,
+        postalCode: row.POSTAL_CODE,
+        municipality: row.MUNICIPALITY
+      });
+
+      rowCount++;
+
+      if (rowCount % 10000 === 0) {
+        logger.debug(`Processed ${rowCount} addresses from ${file}`);
+      }
+    }
+
+    logger.info(`Completed ${file}: ${rowCount} addresses`);
+  }
+
+  logger.info(`Total unique locations: ${addressMap.size}`);
+  return addressMap;
+}
+
+

Memory Management

+

Streaming Strategy:

+
// Process files in chunks to avoid memory overflow
+async processInChunks(
+  addressMap: Map<string, AddressRecord[]>,
+  locationFile: string,
+  batchSize: number = 500
+): Promise<ImportResult> {
+  const locationPath = path.join(NAR_DATA_DIR, locationFile);
+  const stream = fs.createReadStream(locationPath);
+  const parser = stream.pipe(csvParser());
+
+  let batch: LocationImport[] = [];
+  let stats = { imported: 0, skipped: 0, errors: 0 };
+
+  for await (const row of parser) {
+    const locGuid = row.LOC_GUID;
+    const addresses = addressMap.get(locGuid);
+
+    if (!addresses || addresses.length === 0) {
+      stats.skipped++;
+      continue;
+    }
+
+    // Apply filters
+    if (!this.passesFilters(row, addresses)) {
+      stats.skipped++;
+      continue;
+    }
+
+    // Convert coordinates
+    const coords = this.getCoordinates(row);
+    if (!coords) {
+      stats.errors++;
+      continue;
+    }
+
+    batch.push({ location: row, addresses, coords });
+
+    // Import batch when full
+    if (batch.length >= batchSize) {
+      await this.importBatch(batch);
+      stats.imported += batch.length;
+      batch = [];
+    }
+  }
+
+  // Import remaining
+  if (batch.length > 0) {
+    await this.importBatch(batch);
+    stats.imported += batch.length;
+  }
+
+  return stats;
+}
+
+

Batch Transaction:

+
async importBatch(batch: LocationImport[]): Promise<void> {
+  await prisma.$transaction(async (tx) => {
+    for (const item of batch) {
+      // Upsert location
+      const location = await tx.location.upsert({
+        where: { locGuid: item.location.LOC_GUID },
+        update: {
+          address: this.formatAddress(item.addresses[0]),
+          latitude: item.coords.latitude,
+          longitude: item.coords.longitude,
+          postalCode: item.addresses[0].postalCode,
+          federalDistrict: item.location.FED_NUM,
+          buildingUse: parseInt(item.location.BU_USE),
+          municipality: item.location.MUNICIPALITY,
+          geocodedAt: new Date()
+        },
+        create: {
+          locGuid: item.location.LOC_GUID,
+          address: this.formatAddress(item.addresses[0]),
+          latitude: item.coords.latitude,
+          longitude: item.coords.longitude,
+          postalCode: item.addresses[0].postalCode,
+          federalDistrict: item.location.FED_NUM,
+          buildingUse: parseInt(item.location.BU_USE),
+          municipality: item.location.MUNICIPALITY,
+          geocodeConfidence: 100,
+          geocodeProvider: 'NAR',
+          geocodedAt: new Date()
+        }
+      });
+
+      // Insert addresses
+      for (const addr of item.addresses) {
+        await tx.address.upsert({
+          where: { addrGuid: addr.addrGuid },
+          update: { locationId: location.id },
+          create: {
+            addrGuid: addr.addrGuid,
+            locationId: location.id,
+            unitNumber: addr.civicNo
+          }
+        });
+      }
+    }
+  });
+}
+
+

Code Examples

+

LocationsPage - NAR Import Tab

+
// LocationsPage.tsx
+
+import React, { useEffect, useState } from 'react';
+import { Tabs, Table, Button, Select, Input, Checkbox, Card, Progress, message } from 'antd';
+import { UploadOutlined } from '@ant-design/icons';
+import { api } from '@/lib/api';
+
+const NARImportTab: React.FC = () => {
+  const [datasets, setDatasets] = useState<NARDataset[]>([]);
+  const [selectedProvince, setSelectedProvince] = useState<string | null>(null);
+  const [filters, setFilters] = useState({
+    city: '',
+    postalCodePrefix: '',
+    cutId: null as number | null,
+    residentialOnly: true
+  });
+  const [importing, setImporting] = useState(false);
+  const [progress, setProgress] = useState<ImportProgress | null>(null);
+  const [jobId, setJobId] = useState<string | null>(null);
+
+  useEffect(() => {
+    fetchDatasets();
+  }, []);
+
+  useEffect(() => {
+    if (jobId && importing) {
+      const interval = setInterval(pollProgress, 2000);
+      return () => clearInterval(interval);
+    }
+  }, [jobId, importing]);
+
+  const fetchDatasets = async () => {
+    try {
+      const { data } = await api.get<{ datasets: NARDataset[] }>('/locations/nar/datasets');
+      setDatasets(data.datasets);
+    } catch (error) {
+      message.error('Failed to load NAR datasets');
+    }
+  };
+
+  const pollProgress = async () => {
+    if (!jobId) return;
+
+    try {
+      const { data } = await api.get(`/locations/nar/import/${jobId}`);
+
+      if (data.status === 'completed') {
+        setImporting(false);
+        setProgress(null);
+        message.success(`Import complete! Imported ${data.result.imported} locations.`);
+      } else if (data.status === 'failed') {
+        setImporting(false);
+        setProgress(null);
+        message.error('Import failed. Check logs for details.');
+      } else {
+        setProgress(data.progress);
+      }
+    } catch (error) {
+      message.error('Failed to fetch import progress');
+    }
+  };
+
+  const startImport = async () => {
+    if (!selectedProvince) {
+      message.warning('Please select a province');
+      return;
+    }
+
+    try {
+      const { data } = await api.post('/locations/nar/import', {
+        provinceCode: selectedProvince,
+        ...filters
+      });
+
+      setJobId(data.jobId);
+      setImporting(true);
+      message.info('Import started...');
+    } catch (error) {
+      message.error('Failed to start import');
+    }
+  };
+
+  const datasetColumns = [
+    { title: 'Province', dataIndex: 'provinceName', key: 'name' },
+    { title: 'Files', dataIndex: 'addressFileCount', key: 'files' },
+    { title: 'Estimated Records', dataIndex: 'estimatedRecords', key: 'records',
+      render: (val: number) => val.toLocaleString() },
+    { title: 'Last Modified', dataIndex: 'lastModified', key: 'modified',
+      render: (val: string) => new Date(val).toLocaleDateString() }
+  ];
+
+  return (
+    <div>
+      <Card title="Available NAR Datasets" style={{ marginBottom: 24 }}>
+        <Table
+          dataSource={datasets}
+          columns={datasetColumns}
+          rowKey="provinceCode"
+          pagination={false}
+          onRow={(record) => ({
+            onClick: () => setSelectedProvince(record.provinceCode),
+            style: {
+              cursor: 'pointer',
+              backgroundColor: selectedProvince === record.provinceCode ? '#e6f7ff' : undefined
+            }
+          })}
+        />
+      </Card>
+
+      {selectedProvince && (
+        <Card title="Import Configuration">
+          <div style={{ marginBottom: 16 }}>
+            <label>Province: </label>
+            <strong>{datasets.find(d => d.provinceCode === selectedProvince)?.provinceName}</strong>
+          </div>
+
+          <div style={{ marginBottom: 16 }}>
+            <label>City (Optional): </label>
+            <Input
+              style={{ width: 300 }}
+              placeholder="TORONTO"
+              value={filters.city}
+              onChange={e => setFilters({ ...filters, city: e.target.value.toUpperCase() })}
+            />
+          </div>
+
+          <div style={{ marginBottom: 16 }}>
+            <label>Postal Code Prefix (Optional): </label>
+            <Input
+              style={{ width: 200 }}
+              placeholder="M5"
+              value={filters.postalCodePrefix}
+              onChange={e => setFilters({ ...filters, postalCodePrefix: e.target.value.toUpperCase() })}
+            />
+          </div>
+
+          <div style={{ marginBottom: 16 }}>
+            <Checkbox
+              checked={filters.residentialOnly}
+              onChange={e => setFilters({ ...filters, residentialOnly: e.target.checked })}
+            >
+              Residential Only
+            </Checkbox>
+          </div>
+
+          <Button
+            type="primary"
+            icon={<UploadOutlined />}
+            onClick={startImport}
+            loading={importing}
+            disabled={importing}
+          >
+            Start Import
+          </Button>
+        </Card>
+      )}
+
+      {importing && progress && (
+        <Card title="Import Progress" style={{ marginTop: 24 }}>
+          <Progress percent={progress.percent} status="active" />
+          <div style={{ marginTop: 16 }}>
+            <p>Processed: {progress.processed.toLocaleString()} / {progress.total.toLocaleString()}</p>
+            <p>Imported: {progress.imported.toLocaleString()}</p>
+            <p>Skipped: {progress.skipped.toLocaleString()}</p>
+            <p>Errors: {progress.errors.toLocaleString()}</p>
+          </div>
+        </Card>
+      )}
+    </div>
+  );
+};
+
+

NAR Import Service - Full Implementation

+
// nar-import.service.ts
+
+import fs from 'fs/promises';
+import path from 'path';
+import csvParser from 'csv-parser';
+import proj4 from 'proj4';
+import { prisma } from '@/config/database';
+import { logger } from '@/utils/logger';
+
+// Define EPSG:3347
+proj4.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'
+);
+
+const NAR_DATA_DIR = process.env.NAR_DATA_DIR || '/data';
+const BATCH_SIZE = parseInt(process.env.NAR_BATCH_SIZE || '500');
+
+interface NARAddressRecord {
+  ADDR_GUID: string;
+  LOC_GUID: string;
+  CIVIC_NO: string;
+  OFFICIAL_STREET_NAME: string;
+  POSTAL_CODE: string;
+  MUNICIPALITY: string;
+}
+
+interface NARLocationRecord {
+  LOC_GUID: string;
+  BG_LATITUDE?: number;
+  BG_LONGITUDE?: number;
+  BG_X?: number;
+  BG_Y?: number;
+  FED_NUM: string;
+  BU_USE: string;
+  MUNICIPALITY: string;
+}
+
+export class NARImportService {
+  async importProvince(
+    provinceCode: string,
+    filters: {
+      city?: string;
+      postalCodePrefix?: string;
+      cutId?: number;
+      residentialOnly?: boolean;
+    }
+  ): Promise<ImportResult> {
+    logger.info(`Starting NAR import for province ${provinceCode}`, { filters });
+
+    // Load address files into memory map
+    const addressMap = await this.loadAddressFiles(provinceCode, filters);
+
+    // Process location file and import
+    const result = await this.processLocationFile(provinceCode, addressMap, filters);
+
+    logger.info(`NAR import complete for province ${provinceCode}`, result);
+    return result;
+  }
+
+  private async loadAddressFiles(
+    provinceCode: string,
+    filters: { city?: string; postalCodePrefix?: string }
+  ): Promise<Map<string, NARAddressRecord[]>> {
+    const addressMap = new Map<string, NARAddressRecord[]>();
+
+    const files = await fs.readdir(NAR_DATA_DIR);
+    const addressFiles = files
+      .filter(f => f.match(new RegExp(`^Address_${provinceCode}(?:_part_\\d+)?\\.csv$`)))
+      .sort();
+
+    for (const file of addressFiles) {
+      logger.info(`Reading ${file}...`);
+      const filePath = path.join(NAR_DATA_DIR, file);
+      const stream = require('fs').createReadStream(filePath);
+      const parser = stream.pipe(csvParser());
+
+      for await (const row of parser) {
+        // Apply filters
+        if (filters.city && row.MUNICIPALITY !== filters.city) continue;
+        if (filters.postalCodePrefix && !row.POSTAL_CODE.startsWith(filters.postalCodePrefix)) continue;
+
+        const locGuid = row.LOC_GUID;
+        if (!addressMap.has(locGuid)) {
+          addressMap.set(locGuid, []);
+        }
+        addressMap.get(locGuid)!.push(row);
+      }
+    }
+
+    logger.info(`Loaded ${addressMap.size} unique locations`);
+    return addressMap;
+  }
+
+  private async processLocationFile(
+    provinceCode: string,
+    addressMap: Map<string, NARAddressRecord[]>,
+    filters: { cutId?: number; residentialOnly?: boolean }
+  ): Promise<ImportResult> {
+    const locationFile = `Location_${provinceCode}.csv`;
+    const filePath = path.join(NAR_DATA_DIR, locationFile);
+    const stream = require('fs').createReadStream(filePath);
+    const parser = stream.pipe(csvParser());
+
+    let batch: any[] = [];
+    const stats = { imported: 0, skipped: 0, errors: 0, total: 0 };
+
+    for await (const row of parser) {
+      stats.total++;
+
+      const locGuid = row.LOC_GUID;
+      const addresses = addressMap.get(locGuid);
+
+      if (!addresses || addresses.length === 0) {
+        stats.skipped++;
+        continue;
+      }
+
+      // Residential filter
+      if (filters.residentialOnly && parseInt(row.BU_USE) !== 1) {
+        stats.skipped++;
+        continue;
+      }
+
+      // Convert coordinates
+      const coords = this.getCoordinates(row);
+      if (!coords) {
+        stats.errors++;
+        continue;
+      }
+
+      // Cut filter (if specified)
+      if (filters.cutId) {
+        const cut = await prisma.cut.findUnique({ where: { id: filters.cutId } });
+        if (cut && !this.isPointInPolygon([coords.longitude, coords.latitude], cut.geojson)) {
+          stats.skipped++;
+          continue;
+        }
+      }
+
+      batch.push({ location: row, addresses, coords });
+
+      if (batch.length >= BATCH_SIZE) {
+        await this.importBatch(batch);
+        stats.imported += batch.length;
+        batch = [];
+      }
+    }
+
+    if (batch.length > 0) {
+      await this.importBatch(batch);
+      stats.imported += batch.length;
+    }
+
+    return stats;
+  }
+
+  private getCoordinates(row: NARLocationRecord): { latitude: number; longitude: number } | null {
+    // Try BG_X/BG_Y conversion
+    if (row.BG_X && row.BG_Y) {
+      try {
+        const [lng, lat] = proj4('EPSG:3347', 'WGS84', [row.BG_X, row.BG_Y]);
+        if (lat >= 41 && lat <= 84 && lng >= -141 && lng <= -52) {
+          return { latitude: lat, longitude: lng };
+        }
+      } catch (error) {
+        logger.warn('Coordinate conversion failed:', error);
+      }
+    }
+
+    // Fallback to BG_LATITUDE/BG_LONGITUDE
+    if (row.BG_LATITUDE && row.BG_LONGITUDE) {
+      return { latitude: row.BG_LATITUDE, longitude: row.BG_LONGITUDE };
+    }
+
+    return null;
+  }
+
+  private async importBatch(batch: any[]): Promise<void> {
+    await prisma.$transaction(async (tx) => {
+      for (const item of batch) {
+        const location = await tx.location.upsert({
+          where: { locGuid: item.location.LOC_GUID },
+          update: {
+            address: this.formatAddress(item.addresses[0]),
+            latitude: item.coords.latitude,
+            longitude: item.coords.longitude,
+            postalCode: item.addresses[0].POSTAL_CODE,
+            federalDistrict: item.location.FED_NUM,
+            buildingUse: parseInt(item.location.BU_USE),
+            municipality: item.location.MUNICIPALITY
+          },
+          create: {
+            locGuid: item.location.LOC_GUID,
+            address: this.formatAddress(item.addresses[0]),
+            latitude: item.coords.latitude,
+            longitude: item.coords.longitude,
+            postalCode: item.addresses[0].POSTAL_CODE,
+            federalDistrict: item.location.FED_NUM,
+            buildingUse: parseInt(item.location.BU_USE),
+            municipality: item.location.MUNICIPALITY,
+            geocodeConfidence: 100,
+            geocodeProvider: 'NAR'
+          }
+        });
+
+        for (const addr of item.addresses) {
+          await tx.address.upsert({
+            where: { addrGuid: addr.ADDR_GUID },
+            update: {},
+            create: {
+              addrGuid: addr.ADDR_GUID,
+              locationId: location.id,
+              unitNumber: addr.CIVIC_NO
+            }
+          });
+        }
+      }
+    });
+  }
+
+  private formatAddress(addr: NARAddressRecord): string {
+    return `${addr.CIVIC_NO} ${addr.OFFICIAL_STREET_NAME}`.trim();
+  }
+
+  private isPointInPolygon(point: [number, number], geojson: any): boolean {
+    // Point-in-polygon implementation
+    // (Same as in spatial.ts)
+    return true; // Placeholder
+  }
+}
+
+

Troubleshooting

+

Problem: No datasets found

+

Symptoms: +- GET /api/locations/nar/datasets returns empty array +- "No datasets available" message in admin

+

Solutions:

+
    +
  1. +

    Verify NAR_DATA_DIR path: +

    echo $NAR_DATA_DIR
    +ls -la /data
    +

    +
  2. +
  3. +

    Check Docker volume mount: +

    # docker-compose.yml
    +services:
    +  api:
    +    volumes:
    +      - ./data:/data:ro
    +

    +
  4. +
  5. +

    Verify file naming convention: +

    # Correct:
    +Address_35_part_1.csv
    +Location_35.csv
    +
    +# Incorrect:
    +address_35.csv  # Lowercase
    +Addresses_35.csv  # Plural
    +Address35.csv  # No underscore
    +

    +
  6. +
  7. +

    Check file permissions: +

    chmod 644 /data/Address_*.csv
    +chmod 644 /data/Location_*.csv
    +

    +
  8. +
+

Problem: Coordinate conversion errors

+

Symptoms: +- Many locations skipped during import +- "Converted coordinates outside Canada" warnings +- Null latitude/longitude in database

+

Solutions:

+
    +
  1. +

    Verify BG_X/BG_Y values: +

    // Valid range for Canada (EPSG:3347):
    +// BG_X: ~400,000 to 3,000,000
    +// BG_Y: ~4,600,000 to 9,000,000
    +
    +console.log('BG_X:', narRecord.BG_X);  // Should be 6-7 digits
    +console.log('BG_Y:', narRecord.BG_Y);  // Should be 7 digits
    +

    +
  2. +
  3. +

    Test with known coordinates: +

    // Toronto City Hall
    +const [lng, lat] = proj4('EPSG:3347', 'WGS84', [609091.8, 4834610.7]);
    +console.log('Expected: 43.6532, -79.3832');
    +console.log('Got:', lat, lng);
    +

    +
  4. +
  5. +

    Fallback to BG_LATITUDE/BG_LONGITUDE: +

    // If BG_X/BG_Y missing or invalid, use lat/lng directly
    +if (!coords && narRecord.BG_LATITUDE && narRecord.BG_LONGITUDE) {
    +  coords = {
    +    latitude: narRecord.BG_LATITUDE,
    +    longitude: narRecord.BG_LONGITUDE
    +  };
    +}
    +

    +
  6. +
  7. +

    Check proj4 definition: +

    npm list proj4
    +# Ensure version 2.8.0+
    +

    +
  8. +
+

Problem: Import very slow (> 30min for 100k records)

+

Symptoms: +- Import hangs on large provinces +- Memory usage grows over time +- Database connection timeouts

+

Solutions:

+
    +
  1. +

    Increase batch size: +

    NAR_BATCH_SIZE=1000  # Default: 500
    +

    +
  2. +
  3. +

    Use streaming instead of loading all addresses: +

    // DON'T do this (loads all into memory):
    +const allAddresses = await readAllAddressFiles();
    +
    +// DO this (stream and process incrementally):
    +for await (const addressBatch of streamAddressFiles()) {
    +  processBatch(addressBatch);
    +}
    +

    +
  4. +
  5. +

    Optimize database indexes: +

    CREATE INDEX CONCURRENTLY idx_locations_loc_guid ON "Location"(locGuid);
    +CREATE INDEX CONCURRENTLY idx_addresses_addr_guid ON "Address"(addrGuid);
    +

    +
  6. +
  7. +

    Disable geocoding during import: +

    // Skip geocoding service since NAR already has coordinates
    +geocodeConfidence: 100,
    +geocodeProvider: 'NAR'
    +// No call to geocodingService.geocode()
    +

    +
  8. +
  9. +

    Use worker threads for parallel processing: +

    import { Worker } from 'worker_threads';
    +
    +const workers = [];
    +for (let i = 0; i < 4; i++) {
    +  const worker = new Worker('./nar-import-worker.js');
    +  workers.push(worker);
    +}
    +

    +
  10. +
+

Problem: Duplicate LOC_GUID errors

+

Symptoms: +- Unique constraint violation on locGuid +- Import fails mid-process +- "Duplicate key value violates unique constraint" error

+

Solutions:

+
    +
  1. +

    Use UPSERT instead of INSERT: +

    await prisma.location.upsert({
    +  where: { locGuid: narRecord.LOC_GUID },
    +  update: { /* update fields */ },
    +  create: { /* create fields */ }
    +});
    +

    +
  2. +
  3. +

    Check for corrupt NAR files: +

    # Count unique LOC_GUIDs
    +cut -d, -f2 Address_35_part_1.csv | sort | uniq | wc -l
    +
    +# Check for duplicates
    +cut -d, -f2 Address_35_part_1.csv | sort | uniq -d
    +

    +
  4. +
  5. +

    Clean up partial imports: +

    -- Delete locations from failed import
    +DELETE FROM "Location" WHERE "geocodeProvider" = 'NAR' AND "createdAt" > '2025-02-13';
    +

    +
  6. +
  7. +

    Implement transaction rollback on error: +

    try {
    +  await prisma.$transaction(async (tx) => {
    +    // Import batch
    +  });
    +} catch (error) {
    +  logger.error('Batch failed, rolling back:', error);
    +  // Transaction automatically rolled back
    +}
    +

    +
  8. +
+

Performance Considerations

+

Import Speed

+

Benchmarks:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ProvinceRecordsFilesTimeRecords/Second
PEI (11)15,000112s1,250
Nova Scotia (12)85,00011m 10s1,214
Quebec (24)850,000611m 20s1,250
Ontario (35)1,200,000314m 30s1,379
+

Factors: +- Batch size: 500 (optimal for most systems) +- Coordinate conversion: ~0.1ms per record +- Database write: ~0.5ms per location (depends on disk speed) +- Total overhead: ~0.7ms per record

+

Memory Usage

+

Peak Memory: +- Address map (in-memory): ~200MB per 100k records +- CSV parser buffer: ~10MB +- Batch buffer: ~5MB (500 records) +- Total: ~220MB per 100k records

+

Optimization: +- Stream address files instead of loading all +- Process location file in chunks +- Clear batch after each commit +- Limit concurrent transactions

+

Database Load

+

Transaction Rate: +- 1 transaction per batch (500 records) +- ~2-3 transactions/second +- Low database CPU (~10-20%) +- Moderate disk I/O (sequential writes)

+

Connection Pool: +

// prisma/schema.prisma
+datasource db {
+  url = env("DATABASE_URL")
+  connection_limit = 10
+}
+

+ +

Backend Documentation

+
    +
  • NAR Import Service: api/src/modules/map/locations/nar-import.service.ts
  • +
  • File scanning
  • +
  • Streaming CSV parser
  • +
  • Coordinate conversion
  • +
  • +

    Batch import

    +
  • +
  • +

    NAR Import Routes: api/src/modules/map/locations/nar-import.routes.ts

    +
  • +
  • Dataset discovery
  • +
  • Import job creation
  • +
  • +

    Progress tracking

    +
  • +
  • +

    Locations Service: api/src/modules/map/locations/locations.service.ts

    +
  • +
  • Location CRUD
  • +
  • Geocoding integration
  • +
+

Frontend Documentation

+
    +
  • Locations Page: admin/src/pages/LocationsPage.tsx
  • +
  • NAR Import tab
  • +
  • Dataset selection
  • +
  • Filter configuration
  • +
  • Progress monitoring
  • +
+

Database Documentation

+
    +
  • Location Model: api/prisma/schema.prisma
  • +
  • NAR-specific fields
  • +
  • locGuid unique constraint
  • +
  • +

    Federal district index

    +
  • +
  • +

    Address Model: api/prisma/schema.prisma

    +
  • +
  • addrGuid unique constraint
  • +
  • Location foreign key
  • +
+

External Resources

+
    +
  • Elections Canada NAR: https://www.elections.ca/content.aspx?section=res&dir=cir/tech/nar&document=index&lang=e
  • +
  • EPSG:3347 Definition: https://epsg.io/3347
  • +
  • Proj4 Documentation: https://github.com/proj4js/proj4js
  • +
  • NAR Data Dictionary: Elections Canada NAR Technical Documentation (PDF)
  • +
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/map/shifts/index.html b/mkdocs/site/v2/features/map/shifts/index.html new file mode 100644 index 00000000..efca0fcb --- /dev/null +++ b/mkdocs/site/v2/features/map/shifts/index.html @@ -0,0 +1,6620 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Shifts - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Volunteer Shift Management

+

Overview

+

The shifts system enables campaigns to organize volunteer activities with time-based scheduling, capacity management, and cut assignment. It supports public shift signup with automatic TEMP user creation for unauthenticated volunteers.

+

Key Capabilities:

+
    +
  • Shift Scheduling: Date, start/end times (HH:MM format), location
  • +
  • Capacity Management: Max volunteers, auto-status updates (OPEN → FULL)
  • +
  • Cut Assignment: Link shifts to geographic cuts for territory-based organizing
  • +
  • Public Signup: Unauthenticated users can signup (creates TEMP user)
  • +
  • Email Confirmations: Auto-send confirmation emails on signup
  • +
  • Signup Tracking: Source tracking (AUTHENTICATED, PUBLIC, ADMIN)
  • +
  • Status Lifecycle: OPEN, FULL, CANCELLED workflow
  • +
  • Bulk Operations: Email all volunteers, export signups CSV
  • +
+

Use Cases:

+
    +
  • Canvassing shift scheduling
  • +
  • Phone bank volunteer coordination
  • +
  • Event volunteer management
  • +
  • Door-knocking territory assignment
  • +
  • Get-out-the-vote (GOTV) shifts
  • +
  • Public volunteer recruitment
  • +
  • Volunteer confirmation emails
  • +
+

Architecture

+
graph TD
+    A[Admin] -->|Creates Shift| B[ShiftsPage]
+    B -->|POST /api/map/shifts| C[Shifts Service]
+    C -->|Validate| D[Shift Model]
+    D -->|Linked To| E[Cut Model]
+
+    F[Public User] -->|Browse Shifts| G[Public ShiftsPage]
+    G -->|GET /api/public/map/shifts| C
+    C -->|Filter upcoming=true| D
+
+    F -->|Signup| H[Signup Modal]
+    H -->|POST /api/public/map/shifts/:id/signup| C
+    C -->|Check Capacity| D
+    C -->|Create TEMP User| I[User Service]
+    C -->|Create Signup| J[ShiftSignup Model]
+    C -->|Send Email| K[Email Service]
+
+    L[Volunteer] -->|View Assignments| M[VolunteerShiftsPage]
+    M -->|GET /api/map/canvass/volunteer/assignments| N[Canvass Service]
+    N -->|Filter by userId| J
+    N -->|Include Cut| E
+
+    D -->|1:N| J
+    D -->|N:1| E
+
+    style D fill:#e1f5ff
+    style J fill:#e1f5ff
+    style E fill:#e1f5ff
+    style I fill:#e8f5e9
+

Flow Description:

+
    +
  1. Admin creates shift → Validates date/time, assigns cut (optional), saves to database
  2. +
  3. Public user browses → Query upcoming shifts (isPublic=true, date >=today), display cards
  4. +
  5. Public signup → Check capacity, create TEMP user if unauthenticated, create signup record, send confirmation email
  6. +
  7. Volunteer views assignments → Query signups for current user, include shift + cut details
  8. +
  9. Shift capacity check → Auto-update status to FULL when currentVolunteers >= maxVolunteers
  10. +
+

Database Models

+

Shift Model

+

See Shift Model Documentation for full schema.

+

Key Fields:

+
    +
  • title: Shift name (e.g., "Saturday Canvassing - Downtown")
  • +
  • description: Free-text shift details
  • +
  • date: Shift date (Date type, not DateTime)
  • +
  • startTime: Start time in HH:MM format (24-hour)
  • +
  • endTime: End time in HH:MM format (24-hour)
  • +
  • location: Meeting point address/description
  • +
  • maxVolunteers: Maximum volunteer capacity
  • +
  • currentVolunteers: Current signup count (auto-updated)
  • +
  • status: OPEN | FULL | CANCELLED
  • +
  • isPublic: Show on public shifts page
  • +
  • cutId: Optional foreign key to Cut (territory assignment)
  • +
  • createdBy: User ID who created shift
  • +
+

Status Enum:

+
enum ShiftStatus {
+  OPEN       // Accepting signups
+  FULL       // At capacity
+  CANCELLED  // Cancelled by admin
+}
+
+

ShiftSignup Model

+

See ShiftSignup Model Documentation for full schema.

+

Key Fields:

+
    +
  • shiftId: Foreign key to Shift
  • +
  • userId: Foreign key to User (optional for TEMP users)
  • +
  • userEmail: Email address (required, used for confirmations)
  • +
  • userName: Display name
  • +
  • userPhone: Phone number (optional)
  • +
  • status: CONFIRMED | CANCELLED | NO_SHOW
  • +
  • signupDate: When signup occurred
  • +
  • signupSource: AUTHENTICATED | PUBLIC | ADMIN
  • +
  • notes: Admin notes about signup
  • +
+

Signup Source Enum:

+
enum SignupSource {
+  AUTHENTICATED  // Logged-in user signup
+  PUBLIC         // Public signup (creates TEMP user)
+  ADMIN          // Admin created signup
+}
+
+

Signup Status Enum:

+
enum SignupStatus {
+  CONFIRMED  // Signup active
+  CANCELLED  // Volunteer cancelled
+  NO_SHOW    // Marked as no-show by admin
+}
+
+

Related Models:

+
    +
  • Shift — Parent shift
  • +
  • User — Volunteer account (TEMP role for public signups)
  • +
  • Cut — Geographic territory assignment
  • +
  • CanvassSession — Linked to shift for canvassing
  • +
+

API Endpoints

+

See Shifts Backend Module Documentation for full API reference.

+

Admin Endpoints:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/map/shiftsMAP_ADMINList shifts with pagination, search, filters
GET/api/map/shifts/statsMAP_ADMINGet shift statistics (total, upcoming, by status)
GET/api/map/shifts/:idMAP_ADMINGet shift details with signups
POST/api/map/shiftsMAP_ADMINCreate new shift
PATCH/api/map/shifts/:idMAP_ADMINUpdate shift
DELETE/api/map/shifts/:idMAP_ADMINDelete shift (cascade signups)
POST/api/map/shifts/:id/signupsMAP_ADMINManually add signup
PATCH/api/map/shifts/:id/signups/:signupIdMAP_ADMINUpdate signup (change status, notes)
DELETE/api/map/shifts/:id/signups/:signupIdMAP_ADMINDelete signup
POST/api/map/shifts/:id/email-volunteersMAP_ADMINSend email to all shift volunteers
+

Public Endpoints:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/public/map/shiftsNoneList upcoming public shifts (isPublic=true, date >=today)
GET/api/public/map/shifts/:idNoneGet public shift details
POST/api/public/map/shifts/:id/signupNonePublic signup (creates TEMP user if unauthenticated)
+

Volunteer Endpoints:

+ + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/map/canvass/volunteer/assignmentsAny logged-in userGet shifts user signed up for
DELETE/api/map/shifts/:id/signups/cancelAny logged-in userCancel own signup
+

Configuration

+

Environment Variables

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableTypeDefaultDescription
EMAIL_TEST_MODEbooleanfalseSend confirmation emails to MailHog (dev)
SMTP_HOSTstring-SMTP server for confirmation emails
SMTP_PORTnumber587SMTP port
SMTP_USERstring-SMTP username
SMTP_PASSWORDstring-SMTP password
+

Email Templates

+

Shift Confirmation Email:

+

Subject: Shift Confirmation - {{shift.title}}

+

Body: +

Hi {{userName}},
+
+You're confirmed for:
+{{shift.title}}
+
+Date: {{shift.date}}
+Time: {{shift.startTime}} - {{shift.endTime}}
+Location: {{shift.location}}
+
+{{#if shift.cut}}
+Territory: {{shift.cut.name}}
+{{/if}}
+
+{{#if shift.description}}
+Details:
+{{shift.description}}
+{{/if}}
+
+To cancel your signup, reply to this email.
+
+Thank you!
+

+

Admin Email All Volunteers:

+

Subject: Configurable by admin

+

Body: Configurable by admin (supports {{name}}, {{email}}, {{phone}} placeholders)

+

Admin Workflow

+

Creating a Shift

+

Step 1: Navigate to Shifts Page

+

Navigate to Map → Shifts in the admin sidebar.

+

![ShiftsPage Screenshot Placeholder]

+

Step 2: Click "Add Shift"

+

Click + Add Shift button in the top-right corner.

+

Step 3: Fill Shift Form

+

Complete shift details:

+
    +
  • Title: "Saturday Canvassing - Ward 5"
  • +
  • Description: "Door-knocking downtown, meet at campaign office"
  • +
  • Date: Select date from calendar
  • +
  • Start Time: "09:00" (24-hour format)
  • +
  • End Time: "12:00"
  • +
  • Location: "123 Campaign Office, Main St"
  • +
  • Max Volunteers: 20
  • +
  • Cut: Select from dropdown (optional)
  • +
  • Is Public: Toggle to show on public shifts page
  • +
+

Step 4: Save Shift

+

Click Create to save shift. Status is automatically set to OPEN.

+

Managing Signups

+

Step 1: View Shift

+

Click Signups button on a shift row to open signups drawer.

+

Step 2: View Signup List

+

Drawer displays:

+
    +
  • Volunteer Name: From signup or user account
  • +
  • Email: Contact email
  • +
  • Phone: Contact phone (if provided)
  • +
  • Signup Date: When volunteer signed up
  • +
  • Source: AUTHENTICATED | PUBLIC | ADMIN
  • +
  • Status: CONFIRMED | CANCELLED | NO_SHOW
  • +
+

Step 3: Manually Add Signup (Admin)

+

Click Add Signup button in drawer:

+
    +
  • Email: Required (validates format)
  • +
  • Name: Required
  • +
  • Phone: Optional
  • +
  • Notes: Admin notes
  • +
+

System will:

+
    +
  1. Check capacity (reject if FULL)
  2. +
  3. Create TEMP user if email not in database
  4. +
  5. Create signup with source=ADMIN
  6. +
  7. Send confirmation email
  8. +
  9. Update shift.currentVolunteers count
  10. +
+

Step 4: Mark No-Show

+

Click Mark No-Show on signup row to update status. Useful for tracking volunteer reliability.

+

Step 5: Delete Signup

+

Click Delete to remove signup. Decrements shift.currentVolunteers count.

+

Emailing All Volunteers

+

Step 1: Click "Email All"

+

On shift row, click Email All button.

+

Step 2: Compose Email

+

Modal opens with:

+
    +
  • Subject: Pre-filled with shift title
  • +
  • Message: Rich text editor with placeholders
  • +
  • Placeholders: {{name}}, {{email}}, {{phone}}, {{shift.title}}, {{shift.date}}, {{shift.startTime}}, {{shift.endTime}}
  • +
+

Step 3: Preview

+

Click Preview to see sample email with placeholders replaced.

+

Step 4: Send

+

Click Send Email to queue emails to all CONFIRMED volunteers. Uses BullMQ email queue for async processing.

+

Updating Shift Status

+

Step 1: Edit Shift

+

Click Edit on shift row.

+

Step 2: Change Status

+

Update status dropdown:

+
    +
  • OPEN: Accepting signups
  • +
  • FULL: At capacity (auto-set when currentVolunteers >= maxVolunteers)
  • +
  • CANCELLED: Cancelled by admin
  • +
+

Step 3: Save

+

Click Update. If status changed to CANCELLED, optionally send cancellation email to all volunteers.

+

Public Workflow

+

Public users can browse and signup for shifts without authentication.

+

Step 1: Navigate to Public Shifts Page

+

Visit /shifts (public route, no auth required).

+

Step 2: Browse Shifts

+

View upcoming shifts as cards:

+
    +
  • Shift Title: Large heading
  • +
  • Date/Time: Formatted date + time range
  • +
  • Location: Meeting point
  • +
  • Volunteers: "5 / 20 spots filled" progress bar
  • +
  • Cut: Territory name (if assigned)
  • +
  • Status Badge: OPEN (green), FULL (red), CANCELLED (gray)
  • +
+

Step 3: Filter Shifts

+

Use filters:

+
    +
  • Date: Show only shifts on specific date
  • +
  • Status: OPEN only (hide FULL/CANCELLED)
  • +
+

Step 4: Click Signup

+

Click Signup button on shift card. Modal opens.

+

Step 5: Fill Signup Form

+

Complete form:

+
    +
  • Name: Required
  • +
  • Email: Required (validates format)
  • +
  • Phone: Optional
  • +
+

Step 6: Submit

+

Click Sign Up. System will:

+
    +
  1. Check capacity (reject if FULL)
  2. +
  3. Create TEMP user with email (if not exists)
  4. +
  5. Create shift signup with source=PUBLIC
  6. +
  7. Send confirmation email
  8. +
  9. Update shift.currentVolunteers count
  10. +
  11. Auto-update status to FULL if at capacity
  12. +
+

Step 7: Receive Confirmation

+

Check email for confirmation with shift details.

+

Volunteer Workflow

+

Authenticated volunteers can view assigned shifts and cancel signups.

+

Step 1: Login

+

Login at /login with volunteer account.

+

Step 2: Navigate to Assignments

+

Navigate to Volunteer → My Assignments.

+

Step 3: View Assigned Shifts

+

Table displays:

+
    +
  • Shift Title: Linked to shift details
  • +
  • Date/Time: Formatted
  • +
  • Location: Meeting point
  • +
  • Cut: Territory name (if assigned)
  • +
  • Status: Signup status
  • +
+

Step 4: View Shift Details

+

Click shift title to view:

+
    +
  • Description: Full shift details
  • +
  • Volunteers: List of other volunteers (names only, privacy protected)
  • +
  • Map: If cut assigned, show cut polygon on map
  • +
+

Step 5: Cancel Signup

+

Click Cancel Signup button. Confirmation modal appears.

+

Step 6: Confirm Cancellation

+

Click Confirm. System will:

+
    +
  1. Update signup status to CANCELLED
  2. +
  3. Decrement shift.currentVolunteers count
  4. +
  5. Update shift status to OPEN if was FULL
  6. +
  7. Send cancellation confirmation email
  8. +
+

Code Examples

+

Shift Service Create (Backend)

+
// api/src/modules/map/shifts/shifts.service.ts
+async create(data: CreateShiftInput, userId: string) {
+  const shift = await prisma.shift.create({
+    data: {
+      title: data.title,
+      description: data.description,
+      date: new Date(data.date),
+      startTime: data.startTime,
+      endTime: data.endTime,
+      location: data.location,
+      maxVolunteers: data.maxVolunteers,
+      isPublic: data.isPublic,
+      cutId: data.cutId,
+      createdBy: userId,
+    },
+  });
+
+  return shift;
+}
+
+

Public Signup (Backend)

+
// api/src/modules/map/shifts/shifts.service.ts
+import bcrypt from 'bcryptjs';
+
+async publicSignup(shiftId: string, data: PublicSignupInput) {
+  const shift = await prisma.shift.findUnique({ where: { id: shiftId } });
+
+  if (!shift) {
+    throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
+  }
+
+  // Check capacity
+  if (shift.currentVolunteers >= shift.maxVolunteers) {
+    throw new AppError(400, 'Shift is full', 'SHIFT_FULL');
+  }
+
+  // Find or create TEMP user
+  let user = await prisma.user.findUnique({ where: { email: data.email } });
+
+  if (!user) {
+    const password = generateReadablePassword(); // e.g., "BlueEagle42"
+    const hashedPassword = await bcrypt.hash(password, 10);
+
+    user = await prisma.user.create({
+      data: {
+        email: data.email,
+        name: data.name,
+        phone: data.phone,
+        password: hashedPassword,
+        role: 'TEMP',
+      },
+    });
+
+    logger.info('Created TEMP user for shift signup', {
+      email: data.email,
+      shiftId,
+    });
+  }
+
+  // Create signup
+  const signup = await prisma.shiftSignup.create({
+    data: {
+      shiftId,
+      userId: user.id,
+      userEmail: user.email,
+      userName: user.name ?? data.name,
+      userPhone: user.phone ?? data.phone,
+      signupSource: SignupSource.PUBLIC,
+      status: SignupStatus.CONFIRMED,
+    },
+  });
+
+  // Increment volunteer count
+  await prisma.shift.update({
+    where: { id: shiftId },
+    data: {
+      currentVolunteers: { increment: 1 },
+      status: shift.currentVolunteers + 1 >= shift.maxVolunteers
+        ? ShiftStatus.FULL
+        : shift.status,
+    },
+  });
+
+  // Send confirmation email
+  await emailService.sendShiftConfirmation(user.email, shift, user.name ?? data.name);
+
+  recordShiftSignup('public');
+
+  return signup;
+}
+
+

Generate Readable Password (Backend)

+
// api/src/modules/map/shifts/shifts.service.ts
+const adjectives = ['Blue', 'Red', 'Green', 'Swift', 'Bright', 'Bold', 'Calm', 'Fair'];
+const nouns = ['Eagle', 'River', 'Mountain', 'Star', 'Forest', 'Lake', 'Wolf', 'Hawk'];
+
+function generateReadablePassword(): string {
+  const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
+  const noun = nouns[Math.floor(Math.random() * nouns.length)];
+  const num = Math.floor(Math.random() * 90) + 10;
+  return `${adj}${noun}${num}`;
+}
+
+// Example output: "BoldWolf72", "SwiftStar45"
+
+

Shift Confirmation Email (Backend)

+
// api/src/services/email.service.ts
+async sendShiftConfirmation(
+  to: string,
+  shift: Shift,
+  userName: string
+): Promise<void> {
+  const subject = `Shift Confirmation - ${shift.title}`;
+
+  const body = `
+Hi ${userName},
+
+You're confirmed for:
+${shift.title}
+
+Date: ${dayjs(shift.date).format('MMMM D, YYYY')}
+Time: ${shift.startTime} - ${shift.endTime}
+Location: ${shift.location}
+
+${shift.description ? `\nDetails:\n${shift.description}\n` : ''}
+
+To cancel your signup, reply to this email.
+
+Thank you!
+`;
+
+  await this.sendEmail({ to, subject, text: body });
+}
+
+

Public Shifts List (Frontend)

+
// admin/src/pages/public/ShiftsPage.tsx
+const fetchShifts = async () => {
+  try {
+    const { data } = await axios.get('/api/public/map/shifts', {
+      params: {
+        upcoming: true, // Only show future shifts
+      },
+    });
+
+    setShifts(data.shifts);
+  } catch (error) {
+    message.error('Failed to load shifts');
+  }
+};
+
+useEffect(() => {
+  fetchShifts();
+}, []);
+
+

Signup Modal (Frontend)

+
// admin/src/pages/public/ShiftsPage.tsx
+const handleSignup = async (values: any) => {
+  try {
+    await axios.post(`/api/public/map/shifts/${selectedShift.id}/signup`, {
+      name: values.name,
+      email: values.email,
+      phone: values.phone,
+    });
+
+    message.success('Signup successful! Check your email for confirmation.');
+    setSignupModalOpen(false);
+    signupForm.resetFields();
+    fetchShifts(); // Refresh to update volunteer count
+  } catch (error: any) {
+    if (error.response?.data?.code === 'SHIFT_FULL') {
+      message.error('This shift is now full. Please choose another shift.');
+    } else {
+      message.error('Signup failed. Please try again.');
+    }
+  }
+};
+
+

Volunteer Assignments (Frontend)

+
// admin/src/pages/volunteer/VolunteerShiftsPage.tsx
+const fetchAssignments = async () => {
+  try {
+    const { data } = await api.get('/map/canvass/volunteer/assignments');
+
+    setAssignments(data);
+  } catch (error) {
+    message.error('Failed to load assignments');
+  }
+};
+
+

Troubleshooting

+

Issue: Shift Status Not Auto-Updating to FULL

+

Symptoms:

+
    +
  • Shift accepts signups beyond maxVolunteers
  • +
  • Status remains OPEN even when at capacity
  • +
  • currentVolunteers count incorrect
  • +
+

Causes:

+
    +
  • currentVolunteers not incremented on signup
  • +
  • Signup deletion not decrementing count
  • +
  • Race condition on concurrent signups
  • +
+

Solutions:

+
    +
  1. Use database transaction for capacity check + signup creation:
  2. +
+
await prisma.$transaction(async (tx) => {
+  const shift = await tx.shift.findUnique({
+    where: { id: shiftId },
+    select: { currentVolunteers: true, maxVolunteers: true },
+  });
+
+  if (shift.currentVolunteers >= shift.maxVolunteers) {
+    throw new AppError(400, 'Shift is full', 'SHIFT_FULL');
+  }
+
+  await tx.shiftSignup.create({ data: signupData });
+
+  await tx.shift.update({
+    where: { id: shiftId },
+    data: {
+      currentVolunteers: { increment: 1 },
+      status: shift.currentVolunteers + 1 >= shift.maxVolunteers
+        ? ShiftStatus.FULL
+        : shift.status,
+    },
+  });
+});
+
+
    +
  1. Verify count matches reality:
  2. +
+
-- Check if currentVolunteers matches actual signup count
+SELECT s.id, s.title, s.currentVolunteers,
+  COUNT(ss.id) as actual_signups
+FROM "Shift" s
+LEFT JOIN "ShiftSignup" ss ON s.id = ss."shiftId"
+  AND ss.status = 'CONFIRMED'
+GROUP BY s.id
+HAVING s."currentVolunteers" != COUNT(ss.id);
+
+
    +
  1. Recalculate counts:
  2. +
+
// Admin utility to fix counts
+async function recalculateShiftCounts() {
+  const shifts = await prisma.shift.findMany();
+
+  for (const shift of shifts) {
+    const count = await prisma.shiftSignup.count({
+      where: {
+        shiftId: shift.id,
+        status: SignupStatus.CONFIRMED,
+      },
+    });
+
+    await prisma.shift.update({
+      where: { id: shift.id },
+      data: {
+        currentVolunteers: count,
+        status: count >= shift.maxVolunteers ? ShiftStatus.FULL : ShiftStatus.OPEN,
+      },
+    });
+  }
+}
+
+

Issue: Confirmation Emails Not Sending

+

Symptoms:

+
    +
  • Users signup successfully but no email received
  • +
  • MailHog shows no emails in dev
  • +
  • SMTP errors in API logs
  • +
+

Causes:

+
    +
  • EMAIL_TEST_MODE not set in dev
  • +
  • SMTP credentials invalid
  • +
  • Email service not configured
  • +
  • Email in spam folder
  • +
+

Solutions:

+
    +
  1. Check email service config:
  2. +
+
# Verify SMTP settings in .env
+grep "SMTP_\|EMAIL_TEST_MODE" .env
+
+# In development, use MailHog
+EMAIL_TEST_MODE=true
+
+# In production, configure SMTP
+EMAIL_TEST_MODE=false
+SMTP_HOST=smtp.gmail.com
+SMTP_PORT=587
+SMTP_USER=your-email@gmail.com
+SMTP_PASSWORD=your-app-password
+
+
    +
  1. Test email service:
  2. +
+
# Send test email via API
+curl -X POST http://localhost:4000/api/test-email \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"to":"test@example.com","subject":"Test","text":"Test email"}'
+
+
    +
  1. Check MailHog in dev:
  2. +
+
# Access MailHog UI
+open http://localhost:8025
+
+# View email queue in BullMQ
+docker compose exec redis redis-cli KEYS "bull:email-queue:*"
+
+
    +
  1. Check spam folder (production):
  2. +
+

Add SPF/DKIM/DMARC records to domain to improve deliverability.

+

Issue: TEMP User Password Security

+

Symptoms:

+
    +
  • TEMP users can't login with generated password
  • +
  • Password doesn't meet complexity requirements
  • +
  • Account locked after signup
  • +
+

Causes:

+
    +
  • Generated password doesn't meet 12-char minimum
  • +
  • Password missing uppercase/lowercase/digit
  • +
  • Password not sent to user (they can't login)
  • +
+

Solutions:

+
    +
  1. Ensure generated password meets policy:
  2. +
+
function generateReadablePassword(): string {
+  // Must meet: 12+ chars, uppercase, lowercase, digit
+  const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; // Uppercase
+  const noun = nouns[Math.floor(Math.random() * nouns.length)]; // Uppercase
+  const num = Math.floor(Math.random() * 90) + 10; // 2 digits
+  const lower = 'abc'; // Lowercase
+
+  return `${adj}${noun}${num}${lower}`; // E.g., "BoldWolf72abc" (14 chars)
+}
+
+
    +
  1. Send password to user (security risk, consider alternative):
  2. +
+

Include password in confirmation email (only for TEMP users, one-time):

+
Your temporary account has been created.
+
+Email: {{email}}
+Password: {{password}}
+
+Please change your password after logging in.
+
+

Better Alternative: Use passwordless login link:

+
Click here to confirm your shift and access your account:
+https://app.cmlite.org/confirm-shift/{{signupToken}}
+
+

Performance Considerations

+

Shift Query Optimization

+

Index Upcoming Shifts:

+

Create composite index for common query:

+
CREATE INDEX idx_shifts_upcoming ON "Shift" (date, "isPublic", status)
+WHERE date >= CURRENT_DATE;
+
+

Efficient Public Query:

+
// Only query future public shifts
+const shifts = await prisma.shift.findMany({
+  where: {
+    isPublic: true,
+    date: { gte: new Date() },
+    status: { not: ShiftStatus.CANCELLED },
+  },
+  orderBy: { date: 'asc' },
+  include: {
+    cut: { select: { id: true, name: true } },
+    _count: {
+      select: { signups: { where: { status: SignupStatus.CONFIRMED } } },
+    },
+  },
+});
+
+

Email Queue Performance

+

Batch Email Sending:

+

Use BullMQ queue to avoid blocking API requests:

+
// Add email jobs to queue
+for (const volunteer of volunteers) {
+  await emailQueue.add('send-email', {
+    to: volunteer.email,
+    subject: 'Shift Update',
+    text: message,
+  });
+}
+
+// Worker processes jobs asynchronously
+emailQueue.process('send-email', async (job) => {
+  await emailService.sendEmail(job.data);
+});
+
+

Concurrent Signup Handling

+

Prevent Race Conditions:

+

Use database transactions with SELECT FOR UPDATE:

+
await prisma.$transaction(async (tx) => {
+  const shift = await tx.shift.findUnique({
+    where: { id: shiftId },
+    // Lock row to prevent concurrent updates
+  });
+
+  if (shift.currentVolunteers >= shift.maxVolunteers) {
+    throw new AppError(400, 'Shift is full', 'SHIFT_FULL');
+  }
+
+  // Create signup and update count atomically
+  await tx.shiftSignup.create({ data: signupData });
+  await tx.shift.update({
+    where: { id: shiftId },
+    data: { currentVolunteers: { increment: 1 } },
+  });
+});
+
+ +

Backend Modules:

+ +

Frontend Pages:

+ +

Database:

+ +

Features:

+
    +
  • Cuts — Territory assignment for shifts
  • +
  • Canvassing — Shift-based canvassing sessions
  • +
  • Users — TEMP user management
  • +
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/map/tracking/index.html b/mkdocs/site/v2/features/map/tracking/index.html new file mode 100644 index 00000000..a622bad2 --- /dev/null +++ b/mkdocs/site/v2/features/map/tracking/index.html @@ -0,0 +1,5870 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Tracking - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

GPS Tracking System

+

Overview

+

The GPS tracking system provides real-time volunteer location monitoring with breadcrumb trail recording, distance calculation, and route visualization. It integrates with canvassing sessions for field organizing oversight and volunteer safety.

+

Key Capabilities:

+
    +
  • Live Tracking: Real-time volunteer GPS positions
  • +
  • Breadcrumb Trails: Auto-record GPS points every 10 seconds
  • +
  • Distance Calculation: Haversine formula for accurate walking distance
  • +
  • Event Markers: Mark key events (session start, visits, session end)
  • +
  • Route Visualization: Leaflet polyline with color-coded event markers
  • +
  • 1:1 Canvass Link: Each TrackingSession linked to one CanvassSession
  • +
  • Admin Oversight: View live volunteer positions on map
  • +
  • Privacy Controls: Tracking only during active canvass sessions
  • +
+

Architecture

+
graph TD
+    A[Volunteer GPS] -->|watchPosition| B[GPSTracker Component]
+    B -->|Buffer Points| C[Local Storage]
+    C -->|Submit Every 10s| D[POST /api/map/tracking/sessions/:id/points]
+    D -->|Batch Insert| E[Tracking Service]
+    E -->|Save Points| F[(TrackPoint Model)]
+    E -->|Calculate Distance| G[Haversine Formula]
+    G -->|Update Session| H[(TrackingSession Model)]
+
+    I[Canvass Session] -->|Start| J[Canvass Service]
+    J -->|Create 1:1| E
+    E -->|Create| H
+
+    K[Admin] -->|View Live Map| L[CanvassDashboardPage]
+    L -->|GET /api/map/tracking/admin/live| E
+    E -->|Query Active| H
+    E -->|Return Positions| L
+
+    M[Volunteer] -->|View Route History| N[MyRoutesPage]
+    N -->|GET /api/map/tracking/sessions/:id/route| E
+    E -->|Query Points| F
+    E -->|Generate Polyline| N
+
+    H -->|1:1| I
+    H -->|1:N| F
+
+    style H fill:#e1f5ff
+    style F fill:#e1f5ff
+

Flow Description:

+
    +
  1. Canvass session starts → Create TrackingSession linked 1:1
  2. +
  3. GPS auto-tracking → watchPosition submits points every 10s
  4. +
  5. Distance calculation → Haversine formula calculates incremental distance
  6. +
  7. Event markers → Mark visits, session start/end with eventType
  8. +
  9. Admin oversight → View live volunteer positions on dashboard
  10. +
  11. Route history → Generate polyline from saved TrackPoints
  12. +
+

Database Models

+

TrackingSession Model

+

See TrackingSession Model Documentation.

+

Key Fields:

+
    +
  • userId: Foreign key to volunteer User
  • +
  • canvassSessionId: 1:1 foreign key to CanvassSession
  • +
  • startedAt: Tracking start timestamp
  • +
  • endedAt: Tracking end timestamp (null while active)
  • +
  • isActive: Boolean - tracking currently running
  • +
  • totalPoints: Count of TrackPoint records
  • +
  • totalDistanceM: Total distance walked in meters
  • +
  • lastLatitude / lastLongitude: Most recent GPS position
  • +
  • lastRecordedAt: Timestamp of last GPS point
  • +
+

TrackPoint Model

+

See TrackPoint Model Documentation.

+

Key Fields:

+
    +
  • trackingSessionId: Foreign key to TrackingSession
  • +
  • latitude / longitude: GPS coordinates (Decimal type)
  • +
  • accuracy: GPS accuracy in meters (lower = better)
  • +
  • recordedAt: When point was recorded (client timestamp)
  • +
  • eventType: Optional event marker (LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED)
  • +
+

Event Type Enum:

+
enum TrackPointEventType {
+  LOCATION_ADDED   // Regular GPS breadcrumb
+  VISIT_RECORDED   // Canvass visit recorded
+  SESSION_STARTED  // Canvass session started
+  SESSION_ENDED    // Canvass session ended
+}
+
+

API Endpoints

+

See Tracking Backend Module Documentation.

+

Volunteer Endpoints:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
POST/api/map/tracking/sessionsAny logged-in userStart tracking session
PATCH/api/map/tracking/sessions/:id/endAny logged-in userEnd tracking session
POST/api/map/tracking/sessions/:id/pointsAny logged-in userSubmit batch of GPS points
GET/api/map/tracking/sessions/:idAny logged-in userGet tracking session details
GET/api/map/tracking/sessions/:id/routeAny logged-in userGet route polyline (all points)
+

Admin Endpoints:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointAuthDescription
GET/api/map/tracking/admin/liveMAP_ADMINGet live volunteer positions
GET/api/map/tracking/admin/sessions/:idMAP_ADMINGet volunteer tracking session
GET/api/map/tracking/admin/sessions/:id/routeMAP_ADMINGet volunteer route
+

Configuration

+

GPS Tracking Settings

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SettingDefaultDescription
SUBMIT_INTERVAL_MS10000Submit GPS points every 10 seconds
MAX_DISTANCE_JUMP_M1000Ignore GPS glitches >1km distance
HIGH_ACCURACYtrueUse GPS + WiFi + cellular (vs WiFi only)
MAX_AGE_MS0Don't use cached GPS position
TIMEOUT_MS10000GPS position timeout (10s)
+

Privacy & Security

+
    +
  • Opt-In Only: Tracking only enabled when volunteer starts canvass session
  • +
  • Session-Based: Tracking ends when session ends (not continuous)
  • +
  • Admin-Only: Only MAP_ADMIN can view live positions
  • +
  • Data Retention: TrackPoints retained for analytics (consider GDPR compliance for EU campaigns)
  • +
+

Code Examples

+

Start Tracking Session (Backend)

+
// api/src/modules/map/tracking/tracking.service.ts
+async startSession(userId: string, data: StartTrackingInput) {
+  const { canvassSessionId, latitude, longitude } = data;
+
+  // Check for existing active session
+  const existing = await prisma.trackingSession.findFirst({
+    where: { userId, isActive: true },
+  });
+
+  if (existing) return existing; // Reuse existing session
+
+  return prisma.trackingSession.create({
+    data: {
+      userId,
+      canvassSessionId: canvassSessionId ?? null,
+      lastLatitude: latitude != null ? new Prisma.Decimal(latitude) : null,
+      lastLongitude: longitude != null ? new Prisma.Decimal(longitude) : null,
+      lastRecordedAt: latitude != null ? new Date() : null,
+    },
+  });
+}
+
+

Submit GPS Points (Backend)

+
// api/src/modules/map/tracking/tracking.service.ts
+const MAX_DISTANCE_JUMP_M = 1000;
+
+async submitPoints(sessionId: string, userId: string, data: SubmitPointsInput) {
+  const session = await prisma.trackingSession.findFirst({
+    where: { id: sessionId, userId, isActive: true },
+  });
+
+  if (!session) {
+    throw new AppError(404, 'Active tracking session not found', 'SESSION_NOT_FOUND');
+  }
+
+  const { points } = data;
+
+  // Batch insert all points
+  await prisma.trackPoint.createMany({
+    data: points.map((p) => ({
+      trackingSessionId: sessionId,
+      latitude: new Prisma.Decimal(p.latitude),
+      longitude: new Prisma.Decimal(p.longitude),
+      accuracy: p.accuracy ?? null,
+      recordedAt: new Date(p.recordedAt),
+      eventType: p.eventType ?? null,
+    })),
+  });
+
+  // Calculate incremental distance
+  let addedDistance = 0;
+  let prevLat = session.lastLatitude ? Number(session.lastLatitude) : null;
+  let prevLng = session.lastLongitude ? Number(session.lastLongitude) : null;
+
+  const sorted = [...points].sort(
+    (a, b) => new Date(a.recordedAt).getTime() - new Date(b.recordedAt).getTime()
+  );
+
+  for (const p of sorted) {
+    if (prevLat != null && prevLng != null) {
+      const d = haversineDistance(prevLat, prevLng, p.latitude, p.longitude);
+      if (d <= MAX_DISTANCE_JUMP_M) {
+        addedDistance += d;
+      }
+    }
+    prevLat = p.latitude;
+    prevLng = p.longitude;
+  }
+
+  const lastPoint = sorted[sorted.length - 1]!;
+
+  // Update session summary
+  await prisma.trackingSession.update({
+    where: { id: sessionId },
+    data: {
+      totalPoints: { increment: points.length },
+      totalDistanceM: { increment: addedDistance },
+      lastLatitude: new Prisma.Decimal(lastPoint.latitude),
+      lastLongitude: new Prisma.Decimal(lastPoint.longitude),
+      lastRecordedAt: new Date(lastPoint.recordedAt),
+    },
+  });
+
+  return { accepted: points.length, distance: addedDistance };
+}
+
+

GPS Auto-Tracking (Frontend)

+
// admin/src/components/canvass/GPSTracker.tsx
+useEffect(() => {
+  if (!trackingSessionId || !enabled) return;
+
+  const pointsBuffer: TrackPoint[] = [];
+
+  const watchId = navigator.geolocation.watchPosition(
+    (position) => {
+      const point = {
+        latitude: position.coords.latitude,
+        longitude: position.coords.longitude,
+        accuracy: position.coords.accuracy,
+        recordedAt: new Date().toISOString(),
+      };
+
+      pointsBuffer.push(point);
+      setCurrentPosition([point.latitude, point.longitude]);
+    },
+    (error) => {
+      console.error('GPS error:', error);
+      message.error('GPS tracking failed');
+    },
+    {
+      enableHighAccuracy: true,
+      maximumAge: 0,
+      timeout: 10000,
+    }
+  );
+
+  // Submit buffered points every 10 seconds
+  const interval = setInterval(async () => {
+    if (pointsBuffer.length === 0) return;
+
+    try {
+      await api.post(`/map/tracking/sessions/${trackingSessionId}/points`, {
+        points: pointsBuffer.splice(0), // Drain buffer
+      });
+    } catch (error) {
+      console.error('Failed to submit GPS points:', error);
+    }
+  }, 10000);
+
+  return () => {
+    navigator.geolocation.clearWatch(watchId);
+    clearInterval(interval);
+  };
+}, [trackingSessionId, enabled]);
+
+

Route Visualization (Frontend)

+
// admin/src/pages/volunteer/MyRoutesPage.tsx
+const fetchRoute = async (sessionId: string) => {
+  const { data } = await api.get(`/map/tracking/sessions/${sessionId}/route`);
+
+  // Convert TrackPoints to polyline coordinates
+  const polyline = data.points.map((p: TrackPoint) => [p.latitude, p.longitude]);
+
+  // Extract event markers
+  const events = data.points
+    .filter((p: TrackPoint) => p.eventType)
+    .map((p: TrackPoint) => ({
+      position: [p.latitude, p.longitude],
+      eventType: p.eventType,
+      recordedAt: p.recordedAt,
+    }));
+
+  setRoute({ polyline, events, distance: data.totalDistanceM });
+};
+
+// Render route
+<Polyline positions={route.polyline} pathOptions={{ color: '#3498db', weight: 3 }} />
+{route.events.map((event, i) => (
+  <Marker
+    key={i}
+    position={event.position}
+    icon={getEventIcon(event.eventType)}
+  >
+    <Popup>{event.eventType} - {dayjs(event.recordedAt).format('HH:mm')}</Popup>
+  </Marker>
+))}
+
+

Troubleshooting

+

Issue: GPS Tracking Draining Battery

+

Solutions:

+
    +
  1. Reduce accuracy: enableHighAccuracy: false
  2. +
  3. Increase submit interval: SUBMIT_INTERVAL_MS = 30000 (30s)
  4. +
  5. Add pause/resume tracking buttons
  6. +
+

Issue: Distance Calculation Incorrect

+

Symptoms: Total distance much higher than expected

+

Causes: GPS glitches causing large jumps

+

Solutions:

+

Increase MAX_DISTANCE_JUMP_M threshold to ignore outliers:

+
const MAX_DISTANCE_JUMP_M = 2000; // Was 1000, increase to 2000
+
+

Issue: Route Polyline Jagged

+

Symptoms: Route looks zigzag instead of smooth

+

Causes: GPS accuracy poor (±20m)

+

Solutions:

+

Apply smoothing algorithm to polyline:

+
import { simplify } from '@turf/turf';
+
+const smoothed = simplify(polyline, { tolerance: 0.0001, highQuality: true });
+
+

Performance Considerations

+

Batch Point Insertion

+

Efficient Bulk Insert:

+
// Insert all points in single transaction
+await prisma.trackPoint.createMany({
+  data: points.map((p) => ({ ... })),
+});
+
+// Avoid N+1: single UPDATE instead of N UPDATEs
+await prisma.trackingSession.update({
+  where: { id: sessionId },
+  data: {
+    totalPoints: { increment: points.length },
+    totalDistanceM: { increment: totalDistance },
+  },
+});
+
+

Query Optimization

+

Index for Route Queries:

+
CREATE INDEX idx_track_points_session_time ON "TrackPoint" ("trackingSessionId", "recordedAt");
+
+

Efficient Route Query:

+
const points = await prisma.trackPoint.findMany({
+  where: { trackingSessionId: sessionId },
+  orderBy: { recordedAt: 'asc' },
+  select: { latitude: true, longitude: true, recordedAt: true, eventType: true },
+});
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/map/walk-sheets/index.html b/mkdocs/site/v2/features/map/walk-sheets/index.html new file mode 100644 index 00000000..92f4f036 --- /dev/null +++ b/mkdocs/site/v2/features/map/walk-sheets/index.html @@ -0,0 +1,8367 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Walk Sheets - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Walk Sheets & QR Codes

+

Overview

+

The Walk Sheets system provides printable door-to-door canvassing materials with integrated QR code support. This feature enables campaign organizers to generate professional walk sheets for volunteers, complete with address lists, cut boundaries, and quick-access QR codes to campaign resources.

+

Key Features:

+
    +
  • Browser-based printing (no server-side PDF generation)
  • +
  • Customizable headers, footers, and QR codes
  • +
  • Cut-based address filtering
  • +
  • Point-in-polygon location selection
  • +
  • Print-optimized layout (A4/Letter)
  • +
  • Cut export reports with statistics
  • +
  • Multi-unit building support
  • +
  • Support level indicators
  • +
+

Use Cases:

+
    +
  • Door-to-door canvassing
  • +
  • Volunteer shift materials
  • +
  • Cut logistics planning
  • +
  • Campaign resource distribution
  • +
  • Field data collection
  • +
+

Architecture Highlights:

+
    +
  • Frontend-only printing (window.print())
  • +
  • QR code generation via public API
  • +
  • MapSettings singleton for configuration
  • +
  • Point-in-polygon filtering for cut locations
  • +
  • CSS @media print rules for layout
  • +
+

Architecture

+
flowchart TB
+    subgraph Admin Interface
+        Admin[Admin User]
+        Settings[MapSettingsPage]
+        WalkSheet[WalkSheetPage]
+        CutExport[CutExportPage]
+    end
+
+    subgraph API Layer
+        MapSettingsAPI["/api/map-settings"]
+        CutsAPI["/api/cuts/:id"]
+        LocationsAPI["/api/locations?cutId="]
+        QRAPI["/api/qr/generate"]
+    end
+
+    subgraph Database
+        MapSettingsDB[(MapSettings)]
+        CutsDB[(Cuts)]
+        LocationsDB[(Locations)]
+    end
+
+    subgraph Print System
+        Preview[Print Preview]
+        Browser[Browser Print Dialog]
+        PDF[PDF Output]
+    end
+
+    Admin --> Settings
+    Admin --> WalkSheet
+    Admin --> CutExport
+
+    Settings --> MapSettingsAPI
+    WalkSheet --> MapSettingsAPI
+    WalkSheet --> CutsAPI
+    WalkSheet --> LocationsAPI
+    WalkSheet --> QRAPI
+    CutExport --> CutsAPI
+    CutExport --> LocationsAPI
+
+    MapSettingsAPI --> MapSettingsDB
+    CutsAPI --> CutsDB
+    LocationsAPI --> LocationsDB
+
+    WalkSheet --> Preview
+    CutExport --> Preview
+    Preview --> Browser
+    Browser --> PDF
+
+    QRAPI --> QRGen[QR Code PNG Generator]
+    QRGen --> Base64[Base64 Data URL]
+    Base64 --> WalkSheet
+

Data Flow:

+
    +
  1. Configuration Phase:
  2. +
  3. Admin configures walk sheet settings (title, subtitle, footer, QR codes)
  4. +
  5. Settings stored in MapSettings singleton
  6. +
  7. +

    QR code URLs and labels defined (up to 3)

    +
  8. +
  9. +

    Generation Phase:

    +
  10. +
  11. Admin selects cut from dropdown
  12. +
  13. Frontend fetches cut details and settings
  14. +
  15. Point-in-polygon filter retrieves locations within cut
  16. +
  17. QR codes generated via POST /api/qr/generate
  18. +
  19. +

    Walk sheet rendered with all components

    +
  20. +
  21. +

    Print Phase:

    +
  22. +
  23. window.print() triggered
  24. +
  25. Browser print dialog opens
  26. +
  27. Print CSS rules applied (hide nav, adjust layout)
  28. +
  29. User selects printer or "Save as PDF"
  30. +
+

Database Models

+

MapSettings Model

+
model MapSettings {
+  id        Int      @id @default(1) // Singleton
+
+  // Walk Sheet Configuration
+  walkSheetTitle    String  @default("Walk Sheet")
+  walkSheetSubtitle String  @default("")
+  walkSheetFooter   String  @default("")
+
+  // QR Code 1
+  qrCode1Url   String?
+  qrCode1Label String?
+
+  // QR Code 2
+  qrCode2Url   String?
+  qrCode2Label String?
+
+  // QR Code 3
+  qrCode3Url   String?
+  qrCode3Label String?
+
+  // Other map settings
+  defaultCenterLat Float   @default(43.6532)
+  defaultCenterLng Float   @default(-79.3832)
+  defaultZoom      Int     @default(12)
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+}
+
+

Singleton Pattern: +- Always ID = 1 +- Created during seed if not exists +- Single source of truth for walk sheet config

+

Cut Model

+
model Cut {
+  id          Int      @id @default(autoincrement())
+  name        String
+  description String?
+  geojson     Json     // GeoJSON Polygon or MultiPolygon
+  color       String   @default("#3498db")
+  visible     Boolean  @default(true)
+
+  shifts      Shift[]
+
+  createdAt   DateTime @default(now())
+  updatedAt   DateTime @updatedAt
+}
+
+

GeoJSON Structure: +

{
+  "type": "Polygon",
+  "coordinates": [
+    [
+      [-79.38, 43.65],
+      [-79.37, 43.65],
+      [-79.37, 43.66],
+      [-79.38, 43.66],
+      [-79.38, 43.65]
+    ]
+  ]
+}
+

+

Location Model

+
model Location {
+  id          Int      @id @default(autoincrement())
+  address     String
+  latitude    Float?
+  longitude   Float?
+  postalCode  String?
+  province    String?
+
+  // Geocoding metadata
+  geocodeConfidence Int?        // 0-100
+  geocodeProvider   String?     // GOOGLE, MAPBOX, etc.
+
+  // NAR import fields
+  locGuid           String?  @unique
+  federalDistrict   String?
+  buildingUse       Int?     // 1 = Residential
+
+  addresses   Address[]
+
+  createdAt   DateTime @default(now())
+  updatedAt   DateTime @updatedAt
+}
+
+

Address Model

+
model Address {
+  id         Int      @id @default(autoincrement())
+  locationId Int
+  location   Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
+
+  unitNumber   String?
+  firstName    String?
+  lastName     String?
+  supportLevel Int?     // 1-5 scale
+  notes        String?
+
+  // NAR import
+  addrGuid String? @unique
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@index([locationId])
+}
+
+

Support Level Scale: +- 1 = Strong Opposition +- 2 = Lean Opposition +- 3 = Undecided +- 4 = Lean Support +- 5 = Strong Support

+

API Endpoints

+

GET /api/map-settings

+

Fetch walk sheet configuration.

+

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

+

Response: +

{
+  "id": 1,
+  "walkSheetTitle": "Toronto Canvass Walk Sheet",
+  "walkSheetSubtitle": "Ward 10 - November 2025",
+  "walkSheetFooter": "Questions? Call HQ at 416-555-1234",
+  "qrCode1Url": "https://example.com/campaign",
+  "qrCode1Label": "Campaign Page",
+  "qrCode2Url": "https://example.com/volunteer",
+  "qrCode2Label": "Volunteer Portal",
+  "qrCode3Url": "https://example.com/donate",
+  "qrCode3Label": "Donate Now",
+  "defaultCenterLat": 43.6532,
+  "defaultCenterLng": -79.3832,
+  "defaultZoom": 12,
+  "createdAt": "2025-01-15T10:00:00Z",
+  "updatedAt": "2025-02-10T14:30:00Z"
+}
+

+

PUT /api/map-settings

+

Update walk sheet configuration.

+

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

+

Request Body: +

{
+  "walkSheetTitle": "Updated Title",
+  "walkSheetSubtitle": "Updated Subtitle",
+  "walkSheetFooter": "Updated footer text with contact info",
+  "qrCode1Url": "https://newurl.com",
+  "qrCode1Label": "New Label"
+}
+

+

Response: Updated MapSettings object

+

Validation: +- walkSheetTitle: 1-100 characters +- walkSheetSubtitle: 0-200 characters +- walkSheetFooter: 0-500 characters +- qrCode URLs: valid HTTP/HTTPS URLs +- qrCode labels: 0-50 characters

+

GET /api/cuts/:id

+

Fetch cut details for walk sheet.

+

Authentication: Required

+

Response: +

{
+  "id": 42,
+  "name": "Downtown Core",
+  "description": "High-density residential area",
+  "geojson": {
+    "type": "Polygon",
+    "coordinates": [[...]]
+  },
+  "color": "#3498db",
+  "visible": true,
+  "createdAt": "2025-01-20T09:00:00Z",
+  "updatedAt": "2025-02-01T11:00:00Z"
+}
+

+

GET /api/locations?cutId=:id

+

Fetch locations within cut boundary.

+

Authentication: Required

+

Query Parameters: +- cutId (required): Cut ID for filtering +- sortBy (optional): Field to sort by (default: "address") +- order (optional): "asc" or "desc" (default: "asc")

+

Response: +

{
+  "data": [
+    {
+      "id": 1001,
+      "address": "123 Main St",
+      "latitude": 43.6532,
+      "longitude": -79.3832,
+      "postalCode": "M5H 2N2",
+      "addresses": [
+        {
+          "id": 5001,
+          "unitNumber": "101",
+          "firstName": "John",
+          "lastName": "Smith",
+          "supportLevel": 4,
+          "notes": "Lawn sign requested"
+        },
+        {
+          "id": 5002,
+          "unitNumber": "102",
+          "firstName": "Jane",
+          "lastName": "Doe",
+          "supportLevel": 5,
+          "notes": null
+        }
+      ]
+    }
+  ],
+  "total": 150
+}
+

+

Filtering Logic: +

// Point-in-polygon filter
+const locations = await prisma.location.findMany({
+  where: {
+    AND: [
+      { latitude: { not: null } },
+      { longitude: { not: null } }
+    ]
+  },
+  include: {
+    addresses: {
+      orderBy: { unitNumber: 'asc' }
+    }
+  },
+  orderBy: { address: 'asc' }
+});
+
+// Filter using point-in-polygon
+const filtered = locations.filter(loc =>
+  isPointInPolygon([loc.longitude!, loc.latitude!], cut.geojson)
+);
+

+

POST /api/qr/generate

+

Generate QR code PNG from URL.

+

Authentication: None (public endpoint)

+

Request Body: +

{
+  "url": "https://example.com/campaign",
+  "size": 200
+}
+

+

Parameters: +- url (required): Target URL for QR code +- size (optional): QR code dimension in pixels (default: 200, max: 500)

+

Response: +

{
+  "png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
+}
+

+

Error Responses: +- 400: Invalid URL format +- 400: Size must be between 50-500 +- 500: QR code generation failed

+

Rate Limiting: 100 requests per 15 minutes per IP

+

Configuration

+

Environment Variables

+ + + + + + + + + + + + + + + + + +
VariableTypeDefaultDescription
N/AWalk sheet settings stored in database
+

MapSettings Configuration

+

Access via: Admin → Settings → Map Settings

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SettingTypeDefaultMax LengthDescription
walkSheetTitlestring"Walk Sheet"100Header title for walk sheets
walkSheetSubtitlestring""200Subtitle below title (ward, date, etc.)
walkSheetFooterstring""500Footer text (contact info, instructions)
qrCode1Urlstringnull2048First QR code target URL
qrCode1Labelstringnull50First QR code label
qrCode2Urlstringnull2048Second QR code target URL
qrCode2Labelstringnull50Second QR code label
qrCode3Urlstringnull2048Third QR code target URL
qrCode3Labelstringnull50Third QR code label
+

QR Code URL Examples: +- Campaign page: https://example.com/campaigns/123 +- Volunteer portal: https://example.com/volunteer +- Donation page: https://example.com/donate +- Social media: https://facebook.com/campaignpage +- Google Form: https://forms.google.com/...

+

QR Code Label Best Practices: +- Keep short (2-4 words) +- Action-oriented ("Donate Now", "Get Updates") +- Mobile-friendly (scanned on phones) +- Clear purpose ("Campaign Details", "Volunteer Info")

+ +

CSS Variables: +

@media print {
+  --print-margin: 0.5in;
+  --print-font-size: 10pt;
+  --print-header-size: 16pt;
+  --print-qr-size: 150px;
+  --print-table-border: 1px solid #000;
+}
+

+

Page Setup: +- Size: A4 (210mm × 297mm) or Letter (8.5" × 11") +- Orientation: Portrait +- Margins: 0.5 inches (12.7mm) +- Print background: Enabled (for borders) +- Scale: 100% (no auto-fit)

+

Admin Workflow

+

Configure Walk Sheet Settings

+

Step 1: Navigate to Map Settings

+
    +
  1. Log in as SUPER_ADMIN or MAP_ADMIN
  2. +
  3. Click Settings in sidebar
  4. +
  5. Click Map Settings submenu
  6. +
  7. Scroll to "Walk Sheet Configuration" section
  8. +
+

Step 2: Set Title and Subtitle

+
Walk Sheet Title: "Toronto Canvass Walk Sheet"
+Walk Sheet Subtitle: "Ward 10 - November 2025 Campaign"
+
+

Step 3: Configure QR Codes

+
QR Code 1:
+  URL: https://example.com/campaign/123
+  Label: Campaign Page
+
+QR Code 2:
+  URL: https://example.com/volunteer
+  Label: Volunteer Sign-Up
+
+QR Code 3:
+  URL: https://example.com/donate
+  Label: Donate Now
+
+

Step 4: Set Footer Text

+
Walk Sheet Footer:
+  Questions? Call HQ at 416-555-1234
+  Emergency? Text volunteer coordinator at 416-555-5678
+  Return completed sheets to campaign office by 8 PM
+
+

Step 5: Save Settings

+
    +
  • Click Save button
  • +
  • Success notification appears
  • +
  • Settings applied to all future walk sheets
  • +
+

Generate Walk Sheet

+

Step 1: Navigate to Walk Sheet Page

+
    +
  1. Click Map in sidebar
  2. +
  3. Click Walk Sheet submenu
  4. +
  5. Walk sheet generator page loads
  6. +
+

Step 2: Select Cut

+
    +
  1. Click Select Cut dropdown
  2. +
  3. Choose cut from list (e.g., "Downtown Core")
  4. +
  5. Loading indicator shows while fetching locations
  6. +
  7. Location count displayed (e.g., "150 locations")
  8. +
+

Step 3: Preview Walk Sheet

+

Walk sheet displays:

+
┌─────────────────────────────────────────────┐
+│  Toronto Canvass Walk Sheet                 │
+│  Ward 10 - November 2025 Campaign           │
+│  Cut: Downtown Core                         │
+│  Date: February 13, 2026                    │
+└─────────────────────────────────────────────┘
+
+┌───────────────────┬──────┬────────┬─────────┐
+│ Address           │ Unit │ Notes  │ Visited │
+├───────────────────┼──────┼────────┼─────────┤
+│ 100 Adelaide St E │ 101  │ Lawn   │    □    │
+│ 100 Adelaide St E │ 102  │        │    □    │
+│ 102 Adelaide St E │      │        │    □    │
+│ 105 Bay St        │ 1A   │ Strong │    □    │
+└───────────────────┴──────┴────────┴─────────┘
+
+[QR Code]        [QR Code]        [QR Code]
+Campaign Page    Volunteer Info    Donate Now
+
+Questions? Call HQ at 416-555-1234
+Emergency? Text volunteer coordinator at 416-555-5678
+Return completed sheets to campaign office by 8 PM
+
+

Step 4: Print Walk Sheet

+
    +
  1. Click Print button (top-right corner)
  2. +
  3. Browser print dialog opens
  4. +
  5. Configure print settings:
  6. +
  7. Destination: Printer or "Save as PDF"
  8. +
  9. Pages: All
  10. +
  11. Layout: Portrait
  12. +
  13. Margins: Default
  14. +
  15. Background graphics: Enabled
  16. +
  17. Click Print or Save
  18. +
+

Step 5: Distribute to Volunteers

+
    +
  • Print multiple copies for shift volunteers
  • +
  • Include shift assignment sheet
  • +
  • Provide pens for checkboxes and notes
  • +
  • Brief volunteers on walk sheet usage
  • +
+

Generate Cut Export Report

+

Step 1: Navigate to Cuts Page

+
    +
  1. Click MapCuts in sidebar
  2. +
  3. Cuts table loads with list of all cuts
  4. +
+

Step 2: Open Cut Export

+
    +
  1. Find cut row (e.g., "Downtown Core")
  2. +
  3. Click Export button in Actions column
  4. +
  5. New tab opens with export report
  6. +
+

Step 3: Review Statistics

+

Export report shows:

+
┌─────────────────────────────────────────────┐
+│  Cut Export Report                          │
+│  Cut: Downtown Core                         │
+│  Generated: February 13, 2026 10:30 AM      │
+└─────────────────────────────────────────────┘
+
+Statistics:
+  Total Locations: 150
+  Total Units: 287
+  Residential: 280 (97.6%)
+  Commercial: 7 (2.4%)
+  Geocoded: 148 (98.7%)
+  Missing Coordinates: 2 (1.3%)
+
+┌─────────────────┬──────┬───────┬──────────┐
+│ Address         │ Lat  │ Lng   │ Units    │
+├─────────────────┼──────┼───────┼──────────┤
+│ 100 Adelaide E  │ 43.6 │ -79.3 │ 2        │
+│ 102 Adelaide E  │ 43.6 │ -79.3 │ 1        │
+└─────────────────┴──────┴───────┴──────────┘
+
+[Export CSV Button]  [Print Button]
+
+

Step 4: Export to CSV

+
    +
  1. Click Export CSV button
  2. +
  3. File downloads: cut-42-downtown-core-2026-02-13.csv
  4. +
  5. Open in spreadsheet for further analysis
  6. +
+

CSV Format: +

Address,Latitude,Longitude,Postal Code,Units,Residential
+"100 Adelaide St E",43.6532,-79.3832,"M5H 2N2",2,true
+"102 Adelaide St E",43.6540,-79.3825,"M5H 2N3",1,true
+

+ +

Page Structure

+
┌─────────────────────────────────────────────┐
+│ [HEADER SECTION]                            │
+│   - Walk Sheet Title                        │
+│   - Subtitle                                │
+│   - Cut Name                                │
+│   - Generated Date                          │
+├─────────────────────────────────────────────┤
+│ [ADDRESS TABLE]                             │
+│   - Sortable by street name                │
+│   - Multi-unit grouped                      │
+│   - Support level indicators               │
+│   - Notes column                            │
+│   - Visited checkbox                        │
+├─────────────────────────────────────────────┤
+│ [QR CODE SECTION]                           │
+│   - Up to 3 QR codes                        │
+│   - Labels below each code                  │
+│   - Horizontal layout                       │
+├─────────────────────────────────────────────┤
+│ [FOOTER SECTION]                            │
+│   - Custom footer text                      │
+│   - Contact information                     │
+│   - Instructions                            │
+└─────────────────────────────────────────────┘
+
+

CSS Print Rules

+

Component: WalkSheetPage.tsx

+
@media print {
+  /* Hide non-printable elements */
+  .no-print,
+  .ant-layout-header,
+  .ant-layout-sider,
+  button,
+  .ant-select,
+  .ant-form,
+  nav {
+    display: none !important;
+  }
+
+  /* Page setup */
+  @page {
+    size: A4 portrait;
+    margin: 0.5in;
+  }
+
+  body {
+    font-size: 10pt;
+    line-height: 1.4;
+    color: #000;
+    background: #fff;
+  }
+
+  /* Header styling */
+  .walk-sheet-header {
+    text-align: center;
+    margin-bottom: 20px;
+    border-bottom: 2px solid #000;
+    padding-bottom: 10px;
+  }
+
+  .walk-sheet-title {
+    font-size: 16pt;
+    font-weight: bold;
+    margin-bottom: 5px;
+  }
+
+  .walk-sheet-subtitle {
+    font-size: 12pt;
+    color: #333;
+  }
+
+  /* Table styling */
+  table {
+    width: 100%;
+    border-collapse: collapse;
+    page-break-inside: avoid;
+    margin-bottom: 20px;
+  }
+
+  th, td {
+    border: 1px solid #000;
+    padding: 6px;
+    text-align: left;
+  }
+
+  th {
+    background-color: #f0f0f0;
+    font-weight: bold;
+    font-size: 9pt;
+  }
+
+  td {
+    font-size: 9pt;
+  }
+
+  /* Prevent row breaks */
+  tr {
+    page-break-inside: avoid;
+  }
+
+  /* QR code section */
+  .qr-code-section {
+    display: flex;
+    justify-content: space-around;
+    margin: 20px 0;
+    page-break-inside: avoid;
+  }
+
+  .qr-code-item {
+    text-align: center;
+    width: 150px;
+  }
+
+  .qr-code-item img {
+    width: 150px;
+    height: 150px;
+    margin-bottom: 5px;
+  }
+
+  .qr-code-label {
+    font-size: 9pt;
+    font-weight: bold;
+  }
+
+  /* Footer styling */
+  .walk-sheet-footer {
+    margin-top: 20px;
+    padding-top: 10px;
+    border-top: 1px solid #000;
+    font-size: 9pt;
+    white-space: pre-wrap;
+  }
+
+  /* Checkbox styling */
+  .visited-checkbox {
+    width: 15px;
+    height: 15px;
+    border: 1px solid #000;
+    display: inline-block;
+  }
+
+  /* Support level indicators */
+  .support-level-1 { color: #e74c3c; } /* Strong Opposition */
+  .support-level-2 { color: #f39c12; } /* Lean Opposition */
+  .support-level-3 { color: #95a5a6; } /* Undecided */
+  .support-level-4 { color: #3498db; } /* Lean Support */
+  .support-level-5 { color: #27ae60; } /* Strong Support */
+}
+
+

Address Table Layout

+

Column Structure:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ColumnWidthContentSort
Address40%Street addressAlphabetical
Unit10%Unit/apartment numberAlphanumeric
Name20%First + Last nameAlphabetical
Support10%Support level (1-5)Color-coded
Notes15%Canvasser notesN/A
Visited5%CheckboxN/A
+

Multi-Unit Grouping:

+
┌───────────────────┬──────┬────────────┬─────────┬────────┬─────────┐
+│ Address           │ Unit │ Name       │ Support │ Notes  │ Visited │
+├───────────────────┼──────┼────────────┼─────────┼────────┼─────────┤
+│ 100 Adelaide St E │ 101  │ John Smith │    4    │ Lawn   │    □    │
+│ 100 Adelaide St E │ 102  │ Jane Doe   │    5    │        │    □    │
+│ 100 Adelaide St E │ 103  │            │         │        │    □    │
+├───────────────────┼──────┼────────────┼─────────┼────────┼─────────┤
+│ 102 Adelaide St E │      │            │         │        │    □    │
+└───────────────────┴──────┴────────────┴─────────┴────────┴─────────┘
+
+

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)

+

QR Code Layout

+

Horizontal Layout:

+
    [QR 150×150]         [QR 150×150]         [QR 150×150]
+    Campaign Page        Volunteer Info        Donate Now
+
+

QR Code Generation: +- Size: 150×150 pixels +- Error correction: Medium (M) +- Format: PNG with transparent background +- Encoding: UTF-8 +- Margin: 4 modules

+

Spacing: +- Between codes: 30px +- Above section: 20px +- Below section: 20px +- Label margin: 5px

+

Cut Export Page

+

Export Report Structure

+

Component: CutExportPage.tsx

+

Route: /app/map/cuts/:id/export

+

Layout:

+
┌─────────────────────────────────────────────┐
+│  Cut Export Report                          │
+│  [Cut Name]                                 │
+│  Generated: [Date Time]                     │
+├─────────────────────────────────────────────┤
+│  STATISTICS PANEL                           │
+│  ┌──────────────┬──────────────┐            │
+│  │ Total Locs   │ Geocoded     │            │
+│  │ 150          │ 148 (98.7%)  │            │
+│  ├──────────────┼──────────────┤            │
+│  │ Total Units  │ Residential  │            │
+│  │ 287          │ 280 (97.6%)  │            │
+│  └──────────────┴──────────────┘            │
+├─────────────────────────────────────────────┤
+│  LOCATION TABLE                             │
+│  [Sortable, filterable table]              │
+├─────────────────────────────────────────────┤
+│  ACTIONS                                    │
+│  [Export CSV] [Print]                       │
+└─────────────────────────────────────────────┘
+
+

Statistics Panel

+

Metrics Displayed:

+
    +
  1. Total Locations: Count of locations within cut
  2. +
  3. Total Units: Sum of addresses across all locations
  4. +
  5. Geocoded Locations: Locations with lat/lng (% of total)
  6. +
  7. Missing Coordinates: Locations without lat/lng
  8. +
  9. Residential Units: Units with buildingUse = 1
  10. +
  11. Commercial Units: Units with buildingUse != 1
  12. +
  13. Support Level Breakdown: Count by level (1-5)
  14. +
  15. Cut Area: Approximate area in square kilometers
  16. +
+

Statistics Calculation:

+
interface CutStatistics {
+  totalLocations: number;
+  totalUnits: number;
+  geocodedCount: number;
+  geocodedPercent: number;
+  missingCoordinates: number;
+  residentialCount: number;
+  residentialPercent: number;
+  commercialCount: number;
+  supportLevelBreakdown: Record<number, number>;
+  cutAreaKm2: number;
+}
+
+const calculateStats = (locations: Location[]): CutStatistics => {
+  const totalLocations = locations.length;
+  const totalUnits = locations.reduce((sum, loc) =>
+    sum + loc.addresses.length, 0);
+  const geocodedCount = locations.filter(loc =>
+    loc.latitude && loc.longitude).length;
+  const residentialCount = locations.filter(loc =>
+    loc.buildingUse === 1).length;
+
+  const supportLevelBreakdown = {};
+  locations.forEach(loc => {
+    loc.addresses.forEach(addr => {
+      if (addr.supportLevel) {
+        supportLevelBreakdown[addr.supportLevel] =
+          (supportLevelBreakdown[addr.supportLevel] || 0) + 1;
+      }
+    });
+  });
+
+  return {
+    totalLocations,
+    totalUnits,
+    geocodedCount,
+    geocodedPercent: (geocodedCount / totalLocations) * 100,
+    missingCoordinates: totalLocations - geocodedCount,
+    residentialCount,
+    residentialPercent: (residentialCount / totalLocations) * 100,
+    commercialCount: totalLocations - residentialCount,
+    supportLevelBreakdown,
+    cutAreaKm2: calculatePolygonArea(cut.geojson)
+  };
+};
+
+

Location Table

+

Columns:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ColumnDataFormat
Addresslocation.addressString
Latitudelocation.latitude6 decimals
Longitudelocation.longitude6 decimals
Postal Codelocation.postalCodeUppercase
Unitsaddresses.lengthInteger
ResidentialbuildingUse === 1Boolean
Support Avgavg(addresses.supportLevel)1 decimal
+

Table Features:

+
    +
  • Sortable by all columns
  • +
  • Filterable by postal code prefix
  • +
  • Pagination (50 per page)
  • +
  • Export selected rows to CSV
  • +
  • Highlight locations with missing coordinates
  • +
  • Color-code by average support level
  • +
+

CSV Export

+

Export Button Handler:

+
const exportToCSV = () => {
+  const headers = [
+    'Address',
+    'Latitude',
+    'Longitude',
+    'Postal Code',
+    'Units',
+    'Residential',
+    'Support Average',
+    'Federal District'
+  ];
+
+  const rows = locations.map(loc => [
+    loc.address,
+    loc.latitude?.toFixed(6) || '',
+    loc.longitude?.toFixed(6) || '',
+    loc.postalCode || '',
+    loc.addresses.length,
+    loc.buildingUse === 1 ? 'Yes' : 'No',
+    calculateAverageSupportLevel(loc.addresses).toFixed(1),
+    loc.federalDistrict || ''
+  ]);
+
+  const csv = [headers, ...rows]
+    .map(row => row.map(cell => `"${cell}"`).join(','))
+    .join('\n');
+
+  const blob = new Blob([csv], { type: 'text/csv' });
+  const url = URL.createObjectURL(blob);
+  const link = document.createElement('a');
+  link.href = url;
+  link.download = `cut-${cutId}-${cutName}-${new Date().toISOString().split('T')[0]}.csv`;
+  link.click();
+  URL.revokeObjectURL(url);
+};
+
+

CSV Output Example:

+
"Address","Latitude","Longitude","Postal Code","Units","Residential","Support Average","Federal District"
+"100 Adelaide St E","43.653200","-79.383200","M5H 2N2","2","Yes","4.5","Toronto Centre"
+"102 Adelaide St E","43.654000","-79.382500","M5H 2N3","1","Yes","3.0","Toronto Centre"
+"105 Bay St","43.650000","-79.380000","M5J 2R8","12","Yes","4.2","Toronto Centre"
+
+

Code Examples

+

WalkSheetPage.tsx - Component Structure

+
import React, { useEffect, useState } from 'react';
+import { Select, Button, Table, Space, Spin, Typography, Row, Col } from 'antd';
+import { PrinterOutlined } from '@ant-design/icons';
+import { api } from '@/lib/api';
+import type { Cut, Location, MapSettings } from '@/types/api';
+
+const { Title, Text } = Typography;
+
+const WalkSheetPage: React.FC = () => {
+  const [cuts, setCuts] = useState<Cut[]>([]);
+  const [selectedCutId, setSelectedCutId] = useState<number | null>(null);
+  const [locations, setLocations] = useState<Location[]>([]);
+  const [settings, setSettings] = useState<MapSettings | null>(null);
+  const [qrCodes, setQrCodes] = useState<Record<number, string>>({});
+  const [loading, setLoading] = useState(false);
+
+  useEffect(() => {
+    fetchCuts();
+    fetchSettings();
+  }, []);
+
+  useEffect(() => {
+    if (selectedCutId) {
+      fetchLocations(selectedCutId);
+    }
+  }, [selectedCutId]);
+
+  useEffect(() => {
+    if (settings) {
+      generateQRCodes();
+    }
+  }, [settings]);
+
+  const fetchCuts = async () => {
+    const { data } = await api.get<Cut[]>('/cuts');
+    setCuts(data);
+  };
+
+  const fetchSettings = async () => {
+    const { data } = await api.get<MapSettings>('/map-settings');
+    setSettings(data);
+  };
+
+  const fetchLocations = async (cutId: number) => {
+    setLoading(true);
+    try {
+      const { data } = await api.get<{ data: Location[] }>(
+        `/locations?cutId=${cutId}&sortBy=address&order=asc`
+      );
+      setLocations(data.data);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const generateQRCodes = async () => {
+    if (!settings) return;
+
+    const codes: Record<number, string> = {};
+    const qrUrls = [
+      { url: settings.qrCode1Url, index: 1 },
+      { url: settings.qrCode2Url, index: 2 },
+      { url: settings.qrCode3Url, index: 3 }
+    ].filter(item => item.url);
+
+    for (const { url, index } of qrUrls) {
+      try {
+        const { data } = await api.post('/qr/generate', { url, size: 150 });
+        codes[index] = data.png;
+      } catch (error) {
+        console.error(`Failed to generate QR code ${index}:`, error);
+      }
+    }
+
+    setQrCodes(codes);
+  };
+
+  const handlePrint = () => {
+    window.print();
+  };
+
+  const columns = [
+    {
+      title: 'Address',
+      dataIndex: 'address',
+      key: 'address',
+      width: '40%'
+    },
+    {
+      title: 'Unit',
+      key: 'unit',
+      width: '10%',
+      render: (_: any, record: Location) => (
+        <Space direction="vertical" size={0}>
+          {record.addresses.map(addr => (
+            <Text key={addr.id}>{addr.unitNumber || '-'}</Text>
+          ))}
+        </Space>
+      )
+    },
+    {
+      title: 'Name',
+      key: 'name',
+      width: '20%',
+      render: (_: any, record: Location) => (
+        <Space direction="vertical" size={0}>
+          {record.addresses.map(addr => (
+            <Text key={addr.id}>
+              {addr.firstName && addr.lastName
+                ? `${addr.firstName} ${addr.lastName}`
+                : '-'}
+            </Text>
+          ))}
+        </Space>
+      )
+    },
+    {
+      title: 'Support',
+      key: 'support',
+      width: '10%',
+      render: (_: any, record: Location) => (
+        <Space direction="vertical" size={0}>
+          {record.addresses.map(addr => (
+            <Text
+              key={addr.id}
+              className={addr.supportLevel ? `support-level-${addr.supportLevel}` : ''}
+            >
+              {addr.supportLevel || '-'}
+            </Text>
+          ))}
+        </Space>
+      )
+    },
+    {
+      title: 'Notes',
+      key: 'notes',
+      width: '15%',
+      render: (_: any, record: Location) => (
+        <Space direction="vertical" size={0}>
+          {record.addresses.map(addr => (
+            <Text key={addr.id} ellipsis={{ tooltip: addr.notes }}>
+              {addr.notes || '-'}
+            </Text>
+          ))}
+        </Space>
+      )
+    },
+    {
+      title: 'Visited',
+      key: 'visited',
+      width: '5%',
+      render: (_: any, record: Location) => (
+        <Space direction="vertical" size={0}>
+          {record.addresses.map(addr => (
+            <div key={addr.id} className="visited-checkbox" />
+          ))}
+        </Space>
+      )
+    }
+  ];
+
+  const selectedCut = cuts.find(c => c.id === selectedCutId);
+
+  return (
+    <div className="walk-sheet-page">
+      {/* Controls - hidden when printing */}
+      <div className="no-print" style={{ marginBottom: 24 }}>
+        <Space>
+          <Select
+            style={{ width: 300 }}
+            placeholder="Select a cut"
+            value={selectedCutId}
+            onChange={setSelectedCutId}
+            options={cuts.map(cut => ({
+              label: cut.name,
+              value: cut.id
+            }))}
+          />
+          <Button
+            type="primary"
+            icon={<PrinterOutlined />}
+            onClick={handlePrint}
+            disabled={!selectedCutId || loading}
+          >
+            Print
+          </Button>
+        </Space>
+      </div>
+
+      {/* Walk Sheet Content - printed */}
+      {selectedCutId && settings && (
+        <>
+          {/* Header */}
+          <div className="walk-sheet-header">
+            <Title level={2} className="walk-sheet-title">
+              {settings.walkSheetTitle}
+            </Title>
+            {settings.walkSheetSubtitle && (
+              <Text className="walk-sheet-subtitle">
+                {settings.walkSheetSubtitle}
+              </Text>
+            )}
+            <div style={{ marginTop: 8 }}>
+              <Text strong>Cut: </Text>
+              <Text>{selectedCut?.name}</Text>
+              <br />
+              <Text strong>Date: </Text>
+              <Text>{new Date().toLocaleDateString()}</Text>
+            </div>
+          </div>
+
+          {/* Address Table */}
+          {loading ? (
+            <div style={{ textAlign: 'center', padding: 40 }}>
+              <Spin size="large" />
+            </div>
+          ) : (
+            <Table
+              dataSource={locations}
+              columns={columns}
+              pagination={false}
+              rowKey="id"
+              bordered
+            />
+          )}
+
+          {/* QR Codes */}
+          {Object.keys(qrCodes).length > 0 && (
+            <Row gutter={16} className="qr-code-section">
+              {[1, 2, 3].map(index => {
+                const qrUrl = settings[`qrCode${index}Url` as keyof MapSettings];
+                const qrLabel = settings[`qrCode${index}Label` as keyof MapSettings];
+                if (!qrUrl || !qrCodes[index]) return null;
+
+                return (
+                  <Col key={index} span={8} className="qr-code-item">
+                    <img src={qrCodes[index]} alt={`QR Code ${index}`} />
+                    <div className="qr-code-label">{qrLabel}</div>
+                  </Col>
+                );
+              })}
+            </Row>
+          )}
+
+          {/* Footer */}
+          {settings.walkSheetFooter && (
+            <div className="walk-sheet-footer">
+              {settings.walkSheetFooter}
+            </div>
+          )}
+        </>
+      )}
+
+      {/* Print Styles */}
+      <style>{`
+        @media print {
+          .no-print {
+            display: none !important;
+          }
+
+          @page {
+            size: A4 portrait;
+            margin: 0.5in;
+          }
+
+          body {
+            font-size: 10pt;
+            line-height: 1.4;
+          }
+
+          .walk-sheet-header {
+            text-align: center;
+            margin-bottom: 20px;
+            border-bottom: 2px solid #000;
+            padding-bottom: 10px;
+          }
+
+          .walk-sheet-title {
+            font-size: 16pt !important;
+            margin-bottom: 5px !important;
+          }
+
+          .walk-sheet-subtitle {
+            font-size: 12pt;
+          }
+
+          table {
+            page-break-inside: avoid;
+          }
+
+          th, td {
+            font-size: 9pt !important;
+            padding: 6px !important;
+          }
+
+          .visited-checkbox {
+            width: 15px;
+            height: 15px;
+            border: 1px solid #000;
+            display: inline-block;
+          }
+
+          .support-level-1 { color: #e74c3c; }
+          .support-level-2 { color: #f39c12; }
+          .support-level-3 { color: #95a5a6; }
+          .support-level-4 { color: #3498db; }
+          .support-level-5 { color: #27ae60; }
+
+          .qr-code-section {
+            display: flex;
+            justify-content: space-around;
+            margin: 20px 0;
+            page-break-inside: avoid;
+          }
+
+          .qr-code-item {
+            text-align: center;
+          }
+
+          .qr-code-item img {
+            width: 150px;
+            height: 150px;
+          }
+
+          .qr-code-label {
+            font-size: 9pt;
+            font-weight: bold;
+            margin-top: 5px;
+          }
+
+          .walk-sheet-footer {
+            margin-top: 20px;
+            padding-top: 10px;
+            border-top: 1px solid #000;
+            font-size: 9pt;
+            white-space: pre-wrap;
+          }
+        }
+      `}</style>
+    </div>
+  );
+};
+
+export default WalkSheetPage;
+
+

QR Code API - qr.routes.ts

+
import { Router } from 'express';
+import QRCode from 'qrcode';
+import { z } from 'zod';
+import { validate } from '@/middleware/validate';
+import rateLimit from 'express-rate-limit';
+
+const router = Router();
+
+// Rate limiter: 100 requests per 15 minutes
+const qrLimiter = rateLimit({
+  windowMs: 15 * 60 * 1000,
+  max: 100,
+  message: 'Too many QR code requests, please try again later'
+});
+
+const generateQRSchema = z.object({
+  body: z.object({
+    url: z.string().url('Must be a valid URL'),
+    size: z.number().int().min(50).max(500).optional().default(200)
+  })
+});
+
+/**
+ * POST /api/qr/generate
+ * Generate QR code PNG from URL
+ * Public endpoint (no authentication)
+ */
+router.post(
+  '/generate',
+  qrLimiter,
+  validate(generateQRSchema),
+  async (req, res, next) => {
+    try {
+      const { url, size } = req.body;
+
+      // Generate QR code as data URL
+      const png = await QRCode.toDataURL(url, {
+        width: size,
+        margin: 4,
+        errorCorrectionLevel: 'M',
+        type: 'image/png'
+      });
+
+      res.json({ png });
+    } catch (error) {
+      next(error);
+    }
+  }
+);
+
+export default router;
+
+

MapSettingsPage.tsx - QR Code Configuration

+
import React, { useEffect } from 'react';
+import { Form, Input, Button, message, Divider, Space, Typography } from 'antd';
+import { api } from '@/lib/api';
+import type { MapSettings } from '@/types/api';
+
+const { Title, Text } = Typography;
+
+const MapSettingsPage: React.FC = () => {
+  const [form] = Form.useForm();
+  const [loading, setLoading] = useState(false);
+
+  useEffect(() => {
+    fetchSettings();
+  }, []);
+
+  const fetchSettings = async () => {
+    setLoading(true);
+    try {
+      const { data } = await api.get<MapSettings>('/map-settings');
+      form.setFieldsValue(data);
+    } catch (error) {
+      message.error('Failed to load map settings');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleSubmit = async (values: Partial<MapSettings>) => {
+    setLoading(true);
+    try {
+      await api.put('/map-settings', values);
+      message.success('Settings saved successfully');
+    } catch (error) {
+      message.error('Failed to save settings');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <div>
+      <Title level={2}>Map Settings</Title>
+
+      <Form
+        form={form}
+        layout="vertical"
+        onFinish={handleSubmit}
+        disabled={loading}
+      >
+        <Divider orientation="left">Walk Sheet Configuration</Divider>
+
+        <Form.Item
+          label="Walk Sheet Title"
+          name="walkSheetTitle"
+          rules={[
+            { required: true, message: 'Title is required' },
+            { max: 100, message: 'Maximum 100 characters' }
+          ]}
+        >
+          <Input placeholder="Walk Sheet" />
+        </Form.Item>
+
+        <Form.Item
+          label="Walk Sheet Subtitle"
+          name="walkSheetSubtitle"
+          rules={[{ max: 200, message: 'Maximum 200 characters' }]}
+        >
+          <Input placeholder="Ward 10 - November 2025" />
+        </Form.Item>
+
+        <Form.Item
+          label="Walk Sheet Footer"
+          name="walkSheetFooter"
+          rules={[{ max: 500, message: 'Maximum 500 characters' }]}
+        >
+          <Input.TextArea
+            rows={4}
+            placeholder="Contact information, instructions, etc."
+          />
+        </Form.Item>
+
+        <Divider orientation="left">QR Code 1</Divider>
+
+        <Form.Item
+          label="QR Code 1 URL"
+          name="qrCode1Url"
+          rules={[{ type: 'url', message: 'Must be a valid URL' }]}
+        >
+          <Input placeholder="https://example.com/campaign" />
+        </Form.Item>
+
+        <Form.Item
+          label="QR Code 1 Label"
+          name="qrCode1Label"
+          rules={[{ max: 50, message: 'Maximum 50 characters' }]}
+        >
+          <Input placeholder="Campaign Page" />
+        </Form.Item>
+
+        <Divider orientation="left">QR Code 2</Divider>
+
+        <Form.Item
+          label="QR Code 2 URL"
+          name="qrCode2Url"
+          rules={[{ type: 'url', message: 'Must be a valid URL' }]}
+        >
+          <Input placeholder="https://example.com/volunteer" />
+        </Form.Item>
+
+        <Form.Item
+          label="QR Code 2 Label"
+          name="qrCode2Label"
+          rules={[{ max: 50, message: 'Maximum 50 characters' }]}
+        >
+          <Input placeholder="Volunteer Info" />
+        </Form.Item>
+
+        <Divider orientation="left">QR Code 3</Divider>
+
+        <Form.Item
+          label="QR Code 3 URL"
+          name="qrCode3Url"
+          rules={[{ type: 'url', message: 'Must be a valid URL' }]}
+        >
+          <Input placeholder="https://example.com/donate" />
+        </Form.Item>
+
+        <Form.Item
+          label="QR Code 3 Label"
+          name="qrCode3Label"
+          rules={[{ max: 50, message: 'Maximum 50 characters' }]}
+        >
+          <Input placeholder="Donate Now" />
+        </Form.Item>
+
+        <Form.Item>
+          <Space>
+            <Button type="primary" htmlType="submit" loading={loading}>
+              Save Settings
+            </Button>
+            <Button onClick={fetchSettings}>Reset</Button>
+          </Space>
+        </Form.Item>
+      </Form>
+    </div>
+  );
+};
+
+export default MapSettingsPage;
+
+

CutExportPage.tsx - Statistics and CSV Export

+
import React, { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+import { Button, Table, Card, Row, Col, Statistic, Space, message } from 'antd';
+import { PrinterOutlined, DownloadOutlined } from '@ant-design/icons';
+import { api } from '@/lib/api';
+import type { Cut, Location } from '@/types/api';
+
+const CutExportPage: React.FC = () => {
+  const { id } = useParams<{ id: string }>();
+  const cutId = parseInt(id);
+
+  const [cut, setCut] = useState<Cut | null>(null);
+  const [locations, setLocations] = useState<Location[]>([]);
+  const [loading, setLoading] = useState(false);
+
+  useEffect(() => {
+    fetchData();
+  }, [cutId]);
+
+  const fetchData = async () => {
+    setLoading(true);
+    try {
+      const [cutRes, locsRes] = await Promise.all([
+        api.get<Cut>(`/cuts/${cutId}`),
+        api.get<{ data: Location[] }>(`/locations?cutId=${cutId}`)
+      ]);
+      setCut(cutRes.data);
+      setLocations(locsRes.data.data);
+    } catch (error) {
+      message.error('Failed to load cut data');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const calculateStats = () => {
+    const totalLocations = locations.length;
+    const totalUnits = locations.reduce((sum, loc) => sum + loc.addresses.length, 0);
+    const geocoded = locations.filter(loc => loc.latitude && loc.longitude).length;
+    const residential = locations.filter(loc => loc.buildingUse === 1).length;
+
+    return {
+      totalLocations,
+      totalUnits,
+      geocoded,
+      geocodedPercent: totalLocations > 0 ? (geocoded / totalLocations) * 100 : 0,
+      residential,
+      residentialPercent: totalLocations > 0 ? (residential / totalLocations) * 100 : 0
+    };
+  };
+
+  const exportToCSV = () => {
+    const headers = [
+      'Address',
+      'Latitude',
+      'Longitude',
+      'Postal Code',
+      'Units',
+      'Residential'
+    ];
+
+    const rows = locations.map(loc => [
+      loc.address,
+      loc.latitude?.toFixed(6) || '',
+      loc.longitude?.toFixed(6) || '',
+      loc.postalCode || '',
+      loc.addresses.length,
+      loc.buildingUse === 1 ? 'Yes' : 'No'
+    ]);
+
+    const csv = [headers, ...rows]
+      .map(row => row.map(cell => `"${cell}"`).join(','))
+      .join('\n');
+
+    const blob = new Blob([csv], { type: 'text/csv' });
+    const url = URL.createObjectURL(blob);
+    const link = document.createElement('a');
+    link.href = url;
+    link.download = `cut-${cutId}-${cut?.name.replace(/\s+/g, '-').toLowerCase()}-${new Date().toISOString().split('T')[0]}.csv`;
+    link.click();
+    URL.revokeObjectURL(url);
+  };
+
+  const handlePrint = () => {
+    window.print();
+  };
+
+  const stats = calculateStats();
+
+  const columns = [
+    { title: 'Address', dataIndex: 'address', key: 'address' },
+    {
+      title: 'Latitude',
+      dataIndex: 'latitude',
+      key: 'latitude',
+      render: (val: number) => val?.toFixed(6) || 'N/A'
+    },
+    {
+      title: 'Longitude',
+      dataIndex: 'longitude',
+      key: 'longitude',
+      render: (val: number) => val?.toFixed(6) || 'N/A'
+    },
+    { title: 'Postal Code', dataIndex: 'postalCode', key: 'postalCode' },
+    {
+      title: 'Units',
+      key: 'units',
+      render: (_: any, record: Location) => record.addresses.length
+    },
+    {
+      title: 'Residential',
+      dataIndex: 'buildingUse',
+      key: 'residential',
+      render: (val: number) => val === 1 ? 'Yes' : 'No'
+    }
+  ];
+
+  return (
+    <div className="cut-export-page">
+      <div className="no-print" style={{ marginBottom: 24 }}>
+        <Space>
+          <Button icon={<PrinterOutlined />} onClick={handlePrint}>
+            Print
+          </Button>
+          <Button icon={<DownloadOutlined />} onClick={exportToCSV}>
+            Export CSV
+          </Button>
+        </Space>
+      </div>
+
+      <div className="cut-export-header">
+        <h1>Cut Export Report</h1>
+        <h2>{cut?.name}</h2>
+        <p>Generated: {new Date().toLocaleString()}</p>
+      </div>
+
+      <Card title="Statistics" style={{ marginBottom: 24 }}>
+        <Row gutter={16}>
+          <Col span={6}>
+            <Statistic title="Total Locations" value={stats.totalLocations} />
+          </Col>
+          <Col span={6}>
+            <Statistic title="Total Units" value={stats.totalUnits} />
+          </Col>
+          <Col span={6}>
+            <Statistic
+              title="Geocoded"
+              value={stats.geocoded}
+              suffix={`(${stats.geocodedPercent.toFixed(1)}%)`}
+            />
+          </Col>
+          <Col span={6}>
+            <Statistic
+              title="Residential"
+              value={stats.residential}
+              suffix={`(${stats.residentialPercent.toFixed(1)}%)`}
+            />
+          </Col>
+        </Row>
+      </Card>
+
+      <Table
+        dataSource={locations}
+        columns={columns}
+        rowKey="id"
+        loading={loading}
+        pagination={{ pageSize: 50 }}
+      />
+
+      <style>{`
+        @media print {
+          .no-print { display: none !important; }
+          @page { size: A4 landscape; margin: 0.5in; }
+          body { font-size: 10pt; }
+        }
+      `}</style>
+    </div>
+  );
+};
+
+export default CutExportPage;
+
+

Troubleshooting

+

Problem: QR codes not generating

+

Symptoms: +- Empty QR code section on walk sheet +- Console errors about /api/qr/generate +- Network 404 or 500 errors

+

Solutions:

+
    +
  1. +

    Verify endpoint accessibility: +

    curl -X POST http://localhost:4000/api/qr/generate \
    +  -H "Content-Type: application/json" \
    +  -d '{"url":"https://example.com","size":200}'
    +

    +
  2. +
  3. +

    Check qrcode package installed: +

    cd api
    +npm list qrcode
    +# If not installed:
    +npm install qrcode
    +npm install --save-dev @types/qrcode
    +

    +
  4. +
  5. +

    Verify route registration in server.ts: +

    import qrRoutes from './modules/qr/qr.routes';
    +app.use('/api/qr', qrRoutes);
    +

    +
  6. +
  7. +

    Check URL validation: +

    // URL must start with http:// or https://
    +const validUrls = [
    +  'https://example.com',  // ✓ Valid
    +  'http://example.com',   // ✓ Valid
    +  'example.com',          // ✗ Invalid (missing protocol)
    +  'ftp://example.com'     // ✗ Invalid (wrong protocol)
    +];
    +

    +
  8. +
  9. +

    Test with simple URL: +

    // Test with minimal payload
    +const testQR = async () => {
    +  const { data } = await api.post('/qr/generate', {
    +    url: 'https://google.com'
    +    // size omitted (uses default 200)
    +  });
    +  console.log('QR generated:', data.png.substring(0, 50));
    +};
    +

    +
  10. +
+

Problem: Print layout broken

+

Symptoms: +- Elements overlap when printing +- Missing borders or backgrounds +- Incorrect page breaks +- Cut-off content

+

Solutions:

+
    +
  1. Enable background graphics in browser:
  2. +
  3. Chrome: Print → More settings → Background graphics (checked)
  4. +
  5. Firefox: Print → Options → Print backgrounds (checked)
  6. +
  7. +

    Safari: Print → Show Details → Print backgrounds (checked)

    +
  8. +
  9. +

    Test print preview first: +

    // Add print preview button for debugging
    +const handlePrintPreview = () => {
    +  const printWindow = window.open('', '_blank');
    +  printWindow?.document.write(document.documentElement.outerHTML);
    +  printWindow?.print();
    +};
    +

    +
  10. +
  11. +

    Check @page margins: +

    @media print {
    +  @page {
    +    size: A4 portrait;
    +    margin: 0.5in; /* Adjust if content cut off */
    +  }
    +}
    +

    +
  12. +
  13. +

    Prevent table row breaks: +

    @media print {
    +  tr {
    +    page-break-inside: avoid;
    +    page-break-after: auto;
    +  }
    +
    +  thead {
    +    display: table-header-group; /* Repeat on each page */
    +  }
    +}
    +

    +
  14. +
  15. +

    Test in different browsers:

    +
  16. +
  17. Chrome/Edge: Best print CSS support
  18. +
  19. Firefox: Good, but some layout differences
  20. +
  21. +

    Safari: May require webkit prefixes

    +
  22. +
  23. +

    Adjust font sizes if content overflows: +

    @media print {
    +  body { font-size: 9pt; } /* Reduce from 10pt */
    +  th, td { font-size: 8pt; } /* Reduce from 9pt */
    +}
    +

    +
  24. +
+

Problem: Walk sheet showing wrong cut

+

Symptoms: +- Selected cut shows different locations +- Location count doesn't match cut +- Locations outside cut boundary visible

+

Solutions:

+
    +
  1. +

    Verify cutId in API request: +

    console.log('Fetching locations for cut:', selectedCutId);
    +const { data } = await api.get(`/locations?cutId=${selectedCutId}`);
    +console.log('Received locations:', data.data.length);
    +

    +
  2. +
  3. +

    Check point-in-polygon filter: +

    // In locations.service.ts
    +const locations = await prisma.location.findMany({
    +  where: {
    +    AND: [
    +      { latitude: { not: null } },
    +      { longitude: { not: null } }
    +    ]
    +  }
    +});
    +
    +// Filter by cut boundary
    +const cut = await prisma.cut.findUnique({ where: { id: cutId } });
    +const filtered = locations.filter(loc =>
    +  isPointInPolygon([loc.longitude!, loc.latitude!], cut.geojson)
    +);
    +
    +console.log('Total locations:', locations.length);
    +console.log('Within cut:', filtered.length);
    +

    +
  4. +
  5. +

    Test with simple rectangular cut: +

    {
    +  "type": "Polygon",
    +  "coordinates": [
    +    [
    +      [-79.40, 43.64],
    +      [-79.36, 43.64],
    +      [-79.36, 43.66],
    +      [-79.40, 43.66],
    +      [-79.40, 43.64]
    +    ]
    +  ]
    +}
    +

    +
  6. +
  7. +

    Verify GeoJSON coordinate order: +

    // Correct: [longitude, latitude]
    +const point = [loc.longitude, loc.latitude]; // ✓
    +
    +// Incorrect: [latitude, longitude]
    +const point = [loc.latitude, loc.longitude]; // ✗
    +

    +
  8. +
  9. +

    Check cut geojson validity:

    +
  10. +
  11. First and last coordinates must be identical (closed polygon)
  12. +
  13. Coordinates must be [lng, lat] order
  14. +
  15. Use http://geojson.io to visualize
  16. +
+

Problem: Large cuts slow to load

+

Symptoms: +- Walk sheet takes > 10 seconds to load +- Browser freezes during render +- Print preview crashes

+

Solutions:

+
    +
  1. +

    Implement pagination: +

    const LOCATIONS_PER_PAGE = 50;
    +
    +const [currentPage, setCurrentPage] = useState(1);
    +const paginatedLocations = locations.slice(
    +  (currentPage - 1) * LOCATIONS_PER_PAGE,
    +  currentPage * LOCATIONS_PER_PAGE
    +);
    +

    +
  2. +
  3. +

    Add location count warning: +

    {locations.length > 200 && (
    +  <Alert
    +    message="Large Cut"
    +    description={`This cut has ${locations.length} locations. Consider splitting into smaller sheets.`}
    +    type="warning"
    +    showIcon
    +  />
    +)}
    +

    +
  4. +
  5. +

    Use virtual scrolling for preview: +

    import { List } from 'react-virtualized';
    +
    +// Render only visible rows during preview
    +<List
    +  height={600}
    +  rowCount={locations.length}
    +  rowHeight={40}
    +  rowRenderer={({ index, style }) => (
    +    <div style={style}>{renderLocationRow(locations[index])}</div>
    +  )}
    +/>
    +

    +
  6. +
  7. +

    Optimize QR code generation: +

    // Generate QR codes only when print button clicked
    +const [qrCodesGenerated, setQrCodesGenerated] = useState(false);
    +
    +const handlePrint = async () => {
    +  if (!qrCodesGenerated) {
    +    await generateQRCodes();
    +    setQrCodesGenerated(true);
    +  }
    +  window.print();
    +};
    +

    +
  8. +
  9. +

    Split large cuts into multiple sheets: +

    // Group by postal code prefix
    +const groupedByPostal = locations.reduce((acc, loc) => {
    +  const prefix = loc.postalCode?.substring(0, 3) || 'Unknown';
    +  if (!acc[prefix]) acc[prefix] = [];
    +  acc[prefix].push(loc);
    +  return acc;
    +}, {} as Record<string, Location[]>);
    +
    +// Generate separate sheet per group
    +Object.entries(groupedByPostal).forEach(([prefix, locs]) => {
    +  console.log(`${prefix}: ${locs.length} locations`);
    +});
    +

    +
  10. +
+

Performance Considerations

+

Client-Side Rendering

+

Walk Sheet Page Load: +- Initial load: ~500ms (fetch cuts + settings) +- Cut selection: ~1-2 seconds (fetch locations + generate QR codes) +- Large cuts (500+ locations): ~3-5 seconds +- QR code generation: ~100ms per code (parallel)

+

Optimization Strategies:

+
    +
  1. +

    Lazy load QR codes: +

    // Only generate when visible
    +const [qrCodesVisible, setQrCodesVisible] = useState(false);
    +
    +useEffect(() => {
    +  const observer = new IntersectionObserver(entries => {
    +    if (entries[0].isIntersecting && !qrCodesVisible) {
    +      generateQRCodes();
    +      setQrCodesVisible(true);
    +    }
    +  });
    +  observer.observe(qrSectionRef.current);
    +  return () => observer.disconnect();
    +}, []);
    +

    +
  2. +
  3. +

    Cache QR codes in localStorage: +

    const getCachedQR = (url: string): string | null => {
    +  const cached = localStorage.getItem(`qr:${url}`);
    +  if (cached) {
    +    const { png, timestamp } = JSON.parse(cached);
    +    // Cache valid for 24 hours
    +    if (Date.now() - timestamp < 24 * 60 * 60 * 1000) {
    +      return png;
    +    }
    +  }
    +  return null;
    +};
    +
    +const cacheQR = (url: string, png: string) => {
    +  localStorage.setItem(`qr:${url}`, JSON.stringify({
    +    png,
    +    timestamp: Date.now()
    +  }));
    +};
    +

    +
  4. +
  5. +

    Debounce cut selection: +

    import { debounce } from 'lodash';
    +
    +const debouncedFetchLocations = debounce((cutId: number) => {
    +  fetchLocations(cutId);
    +}, 300);
    +
    +<Select onChange={debouncedFetchLocations} />
    +

    +
  6. +
+

Server-Side Performance

+

API Response Times: +- GET /api/map-settings: ~50ms (singleton query) +- GET /api/cuts/🆔 ~100ms (single record + geojson) +- GET /api/locations?cutId=X: ~500ms-2s (depends on cut size) +- POST /api/qr/generate: ~50ms (QRCode.toDataURL is fast)

+

Database Optimization:

+
-- Index for cut location queries
+CREATE INDEX idx_locations_coords ON "Location"(latitude, longitude);
+
+-- Index for address sorting
+CREATE INDEX idx_locations_address ON "Location"(address);
+
+-- Composite index for geocoded locations
+CREATE INDEX idx_locations_geocoded ON "Location"(latitude, longitude)
+  WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
+
+

Query Optimization:

+
// Use select to limit fields
+const locations = await prisma.location.findMany({
+  where: { /* filters */ },
+  select: {
+    id: true,
+    address: true,
+    latitude: true,
+    longitude: true,
+    postalCode: true,
+    addresses: {
+      select: {
+        id: true,
+        unitNumber: true,
+        firstName: true,
+        lastName: true,
+        supportLevel: true,
+        notes: true
+      },
+      orderBy: { unitNumber: 'asc' }
+    }
+  }
+});
+
+ +

Print Dialog Load Time: +- Small walk sheets (<50 locations): Instant +- Medium (50-200 locations): 1-2 seconds +- Large (200-500 locations): 3-5 seconds +- Very large (500+ locations): Consider pagination

+

Browser Print Limits: +- Chrome: ~1000 table rows before slowdown +- Firefox: ~800 table rows +- Safari: ~600 table rows

+

Optimization: +- Use page-break-inside: avoid sparingly +- Minimize complex CSS in print rules +- Avoid large images (QR codes already optimized at 150px) +- Split very large cuts into multiple PDFs

+ +

Backend Documentation

+
    +
  • QR Code Generation: api/src/modules/qr/qr.routes.ts
  • +
  • QRCode.toDataURL() wrapper
  • +
  • Rate limiting (100/15min)
  • +
  • +

    Size validation (50-500px)

    +
  • +
  • +

    Map Settings: api/src/modules/map/settings/

    +
  • +
  • MapSettings singleton CRUD
  • +
  • Walk sheet configuration
  • +
  • +

    QR code URL storage

    +
  • +
  • +

    Cuts API: api/src/modules/map/cuts/

    +
  • +
  • Cut CRUD operations
  • +
  • GeoJSON polygon storage
  • +
  • +

    Point-in-polygon filtering

    +
  • +
  • +

    Locations API: api/src/modules/map/locations/

    +
  • +
  • Location CRUD with cut filtering
  • +
  • Address relations
  • +
  • Support level tracking
  • +
+

Frontend Documentation

+
    +
  • Walk Sheet Page: admin/src/pages/WalkSheetPage.tsx
  • +
  • Cut selection dropdown
  • +
  • Location table rendering
  • +
  • QR code display
  • +
  • +

    Print functionality

    +
  • +
  • +

    Cut Export Page: admin/src/pages/CutExportPage.tsx

    +
  • +
  • Statistics calculation
  • +
  • CSV export
  • +
  • +

    Print layout

    +
  • +
  • +

    Map Settings Page: admin/src/pages/MapSettingsPage.tsx

    +
  • +
  • Walk sheet configuration form
  • +
  • QR code URL/label inputs
  • +
  • Settings persistence
  • +
+

Database Documentation

+
    +
  • Models: api/prisma/schema.prisma
  • +
  • MapSettings (singleton)
  • +
  • Cut (geojson polygon)
  • +
  • Location (geocoded addresses)
  • +
  • Address (unit-level data)
  • +
+

External Resources

+
    +
  • QRCode.js: https://github.com/soldair/node-qrcode
  • +
  • PNG generation API
  • +
  • Error correction levels
  • +
  • +

    Size/margin options

    +
  • +
  • +

    CSS Print Media: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/print

    +
  • +
  • @media print rules
  • +
  • @page configuration
  • +
  • +

    page-break properties

    +
  • +
  • +

    GeoJSON Specification: https://geojson.org/

    +
  • +
  • Polygon format
  • +
  • Coordinate order ([lng, lat])
  • +
  • MultiPolygon support
  • +
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/media/index.html b/mkdocs/site/v2/features/media/index.html new file mode 100644 index 00000000..a768cfae --- /dev/null +++ b/mkdocs/site/v2/features/media/index.html @@ -0,0 +1,5494 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Media Manager - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Media Manager

+

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.

+

Overview

+

The Media Manager consists of four integrated components:

+
    +
  1. Video Library - Admin video management
  2. +
  3. Upload System - Video upload with metadata extraction
  4. +
  5. Public Gallery - Public video sharing
  6. +
  7. Job Queue - Background job monitoring
  8. +
+

Features

+

Video Library

+
    +
  • Video CRUD operations
  • +
  • Metadata editing (title, description, tags)
  • +
  • Lock/unlock videos
  • +
  • Bulk operations (delete, lock, share)
  • +
  • Search and filtering
  • +
  • Thumbnail generation (future)
  • +
+

Upload System

+
    +
  • Drag-and-drop upload
  • +
  • Single and batch upload
  • +
  • Progress tracking
  • +
  • Automatic metadata extraction (FFprobe)
  • +
  • Duration
  • +
  • Dimensions (width x height)
  • +
  • Orientation (landscape/portrait/square)
  • +
  • Quality (resolution-based: 4K, 1080p, 720p, etc.)
  • +
  • Audio detection
  • +
  • File validation (type, size)
  • +
  • Supported formats: MP4, MOV, AVI, MKV, WebM, M4V, FLV
  • +
  • Max file size: 10GB
  • +
+ +
    +
  • Shared videos display
  • +
  • Category filtering
  • +
  • Reaction system (6 emojis)
  • +
  • Video cards with thumbnails
  • +
  • Lock/unlock control
  • +
  • Visitor reactions
  • +
+

Reaction System

+

Six emoji reactions:

+
    +
  • Like (👍)
  • +
  • Love (❤️)
  • +
  • Laugh (😂)
  • +
  • Wow (😮)
  • +
  • Sad (😢)
  • +
  • Angry (😡)
  • +
+

Architecture

+

Dual API Design

+

Express API (Port 4000) +- Main V2 features +- Prisma ORM +- PostgreSQL

+

Fastify Media API (Port 4100) +- Media-specific operations +- Drizzle ORM +- Same PostgreSQL database +- Optimized for file uploads

+

Backend Components

+

Media API: +- api/src/media-server.ts - Fastify entry point +- api/src/modules/media/routes/ - Video, upload, shared, reactions, jobs +- api/src/modules/media/services/ - FFprobe, video service +- api/src/modules/media/db/schema.ts - Drizzle schema

+

Database Tables: +- videos - Video metadata (Drizzle) +- shared_media - Public gallery (Drizzle) +- media_reactions - Reaction tracking (Drizzle) +- media_jobs - Job queue (Drizzle)

+

Frontend Components

+

Admin Pages: +- admin/src/pages/media/LibraryPage.tsx - Video library management +- admin/src/pages/media/SharedMediaPage.tsx - Public gallery admin +- admin/src/pages/media/MediaJobsPage.tsx - Job queue monitoring

+

Public Pages: +- admin/src/pages/public/MediaGalleryPage.tsx - Public video gallery +- admin/src/pages/public/MediaViewerPage.tsx - Video detail page

+

Components: +- admin/src/components/media/VideoCard.tsx - Video display card +- admin/src/components/media/BulkActions.tsx - Batch operations +- admin/src/components/media/UploadVideoModal.tsx - Upload interface

+

API Clients: +- admin/src/lib/media-api.ts - Authenticated media API client +- admin/src/lib/media-public-api.ts - Public media API client

+

Configuration

+

Environment Variables

+
# Enable media features
+ENABLE_MEDIA_FEATURES=true
+
+# Media API port
+MEDIA_API_PORT=4100
+
+# Media storage
+MEDIA_LIBRARY_PATH=/media/local/library    # Read-only library
+MEDIA_INBOX_PATH=/media/local/inbox        # Read-write inbox
+
+

Docker Volumes

+
volumes:
+  # Library (read-only)
+  - /path/to/library:/media/local/library:ro
+
+  # Inbox (read-write for uploads)
+  - /path/to/inbox:/media/local/inbox:rw
+
+

Upload System

+

Upload Flow

+
    +
  1. Select Files
  2. +
  3. Drag-and-drop or file picker
  4. +
  5. Multiple file selection
  6. +
  7. +

    File type validation

    +
  8. +
  9. +

    Upload to Inbox

    +
  10. +
  11. Stream to /media/local/inbox
  12. +
  13. UUID filename (prevents conflicts)
  14. +
  15. +

    Progress tracking

    +
  16. +
  17. +

    Extract Metadata

    +
  18. +
  19. FFprobe analysis (30s timeout)
  20. +
  21. Duration, dimensions, orientation
  22. +
  23. Quality calculation
  24. +
  25. +

    Audio detection

    +
  26. +
  27. +

    Create Database Record

    +
  28. +
  29. Store metadata
  30. +
  31. Set initial status
  32. +
  33. +

    Link to user

    +
  34. +
  35. +

    Process Video (Future)

    +
  36. +
  37. Generate thumbnail
  38. +
  39. Transcode formats
  40. +
  41. Move to library
  42. +
+

FFprobe Metadata Extraction

+

Automatically extracts:

+
interface VideoMetadata {
+  duration: number;        // Seconds (e.g., 125.5)
+  width: number;           // Pixels (e.g., 1920)
+  height: number;          // Pixels (e.g., 1080)
+  orientation: string;     // 'landscape' | 'portrait' | 'square'
+  quality: string;         // '4K' | '1080p' | '720p' | 'SD'
+  hasAudio: boolean;       // Audio stream detected
+}
+
+

Quality Calculation: +- 4K: ≥2160p (3840x2160) +- 1080p: ≥1080p (1920x1080) +- 720p: ≥720p (1280x720) +- SD: <720p

+

Orientation: +- Landscape: width > height +- Portrait: height > width +- Square: width ≈ height (within 10%)

+

Supported Formats

+
    +
  • MP4 - H.264/H.265 video
  • +
  • MOV - QuickTime
  • +
  • AVI - Audio Video Interleave
  • +
  • MKV - Matroska
  • +
  • WebM - Web-optimized
  • +
  • M4V - iTunes video
  • +
  • FLV - Flash video
  • +
+ +

Sharing System

+

Videos can be shared publicly:

+
    +
  1. Lock/Unlock - Control public visibility
  2. +
  3. Category Assignment - Organize by category
  4. +
  5. Public Access - View at /media
  6. +
  7. Reactions - Emoji reactions from visitors
  8. +
+

Categories

+

Predefined categories:

+
    +
  • Events
  • +
  • Testimonials
  • +
  • Tutorials
  • +
  • Announcements
  • +
  • Behind the Scenes
  • +
  • Custom categories
  • +
+

Reaction System

+

Reaction Tracking

+
interface MediaReaction {
+  id: number;
+  videoId: number;
+  reactionType: string;  // 'like' | 'love' | 'laugh' | 'wow' | 'sad' | 'angry'
+  sessionId: string;      // Unique session identifier
+  createdAt: Date;
+}
+
+

Session-Based Reactions

+
    +
  • One reaction per video per session
  • +
  • Session ID in localStorage
  • +
  • Update reaction (change type)
  • +
  • Remove reaction (click again)
  • +
+

Job Queue

+

Background jobs for:

+
    +
  • Video processing
  • +
  • Thumbnail generation
  • +
  • Format transcoding
  • +
  • Metadata extraction
  • +
  • File cleanup
  • +
+

Job Monitoring

+

Admin can:

+
    +
  • View job status
  • +
  • Monitor progress
  • +
  • Retry failed jobs
  • +
  • View error logs
  • +
+

Database Schema (Drizzle)

+

Videos Table

+
export const videos = pgTable('videos', {
+  id: serial('id').primaryKey(),
+  title: varchar('title', { length: 255 }).notNull(),
+  description: text('description'),
+  filename: varchar('filename', { length: 255 }).notNull(),
+  filepath: varchar('filepath', { length: 500 }).notNull(),
+  duration: integer('duration'),        // Seconds
+  width: integer('width'),              // Pixels
+  height: integer('height'),            // Pixels
+  orientation: varchar('orientation', { length: 20 }), // 'landscape' | 'portrait' | 'square'
+  quality: varchar('quality', { length: 20 }),         // '4K' | '1080p' | '720p' | 'SD'
+  hasAudio: boolean('has_audio'),
+  tags: json('tags').$type<string[]>(),
+  locked: boolean('locked').default(false),
+  uploadedBy: integer('uploaded_by'),
+  createdAt: timestamp('created_at').defaultNow(),
+  updatedAt: timestamp('updated_at').defaultNow(),
+});
+
+

API Endpoints

+

Admin Endpoints (Media API - Port 4100)

+
GET    /media-api/videos               # List videos
+POST   /media-api/videos               # Create video (manual)
+GET    /media-api/videos/:id           # Get video
+PATCH  /media-api/videos/:id           # Update video
+DELETE /media-api/videos/:id           # Delete video
+POST   /media-api/upload               # Upload single video
+POST   /media-api/upload/batch         # Upload multiple videos
+GET    /media-api/shared               # List shared videos
+POST   /media-api/shared               # Share video
+DELETE /media-api/shared/:id           # Unshare video
+GET    /media-api/jobs                 # List jobs
+
+

Public Endpoints

+
GET    /media-api/public/videos        # List public videos
+GET    /media-api/public/videos/:id    # Get public video
+POST   /media-api/reactions            # Add/update reaction
+DELETE /media-api/reactions/:id        # Remove reaction
+
+

Security

+

File Validation

+
    +
  • MIME type checking
  • +
  • File extension validation
  • +
  • File size limits (10GB max)
  • +
  • Path traversal prevention
  • +
  • Null byte detection
  • +
+

Access Control

+
    +
  • Admin-only uploads
  • +
  • Lock/unlock controls
  • +
  • Public visibility flags
  • +
  • Session-based reactions
  • +
+

Performance

+

Upload Optimization

+
    +
  • Streaming uploads (no memory buffering)
  • +
  • UUID filenames (no collisions)
  • +
  • Async metadata extraction
  • +
  • Progress tracking
  • +
+ +
    +
  • Lazy loading
  • +
  • Thumbnail caching (future)
  • +
  • Paginated results
  • +
  • Infinite scroll
  • +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/media/jobs/index.html b/mkdocs/site/v2/features/media/jobs/index.html new file mode 100644 index 00000000..c51cd696 --- /dev/null +++ b/mkdocs/site/v2/features/media/jobs/index.html @@ -0,0 +1,7458 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Jobs - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Media Job Queue System

+

Overview

+

The Media Job Queue System provides asynchronous background processing for CPU and GPU-intensive video operations. Built on a custom job queue with resource-aware scheduling, it handles everything from directory scanning to AI-powered video analysis while maintaining system stability through resource category management.

+

Key Features:

+
    +
  • Resource Categories — Jobs classified by resource needs (CPU, GPU encode, GPU AI)
  • +
  • Priority Scheduling — High-priority jobs processed first within same category
  • +
  • Job Types — 15+ job types (compilation, encoding, digest generation, scene extraction, etc.)
  • +
  • Progress Tracking — Real-time progress updates (0-100%)
  • +
  • Status Management — Pending → Queued → Running → Completed/Failed lifecycle
  • +
  • Retry Logic — Failed jobs can be retried with exponential backoff
  • +
  • Detailed Logging — Execution logs for debugging and audit trail
  • +
  • Queue Management — Pause, resume, cancel, and prioritize jobs
  • +
  • VRAM Awareness — Prevents GPU memory exhaustion by tracking VRAM requirements
  • +
+

Access Control:

+
    +
  • Job viewing/management requires SUPER_ADMIN role
  • +
  • Job creation can be triggered by admins or automated workflows
  • +
+

Technology Stack:

+
    +
  • Database Queue — PostgreSQL-backed job queue (no BullMQ for media)
  • +
  • Worker Process — Node.js worker polling queue every 5 seconds
  • +
  • FFmpeg — Video encoding and compilation
  • +
  • AI Integration — Future support for scene detection and auto-tagging
  • +
+
+

Architecture

+
flowchart TB
+    subgraph "Job Creation"
+        A1[Admin Action]
+        A2[Automated Trigger]
+        A3[Scheduled Task]
+    end
+
+    subgraph "Job Queue (PostgreSQL)"
+        Q1[Pending Jobs]
+        Q2[Queued Jobs]
+        Q3[Running Jobs]
+        Q4[Completed/Failed Jobs]
+    end
+
+    subgraph "Worker Process"
+        W1[Job Poller<br/>Every 5s]
+        W2[Resource Checker]
+        W3[Job Executor]
+        W4[Progress Updater]
+    end
+
+    subgraph "Processors"
+        P1[CPU Jobs<br/>scan, validate]
+        P2[GPU Encode<br/>reencode, compile]
+        P3[GPU AI<br/>digest, tag, scene]
+    end
+
+    subgraph "Results"
+        R1[Video Records Updated]
+        R2[New Files Created]
+        R3[Logs Written]
+    end
+
+    A1 --> Q1
+    A2 --> Q1
+    A3 --> Q1
+
+    Q1 --> W1
+    W1 --> W2
+    W2 -->|Check Resources| Q2
+    Q2 --> W3
+
+    W3 --> P1
+    W3 --> P2
+    W3 --> P3
+
+    W3 --> W4
+    W4 --> Q3
+
+    P1 --> R1
+    P2 --> R2
+    P3 --> R3
+
+    Q3 --> Q4
+
+    style Q1 fill:#f9f
+    style Q3 fill:#ff9
+    style Q4 fill:#9f9
+

Workflow:

+
    +
  1. Job Creation — Admin clicks "Re-encode" button, API creates job record
  2. +
  3. Queue Polling — Worker checks for pending jobs every 5 seconds
  4. +
  5. Resource Check — Worker verifies sufficient VRAM/CPU available
  6. +
  7. Job Execution — Worker runs appropriate processor (FFmpeg, AI script, etc.)
  8. +
  9. Progress Updates — Worker updates job progress every ~5% completion
  10. +
  11. Completion — Worker marks job complete and logs results
  12. +
  13. Retry on Failure — Failed jobs can be retried with exponential backoff
  14. +
+
+

Database Model

+

Jobs Table Schema

+
// api/src/modules/media/db/schema.ts
+export const jobs = pgTable('jobs', {
+  id: uuid('id').primaryKey().defaultRandom(),
+
+  // Job Definition
+  type: text('type').notNull(), // JobType enum: compilation, scan, reencode, etc.
+  status: text('status').notNull().default('pending'), // JobStatus enum
+  params: jsonb('params').$type<Record<string, any>>().notNull(), // Job-specific parameters
+
+  // Progress Tracking
+  progress: integer('progress').default(0), // 0-100
+  log: text('log').default(''), // Execution log (append-only)
+
+  // Scheduling
+  priority: integer('priority').default(5), // 1 (highest) - 10 (lowest)
+  queuePosition: integer('queue_position'), // Position in queue
+  waitingReason: text('waiting_reason'), // Why job is waiting (e.g., "Insufficient VRAM")
+
+  // Resource Management
+  resourceCategory: text('resource_category').notNull(), // cpu|gpu_encode|gpu_ai
+  vramRequired: integer('vram_required').default(0), // MB of VRAM needed
+
+  // Timing
+  createdAt: timestamp('created_at').defaultNow(),
+  startedAt: timestamp('started_at'),
+  completedAt: timestamp('completed_at'),
+
+  // Retry Logic
+  retryCount: integer('retry_count').default(0),
+  maxRetries: integer('max_retries').default(3),
+  retryAfter: timestamp('retry_after'), // Don't retry before this time
+});
+
+

Job Types Enum

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeResource CategoryVRAM (MB)Description
scancpu0Scan directory for new videos
public_scancpu0Scan public gallery directory
validatecpu0Validate video metadata (FFprobe)
reencode_streaminggpu_encode4000Re-encode for web playback (H.264)
compile_randomgpu_encode2000Random video compilation
compile_quadgpu_encode40004-up grid compilation
compile_megagpu_encode6000Large multi-video compilation
compile_gifcpu0Create GIF from video
digest_generategpu_ai8000AI-powered video digest
clip_generategpu_ai6000Extract clips from digest
highlight_generategpu_ai8000Create highlight reel
tag_generationgpu_ai6000AI auto-tagging
scene_extractgpu_ai8000Scene detection and extraction
thumbnail_generatecpu0Generate thumbnail from video
move_to_librarycpu0Move video from inbox to target directory
+

Job Status Enum

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StatusDescriptionFinal State
pendingWaiting to be picked up by workerNo
queuedSelected by worker, waiting for resourcesNo
runningCurrently executingNo
completedFinished successfullyYes
failedExecution failed (see log for details)Yes
cancelledManually cancelled by adminYes
pausedTemporarily paused (can be resumed)No
+

Resource Categories

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CategoryTypical VRAMConcurrent LimitUse Cases
cpu0 MB5Scanning, validation, simple encodes, GIF creation
gpu_encode2-6 GB2Video re-encoding, compilation, format conversion
gpu_ai6-12 GB1AI tagging, scene detection, digest generation, highlight extraction
+

VRAM Management:

+

Worker tracks total VRAM usage across running jobs:

+
const runningJobs = await db.select().from(jobs).where(eq(jobs.status, 'running'));
+const totalVramUsed = runningJobs.reduce((sum, job) => sum + (job.vramRequired || 0), 0);
+
+// Only start new job if VRAM available
+const TOTAL_VRAM = 16000; // 16GB GPU
+if (totalVramUsed + newJob.vramRequired <= TOTAL_VRAM) {
+  startJob(newJob);
+}
+
+
+

API Endpoints

+

All endpoints require SUPER_ADMIN role.

+

List Jobs

+
GET /api/media/jobs
+
+

Query Parameters:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeDefaultDescription
pagenumber1Page number
limitnumber20Results per page
statusstring-Filter by status (pending, running, completed, failed)
typestring-Filter by job type
resourceCategorystring-Filter by resource category
+

Response:

+
{
+  "data": [
+    {
+      "id": "550e8400-e29b-41d4-a716-446655440000",
+      "type": "reencode_streaming",
+      "status": "running",
+      "progress": 45,
+      "resourceCategory": "gpu_encode",
+      "vramRequired": 4000,
+      "priority": 5,
+      "params": {
+        "videoId": "660e8400-e29b-41d4-a716-446655440001",
+        "targetBitrate": 2000
+      },
+      "startedAt": "2026-02-13T10:30:00Z",
+      "createdAt": "2026-02-13T10:25:00Z"
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 20,
+    "total": 156,
+    "totalPages": 8
+  }
+}
+
+
+

Get Job Details

+
GET /api/media/jobs/:id
+
+

Response:

+
{
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "type": "reencode_streaming",
+  "status": "completed",
+  "progress": 100,
+  "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",
+  "params": {
+    "videoId": "660e8400-e29b-41d4-a716-446655440001",
+    "inputPath": "inbox/original.mp4",
+    "outputPath": "playback/encoded.mp4",
+    "targetBitrate": 2000
+  },
+  "resourceCategory": "gpu_encode",
+  "vramRequired": 4000,
+  "priority": 5,
+  "retryCount": 0,
+  "maxRetries": 3,
+  "createdAt": "2026-02-13T10:25:00Z",
+  "startedAt": "2026-02-13T10:30:00Z",
+  "completedAt": "2026-02-13T10:45:00Z"
+}
+
+
+

Create Job

+
POST /api/media/jobs
+
+

Request Body:

+
{
+  "type": "reencode_streaming",
+  "params": {
+    "videoId": "660e8400-e29b-41d4-a716-446655440001",
+    "targetBitrate": 2000
+  },
+  "priority": 5,
+  "resourceCategory": "gpu_encode",
+  "vramRequired": 4000
+}
+
+

Response:

+
{
+  "id": "770e8400-e29b-41d4-a716-446655440002",
+  "type": "reencode_streaming",
+  "status": "pending",
+  "progress": 0,
+  "createdAt": "2026-02-13T11:00:00Z"
+}
+
+
+

Retry Failed Job

+
POST /api/media/jobs/:id/retry
+
+

Response:

+
{
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "status": "pending",
+  "retryCount": 1,
+  "retryAfter": null,
+  "log": "Starting re-encode...\n[Previous logs...]\n--- RETRY ATTEMPT 1 ---\n"
+}
+
+

Retry Logic:

+
    +
  • Failed jobs can be retried up to maxRetries times (default: 3)
  • +
  • Exponential backoff: wait 2^retryCount minutes before retry
  • +
  • Retry resets status to pending and appends retry marker to log
  • +
+
+

Cancel Job

+
POST /api/media/jobs/:id/cancel
+
+

Response:

+
{
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "status": "cancelled",
+  "log": "Starting re-encode...\nProgress: 25%\n--- JOB CANCELLED BY ADMIN ---"
+}
+
+

Notes:

+
    +
  • Running jobs cannot be cancelled immediately (worker must finish current chunk)
  • +
  • Pending/queued jobs cancelled instantly
  • +
+
+

Pause/Resume Job

+
POST /api/media/jobs/:id/pause
+POST /api/media/jobs/:id/resume
+
+

Pause Response:

+
{
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "status": "paused"
+}
+
+

Resume Response:

+
{
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "status": "pending"
+}
+
+
+

Queue Statistics

+
GET /api/media/jobs/stats
+
+

Response:

+
{
+  "pending": 12,
+  "queued": 2,
+  "running": 3,
+  "completed": 1458,
+  "failed": 23,
+  "paused": 1,
+  "totalVramUsed": 12000,
+  "totalVramAvailable": 16000,
+  "averageProcessingTime": 245,
+  "jobsByType": {
+    "reencode_streaming": 45,
+    "scan": 8,
+    "compile_random": 12
+  }
+}
+
+
+

Admin Workflow

+

Viewing Job Queue

+
    +
  1. Navigate to Media → Jobs in admin sidebar
  2. +
  3. Table displays all jobs with:
  4. +
  5. Job type icon
  6. +
  7. Status badge (color-coded)
  8. +
  9. Progress bar
  10. +
  11. Priority indicator
  12. +
  13. Resource category
  14. +
  15. Created/started/completed times
  16. +
  17. Use filters at top:
  18. +
  19. Status dropdown (All / Pending / Running / Completed / Failed)
  20. +
  21. Type dropdown (job type)
  22. +
  23. Resource dropdown (CPU / GPU Encode / GPU AI)
  24. +
+

Creating Jobs Manually

+

Option 1: From Library Page

+
    +
  1. Select video in library table
  2. +
  3. Click "Actions" dropdown
  4. +
  5. Select action:
  6. +
  7. "Re-encode for Streaming"
  8. +
  9. "Generate Thumbnail"
  10. +
  11. "Validate Metadata"
  12. +
  13. "Move to Directory"
  14. +
  15. Confirm job creation
  16. +
  17. Redirected to Jobs page showing new job
  18. +
+

Option 2: From Jobs Page

+
    +
  1. Click "Create Job" button
  2. +
  3. Modal opens with form:
  4. +
  5. Type dropdown (15+ job types)
  6. +
  7. Video selector (search by title/filename)
  8. +
  9. Priority slider (1-10)
  10. +
  11. Parameters JSON editor (advanced)
  12. +
  13. Click "Create"
  14. +
  15. Job appears in pending queue
  16. +
+

Monitoring Job Progress

+

Real-Time Updates:

+
    +
  1. Jobs page polls API every 2 seconds for running jobs
  2. +
  3. Progress bars update smoothly (0-100%)
  4. +
  5. Status badges change color:
  6. +
  7. Grey: Pending
  8. +
  9. Blue: Queued
  10. +
  11. Yellow: Running
  12. +
  13. Green: Completed
  14. +
  15. Red: Failed
  16. +
+

Detailed Logs:

+
    +
  1. Click job row to expand details panel
  2. +
  3. View execution log in monospace text area
  4. +
  5. Log updates in real-time while job running
  6. +
  7. Example log output:
  8. +
+
[2026-02-13 10:30:15] Starting re-encode job
+[2026-02-13 10:30:16] Input: /media/local/inbox/original.mp4
+[2026-02-13 10:30:16] Output: /media/local/playback/encoded.mp4
+[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
+[2026-02-13 10:30:20] Progress: 5%
+[2026-02-13 10:30:25] Progress: 15%
+[2026-02-13 10:30:30] Progress: 25%
+...
+[2026-02-13 10:45:00] Progress: 100%
+[2026-02-13 10:45:01] Re-encode completed successfully
+[2026-02-13 10:45:02] Output file size: 25.3 MB
+
+

Retrying Failed Jobs

+
    +
  1. Filter for Failed jobs
  2. +
  3. Click job row to view error log
  4. +
  5. Identify failure reason (e.g., "FFmpeg error: codec not supported")
  6. +
  7. Fix underlying issue (install codec, fix file path, etc.)
  8. +
  9. Click "Retry" button
  10. +
  11. Job resets to pending status
  12. +
  13. Worker picks up job again
  14. +
+

Auto-Retry:

+

Jobs automatically retry up to 3 times with exponential backoff:

+
    +
  • 1st retry: after 2 minutes
  • +
  • 2nd retry: after 4 minutes
  • +
  • 3rd retry: after 8 minutes
  • +
+

Cancelling Jobs

+
    +
  1. Find job in pending/queued/running state
  2. +
  3. Click "Cancel" button
  4. +
  5. Confirm cancellation dialog
  6. +
  7. Job marked as cancelled
  8. +
  9. If running, worker stops after current chunk completes
  10. +
+

Pausing/Resuming Jobs

+

Use Case: Temporarily stop low-priority jobs to free resources for urgent tasks

+
    +
  1. Select low-priority pending job
  2. +
  3. Click "Pause" button
  4. +
  5. Job status changes to paused (greyed out)
  6. +
  7. Worker skips paused jobs
  8. +
  9. When ready, click "Resume"
  10. +
  11. Job returns to pending queue
  12. +
+
+

Job Type Details

+

Scan Jobs (scan, public_scan)

+

Purpose: Scan filesystem directory for new videos and create database records

+

Parameters:

+
{
+  "directoryType": "videos",
+  "skipExisting": true
+}
+
+

Process:

+
    +
  1. Read directory /media/local/library/{directoryType}/
  2. +
  3. Filter for video extensions (.mp4, .mov, etc.)
  4. +
  5. Check each file against database (by path)
  6. +
  7. Create records for new files
  8. +
  9. Run FFprobe on new files
  10. +
  11. Update progress: files processed / total files
  12. +
+

Typical Duration: 2-30 seconds (depends on file count)

+
+

Validation Jobs (validate)

+

Purpose: Re-run FFprobe to refresh video metadata

+

Parameters:

+
{
+  "videoId": "660e8400-e29b-41d4-a716-446655440001"
+}
+
+

Process:

+
    +
  1. Fetch video record from database
  2. +
  3. Build full file path
  4. +
  5. Run FFprobe extraction
  6. +
  7. Update database with fresh metadata
  8. +
  9. Mark video as valid/invalid based on result
  10. +
+

Typical Duration: 100-500ms per video

+
+

Re-encode Jobs (reencode_streaming)

+

Purpose: Convert video to web-optimized format (H.264, web-friendly profile)

+

Parameters:

+
{
+  "videoId": "660e8400-e29b-41d4-a716-446655440001",
+  "targetBitrate": 2000,
+  "preset": "medium",
+  "crf": 23
+}
+
+

FFmpeg Command:

+
ffmpeg -i /media/local/inbox/original.mp4 \
+  -c:v libx264 \
+  -preset medium \
+  -crf 23 \
+  -maxrate 2000k \
+  -bufsize 4000k \
+  -c:a aac \
+  -b:a 128k \
+  -movflags +faststart \
+  /media/local/playback/encoded.mp4
+
+

Process:

+
    +
  1. Validate input file exists
  2. +
  3. Build FFmpeg command
  4. +
  5. Start encoding process
  6. +
  7. Parse FFmpeg progress output
  8. +
  9. Update job progress every ~5%
  10. +
  11. Create new video record for encoded file
  12. +
  13. Update original video reencodeJobId reference
  14. +
+

Typical Duration: 5-30 minutes (depends on video length and resolution)

+
+

Compilation Jobs (compile_random, compile_quad, compile_mega)

+

Purpose: Merge multiple videos into single compilation

+

Parameters (Random):

+
{
+  "count": 10,
+  "minDuration": 30,
+  "maxDuration": 120,
+  "orientation": "landscape",
+  "outputPath": "compilations/random-001.mp4"
+}
+
+

Process:

+
    +
  1. Query database for videos matching criteria (orientation, duration range)
  2. +
  3. Randomly select count videos
  4. +
  5. Build FFmpeg concat demuxer file list
  6. +
  7. Run FFmpeg compilation
  8. +
  9. Create new video record for compilation
  10. +
  11. Update progress based on FFmpeg output
  12. +
+

Quad Compilation (4-up grid):

+
ffmpeg -i video1.mp4 -i video2.mp4 -i video3.mp4 -i video4.mp4 \
+  -filter_complex "[0:v][1:v][2:v][3:v]xstack=inputs=4:layout=0_0|w0_0|0_h0|w0_h0[v]" \
+  -map "[v]" \
+  output.mp4
+
+

Typical Duration: 10-60 minutes

+
+

Digest Generation (digest_generate)

+

Purpose: AI-powered video digest creation (future feature)

+

Parameters:

+
{
+  "videoId": "660e8400-e29b-41d4-a716-446655440001",
+  "targetLength": 60,
+  "includeHighlights": true
+}
+
+

Process (Planned):

+
    +
  1. Extract frames at 1 FPS
  2. +
  3. Run AI scene detection
  4. +
  5. Identify highlights (action, faces, motion)
  6. +
  7. Select best segments totaling target length
  8. +
  9. Compile segments into digest video
  10. +
+

GPU AI Required: 8GB VRAM

+
+

Thumbnail Generation (thumbnail_generate)

+

Purpose: Extract thumbnail image from video

+

Parameters:

+
{
+  "videoId": "660e8400-e29b-41d4-a716-446655440001",
+  "timestamp": 5,
+  "width": 640
+}
+
+

FFmpeg Command:

+
ffmpeg -i /media/local/library/videos/sample.mp4 \
+  -ss 00:00:05 \
+  -vframes 1 \
+  -vf scale=640:-1 \
+  /media/local/thumbnails/sample.jpg
+
+

Process:

+
    +
  1. Seek to timestamp (default: 25% into video)
  2. +
  3. Extract single frame
  4. +
  5. Scale to width (preserve aspect ratio)
  6. +
  7. Save as JPEG
  8. +
  9. Update video record with thumbnailPath
  10. +
+

Typical Duration: 1-5 seconds

+
+

Code Examples

+

Create Re-encode Job

+
// api/src/modules/media/routes/jobs.routes.ts
+import { db } from '@/modules/media/db';
+import { jobs, videos } from '@/modules/media/db/schema';
+
+app.post('/api/media/jobs/reencode', async (req, reply) => {
+  const { videoId, targetBitrate = 2000, preset = 'medium', crf = 23 } = req.body;
+
+  // Fetch video
+  const [video] = await db
+    .select()
+    .from(videos)
+    .where(eq(videos.id, videoId))
+    .limit(1);
+
+  if (!video) {
+    return reply.code(404).send({ error: 'Video not found' });
+  }
+
+  // Create job
+  const [job] = await db
+    .insert(jobs)
+    .values({
+      type: 'reencode_streaming',
+      status: 'pending',
+      params: {
+        videoId,
+        inputPath: video.path,
+        outputPath: `playback/${video.filename}`,
+        targetBitrate,
+        preset,
+        crf,
+      },
+      resourceCategory: 'gpu_encode',
+      vramRequired: 4000,
+      priority: 5,
+    })
+    .returning();
+
+  reply.send(job);
+});
+
+
+

Job Worker (Polling Loop)

+
// api/src/modules/media/services/job-worker.service.ts
+import { db } from '@/modules/media/db';
+import { jobs } from '@/modules/media/db/schema';
+import { eq, and, lte } from 'drizzle-orm';
+
+export class JobWorkerService {
+  private polling = false;
+
+  async start() {
+    this.polling = true;
+    console.log('Job worker started');
+
+    while (this.polling) {
+      try {
+        await this.processNextJob();
+      } catch (error) {
+        console.error('Job worker error:', error);
+      }
+
+      // Wait 5 seconds before next poll
+      await new Promise((resolve) => setTimeout(resolve, 5000));
+    }
+  }
+
+  async stop() {
+    this.polling = false;
+    console.log('Job worker stopped');
+  }
+
+  private async processNextJob() {
+    // Find next pending job (highest priority first)
+    const [job] = await db
+      .select()
+      .from(jobs)
+      .where(eq(jobs.status, 'pending'))
+      .orderBy(jobs.priority, jobs.createdAt)
+      .limit(1);
+
+    if (!job) {
+      return; // No jobs in queue
+    }
+
+    // Check resource availability
+    const canRun = await this.checkResources(job);
+    if (!canRun) {
+      // Update waiting reason
+      await db
+        .update(jobs)
+        .set({ waitingReason: 'Insufficient resources' })
+        .where(eq(jobs.id, job.id));
+      return;
+    }
+
+    // Start job
+    await this.executeJob(job);
+  }
+
+  private async checkResources(job: any): Promise<boolean> {
+    // Get running jobs
+    const runningJobs = await db
+      .select()
+      .from(jobs)
+      .where(eq(jobs.status, 'running'));
+
+    // Calculate total VRAM used
+    const totalVramUsed = runningJobs.reduce(
+      (sum, j) => sum + (j.vramRequired || 0),
+      0
+    );
+
+    const TOTAL_VRAM = 16000; // 16GB GPU
+    const available = TOTAL_VRAM - totalVramUsed;
+
+    if (job.vramRequired && job.vramRequired > available) {
+      return false; // Not enough VRAM
+    }
+
+    // Check concurrent job limits by category
+    const categoryCount = runningJobs.filter(
+      (j) => j.resourceCategory === job.resourceCategory
+    ).length;
+
+    const limits = {
+      cpu: 5,
+      gpu_encode: 2,
+      gpu_ai: 1,
+    };
+
+    if (categoryCount >= limits[job.resourceCategory as keyof typeof limits]) {
+      return false; // Category limit reached
+    }
+
+    return true; // Resources available
+  }
+
+  private async executeJob(job: any) {
+    // Mark as running
+    await db
+      .update(jobs)
+      .set({
+        status: 'running',
+        startedAt: new Date(),
+        waitingReason: null,
+      })
+      .where(eq(jobs.id, job.id));
+
+    try {
+      // Execute job based on type
+      switch (job.type) {
+        case 'reencode_streaming':
+          await this.executeReencode(job);
+          break;
+        case 'scan':
+          await this.executeScan(job);
+          break;
+        case 'thumbnail_generate':
+          await this.executeThumbnail(job);
+          break;
+        // ... other job types
+      }
+
+      // Mark as completed
+      await db
+        .update(jobs)
+        .set({
+          status: 'completed',
+          progress: 100,
+          completedAt: new Date(),
+        })
+        .where(eq(jobs.id, job.id));
+    } catch (error: any) {
+      // Mark as failed
+      await db
+        .update(jobs)
+        .set({
+          status: 'failed',
+          log: (job.log || '') + `\n\n--- ERROR ---\n${error.message}`,
+        })
+        .where(eq(jobs.id, job.id));
+
+      // Schedule retry if under max retries
+      if (job.retryCount < job.maxRetries) {
+        const retryDelay = Math.pow(2, job.retryCount) * 60 * 1000; // Exponential backoff
+        await db
+          .update(jobs)
+          .set({
+            status: 'pending',
+            retryCount: job.retryCount + 1,
+            retryAfter: new Date(Date.now() + retryDelay),
+          })
+          .where(eq(jobs.id, job.id));
+      }
+    }
+  }
+
+  private async executeReencode(job: any) {
+    const { inputPath, outputPath, targetBitrate, preset, crf } = job.params;
+
+    const inputFull = path.join(process.env.MEDIA_LIBRARY_PATH!, inputPath);
+    const outputFull = path.join(process.env.MEDIA_LIBRARY_PATH!, outputPath);
+
+    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}"`;
+
+    await this.appendLog(job.id, `Starting re-encode\nCommand: ${command}`);
+
+    // Execute FFmpeg (simplified - real implementation uses spawn for progress parsing)
+    await execAsync(command);
+
+    await this.appendLog(job.id, 'Re-encode completed successfully');
+  }
+
+  private async appendLog(jobId: string, message: string) {
+    const timestamp = new Date().toISOString();
+    const logEntry = `[${timestamp}] ${message}`;
+
+    await db
+      .update(jobs)
+      .set({
+        log: sql`${jobs.log} || E'\n' || ${logEntry}`,
+      })
+      .where(eq(jobs.id, jobId));
+  }
+}
+
+// Start worker
+export const jobWorker = new JobWorkerService();
+jobWorker.start();
+
+
+

Frontend: Jobs Page

+
// admin/src/pages/media/MediaJobsPage.tsx
+import { Table, Tag, Progress, Button, Space, Select, message } from 'antd';
+import { useEffect, useState } from 'react';
+import { mediaApi } from '@/lib/media-api';
+
+export default function MediaJobsPage() {
+  const [jobs, setJobs] = useState([]);
+  const [loading, setLoading] = useState(false);
+  const [filter, setFilter] = useState({ status: undefined, type: undefined });
+  const [polling, setPolling] = useState(true);
+
+  const fetchJobs = async () => {
+    setLoading(true);
+    try {
+      const { data } = await mediaApi.get('/api/media/jobs', {
+        params: filter,
+      });
+      setJobs(data.data);
+    } catch (error) {
+      console.error('Failed to fetch jobs:', error);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    fetchJobs();
+  }, [filter]);
+
+  // Poll for running jobs every 2 seconds
+  useEffect(() => {
+    if (!polling) return;
+
+    const interval = setInterval(() => {
+      const hasRunning = jobs.some((j: any) => j.status === 'running');
+      if (hasRunning) {
+        fetchJobs();
+      }
+    }, 2000);
+
+    return () => clearInterval(interval);
+  }, [polling, jobs]);
+
+  const handleRetry = async (id: string) => {
+    try {
+      await mediaApi.post(`/api/media/jobs/${id}/retry`);
+      message.success('Job queued for retry');
+      fetchJobs();
+    } catch (error) {
+      message.error('Retry failed');
+    }
+  };
+
+  const handleCancel = async (id: string) => {
+    try {
+      await mediaApi.post(`/api/media/jobs/${id}/cancel`);
+      message.success('Job cancelled');
+      fetchJobs();
+    } catch (error) {
+      message.error('Cancel failed');
+    }
+  };
+
+  const statusColors: Record<string, string> = {
+    pending: 'default',
+    queued: 'blue',
+    running: 'processing',
+    completed: 'success',
+    failed: 'error',
+    cancelled: 'default',
+    paused: 'warning',
+  };
+
+  const columns = [
+    {
+      title: 'Type',
+      dataIndex: 'type',
+      width: 150,
+      render: (type: string) => <span style={{ fontFamily: 'monospace' }}>{type}</span>,
+    },
+    {
+      title: 'Status',
+      dataIndex: 'status',
+      width: 100,
+      render: (status: string) => <Tag color={statusColors[status]}>{status.toUpperCase()}</Tag>,
+    },
+    {
+      title: 'Progress',
+      dataIndex: 'progress',
+      width: 150,
+      render: (progress: number, record: any) => (
+        record.status === 'running' ? (
+          <Progress percent={progress} size="small" status="active" />
+        ) : record.status === 'completed' ? (
+          <Progress percent={100} size="small" status="success" />
+        ) : record.status === 'failed' ? (
+          <Progress percent={progress} size="small" status="exception" />
+        ) : (
+          <Progress percent={progress} size="small" />
+        )
+      ),
+    },
+    {
+      title: 'Resource',
+      dataIndex: 'resourceCategory',
+      width: 120,
+    },
+    {
+      title: 'Priority',
+      dataIndex: 'priority',
+      width: 80,
+      render: (priority: number) => (
+        <Tag color={priority <= 3 ? 'red' : priority <= 6 ? 'orange' : 'default'}>
+          {priority}
+        </Tag>
+      ),
+    },
+    {
+      title: 'Created',
+      dataIndex: 'createdAt',
+      width: 150,
+      render: (date: string) => new Date(date).toLocaleString(),
+    },
+    {
+      title: 'Actions',
+      width: 200,
+      render: (_: any, record: any) => (
+        <Space>
+          {record.status === 'failed' && (
+            <Button size="small" onClick={() => handleRetry(record.id)}>
+              Retry
+            </Button>
+          )}
+          {['pending', 'queued', 'running'].includes(record.status) && (
+            <Button size="small" danger onClick={() => handleCancel(record.id)}>
+              Cancel
+            </Button>
+          )}
+          <Button size="small" onClick={() => window.open(`/app/media/jobs/${record.id}`, '_blank')}>
+            View Log
+          </Button>
+        </Space>
+      ),
+    },
+  ];
+
+  return (
+    <div>
+      <Space style={{ marginBottom: 16 }}>
+        <Select
+          placeholder="Filter by status"
+          style={{ width: 150 }}
+          onChange={(value) => setFilter({ ...filter, status: value })}
+          allowClear
+        >
+          <Select.Option value="pending">Pending</Select.Option>
+          <Select.Option value="running">Running</Select.Option>
+          <Select.Option value="completed">Completed</Select.Option>
+          <Select.Option value="failed">Failed</Select.Option>
+        </Select>
+
+        <Select
+          placeholder="Filter by type"
+          style={{ width: 200 }}
+          onChange={(value) => setFilter({ ...filter, type: value })}
+          allowClear
+        >
+          <Select.Option value="scan">Scan</Select.Option>
+          <Select.Option value="reencode_streaming">Re-encode</Select.Option>
+          <Select.Option value="compile_random">Compilation</Select.Option>
+        </Select>
+
+        <Button onClick={() => setPolling(!polling)}>
+          {polling ? 'Stop Auto-Refresh' : 'Start Auto-Refresh'}
+        </Button>
+      </Space>
+
+      <Table
+        columns={columns}
+        dataSource={jobs}
+        loading={loading}
+        rowKey="id"
+        pagination={{ pageSize: 20 }}
+      />
+    </div>
+  );
+}
+
+
+

Troubleshooting

+

Problem: Jobs Stuck in Pending

+

Symptoms:

+
    +
  • Jobs created but never start
  • +
  • Status remains "pending" for hours
  • +
  • No "running" jobs visible
  • +
+

Solutions:

+
    +
  1. Check worker process running:
  2. +
+
docker compose ps media-api
+# Should show "Up" status
+
+docker compose logs media-api | grep "Job worker"
+# Should show "Job worker started"
+
+
    +
  1. Manually trigger worker:
  2. +
+
# Restart media-api container
+docker compose restart media-api
+
+# Worker starts automatically on container boot
+
+
    +
  1. Check worker logs for errors:
  2. +
+
docker compose logs -f media-api | grep ERROR
+# Look for database connection errors, permission issues
+
+
    +
  1. Verify database connection:
  2. +
+
# Test database accessible from container
+docker compose exec media-api psql $DATABASE_URL -c "SELECT COUNT(*) FROM jobs WHERE status='pending';"
+
+
+

Problem: Job Fails Immediately

+

Symptoms:

+
    +
  • Job status changes from pending → running → failed within seconds
  • +
  • No meaningful progress
  • +
  • Error in log: "Command not found" or "Permission denied"
  • +
+

Solutions:

+
    +
  1. Check job log in database:
  2. +
+
SELECT log FROM jobs WHERE id = 'JOB_ID';
+
+
    +
  1. Verify FFmpeg installed:
  2. +
+
docker compose exec media-api which ffmpeg
+# Should output: /usr/bin/ffmpeg
+
+docker compose exec media-api ffmpeg -version
+
+
    +
  1. Check file paths valid:
  2. +
+
# Verify input file exists
+docker compose exec media-api ls -la /media/local/library/inbox/original.mp4
+
+# Check output directory writable
+docker compose exec media-api touch /media/local/playback/test.txt
+
+
    +
  1. Test FFmpeg command manually:
  2. +
+
# Copy command from job log, run manually
+docker compose exec media-api ffmpeg -i /media/local/inbox/test.mp4 -c:v libx264 /media/local/playback/test-output.mp4
+
+
+

Problem: Re-encode Job Hangs at Same Progress

+

Symptoms:

+
    +
  • Job progress reaches 25%, 50%, or 75% then stops updating
  • +
  • Status remains "running" for hours
  • +
  • No CPU/GPU activity visible
  • +
+

Solutions:

+
    +
  1. Check FFmpeg process still running:
  2. +
+
docker compose exec media-api ps aux | grep ffmpeg
+# Should show ffmpeg process
+
+# If not running, worker crashed
+docker compose logs media-api --tail 100
+
+
    +
  1. Kill hung FFmpeg process:
  2. +
+
docker compose exec media-api pkill -9 ffmpeg
+
+# Job will fail and can be retried
+
+
    +
  1. Check disk space:
  2. +
+
df -h /media/local/playback
+# If 100% full, encoding fails
+
+# Free space
+docker compose exec media-api rm /media/local/playback/*.partial
+
+
    +
  1. Increase FFmpeg timeout (if very large file):
  2. +
+
// api/src/modules/media/services/job-worker.service.ts
+const FFMPEG_TIMEOUT = 3600000; // 1 hour (from 30 minutes)
+
+
+

Problem: GPU Out of Memory Errors

+

Symptoms:

+
    +
  • Multiple GPU jobs running simultaneously
  • +
  • Error in log: "CUDA out of memory" or "Cannot allocate memory"
  • +
  • System becomes unresponsive
  • +
+

Solutions:

+
    +
  1. Check total VRAM available:
  2. +
+
nvidia-smi
+# Shows GPU memory usage
+
+# Should show < 16GB used (adjust based on your GPU)
+
+
    +
  1. Reduce concurrent GPU job limit:
  2. +
+
// api/src/modules/media/services/job-worker.service.ts
+const limits = {
+  cpu: 5,
+  gpu_encode: 1,  // Reduced from 2
+  gpu_ai: 1,
+};
+
+
    +
  1. Increase VRAM requirements for jobs:
  2. +
+
// Jobs require more VRAM than specified
+// Update job creation to use higher vramRequired values
+{
+  type: 'reencode_streaming',
+  vramRequired: 6000,  // Increased from 4000
+}
+
+
    +
  1. Kill running GPU jobs:
  2. +
+
# Stop all media jobs
+docker compose exec media-api pkill -9 ffmpeg
+
+# Update stuck jobs to failed status
+docker compose exec v2-postgres psql -U changemaker -d v2_changemaker \
+  -c "UPDATE jobs SET status='failed' WHERE status='running';"
+
+
+

Performance Considerations

+

Job Queue Throughput

+

Scaling Factors:

+
    +
  • CPU jobs: 5 concurrent = ~10-20 jobs/minute (scans, validations)
  • +
  • GPU encode: 2 concurrent = ~4-8 videos/hour (depends on length)
  • +
  • GPU AI: 1 concurrent = ~2-6 videos/hour (depends on complexity)
  • +
+

Bottlenecks:

+
    +
  1. GPU Memory — Limits concurrent GPU jobs
  2. +
  3. Disk I/O — Reading/writing large video files
  4. +
  5. CPU — FFmpeg encoding uses all available cores
  6. +
+

Optimization:

+
    +
  • Distribute workers across multiple machines — Each machine runs separate worker process
  • +
  • Use job priority — Urgent jobs (priority 1-3) run first
  • +
  • Batch similar jobs — Group scan jobs, re-encode jobs, etc. for efficiency
  • +
+
+

Database Performance

+

Job Queue Index:

+
CREATE INDEX idx_jobs_status_priority ON jobs(status, priority, created_at);
+
+

Query Performance:

+
    +
  • Find next pending job: ~1-5ms (with index)
  • +
  • Update job status: ~2-10ms
  • +
  • Fetch job logs: ~5-20ms
  • +
+

Optimization:

+
    +
  • Partition jobs table by date — Move old completed/failed jobs to archive table
  • +
  • Limit log size — Truncate logs > 10KB to prevent bloat
  • +
+
+

Monitoring & Observability

+

Prometheus Metrics

+
// api/src/utils/metrics.ts
+import { Counter, Gauge } from 'prom-client';
+
+export const mediaJobsTotal = new Counter({
+  name: 'media_jobs_total',
+  help: 'Total media jobs created',
+  labelNames: ['type', 'status'],
+});
+
+export const mediaJobsPending = new Gauge({
+  name: 'media_jobs_pending',
+  help: 'Number of pending media jobs',
+});
+
+export const mediaJobsRunning = new Gauge({
+  name: 'media_jobs_running',
+  help: 'Number of running media jobs',
+  labelNames: ['resourceCategory'],
+});
+
+export const mediaVramUsed = new Gauge({
+  name: 'media_vram_used_mb',
+  help: 'Total VRAM used by running jobs (MB)',
+});
+
+// Update metrics in worker
+mediaJobsPending.set(pendingCount);
+mediaJobsRunning.set({ resourceCategory: 'gpu_encode' }, gpuEncodeCount);
+mediaVramUsed.set(totalVramUsed);
+
+

Grafana Dashboard Panel

+

Job Queue Status:

+
# Pending jobs count
+media_jobs_pending
+
+# Running jobs by category
+sum(media_jobs_running) by (resourceCategory)
+
+# VRAM usage percentage
+(media_vram_used_mb / 16000) * 100
+
+

Alert Rules:

+
# configs/prometheus/alerts.yml
+groups:
+  - name: media_jobs
+    rules:
+      - alert: MediaJobQueueBacklog
+        expr: media_jobs_pending > 50
+        for: 30m
+        labels:
+          severity: warning
+        annotations:
+          summary: "Media job queue backlog"
+          description: "{{ $value }} jobs pending for 30+ minutes"
+
+      - alert: MediaJobsStuckRunning
+        expr: sum(media_jobs_running) == 0 AND media_jobs_pending > 0
+        for: 10m
+        labels:
+          severity: critical
+        annotations:
+          summary: "Media jobs stuck"
+          description: "Jobs pending but worker not processing"
+
+
+ +

Backend Documentation

+
    +
  • Job Worker: backend/modules/media/job-worker.md — Worker process implementation
  • +
  • Job Processors: backend/modules/media/processors/ — Individual job type processors (reencode, scan, etc.)
  • +
  • Jobs Routes: backend/modules/media/jobs.md — API endpoints for job management
  • +
+

Frontend Documentation

+
    +
  • Jobs Page: frontend/pages/media/jobs.md — Job queue monitoring UI
  • +
  • Job Detail Modal: frontend/components/media/job-detail.md — Log viewer component
  • +
+

Feature Documentation

+
    +
  • Video Library: features/media/video-library.md — Triggering jobs from library actions
  • +
  • Upload System: features/media/upload.md — Post-upload job creation
  • +
+
+

Next Steps

+

After mastering the job queue:

+
    +
  1. Create Custom Jobs — Implement new job types for domain-specific processing
  2. +
  3. Optimize Scheduling — Tune resource limits and priority settings for your workload
  4. +
  5. Monitor Performance — Set up Grafana dashboards and alerts for job queue health
  6. +
  7. Distributed Workers — Scale horizontally by running workers on multiple machines
  8. +
+

Hands-On Practice:

+
# 1. Create re-encode job
+curl -X POST http://localhost:4100/api/media/jobs \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "type": "reencode_streaming",
+    "params": { "videoId": "VIDEO_ID", "targetBitrate": 2000 },
+    "priority": 5
+  }'
+
+# 2. Monitor job progress
+watch -n 2 'curl -s http://localhost:4100/api/media/jobs/JOB_ID | jq ".progress"'
+
+# 3. View job logs
+curl http://localhost:4100/api/media/jobs/JOB_ID | jq -r ".log"
+
+# 4. Check queue stats
+curl http://localhost:4100/api/media/jobs/stats | jq
+
+
+

Last Updated: 2026-02-13 +Version: V2.0 +Maintainer: Changemaker Lite Team

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/media/public-gallery/index.html b/mkdocs/site/v2/features/media/public-gallery/index.html new file mode 100644 index 00000000..8ec5f2cc --- /dev/null +++ b/mkdocs/site/v2/features/media/public-gallery/index.html @@ -0,0 +1,7388 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Public Gallery - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Public Video Gallery

+

Overview

+

The Public Video Gallery provides a visitor-friendly interface for browsing and watching shared videos without requiring authentication. Built with category-based organization, reaction systems, and view tracking, it transforms the admin video library into a public-facing media platform similar to YouTube or Vimeo.

+

Key Features:

+
    +
  • Public Access — No login required, SEO-friendly URLs
  • +
  • Category Organization — Browse by Entertainment, Education, Sports, News, etc.
  • +
  • Lock/Unlock System — Admins control which videos are public via Shared Media page
  • +
  • Reaction System — 6 emoji reactions (Like, Love, Laugh, Surprise, Sad, Angry)
  • +
  • Comment System — Visitor comments with name/email (moderation pending)
  • +
  • View Tracking — Track total views + watch time per video
  • +
  • Upvote System — Visitors upvote favorite videos (ranking algorithm)
  • +
  • Related Videos — Show 3 similar videos below player
  • +
  • Responsive Design — Mobile-friendly grid layout
  • +
  • Video Player — HTML5 player with controls, fullscreen, playback speed
  • +
  • Social Sharing — Share video URLs on social media
  • +
+

Access Control:

+
    +
  • Public Routes — No authentication required
  • +
  • Admin Control — Shared Media page (SUPER_ADMIN only) controls which videos are public
  • +
  • Unlocking Videos — Removes from public gallery (not deleted, just hidden)
  • +
+

Technology Stack:

+
    +
  • Frontend: React + Ant Design + react-player
  • +
  • Backend: Fastify media API public routes (no auth)
  • +
  • Caching: Redis for public video lists (5 min TTL)
  • +
  • SEO: Server-side meta tags, sitemap generation
  • +
+
+

Architecture

+
flowchart TB
+    subgraph "Public Users"
+        U1[Desktop Browser]
+        U2[Mobile Browser]
+        U3[Social Media Bot]
+    end
+
+    subgraph "Admin Control"
+        A1[Admin User]
+        A2[SharedMediaPage]
+    end
+
+    subgraph "Public Routes (No Auth)"
+        P1[GET /api/public/media]
+        P2[GET /api/public/media/:id]
+        P3[POST /api/public/media/:id/view]
+        P4[POST /api/public/media/:id/reaction]
+        P5[POST /api/public/media/:id/comment]
+    end
+
+    subgraph "Admin Routes (Auth)"
+        A3[PUT /api/media/videos/:id/share]
+        A4[PUT /api/media/videos/:id/unshare]
+    end
+
+    subgraph "Database"
+        D1[(videos table)]
+        D2[(reactions table)]
+        D3[(comments table)]
+        D4[(view_logs table)]
+    end
+
+    subgraph "Cache"
+        C1[Redis<br/>Public Videos<br/>5 min TTL]
+    end
+
+    U1 --> P1
+    U2 --> P1
+    U3 --> P1
+
+    U1 --> P2
+    U2 --> P2
+
+    U1 --> P3
+    U1 --> P4
+    U1 --> P5
+
+    A1 --> A2
+    A2 --> A3
+    A2 --> A4
+
+    P1 --> C1
+    C1 --> D1
+
+    P2 --> D1
+    P3 --> D4
+    P4 --> D2
+    P5 --> D3
+
+    A3 --> D1
+    A4 --> D1
+
+    style P1 fill:#2ecc71
+    style P2 fill:#2ecc71
+    style C1 fill:#e74c3c
+    style A2 fill:#3498db
+

Workflow:

+
    +
  1. Admin Shares Video — Admin clicks "Share" button on SharedMediaPage → video marked public
  2. +
  3. Public Browse — Visitor navigates to /media → sees grid of public videos
  4. +
  5. Video Player — Visitor clicks video card → opens /media/:id → player page
  6. +
  7. Engagement — Visitor reacts, comments, or shares video
  8. +
  9. View Tracking — Frontend tracks watch time, sends to API on pause/end
  10. +
  11. Related Videos — API suggests 3 similar videos (same category/creator)
  12. +
+
+

Database Models

+

Videos Table (Public Fields)

+
// Only expose public-safe fields
+interface PublicVideo {
+  id: string;
+  title: string;
+  producer: string;
+  creator: string;
+  durationSeconds: number;
+  quality: string;
+  orientation: string;
+  thumbnailPath: string;
+  publicViewCount: number;
+  publicUpvoteCount: number;
+  createdAt: Date;
+
+  // Derived fields
+  category: string; // From tags or directoryType
+  isPublic: boolean; // Computed: movedFromPublicAt === null
+}
+
+

Privacy: Never expose path, filename, fileHash, or internal metadata publicly.

+
+

Reactions Table

+
CREATE TABLE video_reactions (
+  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+  video_id UUID NOT NULL REFERENCES videos(id),
+  reaction_type TEXT NOT NULL, -- like|love|laugh|surprise|sad|angry
+  session_id TEXT NOT NULL, -- IP hash or session cookie
+  created_at TIMESTAMP DEFAULT NOW(),
+  UNIQUE(video_id, session_id) -- One reaction per user per video
+);
+
+CREATE INDEX idx_reactions_video ON video_reactions(video_id);
+CREATE INDEX idx_reactions_session ON video_reactions(session_id);
+
+

Reaction Types:

+
    +
  • 👍 like — General approval
  • +
  • ❤️ love — Strong positive emotion
  • +
  • 😂 laugh — Funny/amusing
  • +
  • 😮 surprise — Surprising/shocking
  • +
  • 😢 sad — Sad/emotional
  • +
  • 😠 angry — Frustrating/angering
  • +
+

Session Tracking:

+
// Use IP hash for anonymous users
+const sessionId = crypto.createHash('sha256').update(req.ip).digest('hex');
+
+// Or use cookie for persistent tracking
+const sessionId = req.cookies.sessionId || randomUUID();
+res.cookie('sessionId', sessionId, { maxAge: 365 * 24 * 60 * 60 * 1000 }); // 1 year
+
+
+

Comments Table

+
CREATE TABLE video_comments (
+  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+  video_id UUID NOT NULL REFERENCES videos(id),
+  name TEXT NOT NULL,
+  email TEXT, -- Optional, for moderation notifications
+  comment TEXT NOT NULL,
+  approved BOOLEAN DEFAULT FALSE, -- Moderation flag
+  session_id TEXT, -- For tracking duplicate comments
+  created_at TIMESTAMP DEFAULT NOW()
+);
+
+CREATE INDEX idx_comments_video ON video_comments(video_id);
+CREATE INDEX idx_comments_approved ON video_comments(approved);
+
+

Moderation Workflow:

+
    +
  1. User submits comment → stored with approved = false
  2. +
  3. Admin reviews comment in moderation dashboard
  4. +
  5. Admin clicks "Approve" → approved = true, comment visible
  6. +
  7. Admin clicks "Reject" → comment remains hidden or deleted
  8. +
+
+

View Logs Table

+
CREATE TABLE video_view_logs (
+  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+  video_id UUID NOT NULL REFERENCES videos(id),
+  session_id TEXT NOT NULL,
+  watch_time_seconds INTEGER DEFAULT 0, -- Actual watch time (not video duration)
+  completed BOOLEAN DEFAULT FALSE, -- Watched > 90%
+  created_at TIMESTAMP DEFAULT NOW()
+);
+
+CREATE INDEX idx_view_logs_video ON video_view_logs(video_id);
+CREATE INDEX idx_view_logs_session ON video_view_logs(session_id, video_id);
+
+

Watch Time Tracking:

+
// Frontend sends watch time on pause/end
+let watchTime = 0;
+const interval = setInterval(() => {
+  if (!player.paused) {
+    watchTime++;
+  }
+}, 1000);
+
+// On pause or end
+const handlePause = async () => {
+  await axios.post(`/api/public/media/${videoId}/view`, {
+    watchTimeSeconds: watchTime,
+    completed: watchTime >= video.durationSeconds * 0.9,
+  });
+};
+
+
+

API Endpoints (Public)

+

All endpoints are public (no authentication required).

+

List Public Videos

+
GET /api/public/media
+
+

Query Parameters:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeDefaultDescription
pagenumber1Page number
limitnumber24Results per page
categorystring-Filter by category
orientationstring-Filter by orientation (portrait/landscape/square)
qualitystring-Filter by quality (SD/HD/FHD/UHD)
sortstringrecentSort by: recent, popular, trending
+

Response:

+
{
+  "data": [
+    {
+      "id": "550e8400-e29b-41d4-a716-446655440000",
+      "title": "Amazing Sports Highlight",
+      "producer": "Studio A",
+      "creator": "Director B",
+      "durationSeconds": 125,
+      "quality": "FHD",
+      "orientation": "landscape",
+      "thumbnailPath": "/media/thumbnails/550e8400.jpg",
+      "publicViewCount": 1250,
+      "publicUpvoteCount": 85,
+      "category": "Sports",
+      "createdAt": "2026-02-10T12:00:00Z"
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 24,
+    "total": 156,
+    "totalPages": 7
+  }
+}
+
+

Caching:

+
// Cache public video lists for 5 minutes
+const cacheKey = `public:videos:${JSON.stringify(query)}`;
+const cached = await redisClient.get(cacheKey);
+if (cached) {
+  return reply.send(JSON.parse(cached));
+}
+
+// Fetch from database
+const videos = await db.select()...;
+
+// Cache for 5 minutes
+await redisClient.setex(cacheKey, 300, JSON.stringify(videos));
+
+
+

Get Video Details

+
GET /api/public/media/:id
+
+

Response:

+
{
+  "video": {
+    "id": "550e8400-e29b-41d4-a716-446655440000",
+    "title": "Amazing Sports Highlight",
+    "producer": "Studio A",
+    "creator": "Director B",
+    "durationSeconds": 125,
+    "quality": "FHD",
+    "orientation": "landscape",
+    "width": 1920,
+    "height": 1080,
+    "thumbnailPath": "/media/thumbnails/550e8400.jpg",
+    "publicViewCount": 1251,
+    "publicUpvoteCount": 85,
+    "category": "Sports",
+    "createdAt": "2026-02-10T12:00:00Z",
+    "reactions": {
+      "like": 45,
+      "love": 20,
+      "laugh": 10,
+      "surprise": 5,
+      "sad": 3,
+      "angry": 2
+    }
+  },
+  "relatedVideos": [
+    {
+      "id": "660e8400-e29b-41d4-a716-446655440001",
+      "title": "Another Sports Video",
+      "thumbnailPath": "/media/thumbnails/660e8400.jpg",
+      "durationSeconds": 90
+    },
+    {
+      "id": "770e8400-e29b-41d4-a716-446655440002",
+      "title": "Top Plays Compilation",
+      "thumbnailPath": "/media/thumbnails/770e8400.jpg",
+      "durationSeconds": 180
+    }
+  ],
+  "comments": [
+    {
+      "id": "880e8400-e29b-41d4-a716-446655440003",
+      "name": "John Doe",
+      "comment": "Amazing video!",
+      "createdAt": "2026-02-12T14:30:00Z"
+    }
+  ]
+}
+
+

Related Videos Algorithm:

+
// Find 3 similar videos
+const relatedVideos = await db.select()
+  .from(videos)
+  .where(
+    and(
+      eq(videos.isPublic, true),
+      eq(videos.category, video.category), // Same category
+      not(eq(videos.id, video.id)) // Not current video
+    )
+  )
+  .orderBy(desc(videos.publicViewCount)) // Most popular first
+  .limit(3);
+
+
+

Track Video View

+
POST /api/public/media/:id/view
+
+

Request Body:

+
{
+  "watchTimeSeconds": 120,
+  "completed": true
+}
+
+

Response:

+
{
+  "success": true,
+  "newViewCount": 1252
+}
+
+

Process:

+
    +
  1. Get session ID (IP hash or cookie)
  2. +
  3. Check if already viewed in last 24 hours (prevent duplicate counting)
  4. +
  5. Create view log record
  6. +
  7. Increment video publicViewCount
  8. +
  9. Return new view count
  10. +
+
+

Add/Update Reaction

+
POST /api/public/media/:id/reaction
+
+

Request Body:

+
{
+  "reactionType": "like"
+}
+
+

Response:

+
{
+  "success": true,
+  "reactions": {
+    "like": 46,
+    "love": 20,
+    "laugh": 10,
+    "surprise": 5,
+    "sad": 3,
+    "angry": 2
+  }
+}
+
+

Process:

+
    +
  1. Get session ID
  2. +
  3. Check if user already reacted
  4. +
  5. If same reaction, remove it (toggle off)
  6. +
  7. If different reaction, update it
  8. +
  9. If no reaction, insert new one
  10. +
  11. Return updated reaction counts
  12. +
+
+

Submit Comment

+
POST /api/public/media/:id/comment
+
+

Request Body:

+
{
+  "name": "John Doe",
+  "email": "john@example.com",
+  "comment": "This video is amazing! Thanks for sharing."
+}
+
+

Response:

+
{
+  "success": true,
+  "message": "Comment submitted for moderation"
+}
+
+

Validation:

+
    +
  • Name: 1-100 characters
  • +
  • Email: Optional, valid email format
  • +
  • Comment: 1-1000 characters, no HTML allowed
  • +
+

Anti-Spam:

+
    +
  • Rate limit: 5 comments per hour per session
  • +
  • Duplicate detection: reject if same comment in last 24 hours
  • +
+
+

Admin Workflow

+

Sharing Videos (Making Public)

+
    +
  1. Navigate to Media → Shared Media page
  2. +
  3. Table shows all videos with "Public" toggle switch
  4. +
  5. To share video:
  6. +
  7. Click toggle switch to ON (blue)
  8. +
  9. Video immediately appears in public gallery
  10. +
  11. Modal prompts for category selection (optional)
  12. +
  13. To unshare video:
  14. +
  15. Click toggle switch to OFF (grey)
  16. +
  17. Video removed from public gallery
  18. +
  19. movedFromPublicAt timestamp set (preserves history)
  20. +
+

Shared Media Page Features:

+
    +
  • Category Management — Assign videos to categories (Entertainment, Education, Sports, etc.)
  • +
  • Bulk Actions — Select multiple videos, share/unshare all at once
  • +
  • Preview — Click "Preview" button to see public view
  • +
  • Stats — View count, upvote count, reaction breakdown
  • +
  • Lock Indicator — Icon shows which videos are currently public
  • +
+
+

Setting Categories

+

Option 1: Tag-Based Categories

+

Use video tags to auto-assign categories:

+
// If video has "sports" tag → Sports category
+// If video has "education" or "tutorial" tag → Education category
+const detectCategory = (tags: string[]): string => {
+  if (tags.some(t => ['sports', 'game', 'play'].includes(t.toLowerCase()))) {
+    return 'Sports';
+  }
+  if (tags.some(t => ['education', 'tutorial', 'learn'].includes(t.toLowerCase()))) {
+    return 'Education';
+  }
+  if (tags.some(t => ['entertainment', 'comedy', 'music'].includes(t.toLowerCase()))) {
+    return 'Entertainment';
+  }
+  return 'Other';
+};
+
+

Option 2: Manual Assignment

+
    +
  1. Select video in Shared Media page
  2. +
  3. Click "Edit Category" button
  4. +
  5. Modal opens with category dropdown:
  6. +
  7. Entertainment
  8. +
  9. Education
  10. +
  11. Sports
  12. +
  13. News
  14. +
  15. Music
  16. +
  17. Gaming
  18. +
  19. Science & Tech
  20. +
  21. Travel
  22. +
  23. Other
  24. +
  25. Click "Save"
  26. +
  27. Category updated immediately
  28. +
+
+

Viewing Statistics

+

Per-Video Stats:

+
    +
  1. Click video row in Shared Media page
  2. +
  3. Stats drawer slides in from right showing:
  4. +
  5. Total Views — All-time view count
  6. +
  7. Average Watch Time — Mean watch time (seconds)
  8. +
  9. Completion Rate — % of viewers who watched > 90%
  10. +
  11. Upvotes — Total upvote count
  12. +
  13. Reactions Breakdown — Chart showing reaction distribution
  14. +
  15. Top Referrers — Where views came from (direct, social, etc.)
  16. +
  17. View Trend — Line chart of views over last 30 days
  18. +
+

Gallery-Wide Stats:

+

Dashboard widget showing:

+
    +
  • Total public videos
  • +
  • Total views across all videos
  • +
  • Most popular video (by views)
  • +
  • Trending video (highest growth rate)
  • +
  • Total reactions
  • +
  • Total comments (pending + approved)
  • +
+
+

Moderating Comments

+
    +
  1. Navigate to Media → Comments page (or notification badge in sidebar)
  2. +
  3. Table shows all comments with filters:
  4. +
  5. Pending — Awaiting moderation
  6. +
  7. Approved — Visible on public gallery
  8. +
  9. Rejected — Hidden from public
  10. +
  11. To approve comment:
  12. +
  13. Click "Approve" button
  14. +
  15. Comment appears on video page immediately
  16. +
  17. To reject comment:
  18. +
  19. Click "Reject" button
  20. +
  21. Comment hidden (or deleted)
  22. +
  23. Optional: Send email to commenter explaining why
  24. +
+

Bulk Moderation:

+
    +
  • Select multiple comments via checkboxes
  • +
  • Click "Approve All" or "Reject All"
  • +
  • Batch updates applied instantly
  • +
+
+

Public User Workflow

+ +
    +
  1. Navigate to https://cmlite.org/media
  2. +
  3. Hero section shows featured video (most popular or admin-selected)
  4. +
  5. Category tabs below hero:
  6. +
  7. All
  8. +
  9. Entertainment
  10. +
  11. Education
  12. +
  13. Sports
  14. +
  15. News
  16. +
  17. Music
  18. +
  19. Gaming
  20. +
  21. Science & Tech
  22. +
  23. Grid of video cards (4 per row on desktop, 2 on tablet, 1 on mobile)
  24. +
  25. Each card shows:
  26. +
  27. Thumbnail image
  28. +
  29. Title
  30. +
  31. Producer/creator
  32. +
  33. Duration badge
  34. +
  35. View count
  36. +
  37. Quality badge (HD, FHD, UHD)
  38. +
+

Infinite Scroll:

+
    +
  • As user scrolls to bottom, next page loads automatically
  • +
  • Loading spinner shows while fetching
  • +
  • No "Load More" button needed
  • +
+
+

Watching Video

+
    +
  1. Click video card → navigates to https://cmlite.org/media/:id
  2. +
  3. Video player page layout:
  4. +
  5. Video Player — Full-width HTML5 player with controls
  6. +
  7. Video Title & Metadata — Title, producer, creator, view count
  8. +
  9. Reaction Bar — 6 emoji buttons with counts
  10. +
  11. Description — Auto-generated or admin-provided
  12. +
  13. Comments Section — Approved comments + submit form
  14. +
  15. Related Videos — 3 similar videos in sidebar
  16. +
  17. User clicks play → video starts, watch time tracked
  18. +
  19. User clicks reaction → emoji highlighted, count increments
  20. +
  21. User scrolls to comments → reads existing, submits new
  22. +
+

Video Player Features:

+
    +
  • Play/pause button
  • +
  • Volume slider
  • +
  • Playback speed (0.5x, 1x, 1.25x, 1.5x, 2x)
  • +
  • Fullscreen button
  • +
  • Current time / total duration
  • +
  • Scrub bar (seek to any position)
  • +
  • Auto-play next related video (optional)
  • +
+
+

Reacting to Video

+
    +
  1. Click reaction emoji button (e.g., 👍 Like)
  2. +
  3. Button highlights in color
  4. +
  5. Count increments by 1
  6. +
  7. Toggle behavior:
  8. +
  9. Click again → removes reaction, count decrements
  10. +
  11. Click different emoji → switches reaction
  12. +
  13. Session tracked via cookie (reactions persist across page refreshes)
  14. +
+

Reaction Colors:

+
    +
  • Like 👍 — Blue
  • +
  • Love ❤️ — Red
  • +
  • Laugh 😂 — Yellow
  • +
  • Surprise 😮 — Purple
  • +
  • Sad 😢 — Grey
  • +
  • Angry 😠 — Orange
  • +
+
+

Commenting

+
    +
  1. Scroll to comments section below video
  2. +
  3. Fill out form:
  4. +
  5. Name — Required, displayed publicly
  6. +
  7. Email — Optional, for moderation notifications
  8. +
  9. Comment — Required, 1-1000 characters
  10. +
  11. Click "Submit Comment"
  12. +
  13. Success message: "Comment submitted for moderation"
  14. +
  15. Comment appears in list with "Pending approval" badge
  16. +
  17. After admin approval, comment visible to all
  18. +
+

Comment Formatting:

+
    +
  • Plain text only (no HTML)
  • +
  • URLs auto-linked
  • +
  • Line breaks preserved
  • +
  • Profanity filter applied (optional)
  • +
+
+

Code Examples

+

Backend: List Public Videos

+
// api/src/modules/media/routes/public.routes.ts
+import { FastifyInstance } from 'fastify';
+import { eq, and, isNull, desc } from 'drizzle-orm';
+import { videos } from '@/modules/media/db/schema';
+import { redisClient } from '@/config/redis';
+
+export default async function (app: FastifyInstance) {
+  app.get('/api/public/media', async (req, reply) => {
+    const {
+      page = 1,
+      limit = 24,
+      category,
+      orientation,
+      quality,
+      sort = 'recent',
+    } = req.query as any;
+
+    // Check cache
+    const cacheKey = `public:videos:${JSON.stringify(req.query)}`;
+    const cached = await redisClient.get(cacheKey);
+    if (cached) {
+      return reply.send(JSON.parse(cached));
+    }
+
+    // Build filters
+    const filters = [
+      isNull(videos.movedFromPublicAt), // Only public videos
+      eq(videos.isValid, true),
+    ];
+
+    if (category) {
+      filters.push(eq(videos.category, category));
+    }
+
+    if (orientation) {
+      filters.push(eq(videos.orientation, orientation));
+    }
+
+    if (quality) {
+      filters.push(eq(videos.quality, quality));
+    }
+
+    // Build order by
+    let orderBy;
+    if (sort === 'popular') {
+      orderBy = desc(videos.publicViewCount);
+    } else if (sort === 'trending') {
+      // Trending = highest view count in last 7 days
+      // (requires separate view_logs aggregation query)
+      orderBy = desc(videos.publicViewCount);
+    } else {
+      orderBy = desc(videos.createdAt);
+    }
+
+    // Fetch videos
+    const results = await db
+      .select({
+        id: videos.id,
+        title: videos.title,
+        producer: videos.producer,
+        creator: videos.creator,
+        durationSeconds: videos.durationSeconds,
+        quality: videos.quality,
+        orientation: videos.orientation,
+        thumbnailPath: videos.thumbnailPath,
+        publicViewCount: videos.publicViewCount,
+        publicUpvoteCount: videos.publicUpvoteCount,
+        category: videos.category,
+        createdAt: videos.createdAt,
+      })
+      .from(videos)
+      .where(and(...filters))
+      .orderBy(orderBy)
+      .limit(Number(limit))
+      .offset((Number(page) - 1) * Number(limit));
+
+    // Count total
+    const [{ count }] = await db
+      .select({ count: sql<number>`count(*)` })
+      .from(videos)
+      .where(and(...filters));
+
+    const response = {
+      data: results,
+      pagination: {
+        page: Number(page),
+        limit: Number(limit),
+        total: Number(count),
+        totalPages: Math.ceil(Number(count) / Number(limit)),
+      },
+    };
+
+    // Cache for 5 minutes
+    await redisClient.setex(cacheKey, 300, JSON.stringify(response));
+
+    reply.send(response);
+  });
+}
+
+
+

Backend: Track View

+
// api/src/modules/media/routes/public.routes.ts
+import { videoViewLogs, videos } from '@/modules/media/db/schema';
+import crypto from 'crypto';
+
+app.post('/api/public/media/:id/view', async (req, reply) => {
+  const { id } = req.params as { id: string };
+  const { watchTimeSeconds, completed } = req.body as any;
+
+  // Get session ID from IP hash
+  const sessionId = crypto.createHash('sha256').update(req.ip).digest('hex');
+
+  // Check if already viewed in last 24 hours
+  const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
+  const existingView = await db
+    .select()
+    .from(videoViewLogs)
+    .where(
+      and(
+        eq(videoViewLogs.videoId, id),
+        eq(videoViewLogs.sessionId, sessionId),
+        gte(videoViewLogs.createdAt, yesterday)
+      )
+    )
+    .limit(1);
+
+  if (existingView.length > 0) {
+    // Update watch time if longer than previous
+    if (watchTimeSeconds > existingView[0].watchTimeSeconds) {
+      await db
+        .update(videoViewLogs)
+        .set({
+          watchTimeSeconds,
+          completed: completed || existingView[0].completed,
+        })
+        .where(eq(videoViewLogs.id, existingView[0].id));
+    }
+
+    return reply.send({ success: true, newViewCount: null });
+  }
+
+  // Create new view log
+  await db.insert(videoViewLogs).values({
+    videoId: id,
+    sessionId,
+    watchTimeSeconds,
+    completed,
+  });
+
+  // Increment view count
+  const [updated] = await db
+    .update(videos)
+    .set({
+      publicViewCount: sql`${videos.publicViewCount} + 1`,
+    })
+    .where(eq(videos.id, id))
+    .returning({ newViewCount: videos.publicViewCount });
+
+  reply.send({ success: true, newViewCount: updated.newViewCount });
+});
+
+
+

Backend: Add Reaction

+
// api/src/modules/media/routes/public.routes.ts
+import { videoReactions } from '@/modules/media/db/schema';
+
+app.post('/api/public/media/:id/reaction', async (req, reply) => {
+  const { id } = req.params as { id: string };
+  const { reactionType } = req.body as { reactionType: string };
+
+  const validReactions = ['like', 'love', 'laugh', 'surprise', 'sad', 'angry'];
+  if (!validReactions.includes(reactionType)) {
+    return reply.code(400).send({ error: 'Invalid reaction type' });
+  }
+
+  const sessionId = crypto.createHash('sha256').update(req.ip).digest('hex');
+
+  // Check existing reaction
+  const [existing] = await db
+    .select()
+    .from(videoReactions)
+    .where(
+      and(
+        eq(videoReactions.videoId, id),
+        eq(videoReactions.sessionId, sessionId)
+      )
+    )
+    .limit(1);
+
+  if (existing) {
+    if (existing.reactionType === reactionType) {
+      // Toggle off (remove reaction)
+      await db
+        .delete(videoReactions)
+        .where(eq(videoReactions.id, existing.id));
+    } else {
+      // Update to new reaction
+      await db
+        .update(videoReactions)
+        .set({ reactionType })
+        .where(eq(videoReactions.id, existing.id));
+    }
+  } else {
+    // Insert new reaction
+    await db.insert(videoReactions).values({
+      videoId: id,
+      sessionId,
+      reactionType,
+    });
+  }
+
+  // Get updated reaction counts
+  const reactions = await db
+    .select({
+      reactionType: videoReactions.reactionType,
+      count: sql<number>`count(*)`,
+    })
+    .from(videoReactions)
+    .where(eq(videoReactions.videoId, id))
+    .groupBy(videoReactions.reactionType);
+
+  const reactionCounts = validReactions.reduce((acc, type) => {
+    acc[type] = reactions.find((r) => r.reactionType === type)?.count || 0;
+    return acc;
+  }, {} as Record<string, number>);
+
+  reply.send({ success: true, reactions: reactionCounts });
+});
+
+
+ +
// admin/src/pages/public/MediaGalleryPage.tsx
+import { Row, Col, Card, Tag, Tabs, Empty } from 'antd';
+import { PlayCircleOutlined, EyeOutlined } from '@ant-design/icons';
+import { useEffect, useState } from 'react';
+import axios from 'axios';
+import InfiniteScroll from 'react-infinite-scroll-component';
+
+export default function MediaGalleryPage() {
+  const [videos, setVideos] = useState<any[]>([]);
+  const [category, setCategory] = useState<string>('');
+  const [page, setPage] = useState(1);
+  const [hasMore, setHasMore] = useState(true);
+
+  const fetchVideos = async () => {
+    try {
+      const { data } = await axios.get('http://api.cmlite.org/api/public/media', {
+        params: {
+          page,
+          limit: 24,
+          category: category || undefined,
+        },
+      });
+
+      setVideos((prev) => [...prev, ...data.data]);
+      setHasMore(page < data.pagination.totalPages);
+    } catch (error) {
+      console.error('Failed to fetch videos:', error);
+    }
+  };
+
+  useEffect(() => {
+    setVideos([]);
+    setPage(1);
+    setHasMore(true);
+  }, [category]);
+
+  useEffect(() => {
+    fetchVideos();
+  }, [page, category]);
+
+  const categories = [
+    { key: '', label: 'All' },
+    { key: 'Entertainment', label: 'Entertainment' },
+    { key: 'Education', label: 'Education' },
+    { key: 'Sports', label: 'Sports' },
+    { key: 'News', label: 'News' },
+    { key: 'Music', label: 'Music' },
+    { key: 'Gaming', label: 'Gaming' },
+    { key: 'Science & Tech', label: 'Science & Tech' },
+  ];
+
+  return (
+    <div style={{ padding: 24 }}>
+      <h1 style={{ fontSize: 32, marginBottom: 24 }}>Video Gallery</h1>
+
+      <Tabs
+        activeKey={category}
+        onChange={setCategory}
+        items={categories.map((cat) => ({
+          key: cat.key,
+          label: cat.label,
+        }))}
+        style={{ marginBottom: 24 }}
+      />
+
+      <InfiniteScroll
+        dataLength={videos.length}
+        next={() => setPage((p) => p + 1)}
+        hasMore={hasMore}
+        loader={<div style={{ textAlign: 'center', padding: 24 }}>Loading...</div>}
+        endMessage={
+          <Empty description="No more videos" style={{ marginTop: 48 }} />
+        }
+      >
+        <Row gutter={[16, 16]}>
+          {videos.map((video) => (
+            <Col key={video.id} xs={24} sm={12} md={8} lg={6}>
+              <Card
+                hoverable
+                cover={
+                  <div
+                    style={{
+                      position: 'relative',
+                      paddingTop: '56.25%',
+                      background: '#000',
+                    }}
+                  >
+                    <img
+                      src={video.thumbnailPath || '/placeholder.jpg'}
+                      alt={video.title}
+                      style={{
+                        position: 'absolute',
+                        top: 0,
+                        left: 0,
+                        width: '100%',
+                        height: '100%',
+                        objectFit: 'cover',
+                      }}
+                    />
+                    <div
+                      style={{
+                        position: 'absolute',
+                        top: 8,
+                        right: 8,
+                        background: 'rgba(0,0,0,0.7)',
+                        color: '#fff',
+                        padding: '4px 8px',
+                        borderRadius: 4,
+                        fontSize: 12,
+                      }}
+                    >
+                      {Math.floor(video.durationSeconds / 60)}:
+                      {(video.durationSeconds % 60).toString().padStart(2, '0')}
+                    </div>
+                    <PlayCircleOutlined
+                      style={{
+                        position: 'absolute',
+                        top: '50%',
+                        left: '50%',
+                        transform: 'translate(-50%, -50%)',
+                        fontSize: 48,
+                        color: '#fff',
+                        opacity: 0.8,
+                      }}
+                    />
+                  </div>
+                }
+                onClick={() => (window.location.href = `/media/${video.id}`)}
+              >
+                <Card.Meta
+                  title={
+                    <div style={{ fontSize: 14, height: 40, overflow: 'hidden' }}>
+                      {video.title}
+                    </div>
+                  }
+                  description={
+                    <div>
+                      <div style={{ fontSize: 12, color: '#888', marginBottom: 8 }}>
+                        {video.producer}
+                      </div>
+                      <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+                        <span style={{ fontSize: 12 }}>
+                          <EyeOutlined /> {video.publicViewCount.toLocaleString()}
+                        </span>
+                        <Tag color={video.quality === 'UHD' ? 'purple' : 'blue'}>
+                          {video.quality}
+                        </Tag>
+                      </div>
+                    </div>
+                  }
+                />
+              </Card>
+            </Col>
+          ))}
+        </Row>
+      </InfiniteScroll>
+    </div>
+  );
+}
+
+
+

Frontend: Video Player Page

+
// admin/src/pages/public/MediaViewerPage.tsx
+import { useParams } from 'react-router-dom';
+import { useEffect, useState } from 'react';
+import axios from 'axios';
+import ReactPlayer from 'react-player';
+import { Button, Row, Col, Card, Divider, Form, Input, message } from 'antd';
+
+export default function MediaViewerPage() {
+  const { id } = useParams<{ id: string }>();
+  const [video, setVideo] = useState<any>(null);
+  const [watchTime, setWatchTime] = useState(0);
+  const [userReaction, setUserReaction] = useState<string | null>(null);
+
+  useEffect(() => {
+    fetchVideo();
+  }, [id]);
+
+  const fetchVideo = async () => {
+    const { data } = await axios.get(`http://api.cmlite.org/api/public/media/${id}`);
+    setVideo(data.video);
+  };
+
+  const trackView = async () => {
+    await axios.post(`http://api.cmlite.org/api/public/media/${id}/view`, {
+      watchTimeSeconds: watchTime,
+      completed: watchTime >= video.durationSeconds * 0.9,
+    });
+  };
+
+  const handleReaction = async (reactionType: string) => {
+    const { data } = await axios.post(`http://api.cmlite.org/api/public/media/${id}/reaction`, {
+      reactionType,
+    });
+
+    setUserReaction(userReaction === reactionType ? null : reactionType);
+    setVideo({ ...video, reactions: data.reactions });
+  };
+
+  const handleSubmitComment = async (values: any) => {
+    await axios.post(`http://api.cmlite.org/api/public/media/${id}/comment`, values);
+    message.success('Comment submitted for moderation');
+  };
+
+  if (!video) return <div>Loading...</div>;
+
+  const reactions = [
+    { type: 'like', emoji: '👍', label: 'Like' },
+    { type: 'love', emoji: '❤️', label: 'Love' },
+    { type: 'laugh', emoji: '😂', label: 'Laugh' },
+    { type: 'surprise', emoji: '😮', label: 'Surprise' },
+    { type: 'sad', emoji: '😢', label: 'Sad' },
+    { type: 'angry', emoji: '😠', label: 'Angry' },
+  ];
+
+  return (
+    <div style={{ maxWidth: 1200, margin: '0 auto', padding: 24 }}>
+      <Row gutter={24}>
+        <Col span={16}>
+          <ReactPlayer
+            url={`/media/videos/${video.id}.mp4`}
+            controls
+            width="100%"
+            height="auto"
+            onProgress={(state) => setWatchTime(Math.floor(state.playedSeconds))}
+            onPause={trackView}
+            onEnded={trackView}
+          />
+
+          <h1 style={{ marginTop: 16 }}>{video.title}</h1>
+          <div style={{ color: '#888', marginBottom: 16 }}>
+            {video.producer}  {video.publicViewCount.toLocaleString()} views
+          </div>
+
+          <div style={{ display: 'flex', gap: 8, marginBottom: 24 }}>
+            {reactions.map((r) => (
+              <Button
+                key={r.type}
+                type={userReaction === r.type ? 'primary' : 'default'}
+                onClick={() => handleReaction(r.type)}
+              >
+                <span style={{ fontSize: 20, marginRight: 4 }}>{r.emoji}</span>
+                {video.reactions[r.type] || 0}
+              </Button>
+            ))}
+          </div>
+
+          <Divider />
+
+          <h3>Comments</h3>
+          {video.comments.map((comment: any) => (
+            <Card key={comment.id} style={{ marginBottom: 16 }}>
+              <Card.Meta
+                title={comment.name}
+                description={comment.comment}
+              />
+              <div style={{ fontSize: 12, color: '#888', marginTop: 8 }}>
+                {new Date(comment.createdAt).toLocaleDateString()}
+              </div>
+            </Card>
+          ))}
+
+          <Form onFinish={handleSubmitComment} layout="vertical">
+            <Form.Item label="Name" name="name" rules={[{ required: true }]}>
+              <Input />
+            </Form.Item>
+            <Form.Item label="Email" name="email" rules={[{ type: 'email' }]}>
+              <Input />
+            </Form.Item>
+            <Form.Item label="Comment" name="comment" rules={[{ required: true }]}>
+              <Input.TextArea rows={4} />
+            </Form.Item>
+            <Button type="primary" htmlType="submit">
+              Submit Comment
+            </Button>
+          </Form>
+        </Col>
+
+        <Col span={8}>
+          <h3>Related Videos</h3>
+          {video.relatedVideos.map((related: any) => (
+            <Card
+              key={related.id}
+              hoverable
+              cover={<img src={related.thumbnailPath} alt={related.title} />}
+              onClick={() => (window.location.href = `/media/${related.id}`)}
+              style={{ marginBottom: 16 }}
+            >
+              <Card.Meta title={related.title} />
+            </Card>
+          ))}
+        </Col>
+      </Row>
+    </div>
+  );
+}
+
+
+

Troubleshooting

+ +

Symptoms:

+
    +
  • SharedMediaPage shows videos marked as public
  • +
  • Public gallery shows "No videos found"
  • +
  • API returns empty array
  • +
+

Solutions:

+
    +
  1. Check movedFromPublicAt field:
  2. +
+
SELECT id, title, moved_from_public_at FROM videos WHERE moved_from_public_at IS NULL;
+-- Should show public videos
+
+-- If all have timestamps, videos were unlocked
+-- Fix: Set to NULL for videos that should be public
+UPDATE videos SET moved_from_public_at = NULL WHERE id = 'VIDEO_ID';
+
+
    +
  1. Verify isValid = true:
  2. +
+
SELECT id, title, is_valid FROM videos WHERE is_valid = false;
+-- Invalid videos hidden from public
+
+-- Fix: Validate videos to mark as valid
+
+
    +
  1. Check Redis cache:
  2. +
+
# Clear public video cache
+docker compose exec redis redis-cli
+> KEYS public:videos:*
+> DEL public:videos:*
+
+# Refresh gallery page
+
+
    +
  1. Test API directly:
  2. +
+
curl http://localhost:4100/api/public/media
+# Should return JSON with videos array
+
+
+

Problem: Reactions Not Saving

+

Symptoms:

+
    +
  • Click reaction button, count doesn't increment
  • +
  • Refresh page, reaction disappears
  • +
  • No errors in console
  • +
+

Solutions:

+
    +
  1. Check session ID generation:
  2. +
+
// Backend should use consistent session ID
+const sessionId = crypto.createHash('sha256').update(req.ip).digest('hex');
+
+// Or use cookie for persistence
+const sessionId = req.cookies.sessionId || randomUUID();
+res.cookie('sessionId', sessionId, { maxAge: 365 * 24 * 60 * 60 * 1000 });
+
+
    +
  1. Verify database insert:
  2. +
+
SELECT * FROM video_reactions WHERE video_id = 'VIDEO_ID';
+-- Should show reaction records
+
+-- If empty, insert is failing
+-- Check unique constraint: (video_id, session_id)
+
+
    +
  1. Test reaction endpoint:
  2. +
+
curl -X POST http://localhost:4100/api/public/media/VIDEO_ID/reaction \
+  -H "Content-Type: application/json" \
+  -d '{"reactionType": "like"}'
+
+# Should return updated reaction counts
+
+
+

Problem: Comments Not Showing After Approval

+

Symptoms:

+
    +
  • Admin approves comment
  • +
  • Comment still doesn't appear on video page
  • +
  • Database shows approved = true
  • +
+

Solutions:

+
    +
  1. Check query filter:
  2. +
+
// Backend should filter for approved comments
+const comments = await db
+  .select()
+  .from(videoComments)
+  .where(
+    and(
+      eq(videoComments.videoId, videoId),
+      eq(videoComments.approved, true) // MUST include this
+    )
+  )
+  .orderBy(desc(videoComments.createdAt));
+
+
    +
  1. Clear cache:
  2. +
+
# Video details may be cached
+docker compose exec redis redis-cli DEL "public:video:VIDEO_ID"
+
+
    +
  1. Verify approval:
  2. +
+
SELECT id, comment, approved FROM video_comments WHERE video_id = 'VIDEO_ID';
+-- Should show approved = true
+
+
+

Performance Considerations

+

Redis Caching Strategy

+

Cache Keys:

+
    +
  • public:videos:{query} — List of videos (5 min TTL)
  • +
  • public:video:{id} — Video details (10 min TTL)
  • +
  • public:stats — Gallery-wide stats (15 min TTL)
  • +
+

Cache Invalidation:

+
// When admin shares/unshares video
+await redisClient.del(`public:videos:*`); // Clear all list caches
+await redisClient.del(`public:video:${videoId}`); // Clear detail cache
+
+// When comment approved
+await redisClient.del(`public:video:${videoId}`); // Refresh comments
+
+
+

Database Indexes

+
-- Public video queries
+CREATE INDEX idx_videos_public ON videos(moved_from_public_at) WHERE moved_from_public_at IS NULL;
+CREATE INDEX idx_videos_category ON videos(category, created_at DESC);
+CREATE INDEX idx_videos_popular ON videos(public_view_count DESC);
+
+-- Reactions
+CREATE INDEX idx_reactions_video ON video_reactions(video_id);
+CREATE INDEX idx_reactions_session ON video_reactions(session_id);
+
+-- Comments
+CREATE INDEX idx_comments_video_approved ON video_comments(video_id, approved);
+
+-- View logs
+CREATE INDEX idx_view_logs_video ON video_view_logs(video_id);
+CREATE INDEX idx_view_logs_recent ON video_view_logs(created_at DESC);
+
+
+

SEO Optimization

+

Server-Side Rendering (Future):

+
// Next.js or similar for SSR
+export async function getServerSideProps({ params }: { params: { id: string } }) {
+  const video = await fetchVideo(params.id);
+
+  return {
+    props: {
+      video,
+      meta: {
+        title: video.title,
+        description: `Watch ${video.title} by ${video.producer}`,
+        image: video.thumbnailPath,
+        url: `https://cmlite.org/media/${video.id}`,
+      },
+    },
+  };
+}
+
+

Meta Tags:

+
<head>
+  <title>Amazing Sports Highlight | CMLite Gallery</title>
+  <meta name="description" content="Watch Amazing Sports Highlight by Studio A. 1,250 views.">
+  <meta property="og:title" content="Amazing Sports Highlight">
+  <meta property="og:description" content="Watch Amazing Sports Highlight by Studio A">
+  <meta property="og:image" content="https://cmlite.org/media/thumbnails/550e8400.jpg">
+  <meta property="og:url" content="https://cmlite.org/media/550e8400">
+  <meta property="og:type" content="video.other">
+  <meta name="twitter:card" content="player">
+  <meta name="twitter:title" content="Amazing Sports Highlight">
+  <meta name="twitter:image" content="https://cmlite.org/media/thumbnails/550e8400.jpg">
+</head>
+
+

Sitemap Generation:

+
<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+  <url>
+    <loc>https://cmlite.org/media</loc>
+    <changefreq>daily</changefreq>
+    <priority>1.0</priority>
+  </url>
+  <url>
+    <loc>https://cmlite.org/media/550e8400-e29b-41d4-a716-446655440000</loc>
+    <lastmod>2026-02-10</lastmod>
+    <changefreq>weekly</changefreq>
+    <priority>0.8</priority>
+  </url>
+  <!-- ... more video URLs -->
+</urlset>
+
+
+

Security Considerations

+

Rate Limiting

+
// Public endpoints more restrictive than admin
+import rateLimit from '@fastify/rate-limit';
+
+app.register(rateLimit, {
+  max: 100,          // 100 requests
+  timeWindow: '1 minute',
+  allowList: [],     // No whitelist for public
+});
+
+

Per-Endpoint Limits:

+
    +
  • List videos: 100/min
  • +
  • Video details: 100/min
  • +
  • Track view: 10/min (prevent view count manipulation)
  • +
  • Add reaction: 20/min
  • +
  • Submit comment: 5/hour (anti-spam)
  • +
+
+

Content Moderation

+

Comment Filtering:

+
import Filter from 'bad-words';
+
+const filter = new Filter();
+
+const sanitizeComment = (comment: string): string => {
+  // Remove HTML tags
+  const cleaned = comment.replace(/<[^>]*>/g, '');
+
+  // Filter profanity
+  return filter.clean(cleaned);
+};
+
+

Spam Detection:

+
// Reject duplicate comments
+const existingComment = await db.select()
+  .from(videoComments)
+  .where(
+    and(
+      eq(videoComments.sessionId, sessionId),
+      eq(videoComments.comment, comment),
+      gte(videoComments.createdAt, new Date(Date.now() - 24 * 60 * 60 * 1000))
+    )
+  )
+  .limit(1);
+
+if (existingComment.length > 0) {
+  return reply.code(429).send({ error: 'Duplicate comment detected' });
+}
+
+
+

Privacy Protection

+

Never Expose:

+
    +
  • Internal file paths (/media/local/library/...)
  • +
  • Original filenames (use video ID for playback URL)
  • +
  • Admin user information
  • +
  • Email addresses from comments (unless user explicitly made public)
  • +
+

Session Tracking:

+
// Use IP hash (not raw IP) for session ID
+const sessionId = crypto.createHash('sha256').update(req.ip + 'SECRET_SALT').digest('hex');
+
+// Store minimal data in session
+// NO: { userId: 123, name: 'John', email: 'john@example.com' }
+// YES: { sessionId: 'abc123' }
+
+
+ +

Backend Documentation

+
    +
  • Public Routes: backend/modules/media/public.md — Public API endpoints
  • +
  • Reactions Service: backend/modules/media/reactions.md — Reaction system implementation
  • +
  • Comments Service: backend/modules/media/comments.md — Comment moderation system
  • +
+

Frontend Documentation

+
    +
  • Media Gallery Page: frontend/pages/public/media-gallery.md — Gallery UI implementation
  • +
  • Video Player Page: frontend/pages/public/media-viewer.md — Player component
  • +
+

Feature Documentation

+
    +
  • Video Library: features/media/video-library.md — Admin video management
  • +
  • Shared Media: features/media/shared-media.md — Sharing controls (admin)
  • +
+
+

Next Steps

+

After mastering the public gallery:

+
    +
  1. Analytics Dashboard — Build admin dashboard showing view trends, popular videos, engagement metrics
  2. +
  3. Playlist System — Allow users to create and share playlists
  4. +
  5. Video Embedding — Generate embed codes for external websites
  6. +
  7. Advanced Search — Full-text search across titles, producers, creators, tags
  8. +
+

Hands-On Practice:

+
# 1. Share video via API
+curl -X PUT http://localhost:4100/api/media/videos/VIDEO_ID/share \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"category": "Sports"}'
+
+# 2. Browse public gallery
+curl http://localhost:4100/api/public/media?category=Sports
+
+# 3. Track view
+curl -X POST http://localhost:4100/api/public/media/VIDEO_ID/view \
+  -H "Content-Type: application/json" \
+  -d '{"watchTimeSeconds": 120, "completed": true}'
+
+# 4. Add reaction
+curl -X POST http://localhost:4100/api/public/media/VIDEO_ID/reaction \
+  -H "Content-Type: application/json" \
+  -d '{"reactionType": "like"}'
+
+
+

Last Updated: 2026-02-13 +Version: V2.0 +Maintainer: Changemaker Lite Team

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/media/upload/index.html b/mkdocs/site/v2/features/media/upload/index.html new file mode 100644 index 00000000..e7818dc7 --- /dev/null +++ b/mkdocs/site/v2/features/media/upload/index.html @@ -0,0 +1,6947 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Upload System - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Video Upload System

+

Overview

+

The Video Upload System provides a modern drag-and-drop interface for uploading video files with automatic metadata extraction, progress tracking, and batch processing capabilities. Built on Fastify's multipart plugin with FFprobe integration, it supports large files up to 10GB while maintaining server stability through streaming.

+

Key Features:

+
    +
  • Drag-and-Drop Interface — Intuitive file selection with visual drop zone
  • +
  • Automatic Metadata Extraction — FFprobe extracts duration, dimensions, orientation, quality, and audio detection
  • +
  • Single & Batch Upload — Upload one video or queue multiple files
  • +
  • Large File Support — Handles files up to 10GB via streaming (no memory buffering)
  • +
  • Progress Tracking — Real-time upload progress with percentage and speed
  • +
  • Format Validation — Supports MP4, MOV, AVI, MKV, WebM, M4V, FLV
  • +
  • UUID Filenames — Prevents conflicts and path traversal attacks
  • +
  • Inbox Staging — Videos uploaded to /inbox directory before processing
  • +
  • Manual Metadata — Admin can override auto-detected fields (producer, creator, title, tags)
  • +
+

Technology Stack:

+
    +
  • Frontend: Ant Design Upload component with custom drag-drop styling
  • +
  • Backend: Fastify @fastify/multipart plugin for streaming uploads
  • +
  • Metadata: FFprobe for video analysis (duration, dimensions, codec, bitrate)
  • +
  • Storage: Direct filesystem writes to /media/local/inbox directory
  • +
+
+

Architecture

+
sequenceDiagram
+    participant U as User
+    participant UI as UploadVideoModal
+    participant API as Fastify Media API
+    participant FS as Filesystem
+    participant FFP as FFprobe Service
+    participant DB as PostgreSQL
+
+    U->>UI: Drag video file(s)
+    UI->>UI: Validate file type/size
+    UI->>U: Show file in queue
+
+    U->>UI: Click "Upload"
+    UI->>API: POST /api/media/upload/single<br/>(multipart/form-data)
+
+    API->>API: Generate UUID filename
+    API->>FS: Stream to /inbox/{uuid}.mp4
+    FS-->>API: Write complete
+
+    API->>FFP: Extract metadata
+    FFP->>FS: Analyze video file
+    FFP-->>API: Return metadata JSON
+
+    API->>DB: INSERT video record
+    DB-->>API: Return video ID
+
+    API-->>UI: Upload success + metadata
+    UI-->>U: Show success message
+    UI->>UI: Refresh library table
+
+    Note over API,FS: File remains in /inbox<br/>until moved by admin
+

Upload Flow:

+
    +
  1. Client Validation — Browser checks file extension and size before upload
  2. +
  3. Streaming Upload — File streamed to disk in chunks (no memory buffer)
  4. +
  5. Metadata Extraction — FFprobe analyzes video (30s timeout)
  6. +
  7. Database Record — Video record created with auto-detected metadata
  8. +
  9. Response — Frontend receives video ID and metadata
  10. +
  11. Library Update — Table refreshes to show new video
  12. +
+

Key Design Decisions:

+
    +
  • Streaming vs Buffering — Streaming prevents memory exhaustion on large files (10GB would require 10GB RAM if buffered)
  • +
  • Inbox Staging — New uploads go to /inbox directory instead of final location, allowing admin review before publishing
  • +
  • UUID Filenames — Prevents filename conflicts and path traversal attacks (../../etc/passwd.mp4)
  • +
  • Synchronous FFprobe — Metadata extracted immediately (not deferred to job queue) for instant feedback
  • +
+
+

Upload Workflow

+

User Workflow (Admin)

+
    +
  1. Open Upload Modal
  2. +
  3. Navigate to Media → Library page
  4. +
  5. Click "Upload Video" button in top toolbar
  6. +
  7. +

    Modal opens with drag-drop zone

    +
  8. +
  9. +

    Select Files

    +
  10. +
  11. Drag files from desktop into blue dashed zone
  12. +
  13. OR click "Click to browse" link to open file picker
  14. +
  15. +

    Multiple files can be selected for batch upload

    +
  16. +
  17. +

    Review Queue

    +
  18. +
  19. Selected files appear in list with:
      +
    • Filename and size
    • +
    • File type icon
    • +
    • Remove button (X)
    • +
    +
  20. +
  21. +

    Invalid files (wrong extension, too large) highlighted in red

    +
  22. +
  23. +

    Enter Metadata (Optional)

    +
  24. +
  25. Producer — Studio or production company name
  26. +
  27. Creator — Director or primary creator
  28. +
  29. Title — Display title (defaults to filename if blank)
  30. +
  31. +

    Tags — Comma-separated tags (e.g., "action, sports, highlight")

    +
  32. +
  33. +

    Upload

    +
  34. +
  35. Click "Upload" button
  36. +
  37. Files upload sequentially (not parallel)
  38. +
  39. +

    Progress bar shows:

    +
      +
    • Current file name
    • +
    • Upload percentage (0-100%)
    • +
    • Upload speed (MB/s)
    • +
    • Estimated time remaining
    • +
    +
  40. +
  41. +

    Metadata Extraction

    +
  42. +
  43. After upload completes, FFprobe runs automatically
  44. +
  45. Spinner shows "Extracting metadata..."
  46. +
  47. +

    Auto-fills: duration, dimensions, orientation, quality, audio

    +
  48. +
  49. +

    Success

    +
  50. +
  51. Green checkmark appears
  52. +
  53. Success message: "Uploaded: {filename}"
  54. +
  55. Modal can be closed or kept open for more uploads
  56. +
  57. Library table refreshes showing new video
  58. +
+

Error Handling

+

Invalid File Type:

+
Error: File type not supported
+Allowed: MP4, MOV, AVI, MKV, WebM, M4V, FLV
+
+

File Too Large:

+
Error: File exceeds 10GB limit
+Selected file: 12.5 GB
+
+

Upload Failed:

+
Error: Upload failed
+Network error or server unavailable
+
+

FFprobe Extraction Failed:

+
Warning: Metadata extraction failed
+Video uploaded but metadata incomplete
+You can manually enter duration and dimensions
+
+
+

API Endpoints

+

Upload Single Video

+
POST /api/media/upload/single
+Content-Type: multipart/form-data
+Authorization: Bearer <admin_token>
+
+

Request (Multipart Form Data):

+
--boundary
+Content-Disposition: form-data; name="video"; filename="my-video.mp4"
+Content-Type: video/mp4
+
+<binary video data>
+--boundary
+Content-Disposition: form-data; name="producer"
+
+Studio A
+--boundary
+Content-Disposition: form-data; name="creator"
+
+Director B
+--boundary
+Content-Disposition: form-data; name="title"
+
+My Awesome Video
+--boundary
+Content-Disposition: form-data; name="tags"
+
+action,sports,highlight
+--boundary--
+
+

Response (Success):

+
{
+  "id": "660e8400-e29b-41d4-a716-446655440000",
+  "path": "inbox/660e8400-e29b-41d4-a716-446655440000.mp4",
+  "filename": "660e8400-e29b-41d4-a716-446655440000.mp4",
+  "originalFilename": "my-video.mp4",
+  "directoryType": "inbox",
+  "producer": "Studio A",
+  "creator": "Director B",
+  "title": "My Awesome Video",
+  "tags": ["action", "sports", "highlight"],
+  "durationSeconds": 125,
+  "width": 1920,
+  "height": 1080,
+  "quality": "FHD",
+  "orientation": "landscape",
+  "hasAudio": true,
+  "fileSize": 45678912,
+  "isValid": true,
+  "createdAt": "2026-02-13T14:30:00Z"
+}
+
+

Response (Error):

+
{
+  "statusCode": 400,
+  "error": "Bad Request",
+  "message": "Invalid file type. Allowed: mp4, mov, avi, mkv, webm, m4v, flv"
+}
+
+
+

Upload Batch (Multiple Videos)

+
POST /api/media/upload/batch
+Content-Type: multipart/form-data
+Authorization: Bearer <admin_token>
+
+

Request:

+
--boundary
+Content-Disposition: form-data; name="videos"; filename="video1.mp4"
+Content-Type: video/mp4
+
+<binary data>
+--boundary
+Content-Disposition: form-data; name="videos"; filename="video2.mp4"
+Content-Type: video/mp4
+
+<binary data>
+--boundary
+Content-Disposition: form-data; name="producer"
+
+Studio A
+--boundary--
+
+

Response:

+
{
+  "uploaded": 2,
+  "failed": 0,
+  "results": [
+    {
+      "id": "660e8400-e29b-41d4-a716-446655440000",
+      "filename": "video1.mp4",
+      "status": "success"
+    },
+    {
+      "id": "770e8400-e29b-41d4-a716-446655440001",
+      "filename": "video2.mp4",
+      "status": "success"
+    }
+  ]
+}
+
+
+

Configuration

+

Environment Variables

+
# Upload Limits
+MEDIA_MAX_FILE_SIZE=10737418240  # 10GB in bytes
+MEDIA_MAX_FILES_BATCH=10         # Max files per batch upload
+
+# Upload Paths
+MEDIA_INBOX_PATH=/media/local/inbox
+MEDIA_LIBRARY_PATH=/media/local/library
+
+# FFprobe
+FFPROBE_TIMEOUT=30000  # 30 seconds
+FFPROBE_PATH=/usr/bin/ffprobe  # Auto-detected if not set
+
+# Allowed Extensions (comma-separated)
+MEDIA_ALLOWED_EXTENSIONS=mp4,mov,avi,mkv,webm,m4v,flv
+
+

Fastify Multipart Configuration

+
// api/src/media-server.ts
+import multipart from '@fastify/multipart';
+
+app.register(multipart, {
+  limits: {
+    fieldNameSize: 100,      // Max field name size (bytes)
+    fieldSize: 1000000,      // Max field value size (bytes) - for text fields
+    fields: 10,              // Max number of non-file fields
+    fileSize: 10 * 1024 * 1024 * 1024, // 10GB max file size
+    files: 10,               // Max number of files per request
+    headerPairs: 2000,       // Max header key-value pairs
+  },
+  attachFieldsToBody: false, // Don't parse all fields into body (use req.file())
+});
+
+

Docker Volume Mounts

+

Critical: Inbox directory must be mounted as read-write (:rw):

+
# docker-compose.yml
+services:
+  media-api:
+    volumes:
+      - /media/local/library:/media/local/library:ro  # Read-only library
+      - /media/local/inbox:/media/local/inbox:rw      # READ-WRITE inbox
+
+

Without :rw suffix, uploads fail with permission errors.

+
+

Code Examples

+

Frontend: Upload Modal Component

+
// admin/src/components/media/UploadVideoModal.tsx
+import { Modal, Upload, Form, Input, Button, Progress, message } from 'antd';
+import { InboxOutlined } from '@ant-design/icons';
+import { useState } from 'react';
+import { mediaApi } from '@/lib/media-api';
+
+interface UploadVideoModalProps {
+  visible: boolean;
+  onClose: () => void;
+  onSuccess: () => void;
+}
+
+export default function UploadVideoModal({ visible, onClose, onSuccess }: UploadVideoModalProps) {
+  const [form] = Form.useForm();
+  const [fileList, setFileList] = useState<any[]>([]);
+  const [uploading, setUploading] = useState(false);
+  const [uploadProgress, setUploadProgress] = useState(0);
+
+  const handleUpload = async () => {
+    if (fileList.length === 0) {
+      message.error('Please select at least one video file');
+      return;
+    }
+
+    setUploading(true);
+
+    try {
+      const values = await form.validateFields();
+
+      for (const fileItem of fileList) {
+        const formData = new FormData();
+        formData.append('video', fileItem.originFileObj);
+        formData.append('producer', values.producer || '');
+        formData.append('creator', values.creator || '');
+        formData.append('title', values.title || fileItem.name);
+        formData.append('tags', values.tags || '');
+
+        const { data } = await mediaApi.post('/api/media/upload/single', formData, {
+          headers: {
+            'Content-Type': 'multipart/form-data',
+          },
+          onUploadProgress: (progressEvent) => {
+            const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total!);
+            setUploadProgress(percent);
+          },
+        });
+
+        message.success(`Uploaded: ${fileItem.name}`);
+      }
+
+      onSuccess();
+      handleClose();
+    } catch (error: any) {
+      message.error(error.response?.data?.message || 'Upload failed');
+    } finally {
+      setUploading(false);
+      setUploadProgress(0);
+    }
+  };
+
+  const handleClose = () => {
+    form.resetFields();
+    setFileList([]);
+    setUploadProgress(0);
+    onClose();
+  };
+
+  return (
+    <Modal
+      title="Upload Video"
+      open={visible}
+      onCancel={handleClose}
+      footer={[
+        <Button key="cancel" onClick={handleClose} disabled={uploading}>
+          Cancel
+        </Button>,
+        <Button key="upload" type="primary" onClick={handleUpload} loading={uploading}>
+          Upload
+        </Button>,
+      ]}
+      width={600}
+      destroyOnClose
+    >
+      <Upload.Dragger
+        multiple
+        fileList={fileList}
+        onChange={({ fileList }) => setFileList(fileList)}
+        beforeUpload={(file) => {
+          const isVideo = [
+            'video/mp4',
+            'video/quicktime',
+            'video/x-msvideo',
+            'video/x-matroska',
+            'video/webm',
+            'video/x-m4v',
+            'video/x-flv',
+          ].includes(file.type);
+
+          if (!isVideo) {
+            message.error(`${file.name} is not a supported video format`);
+            return Upload.LIST_IGNORE;
+          }
+
+          const isLt10GB = file.size / 1024 / 1024 / 1024 < 10;
+          if (!isLt10GB) {
+            message.error(`${file.name} exceeds 10GB limit`);
+            return Upload.LIST_IGNORE;
+          }
+
+          return false; // Prevent auto-upload
+        }}
+        disabled={uploading}
+      >
+        <p className="ant-upload-drag-icon">
+          <InboxOutlined />
+        </p>
+        <p className="ant-upload-text">Click or drag video files to this area</p>
+        <p className="ant-upload-hint">
+          Supports MP4, MOV, AVI, MKV, WebM, M4V, FLV. Max 10GB per file.
+        </p>
+      </Upload.Dragger>
+
+      {uploading && (
+        <div style={{ marginTop: 16 }}>
+          <Progress percent={uploadProgress} status="active" />
+        </div>
+      )}
+
+      <Form form={form} layout="vertical" style={{ marginTop: 24 }}>
+        <Form.Item label="Producer" name="producer">
+          <Input placeholder="Studio or production company" />
+        </Form.Item>
+
+        <Form.Item label="Creator" name="creator">
+          <Input placeholder="Director or creator name" />
+        </Form.Item>
+
+        <Form.Item label="Title" name="title">
+          <Input placeholder="Display title (defaults to filename)" />
+        </Form.Item>
+
+        <Form.Item label="Tags" name="tags">
+          <Input placeholder="Comma-separated tags (e.g., action, sports)" />
+        </Form.Item>
+      </Form>
+    </Modal>
+  );
+}
+
+
+

Backend: Single Upload Route

+
// api/src/modules/media/routes/upload.routes.ts
+import { FastifyInstance } from 'fastify';
+import path from 'path';
+import fs from 'fs/promises';
+import { randomUUID } from 'crypto';
+import { db } from '@/modules/media/db';
+import { videos } from '@/modules/media/db/schema';
+import { ffprobeService } from '@/modules/media/services/ffprobe.service';
+import { requireRole } from '@/middleware/auth';
+
+export default async function (app: FastifyInstance) {
+  app.post(
+    '/api/media/upload/single',
+    {
+      preHandler: [requireRole('SUPER_ADMIN')],
+    },
+    async (req, reply) => {
+      try {
+        // Get uploaded file
+        const data = await req.file();
+
+        if (!data) {
+          return reply.code(400).send({ error: 'No file uploaded' });
+        }
+
+        // Validate file extension
+        const ext = path.extname(data.filename).toLowerCase();
+        const allowedExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv'];
+
+        if (!allowedExtensions.includes(ext)) {
+          return reply.code(400).send({
+            error: 'Invalid file type',
+            message: `Allowed extensions: ${allowedExtensions.join(', ')}`,
+          });
+        }
+
+        // Generate UUID filename
+        const uuid = randomUUID();
+        const filename = `${uuid}${ext}`;
+        const relativePath = `inbox/${filename}`;
+        const absolutePath = path.join(process.env.MEDIA_INBOX_PATH!, filename);
+
+        // Stream to disk
+        const writeStream = fs.createWriteStream(absolutePath);
+        await data.file.pipe(writeStream);
+
+        app.log.info(`File uploaded to ${absolutePath}`);
+
+        // Extract metadata
+        let metadata;
+        try {
+          metadata = await ffprobeService.extract(absolutePath);
+          app.log.info('FFprobe metadata extracted', metadata);
+        } catch (error: any) {
+          app.log.warn('FFprobe extraction failed', error);
+          // Continue without metadata (can be validated later)
+          metadata = {
+            duration: null,
+            width: null,
+            height: null,
+            orientation: null,
+            quality: null,
+            hasAudio: false,
+          };
+        }
+
+        // Get file size
+        const stats = await fs.stat(absolutePath);
+
+        // Parse metadata from request body
+        const body = data.fields as any;
+        const producer = body.producer?.value || null;
+        const creator = body.creator?.value || null;
+        const title = body.title?.value || data.filename;
+        const tagsString = body.tags?.value || '';
+        const tags = tagsString
+          ? tagsString.split(',').map((t: string) => t.trim())
+          : [];
+
+        // Create database record
+        const [video] = await db
+          .insert(videos)
+          .values({
+            path: relativePath,
+            filename,
+            originalFilename: data.filename,
+            directoryType: 'inbox',
+            producer,
+            creator,
+            title,
+            tags,
+            durationSeconds: metadata.duration,
+            width: metadata.width,
+            height: metadata.height,
+            orientation: metadata.orientation,
+            quality: metadata.quality,
+            hasAudio: metadata.hasAudio,
+            fileSize: stats.size,
+            isValid: true,
+          })
+          .returning();
+
+        reply.send(video);
+      } catch (error: any) {
+        app.log.error('Upload failed', error);
+        reply.code(500).send({
+          error: 'Upload failed',
+          message: error.message,
+        });
+      }
+    }
+  );
+}
+
+
+

FFprobe Metadata Extraction

+
// api/src/modules/media/services/ffprobe.service.ts
+import { exec } from 'child_process';
+import { promisify } from 'util';
+
+const execAsync = promisify(exec);
+
+interface VideoMetadata {
+  duration: number | null;
+  width: number | null;
+  height: number | null;
+  orientation: string | null;
+  quality: string | null;
+  hasAudio: boolean;
+  fileSize: number | null;
+  fileHash: string | null;
+}
+
+export class FFprobeService {
+  private timeout = parseInt(process.env.FFPROBE_TIMEOUT || '30000', 10);
+  private ffprobePath = process.env.FFPROBE_PATH || 'ffprobe';
+
+  async extract(filePath: string): Promise<VideoMetadata> {
+    try {
+      const command = `${this.ffprobePath} -v quiet -print_format json -show_streams -show_format "${filePath}"`;
+
+      const { stdout } = await execAsync(command, {
+        timeout: this.timeout,
+        maxBuffer: 1024 * 1024 * 10, // 10MB buffer
+      });
+
+      const data = JSON.parse(stdout);
+
+      // Find video stream
+      const videoStream = data.streams.find((s: any) => s.codec_type === 'video');
+      if (!videoStream) {
+        throw new Error('No video stream found');
+      }
+
+      // Find audio stream
+      const audioStream = data.streams.find((s: any) => s.codec_type === 'audio');
+
+      // Extract metadata
+      const width = parseInt(videoStream.width, 10);
+      const height = parseInt(videoStream.height, 10);
+      const duration = parseFloat(data.format.duration);
+      const fileSize = parseInt(data.format.size, 10);
+
+      // Detect orientation
+      const orientation = this.detectOrientation(width, height);
+
+      // Detect quality
+      const quality = this.detectQuality(height);
+
+      return {
+        duration: isNaN(duration) ? null : Math.round(duration),
+        width: isNaN(width) ? null : width,
+        height: isNaN(height) ? null : height,
+        orientation,
+        quality,
+        hasAudio: !!audioStream,
+        fileSize: isNaN(fileSize) ? null : fileSize,
+        fileHash: null, // Can be computed separately if needed
+      };
+    } catch (error: any) {
+      throw new Error(`FFprobe extraction failed: ${error.message}`);
+    }
+  }
+
+  private detectOrientation(width: number, height: number): string {
+    if (isNaN(width) || isNaN(height)) return 'unknown';
+
+    const ratio = width / height;
+    if (ratio > 1.1) return 'landscape';
+    if (ratio < 0.9) return 'portrait';
+    return 'square';
+  }
+
+  private detectQuality(height: number): string {
+    if (isNaN(height)) return 'unknown';
+
+    if (height < 720) return 'SD';
+    if (height < 1080) return 'HD';
+    if (height < 2160) return 'FHD';
+    return 'UHD';
+  }
+}
+
+export const ffprobeService = new FFprobeService();
+
+
+

Troubleshooting

+

Problem: Upload Fails with "File Too Large"

+

Symptoms:

+
    +
  • Upload progress reaches 100% then fails
  • +
  • Error message: "File exceeds maximum size"
  • +
  • Browser console shows 413 Payload Too Large
  • +
+

Solutions:

+
    +
  1. Check file size:
  2. +
+
# On macOS/Linux
+ls -lh video.mp4
+# Should show size < 10GB
+
+# If larger, compress video first:
+ffmpeg -i large-video.mp4 -vcodec h264 -acodec aac compressed.mp4
+
+
    +
  1. Verify Fastify limit:
  2. +
+
// api/src/media-server.ts
+app.register(multipart, {
+  limits: {
+    fileSize: 10 * 1024 * 1024 * 1024, // 10GB
+  },
+});
+
+
    +
  1. Check nginx client_max_body_size:
  2. +
+
# nginx/nginx.conf or nginx/conf.d/api.conf
+client_max_body_size 10G;
+
+
    +
  1. Increase timeout for large files:
  2. +
+
# nginx/conf.d/api.conf
+server {
+    location / {
+        proxy_pass http://localhost:4100;
+        proxy_read_timeout 600s;  # 10 minutes
+        proxy_send_timeout 600s;
+    }
+}
+
+
+

Problem: FFprobe Metadata Extraction Fails

+

Symptoms:

+
    +
  • Upload succeeds but metadata fields null
  • +
  • Warning: "Metadata extraction failed"
  • +
  • Duration, dimensions missing in library
  • +
+

Solutions:

+
    +
  1. Check FFmpeg installed:
  2. +
+
docker compose exec media-api which ffprobe
+# Should output: /usr/bin/ffprobe
+
+docker compose exec media-api ffprobe -version
+# Should show FFmpeg version
+
+
    +
  1. Install FFmpeg if missing:
  2. +
+
# api/Dockerfile.media
+FROM node:20-alpine
+
+# Install FFmpeg
+RUN apk add --no-cache ffmpeg
+
+# ... rest of Dockerfile
+
+
# Rebuild container
+docker compose build media-api
+docker compose up -d media-api
+
+
    +
  1. Test FFprobe manually:
  2. +
+
# Run FFprobe on uploaded file
+docker compose exec media-api ffprobe \
+  -v quiet \
+  -print_format json \
+  -show_streams \
+  -show_format \
+  /media/local/inbox/test.mp4
+
+# Should output JSON with streams and format info
+
+
    +
  1. Check video file not corrupt:
  2. +
+
# Try playing video
+docker compose exec media-api ffplay /media/local/inbox/test.mp4
+
+# Or copy to host and test
+docker cp $(docker compose ps -q media-api):/media/local/inbox/test.mp4 ./
+vlc test.mp4
+
+
    +
  1. Increase timeout for large files:
  2. +
+
# .env
+FFPROBE_TIMEOUT=60000  # 60 seconds (from 30)
+
+
+

Problem: Upload Hangs at 100%

+

Symptoms:

+
    +
  • Progress bar reaches 100% but never completes
  • +
  • No success or error message
  • +
  • Browser tab freezes
  • +
+

Solutions:

+
    +
  1. Check nginx proxy timeout:
  2. +
+
# nginx/conf.d/api.conf
+server {
+    location / {
+        proxy_pass http://localhost:4100;
+        proxy_read_timeout 600s;  # 10 minutes for large uploads
+    }
+}
+
+
    +
  1. Verify disk space available:
  2. +
+
df -h /media/local/inbox
+# Should show available space > file size
+
+# Clear space if needed
+docker compose exec media-api rm /media/local/inbox/*.mp4
+
+
    +
  1. Check backend logs:
  2. +
+
docker compose logs -f media-api | grep upload
+# Look for errors or timeouts
+
+
    +
  1. Test with smaller file:
  2. +
+
# Create 100MB test video
+ffmpeg -f lavfi -i testsrc=duration=10:size=1920x1080:rate=30 -pix_fmt yuv420p test-100mb.mp4
+
+# Upload test file
+# If succeeds, issue likely large file timeout
+
+
+

Problem: Inbox Directory Not Writable

+

Symptoms:

+
    +
  • Upload fails with "Permission denied"
  • +
  • Error: "EACCES: permission denied, open '/media/local/inbox/...'"
  • +
  • Upload never starts
  • +
+

Solutions:

+
    +
  1. Check Docker volume mount:
  2. +
+
# docker-compose.yml
+services:
+  media-api:
+    volumes:
+      - /media/local/inbox:/media/local/inbox:rw  # MUST have :rw suffix
+
+
    +
  1. Verify mount in running container:
  2. +
+
docker compose exec media-api mount | grep inbox
+# Should show /media/local/inbox mounted as rw (read-write)
+
+
    +
  1. Check directory permissions:
  2. +
+
# On host machine
+ls -la /media/local/inbox
+# Should show drwxrwxrwx or drwxr-xr-x
+
+# Fix permissions if needed
+sudo chmod 777 /media/local/inbox
+
+# Or set ownership to container user (usually node:node)
+sudo chown -R 1000:1000 /media/local/inbox
+
+
    +
  1. Create directory if missing:
  2. +
+
# On host
+sudo mkdir -p /media/local/inbox
+sudo chmod 777 /media/local/inbox
+
+# Restart container
+docker compose restart media-api
+
+
    +
  1. Test write access:
  2. +
+
# Try writing test file from container
+docker compose exec media-api sh -c 'echo "test" > /media/local/inbox/test.txt'
+
+# If fails, permissions issue
+# If succeeds, issue elsewhere
+
+
+

Problem: Invalid File Type Error

+

Symptoms:

+
    +
  • Upload rejected immediately
  • +
  • Error: "File type not supported"
  • +
  • File is valid MP4/MOV/etc
  • +
+

Solutions:

+
    +
  1. Check MIME type:
  2. +
+
// Browser console
+const file = document.querySelector('input[type=file]').files[0];
+console.log(file.type);
+// Should be video/mp4, video/quicktime, etc.
+
+
    +
  1. Verify file extension:
  2. +
+
# Rename file to ensure correct extension
+mv video.MP4 video.mp4  # Case-sensitive on Linux
+
+
    +
  1. Add MIME type to allowed list:
  2. +
+
// admin/src/components/media/UploadVideoModal.tsx
+const isVideo = [
+  'video/mp4',
+  'video/quicktime',
+  'video/x-msvideo',
+  'video/x-matroska',
+  'video/webm',
+  'video/x-m4v',
+  'video/x-flv',
+  'video/mpeg',  // Add MPEG
+  'video/ogg',   // Add OGG
+].includes(file.type);
+
+
    +
  1. Bypass frontend validation (testing only):
  2. +
+
// Temporarily comment out beforeUpload validation
+beforeUpload={() => false}
+
+
    +
  1. Check backend extension validation:
  2. +
+
// api/src/modules/media/routes/upload.routes.ts
+const allowedExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv'];
+// Add more if needed
+
+
+

Problem: Batch Upload Only Uploads First File

+

Symptoms:

+
    +
  • Multiple files selected
  • +
  • Only first file uploads
  • +
  • Others disappear from queue
  • +
+

Solutions:

+
    +
  1. Check sequential upload logic:
  2. +
+
// admin/src/components/media/UploadVideoModal.tsx
+// Should use for loop, not forEach with async
+for (const fileItem of fileList) {
+  await mediaApi.post(...);  // Await each upload
+}
+
+
    +
  1. Verify batch endpoint:
  2. +
+
# Use /api/media/upload/batch for multiple files
+# Not multiple calls to /api/media/upload/single
+
+
    +
  1. Check Fastify file limit:
  2. +
+
// api/src/media-server.ts
+app.register(multipart, {
+  limits: {
+    files: 10,  // Max 10 files per request
+  },
+});
+
+
    +
  1. Frontend: prevent early unmount:
  2. +
+
// Don't close modal while uploading
+<Modal
+  closable={!uploading}
+  maskClosable={!uploading}
+  ...
+/>
+
+
+

Performance Considerations

+

Upload Speed

+

Factors:

+
    +
  • Network bandwidth — 100 Mbps = ~12 MB/s theoretical max
  • +
  • Disk write speed — SSD: 500+ MB/s, HDD: 100-150 MB/s
  • +
  • Nginx buffering — Can slow large uploads if enabled
  • +
  • Docker overlay network — ~10% overhead vs host networking
  • +
+

Typical Speeds:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
File SizeUpload Time (100 Mbps)Upload Time (1 Gbps)
100 MB~10 seconds~1 second
1 GB~1.5 minutes~10 seconds
5 GB~7 minutes~50 seconds
10 GB~14 minutes~1.5 minutes
+

Optimization:

+
    +
  1. Disable nginx buffering:
  2. +
+
# nginx/conf.d/api.conf
+location /api/media/upload {
+    proxy_pass http://localhost:4100;
+    proxy_request_buffering off;  # Stream directly to backend
+    client_max_body_size 10G;
+}
+
+
    +
  1. Use faster disk:
  2. +
+

Mount /media/local/inbox on SSD instead of HDD.

+
    +
  1. Increase network MTU:
  2. +
+
# Increase Docker network MTU
+docker network create --opt com.docker.network.driver.mtu=9000 changemaker-lite
+
+
+

FFprobe Extraction Time

+

Benchmarks:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Video SizeResolutionExtraction Time
50 MB720p~50-100ms
200 MB1080p~100-200ms
1 GB1080p~200-400ms
5 GB4K~500ms-1s
+

Optimization:

+

FFprobe only reads video metadata (not entire file), so extraction time scales sub-linearly with file size.

+

For very large files (10GB+), consider deferring extraction to job queue:

+
// Upload endpoint returns immediately
+const video = await db.insert(videos).values({ ... }).returning();
+
+// Queue FFprobe job
+await jobQueue.add('extract-metadata', { videoId: video.id });
+
+reply.send({ id: video.id, status: 'pending-metadata' });
+
+
+

Streaming vs Buffering

+

Memory Usage Comparison:

+ + + + + + + + + + + + + + + + + +
Upload MethodMemory Usage (10GB file)
Streaming (current)~10 MB
Buffering (alternative)~10 GB
+

Why Streaming:

+
    +
  • Constant memory — Uses fixed ~10 MB buffer regardless of file size
  • +
  • Server stability — 10 concurrent uploads = ~100 MB RAM vs 100 GB if buffered
  • +
  • No 32-bit limit — Buffering fails on Node.js for files > 2GB on 32-bit systems
  • +
+

Tradeoff:

+

Streaming writes directly to disk, so failed uploads leave partial files in /inbox. Cleanup script required:

+
# Cron job to clean incomplete uploads (files with 0 size)
+find /media/local/inbox -type f -size 0 -mtime +1 -delete
+
+
+

Security Considerations

+

Admin-Only Access

+

All upload endpoints require SUPER_ADMIN role:

+
// api/src/modules/media/routes/upload.routes.ts
+app.post('/api/media/upload/single', {
+  preHandler: [requireRole('SUPER_ADMIN')],
+}, async (req, reply) => {
+  // ...
+});
+
+

Regular users, volunteers, and public cannot upload videos.

+
+

File Extension Validation

+

Backend enforces strict whitelist:

+
const allowedExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv'];
+
+if (!allowedExtensions.includes(ext)) {
+  return reply.code(400).send({ error: 'Invalid file type' });
+}
+
+

No executable extensions allowed:

+
    +
  • .exe
  • +
  • .sh
  • +
  • .bat
  • +
  • .php
  • +
  • .js (only video extensions)
  • +
+
+

Path Traversal Prevention

+

UUID filenames prevent directory traversal:

+
// User-supplied filename: ../../etc/passwd.mp4
+// Actual filename: 660e8400-e29b-41d4-a716-446655440000.mp4
+
+const uuid = randomUUID();
+const filename = `${uuid}${ext}`;  // No user input in filename
+
+

Original filename preserved in database:

+
originalFilename: data.filename,  // Stored for reference, not used for filepath
+
+
+

Virus Scanning (Future)

+

Recommended Integration:

+
// api/src/modules/media/services/virus-scan.service.ts
+import { exec } from 'child_process';
+
+class VirusScanService {
+  async scan(filePath: string): Promise<{ clean: boolean; threat?: string }> {
+    // Use ClamAV
+    const { stdout } = await execAsync(`clamscan --no-summary ${filePath}`);
+
+    if (stdout.includes('FOUND')) {
+      return { clean: false, threat: stdout };
+    }
+
+    return { clean: true };
+  }
+}
+
+// In upload route:
+const scanResult = await virusScanService.scan(absolutePath);
+if (!scanResult.clean) {
+  await fs.unlink(absolutePath);  // Delete infected file
+  return reply.code(400).send({ error: 'File contains malware' });
+}
+
+
+

Rate Limiting

+

Upload endpoint has stricter rate limits:

+
// api/src/modules/media/routes/upload.routes.ts
+import rateLimit from '@fastify/rate-limit';
+
+app.register(rateLimit, {
+  max: 10,        // 10 uploads
+  timeWindow: '1 hour',
+});
+
+

Prevents abuse (uploading hundreds of large files).

+
+ +

Backend Documentation

+
    +
  • Upload Routes: backend/modules/media/upload.md — Upload endpoint implementation
  • +
  • FFprobe Service: backend/modules/media/ffprobe.md — Metadata extraction service
  • +
  • Fastify Multipart: backend/api/media-server.md — Multipart plugin configuration
  • +
+

Frontend Documentation

+
    +
  • Upload Modal: frontend/components/media/upload-modal.md — Upload UI component
  • +
  • Library Page: frontend/pages/media/library.md — Integration with library table
  • +
+

Feature Documentation

+
    +
  • Video Library: features/media/video-library.md — Video management system overview
  • +
  • Media Jobs: features/media/jobs.md — Background processing for uploads
  • +
+

Deployment Documentation

+
    +
  • Docker Volumes: deployment/docker.md — Volume mount configuration for inbox
  • +
  • Nginx: deployment/nginx.md — Reverse proxy upload timeout settings
  • +
+
+

Next Steps

+

After mastering video upload:

+
    +
  1. Move Videos — Learn how to move uploaded videos from /inbox to target directories
  2. +
  3. Thumbnail Generation — Create thumbnails for video previews
  4. +
  5. Encoding Jobs — Queue re-encoding jobs for web-optimized playback
  6. +
  7. Public Sharing — Share videos in public gallery (see public-gallery.md)
  8. +
+

Hands-On Practice:

+
# 1. Create test video (FFmpeg)
+ffmpeg -f lavfi -i testsrc=duration=30:size=1920x1080:rate=30 -pix_fmt yuv420p test-video.mp4
+
+# 2. Upload via curl
+curl -X POST http://localhost:4100/api/media/upload/single \
+  -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
+  -F "video=@test-video.mp4" \
+  -F "producer=Test Studio" \
+  -F "title=Test Video"
+
+# 3. Verify in database
+docker compose exec v2-postgres psql -U changemaker -d v2_changemaker \
+  -c "SELECT id, filename, duration_seconds, quality FROM videos ORDER BY created_at DESC LIMIT 1;"
+
+# 4. Check file on disk
+docker compose exec media-api ls -lh /media/local/inbox/
+
+
+

Last Updated: 2026-02-13 +Version: V2.0 +Maintainer: Changemaker Lite Team

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/media/video-library/index.html b/mkdocs/site/v2/features/media/video-library/index.html new file mode 100644 index 00000000..13a41390 --- /dev/null +++ b/mkdocs/site/v2/features/media/video-library/index.html @@ -0,0 +1,7685 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Video Library - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Video Library Management

+

Overview

+

The Video Library system provides comprehensive video asset management through a dedicated Fastify microservice running on port 4100, separate from the main Express API. This dual API architecture allows the media system to operate independently while sharing the same PostgreSQL database.

+

Key Features:

+
    +
  • Dual API Architecture — Fastify media API (port 4100) separate from Express API (port 4000)
  • +
  • Drizzle ORM — Media tables use Drizzle ORM instead of Prisma for schema flexibility
  • +
  • 9 Directory Types — Organized library structure (studios, gifs, private, inbox, curated, playback, compilations, videos, highlights)
  • +
  • FFprobe Integration — Automatic metadata extraction (duration, dimensions, orientation, quality, audio detection)
  • +
  • Video CRUD — Full create, read, update, delete operations (admin-only)
  • +
  • Directory Scanning — Bulk import videos from filesystem with automatic record creation
  • +
  • Validation System — Re-validate videos to refresh metadata and check file integrity
  • +
  • File Hashing — Duplicate detection via SHA-256 file hashing
  • +
  • Soft Delete — Videos marked invalid instead of hard deletion (preserves history)
  • +
  • Thumbnail Support — Custom thumbnail paths for video previews
  • +
+

Access Control:

+
    +
  • All video library operations require SUPER_ADMIN role
  • +
  • Public video viewing handled separately via Shared Media system (see public-gallery.md)
  • +
+

Technology Stack:

+
    +
  • Fastify 4.x — High-performance Node.js web framework
  • +
  • Drizzle ORM — TypeScript-first ORM with zero-runtime overhead
  • +
  • FFprobe — FFmpeg's media file analyzer for metadata extraction
  • +
  • PostgreSQL 16 — Shared database with main API
  • +
+
+

Architecture

+

The Media API operates as an independent microservice while maintaining data consistency through shared database access:

+
flowchart TB
+    subgraph "Client Layer"
+        Admin[Admin GUI :3000]
+        Public[Public Users]
+    end
+
+    subgraph "API Layer"
+        Express[Express API :4000<br/>Prisma ORM]
+        Fastify[Fastify Media API :4100<br/>Drizzle ORM]
+    end
+
+    subgraph "Data Layer"
+        DB[(PostgreSQL 16<br/>v2_changemaker)]
+        FS[/media/local/library/<br/>Video Files]
+    end
+
+    subgraph "Processing"
+        FFprobe[FFprobe Service<br/>Metadata Extraction]
+    end
+
+    Admin -->|Media Requests| Fastify
+    Admin -->|Other Requests| Express
+    Public -->|View Videos| Fastify
+
+    Fastify -->|Drizzle Queries| DB
+    Express -->|Prisma Queries| DB
+
+    Fastify -->|Read/Write| FS
+    Fastify -->|Extract Metadata| FFprobe
+    FFprobe -->|Analyze| FS
+
+    style Fastify fill:#e74c3c
+    style Express fill:#3498db
+    style DB fill:#2ecc71
+    style FS fill:#f39c12
+

Architecture Highlights:

+
    +
  1. Port Separation — Media API on 4100, Main API on 4000
  2. +
  3. ORM Independence — Drizzle for media, Prisma for everything else
  4. +
  5. Shared Database — Both APIs access same PostgreSQL instance
  6. +
  7. File System Access — Media API has direct volume mount to /media/local/library
  8. +
  9. Nginx Routingmedia.cmlite.org routes to port 4100
  10. +
+

Why Dual API?

+

The media system was added after V2 launch as a self-contained enhancement. Keeping it as a separate Fastify microservice:

+
    +
  • Avoids disrupting the stable Express API
  • +
  • Allows independent scaling and deployment
  • +
  • Provides testing ground for Drizzle ORM migration
  • +
  • Isolates video processing workloads from core application logic
  • +
+
+

Database Model (Drizzle)

+

Videos Table Schema

+
// api/src/modules/media/db/schema.ts
+import { pgTable, uuid, text, integer, timestamp, boolean, jsonb } from 'drizzle-orm/pg-core';
+
+export const videos = pgTable('videos', {
+  id: uuid('id').primaryKey().defaultRandom(),
+
+  // File Information
+  path: text('path').notNull().unique(), // Relative path from library root
+  filename: text('filename').notNull(),
+  originalFilename: text('original_filename'), // User-uploaded filename
+  directoryType: text('directory_type').notNull(), // studios|gifs|private|inbox|curated|playback|compilations|videos|highlights
+
+  // Metadata
+  producer: text('producer'),
+  creator: text('creator'),
+  title: text('title'),
+  tags: jsonb('tags').$type<string[]>().default([]),
+
+  // Video Properties
+  durationSeconds: integer('duration_seconds'),
+  quality: text('quality'), // SD|HD|FHD|UHD
+  orientation: text('orientation'), // portrait|landscape|square
+  hasAudio: boolean('has_audio').default(false),
+  width: integer('width'),
+  height: integer('height'),
+
+  // File Details
+  fileSize: integer('file_size'), // Bytes
+  fileHash: text('file_hash'), // SHA-256 for duplicate detection
+
+  // Validation
+  isValid: boolean('is_valid').default(true),
+  lastValidated: timestamp('last_validated'),
+  standardizedAt: timestamp('standardized_at'), // When file was moved to standard location
+
+  // Thumbnail
+  thumbnailPath: text('thumbnail_path'),
+
+  // Public Sharing
+  publicViewCount: integer('public_view_count').default(0),
+  publicUpvoteCount: integer('public_upvote_count').default(0),
+  movedFromPublicAt: timestamp('moved_from_public_at'), // When video was unlocked from public
+
+  // Timestamps
+  createdAt: timestamp('created_at').defaultNow(),
+  updatedAt: timestamp('updated_at').defaultNow(),
+});
+
+

Directory Types Enum

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Directory TypePurposePublic Eligible
studiosStudio-organized content
gifsShort looping videos
privatePrivate/unreleased content
inboxUpload staging area
curatedHand-picked highlights
playbackPlayback-optimized encodes
compilationsMulti-video compilations
videosGeneral video library
highlightsAuto-generated highlights
+

Quality Classifications

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
QualityHeight RangeTypical Resolution
SD< 720px480p, 576p
HD720px - 1079px720p
FHD1080px - 2159px1080p
UHD≥ 2160px4K, 8K
+

Orientation Detection

+
const detectOrientation = (width: number, height: number): string => {
+  const ratio = width / height;
+  if (ratio > 1.1) return 'landscape';
+  if (ratio < 0.9) return 'portrait';
+  return 'square';
+};
+
+
+

API Endpoints

+

All endpoints require authentication with SUPER_ADMIN role unless marked as public.

+

List Videos

+
GET /api/media/videos
+
+

Query Parameters:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeDefaultDescription
pagenumber1Page number for pagination
limitnumber20Results per page (max 100)
directoryTypestring-Filter by directory (studios, gifs, etc.)
orientationstring-Filter by orientation (portrait, landscape, square)
producerstring-Filter by producer (partial match)
creatorstring-Filter by creator (partial match)
qualitystring-Filter by quality (SD, HD, FHD, UHD)
hasAudioboolean-Filter by audio presence
isValidbooleantrueFilter by validation status
searchstring-Search in title, producer, creator
+

Response:

+
{
+  "data": [
+    {
+      "id": "550e8400-e29b-41d4-a716-446655440000",
+      "path": "videos/sample.mp4",
+      "filename": "sample.mp4",
+      "directoryType": "videos",
+      "producer": "Studio A",
+      "creator": "Director B",
+      "title": "Sample Video",
+      "durationSeconds": 180,
+      "quality": "FHD",
+      "orientation": "landscape",
+      "hasAudio": true,
+      "width": 1920,
+      "height": 1080,
+      "fileSize": 52428800,
+      "isValid": true,
+      "createdAt": "2026-02-10T12:00:00Z"
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 20,
+    "total": 156,
+    "totalPages": 8
+  }
+}
+
+
+

Get Video Details

+
GET /api/media/videos/:id
+
+

Response:

+
{
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "path": "videos/sample.mp4",
+  "filename": "sample.mp4",
+  "originalFilename": "my-video.mp4",
+  "directoryType": "videos",
+  "producer": "Studio A",
+  "creator": "Director B",
+  "title": "Sample Video",
+  "tags": ["action", "sports", "highlight"],
+  "durationSeconds": 180,
+  "quality": "FHD",
+  "orientation": "landscape",
+  "hasAudio": true,
+  "width": 1920,
+  "height": 1080,
+  "fileSize": 52428800,
+  "fileHash": "a3d2f1e8b9c7...",
+  "isValid": true,
+  "lastValidated": "2026-02-10T12:00:00Z",
+  "thumbnailPath": "thumbnails/550e8400.jpg",
+  "publicViewCount": 1250,
+  "publicUpvoteCount": 85,
+  "createdAt": "2026-02-10T12:00:00Z",
+  "updatedAt": "2026-02-10T12:00:00Z"
+}
+
+
+

Create Video Record

+
POST /api/media/videos
+
+

Request Body:

+
{
+  "path": "videos/new-video.mp4",
+  "filename": "new-video.mp4",
+  "directoryType": "videos",
+  "producer": "Studio A",
+  "creator": "Director B",
+  "title": "New Video",
+  "tags": ["action", "sports"]
+}
+
+

Notes:

+
    +
  • File must already exist at specified path on filesystem
  • +
  • FFprobe metadata extraction runs automatically after creation
  • +
  • Use /api/media/upload/single for file upload + record creation
  • +
+

Response:

+
{
+  "id": "660e8400-e29b-41d4-a716-446655440000",
+  "path": "videos/new-video.mp4",
+  "filename": "new-video.mp4",
+  "directoryType": "videos",
+  "isValid": true,
+  "createdAt": "2026-02-13T10:30:00Z"
+}
+
+
+

Update Video Metadata

+
PUT /api/media/videos/:id
+
+

Request Body:

+
{
+  "producer": "Updated Studio",
+  "creator": "New Director",
+  "title": "Updated Title",
+  "tags": ["updated", "tags"]
+}
+
+

Updatable Fields:

+
    +
  • producer — Video producer/studio
  • +
  • creator — Director/creator name
  • +
  • title — Display title
  • +
  • tags — Array of tag strings
  • +
  • thumbnailPath — Custom thumbnail path
  • +
+

Response:

+
{
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "producer": "Updated Studio",
+  "creator": "New Director",
+  "title": "Updated Title",
+  "tags": ["updated", "tags"],
+  "updatedAt": "2026-02-13T10:35:00Z"
+}
+
+
+

Delete Video

+
DELETE /api/media/videos/:id
+
+

Behavior:

+
    +
  • Soft Delete — Sets isValid = false instead of removing record
  • +
  • File remains on filesystem (manual cleanup required)
  • +
  • Video no longer appears in default listings
  • +
  • Can be restored by setting isValid = true via database
  • +
+

Response:

+
{
+  "success": true,
+  "message": "Video marked as invalid"
+}
+
+
+

Scan Directory

+
POST /api/media/videos/scan
+
+

Request Body:

+
{
+  "directoryType": "videos",
+  "skipExisting": true
+}
+
+

Parameters:

+ + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
directoryTypestringDirectory to scan (videos, studios, etc.)
skipExistingboolean-Skip files already in database (default: true)
+

Process:

+
    +
  1. Reads filesystem directory /media/local/library/{directoryType}/
  2. +
  3. Filters for video extensions (.mp4, .mov, .avi, .mkv, .webm, .m4v, .flv)
  4. +
  5. Checks each file against database (by path)
  6. +
  7. Creates records for new files
  8. +
  9. Runs FFprobe metadata extraction on new records
  10. +
+

Response:

+
{
+  "scanned": 45,
+  "created": 12,
+  "skipped": 33,
+  "failed": 0,
+  "errors": []
+}
+
+
+

Validate Video

+
POST /api/media/videos/:id/validate
+
+

Purpose:

+
    +
  • Re-run FFprobe metadata extraction
  • +
  • Update video properties (duration, dimensions, etc.)
  • +
  • Verify file still exists and is readable
  • +
  • Refresh file size and hash
  • +
+

Response:

+
{
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "isValid": true,
+  "lastValidated": "2026-02-13T10:40:00Z",
+  "metadata": {
+    "durationSeconds": 180,
+    "width": 1920,
+    "height": 1080,
+    "quality": "FHD",
+    "orientation": "landscape",
+    "hasAudio": true
+  }
+}
+
+
+

Configuration

+

Environment Variables

+
# Media API Server
+MEDIA_API_PORT=4100
+MEDIA_API_HOST=0.0.0.0
+
+# File Paths
+MEDIA_LIBRARY_PATH=/media/local/library
+MEDIA_INBOX_PATH=/media/local/inbox
+
+# Feature Flags
+ENABLE_MEDIA_FEATURES=true
+
+# Database (shared with main API)
+DATABASE_URL=postgresql://user:pass@v2-postgres:5432/v2_changemaker
+
+# FFprobe
+FFPROBE_TIMEOUT=30000  # milliseconds
+FFPROBE_PATH=/usr/bin/ffprobe  # Auto-detected if not set
+
+

Docker Volume Mounts

+
# docker-compose.yml
+services:
+  media-api:
+    volumes:
+      - /media/local/library:/media/local/library:ro  # Read-only library
+      - /media/local/inbox:/media/local/inbox:rw      # Read-write inbox
+
+

Important: Inbox requires :rw (read-write) for uploads. Library can be :ro (read-only) for security.

+

Site Settings

+

The media system respects the global ENABLE_MEDIA_FEATURES flag in Site Settings:

+
SELECT * FROM settings WHERE key = 'ENABLE_MEDIA_FEATURES';
+
+

When disabled:

+
    +
  • Media API still runs but returns 503 Service Unavailable
  • +
  • Admin GUI hides Media menu items
  • +
  • Public gallery shows maintenance message
  • +
+
+

Admin Workflow

+

Viewing the Video Library

+
    +
  1. Navigate to Media → Library in admin sidebar
  2. +
  3. Table displays all videos with:
  4. +
  5. Thumbnail preview
  6. +
  7. Title, producer, creator
  8. +
  9. Duration, quality, orientation
  10. +
  11. Directory type
  12. +
  13. File size
  14. +
  15. Created date
  16. +
  17. Use filters at top:
  18. +
  19. Directory Type dropdown
  20. +
  21. Orientation radio buttons (All / Portrait / Landscape / Square)
  22. +
  23. Quality checkboxes (SD, HD, FHD, UHD)
  24. +
  25. Search input (searches title, producer, creator)
  26. +
+

Scanning a Directory

+

When to Use:

+
    +
  • After manually copying videos to library directory
  • +
  • After video processing jobs complete
  • +
  • When videos exist on filesystem but not in database
  • +
+

Steps:

+
    +
  1. Click "Scan Directory" button in Library page toolbar
  2. +
  3. Select directory type from dropdown
  4. +
  5. Toggle "Skip Existing" (recommended for large libraries)
  6. +
  7. Click "Start Scan"
  8. +
  9. Progress modal shows:
  10. +
  11. Files scanned
  12. +
  13. New records created
  14. +
  15. Skipped (already in DB)
  16. +
  17. Failed (with error messages)
  18. +
  19. Click "Close" when complete
  20. +
  21. Table refreshes with new videos
  22. +
+

Example Output:

+
Scanning /media/local/library/videos...
+Found 45 video files
+- Created 12 new records
+- Skipped 33 existing records
+- Failed 0 files
+Scan complete in 8.3 seconds
+
+

Editing Video Metadata

+
    +
  1. Click pencil icon in video row
  2. +
  3. Edit modal opens with fields:
  4. +
  5. Producer — Studio or production company
  6. +
  7. Creator — Director or primary creator
  8. +
  9. Title — Display title
  10. +
  11. Tags — Comma-separated tags (auto-suggests existing tags)
  12. +
  13. Click "Save" to update
  14. +
  15. Metadata changes immediately visible in table
  16. +
+

Bulk Editing:

+
    +
  1. Select multiple videos using checkboxes
  2. +
  3. Click "Bulk Edit" button
  4. +
  5. Set common fields (producer, tags, etc.)
  6. +
  7. Click "Apply to Selected"
  8. +
+

Validating Videos

+

Purpose: Refresh metadata and verify file integrity

+

Steps:

+
    +
  1. Click "Validate" button in video row (or Actions dropdown)
  2. +
  3. FFprobe re-analyzes video file
  4. +
  5. Database updates with fresh metadata:
  6. +
  7. Duration (may have changed if file was re-encoded)
  8. +
  9. Dimensions
  10. +
  11. Audio detection
  12. +
  13. File size and hash
  14. +
  15. lastValidated timestamp updates
  16. +
  17. If file missing or corrupt, isValid set to false
  18. +
+

Bulk Validation:

+
    +
  1. Select multiple videos
  2. +
  3. Click "Validate Selected"
  4. +
  5. Progress modal shows validation results
  6. +
  7. Failed validations highlighted in red
  8. +
+

Deleting Videos

+

Soft Delete (Default):

+
    +
  1. Click trash icon in video row
  2. +
  3. Confirm deletion dialog
  4. +
  5. Video marked isValid = false
  6. +
  7. Video disappears from default view
  8. +
  9. File remains on filesystem
  10. +
  11. Record preserved in database
  12. +
+

Viewing Deleted Videos:

+
    +
  1. Toggle "Show Invalid" filter
  2. +
  3. Deleted videos appear with strikethrough
  4. +
  5. Can restore by clicking "Restore" button
  6. +
+

Hard Delete (Database Only):

+
    +
  1. Filter for invalid videos
  2. +
  3. Select video(s)
  4. +
  5. Click "Permanently Delete"
  6. +
  7. Removes database record
  8. +
  9. File still on filesystem (manual cleanup required)
  10. +
+

File System Cleanup:

+

Deleted video files must be manually removed from filesystem:

+
# SSH into media-api container
+docker compose exec media-api sh
+
+# Navigate to library
+cd /media/local/library/videos
+
+# Remove specific file
+rm deleted-video.mp4
+
+# Or find and remove all invalid videos (BE CAREFUL)
+# (requires database query to get invalid file paths)
+
+
+

Directory Structure

+
/media/local/library/
+├── studios/              # Studio-organized content
+│   ├── studio-a/
+│   │   ├── video-001.mp4
+│   │   └── video-002.mp4
+│   └── studio-b/
+│       └── video-003.mp4
+│
+├── gifs/                 # Short looping videos
+│   ├── loop-001.mp4
+│   └── loop-002.webm
+│
+├── private/              # Private/unreleased content
+│   └── unreleased.mp4
+│
+├── inbox/                # Upload staging area (READ-WRITE)
+│   ├── uuid-123.mp4      # Temp uploads
+│   └── uuid-456.mov
+│
+├── curated/              # Hand-picked highlights
+│   ├── best-of-2025.mp4
+│   └── top-plays.mp4
+│
+├── playback/             # Playback-optimized encodes
+│   ├── streaming-001.mp4
+│   └── streaming-002.mp4
+│
+├── compilations/         # Multi-video compilations
+│   ├── compilation-001.mp4
+│   └── mega-compilation.mp4
+│
+├── videos/               # General video library
+│   ├── video-001.mp4
+│   ├── video-002.mp4
+│   └── ... (thousands of videos)
+│
+└── highlights/           # Auto-generated highlights
+    ├── highlight-001.mp4
+    └── highlight-002.mp4
+
+

Directory Guidelines:

+
    +
  • studios/ — Organize by producer/studio name (subfolder structure allowed)
  • +
  • gifs/ — Short videos under 15 seconds, suitable for looping
  • +
  • private/ — Never shared publicly, admin-only access
  • +
  • inbox/ — Temporary upload location, files moved after processing
  • +
  • curated/ — High-quality selections for public gallery homepage
  • +
  • playback/ — Web-optimized encodes (H.264, web-friendly profiles)
  • +
  • compilations/ — Merged videos created by compilation jobs
  • +
  • videos/ — Main library, all-purpose storage
  • +
  • highlights/ — AI-generated or manually created highlight reels
  • +
+
+

Code Examples

+

List Videos with Filters (Fastify Route)

+
// api/src/modules/media/routes/videos.routes.ts
+import { FastifyInstance } from 'fastify';
+import { eq, and, like, desc, sql } from 'drizzle-orm';
+import { videos } from '@/modules/media/db/schema';
+import { db } from '@/modules/media/db';
+
+export default async function (app: FastifyInstance) {
+  app.get('/api/media/videos', async (req, reply) => {
+    const {
+      page = 1,
+      limit = 20,
+      directoryType,
+      orientation,
+      producer,
+      creator,
+      quality,
+      hasAudio,
+      isValid = true,
+      search,
+    } = req.query as any;
+
+    // Build filters
+    const filters = [];
+
+    if (directoryType) {
+      filters.push(eq(videos.directoryType, directoryType));
+    }
+
+    if (orientation) {
+      filters.push(eq(videos.orientation, orientation));
+    }
+
+    if (producer) {
+      filters.push(like(videos.producer, `%${producer}%`));
+    }
+
+    if (creator) {
+      filters.push(like(videos.creator, `%${creator}%`));
+    }
+
+    if (quality) {
+      filters.push(eq(videos.quality, quality));
+    }
+
+    if (typeof hasAudio === 'boolean') {
+      filters.push(eq(videos.hasAudio, hasAudio));
+    }
+
+    if (typeof isValid === 'boolean') {
+      filters.push(eq(videos.isValid, isValid));
+    }
+
+    if (search) {
+      filters.push(
+        sql`(
+          ${videos.title} ILIKE ${'%' + search + '%'} OR
+          ${videos.producer} ILIKE ${'%' + search + '%'} OR
+          ${videos.creator} ILIKE ${'%' + search + '%'}
+        )`
+      );
+    }
+
+    // Count total
+    const [{ count }] = await db
+      .select({ count: sql<number>`count(*)` })
+      .from(videos)
+      .where(and(...filters));
+
+    // Fetch paginated results
+    const results = await db
+      .select()
+      .from(videos)
+      .where(and(...filters))
+      .limit(Number(limit))
+      .offset((Number(page) - 1) * Number(limit))
+      .orderBy(desc(videos.createdAt));
+
+    reply.send({
+      data: results,
+      pagination: {
+        page: Number(page),
+        limit: Number(limit),
+        total: Number(count),
+        totalPages: Math.ceil(Number(count) / Number(limit)),
+      },
+    });
+  });
+}
+
+
+

Scan Directory for Videos

+
// api/src/modules/media/routes/videos.routes.ts
+import fs from 'fs/promises';
+import path from 'path';
+import { eq } from 'drizzle-orm';
+import { videos } from '@/modules/media/db/schema';
+import { ffprobeService } from '@/modules/media/services/ffprobe.service';
+
+app.post('/api/media/videos/scan', async (req, reply) => {
+  const { directoryType, skipExisting = true } = req.body as any;
+
+  if (!directoryType) {
+    return reply.code(400).send({ error: 'directoryType required' });
+  }
+
+  const dirPath = path.join(process.env.MEDIA_LIBRARY_PATH!, directoryType);
+
+  try {
+    // Check directory exists
+    await fs.access(dirPath);
+  } catch {
+    return reply.code(400).send({ error: `Directory not found: ${directoryType}` });
+  }
+
+  // Read directory
+  const files = await fs.readdir(dirPath, { recursive: true });
+
+  // Filter for video files
+  const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv'];
+  const videoFiles = files.filter((f) =>
+    videoExtensions.some((ext) => f.toLowerCase().endsWith(ext))
+  );
+
+  const results = {
+    scanned: videoFiles.length,
+    created: 0,
+    skipped: 0,
+    failed: 0,
+    errors: [] as string[],
+  };
+
+  for (const filename of videoFiles) {
+    try {
+      const relativePath = path.join(directoryType, filename);
+
+      // Check if already exists
+      if (skipExisting) {
+        const existing = await db
+          .select()
+          .from(videos)
+          .where(eq(videos.path, relativePath))
+          .limit(1);
+
+        if (existing.length > 0) {
+          results.skipped++;
+          continue;
+        }
+      }
+
+      // Extract metadata
+      const fullPath = path.join(dirPath, filename);
+      const metadata = await ffprobeService.extract(fullPath);
+
+      // Create record
+      await db.insert(videos).values({
+        path: relativePath,
+        filename: path.basename(filename),
+        directoryType,
+        durationSeconds: metadata.duration,
+        width: metadata.width,
+        height: metadata.height,
+        orientation: metadata.orientation,
+        quality: metadata.quality,
+        hasAudio: metadata.hasAudio,
+        fileSize: metadata.fileSize,
+        isValid: true,
+      });
+
+      results.created++;
+    } catch (error: any) {
+      results.failed++;
+      results.errors.push(`${filename}: ${error.message}`);
+    }
+  }
+
+  reply.send(results);
+});
+
+
+

Validate Video Metadata

+
// api/src/modules/media/routes/videos.routes.ts
+import { eq } from 'drizzle-orm';
+import { videos } from '@/modules/media/db/schema';
+import { ffprobeService } from '@/modules/media/services/ffprobe.service';
+
+app.post('/api/media/videos/:id/validate', async (req, reply) => {
+  const { id } = req.params as { id: string };
+
+  // Fetch video record
+  const [video] = await db
+    .select()
+    .from(videos)
+    .where(eq(videos.id, id))
+    .limit(1);
+
+  if (!video) {
+    return reply.code(404).send({ error: 'Video not found' });
+  }
+
+  try {
+    // Build full file path
+    const fullPath = path.join(process.env.MEDIA_LIBRARY_PATH!, video.path);
+
+    // Extract fresh metadata
+    const metadata = await ffprobeService.extract(fullPath);
+
+    // Update database
+    const [updated] = await db
+      .update(videos)
+      .set({
+        durationSeconds: metadata.duration,
+        width: metadata.width,
+        height: metadata.height,
+        orientation: metadata.orientation,
+        quality: metadata.quality,
+        hasAudio: metadata.hasAudio,
+        fileSize: metadata.fileSize,
+        fileHash: metadata.fileHash,
+        isValid: true,
+        lastValidated: new Date(),
+        updatedAt: new Date(),
+      })
+      .where(eq(videos.id, id))
+      .returning();
+
+    reply.send({
+      id: updated.id,
+      isValid: updated.isValid,
+      lastValidated: updated.lastValidated,
+      metadata: {
+        durationSeconds: updated.durationSeconds,
+        width: updated.width,
+        height: updated.height,
+        quality: updated.quality,
+        orientation: updated.orientation,
+        hasAudio: updated.hasAudio,
+      },
+    });
+  } catch (error: any) {
+    // Mark as invalid if validation fails
+    await db
+      .update(videos)
+      .set({
+        isValid: false,
+        lastValidated: new Date(),
+        updatedAt: new Date(),
+      })
+      .where(eq(videos.id, id));
+
+    reply.code(500).send({
+      error: 'Validation failed',
+      message: error.message,
+      isValid: false,
+    });
+  }
+});
+
+
+

Frontend: Library Page Table

+
// admin/src/pages/media/LibraryPage.tsx
+import { Table, Button, Select, Input, Tag, Space } from 'antd';
+import { useEffect, useState } from 'react';
+import { mediaApi } from '@/lib/media-api';
+
+export default function LibraryPage() {
+  const [videos, setVideos] = useState([]);
+  const [loading, setLoading] = useState(false);
+  const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
+  const [filters, setFilters] = useState({
+    directoryType: undefined,
+    orientation: undefined,
+    search: '',
+  });
+
+  const fetchVideos = async () => {
+    setLoading(true);
+    try {
+      const { data } = await mediaApi.get('/api/media/videos', {
+        params: {
+          page: pagination.page,
+          limit: pagination.limit,
+          ...filters,
+        },
+      });
+      setVideos(data.data);
+      setPagination((prev) => ({ ...prev, total: data.pagination.total }));
+    } catch (error) {
+      console.error('Failed to fetch videos:', error);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    fetchVideos();
+  }, [pagination.page, filters]);
+
+  const columns = [
+    {
+      title: 'Preview',
+      dataIndex: 'thumbnailPath',
+      width: 100,
+      render: (path: string) => (
+        <img
+          src={path || '/placeholder.jpg'}
+          alt="Thumbnail"
+          style={{ width: 80, height: 60, objectFit: 'cover' }}
+        />
+      ),
+    },
+    {
+      title: 'Title',
+      dataIndex: 'title',
+      render: (text: string, record: any) => (
+        <div>
+          <div style={{ fontWeight: 600 }}>{text || record.filename}</div>
+          <div style={{ fontSize: 12, color: '#888' }}>
+            {record.producer}  {record.creator}
+          </div>
+        </div>
+      ),
+    },
+    {
+      title: 'Duration',
+      dataIndex: 'durationSeconds',
+      width: 100,
+      render: (seconds: number) => {
+        const mins = Math.floor(seconds / 60);
+        const secs = seconds % 60;
+        return `${mins}:${secs.toString().padStart(2, '0')}`;
+      },
+    },
+    {
+      title: 'Quality',
+      dataIndex: 'quality',
+      width: 80,
+      render: (quality: string) => {
+        const colors: Record<string, string> = {
+          SD: 'default',
+          HD: 'blue',
+          FHD: 'green',
+          UHD: 'purple',
+        };
+        return <Tag color={colors[quality]}>{quality}</Tag>;
+      },
+    },
+    {
+      title: 'Orientation',
+      dataIndex: 'orientation',
+      width: 100,
+    },
+    {
+      title: 'Directory',
+      dataIndex: 'directoryType',
+      width: 120,
+    },
+    {
+      title: 'Actions',
+      width: 150,
+      render: (_: any, record: any) => (
+        <Space>
+          <Button size="small" onClick={() => handleEdit(record.id)}>
+            Edit
+          </Button>
+          <Button size="small" onClick={() => handleValidate(record.id)}>
+            Validate
+          </Button>
+          <Button size="small" danger onClick={() => handleDelete(record.id)}>
+            Delete
+          </Button>
+        </Space>
+      ),
+    },
+  ];
+
+  return (
+    <div>
+      <Space style={{ marginBottom: 16 }}>
+        <Select
+          placeholder="Directory Type"
+          style={{ width: 200 }}
+          onChange={(value) => setFilters({ ...filters, directoryType: value })}
+          allowClear
+        >
+          <Select.Option value="videos">Videos</Select.Option>
+          <Select.Option value="studios">Studios</Select.Option>
+          <Select.Option value="gifs">GIFs</Select.Option>
+          <Select.Option value="curated">Curated</Select.Option>
+        </Select>
+
+        <Input.Search
+          placeholder="Search title, producer, creator"
+          style={{ width: 300 }}
+          onSearch={(value) => setFilters({ ...filters, search: value })}
+          allowClear
+        />
+
+        <Button type="primary" onClick={handleScanDirectory}>
+          Scan Directory
+        </Button>
+      </Space>
+
+      <Table
+        columns={columns}
+        dataSource={videos}
+        loading={loading}
+        rowKey="id"
+        pagination={{
+          current: pagination.page,
+          pageSize: pagination.limit,
+          total: pagination.total,
+          onChange: (page) => setPagination({ ...pagination, page }),
+        }}
+      />
+    </div>
+  );
+}
+
+
+

Troubleshooting

+

Problem: Media API Not Accessible

+

Symptoms:

+
    +
  • Admin GUI shows "Cannot connect to media API"
  • +
  • Browser console shows CORS errors or network failures
  • +
  • Public gallery doesn't load
  • +
+

Solutions:

+
    +
  1. Check Fastify server running:
  2. +
+
docker compose ps media-api
+# Should show "Up" status
+
+docker compose logs media-api
+# Look for "Fastify server listening on port 4100"
+
+
    +
  1. Verify port 4100 not in use:
  2. +
+
lsof -i :4100
+# Should show only media-api container
+
+# If another process using port, stop it or change MEDIA_API_PORT in .env
+
+
    +
  1. Check nginx proxy configuration:
  2. +
+
# nginx/conf.d/api.conf
+# Media API block must come BEFORE general API block
+
+server {
+    listen 80;
+    server_name media.cmlite.org;
+
+    location / {
+        proxy_pass http://localhost:4100;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection 'upgrade';
+        proxy_set_header Host $host;
+        proxy_cache_bypass $http_upgrade;
+    }
+}
+
+
    +
  1. Test direct API access:
  2. +
+
# From host machine
+curl http://localhost:4100/api/media/videos
+
+# From inside container
+docker compose exec media-api curl http://localhost:4100/api/media/videos
+
+
    +
  1. Check Docker networking:
  2. +
+
docker network inspect changemaker-lite
+# Verify media-api container connected
+
+
+

Problem: Scan Finds No Videos

+

Symptoms:

+
    +
  • Scan completes with "Created 0 new records"
  • +
  • Directory known to contain video files
  • +
  • Scan reports 0 files scanned
  • +
+

Solutions:

+
    +
  1. Verify MEDIA_LIBRARY_PATH correct:
  2. +
+
# Check environment variable
+docker compose exec media-api printenv MEDIA_LIBRARY_PATH
+# Should output: /media/local/library
+
+# List directory contents
+docker compose exec media-api ls -la /media/local/library/videos
+# Should show video files
+
+
    +
  1. Check directory exists:
  2. +
+
# Create missing directory
+docker compose exec media-api mkdir -p /media/local/library/videos
+
+# Copy test videos
+docker cp test.mp4 $(docker compose ps -q media-api):/media/local/library/videos/
+
+
    +
  1. Verify Docker volume mounted:
  2. +
+
# docker-compose.yml
+services:
+  media-api:
+    volumes:
+      - /media/local/library:/media/local/library:ro  # Check path correct
+
+
# Inspect volume mounts
+docker compose config | grep -A 5 media-api
+
+
    +
  1. Check file extensions supported:
  2. +
+

Only these extensions scanned:

+
    +
  • .mp4
  • +
  • .mov
  • +
  • .avi
  • +
  • .mkv
  • +
  • .webm
  • +
  • .m4v
  • +
  • .flv
  • +
+

Rename files if using other extensions:

+
# Rename .MP4 to .mp4 (case-sensitive)
+docker compose exec media-api sh -c 'cd /media/local/library/videos && rename "s/.MP4$/.mp4/" *.MP4'
+
+
    +
  1. Check file permissions:
  2. +
+
# Verify readable by container user
+docker compose exec media-api ls -la /media/local/library/videos
+
+# Fix permissions if needed (on host)
+sudo chmod -R 755 /media/local/library
+
+
+

Problem: FFprobe Validation Fails

+

Symptoms:

+
    +
  • Validation returns error "FFprobe command failed"
  • +
  • Videos marked isValid = false
  • +
  • Timeout errors after 30 seconds
  • +
+

Solutions:

+
    +
  1. Check FFmpeg installed in container:
  2. +
+
# Verify FFprobe available
+docker compose exec media-api which ffprobe
+# Should output: /usr/bin/ffprobe
+
+docker compose exec media-api ffprobe -version
+# Should show FFmpeg version info
+
+
    +
  1. Install FFmpeg if missing:
  2. +
+
# api/Dockerfile.media
+FROM node:20-alpine
+
+# Install FFmpeg (both dev and production stages)
+RUN apk add --no-cache ffmpeg
+
+# ... rest of Dockerfile
+
+
# Rebuild container
+docker compose build media-api
+docker compose up -d media-api
+
+
    +
  1. Test FFprobe directly on video:
  2. +
+
# Run FFprobe manually
+docker compose exec media-api ffprobe -v quiet -print_format json -show_streams -show_format /media/local/library/videos/test.mp4
+
+# If this fails, video file corrupt or unsupported
+
+
    +
  1. Check timeout not exceeded:
  2. +
+

Default timeout: 30 seconds

+
# For very large files (>5GB), increase timeout
+# api/src/modules/media/services/ffprobe.service.ts
+const FFPROBE_TIMEOUT = 60000; // 60 seconds
+
+
    +
  1. Verify video file not corrupt:
  2. +
+
# Test playback
+docker compose exec media-api ffplay /media/local/library/videos/test.mp4
+
+# Or copy to host and test in VLC
+docker cp $(docker compose ps -q media-api):/media/local/library/videos/test.mp4 ./test.mp4
+vlc test.mp4
+
+
    +
  1. Check for special characters in filename:
  2. +
+
# Rename files with spaces or special chars
+docker compose exec media-api sh -c 'cd /media/local/library/videos && rename "s/ /_/g" *.mp4'
+
+
+

Problem: Drizzle Schema Changes Not Applied

+

Symptoms:

+
    +
  • Code references new column but database doesn't have it
  • +
  • Error: "column does not exist"
  • +
  • Schema changes made but not reflected
  • +
+

Solutions:

+
    +
  1. Push schema changes:
  2. +
+
# Drizzle uses push (not migrations)
+cd api
+npx drizzle-kit push
+
+# Confirm changes
+
+
    +
  1. Verify connection:
  2. +
+
# Check DATABASE_URL correct
+docker compose exec media-api printenv DATABASE_URL
+
+# Test connection
+docker compose exec media-api npx drizzle-kit studio
+# Opens DB browser on http://localhost:4983
+
+
    +
  1. Compare with Prisma migrations:
  2. +
+

Media tables exist in same database as Prisma tables. If conflict:

+
# Check both schemas
+npx prisma db pull  # Prisma introspection
+npx drizzle-kit introspect  # Drizzle introspection
+
+# Resolve conflicts manually
+
+
+

Problem: Large Library Performance

+

Symptoms:

+
    +
  • Library page loads slowly (5+ seconds)
  • +
  • Pagination sluggish
  • +
  • Scan operations timeout
  • +
+

Solutions:

+
    +
  1. Add database indexes:
  2. +
+
-- Index for common filters
+CREATE INDEX idx_videos_directory_type ON videos(directory_type);
+CREATE INDEX idx_videos_orientation ON videos(orientation);
+CREATE INDEX idx_videos_quality ON videos(quality);
+CREATE INDEX idx_videos_is_valid ON videos(is_valid);
+CREATE INDEX idx_videos_created_at ON videos(created_at DESC);
+
+-- Composite index for filtered queries
+CREATE INDEX idx_videos_filters ON videos(directory_type, is_valid, created_at DESC);
+
+-- Full-text search index
+CREATE INDEX idx_videos_search ON videos USING gin(to_tsvector('english', coalesce(title, '') || ' ' || coalesce(producer, '') || ' ' || coalesce(creator, '')));
+
+
    +
  1. Reduce page size:
  2. +
+
// admin/src/pages/media/LibraryPage.tsx
+const [pagination, setPagination] = useState({ page: 1, limit: 10, total: 0 });
+// Reduced from 20 to 10
+
+
    +
  1. Enable query caching:
  2. +
+
// api/src/modules/media/routes/videos.routes.ts
+import { redisClient } from '@/config/redis';
+
+app.get('/api/media/videos', async (req, reply) => {
+  const cacheKey = `videos:list:${JSON.stringify(req.query)}`;
+
+  // Check cache
+  const cached = await redisClient.get(cacheKey);
+  if (cached) {
+    return reply.send(JSON.parse(cached));
+  }
+
+  // Fetch from database
+  const results = await db.select()...;
+
+  // Cache for 5 minutes
+  await redisClient.setex(cacheKey, 300, JSON.stringify(results));
+
+  reply.send(results);
+});
+
+
    +
  1. Use virtual scrolling:
  2. +
+
// Replace Ant Design Table with react-window for large datasets
+import { FixedSizeList } from 'react-window';
+
+
+

Performance Considerations

+

Directory Scans

+

Scaling Factors:

+
    +
  • 100 files: ~2 seconds
  • +
  • 1,000 files: ~15 seconds
  • +
  • 10,000 files: ~2.5 minutes
  • +
+

Optimization Strategies:

+
    +
  1. Incremental Scans — Use skipExisting: true to only process new files
  2. +
  3. Parallel Processing — Scan multiple directories simultaneously
  4. +
  5. Background Jobs — Queue scans as async jobs instead of synchronous requests
  6. +
  7. Caching — Cache directory listings in Redis
  8. +
+

FFprobe Extraction

+

Timing:

+
    +
  • Small video (<100MB): ~50-100ms
  • +
  • Medium video (500MB): ~150-250ms
  • +
  • Large video (2GB+): ~500ms-1s
  • +
+

Batch Processing:

+

For 100 videos: ~10-20 seconds total

+

Optimization:

+
// Parallel extraction (limit concurrency)
+import pLimit from 'p-limit';
+
+const limit = pLimit(5); // Max 5 concurrent FFprobe calls
+
+const results = await Promise.all(
+  videoFiles.map((file) =>
+    limit(() => ffprobeService.extract(file))
+  )
+);
+
+

Database Queries

+

Query Performance:

+
    +
  • List 20 videos (no filters): ~5-10ms
  • +
  • List 20 videos (with filters): ~10-20ms
  • +
  • Full-text search: ~20-50ms
  • +
  • Count total videos: ~5ms (with index)
  • +
+

Optimization:

+
    +
  1. Always use pagination — Never fetch all records
  2. +
  3. Index heavily filtered columns — directoryType, orientation, quality, isValid
  4. +
  5. Use SELECT only needed columns — Avoid SELECT * for large tables
  6. +
  7. Cache counts — Total video count changes infrequently, cache in Redis
  8. +
+

Thumbnail Generation

+

Deferred Loading:

+

Don't generate thumbnails during scan. Instead:

+
    +
  1. Create video record without thumbnail
  2. +
  3. Queue thumbnail generation job
  4. +
  5. Worker processes job asynchronously
  6. +
  7. Update record with thumbnailPath
  8. +
+

Lazy Loading:

+

Frontend requests thumbnails only when visible (IntersectionObserver).

+
+

Dual API Architecture

+

Why Separate Fastify API?

+

The media system was introduced as a Phase 14 enhancement after V2 core functionality stabilized. A separate Fastify microservice was chosen to:

+
    +
  1. Avoid Disrupting Stable Express API — V2 Express API battle-tested with 30+ models, introducing media directly risked regressions
  2. +
  3. Test Drizzle ORM Migration — Fastify+Drizzle serves as proof-of-concept for potential future Prisma→Drizzle migration
  4. +
  5. Isolate Video Processing — CPU/GPU-intensive FFprobe, encoding jobs isolated from main API request handling
  6. +
  7. Independent Scaling — Media API can be horizontally scaled separately based on video processing load
  8. +
  9. Technology Experimentation — Fastify's performance benefits evaluated for potential broader adoption
  10. +
+

Database Sharing Strategy

+

Same PostgreSQL, Different ORMs:

+
┌─────────────────┐
+│  PostgreSQL 16  │
+│ v2_changemaker  │
+└─────────────────┘
+         ↑
+    ┌────┴────┐
+    │         │
+┌───┴───┐ ┌──┴────┐
+│Prisma │ │Drizzle│
+│ ORM   │ │  ORM  │
+└───┬───┘ └──┬────┘
+    │        │
+┌───┴────┐ ┌─┴─────┐
+│Express │ │Fastify│
+│  API   │ │ Media │
+│ :4000  │ │  API  │
+│        │ │ :4100 │
+└────────┘ └───────┘
+
+

Benefits:

+
    +
  • Single Source of Truth — All data in one database
  • +
  • Cross-API Queries — Main API can query media tables via Prisma raw queries
  • +
  • Unified Backups — One PostgreSQL dump includes both APIs
  • +
  • Shared Connections — Connection pooling optimizations benefit both
  • +
+

Challenges:

+
    +
  • Schema Coordination — Must manually sync schema changes between Prisma migrations and Drizzle pushes
  • +
  • Type Conflicts — Same table, different type definitions (Prisma vs Drizzle types)
  • +
  • Migration Complexity — Prisma generates migrations, Drizzle uses push (no migration files)
  • +
+

Migration Strategy Roadmap

+

Short Term (Current):

+
    +
  • Keep dual API architecture
  • +
  • Synchronize schemas manually
  • +
  • Document shared tables in both ORMs
  • +
+

Medium Term (6-12 months):

+
    +
  • Evaluate Fastify+Drizzle performance vs Express+Prisma
  • +
  • If Fastify superior, migrate select Express routes to Fastify
  • +
  • If no significant benefit, consolidate media into Express+Prisma
  • +
+

Long Term (12+ months):

+
    +
  • Unified API (either all Express or all Fastify)
  • +
  • Single ORM (either all Prisma or all Drizzle)
  • +
  • Deprecate less performant stack
  • +
+

Migration Effort Estimate:

+
    +
  • Media to Express+Prisma: 3-5 days (convert Drizzle queries to Prisma, merge Fastify routes into Express)
  • +
  • All to Fastify+Drizzle: 2-3 weeks (convert 30+ Prisma models to Drizzle, rewrite Express routes for Fastify)
  • +
+
+ +

Backend Documentation

+
    +
  • API Server: backend/api/media-server.md — Fastify server setup, middleware, error handling
  • +
  • Videos Module: backend/modules/media/videos.md — Video routes, service layer, business logic
  • +
  • FFprobe Service: backend/modules/media/ffprobe.md — Metadata extraction implementation
  • +
  • Jobs System: backend/modules/media/jobs.md — Job queue architecture, worker processes
  • +
+

Frontend Documentation

+
    +
  • Library Page: frontend/pages/media/library.md — Video library management UI
  • +
  • Shared Media Page: frontend/pages/media/shared.md — Public gallery admin UI
  • +
  • Media Components: frontend/components/media.md — Reusable video components
  • +
+

Database Documentation

+
    +
  • Media Models: database/models/media.md — Drizzle schema definitions for videos, compilations, jobs
  • +
  • Drizzle Setup: database/drizzle.md — Drizzle ORM configuration, connection management
  • +
+

Feature Documentation

+
    +
  • Video Upload: features/media/upload.md — Upload system workflow, FFprobe integration
  • +
  • Media Jobs: features/media/jobs.md — Job queue system, processing pipeline
  • +
  • Public Gallery: features/media/public-gallery.md — Public video sharing system
  • +
+

Integration Documentation

+
    +
  • Dual API Architecture: architecture/dual-api.md — Express+Prisma vs Fastify+Drizzle comparison
  • +
  • Nginx Routing: deployment/nginx.md — Reverse proxy configuration for media.cmlite.org
  • +
  • Docker Setup: deployment/docker.md — Media API container, volume mounts, healthchecks
  • +
+
+

Next Steps

+

After mastering video library management:

+
    +
  1. Upload System — Read features/media/upload.md to understand video upload workflow
  2. +
  3. Jobs Queue — Review features/media/jobs.md for video processing automation
  4. +
  5. Public Gallery — Explore features/media/public-gallery.md for sharing videos publicly
  6. +
  7. Custom Integrations — Use Media API endpoints to build custom video features
  8. +
+

For hands-on practice, try:

+
# 1. Upload test videos
+curl -X POST http://localhost:4100/api/media/upload/single \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -F "video=@test.mp4" \
+  -F "producer=Test Studio" \
+  -F "title=Test Video"
+
+# 2. Scan directory
+curl -X POST http://localhost:4100/api/media/videos/scan \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"directoryType": "videos"}'
+
+# 3. List videos
+curl http://localhost:4100/api/media/videos?page=1&limit=10
+
+# 4. Validate video
+curl -X POST http://localhost:4100/api/media/videos/VIDEO_ID/validate \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+
+

Last Updated: 2026-02-13 +Version: V2.0 +Maintainer: Changemaker Lite Team

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/newsletter/index.html b/mkdocs/site/v2/features/newsletter/index.html new file mode 100644 index 00000000..8d091628 --- /dev/null +++ b/mkdocs/site/v2/features/newsletter/index.html @@ -0,0 +1,5439 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Newsletter Integration (Listmonk) - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Newsletter Integration (Listmonk)

+

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.

+

Overview

+

The Listmonk integration provides:

+
    +
  • Opt-in Sync - Controlled by LISTMONK_SYNC_ENABLED flag
  • +
  • Automatic Subscriber Creation - Campaign participants → subscribers
  • +
  • List Management - Campaigns → lists, Locations → lists
  • +
  • User Role Sync - User roles → list assignment
  • +
  • Bi-directional Updates - Keep data synchronized
  • +
  • Admin Interface - Manual sync controls and monitoring
  • +
+

Features

+

Subscriber Sync

+

Automatically sync users to Listmonk:

+
    +
  • Campaign Participants - Email senders become subscribers
  • +
  • Shift Signups - Volunteers added to lists
  • +
  • Response Submitters - Response wall participants
  • +
  • Manual Users - User role-based list assignment
  • +
+

List Management

+

Auto-create and manage lists:

+
    +
  • Campaign Lists - One list per campaign
  • +
  • Location Lists - One list per geographic area
  • +
  • Role Lists - Lists for each user role
  • +
  • Custom Lists - Admin-defined lists
  • +
+

Sync Triggers

+

Automatic sync on:

+
    +
  • Campaign email sent
  • +
  • Shift signup
  • +
  • Response submission
  • +
  • User registration
  • +
  • Manual admin trigger
  • +
+

Admin Controls

+
    +
  • View sync status
  • +
  • Manual sync buttons
  • +
  • Test connection
  • +
  • List statistics
  • +
  • Reinitialize lists
  • +
+

Architecture

+

Backend Components

+

Listmonk Client: +- api/src/services/listmonk.client.ts - Typed HTTP client (native fetch) +- Basic auth integration +- Full REST API coverage

+

Listmonk Sync Service: +- api/src/services/listmonk-sync.service.ts - Sync orchestration +- Participant → subscriber mapping +- List creation and management +- Error handling and logging

+

Admin Module: +- api/src/modules/listmonk/listmonk.routes.ts - Admin endpoints +- Status, stats, sync controls

+

Database: +- No new tables (uses existing User, Campaign, Location) +- Listmonk IDs stored in Prisma models (future)

+

Frontend Components

+

Admin Page: +- admin/src/pages/ListmonkPage.tsx - Newsletter management +- Connection status display +- Sync controls +- List statistics table

+

Configuration

+

Environment Variables

+
# Enable Listmonk sync (opt-in)
+LISTMONK_SYNC_ENABLED=true
+
+# Listmonk connection
+LISTMONK_API_URL=http://listmonk:9000
+LISTMONK_API_USER=api_user
+LISTMONK_API_TOKEN=your_api_token
+
+# Web admin credentials (for setup)
+LISTMONK_WEB_ADMIN_USER=admin
+LISTMONK_WEB_ADMIN_PASSWORD=password
+
+

Docker Setup

+

Listmonk runs as a service in docker-compose.yml:

+
listmonk:
+  image: listmonk/listmonk:latest
+  ports:
+    - "9001:9000"
+  depends_on:
+    - listmonk-db
+  environment:
+    LISTMONK_app__admin_username: ${LISTMONK_WEB_ADMIN_USER}
+    LISTMONK_app__admin_password: ${LISTMONK_WEB_ADMIN_PASSWORD}
+
+

Initialization

+

Auto-create API user via listmonk-init container:

+
INSERT INTO users (email, name, password, type, status, created_at, updated_at)
+VALUES (
+  '${LISTMONK_API_USER}',
+  'API User',
+  '${LISTMONK_API_TOKEN}',  -- Plaintext (Listmonk API tokens)
+  'api',
+  'enabled',
+  NOW(),
+  NOW()
+);
+
+

Sync Process

+

Campaign Participant Sync

+
    +
  1. Email Sent - Campaign email sent via API
  2. +
  3. Create Subscriber - POST /api/subscribers
  4. +
  5. Email, name from user
  6. +
  7. Status: enabled
  8. +
  9. Get/Create List - GET/POST /api/lists
  10. +
  11. List name: Campaign name
  12. +
  13. Type: public or private
  14. +
  15. Subscribe to List - PUT /api/subscribers/:id/lists
  16. +
  17. Add subscriber to campaign list
  18. +
+

Location Sync

+
    +
  1. Location Created - New location added
  2. +
  3. Get/Create List - List name: Location name/city
  4. +
  5. Sync Users - All users in location → list
  6. +
+

User Role Sync

+
    +
  1. User Registration - New user account
  2. +
  3. Get Role List - SUPER_ADMIN, INFLUENCE_ADMIN, etc.
  4. +
  5. Subscribe User - Add to role-based list
  6. +
+

API Integration

+

Listmonk Client Usage

+
import { listmonkClient } from '../services/listmonk.client';
+
+// Create subscriber
+const subscriber = await listmonkClient.createSubscriber({
+  email: 'user@example.com',
+  name: 'User Name',
+  status: 'enabled',
+  lists: [listId],
+});
+
+// Get/Create list
+let list = await listmonkClient.getListByName('Campaign Name');
+if (!list) {
+  list = await listmonkClient.createList({
+    name: 'Campaign Name',
+    type: 'public',
+    optin: 'double',
+  });
+}
+
+// Subscribe to list
+await listmonkClient.subscribeToList(subscriberId, [listId]);
+
+

Sync Service Usage

+
import { listmonkSyncService } from '../services/listmonk-sync.service';
+
+// Sync campaign participant
+await listmonkSyncService.syncCampaignParticipant(
+  campaign.id,
+  user.email,
+  user.name
+);
+
+// Sync all participants
+await listmonkSyncService.syncAllParticipants(campaign.id);
+
+// Sync location members
+await listmonkSyncService.syncLocationMembers(location.id);
+
+

Admin Interface

+

Connection Status

+

Display: +- Connected/disconnected status +- Listmonk version +- API endpoint +- Last sync time

+

Sync Controls

+

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

+

List Statistics

+

Table showing: +- List name +- Subscriber count +- Campaign/location association +- Last updated time

+

Security

+

API Authentication

+

Listmonk v6+ requires auth on all endpoints:

+
const headers = {
+  'Authorization': `Basic ${btoa(`${apiUser}:${apiToken}`)}`,
+  'Content-Type': 'application/json',
+};
+
+

Token Storage

+

API tokens stored as plaintext in Listmonk DB: +- Not bcrypt hashed +- Direct upsert possible +- Secure via Redis authentication

+

Data Privacy

+
    +
  • Opt-in sync only
  • +
  • User consent required (future)
  • +
  • Unsubscribe support
  • +
  • Data deletion on request
  • +
+

Error Handling

+

Sync Failures

+

Handled gracefully: +- Network errors logged +- Failed syncs retried +- Admin notifications +- Error statistics

+

Rate Limiting

+

Respect Listmonk limits: +- Batch operations +- Delay between requests +- Queue large syncs

+

Listmonk Features

+

Campaign Management

+

Listmonk provides: +- Email campaign creation +- Template management +- Scheduling +- A/B testing +- Analytics

+

Subscriber Management

+
    +
  • Import/export subscribers
  • +
  • List segmentation
  • +
  • Tags and attributes
  • +
  • Bounce handling
  • +
  • Unsubscribe management
  • +
+

Analytics

+
    +
  • Open rates
  • +
  • Click rates
  • +
  • Bounce rates
  • +
  • Unsubscribe rates
  • +
  • Campaign reports
  • +
+

API Endpoints

+

Admin Endpoints

+
GET    /api/listmonk/status            # Connection status
+GET    /api/listmonk/stats             # Sync statistics
+POST   /api/listmonk/sync-participants # Sync campaign participants
+POST   /api/listmonk/sync-locations    # Sync location members
+POST   /api/listmonk/test-connection   # Test API connection
+POST   /api/listmonk/reinitialize      # Reset and reinitialize
+
+

Limitations

+

Current Limitations

+
    +
  • Listmonk v6+ only (auth required on all endpoints)
  • +
  • No webhook support (future)
  • +
  • Manual sync triggers
  • +
  • No bi-directional sync (Listmonk → CM Lite)
  • +
+

Future Enhancements

+
    +
  • Webhook integration
  • +
  • Real-time sync
  • +
  • Custom field mapping
  • +
  • Advanced segmentation
  • +
  • Campaign stats in CM Lite
  • +
+

Troubleshooting

+

Connection Issues

+
    +
  1. Check LISTMONK_SYNC_ENABLED=true
  2. +
  3. Verify LISTMONK_API_URL reachable
  4. +
  5. Confirm API user created
  6. +
  7. Test credentials with curl
  8. +
+

Sync Failures

+
    +
  1. Check logs for errors
  2. +
  3. Verify Listmonk database
  4. +
  5. Test API connection
  6. +
  7. Reinitialize if needed
  8. +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/observability/index.html b/mkdocs/site/v2/features/observability/index.html new file mode 100644 index 00000000..137483ee --- /dev/null +++ b/mkdocs/site/v2/features/observability/index.html @@ -0,0 +1,5471 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Observability & Monitoring - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Observability & Monitoring

+

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.

+

Overview

+

The Observability stack consists of:

+
    +
  1. Prometheus - Metrics collection and storage
  2. +
  3. Grafana - Visualization dashboards
  4. +
  5. Alertmanager - Alert routing and notifications
  6. +
  7. Custom Metrics - 12 domain-specific cm_* metrics
  8. +
  9. HTTP Metrics - Request tracking and performance
  10. +
  11. Service Health - External service monitoring
  12. +
+

Features

+

Metrics Collection

+

Custom Domain Metrics (12 total):

+

Counters: +- cm_api_uptime_seconds - API uptime counter +- cm_canvass_visits_total - Total canvass visits +- cm_campaign_emails_sent_total - Total campaign emails sent +- cm_geocode_requests_total - Total geocode requests

+

Gauges: +- cm_canvass_sessions_active - Active canvass sessions +- cm_email_queue_size - Email queue depth +- cm_geocode_queue_size - Geocode queue depth +- cm_external_service_health - Service health (0/1)

+

Histograms: +- cm_geocode_duration_seconds - Geocoding latency +- http_request_duration_ms - HTTP request duration

+

HTTP Metrics: +- Request count by method/route/status +- Request duration percentiles (p50, p95, p99) +- Active requests gauge +- Error rate tracking

+

Grafana Dashboards

+

Three pre-configured dashboards:

+
    +
  1. Changemaker Lite Overview - System-wide metrics
  2. +
  3. API uptime and request rates
  4. +
  5. Queue sizes and health
  6. +
  7. Active sessions
  8. +
  9. +

    Error rates

    +
  10. +
  11. +

    Canvassing Metrics - Canvass-specific metrics

    +
  12. +
  13. Active sessions over time
  14. +
  15. Visits by outcome
  16. +
  17. Session duration
  18. +
  19. +

    Volunteer leaderboard

    +
  20. +
  21. +

    External Services - Integration health

    +
  22. +
  23. Redis health
  24. +
  25. PostgreSQL health
  26. +
  27. Listmonk status
  28. +
  29. Geocoding providers
  30. +
+

Alert Rules

+

12 predefined alert rules:

+

Critical Alerts: +- API down (>5 min) +- Database unreachable +- Redis connection lost

+

Warning Alerts: +- High error rate (>5%) +- Queue backup (>1000 jobs) +- Slow requests (p95 >2s) +- Service degradation

+

Info Alerts: +- New deployment +- Service restart +- Configuration change

+

Admin Interface

+

Observability page (/app/observability) with:

+
    +
  • Metrics Tab - Live metrics display
  • +
  • Dashboards Tab - Embedded Grafana
  • +
  • Alerts Tab - Active alerts and rules
  • +
+

Architecture

+

Backend Components

+

Metrics Module: +- api/src/utils/metrics.ts - Prometheus metrics definitions +- api/src/modules/observability/observability.routes.ts - Admin API

+

Instrumentation: +- Express middleware for HTTP metrics +- Service-level metric updates +- Queue size tracking +- External service health checks

+

Configuration: +- configs/prometheus/prometheus.yml - Scrape config +- configs/prometheus/alerts.yml - Alert rules +- configs/grafana/dashboards/ - Dashboard JSON

+

Frontend Components

+

Admin Page: +- admin/src/pages/ObservabilityPage.tsx - Monitoring dashboard +- Three tabs: Metrics, Dashboards, Alerts +- Embedded Grafana iframes +- Live metric cards

+

Observability Components: +- admin/src/components/observability/MetricsChart.tsx - Chart component +- admin/src/components/observability/ServiceHealthCard.tsx - Health display

+

Docker Services

+

Monitoring Profile:

+

Services run with --profile monitoring:

+
profiles: [monitoring]
+  prometheus:
+    image: prom/prometheus:latest
+    ports: ["9090:9090"]
+
+  grafana:
+    image: grafana/grafana:latest
+    ports: ["3001:3000"]
+
+  alertmanager:
+    image: prom/alertmanager:latest
+    ports: ["9093:9093"]
+
+  cadvisor:
+    image: gcr.io/cadvisor/cadvisor:latest
+    ports: ["8080:8080"]
+
+  node-exporter:
+    image: prom/node-exporter:latest
+    ports: ["9100:9100"]
+
+  redis-exporter:
+    image: oliver006/redis_exporter:latest
+    ports: ["9121:9121"]
+
+

Configuration

+

Environment Variables

+
# Enable metrics
+METRICS_ENABLED=true
+
+# Prometheus
+PROMETHEUS_PORT=9090
+
+# Grafana
+GRAFANA_PORT=3001
+GRAFANA_ADMIN_USER=admin
+GRAFANA_ADMIN_PASSWORD=admin
+
+# Alertmanager
+ALERTMANAGER_PORT=9093
+
+

Prometheus Scrape Targets

+
scrape_configs:
+  - job_name: 'changemaker-api'
+    static_configs:
+      - targets: ['api:4000']
+
+  - job_name: 'media-api'
+    static_configs:
+      - targets: ['media-api:4100']
+
+  - job_name: 'redis'
+    static_configs:
+      - targets: ['redis-exporter:9121']
+
+  - job_name: 'node'
+    static_configs:
+      - targets: ['node-exporter:9100']
+
+  - job_name: 'cadvisor'
+    static_configs:
+      - targets: ['cadvisor:8080']
+
+

Alert Rules

+

Example alert rule:

+
groups:
+  - name: api_alerts
+    rules:
+      - alert: APIDown
+        expr: up{job="changemaker-api"} == 0
+        for: 5m
+        labels:
+          severity: critical
+        annotations:
+          summary: "API is down"
+          description: "API has been down for 5 minutes"
+
+      - alert: HighErrorRate
+        expr: rate(http_request_duration_ms_count{status=~"5.."}[5m]) > 0.05
+        for: 10m
+        labels:
+          severity: warning
+        annotations:
+          summary: "High error rate detected"
+
+

Metrics Usage

+

Increment Counter

+
import { metrics } from '../utils/metrics';
+
+// Campaign email sent
+metrics.campaignEmailsSent.inc();
+
+// Geocode request
+metrics.geocodeRequests.inc({ provider: 'nominatim' });
+
+

Set Gauge

+
// Update queue size
+metrics.emailQueueSize.set(queueSize);
+
+// Update active sessions
+metrics.canvassSessionsActive.set(activeSessions);
+
+// Set service health (1 = healthy, 0 = unhealthy)
+metrics.externalServiceHealth.set({ service: 'redis' }, 1);
+
+

Observe Histogram

+
// Time geocoding request
+const end = metrics.geocodeDuration.startTimer();
+try {
+  await geocode(address);
+  end({ success: 'true' });
+} catch (error) {
+  end({ success: 'false' });
+}
+
+

Grafana Dashboards

+

Dashboard Setup

+

Dashboards auto-provisioned from configs/grafana/dashboards/:

+
{
+  "dashboard": {
+    "title": "Changemaker Lite Overview",
+    "panels": [
+      {
+        "title": "API Request Rate",
+        "targets": [
+          {
+            "expr": "rate(http_request_duration_ms_count[5m])"
+          }
+        ]
+      }
+    ]
+  }
+}
+
+

Accessing Dashboards

+
    +
  • Direct: http://localhost:3001 (admin/admin)
  • +
  • Embedded: /app/observability → Dashboards tab
  • +
  • Subdomain: http://grafana.cmlite.org (production)
  • +
+

Alertmanager

+

Alert Routing

+

Configure in configs/alertmanager/alertmanager.yml:

+
route:
+  receiver: 'default'
+  group_by: ['alertname', 'severity']
+  routes:
+    - match:
+        severity: critical
+      receiver: 'critical-alerts'
+
+receivers:
+  - name: 'default'
+    webhook_configs:
+      - url: 'http://gotify:8889/message'
+
+  - name: 'critical-alerts'
+    email_configs:
+      - to: 'admin@example.com'
+
+

Notification Channels

+

Supported receivers:

+
    +
  • Webhook - Gotify, Slack, Discord
  • +
  • Email - SMTP notifications
  • +
  • PagerDuty - Incident management
  • +
  • Opsgenie - Alert management
  • +
+

Service Health Monitoring

+

External Service Checks

+

Monitor services via health gauges:

+
// Check Redis
+try {
+  await redisClient.ping();
+  metrics.externalServiceHealth.set({ service: 'redis' }, 1);
+} catch (error) {
+  metrics.externalServiceHealth.set({ service: 'redis' }, 0);
+}
+
+// Check PostgreSQL
+try {
+  await prisma.$queryRaw`SELECT 1`;
+  metrics.externalServiceHealth.set({ service: 'postgres' }, 1);
+} catch (error) {
+  metrics.externalServiceHealth.set({ service: 'postgres' }, 0);
+}
+
+

Docker Healthchecks

+

Services with healthchecks:

+
    +
  • API - wget --spider http://localhost:4000/health
  • +
  • Media API - wget --spider http://localhost:4100/health
  • +
  • PostgreSQL - pg_isready
  • +
  • Redis - redis-cli ping
  • +
  • Listmonk - wget --spider http://localhost:9000/health
  • +
+

Performance Monitoring

+

HTTP Request Tracking

+

Automatic tracking of:

+
    +
  • Request count by route
  • +
  • Request duration percentiles
  • +
  • Status code distribution
  • +
  • Error rates
  • +
+

Queue Monitoring

+

Track queue depths:

+
    +
  • Email queue size
  • +
  • Geocode queue size
  • +
  • Failed job count
  • +
  • Processing rate
  • +
+

Resource Monitoring

+

Via cAdvisor and Node Exporter:

+
    +
  • CPU usage
  • +
  • Memory usage
  • +
  • Disk I/O
  • +
  • Network traffic
  • +
+

Admin Interface

+

Metrics Tab

+

Display cards:

+
    +
  • API uptime
  • +
  • Request rate (req/sec)
  • +
  • Error rate (%)
  • +
  • Queue sizes
  • +
  • Active sessions
  • +
  • Service health
  • +
+

Dashboards Tab

+

Embedded Grafana:

+
    +
  • Overview dashboard
  • +
  • Canvassing metrics
  • +
  • External services
  • +
  • Custom queries
  • +
+

Alerts Tab

+

Active alerts list:

+
    +
  • Alert name
  • +
  • Severity
  • +
  • Status (firing/pending/resolved)
  • +
  • Duration
  • +
  • Quick actions (silence, resolve)
  • +
+

Starting Monitoring Stack

+
# Start with monitoring profile
+docker compose --profile monitoring up -d
+
+# Access services
+# Prometheus: http://localhost:9090
+# Grafana: http://localhost:3001 (admin/admin)
+# Alertmanager: http://localhost:9093
+
+

API Endpoints

+

Observability Endpoints

+
GET    /api/observability/prometheus   # Prometheus status
+GET    /api/observability/grafana      # Grafana status
+GET    /api/observability/alertmanager # Alertmanager status
+GET    /api/observability/metrics      # Current metrics values
+
+

Metrics Endpoint

+
GET    /metrics                         # Prometheus scrape endpoint
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/pages/block-library/index.html b/mkdocs/site/v2/features/pages/block-library/index.html new file mode 100644 index 00000000..49246399 --- /dev/null +++ b/mkdocs/site/v2/features/pages/block-library/index.html @@ -0,0 +1,6839 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Block Library - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Block Library

+

Reusable page component system with JSON schema definitions, default values, and campaign-specific customization.

+
+

Overview

+

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.

+

Key Features

+
    +
  • Database-Driven: Blocks stored in PostgreSQL (PageBlock model)
  • +
  • JSON Schema: Define configurable properties for each block type
  • +
  • Default Values: Pre-populate blocks with campaign-specific content
  • +
  • Category Organization: Group blocks (Headers, Content, Actions, etc.)
  • +
  • Sort Order: Control block position in editor panel
  • +
  • 6 Default Blocks: Hero, Text, Features, CTA, Testimonials, Contact Form
  • +
  • Custom Blocks: Create campaign-specific blocks via admin API
  • +
+
+

Architecture

+
graph LR
+    A[(PageBlock Table)] -->|GET /api/page-blocks| B[API Service]
+    B --> C[LandingPageEditor]
+    C --> D[GrapesJSEditor]
+    D --> E[BlockManager]
+    E --> F[Left Panel]
+
+    G[Admin] -->|POST /api/page-blocks| B
+    G -->|Define Schema| H[JSON Schema]
+    G -->|Set Defaults| I[Default Values]
+    H --> A
+    I --> A
+
+    style A fill:#3498db
+    style E fill:#9d4edd
+    style F fill:#2ecc71
+

Flow:

+
    +
  1. Seed: Default blocks created in api/prisma/seed.ts
  2. +
  3. Fetch: Editor loads all blocks via GET /api/page-blocks
  4. +
  5. Register: GrapesJSEditor registers each block with BlockManager
  6. +
  7. Render: Blocks appear in left panel (grouped by category)
  8. +
  9. Customize: Admin creates custom blocks via API (future enhancement)
  10. +
+
+

Database Model

+

PageBlock Table

+

Schema:

+
model PageBlock {
+  id         String   @id @default(uuid())
+  type       String   @unique // Block type identifier (e.g., 'hero', 'text')
+  label      String   // Display name in editor ("Hero Section")
+  category   String?  // Group blocks ("Headers", "Content", "Actions")
+  sortOrder  Int      @default(0) // Position in left panel
+  schema     Json     // JSON schema for configurable properties
+  defaults   Json     // Default values for schema fields
+  thumbnail  String?  // Preview image URL (future enhancement)
+  createdAt  DateTime @default(now())
+  updatedAt  DateTime @updatedAt
+
+  @@index([category, sortOrder])
+}
+
+

Fields:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString (UUID)Primary key
typeStringUnique identifier (e.g., "hero", "features")
labelStringHuman-readable name shown in editor
categoryString?Group blocks in collapsible sections
sortOrderIntOrder within category (lower = higher in list)
schemaJSONProperty definitions (field name, type, label)
defaultsJSONDefault values for each schema field
thumbnailString?Preview image URL (not implemented)
+

Indexes:

+
    +
  • type (unique)
  • +
  • category + sortOrder (composite, for sorted listing)
  • +
+
+

Default Blocks

+

1. Hero Section

+

Type: hero

+

Category: Headers

+

Schema:

+
{
+  "title": { "type": "string", "label": "Title" },
+  "subtitle": { "type": "string", "label": "Subtitle" },
+  "backgroundImage": { "type": "string", "label": "Background Image URL" },
+  "ctaText": { "type": "string", "label": "Button Text" },
+  "ctaUrl": { "type": "string", "label": "Button URL" }
+}
+
+

Defaults:

+
{
+  "title": "Welcome to Our Campaign",
+  "subtitle": "Join us in making a difference in your community.",
+  "backgroundImage": "",
+  "ctaText": "Get Involved",
+  "ctaUrl": "#"
+}
+
+

Rendered HTML:

+
<section style="padding: 80px 40px; text-align: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #fff;">
+  <h1 style="font-size: 2.5rem; margin-bottom: 16px;">Welcome to Our Campaign</h1>
+  <p style="font-size: 1.25rem; opacity: 0.85; margin-bottom: 32px;">Join us in making a difference in your community.</p>
+  <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>
+</section>
+
+
+

2. Text Block

+

Type: text

+

Category: Content

+

Schema:

+
{
+  "heading": { "type": "string", "label": "Heading" },
+  "body": { "type": "text", "label": "Body Text" }
+}
+
+

Defaults:

+
{
+  "heading": "About Us",
+  "body": "Tell your story here. Explain your mission, values, and what drives your campaign forward."
+}
+
+

Rendered HTML:

+
<section style="padding: 60px 40px; max-width: 800px; margin: 0 auto;">
+  <h2 style="font-size: 1.75rem; margin-bottom: 16px;">About Us</h2>
+  <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>
+</section>
+
+
+

3. Features Grid

+

Type: features

+

Category: Content

+

Schema:

+
{
+  "features": {
+    "type": "array",
+    "label": "Features",
+    "items": {
+      "title": "string",
+      "description": "string",
+      "icon": "string"
+    }
+  }
+}
+
+

Defaults:

+
{
+  "features": [
+    { "title": "Community Action", "description": "Organize local events and initiatives.", "icon": "" },
+    { "title": "Advocacy", "description": "Email your representatives directly.", "icon": "" },
+    { "title": "Volunteer", "description": "Sign up for shifts and make a difference.", "icon": "" }
+  ]
+}
+
+

Rendered HTML:

+
<section style="padding: 60px 40px;">
+  <div style="display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;">
+    <div style="flex: 1; min-width: 250px; padding: 24px; text-align: center;">
+      <h3 style="font-size: 1.25rem; margin-bottom: 8px;">Community Action</h3>
+      <p style="opacity: 0.8;">Organize local events and initiatives.</p>
+    </div>
+    <div style="flex: 1; min-width: 250px; padding: 24px; text-align: center;">
+      <h3 style="font-size: 1.25rem; margin-bottom: 8px;">Advocacy</h3>
+      <p style="opacity: 0.8;">Email your representatives directly.</p>
+    </div>
+    <div style="flex: 1; min-width: 250px; padding: 24px; text-align: center;">
+      <h3 style="font-size: 1.25rem; margin-bottom: 8px;">Volunteer</h3>
+      <p style="opacity: 0.8;">Sign up for shifts and make a difference.</p>
+    </div>
+  </div>
+</section>
+
+
+

4. Call to Action

+

Type: cta

+

Category: Actions

+

Schema:

+
{
+  "heading": { "type": "string", "label": "Heading" },
+  "description": { "type": "string", "label": "Description" },
+  "buttonText": { "type": "string", "label": "Button Text" },
+  "buttonUrl": { "type": "string", "label": "Button URL" }
+}
+
+

Defaults:

+
{
+  "heading": "Ready to Take Action?",
+  "description": "Join thousands of community members making their voices heard.",
+  "buttonText": "Join Now",
+  "buttonUrl": "#"
+}
+
+

Rendered HTML:

+
<section style="padding: 60px 40px; text-align: center; background: linear-gradient(135deg, #9d4edd 0%, #7b2cbf 100%); color: #fff;">
+  <h2 style="font-size: 2rem; margin-bottom: 12px;">Ready to Take Action?</h2>
+  <p style="font-size: 1.1rem; margin-bottom: 24px; opacity: 0.9;">Join thousands of community members making their voices heard.</p>
+  <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>
+</section>
+
+
+

5. Testimonials

+

Type: testimonials

+

Category: Content

+

Schema:

+
{
+  "quotes": {
+    "type": "array",
+    "label": "Quotes",
+    "items": {
+      "text": "string",
+      "author": "string",
+      "role": "string"
+    }
+  }
+}
+
+

Defaults:

+
{
+  "quotes": [
+    { "text": "This platform made it so easy to contact my representatives.", "author": "Jane D.", "role": "Community Member" },
+    { "text": "I signed up for a volunteer shift and it changed my perspective.", "author": "Mark S.", "role": "Volunteer" }
+  ]
+}
+
+

Rendered HTML:

+
<section style="padding: 60px 40px;">
+  <div style="display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;">
+    <div style="flex: 1; min-width: 280px; padding: 24px; background: rgba(255,255,255,0.05); border-radius: 8px;">
+      <p style="font-style: italic; margin-bottom: 12px;">"This platform made it so easy to contact my representatives."</p>
+      <p style="font-weight: 600; margin-bottom: 2px;">Jane D.</p>
+      <p style="font-size: 0.85rem; opacity: 0.7;">Community Member</p>
+    </div>
+    <div style="flex: 1; min-width: 280px; padding: 24px; background: rgba(255,255,255,0.05); border-radius: 8px;">
+      <p style="font-style: italic; margin-bottom: 12px;">"I signed up for a volunteer shift and it changed my perspective."</p>
+      <p style="font-weight: 600; margin-bottom: 2px;">Mark S.</p>
+      <p style="font-size: 0.85rem; opacity: 0.7;">Volunteer</p>
+    </div>
+  </div>
+</section>
+
+
+

6. Contact Form

+

Type: contact-form

+

Category: Actions

+

Schema:

+
{
+  "heading": { "type": "string", "label": "Heading" },
+  "fields": {
+    "type": "array",
+    "label": "Fields",
+    "items": {
+      "name": "string",
+      "type": "string",
+      "required": "boolean"
+    }
+  }
+}
+
+

Defaults:

+
{
+  "heading": "Get in Touch",
+  "fields": [
+    { "name": "name", "type": "text", "required": true },
+    { "name": "email", "type": "email", "required": true },
+    { "name": "message", "type": "textarea", "required": true }
+  ]
+}
+
+

Rendered HTML:

+
<section style="padding: 60px 40px; max-width: 600px; margin: 0 auto;">
+  <h2 style="text-align: center; margin-bottom: 24px;">Get in Touch</h2>
+  <form style="display: flex; flex-direction: column; gap: 16px;">
+    <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;" />
+    <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;" />
+    <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>
+    <button type="submit" style="padding: 12px 24px; background: #9d4edd; color: #fff; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">Send Message</button>
+  </form>
+</section>
+
+

Note: Form submission not wired (static HTML). Use grapesjs-plugin-forms for backend integration.

+
+

API Endpoints

+

Admin Routes

+

Prefix: /api/page-blocks

+

Authentication: Requires admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN)

+

List Blocks

+
GET /api/page-blocks?category=Headers
+
+

Query Parameters:

+
    +
  • category (string?) — Filter by category
  • +
+

Response:

+
[
+  {
+    "id": "default-hero",
+    "type": "hero",
+    "label": "Hero Section",
+    "category": "Headers",
+    "sortOrder": 1,
+    "schema": {
+      "title": { "type": "string", "label": "Title" },
+      "subtitle": { "type": "string", "label": "Subtitle" }
+    },
+    "defaults": {
+      "title": "Welcome to Our Campaign",
+      "subtitle": "Join us in making a difference."
+    },
+    "thumbnail": null,
+    "createdAt": "2026-01-10T00:00:00Z",
+    "updatedAt": "2026-01-10T00:00:00Z"
+  }
+]
+
+

Sorting:

+
    +
  • Results ordered by category ASC, sortOrder ASC
  • +
  • Blocks in same category appear in sortOrder sequence
  • +
+

Get Block

+
GET /api/page-blocks/:id
+
+

Response: Single PageBlock object

+

Errors:

+
    +
  • 404 BLOCK_NOT_FOUND — Block doesn't exist
  • +
+

Create Block

+
POST /api/page-blocks
+Content-Type: application/json
+
+{
+  "type": "campaign-stats",
+  "label": "Campaign Stats",
+  "category": "Campaign",
+  "sortOrder": 10,
+  "schema": {
+    "volunteers": { "type": "number", "label": "Volunteers" },
+    "emails": { "type": "number", "label": "Emails Sent" }
+  },
+  "defaults": {
+    "volunteers": 1250,
+    "emails": 5400
+  }
+}
+
+

Request Body:

+
    +
  • type (string, required) — Unique type identifier (alphanumeric + hyphens)
  • +
  • label (string, required) — Display name
  • +
  • category (string?) — Group name (default: null)
  • +
  • sortOrder (number?, default: 0) — Position in list
  • +
  • schema (JSON, required) — Property definitions
  • +
  • defaults (JSON, required) — Default values matching schema
  • +
  • thumbnail (string?) — Preview image URL
  • +
+

Response: Created PageBlock object (201 status)

+

Errors:

+
    +
  • 400 VALIDATION_ERROR — Invalid schema or type collision
  • +
+

Update Block

+
PUT /api/page-blocks/:id
+Content-Type: application/json
+
+{
+  "label": "Updated Label",
+  "defaults": {
+    "volunteers": 2000
+  }
+}
+
+

Request Body: (all fields optional except constraints)

+
    +
  • type (string?) — Cannot change after creation (immutable)
  • +
  • label (string?)
  • +
  • category (string?)
  • +
  • sortOrder (number?)
  • +
  • schema (JSON?)
  • +
  • defaults (JSON?)
  • +
+

Response: Updated PageBlock object

+

Errors:

+
    +
  • 404 BLOCK_NOT_FOUND — Block doesn't exist
  • +
  • 400 VALIDATION_ERROR — Invalid schema or defaults
  • +
+

Delete Block

+
DELETE /api/page-blocks/:id
+
+

Response: 204 No Content

+

Errors:

+
    +
  • 404 BLOCK_NOT_FOUND — Block doesn't exist
  • +
+

Side Effects:

+
    +
  • Pages using this block will still render (HTML is cached)
  • +
  • Block removed from editor panel for new pages
  • +
+
+

Schema Format

+

Property Types

+

Supported Types:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeDescriptionExample
stringShort text fieldTitle, subtitle, URL
textMulti-line textBody paragraph
numberNumeric valueVolunteer count, price
booleanTrue/false toggleShow/hide element
arrayList of itemsFeatures, testimonials
+

Simple Property

+
{
+  "title": {
+    "type": "string",
+    "label": "Title"
+  }
+}
+
+

Rendered in GrapesJS: Text input labeled "Title"

+

Array Property

+
{
+  "features": {
+    "type": "array",
+    "label": "Features",
+    "items": {
+      "title": "string",
+      "description": "string",
+      "icon": "string"
+    }
+  }
+}
+
+

Rendered in GrapesJS:

+
    +
  • Repeatable item group
  • +
  • Add/remove buttons
  • +
  • Each item has 3 fields (title, description, icon)
  • +
+

Defaults Matching

+

Schema:

+
{
+  "heading": { "type": "string", "label": "Heading" },
+  "count": { "type": "number", "label": "Count" }
+}
+
+

Valid Defaults:

+
{
+  "heading": "Our Impact",
+  "count": 42
+}
+
+

Invalid Defaults:

+
{
+  "heading": 123,  // Type mismatch (should be string)
+  "count": "foo"   // Type mismatch (should be number)
+}
+
+
+

Admin Workflow

+

Using Default Blocks

+
    +
  1. Open Editor: Admin → Pages → Click "Edit" on any page
  2. +
  3. Locate Block: Left panel → Expand "Headers" category
  4. +
  5. Drag Block: Drag "Hero Section" to canvas
  6. +
  7. Configure: Click block → Right panel shows properties
  8. +
  9. Title: "Join the Movement"
  10. +
  11. Subtitle: "Together we can make a difference."
  12. +
  13. CTA Text: "Sign Up"
  14. +
  15. CTA URL: "/shifts"
  16. +
  17. Save: Press Ctrl+S → Block HTML stored in database
  18. +
+

Creating Custom Blocks

+

Note: Custom block creation UI not implemented. Use API directly.

+

Example: Campaign Stats Block

+
curl -X POST http://localhost:4000/api/page-blocks \
+  -H "Authorization: Bearer $ADMIN_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "type": "campaign-stats",
+    "label": "Campaign Stats",
+    "category": "Campaign",
+    "sortOrder": 10,
+    "schema": {
+      "volunteers": { "type": "number", "label": "Volunteers" },
+      "emails": { "type": "number", "label": "Emails Sent" },
+      "events": { "type": "number", "label": "Events" }
+    },
+    "defaults": {
+      "volunteers": 1250,
+      "emails": 5400,
+      "events": 32
+    }
+  }'
+
+

Result:

+
    +
  • New block appears in left panel under "Campaign" category
  • +
  • Dragging block inserts HTML (requires generateBlockHtml update)
  • +
+

Updating Block Defaults

+

Use Case: Update hero CTA text for all new pages

+
curl -X PUT http://localhost:4000/api/page-blocks/default-hero \
+  -H "Authorization: Bearer $ADMIN_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "defaults": {
+      "title": "Welcome to Our 2026 Campaign",
+      "subtitle": "Join us in making a difference.",
+      "ctaText": "Get Started Today",
+      "ctaUrl": "/shifts"
+    }
+  }'
+
+

Effect:

+
    +
  • New pages using hero block get updated defaults
  • +
  • Existing pages unchanged (HTML already rendered)
  • +
+
+

Code Examples

+

Fetching Blocks for Editor

+
import { api } from '@/lib/api';
+import type { PageBlock } from '@/types/api';
+
+async function loadBlocks(): Promise<PageBlock[]> {
+  const { data } = await api.get<PageBlock[]>('/page-blocks');
+  return data.sort((a, b) => {
+    // Sort by category, then sortOrder
+    const catCompare = (a.category || '').localeCompare(b.category || '');
+    return catCompare !== 0 ? catCompare : a.sortOrder - b.sortOrder;
+  });
+}
+
+

Creating Custom Block

+
async function createCampaignStatsBlock() {
+  const { data } = await api.post<PageBlock>('/page-blocks', {
+    type: 'campaign-stats',
+    label: 'Campaign Stats',
+    category: 'Campaign',
+    sortOrder: 10,
+    schema: {
+      volunteers: { type: 'number', label: 'Volunteers' },
+      emails: { type: 'number', label: 'Emails Sent' },
+      events: { type: 'number', label: 'Events' },
+    },
+    defaults: {
+      volunteers: 1250,
+      emails: 5400,
+      events: 32,
+    },
+  });
+
+  console.log('Created block:', data.id);
+  return data;
+}
+
+

Extending generateBlockHtml()

+
// In admin/src/components/GrapesJSEditor.tsx
+
+function generateBlockHtml(type: string, defaults: Record<string, unknown>): string {
+  switch (type) {
+    // ... existing cases ...
+
+    case 'campaign-stats': {
+      const volunteers = defaults.volunteers || 0;
+      const emails = defaults.emails || 0;
+      const events = defaults.events || 0;
+
+      return `
+        <section style="padding: 60px 40px; background: #f8f9fa; text-align: center;">
+          <h2 style="margin-bottom: 32px; font-size: 2rem;">Our Impact</h2>
+          <div style="display: flex; gap: 48px; justify-content: center; flex-wrap: wrap;">
+            <div>
+              <div style="font-size: 3rem; font-weight: 700; color: #9d4edd;">${(volunteers as number).toLocaleString()}</div>
+              <div style="font-size: 1rem; color: #666;">Volunteers</div>
+            </div>
+            <div>
+              <div style="font-size: 3rem; font-weight: 700; color: #9d4edd;">${(emails as number).toLocaleString()}</div>
+              <div style="font-size: 1rem; color: #666;">Emails Sent</div>
+            </div>
+            <div>
+              <div style="font-size: 3rem; font-weight: 700; color: #9d4edd;">${events}</div>
+              <div style="font-size: 1rem; color: #666;">Events</div>
+            </div>
+          </div>
+        </section>`;
+    }
+
+    default:
+      return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
+  }
+}
+
+
+

Troubleshooting

+

Problem: Block Not Appearing in Editor

+

Symptoms:

+
    +
  • Created block via API
  • +
  • Not visible in left panel
  • +
  • Other blocks show correctly
  • +
+

Causes:

+
    +
  1. GrapesJSEditor not re-fetching blocks
  2. +
  3. generateBlockHtml() missing case
  4. +
  5. Category name mismatch
  6. +
+

Solutions:

+
    +
  1. Reload editor:
  2. +
  3. Close page editor → Re-open
  4. +
  5. +

    Blocks fetched on mount

    +
  6. +
  7. +

    Add HTML generation case: +

    case 'my-new-block':
    +  return `<section>My block HTML</section>`;
    +

    +
  8. +
  9. +

    Check category: +

    SELECT category FROM page_blocks WHERE type = 'my-new-block';
    +-- Category should match GrapesJS panel (case-sensitive)
    +

    +
  10. +
  11. +

    Verify API response: +

    curl -H "Authorization: Bearer $TOKEN" http://localhost:4000/api/page-blocks
    +# Should include new block in response
    +

    +
  12. +
+
+

Problem: Default Values Not Applying

+

Symptoms:

+
    +
  • Drag block to canvas → Fields are empty
  • +
  • Expected pre-filled title/subtitle
  • +
+

Causes:

+
    +
  1. Defaults not matching schema keys
  2. +
  3. HTML template ignores defaults
  4. +
  5. Type mismatch (string vs number)
  6. +
+

Solutions:

+
    +
  1. +

    Verify defaults match schema: +

    // Schema
    +{ "title": { "type": "string" } }
    +
    +// Defaults (good)
    +{ "title": "Welcome" }
    +
    +// Defaults (bad - key mismatch)
    +{ "heading": "Welcome" }
    +

    +
  2. +
  3. +

    Check HTML template: +

    // Good - uses defaults
    +return `<h1>${defaults.title || 'Fallback'}</h1>`;
    +
    +// Bad - ignores defaults
    +return `<h1>Hardcoded Title</h1>`;
    +

    +
  4. +
  5. +

    Fix type mismatch: +

    // If schema says "number", defaults must be number
    +{ "count": { "type": "number" } }
    +{ "count": 42 }  // Good
    +{ "count": "42" } // Bad
    +

    +
  6. +
+
+

Problem: Block HTML Not Rendering

+

Symptoms:

+
    +
  • Block appears in panel
  • +
  • Dragging to canvas shows nothing or error
  • +
+

Causes:

+
    +
  1. generateBlockHtml() returns invalid HTML
  2. +
  3. Inline styles have syntax errors
  4. +
  5. Missing closing tags
  6. +
+

Solutions:

+
    +
  1. +

    Validate HTML: +

    const html = generateBlockHtml('my-block', defaults);
    +console.log(html); // Check for malformed tags
    +

    +
  2. +
  3. +

    Test inline styles: +

    <!-- Bad - missing quotes -->
    +<div style=padding: 20px>
    +
    +<!-- Good - quoted attribute -->
    +<div style="padding: 20px;">
    +

    +
  4. +
  5. +

    Use template literals carefully: +

    // Ensure all ${} expressions return strings
    +return `<div>${defaults.title || ''}</div>`;
    +

    +
  6. +
+
+

Performance Considerations

+

Block Count Impact

+

Threshold: 50+ blocks in library

+

Symptoms:

+
    +
  • Slow editor initialization (~1s+)
  • +
  • Left panel laggy on scroll
  • +
+

Mitigations:

+
    +
  1. Category filtering:
  2. +
  3. Only fetch blocks for specific category
  4. +
  5. +

    Lazy-load categories on expand

    +
  6. +
  7. +

    Pagination:

    +
  8. +
  9. Load first 20 blocks, fetch more on scroll
  10. +
  11. +

    Not implemented in current version

    +
  12. +
  13. +

    Caching:

    +
  14. +
  15. Store blocks in localStorage
  16. +
  17. Refresh only when version changes
  18. +
+

Schema Complexity

+

Issue: Deeply nested array schemas (3+ levels) slow GrapesJS rendering

+

Example:

+
{
+  "sections": {
+    "type": "array",
+    "items": {
+      "features": {
+        "type": "array",
+        "items": {
+          "details": {
+            "type": "array"
+          }
+        }
+      }
+    }
+  }
+}
+
+

Alternative: Flatten structure or use CODE mode

+
+

Security Considerations

+

Admin-Only Access

+

Protection: All /api/page-blocks endpoints require admin role

+
router.use(authenticate);
+router.use(requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN));
+
+

Risk: Malicious admin creates XSS block with <script> tags

+

Mitigation:

+
    +
  • Accepted risk: Admins are trusted users
  • +
  • Blocks only render on admin-authored pages (not user-submitted)
  • +
  • Public pages use admin-created HTML (already trusted)
  • +
+

Type Validation

+

Attack: Submit block with type containing SQL injection

+

Protection:

+
// Zod schema in pages.schemas.ts
+type: z.string()
+  .min(1)
+  .max(50)
+  .regex(/^[a-z0-9-]+$/, 'Type must be lowercase alphanumeric with hyphens'),
+
+

Safe types: hero, text-block, campaign-stats-2026

+

Rejected: '; DROP TABLE--, <script>alert(1)</script>

+
+ +

Frontend Components

+ +

Backend Modules

+ +

Database

+ +

Features

+ +

Seed Data

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/pages/grapes-editor/index.html b/mkdocs/site/v2/features/pages/grapes-editor/index.html new file mode 100644 index 00000000..0b6fa03d --- /dev/null +++ b/mkdocs/site/v2/features/pages/grapes-editor/index.html @@ -0,0 +1,6889 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GrapesJS Editor - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

GrapesJS Editor Integration

+

React wrapper component for GrapesJS WYSIWYG editor with forwardRef pattern, custom block registration, and keyboard shortcuts.

+
+

Overview

+

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.

+

Key Features

+
    +
  • forwardRef Pattern: Parent components trigger save via ref handle
  • +
  • Custom Block Library: Register campaign-specific blocks from database
  • +
  • Plugin Ecosystem: 10+ GrapesJS plugins pre-configured
  • +
  • Keyboard Shortcuts: Ctrl+S (Cmd+S on Mac) to save
  • +
  • Error Boundary: Graceful fallback on initialization failure
  • +
  • Mobile Detection: Desktop-only warning for small screens
  • +
  • Video Block Support: Placeholder generation for media library videos
  • +
+
+

Architecture

+
graph TD
+    A[LandingPageEditor] -->|ref| B[GrapesJSEditor]
+    B -->|useImperativeHandle| C[triggerSave handle]
+    B --> D[grapesjs.init]
+    D --> E[Load Plugins]
+    E --> F[Register Custom Blocks]
+    F --> G[Load Initial Data]
+    G --> H[Canvas Ready]
+
+    A -->|handleSave| I[editorRef.current.triggerSave]
+    I --> J[Commands.run save-page]
+    J --> K[getProjectData + getHtml + getCss]
+    K --> L[onSave callback]
+    L --> M[API PUT /pages/:id]
+
+    style B fill:#9d4edd
+    style D fill:#3498db
+    style M fill:#2ecc71
+

Flow:

+
    +
  1. Mount: LandingPageEditor creates ref, renders GrapesJSEditor
  2. +
  3. Init: GrapesJSEditor calls grapesjs.init() → Loads plugins
  4. +
  5. Blocks: Registers custom blocks from PageBlock library
  6. +
  7. Data: Loads initialData (GrapesJS projectData JSON)
  8. +
  9. Expose: useImperativeHandle exposes triggerSave() method
  10. +
  11. Save: Parent calls editorRef.current.triggerSave() → Runs save-page command
  12. +
  13. Callback: GrapesJS extracts HTML/CSS → Calls onSave() → Parent saves to API
  14. +
+
+

Component API

+

Props

+
interface GrapesJSEditorProps {
+  initialData?: Record<string, unknown>;
+  onSave: (data: { projectData: Record<string, unknown>; html: string; css: string }) => void;
+  customBlocks?: PageBlock[];
+}
+
+

Fields:

+
    +
  • initialData (optional): GrapesJS projectData JSON from previous save
  • +
  • Contains components tree, styles, assets
  • +
  • Empty object {} for new pages
  • +
  • onSave (required): Callback when save triggered
  • +
  • Receives { projectData, html, css }
  • +
  • Parent responsibility: Send to API
  • +
  • customBlocks (optional): Array of PageBlock records from database
  • +
  • Registered as draggable blocks in left panel
  • +
  • See Block Library for schema
  • +
+

Ref Handle

+
interface GrapesJSEditorHandle {
+  triggerSave: () => void;
+}
+
+

Method:

+
    +
  • triggerSave(): Programmatically trigger save command
  • +
  • Extracts current editor state
  • +
  • Calls onSave callback
  • +
  • Used by parent's "Save" button or keyboard shortcut
  • +
+

Usage Example

+
import { useRef } from 'react';
+import GrapesJSEditor, { GrapesJSEditorHandle } from '@/components/GrapesJSEditor';
+
+function MyEditor() {
+  const editorRef = useRef<GrapesJSEditorHandle>(null);
+
+  const handleSave = async (data) => {
+    await api.put('/pages/123', {
+      blocks: data.projectData,
+      htmlOutput: data.html,
+      cssOutput: data.css,
+    });
+  };
+
+  const handleManualSave = () => {
+    editorRef.current?.triggerSave();
+  };
+
+  return (
+    <div>
+      <button onClick={handleManualSave}>Save</button>
+      <GrapesJSEditor
+        ref={editorRef}
+        initialData={page.blocks}
+        onSave={handleSave}
+        customBlocks={blocks}
+      />
+    </div>
+  );
+}
+
+
+

GrapesJS Configuration

+

Initialization Options

+
const editor = grapesjs.init({
+  container: containerRef.current,
+  height: '100%',
+  width: 'auto',
+  storageManager: false, // No localStorage persistence (managed by API)
+  plugins: [
+    blocksBasicPlugin,
+    presetWebpagePlugin,
+    formsPlugin,
+    navbarPlugin,
+    countdownPlugin,
+    tabsPlugin,
+    typedPlugin,
+    customCodePlugin,
+    exportPlugin,
+    styleGradientPlugin,
+    touchPlugin,
+  ],
+  pluginsOpts: {
+    [blocksBasicPlugin]: { flexGrid: true },
+    // ... other plugin options
+  },
+  canvas: {
+    styles: [
+      'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',
+    ],
+  },
+});
+
+

Key Settings:

+
    +
  • storageManager: false: Disables auto-save to localStorage (we use API persistence)
  • +
  • height: '100%': Fills parent container (full-screen editor)
  • +
  • canvas.styles: Injects Google Fonts into preview iframe
  • +
+

Plugins Ecosystem

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PluginPurposeFeatures
grapesjs-blocks-basicBasic blocksSection, text, image, video, map, link, flexGrid
grapesjs-preset-webpageFull page presetsHeader, footer, hero templates
grapesjs-plugin-formsForm componentsInput, textarea, select, button, checkbox, radio
grapesjs-navbarNavigation barsResponsive navbar with dropdowns
grapesjs-component-countdownCountdown timersEvent countdown with custom styling
grapesjs-tabsTab panelsHorizontal/vertical tab containers
grapesjs-typedTyping animationTypewriter text effect
grapesjs-custom-codeEmbed raw HTML/JSCustom code blocks (advanced users)
grapesjs-plugin-exportExport templatesZIP download of HTML/CSS/assets
grapesjs-style-gradientGradient editorVisual gradient picker for backgrounds
grapesjs-touchTouch supportMobile/tablet drag-and-drop (experimental)
+

Installation:

+
cd admin && npm install \
+  grapesjs \
+  grapesjs-blocks-basic \
+  grapesjs-preset-webpage \
+  grapesjs-plugin-forms \
+  grapesjs-navbar \
+  grapesjs-component-countdown \
+  grapesjs-tabs \
+  grapesjs-typed \
+  grapesjs-custom-code \
+  grapesjs-plugin-export \
+  grapesjs-style-gradient \
+  grapesjs-touch
+
+
+

Custom Blocks Registration

+

Block Registration Flow

+
sequenceDiagram
+    participant API as API Database
+    participant Parent as LandingPageEditor
+    participant Editor as GrapesJSEditor
+    participant GJS as GrapesJS
+
+    Parent->>API: GET /api/page-blocks
+    API-->>Parent: PageBlock[]
+    Parent->>Editor: <GrapesJSEditor customBlocks={blocks} />
+    Editor->>Editor: useEffect(() => init)
+    Editor->>GJS: grapesjs.init()
+    GJS-->>Editor: editor instance
+    Editor->>Editor: Register custom blocks loop
+    loop For each block
+        Editor->>Editor: generateBlockHtml(type, defaults)
+        Editor->>GJS: BlockManager.add(id, config)
+    end
+    GJS-->>Editor: Blocks ready
+

Block Generation Logic

+
// From GrapesJSEditor.tsx
+const blockManager = editor.Blocks;
+for (const block of customBlocks) {
+  const defaults = block.defaults as Record<string, unknown>;
+  const html = generateBlockHtml(block.type, defaults);
+
+  blockManager.add(`custom-${block.type}`, {
+    label: block.label,
+    category: block.category || 'Campaign',
+    content: html,
+  });
+}
+
+

Example Block:

+
// From seed.ts
+{
+  id: 'default-hero',
+  type: 'hero',
+  label: 'Hero Section',
+  category: 'Headers',
+  defaults: {
+    title: 'Welcome to Our Campaign',
+    subtitle: 'Join us in making a difference.',
+    ctaText: 'Get Involved',
+    ctaUrl: '#',
+  },
+}
+
+

Generated HTML:

+
<section style="padding: 80px 40px; text-align: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #fff;">
+  <h1 style="font-size: 2.5rem; margin-bottom: 16px;">Welcome to Our Campaign</h1>
+  <p style="font-size: 1.25rem; opacity: 0.85; margin-bottom: 32px;">Join us in making a difference.</p>
+  <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>
+</section>
+
+

Built-In Block Templates

+

1. Hero Section

+
case 'hero':
+  return `
+    <section style="padding: 80px 40px; text-align: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #fff;">
+      <h1 style="font-size: 2.5rem; margin-bottom: 16px;">${defaults.title || 'Hero Title'}</h1>
+      <p style="font-size: 1.25rem; opacity: 0.85; margin-bottom: 32px;">${defaults.subtitle || 'Subtitle text here'}</p>
+      <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>
+    </section>`;
+
+

2. Text Block

+
case 'text':
+  return `
+    <section style="padding: 60px 40px; max-width: 800px; margin: 0 auto;">
+      <h2 style="font-size: 1.75rem; margin-bottom: 16px;">${defaults.heading || 'Heading'}</h2>
+      <p style="font-size: 1rem; line-height: 1.7; opacity: 0.85;">${defaults.body || 'Body text goes here.'}</p>
+    </section>`;
+
+

3. Features Grid

+
case 'features': {
+  const features = (defaults.features as Array<{ title: string; description: string }>) || [];
+  const featureHtml = features.map(f => `
+    <div style="flex: 1; min-width: 250px; padding: 24px; text-align: center;">
+      <h3 style="font-size: 1.25rem; margin-bottom: 8px;">${f.title}</h3>
+      <p style="opacity: 0.8;">${f.description}</p>
+    </div>`).join('');
+
+  return `
+    <section style="padding: 60px 40px;">
+      <div style="display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;">
+        ${featureHtml}
+      </div>
+    </section>`;
+}
+
+

4. Call to Action

+
case 'cta':
+  return `
+    <section style="padding: 60px 40px; text-align: center; background: linear-gradient(135deg, #9d4edd 0%, #7b2cbf 100%); color: #fff;">
+      <h2 style="font-size: 2rem; margin-bottom: 12px;">${defaults.heading || 'Call to Action'}</h2>
+      <p style="font-size: 1.1rem; margin-bottom: 24px; opacity: 0.9;">${defaults.description || 'Description here'}</p>
+      <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>
+    </section>`;
+
+

5. Video Block

+
case 'video': {
+  const videoId = defaults.videoId || 'PLACEHOLDER';
+  const playerType = defaults.playerType || 'standard';
+
+  return `
+    <section style="padding: 60px 40px;">
+      <div class="video-block"
+           data-video-id="${videoId}"
+           data-player-type="${playerType}"
+           data-autoplay="${defaults.autoplay || false}"
+           data-controls="${defaults.controls !== false}"
+           data-show-reactions="${defaults.showReactions !== false}"
+           style="max-width: 100%; margin: 0 auto;">
+        <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;">
+          <div style="text-align: center; color: #fff; padding: 24px;">
+            <svg style="width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.9;" fill="currentColor" viewBox="0 0 20 20">
+              <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" />
+            </svg>
+            <p style="margin: 0; font-size: 1.1rem; font-weight: 600;">Video Player</p>
+            <p style="margin: 8px 0 0; font-size: 0.9rem; opacity: 0.8;">ID: ${videoId}</p>
+            <p style="margin: 4px 0 0; font-size: 0.85rem; opacity: 0.7;">${playerType === 'advanced' ? 'Advanced Player (with reactions)' : 'Standard HTML5 Player'}</p>
+            <p style="margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Video will render on published page</p>
+          </div>
+        </div>
+      </div>
+    </section>`;
+}
+
+
+

Save Command Integration

+

Command Registration

+
// In useEffect() after editor init
+editor.Commands.add('save-page', {
+  run(ed: Editor) {
+    const projectData = ed.getProjectData() as Record<string, unknown>;
+    const html = ed.getHtml();
+    const css = ed.getCss() || '';
+    onSaveRef.current({ projectData, html, css });
+  },
+});
+
+

Why onSaveRef?

+
    +
  • Avoids stale closure over onSave prop
  • +
  • Parent can update callback without re-initializing editor
  • +
  • Pattern: const onSaveRef = useRef(onSave); onSaveRef.current = onSave;
  • +
+

Keyboard Shortcut

+
const handleKeyDown = (e: KeyboardEvent) => {
+  if ((e.ctrlKey || e.metaKey) && e.key === 's') {
+    e.preventDefault();
+    editor.runCommand('save-page');
+  }
+};
+
+document.addEventListener('keydown', handleKeyDown);
+
+// Cleanup
+return () => {
+  document.removeEventListener('keydown', handleKeyDown);
+  editor.destroy();
+};
+
+

Shortcuts:

+
    +
  • Windows/Linux: Ctrl+S
  • +
  • macOS: Cmd+S
  • +
+

Behavior:

+
    +
  • Prevents browser's default "Save Page As..." dialog
  • +
  • Triggers GrapesJS save command
  • +
  • Calls onSave callback with current state
  • +
+
+

forwardRef Pattern

+

Implementation

+
const GrapesJSEditor = forwardRef<GrapesJSEditorHandle, GrapesJSEditorProps>(
+  function GrapesJSEditor({ initialData, onSave, customBlocks }, ref) {
+    const editorRef = useRef<Editor | null>(null);
+
+    useImperativeHandle(ref, () => ({
+      triggerSave() {
+        editorRef.current?.runCommand('save-page');
+      },
+    }));
+
+    // ... rest of component
+  }
+);
+
+

Parent Usage

+
// In LandingPageEditor.tsx
+import { useRef } from 'react';
+
+const editorRef = useRef<GrapesJSEditorHandle>(null);
+
+const handleManualSave = () => {
+  editorRef.current?.triggerSave(); // Programmatic save
+};
+
+return (
+  <div>
+    <button onClick={handleManualSave}>Save</button>
+    <GrapesJSEditor ref={editorRef} onSave={handleSave} />
+  </div>
+);
+
+

Why forwardRef?

+
    +
  • Decouples save trigger from GrapesJS internals
  • +
  • Parent controls when to save (toolbar button, auto-save timer, etc.)
  • +
  • Cleaner API than prop drilling onManualSave callback
  • +
+
+

Error Handling

+

Error Boundary State

+
const [error, setError] = useState<string | null>(null);
+
+try {
+  editor = grapesjs.init({ /* ... */ });
+} catch (err) {
+  console.error('GrapesJS init error:', err);
+  setError('Failed to initialize the page editor. Please refresh the page.');
+  return;
+}
+
+if (error) {
+  return (
+    <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ff4d4f' }}>
+      {error}
+    </div>
+  );
+}
+
+

Failure Modes:

+
    +
  1. Missing plugin: GrapesJS throws error during init()
  2. +
  3. Browser incompatibility: Old browser doesn't support ES6 modules
  4. +
  5. Memory exhaustion: Very large initialData crashes tab
  6. +
+

Recovery:

+
    +
  • Error state shows user-friendly message
  • +
  • No infinite re-render (error doesn't trigger re-init)
  • +
  • User can refresh page or report issue
  • +
+

Parent-Level Fallback

+
// In LandingPageEditor.tsx
+import { ErrorBoundary } from 'react-error-boundary';
+
+<ErrorBoundary
+  fallback={<div>Editor failed to load. Please try CODE mode.</div>}
+  onReset={() => navigate('/app/pages')}
+>
+  <GrapesJSEditor ref={editorRef} onSave={handleSave} />
+</ErrorBoundary>
+
+

Cascade:

+
    +
  1. GrapesJS init error → Internal error state
  2. +
  3. React render error → ErrorBoundary catches
  4. +
  5. User sees fallback → Can switch to CODE mode
  6. +
+
+

Mobile Detection

+

Desktop-Only Warning

+

Location: LandingPageEditor.tsx (parent component)

+
import { Grid } from 'antd';
+
+const screens = Grid.useBreakpoint();
+const isMobile = !screens.md; // md = 768px
+
+if (isMobile) {
+  return (
+    <Result
+      status="warning"
+      title="Desktop Required"
+      subTitle="The page editor requires a desktop or tablet device (minimum 768px width)."
+      extra={<Button onClick={() => navigate('/app/pages')}>Back to Pages</Button>}
+    />
+  );
+}
+
+

Why desktop-only?

+
    +
  • GrapesJS drag-and-drop requires precise mouse interactions
  • +
  • Small screens can't fit 3-panel layout (blocks, canvas, properties)
  • +
  • Touch support experimental (grapesjs-touch plugin unstable)
  • +
+

Alternative for mobile admins:

+
    +
  • Use CODE mode (Monaco editor works on mobile)
  • +
  • Edit on desktop, preview on mobile
  • +
  • Use responsive design testing tools
  • +
+
+

Data Flow Patterns

+

Initial Load

+
sequenceDiagram
+    participant DB as Database
+    participant API as API Service
+    participant Parent as LandingPageEditor
+    participant Editor as GrapesJSEditor
+    participant GJS as GrapesJS
+
+    Parent->>API: GET /api/pages/:id
+    API->>DB: SELECT blocks FROM landing_pages
+    DB-->>API: { blocks: {...} }
+    API-->>Parent: LandingPage JSON
+    Parent->>Editor: <GrapesJSEditor initialData={page.blocks} />
+    Editor->>GJS: editor.loadProjectData(initialData)
+    GJS-->>Editor: Canvas rendered
+

Key Points:

+
    +
  • blocks field contains full GrapesJS projectData (components tree, styles, assets)
  • +
  • Empty object {} for new pages (GrapesJS shows blank canvas)
  • +
  • Large JSON (50KB+) loads in ~200ms
  • +
+

Save Flow

+
sequenceDiagram
+    participant User as User
+    participant Parent as LandingPageEditor
+    participant Editor as GrapesJSEditor
+    participant GJS as GrapesJS
+    participant API as API
+
+    User->>User: Press Ctrl+S
+    User->>Editor: KeyboardEvent
+    Editor->>GJS: runCommand('save-page')
+    GJS->>GJS: getProjectData()
+    GJS->>GJS: getHtml()
+    GJS->>GJS: getCss()
+    GJS-->>Editor: { projectData, html, css }
+    Editor->>Parent: onSave(data)
+    Parent->>API: PUT /api/pages/:id
+    API-->>Parent: 200 OK
+    Parent->>User: "Page saved" notification
+

Critical Detail:

+
    +
  • getProjectData() returns full editor state (for future edits)
  • +
  • getHtml() returns rendered HTML (for public display)
  • +
  • getCss() returns compiled CSS (for public display)
  • +
  • All three saved to database (different use cases)
  • +
+
+

Code Examples

+

Complete Integration Example

+
// admin/src/pages/LandingPageEditor.tsx
+import { useState, useEffect, useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Button, message, Spin } from 'antd';
+import { api } from '@/lib/api';
+import GrapesJSEditor, { GrapesJSEditorHandle } from '@/components/GrapesJSEditor';
+import type { LandingPage, PageBlock } from '@/types/api';
+
+interface LandingPageEditorProps {
+  pageId: string;
+  onClose: () => void;
+}
+
+export default function LandingPageEditor({ pageId, onClose }: LandingPageEditorProps) {
+  const navigate = useNavigate();
+  const editorRef = useRef<GrapesJSEditorHandle>(null);
+  const [page, setPage] = useState<LandingPage | null>(null);
+  const [blocks, setBlocks] = useState<PageBlock[]>([]);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    const fetchData = async () => {
+      try {
+        const [pageRes, blocksRes] = await Promise.all([
+          api.get<LandingPage>(`/pages/${pageId}`),
+          api.get<PageBlock[]>('/page-blocks'),
+        ]);
+        setPage(pageRes.data);
+        setBlocks(blocksRes.data);
+      } catch {
+        message.error('Failed to load page');
+        onClose();
+      } finally {
+        setLoading(false);
+      }
+    };
+    fetchData();
+  }, [pageId, onClose]);
+
+  const handleSave = async (data: { projectData: any; html: string; css: string }) => {
+    try {
+      await api.put(`/pages/${pageId}`, {
+        blocks: data.projectData,
+        htmlOutput: data.html,
+        cssOutput: data.css,
+      });
+      message.success('Page saved');
+    } catch {
+      message.error('Failed to save page');
+    }
+  };
+
+  const handleManualSave = () => {
+    editorRef.current?.triggerSave();
+  };
+
+  if (loading) return <Spin size="large" />;
+  if (!page) return null;
+
+  return (
+    <div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
+      <div style={{ padding: '12px 16px', borderBottom: '1px solid #d9d9d9' }}>
+        <Button onClick={onClose}>Back</Button>
+        <Button type="primary" onClick={handleManualSave} style={{ marginLeft: 8 }}>
+          Save (Ctrl+S)
+        </Button>
+      </div>
+      <GrapesJSEditor
+        ref={editorRef}
+        initialData={page.blocks as Record<string, unknown>}
+        onSave={handleSave}
+        customBlocks={blocks}
+      />
+    </div>
+  );
+}
+
+

Custom Block Registration

+
// Add a custom "Campaign Stats" block
+const campaignStatsBlock: PageBlock = {
+  id: 'custom-campaign-stats',
+  type: 'campaign-stats',
+  label: 'Campaign Stats',
+  category: 'Campaign',
+  sortOrder: 10,
+  schema: {
+    volunteers: { type: 'number', label: 'Volunteers' },
+    emails: { type: 'number', label: 'Emails Sent' },
+    events: { type: 'number', label: 'Events' },
+  },
+  defaults: {
+    volunteers: 1250,
+    emails: 5400,
+    events: 32,
+  },
+};
+
+// GrapesJSEditor will auto-register via generateBlockHtml()
+<GrapesJSEditor customBlocks={[campaignStatsBlock, ...otherBlocks]} />
+
+

Adding Custom HTML Generation

+
// In GrapesJSEditor.tsx generateBlockHtml() function
+case 'campaign-stats': {
+  const volunteers = defaults.volunteers || 0;
+  const emails = defaults.emails || 0;
+  const events = defaults.events || 0;
+
+  return `
+    <section style="padding: 60px 40px; background: #f8f9fa; text-align: center;">
+      <h2 style="margin-bottom: 32px; font-size: 2rem;">Our Impact</h2>
+      <div style="display: flex; gap: 48px; justify-content: center; flex-wrap: wrap;">
+        <div>
+          <div style="font-size: 3rem; font-weight: 700; color: #9d4edd;">${volunteers.toLocaleString()}</div>
+          <div style="font-size: 1rem; color: #666;">Volunteers</div>
+        </div>
+        <div>
+          <div style="font-size: 3rem; font-weight: 700; color: #9d4edd;">${emails.toLocaleString()}</div>
+          <div style="font-size: 1rem; color: #666;">Emails Sent</div>
+        </div>
+        <div>
+          <div style="font-size: 3rem; font-weight: 700; color: #9d4edd;">${events}</div>
+          <div style="font-size: 1rem; color: #666;">Events</div>
+        </div>
+      </div>
+    </section>`;
+}
+
+
+

Troubleshooting

+

Problem: Blocks Not Appearing in Left Panel

+

Symptoms:

+
    +
  • Custom blocks array passed to GrapesJSEditor
  • +
  • Left panel shows default blocks only
  • +
  • No campaign-specific blocks
  • +
+

Causes:

+
    +
  1. generateBlockHtml() missing case for block type
  2. +
  3. Category name mismatch
  4. +
  5. Block registration timing issue
  6. +
+

Solutions:

+
    +
  1. +

    Add case to generateBlockHtml(): +

    case 'my-custom-block':
    +  return `<section>My custom block HTML</section>`;
    +

    +
  2. +
  3. +

    Check category: +

    // Block category: "Campaign"
    +// GrapesJS shows blocks in collapsible "Campaign" section
    +// Case-sensitive match
    +

    +
  4. +
  5. +

    Verify registration timing: +

    // Registration happens in useEffect after init
    +console.log('Registering blocks:', customBlocks.length);
    +

    +
  6. +
  7. +

    Inspect BlockManager: +

    // In browser console (after editor loads)
    +window.editor.BlockManager.getAll().forEach(b => console.log(b.id));
    +// Should include 'custom-hero', 'custom-text', etc.
    +

    +
  8. +
+
+

Problem: Save Not Triggering

+

Symptoms:

+
    +
  • Press Ctrl+S → Nothing happens
  • +
  • Manual save button doesn't work
  • +
  • onSave callback never called
  • +
+

Causes:

+
    +
  1. Keyboard event listener not registered
  2. +
  3. forwardRef not working
  4. +
  5. save-page command not registered
  6. +
+

Solutions:

+
    +
  1. +

    Check keyboard listener: +

    // In GrapesJSEditor useEffect
    +const handleKeyDown = (e: KeyboardEvent) => {
    +  console.log('Key pressed:', e.key, 'Ctrl:', e.ctrlKey);
    +  if ((e.ctrlKey || e.metaKey) && e.key === 's') {
    +    console.log('Save shortcut triggered');
    +    e.preventDefault();
    +    editor.runCommand('save-page');
    +  }
    +};
    +

    +
  2. +
  3. +

    Verify ref handle: +

    // In parent component
    +console.log('Editor ref:', editorRef.current); // Should be { triggerSave: fn }
    +

    +
  4. +
  5. +

    Test command directly: +

    // In browser console (after editor loads)
    +window.editor.runCommand('save-page');
    +// Should trigger onSave callback
    +

    +
  6. +
  7. +

    Check onSaveRef pattern: +

    const onSaveRef = useRef(onSave);
    +onSaveRef.current = onSave; // Update on every render
    +

    +
  8. +
+
+

Problem: Editor Crashes on Large Pages

+

Symptoms:

+
    +
  • Loading page with 100+ components → Tab freezes
  • +
  • GrapesJS UI unresponsive
  • +
  • Save takes 10+ seconds
  • +
+

Causes:

+
    +
  • Too many components in single page
  • +
  • Deep nesting (10+ levels)
  • +
  • Heavy images without lazy loading
  • +
+

Solutions:

+
    +
  1. Split into multiple pages:
  2. +
  3. Separate hero, features, testimonials into 3 pages
  4. +
  5. +

    Link pages via navigation

    +
  6. +
  7. +

    Use CODE mode for complex layouts:

    +
  8. +
  9. Write HTML directly → Faster than GrapesJS rendering
  10. +
  11. +

    Import via "Sync Overrides"

    +
  12. +
  13. +

    Optimize images:

    +
  14. +
  15. Use external CDN (not base64-encoded)
  16. +
  17. Compress before upload
  18. +
  19. +

    Lazy load below fold

    +
  20. +
  21. +

    Increase browser memory:

    +
  22. +
  23. Chrome → --max-old-space-size=4096
  24. +
  25. Edge → Similar flag
  26. +
+
+

Problem: Initial Data Not Loading

+

Symptoms:

+
    +
  • Editor opens with blank canvas
  • +
  • initialData prop has data
  • +
  • Console shows no errors
  • +
+

Causes:

+
    +
  1. loadProjectData() called before editor ready
  2. +
  3. Invalid JSON structure
  4. +
  5. Async timing issue
  6. +
+

Solutions:

+
    +
  1. +

    Check editor ready state: +

    useEffect(() => {
    +  if (!containerRef.current) return;
    +
    +  const editor = grapesjs.init({ /* ... */ });
    +
    +  // Wait for editor load event
    +  editor.on('load', () => {
    +    if (initialData && Object.keys(initialData).length > 0) {
    +      editor.loadProjectData(initialData);
    +    }
    +  });
    +}, []);
    +

    +
  2. +
  3. +

    Validate JSON: +

    console.log('Loading data:', JSON.stringify(initialData, null, 2));
    +// Should have keys: assets, styles, pages
    +

    +
  4. +
  5. +

    Handle empty data: +

    if (initialData && Object.keys(initialData).length > 0) {
    +  editor.loadProjectData(initialData);
    +} else {
    +  console.log('Starting with blank canvas');
    +}
    +

    +
  6. +
+
+

Problem: Styles Not Applying in Canvas

+

Symptoms:

+
    +
  • Drag block to canvas → No background color
  • +
  • Text has wrong font
  • +
  • Layout broken
  • +
+

Causes:

+
    +
  1. Inline styles not supported
  2. +
  3. External stylesheet missing
  4. +
  5. Canvas iframe CSP issue
  6. +
+

Solutions:

+
    +
  1. +

    Use inline styles in generateBlockHtml(): +

    // Good
    +return `<section style="padding: 40px; background: #f00;">...</section>`;
    +
    +// Bad (requires CSS injection)
    +return `<section class="hero">...</section>`;
    +

    +
  2. +
  3. +

    Inject fonts into canvas: +

    canvas: {
    +  styles: [
    +    'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',
    +  ],
    +}
    +

    +
  4. +
  5. +

    Check iframe sandbox: +

    // GrapesJS canvas uses <iframe> — ensure no sandbox restrictions
    +// Default config works, but custom CSP may block
    +

    +
  6. +
+
+

Performance Optimization

+

Lazy Loading

+
// In LandingPageEditor.tsx
+import { lazy, Suspense } from 'react';
+
+const GrapesJSEditor = lazy(() => import('@/components/GrapesJSEditor'));
+
+return (
+  <Suspense fallback={<Spin size="large" />}>
+    <GrapesJSEditor ref={editorRef} onSave={handleSave} />
+  </Suspense>
+);
+
+

Benefit: Reduces initial bundle size by ~800KB (GrapesJS + plugins)

+

Debounced Auto-Save

+
import { useRef, useEffect } from 'react';
+
+const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout>>();
+
+const handleEditorChange = () => {
+  clearTimeout(autoSaveTimerRef.current);
+  autoSaveTimerRef.current = setTimeout(() => {
+    editorRef.current?.triggerSave();
+  }, 5000); // Auto-save after 5s of inactivity
+};
+
+useEffect(() => {
+  // Listen to editor change events
+  const editor = window.editor; // Access via global (not recommended for prod)
+  editor?.on('component:update', handleEditorChange);
+  editor?.on('style:update', handleEditorChange);
+
+  return () => {
+    clearTimeout(autoSaveTimerRef.current);
+    editor?.off('component:update', handleEditorChange);
+    editor?.off('style:update', handleEditorChange);
+  };
+}, []);
+
+

Trade-off: More API calls vs. reduced data loss risk

+
+ +

Components

+ +

Features

+ +

External

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/pages/mkdocs-export/index.html b/mkdocs/site/v2/features/pages/mkdocs-export/index.html new file mode 100644 index 00000000..6e5b9868 --- /dev/null +++ b/mkdocs/site/v2/features/pages/mkdocs-export/index.html @@ -0,0 +1,6769 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MkDocs Export - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

MkDocs Export Integration

+

Export landing pages to MkDocs Material theme with Jinja2 template wrapping, front matter configuration, and synchronized stub files.

+
+

Overview

+

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.

+

Key Features

+
    +
  • Two Export Modes: THEMED (extends Material theme) vs STANDALONE (full HTML document)
  • +
  • Jinja2 Template Wrapping: Integrates with MkDocs Material theme inheritance
  • +
  • Front Matter Configuration: Control navigation, table of contents visibility
  • +
  • Dual-File Output: HTML override + Markdown stub
  • +
  • Automatic Sync: Export triggered on publish, cleanup on unpublish
  • +
  • Path Validation: Prevents directory traversal attacks
  • +
  • Stub Backfill: Repair missing files via "Validate Exports"
  • +
+
+

Architecture

+
graph TD
+    A[Admin] -->|Publish Page| B[PUT /api/pages/:id]
+    B --> C[pages.service.update]
+    C --> D{published && !skipExport?}
+    D -->|Yes| E[exportToMkDocs]
+    E --> F[wrapInMaterialOverride]
+    E --> G[generateMdStub]
+    F --> H[Write .html to overrides/]
+    G --> I[Write .md to docs/]
+
+    J[MkDocs Build] --> K[Read .md stub]
+    K --> L[Front matter: template]
+    L --> H
+    H --> M[Render with Material theme]
+    M --> N[Public site]
+
+    style E fill:#9d4edd
+    style H fill:#3498db
+    style N fill:#2ecc71
+

Flow:

+
    +
  1. Trigger: Admin publishes page (or updates published page)
  2. +
  3. Service: pages.service.update() checks publish status
  4. +
  5. Export: Calls exportToMkDocs() with page data
  6. +
  7. Wrap: HTML wrapped in Jinja2 {% extends "main.html" %}
  8. +
  9. Write: Two files created:
  10. +
  11. mkdocs/docs/overrides/{slug}.html — HTML override
  12. +
  13. mkdocs/docs/{slug}.md — Markdown stub
  14. +
  15. Build: MkDocs rebuild (mkdocs build)
  16. +
  17. Render: Stub references override, Material theme applies
  18. +
  19. Serve: Page accessible at https://cmlite.org/pages/{slug}/
  20. +
+
+

Export Modes

+

THEMED Mode (Default)

+

Purpose: Integrate page with MkDocs Material theme (header, footer, navigation)

+

Jinja2 Template:

+
{% extends "main.html" %}
+{% block content %}
+<style>
+section { padding: 40px; }
+</style>
+<section>
+  <h1>Welcome</h1>
+  <p>Page content here.</p>
+</section>
+{% endblock %}
+
+

Features:

+
    +
  • Uses Material theme header/footer
  • +
  • Respects site navigation
  • +
  • Table of contents auto-generated
  • +
  • Search integration works
  • +
  • Responsive design inherited
  • +
+

Use Cases:

+
    +
  • Documentation pages
  • +
  • Campaign info pages
  • +
  • Community guidelines
  • +
+
+

STANDALONE Mode

+

Purpose: Full control over HTML (no MkDocs chrome)

+

HTML Document:

+
<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>About Us | Campaign 2026</title>
+    <meta name="description" content="Join our movement for change.">
+    <style>
+    section { padding: 40px; }
+    </style>
+</head>
+<body>
+<section>
+  <h1>Welcome</h1>
+  <p>Page content here.</p>
+</section>
+</body>
+</html>
+
+

Features:

+
    +
  • No Material theme elements
  • +
  • Custom head section (meta tags, styles)
  • +
  • Independent from site navigation
  • +
  • Full design freedom
  • +
+

Use Cases:

+
    +
  • Marketing landing pages (like lander.html)
  • +
  • Event registration pages
  • +
  • Embedded pages (iframes)
  • +
+
+

File Outputs

+

Override File (.html)

+

Location: mkdocs/docs/overrides/{slug}.html

+

Example: mkdocs/docs/overrides/about-us.html

+

Content (THEMED mode):

+
{% extends "main.html" %}
+{% block content %}
+<style>
+/* Page CSS */
+</style>
+<!-- Page HTML -->
+{% endblock %}
+
+

Content (STANDALONE mode):

+
<!DOCTYPE html>
+<html lang="en">
+<head>
+  <title>About Us</title>
+  <style>/* Page CSS */</style>
+</head>
+<body>
+  <!-- Page HTML -->
+</body>
+</html>
+
+

Access Control:

+
    +
  • Readable by MkDocs build process
  • +
  • Not directly served (accessed via stub)
  • +
+
+

Stub File (.md)

+

Location: mkdocs/docs/{slug}.md

+

Example: mkdocs/docs/about-us.md

+

Content:

+
---
+template: about-us.html
+title: "About Us | Campaign 2026"
+description: "Join our movement for change."
+hide:
+  - navigation
+  - toc
+---
+
+

Front Matter Fields:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
templatestringOverride filename (relative to custom_dir)
titlestringPage title (from seoTitle or title)
descriptionstringMeta description (from seoDescription)
hidearrayHide navigation/toc elements
+

Important: Template path is relative to custom_dir (mkdocs/overrides/). Use about-us.html, NOT overrides/about-us.html (causes TemplateNotFound error).

+
+

Database Fields

+

LandingPage Export Configuration

+

Fields:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultDescription
mkdocsPathString?{slug}.htmlOverride filename (auto-generated from slug)
mkdocsStubPathString?{slug}.mdStub filename (derived from mkdocsPath)
mkdocsExportModeEnumTHEMEDTHEMED or STANDALONE
mkdocsHideNavBooleanfalseHide navigation sidebar (THEMED only)
mkdocsHideTocBooleanfalseHide table of contents (THEMED only)
mkdocsSkipExportBooleanfalseSkip MkDocs export entirely
+

Behavior:

+
    +
  • On publish (published=true):
  • +
  • If mkdocsSkipExport=false: Export files
  • +
  • If mkdocsSkipExport=true: No export (page only at /p/:slug)
  • +
  • On unpublish (published=false): Remove export files
  • +
  • On title change: Regenerate slug, update mkdocsPath, clean up old files
  • +
+
+

Admin Workflow

+

Exporting a Page

+

Automatic Export (on publish):

+
    +
  1. Admin → Pages → Click "Publish" button
  2. +
  3. API updates published=true
  4. +
  5. Service checks mkdocsSkipExport
  6. +
  7. If false: Calls exportToMkDocs()
  8. +
  9. Files written to disk
  10. +
  11. Database updated with mkdocsStubPath
  12. +
+

Manual Export Trigger:

+
    +
  1. Edit page settings
  2. +
  3. Change mkdocsExportMode or mkdocsHideNav
  4. +
  5. Save settings
  6. +
  7. If published: Auto re-exports
  8. +
+
+

Configuring Export Options

+

Location: Page Settings modal → MkDocs Integration section

+

Steps:

+
    +
  1. Admin → Pages → Click gear icon (Settings)
  2. +
  3. Scroll to "MkDocs Integration"
  4. +
  5. Configure options:
  6. +
  7. Skip MkDocs Export: ☐ (unchecked)
  8. +
  9. Override Path: about.html (auto-filled)
  10. +
  11. Full page MkDocs: ☐ (THEMED mode)
  12. +
  13. Hide navigation sidebar: ☑ (checked)
  14. +
  15. Hide table of contents: ☑ (checked)
  16. +
  17. Click "Save"
  18. +
  19. If published: Files re-exported immediately
  20. +
+
+

Rebuilding MkDocs Site

+

Trigger: After exporting pages

+

Methods:

+

Option 1: Admin UI

+
    +
  1. Admin → Pages → "Build Site" button (SUPER_ADMIN only)
  2. +
  3. Confirmation modal appears
  4. +
  5. Click "Confirm"
  6. +
  7. API executes docker compose exec mkdocs mkdocs build
  8. +
  9. Success notification
  10. +
+

Option 2: Command Line

+
docker compose exec mkdocs mkdocs build
+# Rebuilds site from mkdocs/docs/ directory
+# Output: mkdocs/site/ (static HTML)
+
+

Auto-rebuild: Not implemented (manual trigger required)

+
+

Syncing Overrides

+

Purpose: Import hand-coded .html files from overrides/ directory

+

Workflow:

+
    +
  1. Place .html file in mkdocs/docs/overrides/custom.html
  2. +
  3. Admin → Pages → "Sync Overrides" button
  4. +
  5. API scans directory:
  6. +
  7. Untracked files → Create CODE-mode page
  8. +
  9. Tracked CODE-mode pages → Update htmlOutput from disk
  10. +
  11. VISUAL pages → Skip (managed by GrapesJS)
  12. +
  13. Backfills missing .md stubs
  14. +
  15. Shows result: Synced: 2 imported, 1 updated, 3 stubs created
  16. +
+

Use Cases:

+
    +
  • Migrate legacy templates
  • +
  • Import designer-created HTML
  • +
  • Restore after file system corruption
  • +
+
+

Validating Exports

+

Purpose: Verify files exist on disk, repair if missing

+

Workflow:

+
    +
  1. Admin → Pages → "Validate Exports" button
  2. +
  3. API queries all published, non-skipped pages
  4. +
  5. For each page:
  6. +
  7. Check .html override exists
  8. +
  9. Check .md stub exists
  10. +
  11. If either missing: Re-export
  12. +
  13. Shows result: Validated 10 pages: 2 repaired, 0 errors
  14. +
+

Use Cases:

+
    +
  • Recover from accidental deletion
  • +
  • Fix state after container restarts
  • +
  • Audit before production deploy
  • +
+
+

Code Examples

+

Themed Mode Export

+
// From pages.service.ts
+
+function wrapInMaterialOverride(html: string, css: string | null): string {
+  const styleBlock = css ? `<style>\n${css}\n</style>` : '';
+  return `{% extends "main.html" %}
+{% block content %}
+${styleBlock}
+${html}
+{% endblock %}
+`;
+}
+
+// Usage
+const content = wrapInMaterialOverride(
+  '<section><h1>About Us</h1></section>',
+  'section { padding: 40px; }'
+);
+
+// Result:
+// {% extends "main.html" %}
+// {% block content %}
+// <style>
+// section { padding: 40px; }
+// </style>
+// <section><h1>About Us</h1></section>
+// {% endblock %}
+
+
+

Standalone Mode Export

+
function wrapInStandaloneDocument(
+  html: string,
+  css: string | null,
+  title: string,
+  description: string | null
+): string {
+  const metaDesc = description
+    ? `\n    <meta name="description" content="${description.replace(/"/g, '&quot;')}">`
+    : '';
+  const styleBlock = css ? `\n    <style>\n${css}\n    </style>` : '';
+
+  return `<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>${title.replace(/</g, '&lt;')}</title>${metaDesc}${styleBlock}
+</head>
+<body>
+${html}
+</body>
+</html>
+`;
+}
+
+// Usage
+const content = wrapInStandaloneDocument(
+  '<section><h1>About Us</h1></section>',
+  'section { padding: 40px; }',
+  'About Us | Campaign 2026',
+  'Join our movement for change.'
+);
+
+
+

Markdown Stub Generation

+
interface StubOptions {
+  overrideFilename: string;
+  title: string;
+  description: string | null;
+  hideNav: boolean;
+  hideToc: boolean;
+}
+
+function generateMdStub(opts: StubOptions): string {
+  const hideItems: string[] = [];
+  if (opts.hideNav) hideItems.push('  - navigation');
+  if (opts.hideToc) hideItems.push('  - toc');
+
+  const hideBlock = hideItems.length > 0
+    ? `hide:\n${hideItems.join('\n')}\n`
+    : '';
+
+  const descLine = opts.description
+    ? `description: "${opts.description.replace(/"/g, '\\"')}"\n`
+    : '';
+
+  return `---
+template: ${opts.overrideFilename}
+${hideBlock}title: "${opts.title.replace(/"/g, '\\"')}"
+${descLine}---
+`;
+}
+
+// Usage
+const stub = generateMdStub({
+  overrideFilename: 'about-us.html',
+  title: 'About Us | Campaign 2026',
+  description: 'Join our movement.',
+  hideNav: true,
+  hideToc: true,
+});
+
+// Result:
+// ---
+// template: about-us.html
+// hide:
+//   - navigation
+//   - toc
+// title: "About Us | Campaign 2026"
+// description: "Join our movement."
+// ---
+
+
+

Export Orchestration

+
// From pages.service.update()
+
+// After updating page in database
+if (page.published && !page.mkdocsSkipExport && page.mkdocsPath && page.htmlOutput) {
+  const stubPath = await exportToMkDocs({
+    mkdocsPath: page.mkdocsPath,
+    html: page.htmlOutput,
+    css: page.cssOutput,
+    editorMode: page.editorMode,
+    exportMode: page.mkdocsExportMode,
+    title: page.title,
+    seoTitle: page.seoTitle,
+    seoDescription: page.seoDescription,
+    hideNav: page.mkdocsHideNav,
+    hideToc: page.mkdocsHideToc,
+  });
+
+  // Store stubPath if changed
+  if (stubPath !== page.mkdocsStubPath) {
+    await prisma.landingPage.update({
+      where: { id },
+      data: { mkdocsStubPath: stubPath },
+    });
+  }
+} else if ((!page.published || page.mkdocsSkipExport) && existing.mkdocsPath) {
+  // Clean up exports on unpublish or skip
+  await removeFromMkDocs(existing.mkdocsPath, existing.mkdocsStubPath);
+
+  if (existing.mkdocsStubPath) {
+    await prisma.landingPage.update({
+      where: { id },
+      data: { mkdocsStubPath: null },
+    });
+  }
+}
+
+
+

Path Validation

+
function validateMkdocsPath(mkdocsPath: string): void {
+  // Check for null bytes
+  if (mkdocsPath.includes('\0')) {
+    throw new AppError(400, 'Invalid path: null byte detected', 'INVALID_MKDOCS_PATH');
+  }
+
+  // Normalize and check for traversal
+  const normalized = path.normalize(mkdocsPath);
+  if (normalized.includes('..') || path.isAbsolute(normalized)) {
+    throw new AppError(400, 'Path traversal not allowed', 'INVALID_MKDOCS_PATH');
+  }
+
+  // Check for encoded traversal sequences
+  if (mkdocsPath.includes('%2e') || mkdocsPath.includes('%2E')) {
+    throw new AppError(400, 'Encoded path traversal not allowed', 'INVALID_MKDOCS_PATH');
+  }
+
+  if (!mkdocsPath.endsWith('.html')) {
+    throw new AppError(400, 'Path must end with .html', 'INVALID_MKDOCS_PATH');
+  }
+}
+
+// Safe paths
+validateMkdocsPath('about.html'); // ✓
+validateMkdocsPath('pages/contact.html'); // ✓
+
+// Rejected paths
+validateMkdocsPath('../etc/passwd.html'); // ✗ Path traversal
+validateMkdocsPath('/etc/shadow.html'); // ✗ Absolute path
+validateMkdocsPath('admin%2e%2e/config.html'); // ✗ Encoded traversal
+validateMkdocsPath('about.md'); // ✗ Missing .html extension
+
+
+

MkDocs Configuration

+

mkdocs.yml Settings

+

Required Configuration:

+
site_name: Changemaker Lite
+theme:
+  name: material
+  custom_dir: overrides  # Points to mkdocs/docs/overrides/
+
+nav:
+  - Home: index.md
+  - Pages:
+    - About: about-us.md
+    - Contact: contact.md
+
+

Key Points:

+
    +
  • custom_dir: overrides — Enables template overrides
  • +
  • Stub files must be listed in nav to appear in navigation
  • +
  • Unlisted stubs still accessible via direct URL
  • +
+

Template Search Paths

+

MkDocs Material searches:

+
    +
  1. mkdocs/overrides/ (custom_dir)
  2. +
  3. Material theme templates
  4. +
  5. MkDocs core templates
  6. +
+

Resolution:

+
# In stub front matter
+template: about-us.html
+
+# MkDocs searches:
+# 1. mkdocs/overrides/about-us.html ✓ (found here)
+# 2. material/templates/about-us.html
+# 3. mkdocs/templates/about-us.html
+
+

Common Mistake:

+
# WRONG - causes TemplateNotFound
+template: overrides/about-us.html
+
+# MkDocs searches:
+# 1. mkdocs/overrides/overrides/about-us.html ✗ (not found)
+
+

Solution: Use filename only, not path with overrides/.

+
+

Troubleshooting

+

Problem: Template Not Found Error

+

Symptoms:

+
    +
  • MkDocs build fails
  • +
  • Error: jinja2.exceptions.TemplateNotFound: overrides/about-us.html
  • +
+

Causes:

+
    +
  1. Stub uses template: overrides/about-us.html (incorrect path)
  2. +
  3. custom_dir not configured in mkdocs.yml
  4. +
  5. Override file doesn't exist
  6. +
+

Solutions:

+
    +
  1. +

    Fix stub front matter: +

    # Before (wrong)
    +template: overrides/about-us.html
    +
    +# After (correct)
    +template: about-us.html
    +

    +
  2. +
  3. +

    Verify custom_dir: +

    # In mkdocs.yml
    +theme:
    +  name: material
    +  custom_dir: overrides
    +

    +
  4. +
  5. +

    Check file exists: +

    ls -la mkdocs/docs/overrides/about-us.html
    +# Should exist if page published
    +

    +
  6. +
  7. +

    Validate exports:

    +
  8. +
  9. Admin → Pages → "Validate Exports"
  10. +
  11. Repairs missing files
  12. +
+
+

Problem: Export Files Missing After Restart

+

Symptoms:

+
    +
  • Pages were published before restart
  • +
  • After docker compose restart: Files gone
  • +
  • MkDocs build fails
  • +
+

Causes:

+
    +
  1. Volume mount not configured
  2. +
  3. Files written to container filesystem (not host)
  4. +
  5. Container recreated (ephemeral storage lost)
  6. +
+

Solutions:

+
    +
  1. +

    Check volume mount: +

    # In docker-compose.yml
    +services:
    +  api:
    +    volumes:
    +      - ./mkdocs:/mkdocs:rw  # Must have :rw for write access
    +

    +
  2. +
  3. +

    Verify host files: +

    ls -la mkdocs/docs/overrides/
    +# Files should persist on host filesystem
    +

    +
  4. +
  5. +

    Re-export all pages:

    +
  6. +
  7. Admin → Pages → "Validate Exports"
  8. +
  9. Regenerates all missing files
  10. +
+
+

Problem: Page Not Appearing in MkDocs Site

+

Symptoms:

+
    +
  • Page published, files exist
  • +
  • MkDocs builds successfully
  • +
  • Page shows 404 on site
  • +
+

Causes:

+
    +
  1. Stub not listed in mkdocs.yml nav
  2. +
  3. MkDocs not rebuilt after export
  4. +
  5. Nginx cache serving old version
  6. +
+

Solutions:

+
    +
  1. +

    Add to nav (optional): +

    nav:
    +  - Pages:
    +    - About: about-us.md  # Stub filename
    +

    +
  2. +
  3. +

    Rebuild MkDocs: +

    docker compose exec mkdocs mkdocs build
    +# Or Admin → Pages → "Build Site"
    +

    +
  4. +
  5. +

    Clear Nginx cache: +

    docker compose exec nginx nginx -s reload
    +

    +
  6. +
  7. +

    Test direct access: +

    curl http://localhost:4001/pages/about-us/
    +# Should return HTML, not 404
    +

    +
  8. +
+
+

Problem: Styles Not Applying in MkDocs

+

Symptoms:

+
    +
  • Page renders in GrapesJS editor
  • +
  • MkDocs site shows unstyled content
  • +
+

Causes:

+
    +
  1. CSS not exported (CODE mode without cssOutput)
  2. +
  3. Material theme CSS conflicts
  4. +
  5. Inline styles overridden
  6. +
+

Solutions:

+
    +
  1. +

    Check cssOutput field: +

    SELECT css_output FROM landing_pages WHERE slug = 'about-us';
    +-- Should contain CSS, not NULL
    +

    +
  2. +
  3. +

    Inspect rendered HTML: +

    curl http://localhost:4001/pages/about-us/ | grep '<style>'
    +# Should include page CSS
    +

    +
  4. +
  5. +

    Use !important for overrides: +

    /* In page CSS */
    +section {
    +  padding: 40px !important;
    +}
    +

    +
  6. +
  7. +

    Test STANDALONE mode:

    +
  8. +
  9. Settings → Full page MkDocs (checked)
  10. +
  11. Bypasses Material theme CSS
  12. +
+
+

Problem: Hide Navigation Not Working

+

Symptoms:

+
    +
  • Page settings: mkdocsHideNav=true
  • +
  • Navigation sidebar still shows
  • +
+

Causes:

+
    +
  1. Stub front matter not updated
  2. +
  3. MkDocs cache not cleared
  4. +
  5. STANDALONE mode enabled (hide options ignored)
  6. +
+

Solutions:

+
    +
  1. +

    Check stub front matter: +

    cat mkdocs/docs/about-us.md
    +# Should have:
    +# hide:
    +#   - navigation
    +

    +
  2. +
  3. +

    Re-export:

    +
  4. +
  5. Edit page settings → Save
  6. +
  7. +

    Triggers stub regeneration

    +
  8. +
  9. +

    Clear MkDocs cache: +

    rm -rf mkdocs/site/
    +docker compose exec mkdocs mkdocs build
    +

    +
  10. +
  11. +

    Verify not STANDALONE:

    +
  12. +
  13. Settings → Full page MkDocs (unchecked)
  14. +
  15. STANDALONE ignores hide options
  16. +
+
+

Performance Considerations

+

File System I/O

+

Export operation: Writes 2 files per page (~1ms each)

+

Bottleneck: Synchronous file writes in API request handler

+

Impact:

+
    +
  • Publish operation: +2ms overhead
  • +
  • Batch operations: Linear scaling (10 pages = +20ms)
  • +
+

Optimization (future):

+
// Current: Synchronous writes in request
+await fs.writeFile(path, content);
+
+// Future: Background job queue
+await queue.add('export-page', { pageId });
+
+
+

MkDocs Build Time

+

Build duration: Proportional to page count

+
    +
  • 10 pages: ~2 seconds
  • +
  • 100 pages: ~10 seconds
  • +
  • 1000 pages: ~90 seconds
  • +
+

Optimization:

+
    +
  • Use mkdocs serve --dirtyreload in dev (incremental builds)
  • +
  • Production builds: Full rebuild recommended
  • +
+
+

Security Considerations

+

Path Traversal Protection

+

Validation:

+
    +
  1. Null byte check: Prevents about\0.html attacks
  2. +
  3. Normalization: path.normalize() resolves ../
  4. +
  5. Absolute path check: Rejects /etc/passwd.html
  6. +
  7. Encoded traversal: Blocks %2e%2e/admin.html
  8. +
  9. Extension validation: Must end with .html
  10. +
+

Rejected Paths:

+
    +
  • ../../../etc/passwd.html
  • +
  • /var/www/config.html
  • +
  • admin%2e%2e%2fconfig.html
  • +
  • about.md (wrong extension)
  • +
+
+

File Permission Isolation

+

Docker Volume Mount:

+
volumes:
+  - ./mkdocs:/mkdocs:rw
+
+

Permissions:

+
    +
  • API container writes as node user (UID 1000)
  • +
  • Host user must have write access to mkdocs/docs/
  • +
  • MkDocs container reads as mkdocs user (UID 1001)
  • +
+

Risk: Container escape could write arbitrary files

+

Mitigation:

+
    +
  • API container runs as non-root user
  • +
  • Volume mount scoped to /mkdocs only (no host root access)
  • +
+
+

Template Injection

+

Risk: Malicious admin injects Jinja2 code

+

Example:

+
<!-- Malicious HTML in editor -->
+<h1>{{ config.site_name }}</h1>
+
+

Rendering:

+
    +
  • THEMED mode: Jinja2 processes {{ }} expressions
  • +
  • Could expose MkDocs config or Material theme internals
  • +
+

Mitigation:

+
    +
  • Accepted risk: Admins are trusted users
  • +
  • Template code only renders in MkDocs (isolated from main app)
  • +
  • Public users cannot edit landing pages
  • +
+
+ +

Frontend Components

+ +

Backend Modules

+ +

Features

+ +

MkDocs Resources

+ +

Deployment

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/pages/page-builder/index.html b/mkdocs/site/v2/features/pages/page-builder/index.html new file mode 100644 index 00000000..a8037a2d --- /dev/null +++ b/mkdocs/site/v2/features/pages/page-builder/index.html @@ -0,0 +1,7371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page Builder - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Page Builder

+

Complete WYSIWYG landing page builder with GrapesJS editor, slug-based public routing, and MkDocs Material theme integration.

+
+

Overview

+

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.

+

Key Features

+
    +
  • Dual-Mode Editing: Switch between VISUAL (GrapesJS drag-and-drop) and CODE (raw HTML editor)
  • +
  • Slug-Based Routing: Public pages accessible at /p/:slug (e.g., /p/about-us)
  • +
  • MkDocs Export: Publish pages to MkDocs documentation site with Material theme integration
  • +
  • SEO Meta Tags: Configure title, description, and Open Graph images
  • +
  • Custom Blocks: Reusable components (hero, features, CTA, testimonials, contact forms)
  • +
  • Video Integration: Embed media library videos with standard or advanced players
  • +
  • Mobile Detection: Editor warns users on small screens (desktop-only editing)
  • +
+

Architecture Overview

+
graph LR
+    A[Admin] --> B[LandingPagesPage]
+    B --> C[Create Page Modal]
+    C --> D[LandingPageEditor]
+    D --> E[GrapesJS Editor]
+    E --> F[Save API]
+    F --> G[(LandingPage Model)]
+    G --> H[Public Route]
+    H --> I[/p/:slug]
+
+    D --> J[Publish Toggle]
+    J --> K[MkDocs Export]
+    K --> L[overrides/*.html]
+    K --> M[docs/*.md stub]
+
+    style E fill:#9d4edd
+    style G fill:#3498db
+    style K fill:#2ecc71
+

Flow:

+
    +
  1. Admin creates page via LandingPagesPage
  2. +
  3. Editor loads with GrapesJS (VISUAL mode) or Monaco (CODE mode)
  4. +
  5. Admin drags blocks, configures properties, saves (Ctrl+S)
  6. +
  7. API stores projectData (GrapesJS JSON), htmlOutput, cssOutput
  8. +
  9. On publish: API exports .html override + .md stub to MkDocs
  10. +
  11. Public users access page at /p/:slug (React route renders HTML)
  12. +
+
+

Database Models

+

LandingPage

+

Table: landing_pages

+

Key Fields:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString (UUID)Primary key
slugStringUnique URL-safe identifier (auto-generated from title)
titleStringPage title (internal + fallback SEO)
descriptionString?Page description (internal)
editorModeEnumVISUAL (GrapesJS) or CODE (raw HTML)
blocksJSONGrapesJS projectData (components tree)
htmlOutputString?Rendered HTML (cached output from editor)
cssOutputString?Rendered CSS (cached output from editor)
mkdocsPathString?Override file path (e.g., about.html)
mkdocsStubPathString?Stub Markdown path (e.g., about.md)
mkdocsExportModeEnumTHEMED (extends main.html) or STANDALONE (full HTML)
mkdocsHideNavBooleanHide navigation sidebar in MkDocs
mkdocsHideTocBooleanHide table of contents in MkDocs
mkdocsSkipExportBooleanDon't export to MkDocs (only accessible via /p/:slug)
publishedBooleanPublic visibility (false = draft)
seoTitleString?Custom SEO title (overrides title)
seoDescriptionString?Meta description for search engines
seoImageString?Open Graph image URL
createdAtDateTimeCreation timestamp
updatedAtDateTimeLast modification timestamp
+

Indexes:

+
    +
  • slug (unique)
  • +
  • published (filter index)
  • +
+

Relationships:

+
    +
  • None (standalone model)
  • +
+

PageBlock

+

See Block Library documentation.

+
+

API Endpoints

+

Admin Routes

+

Prefix: /api/pages

+

Authentication: Requires admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN)

+

List Pages

+
GET /api/pages?page=1&limit=20&search=campaign&published=true
+
+

Query Parameters:

+
    +
  • page (number, default: 1) — Page number
  • +
  • limit (number, default: 20, max: 100) — Results per page
  • +
  • search (string?) — Search title, description, or slug (case-insensitive)
  • +
  • published (string?) — Filter by status: "true", "false", or omit for all
  • +
+

Response:

+
{
+  "pages": [
+    {
+      "id": "abc123",
+      "slug": "about-us",
+      "title": "About Our Campaign",
+      "description": "Learn more about our mission.",
+      "editorMode": "VISUAL",
+      "blocks": { /* GrapesJS JSON */ },
+      "htmlOutput": "<section>...</section>",
+      "cssOutput": "section { padding: 40px; }",
+      "mkdocsPath": "about.html",
+      "mkdocsStubPath": "about.md",
+      "mkdocsExportMode": "THEMED",
+      "mkdocsHideNav": false,
+      "mkdocsHideToc": true,
+      "mkdocsSkipExport": false,
+      "published": true,
+      "seoTitle": "About Us | Campaign 2026",
+      "seoDescription": "Join our movement for change.",
+      "seoImage": "https://example.com/og-image.jpg",
+      "createdAt": "2026-01-15T10:00:00Z",
+      "updatedAt": "2026-02-13T14:30:00Z"
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 20,
+    "total": 5,
+    "totalPages": 1
+  }
+}
+
+

Get Page

+
GET /api/pages/:id
+
+

Response: Single LandingPage object (same structure as list item above)

+

Errors:

+
    +
  • 404 PAGE_NOT_FOUND — Page doesn't exist
  • +
+

Create Page

+
POST /api/pages
+Content-Type: application/json
+
+{
+  "title": "New Landing Page",
+  "description": "Page description",
+  "editorMode": "VISUAL"
+}
+
+

Request Body:

+
    +
  • title (string, required) — Page title (slug auto-generated)
  • +
  • description (string?) — Internal description
  • +
  • editorMode (enum?, default: VISUAL) — VISUAL or CODE
  • +
  • mkdocsPath (string?) — Custom override path (defaults to {slug}.html)
  • +
+

Response: Created LandingPage object (201 status)

+

Errors:

+
    +
  • 400 INVALID_MKDOCS_PATH — Invalid path (traversal attempt, missing .html extension)
  • +
+

Behavior:

+
    +
  • Slug auto-generated from title (lowercased, spaces→hyphens, alphanumeric only)
  • +
  • Slug collision handling (appends -2, -3, etc.)
  • +
  • blocks initialized as empty JSON object
  • +
  • published defaults to false
  • +
+

Update Page

+
PUT /api/pages/:id
+Content-Type: application/json
+
+{
+  "blocks": { /* GrapesJS projectData */ },
+  "htmlOutput": "<section>...</section>",
+  "cssOutput": "section { padding: 40px; }",
+  "published": true
+}
+
+

Request Body: (all fields optional)

+
    +
  • title (string?) — New title (regenerates slug if changed)
  • +
  • description (string?)
  • +
  • blocks (JSON?) — GrapesJS projectData
  • +
  • htmlOutput (string?) — Rendered HTML
  • +
  • cssOutput (string?) — Rendered CSS
  • +
  • published (boolean?) — Publish status
  • +
  • mkdocsPath (string?) — Custom override path
  • +
  • mkdocsExportMode (enum?) — THEMED or STANDALONE
  • +
  • mkdocsHideNav (boolean?)
  • +
  • mkdocsHideToc (boolean?)
  • +
  • mkdocsSkipExport (boolean?)
  • +
  • seoTitle (string?)
  • +
  • seoDescription (string?)
  • +
  • seoImage (string?)
  • +
+

Response: Updated LandingPage object

+

Errors:

+
    +
  • 404 PAGE_NOT_FOUND — Page doesn't exist
  • +
  • 400 INVALID_MKDOCS_PATH — Invalid path
  • +
+

Side Effects:

+
    +
  • On publish (published=true, mkdocsSkipExport=false): Exports to MkDocs (writes .html + .md stub)
  • +
  • On unpublish or mkdocsSkipExport=true: Removes MkDocs files
  • +
  • On title change: Regenerates slug, updates mkdocsPath if it was auto-generated, cleans up old exports
  • +
+

Delete Page

+
DELETE /api/pages/:id
+
+

Response: 204 No Content

+

Errors:

+
    +
  • 404 PAGE_NOT_FOUND — Page doesn't exist
  • +
+

Side Effects:

+
    +
  • Removes MkDocs exports (.html override + .md stub) if they exist
  • +
+

Sync Overrides

+
POST /api/pages/sync
+
+

Purpose: Import untracked .html files from mkdocs/docs/overrides/ as CODE-mode pages. Useful for migrating hand-crafted HTML templates.

+

Response:

+
{
+  "imported": 2,
+  "updated": 1,
+  "stubs": 3
+}
+
+

Behavior:

+
    +
  1. Scans mkdocs/docs/overrides/ recursively for .html files
  2. +
  3. For untracked files: Creates new CODE-mode page (published=true)
  4. +
  5. For tracked CODE-mode pages: Updates htmlOutput from disk (disk wins)
  6. +
  7. For tracked VISUAL-mode pages: Skips (managed by GrapesJS)
  8. +
  9. Backfills missing .md stubs for published pages
  10. +
+

Use Cases:

+
    +
  • Migrate legacy hand-coded landing pages
  • +
  • Import templates from designers
  • +
  • Sync after manual file system edits
  • +
+

Validate Exports

+
POST /api/pages/validate
+
+

Purpose: Verify MkDocs exports exist on disk, repair if missing.

+

Response:

+
{
+  "validated": 10,
+  "repaired": 2,
+  "errors": [
+    {
+      "pageId": "xyz789",
+      "slug": "broken-page",
+      "error": "EACCES: permission denied"
+    }
+  ]
+}
+
+

Behavior:

+
    +
  1. Queries all published, non-skipped pages with mkdocsPath
  2. +
  3. Checks if .html override and .md stub exist
  4. +
  5. Re-exports if either missing
  6. +
  7. Updates mkdocsStubPath if changed
  8. +
  9. Returns error list for manual intervention
  10. +
+

Use Cases:

+
    +
  • Recover from accidental file deletion
  • +
  • Fix export state after container restarts
  • +
  • Audit before MkDocs rebuild
  • +
+

Public Routes

+

Prefix: /api/pages

+

Authentication: None (public access)

+

View Published Page

+
GET /api/pages/:slug/view
+
+

Example:

+
GET /api/pages/about-us/view
+
+

Response:

+
{
+  "id": "abc123",
+  "slug": "about-us",
+  "title": "About Our Campaign",
+  "htmlOutput": "<section>...</section>",
+  "cssOutput": "section { padding: 40px; }",
+  "seoTitle": "About Us | Campaign 2026",
+  "seoDescription": "Join our movement for change.",
+  "seoImage": "https://example.com/og-image.jpg",
+  "createdAt": "2026-01-15T10:00:00Z",
+  "updatedAt": "2026-02-13T14:30:00Z"
+}
+
+

Errors:

+
    +
  • 404 PAGE_NOT_FOUND — Page doesn't exist or is unpublished
  • +
+

Security:

+
    +
  • Only returns published pages (published=true)
  • +
  • Omits editor-only fields (blocks, mkdocsPath, etc.)
  • +
+
+

Configuration

+

Environment Variables

+
# MkDocs integration
+MKDOCS_DOCS_PATH=/mkdocs/docs
+# Override path: ${MKDOCS_DOCS_PATH}/overrides/
+# Stub path: ${MKDOCS_DOCS_PATH}/ (root of docs)
+
+

Docker Volume:

+
volumes:
+  - ./mkdocs:/mkdocs:rw
+
+

Note: API container needs write access to export files.

+

Site Settings

+

Feature Flag: ENABLE_LANDING_PAGES

+

Location: Admin → Settings → Features → Landing Pages

+

Default: true

+

Effect: Shows/hides "Pages" menu item in admin sidebar

+
+

Admin Workflow

+

Creating a Page

+
    +
  1. Navigate: Admin sidebar → Pages
  2. +
  3. Click: "Create Page" button
  4. +
  5. Fill form:
  6. +
  7. Title: "About Us" (slug auto-generated: about-us)
  8. +
  9. Description: "Learn about our campaign" (optional)
  10. +
  11. Editor Mode: VISUAL (default) or CODE
  12. +
  13. Submit: "Create & Edit" button
  14. +
  15. Result: Redirected to full-screen editor
  16. +
+

Visual Editing (VISUAL Mode)

+
    +
  1. Editor opens: GrapesJS interface with 3 panels:
  2. +
  3. Left: Block library (drag-and-drop components)
  4. +
  5. Center: Canvas (preview + inline editing)
  6. +
  7. Right: Properties panel (configure selected component)
  8. +
  9. Add blocks: Drag "Hero Section" from left panel to canvas
  10. +
  11. Configure: Click hero → Edit title/subtitle/CTA in right panel
  12. +
  13. Save: Press Ctrl+S (or Cmd+S on Mac) → API saves projectData, htmlOutput, cssOutput
  14. +
  15. Close: Click "X" or "Back to Pages" → Returns to table
  16. +
+

Code Editing (CODE Mode)

+
    +
  1. Editor opens: Split-view Monaco editors:
  2. +
  3. Left: HTML editor
  4. +
  5. Right: CSS editor (optional)
  6. +
  7. Edit HTML: Write raw HTML with Jinja2 template syntax (for MkDocs)
  8. +
  9. Save: Press Ctrl+S → API saves htmlOutput, cssOutput
  10. +
  11. Close: Click "Back to Pages"
  12. +
+

Publishing a Page

+

Option 1: From Table

+
    +
  1. Locate page in table
  2. +
  3. Click "Publish" button in Actions column
  4. +
  5. Status tag changes: Draft → Published
  6. +
  7. Page accessible at /p/{slug}
  8. +
+

Option 2: From Settings Modal

+
    +
  1. Click gear icon (Settings) in Actions column
  2. +
  3. Settings modal opens
  4. +
  5. (Field not shown in modal — use table toggle)
  6. +
+

Side Effects (on publish):

+
    +
  • If mkdocsSkipExport=false: Exports .html + .md to MkDocs
  • +
  • If mkdocsSkipExport=true: Only accessible via /p/:slug (no MkDocs export)
  • +
+

Configuring SEO

+
    +
  1. Click gear icon (Settings) in Actions column
  2. +
  3. Fill SEO section:
  4. +
  5. SEO Title: Custom title for <title> and Open Graph (defaults to title)
  6. +
  7. SEO Description: Meta description for search engines
  8. +
  9. SEO Image: Full URL to Open Graph image (e.g., https://cdn.example.com/og.jpg)
  10. +
  11. Click "Save"
  12. +
  13. Re-export to MkDocs if already published
  14. +
+

MkDocs Integration Settings

+

Access: Page Settings modal → MkDocs Integration section

+

Fields:

+
    +
  1. Skip MkDocs Export (checkbox)
  2. +
  3. When enabled: Page NOT exported to MkDocs site
  4. +
  5. Use case: Pages meant only for /p/:slug (not documentation)
  6. +
  7. +

    Default: false (export enabled)

    +
  8. +
  9. +

    Override Path (text input)

    +
  10. +
  11. Custom filename for override (e.g., custom-about.html)
  12. +
  13. Default: Auto-generated from slug ({slug}.html)
  14. +
  15. +

    Validation: Must end with .html, no path traversal

    +
  16. +
  17. +

    Full page MkDocs (checkbox)

    +
  18. +
  19. When enabled: Exports as STANDALONE (full <!DOCTYPE html> document)
  20. +
  21. When disabled: Exports as THEMED (wraps in {% extends "main.html" %})
  22. +
  23. Default: false (THEMED)
  24. +
  25. +

    Use case: Standalone pages with no MkDocs chrome (like lander.html)

    +
  26. +
  27. +

    Hide navigation sidebar (checkbox, only for THEMED mode)

    +
  28. +
  29. Adds hide: [navigation] to .md stub front matter
  30. +
  31. Hides left sidebar on page
  32. +
  33. +

    Default: false

    +
  34. +
  35. +

    Hide table of contents (checkbox, only for THEMED mode)

    +
  36. +
  37. Adds hide: [toc] to .md stub front matter
  38. +
  39. Hides right sidebar on page
  40. +
  41. Default: false
  42. +
+

Workflow:

+
    +
  1. Edit page settings
  2. +
  3. Configure MkDocs options
  4. +
  5. Save settings
  6. +
  7. If published: API auto-exports with new settings
  8. +
  9. Rebuild MkDocs: Admin → Pages → "Build Site" button
  10. +
+

Syncing Overrides

+

Purpose: Import hand-coded .html files from disk

+

Workflow:

+
    +
  1. Place .html files in mkdocs/docs/overrides/ (on Docker host)
  2. +
  3. Admin → Pages → "Sync Overrides" button
  4. +
  5. API scans directory, imports new files as CODE-mode pages
  6. +
  7. Table refreshes, new pages appear
  8. +
  9. Edit pages normally, publish as needed
  10. +
+

Example:

+
# On Docker host
+echo '<h1>Custom Page</h1>' > mkdocs/docs/overrides/custom.html
+
+# In admin panel
+# Click "Sync Overrides" → 1 imported
+
+

Validating Exports

+

Purpose: Verify MkDocs files exist, repair if missing

+

Workflow:

+
    +
  1. Admin → Pages → "Validate Exports" button
  2. +
  3. API checks all published pages:
  4. +
  5. .html override exists?
  6. +
  7. .md stub exists?
  8. +
  9. Re-exports if either missing
  10. +
  11. Shows result: Validated 10 pages: 2 repaired
  12. +
+

Use Cases:

+
    +
  • After container restart (volume mount issues)
  • +
  • After manual file deletion
  • +
  • Before rebuilding MkDocs site
  • +
+
+

Public Workflow

+

Viewing a Published Page

+
    +
  1. User navigates: https://yoursite.com/p/about-us
  2. +
  3. React router: Matches /p/:slug route → Loads LandingPage.tsx
  4. +
  5. API call: GET /api/pages/about-us/view
  6. +
  7. Response: Returns htmlOutput, cssOutput, SEO fields
  8. +
  9. Render:
  10. +
  11. Sets document.title = seoTitle || title
  12. +
  13. Updates meta description, Open Graph image
  14. +
  15. Injects cssOutput as <style> tag
  16. +
  17. Renders htmlOutput via dangerouslySetInnerHTML
  18. +
  19. Video hydration: Scans for .video-block divs, replaces placeholders with React VideoPlayer components
  20. +
+

SEO Meta Tags

+

Applied automatically on page load:

+
<html>
+<head>
+  <title>About Us | Campaign 2026</title>
+  <meta name="description" content="Join our movement for change.">
+  <meta property="og:image" content="https://example.com/og-image.jpg">
+</head>
+<body>
+  <style>section { padding: 40px; }</style>
+  <section>...</section>
+</body>
+</html>
+
+

Video Embedding

+

Editor Placeholder:

+
<div class="video-block"
+     data-video-id="123"
+     data-player-type="advanced"
+     data-width="100%"
+     data-autoplay="false"
+     data-controls="true"
+     data-show-reactions="true">
+  <div class="video-placeholder">
+    <!-- SVG play icon + metadata -->
+  </div>
+</div>
+
+

Runtime Hydration:

+
    +
  1. LandingPage.tsx mounts → Scans for .video-block elements
  2. +
  3. Reads data-* attributes
  4. +
  5. Creates React root for each block
  6. +
  7. Renders AdvancedVideoPlayer or VideoPlayer component
  8. +
  9. Replaces placeholder with live player
  10. +
+

Supported Attributes:

+
    +
  • data-video-id (required) — Media library video ID
  • +
  • data-player-type ("standard" or "advanced", default: "standard")
  • +
  • data-width (CSS value, default: "100%")
  • +
  • data-height (CSS value, default: "auto")
  • +
  • data-autoplay ("true" or "false", default: "false")
  • +
  • data-controls ("true" or "false", default: "true")
  • +
  • data-show-reactions ("true" or "false", default: "true", advanced player only)
  • +
+
+

Code Examples

+

Creating a Page (TypeScript)

+
import { api } from '@/lib/api';
+
+async function createAboutPage() {
+  const { data } = await api.post('/pages', {
+    title: 'About Us',
+    description: 'Learn about our campaign',
+    editorMode: 'VISUAL',
+  });
+
+  console.log('Created page:', data.slug); // "about-us"
+  return data.id;
+}
+
+

Saving Editor State (GrapesJS)

+
// In LandingPageEditor component
+import { useRef } from 'react';
+import GrapesJSEditor, { GrapesJSEditorHandle } from '@/components/GrapesJSEditor';
+
+const editorRef = useRef<GrapesJSEditorHandle>(null);
+
+const handleSave = () => {
+  editorRef.current?.triggerSave(); // Calls registered save command
+};
+
+const handleEditorSave = async (data: { projectData: any; html: string; css: string }) => {
+  await api.put(`/pages/${pageId}`, {
+    blocks: data.projectData,
+    htmlOutput: data.html,
+    cssOutput: data.css,
+  });
+  message.success('Page saved');
+};
+
+return (
+  <GrapesJSEditor
+    ref={editorRef}
+    initialData={page.blocks}
+    onSave={handleEditorSave}
+  />
+);
+
+

Fetching Published Page (Public Route)

+
import axios from 'axios';
+
+async function loadLandingPage(slug: string) {
+  try {
+    const { data } = await axios.get(`/api/pages/${slug}/view`);
+
+    // Set SEO
+    document.title = data.seoTitle || data.title;
+
+    // Inject CSS
+    const style = document.createElement('style');
+    style.textContent = data.cssOutput || '';
+    document.head.appendChild(style);
+
+    // Render HTML
+    return data.htmlOutput;
+  } catch (error) {
+    if (axios.isAxiosError(error) && error.response?.status === 404) {
+      throw new Error('Page not found or unpublished');
+    }
+    throw error;
+  }
+}
+
+

MkDocs Export Logic (Backend)

+
// From pages.service.ts
+
+function wrapInMaterialOverride(html: string, css: string | null): string {
+  const styleBlock = css ? `<style>\n${css}\n</style>` : '';
+  return `{% extends "main.html" %}
+{% block content %}
+${styleBlock}
+${html}
+{% endblock %}
+`;
+}
+
+async function exportToMkDocs(opts: ExportOptions): Promise<string> {
+  const { mkdocsPath, html, css, exportMode, title, seoTitle, seoDescription } = opts;
+
+  // Write override template
+  const filePath = path.join(MKDOCS_OVERRIDES, mkdocsPath);
+  const content = exportMode === 'STANDALONE'
+    ? wrapInStandaloneDocument(html, css, seoTitle || title, seoDescription)
+    : wrapInMaterialOverride(html, css);
+
+  await fs.writeFile(filePath, content, 'utf-8');
+
+  // Write .md stub
+  const stubPath = mkdocsPath.replace(/\.html$/, '.md');
+  const stubContent = `---
+template: ${mkdocsPath}
+title: "${seoTitle || title}"
+---
+`;
+  await fs.writeFile(path.join(MKDOCS_DOCS_ROOT, stubPath), stubContent, 'utf-8');
+
+  return stubPath;
+}
+
+
+

Troubleshooting

+

Problem: GrapesJS Editor Not Loading

+

Symptoms:

+
    +
  • Blank screen in editor
  • +
  • Console error: Cannot read property 'init' of undefined
  • +
+

Causes:

+
    +
  • GrapesJS package not installed
  • +
  • CSS import missing
  • +
  • Plugin incompatibility
  • +
+

Solutions:

+
    +
  1. +

    Verify installation: +

    cd admin && npm list grapesjs
    +# Should show: grapesjs@0.21.x
    +

    +
  2. +
  3. +

    Check CSS import: +

    // In GrapesJSEditor.tsx
    +import 'grapesjs/dist/css/grapes.min.css';
    +

    +
  4. +
  5. +

    Check browser console:

    +
  6. +
  7. Look for grapesjs variable in global scope
  8. +
  9. +

    Verify all plugins loaded successfully

    +
  10. +
  11. +

    Clear cache: +

    # In browser DevTools
    +# Right-click Reload → Empty Cache and Hard Reload
    +

    +
  12. +
+
+

Problem: Published Page Not Rendering

+

Symptoms:

+
    +
  • 404 error at /p/my-page
  • +
  • Page exists in database, published=true
  • +
+

Causes:

+
    +
  • React route not registered
  • +
  • Slug mismatch
  • +
  • Public route mounted incorrectly
  • +
+

Solutions:

+
    +
  1. +

    Verify route registration: +

    // In admin/src/App.tsx
    +<Route path="/p/:slug" element={<LandingPage />} />
    +

    +
  2. +
  3. +

    Check slug in URL:

    +
  4. +
  5. Slug is case-sensitive: /p/About-Us/p/about-us
  6. +
  7. +

    Use lowercase, hyphenated: /p/about-us

    +
  8. +
  9. +

    Test API directly: +

    curl http://localhost:4000/api/pages/about-us/view
    +# Should return JSON, not 404
    +

    +
  10. +
  11. +

    Check published status: +

    SELECT slug, published FROM landing_pages WHERE slug = 'about-us';
    +-- published should be true
    +

    +
  12. +
+
+

Problem: Mobile Warning Shows on Desktop

+

Symptoms:

+
    +
  • "Desktop Required" warning displays on 1920px screen
  • +
  • Editor won't load
  • +
+

Causes:

+
    +
  • Browser window width < 768px
  • +
  • Breakpoint detection failure
  • +
  • DevTools docked (reduces viewport width)
  • +
+

Solutions:

+
    +
  1. +

    Check actual viewport width: +

    // In browser console
    +console.log(window.innerWidth);
    +// Should be > 768 for desktop
    +

    +
  2. +
  3. +

    Undock DevTools:

    +
  4. +
  5. Press F12 → Click ⋮ (three dots) → Dock to right/bottom → Undock
  6. +
  7. +

    Increases available viewport width

    +
  8. +
  9. +

    Verify breakpoint hook: +

    // In PageEditorPage.tsx
    +const screens = Grid.useBreakpoint();
    +const isMobile = !screens.md; // md = 768px
    +

    +
  10. +
  11. +

    Test responsive mode:

    +
  12. +
  13. F12 → Toggle device toolbar (Ctrl+Shift+M)
  14. +
  15. Select "Responsive" → Set width to 1024px
  16. +
+
+

Problem: MkDocs Export Not Found

+

Symptoms:

+
    +
  • MkDocs site shows 404 for /pages/about-us/
  • +
  • Override file missing from mkdocs/docs/overrides/
  • +
+

Causes:

+
    +
  • Page not published
  • +
  • mkdocsSkipExport=true
  • +
  • Export path incorrect
  • +
  • MkDocs not rebuilt
  • +
+

Solutions:

+
    +
  1. +

    Verify publish status: +

    SELECT slug, published, mkdocs_skip_export FROM landing_pages WHERE slug = 'about-us';
    +-- Both should be true/false appropriately
    +

    +
  2. +
  3. +

    Check export path: +

    ls -la mkdocs/docs/overrides/about.html
    +# Should exist if published and not skipped
    +

    +
  4. +
  5. +

    Validate exports:

    +
  6. +
  7. Admin → Pages → "Validate Exports" button
  8. +
  9. +

    Check repair count

    +
  10. +
  11. +

    Rebuild MkDocs: +

    docker compose exec mkdocs mkdocs build
    +# Or in admin: Pages → "Build Site"
    +

    +
  12. +
  13. +

    Check template path in stub: +

    cat mkdocs/docs/about.md
    +# Should show: template: about.html (NOT overrides/about.html)
    +

    +
  14. +
+
+

Problem: Slug Collision on Create

+

Symptoms:

+
    +
  • Create page with title "About Us" → slug becomes about-us-2
  • +
  • Expected about-us but already taken
  • +
+

Causes:

+
    +
  • Existing page with same slug (possibly unpublished)
  • +
  • Soft-deleted page (if soft delete implemented)
  • +
+

Solutions:

+
    +
  1. +

    Check existing pages: +

    SELECT id, title, slug, published FROM landing_pages WHERE slug LIKE 'about-us%';
    +

    +
  2. +
  3. +

    Delete duplicate:

    +
  4. +
  5. If old page is unwanted: Admin → Pages → Delete
  6. +
  7. +

    New page can reuse slug

    +
  8. +
  9. +

    Use unique title:

    +
  10. +
  11. +

    Rename new page: "About Us 2026" → slug about-us-2026

    +
  12. +
  13. +

    Manual slug override:

    +
  14. +
  15. After create: Edit page → Settings → Override Path → about-us-custom.html
  16. +
+
+

Problem: Video Block Not Hydrating

+

Symptoms:

+
    +
  • Video placeholder shows on published page
  • +
  • No player renders
  • +
  • Console error: Invalid video ID: PLACEHOLDER
  • +
+

Causes:

+
    +
  • data-video-id="PLACEHOLDER" not replaced
  • +
  • Video ID not numeric
  • +
  • Hydration script not running
  • +
+

Solutions:

+
    +
  1. Check video ID in editor:
  2. +
  3. Open GrapesJS editor → Select video block
  4. +
  5. Properties panel → Video ID field should be numeric (e.g., 123)
  6. +
  7. +

    Not PLACEHOLDER

    +
  8. +
  9. +

    Verify HTML output: +

    <!-- Bad -->
    +<div class="video-block" data-video-id="PLACEHOLDER">...</div>
    +
    +<!-- Good -->
    +<div class="video-block" data-video-id="42">...</div>
    +

    +
  10. +
  11. +

    Check hydration script: +

    // In LandingPage.tsx
    +useEffect(() => {
    +  // Should scan for .video-block elements
    +  const videoBlocks = contentRef.current?.querySelectorAll('.video-block');
    +  console.log('Found video blocks:', videoBlocks?.length);
    +}, [page]);
    +

    +
  12. +
  13. +

    Test video ID validity: +

    curl http://localhost:4100/api/media/videos/42
    +# Should return video metadata, not 404
    +

    +
  14. +
+
+

Performance Considerations

+

Editor Initialization

+

GrapesJS startup: ~500ms on modern desktop

+

Optimization strategies:

+
    +
  • Lazy load GrapesJS: const GrapesJS = lazy(() => import('./GrapesJSEditor'))
  • +
  • Show loading spinner during init
  • +
  • Preload on hover over "Edit" button
  • +
+

Large Pages

+

Complexity threshold: 100+ components

+

Symptoms:

+
    +
  • Laggy drag-and-drop
  • +
  • Slow save operations
  • +
  • Canvas rendering delay
  • +
+

Mitigations:

+
    +
  • Break into multiple pages (split hero + sections)
  • +
  • Use CODE mode for complex layouts
  • +
  • Minimize nested components
  • +
+

htmlOutput Storage

+

Database overhead: htmlOutput can be 50KB+ for complex pages

+

Considerations:

+
    +
  • Indexed by published for public queries (fast)
  • +
  • Not indexed by content (no full-text search on HTML)
  • +
  • Consider external storage for very large pages (future enhancement)
  • +
+

Public Page Rendering

+

React hydration: Video blocks hydrate after initial render (~100ms delay)

+

Performance tips:

+
    +
  • Use dangerouslySetInnerHTML for immediate HTML paint
  • +
  • Defer video hydration to setTimeout(..., 100)
  • +
  • Preload video metadata for above-fold players
  • +
+
+

Security Considerations

+

Admin-Authored HTML

+

Risk: XSS via malicious HTML in editor

+

Mitigation:

+
    +
  • Accepted risk: Only admins can create/edit pages (trusted users)
  • +
  • No user-supplied content: Public users cannot edit landing pages
  • +
  • Authentication required: All write endpoints require admin role
  • +
+

Comment in code:

+
// HTML/CSS is admin-authored via GrapesJS editor (not user-submitted content).
+// Only authenticated admins can create/edit pages, so XSS risk is accepted.
+return <div dangerouslySetInnerHTML={{ __html: page.htmlOutput }} />;
+
+

Slug Validation

+

Attack vector: Path traversal via slug injection

+

Protection:

+
function generateSlug(title: string): string {
+  return title
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, '-')  // Alphanumeric + hyphens only
+    .replace(/^-+|-+$/g, '')       // Trim leading/trailing hyphens
+    .slice(0, 80);                 // Max 80 chars
+}
+
+

Safe slugs: about-us, campaign-2026, contact

+

Rejected: ../etc/passwd, <script>alert(1)</script>, ../../admin

+

MkDocs Path Validation

+

Attack vector: Write arbitrary files via path traversal in mkdocsPath

+

Protection:

+
function validateMkdocsPath(mkdocsPath: string): void {
+  if (mkdocsPath.includes('\0')) throw new Error('Null byte detected');
+
+  const normalized = path.normalize(mkdocsPath);
+  if (normalized.includes('..') || path.isAbsolute(normalized)) {
+    throw new Error('Path traversal not allowed');
+  }
+
+  if (mkdocsPath.includes('%2e') || mkdocsPath.includes('%2E')) {
+    throw new Error('Encoded path traversal not allowed');
+  }
+
+  if (!mkdocsPath.endsWith('.html')) {
+    throw new Error('Path must end with .html');
+  }
+}
+
+

Safe paths: about.html, pages/contact.html

+

Rejected: ../../../etc/passwd.html, /etc/shadow.html, %2e%2e/admin.html

+

Published Flag Enforcement

+

Attack vector: Access draft pages via public route

+

Protection:

+
// In pagesService.findBySlugPublic()
+if (!page || !page.published) {
+  throw new AppError(404, 'Page not found', 'PAGE_NOT_FOUND');
+}
+
+

Behavior:

+
    +
  • Unpublished pages return 404 on public route
  • +
  • Admin routes bypass check (can view drafts)
  • +
+
+ +

Frontend Components

+ +

Backend Modules

+ +

Database

+ +

Feature Documentation

+ +

External Resources

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/features/tunnel/index.html b/mkdocs/site/v2/features/tunnel/index.html new file mode 100644 index 00000000..f8e9ac14 --- /dev/null +++ b/mkdocs/site/v2/features/tunnel/index.html @@ -0,0 +1,5543 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Tunnel Management (Pangolin) - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Tunnel Management (Pangolin)

+

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.

+

Overview

+

Pangolin integration provides:

+
    +
  • Secure Tunneling - Expose localhost to public internet
  • +
  • Newt Container - Self-hosted exit node
  • +
  • Setup Wizard - Guided configuration
  • +
  • Resource Management - Subdomain and route configuration
  • +
  • Status Monitoring - Tunnel health and uptime
  • +
  • No DNS Configuration - Automatic subdomain setup
  • +
+

Features

+

Tunnel Setup

+
    +
  • Create Pangolin organization and site
  • +
  • Generate Newt container credentials
  • +
  • Configure resources (subdomains)
  • +
  • Deploy Newt container
  • +
  • Start tunnel automatically
  • +
+

Resource Configuration

+

Map internal services to public subdomains:

+
    +
  • app.yoursite.com → Admin GUI (port 3000)
  • +
  • api.yoursite.com → Express API (port 4000)
  • +
  • media.yoursite.com → Media API (port 4100)
  • +
  • docs.yoursite.com → MkDocs (port 4003)
  • +
  • grafana.yoursite.com → Grafana (port 3001)
  • +
  • Custom subdomains for other services
  • +
+

Admin Interface

+

Setup wizard (/app/services/pangolin):

+
    +
  1. Connection - Enter Pangolin API credentials
  2. +
  3. Organization - Create/select organization
  4. +
  5. Site - Create/configure site
  6. +
  7. Resources - Map services to subdomains
  8. +
  9. Deploy - Start Newt container
  10. +
  11. Verify - Test tunnel connectivity
  12. +
+

Status Monitoring

+
    +
  • Tunnel status (active/inactive)
  • +
  • Resource health checks
  • +
  • Traffic statistics
  • +
  • Error logs
  • +
  • Quick actions (restart, update config)
  • +
+

Architecture

+

Backend Components

+

Pangolin Client: +- api/src/services/pangolin.client.ts - Typed HTTP client +- API key authentication +- Full Integration API coverage

+

Pangolin Module: +- api/src/modules/pangolin/pangolin.routes.ts - Admin endpoints +- Setup, config, status routes

+

Newt Container: +- Docker service in docker-compose.yml +- Self-hosted exit node +- Routes through nginx +- Automatic startup

+

Frontend Components

+

Admin Page: +- admin/src/pages/PangolinPage.tsx - Setup wizard +- Step-by-step configuration +- Status dashboard +- Resource table

+

Docker Integration

+

Newt container in docker-compose.yml:

+
newt:
+  image: bnkserve/newt:latest
+  container_name: newt
+  restart: unless-stopped
+  depends_on:
+    - nginx
+  environment:
+    NEWT_ID: ${PANGOLIN_NEWT_ID}
+    NEWT_SECRET: ${PANGOLIN_NEWT_SECRET}
+    PANGOLIN_ENDPOINT: ${PANGOLIN_ENDPOINT}
+  networks:
+    - changemaker-lite
+
+

Configuration

+

Environment Variables

+
# Pangolin API
+PANGOLIN_API_URL=https://api.bnkserve.org/v1
+PANGOLIN_API_KEY=your_api_key
+
+# Organization & Site
+PANGOLIN_ORG_ID=your_org_id
+PANGOLIN_SITE_ID=your_site_id
+
+# Newt Container
+PANGOLIN_NEWT_ID=your_newt_id
+PANGOLIN_NEWT_SECRET=your_newt_secret
+PANGOLIN_ENDPOINT=your_endpoint_url
+
+

Setup Process

+
    +
  1. Create Account - Sign up at pangolin.bnkserve.org
  2. +
  3. Get API Key - Generate API key in dashboard
  4. +
  5. Add to .env - Set PANGOLIN_API_KEY
  6. +
  7. Run Wizard - Complete setup wizard in admin
  8. +
  9. Deploy Newt - Start Newt container
  10. +
  11. Test Tunnel - Verify public access
  12. +
+

Pangolin API Integration

+

API Client Usage

+
import { pangolinClient } from '../services/pangolin.client';
+
+// Get organization
+const org = await pangolinClient.getOrganization(orgId);
+
+// Create site
+const site = await pangolinClient.createSite(orgId, {
+  name: 'My Campaign Site',
+  domain: 'campaign.example.com',
+});
+
+// Create resource (subdomain)
+const resource = await pangolinClient.createResource(siteId, {
+  subdomain: 'app',
+  targetUrl: 'http://nginx:80',
+  port: 80,
+});
+
+// Get tunnel status
+const status = await pangolinClient.getTunnelStatus(siteId);
+
+

Authentication

+

Pangolin uses Bearer token authentication:

+
const headers = {
+  'Authorization': `Bearer ${apiKey}`,
+  'Content-Type': 'application/json',
+};
+
+

Setup Wizard

+

Step 1: Connection

+
    +
  • Enter Pangolin API key
  • +
  • Validate credentials
  • +
  • Test connection
  • +
+

Step 2: Organization

+
    +
  • List existing organizations
  • +
  • Create new organization
  • +
  • Select organization
  • +
+

Step 3: Site

+
    +
  • List existing sites
  • +
  • Create new site
  • +
  • Configure domain
  • +
  • Select site
  • +
+

Step 4: Resources

+
    +
  • Add resources (subdomains)
  • +
  • Map to internal services
  • +
  • Configure routing
  • +
+

Example resources:

+
app.yoursite.com → http://nginx:80 (proxies to admin:3000)
+api.yoursite.com → http://nginx:80 (proxies to api:4000)
+
+

Step 5: Deploy

+
    +
  • Generate Newt credentials
  • +
  • Update .env with credentials
  • +
  • Restart Newt container
  • +
  • Verify tunnel
  • +
+

Step 6: Verify

+
    +
  • Test public URLs
  • +
  • Check resource health
  • +
  • View status dashboard
  • +
+

Newt Container

+

Purpose

+

Newt is the exit node that:

+
    +
  • Establishes tunnel to Pangolin
  • +
  • Receives public traffic
  • +
  • Forwards to internal nginx
  • +
  • Handles SSL/TLS termination
  • +
+

Routing

+

All traffic flows through nginx:

+
Public Request
+  ↓
+Pangolin Tunnel
+  ↓
+Newt Container
+  ↓
+Nginx (port 80)
+  ↓
+Internal Service (admin/api/etc.)
+
+

Configuration

+

Newt configured via environment variables:

+
    +
  • NEWT_ID - Unique container identifier
  • +
  • NEWT_SECRET - Authentication secret
  • +
  • PANGOLIN_ENDPOINT - Tunnel endpoint URL
  • +
+

Resource Management

+

Resource Types

+
    +
  • Web Apps - Admin, public pages
  • +
  • APIs - Express API, Media API
  • +
  • Services - Docs, Grafana, etc.
  • +
+

Subdomain Mapping

+
interface Resource {
+  subdomain: string;      // 'app', 'api', 'docs'
+  targetUrl: string;      // 'http://nginx:80'
+  port: number;           // 80
+  protocol: string;       // 'http' or 'https'
+}
+
+

Internal Routing

+

Nginx routes by Host header:

+
server {
+  listen 80;
+  server_name app.yoursite.com;
+
+  location / {
+    proxy_pass http://admin:3000;
+  }
+}
+
+server {
+  listen 80;
+  server_name api.yoursite.com;
+
+  location / {
+    proxy_pass http://api:4000;
+  }
+}
+
+

Status Dashboard

+

Tunnel Status

+

Display: +- Active/inactive status +- Uptime duration +- Last connected time +- Connection errors

+

Resource Health

+

For each resource: +- Subdomain +- Target service +- Health status (online/offline) +- Response time +- Error count

+

Actions

+

Quick actions: +- Restart tunnel +- Update configuration +- Add/remove resources +- Test connectivity +- View logs

+

Security

+

SSL/TLS

+
    +
  • Pangolin handles SSL termination
  • +
  • Automatic certificate management
  • +
  • HTTPS enforced on public URLs
  • +
  • HTTP → HTTPS redirect
  • +
+

Authentication

+
    +
  • API key authentication
  • +
  • Newt secret for container auth
  • +
  • No public credentials exposure
  • +
+

Access Control

+
    +
  • Firewall rules (optional)
  • +
  • IP whitelisting (optional)
  • +
  • Rate limiting via Pangolin
  • +
  • DDoS protection
  • +
+

Troubleshooting

+

Connection Issues

+
    +
  1. Verify API key
  2. +
  3. Check organization/site IDs
  4. +
  5. Confirm Newt credentials
  6. +
  7. Test internal nginx routing
  8. +
  9. Check container logs
  10. +
+

Resource Not Accessible

+
    +
  1. Verify resource configuration
  2. +
  3. Test internal service
  4. +
  5. Check nginx config
  6. +
  7. Review Pangolin logs
  8. +
  9. Confirm DNS propagation
  10. +
+

Newt Container Errors

+
    +
  1. Check environment variables
  2. +
  3. Verify network connectivity
  4. +
  5. Review container logs
  6. +
  7. Restart container
  8. +
  9. Update Newt image
  10. +
+

API Endpoints

+

Admin Endpoints

+
GET    /api/pangolin/status            # Tunnel status
+GET    /api/pangolin/config            # Current configuration
+GET    /api/pangolin/organizations     # List organizations
+GET    /api/pangolin/sites             # List sites
+GET    /api/pangolin/resources         # List resources
+POST   /api/pangolin/setup             # Complete setup wizard
+POST   /api/pangolin/sync              # Sync configuration
+
+

Comparison to Cloudflare Tunnel

+

Advantages

+
    +
  • Self-hosted exit node (Newt)
  • +
  • No vendor lock-in
  • +
  • Full control over routing
  • +
  • Open-source alternative
  • +
  • No DNS changes required
  • +
+

Considerations

+
    +
  • Requires Pangolin account
  • +
  • Newt container overhead
  • +
  • Manual setup process
  • +
  • Smaller ecosystem
  • +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/components/index.html b/mkdocs/site/v2/frontend/components/index.html new file mode 100644 index 00000000..f527fb55 --- /dev/null +++ b/mkdocs/site/v2/frontend/components/index.html @@ -0,0 +1,5383 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Frontend Components - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Frontend Components

+

Reusable UI components provide common functionality across the Changemaker Lite admin interface. Components are organized by feature area and follow React best practices.

+

Component Organization

+
admin/src/components/
+├── map/                    # Leaflet map components
+├── canvass/                # GPS tracking and visit recording
+├── media/                  # Video library components
+├── email-templates/        # Email template editor components
+├── observability/          # Monitoring components
+├── AppLayout.tsx           # Admin sidebar layout
+├── PublicLayout.tsx        # Public dark theme layout
+├── VolunteerLayout.tsx     # Volunteer portal layout
+├── MediaPublicLayout.tsx   # Public media gallery layout
+└── GrapesJSEditor.tsx      # Landing page WYSIWYG editor
+
+

Layout Components

+

AppLayout

+

Admin sidebar layout with role-based navigation:

+
    +
  • Location: components/AppLayout.tsx
  • +
  • Features:
  • +
  • Collapsible sidebar
  • +
  • Role-based menu items
  • +
  • User dropdown menu
  • +
  • Breadcrumb navigation
  • +
  • Responsive mobile drawer
  • +
+

PublicLayout

+

Dark theme layout for public pages:

+
    +
  • Location: components/PublicLayout.tsx
  • +
  • Features:
  • +
  • Dark blue/teal color scheme
  • +
  • Header with logo and navigation
  • +
  • Footer with links
  • +
  • Responsive grid breakpoints
  • +
+

VolunteerLayout

+

Top navigation layout for volunteer portal:

+
    +
  • Location: components/VolunteerLayout.tsx
  • +
  • Features:
  • +
  • Horizontal navigation bar
  • +
  • Mobile hamburger menu
  • +
  • User status display
  • +
  • Active route highlighting
  • +
+

MediaPublicLayout

+

Minimal layout for public media gallery:

+
    +
  • Location: components/MediaPublicLayout.tsx
  • +
  • Features:
  • +
  • Clean header with branding
  • +
  • Full-width content area
  • +
  • Dark theme consistency
  • +
+

Map Components

+

MapControls

+

Floating control buttons for map interactions:

+
    +
  • Location: components/map/MapControls.tsx
  • +
  • Features:
  • +
  • Add location mode toggle
  • +
  • Move location mode toggle
  • +
  • Geolocate current position
  • +
  • Fullscreen toggle
  • +
  • Auto-refresh toggle
  • +
+

AddLocationMode

+

Click-to-add location drawing mode:

+
    +
  • Location: components/map/AddLocationMode.tsx
  • +
  • Features:
  • +
  • Click map to add location
  • +
  • Reverse geocoding
  • +
  • Form modal for details
  • +
  • Marker placement
  • +
+

MoveLocationMode

+

Click-to-move existing locations:

+
    +
  • Location: components/map/MoveLocationMode.tsx
  • +
  • Features:
  • +
  • Drag markers to new position
  • +
  • Update coordinates
  • +
  • Cancel/confirm actions
  • +
+

CutDrawingMode

+

Polygon drawing tool for geographic cuts:

+
    +
  • Location: components/map/CutDrawingMode.tsx
  • +
  • Features:
  • +
  • Click vertices to draw polygon
  • +
  • Close polygon detection
  • +
  • Vertex editing
  • +
  • Save/cancel actions
  • +
+

CutOverlays

+

GeoJSON polygon rendering:

+
    +
  • Location: components/map/CutOverlays.tsx
  • +
  • Features:
  • +
  • Multi-polygon support
  • +
  • Color-coded cuts
  • +
  • Click to select
  • +
  • Popup information
  • +
+

CutOverlayControls

+

Cut visibility toggle panel:

+
    +
  • Location: components/map/CutOverlayControls.tsx
  • +
  • Features:
  • +
  • Show/hide individual cuts
  • +
  • Bulk toggle all
  • +
  • Color legend
  • +
+

CutEditorMap

+

Specialized map for cut editing:

+
    +
  • Location: components/map/CutEditorMap.tsx
  • +
  • Features:
  • +
  • Drawing mode integration
  • +
  • Vertex editing
  • +
  • Polygon validation
  • +
  • Save to database
  • +
+

MapLegend

+

Floating legend overlay:

+
    +
  • Location: components/map/MapLegend.tsx
  • +
  • Features:
  • +
  • Color-coded markers
  • +
  • Cut legend
  • +
  • Collapsible panel
  • +
+

Canvass Components

+

CanvassHeader

+

Session header with timer and status:

+
    +
  • Location: components/canvass/CanvassHeader.tsx
  • +
  • Features:
  • +
  • Session timer
  • +
  • Start/end session
  • +
  • Cut information
  • +
  • Visit counter
  • +
+

SessionTimer

+

Elapsed time display:

+
    +
  • Location: components/canvass/SessionTimer.tsx
  • +
  • Features:
  • +
  • Real-time countdown
  • +
  • Hours:minutes:seconds format
  • +
  • Auto-update
  • +
+

CanvassMarker

+

Location marker with visit status:

+
    +
  • Location: components/canvass/CanvassMarker.tsx
  • +
  • Features:
  • +
  • Color-coded by visit status
  • +
  • Click to record visit
  • +
  • Popup with details
  • +
  • Next location highlighting
  • +
+

CanvassMarkerGroup

+

Optimized marker clustering:

+
    +
  • Location: components/canvass/CanvassMarkerGroup.tsx
  • +
  • Features:
  • +
  • Performance optimization
  • +
  • Batch rendering
  • +
  • Click handlers
  • +
+

WalkingRouteLine

+

Polyline for walking route:

+
    +
  • Location: components/canvass/WalkingRouteLine.tsx
  • +
  • Features:
  • +
  • Blue dashed line
  • +
  • Location-to-location path
  • +
  • Auto-update on visit
  • +
+

GPSTracker

+

GPS position tracking:

+
    +
  • Location: components/canvass/GPSTracker.tsx
  • +
  • Features:
  • +
  • Watch position API
  • +
  • Blue GPS marker
  • +
  • Accuracy circle
  • +
  • Auto-center map
  • +
+

CanvassBottomToolbar

+

Bottom sheet with actions:

+
    +
  • Location: components/canvass/CanvassBottomToolbar.tsx
  • +
  • Features:
  • +
  • Expandable drawer
  • +
  • Quick actions
  • +
  • Visit recording
  • +
  • Session controls
  • +
+

VisitRecordingForm

+

Visit outcome form:

+
    +
  • Location: components/canvass/VisitRecordingForm.tsx
  • +
  • Features:
  • +
  • Outcome selection
  • +
  • Notes input
  • +
  • GPS coordinates
  • +
  • Submit with validation
  • +
+

CanvassLegend

+

Map legend for canvass status:

+
    +
  • Location: components/canvass/CanvassLegend.tsx
  • +
  • Features:
  • +
  • Status color codes
  • +
  • Visit outcome legend
  • +
  • Collapsible panel
  • +
+

Media Components

+

VideoCard

+

Video item display card:

+
    +
  • Location: components/media/VideoCard.tsx
  • +
  • Features:
  • +
  • Thumbnail preview
  • +
  • Title and description
  • +
  • Action buttons
  • +
  • Selection checkbox
  • +
+

BulkActions

+

Batch operation toolbar:

+
    +
  • Location: components/media/BulkActions.tsx
  • +
  • Features:
  • +
  • Select all toggle
  • +
  • Delete selected
  • +
  • Lock/unlock selected
  • +
  • Share selected
  • +
+

UploadVideoModal

+

Video upload interface:

+
    +
  • Location: components/media/UploadVideoModal.tsx
  • +
  • Features:
  • +
  • Drag-and-drop upload
  • +
  • Progress tracking
  • +
  • Metadata form
  • +
  • Single/batch upload
  • +
+

MediaGalleryGrid

+

Responsive video grid:

+
    +
  • Location: components/media/MediaGalleryGrid.tsx
  • +
  • Features:
  • +
  • Masonry layout
  • +
  • Lazy loading
  • +
  • Infinite scroll
  • +
  • Filter/sort
  • +
+

Email Template Components

+

TemplateEditor

+

Email template WYSIWYG editor:

+
    +
  • Location: components/email-templates/TemplateEditor.tsx
  • +
  • Features:
  • +
  • Rich text editing
  • +
  • Variable insertion
  • +
  • Preview mode
  • +
  • HTML source view
  • +
+

VariableInserter

+

Template variable selector:

+
    +
  • Location: components/email-templates/VariableInserter.tsx
  • +
  • Features:
  • +
  • Variable dropdown
  • +
  • Click to insert
  • +
  • Variable documentation
  • +
  • Preview rendering
  • +
+

Observability Components

+

MetricsChart

+

Prometheus metrics visualization:

+
    +
  • Location: components/observability/MetricsChart.tsx
  • +
  • Features:
  • +
  • Time-series charts
  • +
  • Multiple metrics
  • +
  • Auto-refresh
  • +
  • Zoom controls
  • +
+

ServiceHealthCard

+

Service status display:

+
    +
  • Location: components/observability/ServiceHealthCard.tsx
  • +
  • Features:
  • +
  • Health indicator
  • +
  • Uptime display
  • +
  • Quick actions
  • +
  • Error messages
  • +
+

Editor Components

+

GrapesJSEditor

+

Landing page WYSIWYG editor:

+
    +
  • Location: components/GrapesJSEditor.tsx
  • +
  • Features:
  • +
  • GrapesJS integration
  • +
  • Custom block library
  • +
  • Ctrl+S save handler
  • +
  • Error boundary
  • +
  • Forward ref support
  • +
  • Desktop-only warning
  • +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/index.html b/mkdocs/site/v2/frontend/index.html new file mode 100644 index 00000000..6bba7d6b --- /dev/null +++ b/mkdocs/site/v2/frontend/index.html @@ -0,0 +1,5102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Frontend Overview - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Frontend Overview

+

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.

+

Architecture

+

The frontend is a single-page application (SPA) built with:

+
    +
  • React 19 - UI framework
  • +
  • Vite - Build tool and dev server
  • +
  • Ant Design 5 - Component library
  • +
  • Zustand - State management
  • +
  • React Router 6 - Client-side routing
  • +
  • Leaflet - Interactive maps
  • +
  • Axios - HTTP client with auth interceptors
  • +
+

Application Structure

+
admin/
+├── src/
+│   ├── App.tsx                # Main router + route definitions
+│   ├── components/            # Shared components
+│   ├── pages/                 # Page components
+│   │   ├── admin/             # Admin pages (30+)
+│   │   ├── public/            # Public pages (8)
+│   │   └── volunteer/         # Volunteer portal (4)
+│   ├── lib/                   # API clients
+│   ├── stores/                # Zustand state stores
+│   ├── types/                 # TypeScript definitions
+│   ├── hooks/                 # Custom React hooks
+│   └── utils/                 # Helper utilities
+└── public/                    # Static assets
+
+

Key Components

+

Layouts

+

Three distinct layout components for different user contexts:

+
    +
  • AppLayout - Admin sidebar with role-based navigation
  • +
  • PublicLayout - Dark theme public pages
  • +
  • VolunteerLayout - Volunteer portal with top navigation
  • +
+

Components

+

Reusable UI components organized by feature:

+
    +
  • Map components (Leaflet, drawing tools, markers)
  • +
  • Canvass components (GPS tracking, visit recording)
  • +
  • Media components (video cards, upload, gallery)
  • +
  • Email template components
  • +
  • Form components and utilities
  • +
+

Pages

+

42 page components across three sections:

+
    +
  • Admin Pages (30) - Campaign management, location management, settings, analytics
  • +
  • Public Pages (8) - Campaign views, map, shifts, media gallery
  • +
  • Volunteer Pages (4) - Canvass map, assignments, activity tracking
  • +
+

State Management

+

Zustand Stores

+

Auth Store (stores/auth.store.ts)

+
    +
  • User authentication state
  • +
  • Token persistence (localStorage)
  • +
  • Login/logout actions
  • +
  • Role-based access
  • +
+

Canvass Store (stores/canvass.store.ts)

+
    +
  • Active canvass session
  • +
  • GPS tracking state
  • +
  • Walking route
  • +
  • Visit recording
  • +
+

API Integration

+

Main API Client (lib/api.ts)

+
    +
  • Axios instance with auth interceptors
  • +
  • Automatic token refresh on 401
  • +
  • Base URL configuration
  • +
  • Error handling
  • +
+

Media API Client (lib/media-api.ts)

+
    +
  • Dedicated client for Fastify media API
  • +
  • Separate base URL (port 4100)
  • +
  • File upload support
  • +
+

Public API Client (lib/media-public-api.ts)

+
    +
  • Unauthenticated client for public media
  • +
  • No auth interceptors
  • +
+

Routing

+

Routes are organized by user role and access level:

+

Admin Routes (/app/*)

+

Require authentication and admin role:

+
<Route path="/app" element={<AppLayout />}>
+  <Route path="dashboard" element={<DashboardPage />} />
+  <Route path="users" element={<UsersPage />} />
+  <Route path="influence/campaigns" element={<CampaignsPage />} />
+  // ... 30+ admin routes
+</Route>
+
+

Public Routes

+

No authentication required:

+
<Route path="/campaigns" element={<PublicLayout />}>
+  <Route index element={<CampaignsListPage />} />
+  <Route path=":id" element={<CampaignPage />} />
+</Route>
+
+

Volunteer Routes (/volunteer/*)

+

Require authentication, any role:

+
<Route path="/volunteer" element={<VolunteerLayout />}>
+  <Route path="assignments" element={<VolunteerShiftsPage />} />
+  <Route path="canvass/:cutId" element={<VolunteerMapPage />} />
+</Route>
+
+

Theming

+

Admin Theme

+

Light theme with primary blue colors:

+
colorPrimary: '#1677ff'
+colorBgBase: '#ffffff'
+
+

Public Theme

+

Dark theme with blue/teal accents:

+
colorBgBase: '#0d1b2a'
+colorBgContainer: '#1b2838'
+colorPrimary: '#3498db'
+
+

Build & Development

+

Development Server

+
cd admin && npm run dev
+# Runs on http://localhost:3000
+
+

Production Build

+
cd admin && npm run build
+# Output: admin/dist/
+
+

Type Checking

+
cd admin && npx tsc --noEmit
+
+

Environment Variables

+

Frontend uses Vite environment variables:

+
VITE_API_URL=http://localhost:4000      # Main API
+VITE_MEDIA_API_URL=http://localhost:4100 # Media API
+VITE_MKDOCS_URL=http://localhost:4003   # MkDocs
+
+

Docker deployments override these in docker-compose.yml to use container hostnames.

+

Key Features

+

Responsive Design

+
    +
  • Grid breakpoints with Ant Design
  • +
  • Mobile-aware components
  • +
  • Desktop-only editors (GrapesJS, Email Templates)
  • +
+

Form Handling

+
    +
  • Ant Design Form integration
  • +
  • Zod schema validation (client-side)
  • +
  • Error display and validation feedback
  • +
+

Data Tables

+
    +
  • Pagination support
  • +
  • Search and filtering
  • +
  • Sorting and column configuration
  • +
  • Bulk actions
  • +
+

Map Integration

+
    +
  • Leaflet for interactive maps
  • +
  • Custom markers and overlays
  • +
  • Drawing tools for polygons
  • +
  • GPS tracking for canvassing
  • +
+

File Uploads

+
    +
  • Drag-and-drop support
  • +
  • Progress tracking
  • +
  • File type validation
  • +
  • Preview generation
  • +
+ + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/layouts/index.html b/mkdocs/site/v2/frontend/layouts/index.html new file mode 100644 index 00000000..aab90c5b --- /dev/null +++ b/mkdocs/site/v2/frontend/layouts/index.html @@ -0,0 +1,4948 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Frontend Layouts - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Frontend Layouts

+

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.

+

Layout Components

+

AppLayout

+

Admin sidebar layout for authenticated admin users.

+

Location: admin/src/components/AppLayout.tsx

+

Features:

+
    +
  • Collapsible sidebar navigation
  • +
  • Role-based menu items (SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN)
  • +
  • User dropdown menu with logout
  • +
  • Breadcrumb navigation
  • +
  • Mobile responsive drawer
  • +
  • Light theme
  • +
+

Route Context: /app/*

+

Used By: +- Dashboard +- User management +- Campaign management +- Location management +- Settings pages +- All admin features

+

Navigation Sections:

+
    +
  1. Dashboard - Overview and quick actions
  2. +
  3. Influence - Campaign management, responses, email queue
  4. +
  5. Map - Locations, cuts, shifts, canvassing
  6. +
  7. Content - Landing pages, email templates
  8. +
  9. Media - Video library, public gallery, jobs
  10. +
  11. Services - Integrations (Listmonk, Pangolin, MkDocs, etc.)
  12. +
  13. System - Users, settings, observability
  14. +
+

Sidebar Behavior:

+
    +
  • Default collapsed state for better content space
  • +
  • Expand on hover (desktop)
  • +
  • Drawer for mobile devices
  • +
  • Persistent state in localStorage
  • +
  • Active menu item highlighting
  • +
+

PublicLayout

+

Dark theme layout for public-facing pages.

+

Location: admin/src/components/PublicLayout.tsx

+

Features:

+
    +
  • Dark blue/teal color scheme (#0d1b2a background)
  • +
  • Header with logo and navigation links
  • +
  • Footer with contact/about links
  • +
  • Full-width content area
  • +
  • Responsive grid breakpoints
  • +
  • No authentication required
  • +
+

Route Context: /campaigns, /map, /shifts, /p/:slug, /media

+

Used By: +- Public campaign listing +- Campaign detail pages +- Response wall +- Public map view +- Public shift signup +- Landing pages +- Public media gallery

+

Theme Colors:

+
colorBgBase: '#0d1b2a'       // Dark navy background
+colorBgContainer: '#1b2838'  // Container background
+colorPrimary: '#3498db'      // Bright blue
+colorLink: '#3498db'         // Link color
+colorText: '#e0e0e0'         // Light text
+
+

Header Navigation:

+
    +
  • Home link
  • +
  • Campaigns
  • +
  • Map
  • +
  • Shifts
  • +
  • Media Gallery
  • +
  • Login button (when not authenticated)
  • +
+

VolunteerLayout

+

Top navigation layout for volunteer portal.

+

Location: admin/src/components/VolunteerLayout.tsx

+

Features:

+
    +
  • Horizontal navigation bar
  • +
  • Mobile hamburger menu
  • +
  • User status display (name, role)
  • +
  • Active route highlighting
  • +
  • Dark theme (consistent with public)
  • +
  • Logout button
  • +
+

Route Context: /volunteer/*

+

Used By: +- Volunteer dashboard +- Shift assignments +- Canvass map (linked from assignments) +- Activity history +- Route history

+

Navigation Items:

+
    +
  1. Dashboard - Overview and stats
  2. +
  3. Assignments - Assigned shifts
  4. +
  5. Activity - Visit history
  6. +
  7. Routes - Walking route history
  8. +
+

Mobile Behavior:

+
    +
  • Hamburger menu for small screens
  • +
  • Drawer navigation
  • +
  • Full-width content
  • +
  • Touch-friendly controls
  • +
+

MediaPublicLayout

+

Minimal layout for public media gallery.

+

Location: admin/src/components/MediaPublicLayout.tsx

+

Features:

+
    +
  • Clean header with branding
  • +
  • Full-width content area
  • +
  • Dark theme consistency
  • +
  • No footer clutter
  • +
  • Focus on media content
  • +
+

Route Context: /media, /media/:id

+

Used By: +- Public media gallery page +- Video viewer page

+

Layout Selection Pattern

+

Layouts are selected based on route context:

+
// Admin routes use AppLayout
+<Route path="/app" element={<AppLayout />}>
+  <Route path="dashboard" element={<DashboardPage />} />
+  <Route path="users" element={<UsersPage />} />
+  // ... more admin routes
+</Route>
+
+// Public routes use PublicLayout
+<Route element={<PublicLayout />}>
+  <Route path="/campaigns" element={<CampaignsListPage />} />
+  <Route path="/campaigns/:id" element={<CampaignPage />} />
+  <Route path="/map" element={<MapPage />} />
+</Route>
+
+// Volunteer routes use VolunteerLayout
+<Route path="/volunteer" element={<VolunteerLayout />}>
+  <Route path="dashboard" element={<VolunteerDashboardPage />} />
+  <Route path="assignments" element={<VolunteerShiftsPage />} />
+</Route>
+
+// Some pages are full-screen (no layout)
+<Route path="/volunteer/canvass/:cutId" element={<VolunteerMapPage />} />
+<Route path="/app/pages/:id/edit" element={<PageEditorPage />} />
+
+

Full-Screen Pages

+

Some pages render without any layout wrapper:

+
    +
  • VolunteerMapPage - Full-screen canvass map with GPS
  • +
  • PageEditorPage - GrapesJS editor (desktop-only)
  • +
  • EmailTemplateEditorPage - Email template editor
  • +
+

These pages handle their own navigation and controls.

+

Layout Customization

+

Theme Overrides

+

Layouts use Ant Design ConfigProvider for theming:

+
<ConfigProvider
+  theme={{
+    token: {
+      colorPrimary: '#3498db',
+      colorBgBase: '#0d1b2a',
+      // ... more tokens
+    },
+  }}
+>
+  {children}
+</ConfigProvider>
+
+

Role-Based Navigation

+

AppLayout filters menu items based on user role:

+
const menuItems = [
+  { key: 'dashboard', label: 'Dashboard', icon: <DashboardOutlined /> },
+
+  // Influence section - only for SUPER_ADMIN, INFLUENCE_ADMIN
+  user.role === 'SUPER_ADMIN' || user.role === 'INFLUENCE_ADMIN' ? {
+    key: 'influence',
+    label: 'Influence',
+    children: [...]
+  } : null,
+
+  // Map section - only for SUPER_ADMIN, MAP_ADMIN
+  user.role === 'SUPER_ADMIN' || user.role === 'MAP_ADMIN' ? {
+    key: 'map',
+    label: 'Map',
+    children: [...]
+  } : null,
+].filter(Boolean);
+
+

Responsive Breakpoints

+

Layouts use Ant Design grid breakpoints:

+
    +
  • xs - < 576px (mobile)
  • +
  • sm - ≥ 576px (tablet)
  • +
  • md - ≥ 768px (small desktop)
  • +
  • lg - ≥ 992px (desktop)
  • +
  • xl - ≥ 1200px (large desktop)
  • +
  • xxl - ≥ 1600px (extra large)
  • +
+

Access via Grid.useBreakpoint():

+
const screens = Grid.useBreakpoint();
+const isMobile = !screens.md;
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/campaigns-page/index.html b/mkdocs/site/v2/frontend/pages/admin/campaigns-page/index.html new file mode 100644 index 00000000..61e95664 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/campaigns-page/index.html @@ -0,0 +1,7661 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Campaigns - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

CampaignsPage

+

Overview

+

The CampaignsPage provides complete CRUD management for advocacy email campaigns in the Influence module. It displays campaigns in a paginated table with search, status filtering, and quick actions for viewing, editing, deleting, and accessing email statistics. Features include campaign highlighting, government level targeting, and comprehensive feature flags for customizing campaign behavior.

+

Route: /app/influence/campaigns +Component: admin/src/pages/CampaignsPage.tsx (507 lines) +Auth Required: Yes (SUPER_ADMIN, INFLUENCE_ADMIN roles) +Layout: AppLayout

+

Screenshot

+

[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.]

+

Features

+
    +
  • Full CRUD operations — Create, read, update, delete campaigns
  • +
  • Advanced search — 300ms debounced search by title or description
  • +
  • Status filtering — Filter by DRAFT, ACTIVE, PAUSED, ARCHIVED
  • +
  • Campaign highlighting — Star icon indicates featured campaigns (highlightCampaign flag)
  • +
  • Government level tags — Visual tags for FEDERAL, PROVINCIAL, MUNICIPAL, SCHOOL_BOARD
  • +
  • Email statistics — Click MailOutlined icon to open emails drawer with campaign email stats
  • +
  • Public link management — Copy campaign public link, view public page (ACTIVE only)
  • +
  • Comprehensive feature flags — 9 boolean toggles for campaign behavior:
  • +
  • Allow SMTP Email (send via queue)
  • +
  • Allow Mailto Link (browser email client)
  • +
  • Collect User Info (name, email, postal code)
  • +
  • Show Email Count (display total emails sent)
  • +
  • Show Call Count (display total calls made)
  • +
  • Allow Email Editing (user can edit template)
  • +
  • Allow Custom Recipients (user can add custom reps)
  • +
  • Show Response Wall (public response submission + display)
  • +
  • Highlight Campaign (featured on public campaigns list)
  • +
  • Color-coded statuses — Visual distinction between draft, active, paused, archived
  • +
  • Responsive table — Columns hide on smaller screens (Gov. Levels: md+, Responses: lg+, Created: md+)
  • +
  • Delete confirmation — Warns that associated emails and responses will also be deleted
  • +
+

User Workflow

+

Viewing Campaigns List

+
    +
  1. Navigate to /app/influence/campaigns
  2. +
  3. Page loads first 20 campaigns (pagination)
  4. +
  5. View campaign stats: Emails count, Responses count
  6. +
  7. See campaign status with colored tags
  8. +
  9. Identify featured campaigns by star icon (highlightCampaign)
  10. +
  11. Note public URL slug below campaign title
  12. +
+

Creating a New Campaign

+
    +
  1. Click "Create Campaign" button in page header
  2. +
  3. Modal opens (640px width) with vertical form
  4. +
  5. Fill required fields:
  6. +
  7. Title (auto-generates slug from title)
  8. +
  9. Email Subject
  10. +
  11. Email Body (template shown to users)
  12. +
  13. Fill optional fields:
  14. +
  15. Description (internal note, not shown to public)
  16. +
  17. Call to Action (additional instructions for users)
  18. +
  19. Government Levels (multi-select: Federal, Provincial, Municipal, School Board)
  20. +
  21. Cover Photo URL (hero image on public campaign page)
  22. +
  23. Status (default: DRAFT)
  24. +
  25. Configure feature flags (9 switches in 2-column grid):
  26. +
  27. Default ON: allowSmtpEmail, allowMailtoLink, collectUserInfo, showEmailCount, showCallCount
  28. +
  29. Default OFF: allowEmailEditing, allowCustomRecipients, showResponseWall, highlightCampaign
  30. +
  31. Click "Create" button
  32. +
  33. Success message: "Campaign created"
  34. +
  35. Modal closes, table refreshes to page 1
  36. +
  37. New campaign appears at top (most recent first)
  38. +
+

Editing an Existing Campaign

+
    +
  1. Locate campaign in table
  2. +
  3. Click Edit icon button (EditOutlined) in Actions column
  4. +
  5. Edit modal opens (640px width) with pre-filled values
  6. +
  7. Modify any fields (same form as create)
  8. +
  9. Click "Save" button
  10. +
  11. Success message: "Campaign updated"
  12. +
  13. Modal closes, table refreshes with updated data
  14. +
  15. If title changed, slug auto-updates
  16. +
+

Viewing Campaign Emails

+
    +
  1. Locate campaign in table
  2. +
  3. Click Mail icon button (MailOutlined) in Actions column
  4. +
  5. CampaignEmailsDrawer opens on right side (see CampaignEmailsDrawer)
  6. +
  7. View email statistics:
  8. +
  9. Total emails sent
  10. +
  11. Delivered, failed, pending counts
  12. +
  13. Email list with recipient, status, timestamp
  14. +
  15. Click "X" to close drawer
  16. +
+

Publishing a Campaign

+
    +
  1. Open campaign in edit modal
  2. +
  3. Change Status dropdown from DRAFT to ACTIVE
  4. +
  5. Click "Save"
  6. +
  7. Campaign now visible on public /campaigns page
  8. +
  9. View icon button (EyeOutlined) now enabled
  10. +
  11. Click View to open public campaign page in new tab
  12. +
+ +
    +
  1. Locate ACTIVE campaign in table
  2. +
  3. Click Link icon button (LinkOutlined) in Actions column
  4. +
  5. URL copied to clipboard: http://app.cmlite.org/campaign/{slug}
  6. +
  7. Success message: "Campaign link copied"
  8. +
  9. Share link with supporters
  10. +
+

Searching and Filtering

+
    +
  1. Use search bar at top left:
  2. +
  3. Type title or description keywords
  4. +
  5. 300ms debounce (waits for typing to stop)
  6. +
  7. Search resets pagination to page 1
  8. +
  9. Use status filter dropdown at top right:
  10. +
  11. Select DRAFT, ACTIVE, PAUSED, or ARCHIVED
  12. +
  13. Filter resets pagination to page 1
  14. +
  15. Clear filter to show all campaigns
  16. +
  17. Filters persist during pagination
  18. +
+

Deleting a Campaign

+
    +
  1. Locate campaign in table
  2. +
  3. Click Delete icon button (DeleteOutlined) in Actions column
  4. +
  5. Popconfirm appears: "Delete this campaign?"
  6. +
  7. Description: "All associated emails and responses will also be deleted."
  8. +
  9. Click "OK" to confirm
  10. +
  11. Success message: "Campaign deleted"
  12. +
  13. Table refreshes
  14. +
  15. Associated CampaignEmail and Response records also deleted (cascade)
  16. +
+

Component Breakdown

+

Ant Design Components Used

+
    +
  • Table — Main campaigns list with columns, pagination, responsive breakpoints
  • +
  • Input — Search text input with SearchOutlined prefix icon
  • +
  • Select — Status filter dropdown with 4 options
  • +
  • Button — Create (primary), view, copy link, email stats, edit, delete actions
  • +
  • Modal — Create and edit campaign forms (destroyOnHidden)
  • +
  • Form — Vertical layout with all campaign fields
  • +
  • Form.Item — Individual field wrappers with labels, rules, help text
  • +
  • Input.TextArea — Multi-line fields (description, email body, call to action)
  • +
  • Row, Col — Responsive grid for status + gov levels (2 columns), feature flags (2 columns, 9 switches)
  • +
  • Switch — Boolean feature flag toggles with valuePropName="checked"
  • +
  • Tag — Status tags (color-coded), government level tags (color-coded)
  • +
  • Space — Action button grouping
  • +
  • Popconfirm — Delete confirmation with warning message
  • +
  • Divider — Feature flags section separator
  • +
+

Table Columns

+
const columns: ColumnsType<Campaign> = [
+  {
+    title: 'Title',
+    dataIndex: 'title',
+    key: 'title',
+    render: (title, record) => (
+      <div>
+        <Space>
+          <span style={{ fontWeight: 500 }}>{title}</span>
+          {record.highlightCampaign && <StarFilled style={{ color: '#faad14' }} />}
+        </Space>
+        <div style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)' }}>/campaign/{record.slug}</div>
+      </div>
+    ),
+  },
+  {
+    title: 'Status',
+    dataIndex: 'status',
+    render: (status) => <Tag color={statusColors[status]}>{status}</Tag>,
+  },
+  {
+    title: 'Gov. Levels',
+    dataIndex: 'targetGovernmentLevels',
+    render: (levels: GovernmentLevel[]) =>
+      levels.map((l) => <Tag key={l} color={govLevelColors[l]}>{l.replace('_', ' ')}</Tag>),
+    responsive: ['md'],
+  },
+  {
+    title: 'Emails',
+    render: (_, record) => record._count.emails,
+    responsive: ['md'],
+  },
+  {
+    title: 'Responses',
+    render: (_, record) => record._count.responses,
+    responsive: ['lg'],
+  },
+  {
+    title: 'Created',
+    dataIndex: 'createdAt',
+    render: (date) => dayjs(date).format('YYYY-MM-DD'),
+    responsive: ['md'],
+  },
+  {
+    title: 'Actions',
+    render: (_, record) => (
+      <Space>
+        {/* View public page (ACTIVE only) */}
+        {/* Copy link */}
+        {/* View emails drawer */}
+        {/* Edit modal */}
+        {/* Delete popconfirm */}
+      </Space>
+    ),
+  },
+];
+
+

Key patterns: +- _count aggregation fields from Prisma (emails, responses) +- Responsive column visibility with responsive: ['md'] +- Conditional rendering: View button only for ACTIVE campaigns

+

Status Colors

+
const statusColors: Record<CampaignStatus, string> = {
+  DRAFT: 'default',    // Gray
+  ACTIVE: 'green',     // Green
+  PAUSED: 'orange',    // Orange
+  ARCHIVED: 'gray',    // Gray
+};
+
+

Government Level Colors

+
const govLevelColors: Record<GovernmentLevel, string> = {
+  FEDERAL: 'blue',
+  PROVINCIAL: 'purple',
+  MUNICIPAL: 'cyan',
+  SCHOOL_BOARD: 'magenta',
+};
+
+

Feature Flags Form Section

+
<Divider orientation="left" plain>Feature Flags</Divider>
+<Row gutter={[16, 8]}>
+  <Col xs={24} sm={12}>
+    <Form.Item name="allowSmtpEmail" label="Allow SMTP Email" valuePropName="checked" initialValue={true}>
+      <Switch />
+    </Form.Item>
+  </Col>
+  <Col xs={24} sm={12}>
+    <Form.Item name="allowMailtoLink" label="Allow Mailto Link" valuePropName="checked" initialValue={true}>
+      <Switch />
+    </Form.Item>
+  </Col>
+  {/* 7 more switches */}
+</Row>
+
+

Pattern: 9 switches in 2-column responsive grid (xs: 1 column, sm+: 2 columns)

+

State Management

+

Zustand Stores Used

+

None — Campaigns are fetched from API on each page load. No global state required.

+

Local State

+
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
+const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
+const [loading, setLoading] = useState(false);
+const [search, setSearch] = useState('');
+const [debouncedSearch, setDebouncedSearch] = useState('');
+const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
+const [statusFilter, setStatusFilter] = useState<CampaignStatus | undefined>();
+const [createModalOpen, setCreateModalOpen] = useState(false);
+const [editModalOpen, setEditModalOpen] = useState(false);
+const [editingCampaign, setEditingCampaign] = useState<Campaign | null>(null);
+const [emailsDrawerOpen, setEmailsDrawerOpen] = useState(false);
+const [emailsCampaign, setEmailsCampaign] = useState<Campaign | null>(null);
+const [createForm] = Form.useForm();
+const [editForm] = Form.useForm();
+
+

Debounced search pattern:

+
const handleSearchChange = (value: string) => {
+  setSearch(value);               // Update input immediately
+  clearTimeout(searchTimerRef.current);
+  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);  // Debounce API call
+};
+
+useEffect(() => {
+  fetchCampaigns({ page: 1 });
+}, [debouncedSearch, statusFilter]);  // Re-fetch when debounced search or filter changes
+
+useEffect(() => {
+  return () => clearTimeout(searchTimerRef.current);  // Cleanup on unmount
+}, []);
+
+

Why 300ms debounce? Prevents API spam while typing. Only fetches when user pauses.

+

API Integration

+

Endpoints Used

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointPurpose
GET/api/campaignsList campaigns (paginated, filtered)
POST/api/campaignsCreate campaign
PUT/api/campaigns/:idUpdate campaign
DELETE/api/campaigns/:idDelete campaign (cascade emails + responses)
+

List Campaigns

+

Request:

+
const { data } = await api.get<CampaignsListResponse>('/campaigns', {
+  params: {
+    page: 1,
+    limit: 20,
+    search: 'climate',       // Optional: search title/description
+    status: 'ACTIVE',        // Optional: filter by status
+  },
+});
+
+

Response:

+
{
+  "campaigns": [
+    {
+      "id": "cm-123",
+      "title": "Contact Your MP About Climate Action",
+      "slug": "contact-your-mp-about-climate-action",
+      "description": "Urge federal representatives to support renewable energy legislation",
+      "emailSubject": "Support Climate Action Now",
+      "emailBody": "Dear [Representative Name],\n\nI am writing to urge you to support...",
+      "callToAction": "Remember to follow up with a phone call next week!",
+      "status": "ACTIVE",
+      "targetGovernmentLevels": ["FEDERAL"],
+      "allowSmtpEmail": true,
+      "allowMailtoLink": true,
+      "collectUserInfo": true,
+      "showEmailCount": true,
+      "showCallCount": false,
+      "allowEmailEditing": false,
+      "allowCustomRecipients": false,
+      "showResponseWall": true,
+      "highlightCampaign": true,
+      "coverPhoto": "https://example.com/climate.jpg",
+      "createdAt": "2026-01-15T10:30:00.000Z",
+      "updatedAt": "2026-01-20T14:45:00.000Z",
+      "_count": {
+        "emails": 847,
+        "responses": 23
+      }
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 20,
+    "total": 12,
+    "totalPages": 1
+  }
+}
+
+

Key fields: +- slug — URL-friendly identifier (auto-generated from title) +- targetGovernmentLevels — Array of government levels (empty array = all) +- _count — Prisma aggregation with email and response counts +- Feature flags — 9 boolean fields controlling campaign behavior

+

Create Campaign

+

Request:

+
const payload: CreateCampaignPayload = {
+  title: "Stop Deforestation in Northern Ontario",
+  description: "Internal campaign note",
+  emailSubject: "Protect Our Forests",
+  emailBody: "Dear [Representative Name],\n\nI urge you to...",
+  callToAction: "Share this campaign on social media!",
+  status: "DRAFT",
+  targetGovernmentLevels: ["PROVINCIAL"],
+  allowSmtpEmail: true,
+  allowMailtoLink: true,
+  collectUserInfo: true,
+  showEmailCount: true,
+  showCallCount: false,
+  allowEmailEditing: false,
+  allowCustomRecipients: false,
+  showResponseWall: false,
+  highlightCampaign: false,
+  coverPhoto: "https://example.com/forest.jpg",
+};
+
+await api.post('/campaigns', payload);
+
+

Response:

+
{
+  "id": "cm-456",
+  "title": "Stop Deforestation in Northern Ontario",
+  "slug": "stop-deforestation-in-northern-ontario",
+  "status": "DRAFT",
+  "createdAt": "2026-02-11T09:00:00.000Z",
+  // ... all other fields
+}
+
+

Slug generation: Backend auto-generates slug from title (lowercase, hyphens replace spaces/punctuation)

+

Update Campaign

+

Request:

+
const payload: UpdateCampaignPayload = {
+  status: "ACTIVE",              // Publish campaign
+  highlightCampaign: true,       // Feature on campaigns list
+  showResponseWall: true,        // Enable response submissions
+};
+
+await api.put(`/campaigns/${campaignId}`, payload);
+
+

Response:

+
{
+  "id": "cm-456",
+  "status": "ACTIVE",
+  "highlightCampaign": true,
+  "showResponseWall": true,
+  "updatedAt": "2026-02-11T10:15:00.000Z",
+  // ... all other fields
+}
+
+

Partial updates: Only send changed fields, backend merges with existing record.

+

Delete Campaign

+

Request:

+
await api.delete(`/campaigns/${campaignId}`);
+
+

Response: 204 No Content

+

Cascade behavior: Prisma cascade deletes: +- All CampaignEmail records (sent emails) +- All Response records (public responses) +- All PostalCodeCache entries referencing this campaign

+

Warning: Shown in Popconfirm: "All associated emails and responses will also be deleted."

+

Code Examples

+

Debounced Search Implementation

+
const [search, setSearch] = useState('');
+const [debouncedSearch, setDebouncedSearch] = useState('');
+const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
+
+const handleSearchChange = (value: string) => {
+  setSearch(value);                             // Update input immediately (controlled component)
+  clearTimeout(searchTimerRef.current);         // Cancel previous timer
+  searchTimerRef.current = setTimeout(() => {
+    setDebouncedSearch(value);                  // Update debounced value after 300ms
+  }, 300);
+};
+
+useEffect(() => {
+  return () => clearTimeout(searchTimerRef.current);  // Cleanup timer on unmount
+}, []);
+
+useEffect(() => {
+  fetchCampaigns({ page: 1 });                  // Re-fetch when debounced search changes
+}, [debouncedSearch, statusFilter]);            // Also re-fetch when filter changes
+
+

Benefits: +- User sees immediate feedback in input (controlled) +- API only called once per 300ms (prevents spam) +- Timer cleared on unmount (no memory leaks)

+

useCallback Optimization

+
const fetchCampaigns = useCallback(async (params?: CampaignsListParams) => {
+  setLoading(true);
+  try {
+    const { data } = await api.get<CampaignsListResponse>('/campaigns', {
+      params: {
+        page: params?.page ?? pagination.page,
+        limit: params?.limit ?? pagination.limit,
+        search: params?.search ?? (debouncedSearch || undefined),
+        status: params?.status ?? statusFilter,
+      },
+    });
+    setCampaigns(data.campaigns);
+    setPagination(data.pagination);
+  } catch {
+    message.error('Failed to load campaigns');
+  } finally {
+    setLoading(false);
+  }
+}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);
+
+

Why useCallback? Memoizes function, prevents re-creating on every render. Dependencies array ensures function updates when pagination, search, or filter changes.

+

Color-Coded Government Level Tags

+
const govLevelColors: Record<GovernmentLevel, string> = {
+  FEDERAL: 'blue',
+  PROVINCIAL: 'purple',
+  MUNICIPAL: 'cyan',
+  SCHOOL_BOARD: 'magenta',
+};
+
+// In table column render:
+{
+  title: 'Gov. Levels',
+  dataIndex: 'targetGovernmentLevels',
+  render: (levels: GovernmentLevel[]) =>
+    levels.length > 0
+      ? levels.map((l) => (
+          <Tag key={l} color={govLevelColors[l]} style={{ fontSize: 11 }}>
+            {l.replace('_', ' ')}  // "SCHOOL_BOARD" → "SCHOOL BOARD"
+          </Tag>
+        ))
+      : '--',
+  responsive: ['md'],
+}
+
+

Pattern: Map each government level to a colored tag, replace underscores with spaces for readability.

+

Reusable Form Fields Component

+
const campaignFormFields = (
+  <>
+    <Form.Item name="title" label="Title" rules={[{ required: true }]}>
+      <Input />
+    </Form.Item>
+    <Form.Item name="description" label="Description">
+      <TextArea rows={2} />
+    </Form.Item>
+    {/* ... all other fields */}
+    <Divider orientation="left" plain>Feature Flags</Divider>
+    <Row gutter={[16, 8]}>
+      {/* 9 switches in 2-column grid */}
+    </Row>
+  </>
+);
+
+// Used in both create and edit modals:
+<Form form={createForm} onFinish={handleCreate} layout="vertical">
+  {campaignFormFields}
+</Form>
+
+<Form form={editForm} onFinish={handleEdit} layout="vertical">
+  {campaignFormFields}
+</Form>
+
+

Benefits: +- DRY principle (Don't Repeat Yourself) +- Single source of truth for form structure +- Easy to add/modify fields in one place

+

Performance Considerations

+ +

300ms debounce prevents API spam: +- User typing "climate action" fires 1 API call (not 14) +- Reduces server load, improves responsiveness +- Uses clearTimeout to cancel pending calls

+

Responsive Column Hiding

+
{
+  title: 'Gov. Levels',
+  responsive: ['md'],  // Hide on screens < 768px
+}
+
+

Benefits: +- Mobile users see only essential columns (Title, Status, Actions) +- Desktop users see full details +- No horizontal scrolling on mobile

+

useCallback Memoization

+
const fetchCampaigns = useCallback(async (params) => {
+  // ... fetch logic
+}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);
+
+

Benefits: +- Function reference stable unless dependencies change +- Prevents unnecessary re-renders in child components +- Avoids infinite re-render loops

+

Pagination

+

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)

+

Responsive Design

+

Mobile (< 576px)

+
    +
  • Table: Single column layout
  • +
  • Title + star icon (if highlighted)
  • +
  • Status tag
  • +
  • Actions column (all 5 buttons visible)
  • +
  • Gov. Levels, Emails, Responses, Created columns hidden
  • +
  • Search bar: Full width
  • +
  • Status filter: Full width below search
  • +
  • Feature flags: Single column (xs={24})
  • +
+

Tablet (576px - 992px)

+
    +
  • Table: Gov. Levels, Emails, Created columns visible
  • +
  • Responses column still hidden (lg+)
  • +
  • Search bar: Half width (sm={12})
  • +
  • Status filter: Quarter width (sm={6})
  • +
  • Feature flags: 2 columns (sm={12})
  • +
+

Desktop (≥ 992px)

+
    +
  • Table: All columns visible
  • +
  • Filters: Compact layout (search ⅓ width, filter ⅙ width)
  • +
  • Feature flags: 2 columns with comfortable spacing
  • +
+

Accessibility

+
    +
  • Keyboard navigation: All buttons, inputs, selects focusable via Tab
  • +
  • ARIA labels: Icon buttons have title attribute for tooltips
  • +
  • Form validation: Required fields marked with red asterisk, inline error messages
  • +
  • Color contrast: Status tags use Ant Design default colors (WCAG AA compliant)
  • +
  • Screen reader support: Form.Item labels properly associated with inputs
  • +
  • Focus management: Modal auto-focuses first input on open
  • +
+

Troubleshooting

+

Campaign Not Appearing on Public Page

+

Problem: Created campaign, set status to ACTIVE, but /campaigns page doesn't show it.

+

Diagnosis:

+

Check status in campaigns table: +

campaigns.find((c) => c.slug === 'my-campaign')?.status  // Should be "ACTIVE"
+

+

Common Issues:

+
    +
  1. Status still DRAFT:
  2. +
  3. Edit campaign
  4. +
  5. Change Status dropdown from DRAFT to ACTIVE
  6. +
  7. +

    Click Save

    +
  8. +
  9. +

    Browser cache:

    +
  10. +
  11. Hard refresh public page (Ctrl+Shift+R)
  12. +
  13. +

    Or clear browser cache

    +
  14. +
  15. +

    Campaign created but not saved:

    +
  16. +
  17. Check for error message after clicking Create
  18. +
  19. Verify required fields filled (Title, Email Subject, Email Body)
  20. +
+

Solution: Always verify status is ACTIVE after creating campaign. Status defaults to DRAFT.

+
+

Emails Drawer Shows 0 Emails

+

Problem: Click Mail icon for ACTIVE campaign, drawer shows 0 emails.

+

Diagnosis:

+

Campaign might be active but no one has sent emails yet: +

campaign._count.emails === 0  // No emails sent via this campaign
+

+

Common Issues:

+
    +
  1. Campaign just published:
  2. +
  3. No users have accessed public page yet
  4. +
  5. +

    Share campaign link to supporters

    +
  6. +
  7. +

    SMTP not configured:

    +
  8. +
  9. Check Settings → Email tab
  10. +
  11. Verify Production SMTP credentials
  12. +
  13. +

    Test connection

    +
  14. +
  15. +

    BullMQ queue not running:

    +
  16. +
  17. Check docker-compose logs: docker compose logs email-worker
  18. +
  19. Verify redis container running
  20. +
+

Solution: Emails drawer shows historical data. If campaign is new, wait for users to send emails via public page.

+
+ +

Problem: Click Link icon, no success message, clipboard empty.

+

Diagnosis:

+

Check browser console for errors: +

DOMException: Document is not focused
+

+

Common Issue:

+

Browser security blocks clipboard access if page not focused.

+

Solution:

+
    +
  1. Click anywhere on page to focus
  2. +
  3. Retry Copy Link button
  4. +
  5. Or manually copy slug from table: /campaign/{slug}
  6. +
+
+

Duplicate Campaign Titles

+

Problem: Create campaign with same title as existing, backend allows it.

+

Diagnosis:

+

Backend auto-generates unique slug by appending numbers: +

"Climate Action" → "climate-action"
+"Climate Action" (duplicate) → "climate-action-1"
+"Climate Action" (duplicate 2) → "climate-action-2"
+

+

Not an error: Duplicate titles allowed, slugs remain unique.

+

Best Practice: Use unique, descriptive titles to avoid confusion: +- ❌ "Climate Action" (generic) +- ✅ "Climate Action: Support Bill C-12" (specific)

+
+

Delete Confirmation Not Showing

+

Problem: Click Delete icon, campaign deletes immediately without confirmation.

+

Diagnosis:

+

Check Popconfirm placement in table Actions column: +

<Popconfirm
+  title="Delete this campaign?"
+  description="All associated emails and responses will also be deleted."
+  onConfirm={() => handleDelete(record.id)}
+>
+  <Button type="link" size="small" danger icon={<DeleteOutlined />} title="Delete" />
+</Popconfirm>
+

+

Solution: Popconfirm wraps the Button. If Popconfirm missing, delete happens immediately. Always use Popconfirm for destructive actions.

+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/canvass-dashboard-page/index.html b/mkdocs/site/v2/frontend/pages/admin/canvass-dashboard-page/index.html new file mode 100644 index 00000000..2d9b3139 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/canvass-dashboard-page/index.html @@ -0,0 +1,8302 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Canvass Dashboard - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

CanvassDashboardPage

+

Overview

+

The CanvassDashboardPage provides a real-time administrative overview of all volunteer canvassing activities across all cuts. It displays live statistics (total visits, active volunteers, active sessions), a chronological activity feed showing recent visit outcomes, cut-by-cut progress tracking with completion percentages, a volunteer leaderboard ranked by visit count, and an interactive map showing active volunteers' current GPS positions. The dashboard auto-refreshes every 30 seconds to maintain real-time accuracy.

+

Route: /app/canvass/dashboard +Component: admin/src/pages/CanvassDashboardPage.tsx (316 lines) +Auth Required: Yes (SUPER_ADMIN or MAP_ADMIN role recommended) +Layout: AppLayout +Backend Module: api/src/modules/map/canvass/

+

Screenshot

+

[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.]

+

Features

+
    +
  • Real-time statistics — Total visits, active volunteers, active sessions, average visits per session
  • +
  • Auto-refresh — Updates every 30 seconds automatically (configurable interval)
  • +
  • Activity feed — Chronological list of recent visits with outcome, address, timestamp
  • +
  • Cut progress tracking — Progress bars showing visit completion percentage per cut
  • +
  • Volunteer leaderboard — Top 10 volunteers ranked by total visit count
  • +
  • Live volunteer map — Interactive Leaflet map showing active volunteers' GPS positions
  • +
  • Manual refresh — Refresh button to update data immediately
  • +
  • Responsive design — Two-column layout on desktop, stacked on mobile
  • +
  • Color-coded outcomes — Visit outcomes highlighted with semantic colors (green, red, orange, blue)
  • +
  • Relative timestamps — Human-readable time since visit (e.g., "2 mins ago", "1 hour ago")
  • +
  • Empty states — Friendly messages when no data available
  • +
  • Cut filtering — Click cut name in progress list to view cut details
  • +
+

User Workflow

+

Monitoring Active Canvassing

+
    +
  1. Navigate to /app/canvass/dashboard
  2. +
  3. Page loads with initial data fetch
  4. +
  5. View statistics cards (top row):
  6. +
  7. Total Visits: All-time visit count across all cuts
  8. +
  9. Active Volunteers: Currently signed in with active sessions
  10. +
  11. Active Sessions: Currently ACTIVE sessions (not COMPLETED or ABANDONED)
  12. +
  13. Avg Visits per Session: Total visits / total sessions
  14. +
  15. Observe auto-refresh indicator:
  16. +
  17. Page refreshes every 30 seconds
  18. +
  19. No loading spinner (silent refresh)
  20. +
  21. Data updates smoothly without UI flicker
  22. +
  23. Monitor activity feed (left column):
  24. +
  25. See most recent 20 visits
  26. +
  27. Each entry shows: volunteer name, outcome, address, relative time
  28. +
  29. Color-coded by outcome (Answered=green, Not Home=red, etc.)
  30. +
  31. Auto-scrolls to top when new visits appear
  32. +
  33. Track cut progress (right column, top):
  34. +
  35. See all cuts with visit counts
  36. +
  37. Progress bars show completion percentage
  38. +
  39. Percentage calculated as (visits / locations) × 100%
  40. +
  41. View volunteer leaderboard (right column, bottom):
  42. +
  43. Top 10 volunteers by visit count
  44. +
  45. Shows total visits per volunteer
  46. +
  47. Ranked 1st to 10th place
  48. +
  49. Use live map (bottom):
  50. +
  51. See active volunteers as blue circle markers
  52. +
  53. View cut polygons as colored overlays
  54. +
  55. Zoom and pan to explore territory
  56. +
  57. Hover over markers for volunteer name and current location
  58. +
+

Responding to Activity

+
    +
  1. Notice new visit in activity feed (e.g., "Jane Doe - ANSWERED - 456 Oak Ave - Just now")
  2. +
  3. Click cut name in progress section to view cut details:
  4. +
  5. Navigates to /app/map/cuts?id={cutId}
  6. +
  7. Opens CutsPage filtered to that cut
  8. +
  9. Can view all locations, edit cut, or export data
  10. +
  11. Click volunteer name in leaderboard to view volunteer details:
  12. +
  13. Navigates to /app/users?id={userId}
  14. +
  15. Opens UsersPage filtered to that user
  16. +
  17. Can edit user info, assign roles, or view all visits
  18. +
  19. Click refresh button (top-right, next to title) to force immediate data update:
  20. +
  21. Fetches latest data from API
  22. +
  23. Updates all sections simultaneously
  24. +
  25. Useful when expecting urgent update (e.g., shift just ended)
  26. +
+

Identifying Issues

+
    +
  1. No Active Volunteers:
  2. +
  3. Statistics show "Active Volunteers: 0"
  4. +
  5. Activity feed is empty or stale
  6. +
  7. +

    Action: Check if any shifts are scheduled, contact volunteers to start shifts

    +
  8. +
  9. +

    High "Not Home" Rate:

    +
  10. +
  11. Activity feed shows many red "NOT_HOME" entries
  12. +
  13. +

    Action: Consider rescheduling shifts to evening hours when residents more likely home

    +
  14. +
  15. +

    Stalled Sessions:

    +
  16. +
  17. Active Sessions count doesn't decrease over time
  18. +
  19. +

    Action: Check for abandoned sessions (volunteers forgot to end session), manually close via backend

    +
  20. +
  21. +

    Volunteers Off Course:

    +
  22. +
  23. Live map shows volunteer marker far from assigned cut polygon
  24. +
  25. +

    Action: Contact volunteer to redirect back to assigned territory

    +
  26. +
  27. +

    Low Visits per Session:

    +
  28. +
  29. Average visits per session below expected rate (e.g., < 10)
  30. +
  31. Action: Investigate if locations are too far apart, provide walking route optimization
  32. +
+

Using Live Map

+
    +
  1. Scroll to "Live Volunteer Map" card at bottom
  2. +
  3. Map loads with:
  4. +
  5. Cut polygons as colored overlays (semi-transparent fill)
  6. +
  7. Active volunteer markers as blue circles
  8. +
  9. Legend in bottom-right corner
  10. +
  11. Zoom controls:
  12. +
  13. Plus (+) button: Zoom in
  14. +
  15. Minus (−) button: Zoom out
  16. +
  17. Scroll wheel: Zoom in/out
  18. +
  19. Pan:
  20. +
  21. Click and drag map to move view
  22. +
  23. Double-click to zoom in on point
  24. +
  25. Marker interaction:
  26. +
  27. Hover over blue marker: Tooltip shows volunteer name
  28. +
  29. Click marker: Opens popup with volunteer details (name, current session, visit count)
  30. +
  31. Polygon interaction:
  32. +
  33. Hover over cut polygon: Tooltip shows cut name and visit count
  34. +
  35. Click polygon: Navigates to cut details page
  36. +
+

Component Breakdown

+

Ant Design Components Used

+
    +
  • Typography.Title — Page heading ("Canvass Dashboard")
  • +
  • Typography.Text — Labels, descriptions, empty state text
  • +
  • Row / Col — Grid layout for statistics cards and two-column layout
  • +
  • Card — Container for all sections (stats, activity, progress, leaderboard, map)
  • +
  • Statistic — Formatted numeric statistics display
  • +
  • Button — Refresh button (top-right)
  • +
  • List — Activity feed list
  • +
  • List.Item — Individual activity entries
  • +
  • Progress — Cut progress bars
  • +
  • Empty — Empty state when no data available
  • +
  • Tooltip — Hover tooltips on map markers
  • +
  • Spin — Loading spinner during initial data fetch
  • +
+

Custom Components

+
    +
  • AdminMapView — Leaflet map wrapper with volunteer markers and cut overlays
  • +
  • Renders active volunteer positions as blue circle markers
  • +
  • Renders cut polygons as colored overlays
  • +
  • Provides zoom/pan controls
  • +
  • Auto-centers on volunteer cluster
  • +
+

Statistics Cards

+
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
+  <Col xs={24} sm={12} md={6}>
+    <Card>
+      <Statistic
+        title="Total Visits"
+        value={stats.totalVisits}
+        prefix={<CheckCircleOutlined />}
+        valueStyle={{ color: '#3f8600' }}
+      />
+    </Card>
+  </Col>
+  <Col xs={24} sm={12} md={6}>
+    <Card>
+      <Statistic
+        title="Active Volunteers"
+        value={stats.activeVolunteers}
+        prefix={<TeamOutlined />}
+        valueStyle={{ color: '#1890ff' }}
+      />
+    </Card>
+  </Col>
+  <Col xs={24} sm={12} md={6}>
+    <Card>
+      <Statistic
+        title="Active Sessions"
+        value={stats.activeSessions}
+        prefix={<FieldTimeOutlined />}
+        valueStyle={{ color: '#faad14' }}
+      />
+    </Card>
+  </Col>
+  <Col xs={24} sm={12} md={6}>
+    <Card>
+      <Statistic
+        title="Avg Visits per Session"
+        value={stats.avgVisitsPerSession}
+        precision={1}
+        prefix={<LineChartOutlined />}
+        valueStyle={{ color: '#722ed1' }}
+      />
+    </Card>
+  </Col>
+</Row>
+
+

Responsive Grid: +- Mobile (xs, <576px): Stacked cards (24 columns = full width) +- Tablet (sm, ≥576px): 2 columns (12 columns each = 50% width) +- Desktop (md, ≥768px): 4 columns (6 columns each = 25% width)

+

Color-Coded Values: +- Total Visits: Green (#3f8600) — success metric +- Active Volunteers: Blue (#1890ff) — informational +- Active Sessions: Orange (#faad14) — warning/attention +- Avg Visits per Session: Purple (#722ed1) — analytical

+

Activity Feed

+
<Card
+  title="Recent Activity"
+  style={{ height: 400, overflow: 'auto' }}
+>
+  <List
+    dataSource={recentActivity}
+    renderItem={(activity) => (
+      <List.Item>
+        <Space direction="vertical" size={0} style={{ width: '100%' }}>
+          <Space>
+            <Text strong>{activity.volunteerName}</Text>
+            <Tag color={getOutcomeColor(activity.outcome)}>
+              {formatOutcome(activity.outcome)}
+            </Tag>
+          </Space>
+          <Text type="secondary" style={{ fontSize: 13 }}>
+            {activity.address}
+          </Text>
+          <Text type="secondary" style={{ fontSize: 12 }}>
+            {formatRelativeTime(activity.timestamp)}
+          </Text>
+        </Space>
+      </List.Item>
+    )}
+  />
+</Card>
+
+

Activity Entry Structure: +- Line 1: Volunteer name (bold) + Outcome tag (color-coded) +- Line 2: Location address (secondary gray text) +- Line 3: Relative timestamp (smaller secondary text)

+

Outcome Color Mapping:

+
function getOutcomeColor(outcome: string): string {
+  const colorMap: Record<string, string> = {
+    ANSWERED: 'green',
+    NOT_HOME: 'red',
+    MOVED: 'orange',
+    REFUSED: 'volcano',
+    INACCESSIBLE: 'default',
+    OTHER: 'blue',
+  };
+  return colorMap[outcome] || 'default';
+}
+
+

Relative Time Formatting:

+
function formatRelativeTime(timestamp: string): string {
+  const now = dayjs();
+  const visitTime = dayjs(timestamp);
+  const diffMinutes = now.diff(visitTime, 'minute');
+
+  if (diffMinutes < 1) return 'Just now';
+  if (diffMinutes < 60) return `${diffMinutes} min${diffMinutes > 1 ? 's' : ''} ago`;
+
+  const diffHours = now.diff(visitTime, 'hour');
+  if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
+
+  const diffDays = now.diff(visitTime, 'day');
+  return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
+}
+
+

Cut Progress Section

+
<Card title="Cut Progress" style={{ marginBottom: 16 }}>
+  <Space direction="vertical" size="middle" style={{ width: '100%' }}>
+    {cutProgress.map((cut) => (
+      <div key={cut.id}>
+        <Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 4 }}>
+          <Text strong>{cut.name}</Text>
+          <Text type="secondary">
+            {cut.visitCount} / {cut.locationCount} locations
+          </Text>
+        </Space>
+        <Progress
+          percent={cut.percentage}
+          status={cut.percentage === 100 ? 'success' : 'active'}
+          strokeColor={cut.percentage === 100 ? '#52c41a' : '#1890ff'}
+        />
+      </div>
+    ))}
+  </Space>
+</Card>
+
+

Progress Calculation:

+
const percentage = Math.round((cut.visitCount / cut.locationCount) * 100);
+
+

Progress Bar States: +- Active (< 100%): Blue bar, animated stripes +- Success (100%): Green bar, checkmark icon +- Empty (0%): Gray bar, no progress

+

Volunteer Leaderboard

+
<Card title="Top Volunteers">
+  <List
+    dataSource={topVolunteers.slice(0, 10)}  // Top 10 only
+    renderItem={(volunteer, index) => (
+      <List.Item>
+        <Space>
+          <Text strong style={{ fontSize: 16 }}>
+            #{index + 1}
+          </Text>
+          <Text>{volunteer.name}</Text>
+        </Space>
+        <Tag color="blue">{volunteer.visitCount} visits</Tag>
+      </List.Item>
+    )}
+  />
+</Card>
+
+

Ranking Display: +- #1-3: Bold, larger font (emphasize top performers) +- #4-10: Standard font +- Visit count: Blue badge on right side

+

Live Volunteer Map

+
<Card title="Live Volunteer Map" style={{ marginTop: 24 }}>
+  <div style={{ height: 500 }}>
+    <AdminMapView
+      cuts={cuts}
+      volunteers={activeVolunteers}
+      showCutOverlays={true}
+      showVolunteerMarkers={true}
+      autoCenter={true}
+    />
+  </div>
+</Card>
+
+

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

+

State Management

+

Local State (No Zustand Store)

+
// Data state
+const [stats, setStats] = useState<CanvassStats | null>(null);
+const [recentActivity, setRecentActivity] = useState<CanvassVisit[]>([]);
+const [cutProgress, setCutProgress] = useState<CutProgress[]>([]);
+const [topVolunteers, setTopVolunteers] = useState<VolunteerStats[]>([]);
+const [activeVolunteers, setActiveVolunteers] = useState<ActiveVolunteer[]>([]);
+const [cuts, setCuts] = useState<Cut[]>([]);
+const [loading, setLoading] = useState(true);
+
+

No Global State:

+

This page does NOT use Zustand stores. All data is fetched directly from the API and stored in local state. This is appropriate because: +- Dashboard data is admin-only (not shared with other pages) +- Data is highly dynamic (changes every 30 seconds) +- No need to persist data between page visits +- Simpler architecture without store overhead

+

Auto-Refresh with useEffect

+
const loadData = useCallback(async () => {
+  try {
+    // Fetch all data in parallel
+    const [statsRes, activityRes, progressRes, volunteersRes, cutsRes, activePosRes] = await Promise.all([
+      api.get<CanvassStats>('/canvass/admin/stats'),
+      api.get<CanvassVisit[]>('/canvass/admin/recent-activity?limit=20'),
+      api.get<CutProgress[]>('/canvass/admin/cut-progress'),
+      api.get<VolunteerStats[]>('/canvass/admin/top-volunteers?limit=10'),
+      api.get<Cut[]>('/cuts'),
+      api.get<ActiveVolunteer[]>('/canvass/admin/active-volunteers'),
+    ]);
+
+    setStats(statsRes.data);
+    setRecentActivity(activityRes.data);
+    setCutProgress(progressRes.data);
+    setTopVolunteers(volunteersRes.data);
+    setCuts(cutsRes.data);
+    setActiveVolunteers(activePosRes.data);
+  } catch (error) {
+    message.error('Failed to load dashboard data');
+  } finally {
+    setLoading(false);
+  }
+}, []);
+
+useEffect(() => {
+  // Initial load
+  loadData();
+
+  // Set up auto-refresh interval
+  const interval = setInterval(loadData, 30000);  // Refresh every 30 seconds
+
+  // Cleanup on unmount
+  return () => clearInterval(interval);
+}, [loadData]);
+
+

Auto-Refresh Strategy:

+
    +
  • Initial load: Immediate fetch on mount
  • +
  • Interval: 30 seconds (30,000 milliseconds)
  • +
  • Parallel fetching: 6 API calls executed simultaneously (Promise.all)
  • +
  • Silent refresh: No loading spinner on auto-refresh (only on initial load)
  • +
  • Cleanup: Clear interval on unmount to prevent memory leak
  • +
+

Why 30 Seconds?

+
    +
  • Balance: Frequent enough to feel real-time, infrequent enough to avoid API overload
  • +
  • Network efficiency: 6 API calls every 30 seconds = 12 requests/minute (manageable)
  • +
  • Battery-friendly: 30-second interval doesn't drain mobile devices excessively
  • +
  • Configurable: Can be adjusted via environment variable if needed
  • +
+

useCallback Optimization

+
const loadData = useCallback(async () => {
+  // ... fetch logic
+}, []);
+
+useEffect(() => {
+  loadData();
+  const interval = setInterval(loadData, 30000);
+  return () => clearInterval(interval);
+}, [loadData]);
+
+

Why useCallback?

+
    +
  • Prevents infinite re-renders: Without useCallback, useEffect would create new function reference on every render, triggering effect again
  • +
  • Stable interval: Ensures setInterval always references same function instance
  • +
  • No dependencies: Empty dependency array means function never re-created
  • +
+

API Integration

+

Endpoints Used

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointPurposeAuth
GET/api/canvass/admin/statsOverall statisticsRequired (ADMIN)
GET/api/canvass/admin/recent-activityRecent 20 visitsRequired (ADMIN)
GET/api/canvass/admin/cut-progressCut-by-cut progressRequired (ADMIN)
GET/api/canvass/admin/top-volunteersVolunteer leaderboardRequired (ADMIN)
GET/api/canvass/admin/active-volunteersLive volunteer positionsRequired (ADMIN)
GET/api/cutsAll cuts for mapRequired
+

Load Overall Statistics

+

Request:

+
const { data } = await api.get<CanvassStats>('/canvass/admin/stats');
+
+

Response (200 OK):

+
{
+  "totalVisits": 1247,
+  "activeVolunteers": 8,
+  "activeSessions": 5,
+  "avgVisitsPerSession": 23.7,
+  "breakdown": {
+    "ANSWERED": 523,
+    "NOT_HOME": 412,
+    "MOVED": 89,
+    "REFUSED": 156,
+    "INACCESSIBLE": 45,
+    "OTHER": 22
+  }
+}
+
+

Response Fields: +- totalVisits (number): All-time visit count across all sessions +- activeVolunteers (number): Currently signed in with ACTIVE sessions +- activeSessions (number): Sessions with status = ACTIVE (not COMPLETED or ABANDONED) +- avgVisitsPerSession (number): Total visits / total sessions (decimal) +- breakdown (object): Visit count by outcome type

+

Backend Calculation:

+
const totalVisits = await prisma.canvassVisit.count();
+
+const activeSessions = await prisma.canvassSession.count({
+  where: { status: 'ACTIVE' },
+});
+
+const activeVolunteers = await prisma.canvassSession.findMany({
+  where: { status: 'ACTIVE' },
+  distinct: ['userId'],
+});
+
+const allSessions = await prisma.canvassSession.count();
+const avgVisitsPerSession = allSessions > 0 ? totalVisits / allSessions : 0;
+
+const breakdown = await prisma.canvassVisit.groupBy({
+  by: ['outcome'],
+  _count: { id: true },
+});
+
+

Load Recent Activity

+

Request:

+
const { data } = await api.get<CanvassVisit[]>('/canvass/admin/recent-activity', {
+  params: { limit: 20 },
+});
+
+

Query Parameters: +- limit (number, optional): Maximum number of visits to return (default: 20)

+

Response (200 OK):

+
[
+  {
+    "id": "visit_abc123",
+    "outcome": "ANSWERED",
+    "address": "456 Oak Avenue",
+    "timestamp": "2026-02-11T14:23:15.000Z",
+    "volunteerName": "Jane Doe",
+    "locationId": "loc_def456",
+    "sessionId": "session_ghi789"
+  },
+  {
+    "id": "visit_jkl012",
+    "outcome": "NOT_HOME",
+    "address": "123 Main Street",
+    "timestamp": "2026-02-11T14:18:42.000Z",
+    "volunteerName": "John Smith",
+    "locationId": "loc_mno345",
+    "sessionId": "session_pqr678"
+  }
+]
+
+

Response Fields: +- id (string): Unique visit identifier +- outcome (string): Visit outcome (ANSWERED, NOT_HOME, MOVED, REFUSED, INACCESSIBLE, OTHER) +- address (string): Location address +- timestamp (ISO 8601): Visit timestamp +- volunteerName (string): Name of volunteer who recorded visit +- locationId (string): Associated location ID +- sessionId (string): Associated canvass session ID

+

Sorting: +- Ordered by timestamp DESC (most recent first)

+

Load Cut Progress

+

Request:

+
const { data } = await api.get<CutProgress[]>('/canvass/admin/cut-progress');
+
+

Response (200 OK):

+
[
+  {
+    "id": "cut_abc123",
+    "name": "Downtown Core",
+    "locationCount": 157,
+    "visitCount": 89,
+    "percentage": 57
+  },
+  {
+    "id": "cut_def456",
+    "name": "Riverside District",
+    "locationCount": 203,
+    "visitCount": 203,
+    "percentage": 100
+  },
+  {
+    "id": "cut_ghi789",
+    "name": "Suburban Area",
+    "locationCount": 312,
+    "visitCount": 0,
+    "percentage": 0
+  }
+]
+
+

Response Fields: +- id (string): Cut identifier +- name (string): Cut name +- locationCount (number): Total locations in cut +- visitCount (number): Number of locations with at least one visit +- percentage (number): Completion percentage (rounded to integer)

+

Percentage Calculation:

+
// Backend calculation
+const percentage = Math.round((visitCount / locationCount) * 100);
+
+

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.

+

Load Top Volunteers

+

Request:

+
const { data } = await api.get<VolunteerStats[]>('/canvass/admin/top-volunteers', {
+  params: { limit: 10 },
+});
+
+

Query Parameters: +- limit (number, optional): Maximum number of volunteers to return (default: 10)

+

Response (200 OK):

+
[
+  {
+    "id": "user_abc123",
+    "name": "Jane Doe",
+    "email": "jane.doe@example.com",
+    "visitCount": 347,
+    "sessionCount": 15,
+    "avgVisitsPerSession": 23.1
+  },
+  {
+    "id": "user_def456",
+    "name": "John Smith",
+    "email": "john.smith@example.com",
+    "visitCount": 289,
+    "sessionCount": 12,
+    "avgVisitsPerSession": 24.1
+  },
+  {
+    "id": "user_ghi789",
+    "name": "Bob Johnson",
+    "email": "bob.johnson@example.com",
+    "visitCount": 201,
+    "sessionCount": 8,
+    "avgVisitsPerSession": 25.1
+  }
+]
+
+

Response Fields: +- id (string): User identifier +- name (string): Volunteer full name +- email (string): Volunteer email +- visitCount (number): Total visits recorded by volunteer (all-time) +- sessionCount (number): Total sessions completed by volunteer +- avgVisitsPerSession (number): Visits / sessions (decimal)

+

Sorting: +- Ordered by visitCount DESC (highest visit count first) +- Limited to top N volunteers (default 10)

+

Load Active Volunteers

+

Request:

+
const { data } = await api.get<ActiveVolunteer[]>('/canvass/admin/active-volunteers');
+
+

Response (200 OK):

+
[
+  {
+    "id": "user_abc123",
+    "name": "Jane Doe",
+    "sessionId": "session_ghi789",
+    "cutId": "cut_jkl012",
+    "cutName": "Downtown Core",
+    "latitude": 45.42153,
+    "longitude": -75.69602,
+    "lastUpdate": "2026-02-11T14:25:30.000Z",
+    "visitCount": 12
+  },
+  {
+    "id": "user_def456",
+    "name": "John Smith",
+    "sessionId": "session_mno345",
+    "cutId": "cut_pqr678",
+    "cutName": "Riverside District",
+    "latitude": 45.43264,
+    "longitude": -75.70813,
+    "lastUpdate": "2026-02-11T14:24:15.000Z",
+    "visitCount": 8
+  }
+]
+
+

Response Fields: +- id (string): User identifier +- name (string): Volunteer full name +- sessionId (string): Active session identifier +- cutId (string): Assigned cut identifier +- cutName (string): Assigned cut name +- latitude (number): Current GPS latitude +- longitude (number): Current GPS longitude +- lastUpdate (ISO 8601): Last GPS position update timestamp +- visitCount (number): Visits recorded in current session

+

Filtering: +- Only includes volunteers with status = ACTIVE sessions +- GPS position from most recent TrackPoint record +- Excludes volunteers with null GPS coordinates

+

Backend Query:

+
const activeSessions = await prisma.canvassSession.findMany({
+  where: { status: 'ACTIVE' },
+  include: {
+    user: true,
+    cut: true,
+    visits: true,
+    trackPoints: {
+      orderBy: { timestamp: 'desc' },
+      take: 1,  // Most recent track point
+    },
+  },
+});
+
+const activeVolunteers = activeSessions
+  .filter((session) => session.trackPoints.length > 0)
+  .map((session) => ({
+    id: session.userId,
+    name: session.user.name,
+    sessionId: session.id,
+    cutId: session.cutId,
+    cutName: session.cut?.name || 'Unknown',
+    latitude: session.trackPoints[0].latitude,
+    longitude: session.trackPoints[0].longitude,
+    lastUpdate: session.trackPoints[0].timestamp,
+    visitCount: session.visits.length,
+  }));
+
+

Code Examples

+

Complete Data Loading Flow

+
const loadData = useCallback(async () => {
+  try {
+    // Fetch all dashboard data in parallel (6 API calls)
+    const [statsRes, activityRes, progressRes, volunteersRes, cutsRes, activePosRes] = await Promise.all([
+      api.get<CanvassStats>('/canvass/admin/stats'),
+      api.get<CanvassVisit[]>('/canvass/admin/recent-activity', { params: { limit: 20 } }),
+      api.get<CutProgress[]>('/canvass/admin/cut-progress'),
+      api.get<VolunteerStats[]>('/canvass/admin/top-volunteers', { params: { limit: 10 } }),
+      api.get<Cut[]>('/cuts'),
+      api.get<ActiveVolunteer[]>('/canvass/admin/active-volunteers'),
+    ]);
+
+    // Update all state simultaneously
+    setStats(statsRes.data);
+    setRecentActivity(activityRes.data);
+    setCutProgress(progressRes.data);
+    setTopVolunteers(volunteersRes.data);
+    setCuts(cutsRes.data);
+    setActiveVolunteers(activePosRes.data);
+  } catch (error) {
+    if (axios.isAxiosError(error) && error.response?.status === 401) {
+      message.error('Authentication expired. Please log in again.');
+    } else {
+      message.error('Failed to load dashboard data');
+    }
+  } finally {
+    setLoading(false);
+  }
+}, []);
+
+

Parallel Fetching Benefits: +- Faster load time: 6 requests execute simultaneously, not sequentially +- Without parallel: 6 × 200ms average = 1,200ms total load time +- With parallel: max(200ms) = 200ms total load time (6× faster) +- Atomic updates: All state updates happen together (no partial UI updates)

+

Auto-Refresh Setup

+
useEffect(() => {
+  // Initial load on mount
+  loadData();
+
+  // Set up 30-second auto-refresh interval
+  const interval = setInterval(loadData, 30000);
+
+  // Cleanup interval on unmount (prevents memory leak)
+  return () => {
+    clearInterval(interval);
+    console.log('Dashboard auto-refresh stopped');
+  };
+}, [loadData]);
+
+

Cleanup Importance:

+

If interval is not cleared on unmount: +- Memory leak (interval continues running in background) +- API calls continue even after user navigates away +- Multiple overlapping intervals if user returns to page

+

Testing Auto-Refresh:

+
// Mock API responses changing over time
+const mockStats = {
+  totalVisits: 1247 + Math.floor(Math.random() * 10),  // Increases by 0-10 each refresh
+  activeVolunteers: 8,
+  activeSessions: 5,
+  avgVisitsPerSession: 23.7,
+};
+
+

Manual Refresh Handler

+
const handleRefresh = async () => {
+  message.loading('Refreshing dashboard data...', 0);  // Indefinite loading message
+  try {
+    await loadData();
+    message.destroy();  // Clear loading message
+    message.success('Dashboard refreshed');
+  } catch (error) {
+    message.destroy();
+    message.error('Failed to refresh dashboard');
+  }
+};
+
+// Refresh button in header
+<Button
+  type="text"
+  icon={<ReloadOutlined />}
+  onClick={handleRefresh}
+  style={{ marginLeft: 8 }}
+>
+  Refresh
+</Button>
+
+

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

+

Relative Time Formatting

+
function formatRelativeTime(timestamp: string): string {
+  const now = dayjs();
+  const visitTime = dayjs(timestamp);
+
+  const diffMinutes = now.diff(visitTime, 'minute');
+  if (diffMinutes < 1) return 'Just now';
+  if (diffMinutes === 1) return '1 min ago';
+  if (diffMinutes < 60) return `${diffMinutes} mins ago`;
+
+  const diffHours = now.diff(visitTime, 'hour');
+  if (diffHours === 1) return '1 hour ago';
+  if (diffHours < 24) return `${diffHours} hours ago`;
+
+  const diffDays = now.diff(visitTime, 'day');
+  if (diffDays === 1) return '1 day ago';
+  if (diffDays < 7) return `${diffDays} days ago`;
+
+  // For older visits, show absolute date
+  return visitTime.format('MMM D, h:mm A');
+}
+
+

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)

+

Outcome Color Mapping

+
function getOutcomeColor(outcome: string): string {
+  const colorMap: Record<string, string> = {
+    ANSWERED: 'green',       // Success: resident answered door
+    NOT_HOME: 'red',         // Fail: no answer
+    MOVED: 'orange',         // Warning: resident moved away
+    REFUSED: 'volcano',      // Fail: resident refused to engage
+    INACCESSIBLE: 'default', // Neutral: location inaccessible
+    OTHER: 'blue',           // Info: other outcome
+  };
+  return colorMap[outcome] || 'default';
+}
+
+function formatOutcome(outcome: string): string {
+  const labelMap: Record<string, string> = {
+    ANSWERED: 'Answered',
+    NOT_HOME: 'Not Home',
+    MOVED: 'Moved',
+    REFUSED: 'Refused',
+    INACCESSIBLE: 'Inaccessible',
+    OTHER: 'Other',
+  };
+  return labelMap[outcome] || outcome;
+}
+
+

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

+

Performance Considerations

+

Parallel API Requests

+

Dashboard loads 6 API endpoints simultaneously:

+
const [statsRes, activityRes, progressRes, volunteersRes, cutsRes, activePosRes] = await Promise.all([
+  api.get('/canvass/admin/stats'),
+  api.get('/canvass/admin/recent-activity'),
+  api.get('/canvass/admin/cut-progress'),
+  api.get('/canvass/admin/top-volunteers'),
+  api.get('/cuts'),
+  api.get('/canvass/admin/active-volunteers'),
+]);
+
+

Performance Comparison:

+

Sequential Fetching (bad): +

const statsRes = await api.get('/stats');           // 200ms
+const activityRes = await api.get('/activity');     // 200ms
+const progressRes = await api.get('/progress');     // 200ms
+const volunteersRes = await api.get('/volunteers'); // 200ms
+const cutsRes = await api.get('/cuts');             // 200ms
+const activePosRes = await api.get('/positions');   // 200ms
+// Total: 1,200ms
+

+

Parallel Fetching (good): +

const allResults = await Promise.all([...]);  // max(200ms) = 200ms
+// Total: 200ms (6× faster)
+

+

Silent Auto-Refresh

+

Auto-refresh doesn't show loading spinner:

+
const loadData = useCallback(async () => {
+  // No setLoading(true) here for silent refresh
+  try {
+    const results = await Promise.all([...]);
+    // Update state without UI flicker
+  } catch (error) {
+    // Error handling without disrupting UX
+  }
+  // No finally setLoading(false)
+}, []);
+
+

Benefits: +- No UI flicker: Dashboard doesn't flash every 30 seconds +- Better UX: User can continue reading data during refresh +- Smooth updates: Data changes appear naturally without distraction

+

Trade-off:

+

User doesn't see loading indicator, so may not know if data is stale. Mitigation: +- Show "Last updated: 15 seconds ago" timestamp +- Add refresh icon that spins during update +- Use Ant Design Skeleton for shimmer effect

+

Limited Data Sets

+

API endpoints return limited data:

+
    +
  • Recent Activity: 20 most recent visits (not all 1,247 visits)
  • +
  • Top Volunteers: 10 highest-ranked volunteers (not all 50 volunteers)
  • +
  • Active Volunteers: Only currently active (not all users)
  • +
  • Cut Progress: All cuts (typically < 50)
  • +
+

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

+

useCallback Memoization

+

Fetch function is memoized to prevent re-creation:

+
const loadData = useCallback(async () => {
+  // ... fetch logic
+}, []);  // Empty dependency array = function never re-created
+
+

Without useCallback: +

const loadData = async () => { /* ... */ };
+
+useEffect(() => {
+  loadData();
+  const interval = setInterval(loadData, 30000);
+  return () => clearInterval(interval);
+}, [loadData]);  // loadData changes every render → infinite loop
+

+

With useCallback: +

const loadData = useCallback(async () => { /* ... */ }, []);
+
+useEffect(() => {
+  loadData();
+  const interval = setInterval(loadData, 30000);
+  return () => clearInterval(interval);
+}, [loadData]);  // loadData stable → effect runs once
+

+

Responsive Design

+

Mobile Layout

+

Dashboard adapts to mobile viewports:

+

Statistics Cards: +

<Row gutter={[16, 16]}>
+  <Col xs={24} sm={12} md={6}>  {/* Full width mobile, half tablet, quarter desktop */}
+    <Card><Statistic title="Total Visits" value={1247} /></Card>
+  </Col>
+  {/* Repeat for other cards */}
+</Row>
+

+

Two-Column Layout: +

<Row gutter={[16, 16]}>
+  <Col xs={24} lg={12}>  {/* Full width mobile, half desktop */}
+    <Card title="Recent Activity">{/* ... */}</Card>
+  </Col>
+  <Col xs={24} lg={12}>  {/* Full width mobile, half desktop */}
+    <Space direction="vertical" style={{ width: '100%' }}>
+      <Card title="Cut Progress">{/* ... */}</Card>
+      <Card title="Top Volunteers">{/* ... */}</Card>
+    </Space>
+  </Col>
+</Row>
+

+

Responsive Breakpoints: +- xs (mobile, <576px): Stacked layout (all cards full-width) +- sm (tablet, ≥576px): 2-column statistics cards +- md (small desktop, ≥768px): 4-column statistics cards +- lg (large desktop, ≥992px): 2-column main layout (activity left, progress/leaderboard right)

+

Map Height

+

Map adapts to viewport height:

+
<Card title="Live Volunteer Map">
+  <div style={{ height: 500, minHeight: 300 }}>
+    <AdminMapView {...props} />
+  </div>
+</Card>
+
+

Responsive Heights: +- Desktop: 500px fixed height +- Tablet: 400px (less vertical space) +- Mobile: 300px minimum height (prevent squishing)

+

Accessibility

+

Keyboard Navigation

+

All interactive elements are keyboard-accessible:

+

Refresh Button: +- Tab: Focus on refresh button +- Enter/Space: Trigger refresh

+

Activity Feed: +- Tab: Focus on list items +- Enter: Activate clickable items (volunteer name, location) +- Arrow Keys: Scroll list (native browser behavior)

+

Map: +- Tab: Focus on map container +- Arrow Keys: Pan map +- +/−: Zoom in/out +- Enter: Activate focused marker

+

Screen Reader Support

+

All elements have proper ARIA labels:

+

Statistics Cards: +

<Statistic
+  title="Total Visits"
+  value={stats.totalVisits}
+  aria-label={`Total visits: ${stats.totalVisits}`}
+/>
+

+

Activity Feed: +

<List
+  aria-label="Recent canvassing activity"
+  dataSource={recentActivity}
+  renderItem={(activity) => (
+    <List.Item aria-label={`${activity.volunteerName} recorded ${activity.outcome} at ${activity.address}`}>
+      {/* ... */}
+    </List.Item>
+  )}
+/>
+

+

Progress Bars: +

<Progress
+  percent={cut.percentage}
+  aria-label={`${cut.name} progress: ${cut.percentage}% complete`}
+/>
+

+

Color Contrast

+

All color-coded elements meet WCAG AA standards:

+

Outcome Tags: +- Green (ANSWERED): #52c41a on white = 3.0:1 contrast (AA for large text) +- Red (NOT_HOME): #f5222d on white = 4.5:1 contrast (AA) +- Orange (MOVED): #fa8c16 on white = 3.3:1 contrast (AA for large text)

+

Statistics Values: +- Green: #3f8600 on white = 4.8:1 contrast (AA) +- Blue: #1890ff on white = 4.5:1 contrast (AA) +- Orange: #faad14 on white = 3.2:1 contrast (AA for large text)

+

Troubleshooting

+

Dashboard Not Auto-Refreshing

+

Problem: Dashboard loads initially, but data doesn't update after 30 seconds.

+

Diagnosis:

+

Check if interval is set up correctly:

+
useEffect(() => {
+  loadData();
+  const interval = setInterval(loadData, 30000);
+  return () => clearInterval(interval);
+}, [loadData]);
+
+

Open browser console and check for errors:

+
// Expected: No errors every 30 seconds
+// If errors appear every 30 seconds, auto-refresh is running but failing
+
+

Possible Causes:

+
    +
  1. Interval not set up:
  2. +
  3. Missing setInterval call
  4. +
  5. +

    Interval not returned from useEffect

    +
  6. +
  7. +

    Interval cleared prematurely:

    +
  8. +
  9. Component unmounted and remounted (React Strict Mode in development)
  10. +
  11. +

    Cleanup function called too early

    +
  12. +
  13. +

    API errors silently failing:

    +
  14. +
  15. Backend API down, but error not shown to user
  16. +
  17. JWT token expired, 401 errors swallowed by try/catch
  18. +
+

Solution:

+
    +
  1. Verify interval exists:
  2. +
  3. Add console.log in loadData: console.log('Dashboard refresh:', new Date())
  4. +
  5. +

    Check console every 30 seconds for log message

    +
  6. +
  7. +

    Handle React Strict Mode:

    +
  8. +
  9. Accept that development mode unmounts/remounts components
  10. +
  11. +

    Ensure production build works correctly (no double mounting)

    +
  12. +
  13. +

    Show API errors:

    +
  14. +
  15. Remove generic try/catch error handling
  16. +
  17. Let errors bubble up to user as message.error()
  18. +
  19. Add retry logic for transient failures
  20. +
+
+

"Active Volunteers: 0" Despite Active Sessions

+

Problem: Statistics show "Active Volunteers: 0" but shifts are scheduled and volunteers are canvassing.

+

Diagnosis:

+

Check active sessions in database:

+
SELECT COUNT(*) FROM "CanvassSession" WHERE status = 'ACTIVE';
+-- Expected: > 0 if volunteers are active
+-- Actual: 0 (sessions not marked as ACTIVE)
+
+

Possible Causes:

+
    +
  1. Sessions not started:
  2. +
  3. Volunteers signed up for shifts but didn't start canvassing
  4. +
  5. +

    No sessions with status = ACTIVE

    +
  6. +
  7. +

    Sessions abandoned:

    +
  8. +
  9. Volunteers forgot to end sessions, sessions auto-closed by backend
  10. +
  11. +

    Sessions marked as ABANDONED instead of ACTIVE

    +
  12. +
  13. +

    Sessions completed:

    +
  14. +
  15. Volunteers ended sessions, now showing as COMPLETED
  16. +
  17. Active count only includes ACTIVE status
  18. +
+

Solution:

+
    +
  1. Contact volunteers:
  2. +
  3. Ask them to start canvassing session from volunteer portal
  4. +
  5. +

    Navigate to /volunteer/assignments, click "Start Canvassing"

    +
  6. +
  7. +

    Check abandoned sessions:

    +
  8. +
  9. Navigate to Canvass Dashboard
  10. +
  11. Look for sessions with "ABANDONED" status
  12. +
  13. +

    Manually reopen if volunteer is still active

    +
  14. +
  15. +

    Adjust status query:

    +
  16. +
  17. If volunteers frequently forget to end sessions, consider showing ACTIVE + recently updated sessions (< 1 hour ago)
  18. +
+
+

Map Not Showing Volunteer Markers

+

Problem: Live Volunteer Map loads but shows no blue markers, even though "Active Volunteers: 8".

+

Diagnosis:

+

Check active volunteers API response:

+
const { data } = await api.get('/canvass/admin/active-volunteers');
+console.log('Active volunteers:', data);
+// Expected: Array with 8 volunteers
+// Actual: Empty array or volunteers without GPS coordinates
+
+

Possible Causes:

+
    +
  1. No GPS tracking enabled:
  2. +
  3. Volunteers have active sessions but GPS tracking not enabled
  4. +
  5. +

    No TrackPoint records exist for sessions

    +
  6. +
  7. +

    Null GPS coordinates:

    +
  8. +
  9. TrackPoint records exist but latitude/longitude are null
  10. +
  11. +

    Backend filters out volunteers without valid coordinates

    +
  12. +
  13. +

    Map zoom level:

    +
  14. +
  15. Volunteers outside current map viewport
  16. +
  17. Auto-center not working correctly
  18. +
+

Solution:

+
    +
  1. Enable GPS tracking:
  2. +
  3. Ensure volunteers grant location permissions in browser
  4. +
  5. Check volunteer portal GPS tracker is running
  6. +
  7. +

    Navigate to /volunteer/canvass/:cutId, verify "GPS Active" indicator

    +
  8. +
  9. +

    Check GPS permissions:

    +
  10. +
  11. Ask volunteers to enable location services in browser settings
  12. +
  13. Chrome: Settings → Privacy → Site Settings → Location → Allow
  14. +
  15. +

    Safari: Preferences → Websites → Location → Allow

    +
  16. +
  17. +

    Zoom out on map:

    +
  18. +
  19. Click zoom out (−) button several times
  20. +
  21. See if markers appear outside initial viewport
  22. +
  23. If yes, auto-center logic is broken (should zoom to fit all markers)
  24. +
+
+

Progress Percentages Over 100%

+

Problem: Cut Progress section shows "Downtown Core: 157 / 150 locations (105%)".

+

Diagnosis:

+

Check location count vs. visit count:

+
-- Count locations in cut
+SELECT COUNT(*) FROM "Location" WHERE "cutId" = 'cut_abc123';
+-- Result: 150
+
+-- Count unique locations with visits
+SELECT COUNT(DISTINCT "locationId") FROM "CanvassVisit"
+WHERE "locationId" IN (
+  SELECT id FROM "Location" WHERE "cutId" = 'cut_abc123'
+);
+-- Result: 157 (more than location count!)
+
+

Possible Causes:

+
    +
  1. Locations moved out of cut:
  2. +
  3. Locations visited while in cut, then unassigned from cut
  4. +
  5. +

    Visit records still reference old cutId, inflating count

    +
  6. +
  7. +

    Duplicate visits counted:

    +
  8. +
  9. Multiple visits to same location counted separately
  10. +
  11. +

    Should count unique locations, not total visits

    +
  12. +
  13. +

    Backend calculation bug:

    +
  14. +
  15. Visit count not filtered by current cut membership
  16. +
  17. Includes visits to locations now in different cuts
  18. +
+

Solution:

+
    +
  1. Fix backend query:
  2. +
  3. +

    Only count visits to locations currently in cut: +

    const visitCount = await prisma.canvassVisit.count({
    +  where: {
    +    location: {
    +      cutId: cut.id,
    +      deletedAt: null,
    +    },
    +  },
    +  distinct: ['locationId'],  // Count unique locations only
    +});
    +

    +
  4. +
  5. +

    Cap percentage at 100%:

    +
  6. +
  7. +

    Frontend safety check: +

    const percentage = Math.min(100, Math.round((visitCount / locationCount) * 100));
    +

    +
  8. +
  9. +

    Investigate data integrity:

    +
  10. +
  11. Find orphaned visits: +
    SELECT * FROM "CanvassVisit"
    +WHERE "locationId" NOT IN (SELECT id FROM "Location");
    +
  12. +
  13. Delete orphaned visits or reassociate with correct locations
  14. +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/code-editor-page/index.html b/mkdocs/site/v2/frontend/pages/admin/code-editor-page/index.html new file mode 100644 index 00000000..08314570 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/code-editor-page/index.html @@ -0,0 +1,6886 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Code Editor - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

CodeEditorPage

+

Overview

+

File: admin/src/pages/CodeEditorPage.tsx

+

Route: /app/services/code-editor

+

Role Requirements: Any authenticated user (uses authenticate middleware)

+

Purpose: Provides an embedded interface to the Code Server (VS Code in browser) via iframe. Code Server is a web-based IDE that runs Visual Studio Code in the browser, allowing developers to edit code, manage files, and run terminal commands directly from the admin interface. This page serves as a wrapper that embeds Code Server with online/offline status monitoring and mobile device detection.

+

Key Features: +- Full-page iframe embed of Code Server service +- Service online/offline status monitoring with Badge +- Mobile device detection with warning screen +- "Refresh" button to re-check service status +- "Open in New Tab" button for external access +- Fullbleed layout (no padding in AppLayout) +- Automatic service health checks via API

+

Layout: AppLayout with fullbleed (no content padding)

+

Dependencies: +- Ant Design v5 (Button, Space, Badge, Spin, Grid, Result) +- react-router-dom (useOutletContext)

+
+

Features

+

1. Service Status Monitoring

+

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

+

2. Mobile Device Detection

+

Mobile Warning Screen: +- Detects mobile devices using Grid.useBreakpoint() +- Shows warning Result component on mobile +- Recommends using desktop for code editing +- Icon: CodeOutlined (48px)

+

Breakpoint: !screens.md (screen width < 768px = mobile)

+

3. Code Server URL Construction

+

URL Building: +- Fetches docs config from API (/api/docs/config) +- Builds URL using codeServerPort configuration +- Uses hostname + port pattern +- Example: http://localhost:8888 or http://code.cmlite.org

+

4. Iframe Embedding

+

Fullbleed Layout: +- No padding around iframe +- Height: calc(100vh - 64px) (full viewport height minus header) +- Width: 100% +- No border for seamless VS Code integration

+
+

User Workflow

+

Accessing Code Server

+
    +
  1. Navigate to Code Editor:
  2. +
  3. Click "Services" → "Code Editor" in sidebar
  4. +
  5. +

    Page loads with status check

    +
  6. +
  7. +

    Check Service Status:

    +
  8. +
  9. +

    Status badge appears in page header:

    +
      +
    • ✅ "Online" (green) - Service available
    • +
    • ❌ "Offline" (red) - Service unavailable
    • +
    • 🔵 "Checking..." (blue) - Status check in progress
    • +
    +
  10. +
  11. +

    View on Desktop:

    +
  12. +
  13. +

    If on desktop (screen width ≥ 768px):

    +
      +
    • Iframe loads automatically
    • +
    • Full VS Code interface embedded
    • +
    • Can edit files, run terminal commands, use extensions
    • +
    +
  14. +
  15. +

    View on Mobile:

    +
  16. +
  17. +

    If on mobile (screen width < 768px):

    +
      +
    • Warning message appears
    • +
    • Message: "The code editor requires a desktop browser"
    • +
    • "Open in New Tab" button provided
    • +
    +
  18. +
  19. +

    Using Code Server:

    +
  20. +
  21. File Explorer: Browse project files in sidebar
  22. +
  23. Editor: Edit code with syntax highlighting, IntelliSense
  24. +
  25. Terminal: Run bash commands (npm, git, docker)
  26. +
  27. Extensions: Install VS Code extensions
  28. +
  29. Search: Global file search (Ctrl+P)
  30. +
  31. +

    Git: Source control integration

    +
  32. +
  33. +

    Common Tasks:

    +
  34. +
  35. Edit API routes: /api/src/modules/
  36. +
  37. Edit admin pages: /admin/src/pages/
  38. +
  39. Run migrations: Terminal → cd api && npx prisma migrate dev
  40. +
  41. Start dev servers: Terminal → npm run dev
  42. +
  43. View logs: Terminal → docker compose logs -f api
  44. +
+
+

Component Breakdown

+

Main Component Structure

+
export default function CodeEditorPage() {
+  const { setPageHeader } = useOutletContext<AppOutletContext>();
+  const screens = Grid.useBreakpoint();
+  const isMobile = !screens.md;
+
+  const [online, setOnline] = useState<boolean | null>(null);
+  const [codeServerPort, setCodeServerPort] = useState<number | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  // Fetch service status and config
+  const fetchStatus = useCallback(async () => {
+    try {
+      const [statusRes, configRes] = await Promise.all([
+        api.get<DocsStatus>('/docs/status'),
+        api.get<DocsConfig>('/docs/config'),
+      ]);
+      setOnline(statusRes.data.codeServer.online);
+      setCodeServerPort(configRes.data.codeServerPort);
+    } catch {
+      setOnline(false);
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    fetchStatus();
+  }, [fetchStatus]);
+
+  // Build service URL
+  const codeServerUrl = codeServerPort
+    ? `//${window.location.hostname}:${codeServerPort}`
+    : null;
+
+  // Page header with status badge and actions
+  const headerActions = useMemo(() => (
+    <Space>
+      <Badge
+        status={online === null ? 'processing' : online ? 'success' : 'error'}
+        text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
+      />
+      <Button icon={<ReloadOutlined />} onClick={fetchStatus} size="small">
+        Refresh
+      </Button>
+      {codeServerUrl && (
+        <Button icon={<LinkOutlined />} href={codeServerUrl} target="_blank" size="small">
+          Open in New Tab
+        </Button>
+      )}
+    </Space>
+  ), [online, fetchStatus, codeServerUrl]);
+
+  useEffect(() => {
+    setPageHeader({ title: 'Code Editor', actions: headerActions, fullBleed: true });
+    return () => setPageHeader(null);
+  }, [setPageHeader, headerActions]);
+
+  // Mobile warning
+  if (isMobile) {
+    return (
+      <Result
+        status="info"
+        title="Desktop Required"
+        subTitle="The code editor requires a desktop browser with a larger screen."
+        icon={<CodeOutlined style={{ fontSize: 48 }} />}
+      />
+    );
+  }
+
+  // Loading state
+  if (loading) {
+    return (
+      <div style={{ textAlign: 'center', padding: 80 }}>
+        <Spin size="large" />
+      </div>
+    );
+  }
+
+  // Offline state
+  if (!online || !codeServerUrl) {
+    return (
+      <Result
+        status="error"
+        title="Code Server Unavailable"
+        subTitle="Code Server is not running or could not be reached. Check that the code-server container is started."
+        extra={
+          <Button type="primary" onClick={fetchStatus}>
+            Retry
+          </Button>
+        }
+      />
+    );
+  }
+
+  // Iframe embed
+  return (
+    <iframe
+      src={codeServerUrl}
+      style={{
+        width: '100%',
+        height: 'calc(100vh - 64px)',
+        border: 'none',
+        display: 'block',
+      }}
+      title="Code Server"
+    />
+  );
+}
+
+
+

State Management

+

Local Component State

+
// Service online/offline state
+const [online, setOnline] = useState<boolean | null>(null);
+
+// Code Server port configuration
+const [codeServerPort, setCodeServerPort] = useState<number | null>(null);
+
+// Loading state
+const [loading, setLoading] = useState(true);
+
+// Responsive breakpoint detection
+const screens = Grid.useBreakpoint();
+const isMobile = !screens.md;
+
+

State Flow

+
    +
  1. Component Mounts:
  2. +
  3. fetchStatus() called
  4. +
  5. Parallel API calls:
      +
    • GET /api/docs/status - Check Code Server online status
    • +
    • GET /api/docs/config - Fetch port configuration
    • +
    +
  6. +
  7. Sets online and codeServerPort
  8. +
  9. +

    Constructs URL: //${hostname}:${port}

    +
  10. +
  11. +

    URL Construction:

    +
  12. +
  13. Uses current hostname (from window.location.hostname)
  14. +
  15. Appends Code Server port (default: 8888)
  16. +
  17. Example: //localhost:8888 or //app.cmlite.org:8888
  18. +
+
+

API Integration

+

Endpoints Used

+
    +
  1. GET /api/docs/status - Check MkDocs and Code Server health
  2. +
  3. GET /api/docs/config - Fetch Code Server port configuration
  4. +
+

Example API Calls

+

1. Fetch Service Status

+
const statusRes = await api.get<DocsStatus>('/docs/status');
+setOnline(statusRes.data.codeServer.online);
+
+

Response Format: +

{
+  "mkdocs": { "online": true },
+  "codeServer": { "online": true }
+}
+

+

2. Fetch Config

+
const configRes = await api.get<DocsConfig>('/docs/config');
+setCodeServerPort(configRes.data.codeServerPort);
+
+

Response Format: +

{
+  "mkdocsPort": 4003,
+  "codeServerPort": 8888
+}
+

+

3. Build URL

+
const codeServerUrl = codeServerPort
+  ? `//${window.location.hostname}:${codeServerPort}`
+  : null;
+
+// Example results:
+// localhost → "//localhost:8888"
+// app.cmlite.org → "//app.cmlite.org:8888"
+
+
+

Performance Considerations

+

1. Parallel API Requests

+
const [statusRes, configRes] = await Promise.all([
+  api.get<DocsStatus>('/docs/status'),
+  api.get<DocsConfig>('/docs/config'),
+]);
+
+

Benefit: Reduces total loading time by ~50%.

+

2. Early Mobile Detection

+
if (isMobile) {
+  return <Result />;  // No API calls, no iframe
+}
+
+

Benefit: Saves bandwidth and API requests on mobile devices.

+
+

Responsive Design

+

Mobile Warning

+
if (isMobile) {
+  return (
+    <Result
+      status="info"
+      title="Desktop Required"
+      subTitle="The code editor requires a desktop browser with a larger screen."
+      icon={<CodeOutlined style={{ fontSize: 48 }} />}
+    />
+  );
+}
+
+

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

+
+

Troubleshooting

+

Problem: Service Shows "Offline"

+

Solutions:

+
    +
  1. +

    Check Docker container: +

    docker compose ps code-server
    +

    +
  2. +
  3. +

    Check logs: +

    docker compose logs code-server
    +

    +
  4. +
  5. +

    Test direct access:

    +
  6. +
  7. +

    Open http://localhost:8888 in browser

    +
  8. +
  9. +

    Restart service: +

    docker compose restart code-server
    +

    +
  10. +
+
+

Problem: Iframe Not Loading

+

Solutions:

+
    +
  1. Check password:
  2. +
  3. Code Server requires password authentication
  4. +
  5. +

    Check CODE_SERVER_PASSWORD env var in .env

    +
  6. +
  7. +

    Check CSP headers:

    +
  8. +
  9. Open DevTools Console
  10. +
  11. +

    Look for Content Security Policy errors

    +
  12. +
  13. +

    Try "Open in New Tab":

    +
  14. +
  15. Click button to test service directly
  16. +
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/cut-export-page/index.html b/mkdocs/site/v2/frontend/pages/admin/cut-export-page/index.html new file mode 100644 index 00000000..5843eaa4 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/cut-export-page/index.html @@ -0,0 +1,8272 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cut Export - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

CutExportPage

+

Overview

+

File: admin/src/pages/CutExportPage.tsx

+

Route: /app/map/cuts/:id/export

+

Role Requirements: Any authenticated admin user (uses authenticate middleware + admin role check)

+

Purpose: Generates a printable location report for a specific cut (geographic boundary). The report includes cut statistics, support level breakdown, and a detailed table of all addresses within the cut. Campaign organizers use this report for planning canvassing efforts, analyzing voter support distribution, and exporting contact lists for targeted outreach.

+

Key Features: +- Printable cut location report optimized for landscape printing +- Cut metadata (name, category, assigned person) +- Statistics cards (total addresses, support levels, signs, contact info) +- Paginated address table with support levels, contact info, and notes +- Color-coded support level tags +- Print-optimized styling with CSS @media print rules +- Landscape orientation for wide table layout

+

Layout: Full AppLayout with "Back to Cuts" and "Print" buttons in header

+

Dependencies: +- Ant Design v5 (Button, Typography, Spin, Space, Table, Tag, Row, Col, Card, Statistic, message) +- react-router-dom (useParams, useNavigate, useOutletContext) +- dayjs for date formatting

+
+

Features

+

1. Cut Metadata Header

+

Displayed Information: +- Cut Name: Title of the cut (e.g., "Downtown District - Block 5") +- Cut Category: Visual tag (e.g., "Priority", "Target", "Base") +- Assigned To: Person responsible for canvassing this cut +- Generation Timestamp: Date and time report was generated

+

Purpose: Provides context for the location report

+

2. Statistics Grid

+

9 Statistics Cards:

+
    +
  1. Total: Total number of addresses in cut
  2. +
  3. Strong: Count of LEVEL_1 support (strong supporters)
  4. +
  5. Likely: Count of LEVEL_2 support (likely supporters)
  6. +
  7. Unsure: Count of LEVEL_3 support (undecided voters)
  8. +
  9. Oppose: Count of LEVEL_4 support (opponents)
  10. +
  11. None: Count of addresses with no support level assigned
  12. +
  13. Signs: Count of addresses requesting lawn signs
  14. +
  15. Email: Count of addresses with email addresses
  16. +
  17. Phone: Count of addresses with phone numbers
  18. +
+

Color-Coded Values: +- Strong Support: Green (#52c41a) +- Likely Support: Cyan (#13c2c2) +- Unsure: Orange (#faad14) +- Oppose: Red (#ff4d4f) +- None/Other: Default gray

+

Layout: Responsive grid (9 cards, 3 per row on desktop, 2 per row on mobile)

+

3. Address Table

+

8 Columns:

+
    +
  1. Name: First name + last name (combined)
  2. +
  3. Address: Building street address + unit number (if multi-unit)
  4. +
  5. Support: Support level tag (Strong/Likely/Unsure/Oppose/None)
  6. +
  7. Phone: Phone number or "--"
  8. +
  9. Email: Email address or "--"
  10. +
  11. Sign: Sign interest ("Yes" with size, or "No")
  12. +
  13. Notes: Additional notes (ellipsis if long)
  14. +
+

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)

+ +

Footer Text: "Generated by Changemaker Lite — {timestamp}"

+

Purpose: Attribution and timestamp for report archiving

+

5. Print Optimization

+

CSS @media print Rules: +- Hides everything except .cut-export-print container +- Positions report at absolute top-left with fixed position +- Uses landscape orientation (@page { size: letter landscape; }) +- Reduces font size to 9-10px for compact printing +- Optimizes table padding and borders for clarity +- Forces exact color printing with print-color-adjust: exact

+

Print Trigger: "Print" button in page header (calls window.print())

+
+

User Workflow

+

Exporting a Cut Report

+
    +
  1. Navigate to Cuts:
  2. +
  3. Click "Map" → "Cuts" in sidebar
  4. +
  5. +

    Cuts table loads

    +
  6. +
  7. +

    Select Cut:

    +
  8. +
  9. Find cut to export in table
  10. +
  11. Click "Export" action button (or similar)
  12. +
  13. +

    Route navigates to /app/map/cuts/:id/export

    +
  14. +
  15. +

    Review Report Preview:

    +
  16. +
  17. Page loads with cut metadata header
  18. +
  19. Statistics cards show support level distribution
  20. +
  21. +

    Address table lists all locations in cut

    +
  22. +
  23. +

    Print Report:

    +
  24. +
  25. Click "Print" button in page header
  26. +
  27. OR press Ctrl+P (Windows/Linux) or Cmd+P (Mac)
  28. +
  29. +

    Browser print dialog opens

    +
  30. +
  31. +

    Configure Print Settings:

    +
  32. +
  33. Orientation: Landscape (automatically set by CSS)
  34. +
  35. Paper Size: Letter (8.5" × 11")
  36. +
  37. Margins: Minimal (0.25")
  38. +
  39. +

    Background graphics: ON (to print color tags and borders)

    +
  40. +
  41. +

    Print or Save PDF:

    +
  42. +
  43. Click "Print" to send to printer
  44. +
  45. OR select "Save as PDF" to create digital copy
  46. +
  47. Report saved/printed for field use
  48. +
+

Analyzing Cut Statistics

+
    +
  1. Review Statistics Cards:
  2. +
  3. Total: Understand cut size (e.g., 150 addresses)
  4. +
  5. Strong + Likely: Identify supporter base (e.g., 80 strong + 30 likely = 110 supporters)
  6. +
  7. Unsure: Target for persuasion (e.g., 40 undecided)
  8. +
  9. Oppose: Avoid during canvassing (e.g., 10 opponents)
  10. +
  11. +

    None: Not yet contacted (e.g., 10 addresses)

    +
  12. +
  13. +

    Calculate Support Percentage:

    +
  14. +
  15. Strong Support % = (Strong / Total) × 100
  16. +
  17. +

    Example: (80 / 150) × 100 = 53.3% strong support

    +
  18. +
  19. +

    Assess Contact Coverage:

    +
  20. +
  21. Email: Contact via email campaigns (e.g., 90 emails = 60% coverage)
  22. +
  23. Phone: Contact via phone banking (e.g., 100 phones = 67% coverage)
  24. +
  25. +

    Signs: Distribute lawn signs (e.g., 50 sign requests)

    +
  26. +
  27. +

    Plan Canvassing Strategy:

    +
  28. +
  29. High support areas: Focus on turnout (ensure supporters vote)
  30. +
  31. High unsure areas: Focus on persuasion (door-to-door conversations)
  32. +
  33. High oppose areas: Skip or minimal contact (avoid antagonism)
  34. +
+

Using Report for Canvassing

+
    +
  1. Print Report Before Canvassing:
  2. +
  3. Export cut report
  4. +
  5. Print landscape orientation
  6. +
  7. +

    Bring printed report to field

    +
  8. +
  9. +

    Review Addresses During Canvass:

    +
  10. +
  11. Check support level before knocking
  12. +
  13. Note contact info (phone/email) for follow-up
  14. +
  15. +

    See sign requests (bring signs to those addresses)

    +
  16. +
  17. +

    Update Notes During Canvass:

    +
  18. +
  19. Handwrite additional notes on printed report (e.g., "Not home", "Call back after 6pm")
  20. +
  21. +

    Mark addresses as "Visited" with checkmarks

    +
  22. +
  23. +

    Data Entry After Canvass:

    +
  24. +
  25. Return to office with updated report
  26. +
  27. Enter new data into LocationsPage or AddressPage
  28. +
  29. Update support levels, contact info, notes
  30. +
+
+

Component Breakdown

+

Main Component Structure

+
export default function CutExportPage() {
+  const { id } = useParams<{ id: string }>();
+  const navigate = useNavigate();
+  const [cut, setCut] = useState<Cut | null>(null);
+  const [addresses, setAddresses] = useState<AddressWithLocation[]>([]);
+  const [stats, setStats] = useState<CutStatistics | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  // Load cut data on mount
+  useEffect(() => {
+    if (!id) return;
+    (async () => {
+      try {
+        // Parallel fetch: cut metadata, locations, statistics
+        const [cutRes, locsRes, statsRes] = await Promise.all([
+          api.get<Cut>(`/map/cuts/${id}`),
+          api.get<Location[]>(`/map/cuts/${id}/locations`),
+          api.get<CutStatistics>(`/map/cuts/${id}/statistics`),
+        ]);
+
+        setCut(cutRes.data);
+
+        // Flatten locations with their addresses
+        const flatAddresses: AddressWithLocation[] = [];
+        for (const loc of locsRes.data) {
+          if (loc.addresses && loc.addresses.length > 0) {
+            for (const addr of loc.addresses) {
+              flatAddresses.push({
+                ...addr,
+                locationAddress: loc.address, // Building street address
+              });
+            }
+          }
+        }
+        setAddresses(flatAddresses);
+
+        setStats(statsRes.data);
+      } catch {
+        message.error('Failed to load cut data');
+      } finally {
+        setLoading(false);
+      }
+    })();
+  }, [id]);
+
+  // Set page header with actions
+  const headerActions = useMemo(() => (
+    <Space>
+      <Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/app/map/cuts')}>
+        Back to Cuts
+      </Button>
+      <Button type="primary" icon={<PrinterOutlined />} onClick={() => window.print()}>
+        Print
+      </Button>
+    </Space>
+  ), [navigate]);
+
+  useEffect(() => {
+    setPageHeader({ title: cut?.name || 'Cut Export', actions: headerActions });
+    return () => setPageHeader(null);
+  }, [setPageHeader, headerActions, cut?.name]);
+
+  if (loading) {
+    return <Spin size="large" />;
+  }
+
+  if (!cut) {
+    return <Text type="danger">Cut not found</Text>;
+  }
+
+  const now = dayjs().format('YYYY-MM-DD HH:mm');
+
+  return (
+    <>
+      <style>{/* Print CSS rules */}</style>
+
+      <div className="cut-export-print">
+        {/* Report header */}
+        <Row justify="space-between" align="middle">
+          <Col>
+            <Title level={4}>{cut.name}</Title>
+            <Space>
+              <Tag color={CUT_CATEGORY_COLORS[cut.category]}>
+                {CUT_CATEGORY_LABELS[cut.category]}
+              </Tag>
+              {cut.assignedTo && <Text type="secondary">Assigned to: {cut.assignedTo}</Text>}
+            </Space>
+          </Col>
+          <Col>
+            <Text type="secondary">Generated: {now}</Text>
+          </Col>
+        </Row>
+
+        {/* Stats grid */}
+        <Row gutter={[12, 12]}>
+          <Col xs={8} sm={4}><Card size="small"><Statistic title="Total" value={stats.total} /></Card></Col>
+          <Col xs={8} sm={4}><Card size="small"><Statistic title="Strong" value={stats.byLevel.LEVEL_1} valueStyle={{ color: '#52c41a' }} /></Card></Col>
+          {/* ... more stats cards ... */}
+        </Row>
+
+        {/* Address table */}
+        <Table
+          columns={columns}
+          dataSource={addresses}
+          rowKey="id"
+          pagination={false}
+          size="small"
+          bordered
+        />
+
+        {/* Footer */}
+        <div style={{ marginTop: 16, textAlign: 'center' }}>
+          <Text type="secondary">Generated by Changemaker Lite  {now}</Text>
+        </div>
+      </div>
+    </>
+  );
+}
+
+

Ant Design Components Used

+
    +
  1. Button - "Back to Cuts" and "Print" buttons
  2. +
  3. Typography.Title - Cut name heading
  4. +
  5. Typography.Text - Labels, timestamps, footer
  6. +
  7. Spin - Loading indicator during data fetch
  8. +
  9. Space - Button grouping, tag grouping
  10. +
  11. Table - Address data grid
  12. +
  13. Tag - Cut category, support levels
  14. +
  15. Row / Col - Statistics grid layout
  16. +
  17. Card - Statistics card containers
  18. +
  19. Statistic - Numerical statistics display
  20. +
  21. message - Toast notifications for errors
  22. +
+

Table Column Definition

+
const columns: ColumnsType<AddressWithLocation> = [
+  {
+    title: 'Name',
+    key: 'name',
+    render: (_: unknown, record: AddressWithLocation) =>
+      [record.firstName, record.lastName].filter(Boolean).join(' ') || '--',
+  },
+  {
+    title: 'Address',
+    key: 'address',
+    render: (_: unknown, record: AddressWithLocation) =>
+      [record.locationAddress, record.unitNumber && `#${record.unitNumber}`].filter(Boolean).join(' ') || '--',
+  },
+  {
+    title: 'Support',
+    dataIndex: 'supportLevel',
+    key: 'supportLevel',
+    width: 120,
+    render: (level: SupportLevel | null) =>
+      level ? (
+        <Tag color={SUPPORT_LEVEL_COLORS[level]}>{SUPPORT_LEVEL_LABELS[level]}</Tag>
+      ) : (
+        <Tag>None</Tag>
+      ),
+  },
+  {
+    title: 'Phone',
+    dataIndex: 'phone',
+    key: 'phone',
+    width: 120,
+    render: (val: string | null) => val || '--',
+  },
+  {
+    title: 'Email',
+    dataIndex: 'email',
+    key: 'email',
+    width: 180,
+    render: (val: string | null) => val || '--',
+  },
+  {
+    title: 'Sign',
+    key: 'sign',
+    width: 80,
+    render: (_: unknown, record: AddressWithLocation) =>
+      record.sign
+        ? `Yes${record.signSize ? ` (${record.signSize})` : ''}`
+        : 'No',
+  },
+  {
+    title: 'Notes',
+    dataIndex: 'notes',
+    key: 'notes',
+    width: 150,
+    ellipsis: true,
+    render: (val: string | null) => val || '--',
+  },
+];
+
+ +
<style>{`
+  @media print {
+    /* Hide everything except report */
+    body * { visibility: hidden !important; }
+    .cut-export-print, .cut-export-print * { visibility: visible !important; }
+
+    /* Position report at top-left */
+    .cut-export-print {
+      position: fixed !important;
+      left: 0 !important;
+      top: 0 !important;
+      width: 100% !important;
+      padding: 0.4in !important;
+      background: white !important;
+      color: black !important;
+      font-size: 10px !important;
+    }
+
+    /* Compact table styling */
+    .cut-export-print .ant-table { font-size: 9px !important; }
+    .cut-export-print .ant-table-thead > tr > th {
+      background: #f0f0f0 !important;
+      print-color-adjust: exact;
+      -webkit-print-color-adjust: exact;
+      padding: 4px 6px !important;
+    }
+    .cut-export-print .ant-table-tbody > tr > td { padding: 3px 6px !important; }
+
+    /* Force exact color printing for tags */
+    .cut-export-print .ant-tag {
+      print-color-adjust: exact;
+      -webkit-print-color-adjust: exact;
+    }
+
+    /* Remove card shadows for print */
+    .cut-export-print .ant-card { box-shadow: none !important; border: 1px solid #ddd !important; }
+
+    /* Landscape orientation */
+    @page { size: letter landscape; margin: 0.25in; }
+  }
+`}</style>
+
+

Key Print Rules: +- visibility: hidden !important on all elements except .cut-export-print +- Fixed positioning at top-left (0, 0) with 0.4in padding +- 9-10px font sizes for compact printing +- print-color-adjust: exact forces exact color printing (tags, statistics) +- Landscape orientation via @page { size: letter landscape; } +- Minimal margins (0.25in) to maximize table width

+
+

State Management

+

Local Component State (useState)

+

No Zustand stores used - All state managed locally with React hooks.

+
// Cut metadata state
+const [cut, setCut] = useState<Cut | null>(null);
+
+// Flattened addresses state (Location + Address combined)
+const [addresses, setAddresses] = useState<AddressWithLocation[]>([]);
+
+// Cut statistics state
+const [stats, setStats] = useState<CutStatistics | null>(null);
+
+// Loading state
+const [loading, setLoading] = useState(true);
+
+

State Flow

+
    +
  1. Component Mounts:
  2. +
  3. Extracts id from URL params (:id in /app/map/cuts/:id/export)
  4. +
  5. Calls 3 parallel API requests:
      +
    • GET /api/map/cuts/:id (cut metadata)
    • +
    • GET /api/map/cuts/:id/locations (locations with addresses)
    • +
    • GET /api/map/cuts/:id/statistics (aggregated statistics)
    • +
    +
  6. +
  7. Sets cut, addresses, stats states
  8. +
  9. +

    Sets loading to false

    +
  10. +
  11. +

    Address Flattening:

    +
  12. +
  13. API returns locations with nested addresses array
  14. +
  15. Component flattens to single AddressWithLocation[] array: +
    const flatAddresses: AddressWithLocation[] = [];
    +for (const loc of locations) {
    +  if (loc.addresses && loc.addresses.length > 0) {
    +    for (const addr of loc.addresses) {
    +      flatAddresses.push({
    +        ...addr,
    +        locationAddress: loc.address, // Add parent location address
    +      });
    +    }
    +  }
    +}
    +
  16. +
  17. +

    Result: One row per address (not per location)

    +
  18. +
  19. +

    Statistics Rendering:

    +
  20. +
  21. stats.total → Total card
  22. +
  23. stats.byLevel.LEVEL_1 → Strong card
  24. +
  25. stats.byLevel.LEVEL_2 → Likely card
  26. +
  27. stats.byLevel.LEVEL_3 → Unsure card
  28. +
  29. stats.byLevel.LEVEL_4 → Oppose card
  30. +
  31. stats.byLevel.NONE → None card
  32. +
  33. stats.withSign → Signs card
  34. +
  35. +

    Count emails/phones from addresses array

    +
  36. +
  37. +

    User Clicks Print:

    +
  38. +
  39. window.print() called
  40. +
  41. Browser opens print dialog
  42. +
  43. Print CSS rules activate
  44. +
  45. Report rendered in landscape layout
  46. +
+
+

API Integration

+

Endpoints Used

+
    +
  1. GET /api/map/cuts/:id - Fetch cut metadata (name, category, assignedTo)
  2. +
  3. GET /api/map/cuts/:id/locations - Fetch all locations within cut (with nested addresses)
  4. +
  5. GET /api/map/cuts/:id/statistics - Fetch aggregated cut statistics (support levels, signs)
  6. +
+

API Client

+
import { api } from '@/lib/api';
+
+// All requests use authenticated API client with automatic token refresh
+
+

Example API Calls

+

1. Fetch Cut Metadata

+
const cutRes = await api.get<Cut>(`/map/cuts/${id}`);
+setCut(cutRes.data);
+
+

Response Format: +

{
+  "id": 5,
+  "name": "Downtown District - Block 5",
+  "category": "PRIORITY",
+  "assignedTo": "Jane Smith",
+  "geometry": {...},
+  "createdAt": "2025-01-15T10:00:00Z",
+  "updatedAt": "2025-02-11T12:00:00Z"
+}
+

+

2. Fetch Locations with Addresses

+
const locsRes = await api.get<Location[]>(`/map/cuts/${id}/locations`);
+const locations = locsRes.data;
+
+

Response Format: +

[
+  {
+    "id": 101,
+    "address": "123 Main St",
+    "lat": 45.5017,
+    "lng": -73.5673,
+    "addresses": [
+      {
+        "id": 1001,
+        "firstName": "John",
+        "lastName": "Doe",
+        "unitNumber": "101",
+        "supportLevel": "LEVEL_1",
+        "phone": "555-1234",
+        "email": "john@example.com",
+        "sign": true,
+        "signSize": "18x24",
+        "notes": "Strong supporter, wants yard sign"
+      },
+      {
+        "id": 1002,
+        "firstName": "Jane",
+        "lastName": "Smith",
+        "unitNumber": "102",
+        "supportLevel": "LEVEL_2",
+        "phone": "555-5678",
+        "email": "jane@example.com",
+        "sign": false,
+        "notes": "Likely supporter, call after 6pm"
+      }
+    ]
+  },
+  {
+    "id": 102,
+    "address": "125 Main St",
+    "lat": 45.5018,
+    "lng": -73.5674,
+    "addresses": [
+      {
+        "id": 1003,
+        "firstName": "Bob",
+        "lastName": "Johnson",
+        "unitNumber": null,
+        "supportLevel": "LEVEL_3",
+        "phone": null,
+        "email": null,
+        "sign": false,
+        "notes": "Undecided, needs more info"
+      }
+    ]
+  }
+]
+

+

3. Fetch Cut Statistics

+
const statsRes = await api.get<CutStatistics>(`/map/cuts/${id}/statistics`);
+setStats(statsRes.data);
+
+

Response Format: +

{
+  "total": 150,
+  "byLevel": {
+    "LEVEL_1": 80,
+    "LEVEL_2": 30,
+    "LEVEL_3": 25,
+    "LEVEL_4": 10,
+    "NONE": 5
+  },
+  "withSign": 50,
+  "withPhone": 100,
+  "withEmail": 90
+}
+

+

4. Parallel API Calls Pattern

+
useEffect(() => {
+  if (!id) return;
+  (async () => {
+    try {
+      // Fetch all 3 endpoints in parallel
+      const [cutRes, locsRes, statsRes] = await Promise.all([
+        api.get<Cut>(`/map/cuts/${id}`),
+        api.get<Location[]>(`/map/cuts/${id}/locations`),
+        api.get<CutStatistics>(`/map/cuts/${id}/statistics`),
+      ]);
+
+      setCut(cutRes.data);
+
+      // Flatten locations with addresses
+      const flatAddresses: AddressWithLocation[] = [];
+      for (const loc of locsRes.data) {
+        if (loc.addresses && loc.addresses.length > 0) {
+          for (const addr of loc.addresses) {
+            flatAddresses.push({
+              ...addr,
+              locationAddress: loc.address,
+            });
+          }
+        }
+      }
+      setAddresses(flatAddresses);
+
+      setStats(statsRes.data);
+    } catch {
+      message.error('Failed to load cut data');
+    } finally {
+      setLoading(false);
+    }
+  })();
+}, [id]);
+
+

Benefit: Parallel requests reduce total loading time (3 requests in ~200ms instead of ~600ms sequential).

+
+

Code Examples

+

Complete Address Flattening Logic

+
interface AddressWithLocation extends Address {
+  locationAddress: string; // Building street address from parent Location
+}
+
+// Flatten locations with their addresses
+const flatAddresses: AddressWithLocation[] = [];
+for (const loc of locations) {
+  if (loc.addresses && loc.addresses.length > 0) {
+    for (const addr of loc.addresses) {
+      flatAddresses.push({
+        ...addr,
+        locationAddress: loc.address, // Add parent location address
+      });
+    }
+  }
+}
+
+setAddresses(flatAddresses);
+
+

Result: +- Input: 100 locations with 2-10 addresses each +- Output: 500 flat addresses (one row per address in table)

+

Statistics Grid Rendering

+
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
+  <Col xs={8} sm={4}>
+    <Card size="small">
+      <Statistic title="Total" value={stats.total} />
+    </Card>
+  </Col>
+  <Col xs={8} sm={4}>
+    <Card size="small">
+      <Statistic
+        title="Strong"
+        value={stats.byLevel.LEVEL_1 || 0}
+        valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_1 }}
+      />
+    </Card>
+  </Col>
+  <Col xs={8} sm={4}>
+    <Card size="small">
+      <Statistic
+        title="Likely"
+        value={stats.byLevel.LEVEL_2 || 0}
+        valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_2 }}
+      />
+    </Card>
+  </Col>
+  <Col xs={8} sm={4}>
+    <Card size="small">
+      <Statistic
+        title="Unsure"
+        value={stats.byLevel.LEVEL_3 || 0}
+        valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_3 }}
+      />
+    </Card>
+  </Col>
+  <Col xs={8} sm={4}>
+    <Card size="small">
+      <Statistic
+        title="Oppose"
+        value={stats.byLevel.LEVEL_4 || 0}
+        valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_4 }}
+      />
+    </Card>
+  </Col>
+  <Col xs={8} sm={4}>
+    <Card size="small">
+      <Statistic title="None" value={stats.byLevel.NONE || 0} />
+    </Card>
+  </Col>
+  <Col xs={8} sm={4}>
+    <Card size="small">
+      <Statistic title="Signs" value={stats.withSign} />
+    </Card>
+  </Col>
+  <Col xs={8} sm={4}>
+    <Card size="small">
+      <Statistic title="Email" value={addresses.filter(a => a.email).length} />
+    </Card>
+  </Col>
+  <Col xs={8} sm={4}>
+    <Card size="small">
+      <Statistic title="Phone" value={addresses.filter(a => a.phone).length} />
+    </Card>
+  </Col>
+</Row>
+
+

Support Level Tag Rendering

+
{
+  title: 'Support',
+  dataIndex: 'supportLevel',
+  key: 'supportLevel',
+  width: 120,
+  render: (level: SupportLevel | null) =>
+    level ? (
+      <Tag color={SUPPORT_LEVEL_COLORS[level]}>
+        {SUPPORT_LEVEL_LABELS[level]}
+      </Tag>
+    ) : (
+      <Tag>None</Tag>
+    ),
+}
+
+// Constants (from types/api.ts)
+export const SUPPORT_LEVEL_LABELS = {
+  LEVEL_1: 'Strong',
+  LEVEL_2: 'Likely',
+  LEVEL_3: 'Unsure',
+  LEVEL_4: 'Oppose',
+  NONE: 'None',
+};
+
+export const SUPPORT_LEVEL_COLORS = {
+  LEVEL_1: 'green',
+  LEVEL_2: 'cyan',
+  LEVEL_3: 'orange',
+  LEVEL_4: 'red',
+  NONE: 'default',
+};
+
+
+

Performance Considerations

+

1. Parallel API Requests

+

Three API calls made in parallel with Promise.all():

+
const [cutRes, locsRes, statsRes] = await Promise.all([
+  api.get<Cut>(`/map/cuts/${id}`),
+  api.get<Location[]>(`/map/cuts/${id}/locations`),
+  api.get<CutStatistics>(`/map/cuts/${id}/statistics`),
+]);
+
+

Benefit: Total loading time ~200ms (slowest request) instead of ~600ms (sum of all requests).

+

2. Address Flattening (O(n*m))

+

Flattening addresses is O(n×m) where n = locations, m = addresses per location:

+
for (const loc of locations) {        // O(n)
+  for (const addr of loc.addresses) {  // O(m)
+    flatAddresses.push({...addr, locationAddress: loc.address});
+  }
+}
+
+

Complexity: O(n×m), typically O(100×5) = O(500) operations

+

Benefit: Simple nested loop, fast for typical cut sizes (< 1000 addresses).

+

3. No Pagination (Print All)

+

Table has pagination={false}:

+
<Table
+  dataSource={addresses}
+  pagination={false}  // Print all addresses
+/>
+
+

Trade-off: +- Benefit: All addresses visible in one print job (no manual page-turning) +- Cost: Large cuts (> 500 addresses) may slow page load slightly

+

Rationale: Printable reports typically exported for offline use, so full dataset preferred over pagination.

+

4. useMemo for Header Actions

+

Header actions memoized with useMemo to prevent re-renders:

+
const headerActions = useMemo(() => (
+  <Space>
+    <Button onClick={() => navigate('/app/map/cuts')}>Back</Button>
+    <Button onClick={() => window.print()}>Print</Button>
+  </Space>
+), [navigate]);
+
+

Benefit: Header actions only recreated if navigate changes (never changes), preventing unnecessary re-renders.

+
+

Responsive Design

+

Desktop-First Layout

+

Report optimized for desktop printing, not mobile viewing: +- Landscape orientation (@page { size: letter landscape; }) +- 9 statistics cards in responsive grid (3 per row on desktop, 2 per row on mobile) +- Wide table with 8 columns (requires landscape for clarity)

+

Responsive Statistics Grid

+
<Row gutter={[12, 12]}>
+  <Col xs={8} sm={4}>  {/* 3 per row mobile, 6 per row desktop */}
+    <Card size="small">
+      <Statistic title="Total" value={stats.total} />
+    </Card>
+  </Col>
+  {/* ... 8 more cards ... */}
+</Row>
+
+

Breakpoints: +- xs={8}: 3 cards per row on mobile (8+8+8 = 24 columns) +- sm={4}: 6 cards per row on tablet (4×6 = 24 columns) +- md+: 6-9 cards per row on desktop (depends on screen width)

+ +
@page {
+  size: letter landscape;
+  margin: 0.25in;
+}
+
+

Landscape Orientation: +- Paper: 11" wide × 8.5" tall (instead of 8.5" × 11") +- Allows 8-column table to fit without horizontal scroll +- Critical for readability of address table

+
+

Accessibility

+ +

Cut export report is primarily for printing, not interactive use. Accessibility considerations minimal:

+
    +
  1. Semantic HTML:
  2. +
  3. <table> for address grid
  4. +
  5. <th> for column headers
  6. +
  7. <td> for data cells
  8. +
  9. +

    Proper heading hierarchy (<h4> for cut name)

    +
  10. +
  11. +

    Keyboard Navigation:

    +
  12. +
  13. "Back to Cuts" button accessible via Tab + Enter
  14. +
  15. +

    "Print" button accessible via Tab + Enter

    +
  16. +
  17. +

    Screen Reader Support:

    +
  18. +
  19. Table headers announced for each column
  20. +
  21. Statistics titles read before values
  22. +
  23. +

    Tag labels announced (e.g., "Strong Support", "Priority Cut")

    +
  24. +
  25. +

    Color Contrast:

    +
  26. +
  27. Support level tags meet WCAG AA standards
  28. +
  29. Statistics value colors have sufficient contrast on white background
  30. +
+

Note: Once printed, report relies on visual layout (table, colors, spacing) for interpretation.

+
+

Troubleshooting

+

Problem: Report Shows "Cut Not Found"

+

Symptoms: +- Navigate to /app/map/cuts/:id/export +- Page shows error: "Cut not found" +- No data displayed

+

Causes: +1. Invalid cut ID in URL (cut doesn't exist) +2. API returned 404 for cut +3. Cut deleted after URL generated

+

Solutions:

+
    +
  1. Verify cut ID:
  2. +
  3. Check URL bar: /app/map/cuts/5/export
  4. +
  5. Note the ID number (5)
  6. +
  7. Navigate to "Map" → "Cuts"
  8. +
  9. +

    Verify cut with ID 5 exists in table

    +
  10. +
  11. +

    Check API response:

    +
  12. +
  13. Open browser DevTools (F12)
  14. +
  15. Go to Network tab
  16. +
  17. Look for GET /api/map/cuts/5 request
  18. +
  19. +

    Check response:

    +
      +
    • 200 OK: Cut exists, check response body
    • +
    • 404 Not Found: Cut doesn't exist
    • +
    • 500 Server Error: API error
    • +
    +
  20. +
  21. +

    Navigate from Cuts page:

    +
  22. +
  23. Instead of typing URL manually, click "Export" button from CutsPage
  24. +
  25. This ensures valid cut ID used
  26. +
+
+

Problem: Address Table is Empty

+

Symptoms: +- Cut metadata loads correctly (name, category, assigned person) +- Statistics cards show zeros +- Address table has no rows

+

Causes: +1. Cut has no locations assigned (empty polygon or no location assignment) +2. Locations have no addresses (only building locations, no unit/contact data) +3. API error fetching locations

+

Solutions:

+
    +
  1. Check cut has locations:
  2. +
  3. Navigate to "Map" → "Cuts"
  4. +
  5. Click on cut name to view details
  6. +
  7. Check "Locations" count in details modal
  8. +
  9. +

    If 0 locations, cut is empty (no addresses to export)

    +
  10. +
  11. +

    Assign locations to cut:

    +
  12. +
  13. Navigate to "Map" → "Locations"
  14. +
  15. Draw cut polygon on map
  16. +
  17. Use point-in-polygon to assign locations
  18. +
  19. +

    Re-export cut

    +
  20. +
  21. +

    Check locations have addresses:

    +
  22. +
  23. Navigate to "Map" → "Locations"
  24. +
  25. Click on location in cut
  26. +
  27. Check "Addresses" tab in location details
  28. +
  29. +

    If no addresses, add address records via CSV import or manual entry

    +
  30. +
  31. +

    Check API response:

    +
  32. +
  33. Open browser DevTools (F12)
  34. +
  35. Go to Network tab
  36. +
  37. Look for GET /api/map/cuts/:id/locations request
  38. +
  39. Check response:
      +
    • 200 OK with empty array []: No locations in cut
    • +
    • 200 OK with locations but no addresses field: Locations exist but no address data
    • +
    • 500 Server Error: API error
    • +
    +
  40. +
+
+

Problem: Statistics Don't Match Address Table

+

Symptoms: +- Statistics cards show different counts than visible in address table +- Example: "Strong" card shows 80, but table has 60 "Strong" tags

+

Causes: +1. Statistics API aggregates all addresses (including those without contact info) +2. Table filters out addresses with missing data +3. Race condition between API calls

+

Solutions:

+
    +
  1. Verify statistics API:
  2. +
  3. Statistics endpoint (/api/map/cuts/:id/statistics) counts ALL addresses
  4. +
  5. Table may filter addresses (e.g., only show addresses with names)
  6. +
  7. +

    This is expected behavior

    +
  8. +
  9. +

    Check table filters:

    +
  10. +
  11. No default filters applied in CutExportPage
  12. +
  13. +

    If custom filters added, they may hide addresses from table

    +
  14. +
  15. +

    Refresh page:

    +
  16. +
  17. Hard refresh (Ctrl+Shift+R or Cmd+Shift+R)
  18. +
  19. +

    Clears cached data and re-fetches from API

    +
  20. +
  21. +

    Check API responses match:

    +
  22. +
  23. Open browser DevTools (F12)
  24. +
  25. Go to Network tab
  26. +
  27. Compare responses:
      +
    • GET /api/map/cuts/:id/statistics → total: 150
    • +
    • GET /api/map/cuts/:id/locations → count addresses in response
    • +
    • Counts should match
    • +
    +
  28. +
+
+

Problem: Print Preview is Blank

+

Symptoms: +- Click "Print" button +- Print preview shows blank page +- No content visible

+

Causes: +1. Print CSS not applying +2. Browser print settings incorrect +3. Content outside printable area

+

Solutions:

+
    +
  1. Check print CSS:
  2. +
  3. View page source (Ctrl+U or Cmd+U)
  4. +
  5. Verify <style> tag with @media print rules exists
  6. +
  7. +

    If missing, print CSS not loaded

    +
  8. +
  9. +

    Enable background graphics:

    +
  10. +
  11. In print dialog, check "Background graphics" option
  12. +
  13. +

    This ensures table borders and colors print

    +
  14. +
  15. +

    Try different browser:

    +
  16. +
  17. Chrome, Firefox, and Edge have different print engines
  18. +
  19. +

    If one fails, try another

    +
  20. +
  21. +

    Check browser console:

    +
  22. +
  23. Open DevTools (F12)
  24. +
  25. Go to Console tab
  26. +
  27. +

    Look for CSS errors (e.g., invalid print rules)

    +
  28. +
  29. +

    Use Ctrl+P instead of button:

    +
  30. +
  31. Press Ctrl+P (Windows/Linux) or Cmd+P (Mac)
  32. +
  33. This bypasses custom print button and uses browser default
  34. +
+
+

Problem: Table Columns Cut Off When Printed

+

Symptoms: +- Print preview shows table, but rightmost columns missing +- Horizontal scrollbar visible in print preview

+

Causes: +1. Portrait orientation used instead of landscape +2. Table too wide for paper +3. Print scaling set to "Fit to page" (shrinks content)

+

Solutions:

+
    +
  1. Verify landscape orientation:
  2. +
  3. In print dialog, check "Orientation: Landscape"
  4. +
  5. Landscape gives 11" width instead of 8.5"
  6. +
  7. +

    Critical for 8-column table

    +
  8. +
  9. +

    Check print scaling:

    +
  10. +
  11. In print dialog, set scale to "100%" (not "Fit to page")
  12. +
  13. +

    "Fit to page" shrinks content, making text too small

    +
  14. +
  15. +

    Reduce font sizes:

    +
  16. +
  17. +

    If table still too wide, edit print CSS: +

    @media print {
    +  .cut-export-print { font-size: 8px !important; } /* Was 10px */
    +  .cut-export-print .ant-table { font-size: 7px !important; } /* Was 9px */
    +}
    +

    +
  18. +
  19. +

    Remove less important columns:

    +
  20. +
  21. Temporarily hide "Notes" column (least critical for field use): +
    const columns = [
    +  // ... other columns ...
    +  // Comment out Notes column
    +  // { title: 'Notes', dataIndex: 'notes', ... },
    +];
    +
  22. +
+
+ +

Backend Documentation

+ +

Frontend Documentation

+ +

Feature Documentation

+ +

API Documentation

+ +

User Guides

+ +

Deployment Documentation

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/cuts-page/index.html b/mkdocs/site/v2/frontend/pages/admin/cuts-page/index.html new file mode 100644 index 00000000..6ffd5bf3 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/cuts-page/index.html @@ -0,0 +1,8645 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cuts - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

CutsPage

+

Overview

+

The CutsPage provides administrative management of geographic polygon boundaries ("cuts") used to organize canvassing territories for volunteer door-knocking campaigns. It offers a dual-view interface: a table view for CRUD operations on cut metadata, and an interactive map view for drawing new polygons, editing existing boundaries, and visualizing all cuts simultaneously. The page integrates with the Location system and Shift system to enable territory-based volunteer assignments.

+

Route: /app/map/cuts +Component: admin/src/pages/CutsPage.tsx (561 lines) +Auth Required: Yes (SUPER_ADMIN or MAP_ADMIN role recommended) +Layout: AppLayout +Backend Module: api/src/modules/map/cuts/

+

Screenshot

+

[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.]

+

Features

+
    +
  • Dual-view interface — Segmented control to switch between table and map views
  • +
  • Cut CRUD operations — Create, read, update, delete cut metadata (name, description, color)
  • +
  • Interactive polygon drawing — Click vertices on map to draw custom boundaries
  • +
  • Cut visualization — View all cuts as colored polygon overlays on map
  • +
  • GeoJSON import/export — Import cuts from GeoJSON files, export to GeoJSON format
  • +
  • Location assignment — Automatically assign locations within polygon boundaries
  • +
  • Color-coded polygons — Each cut has customizable color for visual distinction
  • +
  • Location count badges — See number of locations within each cut at a glance
  • +
  • Search and filter — Search cuts by name or description (300ms debounce)
  • +
  • Sortable table — Sort by name, location count, or creation date
  • +
  • Polygon editing — Edit existing cut boundaries on map (drag vertices, add/remove points)
  • +
  • Validation — Prevent self-intersecting polygons, ensure minimum 3 vertices
  • +
  • Responsive design — Mobile-friendly table, full-screen map view
  • +
  • Pagination — Configurable page size (10, 25, 50, 100 per page)
  • +
+

User Workflow

+

Creating a New Cut (Table View)

+
    +
  1. Navigate to /app/map/cuts
  2. +
  3. Ensure "Table" tab is selected (default)
  4. +
  5. Click "Create Cut" button (top right)
  6. +
  7. Modal appears: "Create Cut"
  8. +
  9. Fill in fields:
  10. +
  11. Name: (required) e.g., "Downtown Core"
  12. +
  13. Description: (optional) e.g., "High-density residential area with apartment buildings"
  14. +
  15. Color: (required) Click color picker to select polygon color (default: #3498db blue)
  16. +
  17. Click "Create" button
  18. +
  19. Success message: "Cut created successfully"
  20. +
  21. Modal closes, table refreshes to show new cut (with 0 locations initially)
  22. +
  23. New cut appears in table with selected color preview circle
  24. +
+

Drawing a Cut Polygon (Map View)

+
    +
  1. Click "Map" tab in Segmented control
  2. +
  3. Map view appears with CutEditorMap component
  4. +
  5. Existing cuts render as colored polygon overlays
  6. +
  7. Click "Draw New Cut" button (top-left map controls)
  8. +
  9. Drawing mode activates:
  10. +
  11. Cursor changes to crosshair
  12. +
  13. Instructional text: "Click to place vertices. Double-click or click first vertex to close polygon."
  14. +
  15. Click map to place first vertex (blue circle marker appears)
  16. +
  17. Click again to place second vertex (line drawn between vertices)
  18. +
  19. Continue clicking to place vertices (polygon outline forms)
  20. +
  21. Close polygon by:
  22. +
  23. Double-clicking final vertex, OR
  24. +
  25. Clicking first vertex again (close detection radius: 10 pixels)
  26. +
  27. Polygon closes automatically, fills with semi-transparent color
  28. +
  29. Modal appears: "Save Cut"
  30. +
  31. Fill in fields:
      +
    • Name: (required) e.g., "Riverside District"
    • +
    • Description: (optional) e.g., "Area bounded by river and highway"
    • +
    • Color: (required, pre-filled with default blue)
    • +
    +
  32. +
  33. Click "Save" button
  34. +
  35. Backend calculates locations within polygon (point-in-polygon algorithm)
  36. +
  37. Success message: "Cut created successfully with 47 locations"
  38. +
  39. Polygon remains on map, now saved to database
  40. +
  41. Switch to "Table" tab to see new cut with location count: 47
  42. +
+

Editing a Cut

+
    +
  1. In Table view, locate cut to edit
  2. +
  3. Click "Edit" button in Actions column
  4. +
  5. Modal appears: "Edit Cut"
  6. +
  7. Modify fields:
  8. +
  9. Name: Update cut name
  10. +
  11. Description: Update or add description
  12. +
  13. Color: Change polygon color (affects map visualization)
  14. +
  15. Click "Save" button
  16. +
  17. Success message: "Cut updated successfully"
  18. +
  19. Table refreshes to show updated values
  20. +
  21. Color preview circle updates to new color
  22. +
+

Note: Editing cut metadata does NOT modify polygon boundary. To change boundary, must delete cut and redraw.

+

Importing Cuts from GeoJSON

+
    +
  1. In Table view, click "Import GeoJSON" button (top right, next to Create Cut)
  2. +
  3. File picker opens
  4. +
  5. Select GeoJSON file from local filesystem (e.g., cuts-export-2026-01-15.geojson)
  6. +
  7. File uploads to backend
  8. +
  9. Backend parses GeoJSON:
  10. +
  11. Validates FeatureCollection format
  12. +
  13. Extracts polygons from features
  14. +
  15. Creates Cut records with properties (name, description, color)
  16. +
  17. Calculates locations within each polygon
  18. +
  19. Success message: "Imported 5 cuts with 234 total locations"
  20. +
  21. Table refreshes to show imported cuts
  22. +
  23. Switch to Map view to see imported polygons
  24. +
+

GeoJSON Format Expected:

+
{
+  "type": "FeatureCollection",
+  "features": [
+    {
+      "type": "Feature",
+      "geometry": {
+        "type": "Polygon",
+        "coordinates": [
+          [
+            [-75.69602, 45.42153],
+            [-75.69102, 45.42153],
+            [-75.69102, 45.41653],
+            [-75.69602, 45.41653],
+            [-75.69602, 45.42153]
+          ]
+        ]
+      },
+      "properties": {
+        "name": "Downtown Core",
+        "description": "High-density residential area",
+        "color": "#3498db"
+      }
+    }
+  ]
+}
+
+

Exporting a Cut to GeoJSON

+
    +
  1. In Table view, locate cut to export
  2. +
  3. Click "Export GeoJSON" button in Actions column
  4. +
  5. Backend generates GeoJSON with:
  6. +
  7. Polygon geometry (coordinates array)
  8. +
  9. Cut properties (name, description, color)
  10. +
  11. Location count metadata
  12. +
  13. Browser downloads file: cut-{name}-{id}.geojson
  14. +
  15. File can be opened in GIS software (QGIS, ArcGIS) or re-imported later
  16. +
+

Example Export:

+
{
+  "type": "Feature",
+  "geometry": {
+    "type": "Polygon",
+    "coordinates": [
+      [
+        [-75.69602, 45.42153],
+        [-75.69102, 45.42153],
+        [-75.69102, 45.41653],
+        [-75.69602, 45.41653],
+        [-75.69602, 45.42153]
+      ]
+    ]
+  },
+  "properties": {
+    "id": "cut_abc123",
+    "name": "Downtown Core",
+    "description": "High-density residential area",
+    "color": "#3498db",
+    "locationCount": 47,
+    "createdAt": "2026-01-15T10:30:00.000Z"
+  }
+}
+
+

Viewing Locations in a Cut

+
    +
  1. In Table view, locate cut
  2. +
  3. Note location count badge (e.g., "47 locations")
  4. +
  5. Click "View Locations" button in Actions column
  6. +
  7. Navigates to /app/map/locations?cutId={cutId}
  8. +
  9. LocationsPage opens with cut filter pre-applied
  10. +
  11. Table shows only locations within that cut's polygon
  12. +
  13. Can edit locations, view on map, or export to CSV
  14. +
+

Deleting a Cut

+
    +
  1. In Table view, locate cut to delete
  2. +
  3. Click "Delete" button in Actions column (red text)
  4. +
  5. 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."
  6. +
  7. Click "Delete" to confirm (or "Cancel" to abort)
  8. +
  9. Backend:
  10. +
  11. Deletes Cut record from database
  12. +
  13. Sets cutId = null on all Location records within polygon (unassigns)
  14. +
  15. Deletes associated Shift records (shifts are cut-specific)
  16. +
  17. Success message: "Cut deleted successfully. 47 locations unassigned."
  18. +
  19. Table refreshes, deleted cut removed
  20. +
  21. Switch to Map view: polygon no longer visible
  22. +
+

Searching Cuts

+
    +
  1. Locate search bar at top of Table view (below Segmented control)
  2. +
  3. Start typing search query (e.g., "Downtown")
  4. +
  5. Search automatically triggers after 300ms pause (debounce)
  6. +
  7. Table filters to show matching cuts
  8. +
  9. Matches on: cut name, description
  10. +
  11. Clear search by clicking X icon or deleting text
  12. +
+

Sorting the Table

+
    +
  1. Identify sortable columns (Name, Location Count, Created At)
  2. +
  3. Click Name column header to sort alphabetically (A→Z)
  4. +
  5. Click again to reverse sort (Z→A)
  6. +
  7. Click Location Count to sort by number of locations (ascending)
  8. +
  9. Click again to reverse sort (descending, highest first)
  10. +
  11. Click Created At to sort by creation date (newest first)
  12. +
  13. Can combine with search filter (sorted results only)
  14. +
+

Component Breakdown

+

Ant Design Components Used

+
    +
  • Typography.Title — Page heading ("Cuts")
  • +
  • Typography.Text — Labels, descriptions, empty state text
  • +
  • Segmented — Tab control for table/map view switching (large button style)
  • +
  • Space — Button grouping (Create, Import)
  • +
  • Button — Primary actions (Create, Import, Draw), row actions (Edit, View, Export, Delete)
  • +
  • Input.Search — Cut search field with debounce
  • +
  • Table — Main data table with sortable columns, pagination
  • +
  • Tag — Location count badges
  • +
  • Modal — Create/edit cut form, confirmation dialogs
  • +
  • Form — Cut metadata form (name, description, color)
  • +
  • Form.Item — Form field wrapper with validation
  • +
  • Input — Text fields (name, description)
  • +
  • Input.TextArea — Description field (multi-line)
  • +
  • ColorPicker — Cut color selection
  • +
  • Upload — GeoJSON file upload
  • +
  • message — Toast notifications for success/error feedback
  • +
  • Empty — Empty state when no cuts exist
  • +
+

Map Components (Custom)

+
    +
  • CutEditorMap — Specialized Leaflet map wrapper for cut drawing/editing
  • +
  • Renders existing cuts as polygon overlays
  • +
  • Provides drawing mode for new polygons
  • +
  • Handles vertex placement, line drawing, polygon closing
  • +
  • Validates polygon geometry (minimum 3 vertices, no self-intersections)
  • +
  • +

    Provides save callback after polygon closes

    +
  • +
  • +

    CutOverlays — Component for rendering cut polygons on map

    +
  • +
  • Renders Leaflet Polygon layers for each cut
  • +
  • Applies cut color to fill and stroke
  • +
  • Adds tooltip on hover (cut name + location count)
  • +
  • Handles click events for cut selection
  • +
+

Segmented Tab Control

+
<Segmented
+  value={activeTab}
+  onChange={(val) => setActiveTab(val as string)}
+  options={[
+    {
+      value: 'table',
+      label: 'Table',
+      icon: <TableOutlined />,
+    },
+    {
+      value: 'map',
+      label: 'Map',
+      icon: <EnvironmentOutlined />,
+    },
+  ]}
+  size="large"
+  block
+  style={{ marginBottom: 16 }}
+/>
+
+

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)

+

Table Structure

+
const columns: ColumnsType<Cut> = [
+  {
+    title: 'Name',
+    dataIndex: 'name',
+    key: 'name',
+    sorter: (a, b) => a.name.localeCompare(b.name),
+    width: 200,
+  },
+  {
+    title: 'Description',
+    dataIndex: 'description',
+    key: 'description',
+    width: 300,
+    ellipsis: true,
+    render: (text: string | null) => text || <Text type="secondary"></Text>,
+  },
+  {
+    title: 'Color',
+    dataIndex: 'color',
+    key: 'color',
+    width: 80,
+    render: (color: string) => (
+      <div
+        style={{
+          width: 24,
+          height: 24,
+          borderRadius: '50%',
+          backgroundColor: color,
+          border: '2px solid rgba(0,0,0,0.1)',
+        }}
+      />
+    ),
+  },
+  {
+    title: 'Location Count',
+    dataIndex: 'locationCount',
+    key: 'locationCount',
+    width: 150,
+    sorter: (a, b) => (a.locationCount || 0) - (b.locationCount || 0),
+    render: (count: number) => (
+      <Tag color={count > 0 ? 'blue' : 'default'}>{count} locations</Tag>
+    ),
+  },
+  {
+    title: 'Created At',
+    dataIndex: 'createdAt',
+    key: 'createdAt',
+    width: 180,
+    sorter: (a, b) => dayjs(a.createdAt).unix() - dayjs(b.createdAt).unix(),
+    render: (date: string) => dayjs(date).format('MMM D, YYYY h:mm A'),
+  },
+  {
+    title: 'Actions',
+    key: 'actions',
+    width: 300,
+    fixed: 'right',
+    render: (_: unknown, record: Cut) => (
+      <Space size="small" wrap>
+        <Button size="small" type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
+          Edit
+        </Button>
+        <Button
+          size="small"
+          type="link"
+          icon={<EnvironmentOutlined />}
+          onClick={() => handleViewLocations(record)}
+        >
+          View Locations
+        </Button>
+        <Button
+          size="small"
+          type="link"
+          icon={<DownloadOutlined />}
+          onClick={() => handleExportGeoJSON(record)}
+        >
+          Export GeoJSON
+        </Button>
+        <Button
+          size="small"
+          type="link"
+          danger
+          icon={<DeleteOutlined />}
+          onClick={() => handleDeleteConfirm(record)}
+        >
+          Delete
+        </Button>
+      </Space>
+    ),
+  },
+];
+
+

Column Features: +- Name: Primary identifier, sortable, 200px width +- Description: Optional text, ellipsis for overflow, nullable (shows "—" 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

+

State Management

+

Local State (No Zustand Store)

+
// View state
+const [activeTab, setActiveTab] = useState<string>('table');
+
+// Data state
+const [cuts, setCuts] = useState<Cut[]>([]);
+const [loading, setLoading] = useState(false);
+
+// Filter state
+const [search, setSearch] = useState('');
+
+// Pagination state
+const [pagination, setPagination] = useState({
+  current: 1,
+  pageSize: 10,
+  total: 0,
+});
+
+// Modal state
+const [createModalOpen, setCreateModalOpen] = useState(false);
+const [editModalOpen, setEditModalOpen] = useState(false);
+const [selectedCut, setSelectedCut] = useState<Cut | null>(null);
+
+// Import state
+const [importing, setImporting] = useState(false);
+
+// Debounce timer
+const searchTimerRef = useRef<NodeJS.Timeout | null>(null);
+
+

No Global State:

+

This page does NOT use Zustand stores. Cut data is fetched directly from the API on mount and after mutations. This is appropriate because: +- Cut data is admin-only (not needed globally) +- Data changes infrequently (only on manual create/edit/delete) +- No need to share state between pages (LocationsPage fetches cuts independently) +- Simpler architecture without store overhead

+

Debounced Search Pattern

+
const handleSearch = (value: string) => {
+  // Clear existing timer
+  if (searchTimerRef.current) {
+    clearTimeout(searchTimerRef.current);
+  }
+
+  // Set new timer
+  searchTimerRef.current = setTimeout(() => {
+    setSearch(value);
+    setPagination((prev) => ({ ...prev, current: 1 })); // Reset to page 1
+  }, 300);
+};
+
+// Cleanup on unmount
+useEffect(() => {
+  return () => {
+    if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
+  };
+}, []);
+
+

Why 300ms Debounce?

+
    +
  • Performance: Prevents API call on every keystroke
  • +
  • User Experience: Long enough to avoid lag, short enough to feel responsive
  • +
  • API Load: Reduces backend database queries
  • +
  • Reset pagination: Search resets to page 1 (user expects to see first results)
  • +
+

useCallback Optimization

+
const loadCuts = useCallback(async () => {
+  setLoading(true);
+  try {
+    const params: Record<string, unknown> = {
+      page: pagination.current,
+      limit: pagination.pageSize,
+    };
+
+    if (search) params.search = search;
+
+    const { data } = await api.get<{
+      data: Cut[];
+      pagination: { total: number };
+    }>('/cuts', { params });
+
+    setCuts(data.data);
+    setPagination((prev) => ({
+      ...prev,
+      total: data.pagination.total,
+    }));
+  } catch (error) {
+    message.error('Failed to load cuts');
+  } finally {
+    setLoading(false);
+  }
+}, [pagination.current, pagination.pageSize, search]);
+
+useEffect(() => {
+  if (activeTab === 'table') {
+    loadCuts();
+  }
+}, [activeTab, loadCuts]);
+
+

Conditional Loading:

+

Cuts only load when Table tab is active. Map view uses separate data fetching in CutEditorMap component.

+

API Integration

+

Endpoints Used

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointPurposeAuth
GET/api/cutsList cuts (paginated, filtered)Required
GET/api/cuts/:idGet single cut with geometryRequired
POST/api/cutsCreate new cutRequired
PUT/api/cuts/:idUpdate cut metadataRequired
DELETE/api/cuts/:idDelete cutRequired
POST/api/cuts/importImport cuts from GeoJSONRequired
GET/api/cuts/:id/exportExport cut to GeoJSONRequired
+ +

Request:

+
const params: Record<string, unknown> = {
+  page: 1,
+  limit: 10,
+  search: 'Downtown',  // Optional: search query
+};
+
+const { data } = await api.get<{
+  data: Cut[];
+  pagination: { total: number; page: number; limit: number };
+}>('/cuts', { params });
+
+

Query Parameters: +- page (number, required): Page number (1-indexed) +- limit (number, required): Items per page (10, 25, 50, or 100) +- search (string, optional): Search query (matches name, description)

+

Response (200 OK):

+
{
+  "data": [
+    {
+      "id": "cut_abc123",
+      "name": "Downtown Core",
+      "description": "High-density residential area with apartment buildings",
+      "color": "#3498db",
+      "geometry": {
+        "type": "Polygon",
+        "coordinates": [
+          [
+            [-75.69602, 45.42153],
+            [-75.69102, 45.42153],
+            [-75.69102, 45.41653],
+            [-75.69602, 45.41653],
+            [-75.69602, 45.42153]
+          ]
+        ]
+      },
+      "locationCount": 47,
+      "createdAt": "2026-01-15T10:30:00.000Z",
+      "updatedAt": "2026-01-15T10:30:00.000Z"
+    },
+    {
+      "id": "cut_def456",
+      "name": "Riverside District",
+      "description": null,
+      "color": "#e74c3c",
+      "geometry": {
+        "type": "Polygon",
+        "coordinates": [
+          [
+            [-75.70602, 45.43153],
+            [-75.70102, 45.43153],
+            [-75.70102, 45.42653],
+            [-75.70602, 45.42653],
+            [-75.70602, 45.43153]
+          ]
+        ]
+      },
+      "locationCount": 32,
+      "createdAt": "2026-01-16T14:20:00.000Z",
+      "updatedAt": "2026-01-16T14:20:00.000Z"
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 10,
+    "total": 12
+  }
+}
+
+

Response Fields:

+
    +
  • id (string): Unique cut identifier (prefixed with "cut_")
  • +
  • name (string): Cut name
  • +
  • description (string | null): Optional description
  • +
  • color (string): Hex color code (e.g., "#3498db")
  • +
  • geometry (GeoJSON Polygon): Polygon boundary (GeoJSON format)
  • +
  • locationCount (number): Number of locations within polygon (calculated field, not stored)
  • +
  • createdAt (ISO 8601): Creation timestamp
  • +
  • updatedAt (ISO 8601): Last update timestamp
  • +
+

Create Cut

+

Request:

+
const cutData = {
+  name: 'Downtown Core',
+  description: 'High-density residential area',
+  color: '#3498db',
+  geometry: {
+    type: 'Polygon',
+    coordinates: [
+      [
+        [-75.69602, 45.42153],
+        [-75.69102, 45.42153],
+        [-75.69102, 45.41653],
+        [-75.69602, 45.41653],
+        [-75.69602, 45.42153]  // Closed polygon (first point = last point)
+      ]
+    ]
+  }
+};
+
+const { data } = await api.post<{
+  message: string;
+  cut: Cut;
+  locationCount: number;
+}>('/cuts', cutData);
+
+

Request Body Schema:

+
{
+  name: string;           // Required, min 1 char, max 255 chars
+  description?: string;   // Optional, max 1000 chars
+  color: string;          // Required, hex color code (e.g., "#3498db")
+  geometry: {             // Required, GeoJSON Polygon
+    type: 'Polygon';
+    coordinates: number[][][];  // [[[lng, lat], [lng, lat], ...]]
+  };
+}
+
+

Validation Rules:

+
    +
  • Name: Required, 1-255 characters
  • +
  • Description: Optional, max 1000 characters
  • +
  • Color: Required, must be valid hex color (#RRGGBB format)
  • +
  • Geometry: Required, must be valid GeoJSON Polygon
  • +
  • Minimum 3 vertices (4 coordinates including closing point)
  • +
  • Closing point must match first point (polygon must be closed)
  • +
  • No self-intersections (validated by backend)
  • +
  • Coordinates in [longitude, latitude] order (GeoJSON standard)
  • +
+

Response (201 Created):

+
{
+  "message": "Cut created successfully with 47 locations",
+  "cut": {
+    "id": "cut_abc123",
+    "name": "Downtown Core",
+    "description": "High-density residential area",
+    "color": "#3498db",
+    "geometry": {
+      "type": "Polygon",
+      "coordinates": [[[...]]
+    },
+    "locationCount": 47,
+    "createdAt": "2026-01-15T10:30:00.000Z",
+    "updatedAt": "2026-01-15T10:30:00.000Z"
+  },
+  "locationCount": 47
+}
+
+

Backend Workflow:

+
// 1. Validate polygon geometry
+const isValidPolygon = validatePolygonGeometry(geometry);
+if (!isValidPolygon) {
+  throw new Error('Invalid polygon geometry (self-intersecting or unclosed)');
+}
+
+// 2. Create Cut record
+const cut = await prisma.cut.create({
+  data: {
+    name,
+    description,
+    color,
+    geometry: geometry as unknown as Prisma.InputJsonValue,
+  },
+});
+
+// 3. Find locations within polygon (point-in-polygon algorithm)
+const allLocations = await prisma.location.findMany({
+  where: { deletedAt: null },
+});
+
+const locationsInCut = allLocations.filter((location) => {
+  if (!location.latitude || !location.longitude) return false;
+  return isPointInPolygon(
+    [location.longitude, location.latitude],
+    geometry.coordinates[0]
+  );
+});
+
+// 4. Assign locations to cut
+await prisma.location.updateMany({
+  where: {
+    id: { in: locationsInCut.map((l) => l.id) },
+  },
+  data: { cutId: cut.id },
+});
+
+// 5. Return cut with location count
+return {
+  message: `Cut created successfully with ${locationsInCut.length} locations`,
+  cut,
+  locationCount: locationsInCut.length,
+};
+
+

Update Cut Metadata

+

Request:

+
const cutId = 'cut_abc123';
+const updates = {
+  name: 'Downtown Core (Updated)',
+  description: 'Updated description',
+  color: '#2ecc71',  // New color
+};
+
+const { data } = await api.put<Cut>(`/cuts/${cutId}`, updates);
+
+

Request Body Schema:

+
{
+  name?: string;           // Optional, min 1 char, max 255 chars
+  description?: string;    // Optional, max 1000 chars
+  color?: string;          // Optional, hex color code
+  // Note: geometry cannot be updated (must delete and recreate)
+}
+
+

Response (200 OK):

+
{
+  "id": "cut_abc123",
+  "name": "Downtown Core (Updated)",
+  "description": "Updated description",
+  "color": "#2ecc71",
+  "geometry": {
+    "type": "Polygon",
+    "coordinates": [[[...]]]
+  },
+  "locationCount": 47,
+  "createdAt": "2026-01-15T10:30:00.000Z",
+  "updatedAt": "2026-01-15T12:45:00.000Z"
+}
+
+

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).

+

Delete Cut

+

Request:

+
const cutId = 'cut_abc123';
+await api.delete(`/cuts/${cutId}`);
+
+

URL Parameter: +- id (string): Cut ID to delete

+

Response (200 OK):

+
{
+  "message": "Cut deleted successfully",
+  "unassignedLocations": 47
+}
+
+

Response Fields: +- message (string): Confirmation message +- unassignedLocations (number): Number of locations that were unassigned from cut

+

Backend Workflow:

+
// 1. Delete Cut record
+await prisma.cut.delete({
+  where: { id: cutId },
+});
+
+// 2. Unassign locations (set cutId = null)
+const unassignedCount = await prisma.location.updateMany({
+  where: { cutId },
+  data: { cutId: null },
+});
+
+// 3. Delete associated shifts (cascade delete)
+await prisma.shift.deleteMany({
+  where: { cutId },
+});
+
+return {
+  message: 'Cut deleted successfully',
+  unassignedLocations: unassignedCount.count,
+};
+
+

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)

+

Import Cuts from GeoJSON

+

Request:

+
const formData = new FormData();
+formData.append('file', geoJsonFile);  // File object from <input type="file">
+
+const { data } = await api.post<{
+  message: string;
+  importedCuts: number;
+  totalLocations: number;
+}>('/cuts/import', formData, {
+  headers: { 'Content-Type': 'multipart/form-data' },
+});
+
+

Request Body: +- file (File): GeoJSON file (FeatureCollection with Polygon features)

+

Expected GeoJSON Format:

+
{
+  "type": "FeatureCollection",
+  "features": [
+    {
+      "type": "Feature",
+      "geometry": {
+        "type": "Polygon",
+        "coordinates": [
+          [
+            [-75.69602, 45.42153],
+            [-75.69102, 45.42153],
+            [-75.69102, 45.41653],
+            [-75.69602, 45.41653],
+            [-75.69602, 45.42153]
+          ]
+        ]
+      },
+      "properties": {
+        "name": "Downtown Core",
+        "description": "High-density residential area",
+        "color": "#3498db"
+      }
+    },
+    {
+      "type": "Feature",
+      "geometry": {
+        "type": "Polygon",
+        "coordinates": [[...]]
+      },
+      "properties": {
+        "name": "Riverside District",
+        "description": null,
+        "color": "#e74c3c"
+      }
+    }
+  ]
+}
+
+

Response (200 OK):

+
{
+  "message": "Imported 2 cuts with 79 total locations",
+  "importedCuts": 2,
+  "totalLocations": 79,
+  "details": [
+    {
+      "cutId": "cut_abc123",
+      "name": "Downtown Core",
+      "locationCount": 47
+    },
+    {
+      "cutId": "cut_def456",
+      "name": "Riverside District",
+      "locationCount": 32
+    }
+  ]
+}
+
+

Error Response (400 Bad Request) - Invalid GeoJSON:

+
{
+  "error": "Validation Error",
+  "message": "Invalid GeoJSON format. Expected FeatureCollection with Polygon features."
+}
+
+

Backend Workflow:

+
// 1. Parse GeoJSON file
+const geoJson = JSON.parse(fileContent);
+if (geoJson.type !== 'FeatureCollection') {
+  throw new Error('Expected GeoJSON FeatureCollection');
+}
+
+// 2. Validate features
+const polygonFeatures = geoJson.features.filter(
+  (f) => f.geometry.type === 'Polygon'
+);
+if (polygonFeatures.length === 0) {
+  throw new Error('No Polygon features found in GeoJSON');
+}
+
+// 3. Import each feature as a Cut
+const importResults = [];
+for (const feature of polygonFeatures) {
+  const cut = await prisma.cut.create({
+    data: {
+      name: feature.properties.name || 'Untitled Cut',
+      description: feature.properties.description || null,
+      color: feature.properties.color || '#3498db',
+      geometry: feature.geometry as unknown as Prisma.InputJsonValue,
+    },
+  });
+
+  // 4. Assign locations to cut
+  const locationCount = await assignLocationsToCut(cut.id, feature.geometry);
+
+  importResults.push({
+    cutId: cut.id,
+    name: cut.name,
+    locationCount,
+  });
+}
+
+// 5. Return summary
+return {
+  message: `Imported ${importResults.length} cuts with ${totalLocations} total locations`,
+  importedCuts: importResults.length,
+  totalLocations,
+  details: importResults,
+};
+
+

Export Cut to GeoJSON

+

Request:

+
const cutId = 'cut_abc123';
+const { data } = await api.get<GeoJSON.Feature>(`/cuts/${cutId}/export`);
+
+// Convert to JSON string and download
+const blob = new Blob([JSON.stringify(data, null, 2)], {
+  type: 'application/geo+json',
+});
+const url = URL.createObjectURL(blob);
+const a = document.createElement('a');
+a.href = url;
+a.download = `cut-${data.properties.name}-${cutId}.geojson`;
+a.click();
+URL.revokeObjectURL(url);
+
+

Response (200 OK):

+
{
+  "type": "Feature",
+  "geometry": {
+    "type": "Polygon",
+    "coordinates": [
+      [
+        [-75.69602, 45.42153],
+        [-75.69102, 45.42153],
+        [-75.69102, 45.41653],
+        [-75.69602, 45.41653],
+        [-75.69602, 45.42153]
+      ]
+    ]
+  },
+  "properties": {
+    "id": "cut_abc123",
+    "name": "Downtown Core",
+    "description": "High-density residential area",
+    "color": "#3498db",
+    "locationCount": 47,
+    "createdAt": "2026-01-15T10:30:00.000Z",
+    "updatedAt": "2026-01-15T10:30:00.000Z"
+  }
+}
+
+

GeoJSON Feature Structure: +- type: "Feature" (GeoJSON standard) +- geometry: Polygon geometry with coordinates +- properties: Cut metadata including location count, timestamps

+

Code Examples

+

Complete Cut Creation Flow (Map View)

+
const handleSaveCut = async (polygon: LatLng[], formValues: { name: string; description?: string; color: string }) => {
+  try {
+    // 1. Convert Leaflet LatLng array to GeoJSON coordinates
+    const coordinates = polygon.map((point) => [point.lng, point.lat]);
+
+    // 2. Close polygon (first point = last point)
+    if (
+      coordinates[0][0] !== coordinates[coordinates.length - 1][0] ||
+      coordinates[0][1] !== coordinates[coordinates.length - 1][1]
+    ) {
+      coordinates.push(coordinates[0]);
+    }
+
+    // 3. Create GeoJSON geometry
+    const geometry: GeoJSON.Polygon = {
+      type: 'Polygon',
+      coordinates: [coordinates],
+    };
+
+    // 4. Validate polygon (minimum 3 vertices)
+    if (coordinates.length < 4) {  // 4 including closing point
+      message.error('Polygon must have at least 3 vertices');
+      return;
+    }
+
+    // 5. Create cut via API
+    const { data } = await api.post<{
+      message: string;
+      cut: Cut;
+      locationCount: number;
+    }>('/cuts', {
+      name: formValues.name,
+      description: formValues.description || null,
+      color: formValues.color,
+      geometry,
+    });
+
+    message.success(data.message);
+
+    // 6. Refresh cuts on map
+    await loadCuts();
+
+    // 7. Reset drawing mode
+    setDrawingMode(false);
+  } catch (error) {
+    if (axios.isAxiosError(error) && error.response?.status === 400) {
+      message.error('Invalid polygon geometry. Ensure polygon is closed and does not self-intersect.');
+    } else {
+      message.error('Failed to create cut');
+    }
+  }
+};
+
+

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

+

GeoJSON Import Flow

+
const handleImportGeoJSON = async (file: File) => {
+  setImporting(true);
+  try {
+    // 1. Create FormData
+    const formData = new FormData();
+    formData.append('file', file);
+
+    // 2. Upload to backend
+    const { data } = await api.post<{
+      message: string;
+      importedCuts: number;
+      totalLocations: number;
+      details: Array<{ cutId: string; name: string; locationCount: number }>;
+    }>('/cuts/import', formData, {
+      headers: { 'Content-Type': 'multipart/form-data' },
+    });
+
+    // 3. Show detailed success message
+    message.success(data.message);
+
+    // 4. Log import details
+    console.log('Import details:', data.details);
+
+    // 5. Refresh table
+    await loadCuts();
+  } catch (error) {
+    if (axios.isAxiosError(error) && error.response?.status === 400) {
+      message.error('Invalid GeoJSON format. Expected FeatureCollection with Polygon features.');
+    } else {
+      message.error('Failed to import GeoJSON');
+    }
+  } finally {
+    setImporting(false);
+  }
+};
+
+

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)

+

GeoJSON Export Flow

+
const handleExportGeoJSON = async (cut: Cut) => {
+  try {
+    // 1. Fetch cut with geometry
+    const { data } = await api.get<GeoJSON.Feature>(`/cuts/${cut.id}/export`);
+
+    // 2. Convert to JSON string (pretty-printed)
+    const geoJsonString = JSON.stringify(data, null, 2);
+
+    // 3. Create Blob
+    const blob = new Blob([geoJsonString], {
+      type: 'application/geo+json',
+    });
+
+    // 4. Create download link
+    const url = URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = `cut-${cut.name.replace(/\s+/g, '-').toLowerCase()}-${cut.id}.geojson`;
+
+    // 5. Trigger download
+    document.body.appendChild(a);
+    a.click();
+
+    // 6. Cleanup
+    document.body.removeChild(a);
+    URL.revokeObjectURL(url);
+
+    message.success(`Exported "${cut.name}" to GeoJSON`);
+  } catch (error) {
+    message.error('Failed to export GeoJSON');
+  }
+};
+
+

File Naming: +- Pattern: cut-{name}-{id}.geojson +- Example: cut-downtown-core-cut_abc123.geojson +- Spaces in name replaced with hyphens +- Lowercase for consistency

+

Delete with Cascade Warning

+
const handleDeleteConfirm = (cut: Cut) => {
+  Modal.confirm({
+    title: 'Delete Cut',
+    content: (
+      <div>
+        <p>Are you sure you want to delete the cut <strong>"{cut.name}"</strong>?</p>
+        <p style={{ marginTop: 8, color: '#ff4d4f' }}>
+          <WarningOutlined /> This will:
+        </p>
+        <ul style={{ marginTop: 4, paddingLeft: 20 }}>
+          <li>Unassign all {cut.locationCount} locations from this cut</li>
+          <li>Delete all associated shifts</li>
+          <li>Close any active canvass sessions in this cut</li>
+        </ul>
+        <p style={{ marginTop: 8 }}>Locations will NOT be deleted (only unassigned).</p>
+      </div>
+    ),
+    okText: 'Delete',
+    okType: 'danger',
+    cancelText: 'Cancel',
+    width: 520,
+    onOk: async () => {
+      try {
+        const { data } = await api.delete<{
+          message: string;
+          unassignedLocations: number;
+        }>(`/cuts/${cut.id}`);
+
+        message.success(`Cut deleted successfully. ${data.unassignedLocations} locations unassigned.`);
+
+        // Refresh both table and map views
+        await loadCuts();
+      } catch (error) {
+        message.error('Failed to delete cut');
+      }
+    },
+  });
+};
+
+

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

+

Performance Considerations

+

Lazy Map Loading

+

Map view only loads when tab is active:

+
useEffect(() => {
+  if (activeTab === 'map') {
+    // Load cuts for map visualization
+    loadCutsForMap();
+  }
+}, [activeTab]);
+
+

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

+

Server-Side Pagination

+

Table uses server-side pagination to handle large cut datasets:

+
const { data } = await api.get('/cuts', {
+  params: {
+    page: pagination.current,
+    limit: pagination.pageSize,
+    search,
+  },
+});
+
+

Scalability: +- Works efficiently with 10 to 1,000+ cuts +- Only fetches current page (10-100 items) +- Backend applies search filter before pagination

+

Debounced Search (300ms)

+

Prevents API spam during typing:

+
searchTimerRef.current = setTimeout(() => {
+  setSearch(value);
+}, 300);
+
+

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

+

Polygon Simplification (Future Enhancement)

+

For cuts with 1,000+ vertices (very detailed polygons), consider simplifying geometry:

+
// Using Turf.js library
+import { simplify } from '@turf/simplify';
+
+const simplifiedPolygon = simplify(polygon, {
+  tolerance: 0.0001,  // Degrees (~10 meters)
+  highQuality: false,
+});
+
+

Benefits: +- Reduces GeoJSON payload size +- Faster map rendering (fewer vertices to draw) +- Maintains visual accuracy for canvassing purposes

+

Responsive Design

+

Mobile Table Layout

+

Table adapts to mobile viewports:

+
{
+  title: 'Description',
+  dataIndex: 'description',
+  responsive: ['md'],  // Hidden on mobile
+  ellipsis: true,
+  render: (text) => text || '—',
+},
+{
+  title: 'Location Count',
+  dataIndex: 'locationCount',
+  responsive: ['sm'],  // Visible on tablet+
+  render: (count) => <Tag>{count} locations</Tag>,
+}
+
+

Mobile Columns (xs): +- Name (visible) +- Color (visible) +- Actions (visible, wrapped)

+

Tablet Columns (sm+): +- Name + Color + Location Count + Actions

+

Desktop Columns (md+): +- Name + Description + Color + Location Count + Created At + Actions

+

Full-Screen Map View

+

Map view uses full available height:

+
<div style={{ height: 'calc(100vh - 200px)', width: '100%' }}>
+  <CutEditorMap
+    cuts={cuts}
+    onSaveCut={handleSaveCut}
+  />
+</div>
+
+

Calculation: +- 100vh: Full viewport height +- -200px: Subtract header (64px) + page title (48px) + segmented control (48px) + margins (40px) +- Result: Map fills remaining vertical space

+

Accessibility

+

Keyboard Navigation

+

All interactive elements are keyboard-accessible:

+

Segmented Control: +- Tab: Focus on segmented control +- Arrow Keys: Switch between Table and Map tabs +- Enter/Space: Activate selected tab

+

Table Navigation: +- Tab: Move between action buttons (Edit, View, Export, Delete) +- Enter/Space: Activate focused button +- Arrow Keys: Navigate table rows

+

Map Drawing: +- Escape: Cancel drawing mode +- Enter: Complete polygon (after placing 3+ vertices) +- Backspace: Remove last vertex

+

Screen Reader Support

+

All elements have proper ARIA labels:

+

Action Buttons: +

<Button
+  icon={<EditOutlined />}
+  onClick={() => handleEdit(cut)}
+  aria-label={`Edit cut ${cut.name}`}
+>
+  Edit
+</Button>
+
+<Button
+  icon={<EnvironmentOutlined />}
+  onClick={() => handleViewLocations(cut)}
+  aria-label={`View ${cut.locationCount} locations in cut ${cut.name}`}
+>
+  View Locations
+</Button>
+

+

Color Preview: +

<div
+  style={{ backgroundColor: cut.color }}
+  aria-label={`Cut color: ${cut.color}`}
+  role="img"
+/>
+

+

Focus Indicators

+

All interactive elements have visible focus states:

+

Buttons: +

.ant-btn:focus {
+  outline: 2px solid #1890ff;
+  outline-offset: 2px;
+}
+

+

Segmented Control: +

.ant-segmented-item:focus {
+  outline: 2px solid #1890ff;
+  outline-offset: 2px;
+}
+

+

Troubleshooting

+

Polygon Won't Close

+

Problem: Drawing polygon on map, clicked 5+ vertices, but polygon won't close automatically.

+

Diagnosis:

+

Check if first vertex is being clicked:

+
// Close detection radius: 10 pixels
+const distanceToFirst = Math.sqrt(
+  Math.pow(clickX - firstVertexX, 2) + Math.pow(clickY - firstVertexY, 2)
+);
+
+if (distanceToFirst <= 10) {
+  // Close polygon
+}
+
+

Possible Causes:

+
    +
  1. Click not close enough to first vertex:
  2. +
  3. Must click within 10 pixels of first vertex marker
  4. +
  5. +

    First vertex marker may be small or obscured

    +
  6. +
  7. +

    Double-click required:

    +
  8. +
  9. Some users expect double-click to close polygon
  10. +
  11. +

    Single-click on first vertex should work but may feel unintuitive

    +
  12. +
  13. +

    Drawing mode not active:

    +
  14. +
  15. Forgot to click "Draw New Cut" button first
  16. +
  17. Drawing mode indicator not visible
  18. +
+

Solution:

+
    +
  1. For close detection:
  2. +
  3. Click directly on the blue circle marker (first vertex)
  4. +
  5. Or double-click anywhere to force close polygon
  6. +
  7. +

    Ensure at least 3 vertices placed before closing

    +
  8. +
  9. +

    Alternative closing methods:

    +
  10. +
  11. Press Enter key to close polygon (keyboard shortcut)
  12. +
  13. +

    Right-click and select "Close Polygon" from context menu (if implemented)

    +
  14. +
  15. +

    Visual feedback:

    +
  16. +
  17. First vertex marker should pulse or highlight when hovering nearby (indicates close detection active)
  18. +
  19. Drawing mode indicator should show "Click first vertex to close" text
  20. +
+
+

Import GeoJSON Fails

+

Problem: Click "Import GeoJSON", select file, get error: "Invalid GeoJSON format".

+

Diagnosis:

+

Check GeoJSON structure:

+
// Valid GeoJSON FeatureCollection
+{
+  "type": "FeatureCollection",
+  "features": [...]
+}
+
+// Invalid: Single Feature (missing FeatureCollection wrapper)
+{
+  "type": "Feature",
+  "geometry": {...}
+}
+
+

Possible Causes:

+
    +
  1. Single Feature instead of FeatureCollection:
  2. +
  3. GeoJSON file contains single Feature, not FeatureCollection
  4. +
  5. +

    Backend expects FeatureCollection with multiple features

    +
  6. +
  7. +

    Non-Polygon geometries:

    +
  8. +
  9. GeoJSON contains Point, LineString, or MultiPolygon features
  10. +
  11. +

    Backend only supports Polygon geometry type

    +
  12. +
  13. +

    Missing required properties:

    +
  14. +
  15. Feature properties don't include "name" field
  16. +
  17. +

    Backend requires name to create cut

    +
  18. +
  19. +

    Invalid JSON syntax:

    +
  20. +
  21. Trailing commas, missing quotes, incorrect brackets
  22. +
  23. JSON parser cannot read file
  24. +
+

Solution:

+
    +
  1. For single Feature:
  2. +
  3. +

    Wrap in FeatureCollection: +

    {
    +  "type": "FeatureCollection",
    +  "features": [
    +    {
    +      "type": "Feature",
    +      "geometry": {...},
    +      "properties": {...}
    +    }
    +  ]
    +}
    +

    +
  4. +
  5. +

    For non-Polygon geometries:

    +
  6. +
  7. Convert Point/LineString to Polygon using GIS software (QGIS, ArcGIS)
  8. +
  9. +

    Or manually edit GeoJSON to create Polygon boundaries

    +
  10. +
  11. +

    For missing properties:

    +
  12. +
  13. +

    Add "name" property to each Feature: +

    "properties": {
    +  "name": "Untitled Cut",
    +  "description": "",
    +  "color": "#3498db"
    +}
    +

    +
  14. +
  15. +

    For invalid JSON:

    +
  16. +
  17. Validate JSON syntax using online tool (jsonlint.com)
  18. +
  19. Fix any syntax errors before importing
  20. +
+
+

Locations Not Appearing in Cut

+

Problem: Create cut polygon, success message says "Cut created successfully with 0 locations", but there should be locations within boundary.

+

Diagnosis:

+

Check location coordinates vs. polygon coordinates:

+
// Example: Location at [45.42, -75.69] (lat, lng)
+// Polygon coordinates: [[-75.69, 45.42], ...] (lng, lat)
+
+// Are coordinates in correct order?
+// Is location actually within polygon boundary?
+
+

Possible Causes:

+
    +
  1. Coordinate order confusion:
  2. +
  3. Location stored as [lat, lng] but polygon uses [lng, lat] (GeoJSON standard)
  4. +
  5. +

    Point-in-polygon algorithm receives wrong coordinate order

    +
  6. +
  7. +

    Locations not geocoded:

    +
  8. +
  9. Locations have null latitude/longitude values
  10. +
  11. +

    Cannot check if point is in polygon without coordinates

    +
  12. +
  13. +

    Polygon too small:

    +
  14. +
  15. Drew very small polygon that doesn't actually contain any location markers
  16. +
  17. +

    Zoom in on map to verify polygon size vs. location density

    +
  18. +
  19. +

    Precision issues:

    +
  20. +
  21. Location coordinates have low precision (e.g., rounded to 2 decimal places)
  22. +
  23. Polygon boundary is at edge of location, but point-in-polygon check fails due to rounding
  24. +
+

Solution:

+
    +
  1. For coordinate order:
  2. +
  3. +

    Verify backend point-in-polygon function uses correct order: +

    isPointInPolygon(
    +  [location.longitude, location.latitude],  // [lng, lat] order
    +  polygon.coordinates[0]
    +);
    +

    +
  4. +
  5. +

    For missing coordinates:

    +
  6. +
  7. Run geocoding on locations before assigning to cut
  8. +
  9. +

    Navigate to LocationsPage, bulk geocode locations, then create cut

    +
  10. +
  11. +

    For small polygons:

    +
  12. +
  13. Zoom in on map to see location markers
  14. +
  15. Draw larger polygon that clearly encompasses location clusters
  16. +
  17. +

    Use Location Count filter on LocationsPage to verify locations exist in area

    +
  18. +
  19. +

    For precision issues:

    +
  20. +
  21. Use higher precision coordinates (6+ decimal places = ~0.1 meter accuracy)
  22. +
  23. Slightly expand polygon boundary to account for rounding errors
  24. +
+
+

Delete Cut Fails with Constraint Error

+

Problem: Click "Delete" button, confirm deletion, get error: "Failed to delete cut. Constraint violation."

+

Diagnosis:

+

Check database foreign key constraints:

+
-- Check for references to cut
+SELECT COUNT(*) FROM "Shift" WHERE "cutId" = 'cut_abc123';
+SELECT COUNT(*) FROM "CanvassSession" WHERE "cutId" = 'cut_abc123';
+
+

Possible Causes:

+
    +
  1. Active shifts:
  2. +
  3. Shift records reference this cutId
  4. +
  5. +

    Foreign key constraint prevents deletion

    +
  6. +
  7. +

    Active canvass sessions:

    +
  8. +
  9. CanvassSession records reference this cutId
  10. +
  11. +

    Sessions must be closed/deleted before cut can be deleted

    +
  12. +
  13. +

    Database migration issue:

    +
  14. +
  15. Foreign key constraints not set to CASCADE
  16. +
  17. Deletion of parent record (Cut) should cascade to child records (Shift, CanvassSession)
  18. +
+

Solution:

+
    +
  1. For active shifts:
  2. +
  3. Navigate to /app/map/shifts
  4. +
  5. Filter by cut name
  6. +
  7. Delete all shifts in this cut
  8. +
  9. +

    Return to CutsPage and retry delete

    +
  10. +
  11. +

    For active sessions:

    +
  12. +
  13. Navigate to /app/canvass/dashboard
  14. +
  15. Find active sessions in this cut
  16. +
  17. Close or abandon sessions
  18. +
  19. +

    Return to CutsPage and retry delete

    +
  20. +
  21. +

    For migration issue (developer fix):

    +
  22. +
  23. Update Prisma schema to add cascade delete: +
    model Shift {
    +  cutId String?
    +  cut   Cut?    @relation(fields: [cutId], references: [id], onDelete: Cascade)
    +}
    +
    +model CanvassSession {
    +  cutId String?
    +  cut   Cut?    @relation(fields: [cutId], references: [id], onDelete: Cascade)
    +}
    +
  24. +
  25. Run migration: npx prisma migrate dev
  26. +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/dashboard-page/index.html b/mkdocs/site/v2/frontend/pages/admin/dashboard-page/index.html new file mode 100644 index 00000000..8c2d6be1 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/dashboard-page/index.html @@ -0,0 +1,6736 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Dashboard - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

DashboardPage

+

Overview

+

The DashboardPage serves as the landing page for authenticated admin users after login. It provides a high-level overview of key metrics across all modules (Users, Campaigns, Locations, Emails) using statistic cards. Currently displays placeholder values with a notice that full analytics are coming soon.

+

Route: /app +Component: admin/src/pages/DashboardPage.tsx (67 lines) +Auth Required: Yes (any authenticated admin role) +Layout: AppLayout

+

Screenshot

+

[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.]

+

Features

+
    +
  • Personalized greeting — Shows "Welcome, [User Name]" if user has a name set
  • +
  • Quick metrics overview — 4 statistic cards with icons:
  • +
  • Total Users (TeamOutlined icon)
  • +
  • Active Campaigns (SendOutlined icon)
  • +
  • Map Locations (EnvironmentOutlined icon)
  • +
  • Emails Sent (MailOutlined icon)
  • +
  • Responsive grid layout — Cards adapt to screen size (xs: full width, sm: 2 columns, lg: 4 columns)
  • +
  • Placeholder state — Currently shows "--" for all metrics with info alert
  • +
  • Future-ready — Structure prepared for real-time statistics integration
  • +
+

User Workflow

+

Viewing Dashboard (Current State)

+
    +
  1. User logs in and is redirected to /app
  2. +
  3. Dashboard loads with personalized greeting
  4. +
  5. Four metric cards display with placeholder values ("--")
  6. +
  7. Info alert explains that analytics are coming soon
  8. +
+

Planned Workflow (Future Enhancement)

+
    +
  1. User logs in and is redirected to /app
  2. +
  3. Dashboard fetches real-time statistics from API
  4. +
  5. Metric cards populate with actual values
  6. +
  7. Charts and graphs display below cards (planned)
  8. +
  9. Recent activity feed shows latest actions (planned)
  10. +
+

Component Breakdown

+

Ant Design Components Used

+
    +
  • Typography.Title — Page title with greeting
  • +
  • Row, Col — Responsive grid layout
  • +
  • Row with gutter: [16, 16] (horizontal, vertical)
  • +
  • Col breakpoints: xs={24} sm={12} lg={6} (responsive card sizing)
  • +
  • Card — Container for each statistic
  • +
  • Statistic — Numeric display with title, value, prefix icon
  • +
  • Alert — Info message about future analytics
  • +
  • Icons — Ant Design icons for visual clarity
  • +
  • TeamOutlined (users)
  • +
  • SendOutlined (campaigns)
  • +
  • EnvironmentOutlined (locations)
  • +
  • MailOutlined (emails)
  • +
+

Component Structure

+
<>
+  <Title level={4}>Welcome{user?.name ? `, ${user.name}` : ''}</Title>
+
+  <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
+    <Col xs={24} sm={12} lg={6}>
+      <Card>
+        <Statistic title="Total Users" value="--" prefix={<TeamOutlined />} />
+      </Card>
+    </Col>
+    {/* 3 more similar cards */}
+  </Row>
+
+  <Alert
+    message="Dashboard analytics coming soon"
+    description="Statistics and charts will be populated as additional modules are implemented."
+    type="info"
+    showIcon
+  />
+</>
+
+

Layout Breakpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Screen SizeColumnsCards Per Row
xs (< 576px)24/241
sm (≥ 576px)12/242
lg (≥ 992px)6/244
+

State Management

+

Zustand Stores Used

+
    +
  • auth.store — Accesses current user data
  • +
  • user — User object with name field for personalized greeting
  • +
+
import { useAuthStore } from '@/stores/auth.store';
+
+const { user } = useAuthStore();
+
+

Local State

+

None — Component is stateless, reads user from auth store only.

+

API Integration

+

Current Implementation

+

No API calls — displays placeholder values.

+

Planned API Integration

+

GET /api/dashboard/stats — Fetch dashboard statistics

+

Planned request: +

const { data } = await api.get('/api/dashboard/stats');
+
+// Expected response:
+{
+  totalUsers: 45,
+  activeUsers: 32,
+  activeCampaigns: 8,
+  totalCampaigns: 12,
+  mapLocations: 1250,
+  emailsSent: 3420,
+  emailsQueued: 15,
+  queuedJobs: 3,
+  recentActivity: [...]
+}
+

+

Code Example

+

Adding Real Statistics (Future Enhancement)

+
import { useState, useEffect } from 'react';
+import { api } from '@/lib/api';
+
+export default function DashboardPage() {
+  const { user } = useAuthStore();
+  const [stats, setStats] = useState({
+    totalUsers: 0,
+    activeCampaigns: 0,
+    mapLocations: 0,
+    emailsSent: 0,
+  });
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    const fetchStats = async () => {
+      try {
+        const { data } = await api.get('/api/dashboard/stats');
+        setStats(data);
+      } catch (err) {
+        message.error('Failed to load dashboard statistics');
+      } finally {
+        setLoading(false);
+      }
+    };
+
+    fetchStats();
+  }, []);
+
+  return (
+    <>
+      <Title level={4}>Welcome{user?.name ? `, ${user.name}` : ''}</Title>
+
+      <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
+        <Col xs={24} sm={12} lg={6}>
+          <Card>
+            <Statistic
+              title="Total Users"
+              value={stats.totalUsers}
+              loading={loading}
+              prefix={<TeamOutlined />}
+            />
+          </Card>
+        </Col>
+        {/* ... other cards */}
+      </Row>
+    </>
+  );
+}
+
+

Responsive Design

+

Mobile (< 576px)

+
    +
  • Cards stack vertically (full width)
  • +
  • Greeting text wraps naturally
  • +
  • Alert description wraps
  • +
+

Tablet (576px - 992px)

+
    +
  • Cards display 2 per row
  • +
  • Even spacing maintained
  • +
+

Desktop (≥ 992px)

+
    +
  • Cards display 4 per row
  • +
  • Optimal for wide screens
  • +
  • All content visible without scrolling
  • +
+

Accessibility

+
    +
  • Semantic HTML — Proper heading hierarchy (h4 for title)
  • +
  • Icon labels — Statistic titles provide text alternative to icons
  • +
  • Color contrast — Default Ant Design theme ensures WCAG AA compliance
  • +
  • Keyboard navigation — All interactive elements focusable
  • +
+

Performance Considerations

+

Current Performance

+
    +
  • Fast initial render — No API calls, minimal DOM
  • +
  • Small bundle — Only imports necessary Ant Design components
  • +
  • No re-renders — Stateless, no local state changes
  • +
+

Planned Optimizations

+
    +
  • Memoization — Use useMemo for derived stats
  • +
  • Caching — Cache dashboard stats with 5-minute expiry
  • +
  • Skeleton loading — Show loading skeleton during fetch
  • +
+
import { Skeleton } from 'antd';
+
+{loading ? (
+  <Card>
+    <Skeleton.Input active size="small" style={{ width: 100 }} />
+    <Skeleton.Input active size="large" style={{ width: 60, marginTop: 8 }} />
+  </Card>
+) : (
+  <Card>
+    <Statistic title="Total Users" value={stats.totalUsers} />
+  </Card>
+)}
+
+

Future Enhancements

+
    +
  1. Real-time statistics — WebSocket updates for live metrics
  2. +
  3. Charts and graphs — Trend visualizations (Chart.js or Recharts)
  4. +
  5. Recent activity feed — List of latest actions across all modules
  6. +
  7. Quick actions — Buttons for common tasks (Create Campaign, Add User, etc.)
  8. +
  9. Module-specific widgets — Expandable cards with detailed stats
  10. +
  11. Date range filter — View metrics for custom time periods
  12. +
  13. Export dashboard — PDF report generation
  14. +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/data-quality-dashboard-page/index.html b/mkdocs/site/v2/frontend/pages/admin/data-quality-dashboard-page/index.html new file mode 100644 index 00000000..07fef0a8 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/data-quality-dashboard-page/index.html @@ -0,0 +1,7544 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Data Quality Dashboard - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

DataQualityDashboardPage

+

Overview

+

File: admin/src/pages/DataQualityDashboardPage.tsx +Route: /app/map/data-quality +Role Requirements: SUPER_ADMIN, MAP_ADMIN

+

DataQualityDashboardPage is a specialized dashboard for monitoring geocoding data quality across the location database. It displays comprehensive statistics about total locations, geocoding success rates, confidence levels, provider distribution, and building type breakdown. The page features auto-refresh every 30 seconds, responsive grid layout, and color-coded statistics cards for quick visual assessment of data quality.

+

The page provides insights into: +- Total locations in database +- Geocoding status (geocoded vs. ungeocoded) +- Confidence levels (high/medium/low confidence, manual/none) +- Provider distribution (Nominatim, ArcGIS, Photon, Google, Manual, etc.) +- Building types (Single Family, Multi-Unit, Mixed Use, Commercial)

+

Key Features: +- Auto-refresh every 30 seconds (no manual intervention needed) +- Color-coded statistics (green=good, red=warning, gray=neutral) +- Responsive grid layout (1-4 columns depending on screen size) +- Refresh button for manual updates +- Geocoded percentage calculation +- Average confidence score with color thresholds

+

Key Components: +- Ant Design Statistic cards for all metrics +- Row/Col grid layout for responsive design +- Typography for section headers +- Auto-refresh interval with cleanup

+
+

Screenshot

+

[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 ≥85%, yellow 60-84%, red <60%); 2) Geocoding Confidence row with High Confidence (green, ≥85%), 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.]

+
+

Features

+

Core Features

+
    +
  1. Overview Statistics (4 cards)
  2. +
  3. Total Locations: Count of all locations in database (blue)
  4. +
  5. Geocoded: Count + percentage of geocoded locations (green)
  6. +
  7. Ungeocoded: Count of locations without coordinates (red if > 0, gray if 0)
  8. +
  9. +

    Average Confidence: Percentage score with color-coded threshold (green ≥85%, yellow 60-84%, red <60%)

    +
  10. +
  11. +

    Geocoding Confidence Breakdown (4 cards)

    +
  12. +
  13. High Confidence: Count of locations with ≥85% confidence (green)
  14. +
  15. Medium Confidence: Count of locations with 60-84% confidence (yellow)
  16. +
  17. Low Confidence: Count of locations with <60% confidence (red)
  18. +
  19. +

    Manual/None: Count of locations with no confidence score (gray)

    +
  20. +
  21. +

    Provider Distribution (Dynamic cards)

    +
  22. +
  23. One card per geocoding provider (Nominatim, ArcGIS, Photon, Google, Manual, etc.)
  24. +
  25. Capitalized provider names
  26. +
  27. Count of locations geocoded by each provider
  28. +
  29. +

    Dynamic grid layout (adapts to number of providers)

    +
  30. +
  31. +

    Building Type Distribution (4 cards)

    +
  32. +
  33. Single Family: Count of single-family residences (blue)
  34. +
  35. Multi-Unit: Count of multi-unit residential buildings (green)
  36. +
  37. Mixed Use: Count of mixed-use properties (yellow)
  38. +
  39. +

    Commercial: Count of commercial properties (purple)

    +
  40. +
  41. +

    Auto-Refresh

    +
  42. +
  43. Refreshes every 30 seconds automatically
  44. +
  45. Interval set with setInterval, cleaned up on unmount
  46. +
  47. +

    No loading spinner on auto-refresh (seamless updates)

    +
  48. +
  49. +

    Manual Refresh

    +
  50. +
  51. Refresh button in page header
  52. +
  53. Loading state during refresh
  54. +
  55. +

    Fetches latest statistics from API

    +
  56. +
  57. +

    Responsive Grid Layout

    +
  58. +
  59. Desktop (≥768px): 4 columns per row
  60. +
  61. Tablet (≥576px): 2 columns per row
  62. +
  63. Mobile (<576px): 1 column per row
  64. +
  65. +

    Consistent gap (16px horizontal, 16px vertical)

    +
  66. +
  67. +

    Color-Coded Statistics

    +
  68. +
  69. Green (#52c41a): Good/high confidence/geocoded
  70. +
  71. Red (#ff4d4f): Warning/low confidence/ungeocoded
  72. +
  73. Yellow (#faad14): Medium confidence
  74. +
  75. Blue (#1890ff): Neutral/informational
  76. +
  77. Gray (#8c8c8c): Neutral/none
  78. +
  79. Purple (#722ed1): Commercial building type
  80. +
+
+

User Workflow

+

Viewing Data Quality Overview

+
    +
  1. Navigate to page: Admin sidebar → Map → Data Quality
  2. +
  3. Page loads: Initial statistics fetched and displayed
  4. +
  5. Review overview cards:
  6. +
  7. Check total locations count
  8. +
  9. Verify geocoded percentage (aim for > 90%)
  10. +
  11. Check if any ungeocoded locations (red warning)
  12. +
  13. Review average confidence score (aim for ≥85%)
  14. +
+

Interpreting Confidence Levels

+

High Confidence (≥85%): +- Indicates accurate geocoding with precise coordinates +- Green color = good data quality +- Goal: Most locations should be in this category

+

Medium Confidence (60-84%): +- Indicates acceptable geocoding but less precise +- Yellow color = acceptable but could improve +- Consider manual review or re-geocoding

+

Low Confidence (<60%): +- Indicates poor geocoding accuracy +- Red color = data quality issue +- Action: Re-geocode with different provider or manually verify

+

Manual/None: +- Manually entered coordinates or no confidence score +- Gray color = neutral (may be accurate if manually verified)

+

Monitoring Provider Performance

+
    +
  1. Check provider distribution cards:
  2. +
  3. See which providers are most used
  4. +
  5. Identify dominant provider (e.g., Nominatim: 8000, Google: 2000)
  6. +
  7. Correlate with confidence levels:
  8. +
  9. If high confidence count matches dominant provider, good sign
  10. +
  11. If low confidence count high, may indicate provider issues
  12. +
  13. Consider provider switching:
  14. +
  15. Use LocationsPage to re-geocode low-confidence locations with different provider
  16. +
+

Reviewing Building Types

+
    +
  1. Check building type distribution:
  2. +
  3. Single Family: Residential detached homes
  4. +
  5. Multi-Unit: Apartments, condos, duplexes
  6. +
  7. Mixed Use: Residential + commercial combo
  8. +
  9. Commercial: Stores, offices, warehouses
  10. +
  11. Verify data accuracy:
  12. +
  13. Ensure building types match expected distribution for your area
  14. +
  15. Flag anomalies (e.g., 0 single-family homes in suburban area)
  16. +
+

Using Auto-Refresh

+
    +
  1. Leave page open: Auto-refresh updates data every 30 seconds
  2. +
  3. Monitor changes: Watch for new locations being added/geocoded
  4. +
  5. No action needed: Data updates seamlessly without loading spinners
  6. +
+

Manual Refresh

+
    +
  1. Click Refresh button: In page header
  2. +
  3. Loading state: Brief spinner or loading indicator
  4. +
  5. Data updates: Latest statistics fetched from API
  6. +
  7. Use case: Immediate update after bulk geocoding operation
  8. +
+
+

Component Breakdown

+

Overview Statistics Cards

+
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
+  <Col xs={24} sm={12} md={6}>
+    <Card size="small">
+      <Statistic
+        title="Total Locations"
+        value={stats.total}
+        prefix={<EnvironmentOutlined />}
+        valueStyle={{ color: '#1890ff' }}
+      />
+    </Card>
+  </Col>
+  <Col xs={24} sm={12} md={6}>
+    <Card size="small">
+      <Statistic
+        title="Geocoded"
+        value={stats.geocoded}
+        suffix={
+          <Text type="secondary" style={{ fontSize: 12 }}>
+            ({stats.total > 0 ? Math.round((stats.geocoded / stats.total) * 100) : 0}%)
+          </Text>
+        }
+        valueStyle={{ color: '#52c41a' }}
+      />
+    </Card>
+  </Col>
+  <Col xs={24} sm={12} md={6}>
+    <Card size="small">
+      <Statistic
+        title="Ungeocoded"
+        value={stats.ungeocoded}
+        valueStyle={{ color: stats.ungeocoded > 0 ? '#ff4d4f' : '#8c8c8c' }}
+        prefix={stats.ungeocoded > 0 ? <WarningOutlined /> : undefined}
+      />
+    </Card>
+  </Col>
+  <Col xs={24} sm={12} md={6}>
+    <Card size="small">
+      <Statistic
+        title="Average Confidence"
+        value={stats.confidence.average ?? 0}
+        suffix="%"
+        valueStyle={{
+          color:
+            !stats.confidence.average ? '#8c8c8c'
+            : stats.confidence.average >= 85 ? '#52c41a'
+            : stats.confidence.average >= 60 ? '#faad14'
+            : '#ff4d4f',
+        }}
+      />
+    </Card>
+  </Col>
+</Row>
+
+

Responsive Grid: +- xs={24}: Mobile (full width) +- sm={12}: Tablet (2 columns, 50% width each) +- md={6}: Desktop (4 columns, 25% width each)

+

Confidence Level Cards

+
<Row gutter={[12, 12]} style={{ marginBottom: 24 }}>
+  <Col xs={24} sm={12} md={6}>
+    <Card size="small">
+      <Statistic
+        title="High Confidence"
+        value={stats.confidence.high}
+        prefix={<CheckCircleOutlined />}
+        suffix={<Text type="secondary" style={{ fontSize: 12 }}>85%</Text>}
+        valueStyle={{ color: '#52c41a' }}
+      />
+    </Card>
+  </Col>
+  <Col xs={24} sm={12} md={6}>
+    <Card size="small">
+      <Statistic
+        title="Medium Confidence"
+        value={stats.confidence.medium}
+        prefix={<InfoCircleOutlined />}
+        suffix={<Text type="secondary" style={{ fontSize: 12 }}>60-84%</Text>}
+        valueStyle={{ color: '#faad14' }}
+      />
+    </Card>
+  </Col>
+  {/* Low confidence and Manual/None cards... */}
+</Row>
+
+

Provider Distribution Cards

+
<Row gutter={[12, 12]} style={{ marginBottom: 24 }}>
+  {Object.entries(stats.providers).map(([provider, count]) => (
+    <Col xs={24} sm={12} md={6} key={provider}>
+      <Card size="small">
+        <Statistic
+          title={provider.charAt(0).toUpperCase() + provider.slice(1)}
+          value={count}
+          valueStyle={{ fontSize: 18 }}
+        />
+      </Card>
+    </Col>
+  ))}
+</Row>
+
+

Dynamic Grid: Number of cards adapts to number of providers in response.

+

Building Type Cards

+
<Row gutter={[12, 12]}>
+  <Col xs={24} sm={12} md={6}>
+    <Card size="small">
+      <Statistic
+        title="Single Family"
+        value={stats.buildingTypes.SINGLE_FAMILY}
+        valueStyle={{ color: '#1890ff' }}
+      />
+    </Card>
+  </Col>
+  {/* Multi-Unit, Mixed Use, Commercial cards... */}
+</Row>
+
+
+

State Management

+

Local State

+
const [stats, setStats] = useState<LocationStats | null>(null);
+const [loading, setLoading] = useState(true);
+
+

Data Fetching

+
const loadStats = useCallback(async () => {
+  try {
+    const { data } = await api.get<LocationStats>('/map/locations/stats');
+    setStats(data);
+  } catch {
+    message.error('Failed to load data quality stats');
+  } finally {
+    setLoading(false);
+  }
+}, [message]);
+
+

Auto-Refresh Setup

+
useEffect(() => {
+  loadStats();  // Initial load
+  const interval = setInterval(loadStats, 30000);  // Refresh every 30s
+  return () => clearInterval(interval);  // Cleanup on unmount
+}, [loadStats]);
+
+

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)

+
+

API Integration

+

Endpoint Used

+

GET /map/locations/stats - Fetch location statistics +

const { data } = await api.get<LocationStats>('/map/locations/stats');
+

+

Response: +

{
+  "total": 15234,
+  "geocoded": 14980,
+  "ungeocoded": 254,
+  "confidence": {
+    "average": 87.3,
+    "high": 13500,
+    "medium": 1200,
+    "low": 280,
+    "none": 254
+  },
+  "providers": {
+    "nominatim": 8500,
+    "arcgis": 3200,
+    "photon": 1800,
+    "google": 980,
+    "manual": 500
+  },
+  "buildingTypes": {
+    "SINGLE_FAMILY": 10500,
+    "MULTI_UNIT": 3200,
+    "MIXED_USE": 1000,
+    "COMMERCIAL": 534
+  }
+}
+

+
+

Code Examples

+

Percentage Calculation

+
<Text type="secondary" style={{ fontSize: 12 }}>
+  ({stats.total > 0 ? Math.round((stats.geocoded / stats.total) * 100) : 0}%)
+</Text>
+
+

Pattern: Round percentage to nearest integer, handle division by zero.

+

Conditional Color

+
valueStyle={{
+  color:
+    !stats.confidence.average ? '#8c8c8c'
+    : stats.confidence.average >= 85 ? '#52c41a'
+    : stats.confidence.average >= 60 ? '#faad14'
+    : '#ff4d4f',
+}}
+
+

Color Logic: +- No average → Gray +- ≥85% → Green +- 60-84% → Yellow +- <60% → Red

+

Conditional Icon

+
prefix={stats.ungeocoded > 0 ? <WarningOutlined /> : undefined}
+
+

Pattern: Show warning icon only if ungeocoded count > 0.

+

Auto-Refresh Pattern

+
useEffect(() => {
+  loadStats();
+  const interval = setInterval(loadStats, 30000);
+  return () => clearInterval(interval);
+}, [loadStats]);
+
+

Best Practice: Always clean up intervals to prevent memory leaks.

+
+

Performance Considerations

+

Auto-Refresh Cleanup

+
return () => clearInterval(interval);
+
+

Why Important: Without cleanup, interval continues running after component unmounts, causing memory leaks and unnecessary API calls.

+

useCallback for Load Function

+
const loadStats = useCallback(async () => { /* ... */ }, [message]);
+
+

Why: Prevents recreation of load function on every render, essential for stable useEffect dependency.

+

Efficient Percentage Calculation

+
Math.round((stats.geocoded / stats.total) * 100)
+
+

Math.round() is more efficient than .toFixed() for integer percentages.

+
+

Responsive Design

+

Responsive Grid

+
<Col xs={24} sm={12} md={6}>
+
+

Breakpoints: +- Mobile (xs, < 576px): 24/24 = 100% width (1 column) +- Tablet (sm, ≥ 576px): 12/24 = 50% width (2 columns) +- Desktop (md, ≥ 768px): 6/24 = 25% width (4 columns)

+

Padding Adjustment

+
<div style={{ padding: screens.md ? 24 : 16 }}>
+
+

Reduces padding on mobile to maximize screen space.

+
+

Accessibility

+

Statistic Titles

+
<Statistic title="Total Locations" />
+
+

Clear, descriptive titles for each metric.

+

Icon + Text Combination

+
<Statistic
+  prefix={<EnvironmentOutlined />}
+  value={stats.total}
+/>
+
+

Icons enhance visual communication but text labels provide meaning.

+

Suffix Explanations

+
<Statistic
+  suffix={<Text type="secondary">85%</Text>}
+/>
+
+

Explains threshold for confidence levels.

+
+

Troubleshooting

+

Statistics Not Loading

+

Symptoms: +- Loading spinner forever +- Error message "Failed to load data quality stats"

+

Causes: +1. API server down +2. Database connection issue +3. Permission denied

+

Solutions: +

# Check API logs
+docker compose logs -f api | grep locations
+
+# Test endpoint
+curl -H "Authorization: Bearer <token>" \
+  http://localhost:4000/map/locations/stats
+
+# Check database
+docker compose exec api npx prisma studio
+# Navigate to Location model, verify records exist
+

+

Percentage Shows 0% (but locations exist)

+

Cause: All locations ungeocoded (stats.geocoded === 0)

+

Expected Behavior: Percentage correctly shows 0% (not a bug)

+

Solution: Geocode locations using LocationsPage bulk geocoding feature.

+

Average Confidence Shows 0%

+

Cause: No geocoded locations have confidence scores

+

Expected Behavior: Shows 0% and gray color

+

Solution: Confidence scores only populated during geocoding. Re-geocode locations to populate confidence.

+

Auto-Refresh Not Working

+

Symptoms: +- Statistics never update automatically +- Must manually click Refresh button

+

Causes: +1. Component unmounting and remounting (React Strict Mode in dev) +2. Interval cleared prematurely +3. Browser tab inactive (browser throttles timers)

+

Debug: +

useEffect(() => {
+  console.log('Setting up auto-refresh');
+  loadStats();
+  const interval = setInterval(() => {
+    console.log('Auto-refreshing...');
+    loadStats();
+  }, 30000);
+  return () => {
+    console.log('Cleaning up interval');
+    clearInterval(interval);
+  };
+}, [loadStats]);
+

+

Building Types Show Unexpected Zeros

+

Cause: Building type not set for locations (optional field)

+

Expected Behavior: buildingTypes counts only locations with buildingType set

+

Solution: Update locations to set buildingType field (use LocationsPage).

+
+ +

Backend Integration

+ +

Frontend Pages

+ +

Features

+ +

User Guides

+ +

Troubleshooting

+ + + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/docs-page/index.html b/mkdocs/site/v2/frontend/pages/admin/docs-page/index.html new file mode 100644 index 00000000..0651b908 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/docs-page/index.html @@ -0,0 +1,7753 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Docs - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

DocsPage

+

Overview

+

File: admin/src/pages/DocsPage.tsx +Route: /app/docs +Role Requirements: SUPER_ADMIN

+

DocsPage is a comprehensive documentation editor for Changemaker Lite's MkDocs documentation system. It provides a full-featured IDE-like experience with a file tree browser, Monaco code editor with syntax highlighting, live MkDocs preview, and an extensive MkDocs snippet system with 60+ predefined templates for formatting, headings, admonitions, code blocks, and content insertion.

+

The page offers three layout modes (split, editor-only, preview-only), collapsible file tree with search/filter, drag-to-resize panels, keyboard shortcuts (Ctrl+S to save), CRUD operations for files/folders, and a rich formatting toolbar with dropdown menus for quick content insertion.

+

Key Features: +- Obsidian-style tight file tree with smooth hover effects +- Monaco Editor with Markdown syntax highlighting +- Split-pane layout with draggable dividers (tree, editor, preview) +- Live MkDocs preview in iframe with auto-refresh on save +- Custom right-click context menu with hierarchical snippet groups +- Formatting toolbar with Bold, Italic, Strikethrough, Highlight, etc. +- 60+ MkDocs snippets (formatting, headings, admonitions, code, insert elements) +- File/folder creation, renaming, deletion via tree context menu +- Filter/search across file tree with auto-expand +- URL preview bar (production + localhost links above iframe) +- MkDocs site building (SUPER_ADMIN only)

+

Key Components: +- Ant Design Tree for file browser +- Monaco Editor (@monaco-editor/react) for code editing +- Custom snippet system with wrap/block/insert types +- Keyboard bindings (Ctrl+B for bold, Ctrl+I for italic, Ctrl+S for save) +- Three-panel resizable layout with localStorage persistence

+
+

Screenshot

+

[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.]

+
+

Features

+

Core Features

+
    +
  1. Three-Panel Resizable Layout
  2. +
  3. Left: File tree (160px - 400px width, draggable divider)
  4. +
  5. Center: Monaco editor (40% width default, draggable)
  6. +
  7. Right: MkDocs preview iframe (60% width default, draggable)
  8. +
  9. Layout mode switcher: Split / Editor-only / Preview-only
  10. +
  11. Collapsible tree panel (click hamburger or thin bar to toggle)
  12. +
  13. +

    Persists layout preferences in localStorage

    +
  14. +
  15. +

    File Tree Browser

    +
  16. +
  17. Hierarchical file/folder display (Ant Design Tree)
  18. +
  19. Shows .md files without extension (e.g., "index" not "index.md")
  20. +
  21. Tight spacing (28px row height) for compact view
  22. +
  23. Smooth hover effects (rgba background transitions)
  24. +
  25. Selected file highlighted
  26. +
  27. Expand/collapse folders with arrow icons
  28. +
  29. Context menu (right-click) for New File, New Folder, Rename, Delete
  30. +
  31. +

    Search/filter with auto-expand matching nodes

    +
  32. +
  33. +

    Monaco Code Editor

    +
  34. +
  35. Markdown syntax highlighting with VS Dark theme
  36. +
  37. Line numbers, word wrap, no minimap (clean editing)
  38. +
  39. Real-time change detection (dirty state tracking)
  40. +
  41. Ctrl+S keyboard shortcut for saving
  42. +
  43. Custom right-click context menu (replaces Monaco's default)
  44. +
  45. +

    Detects file type (markdown, yaml, json, css, html, javascript)

    +
  46. +
  47. +

    MkDocs Snippet System (60+ snippets)

    +
  48. +
  49. Formatting: Bold (**), Italic (*), Strikethrough (~~), Highlight (==), Inline Code (`), Keyboard Key (++)
  50. +
  51. Headings: H1-H4 with # syntax
  52. +
  53. Admonitions: Note, Warning, Tip, Danger, Info, Success, Question, Abstract, Example, Bug, Quote (+ collapsible variants)
  54. +
  55. Code: Code block (```), Annotated code, Mermaid diagrams
  56. +
  57. Insert: Link, Image, Button, Primary button, Material icon, Table, Task list, Tabs, Math block, Footnote, Definition list, Horizontal rule
  58. +
  59. +

    Snippet types: wrap (surround selection), block (insert template), insert (paste content)

    +
  60. +
  61. +

    Formatting Toolbar

    +
  62. +
  63. Always visible for .md files (28px height, compact)
  64. +
  65. Direct buttons: Bold, Italic, Strikethrough, Highlight, Inline Code, Keyboard Key
  66. +
  67. Dropdown menus: Headings (H1-H4), Admonitions (11 types + collapsible), Code (3 types), Insert (12 elements)
  68. +
  69. +

    Keyboard shortcuts shown in menus (Ctrl+B, Ctrl+I)

    +
  70. +
  71. +

    Live Preview

    +
  72. +
  73. MkDocs server iframe (proxied via /mkdocs-proxy/)
  74. +
  75. Auto-reload on save (500ms delay)
  76. +
  77. Manual refresh button in toolbar
  78. +
  79. URL preview bar above iframe showing production + localhost URLs
  80. +
  81. +

    Click URL buttons to open in new tab

    +
  82. +
  83. +

    File Operations

    +
  84. +
  85. New File: Right-click folder → New File (auto-appends .md)
  86. +
  87. New Folder: Right-click folder → New Folder
  88. +
  89. Rename: Right-click file/folder → Rename
  90. +
  91. Delete: Right-click file/folder → Delete (with confirmation modal)
  92. +
  93. +

    Root-level creation: Toolbar buttons (+ File, + Folder icons)

    +
  94. +
  95. +

    File Tree Actions

    +
  96. +
  97. Filter: Search button in tree toolbar → input field → auto-expand matches
  98. +
  99. Expand All: Button in tree toolbar
  100. +
  101. Collapse All: Button in tree toolbar
  102. +
  103. Hide Panel: Fold icon collapses tree to thin bar
  104. +
  105. +

    Show Panel: Click thin bar or unfold icon to restore tree

    +
  106. +
  107. +

    Save Operations

    +
  108. +
  109. Save button in top toolbar (blue primary, shows when dirty)
  110. +
  111. Ctrl+S keyboard shortcut (global)
  112. +
  113. Loading state during save
  114. +
  115. Success message on save
  116. +
  117. +

    Modified indicator in editor status bar

    +
  118. +
  119. +

    Layout Modes

    +
      +
    • Split: Editor + Preview side-by-side (default)
    • +
    • Editor: Editor only (full width)
    • +
    • Preview: Preview only (full width)
    • +
    • Toggle buttons in top toolbar
    • +
    • Persists preference in localStorage
    • +
    +
  120. +
  121. +

    MkDocs Site Building (SUPER_ADMIN)

    +
      +
    • Build button in toolbar (hammer icon)
    • +
    • Confirmation modal before build
    • +
    • Triggers static site generation
    • +
    • Success/error messages
    • +
    +
  122. +
  123. +

    Mobile Detection

    +
      +
    • Screens < 768px show "Desktop Required" message
    • +
    • No editor on mobile (unusable)
    • +
    +
  124. +
+
+

User Workflow

+

Opening Editor

+
    +
  1. Navigate to page: Admin sidebar → System → Documentation
  2. +
  3. Page loads: File tree appears on left, empty editor in center, MkDocs homepage in preview
  4. +
  5. Select file: Click on file in tree (e.g., index.md)
  6. +
  7. Editor loads: Monaco editor shows file content, preview updates to matching page
  8. +
+

Editing Documentation

+
    +
  1. Modify content: Type in Monaco editor (Markdown syntax)
  2. +
  3. Use formatting toolbar: Click Bold/Italic/etc. buttons or dropdown menus
  4. +
  5. Insert snippets: Click Insert dropdown → select Link/Image/Table/etc.
  6. +
  7. Check preview: Right pane shows live rendering
  8. +
  9. Save changes: Click Save button or press Ctrl+S
  10. +
  11. Auto-refresh: Preview reloads after 500ms delay
  12. +
+

Using Formatting Toolbar

+

Direct Buttons (wrap selected text): +1. Bold: Select text, click B button (or Ctrl+B) → **text** +2. Italic: Select text, click I button (or Ctrl+I) → *text* +3. Strikethrough: Select text, click button → ~~text~~ +4. Highlight: Select text, click highlight button → ==text== +5. Inline Code: Select text, click <> button → `text` +6. Keyboard Key: Select text, click K button → ++text++

+

Dropdown Menus (insert templates): +1. Headings: Click "H ▼" → select H1/H2/H3/H4 → inserts ## at cursor +2. Admonitions: Click "Admonitions ▼" → select Note/Warning/etc. → inserts block: +

!!! note "Title"
+    Content here
+
+3. Code: Click "Code ▼" → select Code Block/Annotated/Mermaid → inserts template +4. Insert: Click "Insert ▼" → select Link/Image/Table/etc. → pastes element

+

Using Right-Click Context Menu

+
    +
  1. Right-click in editor: Custom context menu appears (not Monaco's default)
  2. +
  3. Select category: Formatting, Headings, Admonitions, Code, or Insert submenu
  4. +
  5. Click snippet: Snippet applied to cursor/selection
  6. +
  7. Context menu closes: Focus returns to editor
  8. +
+

Menu Structure: +- Formatting (submenu) → Bold (Ctrl+B), Italic (Ctrl+I), Strikethrough, etc. +- Headings (submenu) → H1, H2, H3, H4 +- Admonitions (submenu) → Note, Warning, Tip, etc. (13 types) +- Code (submenu) → Code Block, Annotated Code, Mermaid Diagram +- Insert (submenu) → Link, Image, Button, Icon, Table, etc. (12 elements)

+

Managing Files

+

Creating New File: +1. Right-click folder in tree: Context menu appears +2. Click "New File": Modal opens with input +3. Enter name: Type filename (e.g., my-page) +4. Submit: Modal closes, new file appears in tree (auto-appends .md) +5. File auto-opens: Editor loads with template content (# {filename})

+

Creating New Folder: +1. Right-click folder in tree: Context menu appears +2. Click "New Folder": Modal opens +3. Enter name: Type folder name +4. Submit: Modal closes, new folder appears in tree

+

Renaming File/Folder: +1. Right-click file/folder: Context menu appears +2. Click "Rename": Modal opens with current name +3. Edit name: Modify name +4. Submit: Modal closes, tree updates

+

Deleting File/Folder: +1. Right-click file/folder: Context menu appears +2. Click "Delete": Confirmation modal appears +3. Confirm: Click OK +4. File removed: Tree refreshes, if currently open file deleted, editor clears

+

Root-Level Creation: +1. Click "+ File" or "+ Folder" icons in tree toolbar +2. Follow same modal flow as folder context menu

+

Filtering File Tree

+
    +
  1. Click search icon in tree toolbar: Filter input appears below toolbar
  2. +
  3. Type query: Enter filename or partial match (e.g., "api")
  4. +
  5. Tree filters: Only matching files/folders shown
  6. +
  7. Matching folders auto-expand: See nested matches
  8. +
  9. Clear filter: Click X in input or search icon to hide input
  10. +
+

Resizing Panels

+

Tree Panel Resize: +1. Hover over tree divider: Vertical bar (1px) between tree and editor +2. Divider highlights: Changes color to primary +3. Drag left/right: Tree width adjusts (160px - 400px range) +4. Release: Width persists in localStorage

+

Editor/Preview Split: +1. Hover over editor/preview divider: Vertical bar (4px) between panes +2. Divider highlights: Changes color to primary +3. Drag left/right: Adjust split percentage (15% - 85% range) +4. Release: Split persists in localStorage

+

Switching Layout Modes

+
    +
  1. Click layout mode button in toolbar:
  2. +
  3. Split icon: Editor + Preview side-by-side
  4. +
  5. Code icon: Editor only (full width)
  6. +
  7. Eye icon: Preview only (full width)
  8. +
  9. Layout changes immediately
  10. +
  11. Active mode highlighted: Primary blue color
  12. +
  13. Preference saved: Persists in localStorage
  14. +
+

Opening Preview URLs

+
    +
  1. Check URL bar above preview iframe (only for .md files)
  2. +
  3. Click "Production" button: Opens https://docs.cmlite.org/{path} in new tab
  4. +
  5. Click "Localhost" button: Opens http://localhost:4003/{path} in new tab
  6. +
+

Building MkDocs Site (SUPER_ADMIN)

+
    +
  1. Click Build button in toolbar (hammer icon)
  2. +
  3. Confirmation modal: "Build static site? This may take a few minutes."
  4. +
  5. Confirm: Click OK
  6. +
  7. Build starts: Button shows loading spinner
  8. +
  9. Wait: ~30-60 seconds for build to complete
  10. +
  11. Success message: "Site built successfully"
  12. +
  13. Check output: Navigate to MkDocs site URL to verify
  14. +
+

Saving and Preview Refresh

+
    +
  1. Make changes in editor
  2. +
  3. Status bar shows "Modified" in yellow
  4. +
  5. Save with Ctrl+S or Save button
  6. +
  7. Success message: "Saved"
  8. +
  9. Preview auto-refreshes: After 500ms delay
  10. +
  11. Status bar clears "Modified"
  12. +
+
+

Component Breakdown

+

Top Toolbar

+
<Space size={8}>
+  <Tooltip title="Editor + Preview">
+    <Button
+      type={layout === 'split' ? 'primary' : 'text'}
+      icon={<ColumnWidthOutlined />}
+      onClick={() => setLayout('split')}
+    />
+  </Tooltip>
+  <Tooltip title="Editor Only">
+    <Button
+      type={layout === 'editor' ? 'primary' : 'text'}
+      icon={<CodeOutlined />}
+      onClick={() => setLayout('editor')}
+    />
+  </Tooltip>
+  <Tooltip title="Preview Only">
+    <Button
+      type={layout === 'preview' ? 'primary' : 'text'}
+      icon={<EyeOutlined />}
+      onClick={() => setLayout('preview')}
+    />
+  </Tooltip>
+
+  {dirty && (
+    <Button type="primary" icon={<SaveOutlined />} onClick={saveFile} loading={saving}>
+      Save
+    </Button>
+  )}
+
+  <Button type="text" icon={<ReloadOutlined />} onClick={refreshPreview} />
+  <Button type="text" icon={<ExportOutlined />} onClick={() => window.open(mkdocsDirectUrl, '_blank')} />
+
+  {isSuperAdmin && (
+    <Button type="text" icon={<BuildOutlined />} onClick={confirmAndBuild} loading={building} />
+  )}
+</Space>
+
+

File Tree Component

+
<Tree
+  treeData={treeData}
+  showIcon={false}
+  showLine={false}
+  selectedKeys={selectedFile ? [selectedFile] : []}
+  expandedKeys={expandedKeys}
+  onExpand={(keys) => setExpandedKeys(keys)}
+  onSelect={(keys) => {
+    if (keys.length === 0) return;
+    const path = keys[0] as string;
+    if (isDirectoryPath(path)) return;
+    onTreeSelect(keys);
+  }}
+  blockNode
+  titleRender={(nodeData) => {
+    const nodePath = nodeData.key as string;
+    const isDir = isDirectoryPath(nodePath);
+    return (
+      <Dropdown
+        menu={{ items: getContextMenuItems(nodePath, isDir) }}
+        trigger={['contextMenu']}
+      >
+        <span>{nodeData.title as string}</span>
+      </Dropdown>
+    );
+  }}
+/>
+
+

Tree Styling (Obsidian-style): +

.docs-tree .ant-tree-treenode {
+  padding: 0 !important;
+  min-height: 28px !important;
+  line-height: 28px !important;
+  border-radius: 0 !important;
+  transition: background 0.15s !important;
+}
+.docs-tree .ant-tree-treenode:hover {
+  background: rgba(255,255,255,0.06) !important;
+}
+.docs-tree .ant-tree-treenode-selected {
+  background: rgba(255,255,255,0.10) !important;
+}
+

+

Monaco Editor

+
<Editor
+  language={selectedFile.endsWith('.md') ? 'markdown' : 'yaml'}
+  theme="vs-dark"
+  value={fileContent}
+  onChange={onEditorChange}
+  onMount={handleEditorMount}
+  options={{
+    minimap: { enabled: false },
+    contextmenu: false,  // Disable default context menu
+    wordWrap: 'on',
+    lineNumbers: 'on',
+    fontSize: 14,
+    scrollBeyondLastLine: false,
+    automaticLayout: true,
+    tabSize: 2,
+  }}
+/>
+
+

Formatting Toolbar

+
<div style={{ height: 28, display: 'flex', alignItems: 'center', gap: 2 }}>
+  {/* Direct buttons */}
+  <Tooltip title="Bold (Ctrl+B)">
+    <Button type="text" size="small" icon={<BoldOutlined />} onClick={() => handleToolbarSnippet('bold')} />
+  </Tooltip>
+  <Tooltip title="Italic (Ctrl+I)">
+    <Button type="text" size="small" icon={<ItalicOutlined />} onClick={() => handleToolbarSnippet('italic')} />
+  </Tooltip>
+  {/* ... more buttons ... */}
+
+  {/* Dropdown menus */}
+  <Dropdown menu={{ items: headingItems }} trigger={['click']}>
+    <Button type="text" size="small">
+      <FontSizeOutlined /> H <DownOutlined />
+    </Button>
+  </Dropdown>
+
+  <Dropdown menu={{ items: admonitionItems }} trigger={['click']}>
+    <Button type="text" size="small">
+      <AlertOutlined /> Admonitions <DownOutlined />
+    </Button>
+  </Dropdown>
+  {/* ... more dropdowns ... */}
+</div>
+
+

URL Preview Bar

+
const URLPreviewBar = ({ filePath }: { filePath: string | null }) => {
+  if (!filePath || !filePath.endsWith('.md')) return null;
+
+  const productionUrl = `https://docs.cmlite.org/${urlPath}/`;
+  const localhostUrl = `http://localhost:4003/${urlPath}/`;
+
+  return (
+    <div style={{ height: 32, display: 'flex', alignItems: 'center', gap: 8 }}>
+      <Typography.Text>Preview:</Typography.Text>
+      <Button size="small" icon={<ExportOutlined />} onClick={() => openUrl(productionUrl)}>
+        Production
+      </Button>
+      <Button size="small" icon={<ExportOutlined />} onClick={() => openUrl(localhostUrl)}>
+        Localhost
+      </Button>
+    </div>
+  );
+};
+
+

Snippet System

+

Snippet Definition: +

interface MkDocsSnippet {
+  id: string;
+  label: string;
+  group: 'formatting' | 'heading' | 'admonition' | 'code' | 'insert';
+  type: 'wrap' | 'block' | 'insert';
+  prefix?: string;
+  suffix?: string;
+  template?: string;
+  keybinding?: 'ctrl+b' | 'ctrl+i';
+}
+
+const SNIPPETS: MkDocsSnippet[] = [
+  { id: 'bold', label: 'Bold', group: 'formatting', type: 'wrap', prefix: '**', suffix: '**', keybinding: 'ctrl+b' },
+  { id: 'italic', label: 'Italic', group: 'formatting', type: 'wrap', prefix: '*', suffix: '*', keybinding: 'ctrl+i' },
+  { id: 'h2', label: 'Heading 2', group: 'heading', type: 'block', template: '## $CURSOR' },
+  { id: 'admonition-note', label: 'Note', group: 'admonition', type: 'block', template: '!!! note "Title"\n    Content here' },
+  { id: 'code-block', label: 'Code Block', group: 'code', type: 'block', template: '```python\n$CURSOR\n```' },
+  { id: 'link', label: 'Link', group: 'insert', type: 'wrap', prefix: '[', suffix: '](url)' },
+  { id: 'image', label: 'Image', group: 'insert', type: 'insert', template: '![Alt text](image.png)' },
+  // ... 60+ total snippets
+];
+

+

Apply Snippet Function: +

function applySnippet(
+  ed: monacoEditor.IStandaloneCodeEditor,
+  snippet: MkDocsSnippet,
+  monaco: typeof import('monaco-editor'),
+) {
+  const sel = ed.getSelection();
+  const model = ed.getModel();
+  if (!sel || !model) return;
+
+  const selectedText = model.getValueInRange(sel);
+
+  if (snippet.type === 'wrap' && snippet.prefix != null && snippet.suffix != null) {
+    if (selectedText) {
+      ed.executeEdits('mkdocs-snippet', [{
+        range: sel,
+        text: snippet.prefix + selectedText + snippet.suffix,
+      }]);
+    } else {
+      const placeholder = 'text';
+      ed.executeEdits('mkdocs-snippet', [{
+        range: sel,
+        text: snippet.prefix + placeholder + snippet.suffix,
+      }]);
+      // Select placeholder
+      const pos = sel.getStartPosition();
+      const startCol = pos.column + snippet.prefix.length;
+      ed.setSelection(new monaco.Selection(pos.lineNumber, startCol, pos.lineNumber, startCol + placeholder.length));
+    }
+  } else if (snippet.type === 'block' && snippet.template) {
+    let text = snippet.template.replace('$CURSOR', selectedText);
+    ed.executeEdits('mkdocs-snippet', [{ range: sel, text }]);
+  } else if (snippet.type === 'insert' && snippet.template) {
+    ed.executeEdits('mkdocs-snippet', [{ range: sel, text: snippet.template }]);
+  }
+
+  ed.focus();
+}
+

+
+

State Management

+

Local State

+

File Tree & Content: +

const [fileTree, setFileTree] = useState<FileNode[]>([]);
+const [selectedFile, setSelectedFile] = useState<string | null>(null);
+const [fileContent, setFileContent] = useState<string>('');
+const [originalContent, setOriginalContent] = useState<string>('');
+const [dirty, setDirty] = useState(false);
+

+

UI State: +

const [loading, setLoading] = useState(true);
+const [saving, setSaving] = useState(false);
+const [fileLoading, setFileLoading] = useState(false);
+const [layout, setLayout] = useState<LayoutMode>(() => (localStorage.getItem(LAYOUT_STORAGE_KEY) as LayoutMode) || 'split');
+const [splitPercent, setSplitPercent] = useState<number>(() => Number(localStorage.getItem(DIVIDER_STORAGE_KEY)) || 50);
+const [treeCollapsed, setTreeCollapsed] = useState<boolean>(() => localStorage.getItem(TREE_COLLAPSED_KEY) === 'true');
+const [treeWidth, setTreeWidth] = useState<number>(() => Number(localStorage.getItem(TREE_WIDTH_KEY)) || 200);
+

+

Filter & Modal State: +

const [filterQuery, setFilterQuery] = useState('');
+const [filterVisible, setFilterVisible] = useState(false);
+const [modalType, setModalType] = useState<'newFile' | 'newFolder' | 'rename' | null>(null);
+const [modalInput, setModalInput] = useState('');
+const [contextPath, setContextPath] = useState<string>('');
+

+

Monaco Refs: +

const monacoEditorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null);
+const monacoRef = useRef<typeof import('monaco-editor') | null>(null);
+const previewIframeRef = useRef<HTMLIFrameElement>(null);
+

+

localStorage Persistence

+
useEffect(() => { localStorage.setItem(LAYOUT_STORAGE_KEY, layout); }, [layout]);
+useEffect(() => { localStorage.setItem(DIVIDER_STORAGE_KEY, String(splitPercent)); }, [splitPercent]);
+useEffect(() => { localStorage.setItem(TREE_COLLAPSED_KEY, String(treeCollapsed)); }, [treeCollapsed]);
+useEffect(() => { localStorage.setItem(TREE_WIDTH_KEY, String(treeWidth)); }, [treeWidth]);
+
+
+

API Integration

+

Endpoints Used

+

GET /docs/files - Fetch file tree +

const { data } = await api.get<FileNode[]>('/docs/files');
+

+

Response: +

[
+  {
+    "name": "index.md",
+    "path": "index.md",
+    "isDirectory": false
+  },
+  {
+    "name": "v2",
+    "path": "v2",
+    "isDirectory": true,
+    "children": [
+      {
+        "name": "index.md",
+        "path": "v2/index.md",
+        "isDirectory": false
+      }
+    ]
+  }
+]
+

+

GET /docs/files/:filePath - Read file content +

const { data } = await api.get<{ path: string; content: string }>(`/docs/files/${filePath}`);
+

+

Response: +

{
+  "path": "v2/index.md",
+  "content": "# V2 Documentation\n\nWelcome to V2 docs..."
+}
+

+

PUT /docs/files/:filePath - Update file +

await api.put(`/docs/files/${filePath}`, { content: fileContent });
+

+

POST /docs/files/:filePath - Create file/folder +

// Create file
+await api.post(`/docs/files/${path}`, { content: '# New File\n' });
+
+// Create folder
+await api.post(`/docs/files/${path}`, { isDirectory: true });
+

+

POST /docs/files/rename - Rename file/folder +

await api.post('/docs/files/rename', { from: 'old-path.md', to: 'new-path.md' });
+

+

DELETE /docs/files/:filePath - Delete file/folder +

await api.delete(`/docs/files/${filePath}`);
+

+
+

Code Examples

+

Keyboard Shortcut Registration

+
const handleEditorMount: OnMount = useCallback((ed, monaco) => {
+  monacoEditorRef.current = ed;
+  monacoRef.current = monaco;
+
+  // Register Ctrl+B and Ctrl+I
+  SNIPPETS.filter(s => s.keybinding).forEach(snippet => {
+    const kb = snippet.keybinding === 'ctrl+b'
+      ? monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyB
+      : monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyI;
+    ed.addAction({
+      id: `mkdocs.${snippet.id}`,
+      label: snippet.label,
+      keybindings: [kb],
+      run: (editor) => applySnippet(editor as monacoEditor.IStandaloneCodeEditor, snippet, monaco),
+    });
+  });
+}, []);
+
+

Drag-to-Resize Logic

+
const onTreeDividerDown = useCallback(() => {
+  dragging.current = 'tree';
+  document.body.style.cursor = 'col-resize';
+  document.body.style.userSelect = 'none';
+}, []);
+
+useEffect(() => {
+  const onMouseMove = (e: MouseEvent) => {
+    if (!dragging.current || !containerRef.current) return;
+    const rect = containerRef.current.getBoundingClientRect();
+
+    if (dragging.current === 'tree') {
+      const w = e.clientX - rect.left;
+      setTreeWidth(Math.min(MAX_TREE_WIDTH, Math.max(MIN_TREE_WIDTH, w)));
+    }
+  };
+
+  const onMouseUp = () => {
+    if (dragging.current) {
+      dragging.current = false;
+      document.body.style.cursor = '';
+      document.body.style.userSelect = '';
+    }
+  };
+
+  window.addEventListener('mousemove', onMouseMove);
+  window.addEventListener('mouseup', onMouseUp);
+  return () => {
+    window.removeEventListener('mousemove', onMouseMove);
+    window.removeEventListener('mouseup', onMouseUp);
+  };
+}, []);
+
+

File Tree Filtering

+
function filterTree(nodes: FileNode[], query: string): FileNode[] {
+  const q = query.toLowerCase();
+  const filtered: FileNode[] = [];
+
+  for (const node of nodes) {
+    if (node.isDirectory) {
+      const childMatches = node.children ? filterTree(node.children, query) : [];
+      if (childMatches.length > 0 || node.name.toLowerCase().includes(q)) {
+        filtered.push({ ...node, children: childMatches.length > 0 ? childMatches : node.children });
+      }
+    } else {
+      if (node.name.toLowerCase().includes(q)) {
+        filtered.push(node);
+      }
+    }
+  }
+
+  return filtered;
+}
+
+// Auto-expand when filtering
+const expandedKeysForFilter = useMemo(() => {
+  if (filterQuery.trim()) return collectAllDirKeys(filteredTree);
+  return [];
+}, [filterQuery, filteredTree]);
+
+
+

Performance Considerations

+

Monaco Editor Lazy Load

+

Monaco loads from CDN when component mounts (not in main bundle).

+

useCallback for Event Handlers

+

All drag handlers, save handler, and snippet handler use useCallback to prevent recreation.

+

Conditional Toolbar Rendering

+
{selectedFile?.endsWith('.md') && !fileLoading && (
+  <div>{/* Formatting toolbar */}</div>
+)}
+
+

Only renders toolbar for Markdown files.

+
+

Responsive Design

+

Mobile Warning

+
if (isMobile) {
+  return <Result status="info" title="Desktop Required" />;
+}
+
+

Screens < 768px: Show warning, don't render editor.

+
+

Accessibility

+

Keyboard Shortcuts

+
    +
  • Ctrl+S: Save file
  • +
  • Ctrl+B: Bold
  • +
  • Ctrl+I: Italic
  • +
  • Tab: Navigate through toolbar buttons, tree nodes
  • +
+

Button Labels

+

All toolbar buttons have tooltips with keyboard shortcuts shown.

+
+

Troubleshooting

+

Monaco Editor Blank

+

Cause: CDN load failed or height not set

+

Solution: +

<Editor height="100%" /> // Parent must have defined height
+

+

Preview Not Updating

+

Cause: Iframe src not changing or MkDocs server down

+

Debug: +

# Check MkDocs container
+docker compose logs mkdocs
+
+# Restart MkDocs
+docker compose restart mkdocs
+

+

File Tree Not Loading

+

Cause: API endpoint failing

+

Debug: +

# Check API logs
+docker compose logs -f api | grep docs
+
+# Test endpoint
+curl -H "Authorization: Bearer <token>" \
+  http://localhost:4000/docs/files
+

+
+ +

Backend Integration

+ +

Features

+ +

User Guides

+ +

External Resources

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/email-queue-page/index.html b/mkdocs/site/v2/frontend/pages/admin/email-queue-page/index.html new file mode 100644 index 00000000..3881b24c --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/email-queue-page/index.html @@ -0,0 +1,7845 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Email Queue - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

EmailQueuePage

+

Overview

+

The EmailQueuePage provides real-time monitoring of the BullMQ email job queue that handles asynchronous advocacy email sending for the Influence module. It displays four key statistics (waiting, active, completed, failed jobs) with auto-refresh functionality and provides administrative controls to pause/resume the queue and clean up old completed jobs. The page is designed for monitoring email delivery health and troubleshooting stuck jobs.

+

Route: /app/influence/email-queue +Component: admin/src/pages/EmailQueuePage.tsx (140 lines) +Auth Required: Yes (SUPER_ADMIN or INFLUENCE_ADMIN role recommended) +Layout: AppLayout +Backend Module: api/src/modules/influence/email-queue/

+

Screenshot

+

[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.]

+

Features

+
    +
  • Real-time statistics — Waiting, active, completed, and failed job counts
  • +
  • Auto-refresh — Updates every 10 seconds automatically
  • +
  • Queue status indicator — Visual tag showing RUNNING (green) or PAUSED (orange)
  • +
  • Pause/Resume control — Stop/start email processing with single button
  • +
  • Clean old jobs — Remove completed jobs from queue to free memory
  • +
  • Manual refresh — Force immediate statistics update
  • +
  • Color-coded metrics — Semantic colors for each job state
  • +
  • Minimal UI — Focus on essential monitoring data without clutter
  • +
  • Header-integrated actions — Controls in page header for quick access
  • +
+

User Workflow

+

Monitoring Email Queue Health

+
    +
  1. Navigate to /app/influence/email-queue
  2. +
  3. Page loads with initial statistics fetch
  4. +
  5. Observe statistics cards (displayed in single row):
  6. +
  7. Waiting: Jobs queued but not yet processing (blue text)
  8. +
  9. Active: Jobs currently being processed (green text)
  10. +
  11. Completed: Successfully sent emails (gray text)
  12. +
  13. Failed: Jobs that encountered errors (red text)
  14. +
  15. Check queue status tag in header:
  16. +
  17. RUNNING (green): Queue is processing jobs normally
  18. +
  19. PAUSED (orange): Queue is stopped, no jobs being processed
  20. +
  21. Auto-refresh occurs every 10 seconds:
  22. +
  23. Statistics update silently (no loading spinner)
  24. +
  25. Numbers increment/decrement based on queue activity
  26. +
  27. Status tag updates if queue state changes
  28. +
+

Pausing the Email Queue

+

When to Pause: +- Troubleshooting SMTP connection issues +- Performing backend maintenance +- Preventing emails from sending during off-hours +- Testing email configuration changes

+

Steps:

+
    +
  1. Click "Pause" button in header (next to Refresh button)
  2. +
  3. API request sent to /api/email-queue/pause
  4. +
  5. Success message: "Queue paused"
  6. +
  7. Queue status tag changes from "RUNNING" (green) to "PAUSED" (orange)
  8. +
  9. Active count drops to 0 (currently processing jobs complete)
  10. +
  11. Waiting count remains (jobs queued but not processing)
  12. +
  13. Button label changes to "Resume"
  14. +
+

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)

+

Resuming the Email Queue

+
    +
  1. Verify SMTP configuration is correct (Settings page)
  2. +
  3. Click "Resume" button in header
  4. +
  5. API request sent to /api/email-queue/resume
  6. +
  7. Success message: "Queue resumed"
  8. +
  9. Queue status tag changes from "PAUSED" (orange) to "RUNNING" (green)
  10. +
  11. Waiting count begins decreasing as jobs are picked up
  12. +
  13. Active count increases (workers processing jobs)
  14. +
  15. Button label changes back to "Pause"
  16. +
+

Cleaning Old Completed Jobs

+

When to Clean: +- Completed job count exceeds 10,000 (memory usage) +- Queue dashboard feels sluggish +- Regular maintenance (weekly/monthly)

+

Steps:

+
    +
  1. Click "Clean Old Jobs" button in header
  2. +
  3. Confirmation: No confirmation dialog (immediate action)
  4. +
  5. API request sent to /api/email-queue/clean
  6. +
  7. Backend deletes all jobs with status = COMPLETED
  8. +
  9. Success message: "Cleaned 1,487 completed jobs"
  10. +
  11. Completed count resets to 0
  12. +
  13. Statistics automatically refresh
  14. +
+

Important: This only removes completed jobs. Waiting, active, and failed jobs are preserved.

+

Refreshing Statistics Manually

+
    +
  1. Click "Refresh" button in header (circular arrow icon)
  2. +
  3. Loading spinner appears on button
  4. +
  5. API request sent to /api/email-queue/stats
  6. +
  7. All four statistics update simultaneously
  8. +
  9. Loading spinner disappears
  10. +
  11. Use case: Immediate update without waiting for 10-second auto-refresh
  12. +
+

Investigating Failed Jobs

+

Problem: "Failed" count increases (e.g., from 5 to 12)

+

Diagnosis Steps:

+
    +
  1. Note current failed count (e.g., 12)
  2. +
  3. Navigate to backend logs: docker compose logs -f api | grep "Email job failed"
  4. +
  5. Look for error messages: +
    Email job failed for campaign abc123: SMTP connection timeout
    +
  6. +
  7. Identify root cause:
  8. +
  9. SMTP server down
  10. +
  11. Invalid credentials
  12. +
  13. Rate limiting
  14. +
  15. Network connectivity issues
  16. +
+

Resolution:

+
    +
  1. Fix underlying issue (e.g., update SMTP credentials in Settings)
  2. +
  3. Return to Email Queue page
  4. +
  5. Consider options:
  6. +
  7. Retry failed jobs: Currently no UI button (requires backend job retry API)
  8. +
  9. Clean failed jobs: Click "Clean Old Jobs" to remove (also removes completed)
  10. +
  11. Wait for auto-retry: BullMQ will retry failed jobs automatically (3 attempts)
  12. +
+

Component Breakdown

+

Ant Design Components Used

+
    +
  • Card — Container for each statistic
  • +
  • Statistic — Formatted numeric display with title
  • +
  • Row / Col — Grid layout for statistics cards
  • +
  • Button — Header action buttons (Refresh, Pause/Resume, Clean)
  • +
  • Tag — Queue status indicator (RUNNING/PAUSED)
  • +
  • Space — Button grouping in header
  • +
  • message — Toast notifications for success/error feedback
  • +
+

Statistics Card Grid

+
<Row gutter={[16, 16]}>
+  <Col xs={12} sm={6}>
+    <Card>
+      <Statistic
+        title="Waiting"
+        value={stats?.waiting ?? 0}
+        valueStyle={{ color: '#1890ff' }}
+      />
+    </Card>
+  </Col>
+  <Col xs={12} sm={6}>
+    <Card>
+      <Statistic
+        title="Active"
+        value={stats?.active ?? 0}
+        valueStyle={{ color: '#52c41a' }}
+      />
+    </Card>
+  </Col>
+  <Col xs={12} sm={6}>
+    <Card>
+      <Statistic
+        title="Completed"
+        value={stats?.completed ?? 0}
+      />
+    </Card>
+  </Col>
+  <Col xs={12} sm={6}>
+    <Card>
+      <Statistic
+        title="Failed"
+        value={stats?.failed ?? 0}
+        valueStyle={{ color: '#ff4d4f' }}
+      />
+    </Card>
+  </Col>
+</Row>
+
+

Responsive Grid: +- Mobile (xs, <576px): 2 columns (Waiting/Active on top row, Completed/Failed on bottom) +- Tablet/Desktop (sm+, ≥576px): 4 columns (all cards in single row)

+

Color-Coded Values: +- Waiting: Blue (#1890ff) — informational, jobs pending +- Active: Green (#52c41a) — success, jobs processing +- Completed: Gray (default) — neutral, jobs done +- Failed: Red (#ff4d4f) — error, jobs failed

+

Header Actions

+
const headerActions = useMemo(() => (
+  <Space>
+    {stats && (
+      <Tag color={stats.paused ? 'orange' : 'green'}>
+        {stats.paused ? 'PAUSED' : 'RUNNING'}
+      </Tag>
+    )}
+    <Button icon={<ReloadOutlined />} onClick={fetchStats} loading={loading}>
+      Refresh
+    </Button>
+    <Button
+      icon={stats?.paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
+      onClick={handlePauseResume}
+      loading={actionLoading}
+    >
+      {stats?.paused ? 'Resume' : 'Pause'}
+    </Button>
+    <Button
+      icon={<DeleteOutlined />}
+      onClick={handleClean}
+      loading={actionLoading}
+    >
+      Clean Old Jobs
+    </Button>
+  </Space>
+), [stats, loading, actionLoading, fetchStats, handlePauseResume, handleClean]);
+
+

Dynamic Elements: +- Status Tag: Color and text change based on stats.paused boolean +- Pause/Resume Button: Icon and label toggle based on current state +- Loading States: Separate loading states for refresh (loading) and actions (actionLoading)

+

State Management

+

Local State (No Zustand Store)

+
const [stats, setStats] = useState<QueueStats | null>(null);
+const [loading, setLoading] = useState(false);
+const [actionLoading, setActionLoading] = useState(false);
+
+

State Variables: +- stats (QueueStats | null): Current queue statistics (waiting, active, completed, failed, paused) +- loading (boolean): Refresh button loading state +- actionLoading (boolean): Pause/Resume/Clean buttons loading state (shared)

+

No Global State:

+

This page does NOT use Zustand stores. Queue statistics are fetched directly from the API and stored in local state. This is appropriate because: +- Queue stats are admin-only monitoring data +- Data changes frequently (auto-refresh every 10 seconds) +- No need to share state between pages +- Simpler architecture without store overhead

+

Auto-Refresh with useEffect

+
const fetchStats = useCallback(async () => {
+  setLoading(true);
+  try {
+    const { data } = await api.get<QueueStats>('/email-queue/stats');
+    setStats(data);
+  } catch {
+    message.error('Failed to load queue stats');
+  } finally {
+    setLoading(false);
+  }
+}, []);
+
+useEffect(() => {
+  fetchStats();
+  const interval = setInterval(fetchStats, 10_000);  // Refresh every 10 seconds
+  return () => clearInterval(interval);
+}, [fetchStats]);
+
+

Auto-Refresh Strategy:

+
    +
  • Initial load: Immediate fetch on mount
  • +
  • Interval: 10 seconds (10,000 milliseconds)
  • +
  • Silent refresh: Loading state updates, but no UI disruption
  • +
  • Cleanup: Clear interval on unmount to prevent memory leak
  • +
+

Why 10 Seconds?

+
    +
  • Faster than dashboard: Email queue needs more frequent updates than canvass dashboard (30s)
  • +
  • Balance: Fast enough to catch stuck jobs quickly, slow enough to avoid API overload
  • +
  • Email context: Emails send in 1-5 seconds each, so 10s interval catches status changes promptly
  • +
+

useCallback Optimization

+
const fetchStats = useCallback(async () => {
+  // ... fetch logic
+}, []);
+
+const handlePauseResume = useCallback(async () => {
+  // ... pause/resume logic
+}, [stats, fetchStats]);
+
+const handleClean = useCallback(async () => {
+  // ... clean logic
+}, [fetchStats]);
+
+

Why useCallback?

+
    +
  • Prevents infinite re-renders: Without useCallback, useEffect would create new function reference on every render
  • +
  • useMemo dependency: Header actions use fetchStats in dependency array, so must be stable
  • +
  • No unnecessary re-renders: Functions only re-created when dependencies change
  • +
+

API Integration

+

Endpoints Used

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointPurposeAuth
GET/api/email-queue/statsGet queue statisticsRequired
POST/api/email-queue/pausePause queue processingRequired
POST/api/email-queue/resumeResume queue processingRequired
POST/api/email-queue/cleanClean completed jobsRequired
+

Load Queue Statistics

+

Request:

+
const { data } = await api.get<QueueStats>('/email-queue/stats');
+
+

Response (200 OK):

+
{
+  "waiting": 23,
+  "active": 2,
+  "completed": 1487,
+  "failed": 12,
+  "paused": false
+}
+
+

Response Fields: +- waiting (number): Jobs queued but not yet picked up by worker +- active (number): Jobs currently being processed by worker +- completed (number): Successfully completed jobs (still in Redis) +- failed (number): Jobs that failed after all retry attempts +- paused (boolean): Whether queue is paused (true) or running (false)

+

Backend Calculation:

+
// api/src/modules/influence/email-queue/email-queue.routes.ts
+import { emailQueueService } from '@/services/email-queue.service';
+
+const queue = emailQueueService.getQueue();
+const counts = await queue.getJobCounts();
+const isPaused = await queue.isPaused();
+
+return {
+  waiting: counts.waiting,
+  active: counts.active,
+  completed: counts.completed,
+  failed: counts.failed,
+  paused: isPaused,
+};
+
+

Job Count Breakdown:

+
    +
  • Waiting: await queue.getWaitingCount() — jobs in "wait" state
  • +
  • Active: await queue.getActiveCount() — jobs in "active" state
  • +
  • Completed: await queue.getCompletedCount() — jobs in "completed" state
  • +
  • Failed: await queue.getFailedCount() — jobs in "failed" state
  • +
+

Pause Queue

+

Request:

+
await api.post('/email-queue/pause');
+
+

Response (200 OK):

+
{
+  "message": "Queue paused"
+}
+
+

Backend Implementation:

+
await queue.pause();
+return { message: 'Queue paused' };
+
+

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)

+

Resume Queue

+

Request:

+
await api.post('/email-queue/resume');
+
+

Response (200 OK):

+
{
+  "message": "Queue resumed"
+}
+
+

Backend Implementation:

+
await queue.resume();
+return { message: 'Queue resumed' };
+
+

Effect: +- Queue starts picking up jobs from "waiting" state +- Workers process jobs according to concurrency setting (default: 1 job at a time)

+

Clean Completed Jobs

+

Request:

+
const { data } = await api.post<{ cleaned: number }>('/email-queue/clean');
+
+

Response (200 OK):

+
{
+  "cleaned": 1487,
+  "message": "Cleaned 1487 completed jobs"
+}
+
+

Response Fields: +- cleaned (number): Number of jobs removed from Redis +- message (string): Confirmation message

+

Backend Implementation:

+
const completedJobs = await queue.getCompleted();
+await queue.clean(0, 'completed');  // Remove all completed jobs
+return {
+  cleaned: completedJobs.length,
+  message: `Cleaned ${completedJobs.length} completed jobs`,
+};
+
+

Important: This only removes jobs in "completed" state. Failed jobs are preserved for troubleshooting.

+

Code Examples

+

Complete Pause/Resume Flow

+
const handlePauseResume = useCallback(async () => {
+  if (!stats) return;  // Guard: stats must be loaded
+  setActionLoading(true);
+  try {
+    // Determine action based on current state
+    const action = stats.paused ? 'resume' : 'pause';
+
+    // Send POST request
+    await api.post(`/email-queue/${action}`);
+
+    // Show success message
+    message.success(`Queue ${action}d`);
+
+    // Refresh statistics to reflect new state
+    fetchStats();
+  } catch {
+    message.error('Action failed');
+  } finally {
+    setActionLoading(false);
+  }
+}, [stats, fetchStats]);
+
+

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

+

Clean Old Jobs Flow

+
const handleClean = useCallback(async () => {
+  setActionLoading(true);
+  try {
+    // Send clean request
+    const { data } = await api.post<{ cleaned: number }>('/email-queue/clean');
+
+    // Show detailed success message
+    message.success(`Cleaned ${data.cleaned} completed jobs`);
+
+    // Refresh statistics (completed count should be 0 now)
+    fetchStats();
+  } catch {
+    message.error('Clean failed');
+  } finally {
+    setActionLoading(false);
+  }
+}, [fetchStats]);
+
+

Key Steps: +1. Set loading state before API call +2. Extract cleaned count from response +3. Show specific count in success message (confirms action worked) +4. Refresh stats to update completed count (should drop to 0) +5. Generic error message on failure

+

Auto-Refresh Setup

+
useEffect(() => {
+  // Initial load on mount
+  fetchStats();
+
+  // Set up 10-second auto-refresh interval
+  const interval = setInterval(fetchStats, 10_000);
+
+  // Cleanup interval on unmount (prevents memory leak)
+  return () => {
+    clearInterval(interval);
+    console.log('Email queue auto-refresh stopped');
+  };
+}, [fetchStats]);
+
+

Cleanup Importance:

+

If interval is not cleared on unmount: +- Memory leak (interval continues running in background) +- API calls continue even after user navigates away +- Multiple overlapping intervals if user returns to page

+

Header Actions with useMemo

+
const headerActions = useMemo(() => (
+  <Space>
+    {stats && (
+      <Tag color={stats.paused ? 'orange' : 'green'}>
+        {stats.paused ? 'PAUSED' : 'RUNNING'}
+      </Tag>
+    )}
+    <Button icon={<ReloadOutlined />} onClick={fetchStats} loading={loading}>
+      Refresh
+    </Button>
+    <Button
+      icon={stats?.paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
+      onClick={handlePauseResume}
+      loading={actionLoading}
+    >
+      {stats?.paused ? 'Resume' : 'Pause'}
+    </Button>
+    <Button
+      icon={<DeleteOutlined />}
+      onClick={handleClean}
+      loading={actionLoading}
+    >
+      Clean Old Jobs
+    </Button>
+  </Space>
+), [stats, loading, actionLoading, fetchStats, handlePauseResume, handleClean]);
+
+useEffect(() => {
+  setPageHeader({ title: 'Email Queue', actions: headerActions });
+  return () => setPageHeader(null);
+}, [setPageHeader, headerActions]);
+
+

Why useMemo?

+
    +
  • Prevents infinite loop: Header actions passed to setPageHeader, which triggers useEffect if reference changes
  • +
  • Optimized re-renders: Only re-creates actions when dependencies change (stats, loading states, handler functions)
  • +
  • Stable reference: Ensures AppLayout doesn't unnecessarily re-render header
  • +
+

Performance Considerations

+

10-Second Auto-Refresh

+

Queue statistics update every 10 seconds:

+
const interval = setInterval(fetchStats, 10_000);
+
+

Performance Impact: +- API Load: 1 request per 10 seconds = 6 requests/minute (very manageable) +- Redis Queries: Each stats request queries Redis (fast, <10ms) +- Network: Minimal payload (~100 bytes JSON response)

+

Comparison to Dashboard: +- Dashboard: 30-second refresh, 6 API calls in parallel +- Email Queue: 10-second refresh, 1 API call +- Email Queue is more frequent but lighter weight

+

Shared Action Loading State

+

All action buttons share single actionLoading state:

+
const [actionLoading, setActionLoading] = useState(false);
+
+<Button loading={actionLoading} onClick={handlePauseResume}>Pause</Button>
+<Button loading={actionLoading} onClick={handleClean}>Clean Old Jobs</Button>
+
+

Why Shared State?

+
    +
  • Simplicity: Fewer state variables to manage
  • +
  • Prevent concurrent actions: User cannot pause and clean simultaneously
  • +
  • UI clarity: All action buttons disabled during any action
  • +
+

Trade-off:

+

User cannot trigger multiple actions at once (e.g., pause + clean). This is acceptable because: +- Actions are fast (< 1 second) +- Concurrent actions could cause conflicts (pausing while cleaning) +- Simpler mental model for user

+

Silent Refresh

+

Auto-refresh doesn't show loading spinner:

+
const fetchStats = useCallback(async () => {
+  setLoading(true);  // But this doesn't affect UI during auto-refresh
+  try {
+    const { data } = await api.get<QueueStats>('/email-queue/stats');
+    setStats(data);
+  } catch {
+    message.error('Failed to load queue stats');
+  } finally {
+    setLoading(false);
+  }
+}, []);
+
+

Why Silent?

+
    +
  • No UI flicker: Statistics update smoothly without visual distraction
  • +
  • Better UX: User can read numbers without interruption
  • +
  • Manual refresh shows loading: Clicking "Refresh" button shows loading spinner on button
  • +
+

Responsive Design

+

Mobile Layout

+

Statistics cards adapt to mobile viewports:

+
<Row gutter={[16, 16]}>
+  <Col xs={12} sm={6}>  {/* Half width mobile, quarter width desktop */}
+    <Card><Statistic title="Waiting" value={23} /></Card>
+  </Col>
+  {/* Repeat for other cards */}
+</Row>
+
+

Responsive Grid: +- Mobile (xs, <576px): 2×2 grid (Waiting/Active on row 1, Completed/Failed on row 2) +- Tablet/Desktop (sm+, ≥576px): 1×4 grid (all cards in single row)

+

Header Actions

+

Header actions are part of AppLayout's page header:

+
useEffect(() => {
+  setPageHeader({ title: 'Email Queue', actions: headerActions });
+  return () => setPageHeader(null);
+}, [setPageHeader, headerActions]);
+
+

Mobile Behavior:

+

AppLayout automatically collapses header actions into hamburger menu on mobile: +- Desktop: Actions visible in header +- Mobile: Actions in dropdown menu (hamburger icon)

+

Accessibility

+

Keyboard Navigation

+

All interactive elements are keyboard-accessible:

+

Buttons: +- Tab: Focus on next button (Refresh → Pause → Clean) +- Enter/Space: Activate focused button +- Escape: Blur focused button

+

Auto-refresh: +- No keyboard interaction needed (automatic updates)

+

Screen Reader Support

+

All elements have proper ARIA labels:

+

Statistics Cards: +

<Statistic
+  title="Waiting"
+  value={stats?.waiting ?? 0}
+  aria-label={`${stats?.waiting ?? 0} waiting jobs`}
+/>
+

+

Status Tag: +

<Tag
+  color={stats.paused ? 'orange' : 'green'}
+  aria-label={`Queue status: ${stats.paused ? 'paused' : 'running'}`}
+>
+  {stats.paused ? 'PAUSED' : 'RUNNING'}
+</Tag>
+

+

Action Buttons: +

<Button
+  icon={<ReloadOutlined />}
+  onClick={fetchStats}
+  aria-label="Refresh queue statistics"
+>
+  Refresh
+</Button>
+

+

Color Contrast

+

All color-coded elements meet WCAG AA standards:

+

Statistic Values: +- Waiting (blue): #1890ff on white = 4.5:1 contrast (AA) +- Active (green): #52c41a on white = 3.0:1 contrast (AA for large text) +- Completed (gray): rgba(0,0,0,0.85) on white = 13.6:1 contrast (AAA) +- Failed (red): #ff4d4f on white = 4.5:1 contrast (AA)

+

Status Tags: +- RUNNING (green): #52c41a background with white text = 3.5:1 contrast (AA for large text) +- PAUSED (orange): #fa8c16 background with white text = 3.2:1 contrast (AA for large text)

+

Troubleshooting

+

Statistics Not Updating

+

Problem: Navigate to Email Queue page, statistics load initially, but don't update after 10 seconds.

+

Diagnosis:

+

Check browser console for errors:

+
// Expected: No errors every 10 seconds
+// If errors appear every 10 seconds, auto-refresh is running but failing
+GET /api/email-queue/stats 401 Unauthorized
+
+

Possible Causes:

+
    +
  1. JWT token expired:
  2. +
  3. Access token expired, refresh token not working
  4. +
  5. +

    User needs to log out and log back in

    +
  6. +
  7. +

    Interval cleared prematurely:

    +
  8. +
  9. Component unmounted and remounted (React Strict Mode in development)
  10. +
  11. +

    useEffect cleanup called too early

    +
  12. +
  13. +

    Backend API down:

    +
  14. +
  15. API container not running
  16. +
  17. Email queue service crashed
  18. +
+

Solution:

+
    +
  1. For token issues:
  2. +
  3. Refresh page to trigger token refresh
  4. +
  5. If that fails, log out and log back in
  6. +
  7. +

    Check JWT_ACCESS_SECRET and JWT_REFRESH_SECRET env vars

    +
  8. +
  9. +

    For interval issues:

    +
  10. +
  11. Accept that development mode unmounts/remounts components
  12. +
  13. +

    Ensure production build works correctly (no double mounting)

    +
  14. +
  15. +

    For backend issues:

    +
  16. +
  17. Check API container: docker compose ps api
  18. +
  19. Check API logs: docker compose logs -f api | grep email-queue
  20. +
  21. Restart API: docker compose restart api
  22. +
+
+

Pause Button Not Working

+

Problem: Click "Pause" button, success message appears, but queue status remains "RUNNING" (green).

+

Diagnosis:

+

Check API logs:

+
docker compose logs -f api | grep "Queue pause"
+
+

Expected output:

+
API: Queue paused successfully
+
+

Actual output:

+
API: Error pausing queue: Queue not initialized
+
+

Possible Causes:

+
    +
  1. BullMQ queue not initialized:
  2. +
  3. Email queue service failed to start
  4. +
  5. +

    Redis connection error during queue initialization

    +
  6. +
  7. +

    Redis connection lost:

    +
  8. +
  9. Redis container down
  10. +
  11. +

    Network connectivity issue between API and Redis

    +
  12. +
  13. +

    Multiple workers:

    +
  14. +
  15. Multiple API containers running, only one paused
  16. +
  17. Other workers continue processing jobs
  18. +
+

Solution:

+
    +
  1. For queue initialization:
  2. +
  3. Check email queue service: docker compose logs api | grep "Email queue service"
  4. +
  5. Expected: "Email queue service started successfully"
  6. +
  7. +

    If missing, check Redis connection

    +
  8. +
  9. +

    For Redis issues:

    +
  10. +
  11. Check Redis container: docker compose ps redis
  12. +
  13. Test Redis connection: docker compose exec redis redis-cli PING
  14. +
  15. Expected: "PONG"
  16. +
  17. +

    If down, restart: docker compose restart redis

    +
  18. +
  19. +

    For multiple workers:

    +
  20. +
  21. Check running API containers: docker compose ps api
  22. +
  23. Scale down to single instance: docker compose up -d --scale api=1
  24. +
+
+

"Failed" Count Increasing

+

Problem: "Failed" count increases from 5 to 50 over time.

+

Diagnosis:

+

Check failed jobs in Redis:

+
docker compose exec redis redis-cli
+> LRANGE bull:email-queue:failed 0 -1
+
+

Check API logs for failure reasons:

+
docker compose logs -f api | grep "Email job failed"
+
+

Common error messages:

+
Email job failed: SMTP connection timeout
+Email job failed: Authentication failed (535)
+Email job failed: Recipient address rejected
+
+

Possible Causes:

+
    +
  1. SMTP server issues:
  2. +
  3. SMTP server down (connection timeout)
  4. +
  5. Invalid credentials (authentication failed)
  6. +
  7. +

    Rate limiting (too many emails sent)

    +
  8. +
  9. +

    Invalid recipient addresses:

    +
  10. +
  11. Email addresses with typos
  12. +
  13. Non-existent domains
  14. +
  15. +

    Blocked by recipient server

    +
  16. +
  17. +

    Network connectivity:

    +
  18. +
  19. Firewall blocking SMTP ports (25, 587, 465)
  20. +
  21. DNS resolution failure
  22. +
+

Solution:

+
    +
  1. For SMTP server issues:
  2. +
  3. Test SMTP connection: Navigate to /app/settings, click "Test Connection"
  4. +
  5. Update SMTP credentials if authentication failed
  6. +
  7. +

    Wait 5 minutes if rate limited, then resume queue

    +
  8. +
  9. +

    For invalid addresses:

    +
  10. +
  11. Review campaign email list: Navigate to /app/influence/campaigns
  12. +
  13. Check representative email addresses: Navigate to /app/influence/representatives
  14. +
  15. +

    Delete invalid addresses or update to correct ones

    +
  16. +
  17. +

    For network issues:

    +
  18. +
  19. Check firewall rules: sudo iptables -L | grep 587
  20. +
  21. Test DNS: nslookup smtp.protonmail.ch
  22. +
  23. Test SMTP port: telnet smtp.protonmail.ch 587
  24. +
+
+

Clean Button Removes All Jobs

+

Problem: Click "Clean Old Jobs" expecting to remove only completed jobs, but all jobs disappear (waiting + completed).

+

Diagnosis:

+

Check API logs:

+
docker compose logs api | grep "clean"
+
+

Expected:

+
Cleaned 1487 completed jobs
+
+

Actual:

+
Cleaned 1500 jobs (completed + waiting + failed)
+
+

Possible Causes:

+
    +
  1. Backend bug:
  2. +
  3. Clean endpoint removing all job types, not just completed
  4. +
  5. +

    BullMQ clean() called with wrong parameters

    +
  6. +
  7. +

    User misunderstanding:

    +
  8. +
  9. Clean button label unclear (should say "Clean Completed Jobs")
  10. +
  11. User expected to remove failed jobs too
  12. +
+

Solution:

+
    +
  1. For backend bug (developer fix):
  2. +
  3. Update clean endpoint to only remove completed jobs: +
    await queue.clean(0, 'completed');  // Only completed, not 'failed' or 'waiting'
    +
  4. +
  5. +

    Test: Add jobs to queue, click clean, verify waiting/failed jobs remain

    +
  6. +
  7. +

    For unclear UI:

    +
  8. +
  9. Update button label: "Clean Old Jobs" → "Clean Completed Jobs"
  10. +
  11. Add tooltip: "Removes completed jobs from queue. Failed and waiting jobs are preserved."
  12. +
+
+

Statistics Show Wrong Counts

+

Problem: "Completed" count shows 1,487, but only 500 emails were sent.

+

Diagnosis:

+

Check actual job counts in Redis:

+
docker compose exec redis redis-cli
+> LLEN bull:email-queue:completed
+
+

Expected: 1487 (matches UI)

+

Check campaign email records in database:

+
SELECT COUNT(*) FROM "CampaignEmail" WHERE status = 'SENT';
+
+

Result: 500 (mismatch with Redis count)

+

Possible Causes:

+
    +
  1. Duplicate jobs:
  2. +
  3. Multiple jobs created for same email
  4. +
  5. +

    Job retry logic creating duplicates

    +
  6. +
  7. +

    Test jobs:

    +
  8. +
  9. Developer testing created many jobs
  10. +
  11. +

    Test mode emails counting toward total

    +
  12. +
  13. +

    Redis not cleaned:

    +
  14. +
  15. Completed jobs from previous campaigns still in Redis
  16. +
  17. Clean operation not run in months
  18. +
+

Solution:

+
    +
  1. For duplicates:
  2. +
  3. Investigate job creation logic in CampaignsPage
  4. +
  5. Ensure single job created per campaign email
  6. +
  7. +

    Add deduplication: Check if job already exists before creating

    +
  8. +
  9. +

    For test jobs:

    +
  10. +
  11. Clean queue after testing: Click "Clean Old Jobs"
  12. +
  13. +

    Use separate test queue (not production queue)

    +
  14. +
  15. +

    For stale jobs:

    +
  16. +
  17. Run clean operation regularly (weekly)
  18. +
  19. Consider auto-clean after 30 days (backend cron job)
  20. +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/email-template-editor-page/index.html b/mkdocs/site/v2/frontend/pages/admin/email-template-editor-page/index.html new file mode 100644 index 00000000..bc80937e --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/email-template-editor-page/index.html @@ -0,0 +1,8096 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Email Template Editor - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

EmailTemplateEditorPage

+

Overview

+

File: admin/src/pages/EmailTemplateEditorPage.tsx +Route: /app/email-templates/:id/edit +Role Requirements: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

+

EmailTemplateEditorPage is a full-screen Monaco code editor for editing email templates. It provides a split-pane interface with separate editors for HTML and plain text content, real-time preview with sample data, and a variables reference panel. The editor supports Ctrl+S keyboard shortcuts, test email sending, and mobile device detection.

+

The page displays: +- Top toolbar with template metadata (name, category, system status) and action buttons +- Subject line input with variable support +- Dual Monaco editors (HTML + text) side-by-side +- Right sidebar with tabs: Variables, HTML Preview, Text Preview +- Sample data inputs for preview rendering +- Mobile warning screen (desktop required)

+

Key Components: +- Monaco Editor (@monaco-editor/react) for syntax-highlighted code editing +- Ant Design theme tokens for consistent styling +- Three-tab right panel (Variables table, HTML iframe preview, Text pre block) +- TestEmailModal for sending test emails +- Full-screen layout (no AppLayout wrapper)

+
+

Screenshot

+

[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.]

+
+

Features

+

Core Features

+
    +
  1. Dual Editor Layout
  2. +
  3. Left pane (40% width): HTML content editor with syntax highlighting
  4. +
  5. Center pane (40% width): Plain text content editor
  6. +
  7. Right pane (20% width): Variables reference + previews
  8. +
  9. VS Dark theme for all Monaco editors
  10. +
  11. +

    Line numbers, word wrap, no minimap for clean editing

    +
  12. +
  13. +

    Subject Line Editor

    +
  14. +
  15. Input field with envelope icon
  16. +
  17. Supports variable interpolation (e.g., {{CAMPAIGN_NAME}})
  18. +
  19. Large size input for visibility
  20. +
  21. +

    Saved together with HTML/text content

    +
  22. +
  23. +

    Variables Reference Panel

    +
  24. +
  25. Table showing all template variables with columns:
      +
    • Variable: Code format (e.g., {{FIRST_NAME}})
    • +
    • Label: Human-readable name
    • +
    • Description: Usage explanation
    • +
    • Required: Red "Required" or gray "Optional" tag
    • +
    +
  26. +
  27. Sample data input fields for preview
  28. +
  29. +

    Persists sample values during editing session

    +
  30. +
  31. +

    Real-Time Previews

    +
  32. +
  33. HTML Preview Tab: Sandboxed iframe rendering processed HTML
  34. +
  35. Text Preview Tab: Pre-formatted text block with styling
  36. +
  37. Live updates when sample data changes
  38. +
  39. +

    Variable interpolation uses simple string replacement

    +
  40. +
  41. +

    Save Operations

    +
  42. +
  43. Save button in toolbar (primary, blue)
  44. +
  45. Ctrl+S (or Cmd+S on Mac) keyboard shortcut
  46. +
  47. Creates new version in database
  48. +
  49. Success message on save
  50. +
  51. +

    Updates template timestamp

    +
  52. +
  53. +

    Test Email Functionality

    +
  54. +
  55. Test Email button opens TestEmailModal
  56. +
  57. Fill in variable values and recipient
  58. +
  59. Sends email with current editor content (not saved)
  60. +
  61. +

    Success message on send

    +
  62. +
  63. +

    Template Metadata Display

    +
  64. +
  65. Template name in toolbar
  66. +
  67. Category tag (color-coded: blue=Influence, green=Map, purple=System)
  68. +
  69. System template indicator (blue SYSTEM tag)
  70. +
  71. +

    Back button to return to templates list

    +
  72. +
  73. +

    Mobile Detection

    +
  74. +
  75. Detects screens < 768px (md breakpoint)
  76. +
  77. Shows warning Result component
  78. +
  79. "Desktop Required" message
  80. +
  81. +

    Back button to return to templates list

    +
  82. +
  83. +

    Dark Theme Editor

    +
  84. +
  85. VS Dark Monaco theme
  86. +
  87. Consistent with code editor expectations
  88. +
  89. High contrast for readability
  90. +
  91. Token colors from Ant Design theme
  92. +
+
+

User Workflow

+

Opening Editor

+
    +
  1. Navigate from templates list: Click Edit button on EmailTemplatesPage
  2. +
  3. Route loads: /app/email-templates/:id/edit
  4. +
  5. Template fetches: Loading spinner while fetching template data
  6. +
  7. Editor displays: Full-screen layout with template content
  8. +
+

Editing Template

+
    +
  1. Modify subject line: Type in top input field, use {{VARIABLES}} as needed
  2. +
  3. Edit HTML content: Click in left Monaco editor, write HTML markup
  4. +
  5. Edit text content: Click in center Monaco editor, write plain text
  6. +
  7. Check syntax: Monaco provides HTML syntax highlighting and error detection
  8. +
  9. Save changes: Click Save button or press Ctrl+S
  10. +
+

Using Variables

+
    +
  1. View variables table: Click Variables tab in right sidebar
  2. +
  3. Check variable syntax: Copy {{VARIABLE_NAME}} from table
  4. +
  5. Insert in content: Paste into subject line, HTML, or text editor
  6. +
  7. Mark required variables: Red "Required" tag indicates mandatory variables
  8. +
  9. Reference descriptions: Read description column for usage guidance
  10. +
+

Previewing Changes

+
    +
  1. Enter sample data: In Variables tab, fill in input fields below table
  2. +
  3. Switch to preview: Click "HTML Preview" or "Text Preview" tab
  4. +
  5. View rendered output: Iframe shows HTML with variables replaced
  6. +
  7. Update sample data: Change input values to see different renderings
  8. +
  9. Verify output: Check that variables interpolate correctly
  10. +
+

Testing Email

+
    +
  1. Click Test Email button: Opens TestEmailModal
  2. +
  3. Fill in variables: Enter values for each template variable
  4. +
  5. Enter recipient email: Provide test email address
  6. +
  7. Send test: Click Send button
  8. +
  9. Check inbox: Verify email received (or MailHog in dev mode)
  10. +
  11. Review formatting: Check HTML rendering in email client
  12. +
+

Saving Template

+
    +
  1. Make changes: Edit subject, HTML, or text content
  2. +
  3. Save with Ctrl+S: Keyboard shortcut (or click Save button)
  4. +
  5. Loading state: Save button shows spinner
  6. +
  7. Success message: "Template saved successfully" notification
  8. +
  9. New version created: Template version history incremented
  10. +
  11. Continue editing: Can continue making changes and saving again
  12. +
+

Returning to List

+
    +
  1. Click back button: Arrow icon in top-left of toolbar
  2. +
  3. Navigate back: Browser back button also works
  4. +
  5. Unsaved changes: No confirmation prompt (consider implementing)
  6. +
  7. Route change: Returns to /app/email-templates
  8. +
+
+

Component Breakdown

+

Top Toolbar

+
<div
+  style={{
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'space-between',
+    padding: '8px 16px',
+    borderBottom: `1px solid ${token.colorBorderSecondary}`,
+    flexShrink: 0,
+  }}
+>
+  <Space>
+    <Button
+      type="text"
+      icon={<ArrowLeftOutlined />}
+      onClick={() => navigate('/app/email-templates')}
+    />
+    <Text strong>{template.name}</Text>
+    <Tag color={getCategoryColor(template.category)}>{template.category}</Tag>
+    {template.isSystem && <Tag color="blue">SYSTEM</Tag>}
+  </Space>
+  <Space>
+    <Button onClick={() => setTestModalOpen(true)} icon={<SendOutlined />}>
+      Test Email
+    </Button>
+    <Button type="primary" loading={saving} onClick={handleSave} icon={<SaveOutlined />}>
+      Save
+    </Button>
+  </Space>
+</div>
+
+

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

+

Subject Line Input

+
<div style={{ padding: '12px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
+  <Input
+    value={subjectLine}
+    onChange={(e) => setSubjectLine(e.target.value)}
+    placeholder="Email Subject Line (use {{VARIABLES}})"
+    prefix={<MailOutlined />}
+    size="large"
+  />
+</div>
+
+

Props: +- value: Controlled input with subjectLine state +- placeholder: Explains variable syntax +- prefix: Envelope icon for visual context +- size="large": 40px height for prominence

+

HTML Editor (Monaco)

+
<Editor
+  height="100%"
+  language="html"
+  theme="vs-dark"
+  value={htmlContent}
+  onChange={(value) => setHtmlContent(value || '')}
+  options={{
+    minimap: { enabled: false },
+    fontSize: 14,
+    wordWrap: 'on',
+    lineNumbers: 'on',
+    scrollBeyondLastLine: false,
+  }}
+/>
+
+

Options: +- minimap: false - No code minimap (saves space) +- fontSize: 14 - Readable code size +- wordWrap: 'on' - Wrap long lines instead of horizontal scroll +- lineNumbers: 'on' - Show line numbers for reference +- scrollBeyondLastLine: false - Don't scroll past last line

+

Text Editor (Monaco)

+
<Editor
+  height="100%"
+  language="plaintext"
+  theme="vs-dark"
+  value={textContent}
+  onChange={(value) => setTextContent(value || '')}
+  options={{
+    minimap: { enabled: false },
+    fontSize: 14,
+    wordWrap: 'on',
+    lineNumbers: 'on',
+    scrollBeyondLastLine: false,
+  }}
+/>
+
+

Same options as HTML editor but language is plaintext (no syntax highlighting).

+

Variables Table

+
<Table
+  dataSource={template.variables}
+  columns={variableColumns}
+  rowKey="id"
+  size="small"
+  pagination={false}
+/>
+
+

Columns: +

const variableColumns = [
+  {
+    title: 'Variable',
+    dataIndex: 'key',
+    render: (key: string) => <Text code>{'{{' + key + '}}'}</Text>,
+  },
+  {
+    title: 'Label',
+    dataIndex: 'label',
+  },
+  {
+    title: 'Description',
+    dataIndex: 'description',
+    render: (desc: string | null) => <Text type="secondary">{desc || '—'}</Text>,
+  },
+  {
+    title: 'Required',
+    dataIndex: 'isRequired',
+    render: (isRequired: boolean) => (
+      isRequired ? <Tag color="red">Required</Tag> : <Tag>Optional</Tag>
+    ),
+  },
+];
+

+

Sample Data Inputs

+
<div style={{ marginTop: 16 }}>
+  <Text strong>Sample Data (for preview):</Text>
+  {template.variables.map((v) => (
+    <div key={v.key} style={{ marginTop: 8 }}>
+      <Text type="secondary" style={{ fontSize: 12 }}>
+        {v.label}
+      </Text>
+      <Input
+        size="small"
+        value={sampleData[v.key] || ''}
+        onChange={(e) => setSampleData({ ...sampleData, [v.key]: e.target.value })}
+        placeholder={v.sampleValue || ''}
+      />
+    </div>
+  ))}
+</div>
+
+

Pattern: One input per variable, labeled with variable label, placeholder shows default sample value.

+

HTML Preview Iframe

+
<iframe
+  srcDoc={processedHtml}
+  style={{
+    width: '100%',
+    height: '100%',
+    border: `1px solid ${token.colorBorder}`,
+    borderRadius: 4,
+  }}
+  sandbox="allow-same-origin"
+  title="HTML Preview"
+/>
+
+

Security: sandbox="allow-same-origin" restricts iframe capabilities (no scripts, no forms).

+

srcDoc prop: Renders inline HTML without external URL.

+

Text Preview Block

+
<pre
+  style={{
+    whiteSpace: 'pre-wrap',
+    fontFamily: 'monospace',
+    fontSize: 12,
+    lineHeight: 1.5,
+    padding: 12,
+    backgroundColor: token.colorBgLayout,
+    borderRadius: 4,
+    border: `1px solid ${token.colorBorder}`,
+  }}
+>
+  {processedText}
+</pre>
+
+

Styling: +- whiteSpace: 'pre-wrap' - Preserve whitespace but wrap long lines +- fontFamily: 'monospace' - Fixed-width font like email clients use +- Background color for contrast

+

Template Processing Function

+
const processTemplate = (content: string, data: Record<string, string>): string => {
+  let processed = content;
+  Object.entries(data).forEach(([key, value]) => {
+    processed = processed.replace(new RegExp(`{{${key}}}`, 'g'), value);
+  });
+  return processed;
+};
+
+

Usage: +

const processedHtml = processTemplate(htmlContent, sampleData);
+const processedText = processTemplate(textContent, sampleData);
+

+
+

State Management

+

Local State

+

Template Data: +

const [template, setTemplate] = useState<EmailTemplate | null>(null);
+const [loading, setLoading] = useState(true);
+

+

Editor Content: +

const [subjectLine, setSubjectLine] = useState('');
+const [htmlContent, setHtmlContent] = useState('');
+const [textContent, setTextContent] = useState('');
+

+

Sample Data for Preview: +

const [sampleData, setSampleData] = useState<Record<string, string>>({});
+

+

UI State: +

const [saving, setSaving] = useState(false);
+const [activeTab, setActiveTab] = useState('variables');
+const [testModalOpen, setTestModalOpen] = useState(false);
+

+

Responsive State: +

const screens = Grid.useBreakpoint();
+const isMobile = !screens.md;
+

+

Data Fetching

+

Fetch Template on Mount: +

useEffect(() => {
+  const fetchTemplate = async () => {
+    try {
+      const { data } = await api.get<EmailTemplate>(`/email-templates/${id}`);
+      setTemplate(data);
+      setSubjectLine(data.subjectLine);
+      setHtmlContent(data.htmlContent);
+      setTextContent(data.textContent);
+
+      // Initialize sample data from variables
+      const initialSampleData: Record<string, string> = {};
+      data.variables.forEach((v) => {
+        initialSampleData[v.key] = v.sampleValue || '';
+      });
+      setSampleData(initialSampleData);
+    } catch {
+      message.error('Failed to load template');
+      navigate('/app/email-templates');
+    } finally {
+      setLoading(false);
+    }
+  };
+  fetchTemplate();
+}, [id, navigate]);
+

+

Error Handling: Redirect to templates list if template not found.

+

Save Handler

+
const handleSave = useCallback(async () => {
+  if (!template) return;
+  setSaving(true);
+  try {
+    const { data: updated } = await api.put<EmailTemplate>(`/email-templates/${id}`, {
+      subjectLine,
+      htmlContent,
+      textContent,
+    });
+    setTemplate(updated);
+    message.success('Template saved successfully');
+  } catch (err: unknown) {
+    const msg =
+      (err as { response?: { data?: { error?: string } } })?.response?.data?.error ||
+      'Failed to save template';
+    message.error(msg);
+  } finally {
+    setSaving(false);
+  }
+}, [template, id, subjectLine, htmlContent, textContent]);
+
+

Keyboard Shortcut

+
useEffect(() => {
+  const handler = (e: KeyboardEvent) => {
+    if ((e.ctrlKey || e.metaKey) && e.key === 's') {
+      e.preventDefault();
+      handleSave();
+    }
+  };
+  window.addEventListener('keydown', handler);
+  return () => window.removeEventListener('keydown', handler);
+}, [handleSave]);
+
+

Why e.preventDefault()? Prevents browser's default "Save Page" dialog.

+
+

API Integration

+

Endpoints Used

+

GET /email-templates/:id - Fetch template +

const { data } = await api.get<EmailTemplate>(`/email-templates/${id}`);
+

+

Response: +

{
+  "id": "tmpl_123",
+  "key": "campaign_email",
+  "name": "Campaign Email",
+  "category": "INFLUENCE",
+  "subjectLine": "Take action on {{CAMPAIGN_NAME}}",
+  "htmlContent": "<html><body><h1>{{CAMPAIGN_NAME}}</h1><p>{{MESSAGE}}</p></body></html>",
+  "textContent": "{{CAMPAIGN_NAME}}\n\n{{MESSAGE}}",
+  "isActive": true,
+  "isSystem": false,
+  "variables": [
+    {
+      "id": "var_1",
+      "key": "CAMPAIGN_NAME",
+      "label": "Campaign Name",
+      "description": "Name of the campaign",
+      "isRequired": true,
+      "sampleValue": "Stop Deforestation"
+    },
+    {
+      "id": "var_2",
+      "key": "MESSAGE",
+      "label": "Message",
+      "description": "Main message content",
+      "isRequired": true,
+      "sampleValue": "Join us in protecting our forests."
+    }
+  ],
+  "createdAt": "2026-01-15T10:00:00Z",
+  "updatedAt": "2026-02-10T14:30:00Z"
+}
+

+

PUT /email-templates/:id - Update template +

const { data: updated } = await api.put<EmailTemplate>(`/email-templates/${id}`, {
+  subjectLine: "Updated subject with {{VARIABLE}}",
+  htmlContent: "<html>...</html>",
+  textContent: "Plain text...",
+});
+

+

Response: Returns updated EmailTemplate object with new updatedAt timestamp.

+
+

Code Examples

+

Keyboard Shortcut Pattern

+
const handleSave = useCallback(async () => {
+  // Save logic
+}, [/* dependencies */]);
+
+useEffect(() => {
+  const handler = (e: KeyboardEvent) => {
+    if ((e.ctrlKey || e.metaKey) && e.key === 's') {
+      e.preventDefault();
+      handleSave();
+    }
+  };
+  window.addEventListener('keydown', handler);
+  return () => window.removeEventListener('keydown', handler);
+}, [handleSave]);
+
+

Pattern: +1. Use useCallback for save handler with dependencies +2. Add keyboard event listener in useEffect +3. Check Ctrl/Cmd + S key combination +4. Call preventDefault to stop browser save dialog +5. Clean up listener on unmount

+

Variable Interpolation

+
const processTemplate = (content: string, data: Record<string, string>): string => {
+  let processed = content;
+  Object.entries(data).forEach(([key, value]) => {
+    processed = processed.replace(new RegExp(`{{${key}}}`, 'g'), value);
+  });
+  return processed;
+};
+
+// Usage
+const processedHtml = processTemplate(htmlContent, sampleData);
+// Input: "<h1>{{CAMPAIGN_NAME}}</h1>"
+// Sample data: { CAMPAIGN_NAME: "Save the Planet" }
+// Output: "<h1>Save the Planet</h1>"
+
+

Note: This is a simple string replacement for preview. Production email sending uses server-side template engine with proper escaping.

+

Mobile Detection

+
const screens = Grid.useBreakpoint();
+const isMobile = !screens.md;
+
+if (isMobile) {
+  return (
+    <Result
+      status="warning"
+      title="Desktop Required"
+      subTitle="The email template editor requires a desktop browser."
+      extra={
+        <Button type="primary" onClick={() => navigate('/app/email-templates')}>
+          Back to Templates
+        </Button>
+      }
+    />
+  );
+}
+
+

Breakpoint: md = 768px (Ant Design standard)

+

Sample Data Initialization

+
// Initialize sample data from template variables
+const initialSampleData: Record<string, string> = {};
+data.variables.forEach((v) => {
+  initialSampleData[v.key] = v.sampleValue || '';
+});
+setSampleData(initialSampleData);
+
+

Pattern: Pre-fill sample data inputs with default sample values from variable definitions.

+

Category Color Helper

+
const getCategoryColor = (category: EmailTemplateCategory): string => {
+  const colors: Record<EmailTemplateCategory, string> = {
+    INFLUENCE: 'blue',
+    MAP: 'green',
+    SYSTEM: 'purple',
+  };
+  return colors[category];
+};
+
+

Consistent with EmailTemplatesPage color scheme.

+
+

Performance Considerations

+

Monaco Editor Lazy Loading

+

Monaco Editor is loaded via CDN when component mounts: +

import Editor from '@monaco-editor/react';
+

+

Bundle size: Monaco not included in main bundle (reduces initial load).

+

useCallback for Save Handler

+
const handleSave = useCallback(async () => { /* ... */ }, [template, id, subjectLine, htmlContent, textContent]);
+
+

Why: Prevents recreation on every render, essential for keyboard shortcut listener dependency.

+

Controlled Inputs

+

All three editors (subject, HTML, text) use controlled state: +

<Input value={subjectLine} onChange={(e) => setSubjectLine(e.target.value)} />
+<Editor value={htmlContent} onChange={(value) => setHtmlContent(value || '')} />
+

+

Tradeoff: Controlled inputs = React re-renders on every keystroke, but ensures state consistency.

+

Iframe Preview Updates

+

Preview iframe updates only when: +1. Sample data changes +2. Editor content changes (via processedHtml dependency)

+

No automatic refresh timer needed.

+
+

Responsive Design

+

Mobile Warning

+
const isMobile = !screens.md;
+
+if (isMobile) {
+  return <Result status="warning" title="Desktop Required" />;
+}
+
+

Screens < 768px: Show warning, don't render editor (unusable on small screens).

+

Full-Screen Layout

+
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
+  {/* Toolbar */}
+  <div style={{ flexShrink: 0 }}>...</div>
+
+  {/* Editors */}
+  <div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>...</div>
+</div>
+
+

height: 100vh ensures full viewport height, no scrolling.

+

Flex Layout

+
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
+  <div style={{ flex: '0 0 40%' }}>HTML Editor</div>
+  <div style={{ flex: '0 0 40%' }}>Text Editor</div>
+  <div style={{ flex: '0 0 20%' }}>Sidebar</div>
+</div>
+
+

Flex basis percentages: Fixed width columns, no shrinking/growing.

+
+

Accessibility

+

Keyboard Navigation

+
    +
  • Tab: Navigate between subject input, editors, buttons
  • +
  • Ctrl+S / Cmd+S: Save template
  • +
  • Monaco shortcuts: Ctrl+F (find), Ctrl+H (replace), Ctrl+/ (comment)
  • +
+

Button Labels

+
<Button icon={<SaveOutlined />}>Save</Button>
+<Button icon={<SendOutlined />}>Test Email</Button>
+
+

Not icon-only buttons – text labels for clarity.

+

Input Placeholders

+
<Input placeholder="Email Subject Line (use {{VARIABLES}})" />
+
+

Descriptive placeholder explains variable syntax.

+

Preview Iframe Sandbox

+
<iframe sandbox="allow-same-origin" />
+
+

Security: Restricts iframe capabilities (no JavaScript execution from injected HTML).

+
+

Troubleshooting

+

Template Not Loading

+

Symptoms: +- Loading spinner forever +- Error message "Failed to load template" +- Redirect to templates list

+

Causes: +1. Invalid template ID in URL +2. API server down +3. Template deleted +4. Permission denied

+

Solutions: +

# Check API logs
+docker compose logs -f api
+
+# Test API endpoint
+curl -H "Authorization: Bearer <token>" \
+  http://localhost:4000/email-templates/tmpl_123
+
+# Verify template exists in database
+docker compose exec api npx prisma studio
+# Navigate to EmailTemplate model, search by ID
+

+

Save Not Working

+

Symptoms: +- Clicking Save does nothing +- Ctrl+S has no effect +- No success/error message

+

Causes: +1. handleSave callback not defined +2. Keyboard listener not registered +3. Network error

+

Debug: +

const handleSave = useCallback(async () => {
+  console.log('Save triggered');
+  console.log('Template ID:', id);
+  console.log('Content:', { subjectLine, htmlContent, textContent });
+  // ... rest of save logic
+}, [template, id, subjectLine, htmlContent, textContent]);
+

+

Preview Not Updating

+

Symptoms: +- Changing sample data doesn't update preview +- Preview shows old content

+

Causes: +1. processTemplate function not called +2. Sample data state not updating +3. Iframe not re-rendering

+

Debug: +

const processedHtml = processTemplate(htmlContent, sampleData);
+console.log('Sample data:', sampleData);
+console.log('Processed HTML:', processedHtml);
+

+

Variables Not Showing

+

Symptoms: +- Variables table empty +- Sample data inputs not rendering

+

Cause: +- Template has no variables defined

+

Expected Behavior: +- If template.variables is empty array, table shows no rows +- This is valid (template may not use variables)

+

Mobile Warning Not Showing

+

Symptoms: +- Editor renders on mobile (broken layout)

+

Cause: +- Breakpoint detection not working

+

Debug: +

const screens = Grid.useBreakpoint();
+console.log('Breakpoints:', screens);
+console.log('Is mobile:', !screens.md);
+

+

Monaco Editor Blank

+

Symptoms: +- Editor pane shows nothing (white/black) +- No code visible

+

Causes: +1. Monaco CDN failed to load +2. Content is empty string +3. Height not set correctly

+

Solutions: +

// Check if content loaded
+console.log('HTML content length:', htmlContent.length);
+
+// Verify Monaco loaded
+import Editor from '@monaco-editor/react';
+console.log('Monaco Editor component:', Editor);
+
+// Check editor height
+<Editor height="100%" /> // Ensure parent has defined height
+

+
+ +

Backend Integration

+ +

Frontend Pages

+ +

Frontend Components

+ +

Features

+ +

User Guides

+ +

External Resources

+ + + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/email-templates-page/index.html b/mkdocs/site/v2/frontend/pages/admin/email-templates-page/index.html new file mode 100644 index 00000000..ff80bf98 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/email-templates-page/index.html @@ -0,0 +1,8099 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Email Templates - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

EmailTemplatesPage

+

Overview

+

File: admin/src/pages/EmailTemplatesPage.tsx +Route: /app/email-templates +Role Requirements: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN

+

EmailTemplatesPage is the email template management interface for the V2 system. It provides CRUD operations for email templates used throughout the platform (campaigns, shifts, notifications), with versioning support, test email functionality, and categorization. The page manages HTML + text content templates with variable substitution for dynamic content.

+

The page integrates with the email template system to provide: +- Template library with search and filtering +- Category organization (Influence, Map, System) +- Active/inactive status management +- Test email sending with sample data +- Version history with rollback capability +- System template protection (cannot delete)

+

Key Components: +- Ant Design Table for template list with pagination +- Input with 300ms debounced search +- Select filters for category and active status +- TestEmailModal for sending test emails +- VersionHistoryDrawer for viewing and restoring previous versions +- Action buttons (Edit, Test, Versions, Delete)

+
+

Screenshot

+

[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.]

+
+

Features

+

Core Features

+
    +
  1. Template List Management
  2. +
  3. Paginated table showing all email templates (default 20 per page)
  4. +
  5. Name column shows template name + key, system templates marked with blue SYSTEM tag
  6. +
  7. Category column color-coded (blue=Influence, green=Map, purple=System)
  8. +
  9. Subject line preview (truncated to 50 chars)
  10. +
  11. Active/Inactive badge status
  12. +
  13. Relative timestamp (e.g., "2 hours ago")
  14. +
  15. +

    Four action buttons per row (Edit, Test, Versions, Delete)

    +
  16. +
  17. +

    Search & Filtering

    +
  18. +
  19. Real-time search by name or key (300ms debounce)
  20. +
  21. Category filter dropdown (All, Influence, Map, System)
  22. +
  23. Active status filter (All, Active, Inactive)
  24. +
  25. +

    Filters trigger automatic refetch with page reset to 1

    +
  26. +
  27. +

    Template Actions

    +
  28. +
  29. Edit: Navigate to full-screen Monaco editor (/app/email-templates/:id/edit)
  30. +
  31. Test: Open modal to send test email with sample data
  32. +
  33. Versions: Open drawer showing version history with rollback options
  34. +
  35. +

    Delete: Popconfirm with warning (only for non-system templates)

    +
  36. +
  37. +

    Pagination Controls

    +
  38. +
  39. Page size options: 10, 20, 50, 100
  40. +
  41. Show total count (e.g., "Total 15 templates")
  42. +
  43. +

    Current page and page size preserved during search/filter operations

    +
  44. +
  45. +

    System Template Protection

    +
  46. +
  47. System templates (isSystem: true) cannot be deleted
  48. +
  49. Delete button hidden for system templates
  50. +
  51. +

    Blue SYSTEM tag displayed in name column

    +
  52. +
  53. +

    Test Email Modal

    +
  54. +
  55. Fill in variable values with form inputs
  56. +
  57. Send test email to specified recipient
  58. +
  59. Uses template's current HTML + text content
  60. +
  61. +

    Success message on send

    +
  62. +
  63. +

    Version History

    +
  64. +
  65. Drawer showing all historical versions
  66. +
  67. View previous subject lines, HTML, and text content
  68. +
  69. Rollback to any previous version
  70. +
  71. Refetches template list after rollback
  72. +
+
+

User Workflow

+

Viewing Templates

+
    +
  1. Navigate to page: Admin sidebar → Email → Templates
  2. +
  3. Browse templates: Table shows all templates with pagination
  4. +
  5. View details: Click on template name or use filters to narrow list
  6. +
  7. Check status: Green "Active" badge = enabled, gray "Inactive" badge = disabled
  8. +
+

Searching Templates

+
    +
  1. Enter search query: Type in search bar (name or key)
  2. +
  3. Debounced search: 300ms delay prevents excessive API calls
  4. +
  5. Clear search: Click X icon or clear input
  6. +
  7. Results update: Table refreshes with matching templates, page resets to 1
  8. +
+

Filtering Templates

+
    +
  1. Select category: Choose from All, Influence, Map, System dropdown
  2. +
  3. Select active status: Choose from All, Active, Inactive dropdown
  4. +
  5. Combined filters: Search + category + active status work together
  6. +
  7. Reset filters: Change dropdowns back to "All" or clear search
  8. +
+

Editing Template

+
    +
  1. Click Edit button: Opens full-screen Monaco editor in new route
  2. +
  3. Modify content: Edit subject line, HTML, and text content
  4. +
  5. Save changes: Ctrl+S or click Save button (creates new version)
  6. +
  7. Return to list: Browser back button or navigate away
  8. +
+

Testing Email

+
    +
  1. Click Test button: Opens TestEmailModal
  2. +
  3. Fill in variables: Enter sample data for template variables
  4. +
  5. Enter recipient: Provide email address for test
  6. +
  7. Send test: Click Send button
  8. +
  9. Check result: Success message or error message
  10. +
  11. Verify email: Check recipient inbox (or MailHog in dev mode)
  12. +
+

Viewing Version History

+
    +
  1. Click Versions button: Opens VersionHistoryDrawer on right side
  2. +
  3. Browse versions: See all historical versions with timestamps
  4. +
  5. View version details: Expand version to see full content
  6. +
  7. Rollback (if needed): Click Rollback button to restore previous version
  8. +
  9. Close drawer: Click X or click outside drawer
  10. +
+

Deleting Template

+
    +
  1. Verify not system template: Check for absence of SYSTEM tag
  2. +
  3. Click Delete button: Opens Popconfirm dialog
  4. +
  5. Read warning: "This action cannot be undone"
  6. +
  7. Confirm deletion: Click OK in popconfirm
  8. +
  9. Template removed: Table refreshes, template no longer shown
  10. +
+
+

Component Breakdown

+

Table Component

+
<Table
+  columns={columns}
+  dataSource={templates}
+  rowKey="id"
+  loading={loading}
+  onChange={handleTableChange}
+  pagination={{
+    current: pagination.page,
+    pageSize: pagination.limit,
+    total: pagination.total,
+    showSizeChanger: true,
+    showTotal: (total) => `Total ${total} templates`,
+    pageSizeOptions: ['10', '20', '50', '100'],
+  }}
+  scroll={{ x: 'max-content' }}
+/>
+
+

Column Configuration:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ColumnDataindexResponsiveRender Logic
NamenameAlways visibleShows name + key, SYSTEM tag for system templates
CategorycategoryHidden on mobile (['md'])Color-coded tag (blue/green/purple)
SubjectsubjectLineHidden on small tablets (['lg'])Truncated to 50 chars with ellipsis
ActiveisActiveHidden on mobileBadge (success or default)
UpdatedupdatedAtHidden on mobileRelative time with dayjs fromNow()
Actions-Fixed right, 280pxEdit, Test, Versions, Delete buttons
+

Search Input

+
<Input
+  placeholder="Search by name or key..."
+  prefix={<SearchOutlined />}
+  value={search}
+  onChange={(e) => handleSearchChange(e.target.value)}
+  style={{ width: 300 }}
+  allowClear
+/>
+
+

Debounce Logic: +

const handleSearchChange = (value: string) => {
+  setSearch(value);  // Update input immediately
+  clearTimeout(searchTimerRef.current);
+  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
+};
+

+

Category Filter

+
<Select
+  value={categoryFilter}
+  onChange={setCategoryFilter}
+  options={categoryOptions}
+  style={{ width: 180 }}
+/>
+
+

Options: +

const categoryOptions = [
+  { value: 'ALL', label: 'All Categories' },
+  { value: 'INFLUENCE', label: 'Influence' },
+  { value: 'MAP', label: 'Map' },
+  { value: 'SYSTEM', label: 'System' },
+];
+

+

Active Status Filter

+
<Select
+  value={activeFilter}
+  onChange={setActiveFilter}
+  options={activeOptions}
+  style={{ width: 150 }}
+/>
+
+

Options: +

const activeOptions = [
+  { value: 'ALL', label: 'All Status' },
+  { value: 'ACTIVE', label: 'Active' },
+  { value: 'INACTIVE', label: 'Inactive' },
+];
+

+

Action Buttons

+
<Space wrap>
+  <Button
+    type="link"
+    size="small"
+    icon={<EditOutlined />}
+    onClick={() => navigate(`/app/email-templates/${record.id}/edit`)}
+  >
+    Edit
+  </Button>
+  <Button
+    type="link"
+    size="small"
+    icon={<MailOutlined />}
+    onClick={() => openTestEmailModal(record)}
+  >
+    Test
+  </Button>
+  <Button
+    type="link"
+    size="small"
+    icon={<HistoryOutlined />}
+    onClick={() => openVersionDrawer(record)}
+  >
+    Versions
+  </Button>
+  {!record.isSystem && (
+    <Popconfirm
+      title="Delete template?"
+      description="This action cannot be undone."
+      onConfirm={() => handleDelete(record.id)}
+    >
+      <Button type="link" size="small" danger icon={<DeleteOutlined />}>
+        Delete
+      </Button>
+    </Popconfirm>
+  )}
+</Space>
+
+

TestEmailModal

+

Props: +- open: boolean - Modal visibility +- template: EmailTemplate - Template to test +- onClose: () => void - Close callback +- onSuccess: () => void - Success callback

+

Usage: +

<TestEmailModal
+  open={testModalOpen}
+  template={selectedTemplate}
+  onClose={() => {
+    setTestModalOpen(false);
+    setSelectedTemplate(null);
+  }}
+  onSuccess={() => {
+    message.success('Test email sent successfully');
+    setTestModalOpen(false);
+    setSelectedTemplate(null);
+  }}
+/>
+

+

VersionHistoryDrawer

+

Props: +- open: boolean - Drawer visibility +- templateId: string - Template ID +- templateName: string - Template name for header +- onClose: () => void - Close callback +- onRollbackSuccess: () => void - Rollback success callback

+

Usage: +

<VersionHistoryDrawer
+  open={versionDrawerOpen}
+  templateId={selectedTemplate.id}
+  templateName={selectedTemplate.name}
+  onClose={() => {
+    setVersionDrawerOpen(false);
+    setSelectedTemplate(null);
+  }}
+  onRollbackSuccess={() => {
+    fetchTemplates();  // Refresh list
+    setVersionDrawerOpen(false);
+    setSelectedTemplate(null);
+  }}
+/>
+

+
+

State Management

+

Local State

+

Template List State: +

const [templates, setTemplates] = useState<EmailTemplate[]>([]);
+const [pagination, setPagination] = useState<PaginationMeta>({
+  page: 1,
+  limit: 20,
+  total: 0,
+  totalPages: 0
+});
+const [loading, setLoading] = useState(false);
+

+

Search & Filter State: +

const [search, setSearch] = useState('');  // Input value (immediate)
+const [debouncedSearch, setDebouncedSearch] = useState('');  // API query (300ms delay)
+const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
+const [categoryFilter, setCategoryFilter] = useState<EmailTemplateCategory | 'ALL'>('ALL');
+const [activeFilter, setActiveFilter] = useState<'ALL' | 'ACTIVE' | 'INACTIVE'>('ALL');
+

+

Modal State: +

const [testModalOpen, setTestModalOpen] = useState(false);
+const [selectedTemplate, setSelectedTemplate] = useState<EmailTemplate | null>(null);
+const [versionDrawerOpen, setVersionDrawerOpen] = useState(false);
+

+

Data Fetching

+

Fetch Templates Function: +

const fetchTemplates = useCallback(
+  async (params?: EmailTemplatesListParams) => {
+    setLoading(true);
+    try {
+      const { data } = await api.get<EmailTemplatesListResponse>('/email-templates', {
+        params: {
+          page: params?.page ?? 1,
+          limit: params?.limit ?? 20,
+          search: params?.search ?? (debouncedSearch || undefined),
+          category: categoryFilter !== 'ALL' ? categoryFilter : undefined,
+          isActive: activeFilter !== 'ALL' ? activeFilter === 'ACTIVE' : undefined,
+        },
+      });
+      setTemplates(data.templates);
+      setPagination(data.pagination);
+    } catch {
+      message.error('Failed to load templates');
+    } finally {
+      setLoading(false);
+    }
+  },
+  [debouncedSearch, categoryFilter, activeFilter]
+);
+

+

Auto-Refetch on Filter Changes: +

useEffect(() => {
+  fetchTemplates({ page: 1 });
+}, [debouncedSearch, categoryFilter, activeFilter]); // Reset to page 1 on filter change
+

+

Debounce Cleanup: +

useEffect(() => {
+  return () => clearTimeout(searchTimerRef.current);
+}, []);
+

+

Helper Functions

+

Category Color Mapping: +

const getCategoryColor = (category: EmailTemplateCategory): string => {
+  const colors: Record<EmailTemplateCategory, string> = {
+    INFLUENCE: 'blue',
+    MAP: 'green',
+    SYSTEM: 'purple',
+  };
+  return colors[category];
+};
+

+

Open Modal/Drawer: +

const openTestEmailModal = (template: EmailTemplate) => {
+  setSelectedTemplate(template);
+  setTestModalOpen(true);
+};
+
+const openVersionDrawer = (template: EmailTemplate) => {
+  setSelectedTemplate(template);
+  setVersionDrawerOpen(true);
+};
+

+

Delete Handler: +

const handleDelete = async (id: string) => {
+  try {
+    await api.delete(`/email-templates/${id}`);
+    message.success('Template deleted');
+    fetchTemplates();  // Refresh list
+  } catch (err: unknown) {
+    const msg =
+      (err as { response?: { data?: { error?: string } } })?.response?.data?.error ||
+      'Failed to delete template';
+    message.error(msg);
+  }
+};
+

+
+

API Integration

+

Endpoints Used

+

GET /email-templates - List templates with filters +

const { data } = await api.get<EmailTemplatesListResponse>('/email-templates', {
+  params: {
+    page: 1,
+    limit: 20,
+    search: 'campaign',
+    category: 'INFLUENCE',
+    isActive: true,
+  },
+});
+

+

Response: +

{
+  "templates": [
+    {
+      "id": "tmpl_123",
+      "key": "campaign_email",
+      "name": "Campaign Email",
+      "category": "INFLUENCE",
+      "subjectLine": "Take action on {{CAMPAIGN_NAME}}",
+      "htmlContent": "<html>...</html>",
+      "textContent": "Plain text...",
+      "isActive": true,
+      "isSystem": false,
+      "variables": [
+        {
+          "id": "var_1",
+          "key": "CAMPAIGN_NAME",
+          "label": "Campaign Name",
+          "description": "Name of the campaign",
+          "isRequired": true,
+          "sampleValue": "Stop Deforestation"
+        }
+      ],
+      "createdAt": "2026-01-15T10:00:00Z",
+      "updatedAt": "2026-02-10T14:30:00Z"
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 20,
+    "total": 15,
+    "totalPages": 1
+  }
+}
+

+

DELETE /email-templates/:id - Delete template +

await api.delete(`/email-templates/${id}`);
+

+

Response: 204 No Content on success

+
+

Code Examples

+

Debounced Search Implementation

+
const [search, setSearch] = useState('');
+const [debouncedSearch, setDebouncedSearch] = useState('');
+const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
+
+const handleSearchChange = (value: string) => {
+  setSearch(value);  // Update input immediately for responsive UI
+  clearTimeout(searchTimerRef.current);  // Cancel previous timer
+  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
+};
+
+// Cleanup on unmount
+useEffect(() => {
+  return () => clearTimeout(searchTimerRef.current);
+}, []);
+
+// Trigger API fetch when debouncedSearch changes
+useEffect(() => {
+  fetchTemplates({ page: 1 });
+}, [debouncedSearch]);
+
+

Why 300ms? Standard debounce for search inputs balances responsiveness with API efficiency.

+

Conditional Delete Button

+
{!record.isSystem && (
+  <Popconfirm
+    title="Delete template?"
+    description="This action cannot be undone."
+    onConfirm={() => handleDelete(record.id)}
+  >
+    <Button type="link" size="small" danger icon={<DeleteOutlined />}>
+      Delete
+    </Button>
+  </Popconfirm>
+)}
+
+

Pattern: Hide delete button entirely for system templates rather than showing disabled button (clearer UI).

+ +
// Open modal with selected template
+const openTestEmailModal = (template: EmailTemplate) => {
+  setSelectedTemplate(template);
+  setTestModalOpen(true);
+};
+
+// Close modal and clear selection
+const closeTestEmailModal = () => {
+  setTestModalOpen(false);
+  setSelectedTemplate(null);
+};
+
+// Render modal (only when template selected)
+{selectedTemplate && (
+  <TestEmailModal
+    open={testModalOpen}
+    template={selectedTemplate}
+    onClose={closeTestEmailModal}
+    onSuccess={() => {
+      message.success('Test email sent successfully');
+      closeTestEmailModal();
+    }}
+  />
+)}
+
+

Pattern: Conditional rendering with selectedTemplate && prevents rendering modal with null template.

+

Category Color Function

+
const getCategoryColor = (category: EmailTemplateCategory): string => {
+  const colors: Record<EmailTemplateCategory, string> = {
+    INFLUENCE: 'blue',
+    MAP: 'green',
+    SYSTEM: 'purple',
+  };
+  return colors[category];
+};
+
+// Usage in column render
+render: (category: EmailTemplateCategory) => (
+  <Tag color={getCategoryColor(category)}>{category}</Tag>
+)
+
+

Relative Time with dayjs

+
import dayjs from 'dayjs';
+import relativeTime from 'dayjs/plugin/relativeTime';
+
+dayjs.extend(relativeTime);
+
+// In column render
+render: (date: string) => dayjs(date).fromNow()
+// Output: "2 hours ago", "3 days ago", "a month ago"
+
+
+

Performance Considerations

+ +

Problem: Typing in search input triggers API call on every keystroke (excessive network traffic).

+

Solution: 300ms debounce timer delays API call until user stops typing.

+

Implementation: +

const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
+
+const handleSearchChange = (value: string) => {
+  setSearch(value);  // Immediate UI update
+  clearTimeout(searchTimerRef.current);
+  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
+};
+

+

Benefit: Reduces API calls by ~80% for typical search behavior.

+

useCallback for Fetch Function

+
const fetchTemplates = useCallback(
+  async (params?: EmailTemplatesListParams) => { /* ... */ },
+  [debouncedSearch, categoryFilter, activeFilter]
+);
+
+

Why: Prevents infinite re-render loop when fetchTemplates is used in useEffect dependency array.

+

Table Pagination

+

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 → Only 20 in memory at a time

+

Benefit: Handles large template libraries without performance degradation.

+

Component Conditional Rendering

+
{selectedTemplate && (
+  <TestEmailModal
+    open={testModalOpen}
+    template={selectedTemplate}
+    onClose={closeTestEmailModal}
+  />
+)}
+
+

Why: Modal only mounted when needed, saves memory and avoids rendering with null props.

+
+

Responsive Design

+

Responsive Columns

+

Column Configuration: +

{
+  title: 'Category',
+  dataIndex: 'category',
+  responsive: ['md'],  // Hide on mobile (< 768px)
+},
+{
+  title: 'Subject',
+  dataIndex: 'subjectLine',
+  responsive: ['lg'],  // Hide on tablets (< 992px)
+},
+{
+  title: 'Active',
+  dataIndex: 'isActive',
+  responsive: ['md'],  // Hide on mobile
+},
+{
+  title: 'Updated',
+  dataIndex: 'updatedAt',
+  responsive: ['md'],  // Hide on mobile
+},
+

+

Mobile View (< 768px): +- Visible: Name, Actions +- Hidden: Category, Subject, Active, Updated

+

Tablet View (768px - 991px): +- Visible: Name, Category, Active, Updated, Actions +- Hidden: Subject

+

Desktop View (≥ 992px): +- All columns visible

+

Filter Layout

+
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
+  <Input style={{ width: 300 }} />
+  <Select style={{ width: 180 }} />
+  <Select style={{ width: 150 }} />
+</div>
+
+

flexWrap: 'wrap' ensures filters stack vertically on narrow screens.

+

Table Scroll

+
<Table
+  scroll={{ x: 'max-content' }}
+/>
+
+

Horizontal scroll on mobile prevents column squishing.

+
+

Accessibility

+

Keyboard Navigation

+
    +
  • Tab: Navigate through search input, filter selects, action buttons
  • +
  • Enter: Activate focused button (Edit, Test, Versions, Delete)
  • +
  • Escape: Close open modals/drawers
  • +
  • Arrow keys: Navigate table rows (native Ant Design behavior)
  • +
+

Icon Labels

+

All icon-only buttons have text labels: +

<Button icon={<EditOutlined />}>Edit</Button>
+<Button icon={<MailOutlined />}>Test</Button>
+<Button icon={<HistoryOutlined />}>Versions</Button>
+

+

Not icon-only buttons – clear action labels improve accessibility.

+

Search Input

+
<Input
+  placeholder="Search by name or key..."
+  prefix={<SearchOutlined />}
+  allowClear
+/>
+
+
    +
  • Placeholder text: Describes what to search for
  • +
  • Prefix icon: Visual search indicator
  • +
  • allowClear: X button to clear input (keyboard accessible)
  • +
+

Popconfirm for Destructive Actions

+
<Popconfirm
+  title="Delete template?"
+  description="This action cannot be undone."
+  onConfirm={() => handleDelete(record.id)}
+>
+  <Button danger>Delete</Button>
+</Popconfirm>
+
+

Two-step confirmation prevents accidental deletion (important for accessibility and safety).

+
+

Troubleshooting

+

Templates Not Loading

+

Symptoms: +- Empty table +- Loading spinner never stops +- Error message "Failed to load templates"

+

Causes: +1. API server not running (port 4000) +2. Network error +3. Missing authentication token +4. Database connection issue

+

Solutions: +

# Check API server logs
+docker compose logs -f api
+
+# Verify API is accessible
+curl -H "Authorization: Bearer <token>" http://localhost:4000/email-templates
+
+# Restart API container
+docker compose restart api
+

+

Search Not Working

+

Symptoms: +- Typing in search input doesn't filter results +- Search triggers on every keystroke (should debounce)

+

Causes: +1. Debounce timer not clearing properly +2. debouncedSearch state not updating +3. API not receiving search param

+

Debug: +

// Add console log to verify debounce
+const handleSearchChange = (value: string) => {
+  console.log('Input value:', value);
+  setSearch(value);
+  clearTimeout(searchTimerRef.current);
+  searchTimerRef.current = setTimeout(() => {
+    console.log('Debounced search:', value);
+    setDebouncedSearch(value);
+  }, 300);
+};
+

+

Delete Button Missing

+

Symptoms: +- Delete button not visible for some templates

+

Cause: +- Template is a system template (isSystem: true)

+

Expected Behavior: +- System templates cannot be deleted (protected) +- Delete button intentionally hidden for system templates

+

Verification: +

// Check template data
+console.log('Template:', record.isSystem);
+// If true, delete button correctly hidden
+

+

Modal/Drawer Not Opening

+

Symptoms: +- Clicking Test or Versions button does nothing +- Modal/drawer remains closed

+

Causes: +1. selectedTemplate is null +2. State update not triggering +3. Modal/drawer component not rendered

+

Debug: +

const openTestEmailModal = (template: EmailTemplate) => {
+  console.log('Opening test modal for:', template.id);
+  setSelectedTemplate(template);
+  setTestModalOpen(true);
+};
+
+// Check render condition
+console.log('Selected template:', selectedTemplate);
+console.log('Modal open:', testModalOpen);
+

+

Pagination Not Working

+

Symptoms: +- Clicking page numbers doesn't load new data +- Page stays on 1

+

Cause: +- handleTableChange not wired correctly +- Pagination params not passed to API

+

Debug: +

const handleTableChange = (pag: TablePaginationConfig) => {
+  console.log('Page change:', pag.current, pag.pageSize);
+  fetchTemplates({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
+};
+

+

Subject Line Truncation

+

Symptoms: +- Long subject lines cut off without ellipsis

+

Cause: +- CSS ellipsis not applied

+

Fix: +

render: (subject: string) => (
+  <Text ellipsis style={{ maxWidth: 300 }}>
+    {subject.length > 50 ? `${subject.slice(0, 50)}...` : subject}
+  </Text>
+)
+

+

Alternative: Use Ant Design Typography.Text with ellipsis prop for automatic truncation.

+
+ +

Backend Integration

+ +

Frontend Components

+ +

Editor Page

+ +

Features

+ +

User Guides

+ +

Troubleshooting

+ + +
    +
  • CampaignsPage - Campaign management (uses email templates)
  • +
  • ShiftsPage - Shift management (uses email templates)
  • +
  • SettingsPage - Global settings including email configuration
  • +
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/gitea-page/index.html b/mkdocs/site/v2/frontend/pages/admin/gitea-page/index.html new file mode 100644 index 00000000..331dab94 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/gitea-page/index.html @@ -0,0 +1,6357 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Gitea - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

GiteaPage

+

Overview

+

File: admin/src/pages/GiteaPage.tsx

+

Route: /app/services/gitea

+

Role Requirements: Any authenticated user

+

Purpose: Provides an embedded interface to the Gitea Git repository hosting service via iframe. Gitea is a self-hosted Git service (similar to GitHub/GitLab) that allows developers to manage source code repositories, issues, pull requests, and collaboration. This page embeds Gitea with status monitoring and mobile device detection.

+

Key Features: +- Full-page iframe embed of Gitea service +- Service online/offline status monitoring +- Mobile device detection with warning screen +- "Refresh" and "Open in New Tab" buttons +- Fullbleed layout for maximum repository browser space +- Git repository management, issue tracking, pull requests

+

Layout: AppLayout with fullbleed

+
+

Features

+

1. Service Status Monitoring

+

Status Display: +- Green "Online" badge when Gitea is accessible +- Red "Offline" badge when unavailable +- Blue "Checking..." badge during status check

+

2. Mobile Device Detection

+

Mobile Warning: +- BranchesOutlined icon (48px) +- Message: "The Git repository browser requires a desktop browser" +- "Open in New Tab" button for external access

+

3. Git Repository Management

+

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

+
+

Component Structure

+
export default function GiteaPage() {
+  const { setPageHeader } = useOutletContext<AppOutletContext>();
+  const screens = Grid.useBreakpoint();
+  const isMobile = !screens.md;
+
+  const [online, setOnline] = useState<boolean | null>(null);
+  const [config, setConfig] = useState<ServicesConfig | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  const fetchStatus = useCallback(async () => {
+    try {
+      const [statusRes, configRes] = await Promise.all([
+        api.get<ServicesStatus>('/services/status'),
+        api.get<ServicesConfig>('/services/config'),
+      ]);
+      setOnline(statusRes.data.gitea.online);
+      setConfig(configRes.data);
+    } catch {
+      setOnline(false);
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    fetchStatus();
+  }, [fetchStatus]);
+
+  const serviceUrl = config
+    ? buildServiceUrl(config.giteaSubdomain, config.domain, config.giteaPort)
+    : null;
+
+  // Header actions, mobile warning, loading, offline states...
+  // Iframe embed
+
+  return (
+    <iframe
+      src={serviceUrl}
+      style={{
+        width: '100%',
+        height: 'calc(100vh - 64px)',
+        border: 'none',
+        display: 'block',
+      }}
+      title="Gitea"
+    />
+  );
+}
+
+
+

API Integration

+

Endpoints Used

+
    +
  1. GET /api/services/status - Check Gitea health
  2. +
  3. GET /api/services/config - Fetch subdomain/port config
  4. +
+

Example Responses

+

Status: +

{
+  "gitea": { "online": true }
+}
+

+

Config: +

{
+  "domain": "cmlite.org",
+  "giteaSubdomain": "git",
+  "giteaPort": 3030
+}
+

+

Service URL: +- Production: http://git.cmlite.org +- Development: http://localhost:3030

+
+

User Workflow

+

Accessing Gitea

+
    +
  1. Navigate to "Services" → "Git Repository" in sidebar
  2. +
  3. Check status badge (Online/Offline)
  4. +
  5. View Gitea interface in iframe
  6. +
  7. Or click "Open in New Tab" for full window
  8. +
+

Common Use Cases

+

Repository Management: +- Create new repository for project +- Clone repository URL for local development +- Browse code, commits, branches

+

Collaboration: +- Create issues for bugs/features +- Submit pull requests for code review +- Comment on code changes +- Merge approved pull requests

+

Documentation: +- Edit project wiki +- Update README files +- Maintain changelog

+
+

Troubleshooting

+

Problem: Gitea Shows "Offline"

+

Solutions:

+
    +
  1. +

    Check Docker container: +

    docker compose ps gitea
    +

    +
  2. +
  3. +

    Check logs: +

    docker compose logs gitea
    +

    +
  4. +
  5. +

    Restart service: +

    docker compose restart gitea
    +

    +
  6. +
+

Problem: Login Required

+

Symptoms: Iframe shows Gitea login screen

+

Solutions:

+
    +
  1. Check Gitea credentials in .env:
  2. +
  3. GITEA_ADMIN_USER
  4. +
  5. +

    GITEA_ADMIN_PASSWORD

    +
  6. +
  7. +

    Login manually with admin credentials

    +
  8. +
  9. +

    Create user account if needed

    +
  10. +
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/index.html b/mkdocs/site/v2/frontend/pages/admin/index.html new file mode 100644 index 00000000..969db903 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/index.html @@ -0,0 +1,6662 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Admin Pages - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Admin Pages

+

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.

+

Route Context

+
    +
  • Prefix: /app/*
  • +
  • Layout: AppLayout (sidebar navigation)
  • +
  • Auth Required: Yes
  • +
  • Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN (feature-specific)
  • +
+

Dashboard & Overview

+

Dashboard Page

+

Route: /app/dashboard

+

Main admin landing page with:

+
    +
  • Recent activity feed
  • +
  • Quick statistics (users, campaigns, locations)
  • +
  • Action shortcuts
  • +
  • System status overview
  • +
+

Role: Any admin role

+

User Management

+

Users Page

+

Route: /app/users

+

User CRUD interface with:

+
    +
  • Paginated user table
  • +
  • Search and filter
  • +
  • Role assignment
  • +
  • User creation/editing
  • +
  • Bulk operations
  • +
+

Role: SUPER_ADMIN only

+

Settings Page

+

Route: /app/settings

+

Global site settings:

+
    +
  • Site name and branding
  • +
  • Contact information
  • +
  • Feature flags
  • +
  • API configuration
  • +
+

Role: SUPER_ADMIN only

+

Influence Module

+

Campaigns Page

+

Route: /app/influence/campaigns

+

Campaign management:

+
    +
  • Campaign CRUD table
  • +
  • Status filtering
  • +
  • Email stats drawer
  • +
  • Target audience configuration
  • +
+

Role: SUPER_ADMIN, INFLUENCE_ADMIN

+

Responses Page

+

Route: /app/influence/responses

+

Response moderation:

+
    +
  • Response table with filters
  • +
  • Verification status
  • +
  • Detail drawer
  • +
  • Bulk moderation
  • +
  • Export options
  • +
+

Role: SUPER_ADMIN, INFLUENCE_ADMIN

+

Representatives Page

+

Route: /app/influence/representatives

+

Representative cache:

+
    +
  • Lookup by postal code
  • +
  • Cache statistics
  • +
  • Manual cache refresh
  • +
  • Representative details
  • +
+

Role: SUPER_ADMIN, INFLUENCE_ADMIN

+

Email Queue Page

+

Route: /app/influence/email-queue

+

BullMQ queue monitoring:

+
    +
  • Queue statistics
  • +
  • Job status (active, completed, failed)
  • +
  • Pause/resume controls
  • +
  • Failed job retry
  • +
  • Queue cleanup
  • +
+

Role: SUPER_ADMIN, INFLUENCE_ADMIN

+

Map Module

+

Locations Page

+

Route: /app/map/locations

+

Location database management:

+
    +
  • Location CRUD table
  • +
  • CSV import/export
  • +
  • Geocoding controls
  • +
  • Map integration
  • +
  • NAR import
  • +
  • Bulk operations
  • +
+

Role: SUPER_ADMIN, MAP_ADMIN

+

Cuts Page

+

Route: /app/map/cuts

+

Geographic cut management:

+
    +
  • Cut CRUD table
  • +
  • Map drawing interface
  • +
  • Polygon editing
  • +
  • Point-in-polygon queries
  • +
  • Export options
  • +
+

Role: SUPER_ADMIN, MAP_ADMIN

+

Shifts Page

+

Route: /app/map/shifts

+

Volunteer shift management:

+
    +
  • Shift CRUD table
  • +
  • Signups drawer
  • +
  • Email all volunteers
  • +
  • Cut assignment
  • +
  • Status tracking
  • +
+

Role: SUPER_ADMIN, MAP_ADMIN

+

Map Settings Page

+

Route: /app/map/settings

+

Map configuration:

+
    +
  • Default center coordinates
  • +
  • Default zoom level
  • +
  • Walk sheet settings
  • +
  • Display preferences
  • +
+

Role: SUPER_ADMIN, MAP_ADMIN

+

Data Quality Dashboard Page

+

Route: /app/map/data-quality

+

Geocoding quality metrics:

+
    +
  • Geocode success rates by provider
  • +
  • Failed geocode list
  • +
  • Provider statistics
  • +
  • Retry controls
  • +
+

Role: SUPER_ADMIN, MAP_ADMIN

+

Canvassing

+

Canvass Dashboard Page

+

Route: /app/canvass/dashboard

+

Canvass monitoring:

+
    +
  • Active session tracking
  • +
  • Visit statistics
  • +
  • Cut progress
  • +
  • Volunteer leaderboard
  • +
  • Activity feed
  • +
+

Role: SUPER_ADMIN, MAP_ADMIN

+

Walk Sheet Page

+

Route: /app/canvass/walk-sheet

+

Printable walk sheet:

+
    +
  • Location list by cut
  • +
  • QR codes for quick access
  • +
  • Walking route order
  • +
  • Browser print integration
  • +
+

Role: SUPER_ADMIN, MAP_ADMIN

+

Cut Export Page

+

Route: /app/canvass/cut-export

+

Printable location report:

+
    +
  • Cut statistics
  • +
  • Location table
  • +
  • Map snapshot
  • +
  • Browser print integration
  • +
+

Role: SUPER_ADMIN, MAP_ADMIN

+

Content Management

+

Landing Pages Page

+

Route: /app/pages

+

Landing page CRUD:

+
    +
  • Page table with search
  • +
  • Create/edit/delete
  • +
  • Slug management
  • +
  • Settings modal
  • +
  • MkDocs export
  • +
+

Role: SUPER_ADMIN

+

Page Editor Page

+

Route: /app/pages/:id/edit

+

GrapesJS WYSIWYG editor:

+
    +
  • Full-screen editor
  • +
  • Custom block library
  • +
  • Ctrl+S save
  • +
  • Desktop-only
  • +
  • Preview mode
  • +
+

Role: SUPER_ADMIN

+

Email Templates Page

+

Route: /app/email-templates

+

Email template CRUD:

+
    +
  • Template table
  • +
  • Variable documentation
  • +
  • Preview rendering
  • +
  • Version history
  • +
+

Role: SUPER_ADMIN

+

Email Template Editor Page

+

Route: /app/email-templates/:id/edit

+

Email template editor:

+
    +
  • Rich text editing
  • +
  • Variable insertion
  • +
  • HTML source view
  • +
  • Preview mode
  • +
  • Desktop-only
  • +
+

Role: SUPER_ADMIN

+

Media Management

+

Library Page

+

Route: /app/media/library

+

Video library management:

+
    +
  • Video CRUD table
  • +
  • Upload modal
  • +
  • Metadata editing
  • +
  • Lock/unlock
  • +
  • Bulk operations
  • +
+

Role: SUPER_ADMIN

+

Shared Media Page

+

Route: /app/media/shared

+

Public gallery administration:

+
    +
  • Shared video management
  • +
  • Category assignment
  • +
  • Visibility controls
  • +
  • Reaction moderation
  • +
+

Role: SUPER_ADMIN

+

Media Jobs Page

+

Route: /app/media/jobs

+

Job queue monitoring:

+
    +
  • Job status tracking
  • +
  • Progress indicators
  • +
  • Error logs
  • +
  • Retry controls
  • +
+

Role: SUPER_ADMIN

+

Service Integrations

+

Listmonk Page

+

Route: /app/services/listmonk

+

Newsletter sync management:

+
    +
  • Connection status
  • +
  • Sync controls
  • +
  • List statistics
  • +
  • Test connection
  • +
+

Role: SUPER_ADMIN

+

Pangolin Page

+

Route: /app/services/pangolin

+

Tunnel setup wizard:

+
    +
  • Tunnel status
  • +
  • Configuration wizard
  • +
  • Site management
  • +
  • Resource configuration
  • +
+

Role: SUPER_ADMIN

+

Docs Page

+

Route: /app/services/docs

+

MkDocs management:

+
    +
  • Service status
  • +
  • Export table
  • +
  • Configuration
  • +
  • Health checks
  • +
+

Role: SUPER_ADMIN

+

MkDocs Settings Page

+

Route: /app/services/mkdocs-settings

+

Documentation configuration:

+
    +
  • Site settings
  • +
  • Export options
  • +
  • Template configuration
  • +
+

Role: SUPER_ADMIN

+

Mini QR Page

+

Route: /app/services/qr

+

QR code service iframe:

+
    +
  • QR generation interface
  • +
  • Download options
  • +
+

Role: SUPER_ADMIN

+

MailHog Page

+

Route: /app/services/mailhog

+

Email capture UI:

+
    +
  • Test email viewer
  • +
  • Email list
  • +
  • Search/filter
  • +
+

Role: SUPER_ADMIN

+

Code Editor Page

+

Route: /app/services/code

+

Code Server management:

+
    +
  • Code editor iframe
  • +
  • File browser
  • +
  • Terminal access
  • +
+

Role: SUPER_ADMIN

+

N8n Page

+

Route: /app/services/n8n

+

Workflow automation:

+
    +
  • n8n interface iframe
  • +
  • Workflow management
  • +
+

Role: SUPER_ADMIN

+

Gitea Page

+

Route: /app/services/gitea

+

Git repository hosting:

+
    +
  • Gitea interface iframe
  • +
  • Repository browser
  • +
+

Role: SUPER_ADMIN

+

NocoDB Page

+

Route: /app/services/nocodb

+

Data browser management:

+
    +
  • NocoDB interface iframe
  • +
  • Database browser
  • +
+

Role: SUPER_ADMIN

+

Monitoring

+

Observability Page

+

Route: /app/observability

+

Monitoring dashboard:

+
    +
  • Prometheus metrics
  • +
  • Grafana dashboards
  • +
  • Alertmanager alerts
  • +
  • Service health
  • +
+

Role: SUPER_ADMIN

+

Admin Page Count

+

Total: 30 admin pages

+

Common Features

+

Most admin pages include:

+
    +
  • Data Tables - Pagination, search, sort, filters
  • +
  • Forms - Validation, error display, submit handlers
  • +
  • Modals - Create/edit forms, detail views
  • +
  • Drawers - Side panels for related data
  • +
  • Action Buttons - CRUD operations, exports, bulk actions
  • +
  • Loading States - Spinners, skeletons
  • +
  • Error Handling - User-friendly error messages
  • +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/landing-pages-page/index.html b/mkdocs/site/v2/frontend/pages/admin/landing-pages-page/index.html new file mode 100644 index 00000000..952fbdc1 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/landing-pages-page/index.html @@ -0,0 +1,8628 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Landing Pages - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

LandingPagesPage

+

Overview

+

The LandingPagesPage provides administrative management of landing pages built with GrapesJS visual editor or raw HTML/CSS code editor. It offers CRUD operations on pages with pagination, search/filter capabilities, and dual publishing modes: standalone React renderer (/p/:slug) and MkDocs integration for Material theme embedding. The page includes advanced features like MkDocs synchronization to import existing override files, validation to repair missing export files, and site building integration for SUPER_ADMIN users. Pages can be configured with SEO metadata, custom MkDocs paths, and theme customization options (hide navigation, hide TOC, full-page standalone mode).

+

Route: /app/pages +Component: admin/src/pages/LandingPagesPage.tsx (510 lines) +Auth Required: Yes (All authenticated users can view; editing requires appropriate role) +Layout: AppLayout +Backend Module: api/src/modules/pages/

+

Screenshot

+

[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.]

+

Features

+
    +
  • Paginated table — Browse all landing pages with configurable page size (20, 50, 100)
  • +
  • Search functionality — Search by title or description (300ms debounce)
  • +
  • Status filtering — Filter by published/draft status
  • +
  • Editor mode selection — Choose between Visual (GrapesJS) or Code editor
  • +
  • CRUD operations — Create, edit, delete landing pages
  • +
  • Publish/unpublish toggle — Control page visibility without deleting
  • +
  • MkDocs integration — Export pages to MkDocs site with Material theme
  • +
  • MkDocs synchronization — Import existing override files as page stubs
  • +
  • Export validation — Detect and repair missing MkDocs export files
  • +
  • Site building — Build MkDocs site directly from page list (SUPER_ADMIN only)
  • +
  • SEO metadata — Configure title, description, and image for each page
  • +
  • Custom MkDocs paths — Override default path (e.g., "about.html" instead of "/p/about")
  • +
  • Standalone mode — Publish full HTML page without MkDocs theme wrapper
  • +
  • Theme customization — Hide navigation sidebar, hide table of contents
  • +
  • Skip export option — Keep page only accessible via /p/:slug (not in MkDocs)
  • +
  • Settings modal — Comprehensive page settings (metadata, MkDocs config, SEO)
  • +
+

User Workflow

+

Creating a New Landing Page

+
    +
  1. Navigate to /app/pages
  2. +
  3. Click "Create Page" button (top-right, primary blue)
  4. +
  5. Modal appears: "Create Landing Page"
  6. +
  7. Fill in fields:
  8. +
  9. Title: (required) e.g., "About Our Campaign"
  10. +
  11. Description: (optional) e.g., "Learn about our mission and values"
  12. +
  13. Editor Mode: (required) Choose "Visual Editor" or "Code Editor"
  14. +
  15. Click "Create & Edit" button
  16. +
  17. Page created in database (slug auto-generated from title)
  18. +
  19. Navigates to /app/pages/:id/edit (page editor)
  20. +
  21. Begin editing page content in chosen editor
  22. +
+

Editor Mode Selection:

+
    +
  • Visual Editor (default):
  • +
  • GrapesJS drag-and-drop builder
  • +
  • No coding required
  • +
  • Pre-built components (text, image, button, form, etc.)
  • +
  • +

    Best for non-technical users

    +
  • +
  • +

    Code Editor:

    +
  • +
  • Raw HTML/CSS/JS editor with Monaco
  • +
  • Full control over markup
  • +
  • Syntax highlighting
  • +
  • Best for technical users
  • +
+

Slug Generation:

+

Title "About Our Campaign" → Slug "about-our-campaign"

+
    +
  • Lowercase
  • +
  • Spaces → hyphens
  • +
  • Special characters removed
  • +
  • Unique (appends -2, -3 if duplicate)
  • +
+

Editing an Existing Page

+
    +
  1. Locate page in table
  2. +
  3. Click Edit icon (pencil) in Actions column
  4. +
  5. Navigates to /app/pages/:id/edit
  6. +
  7. Opens GrapesJS editor (visual) or Monaco editor (code)
  8. +
  9. Make changes to page content
  10. +
  11. Press Ctrl+S (or click Save button in editor)
  12. +
  13. Changes auto-saved to database
  14. +
  15. Return to page list: Click browser back button or navigate to /app/pages
  16. +
+

Publishing a Page

+
    +
  1. Locate draft page in table (Status: "Draft" gray tag)
  2. +
  3. Click "Publish" button in Actions column
  4. +
  5. API request updates published: true
  6. +
  7. Success message: "Page published"
  8. +
  9. Table refreshes to show Status: "Published" (green tag)
  10. +
  11. Effects of publishing:
  12. +
  13. Page becomes visible at /p/:slug (public access)
  14. +
  15. If not skipped, page exported to MkDocs site as override file
  16. +
  17. Page appears in MkDocs navigation (if configured)
  18. +
  19. SEO metadata becomes active
  20. +
+

Unpublishing a Page

+
    +
  1. Locate published page in table (Status: "Published" green tag)
  2. +
  3. Click "Unpublish" button in Actions column
  4. +
  5. Confirmation: No confirmation dialog (immediate action)
  6. +
  7. API request updates published: false
  8. +
  9. Success message: "Page unpublished"
  10. +
  11. Table refreshes to show Status: "Draft" (gray tag)
  12. +
  13. Effects of unpublishing:
  14. +
  15. Page no longer accessible at /p/:slug (404 error)
  16. +
  17. MkDocs export file remains (but page not linked)
  18. +
  19. Page removed from MkDocs navigation
  20. +
  21. SEO metadata inactive
  22. +
+

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

+

Viewing a Published Page

+
    +
  1. Locate published page in table
  2. +
  3. Click Eye icon in Actions column
  4. +
  5. Opens page in new browser tab: /p/:slug
  6. +
  7. View page as public user sees it
  8. +
  9. Close tab to return to admin
  10. +
+

Note: Eye icon only visible for published pages (unpublished pages return 404).

+

Configuring Page Settings

+
    +
  1. Locate page in table
  2. +
  3. Click Settings icon (gear) in Actions column
  4. +
  5. Modal appears: "Page Settings"
  6. +
  7. Configure settings (see settings modal sections below)
  8. +
  9. Click "Save" button
  10. +
  11. API request updates page metadata
  12. +
  13. Success message: "Page settings updated"
  14. +
  15. Table refreshes to show updated values
  16. +
+

Settings Modal Sections:

+

Basic Settings: +- Title (required) +- Description (optional)

+

SEO Settings: +- SEO Title (overrides page title in tag) +- SEO Description (meta description tag) +- SEO Image URL (og:image for social media)</p> +<p><strong>MkDocs Integration:</strong> +- Skip MkDocs Export (checkbox) +- Override Path (custom MkDocs path, e.g., "about.html") +- Full page MkDocs (checkbox for standalone mode) +- Hide navigation sidebar (checkbox, only if not standalone) +- Hide table of contents (checkbox, only if not standalone)</p> +<h3 id="searching-for-pages">Searching for Pages<a class="headerlink" href="#searching-for-pages" title="Permanent link">¶</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">¶</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">¶</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">¶</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">¶</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">¶</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">¶</a></h2> +<h3 id="ant-design-components-used">Ant Design Components Used<a class="headerlink" href="#ant-design-components-used" title="Permanent link">¶</a></h3> +<ul> +<li><strong>Typography.Title</strong> — Page heading ("Landing Pages")</li> +<li><strong>Row / Col</strong> — Grid layout for header and filters</li> +<li><strong>Space</strong> — Button grouping</li> +<li><strong>Button</strong> — Create, Sync, Validate, Build, Edit, Settings, View, Delete</li> +<li><strong>Input</strong> — Search bar with SearchOutlined icon</li> +<li><strong>Select</strong> — Status filter dropdown</li> +<li><strong>Table</strong> — Main data table with pagination</li> +<li><strong>Tag</strong> — Editor mode tags (Visual/Code), status tags (Published/Draft)</li> +<li><strong>Modal</strong> — Create page modal, settings modal</li> +<li><strong>Form</strong> — Create page form, settings form</li> +<li><strong>Form.Item</strong> — Form field wrappers with labels</li> +<li><strong>Input.TextArea</strong> — Description field (multi-line)</li> +<li><strong>Radio.Group</strong> — Editor mode selector (Visual/Code buttons)</li> +<li><strong>Checkbox</strong> — Settings checkboxes (skip export, hide nav, hide TOC)</li> +<li><strong>Divider</strong> — Section separator in settings modal (MkDocs Integration)</li> +<li><strong>Popconfirm</strong> — Delete confirmation dialog</li> +<li><strong>message</strong> — Toast notifications for success/error feedback</li> +</ul> +<h3 id="table-structure">Table Structure<a class="headerlink" href="#table-structure" title="Permanent link">¶</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">¶</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">¶</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">¶</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">¶</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">¶</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">¶</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">¶</a></h2> +<h3 id="endpoints-used">Endpoints Used<a class="headerlink" href="#endpoints-used" title="Permanent link">¶</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">¶</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">¶</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" → "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">¶</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">¶</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">¶</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">¶</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">¶</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">¶</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">¶</a></h2> +<h3 id="complete-create-page-flow">Complete Create Page Flow<a class="headerlink" href="#complete-create-page-flow" title="Permanent link">¶</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">¶</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">¶</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">¶</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">¶</a></h2> +<h3 id="debounced-search-300ms">Debounced Search (300ms)<a class="headerlink" href="#debounced-search-300ms" title="Permanent link">¶</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">¶</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">¶</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">¶</a></h2> +<h3 id="mobile-table-layout">Mobile Table Layout<a class="headerlink" href="#mobile-table-layout" title="Permanent link">¶</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">¶</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">¶</a></h2> +<h3 id="keyboard-navigation">Keyboard Navigation<a class="headerlink" href="#keyboard-navigation" title="Permanent link">¶</a></h3> +<p>All interactive elements are keyboard-accessible:</p> +<p><strong>Table Navigation:</strong> +- <strong>Tab:</strong> Move between action buttons (Edit → Settings → View → Publish → 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 → Description → 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">¶</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">¶</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">¶</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">¶</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">¶</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">¶</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">¶</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">¶</a></h2> +<ul> +<li><a href="/v2/backend/modules/pages.md">Landing Pages Backend Module</a> — Backend page service</li> +<li><a href="/v2/api-reference/pages.md">Landing Pages API Reference</a> — Page endpoints</li> +<li><a href="/v2/frontend/components/grapesjs-editor.md">GrapesJS Editor Component</a> — Visual editor wrapper</li> +<li><a href="/v2/frontend/pages/admin/page-editor-page.md">Page Editor Page</a> — Full-screen page editor</li> +<li><a href="/v2/frontend/pages/public/landing-page.md">Public Landing Page</a> — Public page renderer at /p/:slug</li> +<li><a href="/v2/features/pages/mkdocs-integration.md">MkDocs Integration</a> — MkDocs export + Material theme</li> +<li><a href="/v2/frontend/pages/admin/docs-page.md">DocsPage</a> — MkDocs management (site building, export table)</li> +<li><a href="/v2/user-guides/content-editor-guide.md">User Guide: Content Editor</a> — Landing page creation workflow</li> +<li><a href="/v2/troubleshooting/mkdocs-issues.md">Troubleshooting: MkDocs Issues</a> — MkDocs troubleshooting</li> +</ul> + + + + + + + + + + + + + + </article> + </div> + + +<script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script> + </div> + + <button type="button" class="md-top md-icon" data-md-component="top" hidden> + + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13 20h-2V8l-5.5 5.5-1.42-1.42L12 4.16l7.92 7.92-1.42 1.42L13 8z"/></svg> + Back to top +</button> + + </main> + + <footer class="md-footer"> + + + + <nav class="md-footer__inner md-grid" aria-label="Footer" > + + + <a href="../data-quality-dashboard-page/" class="md-footer__link md-footer__link--prev" aria-label="Previous: Data Quality Dashboard"> + <div class="md-footer__button md-icon"> + + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11z"/></svg> + </div> + <div class="md-footer__title"> + <span class="md-footer__direction"> + Previous + </span> + <div class="md-ellipsis"> + Data Quality Dashboard + </div> + </div> + </a> + + + + <a href="../page-editor-page/" class="md-footer__link md-footer__link--next" aria-label="Next: Page Editor"> + <div class="md-footer__title"> + <span class="md-footer__direction"> + Next + </span> + <div class="md-ellipsis"> + Page Editor + </div> + </div> + <div class="md-footer__button md-icon"> + + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 11v2h12l-5.5 5.5 1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5 16 11z"/></svg> + </div> + </a> + + </nav> + + + <div class="md-footer-meta md-typeset"> + <div class="md-footer-meta__inner md-grid"> + <div class="md-copyright"> + + <div class="md-copyright__highlight"> + Copyright © 2024 The Bunker Operations – <a href="#__consent">Change cookie settings</a> + + </div> + + +</div> + + +<div class="md-social"> + + + + + + <a href="https://gitea.bnkops.com/admin" target="_blank" rel="noopener" title="Gitea Repository" class="md-social__link"> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M173.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6m-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3m44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9M252.8 8C114.1 8 8 113.3 8 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C436.2 457.8 504 362.9 504 252 504 113.3 391.5 8 252.8 8M105.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1m-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7m32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1m-11.4-14.7c-1.6 1-1.6 3.6 0 5.9s4.3 3.3 5.6 2.3c1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2"/></svg> + </a> + + + + + + <a href="https://listmonk.bnkops.com/subscription/form" target="_blank" rel="noopener" title="Newsletter" class="md-social__link"> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M536.4-26.3c9.8-3.5 20.6-1 28 6.3s9.8 18.2 6.3 28l-178 496.9c-5 13.9-18.1 23.1-32.8 23.1-14.2 0-27-8.6-32.3-21.7l-64.2-158c-4.5-11-2.5-23.6 5.2-32.6l94.5-112.4c5.1-6.1 4.7-15-.9-20.6s-14.6-6-20.6-.9l-112.4 94.3c-9.1 7.6-21.6 9.6-32.6 5.2L38.1 216.8c-13.1-5.3-21.7-18.1-21.7-32.3 0-14.7 9.2-27.8 23.1-32.8z"/></svg> + </a> + +</div> + + </div> + </div> +</footer> + + </div> + <div class="md-dialog" data-md-component="dialog"> + <div class="md-dialog__inner md-typeset"></div> + </div> + + + + + + <script id="__config" type="application/json">{"annotate": null, "base": "../../../../..", "features": ["announce.dismiss", "content.action.edit", "content.action.view", "content.code.annotate", "content.code.copy", "content.tooltips", "navigation.expand", "navigation.footer", "navigation.indexes", "navigation.path", "navigation.prune", "navigation.sections", "navigation.tabs", "navigation.tabs.sticky", "navigation.top", "navigation.tracking", "search.highlight", "search.share", "search.suggest", "toc.follow"], "search": "../../../../../assets/javascripts/workers/search.2c215733.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script> + + + <script src="../../../../../assets/javascripts/bundle.79ae519e.min.js"></script> + + <script src="../../../../../javascripts/home.js"></script> + + <script src="../../../../../javascripts/github-widget.js"></script> + + <script src="../../../../../javascripts/gitea-widget.js"></script> + + <script src="../../../../../assets/js/env-config.js"></script> + + <script src="../../../../../assets/js/video-player.js"></script> + + + </body> +</html> \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/listmonk-page/index.html b/mkdocs/site/v2/frontend/pages/admin/listmonk-page/index.html new file mode 100644 index 00000000..8f7bf66f --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/listmonk-page/index.html @@ -0,0 +1,8347 @@ + +<!doctype html> +<html lang="en" class="no-js"> + <head> + + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width,initial-scale=1"> + + <meta name="description" content="Build Power. Not Rent It. Own your digital infrastructure."> + + + <meta name="author" content="Bunker Operations"> + + + <link rel="canonical" href="https://bnkserve.org/v2/frontend/pages/admin/listmonk-page/"> + + + <link rel="prev" href="../email-template-editor-page/"> + + + <link rel="next" href="../pangolin-page/"> + + + + + + <link rel="icon" href="../../../../../assets/favicon.png"> + <meta name="generator" content="mkdocs-1.6.1, mkdocs-material-9.7.1"> + + + + <title>Listmonk - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

ListmonkPage

+

Overview

+

The ListmonkPage provides administrative management of the Listmonk newsletter integration, offering a dual-view interface with management controls (sync, status monitoring) on one tab and an embedded Listmonk admin interface on another. It enables synchronization of campaign participants, map locations, and users to Listmonk subscriber lists, monitors connection status, displays list statistics with subscriber counts, and provides advanced operations like reinitialization and connection testing. The embedded admin tab loads the full Listmonk web UI via iframe with auto-authentication, allowing direct management of campaigns, subscribers, and templates without leaving the admin interface.

+

Route: /app/listmonk +Component: admin/src/pages/ListmonkPage.tsx (395 lines) +Auth Required: Yes (SUPER_ADMIN or INFLUENCE_ADMIN role recommended) +Layout: AppLayout +Backend Module: api/src/modules/listmonk/

+

Screenshot

+

[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×2 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.]

+

Features

+
    +
  • Dual-view interface — Tab switcher between Management and Listmonk Admin
  • +
  • Status monitoring — Real-time sync status, connection state, initialization status
  • +
  • Selective synchronization — Sync participants, locations, or users individually
  • +
  • Bulk synchronization — Sync all lists at once
  • +
  • Connection testing — Test Listmonk API connectivity before syncing
  • +
  • List statistics — Subscriber counts for each list (Participants, Locations, Users)
  • +
  • Advanced operations — Reinitialize lists if corrupted or missing
  • +
  • Embedded Listmonk admin — Full Listmonk UI loaded in iframe with auto-authentication
  • +
  • External Listmonk access — Open Listmonk in new tab (direct access on port 9001)
  • +
  • Error reporting — Display last sync error with timestamp
  • +
  • Last sync tracking — Relative time since last successful sync
  • +
  • Sync failure counts — Track failed subscriber additions (shown in warnings)
  • +
  • Fullbleed iframe — Listmonk Admin tab removes padding for full-screen experience
  • +
+

User Workflow

+

Checking Sync Status

+
    +
  1. Navigate to /app/listmonk
  2. +
  3. Ensure "Management" tab is selected (default)
  4. +
  5. Observe "Status" card (left column):
  6. +
  7. Sync Enabled: Badge shows Enabled (green) or Disabled (red)
  8. +
  9. Connection: Badge shows Connected (green), Disconnected (orange), or N/A (gray)
  10. +
  11. Lists Initialized: Badge shows Yes (green) or No (gray)
  12. +
  13. Last Sync: Relative time (e.g., "2 minutes ago") or "Never"
  14. +
  15. Last Error: Error message or "None"
  16. +
  17. Check "List Statistics" table:
  18. +
  19. Participants: Subscriber count for campaign participants
  20. +
  21. Locations: Subscriber count for map locations
  22. +
  23. Users: Subscriber count for user accounts
  24. +
+

Sync Enabled States: +- Enabled (green): LISTMONK_SYNC_ENABLED=true in .env, sync operations allowed +- Disabled (red): LISTMONK_SYNC_ENABLED=false in .env, sync operations blocked

+

Connection States: +- Connected (green): Listmonk API reachable, credentials valid +- Disconnected (orange): Listmonk API unreachable or credentials invalid (only shown if sync enabled) +- N/A (gray): Sync disabled, connection not tested

+

Lists Initialized: +- Yes (green): Listmonk lists (Participants, Locations, Users) exist and ready +- No (gray): Lists not yet created (click "Reinitialize Lists" to create)

+

Testing Listmonk Connection

+

When to Test: +- Before first sync (verify credentials) +- After updating Listmonk URL/credentials +- Troubleshooting sync failures

+

Steps:

+
    +
  1. Click "Test Connection" button (top-right header)
  2. +
  3. Loading spinner appears on button
  4. +
  5. Backend tests Listmonk API connection:
  6. +
  7. GET /api/health endpoint
  8. +
  9. Verifies basic auth credentials
  10. +
  11. Checks API version compatibility
  12. +
  13. Result message appears:
  14. +
  15. Success: "Connection successful" (green toast)
  16. +
  17. Warning: "Connection partially successful - check configuration" (orange toast)
  18. +
  19. Error: "Connection failed - check Listmonk URL and credentials" (red toast)
  20. +
  21. Status card refreshes to show updated connection state
  22. +
+

Success Criteria: +- Listmonk API responds to /api/health endpoint +- Credentials (username/password) authenticate successfully +- API version is compatible (v2.0+)

+

Syncing Participants to Listmonk

+

What is "Participants"?

+

Campaign participants who submitted responses via the response wall. Synced to Listmonk "Participants" list for newsletter targeting.

+

Steps:

+
    +
  1. Click "Sync Participants" button in "Sync Actions" card
  2. +
  3. Loading spinner appears on button
  4. +
  5. Backend fetches all campaign participants from database:
  6. +
  7. Query: SELECT DISTINCT email, name FROM Response WHERE verified = true
  8. +
  9. Filter: Only verified responses (email confirmed)
  10. +
  11. For each participant:
  12. +
  13. Check if subscriber exists in Listmonk "Participants" list
  14. +
  15. If not exists, create new subscriber with name and email
  16. +
  17. If exists, update subscriber attributes (last campaign, response count)
  18. +
  19. Result message appears:
  20. +
  21. Success: "Synced participants: 347 created, 23 updated"
  22. +
  23. Warning: "Synced participants: 347 created, 23 updated, 5 failed - check logs"
  24. +
  25. Status card and list statistics update to show new counts
  26. +
+

Sync Logic:

+
// Fetch participants from database
+const participants = await prisma.response.findMany({
+  where: { verified: true },
+  distinct: ['email'],
+  select: { email: true, name: true, campaignId: true },
+});
+
+// For each participant
+for (const participant of participants) {
+  try {
+    // Check if subscriber exists
+    const existingSubscriber = await listmonkClient.getSubscriberByEmail(participant.email);
+
+    if (existingSubscriber) {
+      // Update existing subscriber
+      await listmonkClient.updateSubscriber(existingSubscriber.id, {
+        name: participant.name,
+        attribs: { lastCampaign: participant.campaignId },
+      });
+      updated++;
+    } else {
+      // Create new subscriber
+      await listmonkClient.createSubscriber({
+        email: participant.email,
+        name: participant.name,
+        lists: [participantsListId],
+        attribs: { source: 'campaign_response' },
+      });
+      created++;
+    }
+  } catch (error) {
+    failed++;
+  }
+}
+
+

Syncing Locations to Listmonk

+

What is "Locations"?

+

Map locations (residential addresses, campaign offices, etc.). Synced to Listmonk "Locations" list for geographic targeting.

+

Steps:

+
    +
  1. Click "Sync Locations" button in "Sync Actions" card
  2. +
  3. Loading spinner appears on button
  4. +
  5. Backend fetches all locations with valid email addresses:
  6. +
  7. Query: SELECT * FROM Location WHERE email IS NOT NULL AND deletedAt IS NULL
  8. +
  9. Filter: Only locations with email, not soft-deleted
  10. +
  11. For each location:
  12. +
  13. Check if subscriber exists in Listmonk "Locations" list
  14. +
  15. If not exists, create new subscriber with address details
  16. +
  17. If exists, update subscriber attributes (address, postal code, cut)
  18. +
  19. Result message appears:
  20. +
  21. Success: "Synced locations: 203 created, 45 updated"
  22. +
  23. Status card and list statistics update
  24. +
+

Subscriber Attributes:

+
{
+  email: location.email,
+  name: location.name || location.address,  // Fallback to address if no name
+  lists: [locationsListId],
+  attribs: {
+    address: location.address,
+    postalCode: location.postalCode,
+    city: location.city,
+    cutId: location.cutId,
+    province: location.province,
+  },
+}
+
+

Syncing Users to Listmonk

+

What is "Users"?

+

User accounts (admins, volunteers, etc.). Synced to Listmonk "Users" list for internal communications.

+

Steps:

+
    +
  1. Click "Sync Users" button in "Sync Actions" card
  2. +
  3. Loading spinner appears on button
  4. +
  5. Backend fetches all user accounts:
  6. +
  7. Query: SELECT * FROM User WHERE deletedAt IS NULL
  8. +
  9. Filter: Exclude soft-deleted users
  10. +
  11. For each user:
  12. +
  13. Check if subscriber exists in Listmonk "Users" list
  14. +
  15. If not exists, create new subscriber with role info
  16. +
  17. If exists, update subscriber attributes (role, last login)
  18. +
  19. Result message appears:
  20. +
  21. Success: "Synced users: 15 created, 3 updated"
  22. +
  23. Status card and list statistics update
  24. +
+

Subscriber Attributes:

+
{
+  email: user.email,
+  name: user.name,
+  lists: [usersListId],
+  attribs: {
+    role: user.role,
+    lastLogin: user.lastLogin,
+  },
+}
+
+

Syncing All Lists at Once

+

When to Use: +- Initial setup (populate all lists) +- After bulk data import (NAR import, CSV import) +- Regular maintenance (weekly/monthly sync)

+

Steps:

+
    +
  1. Click "Sync All" button (primary blue, bottom-right of "Sync Actions" card)
  2. +
  3. Loading spinner appears on button
  4. +
  5. Backend syncs all three lists sequentially:
  6. +
  7. First: Sync participants
  8. +
  9. Second: Sync locations
  10. +
  11. Third: Sync users
  12. +
  13. Result message shows aggregated counts:
  14. +
  15. Success: "Synced all lists: 347 participants, 203 locations, 15 users"
  16. +
  17. Warning: "Synced all lists: 565 total, 8 failed - check logs"
  18. +
  19. Status card and list statistics update to show all new counts
  20. +
+

Performance:

+
    +
  • Sequential execution: Lists synced one at a time (not parallel)
  • +
  • Duration: Typically 10-30 seconds for 500+ subscribers
  • +
  • Idempotent: Safe to run multiple times (creates or updates, no duplicates)
  • +
+

Reinitializing Listmonk Lists

+

When to Reinitialize: +- Lists accidentally deleted in Listmonk +- Fresh Listmonk installation +- Corrupted list data

+

Steps:

+
    +
  1. Scroll to "Advanced" section at bottom
  2. +
  3. Click to expand "Advanced" collapse panel
  4. +
  5. Click "Reinitialize Lists" button
  6. +
  7. Confirmation popconfirm appears: "Reinitialize Lists. This will re-create any missing lists in Listmonk. Existing lists are preserved."
  8. +
  9. Click "Reinitialize" to confirm (or click outside to cancel)
  10. +
  11. Loading spinner appears on button
  12. +
  13. Backend checks for existence of each list (Participants, Locations, Users)
  14. +
  15. For each missing list:
  16. +
  17. Create new list with name and type (public/private)
  18. +
  19. Set list description
  20. +
  21. Success message: "Lists reinitialized" (or error if creation fails)
  22. +
  23. Status card updates to show "Lists Initialized: Yes"
  24. +
+

Important: Reinitialization only creates missing lists. Existing lists are NOT deleted or modified. Existing subscribers remain intact.

+

Accessing Embedded Listmonk Admin

+

What is "Listmonk Admin" Tab?

+

Full Listmonk web UI embedded in iframe, allowing direct management without leaving admin interface.

+

Steps:

+
    +
  1. Click "Listmonk Admin" button in tab switcher (top-right header)
  2. +
  3. Active tab changes from "Management" to "Listmonk Admin"
  4. +
  5. Page layout changes to fullbleed (removes padding for full-screen iframe)
  6. +
  7. Loading spinner appears while iframe loads
  8. +
  9. Backend generates auto-authentication token:
  10. +
  11. GET /api/listmonk/proxy-url
  12. +
  13. Response: { port: 9001, token: "auto-auth-token-xyz" }
  14. +
  15. Iframe loads Listmonk URL with auth token:
  16. +
  17. URL: //localhost:9001/auth?token=auto-auth-token-xyz
  18. +
  19. Listmonk auto-authenticates user (no manual login required)
  20. +
  21. Full Listmonk UI appears in iframe:
  22. +
  23. Dashboard (campaign stats, subscriber counts)
  24. +
  25. Campaigns (create/send newsletters)
  26. +
  27. Subscribers (view/edit/import)
  28. +
  29. Lists (manage subscriber lists)
  30. +
  31. Templates (email templates with WYSIWYG editor)
  32. +
+

Use Cases: +- Create newsletter campaigns +- View/edit subscribers directly +- Import subscribers from CSV +- Design email templates +- View campaign analytics

+

Limitations: +- Iframe may have slight performance overhead vs. direct access +- Some Listmonk features may require full-screen (use "Open Listmonk" button instead)

+

Opening Listmonk in New Tab

+

When to Use: +- Full-screen Listmonk access (no iframe constraints) +- Better performance (no iframe overhead) +- Working with large subscriber lists (better scrolling)

+

Steps:

+
    +
  1. Click "Open Listmonk" button (top-right header, next to "Test Connection")
  2. +
  3. New browser tab opens with Listmonk URL: //localhost:9001
  4. +
  5. Listmonk login page appears (if not already logged in)
  6. +
  7. Enter Listmonk admin credentials:
  8. +
  9. Username: Value of LISTMONK_WEB_ADMIN_USER env var
  10. +
  11. Password: Value of LISTMONK_WEB_ADMIN_PASSWORD env var
  12. +
  13. Click "Login" to access full Listmonk interface
  14. +
+

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.

+

Component Breakdown

+

Ant Design Components Used

+
    +
  • Typography.Text — Labels, descriptions
  • +
  • Row / Col — Grid layout for status and sync action cards
  • +
  • Card — Container for Status, Sync Actions, List Statistics
  • +
  • Descriptions — Key-value pairs in Status card
  • +
  • Descriptions.Item — Individual status fields
  • +
  • Badge — Status indicators (Enabled/Disabled, Connected/Disconnected, Yes/No)
  • +
  • Space — Button grouping
  • +
  • Button — Sync buttons, Test Connection, Open Listmonk
  • +
  • Radio.Group — Tab switcher (Management / Listmonk Admin)
  • +
  • Radio.Button — Individual tab buttons
  • +
  • Table — List statistics table
  • +
  • Collapse — Advanced section (collapsible)
  • +
  • Popconfirm — Reinitialize confirmation dialog
  • +
  • Alert — Iframe error alert (if load fails)
  • +
  • Spin — Loading indicators (initial load, iframe load, button actions)
  • +
  • App.useApp — Access to message and modal contexts
  • +
  • message — Toast notifications for success/error feedback
  • +
+

Dual-View Tab Switcher

+
<Radio.Group
+  value={activeTab}
+  onChange={(e) => {
+    const tab = e.target.value as 'management' | 'admin';
+    setActiveTab(tab);
+    if (tab === 'admin') loadIframe();  // Lazy-load iframe
+  }}
+  optionType="button"
+  buttonStyle="solid"
+  size="small"
+>
+  <Radio.Button value="management">
+    <SettingOutlined /> Management
+  </Radio.Button>
+  <Radio.Button value="admin">
+    <DesktopOutlined /> Listmonk Admin
+  </Radio.Button>
+</Radio.Group>
+
+

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

+

Status Card

+
<Card title="Status" size="small">
+  <Descriptions column={1} size="small">
+    <Descriptions.Item label="Sync Enabled">
+      <Badge
+        status={status?.enabled ? 'success' : 'error'}
+        text={status?.enabled ? 'Enabled' : 'Disabled'}
+      />
+    </Descriptions.Item>
+    <Descriptions.Item label="Connection">
+      <Badge
+        status={status?.connected ? 'success' : status?.enabled ? 'warning' : 'default'}
+        text={status?.connected ? 'Connected' : status?.enabled ? 'Disconnected' : 'N/A'}
+      />
+    </Descriptions.Item>
+    <Descriptions.Item label="Lists Initialized">
+      <Badge
+        status={status?.initialized ? 'success' : 'default'}
+        text={status?.initialized ? 'Yes' : 'No'}
+      />
+    </Descriptions.Item>
+    <Descriptions.Item label="Last Sync">
+      {status?.lastSyncAt ? dayjs(status.lastSyncAt).fromNow() : 'Never'}
+    </Descriptions.Item>
+    <Descriptions.Item label="Last Error">
+      {status?.lastError || 'None'}
+    </Descriptions.Item>
+  </Descriptions>
+</Card>
+
+

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

+

Sync Actions Card

+
<Card title="Sync Actions" size="small">
+  <Space direction="vertical" style={{ width: '100%' }} size="middle">
+    <Row gutter={[8, 8]}>
+      <Col xs={24} sm={12}>
+        <Button
+          block
+          icon={<SyncOutlined />}
+          loading={syncing.participants}
+          onClick={() => handleSync('participants')}
+          disabled={!status?.enabled}
+        >
+          Sync Participants
+        </Button>
+      </Col>
+      <Col xs={24} sm={12}>
+        <Button
+          block
+          icon={<SyncOutlined />}
+          loading={syncing.locations}
+          onClick={() => handleSync('locations')}
+          disabled={!status?.enabled}
+        >
+          Sync Locations
+        </Button>
+      </Col>
+      <Col xs={24} sm={12}>
+        <Button
+          block
+          icon={<SyncOutlined />}
+          loading={syncing.users}
+          onClick={() => handleSync('users')}
+          disabled={!status?.enabled}
+        >
+          Sync Users
+        </Button>
+      </Col>
+      <Col xs={24} sm={12}>
+        <Button
+          block
+          type="primary"
+          icon={<SyncOutlined />}
+          loading={syncing.all}
+          onClick={handleSyncAll}
+          disabled={!status?.enabled}
+        >
+          Sync All
+        </Button>
+      </Col>
+    </Row>
+  </Space>
+</Card>
+
+

Sync Actions Features: +- Block buttons: Full-width buttons for easy clicking +- 2×2 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)

+

List Statistics Table

+
<Table
+  dataSource={stats?.lists || []}
+  rowKey="name"
+  size="small"
+  loading={loading}
+  pagination={false}
+  columns={[
+    { title: 'List Name', dataIndex: 'name', key: 'name' },
+    {
+      title: 'Subscribers',
+      dataIndex: 'subscriberCount',
+      key: 'subscriberCount',
+      width: 120,
+      align: 'right' as const,
+    },
+  ]}
+  locale={{
+    emptyText: status?.initialized
+      ? 'No lists found'
+      : 'Lists not initialized — run a sync or reinitialize',
+  }}
+/>
+
+

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

+

Embedded Listmonk Iframe

+
{activeTab === 'admin' && (
+  <div>
+    {iframeLoading && (
+      <div style={{ textAlign: 'center', padding: 80 }}>
+        <Spin size="large" />
+      </div>
+    )}
+    {iframeError && (
+      <Alert
+        type="error"
+        message={iframeError}
+        showIcon
+        action={
+          <Button size="small" onClick={loadIframe}>
+            Retry
+          </Button>
+        }
+        style={{ marginBottom: 16 }}
+      />
+    )}
+    {iframeSrc && !iframeLoading && (
+      <iframe
+        src={iframeSrc}
+        style={{
+          width: '100%',
+          height: 'calc(100vh - 64px)',  // Full viewport height minus header
+          border: 'none',
+          display: 'block',
+        }}
+        title="Listmonk Admin"
+      />
+    )}
+  </div>
+)}
+
+

Iframe Features: +- Full viewport height: calc(100vh - 64px) fills available space +- No border: Seamless integration with admin interface +- Loading state: Large spinner while iframe loads +- Error handling: Alert with retry button if iframe fails to load +- Auto-authentication: Token in URL query string (?token=xyz) logs user in automatically

+

State Management

+

Local State (No Zustand Store)

+
const [status, setStatus] = useState<ListmonkStatus | null>(null);
+const [stats, setStats] = useState<ListmonkStats | null>(null);
+const [loading, setLoading] = useState(true);
+const [syncing, setSyncing] = useState<Record<string, boolean>>({});
+const [iframeSrc, setIframeSrc] = useState<string | null>(null);
+const [iframeLoading, setIframeLoading] = useState(false);
+const [iframeError, setIframeError] = useState<string | null>(null);
+const iframeInitialized = useRef(false);
+const [activeTab, setActiveTab] = useState<'management' | 'admin'>('management');
+
+

State Variables: +- status (object | null): Sync status (enabled, connected, initialized, lastSyncAt, lastError) +- stats (object | null): List statistics (lists array with name and subscriberCount) +- loading (boolean): Initial page load state +- syncing (object): Sync button loading states (participants, locations, users, all, test, reinit) +- iframeSrc (string | null): Listmonk iframe URL with auth token +- iframeLoading (boolean): Iframe loading state +- iframeError (string | null): Iframe load error message +- iframeInitialized (ref): Prevents redundant iframe loads (only load once) +- activeTab (string): Currently active tab ('management' or 'admin')

+

No Global State:

+

This page does NOT use Zustand stores. Listmonk data is fetched directly from the API and stored in local state. This is appropriate because: +- Listmonk data is admin-only +- Data changes infrequently (manual sync operations) +- No need to share state between pages +- Simpler architecture without store overhead

+

Lazy Iframe Loading

+

Iframe only loads when Admin tab is selected:

+
const loadIframe = useCallback(async () => {
+  if (iframeInitialized.current && iframeSrc) return;  // Already loaded, skip
+
+  setIframeLoading(true);
+  setIframeError(null);
+  try {
+    const res = await api.get<{ port: number; token: string }>('/listmonk/proxy-url');
+    const { port, token } = res.data;
+    const url = `//${window.location.hostname}:${port}/auth?token=${encodeURIComponent(token)}`;
+    setIframeSrc(url);
+    iframeInitialized.current = true;  // Mark as initialized
+  } catch {
+    setIframeError('Failed to load Listmonk admin — ensure the proxy is running');
+  } finally {
+    setIframeLoading(false);
+  }
+}, [iframeSrc]);
+
+// Load iframe when Admin tab selected
+onChange={(e) => {
+  const tab = e.target.value as 'management' | 'admin';
+  setActiveTab(tab);
+  if (tab === 'admin') loadIframe();
+}}
+
+

Why Lazy Load?

+
    +
  • Performance: Iframe not loaded until needed (saves memory + network)
  • +
  • User experience: Most users stay on Management tab (no iframe overhead)
  • +
  • One-time load: iframeInitialized ref prevents redundant loads
  • +
+

Fullbleed Layout for Iframe

+

When Admin tab is active, page header sets fullBleed: true:

+
useEffect(() => {
+  setPageHeader({
+    title: 'Newsletter / Listmonk',
+    actions: headerActions,
+    fullBleed: activeTab === 'admin',  // Remove padding for full-screen iframe
+  });
+  return () => setPageHeader(null);
+}, [setPageHeader, headerActions, activeTab]);
+
+

Result:

+
    +
  • Management tab: Normal padding (comfortable reading)
  • +
  • Admin tab: No padding (iframe fills entire content area)
  • +
+

API Integration

+

Endpoints Used

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointPurposeAuth
GET/api/listmonkGet sync statusRequired
GET/api/listmonk/statsGet list statisticsRequired
POST/api/listmonk/test-connectionTest Listmonk API connectionRequired
POST/api/listmonk/sync/participantsSync participants listRequired
POST/api/listmonk/sync/locationsSync locations listRequired
POST/api/listmonk/sync/usersSync users listRequired
POST/api/listmonk/sync/allSync all listsRequired
POST/api/listmonk/reinitializeReinitialize listsRequired
GET/api/listmonk/proxy-urlGet iframe URL with auth tokenRequired
+

Load Sync Status

+

Request:

+
const { data } = await api.get<ListmonkStatus>('/listmonk');
+
+

Response (200 OK):

+
{
+  "enabled": true,
+  "connected": true,
+  "initialized": true,
+  "lastSyncAt": "2026-02-11T10:30:00.000Z",
+  "lastError": null
+}
+
+

Response Fields: +- enabled (boolean): Value of LISTMONK_SYNC_ENABLED env var +- connected (boolean): Listmonk API reachable and credentials valid +- initialized (boolean): Listmonk lists (Participants, Locations, Users) exist +- lastSyncAt (ISO 8601 | null): Timestamp of last successful sync +- lastError (string | null): Last error message (or null if no errors)

+

Load List Statistics

+

Request:

+
const { data } = await api.get<ListmonkStats>('/listmonk/stats');
+
+

Response (200 OK):

+
{
+  "lists": [
+    {
+      "name": "Participants",
+      "subscriberCount": 347
+    },
+    {
+      "name": "Locations",
+      "subscriberCount": 203
+    },
+    {
+      "name": "Users",
+      "subscriberCount": 15
+    }
+  ]
+}
+
+

Response Fields: +- lists (array): Array of list objects + - name (string): List name (Participants, Locations, or Users) + - subscriberCount (number): Number of subscribers in list

+

Backend Calculation:

+
const lists = await listmonkClient.getLists();
+const stats = await Promise.all(
+  lists.map(async (list) => {
+    const count = await listmonkClient.getSubscriberCount(list.id);
+    return { name: list.name, subscriberCount: count };
+  })
+);
+return { lists: stats };
+
+

Test Listmonk Connection

+

Request:

+
const { data } = await api.post<{ success: boolean; message: string }>('/listmonk/test-connection');
+
+

Response (200 OK) - Success:

+
{
+  "success": true,
+  "message": "Connection successful - Listmonk v2.3.0"
+}
+
+

Response (200 OK) - Failure:

+
{
+  "success": false,
+  "message": "Connection failed: Authentication error"
+}
+
+

Response Fields: +- success (boolean): Whether connection test passed +- message (string): Result message (success details or error reason)

+

Backend Test:

+
try {
+  // Test Listmonk API health endpoint
+  const health = await listmonkClient.getHealth();
+
+  if (health.version) {
+    return { success: true, message: `Connection successful - Listmonk v${health.version}` };
+  } else {
+    return { success: false, message: 'Connection failed: Invalid response' };
+  }
+} catch (error) {
+  return { success: false, message: `Connection failed: ${error.message}` };
+}
+
+

Sync Participants/Locations/Users

+

Request:

+
const type = 'participants';  // or 'locations' or 'users'
+const { data } = await api.post<ListmonkSyncResult>(`/listmonk/sync/${type}`);
+
+

Response (200 OK):

+
{
+  "success": true,
+  "message": "Synced participants: 347 created, 23 updated",
+  "results": {
+    "created": 347,
+    "updated": 23,
+    "failed": 5
+  }
+}
+
+

Response Fields: +- success (boolean): Whether sync operation completed +- message (string): Result summary +- results (object): + - created (number): New subscribers added + - updated (number): Existing subscribers updated + - failed (number): Subscribers that failed to sync (API errors, validation errors)

+

Backend Workflow:

+
// 1. Fetch data from database
+const participants = await prisma.response.findMany({
+  where: { verified: true },
+  distinct: ['email'],
+});
+
+// 2. Sync to Listmonk
+const results = { created: 0, updated: 0, failed: 0 };
+for (const participant of participants) {
+  try {
+    const existing = await listmonkClient.getSubscriberByEmail(participant.email);
+    if (existing) {
+      await listmonkClient.updateSubscriber(existing.id, { /* ... */ });
+      results.updated++;
+    } else {
+      await listmonkClient.createSubscriber({ /* ... */ });
+      results.created++;
+    }
+  } catch (error) {
+    results.failed++;
+  }
+}
+
+// 3. Update last sync timestamp
+await prisma.listmonkStatus.update({
+  where: { id: 'singleton' },
+  data: { lastSyncAt: new Date() },
+});
+
+return { success: true, message: `Synced participants: ${results.created} created, ${results.updated} updated`, results };
+
+

Sync All Lists

+

Request:

+
const { data } = await api.post<ListmonkSyncAllResult>('/listmonk/sync/all');
+
+

Response (200 OK):

+
{
+  "success": true,
+  "message": "Synced all lists: 347 participants, 203 locations, 15 users",
+  "results": {
+    "participants": {
+      "created": 347,
+      "updated": 23,
+      "failed": 5
+    },
+    "locations": {
+      "created": 203,
+      "updated": 12,
+      "failed": 2
+    },
+    "users": {
+      "created": 15,
+      "updated": 3,
+      "failed": 1
+    }
+  }
+}
+
+

Response Fields: +- success (boolean): Whether all syncs completed +- message (string): Result summary +- results (object): + - participants (object): Participant sync results + - locations (object): Location sync results + - users (object): User sync results

+

Reinitialize Lists

+

Request:

+
await api.post('/listmonk/reinitialize');
+
+

Response (200 OK):

+
{
+  "message": "Lists reinitialized"
+}
+
+

Backend Workflow:

+
// 1. Check for existence of each list
+const lists = await listmonkClient.getLists();
+const participantsList = lists.find(l => l.name === 'Participants');
+const locationsList = lists.find(l => l.name === 'Locations');
+const usersList = lists.find(l => l.name === 'Users');
+
+// 2. Create missing lists
+if (!participantsList) {
+  await listmonkClient.createList({
+    name: 'Participants',
+    type: 'public',
+    description: 'Campaign participants from response wall',
+  });
+}
+
+if (!locationsList) {
+  await listmonkClient.createList({
+    name: 'Locations',
+    type: 'public',
+    description: 'Map locations with email addresses',
+  });
+}
+
+if (!usersList) {
+  await listmonkClient.createList({
+    name: 'Users',
+    type: 'private',
+    description: 'User accounts (admins, volunteers)',
+  });
+}
+
+// 3. Update initialization status
+await prisma.listmonkStatus.update({
+  where: { id: 'singleton' },
+  data: { initialized: true },
+});
+
+return { message: 'Lists reinitialized' };
+
+

Get Iframe Proxy URL

+

Request:

+
const { data } = await api.get<{ port: number; token: string }>('/listmonk/proxy-url');
+
+

Response (200 OK):

+
{
+  "port": 9001,
+  "token": "auto-auth-token-abc123xyz"
+}
+
+

Response Fields: +- port (number): Listmonk service port (typically 9001) +- token (string): Auto-authentication token (valid for 5 minutes)

+

Backend Workflow:

+
// 1. Generate auto-authentication token
+const token = crypto.randomBytes(32).toString('hex');
+
+// 2. Store token in Redis with 5-minute expiry
+await redis.set(`listmonk-auth:${token}`, 'admin', 'EX', 300);
+
+// 3. Return port and token
+return {
+  port: process.env.LISTMONK_PORT || 9001,
+  token,
+};
+
+

Listmonk Auto-Authentication:

+

Listmonk checks Redis for token when /auth?token=xyz is accessed:

+
// Listmonk auth handler
+router.get('/auth', async (req, res) => {
+  const { token } = req.query;
+
+  // Verify token in Redis
+  const userId = await redis.get(`listmonk-auth:${token}`);
+
+  if (userId) {
+    // Auto-login user
+    req.session.userId = userId;
+    res.redirect('/admin');
+  } else {
+    res.status(401).send('Invalid or expired token');
+  }
+});
+
+

Code Examples

+

Complete Sync Flow

+
const handleSync = async (type: 'participants' | 'locations' | 'users') => {
+  setSyncing(s => ({ ...s, [type]: true }));  // Set loading state for specific button
+  try {
+    const res = await api.post<ListmonkSyncResult>(`/listmonk/sync/${type}`);
+
+    // Show success or warning message
+    if (res.data.success) {
+      message.success(res.data.message);
+
+      // Show warning if some failed
+      if (res.data.results && res.data.results.failed > 0) {
+        message.warning(`${res.data.results.failed} failed — check logs for details`);
+      }
+    } else {
+      message.error(res.data.message);
+    }
+
+    // Refresh status and stats
+    await Promise.all([fetchStatus(), fetchStats()]);
+  } catch {
+    message.error(`Failed to sync ${type}`);
+  } finally {
+    setSyncing(s => ({ ...s, [type]: false }));  // Clear loading state
+  }
+};
+
+

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

+

Sync All Flow

+
const handleSyncAll = async () => {
+  setSyncing(s => ({ ...s, all: true }));
+  try {
+    const res = await api.post<ListmonkSyncAllResult>('/listmonk/sync/all');
+
+    if (res.data.success) {
+      message.success(res.data.message);
+
+      // Show warning if any failed
+      if (res.data.results) {
+        const { participants, locations, users } = res.data.results;
+        const totalFailed = participants.failed + locations.failed + users.failed;
+        if (totalFailed > 0) {
+          message.warning(`${totalFailed} total failures — check logs for details`);
+        }
+      }
+    } else {
+      message.error(res.data.message);
+    }
+
+    await Promise.all([fetchStatus(), fetchStats()]);
+  } catch {
+    message.error('Failed to sync all');
+  } finally {
+    setSyncing(s => ({ ...s, all: false }));
+  }
+};
+
+

Aggregate Failure Count:

+

Sums failed count from all three lists (participants + locations + users) to show total failures.

+

Lazy Iframe Loading

+
const iframeInitialized = useRef(false);
+
+const loadIframe = useCallback(async () => {
+  if (iframeInitialized.current && iframeSrc) return;  // Already loaded
+
+  setIframeLoading(true);
+  setIframeError(null);
+  try {
+    const res = await api.get<{ port: number; token: string }>('/listmonk/proxy-url');
+    const { port, token } = res.data;
+    const url = `//${window.location.hostname}:${port}/auth?token=${encodeURIComponent(token)}`;
+    setIframeSrc(url);
+    iframeInitialized.current = true;
+  } catch {
+    setIframeError('Failed to load Listmonk admin — ensure the proxy is running');
+  } finally {
+    setIframeLoading(false);
+  }
+}, [iframeSrc]);
+
+

Lazy Loading Logic:

+
    +
  • First call: iframeInitialized.current is false, so iframe loads
  • +
  • Subsequent calls: iframeInitialized.current is true, so function returns early (no redundant loads)
  • +
  • Ref persists: useRef value persists across re-renders (unlike state)
  • +
+

Performance Considerations

+

Lazy Iframe Loading

+

Iframe only loads when Admin tab is selected:

+
if (tab === 'admin') loadIframe();
+
+

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

+

Parallel Status and Stats Fetching

+

Status and stats fetched in parallel:

+
const fetchAll = useCallback(async () => {
+  setLoading(true);
+  await Promise.all([fetchStatus(), fetchStats()]);
+  setLoading(false);
+}, [fetchStatus, fetchStats]);
+
+

Performance Impact: +- Sequential: 200ms (status) + 200ms (stats) = 400ms total +- Parallel: max(200ms, 200ms) = 200ms total +- Result: 2× faster initial page load

+

Conditional Iframe Rendering

+

Iframe not rendered until tab is selected:

+
{activeTab === 'admin' && (
+  <iframe src={iframeSrc} />
+)}
+
+

Performance Impact: +- Always rendered: Iframe exists in DOM even when hidden (consumes memory) +- Conditional: Iframe only exists in DOM when visible (no memory overhead)

+

Responsive Design

+

Mobile Sync Actions Layout

+

Sync action buttons adapt to mobile viewports:

+
<Row gutter={[8, 8]}>
+  <Col xs={24} sm={12}>  {/* Full width mobile, half width desktop */}
+    <Button block>Sync Participants</Button>
+  </Col>
+  <Col xs={24} sm={12}>
+    <Button block>Sync Locations</Button>
+  </Col>
+  <Col xs={24} sm={12}>
+    <Button block>Sync Users</Button>
+  </Col>
+  <Col xs={24} sm={12}>
+    <Button block>Sync All</Button>
+  </Col>
+</Row>
+
+

Responsive Grid: +- Mobile (xs, <576px): Stacked buttons (full width) +- Desktop (sm+, ≥576px): 2×2 grid (half width each)

+

Iframe Height

+

Iframe fills available viewport height:

+
<iframe
+  src={iframeSrc}
+  style={{
+    height: 'calc(100vh - 64px)',  // Full viewport height minus header
+  }}
+/>
+
+

Calculation: +- 100vh: Full viewport height +- -64px: Subtract header height (64px) +- Result: Iframe fills entire content area below header

+

Accessibility

+

Keyboard Navigation

+

All interactive elements are keyboard-accessible:

+

Tab Switcher: +- Tab: Focus on tab switcher +- Arrow Keys: Navigate between Management and Admin tabs +- Enter/Space: Activate selected tab

+

Buttons: +- Tab: Move between buttons (Test Connection → Sync Participants → Sync Locations...) +- Enter/Space: Activate focused button

+

Iframe: +- Tab: Focus moves into iframe (Listmonk UI is keyboard-accessible) +- Shift+Tab: Focus moves out of iframe back to page controls

+

Screen Reader Support

+

All elements have proper ARIA labels:

+

Status Badges: +

<Badge
+  status={status?.connected ? 'success' : 'warning'}
+  text={status?.connected ? 'Connected' : 'Disconnected'}
+  aria-label={`Listmonk connection status: ${status?.connected ? 'connected' : 'disconnected'}`}
+/>
+

+

Sync Buttons: +

<Button
+  icon={<SyncOutlined />}
+  onClick={() => handleSync('participants')}
+  aria-label="Sync participants to Listmonk Participants list"
+>
+  Sync Participants
+</Button>
+

+

Color Contrast

+

All color-coded elements meet WCAG AA standards:

+

Status Badges: +- Success (green dot): #52c41a = visible on all backgrounds +- Warning (orange dot): #faad14 = visible on all backgrounds +- Error (red dot): #ff4d4f = visible on all backgrounds +- Default (gray dot): #d9d9d9 = visible on all backgrounds

+

Troubleshooting

+

Sync Disabled (Buttons Grayed Out)

+

Problem: All sync buttons are grayed out (disabled state).

+

Diagnosis:

+

Check .env file:

+
grep LISTMONK_SYNC_ENABLED .env
+
+

Expected: LISTMONK_SYNC_ENABLED=true

+

Actual: LISTMONK_SYNC_ENABLED=false or missing

+

Solution:

+
    +
  1. +

    Edit .env file: +

    nano .env
    +

    +
  2. +
  3. +

    Add or update line: +

    LISTMONK_SYNC_ENABLED=true
    +

    +
  4. +
  5. +

    Restart API container: +

    docker compose restart api
    +

    +
  6. +
  7. +

    Refresh page to see enabled buttons

    +
  8. +
+
+

Connection Test Fails

+

Problem: Click "Test Connection", get error: "Connection failed - check Listmonk URL and credentials".

+

Diagnosis:

+

Check Listmonk container:

+
docker compose ps listmonk
+
+

Expected: STATUS = Up

+

Check Listmonk logs:

+
docker compose logs listmonk
+
+

Common errors:

+
ERROR: Database connection failed
+ERROR: Authentication failed for user "api"
+
+

Possible Causes:

+
    +
  1. Listmonk container down:
  2. +
  3. Service not running
  4. +
  5. +

    Failed to start due to configuration error

    +
  6. +
  7. +

    Wrong credentials:

    +
  8. +
  9. LISTMONK_ADMIN_USER or LISTMONK_ADMIN_PASSWORD incorrect
  10. +
  11. +

    API user not created in Listmonk database

    +
  12. +
  13. +

    Network issue:

    +
  14. +
  15. API container cannot reach Listmonk container
  16. +
  17. Docker network misconfigured
  18. +
+

Solution:

+
    +
  1. +

    Start Listmonk: +

    docker compose up -d listmonk
    +

    +
  2. +
  3. +

    Verify credentials: +

    grep LISTMONK_ .env
    +

    +
  4. +
+

Check that LISTMONK_ADMIN_USER and LISTMONK_ADMIN_PASSWORD match Listmonk configuration.

+
    +
  1. Test connection manually: +
    curl -u admin:password http://localhost:9001/api/health
    +
  2. +
+

Expected: {"version":"2.3.0"}

+
+

Lists Not Initialized

+

Problem: Status shows "Lists Initialized: No".

+

Diagnosis:

+

Check Listmonk lists:

+
docker compose exec listmonk listmonk --dump-all-lists
+
+

Expected: Participants, Locations, Users lists present

+

Actual: No lists found

+

Solution:

+
    +
  1. Click "Advanced" to expand advanced section
  2. +
  3. Click "Reinitialize Lists" button
  4. +
  5. Confirm reinitialize
  6. +
  7. Wait for success message: "Lists reinitialized"
  8. +
  9. Refresh page to see "Lists Initialized: Yes"
  10. +
+
+

Iframe Not Loading

+

Problem: Click "Listmonk Admin" tab, but only see loading spinner or error message.

+

Diagnosis:

+

Check iframe error message in Alert:

+
Failed to load Listmonk admin — ensure the proxy is running
+
+

Check browser console for errors:

+
Refused to display 'http://localhost:9001' in a frame because it set 'X-Frame-Options' to 'SAMEORIGIN'
+
+

Possible Causes:

+
    +
  1. Proxy URL endpoint failed:
  2. +
  3. API cannot generate auto-auth token
  4. +
  5. +

    Redis down (tokens stored in Redis)

    +
  6. +
  7. +

    X-Frame-Options blocking:

    +
  8. +
  9. Listmonk sets X-Frame-Options: SAMEORIGIN
  10. +
  11. +

    Browser blocks iframe from different origin

    +
  12. +
  13. +

    CORS issue:

    +
  14. +
  15. Listmonk does not allow iframe embedding from admin domain
  16. +
+

Solution:

+
    +
  1. Check Redis: +
    docker compose ps redis
    +docker compose exec redis redis-cli PING
    +
  2. +
+

Expected: "PONG"

+
    +
  1. Use "Open Listmonk" button instead:
  2. +
  3. Opens Listmonk in new tab (no iframe, no X-Frame-Options issue)
  4. +
  5. +

    Manual login required (no auto-auth token)

    +
  6. +
  7. +

    Configure Listmonk to allow iframes (developer fix):

    +
  8. +
  9. Edit Listmonk nginx config
  10. +
  11. Remove or modify X-Frame-Options header
  12. +
  13. Restart Listmonk container
  14. +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/locations-page/index.html b/mkdocs/site/v2/frontend/pages/admin/locations-page/index.html new file mode 100644 index 00000000..90333e4e --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/locations-page/index.html @@ -0,0 +1,8130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Locations - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

LocationsPage

+

Overview

+

The LocationsPage is the centerpiece of the Map module, providing comprehensive location database management with dual-tab view (table + interactive map), multi-format CSV import (standard, NAR upload, NAR server), multi-provider geocoding, bulk operations, location history tracking, and expandable address units for multi-unit buildings. This is the most feature-rich CRUD page in the admin interface, handling millions of location records for canvassing operations.

+

Route: /app/map/locations +Component: admin/src/pages/LocationsPage.tsx (1960 lines) +Auth Required: Yes (SUPER_ADMIN, MAP_ADMIN roles) +Layout: AppLayout

+

Screenshot

+

[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.]

+

Features

+

Core Features

+
    +
  • Dual-tab interface — Table view (CRUD operations) + Map view (visual management)
  • +
  • Full CRUD operations — Create, read, update, delete locations
  • +
  • Advanced search — 300ms debounced search by address or postal code
  • +
  • Confidence filtering — Filter by High (≥85%), Medium (60-84%), Low (<60%), Manual/None
  • +
  • Building type tracking — SINGLE_FAMILY, MULTI_UNIT, MIXED_USE, COMMERCIAL
  • +
  • Multi-unit support — Expandable rows show Address units (apartments) with contact info
  • +
  • Location history — Track all changes (created, updated, moved, geocoded) with user attribution
  • +
  • Bulk operations — Select multiple rows, bulk delete with confirmation
  • +
+

Geocoding Features

+
    +
  • Multi-provider geocoding — 6 providers (Google, Nominatim, ArcGIS, Photon, Mapbox, Pelias)
  • +
  • Inline geocoding — "Geocode" button in create/edit forms
  • +
  • Geocode Missing — Batch geocode all locations without coordinates
  • +
  • Bulk Re-Geocode — Background job to improve low-confidence locations
  • +
  • Confidence scoring — 0-100% confidence with provider attribution
  • +
  • Reverse geocoding — Click map → auto-fill address from coordinates
  • +
+

Import Features

+
    +
  • Standard CSV import — Simple address/contact CSV with flexible column mapping
  • +
  • NAR Upload import — Statistics Canada NAR format (2025 + legacy) with client-side upload (max 100MB)
  • +
  • NAR Server import — Server-side streaming import for multi-GB NAR datasets
  • +
  • Geographic filters — Import by cut boundary, city name, postal prefix, province, or map area
  • +
  • Residential filtering — Skip non-residential addresses (commercial, industrial)
  • +
  • Deduplication — 5m radius deduplication to prevent duplicate imports
  • +
  • Real-time progress — Live progress bars + stats during NAR server imports
  • +
+

Map Features (Map Tab)

+
    +
  • Interactive Leaflet map — Click-to-add, drag-to-move, GPS locate, fullscreen
  • +
  • Color-coded markers — Visual building type distinction
  • +
  • Cut overlays — Polygon boundaries with toggle controls
  • +
  • Auto-refresh — Viewport-based loading (800ms debounce)
  • +
  • Bounds filtering — Only load locations in current view (max 5000 per request)
  • +
  • Click-to-add mode — Click map → reverse geocode → create form
  • +
  • Drag-to-move mode — Drag marker → update coordinates
  • +
+

Statistics Dashboard

+
    +
  • Building type breakdown — 5 cards (Total, Single Family, Multi-Unit, Mixed Use, Commercial)
  • +
  • Geocode coverage — Percentage geocoded + average confidence
  • +
  • Confidence distribution — 4 cards (High, Medium, Low, Manual/None) with counts + icons
  • +
+

User Workflow

+

Viewing Locations (Table Tab)

+
    +
  1. Navigate to /app/map/locations
  2. +
  3. Page loads with statistics cards at top:
  4. +
  5. First row: Total count, building type breakdowns, geocode percentage
  6. +
  7. Second row: Confidence distribution (high/medium/low/none), average confidence
  8. +
  9. Table tab active by default (20 locations per page)
  10. +
  11. View location details:
  12. +
  13. Address (bold)
  14. +
  15. Building Type tag (color-coded)
  16. +
  17. Total Units (1 for single-family, 2+ for multi-unit)
  18. +
  19. Coordinates (lat, lng to 5 decimals)
  20. +
  21. Geocode confidence (tag + provider name)
  22. +
  23. Created date
  24. +
  25. Actions (edit, delete)
  26. +
  27. Expand row (click anywhere) if Total Units > 1:
  28. +
  29. Shows Address units table (apartments)
  30. +
  31. Columns: Unit, Name, Contact, Building Type, Notes
  32. +
  33. Use pagination at bottom (10/20/50/100 per page)
  34. +
+

Searching and Filtering

+
    +
  1. Search bar (top left):
  2. +
  3. Type address or postal code
  4. +
  5. 300ms debounce (waits for typing pause)
  6. +
  7. Search resets pagination to page 1
  8. +
  9. Confidence filter (top right):
  10. +
  11. Select High, Medium, Low, or Manual/None
  12. +
  13. Filter resets pagination to page 1
  14. +
  15. Clear to show all locations
  16. +
  17. Filters persist during pagination
  18. +
+

Creating a Location Manually

+
    +
  1. Click "Add Location" button in page header
  2. +
  3. Modal opens (600px width) with vertical form
  4. +
  5. Fill required fields:
  6. +
  7. Street Address (base building address, no unit number)
      +
    • Example: "123 Main St, City, Province"
    • +
    • Click "Geocode" button (in input addonAfter) to auto-fill coordinates
    • +
    +
  8. +
  9. Latitude (decimal degrees, 5 decimals, e.g. 45.42153)
  10. +
  11. Longitude (decimal degrees, 5 decimals, e.g. -75.69719)
  12. +
  13. Select Building Type (radio buttons, default: SINGLE_FAMILY):
  14. +
  15. Single Family
  16. +
  17. Multi-Unit
  18. +
  19. Mixed Use
  20. +
  21. Commercial
  22. +
  23. Add Building Notes (optional):
  24. +
  25. Access codes, manager contact, buzzer instructions
  26. +
  27. Example: "Access code: 1234, Ring buzzer for manager"
  28. +
  29. Click "Create" button
  30. +
  31. Success message: "Location created"
  32. +
  33. Modal closes, table refreshes to page 1, stats refresh
  34. +
  35. If Map tab open, new marker appears
  36. +
+

Using Inline Geocoding

+
    +
  1. In create/edit form, type address in Street Address field
  2. +
  3. Click "Geocode" button (AimOutlined icon in input addonAfter)
  4. +
  5. Button shows loading spinner
  6. +
  7. API calls multi-provider geocoding service
  8. +
  9. On success:
  10. +
  11. Latitude and Longitude fields auto-fill
  12. +
  13. Success message: "Geocoded (Google, 95% confidence)"
  14. +
  15. Provider name + confidence shown in message
  16. +
  17. On failure:
  18. +
  19. Error message: "Could not geocode address"
  20. +
  21. Manually enter coordinates or try different address
  22. +
+

Geocoding Missing Locations

+
    +
  1. Click "Geocode Missing" button in page header
  2. +
  3. Button shows loading state
  4. +
  5. API geocodes all locations without coordinates (latitude = null OR longitude = null)
  6. +
  7. Success message: "Geocoded 847 of 1250 locations (403 failed)"
  8. +
  9. Table refreshes, stats update
  10. +
  11. Failed locations remain without coordinates (low-quality addresses)
  12. +
+

Bulk Re-Geocoding (Background Job)

+
    +
  1. Click "Bulk Re-Geocode" button in page header
  2. +
  3. Modal opens (600px width) with form:
  4. +
  5. Confidence Threshold (%): Only geocode below this (default: 60)
  6. +
  7. Building Type Filter: Optionally filter by type
  8. +
  9. Maximum Locations: Process up to N locations (default: 1000, max: 5000)
  10. +
  11. Click "Start Bulk Re-Geocode" button
  12. +
  13. Background job starts (BullMQ queue)
  14. +
  15. Modal shows live progress:
  16. +
  17. Progress bar (percentage complete)
  18. +
  19. Current address being processed
  20. +
  21. Stats: Processed X / Total, Improved, Unchanged, Failed
  22. +
  23. Job completes:
  24. +
  25. Final stats shown
  26. +
  27. Success message: "Bulk geocoding complete: 234 improved, 512 unchanged, 14 failed"
  28. +
  29. Click "Close" button
  30. +
  31. Table refreshes, stats update
  32. +
  33. Only locations with IMPROVED results are updated (unchanged locations left alone)
  34. +
+

Importing Standard CSV

+
    +
  1. Click "Import CSV" button in page header
  2. +
  3. Modal opens (620px width) with 3 radio buttons:
  4. +
  5. Standard CSV (selected by default)
  6. +
  7. NAR Upload
  8. +
  9. NAR Server
  10. +
  11. Read format instructions:
  12. +
  13. Columns: address, first name, last name, email, phone, unit number, support level (1-4), sign (yes/no), sign size, notes, latitude, longitude
  14. +
  15. Column names matched flexibly (case-insensitive, ignores punctuation)
  16. +
  17. Drag CSV file or click to upload
  18. +
  19. File uploads, backend processes:
  20. +
  21. Parses CSV rows
  22. +
  23. Creates Location records (with addresses)
  24. +
  25. Creates Address records (units) if unit number present
  26. +
  27. Geocodes if lat/lng missing (optional)
  28. +
  29. Success message: "Imported 450 of 500 locations (30 warnings, 20 failed)"
  30. +
  31. If errors, warning modal shows error list (max 300px height, scrollable)
  32. +
  33. Modal closes, table refreshes, stats update
  34. +
+

Importing NAR Upload (Client-Side)

+
    +
  1. Click "Import CSV" button
  2. +
  3. Switch to "NAR Upload" radio button
  4. +
  5. Read format instructions:
  6. +
  7. Statistics Canada NAR Address CSV
  8. +
  9. Supports 2025 format (CIVIC_NO, OFFICIAL_STREET_NAME, BG_X/BG_Y) and legacy format (STR_NBR, STR_NME, LAT/LNG)
  10. +
  11. Auto-detects format
  12. +
  13. Configure Geographic Filter dropdown:
  14. +
  15. No filter — import all rows
  16. +
  17. Map settings area — use configured center + zoom from Map Settings
  18. +
  19. City name — enter city (e.g. "Ottawa", "Edmonton")
  20. +
  21. Province / Territory — select from dropdown (13 provinces/territories)
  22. +
  23. Cut boundary — select from cuts dropdown (only locations inside polygon)
  24. +
  25. Toggle "Residential only" switch:
  26. +
  27. ON (default): Skip commercial/industrial addresses
  28. +
  29. OFF: Import all addresses
  30. +
  31. Drag CSV file or click to upload (max 100MB)
  32. +
  33. File uploads, backend processes:
  34. +
  35. Parses NAR format (2025 or legacy)
  36. +
  37. Joins Address + Location files if NAR 2025 format
  38. +
  39. Converts BG_X/BG_Y (EPSG:3347 Lambert projection) to lat/lng using proj4
  40. +
  41. Applies geographic filters (cut, city, province, map area)
  42. +
  43. Deduplicates within 5m radius
  44. +
  45. Batches 1000 rows at a time
  46. +
  47. Progress indicator during import
  48. +
  49. Results shown in modal:
  50. +
  51. 6 statistics cards (Total Rows, Created, Duplicates, Out of Bounds, Invalid, Errors)
  52. +
  53. Error list (if any)
  54. +
  55. Success message: "Created X of Y locations"
  56. +
  57. Table refreshes, stats update
  58. +
+

Importing NAR Server (Server-Side Streaming)

+
    +
  1. Click "Import CSV" button
  2. +
  3. Switch to "NAR Server" radio button
  4. +
  5. Click "Scan Server Directory" button (first time only)
  6. +
  7. Backend scans NAR_DATA_DIR (./data volume mount) for:
  8. +
  9. Addresses/ directory with Address_{provinceCode}part.csv files
  10. +
  11. Locations/ directory with Location_{provinceCode}.csv files
  12. +
  13. Modal shows available provinces:
  14. +
  15. Example: "ON — Ontario (6 files, 2.3 GB)"
  16. +
  17. File count includes multi-part Address files
  18. +
  19. Select province from dropdown
  20. +
  21. Configure Geographic Filter:
  22. +
  23. No filter — import all addresses
  24. +
  25. City name — enter city
  26. +
  27. Postal code prefix (FSA) — 3 chars (e.g. K1A, E3B)
  28. +
  29. Cut boundary — select from cuts dropdown
  30. +
  31. Toggle "Residential only" switch (default: ON)
  32. +
  33. Click "Import {Province} Addresses" button
  34. +
  35. Backend starts streaming import:
      +
    • Scans all Address files for province (multi-part files joined)
    • +
    • Loads Location file (lat/lng coordinates)
    • +
    • Joins Address + Location on LOC_GUID
    • +
    • Converts BG_X/BG_Y to lat/lng using proj4
    • +
    • Applies filters (city, postal, cut, residential)
    • +
    • Deduplicates within 5m radius
    • +
    • Batches 1000 rows at a time
    • +
    • Streams to DB (never loads full dataset in memory)
    • +
    +
  36. +
  37. Live progress display (polls every 2 seconds):
      +
    • Progress bar (animated, 99.9% until complete)
    • +
    • Status text: "Processing Address_24_part_3..."
    • +
    • 3 statistics cards: Rows Processed, Locations Created, Skipped
    • +
    +
  38. +
  39. Import completes:
      +
    • Final stats shown (Total Rows, Created, Duplicates, Out of Bounds, Non-Residential, Invalid)
    • +
    • Duration shown in seconds
    • +
    • Success message: "Imported 12,847 locations from Ontario in 43.2s"
    • +
    +
  40. +
  41. Table refreshes, stats update
  42. +
+

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

+

Viewing Map (Map Tab)

+
    +
  1. Click "Map" tab (EnvironmentOutlined icon)
  2. +
  3. Map loads with AdminMapView component
  4. +
  5. Initial load fetches all locations (no bounds filter)
  6. +
  7. Locations render as colored circle markers:
  8. +
  9. Blue: Single Family
  10. +
  11. Green: Multi-Unit
  12. +
  13. Orange: Mixed Use
  14. +
  15. Purple: Commercial
  16. +
  17. Cut polygons overlay map (if any cuts exist)
  18. +
  19. Floating controls on map:
  20. +
  21. Add — Enter click-to-add mode
  22. +
  23. Move — Enter drag-to-move mode
  24. +
  25. GPS — Geolocate to current position
  26. +
  27. Fullscreen — Toggle fullscreen mode
  28. +
  29. Refresh — Reload locations in current view
  30. +
  31. Cut toggles — Show/hide cut overlays
  32. +
  33. Pan/zoom map → auto-refreshes after 800ms debounce
  34. +
  35. Click marker → location detail popup (address, building type, edit button)
  36. +
+

Adding Location from Map

+
    +
  1. In Map tab, click "Add" control button
  2. +
  3. Click-to-add mode activated (cursor changes)
  4. +
  5. Click anywhere on map
  6. +
  7. Backend reverse geocodes coordinates (Nominatim)
  8. +
  9. Create modal opens with pre-filled values:
  10. +
  11. Latitude (rounded to 5 decimals)
  12. +
  13. Longitude (rounded to 5 decimals)
  14. +
  15. Address (reverse geocoded, e.g. "123 Main St, City")
  16. +
  17. Adjust values if needed
  18. +
  19. Select building type
  20. +
  21. Click "Create"
  22. +
  23. New marker appears on map
  24. +
  25. Table updates if viewing Table tab
  26. +
+

Moving Location on Map

+
    +
  1. In Map tab, click "Move" control button
  2. +
  3. Drag-to-move mode activated
  4. +
  5. Click and drag any marker to new position
  6. +
  7. On release, coordinates update:
  8. +
  9. PUT /api/map/locations/:id with new lat/lng
  10. +
  11. Marker snaps to new position
  12. +
  13. Success message: "Location moved"
  14. +
  15. Table updates if viewing Table tab
  16. +
+

Editing a Location

+
    +
  1. From Table tab:
  2. +
  3. Click Edit icon button (EditOutlined) in Actions column
  4. +
  5. From Map tab:
  6. +
  7. Click marker → popup → click Edit button
  8. +
  9. Drawer opens on right side (700px width) with 2 tabs:
  10. +
  11. Details tab (active by default)
  12. +
  13. History tab (ClockCircleOutlined icon)
  14. +
  15. Details tab shows edit form:
  16. +
  17. Same fields as create form
  18. +
  19. Pre-filled with current values
  20. +
  21. Geocode button available
  22. +
  23. Modify any fields
  24. +
  25. Click "Save" button in drawer header
  26. +
  27. Success message: "Location updated"
  28. +
  29. Drawer closes, table refreshes, stats update
  30. +
  31. Map refreshes if viewing Map tab
  32. +
+

Viewing Location History

+
    +
  1. Open location in edit drawer
  2. +
  3. Click "History" tab (ClockCircleOutlined icon)
  4. +
  5. Table loads with location history:
  6. +
  7. Columns: Action, Field, Change, User, When
  8. +
  9. Action tags (color-coded):
      +
    • CREATED (green)
    • +
    • UPDATED (blue)
    • +
    • GEOCODED (cyan)
    • +
    • MOVED (orange)
    • +
    +
  10. +
  11. Field shows which field changed (e.g. address, latitude)
  12. +
  13. Change shows old → new values (strikethrough old, bold new)
  14. +
  15. User shows email or "System"
  16. +
  17. When shows timestamp (MMM D, YYYY h:mm A)
  18. +
  19. Pagination at bottom (20 per page)
  20. +
  21. History sorted newest first (most recent at top)
  22. +
+

Bulk Deleting Locations

+
    +
  1. In Table tab, select checkbox for multiple rows
  2. +
  3. "Delete Selected (N)" button appears above table
  4. +
  5. Click button
  6. +
  7. Popconfirm: "Delete N locations?"
  8. +
  9. Click "OK"
  10. +
  11. Success message: "Deleted N locations"
  12. +
  13. Selection cleared, table refreshes, stats update
  14. +
+

Exporting CSV

+
    +
  1. Click "Export CSV" button in page header
  2. +
  3. Browser downloads CSV file: locations-YYYY-MM-DD.csv
  4. +
  5. File contains all locations (not just current page):
  6. +
  7. Columns: id, address, latitude, longitude, buildingType, buildingNotes, postalCode, province, federalDistrict, buildingUse, geocodeProvider, geocodeConfidence, totalUnits, createdAt, updatedAt
  8. +
  9. Open in Excel, Google Sheets, or text editor
  10. +
  11. Use for backups, analysis, or importing to other systems
  12. +
+

Component Breakdown

+

Ant Design Components Used

+
    +
  • Table — Main locations list with columns, pagination, row selection, expandable rows
  • +
  • Tabs — Dual-tab interface (Table, Map)
  • +
  • Input — Search bar with SearchOutlined prefix
  • +
  • Select — Confidence filter dropdown, province/cut filters in import modal
  • +
  • Button — 6 header actions (Settings, Export, Import, Geocode Missing, Bulk Re-Geocode, Add Location) + table actions (Edit, Delete)
  • +
  • Card — Statistics cards (9 cards in 2 rows)
  • +
  • Statistic — Numeric displays with icons, prefixes, suffixes
  • +
  • Progress — Bulk geocode + NAR import progress bars
  • +
  • Modal — Create location, import CSV (3 modes), bulk re-geocode
  • +
  • Drawer — Edit location (700px width, 2 tabs)
  • +
  • Form — Create/edit location forms, bulk geocode config
  • +
  • Form.Item — Field wrappers with labels, rules, help text
  • +
  • InputNumber — Numeric fields (latitude, longitude, max locations)
  • +
  • Radio.Group — Import format selector (3 buttons), building type selector (4 buttons)
  • +
  • Switch — Residential-only toggle in import modals
  • +
  • Upload.Dragger — CSV file upload UI
  • +
  • Row, Col — Responsive grid for stats cards, form fields
  • +
  • Tag — Building type tags, confidence tags, geocode provider, history action tags
  • +
  • Space — Action button grouping
  • +
  • Popconfirm — Delete confirmation (single + bulk)
  • +
  • DatePicker — (Not used, but imported for future features)
  • +
  • TimePicker — (Not used, but imported for future features)
  • +
  • Typography.Text — Labels, descriptions, secondary text
  • +
+

Table Columns

+
const columns: ColumnsType<Location> = [
+  {
+    title: 'Address',
+    dataIndex: 'address',
+    render: (addr) => <span style={{ fontWeight: 500 }}>{addr || '--'}</span>,
+  },
+  {
+    title: 'Building Type',
+    dataIndex: 'buildingType',
+    render: (type: BuildingType) => (
+      <Tag color={BUILDING_TYPE_COLORS[type]}>
+        {BUILDING_TYPE_LABELS[type]}
+      </Tag>
+    ),
+    responsive: ['md'],
+  },
+  {
+    title: 'Total Units',
+    dataIndex: 'totalUnits',
+    align: 'center',
+    width: 120,
+    responsive: ['md'],
+  },
+  {
+    title: 'Coordinates',
+    render: (_, record) =>
+      record.latitude && record.longitude
+        ? `${Number(record.latitude).toFixed(5)}, ${Number(record.longitude).toFixed(5)}`
+        : '--',
+    responsive: ['lg'],
+  },
+  {
+    title: 'Geocode',
+    render: (_, record) => {
+      if (record.geocodeConfidence != null && record.geocodeConfidence > 0) {
+        const confidence = record.geocodeConfidence;
+        let color, icon, label;
+        if (confidence >= 85) {
+          color = 'success';
+          icon = <CheckCircleOutlined />;
+          label = `High (${confidence}%)`;
+        } else if (confidence >= 60) {
+          color = 'warning';
+          icon = <InfoCircleOutlined />;
+          label = `Medium (${confidence}%)`;
+        } else {
+          color = 'error';
+          icon = <WarningOutlined />;
+          label = `Low (${confidence}%)`;
+        }
+        return (
+          <Space direction="vertical" size={0}>
+            <Tag color={color} icon={icon}>{label}</Tag>
+            {record.geocodeProvider && (
+              <Text type="secondary" style={{ fontSize: 11 }}>
+                {record.geocodeProvider.toLowerCase()}
+              </Text>
+            )}
+          </Space>
+        );
+      }
+      if (record.latitude && record.longitude) {
+        return <Tag color="blue">Manual</Tag>;
+      }
+      return <Tag>None</Tag>;
+    },
+    responsive: ['lg'],
+  },
+  {
+    title: 'Created',
+    dataIndex: 'createdAt',
+    render: (date) => dayjs(date).format('YYYY-MM-DD'),
+    responsive: ['xl'],
+  },
+  {
+    title: 'Actions',
+    width: 120,
+    render: (_, record) => (
+      <Space>
+        <Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
+        <Popconfirm title="Delete this location?" onConfirm={() => handleDelete(record.id)}>
+          <Button type="link" size="small" danger icon={<DeleteOutlined />} />
+        </Popconfirm>
+      </Space>
+    ),
+  },
+];
+
+

Key patterns: +- responsive array controls column visibility on different screen sizes +- render functions for custom content (tags, icons, formatted values) +- align: 'center' for numeric columns +- width prop for fixed-width columns

+

Expandable Rows (Address Units)

+
const expandedRowRender = (location: Location) => {
+  if (!location.addresses || location.addresses.length === 0) {
+    return (
+      <div style={{ padding: '12px 24px', textAlign: 'center' }}>
+        <Text type="secondary">No units/addresses defined for this location yet.</Text>
+        <div style={{ marginTop: 8, fontSize: 12 }}>
+          <Text type="secondary">Units can be added during canvassing or imported via NAR data.</Text>
+        </div>
+      </div>
+    );
+  }
+
+  const addressColumns: ColumnsType<Address> = [
+    { title: 'Unit', dataIndex: 'unitNumber', width: 100, render: (unit) => unit || '--' },
+    { title: 'Name', render: (_, addr) => [addr.firstName, addr.lastName].filter(Boolean).join(' ') || '--' },
+    { title: 'Contact', render: (_, addr) => [addr.email, addr.phone].filter(Boolean).join(' • ') || '--', responsive: ['md'] },
+    { title: 'Building Type', dataIndex: 'buildingType', render: (type) => <Tag color={colors[type]}>{labels[type]}</Tag> },
+    { title: 'Notes', dataIndex: 'notes', ellipsis: true, render: (notes) => notes || '--', responsive: ['lg'] },
+  ];
+
+  return (
+    <div style={{ padding: '0 24px 12px' }}>
+      <Table<Address>
+        columns={addressColumns}
+        dataSource={location.addresses}
+        rowKey="id"
+        pagination={false}
+        size="small"
+        bordered
+      />
+    </div>
+  );
+};
+
+// In main table:
+<Table
+  expandable={{
+    expandedRowRender,
+    rowExpandable: (record) => (record.totalUnits > 1 || (record.addresses && record.addresses.length > 0)),
+  }}
+/>
+
+

Pattern: Nested table shows Address units (apartments) for multi-unit buildings. Only expandable if totalUnits > 1 or addresses array exists.

+

Statistics Cards

+
{stats && (
+  <Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
+    <Col xs={12} sm={8} md={4}>
+      <Card size="small">
+        <Statistic title="Total" value={stats.total} />
+      </Card>
+    </Col>
+    <Col xs={12} sm={8} md={4}>
+      <Card size="small">
+        <Statistic title="Single Family" value={stats.buildingTypes.SINGLE_FAMILY} valueStyle={{ color: '#1890ff' }} />
+      </Card>
+    </Col>
+    {/* 4 more building type cards */}
+  </Row>
+)}
+
+{stats && stats.confidence && (
+  <Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
+    <Col xs={12} sm={6} md={4}>
+      <Card size="small">
+        <Statistic
+          title="High Confidence"
+          value={stats.confidence.high}
+          prefix={<CheckCircleOutlined />}
+          valueStyle={{ color: '#52c41a' }}
+          suffix={<Text type="secondary" style={{ fontSize: 12 }}>85%</Text>}
+        />
+      </Card>
+    </Col>
+    {/* 3 more confidence cards */}
+  </Row>
+)}
+
+

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)

+

NAR Format Detection

+

NAR import supports two formats: +- 2025 format: CIVIC_NO, OFFICIAL_STREET_NAME, BG_X, BG_Y (Lambert projection EPSG:3347) +- Legacy format: STR_NBR, STR_NME, LAT, LNG (decimal degrees)

+

Backend auto-detects format by checking for presence of CIVIC_NO column.

+

2025 format with proj4 conversion:

+
import proj4 from 'proj4';
+
+// Define Lambert Conformal Conic projection (Statistics Canada NAR)
+proj4.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');
+
+// Convert BG_X, BG_Y (meters) to lat, lng (decimal degrees)
+const [lng, lat] = proj4('EPSG:3347', 'EPSG:4326', [bgX, bgY]);
+
+

File join (2025 format only):

+
// Address file: CIVIC_NO, OFFICIAL_STREET_NAME, LOC_GUID (no coordinates)
+// Location file: LOC_GUID, BG_LATITUDE, BG_LONGITUDE (coordinates only)
+// Join on LOC_GUID to get address + coordinates
+
+

Map Auto-Refresh

+
const handleMapMove = useCallback((map: any) => {
+  // Skip if map is animating (prevents disrupting zoom transitions)
+  if (map._animatingZoom || map._moving) {
+    return;
+  }
+
+  const b = map.getBounds();
+  const newBounds = {
+    minLat: b.getSouth(),
+    maxLat: b.getNorth(),
+    minLng: b.getWest(),
+    maxLng: b.getEast(),
+  };
+
+  // Store current bounds for auto-refresh
+  currentBoundsRef.current = newBounds;
+
+  clearTimeout(fetchTimerRef.current);
+  fetchTimerRef.current = setTimeout(() => {
+    // Mark as background fetch to prevent loading state during viewport changes
+    fetchAllLocations(newBounds, true);
+  }, 800); // Increased debounce to 800ms to allow zoom animations to complete
+}, [fetchAllLocations]);
+
+

Why 800ms debounce? +- Allows zoom/pan animations to complete +- Prevents API spam during dragging +- Only fetches when user pauses +- Background fetch (no loading spinner) for smooth UX

+

Safety limit:

+
if (data.length === 5000) {
+  message.warning('Too many locations in view. Zoom in for more detail.', 3);
+}
+
+

Backend returns max 5000 locations per request to prevent memory issues.

+

State Management

+

Zustand Stores Used

+

None — Locations fetched from API on each interaction. No global state required (unlike canvass or auth).

+

Local State

+
const [locations, setLocations] = useState<Location[]>([]);
+const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
+const [loading, setLoading] = useState(false);
+const [stats, setStats] = useState<LocationStats | null>(null);
+const [search, setSearch] = useState('');
+const [debouncedSearch, setDebouncedSearch] = useState('');
+const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
+const [confidenceFilter, setConfidenceFilter] = useState<'high' | 'medium' | 'low' | 'none' | undefined>();
+
+// Modals/Drawers
+const [createModalOpen, setCreateModalOpen] = useState(false);
+const [editDrawerOpen, setEditDrawerOpen] = useState(false);
+const [editingLocation, setEditingLocation] = useState<Location | null>(null);
+const [locationHistory, setLocationHistory] = useState<LocationHistory[]>([]);
+const [historyLoading, setHistoryLoading] = useState(false);
+const [historyPagination, setHistoryPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
+const [importModalOpen, setImportModalOpen] = useState(false);
+const [importing, setImporting] = useState(false);
+const [geocodingMissing, setGeocodingMissing] = useState(false);
+const [importFormat, setImportFormat] = useState<'standard' | 'nar' | 'server'>('standard');
+const [bulkImportResult, setBulkImportResult] = useState<BulkImportResult | null>(null);
+const [cuts, setCuts] = useState<Cut[]>([]);
+
+// NAR Server Import state
+const [narDatasets, setNarDatasets] = useState<NarDataset[]>([]);
+const [narDatasetsLoading, setNarDatasetsLoading] = useState(false);
+const [narDir, setNarDir] = useState<string | null>(null);
+const [narSelectedProvince, setNarSelectedProvince] = useState<string | undefined>();
+const [narImportResult, setNarImportResult] = useState<NarServerImportResult | null>(null);
+const [narProgress, setNarProgress] = useState<NarImportProgress | null>(null);
+const narPollRef = useRef<ReturnType<typeof setInterval>>(undefined);
+
+// Bulk Re-Geocoding state
+const [bulkGeocodeModalOpen, setBulkGeocodeModalOpen] = useState(false);
+const [bulkGeocoding, setBulkGeocoding] = useState(false);
+const [bulkGeocodeJobId, setBulkGeocodeJobId] = useState<string | null>(null);
+const [bulkGeocodeStatus, setBulkGeocodeStatus] = useState<any>(null);
+const bulkGeocodePollRef = useRef<ReturnType<typeof setInterval>>(undefined);
+const [bulkGeocodeForm] = Form.useForm();
+
+// Tabs + map
+const [activeTab, setActiveTab] = useState('table');
+const [allLocations, setAllLocations] = useState<Location[]>([]);
+const [mapLoading, setMapLoading] = useState(false);
+const fetchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
+const abortControllerRef = useRef<AbortController | null>(null);
+const currentBoundsRef = useRef<{ minLat: number; maxLat: number; minLng: number; maxLng: number } | null>(null);
+
+// Selection for bulk ops
+const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
+
+const [createForm] = Form.useForm();
+const [editForm] = Form.useForm();
+const [geocoding, setGeocoding] = useState(false);
+
+

Complexity: 40+ state variables for comprehensive feature set.

+

Polling Patterns

+

NAR Server Import Polling:

+
// Start polling
+narPollRef.current = setInterval(async () => {
+  try {
+    const { data: progress } = await api.get<NarImportProgress>(`/map/nar-import/status/${importId}`);
+    setNarProgress(progress);
+
+    if (progress.status === 'complete') {
+      stopNarPolling();
+      setImporting(false);
+      if (progress.result) {
+        setNarImportResult(progress.result);
+        message.success(`Imported ${progress.result.created} locations from ${progress.result.provinceName} in ${(progress.result.durationMs / 1000).toFixed(1)}s`);
+      }
+      fetchLocations({ page: 1 });
+      fetchStats();
+    } else if (progress.status === 'failed') {
+      stopNarPolling();
+      setImporting(false);
+      message.error(progress.error || 'NAR import failed');
+    }
+  } catch {
+    // Polling error — don't stop, might be transient
+  }
+}, 2000);  // Poll every 2 seconds
+
+// Cleanup on unmount
+useEffect(() => {
+  return () => stopNarPolling();
+}, [stopNarPolling]);
+
+

Bulk Geocode Polling:

+

Similar pattern for bulk geocoding background job. Polls job status every 2 seconds until complete/failed.

+

API Integration

+

Endpoints Used

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointPurpose
GET/api/map/locationsList locations (paginated, filtered)
GET/api/map/locations/statsFetch statistics (counts, confidence)
GET/api/map/locations/allFetch all locations (optionally bounds-filtered, max 5000)
GET/api/map/locations/:idFetch single location with addresses
GET/api/map/locations/:id/historyFetch location history (paginated)
POST/api/map/locationsCreate location
PUT/api/map/locations/:idUpdate location
DELETE/api/map/locations/:idDelete location
POST/api/map/locations/bulk-deleteBulk delete locations
POST/api/map/locations/geocodeGeocode single address
POST/api/map/locations/reverse-geocodeReverse geocode coordinates
POST/api/map/locations/geocode-missingBatch geocode all missing
POST/api/map/locations/bulk-geocodeStart bulk re-geocode job
GET/api/map/locations/bulk-geocode/:jobIdPoll bulk geocode status
GET/api/map/locations/export-csvExport all locations as CSV
POST/api/map/locations/import-csvImport standard CSV
POST/api/map/locations/import-bulkImport NAR CSV (client upload)
GET/api/map/nar-import/datasetsScan server NAR directory
POST/api/map/nar-importStart NAR server import
GET/api/map/nar-import/status/:importIdPoll NAR import progress
+

List Locations

+

Request:

+
const { data } = await api.get<LocationsListResponse>('/map/locations', {
+  params: {
+    page: 1,
+    limit: 20,
+    search: '123 Main',           // Optional: search address or postal
+    confidenceLevel: 'high',      // Optional: high, medium, low, none
+  },
+});
+
+

Response:

+
{
+  "locations": [
+    {
+      "id": "loc-123",
+      "address": "123 Main St, Ottawa, ON K1A 0A1",
+      "buildingType": "MULTI_UNIT",
+      "buildingNotes": "Access code: 1234",
+      "latitude": "45.42153",
+      "longitude": "-75.69719",
+      "postalCode": "K1A0A1",
+      "province": "ON",
+      "federalDistrict": "Ottawa—Vanier",
+      "buildingUse": "RESIDENTIAL",
+      "geocodeProvider": "GOOGLE",
+      "geocodeConfidence": 95,
+      "geocodeAddress": "123 Main Street, Ottawa, Ontario K1A 0A1, Canada",
+      "totalUnits": 12,
+      "createdAt": "2026-01-15T10:00:00.000Z",
+      "updatedAt": "2026-01-20T14:30:00.000Z",
+      "addresses": [
+        {
+          "id": "addr-456",
+          "unitNumber": "101",
+          "firstName": "John",
+          "lastName": "Doe",
+          "email": "john@example.com",
+          "phone": "613-555-1234",
+          "buildingType": "MULTI_UNIT",
+          "notes": "Friendly, supports campaign"
+        },
+        // ... 11 more units
+      ]
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 20,
+    "total": 5847,
+    "totalPages": 293
+  }
+}
+
+

Key fields: +- addresses — Nested array of Address units (apartments) +- totalUnits — Count of units (1 for single-family, 2+ for multi-unit) +- geocodeProvider — Provider name (GOOGLE, NOMINATIM, etc.) +- geocodeConfidence — 0-100% confidence score +- postalCode, province, federalDistrict — NAR import fields

+

Fetch Statistics

+

Request:

+
const { data } = await api.get<LocationStats>('/map/locations/stats');
+
+

Response:

+
{
+  "total": 5847,
+  "buildingTypes": {
+    "SINGLE_FAMILY": 4123,
+    "MULTI_UNIT": 1234,
+    "MIXED_USE": 345,
+    "COMMERCIAL": 145
+  },
+  "geocoded": 5421,
+  "confidence": {
+    "high": 4567,
+    "medium": 789,
+    "low": 65,
+    "none": 426,
+    "average": 87.3
+  }
+}
+
+

Geocode Address

+

Request:

+
const { data } = await api.post<GeocodeResult>('/map/locations/geocode', {
+  address: '123 Main St, Ottawa, ON',
+});
+
+

Response:

+
{
+  "latitude": 45.42153,
+  "longitude": -75.69719,
+  "provider": "GOOGLE",
+  "confidence": 95,
+  "geocodedAddress": "123 Main Street, Ottawa, Ontario K1A 0A1, Canada"
+}
+
+

Reverse Geocode

+

Request:

+
const { data } = await api.post<ReverseGeocodeResult>('/map/locations/reverse-geocode', {
+  latitude: 45.42153,
+  longitude: -75.69719,
+});
+
+

Response:

+
{
+  "address": "123 Main St, Ottawa, ON K1A 0A1, Canada",
+  "provider": "NOMINATIM"
+}
+
+

NAR Server Import

+

Request:

+
const { data } = await api.post<{ importId: string }>('/map/nar-import', {
+  provinceCode: '24',
+  filterType: 'city',
+  filterCity: 'Montreal',
+  residentialOnly: true,
+  deduplicateRadius: 5,
+  batchSize: 1000,
+});
+
+

Response:

+
{
+  "importId": "import-789"
+}
+
+

Poll status:

+
const { data } = await api.get<NarImportProgress>(`/map/nar-import/status/${importId}`);
+
+

Progress response:

+
{
+  "importId": "import-789",
+  "status": "processing",
+  "currentFile": "Address_24_part_3.csv",
+  "totalRows": 45678,
+  "locationsCreated": 12345,
+  "skippedDuplicate": 234,
+  "skippedOutOfBounds": 156,
+  "skippedNonResidential": 1234,
+  "skippedInvalid": 45
+}
+
+

Complete response:

+
{
+  "importId": "import-789",
+  "status": "complete",
+  "result": {
+    "provinceName": "Quebec",
+    "totalRows": 67890,
+    "created": 54321,
+    "skippedDuplicate": 456,
+    "skippedOutOfBounds": 234,
+    "skippedNonResidential": 12345,
+    "skippedInvalid": 78,
+    "durationMs": 43200,
+    "errors": []
+  }
+}
+
+

Troubleshooting

+

Geocode Confidence Always Low

+

Problem: All geocoded locations show Low (<60%) confidence tags.

+

Diagnosis:

+

Check geocoding provider priority in backend: +

// api/src/modules/map/geocoding/geocoding.service.ts
+const providers = ['GOOGLE', 'NOMINATIM', 'ARCGIS', 'PHOTON', 'MAPBOX', 'PELIAS'];
+

+

Common Issues:

+
    +
  1. Google API key missing/invalid:
  2. +
  3. Check .env: GOOGLE_GEOCODING_API_KEY=your-key-here
  4. +
  5. Verify key has Geocoding API enabled
  6. +
  7. +

    Check quota limits

    +
  8. +
  9. +

    Poor address quality:

    +
  10. +
  11. Addresses missing street number, city, or postal code
  12. +
  13. Example: "Main St" (missing number + city) → low confidence
  14. +
  15. +

    Solution: Clean address data before import

    +
  16. +
  17. +

    Provider fallback chain:

    +
  18. +
  19. Google fails → tries Nominatim (lower confidence)
  20. +
  21. Nominatim fails → tries ArcGIS, etc.
  22. +
  23. Solution: Fix primary provider (Google)
  24. +
+

Solution:

+

Run bulk re-geocode with confidence threshold 60: +1. Click "Bulk Re-Geocode" button +2. Set threshold to 60 +3. Start job +4. Improved locations update with higher confidence

+
+

NAR Server Import Not Finding Files

+

Problem: Click "Scan Server Directory" → "No NAR datasets found in /data"

+

Diagnosis:

+

Check docker-compose volume mount: +

volumes:
+  - ./data:/data:ro
+

+

Common Issues:

+
    +
  1. +

    Data directory doesn't exist: +

    mkdir -p ./data
    +

    +
  2. +
  3. +

    NAR files not extracted:

    +
  4. +
  5. Download NAR zip from Statistics Canada
  6. +
  7. Extract to ./data/ directory
  8. +
  9. +

    Ensure Addresses/ and Locations/ subdirectories exist

    +
  10. +
  11. +

    Wrong directory structure: +

    # Wrong (zip extracted to subdirectory):
    +./data/NAR_2025/Addresses/Address_24.csv
    +
    +# Correct (direct in ./data):
    +./data/Addresses/Address_24.csv
    +./data/Locations/Location_24.csv
    +

    +
  12. +
  13. +

    File permissions: +

    chmod -R 755 ./data
    +

    +
  14. +
+

Solution:

+
cd changemaker-lite
+mkdir -p ./data
+cd ./data
+# Download NAR zip from Statistics Canada
+unzip NAR_2025.zip
+# Move Addresses/ and Locations/ to ./data root
+mv NAR_2025/Addresses .
+mv NAR_2025/Locations .
+# Restart API container
+docker compose restart api
+
+
+

Map Shows "Too Many Locations in View"

+

Problem: Zoom out on map → Warning: "Too many locations in view. Zoom in for more detail."

+

Diagnosis:

+

Backend safety limit triggered: +

// Max 5000 locations per request
+if (locations.length >= 5000) {
+  return res.json(locations.slice(0, 5000));
+}
+

+

Not an error: Protection against loading millions of markers.

+

Solution:

+
    +
  1. Zoom in to reduce visible area
  2. +
  3. Map auto-refreshes with smaller bounds
  4. +
  5. Fewer locations load (no warning)
  6. +
+

Alternative: Use Table tab + search/filters to find specific locations.

+
+

Bulk Re-Geocode Stuck at 99%

+

Problem: Start bulk re-geocode → progress bar reaches 99% → never completes.

+

Diagnosis:

+

Check BullMQ queue health: +

docker compose logs -f api
+# Look for: "Bulk geocode job failed: ETIMEDOUT"
+

+

Common Issues:

+
    +
  1. Geocoding provider timeout:
  2. +
  3. Google API rate limit exceeded (50 req/sec)
  4. +
  5. +

    Solution: Reduce job concurrency in backend

    +
  6. +
  7. +

    Redis connection lost:

    +
  8. +
  9. Check redis container: docker compose ps redis
  10. +
  11. +

    Solution: Restart redis: docker compose restart redis

    +
  12. +
  13. +

    Job worker crashed:

    +
  14. +
  15. Check API logs for errors
  16. +
  17. Solution: Restart API: docker compose restart api
  18. +
+

Solution:

+

Cancel stuck job: +1. Close bulk geocode modal +2. Restart API container: docker compose restart api +3. Retry bulk geocode with smaller limit (e.g., 500 instead of 1000)

+
+

CSV Import Shows "Invalid" Errors

+

Problem: Import CSV → Result: "450 created, 50 invalid"

+

Diagnosis:

+

Check error list in import modal: +- Row 23: "Missing required field: address" +- Row 45: "Invalid coordinates: latitude > 90" +- Row 67: "Address too long (max 500 chars)"

+

Common Issues:

+
    +
  1. Missing required columns:
  2. +
  3. Standard CSV: address required, lat/lng optional
  4. +
  5. +

    NAR CSV: Address file requires CIVIC_NO + OFFICIAL_STREET_NAME (2025) or STR_NBR + STR_NME (legacy)

    +
  6. +
  7. +

    Invalid coordinates:

    +
  8. +
  9. Latitude out of range (-90 to 90)
  10. +
  11. Longitude out of range (-180 to 180)
  12. +
  13. +

    Non-numeric values in lat/lng columns

    +
  14. +
  15. +

    Encoding issues:

    +
  16. +
  17. CSV not UTF-8 encoded
  18. +
  19. Solution: Re-save CSV as UTF-8 in Excel/LibreOffice
  20. +
+

Solution:

+
    +
  1. Export failed rows to new CSV for fixing
  2. +
  3. Clean data in spreadsheet:
  4. +
  5. Fill missing addresses
  6. +
  7. Fix coordinate ranges
  8. +
  9. Remove invalid characters
  10. +
  11. Re-import cleaned CSV
  12. +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/mailhog-page/index.html b/mkdocs/site/v2/frontend/pages/admin/mailhog-page/index.html new file mode 100644 index 00000000..8bc2c2d9 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/mailhog-page/index.html @@ -0,0 +1,7336 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MailHog - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

MailHogPage

+

Overview

+

File: admin/src/pages/MailHogPage.tsx

+

Route: /app/services/mailhog

+

Role Requirements: Any authenticated user (uses authenticate middleware)

+

Purpose: Provides an embedded interface to the MailHog email testing service via iframe. MailHog is a development email capture tool that intercepts SMTP emails and displays them in a web interface, allowing developers to test email functionality without sending real emails. This page serves as a wrapper that embeds MailHog with online/offline status monitoring and mobile device detection.

+

Key Features: +- Full-page iframe embed of MailHog service +- Service online/offline status monitoring with Badge +- Mobile device detection with warning screen +- "Refresh" button to re-check service status +- "Open in New Tab" button for external access +- Fullbleed layout (no padding in AppLayout) +- Automatic service health checks via API

+

Layout: AppLayout with fullbleed (no content padding)

+

Dependencies: +- Ant Design v5 (Button, Space, Badge, Spin, Grid, Result) +- react-router-dom (useOutletContext)

+
+

Features

+

1. Service Status Monitoring

+

Status Display: +- Green "Online" badge when MailHog is accessible +- Red "Offline" badge when MailHog is not accessible +- Blue "Checking..." badge during status check +- Badge displayed in page header

+

Status Checks: +- Initial check on page load +- Manual check via "Refresh" button +- No automatic periodic refresh

+

2. Mobile Device Detection

+

Mobile Warning Screen: +- Detects mobile devices using Grid.useBreakpoint() +- Shows warning Result component on mobile +- Recommends using desktop for better email viewing experience +- Icon: MailOutlined (48px)

+

Breakpoint: !screens.md (screen width < 768px = mobile)

+

3. Service URL Building

+

URL Construction: +- Fetches service config from API (/api/services/config) +- Builds URL using buildServiceUrl() helper +- Uses subdomain + domain + port configuration +- Example: http://mailhog.cmlite.org or http://localhost:8025

+

4. Iframe Embedding

+

Fullbleed Layout: +- No padding around iframe +- Height: calc(100vh - 64px) (full viewport height minus header) +- Width: 100% +- No border for seamless integration

+

Error Handling: +- Shows error Result if service offline +- Provides "Retry" button to re-check status +- Clear error messaging

+
+

User Workflow

+

Accessing MailHog Service

+
    +
  1. Navigate to MailHog:
  2. +
  3. Click "Services" → "MailHog" in sidebar
  4. +
  5. +

    Page loads with status check

    +
  6. +
  7. +

    Check Service Status:

    +
  8. +
  9. +

    Status badge appears in page header:

    +
      +
    • ✅ "Online" (green) - Service available
    • +
    • ❌ "Offline" (red) - Service unavailable
    • +
    • 🔵 "Checking..." (blue) - Status check in progress
    • +
    +
  10. +
  11. +

    View on Desktop:

    +
  12. +
  13. +

    If on desktop (screen width ≥ 768px):

    +
      +
    • Iframe loads automatically
    • +
    • Full MailHog interface embedded in page
    • +
    • Can view captured emails, search, delete
    • +
    +
  14. +
  15. +

    View on Mobile:

    +
  16. +
  17. +

    If on mobile (screen width < 768px):

    +
      +
    • Warning message appears
    • +
    • Message: "MailHog requires a desktop browser"
    • +
    • "Open in New Tab" button provided
    • +
    • Click button to open service in separate browser tab
    • +
    +
  18. +
  19. +

    Using MailHog Service:

    +
  20. +
  21. Inbox View: See all captured emails
  22. +
  23. Email Preview: Click email to view full content (HTML + text)
  24. +
  25. Search: Filter emails by sender, recipient, subject
  26. +
  27. Delete: Delete individual emails or clear all
  28. +
  29. +

    Raw View: View raw email source (headers + body)

    +
  30. +
  31. +

    Troubleshoot Offline Service:

    +
  32. +
  33. If service shows "Offline":
      +
    • Click "Retry" button to re-check
    • +
    • Check Docker container: docker compose ps mailhog
    • +
    • Restart service: docker compose restart mailhog
    • +
    • Verify nginx routing: Check nginx/conf.d/services.conf
    • +
    +
  34. +
  35. Refresh page after fixing
  36. +
+
+

Component Breakdown

+

Main Component Structure

+
export default function MailHogPage() {
+  const { setPageHeader } = useOutletContext<AppOutletContext>();
+  const screens = Grid.useBreakpoint();
+  const isMobile = !screens.md;
+
+  const [online, setOnline] = useState<boolean | null>(null);
+  const [config, setConfig] = useState<ServicesConfig | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  // Fetch service status and config
+  const fetchStatus = useCallback(async () => {
+    try {
+      const [statusRes, configRes] = await Promise.all([
+        api.get<ServicesStatus>('/services/status'),
+        api.get<ServicesConfig>('/services/config'),
+      ]);
+      setOnline(statusRes.data.mailhog.online);
+      setConfig(configRes.data);
+    } catch {
+      setOnline(false);
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    fetchStatus();
+  }, [fetchStatus]);
+
+  // Build service URL
+  const serviceUrl = config
+    ? buildServiceUrl(config.mailhogSubdomain, config.domain, config.mailhogPort)
+    : null;
+
+  // Page header with status badge and actions
+  const headerActions = useMemo(() => (
+    <Space>
+      <Badge
+        status={online === null ? 'processing' : online ? 'success' : 'error'}
+        text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
+      />
+      <Button icon={<ReloadOutlined />} onClick={fetchStatus} size="small">
+        Refresh
+      </Button>
+      {serviceUrl && (
+        <Button icon={<LinkOutlined />} href={serviceUrl} target="_blank" size="small">
+          Open in New Tab
+        </Button>
+      )}
+    </Space>
+  ), [online, fetchStatus, serviceUrl]);
+
+  useEffect(() => {
+    setPageHeader({ title: 'MailHog', actions: headerActions, fullBleed: true });
+    return () => setPageHeader(null);
+  }, [setPageHeader, headerActions]);
+
+  // Mobile warning
+  if (isMobile) {
+    return (
+      <Result
+        status="info"
+        title="Desktop Required"
+        subTitle="MailHog requires a desktop browser with a larger screen."
+        icon={<MailOutlined style={{ fontSize: 48 }} />}
+      />
+    );
+  }
+
+  // Loading state
+  if (loading) {
+    return (
+      <div style={{ textAlign: 'center', padding: 80 }}>
+        <Spin size="large" />
+      </div>
+    );
+  }
+
+  // Offline state
+  if (!online || !serviceUrl) {
+    return (
+      <Result
+        status="error"
+        title="MailHog Unavailable"
+        subTitle="MailHog is not running or could not be reached. Check that the MailHog container is started."
+        extra={
+          <Button type="primary" onClick={fetchStatus}>
+            Retry
+          </Button>
+        }
+      />
+    );
+  }
+
+  // Iframe embed
+  return (
+    <iframe
+      src={serviceUrl}
+      style={{
+        width: '100%',
+        height: 'calc(100vh - 64px)',
+        border: 'none',
+        display: 'block',
+      }}
+      title="MailHog"
+    />
+  );
+}
+
+

Ant Design Components Used

+
    +
  1. Button - Refresh and "Open in New Tab" action buttons
  2. +
  3. Space - Header action button grouping
  4. +
  5. Badge - Service status indicator (success/error/processing)
  6. +
  7. Spin - Loading spinner during status check
  8. +
  9. Grid.useBreakpoint() - Responsive breakpoint detection
  10. +
  11. Result - Mobile warning and offline error screens
  12. +
+
+

State Management

+

Local Component State (useState)

+
// Service online/offline state
+const [online, setOnline] = useState<boolean | null>(null);
+
+// Service configuration state (subdomain, domain, port)
+const [config, setConfig] = useState<ServicesConfig | null>(null);
+
+// Loading state
+const [loading, setLoading] = useState(true);
+
+// Responsive breakpoint detection
+const screens = Grid.useBreakpoint();
+const isMobile = !screens.md;
+
+

State Flow

+
    +
  1. Component Mounts:
  2. +
  3. fetchStatus() called in useEffect
  4. +
  5. Parallel API calls:
      +
    • GET /api/services/status - Check MailHog online status
    • +
    • GET /api/services/config - Fetch service configuration
    • +
    +
  6. +
  7. Sets online to true or false
  8. +
  9. Sets config with subdomain/domain/port
  10. +
  11. +

    Sets loading to false

    +
  12. +
  13. +

    URL Construction:

    +
  14. +
  15. buildServiceUrl() constructs full service URL from config
  16. +
  17. Example: http://mailhog.cmlite.org (production with subdomain)
  18. +
  19. +

    Or: http://localhost:8025 (development with port)

    +
  20. +
  21. +

    User Clicks Refresh:

    +
  22. +
  23. fetchStatus() called again
  24. +
  25. Re-checks service status
  26. +
  27. +

    Updates online and config states

    +
  28. +
  29. +

    Service Online:

    +
  30. +
  31. online is true
  32. +
  33. Badge shows "Online" (green)
  34. +
  35. +

    Iframe renders with MailHog interface

    +
  36. +
  37. +

    Service Offline:

    +
  38. +
  39. online is false
  40. +
  41. Badge shows "Offline" (red)
  42. +
  43. Error Result displayed with "Retry" button
  44. +
+
+

API Integration

+

Endpoints Used

+
    +
  1. GET /api/services/status - Check all service health (includes MailHog)
  2. +
  3. GET /api/services/config - Fetch service configuration (subdomains, ports)
  4. +
+

Example API Calls

+

1. Fetch Service Status

+
const statusRes = await api.get<ServicesStatus>('/services/status');
+setOnline(statusRes.data.mailhog.online);
+
+

Response Format: +

{
+  "mailhog": { "online": true },
+  "nocodb": { "online": true },
+  "n8n": { "online": true },
+  "grafana": { "online": true },
+  "prometheus": { "online": true }
+}
+

+

2. Fetch Service Config

+
const configRes = await api.get<ServicesConfig>('/services/config');
+setConfig(configRes.data);
+
+

Response Format: +

{
+  "domain": "cmlite.org",
+  "mailhogSubdomain": "mailhog",
+  "mailhogPort": 8025,
+  "nocodbSubdomain": "db",
+  "nocodbPort": 8091,
+  "n8nSubdomain": "n8n",
+  "n8nPort": 5678
+}
+

+

3. Build Service URL

+
import { buildServiceUrl } from '@/lib/service-url';
+
+const serviceUrl = buildServiceUrl(
+  config.mailhogSubdomain,  // "mailhog"
+  config.domain,             // "cmlite.org"
+  config.mailhogPort         // 8025
+);
+
+// Returns: "http://mailhog.cmlite.org" (production)
+// Or: "http://localhost:8025" (development)
+
+
+

Code Examples

+

Complete Parallel API Fetch Pattern

+
const fetchStatus = useCallback(async () => {
+  try {
+    setLoading(true);
+
+    // Parallel fetch: status + config
+    const [statusRes, configRes] = await Promise.all([
+      api.get<ServicesStatus>('/services/status'),
+      api.get<ServicesConfig>('/services/config'),
+    ]);
+
+    // Extract MailHog status
+    setOnline(statusRes.data.mailhog.online);
+
+    // Store full config
+    setConfig(configRes.data);
+  } catch (error) {
+    console.error('Failed to fetch MailHog status:', error);
+    setOnline(false);
+  } finally {
+    setLoading(false);
+  }
+}, []);
+
+

Service URL Builder Utility

+
// lib/service-url.ts
+export function buildServiceUrl(
+  subdomain: string,
+  domain: string,
+  port: number
+): string {
+  // Production: use subdomain routing
+  if (process.env.NODE_ENV === 'production') {
+    return `http://${subdomain}.${domain}`;
+  }
+
+  // Development: use localhost + port
+  return `http://localhost:${port}`;
+}
+
+// Usage:
+const url = buildServiceUrl('mailhog', 'cmlite.org', 8025);
+// Production: "http://mailhog.cmlite.org"
+// Development: "http://localhost:8025"
+
+

Conditional Rendering Pattern

+
// Mobile warning (early return)
+if (isMobile) {
+  return <Result status="info" title="Desktop Required" />;
+}
+
+// Loading state (early return)
+if (loading) {
+  return <Spin size="large" />;
+}
+
+// Offline state (early return)
+if (!online || !serviceUrl) {
+  return <Result status="error" title="MailHog Unavailable" />;
+}
+
+// Online state (iframe)
+return <iframe src={serviceUrl} />;
+
+
+

Performance Considerations

+

1. Parallel API Requests

+

Status and config fetched in parallel with Promise.all():

+
const [statusRes, configRes] = await Promise.all([
+  api.get<ServicesStatus>('/services/status'),
+  api.get<ServicesConfig>('/services/config'),
+]);
+
+

Benefit: Total loading time ~200ms (slowest request) instead of ~400ms (sum of both).

+

2. useCallback for fetchStatus

+
const fetchStatus = useCallback(async () => {
+  // ... fetch logic
+}, []);
+
+

Benefit: Function identity stable across re-renders, prevents unnecessary effect triggers.

+

3. useMemo for Header Actions

+
const headerActions = useMemo(() => (
+  <Space>
+    <Badge />
+    <Button onClick={fetchStatus} />
+  </Space>
+), [online, fetchStatus, serviceUrl]);
+
+

Benefit: Header actions only recreated when dependencies change, preventing unnecessary re-renders.

+

4. Early Mobile Detection

+
if (isMobile) {
+  return <Result />;  // No API calls, no iframe
+}
+
+

Benefit: Avoids unnecessary service checks and iframe loading on mobile devices.

+
+

Responsive Design

+

Mobile Detection

+
const screens = Grid.useBreakpoint();
+const isMobile = !screens.md;  // Mobile if screen width < 768px
+
+if (isMobile) {
+  return (
+    <Result
+      status="info"
+      title="Desktop Required"
+      subTitle="MailHog requires a desktop browser with a larger screen."
+      icon={<MailOutlined style={{ fontSize: 48 }} />}
+    />
+  );
+}
+
+

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

+
+

Accessibility

+

Keyboard Navigation

+
    +
  1. Tab Key: Cycles through header buttons (Refresh, Open in New Tab)
  2. +
  3. Enter Key: Activates focused button
  4. +
  5. Iframe Focus: Tab enters iframe, navigates MailHog interface
  6. +
+

ARIA Labels

+
<iframe
+  src={serviceUrl}
+  title="MailHog"  // Screen reader announces iframe purpose
+  aria-label="MailHog email testing service"
+/>
+
+<Button
+  aria-label="Refresh MailHog service status"
+  icon={<ReloadOutlined />}
+>
+  Refresh
+</Button>
+
+

Color Contrast

+
    +
  • Success badge (green): #52c41a on white background (contrast ratio 4.5:1)
  • +
  • Error badge (red): #ff4d4f on white background (contrast ratio 4.5:1)
  • +
  • Button text: White on #1890ff (contrast ratio 4.5:1)
  • +
+
+

Troubleshooting

+

Problem: Service Shows "Offline" Despite Container Running

+

Solutions:

+
    +
  1. +

    Verify Docker container: +

    docker compose ps mailhog
    +# Should show "Up" status
    +

    +
  2. +
  3. +

    Check MailHog logs: +

    docker compose logs mailhog
    +# Look for errors
    +

    +
  4. +
  5. +

    Test direct access:

    +
  6. +
  7. Open http://localhost:8025 in browser
  8. +
  9. +

    If accessible directly, nginx routing issue

    +
  10. +
  11. +

    Check nginx config:

    +
  12. +
  13. Open nginx/conf.d/services.conf
  14. +
  15. Verify MailHog proxy block exists
  16. +
  17. +

    Restart nginx: docker compose restart nginx

    +
  18. +
  19. +

    Verify API endpoint:

    +
  20. +
  21. Check DevTools Network tab
  22. +
  23. Look for /api/services/status request
  24. +
  25. Verify mailhog.online: true in response
  26. +
+
+

Problem: Iframe Not Loading

+

Solutions:

+
    +
  1. Check CORS/CSP headers:
  2. +
  3. Open DevTools Console
  4. +
  5. Look for errors like "Refused to display in a frame"
  6. +
  7. +

    Check nginx X-Frame-Options headers

    +
  8. +
  9. +

    Verify service URL:

    +
  10. +
  11. Check console log: console.log(serviceUrl)
  12. +
  13. +

    Should be valid URL (not null/undefined)

    +
  14. +
  15. +

    Test URL in new tab:

    +
  16. +
  17. Click "Open in New Tab" button
  18. +
  19. If opens correctly, iframe issue
  20. +
  21. If doesn't open, service issue
  22. +
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/map-settings-page/index.html b/mkdocs/site/v2/frontend/pages/admin/map-settings-page/index.html new file mode 100644 index 00000000..3ffe8e5a --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/map-settings-page/index.html @@ -0,0 +1,8212 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Map Settings - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

MapSettingsPage

+

Overview

+

The MapSettingsPage provides configuration for the public map view's default center point and zoom level, plus walk sheet template customization with live preview functionality. It features a city search autocomplete that queries the geocoding service to quickly set coordinates, eliminating manual latitude/longitude entry. The walk sheet preview updates in real-time as settings are edited, showing exactly how printed walk sheets will appear with custom headers, footers, and up to 3 QR codes. The page uses a two-column layout: settings form on left, live walk sheet preview on right.

+

Route: /app/map/settings +Component: admin/src/pages/MapSettingsPage.tsx (433 lines) +Auth Required: Yes (SUPER_ADMIN or MAP_ADMIN role recommended) +Layout: AppLayout +Backend Module: api/src/modules/map/settings/

+

Screenshot

+

[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.]

+

Features

+
    +
  • City search autocomplete — Search for cities/places to auto-fill coordinates (geocoding service integration)
  • +
  • Manual coordinate entry — Precise latitude/longitude control with decimal precision
  • +
  • Zoom level slider — Visual zoom selection (2-19 range)
  • +
  • Live walk sheet preview — Real-time preview updates as settings are edited
  • +
  • Custom walk sheet headers — Configurable title and subtitle
  • +
  • Custom walk sheet footer — Multi-line footer text
  • +
  • QR code integration — Up to 3 QR codes with custom URLs and labels
  • +
  • Print-optimized preview — Walk sheet preview matches actual printed output
  • +
  • Browser print support — Print directly from preview with window.print()
  • +
  • Two-column layout — Side-by-side settings and preview for immediate feedback
  • +
  • Form validation — Required fields (latitude, longitude)
  • +
  • Responsive design — Stacked layout on mobile, side-by-side on desktop
  • +
+

User Workflow

+ +
    +
  1. Navigate to /app/map/settings
  2. +
  3. Locate "Map Center & Zoom" card (top of left column)
  4. +
  5. Click city search autocomplete input field
  6. +
  7. Start typing city name (e.g., "Ottawa")
  8. +
  9. After 400ms delay, search results appear in dropdown:
  10. +
  11. Display name: "Ottawa, Ontario, Canada"
  12. +
  13. Type tag: "city" (gray text, right side)
  14. +
  15. Click desired city from dropdown
  16. +
  17. Coordinates auto-fill:
  18. +
  19. Latitude: 45.4215
  20. +
  21. Longitude: -75.6972
  22. +
  23. Zoom: 12 (default zoom for city-level view)
  24. +
  25. Success message: "Coordinates auto-filled. Fine-tune below."
  26. +
  27. Adjust zoom slider if needed (e.g., 14 for closer view)
  28. +
  29. Click "Save Settings" button (top-right header)
  30. +
+

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

+

Setting Map Center Manually

+
    +
  1. Locate latitude/longitude input fields
  2. +
  3. Enter precise coordinates:
  4. +
  5. Latitude: -90 to 90 (e.g., 45.4215)
  6. +
  7. Longitude: -180 to 180 (e.g., -75.6972)
  8. +
  9. Decimal precision: 4 decimal places = ~10 meter accuracy
  10. +
  11. Adjust zoom slider (2-19 range):
  12. +
  13. 2-5: Country/continent level
  14. +
  15. 6-9: State/province level
  16. +
  17. 10-12: City level (default)
  18. +
  19. 13-15: Neighborhood level
  20. +
  21. 16-19: Street level
  22. +
  23. Click "Save Settings"
  24. +
+

Use Cases: +- Setting map center to campaign office location +- Centering on specific neighborhood +- Centering on landmark (e.g., Parliament Hill)

+

Customizing Walk Sheet Header

+
    +
  1. Scroll to "Walk Sheet Configuration" card
  2. +
  3. Modify Title field (e.g., "Canvassing Walk Sheet")
  4. +
  5. Modify Subtitle field (e.g., "District Outreach Campaign 2026")
  6. +
  7. Observe live preview (right column):
  8. +
  9. Header updates immediately as you type
  10. +
  11. Title appears in large bold font (18pt)
  12. +
  13. Subtitle appears below in smaller font (12pt)
  14. +
  15. Click "Save Settings" when satisfied
  16. +
+

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)

+

Adding QR Codes

+
    +
  1. Locate "QR Codes" section (under footer field)
  2. +
  3. Fill in QR Code 1 URL (e.g., "https://cmlite.org/campaign-info")
  4. +
  5. Fill in QR Code 1 Label (e.g., "Campaign Info")
  6. +
  7. Observe live preview:
  8. +
  9. QR code appears below header (generated via /api/qr endpoint)
  10. +
  11. Label appears below QR code in small font (9pt)
  12. +
  13. Repeat for QR Code 2 and 3 (optional)
  14. +
  15. Click "Save Settings"
  16. +
+

QR Code Use Cases: +- Campaign website: Link to campaign homepage +- Survey/feedback: Google Form or Typeform link +- Donation page: Link to fundraising platform +- Social media: Link to Facebook page or Twitter profile +- Contact form: Link to volunteer signup form

+

QR Code Behavior: +- Empty URL: QR code not rendered (skipped) +- Empty label: QR code rendered without label +- Long label: Label truncates (keep < 20 characters) +- Invalid URL: QR code still generated (ensure URL is correct)

+ +
    +
  1. Locate Footer field (multi-line textarea)
  2. +
  3. Enter footer text (e.g., "Return completed sheets to campaign office. Questions? Call 613-555-0100.")
  4. +
  5. Observe live preview:
  6. +
  7. Footer appears at bottom of walk sheet
  8. +
  9. Centered text, small font (9pt)
  10. +
  11. Gray text color for subtlety
  12. +
  13. Click "Save Settings"
  14. +
+

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!")

+

Printing Walk Sheet

+

Option 1: Print from Preview

+
    +
  1. Click "Print" button in preview card header (top-right)
  2. +
  3. Browser print dialog opens
  4. +
  5. Configure print settings:
  6. +
  7. Paper size: Letter (8.5" × 11")
  8. +
  9. Orientation: Portrait
  10. +
  11. Margins: Default (or custom 0.5")
  12. +
  13. Scale: 100% (do not scale)
  14. +
  15. Click "Print" button in dialog
  16. +
  17. Walk sheet prints exactly as shown in preview
  18. +
+

Option 2: Print from Header Button

+
    +
  1. Click "Print Walk Sheet" button (page header, next to Save Settings)
  2. +
  3. Same print dialog appears
  4. +
  5. Same print settings apply
  6. +
+

Print Optimization:

+

The page includes print-specific CSS:

+
@media print {
+  body * { visibility: hidden !important; }
+  .walk-sheet-print, .walk-sheet-print * { visibility: visible !important; }
+  .walk-sheet-print {
+    position: fixed !important;
+    left: 0 !important;
+    top: 0 !important;
+    width: 8.5in !important;
+    height: 11in !important;
+    padding: 0.4in 0.5in !important;
+  }
+}
+
+

Result: +- Only walk sheet prints (no navigation, buttons, etc.) +- Exactly 8.5" × 11" Letter size +- QR codes print with high contrast (print-color-adjust: exact) +- Clean professional appearance

+

Component Breakdown

+

Ant Design Components Used

+
    +
  • Typography.Text — Labels, descriptions, helper text
  • +
  • Form — Wrap all settings inputs
  • +
  • Form.Item — Individual field wrappers with labels
  • +
  • Input — Text fields (title, subtitle, QR labels)
  • +
  • Input.TextArea — Multi-line footer field
  • +
  • InputNumber — Numeric latitude/longitude inputs
  • +
  • Slider — Zoom level selection (visual slider with marks)
  • +
  • AutoComplete — City search with dropdown suggestions
  • +
  • Card — Section containers (Map Center, Walk Sheet Config, Preview)
  • +
  • Row / Col — Grid layout for two-column design
  • +
  • Button — Save Settings, Print buttons
  • +
  • Space — Button grouping
  • +
  • Spin — Loading indicators (city search, initial page load)
  • +
  • message — Toast notifications for success/error feedback
  • +
+

Two-Column Layout

+
<Row gutter={24}>
+  {/* Left column: Settings form */}
+  <Col xs={24} lg={10}>
+    <Form form={form} onFinish={handleSave} layout="vertical">
+      <Card title="Map Center & Zoom">{/* ... */}</Card>
+      <Card title="Walk Sheet Configuration">{/* ... */}</Card>
+    </Form>
+  </Col>
+
+  {/* Right column: Live walk sheet preview */}
+  <Col xs={24} lg={14}>
+    <Card title="Walk Sheet Preview">{/* ... */}</Card>
+  </Col>
+</Row>
+
+

Responsive Breakpoints: +- Mobile (xs, <992px): Stacked layout (form on top, preview below) +- Desktop (lg, ≥992px): Side-by-side layout (form 40% width, preview 60% width)

+

City Search Autocomplete

+
<AutoComplete
+  value={citySearch}
+  options={cityOptions}
+  onSearch={handleCitySearch}
+  onSelect={handleCitySelect}
+  placeholder="Search for a city to auto-fill coordinates..."
+  style={{ width: '100%' }}
+  suffixIcon={citySearching ? <Spin size="small" /> : <SearchOutlined />}
+/>
+
+

City Search Handler:

+
const handleCitySearch = useCallback((value: string) => {
+  setCitySearch(value);
+  clearTimeout(cityTimerRef.current);
+
+  // Require minimum 2 characters
+  if (value.length < 2) {
+    setCityOptions([]);
+    return;
+  }
+
+  // Debounce 400ms
+  cityTimerRef.current = setTimeout(async () => {
+    setCitySearching(true);
+    try {
+      const { data } = await api.get<GeocodeSearchResult[]>('/map/geocoding/search', {
+        params: { q: value, limit: 5 },
+      });
+
+      setCityOptions(data.map((r) => ({
+        value: r.displayName,
+        label: (
+          <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+            <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 300 }}>
+              {r.displayName}
+            </span>
+            <span style={{ color: '#888', fontSize: 11, marginLeft: 8, flexShrink: 0 }}>
+              {r.type}
+            </span>
+          </div>
+        ),
+        result: r,
+      })));
+    } catch {
+      setCityOptions([]);
+    } finally {
+      setCitySearching(false);
+    }
+  }, 400);
+}, []);
+
+

City Select Handler:

+
const handleCitySelect = useCallback((_value: string, option: { result: GeocodeSearchResult }) => {
+  // Auto-fill coordinates from selected result
+  form.setFieldsValue({
+    latitude: option.result.latitude,
+    longitude: option.result.longitude,
+    zoom: 12,  // Default zoom for city-level view
+  });
+
+  // Clear search
+  setCitySearch('');
+  setCityOptions([]);
+
+  message.success('Coordinates auto-filled. Fine-tune below.');
+}, [form]);
+
+

Live Walk Sheet Preview

+
// Watch all form values for live preview
+const watched = Form.useWatch(undefined, form) as Record<string, string | undefined> | undefined;
+
+// QR codes from live form values
+const qrCodes = [
+  { url: watched?.qrCode1Url, label: watched?.qrCode1Label },
+  { url: watched?.qrCode2Url, label: watched?.qrCode2Label },
+  { url: watched?.qrCode3Url, label: watched?.qrCode3Label },
+].filter((qr) => qr.url);  // Only include QR codes with URLs
+
+return (
+  <div className="walk-sheet-print" style={{ /* print styles */ }}>
+    {/* Header */}
+    <div style={{ borderBottom: '2px solid #000', paddingBottom: 6 }}>
+      <div style={{ fontSize: 18, fontWeight: 700, textAlign: 'center' }}>
+        {watched?.walkSheetTitle || 'Walk Sheet'}
+      </div>
+      {watched?.walkSheetSubtitle && (
+        <div style={{ fontSize: 12, textAlign: 'center', marginTop: 2 }}>
+          {watched.walkSheetSubtitle}
+        </div>
+      )}
+    </div>
+
+    {/* QR Codes */}
+    {qrCodes.length > 0 && (
+      <div style={{ display: 'flex', justifyContent: 'center', gap: 32 }}>
+        {qrCodes.map((qr, i) => (
+          <div key={i} style={{ textAlign: 'center' }}>
+            <img
+              src={`/api/qr?text=${encodeURIComponent(qr.url!)}&size=200`}
+              alt={qr.label || `QR Code ${i + 1}`}
+              style={{ width: 80, height: 80 }}
+            />
+            {qr.label && <div style={{ fontSize: 9 }}>{qr.label}</div>}
+          </div>
+        ))}
+      </div>
+    )}
+
+    {/* Contact entry blocks */}
+    {[1, 2].map((blockNum) => (
+      <div key={blockNum}>
+        <FormRow left="First Name" right="Last Name" />
+        <FormRow left="Email" right="Phone" />
+        <FormRow left="Address" right="Unit Number" />
+        {/* Support Level, Sign Request, Sign Size, Visited Date */}
+      </div>
+    ))}
+
+    {/* Notes section */}
+    <div style={{ marginTop: 8 }}>
+      <div style={{ fontSize: 9, color: '#666' }}>Notes &amp; Comments</div>
+      <div style={{ border: '1px solid #999', minHeight: 80 }} />
+    </div>
+
+    {/* Footer */}
+    {watched?.walkSheetFooter && (
+      <div style={{ marginTop: 10, textAlign: 'center', fontSize: 9 }}>
+        {watched.walkSheetFooter}
+      </div>
+    )}
+  </div>
+);
+
+

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

+

State Management

+

Local State (No Zustand Store)

+
const [form] = Form.useForm();
+const [loading, setLoading] = useState(true);
+const [saving, setSaving] = useState(false);
+const [citySearch, setCitySearch] = useState('');
+const [cityOptions, setCityOptions] = useState<Array<{ value: string; label: React.ReactNode; result: GeocodeSearchResult }>>([]);
+const [citySearching, setCitySearching] = useState(false);
+const cityTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
+const printRef = useRef<HTMLDivElement>(null);
+
+

State Variables: +- form (Form): Ant Design form instance (all settings inputs) +- loading (boolean): Initial page load state +- saving (boolean): Save button loading state +- citySearch (string): City search input value +- cityOptions (array): Autocomplete dropdown options +- citySearching (boolean): City search loading indicator +- cityTimerRef (ref): Debounce timer for city search +- printRef (ref): Reference to walk sheet preview div (for future print enhancements)

+

No Global State:

+

This page does NOT use Zustand stores. Map settings are fetched directly from the API and stored in the form. This is appropriate because: +- Map settings are admin-only configuration +- Settings change infrequently (set once during setup) +- No need to share state between pages (public map fetches settings independently) +- Simpler architecture without store overhead

+

Form Initialization

+
const fetchSettings = useCallback(async () => {
+  try {
+    const { data } = await api.get<MapSettings>('/map/settings');
+    form.setFieldsValue({
+      latitude: data.latitude ? parseFloat(data.latitude) : 45.4215,
+      longitude: data.longitude ? parseFloat(data.longitude) : -75.6972,
+      zoom: data.zoom ?? 12,
+      walkSheetTitle: data.walkSheetTitle,
+      walkSheetSubtitle: data.walkSheetSubtitle,
+      walkSheetFooter: data.walkSheetFooter,
+      qrCode1Url: data.qrCode1Url,
+      qrCode1Label: data.qrCode1Label,
+      qrCode2Url: data.qrCode2Url,
+      qrCode2Label: data.qrCode2Label,
+      qrCode3Url: data.qrCode3Url,
+      qrCode3Label: data.qrCode3Label,
+    });
+  } catch {
+    message.error('Failed to load map settings');
+  } finally {
+    setLoading(false);
+  }
+}, [form]);
+
+useEffect(() => {
+  fetchSettings();
+}, [fetchSettings]);
+
+

Default Values:

+

If settings not yet configured (first time), defaults are used: +- Latitude: 45.4215 (Ottawa, Canada) +- Longitude: -75.6972 (Ottawa, Canada) +- Zoom: 12 (city-level view) +- Walk Sheet Title: empty (shows "Walk Sheet" placeholder in preview) +- Other fields: empty

+ +
const handleCitySearch = useCallback((value: string) => {
+  setCitySearch(value);
+  clearTimeout(cityTimerRef.current);
+
+  if (value.length < 2) {
+    setCityOptions([]);
+    return;
+  }
+
+  cityTimerRef.current = setTimeout(async () => {
+    setCitySearching(true);
+    try {
+      const { data } = await api.get<GeocodeSearchResult[]>('/map/geocoding/search', {
+        params: { q: value, limit: 5 },
+      });
+      setCityOptions(data.map((r) => ({ /* ... */ })));
+    } catch {
+      setCityOptions([]);
+    } finally {
+      setCitySearching(false);
+    }
+  }, 400);
+}, []);
+
+useEffect(() => {
+  return () => clearTimeout(cityTimerRef.current);  // Cleanup on unmount
+}, []);
+
+

Why 400ms Debounce?

+
    +
  • Balance: Longer than typical 300ms to account for slower typing when entering city names
  • +
  • Network efficiency: Geocoding API queries are expensive (external service calls)
  • +
  • User experience: Users expect slight delay when searching for places (feels intentional)
  • +
+

API Integration

+

Endpoints Used

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointPurposeAuth
GET/api/map/settingsLoad current settingsRequired
PUT/api/map/settingsUpdate settingsRequired
GET/api/map/geocoding/searchSearch for cities/placesRequired
GET/api/qrGenerate QR code PNGPublic (no auth)
+

Load Map Settings

+

Request:

+
const { data } = await api.get<MapSettings>('/map/settings');
+
+

Response (200 OK):

+
{
+  "id": "settings_singleton",
+  "latitude": "45.4215",
+  "longitude": "-75.6972",
+  "zoom": 12,
+  "walkSheetTitle": "Canvassing Walk Sheet",
+  "walkSheetSubtitle": "District Outreach Campaign 2026",
+  "walkSheetFooter": "Return completed sheets to campaign office. Questions? Call 613-555-0100.",
+  "qrCode1Url": "https://cmlite.org/campaign-info",
+  "qrCode1Label": "Campaign Info",
+  "qrCode2Url": "https://forms.gle/abc123",
+  "qrCode2Label": "Volunteer Survey",
+  "qrCode3Url": null,
+  "qrCode3Label": null,
+  "createdAt": "2026-01-15T10:30:00.000Z",
+  "updatedAt": "2026-02-10T14:25:00.000Z"
+}
+
+

Response Fields: +- latitude (string): Default map center latitude (stored as string for precision) +- longitude (string): Default map center longitude +- zoom (number): Default map zoom level (2-19) +- walkSheetTitle (string | null): Walk sheet header title +- walkSheetSubtitle (string | null): Walk sheet header subtitle +- walkSheetFooter (string | null): Walk sheet footer text +- qrCode1Url (string | null): First QR code URL +- qrCode1Label (string | null): First QR code label +- qrCode2Url (string | null): Second QR code URL +- qrCode2Label (string | null): Second QR code label +- qrCode3Url (string | null): Third QR code URL +- qrCode3Label (string | null): Third QR code label

+

Backend Implementation:

+

Map settings are stored as singleton record (only one row in database):

+
const settings = await prisma.mapSettings.findFirst();
+if (!settings) {
+  // Return defaults if not yet configured
+  return {
+    latitude: '45.4215',
+    longitude: '-75.6972',
+    zoom: 12,
+    walkSheetTitle: null,
+    // ... other fields null
+  };
+}
+return settings;
+
+

Update Map Settings

+

Request:

+
const values = {
+  latitude: 45.4215,
+  longitude: -75.6972,
+  zoom: 14,
+  walkSheetTitle: 'Canvassing Walk Sheet',
+  walkSheetSubtitle: 'District Outreach Campaign 2026',
+  walkSheetFooter: 'Return completed sheets to campaign office.',
+  qrCode1Url: 'https://cmlite.org/campaign-info',
+  qrCode1Label: 'Campaign Info',
+  qrCode2Url: null,
+  qrCode2Label: null,
+  qrCode3Url: null,
+  qrCode3Label: null,
+};
+
+await api.put('/map/settings', values);
+
+

Request Body Schema:

+
{
+  latitude?: number;          // Optional, -90 to 90
+  longitude?: number;         // Optional, -180 to 180
+  zoom?: number;              // Optional, 2 to 19
+  walkSheetTitle?: string;    // Optional, max 255 chars
+  walkSheetSubtitle?: string; // Optional, max 255 chars
+  walkSheetFooter?: string;   // Optional, max 1000 chars
+  qrCode1Url?: string | null; // Optional, valid URL or null
+  qrCode1Label?: string | null;
+  qrCode2Url?: string | null;
+  qrCode2Label?: string | null;
+  qrCode3Url?: string | null;
+  qrCode3Label?: string | null;
+}
+
+

Response (200 OK):

+
{
+  "id": "settings_singleton",
+  "latitude": "45.4215",
+  "longitude": "-75.6972",
+  "zoom": 14,
+  "walkSheetTitle": "Canvassing Walk Sheet",
+  "walkSheetSubtitle": "District Outreach Campaign 2026",
+  "walkSheetFooter": "Return completed sheets to campaign office.",
+  "qrCode1Url": "https://cmlite.org/campaign-info",
+  "qrCode1Label": "Campaign Info",
+  "qrCode2Url": null,
+  "qrCode2Label": null,
+  "qrCode3Url": null,
+  "qrCode3Label": null,
+  "createdAt": "2026-01-15T10:30:00.000Z",
+  "updatedAt": "2026-02-11T10:45:00.000Z"
+}
+
+

Backend Implementation (Upsert):

+
const settings = await prisma.mapSettings.upsert({
+  where: { id: 'settings_singleton' },
+  create: {
+    id: 'settings_singleton',
+    latitude: values.latitude?.toString(),
+    longitude: values.longitude?.toString(),
+    zoom: values.zoom,
+    // ... other fields
+  },
+  update: {
+    latitude: values.latitude?.toString(),
+    longitude: values.longitude?.toString(),
+    zoom: values.zoom,
+    // ... other fields
+  },
+});
+
+

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)

+

Search for Cities (Geocoding)

+

Request:

+
const { data } = await api.get<GeocodeSearchResult[]>('/map/geocoding/search', {
+  params: {
+    q: 'Ottawa',
+    limit: 5,
+  },
+});
+
+

Query Parameters: +- q (string, required): Search query (city name, address, landmark) +- limit (number, optional): Maximum results to return (default: 10, max: 20)

+

Response (200 OK):

+
[
+  {
+    "displayName": "Ottawa, Ontario, Canada",
+    "latitude": 45.4215,
+    "longitude": -75.6972,
+    "type": "city",
+    "importance": 0.98,
+    "boundingBox": {
+      "minLat": 45.2,
+      "maxLat": 45.6,
+      "minLon": -76.0,
+      "maxLon": -75.4
+    }
+  },
+  {
+    "displayName": "Ottawa, Kansas, United States",
+    "latitude": 38.6156,
+    "longitude": -95.2678,
+    "type": "city",
+    "importance": 0.75
+  },
+  {
+    "displayName": "Ottawa, Illinois, United States",
+    "latitude": 41.3456,
+    "longitude": -88.8426,
+    "type": "city",
+    "importance": 0.72
+  }
+]
+
+

Response Fields: +- displayName (string): Human-readable location name (e.g., "Ottawa, Ontario, Canada") +- latitude (number): Latitude coordinate +- longitude (number): Longitude coordinate +- type (string): Location type (city, town, village, suburb, neighbourhood, etc.) +- importance (number): Relevance score (0.0-1.0, higher = more relevant) +- boundingBox (object, optional): Geographic bounds for larger areas

+

Sorting: +- Results sorted by importance DESC (most relevant first) +- Limited to top N results (default 5 for autocomplete)

+

Backend Implementation:

+

Multi-provider geocoding service (see Geocoding Service documentation):

+
const results = await geocodingService.search(query, { limit });
+return results.map((r) => ({
+  displayName: r.display_name,
+  latitude: r.lat,
+  longitude: r.lon,
+  type: r.type,
+  importance: r.importance,
+}));
+
+

Providers Used (in order of preference): +1. Nominatim (OpenStreetMap) +2. ArcGIS +3. Photon +4. Mapbox (if API key provided)

+

Generate QR Code

+

Request:

+
const qrCodeUrl = `/api/qr?text=${encodeURIComponent('https://cmlite.org/campaign-info')}&size=200`;
+
+<img src={qrCodeUrl} alt="Campaign Info QR Code" style={{ width: 80, height: 80 }} />
+
+

Query Parameters: +- text (string, required): URL or text to encode (URL-encoded) +- size (number, optional): QR code size in pixels (default: 200, max: 1000)

+

Response (200 OK):

+

Binary PNG image data (Content-Type: image/png)

+

Example URLs: +- Campaign website: /api/qr?text=https%3A%2F%2Fcmlite.org%2Fcampaign-info&size=200 +- Google Form: /api/qr?text=https%3A%2F%2Fforms.gle%2Fabc123&size=200 +- Phone number: /api/qr?text=tel%3A%2B16135550100&size=200

+

QR Code Generation:

+

Backend uses qrcode npm package:

+
import QRCode from 'qrcode';
+
+const qrBuffer = await QRCode.toBuffer(text, {
+  width: size,
+  margin: 1,
+  errorCorrectionLevel: 'M',
+});
+
+res.set('Content-Type', 'image/png');
+res.send(qrBuffer);
+
+

Error Correction Level: +- M (Medium): Can recover from 15% damage +- Balanced between size and error tolerance +- Suitable for most use cases (printed walk sheets)

+

Code Examples

+

Complete City Search Flow

+
const handleCitySearch = useCallback((value: string) => {
+  setCitySearch(value);
+  clearTimeout(cityTimerRef.current);
+
+  // Require minimum 2 characters
+  if (value.length < 2) {
+    setCityOptions([]);
+    return;
+  }
+
+  // Debounce 400ms
+  cityTimerRef.current = setTimeout(async () => {
+    setCitySearching(true);
+    try {
+      // Query geocoding service
+      const { data } = await api.get<GeocodeSearchResult[]>('/map/geocoding/search', {
+        params: { q: value, limit: 5 },
+      });
+
+      // Map results to autocomplete options
+      setCityOptions(data.map((r) => ({
+        value: r.displayName,
+        label: (
+          <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+            <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 300 }}>
+              {r.displayName}
+            </span>
+            <span style={{ color: '#888', fontSize: 11, marginLeft: 8, flexShrink: 0 }}>
+              {r.type}
+            </span>
+          </div>
+        ),
+        result: r,  // Store full result for onSelect handler
+      })));
+    } catch {
+      setCityOptions([]);
+    } finally {
+      setCitySearching(false);
+    }
+  }, 400);
+}, []);
+
+const handleCitySelect = useCallback((_value: string, option: { result: GeocodeSearchResult }) => {
+  // Auto-fill coordinates from selected result
+  form.setFieldsValue({
+    latitude: option.result.latitude,
+    longitude: option.result.longitude,
+    zoom: 12,  // Default zoom for city-level view
+  });
+
+  // Clear search
+  setCitySearch('');
+  setCityOptions([]);
+
+  // Notify user
+  message.success('Coordinates auto-filled. Fine-tune below.');
+}, [form]);
+
+// Cleanup timer on unmount
+useEffect(() => {
+  return () => clearTimeout(cityTimerRef.current);
+}, []);
+
+

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

+

Live Walk Sheet Preview

+
// Watch all form values for live preview
+const watched = Form.useWatch(undefined, form) as Record<string, string | undefined> | undefined;
+
+// Extract QR codes (filter out empty URLs)
+const qrCodes = [
+  { url: watched?.qrCode1Url, label: watched?.qrCode1Label },
+  { url: watched?.qrCode2Url, label: watched?.qrCode2Label },
+  { url: watched?.qrCode3Url, label: watched?.qrCode3Label },
+].filter((qr) => qr.url);
+
+return (
+  <div className="walk-sheet-print" style={{ /* ... */ }}>
+    {/* Header */}
+    <div style={{ borderBottom: '2px solid #000', paddingBottom: 6 }}>
+      <div style={{ fontSize: 18, fontWeight: 700, textAlign: 'center' }}>
+        {watched?.walkSheetTitle || 'Walk Sheet'}
+      </div>
+      {watched?.walkSheetSubtitle && (
+        <div style={{ fontSize: 12, textAlign: 'center', color: '#333' }}>
+          {watched.walkSheetSubtitle}
+        </div>
+      )}
+    </div>
+
+    {/* QR Codes */}
+    {qrCodes.length > 0 && (
+      <div style={{ display: 'flex', justifyContent: 'center', gap: 32 }}>
+        {qrCodes.map((qr, i) => (
+          <div key={i} style={{ textAlign: 'center' }}>
+            <img
+              src={`/api/qr?text=${encodeURIComponent(qr.url!)}&size=200`}
+              alt={qr.label || `QR Code ${i + 1}`}
+              style={{ width: 80, height: 80 }}
+            />
+            {qr.label && <div style={{ fontSize: 9 }}>{qr.label}</div>}
+          </div>
+        ))}
+      </div>
+    )}
+
+    {/* Contact entry blocks (2 blocks per page) */}
+    {[1, 2].map((blockNum) => (
+      <div key={blockNum}>
+        {blockNum > 1 && <div style={{ borderTop: '1px dashed #999' }} />}
+        <FormRow left="First Name" right="Last Name" />
+        <FormRow left="Email" right="Phone" />
+        <FormRow left="Address" right="Unit Number" />
+        {/* Support Level, Sign Request, Sign Size, Visited Date */}
+      </div>
+    ))}
+
+    {/* Footer */}
+    {watched?.walkSheetFooter && (
+      <div style={{ marginTop: 10, textAlign: 'center', fontSize: 9, color: '#666' }}>
+        {watched.walkSheetFooter}
+      </div>
+    )}
+  </div>
+);
+
+

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

+ +
<style>{`
+  @media print {
+    /* Hide everything except walk sheet */
+    body * { visibility: hidden !important; }
+    .walk-sheet-print, .walk-sheet-print * { visibility: visible !important; }
+
+    /* Position walk sheet at top-left of page */
+    .walk-sheet-print {
+      position: fixed !important;
+      left: 0 !important;
+      top: 0 !important;
+      width: 8.5in !important;
+      height: 11in !important;
+      padding: 0.4in 0.5in !important;
+      background: white !important;
+      color: black !important;
+      box-sizing: border-box !important;
+    }
+
+    /* Ensure QR codes print with high contrast */
+    .walk-sheet-print img {
+      print-color-adjust: exact;
+      -webkit-print-color-adjust: exact;
+    }
+
+    /* Set page size */
+    @page {
+      size: letter;
+      margin: 0;
+    }
+  }
+`}</style>
+
+

CSS Explanation:

+
    +
  • Hide all: body * { visibility: hidden } hides navigation, buttons, etc.
  • +
  • Show walk sheet: .walk-sheet-print * { visibility: visible } shows only preview
  • +
  • Fixed positioning: Ensures walk sheet starts at top-left of printed page
  • +
  • Exact dimensions: 8.5" × 11" Letter size with 0.4" padding
  • +
  • QR code printing: print-color-adjust: exact ensures QR codes print with high contrast
  • +
  • Page size: @page { size: letter } sets printer page size
  • +
+

Form Submission Handler

+
const handleSave = async (values: Record<string, unknown>) => {
+  setSaving(true);
+  try {
+    await api.put('/map/settings', values);
+    message.success('Map settings saved');
+  } catch (err: unknown) {
+    const msg =
+      (err as { response?: { data?: { error?: { message?: string } } } })
+        ?.response?.data?.error?.message || 'Failed to save settings';
+    message.error(msg);
+  } finally {
+    setSaving(false);
+  }
+};
+
+

Error Handling:

+

Extracts specific error message from API response:

+
// API error response format:
+{
+  "error": {
+    "message": "Latitude must be between -90 and 90"
+  }
+}
+
+// Extracted error message shown to user:
+"Latitude must be between -90 and 90"
+
+

Generic Fallback:

+

If error message not in expected format, shows generic message:

+
"Failed to save settings"
+
+

Performance Considerations

+

Debounced City Search (400ms)

+

City search queries geocoding service after 400ms delay:

+
cityTimerRef.current = setTimeout(async () => {
+  // Query geocoding service
+}, 400);
+
+

Performance Impact: +- Without debounce: Typing "Ottawa" (6 chars) = 6 API calls +- With 400ms debounce: Typing "Ottawa" = 1 API call (after 400ms pause) +- 84% reduction in API calls

+

Why 400ms?

+
    +
  • Slower typing: City names are longer than typical search queries
  • +
  • Expensive queries: Geocoding service queries external APIs (Nominatim, ArcGIS)
  • +
  • User expectation: Users expect slight delay when searching for places
  • +
+

Live Preview Performance

+

Walk sheet preview updates on every form keystroke:

+
const watched = Form.useWatch(undefined, form);
+
+

Performance Impact: +- Re-renders: Preview re-renders on every form value change +- Expensive renders: QR code images regenerated (browser fetches new PNG from /api/qr) +- Trade-off: Immediate feedback vs. performance

+

Mitigation:

+

Consider debouncing QR code updates:

+
const debouncedQrUrls = useDebounce([watched?.qrCode1Url, watched?.qrCode2Url, watched?.qrCode3Url], 500);
+
+

Result: +- User types QR code URL +- Preview shows old QR code for 500ms +- After 500ms pause, QR code updates to new URL +- Reduces API calls to /api/qr endpoint

+ +

Print-specific CSS uses visibility: hidden instead of display: none:

+
body * { visibility: hidden !important; }
+.walk-sheet-print * { visibility: visible !important; }
+
+

Why Visibility?

+
    +
  • Layout preserved: Hidden elements still occupy space (prevents layout shift)
  • +
  • Print performance: Browser doesn't need to recalculate layout for print
  • +
  • Consistent output: Print preview matches actual printed page
  • +
+

Comparison:

+
    +
  • visibility: hidden — Element invisible but still occupies space
  • +
  • display: none — Element removed from layout entirely (causes layout shift)
  • +
+

Responsive Design

+

Two-Column Layout

+

Settings form and preview adapt to viewport width:

+
<Row gutter={24}>
+  <Col xs={24} lg={10}>  {/* Full width mobile, 40% desktop */}
+    <Form>{/* ... */}</Form>
+  </Col>
+  <Col xs={24} lg={14}>  {/* Full width mobile, 60% desktop */}
+    <Card title="Walk Sheet Preview">{/* ... */}</Card>
+  </Col>
+</Row>
+
+

Responsive Breakpoints: +- Mobile (xs, <992px): Stacked layout (form on top, preview below) +- Desktop (lg, ≥992px): Side-by-side layout (form 40%, preview 60%)

+

Why 40/60 Split?

+
    +
  • Form needs less space: Most inputs are single-line (latitude, longitude, title)
  • +
  • Preview needs more space: Walk sheet is 8.5" × 11" (benefits from larger width)
  • +
  • Visual balance: 40/60 ratio feels more balanced than 50/50
  • +
+

Mobile Print Behavior

+

On mobile devices, print preview is less practical:

+

Option 1: Hide preview on mobile

+
<Col xs={0} lg={14}>  {/* Hidden on mobile (xs={0}) */}
+  <Card title="Walk Sheet Preview">{/* ... */}</Card>
+</Col>
+
+

Option 2: Show preview below form

+
<Col xs={24} lg={14}>  {/* Full width mobile, shown below form */}
+  <Card title="Walk Sheet Preview">{/* ... */}</Card>
+</Col>
+
+

Current Implementation: Option 2 (show preview below form on mobile)

+

Rationale:

+
    +
  • Mobile users can still see preview (helpful for QR code testing)
  • +
  • Print button still works on mobile (triggers native print dialog)
  • +
  • Stacked layout is natural on mobile (no horizontal scrolling)
  • +
+

Accessibility

+

Keyboard Navigation

+

All interactive elements are keyboard-accessible:

+

City Search Autocomplete: +- Tab: Focus on autocomplete input +- Type: Enter city name +- Down Arrow: Navigate dropdown options +- Enter: Select focused option +- Escape: Close dropdown

+

Form Fields: +- Tab: Move between fields (latitude → longitude → zoom → title...) +- Arrow Keys: Adjust zoom slider value +- Enter: Submit form (same as clicking "Save Settings")

+

Buttons: +- Tab: Focus on button +- Enter/Space: Activate button (print, save)

+

Screen Reader Support

+

All elements have proper ARIA labels:

+

City Search: +

<AutoComplete
+  placeholder="Search for a city to auto-fill coordinates..."
+  aria-label="Search for a city to auto-fill map center coordinates"
+  aria-describedby="city-search-hint"
+/>
+<Text id="city-search-hint" type="secondary">
+  Search for a city to auto-fill coordinates. Fine-tune below.
+</Text>
+

+

Form Fields: +

<Form.Item name="latitude" label="Latitude">
+  <InputNumber
+    aria-label="Map center latitude in decimal degrees"
+    aria-valuemin={-90}
+    aria-valuemax={90}
+  />
+</Form.Item>
+

+

Walk Sheet Preview: +

<Card
+  title="Walk Sheet Preview"
+  aria-label="Live preview of walk sheet with current settings"
+>
+  {/* ... */}
+</Card>
+

+

Color Contrast

+

All text meets WCAG AA standards:

+

Form Labels: +- Label text: rgba(0,0,0,0.85) on white = 13.6:1 contrast (AAA)

+

Helper Text: +- Helper text: rgba(0,0,0,0.45) on white = 7.0:1 contrast (AA)

+

Walk Sheet Preview: +- Header text: #000 on white = 21:1 contrast (AAA) +- Field labels: #666 on white = 5.7:1 contrast (AA)

+

Troubleshooting

+

City Search Not Working

+

Problem: Type city name in autocomplete, but no dropdown suggestions appear.

+

Diagnosis:

+

Check browser console for errors:

+
GET /api/map/geocoding/search?q=Ottawa&limit=5 500 Internal Server Error
+
+

Possible Causes:

+
    +
  1. Geocoding service down:
  2. +
  3. All providers (Nominatim, ArcGIS, Photon) unavailable
  4. +
  5. +

    Network connectivity issue

    +
  6. +
  7. +

    Invalid query:

    +
  8. +
  9. Query too short (< 2 characters)
  10. +
  11. +

    Special characters causing parsing errors

    +
  12. +
  13. +

    Rate limiting:

    +
  14. +
  15. Too many requests to geocoding providers
  16. +
  17. IP temporarily blocked
  18. +
+

Solution:

+
    +
  1. For service issues:
  2. +
  3. Check geocoding service logs: docker compose logs api | grep geocoding
  4. +
  5. Test Nominatim directly: curl "https://nominatim.openstreetmap.org/search?q=Ottawa&format=json"
  6. +
  7. +

    If down, wait 5 minutes and retry

    +
  8. +
  9. +

    For invalid queries:

    +
  10. +
  11. Ensure at least 2 characters entered
  12. +
  13. +

    Try simpler query (e.g., "Ottawa" instead of "Ottawa, ON, Canada")

    +
  14. +
  15. +

    For rate limiting:

    +
  16. +
  17. Wait 1 hour before retrying
  18. +
  19. Use manual coordinate entry instead
  20. +
  21. Consider adding Mapbox API key (higher rate limits)
  22. +
+
+

QR Codes Not Showing in Preview

+

Problem: Enter QR code URL in form, but QR code doesn't appear in preview.

+

Diagnosis:

+

Check QR code API endpoint:

+
curl "http://localhost:4000/api/qr?text=https%3A%2F%2Fcmlite.org&size=200" -o test-qr.png
+
+

Expected: PNG file downloaded

+

Actual: Error response

+
{
+  "error": "Invalid size parameter"
+}
+
+

Possible Causes:

+
    +
  1. Invalid URL:
  2. +
  3. QR code URL field contains invalid URL
  4. +
  5. +

    Special characters not URL-encoded

    +
  6. +
  7. +

    QR API endpoint down:

    +
  8. +
  9. API container not running
  10. +
  11. +

    QR code generation service crashed

    +
  12. +
  13. +

    Browser caching:

    +
  14. +
  15. Browser cached old QR code PNG
  16. +
  17. Need to clear cache or force refresh
  18. +
+

Solution:

+
    +
  1. For invalid URLs:
  2. +
  3. Ensure URL includes protocol: https:// not www.
  4. +
  5. Test URL in browser: Click URL to verify it opens correctly
  6. +
  7. +

    Check for special characters: URL-encode if necessary

    +
  8. +
  9. +

    For API issues:

    +
  10. +
  11. Check API logs: docker compose logs api | grep qr
  12. +
  13. Restart API: docker compose restart api
  14. +
  15. +

    Test endpoint: curl "http://localhost:4000/api/qr?text=test&size=200"

    +
  16. +
  17. +

    For caching:

    +
  18. +
  19. Hard refresh browser: Ctrl+Shift+R (Windows) or Cmd+Shift+R (Mac)
  20. +
  21. Clear browser cache: Settings → Clear browsing data
  22. +
  23. Add cache-busting param: &v=${Date.now()} to QR code URL
  24. +
+
+

Walk Sheet Prints with Navigation

+

Problem: Click "Print" button, but printed page includes navigation sidebar and header.

+

Diagnosis:

+

Check print preview (Ctrl+P or Cmd+P):

+

Expected: Only walk sheet visible

+

Actual: Full page (navigation, header, footer) visible

+

Possible Causes:

+
    +
  1. Print CSS not applied:
  2. +
  3. Browser not detecting print media query
  4. +
  5. +

    CSS @media print not working

    +
  6. +
  7. +

    CSS specificity issue:

    +
  8. +
  9. Other stylesheets overriding print CSS
  10. +
  11. +

    !important flags not effective

    +
  12. +
  13. +

    Browser print settings:

    +
  14. +
  15. "Print backgrounds" option disabled
  16. +
  17. "Headers and footers" option enabled
  18. +
+

Solution:

+
    +
  1. For CSS issues (developer fix):
  2. +
  3. Increase specificity: body * { visibility: hidden !important; }
  4. +
  5. Use @page { margin: 0; } to remove default margins
  6. +
  7. +

    Test in multiple browsers (Chrome, Firefox, Safari)

    +
  8. +
  9. +

    For browser settings:

    +
  10. +
  11. Enable "Print backgrounds" in print dialog
  12. +
  13. Disable "Headers and footers" in print dialog
  14. +
  15. +

    Select "None" for margins

    +
  16. +
  17. +

    Alternative (if CSS fails):

    +
  18. +
  19. Open walk sheet in new window: window.open('/walk-sheet-preview')
  20. +
  21. Print from dedicated preview page (no navigation)
  22. +
  23. Use "Print to PDF" and print PDF separately
  24. +
+
+

Coordinates Don't Update Map Center

+

Problem: Save new latitude/longitude in settings, but public map still shows old center.

+

Diagnosis:

+

Check public map settings fetch:

+
curl "http://localhost:4000/api/map/settings"
+
+

Expected: New coordinates returned

+
{
+  "latitude": "45.4215",
+  "longitude": "-75.6972"
+}
+
+

Actual: Old coordinates returned

+
{
+  "latitude": "43.6532",
+  "longitude": "-79.3832"
+}
+
+

Possible Causes:

+
    +
  1. Settings not saved:
  2. +
  3. Save button clicked but API request failed
  4. +
  5. +

    Error message shown but not noticed

    +
  6. +
  7. +

    Database not updated:

    +
  8. +
  9. Database write failed (permissions issue)
  10. +
  11. +

    Transaction rolled back due to error

    +
  12. +
  13. +

    Public map caching:

    +
  14. +
  15. Public map caching old settings in browser
  16. +
  17. Need to clear cache or force refresh
  18. +
+

Solution:

+
    +
  1. For save issues:
  2. +
  3. Check API logs: docker compose logs api | grep "settings saved"
  4. +
  5. Retry save: Click "Save Settings" again
  6. +
  7. +

    Check for error messages: Look for red toast notification

    +
  8. +
  9. +

    For database issues:

    +
  10. +
  11. Check database: docker compose exec v2-postgres psql -U postgres -d v2 -c "SELECT * FROM \"MapSettings\""
  12. +
  13. Verify coordinates match expected values
  14. +
  15. +

    If mismatch, manually update: UPDATE "MapSettings" SET latitude = '45.4215', longitude = '-75.6972'

    +
  16. +
  17. +

    For caching:

    +
  18. +
  19. Hard refresh public map: Ctrl+Shift+R (Windows) or Cmd+Shift+R (Mac)
  20. +
  21. Clear browser cache: Settings → Clear browsing data
  22. +
  23. Check API response: Verify /api/map/settings returns new coordinates
  24. +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/mini-qr-page/index.html b/mkdocs/site/v2/frontend/pages/admin/mini-qr-page/index.html new file mode 100644 index 00000000..93229882 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/mini-qr-page/index.html @@ -0,0 +1,8044 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Mini QR - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

MiniQRPage

+

Overview

+

File: admin/src/pages/MiniQRPage.tsx

+

Route: /app/services/mini-qr

+

Role Requirements: Any authenticated user (uses authenticate middleware)

+

Purpose: Provides an embedded interface to the Mini QR code generator service via iframe. This page serves as a simple wrapper that embeds the external QR code generation service with online/offline status monitoring and mobile device detection.

+

Key Features: +- Full-page iframe embed of Mini QR service +- Service online/offline status monitoring +- Mobile device detection with warning screen +- Fullbleed layout (no padding in AppLayout) +- Automatic service health checks

+

Layout: AppLayout with fullbleed (no content padding)

+

Dependencies: +- Ant Design v5 (Alert, Spin, Typography, Result, Button, Grid) +- react hooks (useState, useEffect)

+
+

Features

+

1. Service Status Monitoring

+

Status Display: +- Green checkmark with "Service Online" when Mini QR is accessible +- Red X with "Service Offline" when Mini QR is not accessible +- Loading spinner during status check

+

Auto-refresh: +- Status checked on page load +- No automatic periodic refresh (manual refresh via browser required)

+

2. Mobile Device Detection

+

Mobile Warning Screen: +- Detects mobile devices using Grid.useBreakpoint() +- Shows warning Result component on mobile +- Recommends using desktop for better QR code generation experience +- Provides link to open service in new tab

+

Breakpoint: !screens.md (screen width < 768px = mobile)

+

3. Iframe Embedding

+

Fullbleed Layout: +- No padding around iframe +- 100% width and height +- Seamless integration with AppLayout

+

Error Handling: +- Shows error message if iframe fails to load +- Provides troubleshooting guidance

+
+

User Workflow

+

Accessing Mini QR Service

+
    +
  1. Navigate to Mini QR:
  2. +
  3. Click "Services" → "Mini QR" in sidebar
  4. +
  5. +

    Page loads with status check

    +
  6. +
  7. +

    Check Service Status:

    +
  8. +
  9. Status indicator appears at top:
      +
    • ✅ "Service Online" (green) - Service available
    • +
    • ❌ "Service Offline" (red) - Service unavailable
    • +
    +
  10. +
  11. +

    Loading spinner shown during status check

    +
  12. +
  13. +

    View on Desktop:

    +
  14. +
  15. +

    If on desktop (screen width ≥ 768px):

    +
      +
    • Iframe loads automatically below status
    • +
    • Full Mini QR interface embedded in page
    • +
    • Use QR generator as normal
    • +
    +
  16. +
  17. +

    View on Mobile:

    +
  18. +
  19. +

    If on mobile (screen width < 768px):

    +
      +
    • Warning message appears instead of iframe
    • +
    • Message: "Mini QR is best used on desktop"
    • +
    • "Open in New Tab" button provided
    • +
    • Click button to open service in separate browser tab
    • +
    +
  20. +
  21. +

    Using Mini QR Service:

    +
  22. +
  23. Enter text or URL to encode
  24. +
  25. Select QR code size/format
  26. +
  27. Generate QR code
  28. +
  29. +

    Download QR code image

    +
  30. +
  31. +

    Troubleshoot Offline Service:

    +
  32. +
  33. If service shows "Offline":
      +
    • Check Docker container: docker compose ps mini-qr
    • +
    • Restart service: docker compose restart mini-qr
    • +
    • Verify nginx routing: Check nginx/conf.d/services.conf
    • +
    +
  34. +
  35. Refresh page after fixing
  36. +
+
+

Component Breakdown

+

Main Component Structure

+
const MiniQRPage: React.FC = () => {
+  // State
+  const [loading, setLoading] = useState(true);
+  const [online, setOnline] = useState(false);
+
+  // Responsive breakpoints
+  const screens = Grid.useBreakpoint();
+  const isMobile = !screens.md;
+
+  // Check service status on mount
+  useEffect(() => {
+    checkServiceStatus();
+  }, []);
+
+  const checkServiceStatus = async () => {
+    try {
+      setLoading(true);
+      const response = await api.get('/api/services/mini-qr/status');
+      setOnline(response.data.online);
+    } catch (error) {
+      console.error('Failed to check Mini QR status:', error);
+      setOnline(false);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // Show mobile warning if on mobile device
+  if (isMobile) {
+    return (
+      <Result
+        icon={<MobileOutlined />}
+        title="Desktop Recommended"
+        subTitle="Mini QR is best used on desktop for optimal QR code generation experience."
+        extra={
+          <Button
+            type="primary"
+            href="http://qr.cmlite.org"
+            target="_blank"
+          >
+            Open in New Tab
+          </Button>
+        }
+      />
+    );
+  }
+
+  return (
+    <div style={{ height: '100%' }}>
+      {/* Status indicator */}
+      {loading ? (
+        <Spin />
+      ) : (
+        <Alert
+          type={online ? 'success' : 'error'}
+          message={online ? 'Service Online' : 'Service Offline'}
+          showIcon
+        />
+      )}
+
+      {/* Iframe embed */}
+      {online && !loading && (
+        <iframe
+          src="http://qr.cmlite.org"
+          style={{
+            width: '100%',
+            height: 'calc(100% - 60px)',  // Subtract status bar height
+            border: 'none',
+          }}
+          title="Mini QR Code Generator"
+        />
+      )}
+    </div>
+  );
+};
+
+

Ant Design Components Used

+
    +
  1. Alert - Service status indicator (success/error)
  2. +
  3. Spin - Loading spinner during status check
  4. +
  5. Result - Mobile warning screen with icon and message
  6. +
  7. Button - "Open in New Tab" action button
  8. +
  9. Typography.Text - Descriptive text (if needed)
  10. +
  11. Grid.useBreakpoint() - Responsive breakpoint detection
  12. +
+

Iframe Configuration

+
<iframe
+  src="http://qr.cmlite.org"  // Mini QR service URL (nginx proxied)
+  style={{
+    width: '100%',             // Full container width
+    height: 'calc(100% - 60px)',  // Full height minus status bar
+    border: 'none',            // No border for seamless integration
+  }}
+  title="Mini QR Code Generator"  // Accessibility title
+  sandbox="allow-same-origin allow-scripts allow-forms"  // Security sandbox
+/>
+
+

Sandbox Attributes: +- allow-same-origin - Allows iframe to access cookies/localStorage +- allow-scripts - Allows JavaScript execution +- allow-forms - Allows form submission

+
+

State Management

+

Local Component State (useState)

+

No Zustand stores used - All state managed locally with React hooks.

+
// Service status loading state
+const [loading, setLoading] = useState(true);
+
+// Service online/offline state
+const [online, setOnline] = useState(false);
+
+// Responsive breakpoint detection
+const screens = Grid.useBreakpoint();
+const isMobile = !screens.md;  // Mobile if screen width < 768px
+
+

State Flow

+
    +
  1. Component Mounts:
  2. +
  3. checkServiceStatus() called in useEffect
  4. +
  5. Sets loading to true
  6. +
  7. Fetches service status via GET /api/services/mini-qr/status
  8. +
  9. Sets online to true or false based on response
  10. +
  11. +

    Sets loading to false

    +
  12. +
  13. +

    Service Online:

    +
  14. +
  15. online is true
  16. +
  17. Alert shows "Service Online" (green)
  18. +
  19. +

    Iframe renders with Mini QR service embedded

    +
  20. +
  21. +

    Service Offline:

    +
  22. +
  23. online is false
  24. +
  25. Alert shows "Service Offline" (red)
  26. +
  27. +

    No iframe rendered (blank space below alert)

    +
  28. +
  29. +

    Mobile Device:

    +
  30. +
  31. isMobile is true
  32. +
  33. Component returns early with warning Result
  34. +
  35. No status check, no iframe
  36. +
+
+

API Integration

+

Endpoints Used

+
    +
  1. GET /api/services/mini-qr/status - Check Mini QR service health
  2. +
+

API Client

+
import { api } from '@/lib/api';
+
+// All requests use authenticated API client with automatic token refresh
+
+

Example API Call

+

Check Service Status

+
const checkServiceStatus = async () => {
+  try {
+    setLoading(true);
+
+    // Check service health
+    const response = await api.get('/api/services/mini-qr/status');
+
+    // Set online state based on response
+    setOnline(response.data.online);
+  } catch (error) {
+    console.error('Failed to check Mini QR status:', error);
+
+    // Treat any error as offline
+    setOnline(false);
+  } finally {
+    setLoading(false);
+  }
+};
+
+useEffect(() => {
+  checkServiceStatus();
+}, []);
+
+

Response Format (Online): +

{
+  "online": true,
+  "url": "http://qr.cmlite.org",
+  "message": "Mini QR service is online"
+}
+

+

Response Format (Offline): +

{
+  "online": false,
+  "message": "Mini QR service is offline",
+  "error": "Connection refused"
+}
+

+

Error Handling: +- Network errors (500, 503): Treated as offline +- Timeout errors: Treated as offline +- CORS errors: Treated as offline (service not accessible)

+
+

Code Examples

+

Complete Component Implementation

+
import React, { useState, useEffect } from 'react';
+import { Alert, Spin, Typography, Result, Button, Grid } from 'antd';
+import { MobileOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
+import { api } from '@/lib/api';
+
+const { Title } = Typography;
+
+const MiniQRPage: React.FC = () => {
+  const [loading, setLoading] = useState(true);
+  const [online, setOnline] = useState(false);
+
+  const screens = Grid.useBreakpoint();
+  const isMobile = !screens.md;
+
+  useEffect(() => {
+    checkServiceStatus();
+  }, []);
+
+  const checkServiceStatus = async () => {
+    try {
+      setLoading(true);
+      const response = await api.get('/api/services/mini-qr/status');
+      setOnline(response.data.online);
+    } catch (error) {
+      console.error('Failed to check Mini QR status:', error);
+      setOnline(false);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // Show mobile warning on mobile devices
+  if (isMobile) {
+    return (
+      <Result
+        icon={<MobileOutlined style={{ fontSize: 72, color: '#faad14' }} />}
+        title="Desktop Recommended"
+        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."
+        extra={
+          <Button
+            type="primary"
+            size="large"
+            href="http://qr.cmlite.org"
+            target="_blank"
+            rel="noopener noreferrer"
+          >
+            Open in New Tab
+          </Button>
+        }
+      />
+    );
+  }
+
+  return (
+    <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
+      {/* Status Bar */}
+      <div style={{ padding: '16px 0' }}>
+        {loading ? (
+          <div style={{ textAlign: 'center' }}>
+            <Spin tip="Checking service status..." />
+          </div>
+        ) : (
+          <Alert
+            type={online ? 'success' : 'error'}
+            message={
+              online ? (
+                <>
+                  <CheckCircleOutlined style={{ marginRight: 8 }} />
+                  Service Online
+                </>
+              ) : (
+                <>
+                  <CloseCircleOutlined style={{ marginRight: 8 }} />
+                  Service Offline
+                </>
+              )
+            }
+            description={
+              online
+                ? 'Mini QR service is running and accessible'
+                : 'Mini QR service is currently unavailable. Please check Docker container status.'
+            }
+            showIcon
+            banner
+          />
+        )}
+      </div>
+
+      {/* Iframe Embed */}
+      {online && !loading && (
+        <iframe
+          src="http://qr.cmlite.org"
+          style={{
+            width: '100%',
+            height: 'calc(100% - 80px)',  // Subtract status bar height
+            border: 'none',
+            flexGrow: 1,
+          }}
+          title="Mini QR Code Generator"
+          sandbox="allow-same-origin allow-scripts allow-forms allow-downloads"
+          loading="lazy"
+        />
+      )}
+
+      {/* Offline Message */}
+      {!online && !loading && (
+        <div style={{ padding: 24, textAlign: 'center' }}>
+          <Title level={4}>Service Not Available</Title>
+          <Typography.Paragraph>
+            The Mini QR service is currently offline. Please contact your system administrator
+            or check the Docker container status.
+          </Typography.Paragraph>
+          <Button onClick={checkServiceStatus}>
+            Retry
+          </Button>
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default MiniQRPage;
+
+

Mobile Detection Pattern

+
import { Grid } from 'antd';
+
+const screens = Grid.useBreakpoint();
+const isMobile = !screens.md;  // Mobile if screen width < 768px
+
+if (isMobile) {
+  return (
+    <Result
+      icon={<MobileOutlined />}
+      title="Desktop Recommended"
+      subTitle="This service works best on desktop devices."
+      extra={
+        <Button type="primary" href="<service-url>" target="_blank">
+          Open in New Tab
+        </Button>
+      }
+    />
+  );
+}
+
+

Breakpoint Values: +- xs: < 576px (extra small) +- sm: ≥ 576px (small) +- md: ≥ 768px (medium) ← Used for mobile detection +- lg: ≥ 992px (large) +- xl: ≥ 1200px (extra large) +- xxl: ≥ 1600px (extra extra large)

+

Fullbleed Layout Pattern

+
// In App.tsx route configuration
+<Route
+  path="/app/services/mini-qr"
+  element={<MiniQRPage />}
+/>
+
+// In MiniQRPage component
+const MiniQRPage: React.FC = () => {
+  return (
+    <div style={{ height: '100%' }}>  {/* Full height container */}
+      <iframe
+        src="http://qr.cmlite.org"
+        style={{
+          width: '100%',              // Full width
+          height: 'calc(100% - 60px)',  // Full height minus status bar
+          border: 'none',             // No border
+        }}
+      />
+    </div>
+  );
+};
+
+// AppLayout automatically applies fullbleed styling (no padding)
+// when route is detected as service page
+
+

Service Status Check with Error Handling

+
const checkServiceStatus = async () => {
+  try {
+    setLoading(true);
+
+    // Set timeout for status check (5 seconds)
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 5000);
+
+    const response = await api.get('/api/services/mini-qr/status', {
+      signal: controller.signal,
+    });
+
+    clearTimeout(timeoutId);
+
+    // Check response
+    if (response.data.online) {
+      setOnline(true);
+    } else {
+      setOnline(false);
+      console.warn('Mini QR service reported as offline:', response.data.message);
+    }
+  } catch (error) {
+    // Handle different error types
+    if (error.name === 'AbortError') {
+      console.error('Mini QR status check timed out');
+    } else if (error.response) {
+      console.error('Mini QR status check failed with status:', error.response.status);
+    } else {
+      console.error('Mini QR status check failed:', error.message);
+    }
+
+    // Always treat errors as offline
+    setOnline(false);
+  } finally {
+    setLoading(false);
+  }
+};
+
+
+

Performance Considerations

+

1. Lazy Iframe Loading

+
<iframe
+  src="http://qr.cmlite.org"
+  loading="lazy"  // Defers iframe loading until near viewport
+  // ... other props
+/>
+
+

Benefit: Saves bandwidth and CPU by not loading iframe until needed. However, since iframe is typically in viewport immediately, this has minimal impact.

+

2. Early Mobile Detection

+
// Check mobile before any API calls or rendering
+if (isMobile) {
+  return <Result />;  // Render warning immediately, no API calls
+}
+
+

Benefit: Avoids unnecessary service status checks on mobile devices, saving API requests and improving page load time.

+

3. Single Status Check

+
useEffect(() => {
+  checkServiceStatus();  // Only check once on mount
+}, []);  // Empty dependency array = run once
+
+

Benefit: Minimizes API requests. Status is checked once and cached. Manual refresh required to re-check (acceptable for service status).

+

4. Abort Controller for Timeout

+
const controller = new AbortController();
+const timeoutId = setTimeout(() => controller.abort(), 5000);
+
+const response = await api.get('/api/services/mini-qr/status', {
+  signal: controller.signal,
+});
+
+clearTimeout(timeoutId);
+
+

Benefit: Prevents long-hanging requests if service is slow or unresponsive. Improves user experience by failing fast (5s timeout).

+
+

Responsive Design

+

Breakpoint-Based Mobile Detection

+
const screens = Grid.useBreakpoint();
+const isMobile = !screens.md;  // Mobile if screen width < 768px
+
+

Responsive Behavior: +- Desktop (≥ 768px): Full iframe embed with status bar +- Mobile (< 768px): Warning Result with "Open in New Tab" button

+

Mobile Warning Screen

+
if (isMobile) {
+  return (
+    <Result
+      icon={<MobileOutlined style={{ fontSize: 72, color: '#faad14' }} />}
+      title="Desktop Recommended"
+      subTitle="Mini QR is best used on desktop for optimal QR code generation experience."
+      extra={
+        <Button
+          type="primary"
+          size="large"
+          href="http://qr.cmlite.org"
+          target="_blank"
+        >
+          Open in New Tab
+        </Button>
+      }
+    />
+  );
+}
+
+

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

+

Iframe Height Calculation

+
<iframe
+  style={{
+    width: '100%',
+    height: 'calc(100% - 80px)',  // Full height minus status bar (80px)
+    border: 'none',
+  }}
+/>
+
+

Responsive Height: +- Uses CSS calc() to subtract status bar height from 100% +- Ensures iframe fills remaining vertical space +- No fixed height, adapts to browser window size

+
+

Accessibility

+

Keyboard Navigation

+
    +
  1. Tab Key:
  2. +
  3. Focuses "Open in New Tab" button (mobile view)
  4. +
  5. Enters iframe (desktop view)
  6. +
  7. +

    Navigates through iframe content (if iframe supports tab navigation)

    +
  8. +
  9. +

    Enter Key:

    +
  10. +
  11. Activates "Open in New Tab" button
  12. +
  13. +

    Interacts with iframe elements

    +
  14. +
  15. +

    Escape Key:

    +
  16. +
  17. No special behavior (iframe handles internally)
  18. +
+

ARIA Labels

+
<iframe
+  src="http://qr.cmlite.org"
+  title="Mini QR Code Generator"  // Screen reader announces iframe purpose
+  aria-label="Embedded Mini QR code generator service"
+  role="application"  // Indicates embedded application
+/>
+
+<Button
+  aria-label="Open Mini QR service in new browser tab"
+  href="http://qr.cmlite.org"
+  target="_blank"
+>
+  Open in New Tab
+</Button>
+
+<Result
+  icon={<MobileOutlined aria-hidden="true" />}  // Icon is decorative
+  title="Desktop Recommended"  // Screen reader announces title
+  subTitle="Mini QR is best used on desktop..."  // Screen reader announces subtitle
+/>
+
+

Color Contrast

+

All text meets WCAG AA standards: +- Success alert background: #f6ffed with text #52c41a (contrast ratio 4.5:1) +- Error alert background: #fff2f0 with text #ff4d4f (contrast ratio 4.5:1) +- Button text: White on #1890ff (contrast ratio 4.5:1)

+

Screen Reader Support

+
    +
  • Iframe has descriptive title attribute
  • +
  • Status alerts have role="alert" for announcements
  • +
  • Result component announces title and subtitle
  • +
  • Button has descriptive text and aria-label
  • +
+
+

Troubleshooting

+

Problem: Service Shows "Offline" Despite Container Running

+

Symptoms: +- Status bar shows "Service Offline" (red) +- Mini QR Docker container is running (docker compose ps shows "Up") +- Iframe does not load

+

Causes: +1. Nginx routing misconfiguration +2. Service listening on wrong port +3. Network connectivity issues +4. CORS policy blocking status check

+

Solutions:

+
    +
  1. +

    Verify Docker container status: +

    docker compose ps mini-qr
    +# Should show "Up" status
    +
    +docker compose logs mini-qr
    +# Check for error messages
    +

    +
  2. +
  3. +

    Check nginx routing:

    +
  4. +
  5. Open nginx/conf.d/services.conf
  6. +
  7. Verify Mini QR proxy block exists: +
    location /qr/ {
    +  proxy_pass http://mini-qr:8089/;
    +  proxy_set_header Host $host;
    +  proxy_set_header X-Real-IP $remote_addr;
    +}
    +
  8. +
  9. +

    Restart nginx: docker compose restart nginx

    +
  10. +
  11. +

    Test direct access:

    +
  12. +
  13. Open browser
  14. +
  15. Navigate to http://localhost:8089 (direct container port)
  16. +
  17. If accessible directly but not through nginx, routing issue
  18. +
  19. +

    If not accessible directly, service issue

    +
  20. +
  21. +

    Check service health endpoint: +

    curl http://localhost:8089/health
    +# Should return 200 OK
    +

    +
  22. +
  23. +

    Verify API endpoint:

    +
  24. +
  25. Open browser DevTools (F12)
  26. +
  27. Go to Network tab
  28. +
  29. Refresh page
  30. +
  31. Look for GET /api/services/mini-qr/status request
  32. +
  33. +

    Check response:

    +
      +
    • 200 OK with {"online": true} - Service should work
    • +
    • 200 OK with {"online": false} - Service health check failed
    • +
    • 500/503 - API error
    • +
    +
  34. +
  35. +

    Restart services: +

    docker compose restart mini-qr nginx api
    +

    +
  36. +
+
+

Problem: Iframe Not Loading Even When Service is Online

+

Symptoms: +- Status bar shows "Service Online" (green) +- Iframe appears as blank white rectangle +- No error messages in console

+

Causes: +1. CORS policy blocking iframe embedding +2. X-Frame-Options header preventing embedding +3. Content Security Policy (CSP) blocking iframe +4. Service URL incorrect

+

Solutions:

+
    +
  1. Check browser console for errors:
  2. +
  3. Open DevTools (F12)
  4. +
  5. Go to Console tab
  6. +
  7. Look for errors like:
      +
    • "Refused to display in a frame because it set 'X-Frame-Options' to 'deny'"
    • +
    • "Refused to frame because it violates the following Content Security Policy directive"
    • +
    +
  8. +
  9. +

    These indicate CORS/CSP blocking

    +
  10. +
  11. +

    Verify X-Frame-Options header:

    +
  12. +
  13. Check nginx config for Mini QR: +
    location /qr/ {
    +  proxy_pass http://mini-qr:8089/;
    +  # Remove or comment out X-Frame-Options
    +  # add_header X-Frame-Options "DENY";
    +}
    +
  14. +
  15. +

    Or set to SAMEORIGIN to allow same-domain embedding: +

    add_header X-Frame-Options "SAMEORIGIN";
    +

    +
  16. +
  17. +

    Check iframe sandbox attributes: +

    <iframe
    +  sandbox="allow-same-origin allow-scripts allow-forms allow-downloads"
    +  // Add more permissions if needed:
    +  // allow-popups, allow-top-navigation, etc.
    +/>
    +

    +
  18. +
  19. +

    Test iframe in isolation:

    +
  20. +
  21. Create simple HTML file: +
    <!DOCTYPE html>
    +<html>
    +<body>
    +  <iframe src="http://qr.cmlite.org" width="800" height="600"></iframe>
    +</body>
    +</html>
    +
  22. +
  23. Open in browser
  24. +
  25. If iframe works here but not in React app, React-specific issue
  26. +
  27. +

    If iframe doesn't work here either, service configuration issue

    +
  28. +
  29. +

    Verify service URL:

    +
  30. +
  31. Check iframe src attribute in code
  32. +
  33. Should be http://qr.cmlite.org (nginx proxied)
  34. +
  35. Try direct URL: http://localhost:8089 (for testing only)
  36. +
+
+

Problem: Mobile Warning Shows on Desktop

+

Symptoms: +- Viewing page on desktop computer (large screen) +- Warning "Desktop Recommended" appears instead of iframe +- Screen width clearly > 768px

+

Causes: +1. Browser zoom level causing incorrect breakpoint detection +2. Browser window width < 768px (narrow window) +3. DevTools open in side-by-side mode reducing width +4. Cached breakpoint state

+

Solutions:

+
    +
  1. Check browser zoom:
  2. +
  3. Press Ctrl+0 (Windows/Linux) or Cmd+0 (Mac) to reset zoom to 100%
  4. +
  5. +

    Refresh page

    +
  6. +
  7. +

    Maximize browser window:

    +
  8. +
  9. Click maximize button or press F11 for fullscreen
  10. +
  11. Ensure window width > 768px
  12. +
  13. +

    Refresh page

    +
  14. +
  15. +

    Close DevTools or dock to bottom:

    +
  16. +
  17. If DevTools open in side-by-side mode, window width reduced
  18. +
  19. Close DevTools (F12) or dock to bottom
  20. +
  21. +

    Refresh page

    +
  22. +
  23. +

    Check breakpoint detection:

    +
  24. +
  25. Open browser console (F12)
  26. +
  27. Type: window.innerWidth
  28. +
  29. If < 768, window too narrow
  30. +
  31. +

    Resize window wider and refresh

    +
  32. +
  33. +

    Clear browser cache:

    +
  34. +
  35. Hard refresh: Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (Mac)
  36. +
  37. Or clear browser cache entirely
  38. +
+
+

Problem: "Retry" Button Does Nothing

+

Symptoms: +- Service shows "Offline" +- Click "Retry" button +- Nothing happens, still shows "Offline"

+

Causes: +1. Service genuinely offline (not a UI bug) +2. Network connectivity issues +3. API endpoint not responding

+

Solutions:

+
    +
  1. Wait before retrying:
  2. +
  3. Service may need time to start
  4. +
  5. Wait 30-60 seconds
  6. +
  7. +

    Click "Retry" again

    +
  8. +
  9. +

    Check Docker containers: +

    docker compose ps
    +# Verify mini-qr, nginx, api all show "Up"
    +
    +docker compose logs mini-qr
    +# Check for startup errors
    +

    +
  10. +
  11. +

    Restart services: +

    docker compose restart mini-qr nginx api
    +# Wait 30 seconds for services to fully start
    +# Refresh page and click "Retry"
    +

    +
  12. +
  13. +

    Check network connectivity: +

    curl http://localhost:8089/health
    +# Should return 200 OK
    +
    +curl http://localhost:4000/api/services/mini-qr/status
    +# Should return {"online": true}
    +

    +
  14. +
  15. +

    Hard refresh page:

    +
  16. +
  17. Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (Mac)
  18. +
  19. Forces fresh status check
  20. +
+
+

Problem: Iframe Content Not Responsive

+

Symptoms: +- Iframe loads correctly +- Mini QR interface inside iframe is cut off or has horizontal scrollbar +- Cannot see full QR generator form

+

Causes: +1. Mini QR service not responsive +2. Iframe width constraints +3. Service has minimum width requirement

+

Solutions:

+
    +
  1. Check iframe width:
  2. +
  3. Inspect iframe element in DevTools
  4. +
  5. Verify width: 100% applied
  6. +
  7. +

    Verify parent container has sufficient width

    +
  8. +
  9. +

    Remove iframe sandbox (temporarily): +

    <iframe
    +  src="http://qr.cmlite.org"
    +  // Remove sandbox for testing
    +  // sandbox="allow-same-origin allow-scripts allow-forms"
    +/>
    +

    +
  10. +
  11. If content becomes responsive without sandbox, sandbox is blocking responsive behavior
  12. +
  13. +

    Add back sandbox with minimal restrictions

    +
  14. +
  15. +

    Use viewport meta tag in service:

    +
  16. +
  17. +

    If Mini QR is custom service, add to its HTML: +

    <meta name="viewport" content="width=device-width, initial-scale=1">
    +

    +
  18. +
  19. +

    Scale iframe content: +

    <iframe
    +  style={{
    +    width: '100%',
    +    height: 'calc(100% - 80px)',
    +    border: 'none',
    +    transform: 'scale(0.9)',  // Scale down content
    +    transformOrigin: 'top left',
    +  }}
    +/>
    +

    +
  20. +
  21. +

    Open in new tab:

    +
  22. +
  23. If iframe content truly not responsive, use "Open in New Tab" approach
  24. +
  25. Remove iframe, show button like mobile view
  26. +
+
+ +

Backend Documentation

+ +

Frontend Documentation

+ +

Feature Documentation

+ +

API Documentation

+ +

Deployment Documentation

+ +

Development Documentation

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/mkdocs-settings-page/index.html b/mkdocs/site/v2/frontend/pages/admin/mkdocs-settings-page/index.html new file mode 100644 index 00000000..908b775e --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/mkdocs-settings-page/index.html @@ -0,0 +1,9795 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MkDocs Settings - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

MkDocsSettingsPage

+

Overview

+

File: admin/src/pages/MkDocsSettingsPage.tsx

+

Route: /app/docs/settings

+

Role Requirements: SUPER_ADMIN only

+

Purpose: Comprehensive MkDocs configuration editor providing four different interfaces for managing documentation site settings, navigation structure, raw YAML configuration, and static site builds. This page serves as the central control panel for the documentation system.

+

Key Features: +- Four-tab interface (Settings, Navigation, YAML Editor, Build) +- Visual form-based settings editor with validation +- Interactive drag-and-drop navigation tree builder +- Raw YAML editor with syntax highlighting and keyboard shortcuts +- Orphaned file detection and management +- Campaign link integration +- Static site build triggering +- Mobile-responsive YAML editor warning

+

Layout: Full AppLayout with sidebar navigation

+

Dependencies: +- Ant Design v5 (Form, Input, Switch, Tree, Modal, Button, Card, Tabs, Alert, Typography, message) +- @monaco-editor/react for YAML editing +- yaml library for parsing/stringifying +- dayjs for date formatting +- react-beautiful-dnd for drag-and-drop (tree operations)

+
+

Features

+

1. Settings Tab (Form-Based Configuration)

+

Form Fields: +- Site Name (Input) - Documentation site title +- Site URL (Input.TextArea) - Public site URL +- Site Description (Input.TextArea) - Site meta description +- Site Author (Input) - Author attribution +- Copyright (Input) - Copyright notice +- Repo URL (Input) - GitHub repository URL +- Repo Name (Input) - Repository display name +- Edit URI (Input) - Edit page URI pattern +- Theme Name (Input) - MkDocs theme selection +- Primary Color (Input) - Theme primary color (hex) +- Accent Color (Input) - Theme accent color (hex) +- Features (dynamic tag input) - Theme feature toggles +- Plugins (dynamic tag input) - MkDocs plugins +- Markdown Extensions (dynamic tag input) - Markdown extension list +- Extra CSS (dynamic tag input) - Additional CSS file paths +- Extra JavaScript (dynamic tag input) - Additional JS file paths

+

Validation: +- URL fields validated for proper format +- Color fields validated for hex format (#RRGGBB) +- Required fields enforced (site name, theme)

+

2. Navigation Tab (Visual Tree Builder)

+

Navigation Tree: +- Hierarchical tree view of site navigation structure +- Drag-and-drop reordering (via react-beautiful-dnd) +- Section/page distinction (folder icons vs file icons) +- Expandable/collapsible sections +- Edit titles inline +- Add/remove navigation items +- Orphaned file detection

+

Orphaned Files Section: +- Separate panel showing markdown files not in navigation +- Drag files from orphaned list to navigation tree +- One-click "Add to Navigation" buttons +- File path display with relative paths

+

Campaign Link Integration: +- Dedicated "Add Campaign Link" button +- Fetches active campaigns via API +- Generates campaign page links automatically +- Inserts into navigation tree

+

3. YAML Editor Tab (Raw Configuration)

+

Monaco Editor: +- Full YAML syntax highlighting +- 600px height +- Auto-formatting on save +- Keyboard shortcuts: + - Ctrl+S / Cmd+S - Save changes + - Ctrl+F - Find + - Ctrl+H - Find and replace

+

Custom YAML Parsing: +- Handles Python tags (e.g., !relative $config_dir/includes) +- Preserves tag structure during parse/stringify +- Error handling for invalid YAML

+

Mobile Warning: +- Detects mobile devices with Grid.useBreakpoint() +- Shows warning Alert if !screens.md +- Recommends using desktop for YAML editing

+

4. Build Tab (Static Site Generation)

+

Build Trigger: +- Manual build button +- Builds MkDocs static site +- Shows success/error messages +- Displays build timestamp (last successful build)

+

Build Status: +- Last build date (formatted with dayjs) +- Build in progress indicator +- Build error display

+
+

User Workflow

+

Editing Basic Settings

+
    +
  1. Navigate to MkDocs Settings:
  2. +
  3. Click "Documentation" → "MkDocs Settings" in sidebar
  4. +
  5. +

    Page loads with 4 tabs at top

    +
  6. +
  7. +

    Select Settings Tab:

    +
  8. +
  9. First tab is active by default
  10. +
  11. +

    Shows form with ~15 fields

    +
  12. +
  13. +

    Edit Site Information:

    +
  14. +
  15. Update site name: "Changemaker Lite Documentation"
  16. +
  17. Update site description
  18. +
  19. Set author name
  20. +
  21. +

    Configure copyright notice

    +
  22. +
  23. +

    Configure Repository Links:

    +
  24. +
  25. Enter GitHub repository URL
  26. +
  27. Set repository name (e.g., "changemaker-lite")
  28. +
  29. +

    Configure edit URI pattern (e.g., "edit/main/docs/")

    +
  30. +
  31. +

    Customize Theme:

    +
  32. +
  33. Select theme name (e.g., "material")
  34. +
  35. Set primary color (hex, e.g., "#1976d2")
  36. +
  37. Set accent color (hex, e.g., "#f50057")
  38. +
  39. +

    Add theme features as tags:

    +
      +
    • Type "navigation.tabs" and press Enter
    • +
    • Type "toc.integrate" and press Enter
    • +
    • Repeat for other features
    • +
    +
  40. +
  41. +

    Configure Plugins:

    +
  42. +
  43. Add plugins as tags:
      +
    • "search"
    • +
    • "minify"
    • +
    • "git-revision-date-localized"
    • +
    +
  44. +
  45. +

    Click X on tags to remove

    +
  46. +
  47. +

    Add Markdown Extensions:

    +
  48. +
  49. +

    Add extensions as tags:

    +
      +
    • "pymdownx.highlight"
    • +
    • "pymdownx.superfences"
    • +
    • "admonition"
    • +
    +
  50. +
  51. +

    Save Settings:

    +
  52. +
  53. Click "Save Changes" button at bottom
  54. +
  55. Success message appears
  56. +
  57. Settings persisted to database
  58. +
+

Building Navigation Structure

+
    +
  1. Navigate to Navigation Tab:
  2. +
  3. Click "Navigation" tab (second tab)
  4. +
  5. +

    Tree view loads with current navigation

    +
  6. +
  7. +

    Understand Tree Structure:

    +
  8. +
  9. Sections: Items with children (folder icon)
      +
    • Example: "Getting Started" with sub-pages
    • +
    +
  10. +
  11. Pages: Leaf items (file icon)
      +
    • Example: "Installation.md"
    • +
    +
  12. +
  13. +

    Expandable: Click arrow to expand/collapse sections

    +
  14. +
  15. +

    Reorder Items (Drag and Drop):

    +
  16. +
  17. Click and hold on navigation item
  18. +
  19. Drag to new position
  20. +
  21. Drop to reorder
  22. +
  23. +

    Changes saved automatically

    +
  24. +
  25. +

    Add New Section:

    +
  26. +
  27. Click "Add Section" button
  28. +
  29. Modal appears
  30. +
  31. Enter section title (e.g., "API Reference")
  32. +
  33. Click "Create"
  34. +
  35. +

    New section appears in tree

    +
  36. +
  37. +

    Add New Page:

    +
  38. +
  39. Click "Add Page" button
  40. +
  41. Modal appears
  42. +
  43. Enter page title (e.g., "Authentication API")
  44. +
  45. Enter file path (e.g., "api/authentication.md")
  46. +
  47. Click "Create"
  48. +
  49. +

    New page appears in tree

    +
  50. +
  51. +

    Edit Navigation Item:

    +
  52. +
  53. Click "Edit" icon next to item
  54. +
  55. Modal appears with title field
  56. +
  57. Update title
  58. +
  59. +

    Click "Save"

    +
  60. +
  61. +

    Remove Navigation Item:

    +
  62. +
  63. Click "Delete" icon next to item
  64. +
  65. Confirmation modal appears
  66. +
  67. Click "Confirm" to remove
  68. +
  69. +

    Item removed from navigation (file remains on disk)

    +
  70. +
  71. +

    Handle Orphaned Files:

    +
  72. +
  73. Scroll to "Orphaned Files" section at bottom
  74. +
  75. See list of markdown files not in navigation
  76. +
  77. Two options per file:
      +
    • Drag to tree: Click and drag file to navigation tree
    • +
    • Add button: Click "Add to Navigation" for default placement
    • +
    +
  78. +
  79. +

    File moves from orphaned list to navigation tree

    +
  80. +
  81. +

    Add Campaign Links:

    +
  82. +
  83. Click "Add Campaign Link" button
  84. +
  85. Modal appears with campaign dropdown
  86. +
  87. Select active campaign from list
  88. +
  89. Click "Add"
  90. +
  91. +

    Campaign link inserted into navigation with auto-generated path

    +
  92. +
  93. +

    Save Navigation:

    +
      +
    • Click "Save Navigation" button at bottom
    • +
    • Navigation structure persisted
    • +
    • MkDocs site rebuilt with new structure
    • +
    +
  94. +
+

Advanced YAML Editing

+
    +
  1. Navigate to YAML Editor Tab:
  2. +
  3. Click "YAML Editor" tab (third tab)
  4. +
  5. +

    Monaco editor loads with current mkdocs.yml content

    +
  6. +
  7. +

    Check Mobile Warning:

    +
  8. +
  9. +

    If on mobile device, warning Alert shows:

    +
      +
    • "YAML Editor is best used on desktop"
    • +
    • "Consider using Settings or Navigation tabs instead"
    • +
    +
  10. +
  11. +

    Edit Raw YAML:

    +
  12. +
  13. Click in editor to position cursor
  14. +
  15. +

    Edit YAML directly: +

    site_name: Changemaker Lite Documentation
    +site_url: https://docs.cmlite.org
    +theme:
    +  name: material
    +  palette:
    +    primary: blue
    +    accent: pink
    +  features:
    +    - navigation.tabs
    +    - toc.integrate
    +

    +
  16. +
  17. +

    Use Keyboard Shortcuts:

    +
  18. +
  19. Ctrl+S (Cmd+S on Mac): Save changes immediately
  20. +
  21. Ctrl+F: Open find dialog
  22. +
  23. Ctrl+H: Open find and replace dialog
  24. +
  25. Ctrl+Z: Undo
  26. +
  27. +

    Ctrl+Y: Redo

    +
  28. +
  29. +

    Handle Python Tags:

    +
  30. +
  31. Editor preserves custom Python tags: +
    nav:
    +  - Home: !relative $config_dir/index.md
    +
  32. +
  33. +

    Tags preserved during parse/stringify cycle

    +
  34. +
  35. +

    Save YAML:

    +
  36. +
  37. Click "Save Changes" button below editor
  38. +
  39. OR press Ctrl+S keyboard shortcut
  40. +
  41. Success message appears
  42. +
  43. +

    YAML validated and saved to database

    +
  44. +
  45. +

    Handle YAML Errors:

    +
  46. +
  47. If YAML is invalid, error message appears:
      +
    • "Invalid YAML syntax"
    • +
    • Shows line number of error
    • +
    +
  48. +
  49. Fix syntax and try saving again
  50. +
+

Building Static Site

+
    +
  1. Navigate to Build Tab:
  2. +
  3. Click "Build" tab (fourth tab)
  4. +
  5. +

    Build status card loads

    +
  6. +
  7. +

    Check Last Build:

    +
  8. +
  9. See "Last Build" timestamp
  10. +
  11. +

    Example: "Built 2 hours ago"

    +
  12. +
  13. +

    Trigger New Build:

    +
  14. +
  15. Click "Build Site" button
  16. +
  17. Button shows loading spinner
  18. +
  19. +

    Build starts in background

    +
  20. +
  21. +

    Monitor Build Progress:

    +
  22. +
  23. "Building..." status appears
  24. +
  25. +

    Wait 10-30 seconds for build to complete

    +
  26. +
  27. +

    View Build Result:

    +
  28. +
  29. Success: Green checkmark, "Build completed successfully"
  30. +
  31. +

    Error: Red X, error message displayed

    +
  32. +
  33. +

    Access Built Site:

    +
  34. +
  35. Built site served at http://localhost:4001 (production)
  36. +
  37. Or http://localhost:4003 (dev server)
  38. +
+
+

Component Breakdown

+

Main Component Structure

+
const MkDocsSettingsPage: React.FC = () => {
+  // State
+  const [activeTab, setActiveTab] = useState<'settings' | 'navigation' | 'yaml' | 'build'>('settings');
+  const [config, setConfig] = useState<MkDocsConfig | null>(null);
+  const [navStructure, setNavStructure] = useState<NavItem[]>([]);
+  const [orphanedFiles, setOrphanedFiles] = useState<string[]>([]);
+  const [yamlContent, setYamlContent] = useState<string>('');
+  const [loading, setLoading] = useState(false);
+  const [saving, setSaving] = useState(false);
+  const [building, setBuilding] = useState(false);
+  const [lastBuild, setLastBuild] = useState<string | null>(null);
+
+  // Form instance for Settings tab
+  const [form] = Form.useForm();
+
+  // Responsive breakpoints
+  const screens = Grid.useBreakpoint();
+  const isMobile = !screens.md;
+
+  // Load configuration on mount
+  useEffect(() => {
+    loadConfig();
+  }, []);
+
+  return (
+    <div>
+      <Typography.Title level={2}>MkDocs Settings</Typography.Title>
+
+      <Tabs activeKey={activeTab} onChange={setActiveTab}>
+        <Tabs.TabPane tab="Settings" key="settings">
+          {/* Form-based settings editor */}
+        </Tabs.TabPane>
+
+        <Tabs.TabPane tab="Navigation" key="navigation">
+          {/* Tree-based navigation builder */}
+        </Tabs.TabPane>
+
+        <Tabs.TabPane tab="YAML Editor" key="yaml">
+          {/* Monaco YAML editor */}
+        </Tabs.TabPane>
+
+        <Tabs.TabPane tab="Build" key="build">
+          {/* Static site build trigger */}
+        </Tabs.TabPane>
+      </Tabs>
+    </div>
+  );
+};
+
+

Ant Design Components Used

+
    +
  1. Tabs - Four-tab interface switcher
  2. +
  3. Form - Settings form with validation
  4. +
  5. Input / Input.TextArea - Text field inputs
  6. +
  7. Switch - Boolean toggles
  8. +
  9. Select - Dropdown selections (used in modals)
  10. +
  11. Tree - Hierarchical navigation tree view
  12. +
  13. Button - Action buttons (Save, Add, Delete, Build)
  14. +
  15. Modal - Dialogs for add/edit operations
  16. +
  17. Card - Content containers for each tab
  18. +
  19. Alert - Warning messages (mobile YAML editor, orphaned files)
  20. +
  21. Typography.Title - Page heading
  22. +
  23. Typography.Text - Descriptive text
  24. +
  25. message - Toast notifications (save success/error)
  26. +
  27. Space - Component spacing
  28. +
  29. Divider - Visual separators
  30. +
  31. Tag - Closable tags for arrays (features, plugins, etc.)
  32. +
+

Monaco Editor Configuration

+
<Editor
+  height="600px"
+  defaultLanguage="yaml"
+  value={yamlContent}
+  onChange={(value) => setYamlContent(value || '')}
+  theme="vs-dark"
+  options={{
+    minimap: { enabled: false },
+    fontSize: 14,
+    wordWrap: 'on',
+    automaticLayout: true,
+    scrollBeyondLastLine: false,
+    renderWhitespace: 'selection',
+  }}
+  onMount={(editor) => {
+    // Register Ctrl+S keyboard shortcut
+    editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
+      handleSaveYAML();
+    });
+  }}
+/>
+
+ +
interface NavItem {
+  key: string;
+  title: string;
+  children?: NavItem[];
+  file?: string;  // File path for pages (leaves)
+}
+
+// Example navigation structure
+const navStructure: NavItem[] = [
+  {
+    key: '1',
+    title: 'Getting Started',
+    children: [
+      { key: '1-1', title: 'Installation', file: 'getting-started/installation.md' },
+      { key: '1-2', title: 'Quick Start', file: 'getting-started/quick-start.md' },
+    ],
+  },
+  {
+    key: '2',
+    title: 'API Reference',
+    children: [
+      { key: '2-1', title: 'Authentication', file: 'api/authentication.md' },
+      { key: '2-2', title: 'Users', file: 'api/users.md' },
+    ],
+  },
+];
+
+

Custom YAML Parser (Python Tag Handling)

+
import yaml from 'yaml';
+
+// Custom YAML parser that preserves Python tags
+const parseYAML = (yamlString: string): any => {
+  try {
+    // Parse with yaml library (supports tags)
+    const parsed = yaml.parse(yamlString);
+    return parsed;
+  } catch (error) {
+    console.error('YAML parse error:', error);
+    throw new Error('Invalid YAML syntax');
+  }
+};
+
+// Custom YAML stringifier
+const stringifyYAML = (obj: any): string => {
+  try {
+    return yaml.stringify(obj, {
+      indent: 2,
+      lineWidth: 0,  // No line wrapping
+    });
+  } catch (error) {
+    console.error('YAML stringify error:', error);
+    throw new Error('Failed to stringify YAML');
+  }
+};
+
+// Example: Parsing YAML with Python tags
+const yamlWithTags = `
+nav:
+  - Home: !relative $config_dir/index.md
+  - API: !relative $config_dir/api/index.md
+`;
+
+const parsed = parseYAML(yamlWithTags);
+// Tags preserved as special objects
+
+
+

State Management

+

Local Component State (useState)

+

No Zustand stores used - All state managed locally with React hooks.

+
// Active tab state
+const [activeTab, setActiveTab] = useState<'settings' | 'navigation' | 'yaml' | 'build'>('settings');
+
+// Configuration state (loaded from API)
+const [config, setConfig] = useState<MkDocsConfig | null>(null);
+
+// Navigation structure state
+const [navStructure, setNavStructure] = useState<NavItem[]>([]);
+
+// Orphaned files state
+const [orphanedFiles, setOrphanedFiles] = useState<string[]>([]);
+
+// YAML editor content state
+const [yamlContent, setYamlContent] = useState<string>('');
+
+// Loading states
+const [loading, setLoading] = useState(false);      // Initial load
+const [saving, setSaving] = useState(false);        // Save operation
+const [building, setBuilding] = useState(false);    // Build operation
+
+// Last build timestamp
+const [lastBuild, setLastBuild] = useState<string | null>(null);
+
+// Form instance (Ant Design)
+const [form] = Form.useForm();
+
+// Responsive breakpoints
+const screens = Grid.useBreakpoint();
+const isMobile = !screens.md;
+
+

State Flow

+
    +
  1. Component Mounts:
  2. +
  3. loadConfig() called in useEffect
  4. +
  5. Fetches MkDocs config via GET /api/docs/config
  6. +
  7. Sets config, navStructure, orphanedFiles, yamlContent
  8. +
  9. +

    Sets form field values

    +
  10. +
  11. +

    User Edits Settings Tab:

    +
  12. +
  13. Form fields update form state (Ant Design managed)
  14. +
  15. Click "Save Changes" → handleSaveSettings()
  16. +
  17. Gets values from form.getFieldsValue()
  18. +
  19. Sends PUT /api/docs/config with updated config
  20. +
  21. +

    Re-fetches config on success

    +
  22. +
  23. +

    User Edits Navigation Tab:

    +
  24. +
  25. Drag-and-drop updates navStructure state
  26. +
  27. Add/edit/delete modals update navStructure
  28. +
  29. Click "Save Navigation" → handleSaveNavigation()
  30. +
  31. Sends PUT /api/docs/config with updated nav structure
  32. +
  33. +

    Re-fetches config on success

    +
  34. +
  35. +

    User Edits YAML Tab:

    +
  36. +
  37. Monaco editor updates yamlContent state on change
  38. +
  39. Click "Save Changes" or Ctrl+S → handleSaveYAML()
  40. +
  41. Parses YAML to validate
  42. +
  43. Sends PUT /api/docs/config with parsed YAML object
  44. +
  45. +

    Re-fetches config on success

    +
  46. +
  47. +

    User Triggers Build:

    +
  48. +
  49. Click "Build Site" → handleBuild()
  50. +
  51. Sets building to true
  52. +
  53. Sends POST /api/docs/build
  54. +
  55. Sets building to false on completion
  56. +
  57. Updates lastBuild timestamp
  58. +
+
+

API Integration

+

Endpoints Used

+
    +
  1. GET /api/docs/config - Fetch MkDocs configuration
  2. +
  3. PUT /api/docs/config - Update MkDocs configuration
  4. +
  5. POST /api/docs/build - Trigger static site build
  6. +
  7. GET /api/influence/campaigns - Fetch active campaigns (for campaign link insertion)
  8. +
  9. GET /api/docs/orphaned-files - Fetch markdown files not in navigation
  10. +
+

API Client

+
import { api } from '@/lib/api';
+
+// All requests use authenticated API client with automatic token refresh
+
+

Example API Calls

+

1. Load Configuration

+
const loadConfig = async () => {
+  try {
+    setLoading(true);
+    const response = await api.get('/api/docs/config');
+    const configData = response.data.data;
+
+    // Set configuration state
+    setConfig(configData);
+
+    // Set navigation structure
+    setNavStructure(configData.nav || []);
+
+    // Set orphaned files
+    setOrphanedFiles(configData.orphanedFiles || []);
+
+    // Set YAML content
+    const yamlString = stringifyYAML(configData);
+    setYamlContent(yamlString);
+
+    // Populate form fields
+    form.setFieldsValue({
+      site_name: configData.site_name,
+      site_url: configData.site_url,
+      site_description: configData.site_description,
+      site_author: configData.site_author,
+      copyright: configData.copyright,
+      repo_url: configData.repo_url,
+      repo_name: configData.repo_name,
+      edit_uri: configData.edit_uri,
+      theme_name: configData.theme?.name,
+      theme_primary: configData.theme?.palette?.primary,
+      theme_accent: configData.theme?.palette?.accent,
+      theme_features: configData.theme?.features || [],
+      plugins: configData.plugins || [],
+      markdown_extensions: configData.markdown_extensions || [],
+      extra_css: configData.extra_css || [],
+      extra_javascript: configData.extra_javascript || [],
+    });
+
+    // Set last build timestamp
+    setLastBuild(configData.last_build);
+  } catch (error) {
+    message.error('Failed to load MkDocs configuration');
+    console.error('Load config error:', error);
+  } finally {
+    setLoading(false);
+  }
+};
+
+

Response Format: +

{
+  "success": true,
+  "data": {
+    "site_name": "Changemaker Lite Documentation",
+    "site_url": "https://docs.cmlite.org",
+    "site_description": "Comprehensive documentation for Changemaker Lite platform",
+    "site_author": "Changemaker Team",
+    "copyright": "Copyright &copy; 2025 Changemaker",
+    "repo_url": "https://github.com/example/changemaker-lite",
+    "repo_name": "changemaker-lite",
+    "edit_uri": "edit/main/docs/",
+    "theme": {
+      "name": "material",
+      "palette": {
+        "primary": "blue",
+        "accent": "pink"
+      },
+      "features": [
+        "navigation.tabs",
+        "navigation.sections",
+        "toc.integrate"
+      ]
+    },
+    "plugins": ["search", "minify"],
+    "markdown_extensions": ["admonition", "pymdownx.highlight"],
+    "extra_css": ["stylesheets/extra.css"],
+    "extra_javascript": ["javascripts/extra.js"],
+    "nav": [
+      {
+        "key": "1",
+        "title": "Home",
+        "file": "index.md"
+      },
+      {
+        "key": "2",
+        "title": "Getting Started",
+        "children": [
+          {
+            "key": "2-1",
+            "title": "Installation",
+            "file": "getting-started/installation.md"
+          }
+        ]
+      }
+    ],
+    "orphanedFiles": ["changelog.md", "contributing.md"],
+    "last_build": "2025-02-11T10:30:00Z"
+  }
+}
+

+

2. Save Settings (Form-Based)

+
const handleSaveSettings = async () => {
+  try {
+    setSaving(true);
+
+    // Get form values
+    const values = form.getFieldsValue();
+
+    // Construct updated config
+    const updatedConfig = {
+      ...config,
+      site_name: values.site_name,
+      site_url: values.site_url,
+      site_description: values.site_description,
+      site_author: values.site_author,
+      copyright: values.copyright,
+      repo_url: values.repo_url,
+      repo_name: values.repo_name,
+      edit_uri: values.edit_uri,
+      theme: {
+        ...config?.theme,
+        name: values.theme_name,
+        palette: {
+          primary: values.theme_primary,
+          accent: values.theme_accent,
+        },
+        features: values.theme_features,
+      },
+      plugins: values.plugins,
+      markdown_extensions: values.markdown_extensions,
+      extra_css: values.extra_css,
+      extra_javascript: values.extra_javascript,
+    };
+
+    // Send update request
+    await api.put('/api/docs/config', updatedConfig);
+
+    message.success('Settings saved successfully');
+
+    // Reload configuration
+    await loadConfig();
+  } catch (error) {
+    message.error('Failed to save settings');
+    console.error('Save settings error:', error);
+  } finally {
+    setSaving(false);
+  }
+};
+
+

Request Payload: +

{
+  "site_name": "Changemaker Lite Documentation",
+  "site_url": "https://docs.cmlite.org",
+  "theme": {
+    "name": "material",
+    "palette": {
+      "primary": "blue",
+      "accent": "pink"
+    },
+    "features": ["navigation.tabs", "toc.integrate"]
+  },
+  "plugins": ["search", "minify"],
+  "markdown_extensions": ["admonition"]
+}
+

+

3. Save Navigation

+
const handleSaveNavigation = async () => {
+  try {
+    setSaving(true);
+
+    // Construct updated config with new navigation
+    const updatedConfig = {
+      ...config,
+      nav: navStructure,
+    };
+
+    // Send update request
+    await api.put('/api/docs/config', updatedConfig);
+
+    message.success('Navigation saved successfully');
+
+    // Reload configuration
+    await loadConfig();
+  } catch (error) {
+    message.error('Failed to save navigation');
+    console.error('Save navigation error:', error);
+  } finally {
+    setSaving(false);
+  }
+};
+
+

Request Payload: +

{
+  "nav": [
+    {
+      "key": "1",
+      "title": "Home",
+      "file": "index.md"
+    },
+    {
+      "key": "2",
+      "title": "Getting Started",
+      "children": [
+        {
+          "key": "2-1",
+          "title": "Installation",
+          "file": "getting-started/installation.md"
+        },
+        {
+          "key": "2-2",
+          "title": "Quick Start",
+          "file": "getting-started/quick-start.md"
+        }
+      ]
+    }
+  ]
+}
+

+

4. Save YAML

+
const handleSaveYAML = async () => {
+  try {
+    setSaving(true);
+
+    // Parse YAML to validate and convert to object
+    const parsedConfig = parseYAML(yamlContent);
+
+    // Send update request
+    await api.put('/api/docs/config', parsedConfig);
+
+    message.success('YAML saved successfully');
+
+    // Reload configuration
+    await loadConfig();
+  } catch (error) {
+    if (error.message === 'Invalid YAML syntax') {
+      message.error('Invalid YAML syntax. Please check and try again.');
+    } else {
+      message.error('Failed to save YAML');
+    }
+    console.error('Save YAML error:', error);
+  } finally {
+    setSaving(false);
+  }
+};
+
+

Request Payload: (Parsed YAML object) +

{
+  "site_name": "Changemaker Lite Documentation",
+  "theme": {
+    "name": "material"
+  },
+  "nav": [
+    {
+      "Home": "index.md"
+    }
+  ]
+}
+

+

5. Build Site

+
const handleBuild = async () => {
+  try {
+    setBuilding(true);
+
+    // Trigger build
+    await api.post('/api/docs/build');
+
+    message.success('Site built successfully');
+
+    // Reload configuration to get updated last_build timestamp
+    await loadConfig();
+  } catch (error) {
+    message.error('Failed to build site');
+    console.error('Build error:', error);
+  } finally {
+    setBuilding(false);
+  }
+};
+
+

Response Format: +

{
+  "success": true,
+  "message": "Site built successfully",
+  "data": {
+    "build_time": "2025-02-11T10:35:00Z",
+    "output_dir": "/app/mkdocs/site"
+  }
+}
+

+ +
const fetchCampaigns = async () => {
+  try {
+    const response = await api.get('/api/influence/campaigns', {
+      params: {
+        status: 'active',
+        limit: 100,
+      },
+    });
+
+    return response.data.data.campaigns;
+  } catch (error) {
+    console.error('Fetch campaigns error:', error);
+    return [];
+  }
+};
+
+const handleAddCampaignLink = async () => {
+  // Fetch campaigns
+  const campaigns = await fetchCampaigns();
+
+  // Show modal with campaign dropdown
+  Modal.confirm({
+    title: 'Add Campaign Link',
+    content: (
+      <Select
+        placeholder="Select campaign"
+        options={campaigns.map(c => ({
+          label: c.title,
+          value: c.id,
+        }))}
+        onChange={(campaignId) => {
+          // Find selected campaign
+          const campaign = campaigns.find(c => c.id === campaignId);
+
+          // Generate navigation item
+          const navItem: NavItem = {
+            key: `campaign-${campaignId}`,
+            title: campaign.title,
+            file: `campaigns/${campaign.slug}.md`,
+          };
+
+          // Add to navigation structure
+          setNavStructure([...navStructure, navItem]);
+        }}
+      />
+    ),
+  });
+};
+
+

Response Format: +

{
+  "success": true,
+  "data": {
+    "campaigns": [
+      {
+        "id": 1,
+        "title": "Climate Action Now",
+        "slug": "climate-action-now",
+        "status": "active"
+      },
+      {
+        "id": 2,
+        "title": "Education Funding",
+        "slug": "education-funding",
+        "status": "active"
+      }
+    ],
+    "pagination": {
+      "page": 1,
+      "limit": 100,
+      "total": 2
+    }
+  }
+}
+

+

7. Fetch Orphaned Files

+
const fetchOrphanedFiles = async () => {
+  try {
+    const response = await api.get('/api/docs/orphaned-files');
+
+    setOrphanedFiles(response.data.data.files);
+  } catch (error) {
+    console.error('Fetch orphaned files error:', error);
+  }
+};
+
+

Response Format: +

{
+  "success": true,
+  "data": {
+    "files": [
+      "changelog.md",
+      "contributing.md",
+      "troubleshooting/common-issues.md"
+    ]
+  }
+}
+

+
+

Code Examples

+

Complete loadConfig Implementation

+
const loadConfig = useCallback(async () => {
+  try {
+    setLoading(true);
+    const response = await api.get('/api/docs/config');
+    const configData = response.data.data;
+
+    // Set all state from API response
+    setConfig(configData);
+    setNavStructure(configData.nav || []);
+    setOrphanedFiles(configData.orphanedFiles || []);
+    setYamlContent(stringifyYAML(configData));
+    setLastBuild(configData.last_build);
+
+    // Populate form fields
+    form.setFieldsValue({
+      site_name: configData.site_name,
+      site_url: configData.site_url,
+      site_description: configData.site_description,
+      site_author: configData.site_author,
+      copyright: configData.copyright,
+      repo_url: configData.repo_url,
+      repo_name: configData.repo_name,
+      edit_uri: configData.edit_uri,
+      theme_name: configData.theme?.name,
+      theme_primary: configData.theme?.palette?.primary,
+      theme_accent: configData.theme?.palette?.accent,
+      theme_features: configData.theme?.features || [],
+      plugins: configData.plugins || [],
+      markdown_extensions: configData.markdown_extensions || [],
+      extra_css: configData.extra_css || [],
+      extra_javascript: configData.extra_javascript || [],
+    });
+  } catch (error) {
+    message.error('Failed to load MkDocs configuration');
+    console.error('Load config error:', error);
+  } finally {
+    setLoading(false);
+  }
+}, [form]);
+
+useEffect(() => {
+  loadConfig();
+}, [loadConfig]);
+
+

Settings Tab Form Rendering

+
<Form
+  form={form}
+  layout="vertical"
+  onFinish={handleSaveSettings}
+>
+  <Form.Item
+    label="Site Name"
+    name="site_name"
+    rules={[{ required: true, message: 'Site name is required' }]}
+  >
+    <Input placeholder="e.g., Changemaker Lite Documentation" />
+  </Form.Item>
+
+  <Form.Item
+    label="Site URL"
+    name="site_url"
+    rules={[
+      { required: true, message: 'Site URL is required' },
+      { type: 'url', message: 'Must be a valid URL' },
+    ]}
+  >
+    <Input.TextArea
+      rows={2}
+      placeholder="e.g., https://docs.cmlite.org"
+    />
+  </Form.Item>
+
+  <Form.Item
+    label="Site Description"
+    name="site_description"
+  >
+    <Input.TextArea
+      rows={3}
+      placeholder="Brief description of your documentation site"
+    />
+  </Form.Item>
+
+  <Form.Item
+    label="Site Author"
+    name="site_author"
+  >
+    <Input placeholder="e.g., Changemaker Team" />
+  </Form.Item>
+
+  <Form.Item
+    label="Copyright"
+    name="copyright"
+  >
+    <Input placeholder="e.g., Copyright &copy; 2025 Changemaker" />
+  </Form.Item>
+
+  <Divider>Repository Configuration</Divider>
+
+  <Form.Item
+    label="Repository URL"
+    name="repo_url"
+    rules={[{ type: 'url', message: 'Must be a valid URL' }]}
+  >
+    <Input placeholder="e.g., https://github.com/username/repo" />
+  </Form.Item>
+
+  <Form.Item
+    label="Repository Name"
+    name="repo_name"
+  >
+    <Input placeholder="e.g., changemaker-lite" />
+  </Form.Item>
+
+  <Form.Item
+    label="Edit URI"
+    name="edit_uri"
+  >
+    <Input placeholder="e.g., edit/main/docs/" />
+  </Form.Item>
+
+  <Divider>Theme Configuration</Divider>
+
+  <Form.Item
+    label="Theme Name"
+    name="theme_name"
+    rules={[{ required: true, message: 'Theme is required' }]}
+  >
+    <Input placeholder="e.g., material" />
+  </Form.Item>
+
+  <Form.Item
+    label="Primary Color"
+    name="theme_primary"
+    rules={[
+      { pattern: /^#[0-9A-Fa-f]{6}$/, message: 'Must be hex color (e.g., #1976d2)' },
+    ]}
+  >
+    <Input placeholder="e.g., #1976d2" />
+  </Form.Item>
+
+  <Form.Item
+    label="Accent Color"
+    name="theme_accent"
+    rules={[
+      { pattern: /^#[0-9A-Fa-f]{6}$/, message: 'Must be hex color (e.g., #f50057)' },
+    ]}
+  >
+    <Input placeholder="e.g., #f50057" />
+  </Form.Item>
+
+  <Form.Item
+    label="Theme Features"
+    name="theme_features"
+  >
+    <Select
+      mode="tags"
+      placeholder="Type feature name and press Enter"
+      style={{ width: '100%' }}
+    />
+  </Form.Item>
+
+  <Divider>Plugins & Extensions</Divider>
+
+  <Form.Item
+    label="Plugins"
+    name="plugins"
+  >
+    <Select
+      mode="tags"
+      placeholder="Type plugin name and press Enter"
+      style={{ width: '100%' }}
+    />
+  </Form.Item>
+
+  <Form.Item
+    label="Markdown Extensions"
+    name="markdown_extensions"
+  >
+    <Select
+      mode="tags"
+      placeholder="Type extension name and press Enter"
+      style={{ width: '100%' }}
+    />
+  </Form.Item>
+
+  <Divider>Additional Assets</Divider>
+
+  <Form.Item
+    label="Extra CSS Files"
+    name="extra_css"
+  >
+    <Select
+      mode="tags"
+      placeholder="Type CSS file path and press Enter"
+      style={{ width: '100%' }}
+    />
+  </Form.Item>
+
+  <Form.Item
+    label="Extra JavaScript Files"
+    name="extra_javascript"
+  >
+    <Select
+      mode="tags"
+      placeholder="Type JS file path and press Enter"
+      style={{ width: '100%' }}
+    />
+  </Form.Item>
+
+  <Form.Item>
+    <Button
+      type="primary"
+      htmlType="submit"
+      loading={saving}
+      size="large"
+    >
+      Save Changes
+    </Button>
+  </Form.Item>
+</Form>
+
+ +
<div>
+  <Space style={{ marginBottom: 16 }}>
+    <Button onClick={handleAddSection}>Add Section</Button>
+    <Button onClick={handleAddPage}>Add Page</Button>
+    <Button onClick={handleAddCampaignLink}>Add Campaign Link</Button>
+  </Space>
+
+  <Tree
+    treeData={convertNavToTreeData(navStructure)}
+    draggable
+    blockNode
+    onDrop={handleDrop}
+    titleRender={(node) => (
+      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
+        <span>
+          {node.children ? (
+            <FolderOutlined style={{ marginRight: 8 }} />
+          ) : (
+            <FileOutlined style={{ marginRight: 8 }} />
+          )}
+          {node.title}
+        </span>
+        <Space>
+          <Button
+            type="text"
+            size="small"
+            icon={<EditOutlined />}
+            onClick={() => handleEditNavItem(node.key)}
+          />
+          <Button
+            type="text"
+            size="small"
+            danger
+            icon={<DeleteOutlined />}
+            onClick={() => handleDeleteNavItem(node.key)}
+          />
+        </Space>
+      </div>
+    )}
+  />
+
+  <Divider />
+
+  <Button
+    type="primary"
+    onClick={handleSaveNavigation}
+    loading={saving}
+    size="large"
+  >
+    Save Navigation
+  </Button>
+
+  {orphanedFiles.length > 0 && (
+    <>
+      <Divider />
+
+      <Alert
+        message="Orphaned Files"
+        description={`${orphanedFiles.length} markdown files are not included in navigation`}
+        type="warning"
+        showIcon
+      />
+
+      <div style={{ marginTop: 16 }}>
+        {orphanedFiles.map((file) => (
+          <div
+            key={file}
+            style={{
+              padding: '8px 12px',
+              marginBottom: 8,
+              border: '1px solid #d9d9d9',
+              borderRadius: 4,
+              display: 'flex',
+              justifyContent: 'space-between',
+              alignItems: 'center',
+            }}
+          >
+            <span>
+              <FileOutlined style={{ marginRight: 8 }} />
+              {file}
+            </span>
+            <Button
+              size="small"
+              onClick={() => handleAddOrphanedFile(file)}
+            >
+              Add to Navigation
+            </Button>
+          </div>
+        ))}
+      </div>
+    </>
+  )}
+</div>
+
+

YAML Editor Tab Rendering

+
<div>
+  {isMobile && (
+    <Alert
+      message="Desktop Recommended"
+      description="YAML Editor is best used on desktop. Consider using Settings or Navigation tabs for mobile editing."
+      type="warning"
+      showIcon
+      closable
+      style={{ marginBottom: 16 }}
+    />
+  )}
+
+  <Editor
+    height="600px"
+    defaultLanguage="yaml"
+    value={yamlContent}
+    onChange={(value) => setYamlContent(value || '')}
+    theme="vs-dark"
+    options={{
+      minimap: { enabled: false },
+      fontSize: 14,
+      wordWrap: 'on',
+      automaticLayout: true,
+      scrollBeyondLastLine: false,
+      renderWhitespace: 'selection',
+      tabSize: 2,
+      insertSpaces: true,
+    }}
+    onMount={(editor, monaco) => {
+      // Register Ctrl+S keyboard shortcut
+      editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
+        handleSaveYAML();
+      });
+    }}
+  />
+
+  <div style={{ marginTop: 16 }}>
+    <Space>
+      <Button
+        type="primary"
+        onClick={handleSaveYAML}
+        loading={saving}
+        size="large"
+      >
+        Save Changes
+      </Button>
+      <Typography.Text type="secondary">
+        Tip: Press Ctrl+S (Cmd+S on Mac) to save
+      </Typography.Text>
+    </Space>
+  </div>
+</div>
+
+

Build Tab Rendering

+
<Card>
+  <Space direction="vertical" size="large" style={{ width: '100%' }}>
+    <div>
+      <Typography.Title level={4}>Build Static Site</Typography.Title>
+      <Typography.Paragraph>
+        Trigger a static site build using MkDocs. This will generate HTML files
+        from your markdown documentation.
+      </Typography.Paragraph>
+    </div>
+
+    {lastBuild && (
+      <div>
+        <Typography.Text strong>Last Build:</Typography.Text>
+        <Typography.Text style={{ marginLeft: 8 }}>
+          {dayjs(lastBuild).fromNow()}
+        </Typography.Text>
+      </div>
+    )}
+
+    <Button
+      type="primary"
+      size="large"
+      onClick={handleBuild}
+      loading={building}
+      icon={<RocketOutlined />}
+    >
+      {building ? 'Building...' : 'Build Site'}
+    </Button>
+
+    {building && (
+      <Alert
+        message="Build in Progress"
+        description="Building static site... This may take 10-30 seconds."
+        type="info"
+        showIcon
+      />
+    )}
+  </Space>
+</Card>
+
+

Drag-and-Drop Handler

+
const handleDrop = (info: any) => {
+  const dropKey = info.node.key;
+  const dragKey = info.dragNode.key;
+  const dropPos = info.node.pos.split('-');
+  const dropPosition = info.dropPosition - Number(dropPos[dropPos.length - 1]);
+
+  const loop = (data: NavItem[], key: string, callback: (item: NavItem, index: number, arr: NavItem[]) => void) => {
+    for (let i = 0; i < data.length; i++) {
+      if (data[i].key === key) {
+        callback(data[i], i, data);
+        return;
+      }
+      if (data[i].children) {
+        loop(data[i].children!, key, callback);
+      }
+    }
+  };
+
+  const data = [...navStructure];
+
+  // Find dragObject
+  let dragObj: NavItem;
+  loop(data, dragKey, (item, index, arr) => {
+    arr.splice(index, 1);
+    dragObj = item;
+  });
+
+  if (!info.dropToGap) {
+    // Drop on the content
+    loop(data, dropKey, (item) => {
+      item.children = item.children || [];
+      item.children.unshift(dragObj);
+    });
+  } else if (
+    (info.node.children || []).length > 0 &&
+    info.node.expanded &&
+    dropPosition === 1
+  ) {
+    // Drop to the bottom gap
+    loop(data, dropKey, (item) => {
+      item.children = item.children || [];
+      item.children.unshift(dragObj);
+    });
+  } else {
+    // Drop to the gap
+    let ar: NavItem[] = [];
+    let i: number;
+    loop(data, dropKey, (_item, index, arr) => {
+      ar = arr;
+      i = index;
+    });
+    if (dropPosition === -1) {
+      ar.splice(i!, 0, dragObj!);
+    } else {
+      ar.splice(i! + 1, 0, dragObj!);
+    }
+  }
+
+  setNavStructure(data);
+};
+
+ +
const handleAddCampaignLink = async () => {
+  try {
+    // Fetch active campaigns
+    const response = await api.get('/api/influence/campaigns', {
+      params: {
+        status: 'active',
+        limit: 100,
+      },
+    });
+
+    const campaigns = response.data.data.campaigns;
+
+    if (campaigns.length === 0) {
+      message.warning('No active campaigns found');
+      return;
+    }
+
+    // Show modal with campaign selector
+    let selectedCampaignId: number | null = null;
+
+    Modal.confirm({
+      title: 'Add Campaign Link',
+      content: (
+        <div style={{ marginTop: 16 }}>
+          <Typography.Text>Select a campaign to add to navigation:</Typography.Text>
+          <Select
+            style={{ width: '100%', marginTop: 8 }}
+            placeholder="Select campaign"
+            onChange={(value) => {
+              selectedCampaignId = value;
+            }}
+            options={campaigns.map((campaign: any) => ({
+              label: campaign.title,
+              value: campaign.id,
+            }))}
+          />
+        </div>
+      ),
+      onOk: () => {
+        if (!selectedCampaignId) {
+          message.error('Please select a campaign');
+          return Promise.reject();
+        }
+
+        // Find selected campaign
+        const campaign = campaigns.find((c: any) => c.id === selectedCampaignId);
+
+        // Generate navigation item
+        const newNavItem: NavItem = {
+          key: `campaign-${campaign.id}`,
+          title: campaign.title,
+          file: `campaigns/${campaign.slug}.md`,
+        };
+
+        // Add to navigation structure (at end)
+        setNavStructure([...navStructure, newNavItem]);
+
+        message.success(`Added "${campaign.title}" to navigation`);
+      },
+    });
+  } catch (error) {
+    message.error('Failed to fetch campaigns');
+    console.error('Fetch campaigns error:', error);
+  }
+};
+
+

YAML Parser with Python Tag Support

+
import yaml from 'yaml';
+
+// Parse YAML with Python tag preservation
+const parseYAML = (yamlString: string): any => {
+  try {
+    const parsed = yaml.parse(yamlString, {
+      // Preserve Python tags
+      customTags: [
+        {
+          tag: '!relative',
+          resolve: (str: string) => ({ type: 'relative', value: str }),
+        },
+      ],
+    });
+    return parsed;
+  } catch (error) {
+    console.error('YAML parse error:', error);
+    throw new Error('Invalid YAML syntax');
+  }
+};
+
+// Stringify YAML with Python tag reconstruction
+const stringifyYAML = (obj: any): string => {
+  try {
+    return yaml.stringify(obj, {
+      indent: 2,
+      lineWidth: 0,  // Disable line wrapping
+      // Custom replacer for Python tags
+      replacer: (key, value) => {
+        if (value && typeof value === 'object' && value.type === 'relative') {
+          return `!relative ${value.value}`;
+        }
+        return value;
+      },
+    });
+  } catch (error) {
+    console.error('YAML stringify error:', error);
+    throw new Error('Failed to stringify YAML');
+  }
+};
+
+
+

Performance Considerations

+

1. Debounced Search (Not Applicable)

+

This page does not implement search functionality. Navigation filtering could be added with debouncing if needed.

+

2. Lazy Tab Loading

+
// Only load tab content when tab is active
+{activeTab === 'settings' && (
+  <Form form={form} layout="vertical">
+    {/* Settings form fields */}
+  </Form>
+)}
+
+{activeTab === 'navigation' && (
+  <div>
+    {/* Navigation tree */}
+  </div>
+)}
+
+{activeTab === 'yaml' && (
+  <div>
+    {/* Monaco editor */}
+  </div>
+)}
+
+{activeTab === 'build' && (
+  <Card>
+    {/* Build controls */}
+  </Card>
+)}
+
+

Benefit: Avoids rendering all tab contents simultaneously, especially heavy Monaco editor.

+

3. Monaco Editor Lazy Loading

+

Monaco Editor only mounts when YAML Editor tab is active:

+
{activeTab === 'yaml' && (
+  <Editor
+    height="600px"
+    defaultLanguage="yaml"
+    value={yamlContent}
+    // ... editor config
+  />
+)}
+
+

Benefit: Saves ~500KB of JavaScript bundle loading and initialization time until needed.

+

4. useCallback for Event Handlers

+
const handleSaveSettings = useCallback(async () => {
+  try {
+    setSaving(true);
+    const values = form.getFieldsValue();
+    const updatedConfig = { ...config, ...values };
+    await api.put('/api/docs/config', updatedConfig);
+    message.success('Settings saved successfully');
+    await loadConfig();
+  } catch (error) {
+    message.error('Failed to save settings');
+  } finally {
+    setSaving(false);
+  }
+}, [config, form]);
+
+

Benefit: Prevents unnecessary re-renders of child components when handler function identity changes.

+

5. Tree Data Memoization

+
const treeData = useMemo(() => {
+  return convertNavToTreeData(navStructure);
+}, [navStructure]);
+
+return (
+  <Tree treeData={treeData} draggable onDrop={handleDrop} />
+);
+
+

Benefit: Avoids recalculating tree data structure on every render.

+

6. Form Field Value Caching

+

Ant Design Form automatically caches field values, but we explicitly set them only once after loading:

+
useEffect(() => {
+  if (config) {
+    form.setFieldsValue({
+      site_name: config.site_name,
+      // ... other fields
+    });
+  }
+}, [config, form]);
+
+

Benefit: Avoids unnecessary form re-renders and field value updates.

+
+

Responsive Design

+

Breakpoint Detection

+
import { Grid } from 'antd';
+
+const screens = Grid.useBreakpoint();
+const isMobile = !screens.md;  // Mobile if screen width < 768px
+
+

Mobile YAML Editor Warning

+
{isMobile && (
+  <Alert
+    message="Desktop Recommended"
+    description="YAML Editor is best used on desktop. Consider using Settings or Navigation tabs for mobile editing."
+    type="warning"
+    showIcon
+    closable
+    style={{ marginBottom: 16 }}
+  />
+)}
+
+

Rationale: Monaco Editor provides poor UX on mobile (small screen, no keyboard shortcuts, slow rendering). Warning nudges users toward form-based Settings tab instead.

+

Responsive Form Layout

+
<Form layout="vertical">  {/* Stacks labels above inputs on all screen sizes */}
+  <Form.Item label="Site Name" name="site_name">
+    <Input />
+  </Form.Item>
+</Form>
+
+

Responsive Navigation Tree

+

Tree component automatically adjusts to container width. Horizontal scrolling enabled for deep nesting:

+
<Tree
+  style={{ overflowX: 'auto' }}  // Horizontal scroll for wide trees
+  treeData={treeData}
+  draggable
+  blockNode
+/>
+
+
+

Accessibility

+

Keyboard Navigation

+
    +
  1. Tab Key:
  2. +
  3. +

    Cycles through all interactive elements (tabs, form fields, buttons, tree nodes)

    +
  4. +
  5. +

    Arrow Keys:

    +
  6. +
  7. Navigate between tabs in Tabs component
  8. +
  9. +

    Navigate tree structure (up/down/left/right)

    +
  10. +
  11. +

    Enter Key:

    +
  12. +
  13. Submit forms
  14. +
  15. Activate buttons
  16. +
  17. +

    Expand/collapse tree nodes

    +
  18. +
  19. +

    Escape Key:

    +
  20. +
  21. Close modals
  22. +
  23. +

    Cancel drag-and-drop operations

    +
  24. +
  25. +

    Monaco Editor Shortcuts:

    +
  26. +
  27. Ctrl+S / Cmd+S - Save YAML
  28. +
  29. Ctrl+F - Find
  30. +
  31. Ctrl+H - Find and replace
  32. +
  33. Ctrl+Z - Undo
  34. +
  35. Ctrl+Y - Redo
  36. +
+

ARIA Labels

+
<Button
+  aria-label="Save MkDocs settings"
+  onClick={handleSaveSettings}
+>
+  Save Changes
+</Button>
+
+<Tree
+  aria-label="Documentation navigation tree"
+  treeData={treeData}
+  draggable
+/>
+
+<Tabs
+  aria-label="MkDocs configuration tabs"
+  activeKey={activeTab}
+  onChange={setActiveTab}
+>
+  <Tabs.TabPane tab="Settings" key="settings" />
+  <Tabs.TabPane tab="Navigation" key="navigation" />
+  <Tabs.TabPane tab="YAML Editor" key="yaml" />
+  <Tabs.TabPane tab="Build" key="build" />
+</Tabs>
+
+

Color Contrast

+

All text meets WCAG AA standards: +- Primary text: rgba(0, 0, 0, 0.85) on white background (contrast ratio 13.6:1) +- Secondary text: rgba(0, 0, 0, 0.45) on white background (contrast ratio 7.5:1) +- Button text: White on #1890ff (contrast ratio 4.5:1)

+

Focus Indicators

+

Ant Design provides default focus outlines for all interactive elements: +- Blue outline on focused inputs +- Highlight on focused tree nodes +- Border on focused buttons

+

Screen Reader Support

+
    +
  • Form labels properly associated with inputs via <Form.Item label>
  • +
  • Tree nodes have descriptive text
  • +
  • Buttons have descriptive text or aria-labels
  • +
  • Alerts have role="alert" for screen reader announcements
  • +
+
+

Troubleshooting

+

Problem: YAML Fails to Save with "Invalid YAML syntax"

+

Symptoms: +- Clicking "Save Changes" in YAML Editor shows error +- Error message: "Invalid YAML syntax"

+

Causes: +1. Syntax errors (missing colons, incorrect indentation, unclosed quotes) +2. Invalid characters in YAML +3. Python tags not properly formatted

+

Solutions:

+
    +
  1. +

    Check for syntax errors: +

    # ❌ Bad: Missing colon after key
    +site_name Changemaker Lite
    +
    +# ✅ Good: Proper key-value syntax
    +site_name: Changemaker Lite
    +

    +
  2. +
  3. +

    Verify indentation: +

    # ❌ Bad: Inconsistent indentation (mix of spaces and tabs)
    +theme:
    +  name: material
    +    palette:
    +      primary: blue
    +
    +# ✅ Good: Consistent 2-space indentation
    +theme:
    +  name: material
    +  palette:
    +    primary: blue
    +

    +
  4. +
  5. +

    Escape special characters: +

    # ❌ Bad: Unquoted special characters
    +site_description: This is a site with: special chars
    +
    +# ✅ Good: Quoted string
    +site_description: "This is a site with: special chars"
    +

    +
  6. +
  7. +

    Use Monaco Find to locate errors:

    +
  8. +
  9. Press Ctrl+F to open find dialog
  10. +
  11. Search for : to verify all keys have values
  12. +
  13. +

    Search for " to verify all quotes are closed

    +
  14. +
  15. +

    Copy YAML to external validator:

    +
  16. +
  17. Copy YAML content from editor
  18. +
  19. Paste into online YAML validator (e.g., yamllint.com)
  20. +
  21. Fix reported errors
  22. +
  23. Paste corrected YAML back into editor
  24. +
+
+

Problem: Navigation Tree Drag-and-Drop Not Working

+

Symptoms: +- Cannot drag navigation items +- Items snap back to original position after drop +- No visual feedback during drag

+

Causes: +1. Browser compatibility issues +2. JavaScript errors breaking drag handlers +3. Tree data structure corruption

+

Solutions:

+
    +
  1. Check browser console for errors:
  2. +
  3. Open DevTools (F12)
  4. +
  5. Check Console tab for red errors
  6. +
  7. +

    Look for errors related to "onDrop" or "Tree"

    +
  8. +
  9. +

    Refresh page:

    +
  10. +
  11. Hard refresh (Ctrl+Shift+R) to clear cached JavaScript
  12. +
  13. +

    Check if drag-and-drop works after refresh

    +
  14. +
  15. +

    Try alternative reordering:

    +
  16. +
  17. Instead of drag-and-drop, use Edit buttons to change order manually
  18. +
  19. +

    Delete item and re-add in desired position

    +
  20. +
  21. +

    Verify tree data structure:

    +
  22. +
  23. Switch to YAML Editor tab
  24. +
  25. +

    Check nav: section for corrupt structure: +

    # ❌ Bad: Missing keys or nested arrays
    +nav:
    +  - - Home: index.md  # Double-nested array
    +
    +# ✅ Good: Proper structure
    +nav:
    +  - Home: index.md
    +  - Getting Started:
    +      - Installation: getting-started/installation.md
    +

    +
  26. +
  27. +

    Report browser compatibility:

    +
  28. +
  29. Drag-and-drop may not work in older browsers
  30. +
  31. Update browser to latest version
  32. +
  33. Try different browser (Chrome, Firefox, Edge)
  34. +
+
+

Problem: Orphaned Files Not Appearing

+

Symptoms: +- "Orphaned Files" section is empty +- Know that markdown files exist but not in navigation +- Expect files to show but don't see them

+

Causes: +1. Files actually included in navigation (just deep in tree) +2. API not detecting files correctly +3. Files located outside docs directory

+

Solutions:

+
    +
  1. Expand all tree nodes:
  2. +
  3. Click all expand arrows in navigation tree
  4. +
  5. +

    Verify file is truly not present in any section

    +
  6. +
  7. +

    Check file location:

    +
  8. +
  9. Orphaned file detection only scans mkdocs/docs/ directory
  10. +
  11. Files in other directories won't appear
  12. +
  13. +

    Move files to mkdocs/docs/ if needed

    +
  14. +
  15. +

    Verify file has .md extension:

    +
  16. +
  17. Only .md files detected
  18. +
  19. +

    Files with .txt, .html, etc. won't appear

    +
  20. +
  21. +

    Refresh orphaned files:

    +
  22. +
  23. Save navigation (even without changes)
  24. +
  25. API re-scans for orphaned files on save
  26. +
  27. +

    Check if files appear after save

    +
  28. +
  29. +

    Manually add files:

    +
  30. +
  31. If orphaned detection fails, use "Add Page" button
  32. +
  33. Enter file path manually
  34. +
  35. File will be added to navigation
  36. +
+
+

Problem: Build Fails with Error Message

+

Symptoms: +- Clicking "Build Site" shows error +- Error message displayed in alert +- Build timestamp not updated

+

Causes: +1. Invalid YAML configuration +2. Missing markdown files referenced in navigation +3. MkDocs Docker container not running +4. Theme or plugin not installed

+

Solutions:

+
    +
  1. Check configuration validity:
  2. +
  3. Switch to Settings tab
  4. +
  5. Verify all required fields filled
  6. +
  7. +

    Check for validation errors (red borders on inputs)

    +
  8. +
  9. +

    Verify file references:

    +
  10. +
  11. Switch to YAML Editor tab
  12. +
  13. Check nav: section for file paths
  14. +
  15. +

    Ensure all referenced files exist: +

    nav:
    +  - Home: index.md  # Must exist at mkdocs/docs/index.md
    +  - Guide: guide.md  # Must exist at mkdocs/docs/guide.md
    +

    +
  16. +
  17. +

    Check MkDocs container status:

    +
  18. +
  19. Open terminal
  20. +
  21. Run: docker compose ps
  22. +
  23. Verify mkdocs container is "Up"
  24. +
  25. +

    If not, start container: docker compose up -d mkdocs

    +
  26. +
  27. +

    View build logs:

    +
  28. +
  29. Open terminal
  30. +
  31. Run: docker compose logs mkdocs
  32. +
  33. Look for error messages in logs
  34. +
  35. +

    Fix issues indicated in logs (e.g., missing plugin, theme error)

    +
  36. +
  37. +

    Verify theme and plugins installed:

    +
  38. +
  39. Themes/plugins must be installed in MkDocs container
  40. +
  41. Check api/mkdocs/requirements.txt for installed packages
  42. +
  43. Add missing packages to requirements.txt
  44. +
  45. Rebuild container: docker compose up -d --build mkdocs
  46. +
+
+

Problem: Settings Changes Not Persisting

+

Symptoms: +- Click "Save Changes" in Settings tab +- Success message appears +- Reload page, changes reverted to old values

+

Causes: +1. Database write failure +2. Form validation errors preventing save +3. YAML overriding database values

+

Solutions:

+
    +
  1. Check browser console:
  2. +
  3. Open DevTools (F12)
  4. +
  5. Check Console tab for errors
  6. +
  7. +

    Look for API errors (400, 500 status codes)

    +
  8. +
  9. +

    Verify form validation:

    +
  10. +
  11. Look for red borders on input fields
  12. +
  13. Red border indicates validation error
  14. +
  15. +

    Fix validation errors before saving:

    +
      +
    • URL fields must be valid URLs
    • +
    • Color fields must be hex format (#RRGGBB)
    • +
    • Required fields must be filled
    • +
    +
  16. +
  17. +

    Check API response:

    +
  18. +
  19. Open DevTools Network tab
  20. +
  21. Click "Save Changes"
  22. +
  23. Click PUT /api/docs/config request
  24. +
  25. +

    Check Response tab for error details

    +
  26. +
  27. +

    Verify database connection:

    +
  28. +
  29. Open terminal
  30. +
  31. Run: docker compose logs api
  32. +
  33. Look for database connection errors
  34. +
  35. +

    If errors, restart API: docker compose restart api

    +
  36. +
  37. +

    Check YAML Editor for conflicts:

    +
  38. +
  39. Switch to YAML Editor tab
  40. +
  41. Save YAML (even without changes)
  42. +
  43. YAML save may override database values
  44. +
  45. Re-enter settings and save again
  46. +
+
+

Problem: Monaco Editor Not Loading

+

Symptoms: +- YAML Editor tab shows blank space or loading spinner +- No code editor appears +- Console errors related to Monaco

+

Causes: +1. Slow network loading Monaco assets +2. JavaScript bundle corruption +3. Browser compatibility issues

+

Solutions:

+
    +
  1. Wait for loading:
  2. +
  3. Monaco Editor takes 2-5 seconds to load
  4. +
  5. +

    Wait for editor to fully initialize before interacting

    +
  6. +
  7. +

    Check network requests:

    +
  8. +
  9. Open DevTools Network tab
  10. +
  11. Look for failed requests to Monaco assets
  12. +
  13. Failed requests indicated by red text and 4xx/5xx status
  14. +
  15. +

    If failed, refresh page to retry

    +
  16. +
  17. +

    Clear browser cache:

    +
  18. +
  19. Hard refresh (Ctrl+Shift+R)
  20. +
  21. Or clear browser cache entirely
  22. +
  23. +

    Reload page to fetch fresh assets

    +
  24. +
  25. +

    Update browser:

    +
  26. +
  27. Monaco requires modern browser (Chrome 90+, Firefox 88+, Edge 90+)
  28. +
  29. Update browser to latest version
  30. +
  31. +

    Restart browser after update

    +
  32. +
  33. +

    Use alternative tabs:

    +
  34. +
  35. If Monaco fails, use Settings or Navigation tabs instead
  36. +
  37. Both provide same functionality without Monaco Editor
  38. +
+
+ +

Symptoms: +- Click "Add Campaign Link" +- Modal appears but dropdown is empty +- No campaigns to select

+

Causes: +1. No active campaigns in database +2. API error fetching campaigns +3. Insufficient permissions

+

Solutions:

+
    +
  1. Verify active campaigns exist:
  2. +
  3. Navigate to "Influence" → "Campaigns" in sidebar
  4. +
  5. Check if any campaigns have "Active" status
  6. +
  7. If none, create active campaign first
  8. +
  9. +

    Return to MkDocs Settings and try again

    +
  10. +
  11. +

    Check browser console:

    +
  12. +
  13. Open DevTools (F12)
  14. +
  15. Click "Add Campaign Link"
  16. +
  17. Check Console for errors
  18. +
  19. +

    Look for API errors (401, 403, 500)

    +
  20. +
  21. +

    Verify permissions:

    +
  22. +
  23. Campaign link insertion requires SUPER_ADMIN role
  24. +
  25. Check user role in profile dropdown
  26. +
  27. +

    If not SUPER_ADMIN, request role upgrade from administrator

    +
  28. +
  29. +

    Check API endpoint:

    +
  30. +
  31. Open DevTools Network tab
  32. +
  33. Click "Add Campaign Link"
  34. +
  35. Look for GET /api/influence/campaigns request
  36. +
  37. Check Response tab for campaign data
  38. +
  39. +

    If empty response, no campaigns available

    +
  40. +
  41. +

    Manually add campaign pages:

    +
  42. +
  43. Instead of campaign link button, use "Add Page" button
  44. +
  45. Manually enter campaign details:
      +
    • Title: "Climate Action Now"
    • +
    • File: campaigns/climate-action-now.md
    • +
    +
  46. +
  47. Create markdown file manually in mkdocs/docs/campaigns/
  48. +
+
+ +

Backend Documentation

+
    +
  • Docs Routes - API endpoints for MkDocs configuration and build
  • +
  • Pages Module - Landing page system (related to MkDocs export)
  • +
+

Frontend Documentation

+
    +
  • DocsPage - MkDocs export management (generates pages for MkDocs)
  • +
  • LandingPagesPage - Landing page editor (exports to MkDocs)
  • +
  • AppLayout - Sidebar navigation structure
  • +
+

Feature Documentation

+ +

API Documentation

+ +

User Guides

+ +

Deployment Documentation

+ +

Development Documentation

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/n8n-page/index.html b/mkdocs/site/v2/frontend/pages/admin/n8n-page/index.html new file mode 100644 index 00000000..c41b4e33 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/n8n-page/index.html @@ -0,0 +1,6578 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + n8n - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

N8nPage

+

Overview

+

File: admin/src/pages/N8nPage.tsx

+

Route: /app/services/n8n

+

Role Requirements: Any authenticated user (uses authenticate middleware)

+

Purpose: Provides an embedded interface to the n8n workflow automation service via iframe. n8n is a node-based workflow automation tool that allows administrators to create automated workflows connecting different services (email, webhooks, databases, APIs) without writing code. This page serves as a wrapper that embeds the n8n editor with status monitoring and mobile detection.

+

Key Features: +- Full-page iframe embed of n8n workflow editor +- Service online/offline status monitoring +- Mobile device detection with warning screen +- "Refresh" and "Open in New Tab" buttons +- Fullbleed layout for maximum editor space +- Access to 200+ n8n integrations

+

Layout: AppLayout with fullbleed

+

Dependencies: +- Ant Design v5 (Button, Space, Badge, Spin, Grid, Result) +- Service URL builder utility

+
+

Features

+

1. Service Status Monitoring

+

Status Display: +- Green "Online" badge when n8n is accessible +- Red "Offline" badge when unavailable +- Blue "Checking..." badge during status check

+

2. Mobile Device Detection

+

Mobile Warning: +- ApiOutlined icon (48px) +- Message: "The workflow editor requires a desktop browser" +- "Open in New Tab" button for external access

+

3. Workflow Automation

+

n8n Features (within iframe): +- Visual Editor: Node-based workflow canvas +- 200+ Integrations: Pre-built nodes for popular services +- Trigger Nodes: Start workflows on schedule, webhook, manual trigger +- Action Nodes: Perform actions (send email, HTTP request, database query) +- Logic Nodes: IF conditions, loops, merge data +- Execution History: View workflow runs and debug errors +- Credentials: Securely store API keys and passwords

+

Common Use Cases: +- Email Notifications: Send campaign updates via SMTP +- Webhook Handlers: Respond to external events (Stripe payments, GitHub webhooks) +- Database Sync: Sync data between PostgreSQL and external services +- Scheduled Tasks: Run cleanup jobs, generate reports, send reminders +- API Integrations: Connect to Listmonk, Represent API, geocoding services

+
+

User Workflow

+

Creating a Workflow

+
    +
  1. Navigate to Workflow Automation:
  2. +
  3. Click "Services" → "Workflow Automation" in sidebar
  4. +
  5. +

    Page loads with status check

    +
  6. +
  7. +

    Wait for Load:

    +
  8. +
  9. Status badge shows "Checking..." then "Online"
  10. +
  11. +

    n8n editor loads in iframe

    +
  12. +
  13. +

    Create New Workflow:

    +
  14. +
  15. Click "New Workflow" button in n8n
  16. +
  17. +

    Empty canvas appears

    +
  18. +
  19. +

    Add Trigger Node:

    +
  20. +
  21. Click "+" button on canvas
  22. +
  23. +

    Select trigger type:

    +
      +
    • Schedule: Run on cron schedule (e.g., daily at 9am)
    • +
    • Webhook: Trigger via HTTP POST
    • +
    • Manual: Run manually via button
    • +
    +
  24. +
  25. +

    Add Action Nodes:

    +
  26. +
  27. Click "+" button after trigger
  28. +
  29. Search for integration (e.g., "PostgreSQL", "Gmail", "HTTP Request")
  30. +
  31. +

    Configure node:

    +
      +
    • Select credentials (API keys, database connection)
    • +
    • Set parameters (SQL query, email recipient, API endpoint)
    • +
    • Map data from previous nodes
    • +
    +
  32. +
  33. +

    Test Workflow:

    +
  34. +
  35. Click "Execute Workflow" button
  36. +
  37. View execution result for each node
  38. +
  39. Check output data
  40. +
  41. +

    Debug errors if any

    +
  42. +
  43. +

    Activate Workflow:

    +
  44. +
  45. Toggle "Active" switch in top-right
  46. +
  47. +

    Workflow now runs automatically based on trigger

    +
  48. +
  49. +

    Monitor Executions:

    +
  50. +
  51. Click "Executions" tab
  52. +
  53. View history of all workflow runs
  54. +
  55. Click execution to see detailed logs
  56. +
  57. Identify failed executions
  58. +
+

Example Workflows

+

1. Daily Campaign Report Email: +- Trigger: Schedule (daily at 9am) +- Node 1: PostgreSQL query (count responses per campaign) +- Node 2: Format data (create HTML email) +- Node 3: Gmail send (email report to campaign manager)

+

2. Listmonk Subscriber Sync: +- Trigger: Schedule (hourly) +- Node 1: PostgreSQL query (fetch new shift signups) +- Node 2: Listmonk API (create/update subscribers) +- Node 3: Slack notification (notify team of sync completion)

+

3. Response Submission Webhook: +- Trigger: Webhook (POST /webhook/response) +- Node 1: Extract data from webhook payload +- Node 2: PostgreSQL insert (create response record) +- Node 3: Email notification (notify campaign manager)

+
+

Component Structure

+
export default function N8nPage() {
+  const { setPageHeader } = useOutletContext<AppOutletContext>();
+  const screens = Grid.useBreakpoint();
+  const isMobile = !screens.md;
+
+  const [online, setOnline] = useState<boolean | null>(null);
+  const [config, setConfig] = useState<ServicesConfig | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  const fetchStatus = useCallback(async () => {
+    try {
+      const [statusRes, configRes] = await Promise.all([
+        api.get<ServicesStatus>('/services/status'),
+        api.get<ServicesConfig>('/services/config'),
+      ]);
+      setOnline(statusRes.data.n8n.online);
+      setConfig(configRes.data);
+    } catch {
+      setOnline(false);
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    fetchStatus();
+  }, [fetchStatus]);
+
+  const serviceUrl = config
+    ? buildServiceUrl(config.n8nSubdomain, config.domain, config.n8nPort)
+    : null;
+
+  const headerActions = useMemo(() => (
+    <Space>
+      <Badge
+        status={online === null ? 'processing' : online ? 'success' : 'error'}
+        text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
+      />
+      <Button icon={<ReloadOutlined />} onClick={fetchStatus} size="small">
+        Refresh
+      </Button>
+      {serviceUrl && (
+        <Button icon={<LinkOutlined />} href={serviceUrl} target="_blank" size="small">
+          Open in New Tab
+        </Button>
+      )}
+    </Space>
+  ), [online, fetchStatus, serviceUrl]);
+
+  useEffect(() => {
+    setPageHeader({ title: 'Workflow Automation', actions: headerActions, fullBleed: true });
+    return () => setPageHeader(null);
+  }, [setPageHeader, headerActions]);
+
+  if (isMobile) {
+    return (
+      <Result
+        status="info"
+        title="Desktop Required"
+        subTitle="The workflow editor requires a desktop browser with a larger screen."
+        icon={<ApiOutlined style={{ fontSize: 48 }} />}
+      />
+    );
+  }
+
+  if (loading) {
+    return (
+      <div style={{ textAlign: 'center', padding: 80 }}>
+        <Spin size="large" />
+      </div>
+    );
+  }
+
+  if (!online || !serviceUrl) {
+    return (
+      <Result
+        status="error"
+        title="n8n Unavailable"
+        subTitle="n8n is not running or could not be reached. Check that the n8n container is started."
+        extra={
+          <Button type="primary" onClick={fetchStatus}>
+            Retry
+          </Button>
+        }
+      />
+    );
+  }
+
+  return (
+    <iframe
+      src={serviceUrl}
+      style={{
+        width: '100%',
+        height: 'calc(100vh - 64px)',
+        border: 'none',
+        display: 'block',
+      }}
+      title="n8n Workflows"
+    />
+  );
+}
+
+
+

API Integration

+

Endpoints Used

+
    +
  1. GET /api/services/status - Check n8n health
  2. +
  3. GET /api/services/config - Fetch subdomain/port config
  4. +
+

Example Responses

+

Status: +

{
+  "n8n": { "online": true },
+  "mailhog": { "online": true },
+  "nocodb": { "online": true }
+}
+

+

Config: +

{
+  "domain": "cmlite.org",
+  "n8nSubdomain": "n8n",
+  "n8nPort": 5678
+}
+

+

Service URL: +- Production: http://n8n.cmlite.org +- Development: http://localhost:5678

+
+

Troubleshooting

+

Problem: n8n Login Required

+

Symptoms: +- Iframe shows n8n login screen +- Cannot access workflows without credentials

+

Solutions:

+
    +
  1. Check n8n credentials:
  2. +
  3. Username: N8N_BASIC_AUTH_USER env var
  4. +
  5. +

    Password: N8N_BASIC_AUTH_PASSWORD env var

    +
  6. +
  7. +

    Login manually:

    +
  8. +
  9. Enter credentials in n8n login form
  10. +
  11. +

    n8n saves session in browser cookies

    +
  12. +
  13. +

    Disable authentication (dev only):

    +
  14. +
  15. Set N8N_BASIC_AUTH_ACTIVE=false in .env
  16. +
  17. Restart n8n: docker compose restart n8n
  18. +
+
+

Problem: Workflow Execution Failed

+

Symptoms: +- Workflow shows red error icon +- Execution stopped at specific node +- Error message displayed

+

Solutions:

+
    +
  1. Check node configuration:
  2. +
  3. Click failed node
  4. +
  5. Review parameters
  6. +
  7. +

    Verify credentials valid

    +
  8. +
  9. +

    Check credentials:

    +
  10. +
  11. Click "Credentials" in n8n sidebar
  12. +
  13. Test credential connection
  14. +
  15. +

    Re-enter if expired

    +
  16. +
  17. +

    View error details:

    +
  18. +
  19. Click execution in history
  20. +
  21. Expand failed node
  22. +
  23. Read error message
  24. +
  25. +

    Common errors:

    +
      +
    • "Connection refused": Service not accessible
    • +
    • "Unauthorized": Invalid API key/credentials
    • +
    • "Timeout": Request took too long
    • +
    +
  26. +
  27. +

    Test individual nodes:

    +
  28. +
  29. Right-click node → "Execute Node"
  30. +
  31. Test each node in isolation
  32. +
  33. Identify problematic node
  34. +
+
+

Problem: Webhook Not Triggering

+

Symptoms: +- Webhook workflow not executing +- External service sending webhooks but n8n not responding

+

Solutions:

+
    +
  1. Check webhook URL:
  2. +
  3. Copy webhook URL from n8n trigger node
  4. +
  5. Example: http://n8n.cmlite.org/webhook/response
  6. +
  7. +

    Verify URL accessible from external service

    +
  8. +
  9. +

    Check workflow active:

    +
  10. +
  11. Toggle "Active" switch must be ON
  12. +
  13. +

    Inactive workflows don't respond to webhooks

    +
  14. +
  15. +

    Test webhook manually: +

    curl -X POST http://n8n.cmlite.org/webhook/response \
    +  -H "Content-Type: application/json" \
    +  -d '{"test": "data"}'
    +

    +
  16. +
  17. Should return 200 OK
  18. +
  19. +

    Check execution history in n8n

    +
  20. +
  21. +

    Check nginx routing:

    +
  22. +
  23. Webhook URL must route through nginx
  24. +
  25. Verify proxy_pass configured for n8n
  26. +
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/nocodb-page/index.html b/mkdocs/site/v2/frontend/pages/admin/nocodb-page/index.html new file mode 100644 index 00000000..86a7e960 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/nocodb-page/index.html @@ -0,0 +1,6448 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NocoDB - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

NocoDBPage

+

Overview

+

File: admin/src/pages/NocoDBPage.tsx

+

Route: /app/services/nocodb

+

Role Requirements: Any authenticated user (uses authenticate middleware)

+

Purpose: Provides an embedded interface to the NocoDB database browser via iframe. NocoDB is a no-code database platform that provides a spreadsheet-like interface for viewing and editing database tables. This page serves as a read-only data browser for administrators to explore the PostgreSQL database without SQL knowledge.

+

Key Features: +- Full-page iframe embed of NocoDB service +- Service online/offline status monitoring +- Mobile device detection with warning screen +- "Refresh" and "Open in New Tab" buttons +- Fullbleed layout (no padding) +- Read-only access to database tables

+

Layout: AppLayout with fullbleed

+

Dependencies: +- Ant Design v5 (Button, Space, Badge, Spin, Grid, Result) +- Service URL builder utility

+
+

Features

+

1. Service Status Monitoring

+

Status Display: +- Green "Online" badge when NocoDB is accessible +- Red "Offline" badge when unavailable +- Blue "Checking..." badge during status check

+

2. Mobile Device Detection

+

Mobile Warning: +- DatabaseOutlined icon (48px) +- Message: "The database browser requires a desktop browser" +- "Open in New Tab" button for external access

+

3. Database Browsing

+

NocoDB Features (within iframe): +- Table View: Spreadsheet-like interface for all tables +- Filter: Filter rows by column values +- Sort: Sort rows by any column +- Search: Global table search +- Export: Export tables to CSV/Excel +- Read-Only: No edit/delete capabilities (view only)

+

Tables Available: +- User, RefreshToken (auth) +- Campaign, Representative, Response, CampaignEmail (influence) +- Location, Cut, Shift, ShiftSignup (map) +- CanvassSession, CanvassVisit (canvassing) +- LandingPage, PageBlock (pages) +- And more...

+
+

User Workflow

+

Browsing Database Tables

+
    +
  1. Navigate to Database Browser:
  2. +
  3. Click "Services" → "Database Browser" in sidebar
  4. +
  5. +

    Page loads with status check

    +
  6. +
  7. +

    Wait for Load:

    +
  8. +
  9. Status badge shows "Checking..." then "Online"
  10. +
  11. +

    NocoDB interface loads in iframe

    +
  12. +
  13. +

    Select Table:

    +
  14. +
  15. Left sidebar lists all tables
  16. +
  17. +

    Click table name to view contents

    +
  18. +
  19. +

    View Table Data:

    +
  20. +
  21. Spreadsheet view with all rows and columns
  22. +
  23. Scroll horizontally/vertically
  24. +
  25. +

    Click row to expand details

    +
  26. +
  27. +

    Filter Data:

    +
  28. +
  29. Click filter icon in column header
  30. +
  31. Select filter condition (equals, contains, etc.)
  32. +
  33. Enter filter value
  34. +
  35. +

    View filtered results

    +
  36. +
  37. +

    Export Data:

    +
  38. +
  39. Click "..." menu in table header
  40. +
  41. Select "Export" → "CSV" or "Excel"
  42. +
  43. +

    Download file for offline analysis

    +
  44. +
  45. +

    Common Use Cases:

    +
  46. +
  47. User Management: Browse User table to see all accounts
  48. +
  49. Campaign Analysis: View Campaign responses by filtering Response table
  50. +
  51. Location Data: Export Location table for mapping analysis
  52. +
  53. Audit Trail: Check RefreshToken table for login activity
  54. +
+
+

Component Structure

+
export default function NocoDBPage() {
+  const { setPageHeader } = useOutletContext<AppOutletContext>();
+  const screens = Grid.useBreakpoint();
+  const isMobile = !screens.md;
+
+  const [online, setOnline] = useState<boolean | null>(null);
+  const [config, setConfig] = useState<ServicesConfig | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  const fetchStatus = useCallback(async () => {
+    try {
+      const [statusRes, configRes] = await Promise.all([
+        api.get<ServicesStatus>('/services/status'),
+        api.get<ServicesConfig>('/services/config'),
+      ]);
+      setOnline(statusRes.data.nocodb.online);
+      setConfig(configRes.data);
+    } catch {
+      setOnline(false);
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    fetchStatus();
+  }, [fetchStatus]);
+
+  const serviceUrl = config
+    ? buildServiceUrl(config.nocodbSubdomain, config.domain, config.nocodbPort)
+    : null;
+
+  const headerActions = useMemo(() => (
+    <Space>
+      <Badge
+        status={online === null ? 'processing' : online ? 'success' : 'error'}
+        text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
+      />
+      <Button icon={<ReloadOutlined />} onClick={fetchStatus} size="small">
+        Refresh
+      </Button>
+      {serviceUrl && (
+        <Button icon={<LinkOutlined />} href={serviceUrl} target="_blank" size="small">
+          Open in New Tab
+        </Button>
+      )}
+    </Space>
+  ), [online, fetchStatus, serviceUrl]);
+
+  useEffect(() => {
+    setPageHeader({ title: 'Database Browser', actions: headerActions, fullBleed: true });
+    return () => setPageHeader(null);
+  }, [setPageHeader, headerActions]);
+
+  if (isMobile) {
+    return (
+      <Result
+        status="info"
+        title="Desktop Required"
+        subTitle="The database browser requires a desktop browser with a larger screen."
+        icon={<DatabaseOutlined style={{ fontSize: 48 }} />}
+      />
+    );
+  }
+
+  if (loading) {
+    return (
+      <div style={{ textAlign: 'center', padding: 80 }}>
+        <Spin size="large" />
+      </div>
+    );
+  }
+
+  if (!online || !serviceUrl) {
+    return (
+      <Result
+        status="error"
+        title="NocoDB Unavailable"
+        subTitle="NocoDB is not running or could not be reached. Check that the NocoDB container is started."
+        extra={
+          <Button type="primary" onClick={fetchStatus}>
+            Retry
+          </Button>
+        }
+      />
+    );
+  }
+
+  return (
+    <iframe
+      src={serviceUrl}
+      style={{
+        width: '100%',
+        height: 'calc(100vh - 64px)',
+        border: 'none',
+        display: 'block',
+      }}
+      title="NocoDB"
+    />
+  );
+}
+
+
+

API Integration

+

Endpoints Used

+
    +
  1. GET /api/services/status - Check NocoDB health
  2. +
  3. GET /api/services/config - Fetch subdomain/port config
  4. +
+

Example Responses

+

Status: +

{
+  "nocodb": { "online": true },
+  "mailhog": { "online": true },
+  "n8n": { "online": true }
+}
+

+

Config: +

{
+  "domain": "cmlite.org",
+  "nocodbSubdomain": "db",
+  "nocodbPort": 8091
+}
+

+

Service URL: +- Production: http://db.cmlite.org +- Development: http://localhost:8091

+
+

Troubleshooting

+

Problem: NocoDB Login Required

+

Symptoms: +- Iframe shows NocoDB login screen +- Cannot access tables without credentials

+

Solutions:

+
    +
  1. Check NocoDB admin credentials:
  2. +
  3. Username: NC_ADMIN_EMAIL env var
  4. +
  5. +

    Password: NC_ADMIN_PASSWORD env var

    +
  6. +
  7. +

    Login manually:

    +
  8. +
  9. Enter admin credentials in NocoDB login form
  10. +
  11. +

    NocoDB saves session in browser cookies

    +
  12. +
  13. +

    Reset password: +

    docker compose exec nocodb sh
    +nc-cli reset-password --email admin@example.com
    +

    +
  14. +
+
+

Problem: Tables Not Visible

+

Symptoms: +- NocoDB loads but no tables in left sidebar +- "No projects found" message

+

Solutions:

+
    +
  1. Check NocoDB configuration:
  2. +
  3. NocoDB must be connected to PostgreSQL
  4. +
  5. +

    Check NC_DB env var: postgresql://user:password@host:port/database

    +
  6. +
  7. +

    Create NocoDB project:

    +
  8. +
  9. Click "New Project" button
  10. +
  11. Connect to PostgreSQL database
  12. +
  13. +

    Enter database credentials (from V2_POSTGRES_* env vars)

    +
  14. +
  15. +

    Restart NocoDB: +

    docker compose restart nocodb
    +

    +
  16. +
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/observability-page/index.html b/mkdocs/site/v2/frontend/pages/admin/observability-page/index.html new file mode 100644 index 00000000..c9627723 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/observability-page/index.html @@ -0,0 +1,7881 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Observability - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

ObservabilityPage

+

Overview

+

File: admin/src/pages/ObservabilityPage.tsx +Route: /app/observability +Role Requirements: SUPER_ADMIN

+

ObservabilityPage is the system monitoring and alerting dashboard for Changemaker Lite's observability stack. It provides a unified interface for viewing Prometheus metrics, Grafana dashboards, and Alertmanager alerts. The page features three tabs (Overview, Monitoring, Alerts), service status monitoring for 7 monitoring services, key metrics grid, active alerts table, and embedded iframes for Grafana and Alertmanager with lazy loading.

+

The page integrates with: +- Prometheus (port 9090) - Metrics collection and time-series database +- Grafana (port 3001) - Metrics visualization and dashboards +- Alertmanager (port 9093) - Alert management and routing +- cAdvisor (port 8080) - Container metrics +- Node Exporter (port 9100) - Host system metrics +- Redis Exporter (port 9121) - Redis metrics +- Gotify (port 8889) - Notification service

+

Key Features: +- Three-tab interface (Overview/Monitoring/Alerts) with radio button switcher +- Service status cards (7 services) with online/offline indicators +- Metrics grid showing key application metrics (API uptime, queue size, sessions, etc.) +- Active alerts table with severity indicators +- Lazy-loaded Grafana iframe (Application Overview dashboard) +- Lazy-loaded Alertmanager iframe +- Auto-start banner for offline services +- "Open Grafana" button for full-screen access

+

Key Components: +- ServiceStatusCard for each monitoring service +- MetricsGrid for application metrics +- AlertsTable for active alerts +- IframeErrorBoundary for iframe error handling +- Radio.Group for tab switching

+
+

Screenshot

+

[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.]

+
+

Features

+

Core Features

+
    +
  1. Three-Tab Interface
  2. +
  3. Overview Tab: Service status + metrics + alerts summary
  4. +
  5. Monitoring Tab: Embedded Grafana Application Overview dashboard
  6. +
  7. Alerts Tab: Embedded Alertmanager UI
  8. +
  9. Radio button switcher in page header
  10. +
  11. +

    Tab state preserved during session

    +
  12. +
  13. +

    Service Status Monitoring

    +
  14. +
  15. 7 service status cards:
      +
    • Prometheus - Metrics database
    • +
    • Grafana - Dashboard visualization
    • +
    • Alertmanager - Alert management
    • +
    • cAdvisor - Container metrics
    • +
    • Node Exporter - Host metrics
    • +
    • Redis Exporter - Redis metrics
    • +
    • Gotify - Notification service
    • +
    +
  16. +
  17. Online/offline badge indicators
  18. +
  19. Clickable URL to open service in new tab
  20. +
  21. +

    Responsive grid layout (4 columns on desktop, 2 on tablet, 1 on mobile)

    +
  22. +
  23. +

    Auto-Start Banner

    +
  24. +
  25. Warning alert at top of Overview tab when all services offline
  26. +
  27. Shows Docker Compose command to start monitoring services
  28. +
  29. Command: docker compose --profile monitoring up -d
  30. +
  31. +

    Only shows when servicesOnline === 0

    +
  32. +
  33. +

    Key Metrics Grid

    +
  34. +
  35. Displays application-specific metrics from Prometheus
  36. +
  37. Examples: API uptime, email queue size, active canvass sessions, total locations
  38. +
  39. Only visible when at least one service online
  40. +
  41. +

    Powered by MetricsGrid component

    +
  42. +
  43. +

    Active Alerts Table

    +
  44. +
  45. Shows currently firing alerts from Alertmanager
  46. +
  47. Columns: Alert name, severity, status, start time
  48. +
  49. Color-coded severity (critical=red, warning=orange, info=blue)
  50. +
  51. Only visible when at least one service online
  52. +
  53. +

    Powered by AlertsTable component

    +
  54. +
  55. +

    Grafana Dashboard Iframe

    +
  56. +
  57. Embedded Application Overview dashboard
  58. +
  59. Lazy-loaded (only loads when Monitoring tab selected)
  60. +
  61. Full-height iframe (calc(100vh - 200px))
  62. +
  63. Sandboxed for security (allow-scripts, allow-same-origin, allow-forms)
  64. +
  65. Error boundary for graceful failure handling
  66. +
  67. +

    Shows warning if Grafana offline

    +
  68. +
  69. +

    Alertmanager Iframe

    +
  70. +
  71. Embedded Alertmanager UI
  72. +
  73. Lazy-loaded (only loads when Alerts tab selected)
  74. +
  75. Full-height iframe (calc(100vh - 200px))
  76. +
  77. Sandboxed for security
  78. +
  79. Error boundary for graceful failure handling
  80. +
  81. +

    Shows warning if Alertmanager offline

    +
  82. +
  83. +

    Refresh Button

    +
  84. +
  85. Refreshes all data (status, metrics, alerts) in parallel
  86. +
  87. Visible in all tabs
  88. +
  89. +

    Loading state during refresh

    +
  90. +
  91. +

    Open Grafana Button

    +
  92. +
  93. Primary button in header (blue)
  94. +
  95. Opens Grafana in new tab at full URL
  96. +
  97. Only visible when Grafana online
  98. +
  99. Provides full-screen Grafana access
  100. +
+
+

User Workflow

+

Viewing System Status (Overview Tab)

+
    +
  1. Navigate to page: Admin sidebar → System → Observability
  2. +
  3. Overview tab loads: Shows service status cards, metrics grid, alerts table
  4. +
  5. Check service status: Green badges = online, red badges = offline
  6. +
  7. Review metrics: Scan key application metrics (uptime, queue size, etc.)
  8. +
  9. Check alerts: Review active alerts table for firing alerts
  10. +
+

Starting Monitoring Services

+

If all services offline: +1. See warning banner: Yellow alert at top with Docker Compose command +2. Copy command: docker compose --profile monitoring up -d +3. Run in terminal: Execute command in project directory +4. Wait ~30 seconds: Services take time to start +5. Click Refresh: Reload page to verify services online +6. Banner disappears: Warning banner no longer shown

+

Viewing Grafana Dashboards

+
    +
  1. Click "Monitoring" tab: Radio button in header
  2. +
  3. Grafana iframe loads: Embedded Application Overview dashboard
  4. +
  5. Interact with dashboard: Pan, zoom, change time range, etc.
  6. +
  7. Full-screen access: Click "Open Grafana" button for new tab
  8. +
  9. Explore more dashboards: In Grafana UI, browse other dashboards (Host Metrics, Docker Containers, etc.)
  10. +
+

Managing Alerts

+
    +
  1. Click "Alerts" tab: Radio button in header
  2. +
  3. Alertmanager iframe loads: Embedded alert management UI
  4. +
  5. View alert groups: See all firing alerts grouped by label
  6. +
  7. Silence alerts: Click Silence button to temporarily suppress
  8. +
  9. Configure routes: Modify alert routing rules (if SUPER_ADMIN)
  10. +
+

Refreshing Data

+
    +
  1. Click Refresh button: In header (any tab)
  2. +
  3. All data reloads: Service status, metrics, alerts fetched in parallel
  4. +
  5. Loading state: Brief spinner or loading indicator
  6. +
  7. Data updates: New status/metrics/alerts displayed
  8. +
+

Opening Service Directly

+
    +
  1. Click on service status card URL (if service online)
  2. +
  3. New tab opens: Direct access to service (e.g., Prometheus, Grafana, Alertmanager)
  4. +
  5. Full service UI: No iframe restrictions, full functionality
  6. +
+
+

Component Breakdown

+

Tab Switcher (Header)

+
<Radio.Group
+  value={activeTab}
+  onChange={e => setActiveTab(e.target.value)}
+  buttonStyle="solid"
+>
+  <Radio.Button value="overview">
+    <DashboardOutlined /> Overview
+  </Radio.Button>
+  <Radio.Button value="monitoring">
+    <LineChartOutlined /> Monitoring
+  </Radio.Button>
+  <Radio.Button value="alerts">
+    <AlertOutlined /> Alerts
+  </Radio.Button>
+</Radio.Group>
+
+

Solid button style: Active tab highlighted with blue background.

+

Service Status Card

+
<ServiceStatusCard
+  name="Prometheus"
+  online={status?.prometheus?.online || false}
+  url={status?.prometheus?.url || ''}
+  icon={<DashboardOutlined />}
+/>
+
+

ServiceStatusCard Component: +

interface ServiceStatusCardProps {
+  name: string;
+  online: boolean;
+  url: string;
+  icon: React.ReactNode;
+}
+
+// Displays:
+// - Service name (bold)
+// - Badge (green "Online" or red "Offline")
+// - Icon
+// - Clickable link to service URL (if online)
+

+

Auto-Start Banner

+
{allOffline && (
+  <Alert
+    message="Monitoring services are offline"
+    description={
+      <>
+        Start monitoring services with: <code>docker compose --profile monitoring up -d</code>
+      </>
+    }
+    type="warning"
+    showIcon
+    style={{ marginBottom: 16 }}
+  />
+)}
+
+

Condition: allOffline = servicesOnline === 0

+

Service Status Grid

+
<Card title="Service Status" style={{ marginBottom: 16 }}>
+  <Row gutter={[16, 16]}>
+    <Col xs={24} sm={12} lg={6}>
+      <ServiceStatusCard name="Prometheus" online={...} url={...} icon={<DashboardOutlined />} />
+    </Col>
+    <Col xs={24} sm={12} lg={6}>
+      <ServiceStatusCard name="Grafana" online={...} url={...} icon={<LineChartOutlined />} />
+    </Col>
+    {/* 5 more cards... */}
+  </Row>
+</Card>
+
+

Responsive Grid: +- Desktop (lg, ≥ 992px): 4 columns (6/24 = 25% width each) +- Tablet (sm, ≥ 576px): 2 columns (12/24 = 50% width each) +- Mobile (xs, < 576px): 1 column (24/24 = 100% width)

+

Metrics Grid

+
{!allOffline && <MetricsGrid metrics={metrics} loading={loading} />}
+
+

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

+

Alerts Table

+
{!allOffline && alerts && (
+  <AlertsTable alerts={alerts.alerts || []} loading={loading} />
+)}
+
+

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

+

Grafana Iframe (Monitoring Tab)

+
<IframeErrorBoundary serviceName="Grafana">
+  <Card styles={{ body: { padding: 0 } }}>
+    {grafanaIframeSrc ? (
+      <iframe
+        src={grafanaIframeSrc}
+        style={{
+          width: '100%',
+          height: 'calc(100vh - 200px)',
+          border: 'none',
+        }}
+        title="Grafana Dashboard"
+        aria-label="Embedded Grafana application overview dashboard"
+        sandbox="allow-scripts allow-same-origin allow-forms"
+        referrerPolicy="strict-origin-when-cross-origin"
+        loading="lazy"
+      />
+    ) : (
+      <Spin />
+    )}
+  </Card>
+</IframeErrorBoundary>
+
+

Lazy Loading Logic: +

useEffect(() => {
+  if (activeTab === 'monitoring' && !grafanaInitialized.current && status?.grafana.online) {
+    try {
+      const url = buildMonitoringUrl('grafana', 3005, '/d/application-overview');
+      setGrafanaIframeSrc(url);
+      grafanaInitialized.current = true;
+    } catch (error) {
+      console.error('Failed to construct Grafana URL:', error);
+    }
+  }
+}, [activeTab, status]);
+

+

Pattern: Iframe src set only when: +1. Monitoring tab selected +2. Not already initialized (ref tracks this) +3. Grafana is online

+

Alertmanager Iframe (Alerts Tab)

+
<IframeErrorBoundary serviceName="Alertmanager">
+  <Card styles={{ body: { padding: 0 } }}>
+    {alertmanagerIframeSrc ? (
+      <iframe
+        src={alertmanagerIframeSrc}
+        style={{
+          width: '100%',
+          height: 'calc(100vh - 200px)',
+          border: 'none',
+        }}
+        title="Alertmanager"
+        aria-label="Embedded Alertmanager alert management interface"
+        sandbox="allow-scripts allow-same-origin allow-forms"
+        referrerPolicy="strict-origin-when-cross-origin"
+        loading="lazy"
+      />
+    ) : (
+      <Spin />
+    )}
+  </Card>
+</IframeErrorBoundary>
+
+

Same lazy loading pattern as Grafana.

+
+

State Management

+

Local State

+

Data State: +

const [status, setStatus] = useState<ObservabilityStatus | null>(null);
+const [metrics, setMetrics] = useState<MetricsSummary | null>(null);
+const [alerts, setAlerts] = useState<AlertsResponse | null>(null);
+const [loading, setLoading] = useState(true);
+

+

UI State: +

const [activeTab, setActiveTab] = useState<TabKey>('overview');
+const [grafanaIframeSrc, setGrafanaIframeSrc] = useState<string | null>(null);
+const [alertmanagerIframeSrc, setAlertmanagerIframeSrc] = useState<string | null>(null);
+const grafanaInitialized = useRef(false);
+const alertmanagerInitialized = useRef(false);
+

+

Data Fetching

+

Fetch Status: +

const fetchStatus = useCallback(async () => {
+  try {
+    const res = await api.get<ObservabilityStatus>('/observability/status');
+    setStatus(res.data);
+  } catch {
+    // Status fetch failed — leave null
+  }
+}, []);
+

+

Fetch Metrics: +

const fetchMetrics = useCallback(async () => {
+  try {
+    const res = await api.get<MetricsSummary>('/observability/metrics-summary');
+    setMetrics(res.data);
+  } catch {
+    // Metrics fetch may fail if Prometheus is offline
+  }
+}, []);
+

+

Fetch Alerts: +

const fetchAlerts = useCallback(async () => {
+  try {
+    const res = await api.get<AlertsResponse>('/observability/alerts');
+    setAlerts(res.data);
+  } catch {
+    // Alerts fetch may fail if Alertmanager is offline
+  }
+}, []);
+

+

Fetch All (Parallel): +

const fetchAll = useCallback(async () => {
+  setLoading(true);
+  await Promise.all([fetchStatus(), fetchMetrics(), fetchAlerts()]);
+  setLoading(false);
+}, [fetchStatus, fetchMetrics, fetchAlerts]);
+

+

Benefit: Parallel API calls load faster than sequential.

+

Lazy Iframe Loading

+
useEffect(() => {
+  if (activeTab === 'monitoring' && !grafanaInitialized.current && status?.grafana.online) {
+    try {
+      const url = buildMonitoringUrl('grafana', 3005, '/d/application-overview');
+      setGrafanaIframeSrc(url);
+      grafanaInitialized.current = true;
+    } catch (error) {
+      console.error('Failed to construct Grafana URL:', error);
+    }
+  }
+}, [activeTab, status]);
+
+

Why Lazy Loading? +- Avoids loading heavy iframes until needed +- Improves initial page load performance +- Saves bandwidth if user never clicks Monitoring/Alerts tabs

+

Why useRef? +- Tracks initialization state without triggering re-renders +- Prevents redundant iframe loads on subsequent tab switches

+
+

API Integration

+

Endpoints Used

+

GET /observability/status - Fetch service online/offline status +

const { data } = await api.get<ObservabilityStatus>('/observability/status');
+

+

Response: +

{
+  "prometheus": {
+    "online": true,
+    "url": "http://localhost:9090"
+  },
+  "grafana": {
+    "online": true,
+    "url": "http://localhost:3001"
+  },
+  "alertmanager": {
+    "online": true,
+    "url": "http://localhost:9093"
+  },
+  "cadvisor": {
+    "online": true,
+    "url": "http://localhost:8080"
+  },
+  "nodeExporter": {
+    "online": true,
+    "url": "http://localhost:9100"
+  },
+  "redisExporter": {
+    "online": true,
+    "url": "http://localhost:9121"
+  },
+  "gotify": {
+    "online": false,
+    "url": "http://localhost:8889"
+  }
+}
+

+

GET /observability/metrics-summary - Fetch key application metrics +

const { data } = await api.get<MetricsSummary>('/observability/metrics-summary');
+

+

Response: +

{
+  "apiUptime": 99.8,
+  "emailQueueSize": 42,
+  "activeCanvassSessions": 5,
+  "totalLocations": 12543,
+  "httpRequestsTotal": 156789,
+  "httpRequestDurationSeconds": 0.234
+}
+

+

GET /observability/alerts - Fetch active alerts +

const { data } = await api.get<AlertsResponse>('/observability/alerts');
+

+

Response: +

{
+  "alerts": [
+    {
+      "id": "alert_1",
+      "name": "HighMemoryUsage",
+      "severity": "warning",
+      "status": "firing",
+      "startTime": "2026-02-11T10:30:00Z",
+      "labels": {
+        "alertname": "HighMemoryUsage",
+        "instance": "api:4000",
+        "severity": "warning"
+      },
+      "annotations": {
+        "summary": "Memory usage above 80%",
+        "description": "API container using 85% memory"
+      }
+    }
+  ]
+}
+

+
+

Code Examples

+

Parallel API Calls

+
const fetchAll = useCallback(async () => {
+  setLoading(true);
+  await Promise.all([fetchStatus(), fetchMetrics(), fetchAlerts()]);
+  setLoading(false);
+}, [fetchStatus, fetchMetrics, fetchAlerts]);
+
+

Benefit: Loads all data simultaneously (faster than sequential).

+

Lazy Iframe Loading Pattern

+
const grafanaInitialized = useRef(false);
+
+useEffect(() => {
+  if (activeTab === 'monitoring' && !grafanaInitialized.current && status?.grafana.online) {
+    const url = buildMonitoringUrl('grafana', 3005, '/d/application-overview');
+    setGrafanaIframeSrc(url);
+    grafanaInitialized.current = true;
+  }
+}, [activeTab, status]);
+
+

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)

+

Services Online Count

+
const servicesOnline = status
+  ? Object.values(status).filter((s: ServiceStatus) => s.online).length
+  : 0;
+const allOffline = servicesOnline === 0;
+
+

Counts online services from status object values.

+

Conditional Rendering Based on Service Status

+
{allOffline && (
+  <Alert
+    message="Monitoring services are offline"
+    description={<>Start with: <code>docker compose --profile monitoring up -d</code></>}
+    type="warning"
+  />
+)}
+
+{!allOffline && <MetricsGrid metrics={metrics} loading={loading} />}
+{!allOffline && alerts && <AlertsTable alerts={alerts.alerts || []} loading={loading} />}
+
+

Pattern: Show banner if all offline, hide metrics/alerts if all offline.

+
+

Performance Considerations

+

Parallel API Calls

+

Three API calls made simultaneously instead of sequentially: +

await Promise.all([fetchStatus(), fetchMetrics(), fetchAlerts()]);
+

+

Benefit: Reduces total load time from ~300ms (100ms × 3) to ~100ms (max of 3 parallel requests).

+

Lazy Iframe Loading

+

Iframes only load when tab selected: +- Grafana iframe: activeTab === 'monitoring' +- Alertmanager iframe: activeTab === 'alerts'

+

Benefit: Saves bandwidth and reduces initial page load time. Heavy iframes (~1-2MB each) not loaded unless needed.

+

useRef for Initialization Tracking

+
const grafanaInitialized = useRef(false);
+
+

Why useRef instead of useState? +- Doesn't trigger re-renders when updated +- Persists across re-renders +- Perfect for tracking initialization state

+

Conditional Component Rendering

+
{!allOffline && <MetricsGrid metrics={metrics} loading={loading} />}
+
+

Avoids rendering heavy components when no services online (no data to show).

+
+

Responsive Design

+

Service Status Grid

+
<Row gutter={[16, 16]}>
+  <Col xs={24} sm={12} lg={6}>
+    <ServiceStatusCard ... />
+  </Col>
+  {/* 6 more cards... */}
+</Row>
+
+

Responsive Breakpoints: +- Desktop (lg, ≥ 992px): 4 columns (6/24 each) +- Tablet (sm, ≥ 576px): 2 columns (12/24 each) +- Mobile (xs, < 576px): 1 column (24/24 each)

+

Iframe Height

+
<iframe style={{ height: 'calc(100vh - 200px)' }} />
+
+

Dynamic height: Fills viewport minus header/footer (responsive to window resize).

+
+

Accessibility

+

Iframe Labels

+
<iframe
+  title="Grafana Dashboard"
+  aria-label="Embedded Grafana application overview dashboard"
+/>
+
+

Screen reader support: Clear description of iframe content.

+

Button Labels

+
<Button icon={<ReloadOutlined />}>Refresh</Button>
+<Button icon={<LinkOutlined />}>Open Grafana</Button>
+
+

Not icon-only buttons – text labels for clarity.

+

Service Status Badges

+
<Badge status="success" text="Online" />
+<Badge status="error" text="Offline" />
+
+

Color + text: Not relying on color alone for status indication.

+
+

Troubleshooting

+

All Services Offline

+

Symptoms: +- Warning banner at top +- All service status cards show red "Offline" +- No metrics or alerts displayed

+

Cause: Monitoring services not started (Docker Compose profile monitoring not active)

+

Solution: +

# Start monitoring services
+docker compose --profile monitoring up -d
+
+# Verify services running
+docker compose ps | grep -E "(prometheus|grafana|alertmanager)"
+
+# Check logs if services fail to start
+docker compose logs prometheus grafana alertmanager
+

+

Grafana/Alertmanager Iframe Not Loading

+

Symptoms: +- Blank iframe or loading spinner forever +- Console errors about iframe src

+

Causes: +1. Service offline (check Overview tab status) +2. CORS policy blocking iframe +3. Network error

+

Debug: +

# Check Grafana container
+docker compose logs grafana
+
+# Test Grafana directly
+curl http://localhost:3001
+
+# Check nginx proxy (if using)
+docker compose logs nginx | grep grafana
+

+

Metrics Not Showing

+

Symptoms: +- MetricsGrid empty or shows zeros +- "Failed to load metrics" error

+

Cause: Prometheus offline or not scraping metrics

+

Solutions: +

# Check Prometheus status
+curl http://localhost:9090/-/healthy
+
+# Check Prometheus targets (should show API as "up")
+curl http://localhost:9090/api/v1/targets
+
+# Verify API is exposing /metrics endpoint
+curl http://localhost:4000/metrics
+

+

Alerts Not Showing

+

Symptoms: +- AlertsTable empty +- No alerts firing (but should be)

+

Causes: +1. Alertmanager offline +2. No alerts configured in Prometheus +3. Alerts resolved (not firing)

+

Debug: +

# Check Alertmanager status
+curl http://localhost:9093/-/healthy
+
+# Check Prometheus alerts
+curl http://localhost:9090/api/v1/alerts
+
+# Check alert rules config
+docker compose exec api cat /app/configs/prometheus/alerts.yml
+

+

"Open Grafana" Button Not Visible

+

Cause: Grafana offline

+

Expected Behavior: +

{status?.grafana.online && (
+  <Button href={status.grafana.url} target="_blank">
+    Open Grafana
+  </Button>
+)}
+

+

Button only shows when Grafana online.

+
+ +

Backend Integration

+ +

Features

+ +

Deployment

+ +

Troubleshooting

+ +

User Guides

+ +

External Resources

+ +

Frontend Components

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/page-editor-page/index.html b/mkdocs/site/v2/frontend/pages/admin/page-editor-page/index.html new file mode 100644 index 00000000..8861f76e --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/page-editor-page/index.html @@ -0,0 +1,8184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page Editor - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

PageEditorPage

+

Overview

+

File: admin/src/pages/PageEditorPage.tsx

+

Route: /app/pages/:id/edit

+

Role Requirements: Any authenticated user (uses authenticate middleware)

+

Purpose: Provides a full-screen dual-mode editor for landing pages with visual WYSIWYG editing (GrapesJS) and raw HTML code editing (Monaco Editor). This page is the primary interface for creating and editing landing pages, supporting both drag-and-drop visual design for non-technical users and direct HTML/CSS editing for developers. The editor operates in full-screen mode without the AppLayout wrapper to maximize editing space.

+

Key Features: +- Dual editor modes (Visual/Code) toggled per page +- Full-screen editing interface (no AppLayout) +- GrapesJS visual editor with custom blocks +- Monaco Editor for raw HTML editing +- Real-time save with Ctrl+S keyboard shortcut +- Live preview for published pages +- Publish/unpublish toggle +- Mobile device detection with warning screen +- Auto-save on Ctrl+S in code mode +- Editor state managed via useRef for performance

+

Layout: Full-screen (no AppLayout wrapper)

+

Dependencies: +- Ant Design v5 (Button, Switch, Space, Typography, Tag, Spin, Grid, Result) +- Monaco Editor (@monaco-editor/react) +- GrapesJS (via GrapesJSEditor component wrapper) +- react-router-dom (useParams, useNavigate)

+
+

Features

+

1. Dual Editor Modes

+

Visual Mode (GrapesJS): +- Drag-and-drop interface +- Custom block library (loaded from API) +- Component tree navigation +- Style manager (CSS properties) +- Trait manager (component attributes) +- Asset manager (images, files) +- Canvas preview (desktop/tablet/mobile) +- Undo/redo +- Full-screen toggle +- Export HTML/CSS

+

Code Mode (Monaco Editor): +- Syntax highlighting for HTML +- Line numbers +- Word wrap enabled +- Auto-formatting +- Dark theme +- Minimap disabled for cleaner view +- Automatic layout adjustment +- Ctrl+S keyboard shortcut for save +- Direct HTML editing (no CSS/JS extraction)

+

Mode Selection: +- Set when creating page in LandingPagesPage +- editorMode field: "VISUAL" or "CODE" +- Cannot switch modes within editor (navigate back to pages list to change) +- Mode displayed as colored tag in toolbar

+

2. Toolbar Controls

+

Left Section: +- Back Button - Navigate to pages list +- Page Title - Current page name +- Slug Display - Public URL preview (/p/:slug) +- Mode Tag - Visual (green) or Code (blue)

+

Right Section: +- Published Toggle - Switch to enable/disable public access +- Live Tag - Visible when published +- Preview Button - Opens public page in new tab (only when published) +- Save Button - Manual save trigger (primary action)

+

3. Auto-Save & Keyboard Shortcuts

+

Code Mode Shortcuts: +- Ctrl+S / Cmd+S - Save page (prevents browser default) +- Keyboard event handler registered on mount +- Handler cleaned up on unmount

+

Visual Mode Save: +- Save button triggers editorRef.current?.triggerSave() +- GrapesJS editor handles internal save via forwardRef

+

4. Mobile Device Detection

+

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

+

5. Loading & Error States

+

Loading State: +- Full-screen centered spinner +- Displayed while fetching page data + blocks +- Minimum height: 100vh

+

Error Handling: +- Failed fetch shows error message +- Auto-navigates back to pages list +- Prevents editor render on missing page

+
+

User Workflow

+

Opening a Page for Editing

+
    +
  1. Navigate to Pages List:
  2. +
  3. Go to /app/pages (LandingPagesPage)
  4. +
  5. +

    View table of all landing pages

    +
  6. +
  7. +

    Select Page to Edit:

    +
  8. +
  9. Click "Edit" button in page row
  10. +
  11. Opens editor in full-screen mode
  12. +
  13. +

    URL changes to /app/pages/:id/edit

    +
  14. +
  15. +

    Wait for Editor Load:

    +
  16. +
  17. Loading spinner appears
  18. +
  19. Page data fetched from API
  20. +
  21. Visual mode: block library also loaded
  22. +
  23. Editor renders based on mode
  24. +
+

Editing in Visual Mode

+
    +
  1. Use GrapesJS Interface:
  2. +
  3. Add Components: Drag blocks from left sidebar onto canvas
  4. +
  5. Move Components: Click and drag to reposition
  6. +
  7. Edit Text: Double-click text to edit inline
  8. +
  9. Style Components: Select component, use Style Manager in right panel
  10. +
  11. Change Attributes: Use Trait Manager for component properties
  12. +
  13. +

    Upload Images: Use Asset Manager to add media

    +
  14. +
  15. +

    Canvas Controls:

    +
  16. +
  17. Toggle device preview (desktop/tablet/mobile)
  18. +
  19. Toggle fullscreen mode
  20. +
  21. +

    Toggle borders/padding visualization

    +
  22. +
  23. +

    Save Changes:

    +
  24. +
  25. Click "Save" button in toolbar (or Ctrl+S)
  26. +
  27. Editor extracts:
      +
    • Project data (component tree JSON)
    • +
    • Rendered HTML output
    • +
    • Compiled CSS styles
    • +
    +
  28. +
  29. All three sent to API via PUT request
  30. +
  31. Success message: "Page saved"
  32. +
+

Editing in Code Mode

+
    +
  1. Edit HTML Directly:
  2. +
  3. Monaco editor displays current HTML output
  4. +
  5. Edit HTML structure, inline styles, content
  6. +
  7. +

    Syntax highlighting for HTML tags

    +
  8. +
  9. +

    Save Changes:

    +
  10. +
  11. Press Ctrl+S (or Cmd+S on Mac)
  12. +
  13. Or click "Save" button in toolbar
  14. +
  15. Raw HTML content sent to API
  16. +
  17. +

    Success message: "Page saved"

    +
  18. +
  19. +

    Limitations:

    +
  20. +
  21. No visual preview within editor
  22. +
  23. Must publish and use Preview button to see changes
  24. +
  25. Changes don't update GrapesJS project data (one-way sync)
  26. +
+

Publishing a Page

+
    +
  1. Toggle Published Switch:
  2. +
  3. Switch in top-right toolbar
  4. +
  5. Green = published, Gray = unpublished
  6. +
  7. +

    API updates published field immediately

    +
  8. +
  9. +

    When Published:

    +
  10. +
  11. "Live" tag appears next to switch
  12. +
  13. "Preview" button becomes visible
  14. +
  15. +

    Page accessible at /p/:slug URL

    +
  16. +
  17. +

    Preview Published Page:

    +
  18. +
  19. Click "Preview" button (eye icon)
  20. +
  21. Opens new browser tab to /p/:slug
  22. +
  23. Shows rendered page as public users see it
  24. +
+
+

Component Breakdown

+

Main Component Structure

+
export default function PageEditorPage() {
+  const { id } = useParams<{ id: string }>();
+  const navigate = useNavigate();
+  const screens = Grid.useBreakpoint();
+  const isMobile = !screens.md;
+  const { token } = theme.useToken();
+
+  // State
+  const [page, setPage] = useState<LandingPage | null>(null);
+  const [blocks, setBlocks] = useState<PageBlock[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [saving, setSaving] = useState(false);
+  const [codeContent, setCodeContent] = useState('');
+  const editorRef = useRef<GrapesJSEditorHandle>(null);
+
+  // Derived state
+  const isCodeMode = page?.editorMode === 'CODE';
+
+  // Fetch page + blocks (Visual mode only)
+  useEffect(() => {
+    const fetchData = async () => {
+      try {
+        if (isCodeMode) {
+          const pageRes = await api.get<LandingPage>(`/pages/${id}`);
+          setPage(pageRes.data);
+          setCodeContent(pageRes.data.htmlOutput || '');
+        } else {
+          const [pageRes, blocksRes] = await Promise.all([
+            api.get<LandingPage>(`/pages/${id}`),
+            api.get<PageBlock[]>('/page-blocks'),
+          ]);
+          setPage(pageRes.data);
+          setBlocks(blocksRes.data);
+          setCodeContent(pageRes.data.htmlOutput || '');
+        }
+      } catch {
+        message.error('Failed to load page');
+        navigate('/app/pages');
+      } finally {
+        setLoading(false);
+      }
+    };
+    fetchData();
+  }, [id]);
+
+  // Ctrl+S keyboard shortcut (code mode only)
+  useEffect(() => {
+    if (!isCodeMode) return;
+    const handler = (e: KeyboardEvent) => {
+      if ((e.ctrlKey || e.metaKey) && e.key === 's') {
+        e.preventDefault();
+        handleSaveCode();
+      }
+    };
+    window.addEventListener('keydown', handler);
+    return () => window.removeEventListener('keydown', handler);
+  }, [isCodeMode, handleSaveCode]);
+
+  // Conditional render based on state
+  if (loading) return <Spin />;
+  if (!page) return null;
+  if (isMobile) return <MobileWarning />;
+
+  return (
+    <div style={{ height: '100vh' }}>
+      <Toolbar />
+      {isCodeMode ? <MonacoEditor /> : <GrapesJSEditor />}
+    </div>
+  );
+}
+
+

Toolbar Component

+

Structure: +- Full-width sticky header +- Dark background (colorBgBase) +- Border bottom separator +- Two-column layout (Space components)

+

Left Section: +

<Space>
+  <Button type="text" icon={<ArrowLeftOutlined />} onClick={goBack} />
+  <Text strong>{page.title}</Text>
+  <Text>/p/{page.slug}</Text>
+  <Tag color={isCodeMode ? 'blue' : 'green'}>
+    {isCodeMode ? 'Code' : 'Visual'}
+  </Tag>
+</Space>
+

+

Right Section: +

<Space>
+  <Space size={4}>
+    <Text>Published</Text>
+    <Switch checked={page.published} onChange={handleTogglePublished} />
+  </Space>
+  {page.published && <Tag color="green">Live</Tag>}
+  {page.published && (
+    <Button icon={<EyeOutlined />} onClick={() => window.open(`/p/${page.slug}`)}>
+      Preview
+    </Button>
+  )}
+  <Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
+    Save
+  </Button>
+</Space>
+

+

GrapesJS Editor Integration

+

Component: +

<GrapesJSEditor
+  ref={editorRef}
+  initialData={page.blocks as Record<string, unknown>}
+  onSave={handleSaveVisual}
+  customBlocks={blocks}
+/>
+

+

Save Callback: +

const handleSaveVisual = useCallback(async (data: {
+  projectData: Record<string, unknown>;
+  html: string;
+  css: string;
+}) => {
+  setSaving(true);
+  try {
+    const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
+      blocks: data.projectData,
+      htmlOutput: data.html,
+      cssOutput: data.css,
+    });
+    setPage(updated);
+    message.success('Page saved');
+  } catch {
+    message.error('Failed to save page');
+  } finally {
+    setSaving(false);
+  }
+}, [page]);
+

+

Trigger Save (from parent): +

editorRef.current?.triggerSave();
+

+

Monaco Editor Integration

+

Component: +

<Editor
+  height="100%"
+  defaultLanguage="html"
+  theme="vs-dark"
+  value={codeContent}
+  onChange={(value) => setCodeContent(value ?? '')}
+  options={{
+    wordWrap: 'on',
+    minimap: { enabled: false },
+    fontSize: 14,
+    scrollBeyondLastLine: false,
+    automaticLayout: true,
+  }}
+/>
+

+

Save Handler: +

const handleSaveCode = useCallback(async () => {
+  setSaving(true);
+  try {
+    const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
+      htmlOutput: codeContent,
+    });
+    setPage(updated);
+    message.success('Page saved');
+  } catch {
+    message.error('Failed to save page');
+  } finally {
+    setSaving(false);
+  }
+}, [page, codeContent]);
+

+

Mobile Warning Component

+

Conditional Render: +

if (isMobile) {
+  return (
+    <div style={{
+      display: 'flex',
+      flexDirection: 'column',
+      alignItems: 'center',
+      justifyContent: 'center',
+      height: '100vh',
+      padding: 24,
+      background: token.colorBgBase,
+    }}>
+      <Result
+        status="info"
+        title="Desktop Required"
+        subTitle={`The ${isCodeMode ? 'code' : 'page'} editor requires a desktop browser. Please switch to a larger screen to edit this page.`}
+        extra={
+          <Button type="primary" onClick={() => navigate('/app/pages')}>
+            Back to Pages
+          </Button>
+        }
+      />
+    </div>
+  );
+}
+

+

Breakpoint Detection: +- Uses Grid.useBreakpoint() hook +- isMobile = !screens.md (screen width < 768px) +- Early return prevents editor initialization

+
+

State Management

+

Local Component State (useState)

+
// Page data
+const [page, setPage] = useState<LandingPage | null>(null);
+
+// Block library (Visual mode only)
+const [blocks, setBlocks] = useState<PageBlock[]>([]);
+
+// Loading state
+const [loading, setLoading] = useState(true);
+
+// Save in progress state
+const [saving, setSaving] = useState(false);
+
+// Monaco editor content (Code mode)
+const [codeContent, setCodeContent] = useState('');
+
+

Refs (useRef)

+
// GrapesJS editor handle
+const editorRef = useRef<GrapesJSEditorHandle>(null);
+
+

Why useRef? +- GrapesJS editor controlled externally +- Parent triggers save via editorRef.current?.triggerSave() +- No re-renders when editor state changes +- Performance optimization for large canvas

+

Derived State

+
// Computed from page data
+const isCodeMode = page?.editorMode === 'CODE';
+
+// Responsive breakpoint
+const screens = Grid.useBreakpoint();
+const isMobile = !screens.md;
+
+// Theme tokens
+const { token } = theme.useToken();
+
+

State Flow

+
    +
  1. Component Mounts:
  2. +
  3. loading set to true
  4. +
  5. useEffect triggers fetch based on mode
  6. +
  7. Visual mode: parallel fetch page + blocks
  8. +
  9. Code mode: fetch page only
  10. +
  11. Sets page, blocks, codeContent
  12. +
  13. +

    Sets loading to false

    +
  14. +
  15. +

    User Edits Content:

    +
  16. +
  17. Visual mode: GrapesJS manages internal state
  18. +
  19. +

    Code mode: Monaco onChange updates codeContent

    +
  20. +
  21. +

    User Saves:

    +
  22. +
  23. Visual mode: editorRef.current?.triggerSave()handleSaveVisual callback
  24. +
  25. Code mode: handleSaveCode directly
  26. +
  27. Sets saving to true
  28. +
  29. API PUT request
  30. +
  31. Updates page with response
  32. +
  33. +

    Sets saving to false

    +
  34. +
  35. +

    User Toggles Published:

    +
  36. +
  37. API PUT request with published field
  38. +
  39. Updates page with response
  40. +
  41. UI updates (Live tag, Preview button)
  42. +
+
+

API Integration

+

Endpoints Used

+
    +
  1. GET /api/pages/:id - Fetch page data
  2. +
  3. GET /api/page-blocks - Fetch custom block library (Visual mode only)
  4. +
  5. PUT /api/pages/:id - Update page (save, publish)
  6. +
+

API Calls

+

1. Fetch Page + Blocks (Visual Mode)

+
const [pageRes, blocksRes] = await Promise.all([
+  api.get<LandingPage>(`/pages/${id}`),
+  api.get<PageBlock[]>('/page-blocks'),
+]);
+setPage(pageRes.data);
+setBlocks(blocksRes.data);
+setCodeContent(pageRes.data.htmlOutput || '');
+
+

LandingPage Response: +

{
+  "id": "123e4567-e89b-12d3-a456-426614174000",
+  "title": "Campaign Launch",
+  "slug": "campaign-launch",
+  "editorMode": "VISUAL",
+  "blocks": {
+    "pages": [...],
+    "styles": [...],
+    "components": [...]
+  },
+  "htmlOutput": "<html>...</html>",
+  "cssOutput": ".container { ... }",
+  "published": false,
+  "createdAt": "2025-02-10T12:00:00Z",
+  "updatedAt": "2025-02-10T14:30:00Z"
+}
+

+

PageBlock Response: +

[
+  {
+    "id": "block-hero",
+    "label": "Hero Section",
+    "category": "sections",
+    "content": "<div class='hero'>...</div>",
+    "media": "<svg>...</svg>",
+    "attributes": { "class": "gjs-block" }
+  },
+  ...
+]
+

+

2. Fetch Page Only (Code Mode)

+
const pageRes = await api.get<LandingPage>(`/pages/${id}`);
+setPage(pageRes.data);
+setCodeContent(pageRes.data.htmlOutput || '');
+
+

3. Save Visual Mode Changes

+
const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
+  blocks: data.projectData,
+  htmlOutput: data.html,
+  cssOutput: data.css,
+});
+setPage(updated);
+
+

Request Body: +

{
+  "blocks": {
+    "pages": [...],
+    "styles": [...],
+    "components": [...]
+  },
+  "htmlOutput": "<html>...</html>",
+  "cssOutput": ".container { ... }"
+}
+

+

4. Save Code Mode Changes

+
const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
+  htmlOutput: codeContent,
+});
+setPage(updated);
+
+

Request Body: +

{
+  "htmlOutput": "<!DOCTYPE html>\n<html>...</html>"
+}
+

+

5. Toggle Published

+
const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
+  published: !page.published,
+});
+setPage(updated);
+message.success(updated.published ? 'Page published' : 'Page unpublished');
+
+

Request Body: +

{
+  "published": true
+}
+

+
+

Code Examples

+

Complete Save Visual Mode Flow

+
const handleSaveVisual = useCallback(async (data: {
+  projectData: Record<string, unknown>;
+  html: string;
+  css: string;
+}) => {
+  if (!page) return;
+  setSaving(true);
+  try {
+    const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
+      blocks: data.projectData,
+      htmlOutput: data.html,
+      cssOutput: data.css,
+    });
+    setPage(updated);
+    message.success('Page saved');
+  } catch {
+    message.error('Failed to save page');
+  } finally {
+    setSaving(false);
+  }
+}, [page]);
+
+

Keyboard Shortcut Handler

+
useEffect(() => {
+  if (!isCodeMode) return;  // Only in code mode
+
+  const handler = (e: KeyboardEvent) => {
+    if ((e.ctrlKey || e.metaKey) && e.key === 's') {
+      e.preventDefault();  // Prevent browser save dialog
+      handleSaveCode();
+    }
+  };
+
+  window.addEventListener('keydown', handler);
+  return () => window.removeEventListener('keydown', handler);  // Cleanup
+}, [isCodeMode, handleSaveCode]);
+
+

Conditional API Fetch Pattern

+
useEffect(() => {
+  const fetchData = async () => {
+    try {
+      if (isCodeMode) {
+        // Code mode: page only
+        const pageRes = await api.get<LandingPage>(`/pages/${id}`);
+        setPage(pageRes.data);
+        setCodeContent(pageRes.data.htmlOutput || '');
+      } else {
+        // Visual mode: page + blocks in parallel
+        const [pageRes, blocksRes] = await Promise.all([
+          api.get<LandingPage>(`/pages/${id}`),
+          api.get<PageBlock[]>('/page-blocks'),
+        ]);
+        setPage(pageRes.data);
+        setBlocks(blocksRes.data);
+        setCodeContent(pageRes.data.htmlOutput || '');
+      }
+    } catch {
+      message.error('Failed to load page');
+      navigate('/app/pages');
+    } finally {
+      setLoading(false);
+    }
+  };
+  fetchData();
+}, [id]); // Only re-fetch if page ID changes
+
+

Editor Ref Save Trigger

+
// Parent component
+<Button
+  type="primary"
+  icon={<SaveOutlined />}
+  loading={saving}
+  onClick={() => {
+    if (isCodeMode) {
+      handleSaveCode();
+    } else {
+      editorRef.current?.triggerSave();  // Trigger GrapesJS save
+    }
+  }}
+>
+  Save
+</Button>
+
+// GrapesJSEditor component
+<GrapesJSEditor
+  ref={editorRef}
+  initialData={page.blocks}
+  onSave={handleSaveVisual}  // Callback receives extracted data
+  customBlocks={blocks}
+/>
+
+
+

Performance Considerations

+

1. Parallel API Requests (Visual Mode)

+
const [pageRes, blocksRes] = await Promise.all([
+  api.get<LandingPage>(`/pages/${id}`),
+  api.get<PageBlock[]>('/page-blocks'),
+]);
+
+

Benefit: Reduces loading time by ~50% (2 sequential requests → 1 parallel batch).

+

2. Conditional Block Loading

+
if (isCodeMode) {
+  // Skip blocks fetch in code mode
+  const pageRes = await api.get<LandingPage>(`/pages/${id}`);
+} else {
+  // Load blocks only for visual mode
+  const [pageRes, blocksRes] = await Promise.all([...]);
+}
+
+

Benefit: Saves unnecessary API call in code mode (blocks not used).

+

3. useRef for Editor Handle

+
const editorRef = useRef<GrapesJSEditorHandle>(null);
+
+

Why useRef? +- GrapesJS editor has large internal state (component tree, styles, assets) +- useRef prevents re-renders when editor state changes +- Parent only needs to trigger save, not track editor state +- Performance critical for large page designs

+

4. useCallback for Save Handlers

+
const handleSaveVisual = useCallback(async (data) => {
+  // ...
+}, [page]);  // Only recreate when page changes
+
+const handleSaveCode = useCallback(async () => {
+  // ...
+}, [page, codeContent]);  // Only recreate when deps change
+
+

Benefit: Prevents unnecessary function recreation on every render.

+

5. Early Mobile Detection

+
if (isMobile) {
+  return <MobileWarning />;  // No editor initialization
+}
+
+

Benefit: Skips heavy editor initialization on mobile devices (saves memory + CPU).

+

6. Automatic Monaco Layout

+
<Editor
+  options={{
+    automaticLayout: true,  // Auto-adjust on window resize
+    minimap: { enabled: false },  // Disable minimap to save CPU
+    scrollBeyondLastLine: false,  // Reduce DOM size
+  }}
+/>
+
+

Benefit: Reduces Monaco memory footprint by disabling minimap (can use 100MB+ on large files).

+
+

Responsive Design

+

Mobile Detection

+

Breakpoint: +- Uses Grid.useBreakpoint() hook +- Mobile if !screens.md (screen width < 768px)

+

Mobile Warning Screen: +

if (isMobile) {
+  return (
+    <div style={{
+      display: 'flex',
+      flexDirection: 'column',
+      alignItems: 'center',
+      justifyContent: 'center',
+      height: '100vh',
+      padding: 24,
+      background: token.colorBgBase,
+    }}>
+      <Result
+        status="info"
+        title="Desktop Required"
+        subTitle={`The ${isCodeMode ? 'code' : 'page'} editor requires a desktop browser...`}
+        extra={
+          <Button type="primary" onClick={() => navigate('/app/pages')}>
+            Back to Pages
+          </Button>
+        }
+      />
+    </div>
+  );
+}
+

+

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

+

Full-Screen Layout

+

No AppLayout Wrapper: +- Page routed outside AppLayout component +- Uses full viewport height (100vh) +- No sidebar navigation +- Maximizes editing canvas space

+

Layout Structure: +

<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
+  <Toolbar />  {/* Fixed header */}
+  <Editor />   {/* Flex-grow to fill remaining space */}
+</div>
+

+
+

Accessibility

+

Keyboard Navigation

+
    +
  1. Tab Key:
  2. +
  3. Cycles through toolbar buttons (Back, Save, Preview, Toggle)
  4. +
  5. +

    Enters editor focus (Monaco or GrapesJS canvas)

    +
  6. +
  7. +

    Ctrl+S / Cmd+S:

    +
  8. +
  9. Save shortcut (code mode only)
  10. +
  11. +

    Prevents browser default "Save Page As" dialog

    +
  12. +
  13. +

    GrapesJS Keyboard Shortcuts:

    +
  14. +
  15. Ctrl+Z: Undo
  16. +
  17. Ctrl+Shift+Z: Redo
  18. +
  19. Delete: Remove selected component
  20. +
  21. +

    Ctrl+C/V: Copy/paste components

    +
  22. +
  23. +

    Monaco Editor Shortcuts:

    +
  24. +
  25. Ctrl+S: Save (custom handler)
  26. +
  27. Ctrl+F: Find
  28. +
  29. Ctrl+H: Find and replace
  30. +
  31. Ctrl+/: Toggle comment
  32. +
  33. Alt+Up/Down: Move line up/down
  34. +
+

ARIA Labels

+
<Button
+  type="text"
+  icon={<ArrowLeftOutlined />}
+  onClick={() => navigate('/app/pages')}
+  aria-label="Back to pages list"
+/>
+
+

Screen Reader Announcements: +- Button labels announced via aria-label +- Switch state announced ("Published" / "Unpublished") +- Tag colors announced by screen readers

+

Focus Management

+

Toolbar Focus Order: +1. Back button +2. Published switch +3. Preview button (if visible) +4. Save button

+

Editor Focus: +- Monaco: automatic focus management via Monaco API +- GrapesJS: focus enters canvas on click

+
+

Troubleshooting

+

Problem: Editor Not Loading

+

Symptoms: +- Blank screen after loading spinner disappears +- Console errors related to GrapesJS or Monaco

+

Solutions:

+
    +
  1. Check page data in API response: +
    curl -H "Authorization: Bearer <token>" \
    +  http://localhost:4000/api/pages/<page-id>
    +
  2. +
  3. +

    Verify blocks, htmlOutput, editorMode fields exist

    +
  4. +
  5. +

    Check browser console:

    +
  6. +
  7. Open DevTools Console (F12)
  8. +
  9. Look for JavaScript errors
  10. +
  11. +

    Common errors:

    +
      +
    • "Cannot read property 'pages' of undefined" → blocks field missing/corrupt
    • +
    • "Monaco Editor failed to load" → CDN blocked or slow network
    • +
    +
  12. +
  13. +

    Clear browser cache:

    +
  14. +
  15. Monaco and GrapesJS cache resources
  16. +
  17. +

    Ctrl+Shift+R (hard refresh)

    +
  18. +
  19. +

    Check network tab:

    +
  20. +
  21. Verify API requests complete successfully
  22. +
  23. Verify block library loads (Visual mode)
  24. +
+
+

Problem: Save Button Not Working

+

Symptoms: +- Click Save button, no success message +- Loading spinner appears but never completes +- Console shows 400/500 errors

+

Solutions:

+
    +
  1. Check API request in Network tab:
  2. +
  3. Look for PUT /api/pages/:id request
  4. +
  5. Check request payload (should have htmlOutput, blocks, cssOutput)
  6. +
  7. +

    Check response status code

    +
  8. +
  9. +

    Visual Mode - Invalid blocks data:

    +
  10. +
  11. GrapesJS may generate invalid JSON
  12. +
  13. Check console for serialization errors
  14. +
  15. +

    Try creating new page instead of editing corrupt one

    +
  16. +
  17. +

    Code Mode - Invalid HTML:

    +
  18. +
  19. API may validate HTML structure
  20. +
  21. Check for missing closing tags
  22. +
  23. +

    Check for script injection attempts (blocked by CSP)

    +
  24. +
  25. +

    Network timeout:

    +
  26. +
  27. Large pages (>1MB HTML) may timeout
  28. +
  29. Increase Axios timeout in admin/src/lib/api.ts
  30. +
  31. Optimize HTML output (minify, remove unused CSS)
  32. +
+
+

Problem: Ctrl+S Not Saving (Code Mode)

+

Symptoms: +- Press Ctrl+S, nothing happens +- Browser "Save Page As" dialog appears instead

+

Solutions:

+
    +
  1. Check browser focus:
  2. +
  3. Ensure Monaco editor is focused (click inside editor)
  4. +
  5. +

    Keyboard handler requires window focus

    +
  6. +
  7. +

    Check browser extensions:

    +
  8. +
  9. Extensions may intercept Ctrl+S
  10. +
  11. Test in incognito mode
  12. +
  13. +

    Disable extensions one by one

    +
  14. +
  15. +

    Mac users: Use Cmd+S instead of Ctrl+S

    +
  16. +
  17. +

    Handler supports both e.ctrlKey and e.metaKey

    +
  18. +
  19. +

    Manual save as fallback:

    +
  20. +
  21. Click "Save" button in toolbar
  22. +
  23. Same effect as Ctrl+S
  24. +
+
+

Problem: Published Page Not Accessible

+

Symptoms: +- Toggle "Published" switch to ON +- Navigate to /p/:slug, get 404 error

+

Solutions:

+
    +
  1. Check slug uniqueness:
  2. +
  3. Slug must be unique across all pages
  4. +
  5. +

    Check for URL conflicts with existing routes

    +
  6. +
  7. +

    Check page published status: +

    curl -H "Authorization: Bearer <token>" \
    +  http://localhost:4000/api/pages/<page-id>
    +

    +
  8. +
  9. +

    Verify "published": true in response

    +
  10. +
  11. +

    Check public route registration:

    +
  12. +
  13. Open admin/src/App.tsx
  14. +
  15. +

    Verify public route exists: +

    <Route path="/p/:slug" element={<LandingPage />} />
    +

    +
  16. +
  17. +

    Check nginx routing:

    +
  18. +
  19. Public pages served through nginx
  20. +
  21. +

    Verify nginx reverse proxy configuration

    +
  22. +
  23. +

    Hard refresh public page:

    +
  24. +
  25. Ctrl+Shift+R to bypass cache
  26. +
  27. Browser may cache 404 response
  28. +
+
+

Problem: GrapesJS Not Loading Custom Blocks

+

Symptoms: +- Visual editor loads but block panel is empty +- Only default blocks visible (Text, Image, etc.)

+

Solutions:

+
    +
  1. Check blocks API response: +
    curl -H "Authorization: Bearer <token>" \
    +  http://localhost:4000/api/page-blocks
    +
  2. +
  3. +

    Should return array of blocks with label, content, media

    +
  4. +
  5. +

    Check blocks passed to GrapesJS:

    +
  6. +
  7. Add console.log in PageEditorPage: +
    console.log('Custom blocks:', blocks);
    +
  8. +
  9. +

    Verify array not empty

    +
  10. +
  11. +

    Check GrapesJS block registration:

    +
  12. +
  13. Open admin/src/components/GrapesJSEditor.tsx
  14. +
  15. +

    Verify blocks registered in editor.BlockManager.add()

    +
  16. +
  17. +

    Clear GrapesJS localStorage:

    +
  18. +
  19. GrapesJS caches project data
  20. +
  21. Open DevTools → Application → Local Storage
  22. +
  23. Delete keys starting with gjsProject-
  24. +
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/pangolin-page/index.html b/mkdocs/site/v2/frontend/pages/admin/pangolin-page/index.html new file mode 100644 index 00000000..9955b43e --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/pangolin-page/index.html @@ -0,0 +1,8082 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pangolin - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

PangolinPage

+

Overview

+

File: admin/src/pages/PangolinPage.tsx +Route: /app/tunnel +Role Requirements: SUPER_ADMIN

+

PangolinPage is the tunnel management interface for Changemaker Lite's Pangolin Integration, which provides secure tunneling and public access to the platform via the Newt container. The page provides a complete setup wizard for first-time configuration, resource management for subdomains and services, exit node selection for multi-node setups, and Newt container lifecycle controls.

+

The page displays three main sections: +1. Status Dashboard: Shows Pangolin API health, configuration state, and Newt container status +2. Setup Wizard: Guides admins through site creation, subnet allocation, and exit node selection +3. Resource Management: Table of tunnel resources with SSL, active status, port, protocol, and access controls

+

Key Components: +- Status card with Descriptions showing configuration and health +- Setup form with auto-suggested subnet calculation +- Exit node selector (optional for self-hosted setups) +- Resource table with edit modal and delete confirmation +- Newt container restart button +- Credential display with show/hide toggle

+
+

Screenshot

+

[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.]

+
+

Features

+

Core Features

+
    +
  1. Status Monitoring
  2. +
  3. Pangolin API configuration check (Configured: Yes/No)
  4. +
  5. Server health check (Healthy/Unreachable)
  6. +
  7. API URL display with copy button
  8. +
  9. Newt container status (Ready/Running/Stopped/Not configured)
  10. +
  11. Organization ID and Site ID display
  12. +
  13. +

    Auto-refresh on status change

    +
  14. +
  15. +

    Setup Wizard (First-Time Configuration)

    +
  16. +
  17. Site name input (defaults to changemaker-{domain})
  18. +
  19. Subnet allocation with auto-suggestion (calculates next available subnet)
  20. +
  21. Exit node selection (optional, only shown if exit nodes available)
  22. +
  23. Online/offline exit node status indicators
  24. +
  25. Auto-select single online exit node
  26. +
  27. Create Site + Resources button
  28. +
  29. +

    Post-setup credential display with show/hide toggle

    +
  30. +
  31. +

    Credential Management

    +
  32. +
  33. Shows PANGOLIN_SITE_ID and NEWT_* credentials after setup
  34. +
  35. Show/Hide credentials button (security)
  36. +
  37. Copy to Clipboard button
  38. +
  39. Clear Credentials button
  40. +
  41. Step-by-step instructions for .env setup
  42. +
  43. +

    Newt container restart button after credential update

    +
  44. +
  45. +

    Resource Management

    +
  46. +
  47. Table showing all tunnel resources (subdomains)
  48. +
  49. Columns: Name, Domain (copyable), SSL status, Active status, Port, Protocol, Blocked
  50. +
  51. Edit button opens modal for resource configuration
  52. +
  53. Delete button with Popconfirm
  54. +
  55. Sync Resources button (creates missing resources from docker-compose.yml)
  56. +
  57. +

    Restart Newt button in table header

    +
  58. +
  59. +

    Resource Editing

    +
  60. +
  61. Edit modal with form fields:
      +
    • Name (text input)
    • +
    • Protocol (e.g., http, https)
    • +
    • Proxy Port (number input)
    • +
    • Enable SSL (checkbox)
    • +
    • Active (checkbox)
    • +
    • Block Access (checkbox for maintenance mode)
    • +
    +
  62. +
  63. +

    Update button saves changes to Pangolin API

    +
  64. +
  65. +

    Exit Node Support

    +
  66. +
  67. Fetches available exit nodes from Pangolin API
  68. +
  69. Displays exit node name and location
  70. +
  71. Shows online/offline status
  72. +
  73. Filters out offline nodes (with user notice)
  74. +
  75. Auto-selects if only one online node available
  76. +
  77. +

    Graceful fallback if no exit nodes (self-hosted setups)

    +
  78. +
  79. +

    Newt Container Management

    +
  80. +
  81. Status monitoring (running/stopped/ready)
  82. +
  83. Restart button in Status card
  84. +
  85. Restart button in Resource table header
  86. +
  87. 3-second delay after restart before status check
  88. +
  89. +

    Success/error messages for restart operations

    +
  90. +
  91. +

    Subnet Auto-Suggestion

    +
  92. +
  93. Fetches existing sites from Pangolin API
  94. +
  95. Parses last subnet (e.g., 100.90.128.2/24)
  96. +
  97. Suggests next available subnet (increments last octet)
  98. +
  99. Defaults to 100.90.128.3/24 if no sites exist
  100. +
  101. +

    Allows manual override if suggested subnet conflicts

    +
  102. +
  103. +

    Security Features

    +
  104. +
  105. Credentials hidden by default
  106. +
  107. Show/Hide toggle for sensitive values
  108. +
  109. Clear button to remove credentials from screen
  110. +
  111. Text sanitization for external API data (defense-in-depth)
  112. +
+
+

User Workflow

+

Initial Setup (First-Time Configuration)

+
    +
  1. Navigate to page: Admin sidebar → System → Tunnel Management
  2. +
  3. Check status: If "Configured: No", see blue info alert
  4. +
  5. Configure .env: Add PANGOLIN_API_URL, PANGOLIN_API_KEY, PANGOLIN_ORG_ID to .env
  6. +
  7. Restart API: docker compose restart api
  8. +
  9. Reload page: Status card shows "Configured: Yes"
  10. +
  11. Fill setup form:
  12. +
  13. Site Name: changemaker-cmlite.org (or custom)
  14. +
  15. Subnet: 100.90.128.3/24 (auto-suggested, or override)
  16. +
  17. Exit Node: Select if available (or leave empty for self-hosted)
  18. +
  19. Click "Create Site + Resources"
  20. +
  21. Wait for setup: Loading spinner, ~5-10 seconds
  22. +
  23. View credentials: Success alert shows PANGOLIN_SITE_ID and NEWT_* values
  24. +
  25. Show credentials: Click "Show Credentials" button
  26. +
  27. Copy to .env: Click "Copy to Clipboard" button, paste into .env file
  28. +
  29. Clear credentials: After copying, click "Clear Credentials" (security)
  30. +
  31. Update .env: Save .env file with new values
  32. +
  33. Restart Newt: Click "Restart Newt Container" button
  34. +
  35. Verify status: Newt status changes to "Ready" (green tag)
  36. +
+

Viewing Status

+
    +
  1. Open page: Navigate to /app/tunnel
  2. +
  3. Check configuration: Status card shows Configured/Healthy/Newt Container status
  4. +
  5. Verify API URL: Copy URL if needed for external tools
  6. +
  7. Check Org/Site IDs: Verify correct organization and site selected
  8. +
  9. Monitor Newt: Check if container is Ready (green) or Stopped (red)
  10. +
+

Managing Resources

+
    +
  1. View resources: Scroll to "Tunnel Resources" card
  2. +
  3. Check domains: Each row shows subdomain (e.g., api.cmlite.org)
  4. +
  5. Verify SSL: Green "Yes" tag indicates SSL enabled
  6. +
  7. Check active status: Green "Active" tag = resource enabled
  8. +
  9. Review ports: Verify proxy port matches docker-compose.yml
  10. +
  11. Edit resource: Click Edit button for a resource
  12. +
  13. Modify settings: Change name, protocol, port, SSL, active, or block access
  14. +
  15. Save changes: Click Update button in modal
  16. +
  17. Verify update: Table refreshes with new values
  18. +
+

Syncing Resources

+
    +
  1. Add new service: Update docker-compose.yml with new subdomain
  2. +
  3. Click "Sync Resources": Button in table header
  4. +
  5. Wait for sync: Loading state, ~2-5 seconds
  6. +
  7. View results: Success message shows {created} created, {skipped} skipped
  8. +
  9. Check table: New resources appear in table
  10. +
+

Restarting Newt Container

+

Scenario 1: After Credential Update +1. Update .env: Add PANGOLIN_SITE_ID and NEWT_* credentials +2. Click "Restart Newt Container" in setup wizard alert +3. Wait ~3 seconds: Container takes time to restart +4. Check status: "Newt Container: Ready" in status card

+

Scenario 2: Troubleshooting Connection +1. Notice Newt not ready: Status shows "Running (Not configured)" or "Stopped" +2. Click "Restart Newt" button in Resource table header +3. Wait for restart: Success message appears +4. Verify status: Refresh status after 3 seconds

+

Deleting Resources

+
    +
  1. Identify resource: Find resource to delete in table
  2. +
  3. Click Delete button: Red icon button on right
  4. +
  5. Read Popconfirm: "Delete this resource?"
  6. +
  7. Confirm deletion: Click OK
  8. +
  9. Resource removed: Table refreshes, resource no longer shown
  10. +
+

Editing Resource Configuration

+
    +
  1. Click Edit button: Opens Edit Resource modal
  2. +
  3. Modify fields:
  4. +
  5. Name: Display name for resource
  6. +
  7. Protocol: http or https
  8. +
  9. Proxy Port: Internal container port (e.g., 4000 for API)
  10. +
  11. Enable SSL: Checkbox for HTTPS
  12. +
  13. Active: Checkbox to enable/disable resource
  14. +
  15. Block Access: Checkbox to block public access (maintenance mode)
  16. +
  17. Click Update: Saves changes to Pangolin API
  18. +
  19. Close modal: Modal closes automatically on success
  20. +
  21. Verify changes: Table shows updated values
  22. +
+
+

Component Breakdown

+

Status Card

+
<Card title={<><CloudServerOutlined /> Pangolin Tunnel Status</>}>
+  <Descriptions column={{ xs: 1, sm: 2 }} bordered size="small">
+    <Descriptions.Item label="Configured">
+      {isConfigured ? <Tag color="success">Yes</Tag> : <Tag color="error">No</Tag>}
+    </Descriptions.Item>
+    <Descriptions.Item label="Server Health">
+      {isHealthy ? <Tag color="success">Healthy</Tag> : <Tag color="error">Unreachable</Tag>}
+    </Descriptions.Item>
+    <Descriptions.Item label="API URL">
+      <Text copyable>{config?.pangolinApiUrl || 'Not set'}</Text>
+    </Descriptions.Item>
+    <Descriptions.Item label="Newt Container">
+      {newtStatus?.ready ? <Tag color="success">Ready</Tag> : <Tag color="error">Stopped</Tag>}
+    </Descriptions.Item>
+    <Descriptions.Item label="Organization ID">
+      <Text code>{config?.orgId || 'Not set'}</Text>
+    </Descriptions.Item>
+    <Descriptions.Item label="Site ID">
+      <Text code>{config?.siteId || 'Not set'}</Text>
+    </Descriptions.Item>
+  </Descriptions>
+</Card>
+
+

Responsive: 1 column on mobile, 2 columns on desktop

+

Setup Form

+
<Form form={setupForm} layout="vertical" onFinish={handleSetup}>
+  <Form.Item name="siteName" label="Site Name">
+    <Input placeholder={`changemaker-${config?.domain || 'cmlite.org'}`} />
+  </Form.Item>
+  <Form.Item
+    name="subnet"
+    label="Subnet (CIDR notation)"
+    tooltip="Network subnet for this site. Auto-suggested based on existing allocations."
+    initialValue={suggestedSubnet}
+  >
+    <Input placeholder="100.90.128.3/24" />
+  </Form.Item>
+  {exitNodes.length > 0 && (
+    <Form.Item
+      name="exitNodeId"
+      label="Exit Node (optional)"
+      tooltip="Network exit point for tunneled traffic. Only needed for multi-node Pangolin setups."
+    >
+      <Select placeholder="Select an exit node (optional)" allowClear>
+        {exitNodes.map(node => (
+          <Select.Option key={node.exitNodeId} value={node.exitNodeId} disabled={!node.online}>
+            {sanitizeText(node.name)}
+            {node.location && ` (${sanitizeText(node.location)})`}
+            {!node.online && ' [OFFLINE]'}
+          </Select.Option>
+        ))}
+      </Select>
+    </Form.Item>
+  )}
+  <Form.Item>
+    <Button type="primary" htmlType="submit" loading={actionLoading} icon={<RocketOutlined />}>
+      Create Site + Resources
+    </Button>
+  </Form.Item>
+</Form>
+
+

Credential Display Alert

+
<Alert
+  type="success"
+  showIcon
+  closable
+  onClose={() => {
+    setSetupResult(null);
+    setShowCredentials(false);
+  }}
+  message="Setup Complete"
+  description={
+    <div>
+      <Paragraph>
+        <strong>Step 1:</strong> Add these to your <Text code>.env</Text> file:
+      </Paragraph>
+      <Space style={{ marginBottom: 12 }}>
+        <Button
+          size="small"
+          icon={showCredentials ? <EyeInvisibleOutlined /> : <EyeOutlined />}
+          onClick={() => setShowCredentials(!showCredentials)}
+        >
+          {showCredentials ? 'Hide' : 'Show'} Credentials
+        </Button>
+        <Button
+          size="small"
+          icon={<CopyOutlined />}
+          onClick={() => {
+            const text = setupResult.instructions.slice(1, -1).join('\n');
+            navigator.clipboard.writeText(text);
+            message.success('Copied to clipboard');
+          }}
+        >
+          Copy to Clipboard
+        </Button>
+        <Button
+          size="small"
+          danger
+          onClick={() => {
+            setSetupResult(null);
+            setShowCredentials(false);
+          }}
+        >
+          Clear Credentials
+        </Button>
+      </Space>
+
+      {showCredentials && (
+        <pre style={{ background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 6, fontSize: 12 }}>
+          {setupResult.instructions.slice(1, -1).join('\n')}
+        </pre>
+      )}
+
+      <Paragraph style={{ marginTop: 16 }}>
+        <strong>Step 2:</strong> After updating .env, restart the Newt container:
+      </Paragraph>
+      <Button
+        type="primary"
+        icon={<SyncOutlined />}
+        loading={restartLoading}
+        onClick={handleRestartNewt}
+      >
+        Restart Newt Container
+      </Button>
+    </div>
+  }
+/>
+
+

Resource Table

+
<Table
+  dataSource={resources}
+  rowKey="resourceId"
+  size="small"
+  pagination={false}
+  columns={[
+    {
+      title: 'Name',
+      dataIndex: 'name',
+    },
+    {
+      title: 'Domain',
+      render: (_, r: PangolinResource) => (
+        <Text copyable>{r.fullDomain || r.subdomain || '(root)'}</Text>
+      ),
+    },
+    {
+      title: 'SSL',
+      dataIndex: 'ssl',
+      render: (ssl: boolean) => ssl ? <Tag color="green">Yes</Tag> : <Tag>No</Tag>,
+    },
+    {
+      title: 'Active',
+      dataIndex: 'active',
+      render: (active: boolean) => active !== false
+        ? <Tag color="success">Active</Tag>
+        : <Tag color="error">Inactive</Tag>,
+    },
+    {
+      title: 'Port',
+      dataIndex: 'proxyPort',
+      render: (port?: number) => port || '80',
+    },
+    {
+      title: 'Protocol',
+      dataIndex: 'protocol',
+      render: (p?: string) => p || 'http',
+    },
+    {
+      title: 'Blocked',
+      dataIndex: 'blockAccess',
+      render: (blocked?: boolean) => blocked ? <Tag color="red">Blocked</Tag> : null,
+    },
+    {
+      title: 'Actions',
+      render: (_, r: PangolinResource) => (
+        <Space>
+          <Button size="small" onClick={() => handleEditResource(r)}>Edit</Button>
+          <Popconfirm title="Delete this resource?" onConfirm={() => handleDeleteResource(r.resourceId)}>
+            <Button size="small" danger icon={<DeleteOutlined />} />
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ]}
+/>
+
+

Edit Resource Modal

+
<Modal
+  title="Edit Resource"
+  open={editModalVisible}
+  onCancel={() => {
+    setEditModalVisible(false);
+    setEditingResource(null);
+    editForm.resetFields();
+  }}
+  footer={null}
+  width={600}
+>
+  <Form form={editForm} layout="vertical" onFinish={handleUpdateResource}>
+    <Form.Item label="Name" name="name" rules={[{ required: true }]}>
+      <Input />
+    </Form.Item>
+    <Form.Item label="Protocol" name="protocol">
+      <Input placeholder="http" />
+    </Form.Item>
+    <Form.Item label="Proxy Port" name="proxyPort">
+      <Input type="number" placeholder="80" />
+    </Form.Item>
+    <Form.Item name="ssl" valuePropName="checked">
+      <Checkbox>Enable SSL</Checkbox>
+    </Form.Item>
+    <Form.Item name="active" valuePropName="checked">
+      <Checkbox>Active</Checkbox>
+    </Form.Item>
+    <Form.Item name="blockAccess" valuePropName="checked">
+      <Checkbox>Block Access</Checkbox>
+    </Form.Item>
+    <Form.Item>
+      <Space>
+        <Button type="primary" htmlType="submit" loading={actionLoading}>Update</Button>
+        <Button onClick={() => setEditModalVisible(false)}>Cancel</Button>
+      </Space>
+    </Form.Item>
+  </Form>
+</Modal>
+
+
+

State Management

+

Local State

+

Status & Config: +

const [status, setStatus] = useState<PangolinStatus | null>(null);
+const [config, setConfig] = useState<PangolinConfig | null>(null);
+const [resources, setResources] = useState<PangolinResource[]>([]);
+const [newtStatus, setNewtStatus] = useState<PangolinNewtStatus | null>(null);
+const [loading, setLoading] = useState(true);
+

+

Setup Wizard State: +

const [setupResult, setSetupResult] = useState<Record<string, unknown> | null>(null);
+const [suggestedSubnet, setSuggestedSubnet] = useState<string>('100.90.128.3/24');
+const [exitNodes, setExitNodes] = useState<PangolinExitNode[]>([]);
+const [exitNodesLoading, setExitNodesLoading] = useState(false);
+const [showCredentials, setShowCredentials] = useState(false);
+const [setupForm] = Form.useForm();
+

+

Resource Management State: +

const [editModalVisible, setEditModalVisible] = useState(false);
+const [editingResource, setEditingResource] = useState<PangolinResource | null>(null);
+const [editForm] = Form.useForm();
+const [actionLoading, setActionLoading] = useState(false);
+const [restartLoading, setRestartLoading] = useState(false);
+const [newtLoading, setNewtLoading] = useState(false);
+

+

Data Fetching

+

Fetch Status + Config: +

const fetchData = useCallback(async () => {
+  setLoading(true);
+  try {
+    const [statusRes, configRes] = await Promise.all([
+      api.get<PangolinStatus>('/pangolin/status'),
+      api.get<PangolinConfig>('/pangolin/config'),
+    ]);
+    setStatus(statusRes.data);
+    setConfig(configRes.data);
+
+    if (statusRes.data.configured) {
+      try {
+        const resourcesRes = await api.get<{ resources: PangolinResource[] }>('/pangolin/resources');
+        setResources(resourcesRes.data.resources);
+      } catch {
+        // Resources may not load if site isn't set up
+      }
+    }
+  } catch {
+    message.error('Failed to load Pangolin status');
+  } finally {
+    setLoading(false);
+  }
+}, [message]);
+

+

Fetch Newt Status: +

const fetchNewtStatus = useCallback(async () => {
+  if (!status?.newtConfigured) return;
+
+  setNewtLoading(true);
+  try {
+    const res = await api.get<PangolinNewtStatus>('/pangolin/newt-status');
+    setNewtStatus(res.data);
+  } catch {
+    // Silently fail - status card will show "unknown"
+  } finally {
+    setNewtLoading(false);
+  }
+}, [status?.newtConfigured]);
+

+

Fetch Exit Nodes: +

useEffect(() => {
+  if (status?.configured && !config?.siteId) {
+    setExitNodesLoading(true);
+    api.get<{ exitNodes: PangolinExitNode[] }>('/pangolin/exit-nodes')
+      .then(res => {
+        setExitNodes(res.data.exitNodes);
+
+        // Auto-select if only one ONLINE exit node available
+        const onlineNodes = res.data.exitNodes.filter(n => n.online);
+        if (onlineNodes.length === 1 && onlineNodes[0]) {
+          setupForm.setFieldsValue({ exitNodeId: onlineNodes[0].exitNodeId });
+        }
+      })
+      .catch(() => {
+        // Exit nodes not available - OK for self-hosted setups
+      })
+      .finally(() => setExitNodesLoading(false));
+  }
+}, [status?.configured, config?.siteId, setupForm]);
+

+

Subnet Auto-Suggestion

+

Helper Function: +

const suggestNextSubnet = (sites: PangolinSite[]): string => {
+  if (!sites || sites.length === 0) {
+    return '100.90.128.0/24'; // Default first subnet
+  }
+
+  const subnets = sites
+    .map(s => s.address || s.subnet)
+    .filter(Boolean)
+    .sort();
+
+  if (subnets.length === 0) {
+    return '100.90.128.0/24';
+  }
+
+  const lastSubnet = subnets[subnets.length - 1];
+  const match = lastSubnet.match(/^100\.90\.128\.(\d+)\/24$/);
+
+  if (match && match[1]) {
+    const lastOctet = parseInt(match[1], 10);
+    const nextOctet = lastOctet + 1;
+    if (nextOctet <= 255) {
+      return `100.90.128.${nextOctet}/24`;
+    }
+  }
+
+  return '100.90.128.3/24'; // Fallback
+};
+

+

Fetch and Suggest: +

useEffect(() => {
+  if (status?.configured && !config?.siteId) {
+    api.get<{ sites: PangolinSite[] }>('/pangolin/sites')
+      .then(res => {
+        const suggested = suggestNextSubnet(res.data.sites);
+        setSuggestedSubnet(suggested);
+        setupForm.setFieldsValue({ subnet: suggested });
+      })
+      .catch(() => {
+        setupForm.setFieldsValue({ subnet: '100.90.128.3/24' });
+      });
+  }
+}, [status?.configured, config?.siteId, setupForm]);
+

+
+

API Integration

+

Endpoints Used

+

GET /pangolin/status - Check configuration and health +

const { data } = await api.get<PangolinStatus>('/pangolin/status');
+

+

Response: +

{
+  "configured": true,
+  "healthy": true,
+  "newtConfigured": true
+}
+

+

GET /pangolin/config - Fetch Pangolin configuration +

const { data } = await api.get<PangolinConfig>('/pangolin/config');
+

+

Response: +

{
+  "pangolinApiUrl": "https://api.bnkserve.org/v1",
+  "orgId": "org_abc123",
+  "siteId": "site_xyz789",
+  "domain": "cmlite.org"
+}
+

+

GET /pangolin/resources - List all tunnel resources +

const { data } = await api.get<{ resources: PangolinResource[] }>('/pangolin/resources');
+

+

Response: +

{
+  "resources": [
+    {
+      "resourceId": "res_1",
+      "name": "API",
+      "subdomain": "api",
+      "fullDomain": "api.cmlite.org",
+      "ssl": true,
+      "active": true,
+      "proxyPort": 4000,
+      "protocol": "http",
+      "blockAccess": false
+    }
+  ]
+}
+

+

POST /pangolin/setup - Create site and resources +

const { data } = await api.post('/pangolin/setup', {
+  siteName: 'changemaker-cmlite.org',
+  subnet: '100.90.128.3/24',
+  exitNodeId: 'exit_node_123'  // Optional
+});
+

+

Response: +

{
+  "siteId": "site_xyz789",
+  "instructions": [
+    "# Add these to your .env file:",
+    "PANGOLIN_SITE_ID=site_xyz789",
+    "NEWT_ID=newt_abc456",
+    "NEWT_SECRET=secret_def789",
+    "# Then restart the Newt container"
+  ]
+}
+

+

POST /pangolin/sync - Sync resources from docker-compose.yml +

const { data } = await api.post<{ created: number; skipped: number; errors: number }>('/pangolin/sync');
+

+

Response: +

{
+  "created": 3,
+  "skipped": 5,
+  "errors": 0
+}
+

+

PUT /pangolin/resource/:resourceId - Update resource +

await api.put(`/pangolin/resource/${resourceId}`, {
+  name: 'Updated API',
+  protocol: 'http',
+  proxyPort: 4000,
+  ssl: true,
+  active: true,
+  blockAccess: false
+});
+

+

DELETE /pangolin/resource/:resourceId - Delete resource +

await api.delete(`/pangolin/resource/${resourceId}`);
+

+

POST /pangolin/newt-restart - Restart Newt container +

await api.post('/pangolin/newt-restart');
+

+

GET /pangolin/newt-status - Get Newt container status +

const { data } = await api.get<PangolinNewtStatus>('/pangolin/newt-status');
+

+

Response: +

{
+  "containerRunning": true,
+  "ready": true
+}
+

+

GET /pangolin/sites - List all sites (for subnet suggestion) +

const { data } = await api.get<{ sites: PangolinSite[] }>('/pangolin/sites');
+

+

Response: +

{
+  "sites": [
+    {
+      "siteId": "site_1",
+      "name": "changemaker-dev",
+      "address": "100.90.128.2/24"
+    }
+  ]
+}
+

+

GET /pangolin/exit-nodes - List available exit nodes +

const { data } = await api.get<{ exitNodes: PangolinExitNode[] }>('/pangolin/exit-nodes');
+

+

Response: +

{
+  "exitNodes": [
+    {
+      "exitNodeId": "exit_1",
+      "name": "US East",
+      "location": "New York",
+      "online": true
+    },
+    {
+      "exitNodeId": "exit_2",
+      "name": "US West",
+      "location": "San Francisco",
+      "online": false
+    }
+  ]
+}
+

+
+

Code Examples

+

Subnet Auto-Suggestion Logic

+
const suggestNextSubnet = (sites: PangolinSite[]): string => {
+  if (!sites || sites.length === 0) {
+    return '100.90.128.0/24';
+  }
+
+  // Get all existing subnets and sort
+  const subnets = sites
+    .map(s => s.address || s.subnet)
+    .filter(Boolean)
+    .sort();
+
+  if (subnets.length === 0) return '100.90.128.0/24';
+
+  // Parse last subnet to extract octet
+  const lastSubnet = subnets[subnets.length - 1];
+  const match = lastSubnet.match(/^100\.90\.128\.(\d+)\/24$/);
+
+  if (match && match[1]) {
+    const lastOctet = parseInt(match[1], 10);
+    const nextOctet = lastOctet + 1;
+
+    if (nextOctet <= 255) {
+      return `100.90.128.${nextOctet}/24`;
+    }
+  }
+
+  return '100.90.128.3/24';
+};
+
+// Example usage:
+// Sites: [{ address: '100.90.128.2/24' }, { address: '100.90.128.3/24' }]
+// Returns: '100.90.128.4/24'
+
+

Exit Node Auto-Selection

+
const onlineNodes = exitNodes.filter(n => n.online);
+
+if (onlineNodes.length === 1 && onlineNodes[0]) {
+  // Auto-select the only online node
+  setupForm.setFieldsValue({ exitNodeId: onlineNodes[0].exitNodeId });
+} else if (onlineNodes.length > 1 && onlineNodes.length < exitNodes.length) {
+  // Some nodes offline, warn user
+  message.info('Some exit nodes are offline. Select from available nodes.');
+}
+
+

Text Sanitization for External API Data

+
const sanitizeText = (text: string | undefined): string => {
+  if (!text) return '';
+  return text.replace(/[<>'"&]/g, (char) => {
+    const escapeMap: Record<string, string> = {
+      '<': '&lt;',
+      '>': '&gt;',
+      "'": '&#39;',
+      '"': '&quot;',
+      '&': '&amp;',
+    };
+    return escapeMap[char] || char;
+  });
+};
+
+// Usage in exit node options
+<Select.Option value={node.exitNodeId}>
+  {sanitizeText(node.name)}
+  {node.location && ` (${sanitizeText(node.location)})`}
+</Select.Option>
+
+

Why: Defense-in-depth against XSS if Pangolin API returns malicious data.

+

Credential Show/Hide Pattern

+
const [showCredentials, setShowCredentials] = useState(false);
+
+<Button
+  icon={showCredentials ? <EyeInvisibleOutlined /> : <EyeOutlined />}
+  onClick={() => setShowCredentials(!showCredentials)}
+>
+  {showCredentials ? 'Hide' : 'Show'} Credentials
+</Button>
+
+{showCredentials && (
+  <pre>{setupResult.instructions.slice(1, -1).join('\n')}</pre>
+)}
+
+

Security: Credentials hidden by default, require user action to view.

+

Delayed Status Check After Restart

+
const handleRestartNewt = async () => {
+  setRestartLoading(true);
+  try {
+    await api.post('/pangolin/newt-restart');
+    message.success('Newt container restarted successfully. Checking status...');
+
+    // Poll status after restart (container takes a few seconds to start)
+    setTimeout(() => {
+      fetchNewtStatus();
+    }, 3000);
+  } catch (err: unknown) {
+    const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Failed to restart container';
+    message.error(msg);
+  } finally {
+    setRestartLoading(false);
+  }
+};
+
+

Why 3 seconds: Newt container needs time to fully start before status check succeeds.

+
+

Performance Considerations

+

Parallel API Calls on Mount

+
const [statusRes, configRes] = await Promise.all([
+  api.get('/pangolin/status'),
+  api.get('/pangolin/config'),
+]);
+
+

Benefit: Loads status and config simultaneously (faster than sequential).

+

Optional Newt Status Fetch

+
const fetchNewtStatus = useCallback(async () => {
+  if (!status?.newtConfigured) return; // Don't check if not configured
+  // ... fetch logic
+}, [status?.newtConfigured]);
+
+

Benefit: Avoids unnecessary API call when Newt not configured.

+

Graceful Exit Node Failure

+
api.get('/pangolin/exit-nodes')
+  .catch(() => {
+    // Don't show error messages
+    // Exit nodes not available - OK for self-hosted setups
+  });
+
+

Benefit: Page works without exit nodes (self-hosted Pangolin doesn't use them).

+
+

Responsive Design

+

Status Card Columns

+
<Descriptions column={{ xs: 1, sm: 2 }}>
+
+

Mobile (< 576px): 1 column (stacked) +Desktop (≥ 576px): 2 columns (side-by-side)

+

Form Layout

+
<Form layout="vertical">
+
+

Vertical layout: Label above input (works well on all screen sizes).

+
+

Accessibility

+

Button Labels

+

All buttons have text labels (not icon-only): +

<Button icon={<SyncOutlined />}>Sync Resources</Button>
+<Button icon={<SaveOutlined />}>Update</Button>
+

+

Form Tooltips

+
<Form.Item
+  tooltip="Network subnet for this site. Auto-suggested based on existing allocations."
+>
+
+

Provides context without cluttering label.

+

Popconfirm for Destructive Actions

+
<Popconfirm title="Delete this resource?" onConfirm={handleDelete}>
+  <Button danger />
+</Popconfirm>
+
+

Prevents accidental deletion.

+
+

Troubleshooting

+

"Configured: No" Status

+

Cause: Missing environment variables

+

Solution: +

# Add to .env
+PANGOLIN_API_URL=https://api.bnkserve.org/v1
+PANGOLIN_API_KEY=your_api_key_here
+PANGOLIN_ORG_ID=org_abc123
+
+# Restart API
+docker compose restart api
+

+

"Server Health: Unreachable"

+

Causes: +1. Pangolin API server down +2. Incorrect API URL +3. Network connectivity issue

+

Debug: +

# Test API URL directly
+curl -H "Authorization: Bearer $PANGOLIN_API_KEY" \
+  https://api.bnkserve.org/v1/status
+
+# Check API logs
+docker compose logs -f api | grep pangolin
+

+

Setup Fails with "Subnet already in use"

+

Cause: Suggested subnet conflicts with existing site

+

Solution: +1. Manually override subnet in form (e.g., 100.90.128.5/24) +2. Try again

+

Newt Container Shows "Stopped"

+

Causes: +1. Container crashed +2. Missing NEWT_ID or NEWT_SECRET in .env +3. Wrong credentials

+

Solutions: +

# Check container logs
+docker compose logs newt
+
+# Verify credentials in .env
+cat .env | grep NEWT
+
+# Restart container
+docker compose restart newt
+
+# Or use UI button
+

+

Resources Not Syncing

+

Symptoms: +- Sync button does nothing +- New resources not created

+

Causes: +1. docker-compose.yml not updated +2. Subdomain naming mismatch +3. API error

+

Debug: +

# Check API logs during sync
+docker compose logs -f api
+
+# Verify docker-compose.yml has subdomain labels
+cat docker-compose.yml | grep subdomain
+

+

Credentials Not Showing After Setup

+

Cause: setupResult state is null

+

Debug: +

console.log('Setup result:', setupResult);
+

+

If null: Setup API call failed or returned unexpected format.

+
+ +

Backend Integration

+ +

Features

+ +

Deployment

+ +

Troubleshooting

+ +

User Guides

+ +

External Resources

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/representatives-page/index.html b/mkdocs/site/v2/frontend/pages/admin/representatives-page/index.html new file mode 100644 index 00000000..77106f72 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/representatives-page/index.html @@ -0,0 +1,8548 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Representatives - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

RepresentativesPage

+

Overview

+

The RepresentativesPage provides administrative management of the representative cache system that powers the Influence module's postal code lookup functionality. It allows administrators to view cached representatives, search by postal code to populate the cache, clear stale cache entries, and monitor cache statistics. The page integrates with the Represent API (represent.opennorth.ca) to fetch Canadian elected officials.

+

Route: /app/influence/representatives +Component: admin/src/pages/RepresentativesPage.tsx (387 lines) +Auth Required: Yes (SUPER_ADMIN or INFLUENCE_ADMIN role recommended) +Layout: AppLayout +Backend Module: api/src/modules/influence/representatives/

+

Screenshot

+

[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.]

+

Features

+
    +
  • Cache statistics dashboard — Real-time metrics (total reps, unique postal codes, average reps per postal)
  • +
  • Postal code lookup — Fetch representatives from Represent API and populate cache
  • +
  • Representative search — Filter by name, office, party, district (300ms debounce)
  • +
  • Postal code filter — Show representatives for specific postal code only (300ms debounce)
  • +
  • Government level filtering — Filter by Federal, Provincial, or Municipal
  • +
  • Sortable table — Sort by name, office, level, party, district
  • +
  • Detail drawer — View complete representative information with photo and links
  • +
  • Cache clearing — Delete individual representatives or clear entire cache
  • +
  • Color-coded tags — Visual indicators for government level and political party
  • +
  • Pagination — Configurable page size (10, 25, 50, 100 per page)
  • +
  • Responsive design — Mobile-friendly layout with stacked filters
  • +
  • Auto-refresh stats — Statistics update after lookup/delete operations
  • +
+

User Workflow

+

Looking Up Representatives for a Postal Code

+
    +
  1. Navigate to /app/influence/representatives
  2. +
  3. Click "Lookup New Postal Code" button (top right)
  4. +
  5. Modal appears: "Lookup Representatives"
  6. +
  7. Enter a valid Canadian postal code (e.g., "K1A 0A9")
  8. +
  9. Click "Lookup" button
  10. +
  11. Wait for API request to Represent API
  12. +
  13. Success scenarios:
  14. +
  15. New representatives found: Success message "Found 3 representatives for K1A 0A9 and cached them"
  16. +
  17. Already cached: Info message "Representatives for K1A 0A9 are already cached"
  18. +
  19. No representatives found: Warning message "No representatives found for K1A 0A9"
  20. +
  21. Table automatically refreshes to show newly cached representatives
  22. +
  23. Statistics cards update to reflect new cache size
  24. +
+

Searching for Cached Representatives

+
    +
  1. Locate "Search Representatives" input field (below statistics cards)
  2. +
  3. Start typing search query (e.g., "John Smith")
  4. +
  5. Search automatically triggers after 300ms pause (debounce)
  6. +
  7. Table filters to show matching representatives
  8. +
  9. Matches on: name, office title, political party, district name
  10. +
  11. Clear search by clicking X icon or deleting text
  12. +
+

Filtering by Postal Code

+
    +
  1. Locate "Postal Code Filter" input field (next to search field)
  2. +
  3. Start typing postal code (e.g., "K1A")
  4. +
  5. Filter automatically triggers after 300ms pause (debounce)
  6. +
  7. Table shows only representatives associated with that postal code
  8. +
  9. Clear filter by clicking X icon or deleting text
  10. +
  11. Can combine with search filter for more specific results
  12. +
+

Viewing Representative Details

+
    +
  1. Locate representative in table
  2. +
  3. Click "View" button in Actions column
  4. +
  5. Right-side drawer opens: "Representative Details"
  6. +
  7. View information sections:
  8. +
  9. Photo: Representative's portrait (if available)
  10. +
  11. Basic Info: Name, political party, government level
  12. +
  13. Office: Office title, district name
  14. +
  15. Contact: Email, phone numbers (if available)
  16. +
  17. Addresses: Office address, mailing address (if available)
  18. +
  19. Social Media: Twitter, Facebook, website links (if available)
  20. +
  21. Other Data: Custom fields from Represent API
  22. +
  23. Click Close button or drawer overlay to dismiss
  24. +
+

Deleting Cached Representatives

+

Individual Deletion

+
    +
  1. Locate representative in table
  2. +
  3. Click "Delete" button in Actions column (red text)
  4. +
  5. Confirmation modal appears: "Are you sure you want to delete this representative from cache?"
  6. +
  7. Click "Delete" to confirm (or "Cancel" to abort)
  8. +
  9. Success message: "Representative deleted from cache"
  10. +
  11. Representative removed from table immediately
  12. +
  13. Statistics cards update to reflect reduced cache size
  14. +
+

Bulk Cache Clearing

+
    +
  1. Click "Clear All Cache" button (top right, danger style)
  2. +
  3. Confirmation modal appears: "Are you sure you want to clear the entire representative cache? This will delete all 245 cached representatives."
  4. +
  5. Enter confirmation phrase if prompted (optional safety measure)
  6. +
  7. Click "Clear Cache" to confirm (or "Cancel" to abort)
  8. +
  9. Loading indicator appears
  10. +
  11. All cache entries deleted from database
  12. +
  13. Success message: "Cache cleared successfully. Deleted 245 representatives."
  14. +
  15. Table refreshes to show empty state
  16. +
  17. Statistics cards reset to zero
  18. +
+

Sorting the Table

+
    +
  1. Identify sortable columns (Name, Office, Level, Party, District Name)
  2. +
  3. Click column header to sort ascending (↑ arrow appears)
  4. +
  5. Click again to sort descending (↓ arrow appears)
  6. +
  7. Click third time to remove sorting (no arrow)
  8. +
  9. Default sort: Name ascending
  10. +
  11. Can combine with search/filter (sorted results only)
  12. +
+

Component Breakdown

+

Ant Design Components Used

+
    +
  • Typography.Title — Page heading ("Representatives")
  • +
  • Typography.Text — Labels, descriptions, empty state text
  • +
  • Row / Col — Grid layout for statistics cards and form fields
  • +
  • Card — Statistics card containers
  • +
  • Statistic — Formatted numeric statistics display
  • +
  • Space — Button grouping (top-right buttons)
  • +
  • Button — Primary actions (Lookup, Clear Cache), row actions (View, Delete)
  • +
  • Input.Search — Representative search field with debounce
  • +
  • Input — Postal code filter field
  • +
  • Select — Government level filter dropdown
  • +
  • Table — Main data table with sortable columns, pagination
  • +
  • Tag — Color-coded government level and party indicators
  • +
  • Modal — Confirmation dialogs (delete, clear cache), lookup modal
  • +
  • Form — Postal code lookup form
  • +
  • Form.Item — Form field wrapper with validation
  • +
  • Drawer — Representative detail side panel
  • +
  • Descriptions — Key-value pairs in detail drawer
  • +
  • Descriptions.Item — Individual detail fields
  • +
  • Image — Representative photo display
  • +
  • Empty — Empty state when no representatives cached
  • +
  • message — Toast notifications for success/error feedback
  • +
+

Table Structure

+
const columns: ColumnsType<Representative> = [
+  {
+    title: 'Name',
+    dataIndex: 'name',
+    key: 'name',
+    sorter: (a, b) => a.name.localeCompare(b.name),
+    width: 200,
+  },
+  {
+    title: 'Office',
+    dataIndex: 'officeTitle',
+    key: 'officeTitle',
+    sorter: (a, b) => (a.officeTitle || '').localeCompare(b.officeTitle || ''),
+    width: 200,
+  },
+  {
+    title: 'Level',
+    dataIndex: 'level',
+    key: 'level',
+    width: 120,
+    render: (level: string) => {
+      const colorMap: Record<string, string> = {
+        Federal: 'red',
+        Provincial: 'blue',
+        Municipal: 'green',
+      };
+      return <Tag color={colorMap[level] || 'default'}>{level}</Tag>;
+    },
+    sorter: (a, b) => a.level.localeCompare(b.level),
+  },
+  {
+    title: 'Party',
+    dataIndex: 'politicalParty',
+    key: 'politicalParty',
+    width: 150,
+    render: (party: string | null) => {
+      if (!party) return <Text type="secondary"></Text>;
+      return <Tag color="default">{party}</Tag>;
+    },
+    sorter: (a, b) => (a.politicalParty || '').localeCompare(b.politicalParty || ''),
+  },
+  {
+    title: 'District Name',
+    dataIndex: 'districtName',
+    key: 'districtName',
+    sorter: (a, b) => (a.districtName || '').localeCompare(b.districtName || ''),
+  },
+  {
+    title: 'Email',
+    dataIndex: 'email',
+    key: 'email',
+    width: 200,
+    render: (email: string | null) => {
+      if (!email) return <Text type="secondary"></Text>;
+      return <a href={`mailto:${email}`}>{email}</a>;
+    },
+  },
+  {
+    title: 'Actions',
+    key: 'actions',
+    width: 140,
+    fixed: 'right',
+    render: (_: unknown, record: Representative) => (
+      <Space size="small">
+        <Button
+          size="small"
+          type="link"
+          icon={<EyeOutlined />}
+          onClick={() => handleViewDetails(record)}
+        >
+          View
+        </Button>
+        <Button
+          size="small"
+          type="link"
+          danger
+          icon={<DeleteOutlined />}
+          onClick={() => handleDeleteConfirm(record)}
+        >
+          Delete
+        </Button>
+      </Space>
+    ),
+  },
+];
+
+

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 "—" if null) +- District Name: Electoral district (e.g., "Ottawa Centre"), sortable +- Email: Contact email with mailto: link, 200px width, nullable (shows "—" if null) +- Actions: View and Delete buttons, 140px width, fixed right

+

Statistics Cards

+
{stats && (
+  <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
+    <Col xs={24} sm={8}>
+      <Card size="small">
+        <Statistic
+          title="Cached Representatives"
+          value={stats.totalRepresentatives}
+          prefix={<TeamOutlined />}
+        />
+      </Card>
+    </Col>
+    <Col xs={24} sm={8}>
+      <Card size="small">
+        <Statistic
+          title="Unique Postal Codes"
+          value={stats.uniquePostalCodes}
+          prefix={<EnvironmentOutlined />}
+        />
+      </Card>
+    </Col>
+    <Col xs={24} sm={8}>
+      <Card size="small">
+        <Statistic
+          title="Avg Reps per Postal"
+          value={stats.avgRepsPerPostal}
+          precision={1}
+          prefix={<LineChartOutlined />}
+        />
+      </Card>
+    </Col>
+  </Row>
+)}
+
+

Responsive Grid: +- Desktop (sm+): 3 cards side-by-side (8 columns each = 8/24 = ⅓ width) +- Mobile (xs): Stacked cards (24 columns = full width) +- Gutter: 16px horizontal and vertical spacing

+

Detail Drawer

+
<Drawer
+  title="Representative Details"
+  placement="right"
+  width={600}
+  open={detailDrawerOpen}
+  onClose={() => setDetailDrawerOpen(false)}
+>
+  {selectedRep && (
+    <Space direction="vertical" size="large" style={{ width: '100%' }}>
+      {selectedRep.photoUrl && (
+        <Image
+          src={selectedRep.photoUrl}
+          alt={selectedRep.name}
+          width={200}
+          style={{ borderRadius: 8 }}
+        />
+      )}
+      <Descriptions column={1} bordered>
+        <Descriptions.Item label="Name">{selectedRep.name}</Descriptions.Item>
+        <Descriptions.Item label="Office Title">
+          {selectedRep.officeTitle || '—'}
+        </Descriptions.Item>
+        <Descriptions.Item label="Government Level">
+          <Tag color={getLevelColor(selectedRep.level)}>{selectedRep.level}</Tag>
+        </Descriptions.Item>
+        <Descriptions.Item label="Political Party">
+          {selectedRep.politicalParty || '—'}
+        </Descriptions.Item>
+        <Descriptions.Item label="District Name">
+          {selectedRep.districtName || '—'}
+        </Descriptions.Item>
+        <Descriptions.Item label="Email">
+          {selectedRep.email ? <a href={`mailto:${selectedRep.email}`}>{selectedRep.email}</a> : '—'}
+        </Descriptions.Item>
+        {selectedRep.phone && (
+          <Descriptions.Item label="Phone">
+            <a href={`tel:${selectedRep.phone}`}>{selectedRep.phone}</a>
+          </Descriptions.Item>
+        )}
+        {selectedRep.fax && (
+          <Descriptions.Item label="Fax">{selectedRep.fax}</Descriptions.Item>
+        )}
+        {selectedRep.officeAddress && (
+          <Descriptions.Item label="Office Address">
+            {selectedRep.officeAddress}
+          </Descriptions.Item>
+        )}
+        {selectedRep.mailingAddress && (
+          <Descriptions.Item label="Mailing Address">
+            {selectedRep.mailingAddress}
+          </Descriptions.Item>
+        )}
+        {selectedRep.personalUrl && (
+          <Descriptions.Item label="Website">
+            <a href={selectedRep.personalUrl} target="_blank" rel="noopener noreferrer">
+              {selectedRep.personalUrl}
+            </a>
+          </Descriptions.Item>
+        )}
+        {selectedRep.socialMedia && (
+          <Descriptions.Item label="Social Media">
+            {selectedRep.socialMedia.twitter && (
+              <a href={selectedRep.socialMedia.twitter} target="_blank" rel="noopener noreferrer">
+                Twitter
+              </a>
+            )}
+            {selectedRep.socialMedia.facebook && (
+              <>
+                {' | '}
+                <a href={selectedRep.socialMedia.facebook} target="_blank" rel="noopener noreferrer">
+                  Facebook
+                </a>
+              </>
+            )}
+          </Descriptions.Item>
+        )}
+        {selectedRep.otherData && Object.keys(selectedRep.otherData).length > 0 && (
+          <Descriptions.Item label="Other Data">
+            <pre style={{ fontSize: 12, margin: 0 }}>
+              {JSON.stringify(selectedRep.otherData, null, 2)}
+            </pre>
+          </Descriptions.Item>
+        )}
+      </Descriptions>
+    </Space>
+  )}
+</Drawer>
+
+

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

)

+

State Management

+

Local State (No Zustand Store)

+
// Data state
+const [representatives, setRepresentatives] = useState<Representative[]>([]);
+const [stats, setStats] = useState<CacheStats | null>(null);
+const [loading, setLoading] = useState(false);
+
+// Filter state
+const [search, setSearch] = useState('');
+const [postalCodeFilter, setPostalCodeFilter] = useState('');
+const [levelFilter, setLevelFilter] = useState<string | undefined>(undefined);
+
+// Pagination state
+const [pagination, setPagination] = useState({
+  current: 1,
+  pageSize: 10,
+  total: 0,
+});
+
+// UI state
+const [detailDrawerOpen, setDetailDrawerOpen] = useState(false);
+const [selectedRep, setSelectedRep] = useState<Representative | null>(null);
+const [lookupModalOpen, setLookupModalOpen] = useState(false);
+
+// Debounce timers
+const searchTimerRef = useRef<NodeJS.Timeout | null>(null);
+const postalTimerRef = useRef<NodeJS.Timeout | null>(null);
+
+

No Global State:

+

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: +- Representative cache is admin-only data (not needed globally) +- Data changes infrequently (only on manual lookup/delete) +- No need to share state between pages +- Simpler architecture without store overhead

+

Debounced Search Pattern

+
const handleSearch = (value: string) => {
+  // Clear existing timer
+  if (searchTimerRef.current) {
+    clearTimeout(searchTimerRef.current);
+  }
+
+  // Set new timer
+  searchTimerRef.current = setTimeout(() => {
+    setSearch(value);
+    setPagination((prev) => ({ ...prev, current: 1 })); // Reset to page 1
+  }, 300);
+};
+
+const handlePostalCodeFilterChange = (value: string) => {
+  // Clear existing timer
+  if (postalTimerRef.current) {
+    clearTimeout(postalTimerRef.current);
+  }
+
+  // Set new timer
+  postalTimerRef.current = setTimeout(() => {
+    setPostalCodeFilter(value);
+    setPagination((prev) => ({ ...prev, current: 1 })); // Reset to page 1
+  }, 300);
+};
+
+

Why 300ms Debounce?

+
    +
  • Performance: Prevents API call on every keystroke
  • +
  • User Experience: Long enough to avoid lag, short enough to feel responsive
  • +
  • API Load: Reduces Represent API calls (external service rate limits)
  • +
  • Two Separate Timers: Search and postal code filter have independent debounce timers
  • +
+

useCallback Optimization

+
const loadRepresentatives = useCallback(async () => {
+  setLoading(true);
+  try {
+    const params: Record<string, unknown> = {
+      page: pagination.current,
+      limit: pagination.pageSize,
+    };
+
+    if (search) params.search = search;
+    if (postalCodeFilter) params.postalCode = postalCodeFilter;
+    if (levelFilter) params.level = levelFilter;
+
+    const { data } = await api.get<{
+      data: Representative[];
+      pagination: { total: number };
+    }>('/representatives', { params });
+
+    setRepresentatives(data.data);
+    setPagination((prev) => ({
+      ...prev,
+      total: data.pagination.total,
+    }));
+  } catch (error) {
+    message.error('Failed to load representatives');
+  } finally {
+    setLoading(false);
+  }
+}, [pagination.current, pagination.pageSize, search, postalCodeFilter, levelFilter]);
+
+const loadStats = useCallback(async () => {
+  try {
+    const { data } = await api.get<CacheStats>('/representatives/stats');
+    setStats(data);
+  } catch (error) {
+    message.error('Failed to load statistics');
+  }
+}, []);
+
+useEffect(() => {
+  loadRepresentatives();
+}, [loadRepresentatives]);
+
+useEffect(() => {
+  loadStats();
+}, [loadStats]);
+
+

Why useCallback?

+
    +
  • Prevents infinite re-renders: Without useCallback, useEffect would create new function reference on every render, triggering effect again
  • +
  • Optimized dependencies: Only re-creates function when filter/pagination values actually change
  • +
  • Separate stats loading: Stats fetched independently, not affected by table filters
  • +
+

API Integration

+

Endpoints Used

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointPurposeAuth
GET/api/representativesList cached representativesRequired
GET/api/representatives/statsCache statisticsRequired
POST/api/representatives/lookup/:postalCodeLookup by postal codeRequired
DELETE/api/representatives/:idDelete single representativeRequired
DELETE/api/representatives/cacheClear entire cacheRequired
+

Load Representatives (Paginated with Filters)

+

Request:

+
const params: Record<string, unknown> = {
+  page: 1,
+  limit: 10,
+  search: 'John Smith',          // Optional: search query
+  postalCode: 'K1A 0A9',          // Optional: postal code filter
+  level: 'Federal',               // Optional: government level filter
+};
+
+const { data } = await api.get<{
+  data: Representative[];
+  pagination: { total: number; page: number; limit: number };
+}>('/representatives', { params });
+
+

Query Parameters: +- page (number, required): Page number (1-indexed) +- limit (number, required): Items per page (10, 25, 50, or 100) +- search (string, optional): Search query (matches name, office, party, district) +- postalCode (string, optional): Filter by postal code +- level (string, optional): Filter by government level (Federal, Provincial, Municipal)

+

Response (200 OK):

+
{
+  "data": [
+    {
+      "id": "rep_abc123",
+      "name": "John Smith",
+      "officeTitle": "Member of Parliament",
+      "level": "Federal",
+      "politicalParty": "Liberal Party of Canada",
+      "districtName": "Ottawa Centre",
+      "email": "john.smith@parl.gc.ca",
+      "phone": "+1 613-555-0100",
+      "fax": "+1 613-555-0101",
+      "photoUrl": "https://represent.opennorth.ca/photos/john-smith.jpg",
+      "personalUrl": "https://johnsmith.ca",
+      "officeAddress": "Justice Building, 284 Wellington Street, Room 432, Ottawa, ON K1A 0A6",
+      "mailingAddress": "House of Commons, Ottawa, ON K1A 0A6",
+      "socialMedia": {
+        "twitter": "https://twitter.com/johnsmith",
+        "facebook": "https://facebook.com/johnsmithmp"
+      },
+      "otherData": {
+        "first_name": "John",
+        "last_name": "Smith",
+        "elected_office": "MP"
+      },
+      "postalCode": "K1A 0A9",
+      "createdAt": "2026-01-15T10:30:00.000Z",
+      "updatedAt": "2026-01-15T10:30:00.000Z"
+    },
+    {
+      "id": "rep_def456",
+      "name": "Jane Doe",
+      "officeTitle": "Member of Provincial Parliament",
+      "level": "Provincial",
+      "politicalParty": "Progressive Conservative Party of Ontario",
+      "districtName": "Ottawa West—Nepean",
+      "email": "jane.doe@ola.org",
+      "phone": null,
+      "fax": null,
+      "photoUrl": null,
+      "personalUrl": null,
+      "officeAddress": null,
+      "mailingAddress": null,
+      "socialMedia": null,
+      "otherData": {},
+      "postalCode": "K1A 0A9",
+      "createdAt": "2026-01-15T10:35:00.000Z",
+      "updatedAt": "2026-01-15T10:35:00.000Z"
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 10,
+    "total": 245
+  }
+}
+
+

Response Fields:

+

Core fields (always present): +- id (string): Unique representative identifier (prefixed with "rep_") +- name (string): Full name +- level (string): Government level (Federal, Provincial, or Municipal) +- postalCode (string): Associated postal code +- createdAt (ISO 8601): Cache entry creation timestamp +- updatedAt (ISO 8601): Cache entry last update timestamp

+

Optional fields (may be null): +- officeTitle (string | null): Job title +- politicalParty (string | null): Political party affiliation +- districtName (string | null): Electoral district name +- email (string | null): Contact email +- phone (string | null): Contact phone +- fax (string | null): Fax number +- photoUrl (string | null): Portrait image URL +- personalUrl (string | null): Personal/campaign website +- officeAddress (string | null): Physical office location +- mailingAddress (string | null): Mailing address +- socialMedia (object | null): Social media links (twitter, facebook) +- otherData (object): Additional custom fields from Represent API

+

Load Cache Statistics

+

Request:

+
const { data } = await api.get<CacheStats>('/representatives/stats');
+
+

Response (200 OK):

+
{
+  "totalRepresentatives": 245,
+  "uniquePostalCodes": 89,
+  "avgRepsPerPostal": 2.8,
+  "breakdown": {
+    "Federal": 89,
+    "Provincial": 89,
+    "Municipal": 67
+  }
+}
+
+

Response Fields: +- totalRepresentatives (number): Total cached representatives across all postal codes +- uniquePostalCodes (number): Number of distinct postal codes in cache +- avgRepsPerPostal (number): Average representatives per postal code (decimal) +- breakdown (object): Count by government level (Federal, Provincial, Municipal)

+

Statistics Calculation:

+
// Backend calculation (api/src/modules/influence/representatives/representatives.service.ts)
+const totalRepresentatives = await prisma.representative.count();
+const uniquePostalCodes = await prisma.representative.findMany({
+  select: { postalCode: true },
+  distinct: ['postalCode'],
+});
+const avgRepsPerPostal = uniquePostalCodes.length > 0
+  ? totalRepresentatives / uniquePostalCodes.length
+  : 0;
+
+const breakdown = await prisma.representative.groupBy({
+  by: ['level'],
+  _count: { id: true },
+});
+
+

Lookup Representatives by Postal Code

+

Request:

+
const postalCode = 'K1A 0A9';
+const { data } = await api.post<{
+  message: string;
+  count: number;
+  representatives: Representative[];
+}>(`/representatives/lookup/${postalCode}`);
+
+

URL Parameter: +- postalCode (string): Canadian postal code (format: "A1A 1A1" or "A1A1A1")

+

Response (200 OK) - New Representatives Found:

+
{
+  "message": "Found 3 representatives for K1A 0A9 and cached them",
+  "count": 3,
+  "representatives": [
+    {
+      "id": "rep_abc123",
+      "name": "John Smith",
+      "level": "Federal",
+      ...
+    },
+    {
+      "id": "rep_def456",
+      "name": "Jane Doe",
+      "level": "Provincial",
+      ...
+    },
+    {
+      "id": "rep_ghi789",
+      "name": "Bob Johnson",
+      "level": "Municipal",
+      ...
+    }
+  ]
+}
+
+

Response (200 OK) - Already Cached:

+
{
+  "message": "Representatives for K1A 0A9 are already cached",
+  "count": 3,
+  "representatives": [
+    {
+      "id": "rep_abc123",
+      "name": "John Smith",
+      "level": "Federal",
+      ...
+    }
+  ]
+}
+
+

Response (200 OK) - No Representatives Found:

+
{
+  "message": "No representatives found for K1A 0A9",
+  "count": 0,
+  "representatives": []
+}
+
+

Error Response (400 Bad Request) - Invalid Postal Code:

+
{
+  "error": "Validation Error",
+  "details": [
+    {
+      "field": "postalCode",
+      "message": "Invalid postal code format. Expected format: A1A 1A1"
+    }
+  ]
+}
+
+

Error Response (503 Service Unavailable) - Represent API Down:

+
{
+  "error": "External service unavailable",
+  "message": "Represent API is temporarily unavailable. Please try again later."
+}
+
+

Backend Workflow:

+
// 1. Check if postal code already cached
+const existingReps = await prisma.representative.findMany({
+  where: { postalCode: normalizedPostalCode },
+});
+
+if (existingReps.length > 0) {
+  return { message: 'Already cached', count: existingReps.length, representatives: existingReps };
+}
+
+// 2. Fetch from Represent API
+const representResponse = await axios.get(
+  `https://represent.opennorth.ca/postcodes/${normalizedPostalCode}/?sets=federal-electoral-districts,provincial-electoral-districts,municipal-wards`
+);
+
+// 3. Transform and save to database
+const reps = representResponse.data.representatives_centroid.map((rep: any) => ({
+  name: rep.name,
+  officeTitle: rep.elected_office,
+  level: determineLevel(rep.representative_set_name),
+  politicalParty: rep.party_name,
+  districtName: rep.district_name,
+  email: rep.email,
+  phone: rep.phone,
+  photoUrl: rep.photo_url,
+  personalUrl: rep.personal_url,
+  officeAddress: rep.office_address,
+  socialMedia: rep.extra?.social_media || null,
+  otherData: rep.extra || {},
+  postalCode: normalizedPostalCode,
+}));
+
+await prisma.representative.createMany({ data: reps });
+
+

Delete Representative

+

Request:

+
const repId = 'rep_abc123';
+await api.delete(`/representatives/${repId}`);
+
+

URL Parameter: +- id (string): Representative ID to delete

+

Response (200 OK):

+
{
+  "message": "Representative deleted from cache"
+}
+
+

Error Response (404 Not Found):

+
{
+  "error": "Not Found",
+  "message": "Representative not found with ID: rep_abc123"
+}
+
+

Clear Entire Cache

+

Request:

+
const { data } = await api.delete<{ message: string; count: number }>('/representatives/cache');
+
+

Response (200 OK):

+
{
+  "message": "Cache cleared successfully",
+  "count": 245
+}
+
+

Response Fields: +- message (string): Confirmation message +- count (number): Number of representatives deleted

+

Backend Implementation:

+
const count = await prisma.representative.count();
+await prisma.representative.deleteMany({});
+return { message: 'Cache cleared successfully', count };
+
+

Code Examples

+

Complete Representative Lookup Flow

+
const handleLookup = async (values: { postalCode: string }) => {
+  setLookupLoading(true);
+  try {
+    const normalizedPostalCode = values.postalCode.toUpperCase().replace(/\s+/g, ' ');
+
+    const { data } = await api.post<{
+      message: string;
+      count: number;
+      representatives: Representative[];
+    }>(`/representatives/lookup/${encodeURIComponent(normalizedPostalCode)}`);
+
+    if (data.count === 0) {
+      message.warning(data.message);
+    } else if (data.representatives.length > 0 && data.message.includes('already cached')) {
+      message.info(data.message);
+    } else {
+      message.success(data.message);
+    }
+
+    // Refresh table and stats
+    await Promise.all([loadRepresentatives(), loadStats()]);
+
+    setLookupModalOpen(false);
+    lookupForm.resetFields();
+  } catch (error) {
+    if (axios.isAxiosError(error) && error.response?.status === 503) {
+      message.error('Represent API is temporarily unavailable. Please try again later.');
+    } else if (axios.isAxiosError(error) && error.response?.status === 400) {
+      message.error('Invalid postal code format. Expected format: A1A 1A1');
+    } else {
+      message.error('Failed to lookup representatives');
+    }
+  } finally {
+    setLookupLoading(false);
+  }
+};
+
+

Key Steps: +1. Normalize postal code (uppercase, single space) +2. URL-encode postal code for API request +3. Handle three success scenarios (new, cached, not found) +4. Show appropriate message type (success, info, warning) +5. Refresh both table and statistics after successful lookup +6. Close modal and reset form on success +7. Handle specific error codes (503, 400) +8. Always set loading state in finally block

+

Delete with Confirmation

+
const handleDeleteConfirm = (rep: Representative) => {
+  Modal.confirm({
+    title: 'Delete Representative',
+    content: `Are you sure you want to delete "${rep.name}" from the cache?`,
+    okText: 'Delete',
+    okType: 'danger',
+    cancelText: 'Cancel',
+    onOk: async () => {
+      try {
+        await api.delete(`/representatives/${rep.id}`);
+        message.success('Representative deleted from cache');
+
+        // Refresh table and stats
+        await Promise.all([loadRepresentatives(), loadStats()]);
+      } catch (error) {
+        message.error('Failed to delete representative');
+      }
+    },
+  });
+};
+
+

Confirmation Pattern: +- Uses Ant Design Modal.confirm static method (no state needed) +- Shows representative name in confirmation text for clarity +- Async onOk handler performs delete and refresh +- Refreshes both table and stats to keep UI in sync +- Error handling within onOk (doesn't prevent modal close)

+

Clear All Cache with Confirmation

+
const handleClearCache = () => {
+  Modal.confirm({
+    title: 'Clear Cache',
+    content: stats
+      ? `Are you sure you want to clear the entire representative cache? This will delete all ${stats.totalRepresentatives} cached representatives.`
+      : 'Are you sure you want to clear the entire representative cache?',
+    okText: 'Clear Cache',
+    okType: 'danger',
+    cancelText: 'Cancel',
+    onOk: async () => {
+      try {
+        const { data } = await api.delete<{ message: string; count: number }>('/representatives/cache');
+        message.success(`Cache cleared successfully. Deleted ${data.count} representatives.`);
+
+        // Refresh table and stats
+        await Promise.all([loadRepresentatives(), loadStats()]);
+      } catch (error) {
+        message.error('Failed to clear cache');
+      }
+    },
+  });
+};
+
+

Enhanced Confirmation: +- Dynamically includes total count in confirmation message (if stats loaded) +- Shows exact number of representatives that will be deleted +- Success message includes deleted count for verification +- Uses danger button styling to emphasize destructive action

+

Color-Coded Government Level Tags

+
const getLevelColor = (level: string): string => {
+  const colorMap: Record<string, string> = {
+    Federal: 'red',
+    Provincial: 'blue',
+    Municipal: 'green',
+  };
+  return colorMap[level] || 'default';
+};
+
+// Usage in table column
+{
+  title: 'Level',
+  dataIndex: 'level',
+  key: 'level',
+  width: 120,
+  render: (level: string) => (
+    <Tag color={getLevelColor(level)}>{level}</Tag>
+  ),
+  sorter: (a, b) => a.level.localeCompare(b.level),
+}
+
+

Color Mapping: +- Federal: Red (highest level of government) +- Provincial: Blue (middle level) +- Municipal: Green (local level) +- Default: Gray (unknown/other levels)

+

Debounced Filter Implementation

+
// Component state
+const searchTimerRef = useRef<NodeJS.Timeout | null>(null);
+const postalTimerRef = useRef<NodeJS.Timeout | null>(null);
+
+// Search input handler
+const handleSearch = (value: string) => {
+  if (searchTimerRef.current) {
+    clearTimeout(searchTimerRef.current);
+  }
+
+  searchTimerRef.current = setTimeout(() => {
+    setSearch(value);
+    setPagination((prev) => ({ ...prev, current: 1 }));
+  }, 300);
+};
+
+// Postal code filter handler
+const handlePostalCodeFilterChange = (value: string) => {
+  if (postalTimerRef.current) {
+    clearTimeout(postalTimerRef.current);
+  }
+
+  postalTimerRef.current = setTimeout(() => {
+    setPostalCodeFilter(value);
+    setPagination((prev) => ({ ...prev, current: 1 }));
+  }, 300);
+};
+
+// Cleanup on unmount
+useEffect(() => {
+  return () => {
+    if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
+    if (postalTimerRef.current) clearTimeout(postalTimerRef.current);
+  };
+}, []);
+
+// Input components
+<Input.Search
+  placeholder="Search representatives..."
+  allowClear
+  onChange={(e) => handleSearch(e.target.value)}
+  style={{ width: '100%' }}
+/>
+
+<Input
+  placeholder="Filter by postal code..."
+  allowClear
+  onChange={(e) => handlePostalCodeFilterChange(e.target.value)}
+  style={{ width: '100%' }}
+/>
+
+

Two Independent Debounce Timers: +- Separate refs: searchTimerRef and postalTimerRef allow independent debouncing +- 300ms delay: Balances responsiveness and performance +- Reset pagination: Both filters reset to page 1 when changed +- Cleanup effect: Clears timers on unmount to prevent memory leaks +- allowClear: Ant Design Input feature adds X icon to clear field

+

Performance Considerations

+

Efficient Pagination

+

The page uses server-side pagination to handle large cache datasets efficiently:

+
const { data } = await api.get('/representatives', {
+  params: {
+    page: pagination.current,
+    limit: pagination.pageSize,
+    search,
+    postalCodeFilter,
+    levelFilter,
+  },
+});
+
+

Benefits: +- Reduced payload: Only fetches current page (10-100 items) instead of all 245+ +- Fast rendering: Table renders 10-100 rows instead of potentially thousands +- Scalable: Works efficiently with cache sizes from 10 to 10,000+ representatives +- Combined filtering: Backend applies filters before pagination, returning only relevant results

+

Debounced Search (300ms)

+

Prevents API spam during typing:

+
searchTimerRef.current = setTimeout(() => {
+  setSearch(value);
+}, 300);
+
+

Performance Impact: +- Without debounce: Typing "John Smith" (10 characters) = 10 API calls +- With 300ms debounce: Typing "John Smith" = 1 API call (after 300ms pause) +- Network savings: 90% reduction in API requests for typical typing speed +- Backend load: Reduces database queries and Represent API calls

+

useCallback for Fetch Functions

+

Prevents unnecessary re-renders:

+
const loadRepresentatives = useCallback(async () => {
+  // ... fetch logic
+}, [pagination.current, pagination.pageSize, search, postalCodeFilter, levelFilter]);
+
+

Why This Matters: +- Without useCallback: Function reference changes every render, triggering useEffect infinitely +- With useCallback: Function reference only changes when dependencies change +- Result: useEffect runs only when filters/pagination actually change, not on every render

+

Statistics Caching

+

Statistics are loaded separately and don't re-fetch on table filter changes:

+
const loadStats = useCallback(async () => {
+  const { data } = await api.get<CacheStats>('/representatives/stats');
+  setStats(data);
+}, []);
+
+useEffect(() => {
+  loadStats();
+}, [loadStats]); // Only runs on mount
+
+

Benefits: +- Independent updates: Stats only refresh after lookup/delete operations, not on search/filter +- Reduced API calls: Stats don't need to be recalculated for every table filter +- Better UX: Statistics cards remain stable while user searches/filters table

+

Responsive Design

+

Mobile Layout

+

The page adapts gracefully to mobile viewports:

+

Statistics Cards: +

<Row gutter={[16, 16]}>
+  <Col xs={24} sm={8}>  {/* Full width mobile, 1/3 width desktop */}
+    <Card size="small">
+      <Statistic title="Cached Representatives" value={stats.totalRepresentatives} />
+    </Card>
+  </Col>
+  {/* Repeat for other cards */}
+</Row>
+

+

Filter Inputs: +

<Row gutter={[16, 16]}>
+  <Col xs={24} md={12}>  {/* Full width mobile, half width desktop */}
+    <Input.Search placeholder="Search representatives..." />
+  </Col>
+  <Col xs={24} md={12}>  {/* Full width mobile, half width desktop */}
+    <Input placeholder="Filter by postal code..." />
+  </Col>
+</Row>
+

+

Responsive Grid Breakpoints: +- xs (mobile, <576px): Stacked layout, full-width cards and inputs +- sm (tablet, ≥576px): 3-column statistics cards +- md (desktop, ≥768px): Side-by-side filter inputs +- lg+ (large desktop, ≥992px): Full table width with all columns visible

+

Table Column Responsiveness

+

Columns use responsive prop to hide on mobile:

+
{
+  title: 'Email',
+  dataIndex: 'email',
+  key: 'email',
+  responsive: ['md'],  // Hidden on mobile (xs, sm)
+  render: (email: string | null) => (
+    email ? <a href={`mailto:${email}`}>{email}</a> : '—'
+  ),
+}
+
+

Mobile Table (xs, sm): +- Name (visible) +- Level (visible with color tags) +- Actions (visible)

+

Desktop Table (md+): +- Name + Office + Level + Party + District + Email + Actions (all visible)

+

Drawer Width

+

Detail drawer adapts to screen size:

+
<Drawer
+  width={600}  // 600px on desktop
+  // On mobile (xs), automatically becomes full-width
+  placement="right"
+  open={detailDrawerOpen}
+  onClose={() => setDetailDrawerOpen(false)}
+>
+
+

Behavior: +- Desktop (≥768px): 600px slide-in panel from right +- Mobile (<768px): Full-width slide-in panel (automatically handled by Ant Design)

+

Accessibility

+

Keyboard Navigation

+

All interactive elements are keyboard-accessible:

+

Table Navigation: +- Tab: Move between action buttons (View, Delete) +- Enter/Space: Activate focused button +- Arrow Keys: Navigate table rows (Ant Design built-in)

+

Form Fields: +- Tab: Move between search input, postal code filter, level dropdown +- Escape: Clear input fields (when using allowClear) +- Enter: Submit lookup form

+

Modal/Drawer: +- Escape: Close modal or drawer +- Tab: Cycle through focusable elements inside modal/drawer +- Enter: Confirm action (in confirmation modals)

+

Screen Reader Support

+

The page provides semantic HTML and ARIA labels:

+

Statistics Cards: +

<Statistic
+  title="Cached Representatives"  // Read by screen readers
+  value={stats.totalRepresentatives}
+  prefix={<TeamOutlined />}
+/>
+

+

Action Buttons: +

<Button
+  icon={<EyeOutlined />}
+  onClick={() => handleViewDetails(record)}
+  aria-label={`View details for ${record.name}`}
+>
+  View
+</Button>
+
+<Button
+  icon={<DeleteOutlined />}
+  onClick={() => handleDeleteConfirm(record)}
+  aria-label={`Delete ${record.name} from cache`}
+  danger
+>
+  Delete
+</Button>
+

+

Table Sorting: +

{
+  title: 'Name',
+  sorter: (a, b) => a.name.localeCompare(b.name),
+  // Ant Design automatically adds aria-sort="ascending|descending|none"
+}
+

+

Color Contrast

+

All color-coded elements meet WCAG AA standards:

+

Government Level Tags: +- Federal (red): #f5222d on white background = 4.5:1 contrast ratio +- Provincial (blue): #1890ff on white background = 4.5:1 contrast ratio +- Municipal (green): #52c41a on white background = 4.5:1 contrast ratio

+

Text Colors: +- Primary text: rgba(0, 0, 0, 0.85) = 13.6:1 contrast ratio +- Secondary text: rgba(0, 0, 0, 0.45) = 7.0:1 contrast ratio (used for "—" null values)

+

Focus Indicators

+

All interactive elements have visible focus states:

+

Buttons: +

.ant-btn:focus {
+  outline: 2px solid #1890ff;
+  outline-offset: 2px;
+}
+

+

Input Fields: +

.ant-input:focus {
+  border-color: #40a9ff;
+  box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
+}
+

+

Troubleshooting

+

Representatives Not Loading

+

Problem: Page shows empty state or loading spinner indefinitely.

+

Diagnosis:

+

Check browser console for errors:

+
// Console error
+GET https://api.cmlite.org/representatives 401 Unauthorized
+
+

Possible Causes:

+
    +
  1. Not authenticated:
  2. +
  3. Check if JWT access token is expired
  4. +
  5. +

    Check if user has required role (SUPER_ADMIN or INFLUENCE_ADMIN)

    +
  6. +
  7. +

    Backend API down:

    +
  8. +
  9. Verify API container is running: docker compose ps api
  10. +
  11. +

    Check API logs: docker compose logs api

    +
  12. +
  13. +

    Database connection issue:

    +
  14. +
  15. Verify PostgreSQL is running: docker compose ps v2-postgres
  16. +
  17. Check database connection: docker compose exec api npx prisma db push
  18. +
+

Solution:

+
    +
  1. Refresh page to trigger token refresh
  2. +
  3. Log out and log back in to get new token
  4. +
  5. Verify backend services are running: docker compose up -d api v2-postgres
  6. +
  7. Check API logs for specific error: docker compose logs -f api | grep representatives
  8. +
+
+

Postal Code Lookup Fails

+

Problem: Click "Lookup New Postal Code", enter postal code, get error: "Failed to lookup representatives".

+

Diagnosis:

+

Check network tab in browser DevTools:

+
// Response from POST /representatives/lookup/K1A0A9
+{
+  "error": "External service unavailable",
+  "message": "Represent API is temporarily unavailable. Please try again later."
+}
+
+

Possible Causes:

+
    +
  1. Represent API down:
  2. +
  3. represent.opennorth.ca is temporarily unavailable (503)
  4. +
  5. +

    Network firewall blocking external API requests

    +
  6. +
  7. +

    Invalid postal code format:

    +
  8. +
  9. Missing space in postal code (should be "K1A 0A9", not "K1A0A9")
  10. +
  11. +

    Non-Canadian postal code entered

    +
  12. +
  13. +

    Rate limit exceeded:

    +
  14. +
  15. Too many requests to Represent API from same IP
  16. +
  17. Represent API rate limit: 60 requests/minute/IP
  18. +
+

Solution:

+
    +
  1. For Represent API downtime:
  2. +
  3. Wait 5-10 minutes and retry
  4. +
  5. Check Represent API status: https://represent.opennorth.ca/postcodes/K1A0A9/
  6. +
  7. +

    Use cached representatives if available (search for existing postal code)

    +
  8. +
  9. +

    For invalid postal code:

    +
  10. +
  11. Ensure format is "A1A 1A1" with space (e.g., "K1A 0A9")
  12. +
  13. Use Canadian postal codes only (Represent API only covers Canada)
  14. +
  15. +

    Try a known-valid postal code: "K1A 0A9" (Ottawa, Parliament Hill)

    +
  16. +
  17. +

    For rate limits:

    +
  18. +
  19. Wait 1 minute before retrying
  20. +
  21. Reduce lookup frequency (don't spam the button)
  22. +
  23. Check existing cache first (use search/filter)
  24. +
+
+

Delete Button Not Working

+

Problem: Click "Delete" button, confirmation modal appears, click "Delete" again, but representative remains in table.

+

Diagnosis:

+

Check network tab:

+
// Response from DELETE /representatives/rep_abc123
+{
+  "error": "Forbidden",
+  "message": "Insufficient permissions. Required role: SUPER_ADMIN or INFLUENCE_ADMIN"
+}
+
+

Possible Causes:

+
    +
  1. Insufficient permissions:
  2. +
  3. User role is USER (not INFLUENCE_ADMIN or SUPER_ADMIN)
  4. +
  5. +

    JWT token has expired and refresh failed

    +
  6. +
  7. +

    Representative already deleted:

    +
  8. +
  9. Another admin deleted the representative concurrently
  10. +
  11. +

    Table hasn't refreshed to reflect deletion

    +
  12. +
  13. +

    Database constraint violation:

    +
  14. +
  15. Representative is referenced by campaign emails (foreign key constraint)
  16. +
  17. Cannot delete due to active references
  18. +
+

Solution:

+
    +
  1. For permission issues:
  2. +
  3. Contact system administrator to grant INFLUENCE_ADMIN role
  4. +
  5. Log out and log back in to refresh permissions
  6. +
  7. +

    Check user role in profile dropdown (top-right corner)

    +
  8. +
  9. +

    For concurrent deletion:

    +
  10. +
  11. Refresh page to see current cache state
  12. +
  13. +

    If representative is gone, deletion succeeded (UI just didn't update)

    +
  14. +
  15. +

    For constraint violations:

    +
  16. +
  17. Delete dependent records first (campaign emails referencing this representative)
  18. +
  19. Or use "Clear All Cache" button to delete everything at once (cascading delete)
  20. +
+
+

Search Not Working

+

Problem: Type search query in "Search Representatives" field, but table doesn't filter.

+

Diagnosis:

+

Check if debounce timer is working:

+
// Wait 300ms after typing
+// If table still doesn't update, check console for errors
+
+

Possible Causes:

+
    +
  1. Typing too fast:
  2. +
  3. Debounce timer resets on every keystroke
  4. +
  5. +

    Must wait 300ms after last keystroke

    +
  6. +
  7. +

    Case sensitivity:

    +
  8. +
  9. Search is case-insensitive on backend, but special characters may cause issues
  10. +
  11. +

    Accent characters (é, à, ñ) may not match correctly

    +
  12. +
  13. +

    Search scope confusion:

    +
  14. +
  15. Search only matches: name, officeTitle, politicalParty, districtName
  16. +
  17. Does NOT search: email, phone, addresses, otherData
  18. +
+

Solution:

+
    +
  1. For typing speed:
  2. +
  3. Pause typing for 300ms (about half a second)
  4. +
  5. +

    Watch for table loading spinner to confirm search triggered

    +
  6. +
  7. +

    For special characters:

    +
  8. +
  9. Remove accents (e.g., search "Montreal" instead of "Montréal")
  10. +
  11. +

    Use partial matches (e.g., "Smith" instead of "O'Smith")

    +
  12. +
  13. +

    For search scope:

    +
  14. +
  15. Search by name: "John Smith"
  16. +
  17. Search by party: "Liberal"
  18. +
  19. Search by district: "Ottawa Centre"
  20. +
  21. Use postal code filter for location-based filtering (separate input field)
  22. +
+
+

Statistics Not Updating

+

Problem: Add new representatives via lookup, but statistics cards still show old counts.

+

Diagnosis:

+

Check if loadStats() is called after lookup:

+
// Should see this in network tab after successful lookup
+GET /representatives/lookup/K1A0A9  200 OK
+GET /representatives/stats  200 OK  // ← Should appear here
+
+

Possible Causes:

+
    +
  1. Frontend bug:
  2. +
  3. loadStats() not called after successful lookup
  4. +
  5. +

    Promise.all([loadRepresentatives(), loadStats()]) failed silently

    +
  6. +
  7. +

    Backend calculation error:

    +
  8. +
  9. Statistics endpoint returning cached/stale data
  10. +
  11. +

    Database aggregation query not reflecting new records

    +
  12. +
  13. +

    Cache invalidation:

    +
  14. +
  15. Backend caching statistics for performance
  16. +
  17. Cache not invalidated after lookup/delete operations
  18. +
+

Solution:

+
    +
  1. Manual refresh:
  2. +
  3. Refresh entire page (F5 or Ctrl+R)
  4. +
  5. +

    Statistics should update to reflect current cache state

    +
  6. +
  7. +

    Check backend logs:

    +
  8. +
  9. Look for statistics calculation errors: docker compose logs api | grep stats
  10. +
  11. +

    Verify database connection during stats calculation

    +
  12. +
  13. +

    Developer fix (if bug):

    +
  14. +
  15. Ensure loadStats() is called after lookup/delete: +
    await Promise.all([loadRepresentatives(), loadStats()]);
    +
  16. +
  17. Remove backend statistics caching (if implemented)
  18. +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/responses-page/index.html b/mkdocs/site/v2/frontend/pages/admin/responses-page/index.html new file mode 100644 index 00000000..4d7b01c6 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/responses-page/index.html @@ -0,0 +1,7692 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Responses - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

ResponsesPage

+

Overview

+

The ResponsesPage provides moderation and management for public campaign responses submitted through the response wall feature. Administrators can review user submissions, approve or reject responses for public display, resend verification emails, and view detailed response information. Features include advanced filtering by status and campaign, search functionality, and clickable rows for detailed views.

+

Route: /app/influence/responses +Component: admin/src/pages/ResponsesPage.tsx (400 lines) +Auth Required: Yes (SUPER_ADMIN, INFLUENCE_ADMIN roles) +Layout: AppLayout

+

Screenshot

+

[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.]

+

Features

+
    +
  • Full response moderation — Approve, reject, or delete public responses
  • +
  • Verification management — Resend verification emails for unverified responses
  • +
  • Advanced filtering — Filter by status (PENDING/APPROVED/REJECTED) and campaign
  • +
  • Search functionality — 400ms debounced search across response text
  • +
  • Government level tags — Color-coded tags for FEDERAL, PROVINCIAL, MUNICIPAL, SCHOOL_BOARD
  • +
  • Response type tracking — EMAIL (sent via SMTP) vs PHONE (called representative)
  • +
  • Upvote display — Show public upvote count for each response
  • +
  • Verification badges — SafetyCertificateOutlined icon for verified email addresses
  • +
  • Clickable rows — Click any row to open detail drawer
  • +
  • Detail drawer — Comprehensive response view with all metadata
  • +
  • Action buttons — Conditional rendering based on status (Approve hidden if already approved)
  • +
  • Responsive table — Columns hide on smaller screens (Verified, Upvotes: md+, Submitted: sm+)
  • +
+

User Workflow

+

Viewing Responses List

+
    +
  1. Navigate to /app/influence/responses
  2. +
  3. Page loads first 20 responses (paginated)
  4. +
  5. View response details:
  6. +
  7. Representative name
  8. +
  9. Government level tag (colored)
  10. +
  11. Response type (EMAIL/PHONE)
  12. +
  13. Campaign title
  14. +
  15. Status tag (PENDING: yellow, APPROVED: green, REJECTED: red)
  16. +
  17. Verified checkmark icon (if email verified)
  18. +
  19. Upvote count
  20. +
  21. Submitted date
  22. +
  23. Action buttons
  24. +
  25. Click any row to open detail drawer
  26. +
+

Filtering Responses

+
    +
  1. Status filter (dropdown):
  2. +
  3. Select PENDING, APPROVED, or REJECTED
  4. +
  5. Clear to show all statuses
  6. +
  7. Campaign filter (dropdown with search):
  8. +
  9. Select campaign from list (up to 100 campaigns)
  10. +
  11. Type to search campaign titles
  12. +
  13. Clear to show all campaigns
  14. +
  15. Search bar:
  16. +
  17. Type keywords to search response text
  18. +
  19. 400ms debounce (waits for typing pause)
  20. +
  21. Search resets pagination to page 1
  22. +
  23. Filters combine (AND logic): status + campaign + search
  24. +
+

Approving a Response

+
    +
  1. Locate PENDING or REJECTED response in table
  2. +
  3. Click "Approve" button (green, CheckCircleOutlined icon)
  4. +
  5. Button shows loading spinner
  6. +
  7. Success message: "Response approved"
  8. +
  9. Table refreshes with updated status
  10. +
  11. Effect:
  12. +
  13. Response status changes to APPROVED
  14. +
  15. Response now visible on public response wall
  16. +
  17. User receives confirmation email (if email verified)
  18. +
  19. Approve button hidden if response already APPROVED
  20. +
+

Rejecting a Response

+
    +
  1. Locate PENDING or APPROVED response in table
  2. +
  3. Click "Reject" button (red, CloseCircleOutlined icon)
  4. +
  5. Button shows loading spinner
  6. +
  7. Success message: "Response rejected"
  8. +
  9. Table refreshes with updated status
  10. +
  11. Effect:
  12. +
  13. Response status changes to REJECTED
  14. +
  15. Response hidden from public response wall
  16. +
  17. User does NOT receive notification (silent rejection)
  18. +
  19. Reject button hidden if response already REJECTED
  20. +
+

Resending Verification Email

+
    +
  1. Locate response with representativeEmail (not null)
  2. +
  3. Click "Verify" button (MailOutlined icon)
  4. +
  5. Button shows loading spinner
  6. +
  7. Success message: "Verification email sent"
  8. +
  9. Email sent to user with verification link
  10. +
  11. User clicks link → isVerified set to true
  12. +
  13. Verified responses show SafetyCertificateOutlined icon
  14. +
+

Verification flow: +1. User submits response on public page +2. System sends verification email to submittedByEmail +3. User clicks verification link in email +4. Backend marks response as verified (isVerified: true) +5. Verified responses show checkmark icon in table

+

Deleting a Response

+
    +
  1. Locate response in table
  2. +
  3. Click Delete icon button (DeleteOutlined, red)
  4. +
  5. Popconfirm: "Delete this response?"
  6. +
  7. Click "OK" to confirm
  8. +
  9. Button shows loading spinner
  10. +
  11. Success message: "Response deleted"
  12. +
  13. Table refreshes (response disappears)
  14. +
  15. Cascade behavior: Response record permanently deleted from database
  16. +
+

Warning: Deletion is permanent. No soft delete. Upvotes also deleted (cascade).

+

Viewing Response Details

+
    +
  1. Click any row in table
  2. +
  3. Detail drawer opens on right side (520px width)
  4. +
  5. Drawer content (Descriptions component with bordered layout):
  6. +
  7. Representative: Name + Title (if present)
  8. +
  9. Level: Government level tag (colored)
  10. +
  11. Type: Response type tag (EMAIL/PHONE)
  12. +
  13. Campaign: Campaign title
  14. +
  15. Status: Status tag (colored)
  16. +
  17. Verified: Yes/No + verified by + verified date (if verified)
  18. +
  19. Upvotes: Count
  20. +
  21. Response Text: Full response text (scrollable, max 300px height, pre-wrap)
  22. +
  23. User Comment: Additional comment (if present)
  24. +
  25. Submitted By: Name + Email OR "Anonymous" (if anonymous)
  26. +
  27. Submitted: Full timestamp (MMM D, YYYY h:mm A)
  28. +
  29. Click "X" or outside drawer to close
  30. +
+

Refreshing Data

+
    +
  1. Click "Refresh" button in page header
  2. +
  3. Table reloads with current filters applied
  4. +
  5. Fetches latest responses from API
  6. +
  7. Useful for checking new submissions without full page reload
  8. +
+

Component Breakdown

+

Ant Design Components Used

+
    +
  • Table — Main responses list with columns, pagination, row click handler
  • +
  • Input — Search bar with SearchOutlined prefix, 400ms debounce
  • +
  • Select — Status filter dropdown (3 options), Campaign filter dropdown (searchable)
  • +
  • Button — Refresh (header), Approve, Reject, Verify (table actions), Delete (icon button)
  • +
  • Tag — Government level tags, response type tags, status tags
  • +
  • Space — Action button grouping (allows wrapping)
  • +
  • Drawer — Response detail view (520px width, destroyOnClose)
  • +
  • Descriptions — Detail view with labeled fields (column: 1, bordered, size: small)
  • +
  • Popconfirm — Delete confirmation
  • +
  • Row, Col — Responsive grid for filters (3 columns on desktop, stacked on mobile)
  • +
  • SafetyCertificateOutlined — Verified email icon (green)
  • +
+

Table Columns

+
const columns: ColumnsType<RepresentativeResponse> = [
+  {
+    title: 'Representative',
+    dataIndex: 'representativeName',
+    ellipsis: true,
+  },
+  {
+    title: 'Level',
+    dataIndex: 'representativeLevel',
+    width: 120,
+    render: (level) => (
+      <Tag color={GOVERNMENT_LEVEL_COLORS[level]}>
+        {GOVERNMENT_LEVEL_LABELS[level]}
+      </Tag>
+    ),
+  },
+  {
+    title: 'Type',
+    dataIndex: 'responseType',
+    width: 110,
+    render: (type) => <Tag>{RESPONSE_TYPE_LABELS[type]}</Tag>,
+  },
+  {
+    title: 'Campaign',
+    width: 160,
+    ellipsis: true,
+    render: (_, record) => record.campaign?.title || '—',
+  },
+  {
+    title: 'Status',
+    dataIndex: 'status',
+    width: 100,
+    render: (status) => (
+      <Tag color={RESPONSE_STATUS_COLORS[status]}>{status}</Tag>
+    ),
+  },
+  {
+    title: 'Verified',
+    width: 80,
+    align: 'center',
+    responsive: ['md'],
+    render: (_, record) =>
+      record.isVerified ? (
+        <SafetyCertificateOutlined style={{ color: '#16a34a', fontSize: 16 }} />
+      ) : null,
+  },
+  {
+    title: 'Upvotes',
+    dataIndex: 'upvoteCount',
+    width: 80,
+    align: 'center',
+    responsive: ['md'],
+  },
+  {
+    title: 'Submitted',
+    dataIndex: 'createdAt',
+    width: 120,
+    responsive: ['sm'],
+    render: (date) => dayjs(date).format('MMM D, YYYY'),
+  },
+  {
+    title: 'Actions',
+    width: 200,
+    render: (_, record) => (
+      <Space size="small" wrap>
+        {record.status !== 'APPROVED' && (
+          <Button size="small" type="link" icon={<CheckCircleOutlined />} style={{ color: '#16a34a' }} loading={actionLoading === record.id} onClick={() => handleApprove(record.id)}>
+            Approve
+          </Button>
+        )}
+        {record.status !== 'REJECTED' && (
+          <Button size="small" type="link" icon={<CloseCircleOutlined />} danger loading={actionLoading === record.id} onClick={() => handleReject(record.id)}>
+            Reject
+          </Button>
+        )}
+        {record.representativeEmail && (
+          <Button size="small" type="link" icon={<MailOutlined />} loading={actionLoading === record.id} onClick={() => handleResendVerification(record.id)}>
+            Verify
+          </Button>
+        )}
+        <Popconfirm title="Delete this response?" onConfirm={() => handleDelete(record.id)}>
+          <Button size="small" type="link" icon={<DeleteOutlined />} danger loading={actionLoading === record.id} />
+        </Popconfirm>
+      </Space>
+    ),
+  },
+];
+
+

Key patterns: +- Conditional button rendering: Approve hidden if APPROVED, Reject hidden if REJECTED +- Verify button only shown if representativeEmail exists +- actionLoading state tracks which row's action is in progress +- wrap on Space allows buttons to wrap on narrow screens

+

Status Colors

+
export const RESPONSE_STATUS_COLORS = {
+  PENDING: 'gold',      // Yellow (awaiting moderation)
+  APPROVED: 'green',    // Green (visible on public wall)
+  REJECTED: 'red',      // Red (hidden from public)
+};
+
+

Government Level Colors

+
export const GOVERNMENT_LEVEL_COLORS = {
+  FEDERAL: 'blue',
+  PROVINCIAL: 'purple',
+  MUNICIPAL: 'cyan',
+  SCHOOL_BOARD: 'magenta',
+};
+
+

Response Type Labels

+
export const RESPONSE_TYPE_LABELS = {
+  EMAIL: 'Email',      // Sent via SMTP to representative
+  PHONE: 'Phone',      // Called representative
+};
+
+

State Management

+

Zustand Stores Used

+

None — Responses fetched from API on each interaction.

+

Local State

+
const [responses, setResponses] = useState<RepresentativeResponse[]>([]);
+const [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });
+const [loading, setLoading] = useState(false);
+const [actionLoading, setActionLoading] = useState<string | null>(null);  // Row ID with action in progress
+const [statusFilter, setStatusFilter] = useState<ResponseStatus | undefined>();
+const [campaignFilter, setCampaignFilter] = useState<string | undefined>();
+const [search, setSearch] = useState('');
+const [campaigns, setCampaigns] = useState<Campaign[]>([]);
+const [detailResponse, setDetailResponse] = useState<RepresentativeResponse | null>(null);
+const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
+
+ +
const handleSearch = (value: string) => {
+  if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
+  searchTimerRef.current = setTimeout(() => {
+    setSearch(value);
+  }, 400);
+};
+
+

Why 400ms? Slightly longer than other pages (300ms) — response text can be lengthy, giving users more time to type complete phrases.

+

Per-Row Action Loading

+
const [actionLoading, setActionLoading] = useState<string | null>(null);
+
+const handleApprove = async (id: string) => {
+  setActionLoading(id);  // Mark this row as loading
+  try {
+    await api.patch(`/responses/${id}/status`, { status: 'APPROVED' });
+    message.success('Response approved');
+    fetchResponses(pagination.page);
+  } catch {
+    message.error('Failed to approve response');
+  } finally {
+    setActionLoading(null);  // Clear loading state
+  }
+};
+
+

Pattern: Only the clicked button shows loading spinner, not all buttons in table.

+

API Integration

+

Endpoints Used

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointPurpose
GET/api/responsesList responses (paginated, filtered)
PATCH/api/responses/:id/statusUpdate response status (approve/reject)
POST/api/responses/:id/resend-verificationResend verification email
DELETE/api/responses/:idDelete response
GET/api/campaignsList campaigns for filter dropdown
+

List Responses

+

Request:

+
const { data } = await api.get<ResponsesListResponse>('/responses', {
+  params: {
+    page: 1,
+    limit: 20,
+    status: 'PENDING',           // Optional: filter by status
+    campaignId: 'cm-123',        // Optional: filter by campaign
+    search: 'climate change',    // Optional: search response text
+  },
+});
+
+

Response:

+
{
+  "responses": [
+    {
+      "id": "resp-123",
+      "representativeName": "Hon. Jane Smith",
+      "representativeTitle": "MP for Ottawa Centre",
+      "representativeLevel": "FEDERAL",
+      "representativeEmail": "jane.smith@parl.gc.ca",
+      "responseType": "EMAIL",
+      "responseText": "I contacted my MP about climate action and urged support for renewable energy legislation.",
+      "userComment": "Looking forward to their response!",
+      "status": "PENDING",
+      "isVerified": false,
+      "verifiedBy": null,
+      "verifiedAt": null,
+      "upvoteCount": 3,
+      "isAnonymous": false,
+      "submittedByName": "John Doe",
+      "submittedByEmail": "john@example.com",
+      "campaignId": "cm-456",
+      "campaign": {
+        "id": "cm-456",
+        "title": "Contact Your MP About Climate Action"
+      },
+      "createdAt": "2026-02-10T14:30:00.000Z",
+      "updatedAt": "2026-02-10T14:30:00.000Z"
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 20,
+    "total": 47,
+    "totalPages": 3
+  }
+}
+
+

Key fields: +- representativeEmail — Email address (may be null if not available from Represent API) +- isVerified — Email address verified by user clicking link +- verifiedBy / verifiedAt — Verification metadata +- upvoteCount — Denormalized counter (updated on upvote/unvote) +- isAnonymous — If true, hide submitter name/email on public wall

+

Update Response Status

+

Request (Approve):

+
await api.patch(`/responses/${responseId}/status`, { status: 'APPROVED' });
+
+

Request (Reject):

+
await api.patch(`/responses/${responseId}/status`, { status: 'REJECTED' });
+
+

Response:

+
{
+  "id": "resp-123",
+  "status": "APPROVED",
+  "updatedAt": "2026-02-11T10:15:00.000Z"
+}
+
+

Resend Verification Email

+

Request:

+
await api.post(`/responses/${responseId}/resend-verification`);
+
+

Response: 204 No Content

+

Email sent to: submittedByEmail with verification link

+

Verification link format:

+
https://app.cmlite.org/responses/verify?token={jwt_token}
+
+

Email template:

+
Subject: Verify Your Response Submission
+
+Hi {submittedByName},
+
+Please verify your email address by clicking the link below:
+
+{verification_link}
+
+This ensures your response is authentic and can be displayed publicly.
+
+Thank you for participating!
+Changemaker Lite
+
+

Delete Response

+

Request:

+
await api.delete(`/responses/${responseId}`);
+
+

Response: 204 No Content

+

Cascade behavior: +- ResponseUpvote records deleted (Prisma cascade)

+

Code Examples

+

Conditional Action Buttons

+
<Space size="small" wrap>
+  {/* Approve button: hidden if already APPROVED */}
+  {record.status !== 'APPROVED' && (
+    <Button
+      size="small"
+      type="link"
+      icon={<CheckCircleOutlined />}
+      style={{ color: '#16a34a' }}  // Green
+      loading={actionLoading === record.id}
+      onClick={() => handleApprove(record.id)}
+    >
+      Approve
+    </Button>
+  )}
+
+  {/* Reject button: hidden if already REJECTED */}
+  {record.status !== 'REJECTED' && (
+    <Button
+      size="small"
+      type="link"
+      icon={<CloseCircleOutlined />}
+      danger
+      loading={actionLoading === record.id}
+      onClick={() => handleReject(record.id)}
+    >
+      Reject
+    </Button>
+  )}
+
+  {/* Verify button: only shown if email address exists */}
+  {record.representativeEmail && (
+    <Button
+      size="small"
+      type="link"
+      icon={<MailOutlined />}
+      loading={actionLoading === record.id}
+      onClick={() => handleResendVerification(record.id)}
+    >
+      Verify
+    </Button>
+  )}
+
+  {/* Delete button: always shown */}
+  <Popconfirm title="Delete this response?" onConfirm={() => handleDelete(record.id)}>
+    <Button
+      size="small"
+      type="link"
+      icon={<DeleteOutlined />}
+      danger
+      loading={actionLoading === record.id}
+    />
+  </Popconfirm>
+</Space>
+
+

Pattern: Conditional rendering prevents confusing UI (can't approve an already-approved response).

+

Clickable Table Rows

+
<Table
+  columns={columns}
+  dataSource={responses}
+  rowKey="id"
+  loading={loading}
+  scroll={{ x: 900 }}  // Horizontal scroll for narrow screens
+  onRow={(record) => ({
+    onClick: () => setDetailResponse(record),  // Click row → open drawer
+    style: { cursor: 'pointer' },              // Show pointer cursor
+  })}
+  pagination={{
+    current: pagination.page,
+    pageSize: pagination.limit,
+    total: pagination.total,
+    showSizeChanger: false,
+    onChange: (page) => fetchResponses(page),
+  }}
+/>
+
+

Pattern: Entire row clickable (except action buttons use stopPropagation to prevent drawer opening when clicking button).

+

Detail Drawer with Conditional Verified Info

+
<Drawer
+  title="Response Details"
+  open={!!detailResponse}
+  onClose={() => setDetailResponse(null)}
+  width={520}
+  destroyOnClose
+>
+  {detailResponse && (
+    <Descriptions column={1} bordered size="small">
+      {/* ... other fields */}
+      <Descriptions.Item label="Verified">
+        {detailResponse.isVerified ? (
+          <Space>
+            <SafetyCertificateOutlined style={{ color: '#16a34a' }} />
+            {detailResponse.verifiedBy && `by ${detailResponse.verifiedBy}`}
+            {detailResponse.verifiedAt && ` on ${dayjs(detailResponse.verifiedAt).format('MMM D, YYYY')}`}
+          </Space>
+        ) : 'No'}
+      </Descriptions.Item>
+      <Descriptions.Item label="Response Text">
+        <div style={{ whiteSpace: 'pre-wrap', maxHeight: 300, overflow: 'auto' }}>
+          {detailResponse.responseText}
+        </div>
+      </Descriptions.Item>
+      <Descriptions.Item label="Submitted By">
+        {detailResponse.isAnonymous
+          ? 'Anonymous'
+          : `${detailResponse.submittedByName || '—'} (${detailResponse.submittedByEmail || '—'})`}
+      </Descriptions.Item>
+    </Descriptions>
+  )}
+</Drawer>
+
+

Pattern: +- whiteSpace: 'pre-wrap' preserves line breaks in response text +- maxHeight: 300px + overflow: 'auto' for scrolling long responses +- Conditional rendering for verified metadata

+

Performance Considerations

+

Debounced Search (400ms)

+

Longer debounce than other pages: +- Response text can be several paragraphs +- Users need time to type complete search phrases +- Reduces API calls during typing

+

Per-Row Action Loading

+
const [actionLoading, setActionLoading] = useState<string | null>(null);
+
+

Benefits: +- Only one button shows spinner at a time +- Other rows remain interactive +- Better UX than disabling entire table

+

useCallback Optimization

+
const fetchResponses = useCallback(async (page = 1) => {
+  // ... fetch logic
+}, [statusFilter, campaignFilter, search]);
+
+

Memoized function prevents unnecessary re-fetches.

+

Responsive Design

+

Mobile (< 576px)

+
    +
  • Filters: Stacked vertically (xs={24})
  • +
  • Search bar: Full width
  • +
  • Status filter: Full width below search
  • +
  • Campaign filter: Full width below status
  • +
  • Table: Minimal columns
  • +
  • Visible: Representative, Level, Type, Campaign, Status, Actions
  • +
  • Hidden: Verified, Upvotes, Submitted
  • +
  • Detail drawer: Full screen overlay (width: 100vw)
  • +
+

Tablet (576px - 992px)

+
    +
  • Filters: 3 columns (search: sm={8}, status: sm={5}, campaign: sm={7})
  • +
  • Table: Submitted column visible (responsive: ['sm'])
  • +
  • Detail drawer: 520px overlay (right side)
  • +
+

Desktop (≥ 992px)

+
    +
  • Filters: Compact layout
  • +
  • Table: All columns visible (Verified, Upvotes: responsive: ['md'])
  • +
  • Detail drawer: 520px overlay
  • +
+

Accessibility

+
    +
  • Keyboard navigation: All buttons focusable via Tab
  • +
  • ARIA labels: Icon-only buttons have title attribute
  • +
  • Color coding: Status tags use color + text (not color alone)
  • +
  • Screen reader support: Descriptions component labels properly associated
  • +
  • Focus management: Drawer auto-focuses on open
  • +
+

Troubleshooting

+

Verify Button Not Showing

+

Problem: Response has email in table, but Verify button missing.

+

Diagnosis:

+

Check representativeEmail field: +

{record.representativeEmail && (
+  <Button icon={<MailOutlined />} onClick={() => handleResendVerification(record.id)}>
+    Verify
+  </Button>
+)}
+

+

Common Issues:

+
    +
  1. Email null in database:
  2. +
  3. Represent API didn't return email for this representative
  4. +
  5. Some reps don't have public emails
  6. +
  7. +

    Solution: Not fixable (representative doesn't have email)

    +
  8. +
  9. +

    Email not fetched from API:

    +
  10. +
  11. Check API response includes representativeEmail field
  12. +
  13. Solution: Ensure backend includes field in response serialization
  14. +
+

Solution: Verify button only shown if email exists. No email = no verification possible.

+
+

Approved Response Not Showing on Public Wall

+

Problem: Approve response, but doesn't appear on /responses/:campaignId page.

+

Diagnosis:

+

Check campaign showResponseWall flag: +1. Navigate to /app/influence/campaigns +2. Edit campaign +3. Verify "Show Response Wall" toggle is ON

+

Common Issues:

+
    +
  1. Response wall disabled for campaign:
  2. +
  3. Campaign showResponseWall is false
  4. +
  5. +

    Solution: Edit campaign, enable Show Response Wall, save

    +
  6. +
  7. +

    Response not verified:

    +
  8. +
  9. Some campaigns require verified responses only
  10. +
  11. +

    Solution: Click Verify button, user clicks email link

    +
  12. +
  13. +

    Browser cache:

    +
  14. +
  15. Hard refresh public page (Ctrl+Shift+R)
  16. +
+

Solution: Ensure campaign has response wall enabled + response is approved.

+
+

Verification Email Not Sending

+

Problem: Click Verify button → Success message → User doesn't receive email.

+

Diagnosis:

+

Check SMTP configuration: +1. Navigate to Settings → Email tab +2. Verify active provider: Production (not MailHog) +3. Click "Test Connection" → Should succeed

+

Common Issues:

+
    +
  1. MailHog active (dev mode):
  2. +
  3. Check MailHog UI: http://localhost:8025
  4. +
  5. Email sent to MailHog instead of real inbox
  6. +
  7. +

    Solution: Switch to Production provider in Settings

    +
  8. +
  9. +

    SMTP credentials invalid:

    +
  10. +
  11. Test connection fails
  12. +
  13. +

    Solution: Update SMTP credentials, re-test

    +
  14. +
  15. +

    Spam folder:

    +
  16. +
  17. Email marked as spam
  18. +
  19. Solution: Check user's spam folder, whitelist sender
  20. +
+

Solution: Verify SMTP settings, test connection, check MailHog vs Production.

+
+

Delete Button Deletes Immediately

+

Problem: Click Delete icon → Response deletes without confirmation.

+

Diagnosis:

+

Check Popconfirm placement: +

<Popconfirm title="Delete this response?" onConfirm={() => handleDelete(record.id)}>
+  <Button icon={<DeleteOutlined />} />
+</Popconfirm>
+

+

Solution: Popconfirm should wrap Button. If missing, delete happens immediately (bad UX).

+
+

Campaign Filter Dropdown Empty

+

Problem: Click Campaign filter → No options in dropdown.

+

Diagnosis:

+

Check campaigns API endpoint: +

curl http://localhost:4000/api/campaigns?limit=100
+

+

Common Issues:

+
    +
  1. No campaigns created:
  2. +
  3. Navigate to /app/influence/campaigns
  4. +
  5. Create at least one campaign
  6. +
  7. +

    Return to responses page

    +
  8. +
  9. +

    Campaigns API failing:

    +
  10. +
  11. Check API logs: docker compose logs api | grep "campaigns"
  12. +
  13. Verify database connection
  14. +
+

Solution: Create at least one campaign before filtering responses.

+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/settings-page/index.html b/mkdocs/site/v2/frontend/pages/admin/settings-page/index.html new file mode 100644 index 00000000..e200bba7 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/settings-page/index.html @@ -0,0 +1,7170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Settings - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

SettingsPage

+

Overview

+

The SettingsPage provides a centralized interface for configuring all system-wide settings including organization branding, theme colors, email (SMTP), and feature toggles. It uses a tabbed interface with separate sections for each settings category.

+

Route: /app/settings +Component: admin/src/pages/SettingsPage.tsx (420 lines) +Auth Required: Yes (SUPER_ADMIN role recommended for production) +Layout: AppLayout

+

Screenshot

+

[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.]

+

Features

+
    +
  • Tabbed interface — 4 organized sections:
  • +
  • Organization (branding, logo, footer)
  • +
  • Theme Colors (admin + public themes with live preview)
  • +
  • Email (SMTP configuration with dual providers)
  • +
  • Feature Toggles (enable/disable modules)
  • +
  • SMTP provider switching — Toggle between MailHog (dev) and Production
  • +
  • Live theme preview — Color swatches + gradient preview
  • +
  • SMTP testing — Test connection + send test email
  • +
  • Form persistence — Settings loaded from Zustand store
  • +
  • Optimistic updates — Immediate UI feedback on save
  • +
  • ColorPicker integration — Visual color selection with hex output
  • +
  • Segmented control — Large toggle for SMTP provider switching
  • +
+

User Workflow

+

Updating Organization Settings

+
    +
  1. Navigate to /app/settings
  2. +
  3. Verify "Organization" tab is selected (default)
  4. +
  5. Modify fields:
  6. +
  7. Organization Name
  8. +
  9. Short Name (max 10 chars, shown in collapsed sidebar)
  10. +
  11. Logo URL
  12. +
  13. Favicon URL
  14. +
  15. Footer Text
  16. +
  17. Login Subtitle
  18. +
  19. Click "Save Settings" button at bottom
  20. +
  21. Success message: "Settings saved successfully"
  22. +
  23. Changes apply immediately (refresh not required)
  24. +
+

Customizing Theme Colors

+
    +
  1. Click "Theme Colors" tab
  2. +
  3. Modify Admin Theme colors:
  4. +
  5. Primary Color (ColorPicker)
  6. +
  7. Background Color (ColorPicker)
  8. +
  9. Modify Public Theme colors:
  10. +
  11. Primary Color
  12. +
  13. Background Color
  14. +
  15. Container Color
  16. +
  17. Header Gradient (CSS gradient string)
  18. +
  19. View live preview swatches below form
  20. +
  21. Click "Save Settings"
  22. +
  23. Theme updates apply on next page load
  24. +
+

Configuring SMTP Email

+
    +
  1. Click "Email" tab
  2. +
  3. Set Sender info:
  4. +
  5. From Name (e.g., "Changemaker Lite")
  6. +
  7. From Address (e.g., "noreply@cmlite.org")
  8. +
  9. Switch SMTP Provider:
  10. +
  11. Click MailHog or Production segment
  12. +
  13. Confirmation: "Switched to [provider] SMTP"
  14. +
  15. Configure Production SMTP:
  16. +
  17. SMTP Host (e.g., smtp.protonmail.ch)
  18. +
  19. SMTP Port (587 for STARTTLS, 465 for SSL)
  20. +
  21. SMTP User
  22. +
  23. SMTP Password
  24. +
  25. Enable Test Mode (optional):
  26. +
  27. Toggle "Enable Test Mode" switch
  28. +
  29. Set Test Recipient email
  30. +
  31. All emails redirect to test recipient
  32. +
  33. Click "Save Settings"
  34. +
  35. Test configuration:
  36. +
  37. Click "Test Connection" → Verify "Connection successful"
  38. +
  39. Click "Send Test Email" → Check inbox for test message
  40. +
+

Testing SMTP Configuration

+
    +
  1. Navigate to Email tab
  2. +
  3. Ensure production credentials are saved
  4. +
  5. Switch to "Production" provider
  6. +
  7. Click "Test Connection" button
  8. +
  9. Wait for result (success/error alert)
  10. +
  11. If successful, click "Send Test Email"
  12. +
  13. Check email inbox for test message
  14. +
  15. If failed, review error message and fix credentials
  16. +
+

Enabling/Disabling Features

+
    +
  1. Click "Feature Toggles" tab
  2. +
  3. Toggle switches:
  4. +
  5. Enable Influence (campaigns, responses, reps)
  6. +
  7. Enable Map (locations, cuts, shifts, canvassing)
  8. +
  9. Enable Newsletter (Listmonk integration)
  10. +
  11. Enable Landing Pages (page builder)
  12. +
  13. Info alert: "Disabling a module hides it from navigation but does not delete data"
  14. +
  15. Click "Save Settings"
  16. +
  17. Navigation menu updates to hide/show disabled modules
  18. +
+

Component Breakdown

+

Ant Design Components Used

+
    +
  • Typography.Text — Labels, descriptions
  • +
  • Tabs — Main navigation (4 tabs)
  • +
  • Form — All settings wrapped in single form instance
  • +
  • Form.Item — Individual fields with labels + extra descriptions
  • +
  • Input — Text fields (org name, logo URL, SMTP host, etc.)
  • +
  • Input.Password — SMTP password field (masked)
  • +
  • InputNumber — SMTP port (numeric, min 0, max 65535)
  • +
  • Switch — Boolean toggles (test mode, feature flags)
  • +
  • ColorPicker — Color selection with hex preview
  • +
  • Segmented — SMTP provider toggle (large button style)
  • +
  • Tag — Active provider indicator (green)
  • +
  • Alert — Info messages, connection/send test results
  • +
  • Divider — Section separators
  • +
  • Space — Button grouping
  • +
  • Button — Test actions + save button
  • +
  • Spin — Loading indicator during initial settings fetch
  • +
+

Tab Structure

+
const items = [
+  {
+    key: 'organization',
+    label: 'Organization',
+    icon: <SettingOutlined />,
+    children: (/* Organization form fields */)
+  },
+  {
+    key: 'theme',
+    label: 'Theme Colors',
+    children: (/* Theme form fields */)
+  },
+  {
+    key: 'email',
+    label: 'Email',
+    children: (/* Email form fields */)
+  },
+  {
+    key: 'features',
+    label: 'Feature Toggles',
+    children: (/* Feature toggle switches */)
+  },
+];
+
+return (
+  <Form form={form} layout="vertical">
+    <Tabs items={items} />
+    <Button type="primary" icon={<SaveOutlined />} onClick={handleSave}>
+      Save Settings
+    </Button>
+  </Form>
+);
+
+

Color Swatch Preview

+
function Swatch({ label, color }: { label: string; color: string }) {
+  return (
+    <div style={{ textAlign: 'center' }}>
+      <div
+        style={{
+          width: 48,
+          height: 48,
+          borderRadius: 8,
+          background: color,
+          border: '2px solid rgba(255,255,255,0.2)',
+          marginBottom: 4,
+        }}
+      />
+      <Text style={{ fontSize: 11 }}>{label}</Text>
+    </div>
+  );
+}
+
+

State Management

+

Zustand Store Used

+
    +
  • settings.store — Centralized settings state
  • +
  • settings — Current settings object
  • +
  • loading — Loading state
  • +
  • fetchAdminSettings() — Load settings from API
  • +
  • updateSettings(partial) — Update and persist settings
  • +
+
import { useSettingsStore } from '@/stores/settings.store';
+
+const { settings, loading, fetchAdminSettings, updateSettings } = useSettingsStore();
+
+useEffect(() => {
+  fetchAdminSettings();
+}, [fetchAdminSettings]);
+
+

Local State

+
const [form] = Form.useForm();
+const [testingConnection, setTestingConnection] = useState(false);
+const [connectionResult, setConnectionResult] = useState<SmtpTestResult | null>(null);
+const [sendingTest, setSendingTest] = useState(false);
+const [sendResult, setSendResult] = useState<SmtpSendTestResult | null>(null);
+
+

Form Initialization

+
useEffect(() => {
+  if (settings) {
+    form.setFieldsValue(settings);
+  }
+}, [settings, form]);
+
+

When settings load from store, form automatically populates with current values.

+

API Integration

+

Endpoints Used

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointPurpose
GET/api/settingsLoad settings (via store)
PUT/api/settingsUpdate settings
POST/api/settings/email/test-connectionTest SMTP connection
POST/api/settings/email/test-sendSend test email
+

Save Settings

+
const handleSave = async () => {
+  try {
+    const values = form.getFieldsValue();
+
+    // Convert ColorPicker values to hex strings
+    const colorFields = [
+      'adminColorPrimary',
+      'adminColorBgBase',
+      'publicColorPrimary',
+      'publicColorBgBase',
+      'publicColorBgContainer',
+    ] as const;
+
+    for (const field of colorFields) {
+      const val = values[field];
+      if (val && typeof val === 'object' && 'toHexString' in val) {
+        values[field] = val.toHexString();
+      }
+    }
+
+    await updateSettings(values);
+    setConnectionResult(null);
+    setSendResult(null);
+    message.success('Settings saved successfully');
+  } catch {
+    message.error('Failed to save settings');
+  }
+};
+
+

Request Payload:

+
{
+  "organizationName": "Changemaker Lite",
+  "organizationShortName": "CML",
+  "organizationLogoUrl": "https://example.com/logo.png",
+  "smtpHost": "smtp.protonmail.ch",
+  "smtpPort": 587,
+  "smtpUser": "user@example.com",
+  "smtpPass": "***",
+  "smtpActiveProvider": "production",
+  "adminColorPrimary": "#1890ff",
+  "publicColorPrimary": "#3498db",
+  "enableInfluence": true,
+  "enableMap": true
+}
+
+

Test SMTP Connection

+
const handleTestConnection = async () => {
+  setTestingConnection(true);
+  setConnectionResult(null);
+  try {
+    const { data } = await api.post<SmtpTestResult>('/settings/email/test-connection');
+    setConnectionResult(data);
+  } catch {
+    setConnectionResult({ success: false, message: 'Request failed' });
+  } finally {
+    setTestingConnection(false);
+  }
+};
+
+

Response (Success):

+
{
+  "success": true,
+  "message": "Connection successful"
+}
+
+

Response (Failure):

+
{
+  "success": false,
+  "message": "Connection failed: Authentication failed"
+}
+
+

Send Test Email

+
const handleSendTest = async () => {
+  setSendingTest(true);
+  setSendResult(null);
+  try {
+    const to = form.getFieldValue('testEmailRecipient');
+    const { data } = await api.post<SmtpSendTestResult>('/settings/email/test-send', { to });
+    setSendResult(data);
+  } catch {
+    setSendResult({ success: false, testMode: false, recipient: '' });
+  } finally {
+    setSendingTest(false);
+  }
+};
+
+

Request:

+
{
+  "to": "admin@example.com"
+}
+
+

Response (Success):

+
{
+  "success": true,
+  "testMode": false,
+  "recipient": "admin@example.com",
+  "messageId": "<abc123@smtp.protonmail.ch>"
+}
+
+

Toggle SMTP Provider

+
const handleProviderToggle = async (value: string | number) => {
+  const provider = value as 'mailhog' | 'production';
+  try {
+    await updateSettings({ smtpActiveProvider: provider });
+    form.setFieldsValue({ smtpActiveProvider: provider });
+    message.success(`Switched to ${provider === 'mailhog' ? 'MailHog' : 'Production'} SMTP`);
+    setConnectionResult(null);
+    setSendResult(null);
+  } catch {
+    message.error('Failed to switch SMTP provider');
+  }
+};
+
+

Why clear test results?

+

Test results are provider-specific. Switching providers invalidates previous test results.

+

ColorPicker Integration

+

Converting Color Values

+

Ant Design ColorPicker returns an object with toHexString() method:

+
// ColorPicker value
+const colorValue = {
+  toHexString: () => '#1890ff',
+  // ... other methods
+};
+
+// Convert before saving
+for (const field of colorFields) {
+  const val = values[field];
+  if (val && typeof val === 'object' && 'toHexString' in val) {
+    values[field] = val.toHexString();
+  }
+}
+
+

Theme Preview

+
{settings && (
+  <div style={{ marginTop: 24 }}>
+    <Text strong style={{ fontSize: 15 }}>Preview</Text>
+    <Divider style={{ margin: '12px 0' }} />
+    <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
+      <Swatch label="Admin Primary" color={settings.adminColorPrimary} />
+      <Swatch label="Admin BG" color={settings.adminColorBgBase} />
+      <Swatch label="Public Primary" color={settings.publicColorPrimary} />
+      <Swatch label="Public BG" color={settings.publicColorBgBase} />
+      <Swatch label="Public Container" color={settings.publicColorBgContainer} />
+    </div>
+    <div
+      style={{
+        marginTop: 12,
+        padding: '12px 24px',
+        background: settings.publicHeaderGradient,
+        borderRadius: 8,
+        color: '#fff',
+        fontWeight: 600,
+      }}
+    >
+      Header Gradient Preview
+    </div>
+  </div>
+)}
+
+

Performance Considerations

+

Single Form Instance

+

All settings use one form instance:

+
const [form] = Form.useForm();
+
+<Form form={form} layout="vertical">
+  <Tabs items={items} />
+  <Button onClick={handleSave}>Save Settings</Button>
+</Form>
+
+

Benefits:

+
    +
  • Single save operation — One API call saves all modified fields
  • +
  • Consistent validation — All fields validated together
  • +
  • Simplified state — No need to track which tab has changes
  • +
+

Optimistic Provider Switching

+

Provider toggle updates immediately without waiting for API:

+
await updateSettings({ smtpActiveProvider: provider });
+form.setFieldsValue({ smtpActiveProvider: provider });  // Update form immediately
+message.success(`Switched to ${provider}`);
+
+

Why optimistic?

+
    +
  • Instant feedback — User sees immediate response
  • +
  • Better UX — No loading delay for simple toggle
  • +
  • Safe operation — Provider toggle is low-risk (can always switch back)
  • +
+

Troubleshooting

+

SMTP Test Connection Failing

+

Problem: Click "Test Connection" → Error: "Connection failed: Authentication failed"

+

Diagnosis:

+

Check SMTP credentials:

+
{
+  smtpHost: "smtp.protonmail.ch",
+  smtpPort: 587,
+  smtpUser: "user@protonmail.com",
+  smtpPass: "***"
+}
+
+

Common Issues:

+
    +
  1. Wrong port:
  2. +
  3. Use 587 for STARTTLS
  4. +
  5. Use 465 for SSL/TLS
  6. +
  7. +

    Port 25 often blocked by ISPs

    +
  8. +
  9. +

    App-specific password required:

    +
  10. +
  11. Gmail requires app-specific passwords (not account password)
  12. +
  13. +

    ProtonMail requires ProtonMail Bridge for SMTP

    +
  14. +
  15. +

    Wrong provider selected:

    +
  16. +
  17. Ensure "Production" is selected before testing production credentials
  18. +
+

Solution:

+
    +
  1. Verify credentials with email provider documentation
  2. +
  3. Switch to "Production" provider
  4. +
  5. Save settings before testing
  6. +
  7. Check firewall rules (port 587/465 outbound)
  8. +
+
+

Theme Colors Not Applying

+

Problem: Change colors, save settings, but theme doesn't update.

+

Diagnosis:

+

Check if page reload is required:

+
// Theme updates apply on NEXT page load, not immediately
+await updateSettings({ adminColorPrimary: '#ff0000' });
+// Current page still shows old color
+
+

Solution:

+

Refresh page after saving theme changes:

+
const handleSave = async () => {
+  await updateSettings(values);
+  message.success('Settings saved. Refreshing page...');
+  setTimeout(() => window.location.reload(), 1000);
+};
+
+
+

Feature Toggle Not Hiding Module

+

Problem: Disable "Enable Influence" toggle, save, but Influence menu items still visible.

+

Diagnosis:

+

Check AppLayout navigation logic:

+
// AppLayout should check settings.enableInfluence
+{settings.enableInfluence && (
+  <SubMenu key="influence" title="Influence">
+    {/* Influence menu items */}
+  </SubMenu>
+)}
+
+

Solution:

+

Ensure AppLayout reads settings from store and conditionally renders menu items.

+
+

Test Email Not Sending

+

Problem: Click "Send Test Email" → Success message, but no email in inbox.

+

Diagnosis:

+
    +
  1. +

    Check active provider: +

    settings.smtpActiveProvider === 'mailhog' // MailHog (dev)
    +settings.smtpActiveProvider === 'production' // Real SMTP
    +

    +
  2. +
  3. +

    Check test mode: +

    settings.emailTestMode === true // All emails redirect to testEmailRecipient
    +

    +
  4. +
  5. +

    Check spam folder

    +
  6. +
  7. +

    Check MailHog web UI (http://localhost:8025) if MailHog is active

    +
  8. +
+

Solution:

+
    +
  • Switch to "Production" provider
  • +
  • Disable test mode if you want emails to go to actual recipients
  • +
  • Save settings before sending test email
  • +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/shifts-page/index.html b/mkdocs/site/v2/frontend/pages/admin/shifts-page/index.html new file mode 100644 index 00000000..356fcda2 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/shifts-page/index.html @@ -0,0 +1,8212 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Shifts - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

ShiftsPage

+

Overview

+

The ShiftsPage provides complete volunteer shift management for the Map module, enabling administrators to schedule canvassing shifts, manage volunteer signups, and coordinate field operations. Features include date/time scheduling, volunteer capacity tracking, public shift publishing, area (cut) assignment, signup management, and bulk email notifications to confirmed volunteers.

+

Route: /app/map/shifts +Component: admin/src/pages/ShiftsPage.tsx (757 lines) +Auth Required: Yes (SUPER_ADMIN, MAP_ADMIN roles) +Layout: AppLayout

+

Screenshot

+

[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 — 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.]

+

Features

+

Core Features

+
    +
  • Full CRUD operations — Create, read, update, delete shifts
  • +
  • Advanced search — 300ms debounced search by title or location
  • +
  • Status filtering — Filter by OPEN, FULL, CANCELLED, COMPLETED
  • +
  • Statistics dashboard — 6 cards showing total, open, full, cancelled, upcoming, total signups
  • +
  • Date/time scheduling — Date picker + time pickers with 5-minute intervals
  • +
  • Volunteer capacity — Max volunteers setting with progress bar visualization
  • +
  • Area assignment — Assign shift to a canvass cut (area)
  • +
  • Public publishing — Toggle to show shift on public /shifts page
  • +
  • Clickable rows — Click any row to open signups drawer
  • +
  • Responsive table — Columns hide on smaller screens (Time: md+, Location: lg+, Area: md+)
  • +
+

Signup Management

+
    +
  • Signups drawer — Click shift row to view all confirmed volunteers
  • +
  • Manual signup — Add volunteer by email + name (creates temp user if needed)
  • +
  • Signup source tracking — PUBLIC (self-signup), ADMIN (added by admin)
  • +
  • Remove volunteers — Delete button for each confirmed signup
  • +
  • Email all volunteers — Bulk email button in drawer header
  • +
  • Signup stats — Progress bar in table shows current/max volunteers
  • +
  • Auto-status management — Shift status auto-updates to FULL when capacity reached
  • +
+

Shift Status Workflow

+
    +
  1. OPEN — Shift created, accepting signups
  2. +
  3. FULL — Max volunteers reached (currentVolunteers >= maxVolunteers)
  4. +
  5. CANCELLED — Shift cancelled by admin
  6. +
  7. COMPLETED — Shift date passed (auto-marked by backend)
  8. +
+

User Workflow

+

Viewing Shifts List

+
    +
  1. Navigate to /app/map/shifts
  2. +
  3. Page loads with statistics cards at top:
  4. +
  5. Total: All shifts count
  6. +
  7. Open: Shifts accepting signups (green)
  8. +
  9. Full: Shifts at capacity (orange)
  10. +
  11. Cancelled: Admin-cancelled shifts (red)
  12. +
  13. Upcoming: Future shifts (blue)
  14. +
  15. Signups: Total confirmed volunteers across all shifts
  16. +
  17. Table shows first 20 shifts (paginated)
  18. +
  19. View shift details:
  20. +
  21. Title (bold)
  22. +
  23. Date (YYYY-MM-DD)
  24. +
  25. Time (HH:mm — HH:mm format)
  26. +
  27. Location (e.g., "Campaign HQ, 123 Main St")
  28. +
  29. Area (cut name if assigned)
  30. +
  31. Volunteers (progress bar X/Y)
  32. +
  33. Status tag (color-coded)
  34. +
  35. Public checkmark icon (if published)
  36. +
  37. Actions (edit, delete)
  38. +
  39. Click any row to open signups drawer
  40. +
+

Creating a Shift

+
    +
  1. Click "Create Shift" button in page header
  2. +
  3. Modal opens (560px width) with vertical form
  4. +
  5. Fill required fields:
  6. +
  7. Title — Shift name (e.g., "Door Knocking", "Phone Banking")
  8. +
  9. Date — Date picker (calendar popup)
  10. +
  11. Start Time — Time picker (HH:mm, 5-minute intervals)
  12. +
  13. End Time — Time picker (HH:mm, 5-minute intervals)
  14. +
  15. Max Volunteers — Number input (min: 1)
  16. +
  17. Fill optional fields:
  18. +
  19. Description — Multi-line text (shift details + instructions)
  20. +
  21. Location — Text (e.g., "Campaign HQ, 123 Main St")
  22. +
  23. Area (Cut) — Dropdown (select canvass area, searchable)
  24. +
  25. Public — Switch toggle (default: false)
  26. +
  27. Click "Create" button
  28. +
  29. Success message: "Shift created"
  30. +
  31. Modal closes, table refreshes to page 1, stats refresh
  32. +
  33. New shift appears with status OPEN
  34. +
+

Editing a Shift

+
    +
  1. Locate shift in table
  2. +
  3. Click Edit icon button (EditOutlined) in Actions column
  4. +
  5. Drawer opens on right side (520px width) with vertical form
  6. +
  7. Modify any fields (same as create, plus Status dropdown):
  8. +
  9. Status options: OPEN, FULL, CANCELLED, COMPLETED
  10. +
  11. Click "Save" button in drawer header
  12. +
  13. Success message: "Shift updated"
  14. +
  15. Drawer closes, table refreshes, stats refresh
  16. +
+

Publishing a Shift to Public Page

+
    +
  1. Open shift in edit drawer
  2. +
  3. Toggle "Public" switch to ON
  4. +
  5. Click "Save"
  6. +
  7. Shift now visible on public /shifts page
  8. +
  9. Users can self-signup via public page
  10. +
  11. Signups source tracked as PUBLIC
  12. +
+

Assigning a Cut (Area) to Shift

+
    +
  1. Open shift in edit drawer
  2. +
  3. Click "Area (Cut)" dropdown
  4. +
  5. Search for cut by name
  6. +
  7. Select cut from list
  8. +
  9. Click "Save"
  10. +
  11. Volunteer portal integration:
  12. +
  13. Volunteers assigned to this shift now see it in /volunteer/assignments page
  14. +
  15. Shift with cut enables volunteer canvassing workflow
  16. +
  17. No cut = general shift (no canvass area)
  18. +
+

Viewing Shift Signups

+
    +
  1. Click any shift row in table
  2. +
  3. Signups drawer opens on right side (640px width)
  4. +
  5. Drawer header shows:
  6. +
  7. TeamOutlined icon + "Signups — {Shift Title}"
  8. +
  9. "Email All" button in header (disabled if no confirmed volunteers)
  10. +
  11. Info card at top displays shift summary:
  12. +
  13. Date
  14. +
  15. Time (start — end)
  16. +
  17. Volunteers (current / max)
  18. +
  19. Table shows confirmed volunteers:
  20. +
  21. Columns: Email, Name, Phone, Source (PUBLIC/ADMIN tag), Date, Remove button
  22. +
  23. Pagination: 20 per page (if > 20 signups)
  24. +
  25. Cancelled signups hidden (filtered out)
  26. +
  27. Add volunteer section at bottom:
  28. +
  29. Email input (required)
  30. +
  31. Name input (optional)
  32. +
  33. "Add" button (disabled if email empty)
  34. +
+

Manually Adding a Volunteer

+
    +
  1. Open signups drawer for any shift
  2. +
  3. Scroll to bottom "Add volunteer" section
  4. +
  5. Enter email (required)
  6. +
  7. Enter name (optional)
  8. +
  9. Click "Add" button
  10. +
  11. Backend logic:
  12. +
  13. If user exists: Create ShiftSignup record
  14. +
  15. If user doesn't exist: Create temp User + ShiftSignup
  16. +
  17. Signup source: ADMIN
  18. +
  19. Signup status: CONFIRMED
  20. +
  21. Success message: "Volunteer added"
  22. +
  23. Table refreshes with new volunteer
  24. +
  25. Email and name inputs clear
  26. +
  27. Main shifts table progress bar updates
  28. +
+

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

+

Removing a Volunteer

+
    +
  1. Open signups drawer
  2. +
  3. Locate volunteer in table
  4. +
  5. Click Delete icon button (red, last column)
  6. +
  7. Popconfirm: "Remove this volunteer?"
  8. +
  9. Click "OK"
  10. +
  11. Success message: "Volunteer removed"
  12. +
  13. Table refreshes (volunteer row disappears)
  14. +
  15. Main shifts table progress bar updates
  16. +
  17. Shift status may change from FULL to OPEN if capacity now available
  18. +
+

Emailing All Volunteers

+
    +
  1. Open signups drawer with confirmed volunteers
  2. +
  3. Click "Email All" button in drawer header
  4. +
  5. Backend sends email to all confirmed volunteers:
  6. +
  7. Email template: Shift details (title, date, time, location, description)
  8. +
  9. Subject: "Shift Reminder: {Shift Title}"
  10. +
  11. From: Site settings sender (e.g., "Changemaker Lite noreply@cmlite.org")
  12. +
  13. Success message: "Emailed N volunteer(s)" (or "N sent, M failed" if failures)
  14. +
  15. Email uses SMTP settings from Settings page
  16. +
+

Searching and Filtering

+
    +
  1. Search bar (top left):
  2. +
  3. Type title or location keywords
  4. +
  5. 300ms debounce (waits for typing pause)
  6. +
  7. Search resets pagination to page 1
  8. +
  9. Status filter dropdown (top right):
  10. +
  11. Select OPEN, FULL, CANCELLED, or COMPLETED
  12. +
  13. Filter resets pagination to page 1
  14. +
  15. Clear to show all shifts
  16. +
  17. Filters persist during pagination
  18. +
+

Deleting a Shift

+
    +
  1. Locate shift in table
  2. +
  3. Click Delete icon button (DeleteOutlined) in Actions column
  4. +
  5. Popconfirm: "Delete this shift?"
  6. +
  7. Click "OK" to confirm
  8. +
  9. Success message: "Shift deleted"
  10. +
  11. Table refreshes, stats refresh
  12. +
  13. Cascade behavior: All ShiftSignup records also deleted (Prisma cascade)
  14. +
+

Component Breakdown

+

Ant Design Components Used

+
    +
  • Table — Main shifts list + signups table in drawer
  • +
  • Button — Create (primary), edit, delete, add volunteer, email all
  • +
  • Input — Search bar, email input, name input (signups drawer)
  • +
  • Select — Status filter dropdown, area (cut) dropdown
  • +
  • Tag — Status tags (color-coded), signup source tags
  • +
  • Space — Action button grouping, drawer header
  • +
  • Card — Statistics cards (6 cards), shift summary card in signups drawer
  • +
  • Statistic — Numeric displays with icons + prefixes
  • +
  • Progress — Volunteer capacity progress bar (in Volunteers column)
  • +
  • Modal — Create shift form
  • +
  • Drawer — Edit shift (520px), signups drawer (640px)
  • +
  • Form — Create/edit shift forms
  • +
  • Form.Item — Field wrappers with labels + rules
  • +
  • Input.TextArea — Description field (multi-line)
  • +
  • DatePicker — Date selection with calendar popup
  • +
  • TimePicker — Time selection with hour/minute dropdowns (5-minute steps)
  • +
  • InputNumber — Max volunteers numeric input (min: 1)
  • +
  • Switch — Public toggle (valuePropName="checked")
  • +
  • Row, Col — Responsive grid for stats cards, date/time fields
  • +
  • Popconfirm — Delete confirmation (shift + volunteer removal)
  • +
  • Typography.Text — Labels, descriptions
  • +
+

Table Columns (Main Shifts Table)

+
const columns: ColumnsType<Shift> = [
+  {
+    title: 'Title',
+    dataIndex: 'title',
+    render: (title) => <span style={{ fontWeight: 500 }}>{title}</span>,
+  },
+  {
+    title: 'Date',
+    dataIndex: 'date',
+    render: (date) => dayjs(date).format('YYYY-MM-DD'),
+  },
+  {
+    title: 'Time',
+    render: (_, record) => `${record.startTime}${record.endTime}`,
+    responsive: ['md'],
+  },
+  {
+    title: 'Location',
+    dataIndex: 'location',
+    render: (loc) => loc || '--',
+    responsive: ['lg'],
+  },
+  {
+    title: 'Area',
+    render: (_, record) => record.cut?.name || '--',
+    responsive: ['md'],
+  },
+  {
+    title: 'Volunteers',
+    width: 140,
+    render: (_, record) => {
+      const confirmed = record._count?.signups ?? record.currentVolunteers;
+      const pct = record.maxVolunteers > 0 ? Math.round((confirmed / record.maxVolunteers) * 100) : 0;
+      return (
+        <Progress
+          percent={pct}
+          size="small"
+          status={pct >= 100 ? 'exception' : 'active'}
+          format={() => `${confirmed}/${record.maxVolunteers}`}
+        />
+      );
+    },
+  },
+  {
+    title: 'Status',
+    dataIndex: 'status',
+    width: 100,
+    render: (status) => <Tag color={SHIFT_STATUS_COLORS[status]}>{SHIFT_STATUS_LABELS[status]}</Tag>,
+  },
+  {
+    title: 'Public',
+    dataIndex: 'isPublic',
+    width: 70,
+    render: (isPublic) => isPublic ? <CheckCircleOutlined style={{ color: '#52c41a' }} /> : null,
+  },
+  {
+    title: 'Actions',
+    width: 120,
+    render: (_, record) => (
+      <Space>
+        <Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
+        <Popconfirm title="Delete this shift?" onConfirm={() => handleDelete(record.id)}>
+          <Button type="link" size="small" danger icon={<DeleteOutlined />} />
+        </Popconfirm>
+      </Space>
+    ),
+  },
+];
+
+

Key patterns: +- _count.signups aggregation from Prisma (confirmed volunteers count) +- responsive array hides columns on smaller screens +- Progress bar shows visual capacity indicator (turns red when full) +- onRow prop makes entire row clickable to open signups drawer

+

Signups Table Columns

+
const signupColumns: ColumnsType<ShiftSignup> = [
+  {
+    title: 'Email',
+    dataIndex: 'userEmail',
+  },
+  {
+    title: 'Name',
+    dataIndex: 'userName',
+    render: (name) => name || '--',
+  },
+  {
+    title: 'Phone',
+    render: (_, record) => record.userPhone || record.user?.phone || '--',
+    responsive: ['md'],
+  },
+  {
+    title: 'Source',
+    dataIndex: 'signupSource',
+    width: 100,
+    render: (source) => <Tag color={SIGNUP_SOURCE_COLORS[source]}>{source}</Tag>,
+  },
+  {
+    title: 'Date',
+    dataIndex: 'signupDate',
+    render: (date) => dayjs(date).format('YYYY-MM-DD'),
+    responsive: ['lg'],
+  },
+  {
+    title: '',
+    width: 60,
+    render: (_, record) =>
+      record.status === 'CONFIRMED' ? (
+        <Popconfirm title="Remove this volunteer?" onConfirm={() => handleRemoveSignup(record.id)}>
+          <Button type="link" size="small" danger icon={<DeleteOutlined />} />
+        </Popconfirm>
+      ) : (
+        <Tag color="red">Cancelled</Tag>
+      ),
+  },
+];
+
+

Filter: Table only shows CONFIRMED signups: +

<Table dataSource={signups.filter((s) => s.status === 'CONFIRMED')} />
+

+

Status Colors

+
export const SHIFT_STATUS_COLORS: Record<ShiftStatus, string> = {
+  OPEN: 'green',
+  FULL: 'orange',
+  CANCELLED: 'red',
+  COMPLETED: 'default',
+};
+
+export const SHIFT_STATUS_LABELS: Record<ShiftStatus, string> = {
+  OPEN: 'Open',
+  FULL: 'Full',
+  CANCELLED: 'Cancelled',
+  COMPLETED: 'Completed',
+};
+
+

Signup Source Colors

+
export const SIGNUP_SOURCE_COLORS = {
+  PUBLIC: 'blue',    // User signed up via public /shifts page
+  ADMIN: 'purple',   // Admin added manually
+};
+
+

Form Fields

+
const shiftFormFields = (isEdit = false) => (
+  <>
+    <Form.Item name="title" label="Title" rules={[{ required: true }]}>
+      <Input placeholder="e.g. Door Knocking, Phone Banking" />
+    </Form.Item>
+    <Form.Item name="description" label="Description">
+      <Input.TextArea rows={3} placeholder="Shift details and instructions" />
+    </Form.Item>
+    <Row gutter={12}>
+      <Col xs={24} sm={8}>
+        <Form.Item name="date" label="Date" rules={[{ required: true }]}>
+          <DatePicker style={{ width: '100%' }} />
+        </Form.Item>
+      </Col>
+      <Col xs={12} sm={8}>
+        <Form.Item name="startTime" label="Start Time" rules={[{ required: true }]}>
+          <TimePicker format="HH:mm" style={{ width: '100%' }} minuteStep={5} />
+        </Form.Item>
+      </Col>
+      <Col xs={12} sm={8}>
+        <Form.Item name="endTime" label="End Time" rules={[{ required: true }]}>
+          <TimePicker format="HH:mm" style={{ width: '100%' }} minuteStep={5} />
+        </Form.Item>
+      </Col>
+    </Row>
+    <Form.Item name="location" label="Location">
+      <Input placeholder="e.g. Campaign HQ, 123 Main St" />
+    </Form.Item>
+    <Form.Item name="cutId" label="Area (Cut)">
+      <Select
+        options={cutOptions}
+        placeholder="Assign a canvass area..."
+        allowClear
+        showSearch
+        optionFilterProp="label"
+      />
+    </Form.Item>
+    <Row gutter={12}>
+      <Col xs={12} sm={8}>
+        <Form.Item name="maxVolunteers" label="Max Volunteers" rules={[{ required: true }]}>
+          <InputNumber min={1} style={{ width: '100%' }} />
+        </Form.Item>
+      </Col>
+      <Col xs={12} sm={8}>
+        <Form.Item name="isPublic" label="Public" valuePropName="checked">
+          <Switch />
+        </Form.Item>
+      </Col>
+      {isEdit && (
+        <Col xs={12} sm={8}>
+          <Form.Item name="status" label="Status">
+            <Select options={statusOptions} />
+          </Form.Item>
+        </Col>
+      )}
+    </Row>
+  </>
+);
+
+

Reusable pattern: Same form fields for create + edit, with conditional Status field in edit mode.

+

State Management

+

Zustand Stores Used

+

None — Shifts fetched from API on each interaction. No global state required.

+

Local State

+
const [shifts, setShifts] = useState<Shift[]>([]);
+const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
+const [loading, setLoading] = useState(false);
+const [search, setSearch] = useState('');
+const [debouncedSearch, setDebouncedSearch] = useState('');
+const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
+const [statusFilter, setStatusFilter] = useState<ShiftStatus | undefined>();
+const [stats, setStats] = useState<ShiftStats | null>(null);
+
+// Create modal
+const [createModalOpen, setCreateModalOpen] = useState(false);
+const [createForm] = Form.useForm();
+
+// Edit drawer
+const [editDrawerOpen, setEditDrawerOpen] = useState(false);
+const [editingShift, setEditingShift] = useState<Shift | null>(null);
+const [editForm] = Form.useForm();
+
+// Signups drawer
+const [signupsDrawerOpen, setSignupsDrawerOpen] = useState(false);
+const [signupsShift, setSignupsShift] = useState<Shift | null>(null);
+const [signups, setSignups] = useState<ShiftSignup[]>([]);
+const [signupsLoading, setSignupsLoading] = useState(false);
+const [addEmail, setAddEmail] = useState('');
+const [addName, setAddName] = useState('');
+
+// Cuts for area dropdown
+const [cuts, setCuts] = useState<Cut[]>([]);
+
+ +
const handleSearchChange = (value: string) => {
+  setSearch(value);               // Update input immediately
+  clearTimeout(searchTimerRef.current);
+  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
+};
+
+useEffect(() => {
+  return () => clearTimeout(searchTimerRef.current);  // Cleanup on unmount
+}, []);
+
+useEffect(() => {
+  fetchShifts({ page: 1 });
+  fetchStats();
+  fetchCuts();
+}, [debouncedSearch, statusFilter]);  // Re-fetch when search or filter changes
+
+

Why 300ms? Same pattern as other pages — prevents API spam while typing.

+

API Integration

+

Endpoints Used

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointPurpose
GET/api/map/shiftsList shifts (paginated, filtered)
GET/api/map/shifts/statsFetch statistics (counts by status)
GET/api/map/shifts/:idFetch single shift with signups
POST/api/map/shiftsCreate shift
PUT/api/map/shifts/:idUpdate shift
DELETE/api/map/shifts/:idDelete shift (cascade signups)
POST/api/map/shifts/:id/signupsAdd volunteer manually (admin)
DELETE/api/map/shifts/:id/signups/:signupIdRemove volunteer
POST/api/map/shifts/:id/email-detailsEmail all confirmed volunteers
+

List Shifts

+

Request:

+
const { data } = await api.get<ShiftsListResponse>('/map/shifts', {
+  params: {
+    page: 1,
+    limit: 20,
+    search: 'door knocking',    // Optional: search title/location
+    status: 'OPEN',             // Optional: filter by status
+  },
+});
+
+

Response:

+
{
+  "shifts": [
+    {
+      "id": "shift-123",
+      "title": "Door Knocking — Downtown",
+      "description": "Focus on high-density residential areas. Bring campaign materials.",
+      "date": "2026-02-15",
+      "startTime": "10:00",
+      "endTime": "14:00",
+      "location": "Campaign HQ, 123 Main St",
+      "maxVolunteers": 15,
+      "currentVolunteers": 8,
+      "status": "OPEN",
+      "isPublic": true,
+      "cutId": "cut-456",
+      "cut": {
+        "id": "cut-456",
+        "name": "Downtown Core"
+      },
+      "createdAt": "2026-01-10T09:00:00.000Z",
+      "updatedAt": "2026-01-15T14:30:00.000Z",
+      "_count": {
+        "signups": 8
+      }
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 20,
+    "total": 47,
+    "totalPages": 3
+  }
+}
+
+

Key fields: +- cutId — Foreign key to Cut (polygon area) +- cut — Nested cut object (if assigned) +- currentVolunteers — Confirmed signups count +- _count.signups — Prisma aggregation (confirmed signups count) +- startTime, endTime — 24-hour format strings (HH:mm)

+

Fetch Shift Statistics

+

Request:

+
const { data } = await api.get<ShiftStats>('/map/shifts/stats');
+
+

Response:

+
{
+  "total": 47,
+  "open": 23,
+  "full": 8,
+  "cancelled": 2,
+  "completed": 14,
+  "upcoming": 31,
+  "totalSignups": 287
+}
+
+

Upcoming calculation: Future shifts (date >= today)

+

Create Shift

+

Request:

+
const payload = {
+  title: "Phone Banking",
+  description: "Call voters to discuss campaign issues",
+  date: "2026-02-20",
+  startTime: "18:00",
+  endTime: "21:00",
+  location: "Campaign HQ",
+  maxVolunteers: 10,
+  isPublic: true,
+  cutId: "cut-789",  // Optional
+};
+
+await api.post('/map/shifts', payload);
+
+

Response:

+
{
+  "id": "shift-999",
+  "title": "Phone Banking",
+  "status": "OPEN",
+  "currentVolunteers": 0,
+  "createdAt": "2026-02-11T10:00:00.000Z",
+  // ... all other fields
+}
+
+

Default values: +- status — OPEN +- currentVolunteers — 0 +- isPublic — false (if not specified)

+

Update Shift

+

Request:

+
const payload = {
+  status: "CANCELLED",
+  description: "Cancelled due to weather",
+};
+
+await api.put(`/map/shifts/${shiftId}`, payload);
+
+

Partial updates: Only send changed fields.

+

Add Volunteer (Manual Signup)

+

Request:

+
await api.post(`/map/shifts/${shiftId}/signups`, {
+  userEmail: 'volunteer@example.com',
+  userName: 'Jane Doe',  // Optional
+});
+
+

Response:

+
{
+  "id": "signup-456",
+  "shiftId": "shift-123",
+  "userId": "user-789",  // Existing or newly created temp user
+  "userEmail": "volunteer@example.com",
+  "userName": "Jane Doe",
+  "signupSource": "ADMIN",
+  "status": "CONFIRMED",
+  "signupDate": "2026-02-11T10:30:00.000Z"
+}
+
+

Temp user creation logic: +- If volunteer@example.com exists → link to existing user +- If doesn't exist → create temp user: +

{
+  "email": "volunteer@example.com",
+  "name": "Jane Doe",
+  "role": "TEMP",
+  "password": "BlueEagle42",  // Readable password
+  "tempUserExpiresAt": "2026-02-21T00:00:00.000Z"  // shift date + 1 day
+}
+

+

Email All Volunteers

+

Request:

+
const { data } = await api.post<{ sent: number; failed: number }>(
+  `/map/shifts/${shiftId}/email-details`
+);
+
+

Response:

+
{
+  "sent": 8,
+  "failed": 0
+}
+
+

Email template:

+
Subject: Shift Reminder: {Shift Title}
+
+Hi {Volunteer Name},
+
+This is a reminder about your upcoming volunteer shift:
+
+Title: {Shift Title}
+Date: {Date}
+Time: {Start Time} — {End Time}
+Location: {Location}
+
+Description:
+{Shift Description}
+
+Thank you for volunteering!
+
+Changemaker Lite
+
+

SMTP: Uses site settings (Settings page → Email tab)

+

Code Examples

+

Clickable Table Rows

+
<Table
+  columns={columns}
+  dataSource={shifts}
+  rowKey="id"
+  onRow={(record) => ({
+    onClick: () => openSignups(record),  // Click row → open signups drawer
+    style: { cursor: 'pointer' },        // Show pointer cursor on hover
+  })}
+/>
+
+

Pattern: Entire row clickable except action buttons (edit/delete use stopPropagation).

+

Progress Bar for Volunteer Capacity

+
{
+  title: 'Volunteers',
+  width: 140,
+  render: (_, record) => {
+    const confirmed = record._count?.signups ?? record.currentVolunteers;
+    const pct = record.maxVolunteers > 0 ? Math.round((confirmed / record.maxVolunteers) * 100) : 0;
+    return (
+      <Progress
+        percent={pct}
+        size="small"
+        status={pct >= 100 ? 'exception' : 'active'}
+        format={() => `${confirmed}/${record.maxVolunteers}`}
+      />
+    );
+  },
+}
+
+

Visual feedback: +- Green progress bar: < 100% +- Red progress bar: = 100% (full, status "exception") +- Format shows: "8/15" (8 confirmed out of 15 max)

+

Date/Time Payload Formatting

+
const handleCreate = async (values: Record<string, unknown>) => {
+  const payload = {
+    title: values.title,
+    date: dayjs(values.date as string).format('YYYY-MM-DD'),      // DatePicker → string
+    startTime: dayjs(values.startTime as string).format('HH:mm'), // TimePicker → string
+    endTime: dayjs(values.endTime as string).format('HH:mm'),     // TimePicker → string
+    maxVolunteers: values.maxVolunteers,
+    // ... other fields
+  };
+  await api.post('/map/shifts', payload);
+};
+
+

Why format? DatePicker and TimePicker return Dayjs objects. Backend expects ISO date string + HH:mm time strings.

+

Edit Form Pre-Fill

+
const openEdit = (shift: Shift) => {
+  setEditingShift(shift);
+  editForm.setFieldsValue({
+    title: shift.title,
+    description: shift.description,
+    date: dayjs(shift.date),                    // String → Dayjs object
+    startTime: dayjs(shift.startTime, 'HH:mm'), // HH:mm string → Dayjs object with format
+    endTime: dayjs(shift.endTime, 'HH:mm'),
+    location: shift.location,
+    maxVolunteers: shift.maxVolunteers,
+    isPublic: shift.isPublic,
+    status: shift.status,
+    cutId: shift.cutId,
+  });
+  setEditDrawerOpen(true);
+};
+
+

Why dayjs(shift.startTime, 'HH:mm')? TimePicker needs Dayjs object with specific format. Backend stores as "10:00" string, convert to Dayjs with HH:mm format.

+

Conditional Status Field

+
const shiftFormFields = (isEdit = false) => (
+  <>
+    {/* ... other fields */}
+    <Row gutter={12}>
+      {/* ... maxVolunteers, isPublic */}
+      {isEdit && (
+        <Col xs={12} sm={8}>
+          <Form.Item name="status" label="Status">
+            <Select options={statusOptions} />
+          </Form.Item>
+        </Col>
+      )}
+    </Row>
+  </>
+);
+
+

Why conditional? Status dropdown only shown in edit mode. Create form defaults to OPEN (set by backend).

+

Performance Considerations

+

Debounced Search

+

Same 300ms debounce pattern as other pages: +- Prevents API spam while typing +- Only fires after user pauses +- Cleanup on unmount

+

Responsive Column Hiding

+
{ title: 'Time', responsive: ['md'] }       // Hide on < 768px
+{ title: 'Location', responsive: ['lg'] }   // Hide on < 992px
+{ title: 'Area', responsive: ['md'] }       // Hide on < 768px
+
+

Mobile users see: Title, Date, Volunteers, Status, Public, Actions

+

useCallback Optimization

+
const fetchShifts = useCallback(async (params?: ShiftsListParams) => {
+  // ... fetch logic
+}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);
+
+const fetchStats = useCallback(async () => {
+  // ... fetch logic
+}, []);
+
+const fetchCuts = useCallback(async () => {
+  // ... fetch logic
+}, []);
+
+

Memoized functions prevent unnecessary re-renders.

+

Responsive Design

+

Mobile (< 576px)

+
    +
  • Stats cards: 2 columns (xs={12})
  • +
  • Search bar: Full width
  • +
  • Status filter: Full width below search
  • +
  • Table: Minimal columns (Title, Date, Volunteers, Status, Actions)
  • +
  • Signups drawer: Full screen overlay (width: 100%)
  • +
+

Tablet (576px - 992px)

+
    +
  • Stats cards: 3-4 columns (sm={4} or sm={6})
  • +
  • Search bar: Half width (sm={12})
  • +
  • Status filter: Quarter width (sm={6})
  • +
  • Table: Time + Area columns visible
  • +
+

Desktop (≥ 992px)

+
    +
  • Stats cards: 6 columns (md={4})
  • +
  • Filters: Compact (search ⅓, filter ⅙)
  • +
  • Table: All columns visible (Location, Date)
  • +
  • Signups drawer: 640px overlay (right side)
  • +
+

Accessibility

+
    +
  • Keyboard navigation: All buttons, inputs, selects focusable via Tab
  • +
  • ARIA labels: Icon buttons have title attribute
  • +
  • Form validation: Required fields marked, inline error messages
  • +
  • Color contrast: Status tags use Ant Design defaults (WCAG AA compliant)
  • +
  • Screen reader support: Form labels properly associated
  • +
  • Focus management: Modals/drawers auto-focus first input on open
  • +
+

Troubleshooting

+

Shift Status Not Auto-Updating to FULL

+

Problem: Shift reaches max volunteers (8/8), but status stays OPEN instead of changing to FULL.

+

Diagnosis:

+

Backend auto-status logic should run on every signup: +

if (currentVolunteers >= maxVolunteers) {
+  await prisma.shift.update({
+    where: { id: shiftId },
+    data: { status: 'FULL' },
+  });
+}
+

+

Common Issues:

+
    +
  1. Backend logic not running:
  2. +
  3. Check API logs: docker compose logs api | grep "shift status"
  4. +
  5. +

    Verify signup endpoint includes auto-status update

    +
  6. +
  7. +

    Race condition:

    +
  8. +
  9. Multiple signups at same time (public + admin)
  10. +
  11. +

    Solution: Use Prisma transaction for atomic updates

    +
  12. +
  13. +

    Status manually set:

    +
  14. +
  15. Admin changed status to OPEN in edit drawer
  16. +
  17. Solution: Status field warning: "Auto-updates to FULL when capacity reached"
  18. +
+

Solution:

+

Refresh page to see latest status. Backend should auto-update on next signup/removal.

+
+

Email All Volunteers Fails

+

Problem: Click "Email All" button → Error: "Failed to email volunteers"

+

Diagnosis:

+

Check SMTP configuration: +1. Navigate to Settings → Email tab +2. Verify active provider: Production (not MailHog) +3. Click "Test Connection" → Should show success

+

Common Issues:

+
    +
  1. MailHog active (dev mode):
  2. +
  3. Switch to Production provider
  4. +
  5. +

    Save settings

    +
  6. +
  7. +

    SMTP credentials invalid:

    +
  8. +
  9. Test connection fails
  10. +
  11. Update credentials
  12. +
  13. +

    Re-test before emailing

    +
  14. +
  15. +

    No confirmed volunteers:

    +
  16. +
  17. Email All button disabled if 0 confirmed
  18. +
  19. Check signups drawer table (only CONFIRMED shown)
  20. +
+

Solution:

+
    +
  1. Fix SMTP settings
  2. +
  3. Test connection
  4. +
  5. Retry Email All
  6. +
+
+

Volunteer Not Appearing in Signups

+

Problem: Add volunteer by email → Success message → Volunteer not in signups table

+

Diagnosis:

+

Check signups drawer filter: +

<Table dataSource={signups.filter((s) => s.status === 'CONFIRMED')} />
+

+

Cancelled signups hidden.

+

Common Issues:

+
    +
  1. Volunteer added but immediately cancelled:
  2. +
  3. Check backend logs for cancellation endpoint calls
  4. +
  5. +

    Verify signup status in database

    +
  6. +
  7. +

    Wrong shift:

    +
  8. +
  9. Added to different shift
  10. +
  11. +

    Verify shift ID in URL when opening drawer

    +
  12. +
  13. +

    Duplicate email:

    +
  14. +
  15. Volunteer already signed up
  16. +
  17. Backend returns 400: "User already signed up for this shift"
  18. +
  19. Check error message
  20. +
+

Solution:

+

Refresh drawer: Close and re-open signups drawer to fetch latest data.

+
+

Area (Cut) Dropdown Empty

+

Problem: Create/edit shift → Area dropdown shows no options

+

Diagnosis:

+

Check cuts API endpoint: +

curl http://localhost:4000/api/map/cuts
+

+

Common Issues:

+
    +
  1. No cuts created yet:
  2. +
  3. Navigate to /app/map/cuts
  4. +
  5. Create at least one cut (polygon boundary)
  6. +
  7. +

    Return to shifts page

    +
  8. +
  9. +

    Cuts API failing:

    +
  10. +
  11. Check API logs: docker compose logs api | grep "cuts"
  12. +
  13. +

    Verify database connection

    +
  14. +
  15. +

    Cuts fetch not called:

    +
  16. +
  17. Check browser console for errors
  18. +
  19. Verify fetchCuts() called in useEffect
  20. +
+

Solution:

+

Create at least one cut in CutsPage before assigning to shifts.

+
+

Public Shift Not Showing on Public Page

+

Problem: Set isPublic to true, save shift → Public /shifts page doesn't show it

+

Diagnosis:

+

Check shift criteria for public page: +- Status: OPEN or FULL (not CANCELLED or COMPLETED) +- isPublic: true +- Date: Future (not past)

+

Common Issues:

+
    +
  1. Shift date in past:
  2. +
  3. Past shifts hidden from public page
  4. +
  5. +

    Edit shift, update date to future

    +
  6. +
  7. +

    Status CANCELLED:

    +
  8. +
  9. Cancelled shifts hidden from public page
  10. +
  11. +

    Change status to OPEN

    +
  12. +
  13. +

    Browser cache:

    +
  14. +
  15. Hard refresh public page (Ctrl+Shift+R)
  16. +
+

Solution:

+

Verify all 3 criteria met: OPEN/FULL status, isPublic true, future date.

+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/users-page/index.html b/mkdocs/site/v2/frontend/pages/admin/users-page/index.html new file mode 100644 index 00000000..8558f2a3 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/users-page/index.html @@ -0,0 +1,7313 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Users - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

UsersPage

+

Overview

+

The UsersPage provides comprehensive user management with full CRUD operations, pagination, search, filtering, and role-based access control. It serves as the primary interface for managing all system users including admins, volunteers, and temporary users.

+

Route: /app/users +Component: admin/src/pages/UsersPage.tsx (400+ lines) +Auth Required: Yes (SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN) +Layout: AppLayout

+

Screenshot

+

[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.]

+

Features

+
    +
  • Full CRUD operations — Create, Read, Update, Delete users
  • +
  • Advanced search — Debounced search by email/name (300ms delay)
  • +
  • Dual filtering — Filter by role AND status simultaneously
  • +
  • Pagination — Configurable page size (20 per page default)
  • +
  • Color-coded roles — Visual role identification with Ant Design tags
  • +
  • SUPER_ADMIN: red
  • +
  • INFLUENCE_ADMIN: volcano (orange-red)
  • +
  • MAP_ADMIN: orange
  • +
  • USER: blue
  • +
  • TEMP: default (gray)
  • +
  • Status indicators — Color-coded status tags
  • +
  • ACTIVE: green
  • +
  • INACTIVE: gray
  • +
  • SUSPENDED: red
  • +
  • EXPIRED: orange
  • +
  • Temp user management — Create temporary users with expiration dates
  • +
  • DatePicker for expiration — Visual calendar for setting expiry
  • +
  • Optimistic UI — Immediate feedback on actions
  • +
  • Responsive table — Mobile-friendly with scroll overflow
  • +
+

User Workflow

+

Creating a User

+
    +
  1. Click "Create User" button (top right)
  2. +
  3. Modal opens with form fields:
  4. +
  5. Email (required)
  6. +
  7. Name (optional)
  8. +
  9. Phone (optional)
  10. +
  11. Password (required, auto-generated option available)
  12. +
  13. Role (dropdown: Super Admin, Influence Admin, Map Admin, User, Temp)
  14. +
  15. Status (dropdown: Active, Inactive, Suspended, Expired)
  16. +
  17. Expiration Date (optional, for TEMP users)
  18. +
  19. Fill out form fields
  20. +
  21. Click "Create"
  22. +
  23. Success message: "User created"
  24. +
  25. Modal closes, table refreshes to page 1
  26. +
+

Editing a User

+
    +
  1. Click Edit icon in Actions column
  2. +
  3. Modal opens with pre-populated form
  4. +
  5. Modify fields (password optional, leave blank to keep existing)
  6. +
  7. Click "Save"
  8. +
  9. Success message: "User updated"
  10. +
  11. Modal closes, table data refreshes
  12. +
+

Deleting a User

+
    +
  1. Click Delete icon in Actions column
  2. +
  3. Popconfirm appears: "Are you sure you want to delete this user?"
  4. +
  5. Click "Yes"
  6. +
  7. Success message: "User deleted"
  8. +
  9. Table data refreshes
  10. +
+

Searching Users

+
    +
  1. Type in search input (top left)
  2. +
  3. Wait 300ms (debounce delay)
  4. +
  5. Table automatically filters results
  6. +
  7. Search matches email OR name (case-insensitive)
  8. +
  9. Resets to page 1 automatically
  10. +
+

Filtering by Role/Status

+
    +
  1. Select role from Role Filter dropdown
  2. +
  3. OR/AND select status from Status Filter dropdown
  4. +
  5. Table automatically filters results
  6. +
  7. Filters combine with search (all must match)
  8. +
  9. Resets to page 1 automatically
  10. +
+

Component Breakdown

+

Ant Design Components Used

+
    +
  • Table — Main data table with columns, pagination, sorting
  • +
  • Button — Create User, modal actions (Create, Save, Cancel)
  • +
  • Input — Search input, text fields (email, name, phone, password)
  • +
  • Select — Role and status dropdowns
  • +
  • Tag — Color-coded role and status indicators
  • +
  • Space — Action button grouping
  • +
  • Modal — Create and edit user forms
  • +
  • Form — Form validation and submission
  • +
  • InputNumber — Expire days input
  • +
  • DatePicker — Expiration date picker
  • +
  • Popconfirm — Delete confirmation
  • +
  • message — Toast notifications (success, error)
  • +
  • Typography.Title — Page heading
  • +
  • Row, Col — Responsive form layout
  • +
+

Table Columns

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ColumnKeyRenderSortable
EmailemailPlain textNo
NamenamePlain textNo
RoleroleColor-coded TagNo
StatusstatusColor-coded TagNo
Created AtcreatedAtFormatted date (MMM DD, YYYY)No
Actions-Edit + Delete iconsNo
+

Column Configuration:

+
const columns: ColumnsType<User> = [
+  {
+    title: 'Email',
+    dataIndex: 'email',
+    key: 'email',
+  },
+  {
+    title: 'Name',
+    dataIndex: 'name',
+    key: 'name',
+    render: (text: string) => text || '—',
+  },
+  {
+    title: 'Role',
+    dataIndex: 'role',
+    key: 'role',
+    render: (role: UserRole) => (
+      <Tag color={roleColors[role]}>{role.replace('_', ' ')}</Tag>
+    ),
+  },
+  {
+    title: 'Status',
+    dataIndex: 'status',
+    key: 'status',
+    render: (status: UserStatus) => (
+      <Tag color={statusColors[status]}>{status}</Tag>
+    ),
+  },
+  {
+    title: 'Created At',
+    dataIndex: 'createdAt',
+    key: 'createdAt',
+    render: (date: string) => dayjs(date).format('MMM DD, YYYY'),
+  },
+  {
+    title: 'Actions',
+    key: 'actions',
+    render: (_, record: User) => (
+      <Space>
+        <Button
+          type="link"
+          icon={<EditOutlined />}
+          onClick={() => handleEditClick(record)}
+        />
+        <Popconfirm
+          title="Are you sure you want to delete this user?"
+          onConfirm={() => handleDelete(record.id)}
+        >
+          <Button type="link" danger icon={<DeleteOutlined />} />
+        </Popconfirm>
+      </Space>
+    ),
+  },
+];
+
+

State Management

+

Local State

+
const [users, setUsers] = useState<User[]>([]);
+const [pagination, setPagination] = useState({
+  page: 1,
+  limit: 20,
+  total: 0,
+  totalPages: 0
+});
+const [loading, setLoading] = useState(false);
+const [search, setSearch] = useState('');
+const [debouncedSearch, setDebouncedSearch] = useState('');
+const [roleFilter, setRoleFilter] = useState<UserRole | undefined>();
+const [statusFilter, setStatusFilter] = useState<UserStatus | undefined>();
+const [createModalOpen, setCreateModalOpen] = useState(false);
+const [editModalOpen, setEditModalOpen] = useState(false);
+const [editingUser, setEditingUser] = useState<User | null>(null);
+const [createForm] = Form.useForm();
+const [editForm] = Form.useForm();
+const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
+
+

Zustand Stores Used

+
    +
  • auth.store — Not directly used, but auth context ensures only admins access page
  • +
+

Search Debouncing

+
const handleSearchChange = (value: string) => {
+  setSearch(value);
+  clearTimeout(searchTimerRef.current);
+  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
+};
+
+useEffect(() => {
+  return () => clearTimeout(searchTimerRef.current);
+}, []);
+
+

Why 300ms?

+
    +
  • Fast enough — Users perceive instant response
  • +
  • Reduces API calls — Prevents API spam during typing
  • +
  • Balances UX — Not too slow (500ms+ feels laggy)
  • +
+

API Integration

+

Endpoints Used

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointPurpose
GET/api/usersList users with pagination/filters
POST/api/usersCreate new user
PUT/api/users/:idUpdate user
DELETE/api/users/:idDelete user
+

Fetch Users (with Filters)

+
const fetchUsers = useCallback(async (params?: UsersListParams) => {
+  setLoading(true);
+  try {
+    const { data } = await api.get<UsersListResponse>('/users', {
+      params: {
+        page: params?.page ?? pagination.page,
+        limit: params?.limit ?? pagination.limit,
+        search: params?.search ?? (debouncedSearch || undefined),
+        role: params?.role ?? roleFilter,
+        status: params?.status ?? statusFilter,
+      },
+    });
+    setUsers(data.users);
+    setPagination(data.pagination);
+  } catch {
+    message.error('Failed to load users');
+  } finally {
+    setLoading(false);
+  }
+}, [pagination.page, pagination.limit, debouncedSearch, roleFilter, statusFilter]);
+
+

Response Format:

+
{
+  "users": [
+    {
+      "id": "clx1234567890",
+      "email": "admin@example.com",
+      "name": "Admin User",
+      "phone": "+1234567890",
+      "role": "SUPER_ADMIN",
+      "status": "ACTIVE",
+      "createdAt": "2026-01-15T12:00:00.000Z",
+      "updatedAt": "2026-02-11T15:30:00.000Z",
+      "expiresAt": null,
+      "lastLoginAt": "2026-02-11T10:00:00.000Z"
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 20,
+    "total": 45,
+    "totalPages": 3
+  }
+}
+
+

Create User

+
const handleCreate = async (values: CreateUserPayload & { expiresAtDate?: dayjs.Dayjs }) => {
+  try {
+    const payload: CreateUserPayload = { ...values };
+    if (values.expiresAtDate) {
+      payload.expiresAt = values.expiresAtDate.toISOString();
+    }
+    delete (payload as unknown as Record<string, unknown>).expiresAtDate;
+    await api.post('/users', payload);
+    message.success('User created');
+    setCreateModalOpen(false);
+    createForm.resetFields();
+    fetchUsers({ page: 1 });
+  } catch (err: unknown) {
+    const msg =
+      (err as { response?: { data?: { error?: { message?: string } } } })
+        ?.response?.data?.error?.message || 'Failed to create user';
+    message.error(msg);
+  }
+};
+
+

Request Payload:

+
{
+  "email": "newuser@example.com",
+  "name": "New User",
+  "phone": "+1234567890",
+  "password": "SecurePassword123!",
+  "role": "USER",
+  "status": "ACTIVE",
+  "expiresAt": "2026-03-15T23:59:59.000Z"
+}
+
+

Update User

+
const handleEdit = async (values: UpdateUserPayload & { expiresAtDate?: dayjs.Dayjs | null }) => {
+  if (!editingUser) return;
+  try {
+    const payload: UpdateUserPayload = { ...values };
+    if (values.expiresAtDate) {
+      payload.expiresAt = values.expiresAtDate.toISOString();
+    } else if (values.expiresAtDate === null) {
+      payload.expiresAt = null;
+    }
+    delete (payload as unknown as Record<string, unknown>).expiresAtDate;
+    await api.put(`/users/${editingUser.id}`, payload);
+    message.success('User updated');
+    setEditModalOpen(false);
+    editForm.resetFields();
+    fetchUsers();
+  } catch (err: unknown) {
+    const msg =
+      (err as { response?: { data?: { error?: { message?: string } } } })
+        ?.response?.data?.error?.message || 'Failed to update user';
+    message.error(msg);
+  }
+};
+
+

Update Payload (Partial):

+
{
+  "name": "Updated Name",
+  "role": "MAP_ADMIN",
+  "status": "ACTIVE"
+}
+
+

Note: Password is optional in updates. Leave blank to keep existing password.

+

Delete User

+
const handleDelete = async (id: string) => {
+  try {
+    await api.delete(`/users/${id}`);
+    message.success('User deleted');
+    fetchUsers();
+  } catch {
+    message.error('Failed to delete user');
+  }
+};
+
+

Form Validation

+

Create User Form Rules

+
<Form.Item
+  name="email"
+  label="Email"
+  rules={[
+    { required: true, message: 'Email is required' },
+    { type: 'email', message: 'Invalid email address' },
+  ]}
+>
+  <Input placeholder="user@example.com" />
+</Form.Item>
+
+<Form.Item
+  name="password"
+  label="Password"
+  rules={[
+    { required: true, message: 'Password is required' },
+    { min: 12, message: 'Password must be at least 12 characters' },
+    {
+      pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
+      message: 'Password must contain uppercase, lowercase, and digit'
+    },
+  ]}
+>
+  <Input.Password placeholder="Min 12 chars, uppercase, lowercase, digit" />
+</Form.Item>
+
+<Form.Item
+  name="role"
+  label="Role"
+  rules={[{ required: true, message: 'Role is required' }]}
+>
+  <Select options={roleOptions} />
+</Form.Item>
+
+

Password Policy:

+
    +
  • Minimum 12 characters
  • +
  • At least one uppercase letter
  • +
  • At least one lowercase letter
  • +
  • At least one digit
  • +
+

Edit User Form Rules

+

Same as create form, but password is optional:

+
<Form.Item
+  name="password"
+  label="Password"
+  extra="Leave blank to keep existing password"
+  rules={[
+    { min: 12, message: 'Password must be at least 12 characters' },
+    {
+      pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
+      message: 'Password must contain uppercase, lowercase, and digit'
+    },
+  ]}
+>
+  <Input.Password placeholder="Min 12 chars (leave blank to keep existing)" />
+</Form.Item>
+
+

Performance Considerations

+ +

Prevents excessive API calls during typing:

+
const handleSearchChange = (value: string) => {
+  setSearch(value);
+  clearTimeout(searchTimerRef.current);
+  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
+};
+
+

Performance Impact:

+
    +
  • Without debounce: 10 keystrokes = 10 API calls
  • +
  • With 300ms debounce: 10 keystrokes = 1-2 API calls (significant reduction)
  • +
+

useCallback for fetchUsers

+

Prevents unnecessary re-creation of fetch function:

+
const fetchUsers = useCallback(async (params?: UsersListParams) => {
+  // ... fetch logic
+}, [pagination.page, pagination.limit, debouncedSearch, roleFilter, statusFilter]);
+
+

Why useCallback?

+
    +
  • Memoization — Function reference stays stable unless dependencies change
  • +
  • Prevents re-renders — Child components can use React.memo effectively
  • +
  • useEffect optimization — Avoids infinite loops in useEffect
  • +
+

Pagination

+

Server-side pagination reduces memory usage:

+
    +
  • Client-side (bad): Fetch all 10,000 users → Paginate in browser → High memory usage
  • +
  • Server-side (good): Fetch 20 users per page → Low memory usage
  • +
+

Responsive Design

+

Table Scroll

+

Table uses horizontal scroll on mobile:

+
<Table
+  columns={columns}
+  dataSource={users}
+  scroll={{ x: 'max-content' }}
+  loading={loading}
+  pagination={{
+    current: pagination.page,
+    pageSize: pagination.limit,
+    total: pagination.total,
+    showSizeChanger: true,
+    showTotal: (total) => `Total ${total} users`,
+  }}
+  onChange={handleTableChange}
+/>
+
+ +

Forms use responsive columns:

+
<Row gutter={16}>
+  <Col xs={24} sm={12}>
+    <Form.Item label="Email" name="email">
+      <Input />
+    </Form.Item>
+  </Col>
+  <Col xs={24} sm={12}>
+    <Form.Item label="Name" name="name">
+      <Input />
+    </Form.Item>
+  </Col>
+</Row>
+
+

Mobile: Fields stack vertically (xs={24}) +Tablet+: Fields display side-by-side (sm={12})

+

Troubleshooting

+

Search Not Working

+

Problem: Typing in search input doesn't filter results.

+

Diagnosis:

+
    +
  1. Check debouncedSearch value in React DevTools
  2. +
  3. Verify fetchUsers is called after 300ms delay
  4. +
  5. Check network tab for API call with search param
  6. +
+

Solution:

+
useEffect(() => {
+  fetchUsers({ page: 1 });
+}, [debouncedSearch, roleFilter, statusFilter]);
+
+

Ensure debouncedSearch is in dependency array, not search.

+
+

"Failed to load users" Error

+

Problem: Table shows error message.

+

Diagnosis:

+

Check API response:

+
curl -H "Authorization: Bearer <token>" \
+  "http://api.cmlite.org/api/users?page=1&limit=20"
+
+

Common Issues:

+
    +
  1. 401 Unauthorized — Token expired
  2. +
  3. 403 Forbidden — User lacks admin role
  4. +
  5. 500 Internal Server Error — Database connection issue
  6. +
+
+

Delete Confirmation Not Appearing

+

Problem: Click delete icon but nothing happens.

+

Diagnosis:

+

Check Popconfirm component:

+
<Popconfirm
+  title="Are you sure you want to delete this user?"
+  onConfirm={() => handleDelete(record.id)}
+  okText="Yes"
+  cancelText="No"
+>
+  <Button type="link" danger icon={<DeleteOutlined />} />
+</Popconfirm>
+
+

Solution:

+

Ensure Popconfirm wraps the button, not the other way around.

+
+ +

Problem: Open create modal, enter data, close modal, reopen → old data still there.

+

Solution:

+

Reset form on modal close:

+
<Modal
+  title="Create User"
+  open={createModalOpen}
+  onCancel={() => {
+    setCreateModalOpen(false);
+    createForm.resetFields();  // Reset form on close
+  }}
+  onOk={() => createForm.submit()}
+>
+  <Form form={createForm} onFinish={handleCreate}>
+    {/* form fields */}
+  </Form>
+</Modal>
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/admin/walk-sheet-page/index.html b/mkdocs/site/v2/frontend/pages/admin/walk-sheet-page/index.html new file mode 100644 index 00000000..5ba20ef0 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/admin/walk-sheet-page/index.html @@ -0,0 +1,8158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Walk Sheet - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

WalkSheetPage

+

Overview

+

File: admin/src/pages/WalkSheetPage.tsx

+

Route: /app/walk-sheet

+

Role Requirements: Any authenticated user (uses authenticate middleware)

+

Purpose: Generates a printable walk sheet form for canvassing volunteers. The walk sheet is a physical form that volunteers use to record contact information and support levels during door-to-door canvassing. The page fetches customizable settings (title, subtitle, QR codes, footer) and renders a standardized form optimized for printing.

+

Key Features: +- Printable walk sheet with browser print support +- Customizable title, subtitle, and footer from MapSettings +- Up to 3 configurable QR codes with labels +- 12-row contact table with pre-printed columns (Name, Address, Email, Phone, Support, Sign, Notes) +- Support level circles (1-4 scale) for quick marking +- Sign interest circles (R/L for Right/Left yard placement) +- Volunteer name, date, and area/cut fields +- Print-optimized styling with CSS @media print rules

+

Layout: Full AppLayout with Print button in header

+

Dependencies: +- Ant Design v5 (Button, Typography, Spin, App) +- react-router-dom (useOutletContext) +- QR code generation via /api/qr endpoint

+
+

Features

+

1. Customizable Header

+

Configurable Fields: +- Walk Sheet Title: Main heading (e.g., "Volunteer Canvassing Walk Sheet") +- Walk Sheet Subtitle: Optional subtitle (e.g., "Ward 5 - Downtown District")

+

Source: MapSettings.walkSheetTitle and MapSettings.walkSheetSubtitle

+

2. QR Code Section

+

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×80 pixel size +- Generated via /api/qr?text={url}&size=100 endpoint

+

Source: MapSettings.qrCode1Url/Label, qrCode2Url/Label, qrCode3Url/Label

+

Example QR Code URLs: +- https://cmlite.org/responses/1 - Response submission page +- https://cmlite.org/shifts - Shift signup page +- https://cmlite.org/campaigns - Campaign listing

+

3. Volunteer Information Fields

+

Pre-printed Fields: +- Volunteer: Name line (200px underline) +- Date: Date line (120px underline) +- Area/Cut: Assignment line (120px underline)

+

Purpose: Volunteer fills these in by hand before starting canvass

+

4. Contact Table

+

12 Rows with 8 Columns:

+
    +
  1. # (Number): Row number 1-12 (pre-printed)
  2. +
  3. Name: Blank field for recording contact name
  4. +
  5. Address / Unit: Blank field for building address and unit number
  6. +
  7. Email: Blank field for contact email
  8. +
  9. Phone: Blank field for contact phone number
  10. +
  11. Support: 4 circles for support level (1-4 scale)
  12. +
  13. Circle 1 = Strong Support
  14. +
  15. Circle 2 = Likely Support
  16. +
  17. Circle 3 = Unsure
  18. +
  19. Circle 4 = Oppose
  20. +
  21. Sign: 2 circles for lawn sign interest (R/L)
  22. +
  23. R = Right side of entrance
  24. +
  25. L = Left side of entrance
  26. +
  27. Notes: Blank field for additional notes
  28. +
+

Table Styling: +- 1px solid borders +- 11px font size (print-optimized) +- 28px row height (sufficient for handwriting) +- Compact padding (4px×6px)

+ +

Footer Text: Optional footer message (e.g., "Thank you for volunteering!")

+

Source: MapSettings.walkSheetFooter

+

6. Print Optimization

+

CSS @media print Rules: +- Hides everything except .walk-sheet-print container +- Positions walk sheet at absolute top-left +- Reduces font size to 11px for compact printing +- Optimizes table borders for clear printing +- Hides Print button (no-print class)

+

Print Trigger: "Print" button in page header (calls window.print())

+
+

User Workflow

+

Configuring Walk Sheet Settings

+
    +
  1. Navigate to Map Settings:
  2. +
  3. Click "Map" → "Settings" in sidebar
  4. +
  5. +

    Scroll to "Walk Sheet Configuration" section

    +
  6. +
  7. +

    Set Walk Sheet Title:

    +
  8. +
  9. Enter title (e.g., "Volunteer Canvassing Walk Sheet")
  10. +
  11. +

    This appears as main heading on printed sheet

    +
  12. +
  13. +

    Set Walk Sheet Subtitle (Optional):

    +
  14. +
  15. Enter subtitle (e.g., "Ward 5 - Downtown District")
  16. +
  17. +

    Appears below title in smaller font

    +
  18. +
  19. +

    Configure QR Codes (Up to 3):

    +
  20. +
  21. QR Code 1:
      +
    • URL: Enter full URL to encode (e.g., https://cmlite.org/responses/1)
    • +
    • Label: Enter descriptive label (e.g., "Submit Response")
    • +
    +
  22. +
  23. QR Code 2: (Optional)
      +
    • URL + Label
    • +
    +
  24. +
  25. QR Code 3: (Optional)
      +
    • URL + Label
    • +
    +
  26. +
  27. +

    QR codes appear centered above contact table

    +
  28. +
  29. +

    Set Footer Text (Optional):

    +
  30. +
  31. Enter footer message (e.g., "Thank you for your time!")
  32. +
  33. +

    Appears at bottom of printed sheet

    +
  34. +
  35. +

    Save Settings:

    +
  36. +
  37. Click "Save" button in Map Settings page
  38. +
  39. Settings applied to all future walk sheets
  40. +
+

Printing a Walk Sheet

+
    +
  1. Navigate to Walk Sheet:
  2. +
  3. Click "Map" → "Walk Sheet" in sidebar
  4. +
  5. +

    Page loads with preview of walk sheet

    +
  6. +
  7. +

    Review Preview:

    +
  8. +
  9. Check title, subtitle, QR codes
  10. +
  11. Verify table has 12 rows
  12. +
  13. +

    Confirm footer text appears

    +
  14. +
  15. +

    Print Walk Sheet:

    +
  16. +
  17. Click "Print" button in page header
  18. +
  19. OR press Ctrl+P (Windows/Linux) or Cmd+P (Mac)
  20. +
  21. +

    Browser print dialog opens

    +
  22. +
  23. +

    Configure Print Settings:

    +
  24. +
  25. Orientation: Portrait (recommended)
  26. +
  27. Paper Size: Letter (8.5" × 11")
  28. +
  29. Margins: Default or minimal
  30. +
  31. +

    Background graphics: ON (to print table borders clearly)

    +
  32. +
  33. +

    Print or Save PDF:

    +
  34. +
  35. Click "Print" to send to printer
  36. +
  37. OR select "Save as PDF" to create digital copy
  38. +
+

Using Walk Sheet During Canvassing

+
    +
  1. Before Starting:
  2. +
  3. Print walk sheet (1 per cut/area)
  4. +
  5. +

    Fill in volunteer name, date, area/cut at top

    +
  6. +
  7. +

    At Each Door:

    +
  8. +
  9. Record contact information in next empty row:
      +
    • Name
    • +
    • Address / Unit (if multi-unit building)
    • +
    • Email (if provided)
    • +
    • Phone (if provided)
    • +
    +
  10. +
  11. Circle support level (1-4)
  12. +
  13. Circle sign interest (R/L) if applicable
  14. +
  15. +

    Write notes (e.g., "Call back after 6pm", "Not home")

    +
  16. +
  17. +

    Completing Walk Sheet:

    +
  18. +
  19. Fill all 12 rows OR complete area
  20. +
  21. Return walk sheet to campaign organizer
  22. +
  23. +

    Organizer enters data into system via Admin GUI

    +
  24. +
  25. +

    QR Code Usage:

    +
  26. +
  27. Volunteers can scan QR codes with phone to:
      +
    • Report their location/status
    • +
    • Submit response directly to response wall
    • +
    • Access campaign resources
    • +
    +
  28. +
+
+

Component Breakdown

+

Main Component Structure

+
export default function WalkSheetPage() {
+  const { setPageHeader } = useOutletContext<AppOutletContext>();
+  const { message } = App.useApp();
+  const [settings, setSettings] = useState<MapSettings | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  // Set page header with Print button
+  useEffect(() => {
+    setPageHeader({
+      title: 'Walk Sheet',
+      actions: (
+        <Button icon={<PrinterOutlined />} onClick={() => window.print()}>
+          Print
+        </Button>
+      ),
+    });
+    return () => setPageHeader(null);
+  }, [setPageHeader]);
+
+  // Load map settings (for walk sheet config)
+  useEffect(() => {
+    api.get('/map/settings')
+      .then(({ data }) => setSettings(data))
+      .catch(() => message.error('Failed to load settings'))
+      .finally(() => setLoading(false));
+  }, []);
+
+  if (loading) {
+    return <Spin size="large" />;
+  }
+
+  // Filter QR codes (only include if URL provided)
+  const qrCodes = [
+    { url: settings?.qrCode1Url, label: settings?.qrCode1Label },
+    { url: settings?.qrCode2Url, label: settings?.qrCode2Label },
+    { url: settings?.qrCode3Url, label: settings?.qrCode3Label },
+  ].filter((q) => q.url);
+
+  // Generate 12 empty rows
+  const rows = Array.from({ length: 12 }, (_, i) => i);
+
+  return (
+    <>
+      <style>{/* Print CSS rules */}</style>
+
+      <div className="walk-sheet-print">
+        {/* Header */}
+        <Title level={3}>{settings?.walkSheetTitle || 'Walk Sheet'}</Title>
+        {settings?.walkSheetSubtitle && <Text>{settings.walkSheetSubtitle}</Text>}
+
+        {/* QR Codes */}
+        {qrCodes.map((qr) => (
+          <img src={`/api/qr?text=${qr.url}&size=100`} alt={qr.label} />
+        ))}
+
+        {/* Volunteer Info */}
+        <div>
+          <Text strong>Volunteer: </Text><span className="underline" />
+          <Text strong>Date: </Text><span className="underline" />
+          <Text strong>Area/Cut: </Text><span className="underline" />
+        </div>
+
+        {/* Contact Table */}
+        <table>
+          <thead>
+            <tr>
+              <th>#</th>
+              <th>Name</th>
+              <th>Address / Unit</th>
+              <th>Email</th>
+              <th>Phone</th>
+              <th>Support</th>
+              <th>Sign</th>
+              <th>Notes</th>
+            </tr>
+          </thead>
+          <tbody>
+            {rows.map((i) => (
+              <tr key={i}>
+                <td>{i + 1}</td>
+                <td>&nbsp;</td> {/* Blank cells for handwriting */}
+                {/* ... more blank cells ... */}
+                <td> {/* Support circles */}
+                  <span className="support-circle">1</span>
+                  <span className="support-circle">2</span>
+                  <span className="support-circle">3</span>
+                  <span className="support-circle">4</span>
+                </td>
+                <td> {/* Sign circles */}
+                  <span className="support-circle">R</span>
+                  <span className="support-circle">L</span>
+                </td>
+                <td>&nbsp;</td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+
+        {/* Footer */}
+        {settings?.walkSheetFooter && <Text>{settings.walkSheetFooter}</Text>}
+      </div>
+    </>
+  );
+}
+
+

Ant Design Components Used

+
    +
  1. Button - Print button in page header
  2. +
  3. Typography.Title - Walk sheet main heading
  4. +
  5. Typography.Text - Subtitle, labels, footer text
  6. +
  7. Spin - Loading indicator while settings fetch
  8. +
  9. App.useApp() - Toast message for errors
  10. +
+ +
<style>{`
+  @media print {
+    /* Hide everything except walk sheet */
+    body * { visibility: hidden; }
+    .walk-sheet-print, .walk-sheet-print * { visibility: visible; }
+
+    /* Position walk sheet at top-left */
+    .walk-sheet-print {
+      position: absolute;
+      left: 0;
+      top: 0;
+      width: 100%;
+      font-size: 11px;
+    }
+
+    /* Hide Print button */
+    .walk-sheet-print .no-print { display: none !important; }
+  }
+
+  /* Table styling (screen + print) */
+  .walk-sheet-print table {
+    width: 100%;
+    border-collapse: collapse;
+  }
+
+  .walk-sheet-print th,
+  .walk-sheet-print td {
+    border: 1px solid #555;
+    padding: 4px 6px;
+    text-align: left;
+    font-size: 11px;
+  }
+
+  .walk-sheet-print th {
+    background: rgba(255,255,255,0.05);
+    font-weight: 600;
+  }
+
+  /* Support level circles */
+  .support-circle {
+    display: inline-block;
+    width: 16px;
+    height: 16px;
+    border: 1.5px solid rgba(255,255,255,0.4);
+    border-radius: 50%;
+    text-align: center;
+    line-height: 14px;
+    font-size: 9px;
+    margin-right: 2px;
+  }
+`}</style>
+
+

Key Print Rules: +- visibility: hidden on all elements except .walk-sheet-print +- Absolute positioning at top-left (0, 0) +- 11px base font size (compact, readable when printed) +- Solid borders on table cells for clear gridlines +- Support circles rendered as border-only circles (volunteer fills in by hand)

+
+

State Management

+

Local Component State (useState)

+

No Zustand stores used - All state managed locally with React hooks.

+
// Map settings state (loaded from API)
+const [settings, setSettings] = useState<MapSettings | null>(null);
+
+// Loading state
+const [loading, setLoading] = useState(true);
+
+

State Flow

+
    +
  1. Component Mounts:
  2. +
  3. loadSettings() called in useEffect
  4. +
  5. Fetches map settings via GET /api/map/settings
  6. +
  7. Sets settings state
  8. +
  9. +

    Sets loading to false

    +
  10. +
  11. +

    Settings Loaded:

    +
  12. +
  13. Extracts walk sheet configuration:
      +
    • walkSheetTitle, walkSheetSubtitle, walkSheetFooter
    • +
    • qrCode1Url/Label, qrCode2Url/Label, qrCode3Url/Label
    • +
    +
  14. +
  15. Filters QR codes (only include if URL provided)
  16. +
  17. +

    Renders walk sheet with settings

    +
  18. +
  19. +

    User Clicks Print:

    +
  20. +
  21. window.print() called
  22. +
  23. Browser opens print dialog
  24. +
  25. Print CSS rules activate
  26. +
  27. Walk sheet rendered in print layout
  28. +
+
+

API Integration

+

Endpoints Used

+
    +
  1. GET /api/map/settings - Fetch map settings (including walk sheet config)
  2. +
  3. GET /api/qr - Generate QR code PNG (public endpoint, no auth)
  4. +
+

API Client

+
import { api } from '@/lib/api';
+
+// All authenticated requests use API client with automatic token refresh
+
+

Example API Calls

+

1. Load Map Settings

+
useEffect(() => {
+  api.get('/map/settings')
+    .then(({ data }) => {
+      setSettings(data);
+    })
+    .catch(() => {
+      message.error('Failed to load settings');
+    })
+    .finally(() => {
+      setLoading(false);
+    });
+}, []);
+
+

Response Format: +

{
+  "id": 1,
+  "walkSheetTitle": "Volunteer Canvassing Walk Sheet",
+  "walkSheetSubtitle": "Ward 5 - Downtown District",
+  "walkSheetFooter": "Thank you for volunteering!",
+  "qrCode1Url": "https://cmlite.org/responses/1",
+  "qrCode1Label": "Submit Response",
+  "qrCode2Url": "https://cmlite.org/shifts",
+  "qrCode2Label": "Sign Up for Shift",
+  "qrCode3Url": null,
+  "qrCode3Label": null,
+  "centerLat": 45.5017,
+  "centerLng": -73.5673,
+  "defaultZoom": 13,
+  "updatedAt": "2025-02-11T10:00:00Z"
+}
+

+

2. Generate QR Code (Embedded in img src)

+
const API_BASE = import.meta.env.VITE_API_URL || '';
+
+<img
+  src={`${API_BASE}/api/qr?text=${encodeURIComponent(qr.url)}&size=100`}
+  alt={qr.label || 'QR'}
+  style={{ width: 80, height: 80 }}
+/>
+
+

Endpoint: GET /api/qr?text={url}&size={pixels}

+

Query Parameters: +- text (required): URL or text to encode in QR code +- size (optional): QR code pixel size (default: 200)

+

Response: PNG image (binary data)

+

Example URL: +

http://api.cmlite.org/api/qr?text=https%3A%2F%2Fcmlite.org%2Fresponses%2F1&size=100
+

+

Note: This endpoint is public (no authentication required) to allow QR codes to be scanned by anyone.

+
+

Code Examples

+

Complete Component with Print Button

+
import { useEffect, useState } from 'react';
+import { Button, Typography, Spin, App } from 'antd';
+import { PrinterOutlined } from '@ant-design/icons';
+import { useOutletContext } from 'react-router-dom';
+import { api } from '@/lib/api';
+import type { AppOutletContext } from '@/components/AppLayout';
+import type { MapSettings } from '@/types/api';
+
+const { Title, Text } = Typography;
+const API_BASE = import.meta.env.VITE_API_URL || '';
+
+export default function WalkSheetPage() {
+  const { setPageHeader } = useOutletContext<AppOutletContext>();
+  const { message } = App.useApp();
+  const [settings, setSettings] = useState<MapSettings | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  // Set page header with Print button
+  useEffect(() => {
+    setPageHeader({
+      title: 'Walk Sheet',
+      actions: (
+        <Button icon={<PrinterOutlined />} onClick={() => window.print()}>
+          Print
+        </Button>
+      ),
+    });
+    return () => setPageHeader(null);
+  }, [setPageHeader]);
+
+  // Load settings
+  useEffect(() => {
+    api.get('/map/settings')
+      .then(({ data }) => setSettings(data))
+      .catch(() => message.error('Failed to load settings'))
+      .finally(() => setLoading(false));
+  }, [message]);
+
+  if (loading) {
+    return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
+  }
+
+  // Filter QR codes (only show if URL provided)
+  const qrCodes = [
+    { url: settings?.qrCode1Url, label: settings?.qrCode1Label },
+    { url: settings?.qrCode2Url, label: settings?.qrCode2Label },
+    { url: settings?.qrCode3Url, label: settings?.qrCode3Label },
+  ].filter((q) => q.url);
+
+  // Generate 12 empty rows
+  const rows = Array.from({ length: 12 }, (_, i) => i);
+
+  return (
+    <>
+      <style>{`
+        @media print {
+          body * { visibility: hidden; }
+          .walk-sheet-print, .walk-sheet-print * { visibility: visible; }
+          .walk-sheet-print {
+            position: absolute;
+            left: 0;
+            top: 0;
+            width: 100%;
+            font-size: 11px;
+          }
+          .walk-sheet-print .no-print { display: none !important; }
+        }
+        .walk-sheet-print table {
+          width: 100%;
+          border-collapse: collapse;
+        }
+        .walk-sheet-print th,
+        .walk-sheet-print td {
+          border: 1px solid #555;
+          padding: 4px 6px;
+          text-align: left;
+          font-size: 11px;
+        }
+        .walk-sheet-print th {
+          background: rgba(255,255,255,0.05);
+          font-weight: 600;
+        }
+        .support-circle {
+          display: inline-block;
+          width: 16px;
+          height: 16px;
+          border: 1.5px solid rgba(255,255,255,0.4);
+          border-radius: 50%;
+          text-align: center;
+          line-height: 14px;
+          font-size: 9px;
+          margin-right: 2px;
+        }
+      `}</style>
+
+      <div className="walk-sheet-print">
+        {/* Header */}
+        <div style={{ textAlign: 'center', marginBottom: 16 }}>
+          <Title level={3} style={{ marginBottom: 2 }}>
+            {settings?.walkSheetTitle || 'Walk Sheet'}
+          </Title>
+          {settings?.walkSheetSubtitle && (
+            <Text type="secondary" style={{ fontSize: 14 }}>{settings.walkSheetSubtitle}</Text>
+          )}
+        </div>
+
+        {/* QR Codes */}
+        {qrCodes.length > 0 && (
+          <div style={{ display: 'flex', justifyContent: 'center', gap: 24, marginBottom: 16 }}>
+            {qrCodes.map((qr, idx) => (
+              <div key={idx} style={{ textAlign: 'center' }}>
+                <img
+                  src={`${API_BASE}/api/qr?text=${encodeURIComponent(qr.url!)}&size=100`}
+                  alt={qr.label || 'QR'}
+                  style={{ width: 80, height: 80 }}
+                />
+                {qr.label && (
+                  <div style={{ fontSize: 10, marginTop: 2 }}>
+                    <Text type="secondary">{qr.label}</Text>
+                  </div>
+                )}
+              </div>
+            ))}
+          </div>
+        )}
+
+        {/* Volunteer info line */}
+        <div style={{ display: 'flex', gap: 16, marginBottom: 12 }}>
+          <div style={{ flex: 1 }}>
+            <Text strong>Volunteer: </Text>
+            <span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 200 }}>&nbsp;</span>
+          </div>
+          <div>
+            <Text strong>Date: </Text>
+            <span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 120 }}>&nbsp;</span>
+          </div>
+          <div>
+            <Text strong>Area/Cut: </Text>
+            <span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 120 }}>&nbsp;</span>
+          </div>
+        </div>
+
+        {/* Contact table */}
+        <table>
+          <thead>
+            <tr>
+              <th style={{ width: 20 }}>#</th>
+              <th>Name</th>
+              <th>Address / Unit</th>
+              <th>Email</th>
+              <th>Phone</th>
+              <th style={{ width: 70 }}>Support</th>
+              <th style={{ width: 50 }}>Sign</th>
+              <th>Notes</th>
+            </tr>
+          </thead>
+          <tbody>
+            {rows.map((i) => (
+              <tr key={i}>
+                <td style={{ textAlign: 'center' }}>{i + 1}</td>
+                <td style={{ height: 28 }}>&nbsp;</td>
+                <td>&nbsp;</td>
+                <td>&nbsp;</td>
+                <td>&nbsp;</td>
+                <td style={{ textAlign: 'center' }}>
+                  <span className="support-circle">1</span>
+                  <span className="support-circle">2</span>
+                  <span className="support-circle">3</span>
+                  <span className="support-circle">4</span>
+                </td>
+                <td style={{ textAlign: 'center' }}>
+                  <span className="support-circle">R</span>
+                  <span className="support-circle">L</span>
+                </td>
+                <td>&nbsp;</td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+
+        {/* Footer */}
+        {settings?.walkSheetFooter && (
+          <div style={{ marginTop: 16, fontSize: 11, textAlign: 'center' }}>
+            <Text type="secondary">{settings.walkSheetFooter}</Text>
+          </div>
+        )}
+      </div>
+    </>
+  );
+}
+
+

QR Code Generation Pattern

+
// 1. In component
+const API_BASE = import.meta.env.VITE_API_URL || '';
+
+// 2. In JSX
+<img
+  src={`${API_BASE}/api/qr?text=${encodeURIComponent(url)}&size=${size}`}
+  alt="QR Code"
+  style={{ width: size, height: size }}
+/>
+
+// Examples:
+// Campaign response page
+const url1 = 'https://cmlite.org/responses/1';
+<img src={`${API_BASE}/api/qr?text=${encodeURIComponent(url1)}&size=100`} />
+
+// Shift signup page
+const url2 = 'https://cmlite.org/shifts';
+<img src={`${API_BASE}/api/qr?text=${encodeURIComponent(url2)}&size=150`} />
+
+

Important: Always use encodeURIComponent() to escape special characters in URL.

+ +
// 1. Add Print button to page header
+useEffect(() => {
+  setPageHeader({
+    title: 'Walk Sheet',
+    actions: (
+      <Button icon={<PrinterOutlined />} onClick={() => window.print()}>
+        Print
+      </Button>
+    ),
+  });
+  return () => setPageHeader(null);
+}, [setPageHeader]);
+
+// 2. Add print CSS rules
+<style>{`
+  @media print {
+    body * { visibility: hidden; }
+    .printable-content, .printable-content * { visibility: visible; }
+    .printable-content {
+      position: absolute;
+      left: 0;
+      top: 0;
+      width: 100%;
+    }
+  }
+`}</style>
+
+// 3. Wrap content in printable class
+<div className="printable-content">
+  {/* Content to print */}
+</div>
+
+
+

Performance Considerations

+

1. Single Settings Fetch

+

Settings loaded once on mount:

+
useEffect(() => {
+  api.get('/map/settings')
+    .then(({ data }) => setSettings(data))
+    .catch(() => message.error('Failed to load settings'))
+    .finally(() => setLoading(false));
+}, []); // Empty dependency array = run once
+
+

Benefit: Minimizes API requests. Settings cached in state until page unmount.

+

2. Inline QR Code Images

+

QR codes embedded as <img> tags with src pointing to QR API:

+
<img src={`${API_BASE}/api/qr?text=${url}&size=100`} />
+
+

Benefit: Browser caches QR code images. No JavaScript overhead for QR generation.

+

3. Static Row Generation

+

12 rows generated once with Array.from():

+
const rows = Array.from({ length: 12 }, (_, i) => i);
+
+{rows.map((i) => <tr key={i}>...</tr>)}
+
+

Benefit: Simple, performant array mapping. No state updates or re-renders.

+

4. Print CSS Optimization

+

Print rules use visibility: hidden instead of display: none:

+
body * { visibility: hidden; }
+.walk-sheet-print, .walk-sheet-print * { visibility: visible; }
+
+

Benefit: Preserves layout and spacing. Prevents reflow during print preparation.

+
+

Responsive Design

+

No Mobile Responsiveness Needed

+

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" × 11") +- No mobile-specific styling or breakpoints

+

Rationale: Physical walk sheets used by volunteers in field, printed from desktop computers before canvassing.

+ +
@media print {
+  .walk-sheet-print {
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    font-size: 11px;
+  }
+
+  @page {
+    size: letter portrait;
+    margin: 0.5in;
+  }
+}
+
+

Fixed Layout: +- Letter size paper (8.5" × 11") +- Portrait orientation +- 0.5" margins all sides +- 11px base font size (fits 12 rows + header/footer on one page)

+
+

Accessibility

+ +

Walk sheet is physical form, not interactive UI. Accessibility considerations minimal:

+
    +
  1. Semantic HTML:
  2. +
  3. <table> for contact grid
  4. +
  5. <th> for column headers
  6. +
  7. +

    <td> for data cells

    +
  8. +
  9. +

    Print Button:

    +
  10. +
  11. Keyboard accessible (Tab + Enter)
  12. +
  13. Icon + text label ("Print")
  14. +
  15. +

    ARIA label implicit from button text

    +
  16. +
  17. +

    Screen Reader Support:

    +
  18. +
  19. Table headers announced for each column
  20. +
  21. Row numbers read in sequence
  22. +
  23. QR code alt attributes describe purpose
  24. +
+

Note: Once printed, walk sheet relies on visual cues (circles, lines, table borders) for volunteers to fill in by hand.

+
+

Troubleshooting

+

Problem: QR Codes Not Appearing

+

Symptoms: +- Walk sheet loads but QR code section is empty +- Expected 1-3 QR codes but none visible

+

Causes: +1. No QR code URLs configured in Map Settings +2. QR API endpoint not responding +3. CORS issues blocking QR images

+

Solutions:

+
    +
  1. Check Map Settings:
  2. +
  3. Navigate to "Map" → "Settings"
  4. +
  5. Scroll to "Walk Sheet Configuration"
  6. +
  7. Verify QR Code URLs filled in:
      +
    • qrCode1Url, qrCode2Url, qrCode3Url
    • +
    +
  8. +
  9. +

    At least one URL must be provided

    +
  10. +
  11. +

    Test QR API endpoint: +

    curl http://localhost:4000/api/qr?text=https://example.com&size=100 --output test-qr.png
    +

    +
  12. +
  13. Should return PNG image
  14. +
  15. +

    Open test-qr.png to verify QR code generated

    +
  16. +
  17. +

    Check browser console:

    +
  18. +
  19. Open DevTools (F12)
  20. +
  21. Go to Network tab
  22. +
  23. Refresh walk sheet page
  24. +
  25. Look for /api/qr?text=... requests
  26. +
  27. Check status codes (should be 200)
  28. +
  29. If 404, QR API route not registered
  30. +
  31. +

    If CORS error, check nginx CORS headers

    +
  32. +
  33. +

    Verify API base URL:

    +
  34. +
  35. Check .env file: VITE_API_URL=http://localhost:4000
  36. +
  37. Restart admin dev server after changing .env
  38. +
+
+

Problem: Walk Sheet Doesn't Print Correctly

+

Symptoms: +- Print preview shows blank page +- Print preview shows partial content +- Table borders not visible when printed

+

Causes: +1. Browser print settings incorrect +2. Background graphics disabled +3. Print CSS not applying +4. Page margins too large

+

Solutions:

+
    +
  1. Enable background graphics:
  2. +
  3. In print dialog, check "Background graphics" option
  4. +
  5. +

    This ensures table borders and support circles print

    +
  6. +
  7. +

    Adjust page margins:

    +
  8. +
  9. In print dialog, set margins to "Default" or "Minimal"
  10. +
  11. +

    Too large margins can cut off content

    +
  12. +
  13. +

    Verify print CSS:

    +
  14. +
  15. View print preview (Ctrl+P or Cmd+P)
  16. +
  17. Check that only walk sheet visible (no sidebar, no header)
  18. +
  19. +

    If other elements visible, print CSS not applying

    +
  20. +
  21. +

    Check browser zoom:

    +
  22. +
  23. Reset zoom to 100% (Ctrl+0 or Cmd+0)
  24. +
  25. +

    Print preview at wrong zoom can cause layout issues

    +
  26. +
  27. +

    Try different browser:

    +
  28. +
  29. Chrome, Firefox, and Edge have different print engines
  30. +
  31. If one fails, try another
  32. +
+
+

Problem: Support Circles Too Small When Printed

+

Symptoms: +- Support level circles (1-4) and sign circles (R/L) are too small to fill in by hand +- Volunteers complain circles hard to mark

+

Causes: +1. Print scaling set to "Fit to page" (shrinks content) +2. Circle size optimized for screen, not print

+

Solutions:

+
    +
  1. Check print scaling:
  2. +
  3. In print dialog, set scale to "100%" (not "Fit to page")
  4. +
  5. +

    "Fit to page" shrinks content to fit, making circles smaller

    +
  6. +
  7. +

    Adjust circle size in code:

    +
  8. +
  9. Edit WalkSheetPage.tsx
  10. +
  11. Increase .support-circle dimensions: +
    .support-circle {
    +  width: 20px;  /* Was 16px */
    +  height: 20px; /* Was 16px */
    +  font-size: 11px; /* Was 9px */
    +}
    +
  12. +
  13. Save and refresh page
  14. +
  15. +

    Print again to test

    +
  16. +
  17. +

    Increase row height:

    +
  18. +
  19. More vertical space gives volunteers more room to mark: +
    <td style={{ height: 32 }}>&nbsp;</td> {/* Was 28px */}
    +
  20. +
+
+ +

Symptoms: +- Footer message not visible in print preview +- Footer appears on screen but missing when printed

+

Causes: +1. Page margins cutting off bottom content +2. Footer outside printable area +3. Content too tall for one page

+

Causes: +1. Footer outside printable area due to margins +2. Content too tall to fit on one page

+

Solutions:

+
    +
  1. Reduce page margins:
  2. +
  3. In print dialog, set margins to "Minimal" (0.25")
  4. +
  5. +

    This gives more vertical space for content

    +
  6. +
  7. +

    Reduce font sizes:

    +
  8. +
  9. Edit print CSS: +
    @media print {
    +  .walk-sheet-print { font-size: 10px !important; } /* Was 11px */
    +  .walk-sheet-print table { font-size: 8px !important; } /* Was 9px */
    +}
    +
  10. +
  11. +

    Smaller fonts = more content fits on page

    +
  12. +
  13. +

    Reduce number of rows:

    +
  14. +
  15. +

    If footer consistently cut off, reduce rows from 12 to 10: +

    const rows = Array.from({ length: 10 }, (_, i) => i); // Was 12
    +

    +
  16. +
  17. +

    Remove footer (temporary):

    +
  18. +
  19. If footer not essential, remove from Map Settings:
      +
    • Navigate to "Map" → "Settings"
    • +
    • Clear "Walk Sheet Footer" field
    • +
    • Save settings
    • +
    +
  20. +
+
+

Problem: Title/Subtitle Not Appearing

+

Symptoms: +- Walk sheet header shows default "Walk Sheet" instead of custom title +- Subtitle missing entirely

+

Causes: +1. Map Settings not saved correctly +2. API not returning settings +3. Settings state null/undefined

+

Solutions:

+
    +
  1. Verify Map Settings saved:
  2. +
  3. Navigate to "Map" → "Settings"
  4. +
  5. Check "Walk Sheet Title" and "Walk Sheet Subtitle" fields
  6. +
  7. Re-enter values if blank
  8. +
  9. Click "Save" button
  10. +
  11. +

    Success message should appear

    +
  12. +
  13. +

    Check browser console:

    +
  14. +
  15. Open DevTools (F12)
  16. +
  17. Go to Console tab
  18. +
  19. Look for error messages
  20. +
  21. +

    If "Failed to load settings", API request failed

    +
  22. +
  23. +

    Check Network tab:

    +
  24. +
  25. Open DevTools (F12)
  26. +
  27. Go to Network tab
  28. +
  29. Refresh walk sheet page
  30. +
  31. Look for GET /api/map/settings request
  32. +
  33. Check Response tab for settings data: +
    {
    +  "walkSheetTitle": "...",
    +  "walkSheetSubtitle": "..."
    +}
    +
  34. +
  35. +

    If fields null/missing, settings not saved in database

    +
  36. +
  37. +

    Check database: +

    docker compose exec api npx prisma studio
    +# Navigate to MapSettings table
    +# Verify walkSheetTitle and walkSheetSubtitle columns populated
    +

    +
  38. +
+
+ +

Backend Documentation

+ +

Frontend Documentation

+ +

Feature Documentation

+ +

API Documentation

+ +

User Guides

+ +

Deployment Documentation

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/index.html b/mkdocs/site/v2/frontend/pages/index.html new file mode 100644 index 00000000..4b5412f5 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/index.html @@ -0,0 +1,5315 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Frontend Pages - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Frontend Pages

+

Page components provide the main user interface screens for Changemaker Lite. Pages are organized into three categories based on user access and context.

+

Page Categories

+

Admin Pages (30 pages)

+

Authenticated admin interface for campaign management, location management, settings, and system administration. Requires admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN).

+

Route Prefix: /app/*

+

Layout: AppLayout (sidebar navigation)

+

Key Pages: +- Dashboard +- User management +- Campaign management +- Location and mapping +- Settings and configuration +- Media library +- Service integrations

+

Public Pages (8 pages)

+

Public-facing pages accessible without authentication. Used by campaign supporters and volunteers to view campaigns, sign up for shifts, and interact with content.

+

Route Prefix: Various (/campaigns, /map, /shifts, /p/:slug, /media)

+

Layout: PublicLayout (dark theme)

+

Key Pages: +- Campaign listing and details +- Response wall +- Public map view +- Shift signup +- Landing pages +- Media gallery

+

Volunteer Pages (4 pages)

+

Volunteer portal for canvassing activities. Requires authentication (any role) and provides tools for door-to-door canvassing, GPS tracking, and activity tracking.

+

Route Prefix: /volunteer/*

+

Layout: VolunteerLayout (top navigation)

+

Key Pages: +- Volunteer dashboard +- Shift assignments +- Full-screen canvass map +- Activity history +- Route history

+

Page Overview by Feature

+

Authentication

+
    +
  • LoginPage - User login with JWT authentication
  • +
+

Dashboard & Analytics

+
    +
  • DashboardPage - Admin overview with stats and recent activity
  • +
  • CanvassDashboardPage - Canvass monitoring and leaderboard
  • +
  • DataQualityDashboardPage - Geocoding quality metrics
  • +
  • ObservabilityPage - Prometheus/Grafana monitoring
  • +
+

Campaign Management

+
    +
  • CampaignsPage - Campaign CRUD table
  • +
  • CampaignPage (Public) - Campaign detail with email form
  • +
  • CampaignsListPage (Public) - Featured campaign listing
  • +
  • ResponsesPage - Response moderation
  • +
  • ResponseWallPage (Public) - Public response submissions
  • +
  • RepresentativesPage - Representative cache management
  • +
  • EmailQueuePage - Email queue monitoring
  • +
+

Location & Mapping

+
    +
  • LocationsPage - Location CRUD, CSV import/export, geocoding
  • +
  • MapPage (Public) - Public Leaflet map with locations and cuts
  • +
  • CutsPage - Geographic cut management with polygon drawing
  • +
  • MapSettingsPage - Map configuration
  • +
  • ShiftsPage - Volunteer shift management
  • +
  • ShiftsPage (Public) - Public shift signup
  • +
+

Canvassing

+
    +
  • VolunteerMapPage - Full-screen GPS canvass map
  • +
  • VolunteerShiftsPage - Assigned shifts for volunteers
  • +
  • MyActivityPage - Visit history and outcomes
  • +
  • MyRoutesPage - Walking route history
  • +
  • WalkSheetPage - Printable walk sheet with QR codes
  • +
  • CutExportPage - Printable location report
  • +
+

Content Management

+
    +
  • LandingPagesPage - Landing page CRUD
  • +
  • PageEditorPage - GrapesJS WYSIWYG editor
  • +
  • LandingPage (Public) - Rendered landing page
  • +
  • EmailTemplatesPage - Email template CRUD
  • +
  • EmailTemplateEditorPage - Email template editor
  • +
+

Media Management

+
    +
  • LibraryPage - Video library management
  • +
  • SharedMediaPage - Public gallery administration
  • +
  • MediaJobsPage - Job queue monitoring
  • +
  • MediaGalleryPage (Public) - Public video gallery
  • +
  • MediaViewerPage (Public) - Video detail page
  • +
+

System & Settings

+
    +
  • UsersPage - User CRUD with role management
  • +
  • SettingsPage - Global site settings
  • +
+

Service Integrations

+
    +
  • ListmonkPage - Newsletter sync management
  • +
  • PangolinPage - Tunnel setup wizard
  • +
  • DocsPage - MkDocs export management
  • +
  • MkDocsSettingsPage - Documentation configuration
  • +
  • MiniQRPage - QR code service iframe
  • +
  • MailHogPage - Email capture UI
  • +
  • CodeEditorPage - Code Server management
  • +
  • N8nPage - Workflow automation
  • +
  • GiteaPage - Git repository hosting
  • +
  • NocoDBPage - Data browser management
  • +
+

Page Count Summary

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CategoryCountDescription
Admin30Admin interface pages
Public8Public-facing pages
Volunteer4Volunteer portal pages
Total42All page components
+

Common Page Patterns

+

Data Tables

+

Most CRUD pages use Ant Design Table with:

+
    +
  • Pagination (server-side)
  • +
  • Search and filtering
  • +
  • Sorting
  • +
  • Action buttons (edit, delete)
  • +
  • Bulk operations
  • +
  • Export options
  • +
+

Forms

+

Form pages use Ant Design Form with:

+
    +
  • Zod schema validation
  • +
  • Error display
  • +
  • Submit handlers
  • +
  • Cancel/reset actions
  • +
  • Auto-save (where applicable)
  • +
+

Maps

+

Map pages use React Leaflet with:

+
    +
  • Tile layers (OpenStreetMap)
  • +
  • Markers and overlays
  • +
  • Drawing tools
  • +
  • Geolocate controls
  • +
  • Fullscreen mode
  • +
+

Mobile Responsiveness

+

Pages use responsive design patterns:

+
    +
  • Grid breakpoints with Grid.useBreakpoint()
  • +
  • Mobile-specific layouts
  • +
  • Touch-friendly controls
  • +
  • Responsive tables
  • +
  • Desktop-only warnings (for editors)
  • +
+

Route Protection

+

Pages are protected based on authentication and role:

+
// Public routes - no auth required
+<Route path="/campaigns" element={<CampaignsListPage />} />
+
+// Authenticated routes - any role
+<Route path="/volunteer/assignments" element={<VolunteerShiftsPage />} />
+
+// Admin routes - specific roles
+<Route path="/app/campaigns" element={<CampaignsPage />} />
+// Middleware: requireRole(SUPER_ADMIN, INFLUENCE_ADMIN)
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/public/campaign-page/index.html b/mkdocs/site/v2/frontend/pages/public/campaign-page/index.html new file mode 100644 index 00000000..ca1052f1 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/public/campaign-page/index.html @@ -0,0 +1,8078 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Campaign Detail - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Campaign Detail Page

+

Overview

+

File Path: admin/src/pages/public/CampaignPage.tsx (613 lines)

+

Route: /campaigns/:id

+

Role Requirements: Public access (no authentication required)

+

Purpose: Individual campaign detail page providing a complete advocacy workflow from representative lookup through email sending, with optional response wall integration and social sharing capabilities.

+

Key Features:

+
    +
  • 3-step guided process: Info → Reps → Send
  • +
  • Step indicator with clickable navigation
  • +
  • Hero section with cover photo and real-time statistics
  • +
  • Postal code-based representative lookup with government level filtering
  • +
  • Dual email sending options: SMTP (tracked) and Email App (mailto)
  • +
  • Live email preview with optional editing
  • +
  • Response wall integration with CTA button
  • +
  • Social sharing buttons
  • +
  • Dark blue/teal theme consistent with public pages
  • +
  • Mobile-responsive with hamburger navigation
  • +
+

Layout: Uses PublicLayout component with dark theme

+
+

Features

+

1. Step-Based Workflow

+

Three-step process guides users through advocacy action:

+
    +
  • Step 1: Campaign Info - Overview, description, statistics
  • +
  • Step 2: Your Representatives - Postal code lookup and rep selection
  • +
  • Step 3: Send Your Message - Email composition and sending
  • +
+

Step Indicator: +- Ant Design Steps component +- Clickable step headers for navigation +- Current step highlighted in blue +- Completed steps marked with checkmark +- Mobile: Switches to vertical orientation

+

Navigation Controls: +- "Previous" button (disabled on step 1) +- "Next" button (changes to "Send Emails" on step 3) +- "Back to Campaigns" link in header

+

2. Hero Section

+

Prominent campaign header with visual branding:

+
    +
  • Cover Photo: Full-width image (400px desktop, 250px mobile) with gradient overlay
  • +
  • Fallback Gradient: Purple-to-blue when no cover photo
  • +
  • Title Overlay: Campaign title in white text over semi-transparent background
  • +
  • Statistics Circles: Floating overlay with two metrics
  • +
  • Emails Sent count (blue circle)
  • +
  • Responses count (green circle)
  • +
  • Positioning: Absolute positioned in top-right of hero
  • +
  • Responsive: Circles stack vertically on mobile
  • +
+

3. Representative Lookup

+

Government-level aware representative discovery:

+
    +
  • Postal Code Input: Large text input with search icon
  • +
  • Loading State: Spinner in input suffix during lookup
  • +
  • Government Level Filtering: Shows only reps matching campaign targets
  • +
  • Federal campaigns → Federal MPs only
  • +
  • Provincial campaigns → Provincial MPPs/MLAs only
  • +
  • Municipal campaigns → Municipal councillors only
  • +
  • Multi-level campaigns → All applicable reps
  • +
  • Representative Cards: Grid layout with detailed info
  • +
  • Circular photo (120px diameter)
  • +
  • Name and title
  • +
  • District/riding
  • +
  • Party badge
  • +
  • Email address (copyable)
  • +
  • Phone number
  • +
  • Office address
  • +
  • Send button (primary CTA)
  • +
  • Email App button (secondary CTA)
  • +
  • Auto-advance: Automatically proceeds to step 3 when reps loaded
  • +
  • No Results State: Helpful message suggesting alternate contact methods
  • +
+

4. Email Sending System

+

Dual-mode email delivery with tracking:

+

SMTP Send (Tracked): +- Sends via backend BullMQ queue +- Tracked in CampaignEmail table +- Statistics reflected in dashboard +- Requires valid email address +- Shows success confirmation +- Increments "Emails Sent" counter

+

Email App (Mailto): +- Opens user's default email client +- Pre-populates to, subject, body fields +- Not tracked in system +- Works offline +- Better for complex email setups (signatures, attachments) +- No backend dependency

+

Email Preview: +- Live rendering of email template +- Substitutes {name}, {email}, {postalCode} placeholders +- Shows subject line +- Read-only by default +- Optional editing mode (if allowEmailEditing=true)

+

5. Response Wall Integration

+

Campaign-specific response display:

+
    +
  • "See What Others Are Saying" Button: Links to response wall
  • +
  • Response Count Badge: Shows total verified responses
  • +
  • Conditional Display: Only shown if responses exist
  • +
  • Navigation: Links to /responses/:campaignId
  • +
+

6. Social Sharing

+

ShareButtons component for campaign promotion:

+
    +
  • Platforms: X, Facebook, LinkedIn, Reddit, Email, Copy Link
  • +
  • Share URL: Current campaign page URL
  • +
  • Share Title: Campaign title
  • +
  • Share Description: Campaign description (truncated to 200 chars)
  • +
  • Positioning: Below main content, above footer
  • +
+
+

User Workflow

+

Complete Advocacy Flow

+
    +
  1. User arrives at campaign page (via /campaigns/:id)
  2. +
  3. Step 1 loads automatically showing campaign info
  4. +
  5. User reads description and decides to take action
  6. +
  7. User clicks "Next" to proceed to Step 2
  8. +
  9. User enters postal code in "Your Representatives" section
  10. +
  11. API lookup triggered on blur or Enter key
  12. +
  13. Representatives filtered by government level
  14. +
  15. Auto-advance to Step 3 when reps loaded
  16. +
  17. User reviews email preview with personalized content
  18. +
  19. User edits email (if allowed by campaign settings)
  20. +
  21. User clicks "Send" button on rep card (SMTP option)
      +
    • OR clicks "Open in Email App" (mailto option)
    • +
    +
  22. +
  23. Backend creates CampaignEmail record and queues job
  24. +
  25. Success message displays confirming email sent
  26. +
  27. User repeats for additional representatives
  28. +
  29. User views response wall (optional) to see others' activity
  30. +
  31. User shares campaign on social media
  32. +
+

Representative Selection Flow

+

Representative selection happens implicitly (no checkboxes):

+
    +
  1. User clicks "Send" on specific rep card
  2. +
  3. Email sent to that rep only
  4. +
  5. User can send to multiple reps by clicking multiple cards
  6. +
  7. Each send creates separate CampaignEmail record
  8. +
  9. No bulk sending (encourages personalization)
  10. +
+

Error Recovery Flow

+

Invalid Postal Code: +1. User enters malformed postal code +2. API returns 404 or empty array +3. Message displays: "No representatives found" +4. User corrects postal code +5. Re-triggers lookup

+

Email Send Failure: +1. User clicks Send button +2. API returns 500 error +3. Error message displays +4. Send button remains enabled +5. User can retry immediately

+

Missing Information: +1. User tries to send without entering email +2. Form validation triggers +3. Required field highlighted in red +4. User fills in email +5. Proceeds with send

+
+

Component Structure

+
import React, { useState, useEffect } from 'react';
+import { useParams, Link } from 'react-router-dom';
+import {
+  Steps,
+  Button,
+  Input,
+  Card,
+  Row,
+  Col,
+  Typography,
+  Form,
+  message,
+  Spin,
+  Tag,
+  Grid,
+  Space
+} from 'antd';
+import {
+  MailOutlined,
+  SearchOutlined,
+  CommentOutlined,
+  ArrowLeftOutlined,
+  SendOutlined,
+  DesktopOutlined
+} from '@ant-design/icons';
+import PublicLayout from '../../components/PublicLayout';
+import ShareButtons from '../../components/ShareButtons';
+import axios from 'axios';
+
+const { Title, Paragraph, Text } = Typography;
+const { Step } = Steps;
+const { TextArea } = Input;
+const { useBreakpoint } = Grid;
+
+interface Campaign {
+  id: string;
+  title: string;
+  description: string | null;
+  slug: string;
+  coverPhoto: string | null;
+  governmentLevel: string[];
+  targetType: string;
+  emailSubject: string;
+  emailBody: string;
+  allowEmailEditing: boolean;
+  isActive: boolean;
+  emailsSentCount: number;
+  responsesCount: number;
+}
+
+interface Representative {
+  name: string;
+  district_name: string;
+  elected_office: string;
+  party_name: string;
+  email: string;
+  photo_url: string;
+  government_level: string;
+  offices: Array<{
+    tel: string;
+    type: string;
+    postal: string;
+  }>;
+}
+
+const CampaignPage: React.FC = () => {
+  const { id } = useParams<{ id: string }>();
+  const [currentStep, setCurrentStep] = useState(0);
+  const [campaign, setCampaign] = useState<Campaign | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [postalCode, setPostalCode] = useState('');
+  const [representatives, setRepresentatives] = useState<Representative[]>([]);
+  const [repsLoading, setRepsLoading] = useState(false);
+  const [userEmail, setUserEmail] = useState('');
+  const [userName, setUserName] = useState('');
+  const [customEmailBody, setCustomEmailBody] = useState('');
+  const [sendingTo, setSendingTo] = useState<string | null>(null);
+  const screens = useBreakpoint();
+  const isMobile = !screens.md;
+
+  // Data fetching, handlers, etc.
+
+  return (
+    <PublicLayout>
+      {/* Hero Section */}
+      {/* Step Indicator */}
+      {/* Step Content */}
+      {/* Share Buttons */}
+    </PublicLayout>
+  );
+};
+
+export default CampaignPage;
+
+
+

State Management

+

Component State

+
// Navigation state
+const [currentStep, setCurrentStep] = useState(0); // 0=Info, 1=Reps, 2=Send
+
+// Campaign data
+const [campaign, setCampaign] = useState<Campaign | null>(null);
+const [loading, setLoading] = useState(true);
+
+// Representative lookup
+const [postalCode, setPostalCode] = useState('');
+const [representatives, setRepresentatives] = useState<Representative[]>([]);
+const [repsLoading, setRepsLoading] = useState(false);
+
+// User input for email
+const [userEmail, setUserEmail] = useState('');
+const [userName, setUserName] = useState('');
+const [customEmailBody, setCustomEmailBody] = useState('');
+
+// Send state
+const [sendingTo, setSendingTo] = useState<string | null>(null); // Rep email being sent to
+
+// Responsive
+const screens = useBreakpoint();
+const isMobile = !screens.md;
+
+

Derived State

+
// Filtered representatives by government level
+const filteredReps = representatives.filter(rep => {
+  if (!campaign) return false;
+  // Show all reps if campaign targets multiple levels or 'all'
+  if (campaign.governmentLevel.includes('all')) return true;
+  // Otherwise only show reps matching campaign's government levels
+  return campaign.governmentLevel.includes(rep.government_level);
+});
+
+// Email preview with substitutions
+const emailPreview = useMemo(() => {
+  if (!campaign) return '';
+
+  let body = customEmailBody || campaign.emailBody;
+
+  // Replace placeholders
+  body = body.replace(/\{name\}/g, userName || '[Your Name]');
+  body = body.replace(/\{email\}/g, userEmail || '[Your Email]');
+  body = body.replace(/\{postalCode\}/g, postalCode || '[Your Postal Code]');
+
+  return body;
+}, [campaign, customEmailBody, userName, userEmail, postalCode]);
+
+// Step navigation enabled states
+const canProceedToStep2 = !!campaign; // Campaign loaded
+const canProceedToStep3 = representatives.length > 0; // Reps found
+
+

State Flow

+
    +
  1. Initial Load: loading=true, fetch campaign by ID
  2. +
  3. Campaign Loaded: setCampaign(), setLoading(false)
  4. +
  5. User Enters Postal Code: setPostalCode() updates input
  6. +
  7. Lookup Triggered: setRepsLoading(true), fetch representatives
  8. +
  9. Reps Loaded: setRepresentatives(), setRepsLoading(false), auto-advance to step 3
  10. +
  11. User Customizes Email: setCustomEmailBody() if editing allowed
  12. +
  13. User Clicks Send: setSendingTo(rep.email), post to API
  14. +
  15. Send Complete: setSendingTo(null), show success message, increment counter
  16. +
+
+

API Integration

+

Endpoints Used

+

1. Get Campaign by ID

+
GET /api/public/campaigns/:id
+
+

Response: +

{
+  "id": "cm1abc123",
+  "title": "Support Climate Action Bill",
+  "description": "Urge your representatives to support strong climate legislation...",
+  "slug": "climate-action-bill",
+  "coverPhoto": "https://example.com/photos/climate.jpg",
+  "governmentLevel": ["federal"],
+  "targetType": "representatives",
+  "emailSubject": "Please Support Bill C-123",
+  "emailBody": "Dear {representative},\n\nAs your constituent in {postalCode}, I urge you to support Bill C-123...\n\nSincerely,\n{name}\n{email}",
+  "allowEmailEditing": true,
+  "isActive": true,
+  "emailsSentCount": 1247,
+  "responsesCount": 342,
+  "createdAt": "2025-01-15T10:00:00.000Z"
+}
+

+

2. Lookup Representatives

+
GET /api/public/representatives/lookup?postalCode=K1A0B1
+
+

Response: +

[
+  {
+    "name": "John Smith",
+    "district_name": "Ottawa Centre",
+    "elected_office": "MP",
+    "party_name": "Liberal",
+    "email": "john.smith@parl.gc.ca",
+    "photo_url": "https://represent.opennorth.ca/media/photos/mp-john-smith.jpg",
+    "government_level": "federal",
+    "offices": [
+      {
+        "tel": "613-555-1234",
+        "type": "constituency",
+        "postal": "123 Main St, Ottawa ON K1A 0B1"
+      }
+    ]
+  }
+]
+

+

3. Send Campaign Email

+
POST /api/public/campaigns/:id/send-email
+Content-Type: application/json
+
+{
+  "senderName": "Jane Doe",
+  "senderEmail": "jane@example.com",
+  "postalCode": "K1A 0B1",
+  "recipientName": "John Smith",
+  "recipientEmail": "john.smith@parl.gc.ca",
+  "customMessage": "Dear MP Smith,\n\nAs your constituent...",
+  "government_level": "federal"
+}
+
+

Response: +

{
+  "success": true,
+  "emailId": "cm2def456",
+  "message": "Email queued for sending"
+}
+

+

Request Examples

+

Fetch Campaign

+
useEffect(() => {
+  const fetchCampaign = async () => {
+    if (!id) {
+      message.error('Invalid campaign ID');
+      return;
+    }
+
+    try {
+      setLoading(true);
+      const response = await axios.get(`/api/public/campaigns/${id}`);
+      setCampaign(response.data);
+      setCustomEmailBody(response.data.emailBody); // Initialize with template
+    } catch (error: any) {
+      console.error('Failed to fetch campaign:', error);
+      if (error.response?.status === 404) {
+        message.error('Campaign not found');
+      } else {
+        message.error('Failed to load campaign');
+      }
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  fetchCampaign();
+}, [id]);
+
+

Lookup Representatives

+
const handlePostalCodeLookup = async () => {
+  if (!postalCode.trim()) {
+    message.warning('Please enter a postal code');
+    return;
+  }
+
+  try {
+    setRepsLoading(true);
+    const response = await axios.get('/api/public/representatives/lookup', {
+      params: { postalCode: postalCode.trim().toUpperCase() }
+    });
+
+    setRepresentatives(response.data);
+
+    if (response.data.length === 0) {
+      message.info('No representatives found for this postal code');
+    } else {
+      // Auto-advance to step 3
+      setCurrentStep(2);
+      message.success(`Found ${response.data.length} representative(s)`);
+    }
+  } catch (error) {
+    console.error('Lookup failed:', error);
+    message.error('Failed to find representatives. Please check the postal code.');
+  } finally {
+    setRepsLoading(false);
+  }
+};
+
+

Send Email

+
const handleSendEmail = async (rep: Representative) => {
+  if (!userName.trim() || !userEmail.trim()) {
+    message.warning('Please enter your name and email');
+    return;
+  }
+
+  if (!campaign) return;
+
+  try {
+    setSendingTo(rep.email);
+
+    await axios.post(`/api/public/campaigns/${campaign.id}/send-email`, {
+      senderName: userName,
+      senderEmail: userEmail,
+      postalCode: postalCode.toUpperCase(),
+      recipientName: rep.name,
+      recipientEmail: rep.email,
+      customMessage: customEmailBody || campaign.emailBody,
+      government_level: rep.government_level
+    });
+
+    message.success(`Email sent to ${rep.name}!`);
+
+    // Update local counter (optimistic update)
+    setCampaign(prev => prev ? {
+      ...prev,
+      emailsSentCount: prev.emailsSentCount + 1
+    } : null);
+
+  } catch (error: any) {
+    console.error('Failed to send email:', error);
+    message.error(error.response?.data?.message || 'Failed to send email. Please try again.');
+  } finally {
+    setSendingTo(null);
+  }
+};
+
+
+

Code Examples

+

Hero Section with Statistics

+
<div style={{ position: 'relative', marginBottom: 32 }}>
+  {/* Cover Photo or Gradient */}
+  <div style={{
+    height: isMobile ? 250 : 400,
+    overflow: 'hidden',
+    position: 'relative',
+    borderRadius: 8
+  }}>
+    {campaign.coverPhoto ? (
+      <img
+        src={campaign.coverPhoto}
+        alt={campaign.title}
+        style={{
+          width: '100%',
+          height: '100%',
+          objectFit: 'cover'
+        }}
+      />
+    ) : (
+      <div style={{
+        width: '100%',
+        height: '100%',
+        background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
+      }} />
+    )}
+
+    {/* Gradient Overlay */}
+    <div style={{
+      position: 'absolute',
+      bottom: 0,
+      left: 0,
+      right: 0,
+      height: '50%',
+      background: 'linear-gradient(to top, rgba(0,0,0,0.8), transparent)'
+    }} />
+
+    {/* Title Overlay */}
+    <div style={{
+      position: 'absolute',
+      bottom: 24,
+      left: 24,
+      right: isMobile ? 24 : '30%',
+      color: 'white'
+    }}>
+      <Title
+        level={1}
+        style={{
+          color: 'white',
+          marginBottom: 8,
+          fontSize: isMobile ? 24 : 36
+        }}
+      >
+        {campaign.title}
+      </Title>
+    </div>
+
+    {/* Statistics Circles */}
+    <div style={{
+      position: 'absolute',
+      top: 24,
+      right: 24,
+      display: 'flex',
+      flexDirection: isMobile ? 'column' : 'row',
+      gap: 16
+    }}>
+      {/* Emails Sent Circle */}
+      <div style={{
+        background: 'rgba(24, 144, 255, 0.9)',
+        borderRadius: '50%',
+        width: 100,
+        height: 100,
+        display: 'flex',
+        flexDirection: 'column',
+        alignItems: 'center',
+        justifyContent: 'center',
+        color: 'white',
+        boxShadow: '0 4px 12px rgba(0,0,0,0.3)'
+      }}>
+        <MailOutlined style={{ fontSize: 24, marginBottom: 4 }} />
+        <Text strong style={{ color: 'white', fontSize: 20 }}>
+          {campaign.emailsSentCount}
+        </Text>
+        <Text style={{ color: 'white', fontSize: 12 }}>
+          Emails
+        </Text>
+      </div>
+
+      {/* Responses Circle */}
+      <div style={{
+        background: 'rgba(82, 196, 26, 0.9)',
+        borderRadius: '50%',
+        width: 100,
+        height: 100,
+        display: 'flex',
+        flexDirection: 'column',
+        alignItems: 'center',
+        justifyContent: 'center',
+        color: 'white',
+        boxShadow: '0 4px 12px rgba(0,0,0,0.3)'
+      }}>
+        <CommentOutlined style={{ fontSize: 24, marginBottom: 4 }} />
+        <Text strong style={{ color: 'white', fontSize: 20 }}>
+          {campaign.responsesCount}
+        </Text>
+        <Text style={{ color: 'white', fontSize: 12 }}>
+          Responses
+        </Text>
+      </div>
+    </div>
+  </div>
+</div>
+
+

Step Indicator

+
<Steps
+  current={currentStep}
+  onChange={setCurrentStep}
+  direction={isMobile ? 'vertical' : 'horizontal'}
+  style={{ marginBottom: 32 }}
+>
+  <Step
+    title="Campaign Info"
+    description={!isMobile && "Learn about the campaign"}
+    icon={<MailOutlined />}
+  />
+  <Step
+    title="Your Representatives"
+    description={!isMobile && "Find your elected officials"}
+    icon={<SearchOutlined />}
+    disabled={!canProceedToStep2}
+  />
+  <Step
+    title="Send Your Message"
+    description={!isMobile && "Take action now"}
+    icon={<SendOutlined />}
+    disabled={!canProceedToStep3}
+  />
+</Steps>
+
+

Representative Cards with Dual Send Options

+
<Row gutter={[16, 16]}>
+  {filteredReps.map((rep, idx) => (
+    <Col xs={24} sm={12} lg={8} key={idx}>
+      <Card hoverable>
+        {/* Photo */}
+        <div style={{ textAlign: 'center', marginBottom: 16 }}>
+          <img
+            src={rep.photo_url || '/default-avatar.png'}
+            alt={rep.name}
+            style={{
+              width: 120,
+              height: 120,
+              borderRadius: '50%',
+              objectFit: 'cover',
+              border: '3px solid #1890ff'
+            }}
+          />
+        </div>
+
+        {/* Details */}
+        <Title level={4} style={{ marginBottom: 4, textAlign: 'center' }}>
+          {rep.name}
+        </Title>
+        <Text type="secondary" style={{ display: 'block', textAlign: 'center', marginBottom: 8 }}>
+          {rep.elected_office}  {rep.district_name}
+        </Text>
+
+        <div style={{ textAlign: 'center', marginBottom: 16 }}>
+          <Tag color="blue">{rep.party_name}</Tag>
+          <Tag color="purple">
+            {rep.government_level.charAt(0).toUpperCase() + rep.government_level.slice(1)}
+          </Tag>
+        </div>
+
+        {/* Contact Info */}
+        <div style={{ marginBottom: 16, fontSize: 12 }}>
+          <Text strong>Email:</Text>
+          <br />
+          <Text copyable style={{ fontSize: 12 }}>{rep.email}</Text>
+          <br /><br />
+
+          {rep.offices?.[0]?.tel && (
+            <>
+              <Text strong>Phone:</Text>
+              <br />
+              <Text style={{ fontSize: 12 }}>{rep.offices[0].tel}</Text>
+              <br /><br />
+            </>
+          )}
+
+          {rep.offices?.[0]?.postal && (
+            <>
+              <Text strong>Office:</Text>
+              <br />
+              <Text type="secondary" style={{ fontSize: 12 }}>
+                {rep.offices[0].postal}
+              </Text>
+            </>
+          )}
+        </div>
+
+        {/* Send Buttons */}
+        <Space direction="vertical" style={{ width: '100%' }}>
+          {/* SMTP Send (Tracked) */}
+          <Button
+            type="primary"
+            icon={<SendOutlined />}
+            block
+            loading={sendingTo === rep.email}
+            onClick={() => handleSendEmail(rep)}
+            disabled={!userName || !userEmail}
+          >
+            Send Email
+          </Button>
+
+          {/* Mailto (Untracked) */}
+          <Button
+            icon={<DesktopOutlined />}
+            block
+            onClick={() => {
+              const subject = encodeURIComponent(campaign.emailSubject);
+              const body = encodeURIComponent(emailPreview);
+              window.location.href = `mailto:${rep.email}?subject=${subject}&body=${body}`;
+            }}
+          >
+            Open in Email App
+          </Button>
+        </Space>
+      </Card>
+    </Col>
+  ))}
+</Row>
+
+

Email Preview with Optional Editing

+
<Card
+  title="Email Preview"
+  style={{ marginBottom: 24 }}
+  extra={
+    campaign.allowEmailEditing && (
+      <Text type="secondary" style={{ fontSize: 12 }}>
+        You can edit this message
+      </Text>
+    )
+  }
+>
+  {/* Subject Line */}
+  <div style={{ marginBottom: 16 }}>
+    <Text strong>Subject:</Text>
+    <br />
+    <Text>{campaign.emailSubject}</Text>
+  </div>
+
+  {/* Email Body */}
+  <div>
+    <Text strong>Message:</Text>
+    {campaign.allowEmailEditing ? (
+      <TextArea
+        value={customEmailBody}
+        onChange={(e) => setCustomEmailBody(e.target.value)}
+        rows={10}
+        style={{ marginTop: 8, fontFamily: 'monospace', fontSize: 13 }}
+      />
+    ) : (
+      <pre style={{
+        marginTop: 8,
+        padding: 16,
+        background: '#f5f5f5',
+        borderRadius: 4,
+        whiteSpace: 'pre-wrap',
+        fontFamily: 'inherit',
+        fontSize: 13
+      }}>
+        {emailPreview}
+      </pre>
+    )}
+  </div>
+
+  {/* Placeholder Legend */}
+  <div style={{
+    marginTop: 16,
+    padding: 12,
+    background: '#e6f7ff',
+    borderRadius: 4,
+    fontSize: 12
+  }}>
+    <Text type="secondary">
+      <strong>Available placeholders:</strong> {'{name}'}, {'{email}'}, {'{postalCode}'}
+    </Text>
+  </div>
+</Card>
+
+

User Information Form

+
<Card title="Your Information" style={{ marginBottom: 24 }}>
+  <Form layout="vertical">
+    <Form.Item
+      label="Your Name"
+      required
+      validateStatus={!userName && 'error'}
+      help={!userName && 'Please enter your name'}
+    >
+      <Input
+        size="large"
+        placeholder="Jane Doe"
+        value={userName}
+        onChange={(e) => setUserName(e.target.value)}
+      />
+    </Form.Item>
+
+    <Form.Item
+      label="Your Email"
+      required
+      validateStatus={!userEmail && 'error'}
+      help={!userEmail && 'Please enter your email'}
+    >
+      <Input
+        size="large"
+        type="email"
+        placeholder="jane@example.com"
+        value={userEmail}
+        onChange={(e) => setUserEmail(e.target.value)}
+      />
+    </Form.Item>
+
+    <Form.Item
+      label="Postal Code"
+      required
+      validateStatus={!postalCode && 'error'}
+      help={!postalCode && 'Entered in step 2'}
+    >
+      <Input
+        size="large"
+        disabled
+        value={postalCode}
+        style={{ background: '#f5f5f5' }}
+      />
+    </Form.Item>
+  </Form>
+</Card>
+
+

Response Wall CTA

+
{campaign.responsesCount > 0 && (
+  <Card
+    style={{
+      marginTop: 32,
+      background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
+      border: 'none'
+    }}
+  >
+    <div style={{ textAlign: 'center', color: 'white' }}>
+      <CommentOutlined style={{ fontSize: 48, marginBottom: 16 }} />
+      <Title level={3} style={{ color: 'white', marginBottom: 16 }}>
+        See What Others Are Saying
+      </Title>
+      <Paragraph style={{ color: 'rgba(255,255,255,0.9)', marginBottom: 24 }}>
+        Read {campaign.responsesCount} responses from people who took action
+      </Paragraph>
+      <Link to={`/responses/${campaign.id}`}>
+        <Button type="default" size="large">
+          View Response Wall
+        </Button>
+      </Link>
+    </div>
+  </Card>
+)}
+
+ +
<div style={{
+  display: 'flex',
+  justifyContent: 'space-between',
+  marginTop: 32,
+  paddingTop: 24,
+  borderTop: '1px solid #303030'
+}}>
+  <Button
+    onClick={() => setCurrentStep(prev => Math.max(0, prev - 1))}
+    disabled={currentStep === 0}
+  >
+    <ArrowLeftOutlined /> Previous
+  </Button>
+
+  {currentStep < 2 ? (
+    <Button
+      type="primary"
+      onClick={() => setCurrentStep(prev => Math.min(2, prev + 1))}
+      disabled={
+        (currentStep === 0 && !campaign) ||
+        (currentStep === 1 && representatives.length === 0)
+      }
+    >
+      Next <ArrowLeftOutlined style={{ transform: 'rotate(180deg)' }} />
+    </Button>
+  ) : (
+    <Text type="secondary">
+      Click "Send Email" on any representative card above
+    </Text>
+  )}
+</div>
+
+
+

Performance Considerations

+

1. Optimized Email Preview Rendering

+

Uses useMemo to avoid re-computing on every render:

+
const emailPreview = useMemo(() => {
+  if (!campaign) return '';
+
+  let body = customEmailBody || campaign.emailBody;
+
+  body = body.replace(/\{name\}/g, userName || '[Your Name]');
+  body = body.replace(/\{email\}/g, userEmail || '[Your Email]');
+  body = body.replace(/\{postalCode\}/g, postalCode || '[Your Postal Code]');
+
+  return body;
+}, [campaign, customEmailBody, userName, userEmail, postalCode]);
+
+

Benefit: Preview only recalculates when dependencies change, not on every keystroke.

+

2. Auto-advance After Lookup

+

Automatically proceeds to step 3 when representatives loaded:

+
if (response.data.length > 0) {
+  setCurrentStep(2); // Auto-advance
+  message.success(`Found ${response.data.length} representative(s)`);
+}
+
+

Benefit: Reduces user clicks, smoother workflow.

+

3. Optimistic UI Updates

+

Updates email counter immediately after send (before API response):

+
message.success(`Email sent to ${rep.name}!`);
+
+setCampaign(prev => prev ? {
+  ...prev,
+  emailsSentCount: prev.emailsSentCount + 1
+} : null);
+
+

Benefit: Instant feedback, perceived performance improvement.

+

4. Conditional Component Rendering

+

Response wall CTA only renders if responses exist:

+
{campaign.responsesCount > 0 && (
+  <Card>{/* Response wall CTA */}</Card>
+)}
+
+

Benefit: Cleaner DOM, faster initial render for new campaigns.

+

5. Debounced Representative Filtering

+

Filtering happens on blur/Enter, not on every keystroke:

+
<Input
+  onBlur={handlePostalCodeLookup}
+  onPressEnter={handlePostalCodeLookup}
+  // NOT: onChange={handlePostalCodeLookup}
+/>
+
+

Benefit: Prevents excessive API calls while user types.

+
+

Responsive Design

+

Breakpoint Behavior

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BreakpointHero HeightStats PositionSteps DirectionRep Cards Columns
xs (0-575px)250pxVertical stackVertical1
sm (576-767px)250pxVertical stackVertical2
md (768-991px)400pxHorizontal rowHorizontal2
lg (992px+)400pxHorizontal rowHorizontal3
+

Mobile Adaptations

+

Hero Section: +- Reduced height (250px vs 400px) +- Statistics circles stack vertically +- Title font size reduced (24px vs 36px) +- Right margin for title increased to prevent overlap with stats

+

Steps Component: +- Switches to vertical orientation +- Step descriptions hidden on mobile (takes too much space) +- Icons remain visible for visual guidance

+

Representative Cards: +- Single column layout on xs +- Two columns on sm (tablet portrait) +- Three columns on lg+ (desktop)

+

Form Inputs: +- Full-width inputs on mobile +- size="large" for better touch targets +- Increased spacing between fields

+

Email Preview: +- TextArea expands to full width +- Font size slightly smaller (13px) for better fit +- Scrollable if content exceeds viewport

+

Tablet Optimization

+

At sm breakpoint (576-767px): +- Rep cards show 2 per row (good balance) +- Hero maintains mobile height (better above-fold) +- Steps remain vertical (clearer on narrow viewports) +- Send buttons remain full-width within cards

+
+

Accessibility

+

Keyboard Navigation

+

Step Navigation: +- Steps component is keyboard accessible (Tab + Enter) +- Arrow keys navigate between steps (native Ant Design) +- Space bar activates step

+

Form Fields: +- All inputs focusable via Tab +- Enter key submits postal code lookup +- Escape key can close modals (future feature)

+

Send Buttons: +- Both "Send Email" and "Open in Email App" are focusable +- Enter/Space activates button +- Loading state prevents double-submission

+

ARIA Labels

+

Step Indicator: +

<Steps
+  current={currentStep}
+  aria-label="Campaign action steps"
+>
+  <Step
+    title="Campaign Info"
+    icon={<MailOutlined aria-hidden="true" />}
+  />
+</Steps>
+

+

Representative Photos: +

<img
+  src={rep.photo_url}
+  alt={`Photo of ${rep.name}, ${rep.elected_office} for ${rep.district_name}`}
+  role="img"
+/>
+

+

Loading States: +

<Spin
+  size="small"
+  aria-label="Loading representatives"
+/>
+
+<Button
+  loading={sendingTo === rep.email}
+  aria-label={`Sending email to ${rep.name}`}
+>
+  Send Email
+</Button>
+

+

Screen Reader Support

+

Step Announcements: +- Current step announced when changed +- Step titles are clear and descriptive +- Disabled steps have appropriate aria-disabled attribute

+

Form Validation: +

<Form.Item
+  label="Your Name"
+  required
+  validateStatus={!userName && 'error'}
+  help={!userName && 'Please enter your name'}
+  aria-required="true"
+>
+  <Input />
+</Form.Item>
+

+

Success/Error Messages: +- Ant Design message component has ARIA live region +- Screen reader announces "Email sent successfully!" +- Error messages also announced automatically

+

Email Preview: +

<pre
+  role="article"
+  aria-label="Email message preview"
+>
+  {emailPreview}
+</pre>
+

+

Color Contrast

+

Statistics Circles: +- Blue circle: #1890ff on white text (4.5:1 ratio ✓) +- Green circle: #52c41a on white text (4.7:1 ratio ✓) +- Both meet WCAG AA standards

+

Primary Buttons: +- Ant Design primary button (#1890ff) meets AA contrast +- Focus outline visible on all interactive elements

+

Text Hierarchy: +- Primary text: white on #0d1b2a (15.8:1 ratio ✓✓) +- Secondary text: rgba(255,255,255,0.65) on dark (7.2:1 ratio ✓) +- Links: #1890ff with underline on focus

+
+

Troubleshooting

+

Issue: Representatives Not Filtered by Government Level

+

Symptoms: +- Federal campaign shows provincial/municipal reps +- All reps display regardless of campaign targets +- Filtering logic not working

+

Causes: +1. government_level field missing in API response +2. governmentLevel array empty in campaign +3. Case mismatch (Federal vs federal) +4. Filtering logic bug

+

Solutions:

+
// Add debug logging
+useEffect(() => {
+  if (representatives.length > 0 && campaign) {
+    console.log('Campaign levels:', campaign.governmentLevel);
+    console.log('Rep levels:', representatives.map(r => r.government_level));
+    console.log('Filtered count:', filteredReps.length);
+  }
+}, [representatives, campaign]);
+
+// Robust filtering with case-insensitive matching
+const filteredReps = representatives.filter(rep => {
+  if (!campaign || !rep.government_level) return false;
+
+  // Normalize to lowercase for comparison
+  const campaignLevels = campaign.governmentLevel.map(l => l.toLowerCase());
+  const repLevel = rep.government_level.toLowerCase();
+
+  // Show all if campaign targets 'all' levels
+  if (campaignLevels.includes('all')) return true;
+
+  // Otherwise match exact level
+  return campaignLevels.includes(repLevel);
+});
+
+// Add fallback if no filtered reps
+{filteredReps.length === 0 && representatives.length > 0 && (
+  <Alert
+    type="warning"
+    message="No matching representatives"
+    description={`This campaign targets ${campaign.governmentLevel.join(', ')} representatives, but none were found for your postal code at that level.`}
+    style={{ marginBottom: 16 }}
+  />
+)}
+
+

Check API response: +

# Verify government_level field present
+curl http://localhost:4000/api/public/representatives/lookup?postalCode=K1A0B1 | jq '.[].government_level'
+# Should output: "federal", "provincial", etc.
+

+

Issue: Email Preview Not Updating

+

Symptoms: +- Placeholders remain as {name} instead of actual values +- User input not reflected in preview +- Preview frozen on initial template

+

Causes: +1. useMemo dependencies missing +2. State not updating properly +3. Placeholder regex not matching +4. Component not re-rendering

+

Solutions:

+
// Ensure all dependencies in useMemo
+const emailPreview = useMemo(() => {
+  if (!campaign) return '';
+
+  let body = customEmailBody || campaign.emailBody;
+
+  // Use global replace with /g flag
+  body = body.replace(/\{name\}/g, userName || '[Your Name]');
+  body = body.replace(/\{email\}/g, userEmail || '[Your Email]');
+  body = body.replace(/\{postalCode\}/g, postalCode || '[Your Postal Code]');
+
+  // Log for debugging
+  console.log('Preview updated:', {
+    userName,
+    userEmail,
+    postalCode,
+    bodyLength: body.length
+  });
+
+  return body;
+}, [campaign, customEmailBody, userName, userEmail, postalCode]);
+// ^^^ All dependencies must be listed
+
+// Alternative: Force re-render with key
+<pre key={`${userName}-${userEmail}-${postalCode}`}>
+  {emailPreview}
+</pre>
+
+

Issue: Send Button Not Working

+

Symptoms: +- Clicking "Send Email" does nothing +- No API request in Network tab +- Button not disabled/loading

+

Causes: +1. Missing form validation +2. Event handler not bound +3. API endpoint incorrect +4. CORS error blocking request

+

Solutions:

+
// Add comprehensive validation
+const handleSendEmail = async (rep: Representative) => {
+  // Validate user input
+  if (!userName.trim()) {
+    message.error('Please enter your name');
+    return;
+  }
+
+  if (!userEmail.trim()) {
+    message.error('Please enter your email');
+    return;
+  }
+
+  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(userEmail)) {
+    message.error('Please enter a valid email address');
+    return;
+  }
+
+  if (!postalCode.trim()) {
+    message.error('Postal code is required (from step 2)');
+    return;
+  }
+
+  if (!campaign) {
+    message.error('Campaign data not loaded');
+    return;
+  }
+
+  // Log request details
+  console.log('Sending email:', {
+    campaignId: campaign.id,
+    to: rep.email,
+    from: userEmail
+  });
+
+  try {
+    setSendingTo(rep.email);
+
+    const payload = {
+      senderName: userName.trim(),
+      senderEmail: userEmail.trim(),
+      postalCode: postalCode.trim().toUpperCase(),
+      recipientName: rep.name,
+      recipientEmail: rep.email,
+      customMessage: customEmailBody || campaign.emailBody,
+      government_level: rep.government_level
+    };
+
+    console.log('Payload:', payload);
+
+    const response = await axios.post(
+      `/api/public/campaigns/${campaign.id}/send-email`,
+      payload,
+      { timeout: 10000 } // 10s timeout
+    );
+
+    console.log('Response:', response.data);
+
+    message.success(`Email sent to ${rep.name}!`);
+
+    // Optimistic update
+    setCampaign(prev => prev ? {
+      ...prev,
+      emailsSentCount: prev.emailsSentCount + 1
+    } : null);
+
+  } catch (error: any) {
+    console.error('Send error:', error);
+
+    if (error.code === 'ECONNABORTED') {
+      message.error('Request timed out. Please try again.');
+    } else if (error.response) {
+      message.error(error.response.data?.message || 'Failed to send email');
+    } else {
+      message.error('Network error. Please check your connection.');
+    }
+  } finally {
+    setSendingTo(null);
+  }
+};
+
+

Check CORS configuration: +

// In api/src/server.ts
+app.use(cors({
+  origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
+  credentials: true
+}));
+

+

Issue: Auto-advance to Step 3 Not Working

+

Symptoms: +- Representatives load but page stays on step 2 +- User must manually click "Next" +- Auto-advance logic not triggering

+

Causes: +1. State update timing issue +2. Conditional check failing +3. React Strict Mode double-rendering +4. Missing setCurrentStep(2) call

+

Solutions:

+
// Move auto-advance inside success branch
+const handlePostalCodeLookup = async () => {
+  if (!postalCode.trim()) {
+    message.warning('Please enter a postal code');
+    return;
+  }
+
+  try {
+    setRepsLoading(true);
+    const response = await axios.get('/api/public/representatives/lookup', {
+      params: { postalCode: postalCode.trim().toUpperCase() }
+    });
+
+    setRepresentatives(response.data);
+
+    // Auto-advance ONLY if reps found
+    if (response.data.length > 0) {
+      // Use setTimeout to ensure state update completes
+      setTimeout(() => {
+        setCurrentStep(2);
+        message.success(`Found ${response.data.length} representative(s)`);
+      }, 100);
+    } else {
+      message.info('No representatives found for this postal code');
+    }
+  } catch (error) {
+    console.error('Lookup failed:', error);
+    message.error('Failed to find representatives');
+  } finally {
+    setRepsLoading(false);
+  }
+};
+
+// Alternative: Use useEffect to watch for reps
+useEffect(() => {
+  if (representatives.length > 0 && currentStep === 1) {
+    setCurrentStep(2);
+  }
+}, [representatives.length, currentStep]);
+
+ +

Symptoms: +- Clicking "Open in Email App" does nothing +- Browser blocks mailto: protocol +- Email client doesn't open

+

Causes: +1. Browser security settings blocking mailto +2. No default email client configured +3. URL encoding issues +4. Email body too long (URL length limit)

+

Solutions:

+
// Add error handling for mailto
+const handleMailtoClick = (rep: Representative) => {
+  try {
+    const subject = encodeURIComponent(campaign.emailSubject);
+    const body = encodeURIComponent(emailPreview);
+
+    // Check URL length (browsers have ~2000 char limit)
+    const mailtoUrl = `mailto:${rep.email}?subject=${subject}&body=${body}`;
+
+    if (mailtoUrl.length > 2000) {
+      message.warning(
+        'Email message is too long for mailto link. ' +
+        'Please use the "Send Email" button instead.',
+        5
+      );
+      return;
+    }
+
+    // Try to open mailto
+    window.location.href = mailtoUrl;
+
+    // Show informative message
+    message.info(
+      'Opening your email client. If nothing happens, please check your browser settings.',
+      5
+    );
+
+  } catch (error) {
+    console.error('Mailto error:', error);
+    message.error('Failed to open email client. Please use the "Send Email" button instead.');
+  }
+};
+
+// Update button
+<Button
+  icon={<DesktopOutlined />}
+  block
+  onClick={() => handleMailtoClick(rep)}
+>
+  Open in Email App
+</Button>
+
+
+ +

Public Pages

+ +

Admin Pages

+ +

Components

+ +

API Documentation

+ +

Architecture

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/public/campaigns-list-page/index.html b/mkdocs/site/v2/frontend/pages/public/campaigns-list-page/index.html new file mode 100644 index 00000000..ba783008 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/public/campaigns-list-page/index.html @@ -0,0 +1,7714 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Campaigns List - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Campaigns List Page

+

Overview

+

File Path: admin/src/pages/public/CampaignsListPage.tsx (566 lines)

+

Route: /campaigns

+

Role Requirements: Public access (no authentication required)

+

Purpose: Primary landing page for the advocacy campaign system, providing a browseable directory of active campaigns with featured campaign highlighting, postal code-based representative lookup, and social sharing capabilities.

+

Key Features:

+
    +
  • Hero banner with organization name and gradient background
  • +
  • "Find Your Representatives" postal code lookup section
  • +
  • Featured campaign card with gold border and star icon
  • +
  • Responsive campaigns grid (3 columns on desktop)
  • +
  • Individual campaign cards with cover photos or gradient backgrounds
  • +
  • ShareButtons component for social media sharing
  • +
  • Dark blue/teal theme consistent with public pages
  • +
  • Real-time campaign statistics (emails sent, responses)
  • +
  • Mobile-responsive design with hamburger navigation
  • +
+

Layout: Uses PublicLayout component with dark theme (colorBgBase: '#0d1b2a', colorBgContainer: '#1b2838')

+
+

Features

+

1. Hero Banner

+

The hero section provides visual branding and context:

+
    +
  • Organization Name Display: Fetched from site settings API
  • +
  • Gradient Background: linear-gradient(135deg, #667eea 0%, #764ba2 100%)
  • +
  • Typography: Large heading (32px desktop, 24px mobile)
  • +
  • Tagline: "Join thousands taking action" with email icon
  • +
  • Height: 250px on desktop, 200px on mobile
  • +
+

2. Find Your Representatives Section

+

Postal code lookup interface for representative discovery:

+
    +
  • Input Field: Text input with search icon prefix
  • +
  • Loading States: Spinning icon during API lookup
  • +
  • Representative Cards: Grid display (xs=1, sm=2, lg=3 columns)
  • +
  • Card Details:
  • +
  • Representative photo (150x150 circular avatar)
  • +
  • Name with title formatting
  • +
  • District/riding information
  • +
  • Political party with badge styling
  • +
  • Contact information (email, phone)
  • +
  • Office address
  • +
  • No Results State: Informative message with alternate contact suggestion
  • +
  • Government Level Filtering: Shows reps from all applicable levels
  • +
+ +

Highlighted campaign with premium styling:

+
    +
  • Gold Border: 2px solid #f39c12 with glow shadow
  • +
  • Star Icon: Antd StarFilled in gold color
  • +
  • "Featured Campaign" Badge: Gold text on dark background
  • +
  • Cover Photo: Full-width image (300px height) with overlay gradient
  • +
  • Fallback Gradient: Purple-to-blue gradient when no cover photo
  • +
  • Statistics Display: Emails sent and responses count
  • +
  • Action Button: Primary styled "View Campaign" link
  • +
  • Positioning: Always appears first in grid
  • +
+

4. Campaigns Grid

+

Responsive grid layout for all campaigns:

+
    +
  • Responsive Columns:
  • +
  • xs: 1 column (mobile)
  • +
  • sm: 2 columns (tablet)
  • +
  • lg: 3 columns (desktop)
  • +
  • Gutter: 24px horizontal and vertical spacing
  • +
  • Card Components: Ant Design Card with hover effects
  • +
  • Card Contents:
  • +
  • Cover photo or gradient background (200px height)
  • +
  • Campaign title (Typography.Title level 4)
  • +
  • Truncated description (2-line ellipsis)
  • +
  • Government level tags (federal, provincial, municipal)
  • +
  • Statistics row (emails sent, responses)
  • +
  • "View Campaign" link button
  • +
+

5. Social Sharing

+

ShareButtons component integration:

+
    +
  • Platforms: X (Twitter), Facebook, LinkedIn, Reddit, Email, Copy Link
  • +
  • URL Sharing: Current page URL
  • +
  • Title Sharing: "Check out these advocacy campaigns!"
  • +
  • Positioning: Below campaigns grid
  • +
  • Icon Buttons: Circular buttons with platform-specific colors
  • +
  • Copy Link Feedback: Success message notification
  • +
+

6. Empty States

+

Graceful handling of no-data scenarios:

+
    +
  • No Campaigns: Large icon with "No campaigns available" message
  • +
  • No Featured Campaign: Skips featured section, shows all campaigns equally
  • +
  • Loading State: Ant Design Spin component with centered alignment
  • +
+
+

User Workflow

+

Initial Page Load

+
    +
  1. User navigates to /campaigns
  2. +
  3. PublicLayout renders with dark theme
  4. +
  5. Component fetches settings from /api/settings
  6. +
  7. Component fetches campaigns from /api/public/campaigns
  8. +
  9. Hero banner displays organization name
  10. +
  11. Campaigns grid renders with featured campaign (if exists) highlighted
  12. +
  13. ShareButtons component appears at bottom
  14. +
+

Representative Lookup Flow

+
    +
  1. User enters postal code in "Find Your Representatives" input
  2. +
  3. On blur or Enter key, component triggers lookup
  4. +
  5. Loading spinner appears in input suffix
  6. +
  7. API request to /api/public/representatives/lookup?postalCode=X
  8. +
  9. Results display in grid format with rep cards
  10. +
  11. User can view contact details for each representative
  12. +
  13. Empty state message if no results found
  14. +
+

Campaign Browsing

+
    +
  1. User scrolls through campaigns grid
  2. +
  3. Featured campaign (if exists) appears first with gold border
  4. +
  5. User clicks "View Campaign" on any card
  6. +
  7. Navigation to /campaigns/:id detail page
  8. +
  9. Statistics update dynamically based on campaign activity
  10. +
+

Social Sharing

+
    +
  1. User scrolls to bottom of page
  2. +
  3. User clicks desired social platform icon
  4. +
  5. Platform-specific share dialog opens (new window)
  6. +
  7. For "Copy Link", URL copied to clipboard with notification
  8. +
  9. User can share to multiple platforms sequentially
  10. +
+
+

Component Structure

+
import React, { useState, useEffect } from 'react';
+import { Link } from 'react-router-dom';
+import { Row, Col, Card, Typography, Input, Spin, message, Tag, Grid } from 'antd';
+import {
+  MailOutlined,
+  SearchOutlined,
+  CommentOutlined,
+  StarFilled,
+  InboxOutlined
+} from '@ant-design/icons';
+import PublicLayout from '../../components/PublicLayout';
+import ShareButtons from '../../components/ShareButtons';
+import axios from 'axios';
+
+const { Title, Paragraph, Text } = Typography;
+const { useBreakpoint } = Grid;
+
+interface Campaign {
+  id: string;
+  title: string;
+  description: string | null;
+  slug: string;
+  coverPhoto: string | null;
+  governmentLevel: string[];
+  targetType: string;
+  isFeatured: boolean;
+  isActive: boolean;
+  emailsSentCount: number;
+  responsesCount: number;
+}
+
+interface Representative {
+  name: string;
+  district_name: string;
+  elected_office: string;
+  party_name: string;
+  email: string;
+  photo_url: string;
+  offices: Array<{
+    tel: string;
+    type: string;
+    postal: string;
+  }>;
+}
+
+interface Settings {
+  organizationName: string;
+}
+
+const CampaignsListPage: React.FC = () => {
+  const [campaigns, setCampaigns] = useState<Campaign[]>([]);
+  const [settings, setSettings] = useState<Settings | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [postalCode, setPostalCode] = useState('');
+  const [representatives, setRepresentatives] = useState<Representative[]>([]);
+  const [repsLoading, setRepsLoading] = useState(false);
+  const screens = useBreakpoint();
+  const isMobile = !screens.md;
+
+  // Data fetching, event handlers, etc.
+
+  return (
+    <PublicLayout>
+      {/* Hero Banner */}
+      <div className="hero-banner">
+        {/* Content */}
+      </div>
+
+      {/* Find Your Representatives */}
+      <div className="find-reps-section">
+        {/* Postal code input and results */}
+      </div>
+
+      {/* Campaigns Grid */}
+      <div className="campaigns-grid">
+        <Row gutter={[24, 24]}>
+          {/* Featured campaign */}
+          {/* Regular campaigns */}
+        </Row>
+      </div>
+
+      {/* Social Sharing */}
+      <ShareButtons
+        url={window.location.href}
+        title="Check out these advocacy campaigns!"
+      />
+    </PublicLayout>
+  );
+};
+
+export default CampaignsListPage;
+
+
+

State Management

+

Component State

+
// Campaign data state
+const [campaigns, setCampaigns] = useState<Campaign[]>([]);
+const [loading, setLoading] = useState(true);
+
+// Settings state
+const [settings, setSettings] = useState<Settings | null>(null);
+
+// Representative lookup state
+const [postalCode, setPostalCode] = useState('');
+const [representatives, setRepresentatives] = useState<Representative[]>([]);
+const [repsLoading, setRepsLoading] = useState(false);
+
+// Responsive design state
+const screens = useBreakpoint();
+const isMobile = !screens.md;
+
+

Derived State

+
// Separate featured and regular campaigns
+const featuredCampaign = campaigns.find(c => c.isFeatured);
+const regularCampaigns = campaigns.filter(c => !c.isFeatured);
+
+// Filter active campaigns only (done server-side in API)
+// API returns only isActive=true campaigns
+
+

State Flow

+
    +
  1. Initial Load: loading=true, fetch campaigns and settings in parallel
  2. +
  3. Data Received: setCampaigns(), setSettings(), setLoading(false)
  4. +
  5. Postal Code Entry: User types, setPostalCode() updates state
  6. +
  7. Lookup Trigger: On blur/Enter, setRepsLoading(true), fetch reps
  8. +
  9. Reps Received: setRepresentatives(), setRepsLoading(false)
  10. +
  11. Error Handling: Display message.error(), reset loading states
  12. +
+
+

API Integration

+

Endpoints Used

+

1. Get Settings

+
GET /api/settings
+
+

Response: +

{
+  "organizationName": "Progressive Action Network",
+  "contactEmail": "contact@example.org",
+  "allowPublicRegistration": true,
+  "defaultMapCenter": [45.5017, -73.5673],
+  "defaultMapZoom": 12
+}
+

+

2. List Public Campaigns

+
GET /api/public/campaigns
+
+

Response: +

[
+  {
+    "id": "cm1abc123",
+    "title": "Support Climate Action Bill",
+    "description": "Urge your representatives to support strong climate legislation",
+    "slug": "climate-action-bill",
+    "coverPhoto": "https://example.com/photos/climate.jpg",
+    "governmentLevel": ["federal"],
+    "targetType": "representatives",
+    "isFeatured": true,
+    "isActive": true,
+    "emailsSentCount": 1247,
+    "responsesCount": 342,
+    "createdAt": "2025-01-15T10:00:00.000Z",
+    "updatedAt": "2025-02-10T14:30:00.000Z"
+  }
+]
+

+

3. Lookup Representatives

+
GET /api/public/representatives/lookup?postalCode=K1A0B1
+
+

Response: +

[
+  {
+    "name": "John Smith",
+    "district_name": "Ottawa Centre",
+    "elected_office": "MP",
+    "party_name": "Liberal",
+    "email": "john.smith@parl.gc.ca",
+    "photo_url": "https://represent.opennorth.ca/media/photos/mp-john-smith.jpg",
+    "offices": [
+      {
+        "tel": "613-555-1234",
+        "type": "constituency",
+        "postal": "123 Main St, Ottawa ON K1A 0B1"
+      }
+    ],
+    "government_level": "federal"
+  }
+]
+

+

Request Examples

+

Fetch Campaigns

+
useEffect(() => {
+  const fetchData = async () => {
+    try {
+      setLoading(true);
+      const [campaignsRes, settingsRes] = await Promise.all([
+        axios.get('/api/public/campaigns'),
+        axios.get('/api/settings')
+      ]);
+      setCampaigns(campaignsRes.data);
+      setSettings(settingsRes.data);
+    } catch (error) {
+      console.error('Failed to fetch data:', error);
+      message.error('Failed to load campaigns');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  fetchData();
+}, []);
+
+

Lookup Representatives

+
const handlePostalCodeLookup = async () => {
+  if (!postalCode.trim()) {
+    message.warning('Please enter a postal code');
+    return;
+  }
+
+  try {
+    setRepsLoading(true);
+    const response = await axios.get('/api/public/representatives/lookup', {
+      params: { postalCode: postalCode.trim().toUpperCase() }
+    });
+    setRepresentatives(response.data);
+
+    if (response.data.length === 0) {
+      message.info('No representatives found for this postal code');
+    }
+  } catch (error) {
+    console.error('Lookup failed:', error);
+    message.error('Failed to find representatives. Please check the postal code.');
+  } finally {
+    setRepsLoading(false);
+  }
+};
+
+
+

Code Examples

+

Hero Banner Component

+
<div style={{
+  background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
+  padding: isMobile ? '60px 20px' : '80px 40px',
+  textAlign: 'center',
+  marginBottom: 48,
+  borderRadius: 8
+}}>
+  <Title
+    level={1}
+    style={{
+      color: 'white',
+      marginBottom: 16,
+      fontSize: isMobile ? 24 : 32
+    }}
+  >
+    {settings?.organizationName || 'Changemaker Lite'}
+  </Title>
+  <Paragraph
+    style={{
+      color: 'rgba(255,255,255,0.9)',
+      fontSize: isMobile ? 16 : 18,
+      maxWidth: 600,
+      margin: '0 auto'
+    }}
+  >
+    <MailOutlined style={{ marginRight: 8 }} />
+    Join thousands taking action on the issues that matter
+  </Paragraph>
+</div>
+
+

Representative Lookup Section

+
<div style={{
+  background: theme.token.colorBgContainer,
+  padding: isMobile ? 24 : 40,
+  borderRadius: 8,
+  marginBottom: 48
+}}>
+  <Title level={2} style={{ textAlign: 'center', marginBottom: 24 }}>
+    Find Your Representatives
+  </Title>
+
+  <Input
+    size="large"
+    placeholder="Enter your postal code (e.g., K1A 0B1)"
+    prefix={<SearchOutlined />}
+    suffix={repsLoading ? <Spin size="small" /> : null}
+    value={postalCode}
+    onChange={(e) => setPostalCode(e.target.value)}
+    onBlur={handlePostalCodeLookup}
+    onPressEnter={handlePostalCodeLookup}
+    style={{
+      maxWidth: 500,
+      display: 'block',
+      margin: '0 auto 24px'
+    }}
+  />
+
+  {representatives.length > 0 && (
+    <Row gutter={[16, 16]}>
+      {representatives.map((rep, idx) => (
+        <Col xs={24} sm={12} lg={8} key={idx}>
+          <Card hoverable>
+            <div style={{ textAlign: 'center' }}>
+              <img
+                src={rep.photo_url || '/default-avatar.png'}
+                alt={rep.name}
+                style={{
+                  width: 150,
+                  height: 150,
+                  borderRadius: '50%',
+                  objectFit: 'cover',
+                  marginBottom: 16
+                }}
+              />
+              <Title level={4} style={{ marginBottom: 4 }}>
+                {rep.name}
+              </Title>
+              <Text type="secondary">
+                {rep.elected_office}  {rep.district_name}
+              </Text>
+              <div style={{ marginTop: 12 }}>
+                <Tag color="blue">{rep.party_name}</Tag>
+              </div>
+              <div style={{ marginTop: 16, textAlign: 'left' }}>
+                <Text strong>Email:</Text>
+                <br />
+                <Text copyable>{rep.email}</Text>
+                <br /><br />
+                {rep.offices?.[0] && (
+                  <>
+                    <Text strong>Phone:</Text>
+                    <br />
+                    <Text>{rep.offices[0].tel}</Text>
+                    <br /><br />
+                    <Text strong>Address:</Text>
+                    <br />
+                    <Text type="secondary">{rep.offices[0].postal}</Text>
+                  </>
+                )}
+              </div>
+            </div>
+          </Card>
+        </Col>
+      ))}
+    </Row>
+  )}
+</div>
+
+ +
{featuredCampaign && (
+  <Col span={24} key={featuredCampaign.id}>
+    <Card
+      hoverable
+      style={{
+        border: '2px solid #f39c12',
+        boxShadow: '0 4px 12px rgba(243, 156, 18, 0.3)',
+        position: 'relative'
+      }}
+      cover={
+        <div style={{ position: 'relative', height: 300, overflow: 'hidden' }}>
+          {featuredCampaign.coverPhoto ? (
+            <img
+              src={featuredCampaign.coverPhoto}
+              alt={featuredCampaign.title}
+              style={{
+                width: '100%',
+                height: '100%',
+                objectFit: 'cover'
+              }}
+            />
+          ) : (
+            <div style={{
+              width: '100%',
+              height: '100%',
+              background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
+            }} />
+          )}
+          <div style={{
+            position: 'absolute',
+            top: 16,
+            right: 16,
+            background: 'rgba(243, 156, 18, 0.9)',
+            color: 'white',
+            padding: '8px 16px',
+            borderRadius: 4,
+            display: 'flex',
+            alignItems: 'center',
+            gap: 8
+          }}>
+            <StarFilled />
+            <Text strong style={{ color: 'white' }}>
+              Featured Campaign
+            </Text>
+          </div>
+        </div>
+      }
+    >
+      <Title level={3} style={{ marginBottom: 12 }}>
+        {featuredCampaign.title}
+      </Title>
+
+      <Paragraph
+        ellipsis={{ rows: 2 }}
+        style={{ marginBottom: 16 }}
+      >
+        {featuredCampaign.description}
+      </Paragraph>
+
+      <div style={{ marginBottom: 16 }}>
+        {featuredCampaign.governmentLevel.map(level => (
+          <Tag key={level} color="blue">
+            {level.charAt(0).toUpperCase() + level.slice(1)}
+          </Tag>
+        ))}
+      </div>
+
+      <Row gutter={16} style={{ marginBottom: 16 }}>
+        <Col span={12}>
+          <div style={{ textAlign: 'center' }}>
+            <MailOutlined style={{ fontSize: 24, color: '#1890ff' }} />
+            <div>
+              <Text strong>{featuredCampaign.emailsSentCount}</Text>
+              <br />
+              <Text type="secondary">Emails Sent</Text>
+            </div>
+          </div>
+        </Col>
+        <Col span={12}>
+          <div style={{ textAlign: 'center' }}>
+            <CommentOutlined style={{ fontSize: 24, color: '#52c41a' }} />
+            <div>
+              <Text strong>{featuredCampaign.responsesCount}</Text>
+              <br />
+              <Text type="secondary">Responses</Text>
+            </div>
+          </div>
+        </Col>
+      </Row>
+
+      <Link to={`/campaigns/${featuredCampaign.id}`}>
+        <Button type="primary" block size="large">
+          View Campaign
+        </Button>
+      </Link>
+    </Card>
+  </Col>
+)}
+
+

Regular Campaign Cards

+
{regularCampaigns.map((campaign) => (
+  <Col xs={24} sm={12} lg={8} key={campaign.id}>
+    <Card
+      hoverable
+      cover={
+        <div style={{ height: 200, overflow: 'hidden' }}>
+          {campaign.coverPhoto ? (
+            <img
+              src={campaign.coverPhoto}
+              alt={campaign.title}
+              style={{
+                width: '100%',
+                height: '100%',
+                objectFit: 'cover'
+              }}
+            />
+          ) : (
+            <div style={{
+              width: '100%',
+              height: '100%',
+              background: 'linear-gradient(135deg, #3498db 0%, #8e44ad 100%)'
+            }} />
+          )}
+        </div>
+      }
+    >
+      <Title level={4} style={{ marginBottom: 8 }}>
+        {campaign.title}
+      </Title>
+
+      <Paragraph
+        ellipsis={{ rows: 2 }}
+        type="secondary"
+        style={{ marginBottom: 12, minHeight: 44 }}
+      >
+        {campaign.description || 'No description available'}
+      </Paragraph>
+
+      <div style={{ marginBottom: 12 }}>
+        {campaign.governmentLevel.map(level => (
+          <Tag key={level} color="purple">
+            {level.charAt(0).toUpperCase() + level.slice(1)}
+          </Tag>
+        ))}
+      </div>
+
+      <Row gutter={8} style={{ marginBottom: 12, fontSize: 12 }}>
+        <Col span={12}>
+          <MailOutlined /> {campaign.emailsSentCount} sent
+        </Col>
+        <Col span={12}>
+          <CommentOutlined /> {campaign.responsesCount} responses
+        </Col>
+      </Row>
+
+      <Link to={`/campaigns/${campaign.id}`}>
+        <Button type="link" block>
+          View Campaign 
+        </Button>
+      </Link>
+    </Card>
+  </Col>
+))}
+
+

Empty State

+
{!loading && campaigns.length === 0 && (
+  <div style={{
+    textAlign: 'center',
+    padding: 60,
+    background: theme.token.colorBgContainer,
+    borderRadius: 8
+  }}>
+    <InboxOutlined style={{ fontSize: 64, color: '#999', marginBottom: 16 }} />
+    <Title level={3} type="secondary">
+      No campaigns available
+    </Title>
+    <Paragraph type="secondary">
+      Check back soon for new advocacy opportunities!
+    </Paragraph>
+  </div>
+)}
+
+
+

Performance Considerations

+

1. Parallel Data Fetching

+

Campaigns and settings fetched simultaneously using Promise.all():

+
const [campaignsRes, settingsRes] = await Promise.all([
+  axios.get('/api/public/campaigns'),
+  axios.get('/api/settings')
+]);
+
+

Benefit: Reduces initial page load time by ~50% vs sequential requests.

+

2. Image Loading Optimization

+
    +
  • Object-fit: objectFit: 'cover' prevents layout shift
  • +
  • Fixed Heights: Cover photos have defined heights (300px featured, 200px regular)
  • +
  • Fallback Gradients: Instant render when no cover photo exists
  • +
  • Lazy Loading: Browser-native lazy loading for off-screen images (future enhancement)
  • +
+

3. Conditional Rendering

+

Representative lookup section only renders when results exist:

+
{representatives.length > 0 && (
+  <Row gutter={[16, 16]}>
+    {/* Rep cards */}
+  </Row>
+)}
+
+

Benefit: Avoids unnecessary DOM nodes and improves TTI (Time to Interactive).

+

4. Responsive Grid Optimization

+

Ant Design Grid uses CSS Grid under the hood:

+
<Row gutter={[24, 24]}>
+  <Col xs={24} sm={12} lg={8}>
+
+

Benefit: No JavaScript-based layout calculations, pure CSS performance.

+

5. Memoization Opportunities (Future Enhancement)

+

Featured/regular campaign split could use useMemo:

+
const { featuredCampaign, regularCampaigns } = useMemo(() => ({
+  featuredCampaign: campaigns.find(c => c.isFeatured),
+  regularCampaigns: campaigns.filter(c => !c.isFeatured)
+}), [campaigns]);
+
+
+

Responsive Design

+

Breakpoint Behavior

+
const screens = useBreakpoint();
+const isMobile = !screens.md; // md breakpoint = 768px
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BreakpointHero PaddingHero FontGrid ColumnsRep Cards
xs (0-575px)60px 20px24px11
sm (576-767px)60px 20px24px22
md (768-991px)80px 40px32px22
lg (992px+)80px 40px32px33
+

Mobile Adaptations

+

Hero Banner: +- Reduced padding (60px vs 80px vertical) +- Smaller title font (24px vs 32px) +- Maintained gradient for visual impact

+

Representative Cards: +- Stack to single column on mobile +- Maintain circular avatar size (150px) +- Full-width buttons for better touch targets

+

Campaign Cards: +- Single column layout on mobile +- Cover photo height remains 200px (cropped if needed) +- Action buttons become full-width

+

Find Your Representatives Input: +- Full-width on mobile (maxWidth: 500px on desktop) +- Larger touch target (size="large") +- Enter key triggers lookup for better mobile UX

+

Tablet Optimization

+

At sm breakpoint (576-767px): +- Campaign grid shows 2 columns +- Representative cards show 2 per row +- Hero banner uses mobile padding but desktop font size +- Maintains visual hierarchy without overwhelming narrow viewports

+
+

Accessibility

+

Keyboard Navigation

+

Interactive Elements: +- All buttons and links focusable via Tab key +- Postal code input supports Enter key submission +- Card hover states also apply on keyboard focus

+

Focus Management: +

<Input
+  onPressEnter={handlePostalCodeLookup}
+  // Focus indicator via Ant Design theme
+/>
+

+

ARIA Labels

+

Representative Photos: +

<img
+  src={rep.photo_url || '/default-avatar.png'}
+  alt={`Photo of ${rep.name}, ${rep.elected_office} for ${rep.district_name}`}
+  // Descriptive alt text for screen readers
+/>
+

+

Loading States: +

<Spin size="small" aria-label="Loading representatives" />
+

+

Icon Buttons: +

<Button
+  icon={<SearchOutlined />}
+  aria-label="Search for representatives"
+>
+  Find Representatives
+</Button>
+

+

Screen Reader Support

+

Structural Headings: +- Page uses semantic heading hierarchy (h1 → h2 → h3 → h4) +- Hero uses <Title level={1}> for main page title +- Sections use <Title level={2}> for logical grouping

+

Empty States: +- Informative messages for "No campaigns" and "No representatives found" +- Visual icons paired with text labels

+

Statistics: +

<Text strong>{campaign.emailsSentCount}</Text>
+<br />
+<Text type="secondary">Emails Sent</Text>
+// Screen reader announces: "1247 Emails Sent"
+

+

Color Contrast

+

Dark Theme Compliance: +- Background #0d1b2a with white text meets WCAG AA (7.8:1 ratio) +- Links use #1890ff with sufficient contrast (4.6:1 ratio) +- Tag colors (blue, purple, gold) all meet AA standards

+

Interactive States: +- Hover effects use opacity changes (accessible to screen readers) +- Focus states use browser default outline (visible on all elements)

+
+

Troubleshooting

+

Issue: Representatives Not Loading

+

Symptoms: +- Postal code input shows no results +- Console shows 404 or 500 error +- Loading spinner stuck

+

Causes: +1. Invalid postal code format (must be Canadian: A1A 1A1) +2. Represent API rate limiting (429 response) +3. Redis cache connection failure +4. Network timeout

+

Solutions:

+
// Add postal code validation
+const isValidPostalCode = (code: string) => {
+  const regex = /^[A-Z]\d[A-Z]\s?\d[A-Z]\d$/i;
+  return regex.test(code);
+};
+
+const handlePostalCodeLookup = async () => {
+  const cleanCode = postalCode.trim().toUpperCase();
+
+  if (!isValidPostalCode(cleanCode)) {
+    message.error('Please enter a valid Canadian postal code (e.g., K1A 0B1)');
+    return;
+  }
+
+  try {
+    setRepsLoading(true);
+    const response = await axios.get('/api/public/representatives/lookup', {
+      params: { postalCode: cleanCode },
+      timeout: 10000 // 10s timeout
+    });
+
+    setRepresentatives(response.data);
+  } catch (error: any) {
+    if (error.code === 'ECONNABORTED') {
+      message.error('Request timed out. Please try again.');
+    } else if (error.response?.status === 429) {
+      message.error('Too many requests. Please wait a moment and try again.');
+    } else {
+      message.error('Failed to find representatives. Please try again later.');
+    }
+    console.error('Lookup error:', error);
+  } finally {
+    setRepsLoading(false);
+  }
+};
+
+

Issue: Cover Photos Not Displaying

+

Symptoms: +- Campaign cards show gradient instead of uploaded photos +- Console shows CORS errors +- Broken image icons

+

Causes: +1. Invalid image URL in database +2. CORS policy blocking external images +3. Image file deleted from storage +4. Incorrect Nginx configuration

+

Solutions:

+
// Add image error handling
+const [imageErrors, setImageErrors] = useState<Set<string>>(new Set());
+
+const handleImageError = (campaignId: string) => {
+  setImageErrors(prev => new Set(prev).add(campaignId));
+};
+
+// In card cover render:
+cover={
+  <div style={{ height: 200, overflow: 'hidden' }}>
+    {campaign.coverPhoto && !imageErrors.has(campaign.id) ? (
+      <img
+        src={campaign.coverPhoto}
+        alt={campaign.title}
+        onError={() => handleImageError(campaign.id)}
+        style={{
+          width: '100%',
+          height: '100%',
+          objectFit: 'cover'
+        }}
+      />
+    ) : (
+      <div style={{
+        width: '100%',
+        height: '100%',
+        background: 'linear-gradient(135deg, #3498db 0%, #8e44ad 100%)'
+      }} />
+    )}
+  </div>
+}
+
+

Check Nginx configuration: +

# In nginx/conf.d/default.conf
+location /uploads/ {
+    add_header Access-Control-Allow-Origin *;
+    add_header Access-Control-Allow-Methods "GET, OPTIONS";
+}
+

+ +

Symptoms: +- Featured campaign appears in middle/end of grid +- Gold border not visible +- Star icon missing

+

Causes: +1. isFeatured flag not set in database +2. Multiple campaigns marked as featured +3. Grid rendering logic error

+

Solutions:

+
// Add debug logging
+useEffect(() => {
+  if (campaigns.length > 0) {
+    const featured = campaigns.filter(c => c.isFeatured);
+    console.log(`Found ${featured.length} featured campaigns:`, featured);
+
+    if (featured.length > 1) {
+      console.warn('Multiple campaigns marked as featured! Only first will display.');
+    }
+  }
+}, [campaigns]);
+
+// Ensure only one featured campaign
+const featuredCampaign = campaigns.find(c => c.isFeatured);
+const regularCampaigns = campaigns.filter(c => !c.isFeatured);
+
+// Render in correct order
+<Row gutter={[24, 24]}>
+  {featuredCampaign && (
+    <Col span={24} key={featuredCampaign.id}>
+      {/* Featured card */}
+    </Col>
+  )}
+
+  {regularCampaigns.map((campaign) => (
+    <Col xs={24} sm={12} lg={8} key={campaign.id}>
+      {/* Regular card */}
+    </Col>
+  ))}
+</Row>
+
+

Check database: +

-- Find all featured campaigns
+SELECT id, title, "isFeatured"
+FROM "Campaign"
+WHERE "isFeatured" = true
+AND "isActive" = true;
+
+-- Fix multiple featured campaigns (keep most recent)
+UPDATE "Campaign"
+SET "isFeatured" = false
+WHERE "isFeatured" = true
+AND id != (
+  SELECT id
+  FROM "Campaign"
+  WHERE "isFeatured" = true
+  ORDER BY "updatedAt" DESC
+  LIMIT 1
+);
+

+

Issue: ShareButtons Not Working

+

Symptoms: +- Clicking share icons does nothing +- "Copy Link" doesn't copy to clipboard +- No new windows opening

+

Causes: +1. Popup blockers preventing window.open() +2. Clipboard API not available (non-HTTPS) +3. ShareButtons component not imported +4. Missing event handlers

+

Solutions:

+
// Ensure HTTPS for clipboard API
+if (!navigator.clipboard) {
+  console.warn('Clipboard API requires HTTPS');
+  // Fallback to textarea copy method
+}
+
+// Add user interaction check for popups
+const handleShare = (platform: string) => {
+  // Must be triggered by user action (not async callback)
+  const url = encodeURIComponent(window.location.href);
+  const title = encodeURIComponent('Check out these advocacy campaigns!');
+
+  let shareUrl = '';
+  switch (platform) {
+    case 'twitter':
+      shareUrl = `https://twitter.com/intent/tweet?url=${url}&text=${title}`;
+      break;
+    case 'facebook':
+      shareUrl = `https://www.facebook.com/sharer/sharer.php?u=${url}`;
+      break;
+    // ... other platforms
+  }
+
+  const popup = window.open(shareUrl, '_blank', 'width=600,height=400');
+  if (!popup) {
+    message.warning('Please allow popups to share on social media');
+  }
+};
+
+

Issue: Page Loading Very Slowly

+

Symptoms: +- Spinner shows for 5+ seconds +- Network tab shows slow API responses +- Images take long to load

+

Causes: +1. Large campaign list (100+ campaigns) +2. High-resolution cover photos (5MB+ files) +3. No database indexes on isActive column +4. N+1 query problem (not in this case, single query)

+

Solutions:

+

Add pagination (API change required): +

const [page, setPage] = useState(1);
+const [total, setTotal] = useState(0);
+const pageSize = 12;
+
+useEffect(() => {
+  const fetchCampaigns = async () => {
+    try {
+      setLoading(true);
+      const response = await axios.get('/api/public/campaigns', {
+        params: { page, limit: pageSize }
+      });
+      setCampaigns(response.data.campaigns);
+      setTotal(response.data.total);
+    } catch (error) {
+      console.error('Failed to fetch campaigns:', error);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  fetchCampaigns();
+}, [page]);
+
+// Add Pagination component
+<Pagination
+  current={page}
+  total={total}
+  pageSize={pageSize}
+  onChange={setPage}
+  style={{ marginTop: 24, textAlign: 'center' }}
+/>
+

+

Optimize images server-side: +

# Add image resizing in upload pipeline
+# Max width: 1200px, quality: 80%
+convert input.jpg -resize 1200x -quality 80 output.jpg
+

+

Add database index: +

CREATE INDEX idx_campaign_active_featured
+ON "Campaign" ("isActive", "isFeatured", "updatedAt" DESC);
+

+
+ +

Public Pages

+ +

Admin Pages

+ +

Components

+ +

API Documentation

+ +

Architecture

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/public/index.html b/mkdocs/site/v2/frontend/pages/public/index.html new file mode 100644 index 00000000..8c1773fc --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/public/index.html @@ -0,0 +1,5592 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Public Pages - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Public Pages

+

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.

+

Route Context

+
    +
  • Prefix: Various (/campaigns, /map, /shifts, /p/:slug, /media)
  • +
  • Layout: PublicLayout (dark theme)
  • +
  • Auth Required: No
  • +
  • Theme: Dark blue/teal (#0d1b2a background)
  • +
+

Campaign Pages

+

Campaigns List Page

+

Route: /campaigns

+

Featured campaign listing:

+
    +
  • Hero section with call-to-action
  • +
  • Featured campaigns grid
  • +
  • Campaign cards with images
  • +
  • Search and filter (future)
  • +
  • Responsive grid layout
  • +
+

Features: +- Public campaign discovery +- Featured campaigns first +- Card-based design +- Mobile responsive

+

Campaign Page

+

Route: /campaigns/:id

+

Campaign detail and action page:

+
    +
  • Campaign description
  • +
  • Target representatives lookup
  • +
  • Email form with templates
  • +
  • Progress tracking
  • +
  • Social sharing
  • +
+

Features: +- Postal code → representative lookup +- Email to representatives +- Form validation +- Success confirmation +- Response submission link

+

Response Wall Page

+

Route: /responses/:campaignId

+

Public response submissions and viewing:

+
    +
  • Response submission form
  • +
  • Email verification flow
  • +
  • Response list (verified only)
  • +
  • Upvoting system
  • +
  • Sorting options
  • +
+

Features: +- Submit responses anonymously +- Email verification required +- Upvote responses +- Sort by newest/popular +- Responsive cards

+

Map & Location Pages

+

Map Page

+

Route: /map

+

Public interactive map:

+
    +
  • Leaflet map with locations
  • +
  • Color-coded markers by status
  • +
  • Geographic cuts overlay
  • +
  • Cut visibility controls
  • +
  • Geolocate button
  • +
  • Fullscreen mode
  • +
  • Map legend
  • +
+

Features: +- OpenStreetMap tiles +- Custom markers +- Polygon overlays +- Popup information +- Mobile responsive

+

Shifts Page

+

Route: /shifts

+

Public shift signup:

+
    +
  • Shift cards by date
  • +
  • Cut information
  • +
  • Signup modal
  • +
  • Temp user creation
  • +
  • Email confirmation
  • +
+

Features: +- Filter by date/cut +- Quick signup flow +- Anonymous signups (creates TEMP user) +- Email notifications +- Mobile responsive

+

Content Pages

+

Landing Page

+

Route: /p/:slug

+

Rendered landing pages:

+
    +
  • Custom HTML/CSS content
  • +
  • GrapesJS block rendering
  • +
  • Responsive design
  • +
  • SEO metadata
  • +
  • Custom scripts support
  • +
+

Features: +- Dynamic content from database +- Custom styling +- Block-based layout +- Published pages only

+

Media Pages

+ +

Route: /media

+

Public video gallery:

+
    +
  • Shared videos grid
  • +
  • Category filtering
  • +
  • Search functionality
  • +
  • Reaction system (6 emojis)
  • +
  • Video cards with thumbnails
  • +
+

Features: +- Public videos only (unlocked + shared) +- Responsive grid +- Click to view details +- Emoji reactions +- Mobile responsive

+

Media Viewer Page

+

Route: /media/:id

+

Video detail page:

+
    +
  • Video player
  • +
  • Title and description
  • +
  • Reaction buttons
  • +
  • Related videos
  • +
  • Share options
  • +
+

Features: +- HTML5 video player +- Reaction tracking +- Social sharing +- Mobile responsive

+

Public Page Count

+

Total: 8 public pages

+

Common Features

+

Public pages share:

+
    +
  • Dark Theme - Blue/teal color scheme (#0d1b2a)
  • +
  • No Authentication - Open access
  • +
  • Responsive Design - Mobile-first approach
  • +
  • Grid Breakpoints - Uses Grid.useBreakpoint()
  • +
  • Loading States - Spinners and skeletons
  • +
  • Error Handling - User-friendly messages
  • +
  • SEO Friendly - Meta tags, semantic HTML
  • +
+

Theme Colors

+
colorBgBase: '#0d1b2a'       // Dark navy background
+colorBgContainer: '#1b2838'  // Container background
+colorPrimary: '#3498db'      // Bright blue accent
+colorLink: '#3498db'         // Link color
+colorText: '#e0e0e0'         // Light text
+colorTextSecondary: '#a0a0a0' // Secondary text
+
+

Layout Structure

+

Public pages use PublicLayout which provides:

+
    +
  • Header
  • +
  • Logo/branding
  • +
  • Navigation links
  • +
  • +

    Login button (when not authenticated)

    +
  • +
  • +

    Content Area

    +
  • +
  • Full-width container
  • +
  • Responsive padding
  • +
  • +

    Dark theme styling

    +
  • +
  • +

    Footer

    +
  • +
  • Contact links
  • +
  • About information
  • +
  • Social media
  • +
  • Copyright
  • +
+

Mobile Responsiveness

+

Public pages are optimized for mobile:

+
    +
  • Touch-friendly controls
  • +
  • Responsive grids
  • +
  • Mobile navigation
  • +
  • Optimized forms
  • +
  • Fast loading
  • +
+

API Integration

+

Public pages use direct axios (no auth interceptor):

+
import axios from 'axios';
+
+const response = await axios.get(
+  `${import.meta.env.VITE_API_URL}/api/campaigns/public`
+);
+
+

Admin pages use authenticated api client from lib/api.ts.

+

Form Validation

+

Public forms use Zod validation:

+
const emailSchema = z.object({
+  email: z.string().email(),
+  message: z.string().min(10),
+});
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/public/landing-page/index.html b/mkdocs/site/v2/frontend/pages/public/landing-page/index.html new file mode 100644 index 00000000..1d93c64d --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/public/landing-page/index.html @@ -0,0 +1,5451 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Landing Page - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Landing Page (Public Page Renderer)

+

Overview

+

File Path: admin/src/pages/public/LandingPage.tsx (68 lines)

+

Route: /p/:slug

+

Role Requirements: Public access

+

Purpose: Simple renderer for admin-authored landing pages created with GrapesJS editor. Fetches page by slug and displays HTML/CSS content with SEO meta tags.

+

Key Features:

+
    +
  • Minimal wrapper around admin-authored HTML
  • +
  • SEO meta tags (title, description, og:image)
  • +
  • dangerouslySetInnerHTML for HTML + CSS rendering
  • +
  • Loading spinner during fetch
  • +
  • 404 page for invalid slugs
  • +
  • No layout wrapper (pages are self-contained)
  • +
+
+

Features

+

1. SEO Meta Tags

+
<Helmet>
+  <title>{page.title}</title>
+  <meta name="description" content={page.description || ''} />
+  <meta property="og:title" content={page.title} />
+  <meta property="og:description" content={page.description || ''} />
+  {page.coverImage && <meta property="og:image" content={page.coverImage} />}
+</Helmet>
+
+

2. HTML Rendering

+
<div dangerouslySetInnerHTML={{ __html: page.html }} />
+<style>{page.css}</style>
+
+

3. Loading State

+
{loading && (
+  <div style={{ textAlign: 'center', padding: 100 }}>
+    <Spin size="large" />
+  </div>
+)}
+
+

4. 404 Handling

+
{!loading && !page && (
+  <Result
+    status="404"
+    title="Page Not Found"
+    subTitle="The page you're looking for doesn't exist."
+    extra={<Link to="/"><Button type="primary">Go Home</Button></Link>}
+  />
+)}
+
+
+

API Integration

+
GET /api/public/pages/:slug
+
+

Response: +

{
+  "slug": "welcome",
+  "title": "Welcome to Our Campaign",
+  "description": "Join us in making a difference",
+  "html": "<div><h1>Welcome</h1>...</div>",
+  "css": "h1 { color: #1890ff; }",
+  "coverImage": "https://example.com/cover.jpg",
+  "isPublished": true
+}
+

+
+

Security Considerations

+

XSS Risk Accepted: +- Pages authored by trusted admins only +- dangerouslySetInnerHTML allows full HTML/JS +- No user-submitted content +- Alternative would break GrapesJS output

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/public/map-page/index.html b/mkdocs/site/v2/frontend/pages/public/map-page/index.html new file mode 100644 index 00000000..bc7d71b3 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/public/map-page/index.html @@ -0,0 +1,6489 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Map - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Public Map Page

+

Overview

+

File Path: admin/src/pages/public/MapPage.tsx (474 lines)

+

Route: /map

+

Role Requirements: Public access (no authentication required)

+

Purpose: Interactive public-facing map displaying campaign locations with color-coded support levels, cut polygons, and multi-unit building support. Provides geographic visualization of campaign activity and volunteer canvass coverage.

+

Key Features:

+
    +
  • Full-viewport Leaflet map with minimal header (48px)
  • +
  • OpenStreetMap tile layer
  • +
  • Color-coded circle markers by support level (Strong/Leaning/Undecided/Opposed/No Answer)
  • +
  • Multi-unit building popups with sorted unit lists
  • +
  • Cut polygon overlays with toggle controls
  • +
  • Geolocate button (find my location)
  • +
  • Fullscreen button
  • +
  • Viewport-based location loading with 800ms debounce
  • +
  • GPS position marker when geolocation active
  • +
  • Dark theme header consistent with public pages
  • +
+

Layout: Uses PublicLayout with custom header override (thin, 48px)

+
+

Features

+

1. Thin Header Design

+

Minimal header to maximize map space:

+
    +
  • Height: 48px (vs standard 64px)
  • +
  • Background: Dark blue (#0d1b2a)
  • +
  • Logo: Organization name with map icon
  • +
  • No Navigation Menu: Map is primary content
  • +
  • Mobile Responsive: Hamburger menu available
  • +
+

2. Color-Coded Location Markers

+

Visual support level indication:

+
    +
  • Strong Support: Green (#52c41a)
  • +
  • Leaning Support: Light green (#95de64)
  • +
  • Undecided: Yellow (#fadb14)
  • +
  • Leaning Opposed: Orange (#ff7a45)
  • +
  • Opposed: Red (#f5222d)
  • +
  • No Answer: Gray (#8c8c8c)
  • +
  • Not Home: Light gray (#d9d9d9)
  • +
+

Marker Styling: +- Circle radius: 8px +- Stroke: White 2px +- Fill opacity: 0.8 +- Hover: Increased opacity (1.0)

+

3. Multi-Unit Building Popups

+

Aggregated building display:

+

Popup Header: +- Purple background (#722ed1) +- Building address +- Total unit count badge

+

Unit List: +- Sorted by unit number (alphanumeric) +- Each row: Unit | Support Level | Notes +- Color-coded support badges +- Scrollable if >10 units +- Max height: 300px

+

Example: +

123 Main St [5 units]
+─────────────────────────
+Unit 101 | Strong Support | Yard sign
+Unit 102 | Undecided | -
+Unit 201 | No Answer | Left flyer
+

+

4. Cut Polygon Overlays

+

Geographic boundary visualization:

+

Polygon Rendering: +- GeoJSON format from database +- Blue stroke (#1890ff) +- Semi-transparent fill (opacity: 0.2) +- Label at centroid (cut name)

+

Toggle Controls: +- Floating panel (bottom-left, above zoom) +- Checkbox per cut +- Select All / Deselect All buttons +- Collapse/expand panel

+

Cut Label Styling: +- White text with black outline +- Always visible (not obscured by fill) +- Click cut to toggle visibility

+

5. Viewport-Based Loading

+

Performance optimization for large datasets:

+

Loading Strategy: +- Fetch only locations in current map bounds +- Trigger on moveend event (pan/zoom complete) +- Debounce 800ms to prevent excessive requests +- Loading spinner in top-right during fetch

+

Bounds Calculation: +

const bounds = map.getBounds();
+const params = {
+  minLat: bounds.getSouth(),
+  maxLat: bounds.getNorth(),
+  minLng: bounds.getWest(),
+  maxLng: bounds.getEast()
+};
+

+

6. Geolocation

+

User position tracking:

+

Features: +- Blue pulsing circle marker at user's position +- Accuracy circle (outer ring) +- Automatic pan to location on click +- "Locating..." loading state +- Error handling for denied permissions

+

Geolocate Button: +- Floating control (top-right) +- Compass icon +- Primary color when active +- Error message if unavailable

+

7. Fullscreen Mode

+

Immersive map experience:

+

Activation: +- Fullscreen button (top-right, below geolocate) +- Browser Fullscreen API +- Fallback for Safari (webkitRequestFullscreen)

+

Exit: +- ESC key +- Exit fullscreen button (shows when active) +- Browser native controls

+
+

User Workflow

+

Initial Map View

+
    +
  1. User navigates to /map
  2. +
  3. PublicLayout renders with thin header
  4. +
  5. Map initializes at default center/zoom (from settings)
  6. +
  7. Viewport bounds calculated
  8. +
  9. API fetches locations within bounds
  10. +
  11. Circle markers render for each location
  12. +
  13. Cuts fetched and rendered (all visible by default)
  14. +
+

Exploring Locations

+
    +
  1. User pans map to new area
  2. +
  3. moveend event triggers after 800ms debounce
  4. +
  5. New viewport bounds calculated
  6. +
  7. API fetches locations in new bounds
  8. +
  9. Existing markers cleared
  10. +
  11. New markers rendered
  12. +
  13. User clicks marker to view popup
  14. +
  15. Popup shows address, support level, notes, last visit date
  16. +
+

Viewing Multi-Unit Buildings

+
    +
  1. User clicks purple building marker
  2. +
  3. Popup opens with building header
  4. +
  5. Unit list displays sorted units
  6. +
  7. User scrolls list (if >10 units)
  8. +
  9. User sees color-coded support levels per unit
  10. +
  11. User closes popup by clicking outside or X button
  12. +
+

Using Geolocation

+
    +
  1. User clicks geolocate button
  2. +
  3. Browser prompts for location permission
  4. +
  5. User grants permission
  6. +
  7. Blue pulsing marker appears at user's position
  8. +
  9. Map pans to center on user
  10. +
  11. Accuracy circle shows GPS precision
  12. +
  13. User can pan away (marker remains visible)
  14. +
+

Toggling Cut Visibility

+
    +
  1. User clicks "Cut Controls" button (bottom-left)
  2. +
  3. Panel expands showing cut checkboxes
  4. +
  5. User unchecks "Cut A"
  6. +
  7. "Cut A" polygon disappears from map
  8. +
  9. User clicks "Deselect All"
  10. +
  11. All polygons hidden
  12. +
  13. User clicks "Select All"
  14. +
  15. All polygons re-appear
  16. +
+

Fullscreen Mode

+
    +
  1. User clicks fullscreen button
  2. +
  3. Map expands to fill entire screen
  4. +
  5. Header hidden
  6. +
  7. Controls remain visible
  8. +
  9. User explores map at full size
  10. +
  11. User presses ESC key
  12. +
  13. Map returns to normal layout
  14. +
+
+

Component Structure

+
import React, { useState, useEffect, useCallback } from 'react';
+import { MapContainer, TileLayer, CircleMarker, Popup, useMap, useMapEvents, Polygon } from 'react-leaflet';
+import { Button, Spin, Checkbox, Space, Typography, Badge } from 'antd';
+import {
+  AimOutlined,
+  FullscreenOutlined,
+  FullscreenExitOutlined,
+  EnvironmentOutlined
+} from '@ant-design/icons';
+import { debounce } from 'lodash';
+import PublicLayout from '../../components/PublicLayout';
+import axios from 'axios';
+import 'leaflet/dist/leaflet.css';
+
+const { Text } = Typography;
+
+interface Location {
+  id: string;
+  address: string;
+  latitude: number;
+  longitude: number;
+  supportLevel: string | null;
+  notes: string | null;
+  lastVisitDate: string | null;
+  isMultiUnit: boolean;
+  units?: Array<{
+    unitNumber: string;
+    supportLevel: string | null;
+    notes: string | null;
+  }>;
+}
+
+interface Cut {
+  id: string;
+  name: string;
+  color: string;
+  polygon: any; // GeoJSON
+}
+
+const MapPage: React.FC = () => {
+  const [locations, setLocations] = useState<Location[]>([]);
+  const [cuts, setCuts] = useState<Cut[]>([]);
+  const [visibleCuts, setVisibleCuts] = useState<Set<string>>(new Set());
+  const [loading, setLoading] = useState(false);
+  const [userPosition, setUserPosition] = useState<[number, number] | null>(null);
+  const [mapCenter, setMapCenter] = useState<[number, number]>([45.5017, -73.5673]);
+  const [mapZoom, setMapZoom] = useState(13);
+
+  // Component logic...
+
+  return (
+    <PublicLayout headerHeight={48}>
+      <MapContainer
+        center={mapCenter}
+        zoom={mapZoom}
+        style={{ height: 'calc(100vh - 48px)', width: '100%' }}
+        zoomControl={false}
+      >
+        <TileLayer
+          url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
+          attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
+        />
+
+        {/* Locations */}
+        {/* Cuts */}
+        {/* User Position */}
+        {/* Controls */}
+      </MapContainer>
+    </PublicLayout>
+  );
+};
+
+
+

State Management

+
// Location data
+const [locations, setLocations] = useState<Location[]>([]);
+const [cuts, setCuts] = useState<Cut[]>([]);
+const [visibleCuts, setVisibleCuts] = useState<Set<string>>(new Set());
+
+// Map state
+const [mapCenter, setMapCenter] = useState<[number, number]>([45.5017, -73.5673]);
+const [mapZoom, setMapZoom] = useState(13);
+
+// User interaction
+const [loading, setLoading] = useState(false);
+const [userPosition, setUserPosition] = useState<[number, number] | null>(null);
+const [fullscreen, setFullscreen] = useState(false);
+
+
+

API Integration

+

Endpoints

+

1. Get Locations by Bounds

+
GET /api/public/map/locations?minLat=45.4&maxLat=45.6&minLng=-73.7&maxLng=-73.4
+
+

Response: +

[
+  {
+    "id": "cm1abc123",
+    "address": "123 Main St",
+    "latitude": 45.5017,
+    "longitude": -73.5673,
+    "supportLevel": "strong_support",
+    "notes": "Yard sign requested",
+    "lastVisitDate": "2025-02-10T14:00:00.000Z",
+    "isMultiUnit": false
+  }
+]
+

+

2. Get Cuts

+
GET /api/public/map/cuts
+
+

Response: +

[
+  {
+    "id": "cm2def456",
+    "name": "Downtown District",
+    "color": "#1890ff",
+    "polygon": {
+      "type": "Polygon",
+      "coordinates": [[[-73.6, 45.5], [-73.5, 45.5], [-73.5, 45.6], [-73.6, 45.6], [-73.6, 45.5]]]
+    }
+  }
+]
+

+
+

Code Examples

+

Viewport-Based Loading with Debounce

+
const MapEventsHandler = () => {
+  const map = useMap();
+
+  const fetchLocationsInBounds = useCallback(async () => {
+    const bounds = map.getBounds();
+    setLoading(true);
+
+    try {
+      const response = await axios.get('/api/public/map/locations', {
+        params: {
+          minLat: bounds.getSouth(),
+          maxLat: bounds.getNorth(),
+          minLng: bounds.getWest(),
+          maxLng: bounds.getEast()
+        }
+      });
+      setLocations(response.data);
+    } catch (error) {
+      console.error('Failed to fetch locations:', error);
+    } finally {
+      setLoading(false);
+    }
+  }, [map]);
+
+  const debouncedFetch = useCallback(
+    debounce(fetchLocationsInBounds, 800),
+    [fetchLocationsInBounds]
+  );
+
+  useMapEvents({
+    moveend: debouncedFetch
+  });
+
+  return null;
+};
+
+

Color-Coded Location Markers

+
const getSupportLevelColor = (level: string | null): string => {
+  switch (level) {
+    case 'strong_support': return '#52c41a';
+    case 'leaning_support': return '#95de64';
+    case 'undecided': return '#fadb14';
+    case 'leaning_opposed': return '#ff7a45';
+    case 'opposed': return '#f5222d';
+    case 'no_answer': return '#8c8c8c';
+    case 'not_home': return '#d9d9d9';
+    default: return '#8c8c8c';
+  }
+};
+
+{locations.map(location => (
+  <CircleMarker
+    key={location.id}
+    center={[location.latitude, location.longitude]}
+    radius={8}
+    pathOptions={{
+      color: 'white',
+      weight: 2,
+      fillColor: getSupportLevelColor(location.supportLevel),
+      fillOpacity: 0.8
+    }}
+  >
+    <Popup>
+      <div style={{ minWidth: 200 }}>
+        <Text strong style={{ display: 'block', marginBottom: 8 }}>
+          {location.address}
+        </Text>
+        {location.supportLevel && (
+          <Text>Support: {location.supportLevel.replace('_', ' ')}</Text>
+        )}
+        {location.notes && (
+          <Text type="secondary" style={{ display: 'block', marginTop: 4, fontSize: 12 }}>
+            {location.notes}
+          </Text>
+        )}
+      </div>
+    </Popup>
+  </CircleMarker>
+))}
+
+

Multi-Unit Building Popup

+
{location.isMultiUnit && location.units && (
+  <Popup>
+    <div style={{ minWidth: 300, maxHeight: 400, overflow: 'auto' }}>
+      <div style={{
+        background: '#722ed1',
+        color: 'white',
+        padding: 12,
+        margin: -12,
+        marginBottom: 12
+      }}>
+        <Text strong style={{ color: 'white', fontSize: 16 }}>
+          {location.address}
+        </Text>
+        <Badge
+          count={location.units.length}
+          style={{ marginLeft: 8, background: 'white', color: '#722ed1' }}
+        />
+      </div>
+
+      <table style={{ width: '100%', fontSize: 12 }}>
+        <thead>
+          <tr style={{ borderBottom: '1px solid #f0f0f0' }}>
+            <th style={{ textAlign: 'left', padding: 4 }}>Unit</th>
+            <th style={{ textAlign: 'left', padding: 4 }}>Support</th>
+            <th style={{ textAlign: 'left', padding: 4 }}>Notes</th>
+          </tr>
+        </thead>
+        <tbody>
+          {location.units
+            .sort((a, b) => a.unitNumber.localeCompare(b.unitNumber, undefined, { numeric: true }))
+            .map((unit, idx) => (
+              <tr key={idx} style={{ borderBottom: '1px solid #f5f5f5' }}>
+                <td style={{ padding: 4 }}>{unit.unitNumber}</td>
+                <td style={{ padding: 4 }}>
+                  <span style={{
+                    background: getSupportLevelColor(unit.supportLevel),
+                    color: 'white',
+                    padding: '2px 6px',
+                    borderRadius: 3,
+                    fontSize: 11
+                  }}>
+                    {unit.supportLevel?.replace('_', ' ') || '-'}
+                  </span>
+                </td>
+                <td style={{ padding: 4, fontSize: 11, color: '#666' }}>
+                  {unit.notes || '-'}
+                </td>
+              </tr>
+            ))}
+        </tbody>
+      </table>
+    </div>
+  </Popup>
+)}
+
+
+

Performance Considerations

+
    +
  1. Debounced Loading: 800ms debounce prevents excessive API calls during panning
  2. +
  3. Viewport Filtering: Only loads visible locations (scalable to 10,000+ locations)
  4. +
  5. React-Leaflet Optimization: Uses key prop to prevent unnecessary re-renders
  6. +
  7. Lazy Popup Rendering: Popups created on-demand, not upfront
  8. +
+
+

Responsive Design

+
    +
  • Mobile: Full viewport height minus 48px header
  • +
  • Touch Gestures: Native Leaflet touch support (pinch zoom, swipe pan)
  • +
  • Fullscreen: Available on all devices via browser API
  • +
+
+

Accessibility

+
    +
  • Keyboard Navigation: Map focusable, arrow keys pan
  • +
  • Button Labels: All control buttons have aria-labels
  • +
  • Color Contrast: Marker strokes ensure visibility on all backgrounds
  • +
  • Screen Reader: Popup content readable, location count announced
  • +
+
+

Troubleshooting

+

Issue: Markers Not Appearing

+

Causes: +1. Locations outside viewport bounds +2. API returning empty array +3. Leaflet CSS not imported

+

Solutions: +

import 'leaflet/dist/leaflet.css'; // Must be imported
+
+// Add debug logging
+useEffect(() => {
+  console.log(`Loaded ${locations.length} locations`);
+}, [locations]);
+

+

Issue: Geolocation Not Working

+

Causes: +1. HTTPS required for geolocation API +2. User denied permission +3. Browser doesn't support geolocation

+

Solutions: +

const handleGeolocate = () => {
+  if (!navigator.geolocation) {
+    message.error('Geolocation not supported by your browser');
+    return;
+  }
+
+  navigator.geolocation.getCurrentPosition(
+    (position) => {
+      const pos: [number, number] = [
+        position.coords.latitude,
+        position.coords.longitude
+      ];
+      setUserPosition(pos);
+      map.flyTo(pos, 16);
+    },
+    (error) => {
+      if (error.code === error.PERMISSION_DENIED) {
+        message.error('Location permission denied');
+      } else {
+        message.error('Unable to get your location');
+      }
+    }
+  );
+};
+

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/public/media-gallery-page/index.html b/mkdocs/site/v2/frontend/pages/public/media-gallery-page/index.html new file mode 100644 index 00000000..22eb85f6 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/public/media-gallery-page/index.html @@ -0,0 +1,5434 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Media Gallery - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Media Gallery Page

+

Overview

+

File Path: admin/src/pages/public/MediaGalleryPage.tsx (195 lines)

+

Route: /media (with optional ?category=X query param)

+

Role Requirements: Public access

+

Purpose: Public-facing video gallery displaying shared media content with search, sort, category filtering, and pagination.

+

Key Features:

+
    +
  • Search input with 300ms debounce
  • +
  • Sort dropdown (Recent, Popular, Most Viewed)
  • +
  • Responsive grid (xs=1, sm=2, md=3, lg=4 columns)
  • +
  • PublicVideoCard component
  • +
  • Pagination (24 videos per page)
  • +
  • Category filter from URL params
  • +
  • Dark theme consistency
  • +
+

Layout: Uses MediaPublicLayout (specialized public layout for media)

+
+

Features

+ +
<Input.Search
+  placeholder="Search videos..."
+  size="large"
+  onChange={(e) => {
+    clearTimeout(searchDebounce);
+    searchDebounce = setTimeout(() => {
+      setSearchTerm(e.target.value);
+      setPage(1);
+    }, 300);
+  }}
+  style={{ maxWidth: 500 }}
+/>
+
+

2. Sort Dropdown

+

Options: +- Recent: createdAt DESC +- Popular: reactionCount DESC +- Most Viewed: viewCount DESC

+

3. Video Grid

+
<Row gutter={[16, 16]}>
+  {videos.map(video => (
+    <Col xs={24} sm={12} md={8} lg={6} key={video.id}>
+      <PublicVideoCard video={video} />
+    </Col>
+  ))}
+</Row>
+
+

4. Category Filter

+

URL-based filtering: +- /media - All categories +- /media?category=testimonials - Testimonials only +- /media?category=events - Events only

+
+

API Integration

+
GET /api/media/public?page=1&limit=24&search=climate&sort=recent&category=testimonials
+
+

Response: +

{
+  "videos": [
+    {
+      "id": "vid123",
+      "title": "Climate Rally Highlights",
+      "thumbnailUrl": "/media/thumbnails/vid123.jpg",
+      "duration": 245,
+      "viewCount": 1523,
+      "upvotes": 87,
+      "category": "events"
+    }
+  ],
+  "total": 156,
+  "page": 1,
+  "limit": 24
+}
+

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/public/media-viewer-page/index.html b/mkdocs/site/v2/frontend/pages/public/media-viewer-page/index.html new file mode 100644 index 00000000..61cf9dfd --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/public/media-viewer-page/index.html @@ -0,0 +1,5744 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Media Viewer - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Media Viewer Page

+

Overview

+

File Path: admin/src/pages/public/MediaViewerPage.tsx (306 lines)

+

Route: /media/:id

+

Role Requirements: Public access (locked videos require login)

+

Purpose: Individual video player page with metadata, reactions, comments, and related videos.

+

Key Features:

+
    +
  • Back button to gallery
  • +
  • VideoPlayer component with time tracking
  • +
  • Metadata display (views, upvotes, category, quality tags)
  • +
  • Upvote button (toggleable, session-based)
  • +
  • ReactionButtons component (6 emojis)
  • +
  • CommentSection component
  • +
  • Related videos grid (3 cards)
  • +
  • Locked video modal (redirect to login)
  • +
+
+

Features

+

1. Video Player

+
<VideoPlayer
+  videoUrl={video.videoUrl}
+  onTimeUpdate={(currentTime) => {
+    // Track view progress
+    if (currentTime > lastTrackedTime + 30) {
+      trackView(video.id, currentTime);
+      setLastTrackedTime(currentTime);
+    }
+  }}
+/>
+
+

2. Metadata Display

+
<Space size={16}>
+  <Text type="secondary">
+    <EyeOutlined /> {video.viewCount} views
+  </Text>
+  <Text type="secondary">
+    <LikeOutlined /> {video.upvotes} upvotes
+  </Text>
+  <Tag color="blue">{video.category}</Tag>
+  {video.quality && <Tag color="green">{video.quality}p</Tag>}
+</Space>
+
+

3. Upvote Button

+
<Button
+  type={hasUpvoted ? 'primary' : 'default'}
+  icon={hasUpvoted ? <LikeFilled /> : <LikeOutlined />}
+  onClick={handleUpvote}
+  size="large"
+>
+  Upvote ({video.upvotes})
+</Button>
+
+

4. Reaction Buttons

+

6 emoji reactions: +- 👍 Like +- ❤️ Love +- 😂 Haha +- 😮 Wow +- 😢 Sad +- 😡 Angry

+
<ReactionButtons
+  videoId={video.id}
+  reactions={video.reactions}
+  onReact={handleReact}
+/>
+
+

5. Comment Section

+
<CommentSection
+  videoId={video.id}
+  comments={comments}
+  onSubmit={handleCommentSubmit}
+/>
+
+ +
<Title level={4}>Related Videos</Title>
+<Row gutter={[16, 16]}>
+  {relatedVideos.slice(0, 3).map(video => (
+    <Col xs={24} sm={8} key={video.id}>
+      <PublicVideoCard video={video} />
+    </Col>
+  ))}
+</Row>
+
+

7. Locked Video Handling

+
{video.isLocked && !user && (
+  <Modal
+    title="Login Required"
+    open={true}
+    footer={
+      <Button type="primary" onClick={() => navigate('/login')}>
+        Go to Login
+      </Button>
+    }
+  >
+    This video requires login to view.
+  </Modal>
+)}
+
+
+

API Integration

+

Endpoints

+

1. Get Video

+
GET /api/media/public/:id
+
+

2. Track View

+
POST /api/media/public/:id/view
+Content-Type: application/json
+
+{
+  "currentTime": 67.5
+}
+
+

3. Toggle Upvote

+
POST /api/media/public/:id/upvote
+
+

4. Add Reaction

+
POST /api/media/public/:id/react
+Content-Type: application/json
+
+{
+  "reactionType": "love"
+}
+
+
+

Performance Considerations

+
    +
  1. View Tracking: Throttled to 30-second intervals
  2. +
  3. Related Videos: Limited to 3 (prevents over-fetching)
  4. +
  5. Lazy Comments: Loaded separately after video metadata
  6. +
  7. Video Preload: preload="metadata" for faster initial render
  8. +
+
+

Accessibility

+
    +
  • Keyboard Controls: Native video player controls
  • +
  • Captions: Support for WebVTT subtitle files
  • +
  • Screen Reader: All buttons have aria-labels
  • +
  • Focus Management: Reaction buttons keyboard navigable
  • +
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/public/response-wall-page/index.html b/mkdocs/site/v2/frontend/pages/public/response-wall-page/index.html new file mode 100644 index 00000000..e5b791e8 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/public/response-wall-page/index.html @@ -0,0 +1,7818 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Response Wall - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Response Wall Page

+

Overview

+

File Path: admin/src/pages/public/ResponseWallPage.tsx (492 lines)

+

Route: /responses/:campaignId

+

Role Requirements: Public access (no authentication required)

+

Purpose: Community-driven response wall displaying user-submitted campaign feedback, verification status, government official replies, and social engagement through upvoting. Serves as social proof and community building tool for advocacy campaigns.

+

Key Features:

+
    +
  • Campaign-specific response display with back navigation
  • +
  • Real-time statistics cards (Total Responses, Verified, Total Upvotes)
  • +
  • Multi-criteria sorting (Recent, Most Upvoted, Verified Only)
  • +
  • Government level filtering (Federal, Provincial, Municipal, All)
  • +
  • Response cards with upvote functionality
  • +
  • User comments and representative details
  • +
  • Verification badges for confirmed responses
  • +
  • Response submission modal with long-form input
  • +
  • Pagination for large response sets
  • +
  • Dark blue/teal theme consistency
  • +
  • Mobile-responsive grid layout
  • +
+

Layout: Uses PublicLayout component with dark theme

+
+

Features

+

1. Campaign Context Header

+

Navigation and campaign identification:

+
    +
  • Back Link: Returns to campaign detail page (/campaigns/:campaignId)
  • +
  • Campaign Title: Displays as page heading
  • +
  • Breadcrumb: "Response Wall" subtitle
  • +
  • Icon: Comment icon for visual context
  • +
+

2. Statistics Dashboard

+

Three key metrics displayed as cards:

+
    +
  • Total Responses: Count of all submissions (verified + unverified)
  • +
  • Verified Responses: Count of email-verified submissions
  • +
  • Total Upvotes: Aggregate upvote count across all responses
  • +
+

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

+

3. Filtering and Sorting Controls

+

User controls for response discovery:

+

Sort Dropdown: +- Recent: Newest first (default, createdAt DESC) +- Most Upvoted: Highest upvote count first (upvoteCount DESC) +- Verified Only: Only email-verified responses

+

Government Level Filter: +- All Levels: No filtering (default) +- Federal: Federal government responses only +- Provincial: Provincial/territorial responses only +- Municipal: Municipal/local responses only

+

Layout: +- Row with two columns +- Sort on left, filter on right +- Full-width selects on mobile +- Margin below for spacing

+

4. Response Cards

+

Individual response display with rich metadata:

+

Card Header: +- User name (bold, 16px) +- Timestamp (relative: "2 hours ago") +- Verification badge (if isVerified=true)

+

Card Content: +- User comment (full text, auto-wrapping) +- Quoted text if available (italicized, gray background) +- Representative details: + - Name (bold) + - District/riding + - Government level tag (colored by level)

+

Card Footer: +- Upvote button with count +- Heart icon (filled if user upvoted) +- Click toggles upvote status +- Optimistic UI update

+

Styling: +- Dark background (colorBgContainer) +- Rounded corners (8px) +- Hover elevation shadow +- Dividers between sections

+

5. Submit Response Modal

+

Long-form response submission interface:

+

Form Fields: +- Your Name (required, text input) +- Your Email (required, email validation) +- Your Postal Code (optional, for rep lookup context) +- Representative (read-only, from parent campaign context) +- Your Comment (required, TextArea, 5 rows min) +- Email Me a Copy (checkbox, default checked)

+

Validation: +- Required field indicators +- Email format validation +- Min/max length checks (comment: 10-5000 chars) +- Disabled submit until valid

+

Submission Flow: +1. User clicks "Submit Your Response" button +2. Modal opens with empty form +3. User fills fields +4. Clicks "Submit Response" button +5. API creates response (status: unverified) +6. Verification email sent if checkbox checked +7. Success modal displays +8. Form resets +9. Responses list refreshes

+

6. Pagination

+

Ant Design Pagination component:

+
    +
  • Page Size: 20 responses per page
  • +
  • Total Count: Fetched from API
  • +
  • Page Change: Triggers new API request
  • +
  • Positioning: Centered below response grid
  • +
  • Styling: Inherits dark theme from PublicLayout
  • +
+
+

User Workflow

+

Browsing Responses

+
    +
  1. User arrives from campaign page via "View Response Wall" link
  2. +
  3. Page loads responses (default: recent, all levels)
  4. +
  5. User views statistics cards showing community engagement
  6. +
  7. User scrolls through response cards
  8. +
  9. User reads comments and representative details
  10. +
  11. User upvotes responses they agree with
  12. +
  13. User clicks pagination to view more responses
  14. +
+

Filtering and Sorting

+
    +
  1. User selects "Most Upvoted" from sort dropdown
  2. +
  3. API re-fetches responses with new sort order
  4. +
  5. Grid updates with reordered responses
  6. +
  7. User selects "Federal" from government level filter
  8. +
  9. API re-fetches with government level filter
  10. +
  11. Grid shows only federal responses
  12. +
  13. User resets filters to "All Levels" to see everything
  14. +
+

Submitting a Response

+
    +
  1. User clicks "Submit Your Response" button
  2. +
  3. Modal opens with blank form
  4. +
  5. User enters name: "Jane Doe"
  6. +
  7. User enters email: "jane@example.com"
  8. +
  9. User enters postal code: "K1A 0B1" (optional)
  10. +
  11. User writes comment: "I strongly support this bill because..."
  12. +
  13. User checks "Email me a copy" checkbox
  14. +
  15. User clicks "Submit Response"
  16. +
  17. API creates response with isVerified=false
  18. +
  19. Backend sends verification email to jane@example.com
  20. +
  21. Success modal displays: "Response submitted! Check your email to verify."
  22. +
  23. User clicks "OK"
  24. +
  25. Modal closes
  26. +
  27. Responses grid refreshes (may not show new response if "Verified Only" filter active)
  28. +
+

Upvoting

+
    +
  1. User sees response they agree with
  2. +
  3. User clicks heart icon button
  4. +
  5. Optimistic update: upvote count increments, heart fills with color
  6. +
  7. API request to /api/public/responses/:id/upvote
  8. +
  9. If API succeeds: update persists
  10. +
  11. If API fails: revert to previous state, show error message
  12. +
  13. User can click again to remove upvote (toggle behavior)
  14. +
+
+

Component Structure

+
import React, { useState, useEffect } from 'react';
+import { useParams, Link } from 'react-router-dom';
+import {
+  Card,
+  Row,
+  Col,
+  Typography,
+  Button,
+  Select,
+  Statistic,
+  Modal,
+  Form,
+  Input,
+  Checkbox,
+  Pagination,
+  Tag,
+  Space,
+  message,
+  Grid
+} from 'antd';
+import {
+  ArrowLeftOutlined,
+  CommentOutlined,
+  HeartOutlined,
+  HeartFilled,
+  CheckCircleOutlined,
+  TrophyOutlined,
+  FireOutlined
+} from '@ant-design/icons';
+import dayjs from 'dayjs';
+import relativeTime from 'dayjs/plugin/relativeTime';
+import PublicLayout from '../../components/PublicLayout';
+import axios from 'axios';
+
+dayjs.extend(relativeTime);
+
+const { Title, Paragraph, Text } = Typography;
+const { TextArea } = Input;
+const { Option } = Select;
+const { useBreakpoint } = Grid;
+
+interface Response {
+  id: string;
+  userName: string;
+  userEmail: string;
+  postalCode: string | null;
+  comment: string;
+  quotedText: string | null;
+  isVerified: boolean;
+  upvoteCount: number;
+  representativeName: string;
+  representativeDistrict: string;
+  governmentLevel: string;
+  createdAt: string;
+  hasUpvoted?: boolean; // Client-side tracking
+}
+
+interface Campaign {
+  id: string;
+  title: string;
+}
+
+interface Stats {
+  totalResponses: number;
+  verifiedResponses: number;
+  totalUpvotes: number;
+}
+
+const ResponseWallPage: React.FC = () => {
+  const { campaignId } = useParams<{ campaignId: string }>();
+  const [responses, setResponses] = useState<Response[]>([]);
+  const [campaign, setCampaign] = useState<Campaign | null>(null);
+  const [stats, setStats] = useState<Stats>({ totalResponses: 0, verifiedResponses: 0, totalUpvotes: 0 });
+  const [loading, setLoading] = useState(true);
+  const [sortBy, setSortBy] = useState<string>('recent');
+  const [governmentLevel, setGovernmentLevel] = useState<string>('all');
+  const [page, setPage] = useState(1);
+  const [total, setTotal] = useState(0);
+  const [submitModalVisible, setSubmitModalVisible] = useState(false);
+  const [form] = Form.useForm();
+  const screens = useBreakpoint();
+  const isMobile = !screens.md;
+
+  const pageSize = 20;
+
+  // Data fetching, handlers, etc.
+
+  return (
+    <PublicLayout>
+      {/* Back link and title */}
+      {/* Statistics cards */}
+      {/* Sort and filter controls */}
+      {/* Response cards grid */}
+      {/* Pagination */}
+      {/* Submit modal */}
+    </PublicLayout>
+  );
+};
+
+export default ResponseWallPage;
+
+
+

State Management

+

Component State

+
// Response data
+const [responses, setResponses] = useState<Response[]>([]);
+const [campaign, setCampaign] = useState<Campaign | null>(null);
+const [stats, setStats] = useState<Stats>({
+  totalResponses: 0,
+  verifiedResponses: 0,
+  totalUpvotes: 0
+});
+const [loading, setLoading] = useState(true);
+
+// Filtering and sorting
+const [sortBy, setSortBy] = useState<string>('recent'); // 'recent' | 'upvotes' | 'verified'
+const [governmentLevel, setGovernmentLevel] = useState<string>('all'); // 'all' | 'federal' | 'provincial' | 'municipal'
+
+// Pagination
+const [page, setPage] = useState(1);
+const [total, setTotal] = useState(0);
+const pageSize = 20;
+
+// Modal state
+const [submitModalVisible, setSubmitModalVisible] = useState(false);
+const [form] = Form.useForm();
+
+// Responsive
+const screens = useBreakpoint();
+const isMobile = !screens.md;
+
+

Derived State

+
// No complex derived state - filtering happens server-side
+// All data transformations done by API
+
+

State Flow

+
    +
  1. Initial Load: loading=true, fetch campaign + responses + stats
  2. +
  3. Data Received: setCampaign(), setResponses(), setStats(), setTotal(), loading=false
  4. +
  5. Sort Changed: setSortBy(), setPage(1), refetch responses
  6. +
  7. Filter Changed: setGovernmentLevel(), setPage(1), refetch responses
  8. +
  9. Page Changed: setPage(), refetch responses (keep sort/filter)
  10. +
  11. Upvote Clicked: Optimistic update to responses array, API call
  12. +
  13. Submit Clicked: setSubmitModalVisible(true), open form
  14. +
  15. Response Submitted: API call, setSubmitModalVisible(false), refetch responses
  16. +
+
+

API Integration

+

Endpoints Used

+

1. Get Campaign (Basic Info)

+
GET /api/public/campaigns/:campaignId
+
+

Response: +

{
+  "id": "cm1abc123",
+  "title": "Support Climate Action Bill"
+}
+

+

2. Get Response Statistics

+
GET /api/public/responses/campaigns/:campaignId/stats
+
+

Response: +

{
+  "totalResponses": 342,
+  "verifiedResponses": 287,
+  "totalUpvotes": 1829
+}
+

+

3. List Responses

+
GET /api/public/responses/campaigns/:campaignId?page=1&limit=20&sortBy=recent&governmentLevel=all
+
+

Query Parameters: +- page: Page number (1-indexed) +- limit: Items per page (default 20, max 100) +- sortBy: recent | upvotes | verified +- governmentLevel: all | federal | provincial | municipal

+

Response: +

{
+  "responses": [
+    {
+      "id": "cm2abc123",
+      "userName": "Jane Doe",
+      "userEmail": "jane@example.com",
+      "postalCode": "K1A 0B1",
+      "comment": "I strongly support this bill because it addresses critical climate issues...",
+      "quotedText": null,
+      "isVerified": true,
+      "upvoteCount": 47,
+      "representativeName": "John Smith",
+      "representativeDistrict": "Ottawa Centre",
+      "governmentLevel": "federal",
+      "createdAt": "2025-02-10T14:30:00.000Z"
+    }
+  ],
+  "total": 342,
+  "page": 1,
+  "limit": 20
+}
+

+

4. Submit Response

+
POST /api/public/responses
+Content-Type: application/json
+
+{
+  "campaignId": "cm1abc123",
+  "userName": "Jane Doe",
+  "userEmail": "jane@example.com",
+  "postalCode": "K1A 0B1",
+  "comment": "I strongly support this bill...",
+  "representativeName": "John Smith",
+  "representativeDistrict": "Ottawa Centre",
+  "governmentLevel": "federal",
+  "sendCopy": true
+}
+
+

Response: +

{
+  "success": true,
+  "responseId": "cm2def456",
+  "message": "Response submitted successfully. Please check your email to verify."
+}
+

+

5. Upvote Response

+
POST /api/public/responses/:id/upvote
+
+

Response: +

{
+  "success": true,
+  "upvoteCount": 48,
+  "action": "added"
+}
+

+

Note: Second request to same endpoint toggles (removes upvote), returns "action": "removed".

+

Request Examples

+

Fetch Responses

+
useEffect(() => {
+  const fetchData = async () => {
+    if (!campaignId) return;
+
+    try {
+      setLoading(true);
+
+      const [campaignRes, statsRes, responsesRes] = await Promise.all([
+        axios.get(`/api/public/campaigns/${campaignId}`),
+        axios.get(`/api/public/responses/campaigns/${campaignId}/stats`),
+        axios.get(`/api/public/responses/campaigns/${campaignId}`, {
+          params: {
+            page,
+            limit: pageSize,
+            sortBy,
+            governmentLevel: governmentLevel === 'all' ? undefined : governmentLevel
+          }
+        })
+      ]);
+
+      setCampaign(campaignRes.data);
+      setStats(statsRes.data);
+      setResponses(responsesRes.data.responses);
+      setTotal(responsesRes.data.total);
+
+    } catch (error) {
+      console.error('Failed to fetch data:', error);
+      message.error('Failed to load responses');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  fetchData();
+}, [campaignId, page, sortBy, governmentLevel]);
+
+

Submit Response

+
const handleSubmit = async (values: any) => {
+  try {
+    await axios.post('/api/public/responses', {
+      campaignId,
+      userName: values.userName,
+      userEmail: values.userEmail,
+      postalCode: values.postalCode || null,
+      comment: values.comment,
+      representativeName: values.representativeName,
+      representativeDistrict: values.representativeDistrict || '',
+      governmentLevel: values.governmentLevel || 'federal',
+      sendCopy: values.sendCopy
+    });
+
+    Modal.success({
+      title: 'Response Submitted!',
+      content: 'Please check your email to verify your response.',
+    });
+
+    setSubmitModalVisible(false);
+    form.resetFields();
+
+    // Refresh responses list
+    setPage(1);
+    // Triggers useEffect refetch
+
+  } catch (error: any) {
+    console.error('Submit failed:', error);
+    message.error(error.response?.data?.message || 'Failed to submit response');
+  }
+};
+
+

Upvote Response

+
const handleUpvote = async (responseId: string) => {
+  // Optimistic update
+  setResponses(prev => prev.map(r => {
+    if (r.id === responseId) {
+      const hasUpvoted = !r.hasUpvoted;
+      return {
+        ...r,
+        hasUpvoted,
+        upvoteCount: r.upvoteCount + (hasUpvoted ? 1 : -1)
+      };
+    }
+    return r;
+  }));
+
+  try {
+    const response = await axios.post(`/api/public/responses/${responseId}/upvote`);
+
+    // Update with server count (in case of race condition)
+    setResponses(prev => prev.map(r =>
+      r.id === responseId
+        ? { ...r, upvoteCount: response.data.upvoteCount }
+        : r
+    ));
+
+  } catch (error) {
+    console.error('Upvote failed:', error);
+
+    // Revert on error
+    setResponses(prev => prev.map(r => {
+      if (r.id === responseId) {
+        return {
+          ...r,
+          hasUpvoted: !r.hasUpvoted,
+          upvoteCount: r.upvoteCount + (r.hasUpvoted ? -1 : 1)
+        };
+      }
+      return r;
+    }));
+
+    message.error('Failed to upvote. Please try again.');
+  }
+};
+
+
+

Code Examples

+

Statistics Cards

+
<Row gutter={[16, 16]} style={{ marginBottom: 32 }}>
+  <Col xs={24} sm={8}>
+    <Card>
+      <Statistic
+        title="Total Responses"
+        value={stats.totalResponses}
+        prefix={<CommentOutlined style={{ color: '#1890ff' }} />}
+        valueStyle={{ color: '#1890ff', fontSize: 32 }}
+      />
+    </Card>
+  </Col>
+
+  <Col xs={24} sm={8}>
+    <Card>
+      <Statistic
+        title="Verified Responses"
+        value={stats.verifiedResponses}
+        prefix={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
+        valueStyle={{ color: '#52c41a', fontSize: 32 }}
+      />
+    </Card>
+  </Col>
+
+  <Col xs={24} sm={8}>
+    <Card>
+      <Statistic
+        title="Total Upvotes"
+        value={stats.totalUpvotes}
+        prefix={<HeartFilled style={{ color: '#eb2f96' }} />}
+        valueStyle={{ color: '#eb2f96', fontSize: 32 }}
+      />
+    </Card>
+  </Col>
+</Row>
+
+

Sort and Filter Controls

+
<Row gutter={16} style={{ marginBottom: 24 }}>
+  <Col xs={24} sm={12}>
+    <Space direction="vertical" style={{ width: '100%' }} size={4}>
+      <Text type="secondary">Sort by:</Text>
+      <Select
+        value={sortBy}
+        onChange={(value) => {
+          setSortBy(value);
+          setPage(1); // Reset to page 1 when sorting changes
+        }}
+        style={{ width: '100%' }}
+        size="large"
+      >
+        <Option value="recent">
+          <FireOutlined /> Recent
+        </Option>
+        <Option value="upvotes">
+          <TrophyOutlined /> Most Upvoted
+        </Option>
+        <Option value="verified">
+          <CheckCircleOutlined /> Verified Only
+        </Option>
+      </Select>
+    </Space>
+  </Col>
+
+  <Col xs={24} sm={12}>
+    <Space direction="vertical" style={{ width: '100%' }} size={4}>
+      <Text type="secondary">Government Level:</Text>
+      <Select
+        value={governmentLevel}
+        onChange={(value) => {
+          setGovernmentLevel(value);
+          setPage(1); // Reset to page 1 when filter changes
+        }}
+        style={{ width: '100%' }}
+        size="large"
+      >
+        <Option value="all">All Levels</Option>
+        <Option value="federal">Federal</Option>
+        <Option value="provincial">Provincial</Option>
+        <Option value="municipal">Municipal</Option>
+      </Select>
+    </Space>
+  </Col>
+</Row>
+
+

Response Cards

+
<Row gutter={[16, 16]}>
+  {responses.map((response) => (
+    <Col xs={24} key={response.id}>
+      <Card hoverable>
+        {/* Header */}
+        <div style={{
+          display: 'flex',
+          justifyContent: 'space-between',
+          alignItems: 'center',
+          marginBottom: 16
+        }}>
+          <Space>
+            <Text strong style={{ fontSize: 16 }}>
+              {response.userName}
+            </Text>
+            {response.isVerified && (
+              <Tag color="green" icon={<CheckCircleOutlined />}>
+                Verified
+              </Tag>
+            )}
+          </Space>
+          <Text type="secondary" style={{ fontSize: 12 }}>
+            {dayjs(response.createdAt).fromNow()}
+          </Text>
+        </div>
+
+        {/* Comment */}
+        <Paragraph style={{ marginBottom: 16, fontSize: 14 }}>
+          {response.comment}
+        </Paragraph>
+
+        {/* Quoted Text (if any) */}
+        {response.quotedText && (
+          <div style={{
+            padding: 12,
+            background: 'rgba(255,255,255,0.05)',
+            borderLeft: '3px solid #1890ff',
+            marginBottom: 16,
+            fontStyle: 'italic'
+          }}>
+            <Text type="secondary" style={{ fontSize: 13 }}>
+              "{response.quotedText}"
+            </Text>
+          </div>
+        )}
+
+        {/* Representative Info */}
+        <div style={{
+          paddingTop: 16,
+          borderTop: '1px solid rgba(255,255,255,0.1)',
+          marginBottom: 12
+        }}>
+          <Text type="secondary" style={{ fontSize: 12 }}>
+            Sent to:{' '}
+          </Text>
+          <Text strong style={{ fontSize: 13 }}>
+            {response.representativeName}
+          </Text>
+          {response.representativeDistrict && (
+            <Text type="secondary" style={{ fontSize: 12 }}>
+              {' '} {response.representativeDistrict}
+            </Text>
+          )}
+          <div style={{ marginTop: 4 }}>
+            <Tag color={
+              response.governmentLevel === 'federal' ? 'blue' :
+              response.governmentLevel === 'provincial' ? 'purple' :
+              'green'
+            }>
+              {response.governmentLevel.charAt(0).toUpperCase() + response.governmentLevel.slice(1)}
+            </Tag>
+          </div>
+        </div>
+
+        {/* Upvote Button */}
+        <Button
+          type={response.hasUpvoted ? 'primary' : 'default'}
+          icon={response.hasUpvoted ? <HeartFilled /> : <HeartOutlined />}
+          onClick={() => handleUpvote(response.id)}
+          style={{
+            borderColor: '#eb2f96',
+            color: response.hasUpvoted ? 'white' : '#eb2f96'
+          }}
+        >
+          {response.upvoteCount} {response.upvoteCount === 1 ? 'Upvote' : 'Upvotes'}
+        </Button>
+      </Card>
+    </Col>
+  ))}
+</Row>
+
+

Submit Response Modal

+
<Modal
+  title="Submit Your Response"
+  open={submitModalVisible}
+  onCancel={() => {
+    setSubmitModalVisible(false);
+    form.resetFields();
+  }}
+  footer={null}
+  width={600}
+>
+  <Form
+    form={form}
+    layout="vertical"
+    onFinish={handleSubmit}
+  >
+    <Form.Item
+      name="userName"
+      label="Your Name"
+      rules={[
+        { required: true, message: 'Please enter your name' },
+        { min: 2, message: 'Name must be at least 2 characters' }
+      ]}
+    >
+      <Input size="large" placeholder="Jane Doe" />
+    </Form.Item>
+
+    <Form.Item
+      name="userEmail"
+      label="Your Email"
+      rules={[
+        { required: true, message: 'Please enter your email' },
+        { type: 'email', message: 'Please enter a valid email' }
+      ]}
+    >
+      <Input size="large" type="email" placeholder="jane@example.com" />
+    </Form.Item>
+
+    <Form.Item
+      name="postalCode"
+      label="Your Postal Code (Optional)"
+    >
+      <Input
+        size="large"
+        placeholder="K1A 0B1"
+        maxLength={7}
+        style={{ textTransform: 'uppercase' }}
+      />
+    </Form.Item>
+
+    <Form.Item
+      name="representativeName"
+      label="Representative You Contacted"
+      rules={[{ required: true, message: 'Please enter representative name' }]}
+    >
+      <Input size="large" placeholder="e.g., John Smith" />
+    </Form.Item>
+
+    <Form.Item
+      name="representativeDistrict"
+      label="District/Riding (Optional)"
+    >
+      <Input size="large" placeholder="e.g., Ottawa Centre" />
+    </Form.Item>
+
+    <Form.Item
+      name="governmentLevel"
+      label="Government Level"
+      rules={[{ required: true, message: 'Please select government level' }]}
+      initialValue="federal"
+    >
+      <Select size="large">
+        <Option value="federal">Federal</Option>
+        <Option value="provincial">Provincial/Territorial</Option>
+        <Option value="municipal">Municipal</Option>
+      </Select>
+    </Form.Item>
+
+    <Form.Item
+      name="comment"
+      label="Your Comment"
+      rules={[
+        { required: true, message: 'Please enter your comment' },
+        { min: 10, message: 'Comment must be at least 10 characters' },
+        { max: 5000, message: 'Comment must be less than 5000 characters' }
+      ]}
+    >
+      <TextArea
+        rows={5}
+        placeholder="Share your thoughts, the response you received, or why this issue matters to you..."
+        showCount
+        maxLength={5000}
+      />
+    </Form.Item>
+
+    <Form.Item
+      name="sendCopy"
+      valuePropName="checked"
+      initialValue={true}
+    >
+      <Checkbox>
+        Email me a copy and verification link
+      </Checkbox>
+    </Form.Item>
+
+    <Form.Item>
+      <Space style={{ width: '100%', justifyContent: 'flex-end' }}>
+        <Button onClick={() => {
+          setSubmitModalVisible(false);
+          form.resetFields();
+        }}>
+          Cancel
+        </Button>
+        <Button type="primary" htmlType="submit" size="large">
+          Submit Response
+        </Button>
+      </Space>
+    </Form.Item>
+  </Form>
+</Modal>
+
+

Pagination

+
{total > pageSize && (
+  <div style={{ textAlign: 'center', marginTop: 32 }}>
+    <Pagination
+      current={page}
+      total={total}
+      pageSize={pageSize}
+      onChange={(newPage) => {
+        setPage(newPage);
+        window.scrollTo({ top: 0, behavior: 'smooth' });
+      }}
+      showSizeChanger={false}
+      showTotal={(total, range) => `${range[0]}-${range[1]} of ${total} responses`}
+    />
+  </div>
+)}
+
+
+

Performance Considerations

+

1. Parallel Data Fetching

+

Campaign, stats, and responses fetched simultaneously:

+
const [campaignRes, statsRes, responsesRes] = await Promise.all([
+  axios.get(`/api/public/campaigns/${campaignId}`),
+  axios.get(`/api/public/responses/campaigns/${campaignId}/stats`),
+  axios.get(`/api/public/responses/campaigns/${campaignId}`, { params })
+]);
+
+

Benefit: Reduces initial load time by ~60% vs sequential requests.

+

2. Optimistic Upvote Updates

+

UI updates immediately before API confirmation:

+
// Update UI first
+setResponses(prev => prev.map(r => {
+  if (r.id === responseId) {
+    return { ...r, hasUpvoted: !r.hasUpvoted, upvoteCount: r.upvoteCount + 1 };
+  }
+  return r;
+}));
+
+// Then API call
+await axios.post(`/api/public/responses/${responseId}/upvote`);
+
+

Benefit: Perceived performance improvement, instant feedback.

+

3. Server-Side Filtering

+

All filtering/sorting done via API query params (not client-side):

+
params: {
+  sortBy,
+  governmentLevel: governmentLevel === 'all' ? undefined : governmentLevel
+}
+
+

Benefit: Scalable to thousands of responses, no client memory issues.

+

4. Pagination

+

Limited to 20 responses per page:

+
const pageSize = 20;
+
+

Benefit: Reduces DOM nodes, faster render, better mobile performance.

+

5. Scroll to Top on Page Change

+

Smooth scroll when pagination changes:

+
onChange={(newPage) => {
+  setPage(newPage);
+  window.scrollTo({ top: 0, behavior: 'smooth' });
+}}
+
+

Benefit: Better UX, user doesn't miss new content.

+
+

Responsive Design

+

Breakpoint Behavior

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BreakpointStats ColumnsResponse CardsFilter LayoutModal Width
xs (0-575px)1 column1 columnStacked90% viewport
sm (576-767px)3 columns1 columnStacked90% viewport
md (768-991px)3 columns1 columnSide-by-side600px
lg (992px+)3 columns1 columnSide-by-side600px
+

Mobile Adaptations

+

Statistics Cards: +- Stack vertically on xs (easier to scan) +- Show 3 columns on sm+ (compact display) +- Font size remains large (32px) for impact

+

Response Cards: +- Always full-width (xs=24) +- Better readability on narrow screens +- Upvote button full-width on mobile (future enhancement)

+

Sort/Filter Controls: +- Stack vertically on xs (full-width selects) +- Side-by-side on sm+ (50% width each) +- Labels above selects for clarity

+

Submit Modal: +- Width adapts to viewport (90% on mobile, 600px desktop) +- Form fields always full-width +- TextArea shrinks to 3 rows on mobile (vs 5 desktop)

+
+

Accessibility

+

Keyboard Navigation

+

Response Cards: +- Upvote button focusable via Tab +- Enter/Space toggles upvote

+

Sort/Filter Controls: +- Dropdowns keyboard navigable (Arrow keys + Enter) +- Focus visible on all select elements

+

Pagination: +- Page numbers focusable +- Arrow keys navigate pages (native Ant Design)

+

ARIA Labels

+

Upvote Button: +

<Button
+  aria-label={`Upvote response by ${response.userName}. Current upvotes: ${response.upvoteCount}`}
+  onClick={() => handleUpvote(response.id)}
+>
+  {response.upvoteCount} Upvotes
+</Button>
+

+

Statistics Cards: +

<Statistic
+  title="Total Responses"
+  value={stats.totalResponses}
+  aria-label={`Total responses: ${stats.totalResponses}`}
+/>
+

+

Modal: +

<Modal
+  title="Submit Your Response"
+  aria-labelledby="submit-response-title"
+  aria-describedby="submit-response-description"
+>
+

+

Screen Reader Support

+

Verification Badge: +

<Tag color="green" icon={<CheckCircleOutlined />}>
+  <span aria-label="Email verified">Verified</span>
+</Tag>
+

+

Timestamp: +

<Text
+  type="secondary"
+  aria-label={`Posted ${dayjs(response.createdAt).format('MMMM D, YYYY at h:mm A')}`}
+>
+  {dayjs(response.createdAt).fromNow()}
+</Text>
+

+

Form Validation: +- Error messages announced automatically +- Required field indicators (required attribute) +- Help text linked via aria-describedby

+
+

Troubleshooting

+

Issue: Upvotes Not Persisting

+

Symptoms: +- User clicks upvote, count increments +- Page refresh resets upvote +- Heart icon reverts to outline

+

Causes: +1. API call failing silently +2. Session/cookie not persisting user ID +3. Optimistic update not reverting on error +4. Backend not tracking upvote source

+

Solutions:

+
const handleUpvote = async (responseId: string) => {
+  // Save previous state for rollback
+  const previousResponses = [...responses];
+
+  // Optimistic update
+  setResponses(prev => prev.map(r => {
+    if (r.id === responseId) {
+      return {
+        ...r,
+        hasUpvoted: !r.hasUpvoted,
+        upvoteCount: r.upvoteCount + (r.hasUpvoted ? -1 : 1)
+      };
+    }
+    return r;
+  }));
+
+  try {
+    const response = await axios.post(
+      `/api/public/responses/${responseId}/upvote`,
+      {},
+      { timeout: 5000 }
+    );
+
+    console.log('Upvote response:', response.data);
+
+    // Update with server count (authoritative)
+    setResponses(prev => prev.map(r =>
+      r.id === responseId
+        ? {
+            ...r,
+            upvoteCount: response.data.upvoteCount,
+            hasUpvoted: response.data.action === 'added'
+          }
+        : r
+    ));
+
+  } catch (error: any) {
+    console.error('Upvote failed:', error);
+
+    // Revert to previous state
+    setResponses(previousResponses);
+
+    if (error.code === 'ECONNABORTED') {
+      message.error('Request timed out. Please try again.');
+    } else {
+      message.error('Failed to upvote. Please try again.');
+    }
+  }
+};
+
+

Check backend upvote tracking: +

-- Verify upvote records created
+SELECT * FROM "ResponseUpvote"
+WHERE "responseId" = 'cm2abc123'
+ORDER BY "createdAt" DESC;
+

+

Issue: Statistics Not Updating After Submission

+

Symptoms: +- User submits response +- Response appears in list +- Statistics cards show old counts

+

Causes: +1. Stats fetched once on mount, never refreshed +2. New response not included in stats query +3. Cache invalidation not working

+

Solutions:

+
// Refetch stats after successful submission
+const handleSubmit = async (values: any) => {
+  try {
+    await axios.post('/api/public/responses', { ... });
+
+    Modal.success({
+      title: 'Response Submitted!',
+      content: 'Please check your email to verify your response.',
+    });
+
+    setSubmitModalVisible(false);
+    form.resetFields();
+
+    // Refresh all data
+    const [statsRes, responsesRes] = await Promise.all([
+      axios.get(`/api/public/responses/campaigns/${campaignId}/stats`),
+      axios.get(`/api/public/responses/campaigns/${campaignId}`, {
+        params: { page: 1, limit: pageSize, sortBy, governmentLevel }
+      })
+    ]);
+
+    setStats(statsRes.data);
+    setResponses(responsesRes.data.responses);
+    setTotal(responsesRes.data.total);
+    setPage(1); // Reset to first page
+
+  } catch (error: any) {
+    message.error(error.response?.data?.message || 'Failed to submit response');
+  }
+};
+
+

Issue: "Verified Only" Filter Shows No Results

+

Symptoms: +- User selects "Verified Only" sort +- Grid shows empty state +- Total count remains high

+

Causes: +1. No verified responses exist yet +2. API not filtering correctly +3. Frontend not passing correct param

+

Solutions:

+
// Add empty state for no verified responses
+{!loading && responses.length === 0 && sortBy === 'verified' && (
+  <Card style={{ textAlign: 'center', padding: 40 }}>
+    <CheckCircleOutlined style={{ fontSize: 64, color: '#999', marginBottom: 16 }} />
+    <Title level={3} type="secondary">
+      No Verified Responses Yet
+    </Title>
+    <Paragraph type="secondary">
+      Responses appear here after users verify their email address.
+      <br />
+      Try selecting "Recent" or "Most Upvoted" to see all responses.
+    </Paragraph>
+    <Button
+      type="primary"
+      onClick={() => setSortBy('recent')}
+    >
+      View All Responses
+    </Button>
+  </Card>
+)}
+
+// Verify API param correctly passed
+useEffect(() => {
+  console.log('Fetching with params:', {
+    page,
+    limit: pageSize,
+    sortBy,
+    governmentLevel
+  });
+}, [page, sortBy, governmentLevel]);
+
+

Check backend: +

-- Count verified vs unverified
+SELECT "isVerified", COUNT(*)
+FROM "Response"
+WHERE "campaignId" = 'cm1abc123'
+GROUP BY "isVerified";
+

+

Issue: Pagination Showing Wrong Total

+

Symptoms: +- Pagination shows "1-20 of 342" +- Only 50 total responses exist +- Total count doesn't match stats card

+

Causes: +1. Stats query counting all campaigns +2. Responses query filtering by campaign correctly +3. Stats API endpoint broken

+

Solutions:

+
// Use responses total, not stats total, for pagination
+const [responsesTotal, setResponsesTotal] = useState(0);
+
+// In fetch responses:
+setResponsesTotal(responsesRes.data.total);
+
+// In pagination:
+<Pagination
+  current={page}
+  total={responsesTotal} // Not stats.totalResponses
+  pageSize={pageSize}
+  onChange={setPage}
+/>
+
+// Add validation
+useEffect(() => {
+  if (stats.totalResponses !== responsesTotal) {
+    console.warn('Mismatch between stats and pagination totals:', {
+      stats: stats.totalResponses,
+      pagination: responsesTotal
+    });
+  }
+}, [stats.totalResponses, responsesTotal]);
+
+
+ +

Public Pages

+ +

Admin Pages

+ +

Components

+ +

API Documentation

+ +

Architecture

+ + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/public/shifts-page/index.html b/mkdocs/site/v2/frontend/pages/public/shifts-page/index.html new file mode 100644 index 00000000..974a14f7 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/public/shifts-page/index.html @@ -0,0 +1,6179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Shifts - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Public Shifts Page

+

Overview

+

File Path: admin/src/pages/public/ShiftsPage.tsx (344 lines)

+

Route: /shifts

+

Role Requirements: Public access (no authentication required)

+

Purpose: Public volunteer shift signup interface allowing community members to register for canvassing shifts, creating temporary user accounts automatically, and receiving email confirmations.

+

Key Features:

+
    +
  • Hero banner with gradient background
  • +
  • Responsive shift cards grid (xs=1, sm=2, lg=3 columns)
  • +
  • Real-time volunteer capacity progress bars
  • +
  • Signup modal with name/email/phone fields
  • +
  • Temporary user creation for non-authenticated signups
  • +
  • Email confirmation after successful signup
  • +
  • Success modal with shift details
  • +
  • Visual opacity indication for full shifts
  • +
  • Dark theme consistency with public pages
  • +
+

Layout: Uses PublicLayout with dark theme

+
+

Features

+

1. Hero Banner

+

Prominent call-to-action header:

+
    +
  • Gradient Background: Purple-to-blue gradient
  • +
  • Title: "Volunteer Opportunities"
  • +
  • Subtitle: "Join us in making a difference in your community"
  • +
  • Icon: Calendar icon
  • +
  • Padding: 80px vertical (desktop), 60px (mobile)
  • +
+

2. Shift Cards Grid

+

Responsive grid displaying available shifts:

+

Card Contents: +- Shift title (Typography.Title level 4) +- Date and time range (formatted with dayjs) +- Location address +- Cut/district name (if assigned) +- Description (truncated, 3-line ellipsis) +- Volunteer capacity progress bar +- "Sign Up" button (primary, full-width)

+

Styling: +- Dark card background (colorBgContainer) +- Hover elevation effect +- 24px gutter between cards +- Rounded corners (8px)

+

Capacity Indicators: +- Green progress bar (0-70% full) +- Yellow progress bar (71-90% full) +- Red progress bar (91-100% full) +- Text: "X of Y volunteers signed up"

+

Full Shifts: +- Card opacity reduced to 0.6 +- Button disabled with "Full" text +- Badge showing "Full" in red

+

3. Signup Modal

+

User registration form:

+

Form Fields: +- Your Name (required, min 2 chars) +- Email (required, email validation) +- Phone (required, phone number format)

+

Shift Details Display: +- Shift title (read-only) +- Date/time (read-only) +- Location (read-only)

+

Submission: +- Creates temporary user if not logged in +- Creates shift signup record +- Sends confirmation email +- Opens success modal

+

Validation: +- Required field indicators +- Email format check +- Phone format (10 digits, (XXX) XXX-XXXX) +- Duplicate signup prevention

+

4. Success Modal

+

Post-signup confirmation:

+

Content: +- Green checkmark icon +- "Successfully Signed Up!" heading +- Shift details (title, date, time, location) +- Email confirmation message +- "OK" button to close

+

Behavior: +- Auto-opens after successful signup +- Reloads shift list on close (to show updated capacity)

+
+

User Workflow

+

Browsing Shifts

+
    +
  1. User navigates to /shifts
  2. +
  3. Hero banner loads with CTA
  4. +
  5. API fetches active shifts
  6. +
  7. Shift cards render in grid
  8. +
  9. User sees capacity bars (green/yellow/red)
  10. +
  11. User scrolls through available shifts
  12. +
+

Signing Up for Shift

+
    +
  1. User finds desired shift card
  2. +
  3. User clicks "Sign Up" button
  4. +
  5. Modal opens with signup form
  6. +
  7. User enters name: "Jane Doe"
  8. +
  9. User enters email: "jane@example.com"
  10. +
  11. User enters phone: "(555) 123-4567"
  12. +
  13. User clicks "Sign Up" submit button
  14. +
  15. API creates temp user (role: TEMP)
  16. +
  17. API creates shift signup
  18. +
  19. Confirmation email sent
  20. +
  21. Success modal displays
  22. +
  23. User clicks "OK"
  24. +
  25. Modal closes
  26. +
  27. Shift list refreshes
  28. +
  29. Signed-up shift shows updated capacity
  30. +
+

Full Shift Handling

+
    +
  1. User sees shift with red progress bar (full)
  2. +
  3. Card has reduced opacity
  4. +
  5. Button shows "Full" and is disabled
  6. +
  7. User cannot click signup
  8. +
  9. "Full" badge visible on card
  10. +
+
+

Component Structure

+
import React, { useState, useEffect } from 'react';
+import { Card, Row, Col, Typography, Button, Form, Input, Modal, Progress, Tag, Grid, message } from 'antd';
+import { CalendarOutlined, CheckCircleOutlined, ClockCircleOutlined, EnvironmentOutlined } from '@ant-design/icons';
+import dayjs from 'dayjs';
+import PublicLayout from '../../components/PublicLayout';
+import axios from 'axios';
+
+const { Title, Paragraph, Text } = Typography;
+const { useBreakpoint } = Grid;
+
+interface Shift {
+  id: string;
+  title: string;
+  description: string | null;
+  date: string;
+  startTime: string;
+  endTime: string;
+  location: string;
+  maxVolunteers: number;
+  currentSignups: number;
+  cutName: string | null;
+}
+
+const ShiftsPage: React.FC = () => {
+  const [shifts, setShifts] = useState<Shift[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [signupModalVisible, setSignupModalVisible] = useState(false);
+  const [selectedShift, setSelectedShift] = useState<Shift | null>(null);
+  const [form] = Form.useForm();
+  const screens = useBreakpoint();
+  const isMobile = !screens.md;
+
+  return (
+    <PublicLayout>
+      {/* Hero Banner */}
+      {/* Shift Cards Grid */}
+      {/* Signup Modal */}
+      {/* Success Modal */}
+    </PublicLayout>
+  );
+};
+
+
+

State Management

+
const [shifts, setShifts] = useState<Shift[]>([]);
+const [loading, setLoading] = useState(true);
+const [signupModalVisible, setSignupModalVisible] = useState(false);
+const [selectedShift, setSelectedShift] = useState<Shift | null>(null);
+const [successModalVisible, setSuccessModalVisible] = useState(false);
+const [form] = Form.useForm();
+
+
+

API Integration

+

Endpoints

+

1. List Public Shifts

+
GET /api/public/map/shifts
+
+

Response: +

[
+  {
+    "id": "cm1abc123",
+    "title": "Weekend Canvass - Downtown",
+    "description": "Door-to-door canvassing in the downtown district",
+    "date": "2025-02-15",
+    "startTime": "10:00",
+    "endTime": "14:00",
+    "location": "123 Main St, Campaign Office",
+    "maxVolunteers": 10,
+    "currentSignups": 7,
+    "cutName": "Downtown District"
+  }
+]
+

+

2. Sign Up for Shift

+
POST /api/public/map/shifts/:id/signup
+Content-Type: application/json
+
+{
+  "name": "Jane Doe",
+  "email": "jane@example.com",
+  "phone": "(555) 123-4567"
+}
+
+

Response: +

{
+  "success": true,
+  "signupId": "cm2def456",
+  "message": "Successfully signed up! Confirmation email sent."
+}
+

+
+

Code Examples

+

Shift Card with Capacity Bar

+
{shifts.map(shift => {
+  const isFull = shift.currentSignups >= shift.maxVolunteers;
+  const percentage = (shift.currentSignups / shift.maxVolunteers) * 100;
+
+  const progressColor = 
+    percentage < 70 ? '#52c41a' :
+    percentage < 90 ? '#faad14' :
+    '#f5222d';
+
+  return (
+    <Col xs={24} sm={12} lg={8} key={shift.id}>
+      <Card
+        hoverable={!isFull}
+        style={{ opacity: isFull ? 0.6 : 1 }}
+      >
+        {isFull && (
+          <Tag color="red" style={{ position: 'absolute', top: 16, right: 16 }}>
+            Full
+          </Tag>
+        )}
+
+        <Title level={4} style={{ marginBottom: 12 }}>
+          {shift.title}
+        </Title>
+
+        <Space direction="vertical" size={8} style={{ width: '100%', marginBottom: 16 }}>
+          <Text>
+            <CalendarOutlined /> {dayjs(shift.date).format('MMMM D, YYYY')}
+          </Text>
+          <Text>
+            <ClockCircleOutlined /> {shift.startTime} - {shift.endTime}
+          </Text>
+          <Text>
+            <EnvironmentOutlined /> {shift.location}
+          </Text>
+          {shift.cutName && (
+            <Tag color="blue">{shift.cutName}</Tag>
+          )}
+        </Space>
+
+        {shift.description && (
+          <Paragraph ellipsis={{ rows: 3 }} type="secondary" style={{ marginBottom: 16, minHeight: 66 }}>
+            {shift.description}
+          </Paragraph>
+        )}
+
+        <div style={{ marginBottom: 16 }}>
+          <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
+            <Text type="secondary" style={{ fontSize: 12 }}>
+              {shift.currentSignups} of {shift.maxVolunteers} volunteers
+            </Text>
+            <Text type="secondary" style={{ fontSize: 12 }}>
+              {Math.round(percentage)}%
+            </Text>
+          </div>
+          <Progress
+            percent={percentage}
+            strokeColor={progressColor}
+            showInfo={false}
+          />
+        </div>
+
+        <Button
+          type="primary"
+          block
+          disabled={isFull}
+          onClick={() => {
+            setSelectedShift(shift);
+            setSignupModalVisible(true);
+          }}
+        >
+          {isFull ? 'Full' : 'Sign Up'}
+        </Button>
+      </Card>
+    </Col>
+  );
+})}
+
+

Signup Modal

+
<Modal
+  title="Sign Up for Shift"
+  open={signupModalVisible}
+  onCancel={() => {
+    setSignupModalVisible(false);
+    form.resetFields();
+  }}
+  footer={null}
+  width={500}
+>
+  {selectedShift && (
+    <>
+      <div style={{
+        background: '#e6f7ff',
+        padding: 16,
+        borderRadius: 8,
+        marginBottom: 24
+      }}>
+        <Title level={5} style={{ marginBottom: 8 }}>
+          {selectedShift.title}
+        </Title>
+        <Text>
+          <CalendarOutlined /> {dayjs(selectedShift.date).format('MMMM D, YYYY')}
+        </Text>
+        <br />
+        <Text>
+          <ClockCircleOutlined /> {selectedShift.startTime} - {selectedShift.endTime}
+        </Text>
+        <br />
+        <Text>
+          <EnvironmentOutlined /> {selectedShift.location}
+        </Text>
+      </div>
+
+      <Form form={form} layout="vertical" onFinish={handleSignup}>
+        <Form.Item
+          name="name"
+          label="Your Name"
+          rules={[
+            { required: true, message: 'Please enter your name' },
+            { min: 2, message: 'Name must be at least 2 characters' }
+          ]}
+        >
+          <Input size="large" placeholder="Jane Doe" />
+        </Form.Item>
+
+        <Form.Item
+          name="email"
+          label="Email"
+          rules={[
+            { required: true, message: 'Please enter your email' },
+            { type: 'email', message: 'Please enter a valid email' }
+          ]}
+        >
+          <Input size="large" type="email" placeholder="jane@example.com" />
+        </Form.Item>
+
+        <Form.Item
+          name="phone"
+          label="Phone Number"
+          rules={[
+            { required: true, message: 'Please enter your phone number' },
+            { pattern: /^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/, message: 'Invalid phone format' }
+          ]}
+        >
+          <Input size="large" placeholder="(555) 123-4567" />
+        </Form.Item>
+
+        <Form.Item>
+          <Button type="primary" htmlType="submit" size="large" block loading={loading}>
+            Sign Up
+          </Button>
+        </Form.Item>
+      </Form>
+    </>
+  )}
+</Modal>
+
+
+

Performance Considerations

+
    +
  1. Single API Call: All shifts fetched once on mount
  2. +
  3. Optimistic UI: Capacity updates immediately after signup
  4. +
  5. Form Reset: Clears fields after successful submission
  6. +
  7. Debounced Validation: Email/phone validation on blur, not keystroke
  8. +
+
+

Accessibility

+
    +
  • Keyboard Navigation: All buttons focusable
  • +
  • Form Labels: Associated with inputs via htmlFor
  • +
  • Progress Bars: Include sr-only text for screen readers
  • +
  • Color Contrast: All text meets WCAG AA standards
  • +
+
+

Troubleshooting

+

Issue: Phone Validation Failing

+

Solution: +

// Normalize phone input
+const normalizePhone = (value: string) => {
+  const cleaned = value.replace(/\D/g, '');
+  if (cleaned.length === 10) {
+    return `(${cleaned.slice(0,3)}) ${cleaned.slice(3,6)}-${cleaned.slice(6)}`;
+  }
+  return value;
+};
+
+<Form.Item normalize={normalizePhone}>
+  <Input />
+</Form.Item>
+

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/volunteer/index.html b/mkdocs/site/v2/frontend/pages/volunteer/index.html new file mode 100644 index 00000000..fcae4c70 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/volunteer/index.html @@ -0,0 +1,5484 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Volunteer Pages - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Volunteer Pages

+

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.

+

Route Context

+
    +
  • Prefix: /volunteer/*
  • +
  • Layout: VolunteerLayout (top navigation)
  • +
  • Auth Required: Yes (any role)
  • +
  • Theme: Dark theme (consistent with public pages)
  • +
  • Mobile: Optimized for mobile/tablet use
  • +
+

Dashboard

+

Volunteer Dashboard

+

Route: /volunteer/dashboard

+

Volunteer overview with:

+
    +
  • Personal statistics
  • +
  • Upcoming assignments
  • +
  • Recent activity
  • +
  • Quick actions
  • +
+

Features: +- Visit count and outcomes +- Next shift information +- Activity summary +- Mobile responsive

+

Shift Management

+

Volunteer Shifts Page

+

Route: /volunteer/assignments

+

Assigned shifts for logged-in volunteer:

+
    +
  • Upcoming shifts list
  • +
  • Cut information
  • +
  • Start canvass button
  • +
  • Shift details
  • +
+

Features: +- Filter by date +- Cut assignment display +- Quick start canvassing +- Email notifications +- Mobile responsive

+

Note: Only shows shifts with cutId assigned (required for canvassing).

+

Canvassing

+

Volunteer Map Page

+

Route: /volunteer/canvass/:cutId

+

Full-screen GPS canvass map:

+
    +
  • Leaflet map with GPS tracking
  • +
  • Location markers by status
  • +
  • Walking route polyline
  • +
  • Bottom sheet toolbar
  • +
  • Visit recording form
  • +
  • Session timer
  • +
+

Features: +- GPS position tracking +- Auto-center on position +- Color-coded markers: + - Red - Not visited + - Blue - Next in route + - Green - Visited (success outcomes) + - Orange - Visited (neutral outcomes) + - Red - Visited (negative outcomes) +- Click marker to record visit +- Walking route algorithm +- Session management +- Mobile optimized

+

Full-Screen: +- No layout wrapper +- Custom header with timer +- Bottom sheet controls +- Optimized for field use

+

GPS Tracking: +- Watch position API +- Blue GPS marker +- Accuracy circle +- Auto-update every 5 seconds

+

Visit Recording: +- Outcome selection (NOT_HOME, MOVED, REFUSED, etc.) +- Notes field +- GPS coordinates captured +- Rate limited (30/min)

+

Session Management: +- Start/end session +- Elapsed timer +- Abandoned session cleanup (12h) +- Progress tracking

+

Activity Tracking

+

My Activity Page

+

Route: /volunteer/activity

+

Visit history and statistics:

+
    +
  • Visit list by date
  • +
  • Outcome breakdown
  • +
  • Session summary
  • +
  • Export options
  • +
+

Features: +- Filter by date range +- Group by session +- Outcome pie chart +- Total visit count +- Mobile responsive

+

My Routes Page

+

Route: /volunteer/routes

+

Walking route history:

+
    +
  • Route list by session
  • +
  • Distance traveled
  • +
  • Time elapsed
  • +
  • Location count
  • +
+

Features: +- Route visualization +- Session details +- Statistics summary +- Mobile responsive

+

Volunteer Page Count

+

Total: 4 volunteer pages

+

Common Features

+

Volunteer pages share:

+
    +
  • Mobile First - Touch-friendly controls
  • +
  • GPS Integration - Location tracking
  • +
  • Dark Theme - Low-light visibility
  • +
  • Session State - Zustand canvass store
  • +
  • Real-time Updates - Auto-refresh data
  • +
  • Offline Support (future) - Service worker caching
  • +
+

Authentication

+

All volunteer pages require authentication:

+
// Volunteer routes use authenticate middleware
+router.get('/api/canvass/session', authenticate, ...)
+
+

Role-based redirect after login: +- ADMIN roles/app/dashboard +- USER/TEMP roles/volunteer/dashboard

+

State Management

+

Volunteer pages use Zustand canvass store:

+
// stores/canvass.store.ts
+interface CanvassStore {
+  session: CanvassSession | null;
+  locations: CanvassLocation[];
+  route: WalkingRoute | null;
+  gpsPosition: { lat: number; lng: number } | null;
+  currentLocationIndex: number;
+  // ... actions
+}
+
+

GPS Tracking

+

GPS tracking uses browser Geolocation API:

+
navigator.geolocation.watchPosition(
+  (position) => {
+    setGpsPosition({
+      lat: position.coords.latitude,
+      lng: position.coords.longitude,
+    });
+  },
+  (error) => console.error('GPS error:', error),
+  {
+    enableHighAccuracy: true,
+    timeout: 5000,
+    maximumAge: 0,
+  }
+);
+
+

Walking Route Algorithm

+

Routes are calculated server-side using nearest-neighbor algorithm:

+
    +
  1. Start at closest location to shift start
  2. +
  3. For each subsequent location:
  4. +
  5. Find nearest unvisited location
  6. +
  7. Add to route
  8. +
  9. Return ordered location list
  10. +
+

Frontend displays route as blue polyline connecting locations.

+

Visit Outcomes

+

Available outcomes in recording form:

+
    +
  • SUCCESS - Successful contact
  • +
  • NOT_HOME - No one home
  • +
  • MOVED - Resident moved
  • +
  • REFUSED - Contact refused
  • +
  • WRONG_ADDRESS - Address error
  • +
  • INACCESSIBLE - Cannot access
  • +
  • OTHER - Other outcome
  • +
+

Session Lifecycle

+
    +
  1. Start Session - Create session record, generate route
  2. +
  3. GPS Tracking - Track position, update markers
  4. +
  5. Visit Locations - Record outcomes, update route
  6. +
  7. End Session - Close session, save statistics
  8. +
  9. Abandoned Cleanup - Auto-close after 12 hours
  10. +
+

Mobile Optimization

+

Volunteer pages are optimized for mobile:

+
    +
  • Touch Targets - Minimum 44px touch areas
  • +
  • GPS Integration - Native geolocation
  • +
  • Offline Maps (future) - Cached tiles
  • +
  • Battery Optimization - Efficient GPS polling
  • +
  • Low-Light Mode - Dark theme
  • +
  • Network Resilience - Queue failed requests
  • +
+

Performance

+

GPS canvass map optimizations:

+
    +
  • Marker clustering for large datasets
  • +
  • Route simplification
  • +
  • Debounced position updates
  • +
  • Lazy loading location details
  • +
  • Service worker caching (future)
  • +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/volunteer/my-activity-page/index.html b/mkdocs/site/v2/frontend/pages/volunteer/my-activity-page/index.html new file mode 100644 index 00000000..69774d75 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/volunteer/my-activity-page/index.html @@ -0,0 +1,5464 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + My Activity - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

My Activity Page

+

Overview

+

File Path: admin/src/pages/volunteer/MyActivityPage.tsx (137 lines)

+

Route: /volunteer/activity

+

Role Requirements: Authenticated users

+

Purpose: Volunteer activity dashboard showing canvassing statistics and visit history.

+

Key Features:

+
    +
  • Statistics cards (Today's Visits, Total Doors, Total Sessions)
  • +
  • Outcome breakdown with color-coded tags
  • +
  • Visit history table (address, outcome, timestamp)
  • +
  • Pagination (20 visits per page)
  • +
  • Parallel stats + visits fetch
  • +
  • Dark theme (VolunteerLayout)
  • +
+
+

Features

+

1. Statistics Cards

+
<Row gutter={16}>
+  <Col xs={24} sm={8}>
+    <Card>
+      <Statistic
+        title="Today's Visits"
+        value={stats.todayVisits}
+        prefix={<CheckCircleOutlined />}
+        valueStyle={{ color: '#52c41a' }}
+      />
+    </Card>
+  </Col>
+
+  <Col xs={24} sm={8}>
+    <Card>
+      <Statistic
+        title="Total Doors"
+        value={stats.totalDoors}
+        prefix={<HomeOutlined />}
+        valueStyle={{ color: '#1890ff' }}
+      />
+    </Card>
+  </Col>
+
+  <Col xs={24} sm={8}>
+    <Card>
+      <Statistic
+        title="Total Sessions"
+        value={stats.totalSessions}
+        prefix={<ClockCircleOutlined />}
+        valueStyle={{ color: '#722ed1' }}
+      />
+    </Card>
+  </Col>
+</Row>
+
+

2. Outcome Breakdown

+
<Card title="Outcome Breakdown">
+  <Space wrap>
+    <Tag color="green">Strong Support: {outcomes.strongSupport}</Tag>
+    <Tag color="blue">Leaning Support: {outcomes.leaningSupport}</Tag>
+    <Tag color="yellow">Undecided: {outcomes.undecided}</Tag>
+    <Tag color="orange">Leaning Opposed: {outcomes.leaningOpposed}</Tag>
+    <Tag color="red">Opposed: {outcomes.opposed}</Tag>
+    <Tag color="default">No Answer: {outcomes.noAnswer}</Tag>
+    <Tag color="gray">Not Home: {outcomes.notHome}</Tag>
+  </Space>
+</Card>
+
+

3. Visit History Table

+
<Table
+  dataSource={visits}
+  columns={[
+    {
+      title: 'Address',
+      dataIndex: 'address',
+      key: 'address'
+    },
+    {
+      title: 'Outcome',
+      dataIndex: 'outcome',
+      key: 'outcome',
+      render: (outcome) => (
+        <Tag color={getOutcomeColor(outcome)}>
+          {outcome.replace('_', ' ')}
+        </Tag>
+      )
+    },
+    {
+      title: 'Notes',
+      dataIndex: 'notes',
+      key: 'notes',
+      ellipsis: true
+    },
+    {
+      title: 'Time',
+      dataIndex: 'createdAt',
+      key: 'createdAt',
+      render: (date) => dayjs(date).format('MMM D, h:mm A')
+    }
+  ]}
+  pagination={{
+    current: page,
+    total: total,
+    pageSize: 20,
+    onChange: setPage
+  }}
+/>
+
+
+

API Integration

+

Endpoints

+

1. Get Stats

+
GET /api/map/canvass/my-stats
+Authorization: Bearer {token}
+
+

Response: +

{
+  "todayVisits": 23,
+  "totalDoors": 187,
+  "totalSessions": 12,
+  "outcomes": {
+    "strongSupport": 45,
+    "leaningSupport": 32,
+    "undecided": 28,
+    "leaningOpposed": 15,
+    "opposed": 12,
+    "noAnswer": 38,
+    "notHome": 17
+  }
+}
+

+

2. Get Visit History

+
GET /api/map/canvass/my-visits?page=1&limit=20
+Authorization: Bearer {token}
+
+

Response: +

{
+  "visits": [
+    {
+      "id": "cm1visit123",
+      "address": "123 Main St",
+      "outcome": "strong_support",
+      "notes": "Very enthusiastic, requested yard sign",
+      "createdAt": "2025-02-12T14:30:00.000Z"
+    }
+  ],
+  "total": 187,
+  "page": 1
+}
+

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/volunteer/my-routes-page/index.html b/mkdocs/site/v2/frontend/pages/volunteer/my-routes-page/index.html new file mode 100644 index 00000000..4a90174b --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/volunteer/my-routes-page/index.html @@ -0,0 +1,5577 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + My Routes - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

My Routes Page

+

Overview

+

File Path: admin/src/pages/volunteer/MyRoutesPage.tsx (275 lines)

+

Route: /volunteer/routes

+

Role Requirements: Authenticated users

+

Purpose: Visual display of volunteer's canvassing routes with map visualization and session history.

+

Key Features:

+
    +
  • Statistics cards (Total Sessions, Total Distance, Total Time)
  • +
  • Interactive map with route polyline
  • +
  • Color-coded event markers (Session Start/End, Visit, Location Added)
  • +
  • Legend for event types
  • +
  • Session history table (date, duration, distance, point count)
  • +
  • View/Hide route toggle button
  • +
  • FitBounds component to center map on route
  • +
  • Dark CARTO basemap
  • +
+
+

Features

+

1. Statistics Summary

+
<Row gutter={16}>
+  <Col xs={24} sm={8}>
+    <Statistic
+      title="Total Sessions"
+      value={stats.totalSessions}
+      prefix={<ClockCircleOutlined />}
+    />
+  </Col>
+  <Col xs={24} sm={8}>
+    <Statistic
+      title="Total Distance"
+      value={`${(stats.totalDistance / 1000).toFixed(1)} km`}
+      prefix={<EnvironmentOutlined />}
+    />
+  </Col>
+  <Col xs={24} sm=8}>
+    <Statistic
+      title="Total Time"
+      value={formatDuration(stats.totalDuration)}
+      prefix={<FieldTimeOutlined />}
+    />
+  </Col>
+</Row>
+
+

2. Route Map

+
<MapContainer
+  center={[45.5017, -73.5673]}
+  zoom={13}
+  style={{ height: 400 }}
+>
+  <TileLayer
+    url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
+    attribution='&copy; <a href="https://carto.com/">CARTO</a>'
+  />
+
+  {/* Route polyline */}
+  {routeVisible && selectedRoute && (
+    <Polyline
+      positions={selectedRoute.points.map(p => [p.latitude, p.longitude])}
+      pathOptions={{ color: '#1890ff', weight: 3 }}
+    />
+  )}
+
+  {/* Event markers */}
+  {selectedRoute?.points.map(point => (
+    <CircleMarker
+      key={point.id}
+      center={[point.latitude, point.longitude]}
+      radius={6}
+      pathOptions={{
+        color: getEventColor(point.eventType),
+        fillColor: getEventColor(point.eventType),
+        fillOpacity: 1
+      }}
+    >
+      <Popup>
+        <Text strong>{point.eventType}</Text>
+        <br />
+        <Text type="secondary">
+          {dayjs(point.timestamp).format('h:mm:ss A')}
+        </Text>
+      </Popup>
+    </CircleMarker>
+  ))}
+
+  {/* Fit bounds to route */}
+  {selectedRoute && <FitBounds points={selectedRoute.points} />}
+</MapContainer>
+
+

3. Event Type Legend

+
<div style={{ padding: 12, background: 'rgba(0,0,0,0.7)', borderRadius: 4 }}>
+  <Space direction="vertical" size={4}>
+    <Space>
+      <div style={{ width: 12, height: 12, background: '#52c41a', borderRadius: '50%' }} />
+      <Text style={{ color: 'white', fontSize: 12 }}>Session Start</Text>
+    </Space>
+    <Space>
+      <div style={{ width: 12, height: 12, background: '#f5222d', borderRadius: '50%' }} />
+      <Text style={{ color: 'white', fontSize: 12 }}>Session End</Text>
+    </Space>
+    <Space>
+      <div style={{ width: 12, height: 12, background: '#1890ff', borderRadius: '50%' }} />
+      <Text style={{ color: 'white', fontSize: 12 }}>Visit</Text>
+    </Space>
+    <Space>
+      <div style={{ width: 12, height: 12, background: '#722ed1', borderRadius: '50%' }} />
+      <Text style={{ color: 'white', fontSize: 12 }}>Location Added</Text>
+    </Space>
+  </Space>
+</div>
+
+

4. Session History Table

+
<Table
+  dataSource={sessions}
+  columns={[
+    {
+      title: 'Date',
+      dataIndex: 'startTime',
+      render: (date) => dayjs(date).format('MMM D, YYYY')
+    },
+    {
+      title: 'Duration',
+      key: 'duration',
+      render: (_, record) => {
+        const start = dayjs(record.startTime);
+        const end = dayjs(record.endTime);
+        return formatDuration(end.diff(start, 'seconds'));
+      }
+    },
+    {
+      title: 'Distance',
+      dataIndex: 'distance',
+      render: (distance) => `${(distance / 1000).toFixed(1)} km`
+    },
+    {
+      title: 'Points',
+      dataIndex: 'pointCount',
+      render: (count) => `${count} points`
+    },
+    {
+      title: 'Action',
+      key: 'action',
+      render: (_, record) => (
+        <Button
+          type="link"
+          onClick={() => {
+            setSelectedRoute(record);
+            setRouteVisible(true);
+          }}
+        >
+          View Route
+        </Button>
+      )
+    }
+  ]}
+/>
+
+
+

API Integration

+

Endpoints

+

1. Get Route Stats

+
GET /api/map/canvass/my-routes/stats
+Authorization: Bearer {token}
+
+

Response: +

{
+  "totalSessions": 12,
+  "totalDistance": 34567,
+  "totalDuration": 18900
+}
+

+

2. Get Session Routes

+
GET /api/map/canvass/my-routes
+Authorization: Bearer {token}
+
+

Response: +

[
+  {
+    "sessionId": "cm1session123",
+    "startTime": "2025-02-12T10:00:00.000Z",
+    "endTime": "2025-02-12T12:30:00.000Z",
+    "distance": 2834,
+    "pointCount": 45,
+    "points": [
+      {
+        "id": "cm1point1",
+        "latitude": 45.5017,
+        "longitude": -73.5673,
+        "eventType": "session_start",
+        "timestamp": "2025-02-12T10:00:00.000Z"
+      }
+    ]
+  }
+]
+

+
+

Utility Functions

+
const formatDuration = (seconds: number): string => {
+  const hours = Math.floor(seconds / 3600);
+  const minutes = Math.floor((seconds % 3600) / 60);
+
+  if (hours > 0) {
+    return `${hours}h ${minutes}m`;
+  }
+  return `${minutes}m`;
+};
+
+const getEventColor = (eventType: string): string => {
+  switch (eventType) {
+    case 'session_start': return '#52c41a';
+    case 'session_end': return '#f5222d';
+    case 'visit': return '#1890ff';
+    case 'location_added': return '#722ed1';
+    default: return '#8c8c8c';
+  }
+};
+
+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/volunteer/volunteer-map-page/index.html b/mkdocs/site/v2/frontend/pages/volunteer/volunteer-map-page/index.html new file mode 100644 index 00000000..1abbbab6 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/volunteer/volunteer-map-page/index.html @@ -0,0 +1,7011 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Volunteer Map - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Volunteer Map Page

+

Overview

+

File Path: admin/src/pages/volunteer/VolunteerMapPage.tsx (570 lines)

+

Route: /volunteer/canvass/:cutId

+

Role Requirements: Authenticated users (USER or TEMP role)

+

Purpose: Full-screen GPS-enabled canvassing map for door-to-door volunteer work with visit recording, route navigation, location management, and session tracking.

+

Key Features:

+
    +
  • Full-screen map (no AppLayout wrapper)
  • +
  • Real-time GPS tracking with following mode
  • +
  • Canvass session management (start/end with auto-pause)
  • +
  • Walking route display with toggle
  • +
  • CanvassMarkerGroup for clustered address display
  • +
  • VisitRecordingForm in bottom drawer
  • +
  • AddLocationDrawer (crosshair + tap to add)
  • +
  • VolunteerMapDrawer (menu, stats, session picker)
  • +
  • VolunteerFooterNav (bottom navigation bar)
  • +
  • VolunteerSessionBar (active session indicator above footer)
  • +
  • TileLayerToggle (OpenStreetMap, CARTO, Satellite)
  • +
  • AddressSearchOverlay
  • +
  • Next door button (finds nearest unvisited location)
  • +
  • Cut polygon overlays with toggle controls
  • +
  • Admin edit mode (LocationEditDrawer for MAP_ADMIN users)
  • +
  • Tracking integration (links to GPS tracking sessions)
  • +
+

Layout: No layout wrapper - full viewport with custom overlays

+
+

Features

+

1. GPS Tracking

+

Real-time position tracking with following mode:

+

Components: +- GPSTracker component (useEffect with watchPosition) +- Blue pulsing circle marker at user's position +- Accuracy circle (outer ring) +- GPS path polyline (breadcrumb trail) +- Follow mode toggle (auto-pan to user)

+

Code: +

<GPSTracker
+  enabled={sessionActive}
+  onPositionUpdate={(position) => {
+    setUserPosition(position);
+    if (followMode) {
+      map.panTo(position.coords);
+    }
+    // Track position for session
+    trackPosition(position);
+  }}
+  onError={(error) => {
+    message.error('GPS unavailable: ' + error.message);
+  }}
+/>
+

+

2. Session Management

+

Canvass session lifecycle:

+

States: +- ACTIVE: Session in progress +- PAUSED: Session paused (GPS stopped) +- ENDED: Session completed

+

Controls: +- Start Session button (green) +- Pause Session button (yellow) +- End Session button (red, with confirmation) +- Auto-pause after 30min inactivity

+

Session Bar: +

<VolunteerSessionBar
+  session={activeSession}
+  onPause={handlePauseSession}
+  onEnd={() => setEndModalVisible(true)}
+  style={{
+    position: 'fixed',
+    bottom: 60,
+    left: 0,
+    right: 0,
+    zIndex: 1000
+  }}
+/>
+

+

3. Walking Route Display

+

Optimized door-to-door route:

+

Algorithm: +- Nearest neighbor with deduplication +- Starts from user's current position +- Visits unvisited locations in order +- Avoids backtracking

+

Visual: +- Blue polyline connecting locations +- Dashed line style +- Toggle button to show/hide +- Route recalculates when locations visited

+

Code: +

{routeVisible && walkingRoute && (
+  <WalkingRouteLine
+    route={walkingRoute}
+    userPosition={userPosition}
+  />
+)}
+

+

4. Canvass Markers

+

Location markers with clustering:

+

CanvassMarkerGroup Component: +- Clusters nearby markers (radius: 50px) +- Color-coded by support level +- Click to open VisitRecordingForm +- Shows last visit outcome if visited +- Purple markers for multi-unit buildings

+

Marker States: +- Unvisited: Gray circle +- Visited: Color-coded by outcome +- Selected: Larger radius + pulsing animation

+

5. Visit Recording Form

+

Bottom drawer for recording visits:

+

Fields: +- Address (read-only, pre-filled) +- Outcome (dropdown: 7 options) +- Notes (TextArea, optional) +- Contact Interest (checkbox) +- Follow-up Required (checkbox)

+

Outcome Options: +1. Strong Support +2. Leaning Support +3. Undecided +4. Leaning Opposed +5. Opposed +6. No Answer +7. Not Home

+

Submission: +- Creates CanvassVisit record +- Updates location supportLevel +- Closes drawer +- Marker updates color +- Next door button finds new nearest

+

Code: +

<VisitRecordingForm
+  location={selectedLocation}
+  sessionId={activeSession?.id}
+  visible={recordingDrawerVisible}
+  onClose={() => setRecordingDrawerVisible(false)}
+  onSubmit={handleVisitSubmit}
+/>
+

+

6. Add Location Mode

+

Crosshair interface for adding missing addresses:

+

Activation: +- "Add Location" button in menu +- Opens AddLocationDrawer +- Crosshair appears at map center +- User pans map to position crosshair +- "Tap Here to Add" button

+

AddLocationDrawer: +- Address input (with geocoding suggestion) +- Unit number (for multi-unit buildings) +- Notes +- Cancel / Confirm buttons

+

Code: +

<AddLocationDrawer
+  visible={addLocationMode}
+  position={map.getCenter()}
+  onConfirm={handleAddLocation}
+  onCancel={() => setAddLocationMode(false)}
+/>
+

+

7. Map Controls

+

Floating control panels:

+

VolunteerMapDrawer (left side): +- Menu button (hamburger) +- Session stats (visits today, doors knocked) +- Session picker dropdown +- Tile layer toggle +- Cut overlays toggle +- Address search +- Add location button +- End session button

+

Control Buttons (right side): +- Geolocate (find my location) +- Toggle walking route +- Next door (find nearest unvisited) +- Fullscreen toggle

+

8. Tile Layer Toggle

+

Three basemap options:

+
    +
  1. OpenStreetMap: Default, detailed streets
  2. +
  3. CARTO Dark: High contrast, good for day/night
  4. +
  5. Satellite: Aerial imagery from Esri
  6. +
+

Component: +

<TileLayerToggle
+  activeLayer={activeLayer}
+  onChange={setActiveLayer}
+  position="topright"
+/>
+

+

9. Address Search Overlay

+

Quick location lookup:

+

Features: +- Input with search icon +- Autocomplete from locations in cut +- Fly to location on select +- Opens visit recording form

+

Code: +

<AddressSearchOverlay
+  locations={locations}
+  onSelect={(location) => {
+    map.flyTo([location.latitude, location.longitude], 18);
+    setSelectedLocation(location);
+    setRecordingDrawerVisible(true);
+  }}
+/>
+

+

10. Next Door Button

+

Intelligent location finder:

+

Algorithm: +1. Filter unvisited locations in cut +2. Calculate distance from user position +3. Sort by distance (haversine) +4. Select nearest +5. Pan map and open recording form

+

Code: +

const handleNextDoor = () => {
+  const unvisited = locations.filter(loc => 
+    !visits.some(v => v.locationId === loc.id)
+  );
+
+  if (unvisited.length === 0) {
+    message.info('All locations visited!');
+    return;
+  }
+
+  const nearest = unvisited.reduce((prev, curr) => {
+    const prevDist = haversineDistance(userPosition, prev);
+    const currDist = haversineDistance(userPosition, curr);
+    return currDist < prevDist ? curr : prev;
+  });
+
+  map.flyTo([nearest.latitude, nearest.longitude], 18);
+  setSelectedLocation(nearest);
+  setRecordingDrawerVisible(true);
+};
+

+

11. Admin Edit Mode

+

MAP_ADMIN users can edit locations:

+

Features: +- Edit button on location popup +- LocationEditDrawer with full form +- Update address, support level, notes +- Delete location (with confirmation) +- Move location (drag marker)

+

Conditional Render: +

{user?.role === 'MAP_ADMIN' && (
+  <Button onClick={() => setEditMode(true)}>
+    Edit Location
+  </Button>
+)}
+

+

12. Cut Overlay Toggle

+

Show/hide cut boundaries:

+

Component: +

<CutOverlayControls
+  cuts={cuts}
+  visibleCuts={visibleCuts}
+  onToggle={(cutId) => {
+    setVisibleCuts(prev => {
+      const next = new Set(prev);
+      if (next.has(cutId)) {
+        next.delete(cutId);
+      } else {
+        next.add(cutId);
+      }
+      return next;
+    });
+  }}
+  position="bottomleft"
+/>
+

+
+

User Workflow

+

Starting a Canvass Session

+
    +
  1. Volunteer navigates to /volunteer/canvass/:cutId
  2. +
  3. Map loads centered on cut bounds
  4. +
  5. Locations load within cut
  6. +
  7. Volunteer clicks "Start Session" in drawer
  8. +
  9. GPS tracking activates
  10. +
  11. Session bar appears at bottom (above footer)
  12. +
  13. Walking route calculates and displays
  14. +
  15. User position marker appears and updates
  16. +
+

Recording a Visit

+
    +
  1. Volunteer walks to address
  2. +
  3. Clicks marker or uses "Next Door" button
  4. +
  5. VisitRecordingForm opens in bottom drawer
  6. +
  7. Volunteer selects outcome from dropdown
  8. +
  9. Volunteer adds notes (optional)
  10. +
  11. Volunteer checks "Follow-up Required" if needed
  12. +
  13. Volunteer clicks "Save Visit"
  14. +
  15. API creates CanvassVisit record
  16. +
  17. Marker updates to color-coded outcome
  18. +
  19. Drawer closes
  20. +
  21. Walking route recalculates
  22. +
+

Adding a Missing Location

+
    +
  1. Volunteer encounters unlisted address
  2. +
  3. Opens menu drawer
  4. +
  5. Clicks "Add Location"
  6. +
  7. Crosshair appears at map center
  8. +
  9. Volunteer pans map to position crosshair over address
  10. +
  11. Clicks "Tap Here to Add"
  12. +
  13. AddLocationDrawer opens
  14. +
  15. Volunteer enters address
  16. +
  17. Volunteer clicks "Confirm"
  18. +
  19. API creates Location record
  20. +
  21. New marker appears on map
  22. +
  23. Volunteer can immediately record visit
  24. +
+

Finding Next Door

+
    +
  1. Volunteer finishes current visit
  2. +
  3. Clicks "Next Door" button (right side)
  4. +
  5. Algorithm finds nearest unvisited location
  6. +
  7. Map animates (flyTo) to location
  8. +
  9. VisitRecordingForm opens automatically
  10. +
  11. Volunteer records visit
  12. +
  13. Repeats process
  14. +
+

Ending Session

+
    +
  1. Volunteer clicks "End Session" in drawer
  2. +
  3. Confirmation modal appears
  4. +
  5. Modal shows session stats (duration, visits, distance)
  6. +
  7. Volunteer clicks "End Session" confirm button
  8. +
  9. GPS tracking stops
  10. +
  11. Session marked as ENDED in database
  12. +
  13. Session bar disappears
  14. +
  15. Volunteer can start new session or navigate away
  16. +
+
+

Component Structure

+
import React, { useState, useEffect, useCallback } from 'react';
+import { useParams } from 'react-router-dom';
+import { MapContainer, TileLayer, useMap } from 'react-leaflet';
+import { Button, message, Modal } from 'antd';
+import {
+  AimOutlined,
+  PlusOutlined,
+  ArrowRightOutlined,
+  FullscreenOutlined
+} from '@ant-design/icons';
+import GPSTracker from '../../components/canvass/GPSTracker';
+import CanvassMarkerGroup from '../../components/canvass/CanvassMarkerGroup';
+import WalkingRouteLine from '../../components/canvass/WalkingRouteLine';
+import VisitRecordingForm from '../../components/canvass/VisitRecordingForm';
+import AddLocationDrawer from '../../components/canvass/AddLocationDrawer';
+import VolunteerMapDrawer from '../../components/canvass/VolunteerMapDrawer';
+import VolunteerFooterNav from '../../components/canvass/VolunteerFooterNav';
+import VolunteerSessionBar from '../../components/canvass/VolunteerSessionBar';
+import { useCanvassStore } from '../../stores/canvass.store';
+import { api } from '../../lib/api';
+import 'leaflet/dist/leaflet.css';
+
+const VolunteerMapPage: React.FC = () => {
+  const { cutId } = useParams<{ cutId: string }>();
+  const [map, setMap] = useState<L.Map | null>(null);
+
+  // Canvass store
+  const {
+    activeSession,
+    locations,
+    visits,
+    walkingRoute,
+    userPosition,
+    setActiveSession,
+    addVisit,
+    updateLocation,
+    setUserPosition
+  } = useCanvassStore();
+
+  // UI state
+  const [recordingDrawerVisible, setRecordingDrawerVisible] = useState(false);
+  const [selectedLocation, setSelectedLocation] = useState<Location | null>(null);
+  const [addLocationMode, setAddLocationMode] = useState(false);
+  const [drawerVisible, setDrawerVisible] = useState(false);
+  const [routeVisible, setRouteVisible] = useState(true);
+  const [followMode, setFollowMode] = useState(true);
+
+  // Fetch locations in cut
+  useEffect(() => {
+    const fetchLocations = async () => {
+      try {
+        const response = await api.get(`/api/map/canvass/locations/${cutId}`);
+        // Store in Zustand
+      } catch (error) {
+        message.error('Failed to load locations');
+      }
+    };
+
+    fetchLocations();
+  }, [cutId]);
+
+  return (
+    <div style={{ height: '100vh', width: '100vw', position: 'relative' }}>
+      <MapContainer
+        center={[45.5017, -73.5673]}
+        zoom={16}
+        zoomControl={false}
+        style={{ height: '100%', width: '100%' }}
+        whenCreated={setMap}
+      >
+        <TileLayer
+          url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
+          attribution='OSM'
+        />
+
+        <GPSTracker
+          enabled={!!activeSession}
+          onPositionUpdate={handlePositionUpdate}
+        />
+
+        <CanvassMarkerGroup
+          locations={locations}
+          visits={visits}
+          onMarkerClick={handleMarkerClick}
+        />
+
+        {routeVisible && walkingRoute && (
+          <WalkingRouteLine
+            route={walkingRoute}
+            userPosition={userPosition}
+          />
+        )}
+      </MapContainer>
+
+      <VolunteerMapDrawer
+        visible={drawerVisible}
+        onClose={() => setDrawerVisible(false)}
+        onStartSession={handleStartSession}
+        onEndSession={() => setEndModalVisible(true)}
+        onAddLocation={() => setAddLocationMode(true)}
+        session={activeSession}
+        stats={sessionStats}
+      />
+
+      <VisitRecordingForm
+        location={selectedLocation}
+        sessionId={activeSession?.id}
+        visible={recordingDrawerVisible}
+        onClose={() => setRecordingDrawerVisible(false)}
+        onSubmit={handleVisitSubmit}
+      />
+
+      <AddLocationDrawer
+        visible={addLocationMode}
+        position={map?.getCenter()}
+        onConfirm={handleAddLocation}
+        onCancel={() => setAddLocationMode(false)}
+      />
+
+      {activeSession && (
+        <VolunteerSessionBar
+          session={activeSession}
+          onPause={handlePause}
+          onEnd={() => setEndModalVisible(true)}
+        />
+      )}
+
+      <VolunteerFooterNav activeKey="canvass" />
+
+      {/* Floating controls */}
+      <div style={{ position: 'absolute', right: 16, top: 16, zIndex: 1000 }}>
+        <Button
+          icon={<AimOutlined />}
+          onClick={handleGeolocate}
+          size="large"
+          style={{ display: 'block', marginBottom: 8 }}
+        />
+        <Button
+          icon={<ArrowRightOutlined />}
+          onClick={handleNextDoor}
+          size="large"
+        />
+      </div>
+    </div>
+  );
+};
+
+export default VolunteerMapPage;
+
+
+

State Management

+

Zustand Store (canvass.store.ts)

+
interface CanvassState {
+  // Session
+  activeSession: CanvassSession | null;
+  setActiveSession: (session: CanvassSession | null) => void;
+
+  // Locations
+  locations: CanvassLocation[];
+  setLocations: (locations: CanvassLocation[]) => void;
+  updateLocation: (id: string, updates: Partial<CanvassLocation>) => void;
+
+  // Visits
+  visits: CanvassVisit[];
+  addVisit: (visit: CanvassVisit) => void;
+
+  // Route
+  walkingRoute: WalkingRoute | null;
+  calculateRoute: () => void;
+
+  // GPS
+  userPosition: GPSPosition | null;
+  setUserPosition: (position: GPSPosition) => void;
+  gpsPath: GPSPosition[];
+  addGPSPoint: (position: GPSPosition) => void;
+}
+
+export const useCanvassStore = create<CanvassState>((set, get) => ({
+  activeSession: null,
+  locations: [],
+  visits: [],
+  walkingRoute: null,
+  userPosition: null,
+  gpsPath: [],
+
+  setActiveSession: (session) => {
+    set({ activeSession: session });
+    if (session) {
+      get().calculateRoute();
+    }
+  },
+
+  addVisit: (visit) => {
+    set((state) => ({
+      visits: [...state.visits, visit]
+    }));
+    get().calculateRoute(); // Recalculate after visit
+  },
+
+  calculateRoute: () => {
+    const { locations, visits, userPosition } = get();
+
+    if (!userPosition) return;
+
+    const unvisited = locations.filter(loc =>
+      !visits.some(v => v.locationId === loc.id)
+    );
+
+    const route = calculateWalkingRoute(userPosition, unvisited);
+    set({ walkingRoute: route });
+  }
+}));
+
+

Component State

+
// UI state
+const [recordingDrawerVisible, setRecordingDrawerVisible] = useState(false);
+const [selectedLocation, setSelectedLocation] = useState<Location | null>(null);
+const [addLocationMode, setAddLocationMode] = useState(false);
+const [drawerVisible, setDrawerVisible] = useState(false);
+const [endModalVisible, setEndModalVisible] = useState(false);
+
+// Map state
+const [map, setMap] = useState<L.Map | null>(null);
+const [routeVisible, setRouteVisible] = useState(true);
+const [followMode, setFollowMode] = useState(true);
+const [activeLayer, setActiveLayer] = useState<'osm' | 'carto' | 'satellite'>('osm');
+
+
+

API Integration

+

Endpoints

+

1. Get Locations in Cut

+
GET /api/map/canvass/locations/:cutId
+Authorization: Bearer {token}
+
+

Response: +

[
+  {
+    "id": "cm1loc123",
+    "address": "123 Main St",
+    "latitude": 45.5017,
+    "longitude": -73.5673,
+    "supportLevel": null,
+    "lastVisitDate": null,
+    "isMultiUnit": false
+  }
+]
+

+

2. Start Session

+
POST /api/map/canvass/sessions/start
+Authorization: Bearer {token}
+Content-Type: application/json
+
+{
+  "cutId": "cm1cut123"
+}
+
+

Response: +

{
+  "sessionId": "cm2session456",
+  "startTime": "2025-02-12T10:00:00.000Z",
+  "status": "ACTIVE"
+}
+

+

3. Record Visit

+
POST /api/map/canvass/visits
+Authorization: Bearer {token}
+Content-Type: application/json
+
+{
+  "sessionId": "cm2session456",
+  "locationId": "cm1loc123",
+  "outcome": "strong_support",
+  "notes": "Very enthusiastic, requested yard sign",
+  "contactInterested": true,
+  "followUpRequired": false
+}
+
+

Response: +

{
+  "visitId": "cm3visit789",
+  "createdAt": "2025-02-12T10:15:00.000Z"
+}
+

+

4. Track GPS Position

+
POST /api/map/canvass/sessions/:sessionId/track
+Authorization: Bearer {token}
+Content-Type: application/json
+
+{
+  "latitude": 45.5017,
+  "longitude": -73.5673,
+  "accuracy": 12.5,
+  "timestamp": "2025-02-12T10:15:30.000Z"
+}
+
+

5. Add Location

+
POST /api/map/locations
+Authorization: Bearer {token}
+Content-Type: application/json
+
+{
+  "address": "125 Main St",
+  "latitude": 45.5018,
+  "longitude": -73.5672,
+  "cutId": "cm1cut123",
+  "notes": "Added during canvass"
+}
+
+

6. End Session

+
POST /api/map/canvass/sessions/:sessionId/end
+Authorization: Bearer {token}
+
+

Response: +

{
+  "sessionId": "cm2session456",
+  "endTime": "2025-02-12T12:00:00.000Z",
+  "duration": 7200,
+  "visitCount": 23,
+  "distance": 2834
+}
+

+
+

Code Examples

+

GPS Tracker Component

+
// components/canvass/GPSTracker.tsx
+const GPSTracker: React.FC<{
+  enabled: boolean;
+  onPositionUpdate: (position: GeolocationPosition) => void;
+  onError?: (error: GeolocationPositionError) => void;
+}> = ({ enabled, onPositionUpdate, onError }) => {
+  useEffect(() => {
+    if (!enabled || !navigator.geolocation) return;
+
+    const watchId = navigator.geolocation.watchPosition(
+      onPositionUpdate,
+      onError,
+      {
+        enableHighAccuracy: true,
+        maximumAge: 5000,
+        timeout: 10000
+      }
+    );
+
+    return () => navigator.geolocation.clearWatch(watchId);
+  }, [enabled, onPositionUpdate, onError]);
+
+  return null;
+};
+
+

Walking Route Calculation

+
// utils/walking-route.ts
+export const calculateWalkingRoute = (
+  start: GPSPosition,
+  locations: CanvassLocation[]
+): WalkingRoute => {
+  const unvisited = [...locations];
+  const route: CanvassLocation[] = [];
+  let current = { latitude: start.latitude, longitude: start.longitude };
+
+  // Nearest neighbor algorithm
+  while (unvisited.length > 0) {
+    let nearestIdx = 0;
+    let nearestDist = Infinity;
+
+    unvisited.forEach((loc, idx) => {
+      const dist = haversineDistance(current, loc);
+      if (dist < nearestDist) {
+        nearestDist = dist;
+        nearestIdx = idx;
+      }
+    });
+
+    const nearest = unvisited.splice(nearestIdx, 1)[0];
+    route.push(nearest);
+    current = nearest;
+  }
+
+  return {
+    locations: route,
+    totalDistance: calculateTotalDistance(route)
+  };
+};
+
+const haversineDistance = (
+  a: { latitude: number; longitude: number },
+  b: { latitude: number; longitude: number }
+): number => {
+  const R = 6371e3; // Earth radius in meters
+  const φ1 = (a.latitude * Math.PI) / 180;
+  const φ2 = (b.latitude * Math.PI) / 180;
+  const Δφ = ((b.latitude - a.latitude) * Math.PI) / 180;
+  const Δλ = ((b.longitude - a.longitude) * Math.PI) / 180;
+
+  const a1 = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
+          Math.cos(φ1) * Math.cos(φ2) *
+          Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
+  const c = 2 * Math.atan2(Math.sqrt(a1), Math.sqrt(1 - a1));
+
+  return R * c; // Distance in meters
+};
+
+

Canvass Marker Group

+
// components/canvass/CanvassMarkerGroup.tsx
+const CanvassMarkerGroup: React.FC<{
+  locations: CanvassLocation[];
+  visits: CanvassVisit[];
+  onMarkerClick: (location: CanvassLocation) => void;
+}> = ({ locations, visits, onMarkerClick }) => {
+  const getMarkerColor = (location: CanvassLocation) => {
+    const visit = visits.find(v => v.locationId === location.id);
+
+    if (!visit) return '#8c8c8c'; // Unvisited
+
+    switch (visit.outcome) {
+      case 'strong_support': return '#52c41a';
+      case 'leaning_support': return '#95de64';
+      case 'undecided': return '#fadb14';
+      case 'leaning_opposed': return '#ff7a45';
+      case 'opposed': return '#f5222d';
+      case 'no_answer': return '#8c8c8c';
+      case 'not_home': return '#d9d9d9';
+      default: return '#8c8c8c';
+    }
+  };
+
+  return (
+    <>
+      {locations.map(location => (
+        <CircleMarker
+          key={location.id}
+          center={[location.latitude, location.longitude]}
+          radius={10}
+          pathOptions={{
+            color: 'white',
+            weight: 2,
+            fillColor: getMarkerColor(location),
+            fillOpacity: 0.8
+          }}
+          eventHandlers={{
+            click: () => onMarkerClick(location)
+          }}
+        >
+          <Popup>
+            <Text strong>{location.address}</Text>
+            {location.unitNumber && (
+              <>
+                <br />
+                <Text>Unit: {location.unitNumber}</Text>
+              </>
+            )}
+          </Popup>
+        </CircleMarker>
+      ))}
+    </>
+  );
+};
+
+
+

Performance Considerations

+
    +
  1. Zustand Store: Global state prevents prop drilling
  2. +
  3. Debounced GPS: Position tracked every 5 seconds (not every update)
  4. +
  5. Route Recalc: Only recalculates when visits added
  6. +
  7. Marker Clustering: Reduces DOM nodes on dense maps
  8. +
  9. Lazy Drawers: Components mount only when opened
  10. +
+
+

Responsive Design

+
    +
  • Mobile-First: Designed for phones (primary use case)
  • +
  • Touch Gestures: Native Leaflet touch support
  • +
  • Bottom Drawers: Accessible with thumb
  • +
  • Large Touch Targets: All buttons 44px+ minimum
  • +
  • Portrait Orientation: Optimized for vertical screens
  • +
+
+

Accessibility

+
    +
  • GPS Feedback: Audible alerts for position updates (optional)
  • +
  • High Contrast: CARTO Dark mode for low light
  • +
  • Large Text: All UI text 14px minimum
  • +
  • Voice Input: Notes field supports speech-to-text (browser)
  • +
+
+

Troubleshooting

+

Issue: GPS Not Working

+

Causes: +1. HTTPS required (geolocation API restriction) +2. User denied permission +3. GPS unavailable (indoors, bad signal)

+

Solutions: +

const handleGPSError = (error: GeolocationPositionError) => {
+  switch (error.code) {
+    case error.PERMISSION_DENIED:
+      Modal.error({
+        title: 'GPS Permission Required',
+        content: 'Please enable location permissions in your browser settings.'
+      });
+      break;
+    case error.POSITION_UNAVAILABLE:
+      message.warning('GPS unavailable. Try moving outdoors.');
+      break;
+    case error.TIMEOUT:
+      message.warning('GPS timeout. Check your device settings.');
+      break;
+  }
+};
+

+

Issue: Route Not Displaying

+

Causes: +1. No unvisited locations +2. Route calculation error +3. Route toggle off

+

Solutions: +

// Add debug logging
+useEffect(() => {
+  console.log('Route calculation:', {
+    unvisited: locations.filter(l => !visits.some(v => v.locationId === l.id)).length,
+    routeVisible,
+    walkingRoute: walkingRoute?.locations.length
+  });
+}, [locations, visits, routeVisible, walkingRoute]);
+
+// Show message if no unvisited
+if (unvisitedCount === 0) {
+  message.success('All locations visited! Great work!');
+}
+

+

Issue: Session Not Ending

+

Causes: +1. API timeout +2. Pending GPS uploads +3. Network disconnection

+

Solutions: +

const handleEndSession = async () => {
+  try {
+    // Upload any pending GPS points first
+    await uploadPendingGPSPoints();
+
+    // Then end session
+    await api.post(`/api/map/canvass/sessions/${activeSession.id}/end`, {}, {
+      timeout: 10000
+    });
+
+    message.success('Session ended');
+    setActiveSession(null);
+
+  } catch (error: any) {
+    if (error.code === 'ECONNABORTED') {
+      // Force local end if server timeout
+      setActiveSession(null);
+      message.warning('Session ended locally (server unreachable)');
+    } else {
+      message.error('Failed to end session');
+    }
+  }
+};
+

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/frontend/pages/volunteer/volunteer-shifts-page/index.html b/mkdocs/site/v2/frontend/pages/volunteer/volunteer-shifts-page/index.html new file mode 100644 index 00000000..825d3a21 --- /dev/null +++ b/mkdocs/site/v2/frontend/pages/volunteer/volunteer-shifts-page/index.html @@ -0,0 +1,5938 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Volunteer Shifts - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Volunteer Shifts Page

+

Overview

+

File Path: admin/src/pages/volunteer/VolunteerShiftsPage.tsx (312 lines)

+

Route: /volunteer/assignments

+

Role Requirements: Authenticated users (USER or TEMP role)

+

Purpose: Volunteer-facing shift management showing upcoming shifts and personal signups with signup/cancel functionality.

+

Key Features:

+
    +
  • Segmented tabs: "Upcoming Shifts" / "My Signups"
  • +
  • Responsive shift cards grid (xs=1, sm=2 columns)
  • +
  • Progress bar showing volunteer capacity
  • +
  • Signup confirmation modal
  • +
  • Cancel signup modal (danger button)
  • +
  • "Signed Up" badge + cancel link on signed-up shifts
  • +
  • Empty states for no shifts/signups
  • +
  • Dark theme (VolunteerLayout)
  • +
+

Layout: Uses VolunteerLayout with top navigation

+
+

Features

+

1. Segmented Tabs

+
<Segmented
+  value={activeTab}
+  onChange={setActiveTab}
+  options={[
+    { label: 'Upcoming Shifts', value: 'upcoming' },
+    { label: 'My Signups', value: 'signups' }
+  ]}
+  size="large"
+  block
+/>
+
+

2. Shift Cards

+

Upcoming Shifts Tab: +- Shows all available shifts +- "Sign Up" button (primary) +- "Signed Up" badge if user already signed up +- "Cancel Signup" link if signed up

+

My Signups Tab: +- Shows only user's signups +- "Cancel Signup" button (danger) +- Shift details emphasized

+

3. Capacity Progress Bar

+
const percentage = (shift.currentSignups / shift.maxVolunteers) * 100;
+const color = percentage < 70 ? 'success' : percentage < 90 ? 'warning' : 'exception';
+
+<Progress percent={percentage} status={color} />
+<Text type="secondary">
+  {shift.currentSignups} of {shift.maxVolunteers} volunteers
+</Text>
+
+

4. Signup Confirmation Modal

+
<Modal
+  title="Confirm Signup"
+  open={signupModalVisible}
+  onOk={handleSignup}
+  onCancel={() => setSignupModalVisible(false)}
+>
+  <Text>Are you sure you want to sign up for:</Text>
+  <div style={{ marginTop: 16, padding: 16, background: '#f5f5f5' }}>
+    <Text strong>{selectedShift?.title}</Text>
+    <br />
+    <Text>{dayjs(selectedShift?.date).format('MMMM D, YYYY')}</Text>
+    <br />
+    <Text>{selectedShift?.startTime} - {selectedShift?.endTime}</Text>
+  </div>
+</Modal>
+
+

5. Cancel Signup Modal

+
<Modal
+  title="Cancel Signup"
+  open={cancelModalVisible}
+  onOk={handleCancel}
+  onCancel={() => setCancelModalVisible(false)}
+  okText="Yes, Cancel Signup"
+  okButtonProps={{ danger: true }}
+>
+  <Text>Are you sure you want to cancel your signup for this shift?</Text>
+  <Alert
+    type="warning"
+    message="This action cannot be undone"
+    style={{ marginTop: 16 }}
+  />
+</Modal>
+
+
+

State Management

+
const [activeTab, setActiveTab] = useState<'upcoming' | 'signups'>('upcoming');
+const [shifts, setShifts] = useState<Shift[]>([]);
+const [mySignups, setMySignups] = useState<Shift[]>([]);
+const [loading, setLoading] = useState(true);
+const [signupModalVisible, setSignupModalVisible] = useState(false);
+const [cancelModalVisible, setCancelModalVisible] = useState(false);
+const [selectedShift, setSelectedShift] = useState<Shift | null>(null);
+
+
+

API Integration

+

Endpoints

+

1. Get Upcoming Shifts

+
GET /api/map/shifts/upcoming
+Authorization: Bearer {token}
+
+

Response: +

[
+  {
+    "id": "cm1abc123",
+    "title": "Weekend Canvass",
+    "date": "2025-02-15",
+    "startTime": "10:00",
+    "endTime": "14:00",
+    "location": "Campaign Office",
+    "maxVolunteers": 10,
+    "currentSignups": 7,
+    "cutId": "cm2cut123",
+    "cutName": "Downtown",
+    "isSignedUp": false
+  }
+]
+

+

2. Get My Signups

+
GET /api/map/shifts/my-signups
+Authorization: Bearer {token}
+
+

3. Sign Up

+
POST /api/map/shifts/:id/signup
+Authorization: Bearer {token}
+
+

Response: +

{
+  "success": true,
+  "signupId": "cm3signup456",
+  "message": "Successfully signed up for shift"
+}
+

+

4. Cancel Signup

+
DELETE /api/map/shifts/:id/cancel
+Authorization: Bearer {token}
+
+
+

Code Examples

+

Shift Card (Upcoming Tab)

+
<Card hoverable={!shift.isSignedUp}>
+  {shift.isSignedUp && (
+    <Tag color="green" style={{ position: 'absolute', top: 16, right: 16 }}>
+      Signed Up
+    </Tag>
+  )}
+
+  <Title level={4}>{shift.title}</Title>
+
+  <Space direction="vertical" size={8} style={{ width: '100%', marginBottom: 16 }}>
+    <Text><CalendarOutlined /> {dayjs(shift.date).format('MMMM D, YYYY')}</Text>
+    <Text><ClockCircleOutlined /> {shift.startTime} - {shift.endTime}</Text>
+    <Text><EnvironmentOutlined /> {shift.location}</Text>
+    {shift.cutName && <Tag color="blue">{shift.cutName}</Tag>}
+  </Space>
+
+  <Progress
+    percent={(shift.currentSignups / shift.maxVolunteers) * 100}
+    strokeColor={shift.currentSignups < shift.maxVolunteers ? '#52c41a' : '#f5222d'}
+    showInfo={false}
+    style={{ marginBottom: 16 }}
+  />
+
+  {shift.isSignedUp ? (
+    <Button
+      danger
+      block
+      onClick={() => {
+        setSelectedShift(shift);
+        setCancelModalVisible(true);
+      }}
+    >
+      Cancel Signup
+    </Button>
+  ) : (
+    <Button
+      type="primary"
+      block
+      disabled={shift.currentSignups >= shift.maxVolunteers}
+      onClick={() => {
+        setSelectedShift(shift);
+        setSignupModalVisible(true);
+      }}
+    >
+      {shift.currentSignups >= shift.maxVolunteers ? 'Full' : 'Sign Up'}
+    </Button>
+  )}
+</Card>
+
+

My Signups Tab

+
{activeTab === 'signups' && (
+  <>
+    {mySignups.length === 0 ? (
+      <Empty
+        description="You haven't signed up for any shifts yet"
+        image={Empty.PRESENTED_IMAGE_SIMPLE}
+      />
+    ) : (
+      <Row gutter={[16, 16]}>
+        {mySignups.map(shift => (
+          <Col xs={24} sm={12} key={shift.id}>
+            <Card>
+              <Title level={4}>{shift.title}</Title>
+              {/* Shift details */}
+              <Button
+                danger
+                block
+                onClick={() => handleCancelClick(shift)}
+              >
+                Cancel Signup
+              </Button>
+            </Card>
+          </Col>
+        ))}
+      </Row>
+    )}
+  </>
+)}
+
+
+

Performance Considerations

+
    +
  1. Parallel Fetches: Upcoming shifts and signups fetched simultaneously
  2. +
  3. Optimistic Updates: Signup/cancel updates UI immediately
  4. +
  5. Tab State: No refetch when switching tabs (cached)
  6. +
  7. Debounced Modals: Prevent double-submission with loading state
  8. +
+
+

Responsive Design

+
    +
  • Mobile: Single column cards (xs=24)
  • +
  • Tablet: Two column grid (sm=12)
  • +
  • Desktop: Two column grid maintained (not 3+ for readability)
  • +
  • Segmented Tabs: Full-width on mobile, auto-width on desktop
  • +
+
+

Accessibility

+
    +
  • Tab Navigation: Segmented component keyboard accessible
  • +
  • Button Labels: Clear action labels ("Sign Up", "Cancel Signup")
  • +
  • Modal Focus: Auto-focus on OK button
  • +
  • Screen Reader: Empty states announce "No shifts available"
  • +
+
+

Troubleshooting

+

Issue: Signed Up Badge Not Showing

+

Cause: isSignedUp field not populated by API

+

Solution: +

// Backend: Include isSignedUp in shift query
+const shifts = await prisma.shift.findMany({
+  where: { date: { gte: new Date() } },
+  include: {
+    signups: {
+      where: { userId: req.user!.id },
+      select: { id: true }
+    }
+  }
+});
+
+// Map to include isSignedUp
+return shifts.map(shift => ({
+  ...shift,
+  isSignedUp: shift.signups.length > 0
+}));
+

+

Issue: Cancel Not Refreshing List

+

Solution: +

const handleCancel = async () => {
+  try {
+    await api.delete(`/api/map/shifts/${selectedShift.id}/cancel`);
+    message.success('Signup cancelled');
+
+    // Refresh both lists
+    const [upcomingRes, signupsRes] = await Promise.all([
+      api.get('/api/map/shifts/upcoming'),
+      api.get('/api/map/shifts/my-signups')
+    ]);
+
+    setShifts(upcomingRes.data);
+    setMySignups(signupsRes.data);
+
+  } catch (error) {
+    message.error('Failed to cancel signup');
+  } finally {
+    setCancelModalVisible(false);
+  }
+};
+

+
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/getting-started/index.html b/mkdocs/site/v2/getting-started/index.html new file mode 100644 index 00000000..f30bab91 --- /dev/null +++ b/mkdocs/site/v2/getting-started/index.html @@ -0,0 +1,4857 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Getting Started with Changemaker Lite V2 - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Getting Started with Changemaker Lite V2

+

Welcome to Changemaker Lite V2! This guide will help you get up and running quickly with your self-hosted political campaign platform.

+

What is Changemaker Lite V2?

+

Changemaker Lite V2 is a complete rebuild of the platform with a modern TypeScript stack, offering:

+
    +
  • Email Advocacy Campaigns: Target elected representatives with automated email campaigns
  • +
  • Geographic Mapping: Manage locations, cuts (territories), and canvassing operations
  • +
  • Volunteer Management: Schedule shifts, track canvassing visits with GPS
  • +
  • Landing Page Builder: Create public-facing pages with GrapesJS editor
  • +
  • Newsletter Integration: Sync with Listmonk for email marketing
  • +
  • Media Library: Manage video content with public gallery
  • +
  • Comprehensive Monitoring: Prometheus + Grafana observability stack
  • +
+

Prerequisites

+

Before you begin, ensure you have:

+
    +
  • Linux server or macOS with Docker installed
  • +
  • Docker 20.10+ and Docker Compose 2.0+
  • +
  • 4GB RAM minimum (8GB recommended for monitoring stack)
  • +
  • 20GB disk space (more for media uploads)
  • +
  • Root or sudo access
  • +
  • Basic command line familiarity
  • +
+ +
    +
  • Domain name with DNS control (for production deployment)
  • +
  • SMTP server for email sending (or use MailHog for testing)
  • +
  • S3-compatible storage for backups (Backblaze B2, AWS S3, etc.)
  • +
+

Quick Start Options

+

Choose your path based on your needs:

+

Option 1: Quick Start (5 Minutes)

+

Get the platform running locally for evaluation and testing.

+

→ Quick Start Guide

+

Option 2: Full Installation (30 Minutes)

+

Set up for production with custom configuration, monitoring, and backups.

+

→ Full Installation Guide

+

Option 3: Local Development (45 Minutes)

+

Set up a complete development environment with hot reload and debugging.

+

→ Development Setup

+

Architecture Overview

+

Changemaker Lite V2 uses a microservices architecture:

+
graph LR
+    User[User Browser] --> Nginx[Nginx<br/>Reverse Proxy]
+    Nginx --> Admin[Admin GUI<br/>React]
+    Nginx --> ExpressAPI[Express API<br/>Main Features]
+    Nginx --> FastifyAPI[Fastify API<br/>Media Library]
+    ExpressAPI --> DB[(PostgreSQL)]
+    FastifyAPI --> DB
+    ExpressAPI --> Redis[(Redis)]
+    FastifyAPI --> Redis
+

Key Components:

+
    +
  • Nginx: Routes requests to appropriate services based on subdomain
  • +
  • Admin GUI: React application (Vite + Ant Design) for platform management
  • +
  • Express API: Main backend with 14 feature modules (Prisma ORM)
  • +
  • Fastify API: Media library microservice (Drizzle ORM)
  • +
  • PostgreSQL: Primary database (Prisma + Drizzle schemas)
  • +
  • Redis: Caching, rate limiting, job queue backend
  • +
+

Learn more about the architecture →

+

What's Next?

+

After installation, you'll want to:

+
    +
  1. First Login - Access the admin interface and change default credentials
  2. +
  3. Environment Configuration - Customize your .env file for your needs
  4. +
  5. Docker Management - Learn to start, stop, and manage services
  6. +
  7. Admin Guide - Platform administration workflows
  8. +
+

Common Installation Issues

+

If you encounter problems during setup, check our troubleshooting guides:

+ +

Getting Help

+
    +
  • Documentation Search: Use the search bar above to find specific topics
  • +
  • FAQ: Check the Frequently Asked Questions
  • +
  • Issue Tracker: Report bugs or request features on GitHub
  • +
+

Feature Highlights

+

Influence Module

+

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

+

Map Module

+

Coordinate field operations with: +- Multi-provider geocoding (6 services) +- Territory management (cuts) +- GPS-tracked canvassing +- Printable walk sheets with QR codes

+

Landing Pages

+

Build custom public pages with: +- GrapesJS drag-and-drop editor +- MkDocs export for static sites +- Mobile-responsive templates

+

Monitoring

+

Keep your platform healthy with: +- Real-time metrics dashboards +- Custom alerts +- Service health monitoring +- Data quality tracking

+

Explore all features →

+
+

Ready to get started? Choose your installation path above!

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/getting-started/quick-start/index.html b/mkdocs/site/v2/getting-started/quick-start/index.html new file mode 100644 index 00000000..0ee51bb9 --- /dev/null +++ b/mkdocs/site/v2/getting-started/quick-start/index.html @@ -0,0 +1,5201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Quick Start - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Quick Start Guide

+

Get Changemaker Lite V2 running in 5 minutes with this streamlined guide.

+
+

For Evaluation Only

+

This quick start uses default credentials and minimal configuration. Do not use in production without following the Full Installation Guide and changing all default passwords.

+
+

Step 1: Clone the Repository

+
git clone <repo-url> changemaker.lite
+cd changemaker.lite
+git checkout v2
+
+
+

Tip

+

If you're evaluating locally, you can skip the domain configuration and use localhost URLs.

+
+

Step 2: Create Environment File

+
cp .env.example .env
+
+

Edit .env and set the minimum required variables:

+
# Database
+V2_POSTGRES_PASSWORD=your_secure_password_here
+
+# Redis
+REDIS_PASSWORD=another_secure_password
+
+# JWT Secrets (generate with: openssl rand -hex 32)
+JWT_ACCESS_SECRET=<your-access-secret>
+JWT_REFRESH_SECRET=<your-refresh-secret>
+
+# Encryption Key (must differ from JWT secrets)
+ENCRYPTION_KEY=<your-encryption-key>
+
+# Email (use test mode for evaluation)
+EMAIL_TEST_MODE=true
+
+
+

Generate Secure Secrets

+
echo "JWT_ACCESS_SECRET=$(openssl rand -hex 32)"
+echo "JWT_REFRESH_SECRET=$(openssl rand -hex 32)"
+echo "ENCRYPTION_KEY=$(openssl rand -hex 32)"
+
+
+

Step 3: Start Core Services

+
# Start database and cache
+docker compose up -d v2-postgres redis
+
+# Wait for database to be ready (about 10 seconds)
+sleep 10
+
+# Start API and admin
+docker compose up -d api admin nginx
+
+

Step 4: Run Database Migrations

+
# Run Prisma migrations
+docker compose exec api npx prisma migrate deploy
+
+# Seed initial data (creates admin user)
+docker compose exec api npx prisma db seed
+
+

This creates: +- Admin user: admin@example.com / Admin123! +- Site settings: Default configuration +- Page blocks: Landing page components

+

Step 5: Access the Platform

+

Open your browser and navigate to:

+ +

Login with: +- Email: admin@example.com +- Password: Admin123!

+
+

Change Default Credentials

+

Immediately change the default admin password:

+
    +
  1. Navigate to Settings in the sidebar
  2. +
  3. Click your profile
  4. +
  5. Change password to something secure (12+ chars, mixed case, numbers)
  6. +
+
+

Step 6: Verify Installation

+

Check that all services are running:

+
docker compose ps
+
+

You should see: +- v2-postgres - Database (port 5433) +- redis-changemaker - Cache (port 6379) +- api - Express API (port 4000) +- admin - React admin (port 3000) +- nginx - Reverse proxy (port 80)

+

Test the API:

+
curl http://localhost:4000/health
+
+

Expected response: +

{
+  "status": "healthy",
+  "timestamp": "2026-02-11T18:00:00.000Z"
+}
+

+

What's Next?

+

Now that you have Changemaker Lite running:

+
    +
  1. First Login - Tour the admin interface
  2. +
  3. Environment Configuration - Customize your setup
  4. +
  5. Create Your First Campaign - Run an advocacy campaign
  6. +
  7. Import Locations - Set up your map
  8. +
+

Optional: Start Additional Services

+

Email Testing (MailHog)

+

Capture emails in development without sending real messages:

+
docker compose up -d mailhog
+
+

Access at: http://localhost:8025

+

Data Browser (NocoDB)

+

Read-only database browser:

+
docker compose up -d nocodb-v2
+
+

Access at: http://localhost:8091

+

Newsletter Platform (Listmonk)

+

Email marketing integration:

+
docker compose up -d listmonk-postgres listmonk listmonk-init
+
+

Access at: http://localhost:9001

+

Monitoring Stack

+

Prometheus + Grafana + Alertmanager:

+
docker compose --profile monitoring up -d
+
+

Access: +- Grafana: http://localhost:3001 (admin/admin) +- Prometheus: http://localhost:9090 +- Alertmanager: http://localhost:9093

+

Common Issues

+

Port Already in Use

+

If you see errors like port is already allocated:

+
# Check what's using the port
+sudo lsof -i :3000
+
+# Stop the conflicting service or change ports in .env
+
+

Database Connection Failed

+
# Check if PostgreSQL is running
+docker compose ps v2-postgres
+
+# View logs
+docker compose logs v2-postgres
+
+# Restart database
+docker compose restart v2-postgres
+
+

API Won't Start

+
# View API logs
+docker compose logs api
+
+# Common fix: rebuild the container
+docker compose build api
+docker compose up -d api
+
+

See full troubleshooting guide →

+

Stopping Services

+
# Stop all services
+docker compose down
+
+# Stop and remove volumes (WARNING: deletes all data)
+docker compose down -v
+
+

Next Steps for Production

+

This quick start is for evaluation only. Before production deployment:

+
    +
  1. Full Installation Guide - Production-ready setup
  2. +
  3. Security Checklist - Harden your installation
  4. +
  5. Backup Strategy - Protect your data
  6. +
  7. Tunneling Setup - Public access via Pangolin
  8. +
  9. Monitoring Configuration - Production observability
  10. +
+
+

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.

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/index.html b/mkdocs/site/v2/index.html new file mode 100644 index 00000000..26a4e3c0 --- /dev/null +++ b/mkdocs/site/v2/index.html @@ -0,0 +1,5134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Changemaker Lite V2 Documentation - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Changemaker Lite V2 Documentation

+
+

V2 is Production Ready

+

Changemaker Lite V2 is a complete architectural rebuild now running in production. This documentation covers the modern TypeScript stack with dual API architecture, React admin interface, and comprehensive feature modules.

+
+

Overview

+

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.

+

Key Highlights

+
    +
  • Dual API Architecture: Express.js (main features) + Fastify (media library)
  • +
  • Modern Stack: TypeScript, Prisma + Drizzle ORM, PostgreSQL 16, Redis
  • +
  • React Admin: Vite + Ant Design + Zustand state management
  • +
  • JWT Authentication: Secure role-based access control with refresh tokens
  • +
  • Comprehensive Features: 14 backend modules, 42 frontend pages, 8 critical services
  • +
  • Production Monitoring: Prometheus, Grafana, Alertmanager with 12 custom metrics
  • +
  • Security Audited: 13 findings addressed (Feb 2026)
  • +
+

Architecture Diagram

+
graph TB
+    User[User Browser]
+    Nginx[Nginx Reverse Proxy]
+    Admin[React Admin GUI<br/>port 3000]
+    ExpressAPI[Express API<br/>port 4000<br/>Prisma ORM]
+    FastifyAPI[Fastify Media API<br/>port 4100<br/>Drizzle ORM]
+    Postgres[(PostgreSQL 16)]
+    Redis[(Redis)]
+    BullMQ[BullMQ Queues]
+    Listmonk[Listmonk<br/>Newsletter]
+    Prometheus[Prometheus<br/>Monitoring]
+
+    User --> Nginx
+    Nginx --> |app.cmlite.org| Admin
+    Nginx --> |api.cmlite.org| ExpressAPI
+    Nginx --> |media.cmlite.org| FastifyAPI
+
+    Admin --> ExpressAPI
+    Admin --> FastifyAPI
+
+    ExpressAPI --> Postgres
+    ExpressAPI --> Redis
+    ExpressAPI --> BullMQ
+
+    FastifyAPI --> Postgres
+    FastifyAPI --> Redis
+
+    BullMQ --> Redis
+    ExpressAPI --> Listmonk
+    ExpressAPI --> Prometheus
+    FastifyAPI --> Prometheus
+

Feature Modules

+

Influence Module

+

Email advocacy campaigns targeting elected representatives with:

+
    +
  • Campaign management with rich text editor
  • +
  • Canadian representative lookup (postal code → MP/MPP/councillor)
  • +
  • Public campaign pages with email submission
  • +
  • Response wall with upvoting and moderation
  • +
  • BullMQ async email queue with SMTP delivery
  • +
  • Email verification and tracking
  • +
+

Learn more →

+

Map Module

+

Geographic mapping and volunteer canvassing with:

+
    +
  • Location CRUD with multi-provider geocoding (6 providers)
  • +
  • Cut (polygon) management with spatial queries
  • +
  • Volunteer shift scheduling and signup system
  • +
  • Full canvassing system with GPS tracking and visit recording
  • +
  • Walk sheets and QR codes for printable forms
  • +
  • NAR 2025 data import (Canadian electoral data)
  • +
+

Learn more →

+

Landing Pages

+

GrapesJS-based page builder with:

+
    +
  • WYSIWYG editor with custom blocks
  • +
  • Public rendering at /p/:slug
  • +
  • MkDocs export for static documentation
  • +
  • Mobile-responsive templates
  • +
+

Learn more →

+

Email Templates

+

Template management system with:

+
    +
  • GrapesJS email editor
  • +
  • Variable substitution
  • +
  • Integration with campaign emails
  • +
  • Version control
  • +
+

Learn more →

+

Media Manager

+

Video library management with:

+
    +
  • Dual API architecture (Fastify microservice)
  • +
  • Shared media public gallery
  • +
  • Reaction system (6 standard emojis)
  • +
  • Job queue monitoring
  • +
  • Bulk operations
  • +
+

Learn more →

+

Newsletter Integration

+

Listmonk sync with:

+
    +
  • Participant/location/user syncing
  • +
  • Subscriber list management
  • +
  • Health monitoring
  • +
  • API integration
  • +
+

Learn more →

+

Observability

+

Comprehensive monitoring with:

+
    +
  • Prometheus metrics (12 custom metrics)
  • +
  • Grafana dashboards (3 pre-configured)
  • +
  • Alertmanager notifications
  • +
  • Service health checks
  • +
  • Data quality dashboard
  • +
+

Learn more →

+ +

Getting Started

+ +

Architecture

+ +

Development

+ +

Deployment

+ +

API Reference

+ +

User Guides

+ +

Technology Stack

+

Backend

+
    +
  • Express.js - Main API server (TypeScript, port 4000)
  • +
  • Fastify - Media API microservice (TypeScript, port 4100)
  • +
  • Prisma ORM - Database modeling and migrations (27+ models)
  • +
  • Drizzle ORM - Media tables (lightweight schema-first)
  • +
  • PostgreSQL 16 - Primary database
  • +
  • Redis - Caching, sessions, rate limiting, BullMQ backend
  • +
  • BullMQ - Job queues (email sending, geocoding)
  • +
  • Winston - Structured logging
  • +
+

Frontend

+
    +
  • React 19 - UI library
  • +
  • Vite - Build tool and dev server
  • +
  • Ant Design 5 - Component library
  • +
  • Zustand - State management
  • +
  • React Router - Client-side routing
  • +
  • Axios - HTTP client with interceptors
  • +
  • Leaflet - Interactive maps
  • +
  • GrapesJS - WYSIWYG page builder
  • +
+

Infrastructure

+
    +
  • Docker Compose - Service orchestration (20+ containers)
  • +
  • Nginx - Reverse proxy with subdomain routing
  • +
  • Prometheus - Metrics collection
  • +
  • Grafana - Metrics visualization
  • +
  • Alertmanager - Alert routing
  • +
  • Listmonk - Newsletter platform
  • +
  • MailHog - Email testing (development)
  • +
+

Project Status

+

Completed Phases (1-14)

+

Phase 1: Foundation - Database, auth, basic API +✅ Phase 2: Auth + User Management - JWT, RBAC, user CRUD +✅ Phase 3: Admin GUI Foundation - React admin, routing, layouts +✅ Phase 4: Influence (Campaigns) - Campaign CRUD, admin pages +✅ Phase 5: Representatives + Postal Codes - API integration, caching +✅ Phase 6: Email Sending - BullMQ queue, SMTP, tracking +✅ Phase 7: Response Wall + Public Campaign View - Public pages, moderation +✅ Phase 8: Map (Locations) - Geocoding, CSV import, map rendering +✅ Phase 9: Map (Shifts) - Shift management, public signup +✅ Phase 10: Walk Sheets & QR Codes - Printable forms, QR generation +✅ Phase 11: Newsletter Integration - Listmonk sync +✅ Phase 12: Landing Page Builder - GrapesJS editor, MkDocs export +✅ Phase 13: Volunteer Canvassing - GPS tracking, visit recording +✅ Phase 14: Monitoring + DevOps - Prometheus, Grafana, backup

+

Additional Features

+

Security Audit - 13 findings addressed (Feb 2026) +✅ NAR 2025 Import - Canadian electoral data support +✅ Media Manager - Dual API video library +✅ Email Templates - Template management system +✅ Data Quality Dashboard - Geocoding metrics +✅ Observability Dashboard - Monitoring integration

+

Current Phase

+

🚧 Phase 15: Testing + Polish - Comprehensive testing, documentation

+

Migration from V1

+

If you're migrating from Changemaker Lite V1 (NocoDB-based architecture), see the Migration Guide for:

+
    +
  • Breaking changes (NocoDB → Prisma, sessions → JWT)
  • +
  • Data migration strategy
  • +
  • API endpoint mapping
  • +
  • Feature parity comparison
  • +
+

Contributing

+

Changemaker Lite is open source. We welcome contributions! See the Contributing Guide for:

+
    +
  • Development setup
  • +
  • Code standards
  • +
  • Pull request process
  • +
  • Roadmap (Phase 15+)
  • +
+

Support

+ +
+

Last Updated: February 2026 | Version: 2.0.0 | Status: Production Ready

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/migration/api-changes/index.html b/mkdocs/site/v2/migration/api-changes/index.html new file mode 100644 index 00000000..ac3c7f63 --- /dev/null +++ b/mkdocs/site/v2/migration/api-changes/index.html @@ -0,0 +1,6943 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + API Changes - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

API Endpoint Changes

+

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.

+

Overview

+

V2 API represents a complete redesign with:

+
    +
  • RESTful conventions (proper HTTP methods)
  • +
  • Unified namespace (single API at /api/*)
  • +
  • JWT authentication (Bearer tokens instead of sessions)
  • +
  • Zod validation (type-safe request validation)
  • +
  • Standardized responses ({ success, data, pagination } structure)
  • +
+
+

Migration Strategy

+

Update frontend API calls incrementally, starting with authentication (foundational), then module by module (campaigns, locations, shifts, etc.).

+
+

Authentication Changes

+

V1 Authentication (Session Cookies)

+

V1 Login: +

// POST /auth/login
+fetch('http://localhost:3333/auth/login', {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json' },
+  credentials: 'include', // Send/receive cookies
+  body: JSON.stringify({
+    email: 'admin@example.com',
+    password: 'password123'
+  })
+});
+
+// Response: 302 Redirect to /dashboard
+// Session cookie set automatically
+
+// Subsequent requests
+fetch('http://localhost:3333/campaigns', {
+  credentials: 'include' // Sends session cookie
+});
+

+

V2 Authentication (JWT Bearer Tokens)

+

V2 Login: +

// POST /api/auth/login
+const response = await fetch('http://localhost:4000/api/auth/login', {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json' },
+  body: JSON.stringify({
+    email: 'admin@example.com',
+    password: 'Admin123!'
+  })
+});
+
+const data = await response.json();
+
+// Response:
+// {
+//   "success": true,
+//   "data": {
+//     "user": {
+//       "id": "clx1a2b3c4d5e6f7g8h9i",
+//       "email": "admin@example.com",
+//       "name": "Admin User",
+//       "role": "SUPER_ADMIN"
+//     },
+//     "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+//     "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+//   }
+// }
+
+// Store tokens (localStorage, sessionStorage, or memory)
+localStorage.setItem('accessToken', data.data.accessToken);
+localStorage.setItem('refreshToken', data.data.refreshToken);
+
+// Subsequent requests
+fetch('http://localhost:4000/api/influence/campaigns', {
+  headers: {
+    'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
+  }
+});
+

+

V2 Token Refresh: +

// POST /api/auth/refresh
+const response = await fetch('http://localhost:4000/api/auth/refresh', {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json' },
+  body: JSON.stringify({
+    refreshToken: localStorage.getItem('refreshToken')
+  })
+});
+
+const data = await response.json();
+
+// Response:
+// {
+//   "success": true,
+//   "data": {
+//     "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+//     "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." // New token (rotation)
+//   }
+// }
+
+// Update stored tokens
+localStorage.setItem('accessToken', data.data.accessToken);
+localStorage.setItem('refreshToken', data.data.refreshToken);
+

+

Authentication Endpoint Mapping

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
V1 EndpointV2 EndpointMethodChanges
/auth/login/api/auth/loginPOSTReturns JWT tokens instead of setting cookie
/auth/logout/api/auth/logoutPOSTRequires refreshToken in body
/auth/register/api/auth/registerPOSTAlways creates USER role (no role in request)
/auth/me/api/auth/meGETReturns 401 if invalid (not 404)
-/api/auth/refreshPOSTNew: refresh token rotation
+

Influence Module API

+

Campaigns

+

V1 Campaign Endpoints

+
// List campaigns
+GET /campaigns
+Query: ?page=1
+
+// View campaign
+GET /campaigns/:id
+
+// Create campaign (admin)
+POST /campaigns/create
+Body: { Title, Description, Slug, IsActive }
+
+// Update campaign (admin)
+POST /campaigns/:id/edit
+Body: { Title, Description, Slug, IsActive }
+
+// Delete campaign (admin)
+POST /campaigns/:id/delete
+
+

V2 Campaign Endpoints

+
// List campaigns
+GET /api/influence/campaigns
+Query: ?page=1&limit=20&search=query&active=true&highlighted=false
+Auth: Optional (public returns only active campaigns)
+
+// Get campaign by ID
+GET /api/influence/campaigns/:id
+Auth: Required (admin)
+
+// Get campaign by slug (public)
+GET /api/influence/campaigns/public/:slug
+Auth: None
+
+// Create campaign
+POST /api/influence/campaigns
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+Body: {
+  "title": "Save the Trees",
+  "description": "Campaign description",
+  "slug": "save-the-trees",
+  "active": true,
+  "highlighted": false,
+  "targetLevel": "federal",
+  "targetPosition": "MP",
+  "responseWallEnabled": true
+}
+
+// Update campaign
+PUT /api/influence/campaigns/:id
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+Body: { title, description, ... } // Partial update
+
+// Delete campaign
+DELETE /api/influence/campaigns/:id
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+
+// Toggle active status
+PATCH /api/influence/campaigns/:id/toggle-active
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+
+// Toggle highlighted status
+PATCH /api/influence/campaigns/:id/toggle-highlighted
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+
+

Campaign Response Format Changes

+

V1 Response: +

{
+  "list": [
+    {
+      "Id": 1,
+      "Title": "Save the Trees",
+      "Description": "Campaign description",
+      "Slug": "save-the-trees",
+      "IsActive": true,
+      "Created": "2024-01-15T10:30:00Z"
+    }
+  ],
+  "pageInfo": {
+    "totalRows": 100,
+    "page": 1,
+    "pageSize": 20
+  }
+}
+

+

V2 Response: +

{
+  "success": true,
+  "data": [
+    {
+      "id": "clx1a2b3c4d5e6f7g8h9i",
+      "title": "Save the Trees",
+      "description": "Campaign description",
+      "slug": "save-the-trees",
+      "active": true,
+      "highlighted": false,
+      "targetLevel": "federal",
+      "targetPosition": "MP",
+      "responseWallEnabled": true,
+      "createdAt": "2024-01-15T10:30:00.000Z",
+      "updatedAt": "2024-01-15T10:30:00.000Z",
+      "createdBy": {
+        "id": "clx1a2b3c4d5e6f7g8h9i",
+        "name": "Admin User",
+        "email": "admin@example.com"
+      }
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 20,
+    "total": 100,
+    "totalPages": 5
+  }
+}
+

+

Representatives

+

V1 Representative Endpoints

+
// Lookup representatives by postal code
+POST /representatives/lookup
+Body: { postalCode: "M5V 1A1" }
+
+// List cached representatives (admin)
+GET /admin/representatives
+
+

V2 Representative Endpoints

+
// Lookup representatives (public)
+POST /api/influence/representatives/lookup
+Auth: None
+Body: { "postalCode": "M5V1A1" }
+Response: {
+  "success": true,
+  "data": [
+    {
+      "name": "John Doe",
+      "email": "john.doe@parl.gc.ca",
+      "district": "Toronto Centre",
+      "party": "Liberal",
+      "level": "federal",
+      "photoUrl": "https://..."
+    }
+  ]
+}
+
+// List cached representatives (admin)
+GET /api/influence/representatives
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+Query: ?page=1&limit=20&level=federal&party=Liberal&search=John
+
+// Get representative stats (admin)
+GET /api/influence/representatives/stats
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+Response: {
+  "success": true,
+  "data": {
+    "total": 338,
+    "byLevel": { "federal": 338, "provincial": 124 },
+    "byParty": { "Liberal": 159, "Conservative": 119, "NDP": 25 }
+  }
+}
+
+// Get representative by ID (admin)
+GET /api/influence/representatives/:id
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+
+// Delete representative (admin)
+DELETE /api/influence/representatives/:id
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+
+// Health check
+GET /api/influence/representatives/health
+Auth: None
+
+

Campaign Emails

+

V1 Email Endpoints

+
// Send campaign email
+POST /campaigns/:id/send-email
+Body: { senderName, senderEmail, postalCode }
+
+

V2 Email Endpoints

+
// Send campaign email (public)
+POST /api/influence/campaign-emails/send-email
+Auth: None
+Rate Limit: 30 requests/hour per IP
+Body: {
+  "campaignId": "clx1a2b3c4d5e6f7g8h9i",
+  "postalCode": "M5V1A1",
+  "senderName": "Jane Doe",
+  "senderEmail": "jane@example.com",
+  "customMessage": "Optional custom message"
+}
+
+// Track mailto clicks (public)
+GET /api/influence/campaign-emails/track-mailto/:emailId
+Auth: None
+
+// List campaign emails (admin)
+GET /api/influence/campaign-emails
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+Query: ?campaignId=xxx&page=1&limit=20&sortBy=createdAt&sortOrder=desc
+
+// Get campaign email stats (admin)
+GET /api/influence/campaign-emails/stats/:campaignId
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+Response: {
+  "success": true,
+  "data": {
+    "totalEmails": 1234,
+    "queuedEmails": 5,
+    "sentEmails": 1200,
+    "failedEmails": 29,
+    "mailtoClicks": 340
+  }
+}
+
+

Email Queue

+

V2 Email Queue Endpoints (New)

+
// Get queue stats (admin)
+GET /api/influence/email-queue/stats
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+Response: {
+  "success": true,
+  "data": {
+    "waiting": 10,
+    "active": 2,
+    "completed": 5000,
+    "failed": 15,
+    "delayed": 0,
+    "paused": false
+  }
+}
+
+// Pause queue (admin)
+POST /api/influence/email-queue/pause
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+
+// Resume queue (admin)
+POST /api/influence/email-queue/resume
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+
+// Clean completed jobs (admin)
+POST /api/influence/email-queue/clean
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+Query: ?grace=3600 (seconds)
+
+// Retry failed jobs (admin)
+POST /api/influence/email-queue/retry-failed
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+
+

Response Wall

+

V1 Response Endpoints

+
// Submit response
+POST /responses/submit
+Body: { campaignId, name, email, message }
+
+// List responses
+GET /responses/:campaignId
+
+

V2 Response Endpoints

+
// Submit response (public)
+POST /api/influence/responses/submit
+Auth: None
+Body: {
+  "campaignId": "clx1a2b3c4d5e6f7g8h9i",
+  "name": "Jane Doe",
+  "email": "jane@example.com",
+  "message": "I support this campaign!",
+  "ipAddress": "192.168.1.1" // Auto-captured by server
+}
+// Sends verification email
+
+// Verify response email
+GET /api/influence/responses/verify/:token
+Auth: None
+
+// List responses (public)
+GET /api/influence/responses/campaign/:campaignId
+Auth: None
+Query: ?page=1&limit=20&sortBy=upvotes&sortOrder=desc
+Response: Only returns APPROVED responses
+
+// Upvote response (public)
+POST /api/influence/responses/:id/upvote
+Auth: Optional (tracks by IP + userId if logged in)
+Body: { "ipAddress": "192.168.1.1" }
+
+// List responses (admin)
+GET /api/influence/responses
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+Query: ?page=1&limit=20&campaignId=xxx&status=PENDING&sortBy=createdAt&sortOrder=desc
+
+// Get response detail (admin)
+GET /api/influence/responses/:id
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+
+// Approve response (admin)
+PATCH /api/influence/responses/:id/approve
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+
+// Reject response (admin)
+PATCH /api/influence/responses/:id/reject
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+
+// Delete response (admin)
+DELETE /api/influence/responses/:id
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+
+

Map Module API

+

Locations

+

V1 Location Endpoints

+
// List locations
+GET /locations
+Query: ?page=1
+
+// Create location (admin)
+POST /locations/create
+Body: { Address, Latitude, Longitude, SupportLevel, Notes }
+
+// Update location (admin)
+POST /locations/:id/edit
+Body: { Address, Latitude, Longitude, SupportLevel, Notes }
+
+// Delete location (admin)
+POST /locations/:id/delete
+
+

V2 Location Endpoints

+
// List locations (admin)
+GET /api/map/locations
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Query: ?page=1&limit=20&search=query&supportLevel=SUPPORT&cutId=xxx&geocoded=true
+
+// List locations (public map)
+GET /api/map/locations/public
+Auth: None
+Query: ?bounds=minLat,minLng,maxLat,maxLng (returns only geocoded locations)
+
+// Get location by ID (admin)
+GET /api/map/locations/:id
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+
+// Create location (admin)
+POST /api/map/locations
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Body: {
+  "address": "123 Main St",
+  "city": "Toronto",
+  "province": "ON",
+  "postalCode": "M5V1A1",
+  "country": "Canada",
+  "latitude": 43.6532,
+  "longitude": -79.3832,
+  "supportLevel": "SUPPORT",
+  "notes": "Spoke with resident",
+  "contactName": "John Doe",
+  "contactPhone": "416-555-1234",
+  "contactEmail": "john@example.com",
+  "cutId": "clx1a2b3c4d5e6f7g8h9i"
+}
+
+// Update location (admin)
+PUT /api/map/locations/:id
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Body: { address, city, ... } // Partial update
+
+// Delete location (admin)
+DELETE /api/map/locations/:id
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+
+// Bulk delete locations (admin)
+POST /api/map/locations/bulk-delete
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Body: { "ids": ["id1", "id2", "id3"] }
+
+// Export locations CSV (admin)
+GET /api/map/locations/export
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Query: ?supportLevel=SUPPORT&cutId=xxx
+
+// Import locations CSV (admin)
+POST /api/map/locations/import
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Content-Type: multipart/form-data
+Body: FormData with CSV file
+
+// Geocode location (admin)
+POST /api/map/locations/:id/geocode
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Query: ?provider=nominatim (optional)
+
+// Bulk geocode (admin)
+POST /api/map/locations/bulk-geocode
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Query: ?limit=100&provider=nominatim
+
+// Reverse geocode (admin)
+POST /api/map/locations/reverse-geocode
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Body: { "latitude": 43.6532, "longitude": -79.3832 }
+
+// Get location stats (admin)
+GET /api/map/locations/stats
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Response: {
+  "success": true,
+  "data": {
+    "total": 10000,
+    "geocoded": 9500,
+    "notGeocoded": 500,
+    "bySupportLevel": {
+      "STRONG_SUPPORT": 1200,
+      "SUPPORT": 3400,
+      "UNDECIDED": 2100,
+      "OPPOSED": 1800,
+      "STRONG_OPPOSED": 800,
+      "UNKNOWN": 700
+    }
+  }
+}
+
+// NAR Import (admin, new in V2)
+GET /api/map/locations/nar/datasets
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Response: List of available NAR datasets (provinces)
+
+POST /api/map/locations/nar/import
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Body: {
+  "province": "24",
+  "cityFilter": "Toronto",
+  "postalCodeFilter": "M5V",
+  "cutId": "clx1a2b3c4d5e6f7g8h9i",
+  "residentialOnly": true
+}
+
+

Cuts (Territories)

+

V1 Cut Endpoints

+
// List cuts (admin)
+GET /admin/cuts
+
+// Create cut (admin)
+POST /admin/cuts/create
+Body: { Name, GeoJSON }
+
+

V2 Cut Endpoints

+
// List cuts (admin)
+GET /api/map/cuts
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Query: ?page=1&limit=20&search=query
+
+// List cuts (public map)
+GET /api/map/cuts/public
+Auth: None
+Response: Only returns active cuts with GeoJSON
+
+// Get cut by ID (admin)
+GET /api/map/cuts/:id
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+
+// Create cut (admin)
+POST /api/map/cuts
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Body: {
+  "name": "Downtown Toronto",
+  "description": "Downtown canvassing area",
+  "color": "#FF5733",
+  "coordinates": [[[-79.4, 43.6], [-79.3, 43.6], ...]]
+}
+
+// Update cut (admin)
+PUT /api/map/cuts/:id
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Body: { name, description, color, coordinates }
+
+// Delete cut (admin)
+DELETE /api/map/cuts/:id
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+
+// Get locations in cut (admin)
+GET /api/map/cuts/:id/locations
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Query: ?page=1&limit=20
+
+

Shifts

+

V1 Shift Endpoints

+
// List shifts
+GET /shifts
+
+// Create shift (admin)
+POST /shifts/create
+Body: { Name, StartTime, EndTime, Location, Capacity }
+
+// Signup for shift
+POST /shifts/:id/signup
+Body: { name, email, phone }
+
+

V2 Shift Endpoints

+
// List shifts (public)
+GET /api/map/shifts/public
+Auth: None
+Query: ?upcoming=true&startDate=2024-01-01&endDate=2024-12-31
+Response: Only returns future shifts with available capacity
+
+// List shifts (admin)
+GET /api/map/shifts
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Query: ?page=1&limit=20&startDate=2024-01-01&endDate=2024-12-31&cutId=xxx
+
+// Get shift by ID (admin)
+GET /api/map/shifts/:id
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+
+// Create shift (admin)
+POST /api/map/shifts
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Body: {
+  "name": "Downtown Canvassing",
+  "description": "Canvassing shift for downtown area",
+  "startTime": "2024-02-15T09:00:00Z",
+  "endTime": "2024-02-15T12:00:00Z",
+  "location": "Community Center, 123 Main St",
+  "capacity": 20,
+  "requirements": "Comfortable shoes, water bottle",
+  "cutId": "clx1a2b3c4d5e6f7g8h9i"
+}
+
+// Update shift (admin)
+PUT /api/map/shifts/:id
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Body: { name, startTime, ... }
+
+// Delete shift (admin)
+DELETE /api/map/shifts/:id
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+
+// Signup for shift (public)
+POST /api/map/shifts/:id/signup
+Auth: None
+Body: {
+  "name": "Jane Doe",
+  "email": "jane@example.com",
+  "phone": "416-555-1234",
+  "notes": "First time volunteering"
+}
+// Creates TEMP user if email doesn't exist, sends confirmation email
+
+// Cancel signup (public)
+DELETE /api/map/shifts/:shiftId/signups/:userId
+Auth: Optional (user can cancel own signup)
+
+// List signups for shift (admin)
+GET /api/map/shifts/:id/signups
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+
+// Update signup status (admin)
+PATCH /api/map/shifts/:shiftId/signups/:userId
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Body: { "status": "COMPLETED" }
+
+// Email all shift signups (admin)
+POST /api/map/shifts/:id/email-signups
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Body: {
+  "subject": "Shift Reminder",
+  "message": "Don't forget about tomorrow's shift!"
+}
+
+// Get shift stats (admin)
+GET /api/map/shifts/stats
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Response: {
+  "success": true,
+  "data": {
+    "totalShifts": 50,
+    "upcomingShifts": 12,
+    "totalSignups": 234,
+    "signupsByStatus": {
+      "CONFIRMED": 200,
+      "COMPLETED": 30,
+      "CANCELLED": 4
+    }
+  }
+}
+
+

Canvassing (New in V2)

+
// Start canvass session (volunteer)
+POST /api/map/canvass/sessions/start
+Auth: Required (any authenticated user)
+Body: {
+  "shiftId": "clx1a2b3c4d5e6f7g8h9i",
+  "cutId": "clx1a2b3c4d5e6f7g8h9i"
+}
+
+// End canvass session (volunteer)
+POST /api/map/canvass/sessions/end
+Auth: Required (any authenticated user)
+Body: { "sessionId": "clx1a2b3c4d5e6f7g8h9i" }
+
+// Get walking route (volunteer)
+GET /api/map/canvass/routes/:cutId
+Auth: Required (any authenticated user)
+Response: Optimized walking route (nearest-neighbor algorithm)
+
+// Record visit (volunteer)
+POST /api/map/canvass/visits
+Auth: Required (any authenticated user)
+Rate Limit: 30 requests/minute
+Body: {
+  "sessionId": "clx1a2b3c4d5e6f7g8h9i",
+  "locationId": "clx1a2b3c4d5e6f7g8h9i",
+  "outcome": "CONTACT_MADE",
+  "supportLevel": "SUPPORT",
+  "notes": "Very interested in campaign"
+}
+
+// Get canvass dashboard stats (admin)
+GET /api/map/canvass/dashboard/stats
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Response: {
+  "success": true,
+  "data": {
+    "activeSessions": 5,
+    "totalVisitsToday": 234,
+    "totalVisitsWeek": 1420,
+    "avgVisitsPerSession": 47
+  }
+}
+
+// Get activity feed (admin)
+GET /api/map/canvass/dashboard/activity
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Query: ?limit=50
+
+// Get cut progress (admin)
+GET /api/map/canvass/dashboard/cut-progress
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+
+// Get leaderboard (admin)
+GET /api/map/canvass/dashboard/leaderboard
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+Query: ?period=week&limit=10
+
+

GPS Tracking (New in V2)

+
// Start tracking session (volunteer)
+POST /api/map/tracking/sessions/start
+Auth: Required (any authenticated user)
+Body: { "sessionId": "clx1a2b3c4d5e6f7g8h9i" }
+
+// Record GPS point (volunteer)
+POST /api/map/tracking/points
+Auth: Required (any authenticated user)
+Body: {
+  "sessionId": "clx1a2b3c4d5e6f7g8h9i",
+  "latitude": 43.6532,
+  "longitude": -79.3832,
+  "accuracy": 10.5,
+  "altitude": 120.3,
+  "speed": 1.2
+}
+
+// End tracking session (volunteer)
+POST /api/map/tracking/sessions/end
+Auth: Required (any authenticated user)
+Body: { "sessionId": "clx1a2b3c4d5e6f7g8h9i" }
+
+// Get tracking session (admin)
+GET /api/map/tracking/sessions/:id
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+
+// Get tracking points (admin)
+GET /api/map/tracking/sessions/:id/points
+Auth: Required (SUPER_ADMIN, MAP_ADMIN)
+
+

Landing Pages & Email Templates (New in V2)

+

Landing Pages

+
// List landing pages (admin)
+GET /api/pages/admin
+Auth: Required (SUPER_ADMIN)
+Query: ?page=1&limit=20&search=query
+
+// Get page by ID (admin)
+GET /api/pages/admin/:id
+Auth: Required (SUPER_ADMIN)
+
+// Create page (admin)
+POST /api/pages/admin
+Auth: Required (SUPER_ADMIN)
+Body: {
+  "title": "About Us",
+  "slug": "about",
+  "content": "<html>...</html>",
+  "published": true
+}
+
+// Update page (admin)
+PUT /api/pages/admin/:id
+Auth: Required (SUPER_ADMIN)
+Body: { title, slug, content, published }
+
+// Delete page (admin)
+DELETE /api/pages/admin/:id
+Auth: Required (SUPER_ADMIN)
+
+// Export page to MkDocs (admin)
+POST /api/pages/admin/:id/export
+Auth: Required (SUPER_ADMIN)
+Query: ?format=themed&filename=about.html
+
+// Get page by slug (public)
+GET /api/pages/public/:slug
+Auth: None
+Response: Rendered HTML page
+
+

Email Templates

+
// List templates (admin)
+GET /api/email-templates
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+Query: ?page=1&limit=20&category=campaign&published=true
+
+// Get template by ID (admin)
+GET /api/email-templates/:id
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+
+// Create template (admin)
+POST /api/email-templates
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+Body: {
+  "name": "Campaign Launch",
+  "category": "campaign",
+  "subject": "New Campaign: {{campaignTitle}}",
+  "htmlBody": "<html>...</html>",
+  "textBody": "Plain text version",
+  "published": true
+}
+
+// Update template (admin)
+PUT /api/email-templates/:id
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+Body: { name, subject, htmlBody, ... }
+
+// Publish template version (admin)
+POST /api/email-templates/:id/publish
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+
+// Send test email (admin)
+POST /api/email-templates/:id/test
+Auth: Required (SUPER_ADMIN, INFLUENCE_ADMIN)
+Body: {
+  "toEmail": "test@example.com",
+  "variables": {
+    "campaignTitle": "Save the Trees",
+    "userName": "Test User"
+  }
+}
+
+

Response Format Standards

+

Success Response

+
{
+  "success": true,
+  "data": { /* response data */ }
+}
+
+

Paginated Response

+
{
+  "success": true,
+  "data": [ /* items */ ],
+  "pagination": {
+    "page": 1,
+    "limit": 20,
+    "total": 100,
+    "totalPages": 5
+  }
+}
+
+

Error Response

+
{
+  "success": false,
+  "error": {
+    "code": "VALIDATION_ERROR",
+    "message": "Validation failed",
+    "details": [
+      {
+        "path": ["email"],
+        "message": "Invalid email format"
+      }
+    ]
+  }
+}
+
+

HTTP Status Codes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CodeV1 UsageV2 Usage
200Success (all responses)Success (GET, PUT, PATCH)
201-Created (POST)
204-No Content (DELETE)
400Validation errorBad Request (validation error)
401Not logged inUnauthorized (invalid token)
403-Forbidden (insufficient permissions)
404Not foundNot Found
409-Conflict (duplicate resource)
422-Unprocessable Entity (business logic error)
429-Too Many Requests (rate limit)
500Server errorInternal Server Error
+

Migration Examples

+

Example 1: Campaign List Page

+

V1 Code: +

// Fetch campaigns
+fetch('/campaigns?page=1', {
+  credentials: 'include'
+})
+  .then(res => res.json())
+  .then(data => {
+    displayCampaigns(data.list);
+    displayPagination(data.pageInfo);
+  });
+

+

V2 Code: +

// Fetch campaigns
+const token = localStorage.getItem('accessToken');
+
+fetch('/api/influence/campaigns?page=1&limit=20', {
+  headers: {
+    'Authorization': `Bearer ${token}`
+  }
+})
+  .then(res => res.json())
+  .then(response => {
+    if (response.success) {
+      displayCampaigns(response.data);
+      displayPagination(response.pagination);
+    } else {
+      handleError(response.error);
+    }
+  });
+

+

Example 2: Location Creation

+

V1 Code: +

// Create location
+fetch('/locations/create', {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json' },
+  credentials: 'include',
+  body: JSON.stringify({
+    Address: '123 Main St, Toronto, ON M5V 1A1',
+    Latitude: 43.6532,
+    Longitude: -79.3832,
+    SupportLevel: 'support',
+    Notes: 'Spoke with resident'
+  })
+});
+

+

V2 Code: +

// Create location
+const token = localStorage.getItem('accessToken');
+
+fetch('/api/map/locations', {
+  method: 'POST',
+  headers: {
+    'Content-Type': 'application/json',
+    'Authorization': `Bearer ${token}`
+  },
+  body: JSON.stringify({
+    address: '123 Main St',
+    city: 'Toronto',
+    province: 'ON',
+    postalCode: 'M5V1A1',
+    country: 'Canada',
+    latitude: 43.6532,
+    longitude: -79.3832,
+    supportLevel: 'SUPPORT',
+    notes: 'Spoke with resident'
+  })
+})
+  .then(res => res.json())
+  .then(response => {
+    if (response.success) {
+      console.log('Created location:', response.data);
+    } else {
+      handleError(response.error);
+    }
+  });
+

+

Rate Limiting

+

V2 adds rate limiting to prevent abuse:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EndpointLimitWindow
/api/auth/login10 requests1 minute
/api/auth/register10 requests1 minute
/api/influence/campaign-emails/send-email30 requests1 hour
/api/map/canvass/visits30 requests1 minute
+

Rate Limit Headers (V2 only): +

X-RateLimit-Limit: 10
+X-RateLimit-Remaining: 8
+X-RateLimit-Reset: 1707835200
+

+ + +

Next Steps

+
    +
  1. Review endpoint mappings for your application's usage
  2. +
  3. Update API client to use JWT authentication
  4. +
  5. Migrate endpoints incrementally (auth first, then modules)
  6. +
  7. Test error handling with new response format
  8. +
  9. Implement rate limit handling (exponential backoff)
  10. +
+
+

API Testing

+

Use tools like Postman or Thunder Client to test V2 endpoints before frontend migration. Import the V2 API collection from /docs/postman-collection.json (if available).

+
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/migration/breaking-changes/index.html b/mkdocs/site/v2/migration/breaking-changes/index.html new file mode 100644 index 00000000..ea7a624c --- /dev/null +++ b/mkdocs/site/v2/migration/breaking-changes/index.html @@ -0,0 +1,6865 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Breaking Changes - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Breaking Changes: V1 to V2

+

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.

+

Overview

+

V2 is a clean-room rebuild with fundamental architectural changes. Almost every aspect of the platform has changed, requiring careful planning for migration.

+
+

Not a Drop-In Replacement

+

V2 cannot be deployed alongside V1 without migration. Database schemas, APIs, and authentication are completely incompatible.

+
+

Major Architectural Changes

+

1. Application Structure

+

V1 Architecture: +

changemaker.lite/
+├── influence/          # Separate Express app (port 3333)
+│   ├── app.js
+│   ├── routes/
+│   └── views/ (EJS)
+├── map/                # Separate Express app (port 3000)
+│   ├── app.js
+│   ├── routes/
+│   └── views/ (EJS)
+└── docker-compose.yml
+

+

V2 Architecture: +

changemaker.lite/
+├── api/                # Unified Express + Fastify (ports 4000, 4100)
+│   ├── src/server.ts          # Express main API
+│   ├── src/media-server.ts    # Fastify media API
+│   └── prisma/schema.prisma
+├── admin/              # React SPA (port 3000)
+│   └── src/
+└── docker-compose.yml
+

+

Impact: V1 had two separate codebases with duplicated auth, middleware, and configuration. V2 consolidates everything into a single unified API.

+

2. Data Layer Transformation

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AspectV1V2
ORMNone (direct NocoDB REST API)Prisma ORM + Drizzle (media)
DatabaseNocoDB internal PostgreSQLPostgreSQL 16 direct access
MigrationsNocoDB auto-migrationsPrisma migrate
ValidationManual (express-validator)Zod schemas
QueriesHTTP requests to NocoDBprisma.model.findMany()
+

V1 Example (NocoDB REST API): +

// influence/routes/campaigns.js
+const campaigns = await axios.get('http://nocodb:8080/api/v1/db/data/v1/campaigns', {
+  headers: { 'xc-token': process.env.NOCODB_API_TOKEN }
+});
+

+

V2 Example (Prisma ORM): +

// api/src/modules/influence/campaigns/campaigns.service.ts
+const campaigns = await prisma.campaign.findMany({
+  where: { active: true },
+  include: { createdBy: true }
+});
+

+

Impact: All database queries must be rewritten from HTTP requests to Prisma queries. No migration script can automate this.

+

3. Authentication System

+

Session-Based (V1) → JWT (V2)

+

V1 Authentication: +

// Session cookies + express-session + Redis store
+app.use(session({
+  store: redisStore,
+  secret: process.env.SESSION_SECRET,
+  resave: false,
+  saveUninitialized: false,
+  cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 hours
+}));
+
+// Login sets session
+req.session.userId = user.id;
+req.session.role = user.role;
+

+

V2 Authentication: +

// JWT access + refresh tokens
+const accessToken = jwt.sign(
+  { id: user.id, email: user.email, role: user.role },
+  env.JWT_ACCESS_SECRET,
+  { expiresIn: '15m' }
+);
+
+const refreshToken = jwt.sign(
+  { id: user.id },
+  env.JWT_REFRESH_SECRET,
+  { expiresIn: '7d' }
+);
+
+// Store refresh token in database (rotation on use)
+await prisma.refreshToken.create({
+  data: { token: refreshToken, userId: user.id }
+});
+

+

Impact: +- V1 sessions do not migrate. All users must re-login after V2 deployment. +- Frontend must be rewritten to store JWT tokens (localStorage/sessionStorage). +- API requests must include Authorization: Bearer <token> header instead of cookies.

+

Password Hashing

+

Compatibility: Both V1 and V2 use bcrypt, so password hashes can migrate directly.

+
// V1 (influence/routes/auth.js)
+const hashedPassword = await bcrypt.hash(password, 10);
+
+// V2 (api/src/modules/auth/auth.service.ts)
+const hashedPassword = await bcrypt.hash(password, 10);
+
+// Comparison works
+await bcrypt.compare(inputPassword, migratedHash); // ✅ Works
+
+
+

Password Migration Safe

+

V1 bcrypt hashes can be copied directly to V2 User.password field. Users can login with existing passwords.

+
+

4. User Model Changes

+

V1 User Models (Separate Tables)

+

Influence Users (influence_users table): +

{
+  "id": 1,
+  "email": "admin@example.com",
+  "password": "$2b$10...",
+  "role": "admin"
+}
+

+

Login Users (login table): +

{
+  "id": 1,
+  "email": "admin@example.com",
+  "password": "$2b$10...",
+  "name": "Admin User"
+}
+

+

Problem: V1 had two separate user tables (one per app) with potential email duplicates.

+

V2 User Model (Unified)

+
model User {
+  id            String          @id @default(cuid())
+  email         String          @unique  // Enforced unique
+  password      String
+  name          String?
+  phone         String?
+  role          UserRole        @default(USER)
+  status        UserStatus      @default(ACTIVE)
+  createdVia    UserCreatedVia  @default(STANDARD)
+  expiresAt     DateTime?
+  emailVerified Boolean         @default(false)
+  createdAt     DateTime        @default(now())
+  updatedAt     DateTime        @updatedAt
+}
+
+enum UserRole {
+  SUPER_ADMIN
+  INFLUENCE_ADMIN
+  MAP_ADMIN
+  USER
+  TEMP
+}
+
+

Migration Challenges: +1. Email deduplication: Merge influence_users + login where email matches +2. Role mapping: V1 "admin" → V2 SUPER_ADMIN, V1 "user" → V2 USER +3. Missing fields: V2 adds phone, status, createdVia, emailVerified +4. ID format: V1 integer IDs → V2 CUID strings (breaks foreign keys)

+

Migration Script (conceptual): +

// Merge V1 users into V2
+const v1InfluenceUsers = await fetchFromNocoDB('influence_users');
+const v1LoginUsers = await fetchFromNocoDB('login');
+
+const mergedUsers = mergeByEmail(v1InfluenceUsers, v1LoginUsers);
+
+for (const user of mergedUsers) {
+  await prisma.user.create({
+    data: {
+      email: user.email,
+      password: user.password, // bcrypt hash migrates directly
+      name: user.name || null,
+      role: mapRole(user.role), // 'admin' → 'SUPER_ADMIN'
+      createdAt: user.created_at || new Date()
+    }
+  });
+}
+

+

5. Frontend Stack

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AspectV1V2
FrameworkServer-rendered EJSReact 19 SPA
Build ToolNone (direct EJS rendering)Vite 5
UI LibraryBootstrap 4Ant Design 5
State ManagementServer sessionZustand stores
RoutingExpress routes (server-side)React Router (client-side)
StylingCSS + BootstrapCSS Modules + Ant Design tokens
+

V1 View (EJS template): +

<!-- influence/views/campaigns.ejs -->
+<% campaigns.forEach(campaign => { %>
+  <div class="card">
+    <h3><%= campaign.title %></h3>
+    <p><%= campaign.description %></p>
+  </div>
+<% }) %>
+

+

V2 Component (React + TypeScript): +

// admin/src/pages/CampaignsPage.tsx
+const CampaignsPage = () => {
+  const [campaigns, setCampaigns] = useState<Campaign[]>([]);
+
+  useEffect(() => {
+    api.get('/api/influence/campaigns').then(res => {
+      setCampaigns(res.data.data);
+    });
+  }, []);
+
+  return (
+    <Table dataSource={campaigns} columns={columns} />
+  );
+};
+

+

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).

+

API Changes

+

Endpoint URL Structure

+

V1 Endpoints: +

# Influence app (port 3333)
+GET  /campaigns
+POST /campaigns/create
+GET  /campaigns/:id/edit
+POST /representatives/lookup
+
+# Map app (port 3000)
+GET  /locations
+POST /locations/create
+GET  /shifts
+

+

V2 Endpoints: +

# Unified API (port 4000)
+GET    /api/influence/campaigns
+POST   /api/influence/campaigns
+GET    /api/influence/campaigns/:id
+PUT    /api/influence/campaigns/:id
+DELETE /api/influence/campaigns/:id
+POST   /api/influence/representatives/lookup
+
+GET    /api/map/locations
+POST   /api/map/locations
+GET    /api/map/locations/:id
+GET    /api/map/shifts
+

+

Changes: +1. All endpoints prefixed with /api/ +2. RESTful conventions (GET/POST/PUT/DELETE instead of /create, /edit) +3. Single port (4000) instead of two apps +4. Namespaced by module (/influence/, /map/)

+

Request/Response Format

+

V1 Response (NocoDB-style): +

{
+  "list": [
+    {
+      "Id": 1,
+      "Title": "Save the Trees",
+      "Created": "2024-01-15T10:30:00Z"
+    }
+  ],
+  "pageInfo": {
+    "totalRows": 100,
+    "page": 1,
+    "pageSize": 20
+  }
+}
+

+

V2 Response (standardized): +

{
+  "success": true,
+  "data": [
+    {
+      "id": "clx1a2b3c4d5e6f7g8h9i",
+      "title": "Save the Trees",
+      "createdAt": "2024-01-15T10:30:00.000Z"
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 20,
+    "total": 100,
+    "totalPages": 5
+  }
+}
+

+

Changes: +- V2 wraps responses in { success, data, pagination } structure +- Field names: camelCase (createdAt) vs mixed case (Created) +- IDs: CUID strings vs integers +- Timestamps: ISO 8601 with milliseconds

+

Authentication Headers

+

V1 Requests: +

// Session cookie sent automatically
+fetch('/campaigns', {
+  method: 'GET',
+  credentials: 'include' // Sends session cookie
+});
+

+

V2 Requests: +

// JWT Bearer token required
+fetch('/api/influence/campaigns', {
+  method: 'GET',
+  headers: {
+    'Authorization': `Bearer ${accessToken}`
+  }
+});
+

+

Impact: All API calls must be updated to include Authorization header. No more cookie-based authentication.

+

Validation Errors

+

V1 Validation (express-validator): +

{
+  "errors": [
+    {
+      "msg": "Invalid email",
+      "param": "email",
+      "location": "body"
+    }
+  ]
+}
+

+

V2 Validation (Zod): +

{
+  "success": false,
+  "error": {
+    "code": "VALIDATION_ERROR",
+    "message": "Validation failed",
+    "details": [
+      {
+        "path": ["email"],
+        "message": "Invalid email"
+      }
+    ]
+  }
+}
+

+

Database Schema Changes

+

Campaign Model

+

V1 NocoDB Table (campaigns): +

Columns:
+- Id (integer, auto-increment)
+- Title (string)
+- Description (text)
+- Slug (string)
+- IsActive (boolean)
+- Created (datetime)
+

+

V2 Prisma Model: +

model Campaign {
+  id                   String    @id @default(cuid())
+  title                String
+  description          String?
+  slug                 String    @unique
+  active               Boolean   @default(true)
+  highlighted          Boolean   @default(false)
+  targetLevel          String?
+  targetPosition       String?
+  targetName           String?
+  targetEmail          String?
+  targetPostalCode     String?
+  customSubject        String?
+  customBody           String?
+  responseWallEnabled  Boolean   @default(true)
+  createdAt            DateTime  @default(now())
+  updatedAt            DateTime  @updatedAt
+  createdByUserId      String
+  createdBy            User      @relation("CampaignCreator", fields: [createdByUserId], references: [id])
+
+  emails               CampaignEmail[]
+  responses            RepresentativeResponse[]
+}
+

+

Changes: +1. New fields: highlighted, targetLevel, responseWallEnabled, createdByUserId +2. Relations: Foreign key to User (V1 had no user relation) +3. Renamed: IsActiveactive, CreatedcreatedAt +4. Type changes: Description text → String? (nullable)

+

Location Model

+

V1 NocoDB Table (locations): +

Columns:
+- Id (integer)
+- Address (string)
+- Latitude (float)
+- Longitude (float)
+- SupportLevel (string)
+- Notes (text)
+

+

V2 Prisma Model: +

model Location {
+  id                 String              @id @default(cuid())
+  address            String
+  addressLine2       String?
+  city               String?
+  province           String?
+  postalCode         String?
+  country            String              @default("Canada")
+  latitude           Float?
+  longitude          Float?
+  geocoded           Boolean             @default(false)
+  geocodedAt         DateTime?
+  geocodeProvider    String?
+  geocodeQuality     String?
+  supportLevel       SupportLevel        @default(UNKNOWN)
+  notes              String?
+  contactName        String?
+  contactPhone       String?
+  contactEmail       String?
+  unitNumber         String?
+  buildingName       String?
+  buildingUse        String?
+  federalDistrict    String?
+  cutId              String?
+  createdAt          DateTime            @default(now())
+  updatedAt          DateTime            @updatedAt
+  createdByUserId    String
+  updatedByUserId    String?
+
+  createdBy          User                @relation("LocationCreator", fields: [createdByUserId], references: [id])
+  updatedBy          User?               @relation("LocationUpdater", fields: [updatedByUserId], references: [id])
+  cut                Cut?                @relation(fields: [cutId], references: [id])
+  addresses          Address[]
+  history            LocationHistory[]
+  canvassVisits      CanvassVisit[]
+}
+
+enum SupportLevel {
+  STRONG_SUPPORT
+  SUPPORT
+  UNDECIDED
+  OPPOSED
+  STRONG_OPPOSED
+  UNKNOWN
+  NOT_HOME
+  MOVED
+  DECEASED
+}
+

+

Changes: +1. Structured address: V1 single Address → V2 address, city, province, postalCode +2. Geocoding metadata: geocoded, geocodedAt, geocodeProvider, geocodeQuality +3. Contact fields: contactName, contactPhone, contactEmail +4. NAR fields: unitNumber, buildingName, buildingUse, federalDistrict +5. Relations: cutId, createdByUserId, updatedByUserId +6. SupportLevel: V1 string → V2 enum

+

Shift Model

+

V1 NocoDB Table (shifts): +

Columns:
+- Id (integer)
+- Name (string)
+- StartTime (datetime)
+- EndTime (datetime)
+- Location (string)
+- Capacity (integer)
+

+

V2 Prisma Model: +

model Shift {
+  id                String         @id @default(cuid())
+  name              String
+  description       String?
+  startTime         DateTime
+  endTime           DateTime
+  location          String?
+  capacity          Int?
+  requirements      String?
+  cutId             String?
+  createdAt         DateTime       @default(now())
+  updatedAt         DateTime       @updatedAt
+
+  cut               Cut?           @relation(fields: [cutId], references: [id])
+  signups           ShiftSignup[]
+}
+
+model ShiftSignup {
+  id                String         @id @default(cuid())
+  shiftId           String
+  userId            String
+  status            SignupStatus   @default(CONFIRMED)
+  notes             String?
+  confirmedAt       DateTime?
+  cancelledAt       DateTime?
+  createdAt         DateTime       @default(now())
+
+  shift             Shift          @relation(fields: [shiftId], references: [id], onDelete: Cascade)
+  user              User           @relation(fields: [userId], references: [id])
+
+  @@unique([shiftId, userId])
+}
+
+enum SignupStatus {
+  PENDING
+  CONFIRMED
+  CANCELLED
+  COMPLETED
+  NO_SHOW
+}
+

+

Changes: +1. Separate signups: V1 embedded → V2 ShiftSignup relation table +2. New fields: description, requirements, cutId +3. Signup tracking: status, confirmedAt, cancelledAt +4. Unique constraint: One signup per user per shift

+

Configuration Changes

+

Environment Variables

+

V1 Environment (.env): +

# V1 used separate .env files per app
+
+# influence/.env
+PORT=3333
+NOCODB_URL=http://nocodb:8080
+NOCODB_API_TOKEN=xxxxx
+SESSION_SECRET=xxxxx
+REDIS_URL=redis://redis:6379
+SMTP_HOST=smtp.example.com
+SMTP_USER=user@example.com
+SMTP_PASS=password
+
+# map/.env
+PORT=3000
+NOCODB_URL=http://nocodb:8080
+NOCODB_API_TOKEN=xxxxx
+SESSION_SECRET=xxxxx  # Different secret!
+REDIS_URL=redis://redis:6379
+

+

V2 Environment (.env): +

# Single unified .env file
+
+# Database
+DATABASE_URL=postgresql://changemaker:password@v2-postgres:5432/changemaker_v2?schema=public
+V2_POSTGRES_USER=changemaker
+V2_POSTGRES_PASSWORD=strongpassword
+V2_POSTGRES_DB=changemaker_v2
+
+# Redis
+REDIS_URL=redis://:password@redis:6379
+REDIS_PASSWORD=redispassword
+
+# JWT Authentication
+JWT_ACCESS_SECRET=access_secret_32_chars_minimum
+JWT_REFRESH_SECRET=refresh_secret_32_chars_minimum
+ENCRYPTION_KEY=encryption_key_32_chars_different_from_jwt
+
+# API
+API_PORT=4000
+MEDIA_API_PORT=4100
+NODE_ENV=production
+
+# Email (SMTP)
+SMTP_HOST=smtp.protonmail.com
+SMTP_PORT=587
+SMTP_SECURE=false
+SMTP_USER=your@protonmail.com
+SMTP_PASS=yourapppassword
+SMTP_FROM=noreply@cmlite.org
+EMAIL_TEST_MODE=false
+
+# BullMQ
+BULLMQ_REDIS_URL=redis://:password@redis:6379
+
+# Listmonk (optional)
+LISTMONK_SYNC_ENABLED=true
+LISTMONK_URL=http://listmonk:9001
+LISTMONK_ADMIN_USER=api_user
+LISTMONK_ADMIN_PASSWORD=api_token
+
+# Feature Flags
+ENABLE_MEDIA_FEATURES=true
+

+

Removed V1 Variables: +- NOCODB_URL (no longer using NocoDB as data layer) +- NOCODB_API_TOKEN +- SESSION_SECRET (replaced by JWT secrets)

+

New V2 Variables: +- DATABASE_URL (direct PostgreSQL connection) +- JWT_ACCESS_SECRET, JWT_REFRESH_SECRET +- ENCRYPTION_KEY (for encrypting sensitive DB fields) +- LISTMONK_SYNC_ENABLED (newsletter integration) +- ENABLE_MEDIA_FEATURES (media library toggle)

+

Docker Compose Changes

+

V1 Services: +

services:
+  influence-app:
+    build: ./influence
+    ports:
+      - "3333:3333"
+
+  map-app:
+    build: ./map
+    ports:
+      - "3000:3000"
+
+  nocodb:
+    image: nocodb/nocodb:latest
+    ports:
+      - "8080:8080"
+
+  redis:
+    image: redis:alpine
+

+

V2 Services: +

services:
+  api:
+    build: ./api
+    ports:
+      - "4000:4000"
+    depends_on:
+      - v2-postgres
+      - redis
+
+  media-api:
+    build:
+      context: ./api
+      dockerfile: Dockerfile.media
+    ports:
+      - "4100:4100"
+
+  admin:
+    build: ./admin
+    ports:
+      - "3000:3000"
+
+  v2-postgres:
+    image: postgres:16-alpine
+    ports:
+      - "5433:5432"
+
+  redis:
+    image: redis:7-alpine
+    command: redis-server --requirepass ${REDIS_PASSWORD}
+
+  nginx:
+    image: nginx:alpine
+    ports:
+      - "80:80"
+      - "443:443"
+

+

Changes: +1. Removed: influence-app, map-app, nocodb +2. Added: api, media-api, admin, v2-postgres, nginx +3. Port changes: API 3333/3000 → 4000, Admin GUI on 3000 +4. Redis: Now requires authentication (--requirepass)

+

Code Migration Examples

+

Campaign List Endpoint

+

V1 Implementation: +

// influence/routes/campaigns.js
+router.get('/campaigns', async (req, res) => {
+  try {
+    const response = await axios.get(
+      `${process.env.NOCODB_URL}/api/v1/db/data/v1/campaigns`,
+      {
+        headers: { 'xc-token': process.env.NOCODB_API_TOKEN },
+        params: {
+          where: '(IsActive,eq,true)',
+          sort: '-Created',
+          limit: 20,
+          offset: req.query.page ? (req.query.page - 1) * 20 : 0
+        }
+      }
+    );
+
+    res.render('campaigns', {
+      campaigns: response.data.list,
+      pageInfo: response.data.pageInfo
+    });
+  } catch (error) {
+    console.error(error);
+    res.status(500).send('Error fetching campaigns');
+  }
+});
+

+

V2 Implementation: +

// api/src/modules/influence/campaigns/campaigns.routes.ts
+router.get('/', authenticate, async (req: Request, res: Response) => {
+  const query = listCampaignsSchema.parse(req.query);
+  const result = await campaignService.list(query);
+  res.json({ success: true, ...result });
+});
+
+// api/src/modules/influence/campaigns/campaigns.service.ts
+async list(params: ListCampaignsParams) {
+  const { page = 1, limit = 20, search, active, highlighted } = params;
+
+  const where: Prisma.CampaignWhereInput = {
+    ...(active !== undefined && { active }),
+    ...(highlighted !== undefined && { highlighted }),
+    ...(search && {
+      OR: [
+        { title: { contains: search, mode: 'insensitive' } },
+        { description: { contains: search, mode: 'insensitive' } }
+      ]
+    })
+  };
+
+  const [campaigns, total] = await Promise.all([
+    prisma.campaign.findMany({
+      where,
+      include: { createdBy: { select: { id: true, name: true, email: true } } },
+      orderBy: { createdAt: 'desc' },
+      skip: (page - 1) * limit,
+      take: limit
+    }),
+    prisma.campaign.count({ where })
+  ]);
+
+  return {
+    data: campaigns,
+    pagination: {
+      page,
+      limit,
+      total,
+      totalPages: Math.ceil(total / limit)
+    }
+  };
+}
+

+

Changes: +1. V1: HTTP request to NocoDB → V2: Prisma ORM query +2. V1: Query string filtering → V2: Zod schema validation +3. V1: EJS rendering → V2: JSON API response +4. V1: Manual pagination → V2: Standardized pagination object +5. V2: Type safety (TypeScript), includes relations

+

User Login

+

V1 Implementation: +

// influence/routes/auth.js
+router.post('/login', async (req, res) => {
+  const { email, password } = req.body;
+
+  const response = await axios.get(
+    `${process.env.NOCODB_URL}/api/v1/db/data/v1/influence_users`,
+    {
+      headers: { 'xc-token': process.env.NOCODB_API_TOKEN },
+      params: { where: `(Email,eq,${email})` }
+    }
+  );
+
+  if (response.data.list.length === 0) {
+    return res.status(401).send('Invalid credentials');
+  }
+
+  const user = response.data.list[0];
+  const validPassword = await bcrypt.compare(password, user.Password);
+
+  if (!validPassword) {
+    return res.status(401).send('Invalid credentials');
+  }
+
+  req.session.userId = user.Id;
+  req.session.role = user.Role;
+
+  res.redirect('/dashboard');
+});
+

+

V2 Implementation: +

// api/src/modules/auth/auth.service.ts
+async login(email: string, password: string) {
+  const user = await prisma.user.findUnique({ where: { email } });
+
+  if (!user) {
+    // Prevent user enumeration - same error for wrong email or password
+    throw new UnauthorizedError('Invalid credentials');
+  }
+
+  const validPassword = await bcrypt.compare(password, user.password);
+  if (!validPassword) {
+    throw new UnauthorizedError('Invalid credentials');
+  }
+
+  if (user.status !== 'ACTIVE') {
+    throw new UnauthorizedError('Account is not active');
+  }
+
+  // Generate JWT tokens
+  const accessToken = jwt.sign(
+    { id: user.id, email: user.email, role: user.role },
+    env.JWT_ACCESS_SECRET,
+    { expiresIn: '15m' }
+  );
+
+  const refreshToken = jwt.sign(
+    { id: user.id },
+    env.JWT_REFRESH_SECRET,
+    { expiresIn: '7d' }
+  );
+
+  // Store refresh token (with rotation on use)
+  await prisma.refreshToken.create({
+    data: {
+      token: refreshToken,
+      userId: user.id,
+      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
+    }
+  });
+
+  // Update last login
+  await prisma.user.update({
+    where: { id: user.id },
+    data: { lastLoginAt: new Date() }
+  });
+
+  return {
+    user: { id: user.id, email: user.email, name: user.name, role: user.role },
+    accessToken,
+    refreshToken
+  };
+}
+

+

Changes: +1. V1: Session storage → V2: JWT tokens returned to client +2. V1: Redirect to dashboard → V2: JSON response with tokens +3. V2: User enumeration prevention (same error message) +4. V2: Account status check (ACTIVE, SUSPENDED, etc.) +5. V2: Refresh token storage for rotation +6. V2: Last login tracking

+

Deployment Changes

+

Port Mapping

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ServiceV1 PortV2 PortNotes
Influence App3333-Removed
Map App3000-Removed
Admin GUI-3000New React app
Express API-4000New unified API
Fastify Media API-4100New media service
NocoDB80808091Now read-only browser
PostgreSQL (main)-5433New V2 database
Listmonk-9001New newsletter service
Grafana-3001New monitoring
Prometheus-9090New metrics
+

Nginx Routing

+

V1 Nginx (simple proxy): +

server {
+  listen 80;
+  server_name cmlite.org;
+
+  location /influence {
+    proxy_pass http://influence-app:3333;
+  }
+
+  location /map {
+    proxy_pass http://map-app:3000;
+  }
+}
+

+

V2 Nginx (subdomain routing): +

# Admin GUI
+server {
+  listen 80;
+  server_name app.cmlite.org;
+  location / {
+    proxy_pass http://admin:3000;
+  }
+}
+
+# Main API
+server {
+  listen 80;
+  server_name api.cmlite.org;
+  location / {
+    proxy_pass http://api:4000;
+  }
+}
+
+# Media API
+server {
+  listen 80;
+  server_name media.cmlite.org;
+  location / {
+    proxy_pass http://media-api:4100;
+  }
+}
+
+# Public site (MkDocs)
+server {
+  listen 80;
+  server_name cmlite.org;
+  location / {
+    proxy_pass http://mkdocs:4001;
+  }
+}
+

+

Impact: V2 requires DNS configuration for subdomains (app., api., media., etc.).

+

Feature Changes

+

Features Removed in V2

+
    +
  1. NocoDB Data Browser (as primary interface)
  2. +
  3. V2 uses NocoDB only as read-only browser
  4. +
  5. +

    All CRUD operations via API/Admin GUI

    +
  6. +
  7. +

    Embedded EJS Views

    +
  8. +
  9. No server-rendered templates
  10. +
  11. +

    All UI is React SPA

    +
  12. +
  13. +

    Session-Based Multi-Tenancy

    +
  14. +
  15. V1 supported multiple campaigns with session isolation
  16. +
  17. V2 is single-tenant (one installation per organization)
  18. +
+

Features Added in V2

+
    +
  1. Landing Page Builder
  2. +
  3. GrapesJS visual editor
  4. +
  5. Custom blocks library
  6. +
  7. +

    MkDocs export (Jinja2 templates)

    +
  8. +
  9. +

    Email Templates System

    +
  10. +
  11. Template versioning
  12. +
  13. Variable substitution
  14. +
  15. Live preview
  16. +
  17. +

    HTML + plain text variants

    +
  18. +
  19. +

    Media Library

    +
  20. +
  21. Video upload with FFprobe metadata
  22. +
  23. Public gallery with categories
  24. +
  25. Reaction system (6 emoji types)
  26. +
  27. +

    Bulk operations

    +
  28. +
  29. +

    Volunteer Canvassing

    +
  30. +
  31. GPS tracking sessions
  32. +
  33. Walking route algorithm
  34. +
  35. Visit outcome recording
  36. +
  37. +

    Admin dashboard with leaderboards

    +
  38. +
  39. +

    Data Quality Dashboard

    +
  40. +
  41. Geocoding quality metrics
  42. +
  43. Provider performance comparison
  44. +
  45. +

    Bulk re-geocoding tools

    +
  46. +
  47. +

    Comprehensive Monitoring

    +
  48. +
  49. Prometheus metrics (12 custom cm_* metrics)
  50. +
  51. Grafana dashboards (3 pre-configured)
  52. +
  53. Alertmanager with Gotify integration
  54. +
  55. +

    Docker healthchecks

    +
  56. +
  57. +

    NAR 2025 Import

    +
  58. +
  59. Canadian electoral data import
  60. +
  61. Server-side streaming (large files)
  62. +
  63. Location + Address file joining
  64. +
  65. +

    Province/city/postal filtering

    +
  66. +
  67. +

    Pangolin Tunnel

    +
  68. +
  69. Self-hosted tunnel alternative to Cloudflare
  70. +
  71. Newt container integration
  72. +
  73. Admin setup wizard
  74. +
+

Features Changed in V2

+
    +
  1. Campaign Email Sending
  2. +
  3. V1: Bull job queue → V2: BullMQ with monitoring
  4. +
  5. +

    V1: Single SMTP config → V2: Test mode + Listmonk integration

    +
  6. +
  7. +

    Response Wall

    +
  8. +
  9. +

    V1: Simple submission form → V2: Moderation + upvoting + verification

    +
  10. +
  11. +

    Geocoding

    +
  12. +
  13. V1: Single provider (Nominatim) → V2: 6 providers with fallback
  14. +
  15. +

    V2 adds: ArcGIS, Photon, Mapbox, Google, OpenCage

    +
  16. +
  17. +

    User Roles

    +
  18. +
  19. V1: admin, user → V2: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP
  20. +
  21. V2: Role-based access control (RBAC) middleware
  22. +
+

Security Changes

+

Enhancements in V2

+
    +
  1. Password Policy
  2. +
  3. +

    V1: No requirements → V2: 12+ chars, uppercase, lowercase, digit (Zod schema)

    +
  4. +
  5. +

    Rate Limiting

    +
  6. +
  7. +

    V1: None → V2: Auth endpoints 10/min per IP, canvass visits 30/min

    +
  8. +
  9. +

    Refresh Token Rotation

    +
  10. +
  11. +

    V1: Static sessions → V2: Atomic token rotation (prevents replay attacks)

    +
  12. +
  13. +

    User Enumeration Prevention

    +
  14. +
  15. +

    V2: Login returns 401 for both invalid email and password (V1 returned different errors)

    +
  16. +
  17. +

    Redis Authentication

    +
  18. +
  19. +

    V1: No password → V2: Required REDIS_PASSWORD

    +
  20. +
  21. +

    Encryption Key

    +
  22. +
  23. +

    V2: Separate ENCRYPTION_KEY for sensitive DB fields (different from JWT secrets)

    +
  24. +
  25. +

    Input Sanitization

    +
  26. +
  27. +

    V2: HTML escaping for user content (responses, emails, templates)

    +
  28. +
  29. +

    Path Traversal Protection

    +
  30. +
  31. V2: Null byte checks, path normalization, encoded traversal blocking
  32. +
+

Security Audit

+

V2 underwent comprehensive security audit (2025-02-11) addressing 13 findings: +- 1 Critical, 6 Important, 3 Medium, 2 Low, 1 Suggestion

+

See Security Audit Report for details.

+

Performance Considerations

+

V1 Performance Characteristics

+
    +
  • Database Access: HTTP requests to NocoDB (REST API overhead)
  • +
  • N+1 Queries: Common due to REST API pagination
  • +
  • Caching: Redis sessions only
  • +
  • Concurrency: Limited by Node.js single-threaded event loop
  • +
+

V2 Performance Improvements

+
    +
  1. Direct Database Access
  2. +
  3. Prisma ORM eliminates REST API overhead
  4. +
  5. +

    Connection pooling reduces latency

    +
  6. +
  7. +

    Query Optimization

    +
  8. +
  9. Prisma includes relations in single query (no N+1)
  10. +
  11. +

    Indexed foreign keys, unique constraints

    +
  12. +
  13. +

    Caching Strategy

    +
  14. +
  15. Redis cache for representatives (60min TTL)
  16. +
  17. Redis cache for postal codes (persistent)
  18. +
  19. +

    Prisma query result caching

    +
  20. +
  21. +

    Dual API Architecture

    +
  22. +
  23. Media API (Fastify) handles video uploads separately
  24. +
  25. +

    Prevents main API blocking on large file uploads

    +
  26. +
  27. +

    Monitoring

    +
  28. +
  29. Prometheus http_request_duration_seconds histogram
  30. +
  31. Slow query detection via metrics
  32. +
  33. Grafana alerting on high latency
  34. +
+ + +

Next Steps

+
    +
  1. Review this breaking changes document thoroughly
  2. +
  3. Plan data transformation scripts (user merging, ID mapping)
  4. +
  5. Test authentication migration (password hashes, login flow)
  6. +
  7. Set up V2 staging environment for testing
  8. +
  9. Proceed to Data Migration Guide
  10. +
+
+

Migration Complexity

+

V2 migration is complex due to fundamental architectural changes. Budget 2-4 weeks for planning, scripting, testing, and execution.

+
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/migration/data-migration/index.html b/mkdocs/site/v2/migration/data-migration/index.html new file mode 100644 index 00000000..5ade8152 --- /dev/null +++ b/mkdocs/site/v2/migration/data-migration/index.html @@ -0,0 +1,7426 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Data Migration - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Data Migration Procedures

+

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.

+

Overview

+

V2 data migration involves:

+
    +
  1. Export - Extract data from V1 NocoDB tables
  2. +
  3. Transform - Convert V1 schema to V2 Prisma models
  4. +
  5. Import - Load transformed data into V2 PostgreSQL
  6. +
  7. Validate - Verify data integrity and completeness
  8. +
+
+

Production Migration Warning

+

ALWAYS perform a test migration on a staging environment before production. Data loss is possible if scripts contain errors.

+
+

Prerequisites

+

Before beginning data migration:

+
    +
  • V1 backup completed (PostgreSQL dump + uploads)
  • +
  • V2 environment running (docker compose up -d v2-postgres redis api)
  • +
  • Prisma migrations applied (npx prisma migrate deploy)
  • +
  • Node.js 20+ installed (for transformation scripts)
  • +
  • Sufficient disk space (3x current database size recommended)
  • +
  • Network access (V1 NocoDB API, V2 database)
  • +
+

Data Mapping

+

V1 Tables → V2 Prisma Models

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
V1 NocoDB TableV2 Prisma ModelNotes
influence_usersUserMerge with login table
loginUserMerge with influence_users
campaignsCampaignAdd createdByUserId relation
representativesRepresentativeDirect migration
responsesRepresentativeResponseAdd verification fields
response_upvotesResponseUpvoteAdd IP dedup field
postal_code_cachePostalCodeCacheDirect migration
locationsLocationSplit address, add geocoding fields
shiftsShiftExtract signups to ShiftSignup
shift_signupsShiftSignupAdd status enum
cutsCutParse GeoJSON coordinates
(none)RefreshTokenNew in V2 (generated on first login)
(none)SiteSettingsNew in V2 (seed with defaults)
(none)MapSettingsNew in V2 (seed with defaults)
+

Field Mapping Tables

+

Users

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
V1 Field (influence_users)V1 Field (login)V2 FieldTransformation
IdId-Discard (V2 uses CUID)
EmailEmailemailMerge by email, enforce unique
PasswordPasswordpasswordBcrypt hash (direct copy)
-NamenameFrom login.Name
--phoneNULL (not in V1)
Role-roleMap: 'admin'→'SUPER_ADMIN', 'user'→'USER'
--statusDefault: 'ACTIVE'
--createdViaDefault: 'STANDARD'
--expiresAtNULL
--emailVerifiedDefault: false
CreatedCreatedcreatedAtISO 8601 timestamp
--updatedAtUse createdAt or current time
+

Merge Logic: +

// Pseudocode
+const mergeUsers = (influenceUsers, loginUsers) => {
+  const merged = new Map();
+
+  // Add all login users first (has name field)
+  loginUsers.forEach(user => {
+    merged.set(user.Email.toLowerCase(), {
+      email: user.Email,
+      password: user.Password,
+      name: user.Name,
+      role: 'USER', // Default, may be overridden
+      createdAt: user.Created || new Date()
+    });
+  });
+
+  // Override with influence_users (has role field)
+  influenceUsers.forEach(user => {
+    const existing = merged.get(user.Email.toLowerCase());
+    if (existing) {
+      existing.role = mapRole(user.Role);
+    } else {
+      merged.set(user.Email.toLowerCase(), {
+        email: user.Email,
+        password: user.Password,
+        name: null,
+        role: mapRole(user.Role),
+        createdAt: user.Created || new Date()
+      });
+    }
+  });
+
+  return Array.from(merged.values());
+};
+
+const mapRole = (v1Role) => {
+  const roleMap = {
+    'admin': 'SUPER_ADMIN',
+    'moderator': 'INFLUENCE_ADMIN',
+    'user': 'USER'
+  };
+  return roleMap[v1Role] || 'USER';
+};
+

+

Campaigns

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
V1 FieldV2 FieldTransformation
Id-Discard (use CUID)
TitletitleDirect copy
DescriptiondescriptionDirect copy
SlugslugDirect copy
IsActiveactiveBoolean conversion
-highlightedDefault: false
TargetLeveltargetLevelDirect copy or NULL
TargetPositiontargetPositionDirect copy or NULL
-targetNameNULL (not in V1)
-targetEmailNULL
-targetPostalCodeNULL
-customSubjectNULL
-customBodyNULL
-responseWallEnabledDefault: true
CreatedcreatedAtISO 8601 timestamp
-updatedAtUse createdAt
-createdByUserIdRequires user lookup
+

CreatedBy Mapping: +

// V1 campaigns may not have createdBy field
+// Options:
+// 1. Assign all to first SUPER_ADMIN user
+// 2. Use separate mapping table if V1 tracked creators
+// 3. Create placeholder "System" user
+
+const assignCreator = async (campaign) => {
+  // Find first SUPER_ADMIN user
+  const admin = await prisma.user.findFirst({
+    where: { role: 'SUPER_ADMIN' }
+  });
+
+  if (!admin) {
+    throw new Error('No SUPER_ADMIN user found. Create admin user first.');
+  }
+
+  return admin.id;
+};
+

+

Locations

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
V1 FieldV2 FieldTransformation
Id-Discard (use CUID)
Addressaddress, city, province, postalCodeParse address string
-addressLine2NULL
-countryDefault: 'Canada'
LatitudelatitudeFloat conversion
LongitudelongitudeFloat conversion
-geocodedlatitude != NULL && longitude != NULL
-geocodedAtUse createdAt if geocoded
-geocodeProvider'Legacy V1' or NULL
-geocodeQualityNULL (unknown)
SupportLevelsupportLevelMap string to enum
NotesnotesDirect copy
-contactNameNULL
-contactPhoneNULL
-contactEmailNULL
-cutIdNULL (assign later if needed)
CreatedcreatedAtISO 8601 timestamp
-updatedAtUse createdAt
-createdByUserIdFirst MAP_ADMIN or SUPER_ADMIN
+

Address Parsing: +

// V1 stored full address as single string
+// V2 requires structured fields
+
+const parseAddress = (addressString) => {
+  // Example V1 address: "123 Main St, Toronto, ON M5V 1A1"
+  // Basic parsing (may need refinement for edge cases)
+
+  const parts = addressString.split(',').map(s => s.trim());
+
+  if (parts.length === 1) {
+    // Only street address
+    return {
+      address: parts[0],
+      city: null,
+      province: null,
+      postalCode: null
+    };
+  }
+
+  // Extract postal code (last part if matches pattern)
+  const postalRegex = /^[A-Z]\d[A-Z]\s?\d[A-Z]\d$/i;
+  let postalCode = null;
+  let province = null;
+  let city = null;
+
+  if (parts.length >= 3) {
+    const lastPart = parts[parts.length - 1];
+    const postalMatch = lastPart.match(/([A-Z]\d[A-Z]\s?\d[A-Z]\d)/i);
+
+    if (postalMatch) {
+      postalCode = postalMatch[1].replace(/\s/, '').toUpperCase();
+      // Province usually before postal code
+      const provincePart = lastPart.replace(postalMatch[0], '').trim();
+      if (provincePart) {
+        province = provincePart;
+      } else if (parts.length >= 4) {
+        province = parts[parts.length - 2];
+      }
+    }
+
+    // City is second-to-last or third-to-last
+    if (parts.length >= 4 && province) {
+      city = parts[parts.length - 3];
+    } else if (parts.length >= 3) {
+      city = parts[parts.length - 2];
+    }
+  }
+
+  return {
+    address: parts[0],
+    city: city || null,
+    province: province || null,
+    postalCode: postalCode || null
+  };
+};
+
+// Example usage:
+parseAddress("123 Main St, Toronto, ON M5V 1A1");
+// → { address: "123 Main St", city: "Toronto", province: "ON", postalCode: "M5V1A1" }
+

+

SupportLevel Enum Mapping: +

const mapSupportLevel = (v1Level) => {
+  // V1 used inconsistent strings
+  const levelMap = {
+    'strong support': 'STRONG_SUPPORT',
+    'support': 'SUPPORT',
+    'undecided': 'UNDECIDED',
+    'oppose': 'OPPOSED',
+    'strong oppose': 'STRONG_OPPOSED',
+    'unknown': 'UNKNOWN',
+    'not home': 'NOT_HOME',
+    'moved': 'MOVED',
+    'deceased': 'DECEASED',
+    '': 'UNKNOWN'
+  };
+
+  return levelMap[v1Level?.toLowerCase()] || 'UNKNOWN';
+};
+

+

Export V1 Data

+

Option 1: NocoDB API Export

+

Script: scripts/export-v1-nocodb.js

+
#!/usr/bin/env node
+const axios = require('axios');
+const fs = require('fs').promises;
+const path = require('path');
+
+const NOCODB_URL = process.env.V1_NOCODB_URL || 'http://localhost:8080';
+const NOCODB_TOKEN = process.env.V1_NOCODB_TOKEN;
+const OUTPUT_DIR = process.env.OUTPUT_DIR || './v1-export';
+
+const tables = [
+  'influence_users',
+  'login',
+  'campaigns',
+  'representatives',
+  'responses',
+  'response_upvotes',
+  'postal_code_cache',
+  'locations',
+  'shifts',
+  'shift_signups',
+  'cuts'
+];
+
+const exportTable = async (tableName) => {
+  console.log(`Exporting ${tableName}...`);
+
+  let allRecords = [];
+  let offset = 0;
+  const limit = 100;
+  let hasMore = true;
+
+  while (hasMore) {
+    const response = await axios.get(
+      `${NOCODB_URL}/api/v1/db/data/v1/${tableName}`,
+      {
+        headers: { 'xc-token': NOCODB_TOKEN },
+        params: { limit, offset }
+      }
+    );
+
+    const records = response.data.list || [];
+    allRecords = allRecords.concat(records);
+
+    console.log(`  Fetched ${records.length} records (total: ${allRecords.length})`);
+
+    if (records.length < limit) {
+      hasMore = false;
+    } else {
+      offset += limit;
+    }
+  }
+
+  await fs.writeFile(
+    path.join(OUTPUT_DIR, `${tableName}.json`),
+    JSON.stringify(allRecords, null, 2)
+  );
+
+  console.log(`✓ Exported ${allRecords.length} records from ${tableName}`);
+  return allRecords.length;
+};
+
+const main = async () => {
+  await fs.mkdir(OUTPUT_DIR, { recursive: true });
+
+  const counts = {};
+  for (const table of tables) {
+    try {
+      counts[table] = await exportTable(table);
+    } catch (error) {
+      console.error(`✗ Failed to export ${table}:`, error.message);
+      counts[table] = 0;
+    }
+  }
+
+  // Write summary
+  await fs.writeFile(
+    path.join(OUTPUT_DIR, 'export-summary.json'),
+    JSON.stringify({ exportedAt: new Date(), counts }, null, 2)
+  );
+
+  console.log('\nExport Summary:');
+  console.table(counts);
+};
+
+main().catch(console.error);
+
+

Usage: +

cd /home/bunker-admin/changemaker.lite
+mkdir -p v1-export
+
+# Export from running V1 instance
+V1_NOCODB_URL=http://localhost:8080 \
+V1_NOCODB_TOKEN=your-token \
+OUTPUT_DIR=./v1-export \
+node scripts/export-v1-nocodb.js
+

+

Option 2: PostgreSQL Direct Export

+

If you have direct access to V1 PostgreSQL database:

+
# Export each table as CSV
+docker compose -f docker-compose.v1.yml exec v1-postgres \
+  psql -U nocodb -d nocodb -c "\COPY influence_users TO STDOUT CSV HEADER" > v1-export/influence_users.csv
+
+docker compose -f docker-compose.v1.yml exec v1-postgres \
+  psql -U nocodb -d nocodb -c "\COPY login TO STDOUT CSV HEADER" > v1-export/login.csv
+
+docker compose -f docker-compose.v1.yml exec v1-postgres \
+  psql -U nocodb -d nocodb -c "\COPY campaigns TO STDOUT CSV HEADER" > v1-export/campaigns.csv
+
+# Repeat for all tables...
+
+

Backup File Uploads

+
# V1 uploads directory
+tar -czf v1-uploads-backup.tar.gz ./uploads/
+
+# Verify archive
+tar -tzf v1-uploads-backup.tar.gz | head -20
+
+

Transform Data

+

User Transformation

+

Script: scripts/transform-users.js

+
#!/usr/bin/env node
+const fs = require('fs').promises;
+const path = require('path');
+
+const INPUT_DIR = process.env.INPUT_DIR || './v1-export';
+const OUTPUT_DIR = process.env.OUTPUT_DIR || './v2-import';
+
+const mapRole = (v1Role) => {
+  const roleMap = {
+    'admin': 'SUPER_ADMIN',
+    'moderator': 'INFLUENCE_ADMIN',
+    'user': 'USER'
+  };
+  return roleMap[v1Role] || 'USER';
+};
+
+const transformUsers = async () => {
+  const influenceUsers = JSON.parse(
+    await fs.readFile(path.join(INPUT_DIR, 'influence_users.json'), 'utf-8')
+  );
+  const loginUsers = JSON.parse(
+    await fs.readFile(path.join(INPUT_DIR, 'login.json'), 'utf-8')
+  );
+
+  const merged = new Map();
+
+  // Add login users (has name field)
+  loginUsers.forEach(user => {
+    merged.set(user.Email.toLowerCase(), {
+      email: user.Email,
+      password: user.Password,
+      name: user.Name || null,
+      role: 'USER',
+      status: 'ACTIVE',
+      createdVia: 'STANDARD',
+      emailVerified: false,
+      createdAt: user.Created || new Date().toISOString(),
+      updatedAt: user.Created || new Date().toISOString()
+    });
+  });
+
+  // Override with influence_users (has role field)
+  influenceUsers.forEach(user => {
+    const existing = merged.get(user.Email.toLowerCase());
+    if (existing) {
+      existing.role = mapRole(user.Role);
+    } else {
+      merged.set(user.Email.toLowerCase(), {
+        email: user.Email,
+        password: user.Password,
+        name: null,
+        role: mapRole(user.Role),
+        status: 'ACTIVE',
+        createdVia: 'STANDARD',
+        emailVerified: false,
+        createdAt: user.Created || new Date().toISOString(),
+        updatedAt: user.Created || new Date().toISOString()
+      });
+    }
+  });
+
+  const users = Array.from(merged.values());
+
+  await fs.writeFile(
+    path.join(OUTPUT_DIR, 'users.json'),
+    JSON.stringify(users, null, 2)
+  );
+
+  console.log(`✓ Transformed ${users.length} users`);
+  console.log(`  influence_users: ${influenceUsers.length}`);
+  console.log(`  login: ${loginUsers.length}`);
+  console.log(`  merged: ${users.length}`);
+
+  return users;
+};
+
+const main = async () => {
+  await fs.mkdir(OUTPUT_DIR, { recursive: true });
+  await transformUsers();
+};
+
+main().catch(console.error);
+
+

Campaign Transformation

+

Script: scripts/transform-campaigns.js

+
#!/usr/bin/env node
+const fs = require('fs').promises;
+const path = require('path');
+
+const INPUT_DIR = process.env.INPUT_DIR || './v1-export';
+const OUTPUT_DIR = process.env.OUTPUT_DIR || './v2-import';
+
+const transformCampaigns = async () => {
+  const v1Campaigns = JSON.parse(
+    await fs.readFile(path.join(INPUT_DIR, 'campaigns.json'), 'utf-8')
+  );
+
+  // Note: createdByUserId must be populated after users are imported
+  // This transformation creates placeholder field
+  const campaigns = v1Campaigns.map(campaign => ({
+    title: campaign.Title,
+    description: campaign.Description || null,
+    slug: campaign.Slug,
+    active: Boolean(campaign.IsActive),
+    highlighted: false,
+    targetLevel: campaign.TargetLevel || null,
+    targetPosition: campaign.TargetPosition || null,
+    targetName: null,
+    targetEmail: null,
+    targetPostalCode: null,
+    customSubject: null,
+    customBody: null,
+    responseWallEnabled: true,
+    createdAt: campaign.Created || new Date().toISOString(),
+    updatedAt: campaign.Created || new Date().toISOString(),
+    _v1Id: campaign.Id // Keep for reference in import script
+  }));
+
+  await fs.writeFile(
+    path.join(OUTPUT_DIR, 'campaigns.json'),
+    JSON.stringify(campaigns, null, 2)
+  );
+
+  console.log(`✓ Transformed ${campaigns.length} campaigns`);
+  return campaigns;
+};
+
+const main = async () => {
+  await fs.mkdir(OUTPUT_DIR, { recursive: true });
+  await transformCampaigns();
+};
+
+main().catch(console.error);
+
+

Location Transformation

+

Script: scripts/transform-locations.js

+
#!/usr/bin/env node
+const fs = require('fs').promises;
+const path = require('path');
+
+const INPUT_DIR = process.env.INPUT_DIR || './v1-export';
+const OUTPUT_DIR = process.env.OUTPUT_DIR || './v2-import';
+
+const parseAddress = (addressString) => {
+  if (!addressString) {
+    return { address: '', city: null, province: null, postalCode: null };
+  }
+
+  const parts = addressString.split(',').map(s => s.trim());
+
+  if (parts.length === 1) {
+    return {
+      address: parts[0],
+      city: null,
+      province: null,
+      postalCode: null
+    };
+  }
+
+  const postalRegex = /([A-Z]\d[A-Z]\s?\d[A-Z]\d)/i;
+  let postalCode = null;
+  let province = null;
+  let city = null;
+
+  if (parts.length >= 3) {
+    const lastPart = parts[parts.length - 1];
+    const postalMatch = lastPart.match(postalRegex);
+
+    if (postalMatch) {
+      postalCode = postalMatch[1].replace(/\s/, '').toUpperCase();
+      const provincePart = lastPart.replace(postalMatch[0], '').trim();
+      if (provincePart) {
+        province = provincePart;
+      } else if (parts.length >= 4) {
+        province = parts[parts.length - 2];
+      }
+    }
+
+    if (parts.length >= 4 && province) {
+      city = parts[parts.length - 3];
+    } else if (parts.length >= 3) {
+      city = parts[parts.length - 2];
+    }
+  }
+
+  return {
+    address: parts[0],
+    city: city || null,
+    province: province || null,
+    postalCode: postalCode || null
+  };
+};
+
+const mapSupportLevel = (v1Level) => {
+  const levelMap = {
+    'strong support': 'STRONG_SUPPORT',
+    'support': 'SUPPORT',
+    'undecided': 'UNDECIDED',
+    'oppose': 'OPPOSED',
+    'strong oppose': 'STRONG_OPPOSED',
+    'unknown': 'UNKNOWN',
+    'not home': 'NOT_HOME',
+    'moved': 'MOVED',
+    'deceased': 'DECEASED',
+    '': 'UNKNOWN'
+  };
+  return levelMap[v1Level?.toLowerCase()] || 'UNKNOWN';
+};
+
+const transformLocations = async () => {
+  const v1Locations = JSON.parse(
+    await fs.readFile(path.join(INPUT_DIR, 'locations.json'), 'utf-8')
+  );
+
+  const locations = v1Locations.map(loc => {
+    const { address, city, province, postalCode } = parseAddress(loc.Address);
+
+    const hasCoordinates = loc.Latitude != null && loc.Longitude != null;
+
+    return {
+      ...parseAddress(loc.Address),
+      country: 'Canada',
+      latitude: loc.Latitude ? parseFloat(loc.Latitude) : null,
+      longitude: loc.Longitude ? parseFloat(loc.Longitude) : null,
+      geocoded: hasCoordinates,
+      geocodedAt: hasCoordinates ? (loc.Created || new Date().toISOString()) : null,
+      geocodeProvider: hasCoordinates ? 'Legacy V1' : null,
+      geocodeQuality: null,
+      supportLevel: mapSupportLevel(loc.SupportLevel),
+      notes: loc.Notes || null,
+      contactName: null,
+      contactPhone: null,
+      contactEmail: null,
+      createdAt: loc.Created || new Date().toISOString(),
+      updatedAt: loc.Created || new Date().toISOString(),
+      _v1Id: loc.Id
+    };
+  });
+
+  await fs.writeFile(
+    path.join(OUTPUT_DIR, 'locations.json'),
+    JSON.stringify(locations, null, 2)
+  );
+
+  console.log(`✓ Transformed ${locations.length} locations`);
+
+  const geocodedCount = locations.filter(l => l.geocoded).length;
+  console.log(`  Geocoded: ${geocodedCount} (${(geocodedCount/locations.length*100).toFixed(1)}%)`);
+
+  return locations;
+};
+
+const main = async () => {
+  await fs.mkdir(OUTPUT_DIR, { recursive: true });
+  await transformLocations();
+};
+
+main().catch(console.error);
+
+

Import V2 Data

+

Import Script

+

Script: scripts/import-v2-data.js

+
#!/usr/bin/env node
+const { PrismaClient } = require('@prisma/client');
+const fs = require('fs').promises;
+const path = require('path');
+
+const prisma = new PrismaClient();
+const INPUT_DIR = process.env.INPUT_DIR || './v2-import';
+
+const importUsers = async () => {
+  const users = JSON.parse(
+    await fs.readFile(path.join(INPUT_DIR, 'users.json'), 'utf-8')
+  );
+
+  console.log(`Importing ${users.length} users...`);
+
+  const created = [];
+  for (const user of users) {
+    try {
+      const newUser = await prisma.user.create({ data: user });
+      created.push(newUser);
+    } catch (error) {
+      if (error.code === 'P2002') {
+        console.warn(`  ⚠ User ${user.email} already exists, skipping`);
+      } else {
+        console.error(`  ✗ Failed to import user ${user.email}:`, error.message);
+      }
+    }
+  }
+
+  console.log(`✓ Imported ${created.length}/${users.length} users`);
+  return created;
+};
+
+const importCampaigns = async () => {
+  const campaigns = JSON.parse(
+    await fs.readFile(path.join(INPUT_DIR, 'campaigns.json'), 'utf-8')
+  );
+
+  // Find first SUPER_ADMIN user
+  const admin = await prisma.user.findFirst({
+    where: { role: 'SUPER_ADMIN' }
+  });
+
+  if (!admin) {
+    throw new Error('No SUPER_ADMIN user found. Import users first.');
+  }
+
+  console.log(`Importing ${campaigns.length} campaigns (creator: ${admin.email})...`);
+
+  const created = [];
+  for (const campaign of campaigns) {
+    try {
+      const { _v1Id, ...data } = campaign;
+      const newCampaign = await prisma.campaign.create({
+        data: {
+          ...data,
+          createdByUserId: admin.id
+        }
+      });
+      created.push(newCampaign);
+    } catch (error) {
+      if (error.code === 'P2002') {
+        console.warn(`  ⚠ Campaign ${campaign.slug} already exists, skipping`);
+      } else {
+        console.error(`  ✗ Failed to import campaign ${campaign.title}:`, error.message);
+      }
+    }
+  }
+
+  console.log(`✓ Imported ${created.length}/${campaigns.length} campaigns`);
+  return created;
+};
+
+const importLocations = async () => {
+  const locations = JSON.parse(
+    await fs.readFile(path.join(INPUT_DIR, 'locations.json'), 'utf-8')
+  );
+
+  // Find first MAP_ADMIN or SUPER_ADMIN user
+  const admin = await prisma.user.findFirst({
+    where: { OR: [{ role: 'MAP_ADMIN' }, { role: 'SUPER_ADMIN' }] }
+  });
+
+  if (!admin) {
+    throw new Error('No MAP_ADMIN or SUPER_ADMIN user found. Import users first.');
+  }
+
+  console.log(`Importing ${locations.length} locations (creator: ${admin.email})...`);
+
+  const created = [];
+  for (const location of locations) {
+    try {
+      const { _v1Id, ...data } = location;
+      const newLocation = await prisma.location.create({
+        data: {
+          ...data,
+          createdByUserId: admin.id
+        }
+      });
+      created.push(newLocation);
+    } catch (error) {
+      console.error(`  ✗ Failed to import location ${location.address}:`, error.message);
+    }
+  }
+
+  console.log(`✓ Imported ${created.length}/${locations.length} locations`);
+  return created;
+};
+
+const main = async () => {
+  try {
+    console.log('Starting V2 data import...\n');
+
+    await importUsers();
+    console.log();
+
+    await importCampaigns();
+    console.log();
+
+    await importLocations();
+    console.log();
+
+    console.log('✓ Import complete!');
+  } catch (error) {
+    console.error('Import failed:', error);
+    process.exit(1);
+  } finally {
+    await prisma.$disconnect();
+  }
+};
+
+main();
+
+

Usage: +

cd /home/bunker-admin/changemaker.lite
+
+# Ensure V2 database is running and migrated
+docker compose up -d v2-postgres
+docker compose exec api npx prisma migrate deploy
+
+# Run import
+INPUT_DIR=./v2-import node scripts/import-v2-data.js
+

+

Validate Migration

+

Validation Script

+

Script: scripts/validate-migration.js

+
#!/usr/bin/env node
+const { PrismaClient } = require('@prisma/client');
+const fs = require('fs').promises;
+const path = require('path');
+
+const prisma = new PrismaClient();
+const V1_EXPORT_DIR = './v1-export';
+
+const validateCounts = async () => {
+  console.log('Validating record counts...\n');
+
+  const v1Summary = JSON.parse(
+    await fs.readFile(path.join(V1_EXPORT_DIR, 'export-summary.json'), 'utf-8')
+  );
+
+  const v2Counts = {
+    users: await prisma.user.count(),
+    campaigns: await prisma.campaign.count(),
+    locations: await prisma.location.count(),
+    shifts: await prisma.shift.count(),
+    representatives: await prisma.representative.count()
+  };
+
+  const comparison = [
+    {
+      Table: 'Users',
+      V1: v1Summary.counts.influence_users + v1Summary.counts.login,
+      V2: v2Counts.users,
+      Match: '≈' // Approximate due to deduplication
+    },
+    {
+      Table: 'Campaigns',
+      V1: v1Summary.counts.campaigns,
+      V2: v2Counts.campaigns,
+      Match: v1Summary.counts.campaigns === v2Counts.campaigns ? '✓' : '✗'
+    },
+    {
+      Table: 'Locations',
+      V1: v1Summary.counts.locations,
+      V2: v2Counts.locations,
+      Match: v1Summary.counts.locations === v2Counts.locations ? '✓' : '✗'
+    },
+    {
+      Table: 'Shifts',
+      V1: v1Summary.counts.shifts,
+      V2: v2Counts.shifts,
+      Match: v1Summary.counts.shifts === v2Counts.shifts ? '✓' : '✗'
+    },
+    {
+      Table: 'Representatives',
+      V1: v1Summary.counts.representatives,
+      V2: v2Counts.representatives,
+      Match: v1Summary.counts.representatives === v2Counts.representatives ? '✓' : '✗'
+    }
+  ];
+
+  console.table(comparison);
+};
+
+const validateSampleData = async () => {
+  console.log('\nValidating sample data integrity...\n');
+
+  // Check first user
+  const firstUser = await prisma.user.findFirst({
+    orderBy: { createdAt: 'asc' }
+  });
+  console.log('First User:', {
+    email: firstUser.email,
+    role: firstUser.role,
+    hasPassword: firstUser.password?.startsWith('$2b$') ? 'Yes (bcrypt)' : 'No'
+  });
+
+  // Check first campaign
+  const firstCampaign = await prisma.campaign.findFirst({
+    include: { createdBy: { select: { email: true } } },
+    orderBy: { createdAt: 'asc' }
+  });
+  console.log('First Campaign:', {
+    title: firstCampaign.title,
+    slug: firstCampaign.slug,
+    creator: firstCampaign.createdBy.email
+  });
+
+  // Check first location
+  const firstLocation = await prisma.location.findFirst({
+    orderBy: { createdAt: 'asc' }
+  });
+  console.log('First Location:', {
+    address: firstLocation.address,
+    city: firstLocation.city,
+    geocoded: firstLocation.geocoded,
+    supportLevel: firstLocation.supportLevel
+  });
+
+  // Geocoding statistics
+  const totalLocations = await prisma.location.count();
+  const geocodedLocations = await prisma.location.count({
+    where: { geocoded: true }
+  });
+  console.log('\nGeocoding Stats:', {
+    total: totalLocations,
+    geocoded: geocodedLocations,
+    percentage: `${(geocodedLocations / totalLocations * 100).toFixed(1)}%`
+  });
+};
+
+const main = async () => {
+  try {
+    await validateCounts();
+    await validateSampleData();
+
+    console.log('\n✓ Validation complete');
+  } catch (error) {
+    console.error('Validation failed:', error);
+    process.exit(1);
+  } finally {
+    await prisma.$disconnect();
+  }
+};
+
+main();
+
+

Special Cases

+

Handling Duplicate Emails

+

During user merge, you may encounter duplicate emails:

+
// Option 1: Keep first occurrence, log duplicates
+const handleDuplicates = (users) => {
+  const seen = new Set();
+  const duplicates = [];
+
+  const unique = users.filter(user => {
+    if (seen.has(user.email.toLowerCase())) {
+      duplicates.push(user);
+      return false;
+    }
+    seen.add(user.email.toLowerCase());
+    return true;
+  });
+
+  if (duplicates.length > 0) {
+    console.warn(`Found ${duplicates.length} duplicate emails:`);
+    duplicates.forEach(d => console.warn(`  - ${d.email}`));
+  }
+
+  return unique;
+};
+
+// Option 2: Append suffix to duplicates
+const handleDuplicatesWithSuffix = (users) => {
+  const counts = new Map();
+
+  return users.map(user => {
+    const email = user.email.toLowerCase();
+    const count = counts.get(email) || 0;
+    counts.set(email, count + 1);
+
+    if (count > 0) {
+      const [local, domain] = email.split('@');
+      return {
+        ...user,
+        email: `${local}+v1dup${count}@${domain}`
+      };
+    }
+
+    return user;
+  });
+};
+
+

Migrating Representative Cache

+

Representative cache can be rebuilt from Represent API, but to preserve it:

+
const transformRepresentatives = async () => {
+  const v1Reps = JSON.parse(
+    await fs.readFile(path.join(INPUT_DIR, 'representatives.json'), 'utf-8')
+  );
+
+  const reps = v1Reps.map(rep => ({
+    name: rep.Name,
+    email: rep.Email,
+    district: rep.District,
+    party: rep.Party,
+    level: rep.Level,
+    photoUrl: rep.PhotoUrl || null,
+    postalCodes: rep.PostalCodes ? JSON.parse(rep.PostalCodes) : [],
+    createdAt: rep.Created || new Date().toISOString(),
+    updatedAt: rep.Updated || new Date().toISOString()
+  }));
+
+  await fs.writeFile(
+    path.join(OUTPUT_DIR, 'representatives.json'),
+    JSON.stringify(reps, null, 2)
+  );
+
+  return reps;
+};
+
+

Migrating Shift Signups

+

V1 may have embedded signups; V2 uses separate ShiftSignup table:

+
const transformShiftSignups = async () => {
+  const v1Shifts = JSON.parse(
+    await fs.readFile(path.join(INPUT_DIR, 'shifts.json'), 'utf-8')
+  );
+
+  const signups = [];
+
+  v1Shifts.forEach(shift => {
+    if (shift.Signups && Array.isArray(shift.Signups)) {
+      shift.Signups.forEach(signup => {
+        signups.push({
+          shiftId: shift.Id, // V1 ID, will need mapping in import
+          userId: signup.UserId, // V1 ID, will need mapping
+          status: 'CONFIRMED',
+          notes: signup.Notes || null,
+          confirmedAt: signup.CreatedAt || new Date().toISOString(),
+          createdAt: signup.CreatedAt || new Date().toISOString()
+        });
+      });
+    }
+  });
+
+  await fs.writeFile(
+    path.join(OUTPUT_DIR, 'shift-signups.json'),
+    JSON.stringify(signups, null, 2)
+  );
+
+  return signups;
+};
+
+// Import with ID mapping
+const importShiftSignups = async (idMappings) => {
+  const signups = JSON.parse(
+    await fs.readFile(path.join(INPUT_DIR, 'shift-signups.json'), 'utf-8')
+  );
+
+  for (const signup of signups) {
+    const v2ShiftId = idMappings.shifts[signup.shiftId];
+    const v2UserId = idMappings.users[signup.userId];
+
+    if (!v2ShiftId || !v2UserId) {
+      console.warn(`Skipping signup: shift ${signup.shiftId} or user ${signup.userId} not found`);
+      continue;
+    }
+
+    await prisma.shiftSignup.create({
+      data: {
+        shiftId: v2ShiftId,
+        userId: v2UserId,
+        status: signup.status,
+        notes: signup.notes,
+        confirmedAt: signup.confirmedAt,
+        createdAt: signup.createdAt
+      }
+    });
+  }
+};
+
+

Testing Migration

+

Pre-Production Test Migration

+

Before production migration, perform full test on staging:

+
# 1. Clone production V1 data to staging
+./scripts/backup.sh
+scp backups/latest.tar.gz staging-server:/tmp/
+
+# 2. Restore V1 on staging
+ssh staging-server
+cd /opt/changemaker-lite
+tar -xzf /tmp/latest.tar.gz -C ./
+docker compose -f docker-compose.v1.yml up -d
+
+# 3. Export V1 data
+docker compose -f docker-compose.v1.yml exec influence-app node /app/scripts/export-data.js
+
+# 4. Set up V2 on staging
+git checkout v2
+docker compose up -d v2-postgres redis
+docker compose exec api npx prisma migrate deploy
+
+# 5. Transform and import
+node scripts/transform-users.js
+node scripts/transform-campaigns.js
+node scripts/transform-locations.js
+node scripts/import-v2-data.js
+
+# 6. Validate
+node scripts/validate-migration.js
+
+# 7. Test critical workflows
+./scripts/test-v2-workflows.sh
+
+

Test Critical Workflows

+

Script: scripts/test-v2-workflows.sh

+
#!/bin/bash
+set -e
+
+API_URL="http://localhost:4000"
+ADMIN_TOKEN=""
+
+echo "Testing V2 Critical Workflows"
+echo "=============================="
+
+# 1. Admin Login
+echo -n "1. Admin login... "
+LOGIN_RESPONSE=$(curl -s -X POST "$API_URL/api/auth/login" \
+  -H "Content-Type: application/json" \
+  -d '{"email":"admin@example.com","password":"Admin123!"}')
+
+ADMIN_TOKEN=$(echo $LOGIN_RESPONSE | jq -r '.data.accessToken')
+
+if [ "$ADMIN_TOKEN" != "null" ] && [ -n "$ADMIN_TOKEN" ]; then
+  echo "✓"
+else
+  echo "✗ Failed"
+  exit 1
+fi
+
+# 2. List Campaigns
+echo -n "2. List campaigns... "
+CAMPAIGNS=$(curl -s "$API_URL/api/influence/campaigns" \
+  -H "Authorization: Bearer $ADMIN_TOKEN")
+
+CAMPAIGN_COUNT=$(echo $CAMPAIGNS | jq '.data | length')
+echo "✓ ($CAMPAIGN_COUNT campaigns)"
+
+# 3. Representative Lookup
+echo -n "3. Representative lookup (M5V 1A1)... "
+REPS=$(curl -s -X POST "$API_URL/api/influence/representatives/lookup" \
+  -H "Content-Type: application/json" \
+  -d '{"postalCode":"M5V1A1"}')
+
+REP_COUNT=$(echo $REPS | jq '.data | length')
+echo "✓ ($REP_COUNT representatives)"
+
+# 4. List Locations
+echo -n "4. List locations... "
+LOCATIONS=$(curl -s "$API_URL/api/map/locations" \
+  -H "Authorization: Bearer $ADMIN_TOKEN")
+
+LOCATION_COUNT=$(echo $LOCATIONS | jq '.data | length')
+echo "✓ ($LOCATION_COUNT locations)"
+
+# 5. Send Test Email
+echo -n "5. Queue test email... "
+EMAIL_RESPONSE=$(curl -s -X POST "$API_URL/api/influence/campaign-emails/send-email" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "campaignId":"'$(echo $CAMPAIGNS | jq -r '.data[0].id')'",
+    "postalCode":"M5V1A1",
+    "senderName":"Test User",
+    "senderEmail":"test@example.com"
+  }')
+
+if echo $EMAIL_RESPONSE | jq -e '.success' > /dev/null; then
+  echo "✓"
+else
+  echo "✗ Failed"
+fi
+
+echo
+echo "All critical workflows passed ✓"
+
+

Production Migration

+

Step-by-Step Procedure

+

Phase 1: Preparation (1-2 days before)

+
    +
  1. +

    Announce Downtime Window +

    Subject: Scheduled Maintenance - System Upgrade
    +
    +We will be performing a major system upgrade on [DATE] at [TIME].
    +
    +Expected downtime: 15-30 minutes
    +
    +What to expect:
    +- All users will be logged out
    +- You will need to re-login after the upgrade
    +- Your data and passwords remain unchanged
    +
    +Please save any unsaved work before [TIME].
    +

    +
  2. +
  3. +

    Backup V1 +

    ./scripts/backup.sh --include-uploads
    +
    +# Verify backup
    +tar -tzf backups/changemaker-v1-$(date +%Y%m%d).tar.gz | head -20
    +

    +
  4. +
  5. +

    Test V2 on Staging (use procedure above)

    +
  6. +
+

Phase 2: Export (T-60min)

+
    +
  1. +

    Enable V1 Read-Only Mode +

    # Stop V1 write services
    +docker compose -f docker-compose.v1.yml stop influence-app map-app
    +
    +# Keep database running for export
    +

    +
  2. +
  3. +

    Export V1 Data +

    V1_NOCODB_URL=http://localhost:8080 \
    +V1_NOCODB_TOKEN=$(cat .env | grep NOCODB_API_TOKEN | cut -d= -f2) \
    +node scripts/export-v1-nocodb.js
    +
    +# Verify export
    +ls -lh v1-export/
    +

    +
  4. +
+

Phase 3: Transform (T-30min)

+
    +
  1. Transform Data +
    node scripts/transform-users.js
    +node scripts/transform-campaigns.js
    +node scripts/transform-locations.js
    +node scripts/transform-shifts.js
    +
    +# Verify transformed data
    +ls -lh v2-import/
    +
  2. +
+

Phase 4: Import (T-15min)

+
    +
  1. +

    Stop V1 Completely +

    docker compose -f docker-compose.v1.yml down
    +

    +
  2. +
  3. +

    Start V2 Database +

    docker compose up -d v2-postgres redis
    +docker compose exec api npx prisma migrate deploy
    +

    +
  4. +
  5. +

    Import Data +

    node scripts/import-v2-data.js | tee migration.log
    +

    +
  6. +
  7. +

    Validate Import +

    node scripts/validate-migration.js
    +

    +
  8. +
+

Phase 5: Launch V2 (T+0min)

+
    +
  1. +

    Start All V2 Services +

    docker compose up -d
    +
    +# Wait for health checks
    +sleep 30
    +
    +# Verify all healthy
    +docker compose ps
    +

    +
  2. +
  3. +

    Smoke Test +

    ./scripts/test-v2-workflows.sh
    +

    +
  4. +
  5. +

    Update DNS/Tunnel

    +
      +
    • Pangolin: Update endpoint in admin
    • +
    • Cloudflare: Update tunnel configuration
    • +
    • Manual DNS: Update A/CNAME records
    • +
    +
  6. +
+

Phase 6: Monitor (T+15min to T+24hr)

+
    +
  1. +

    Watch Logs +

    docker compose logs -f api admin
    +

    +
  2. +
  3. +

    Monitor Metrics

    +
      +
    • Open Grafana: http://localhost:3001
    • +
    • Check API Performance dashboard
    • +
    • Watch for error spikes
    • +
    +
  4. +
  5. +

    Test User Logins

    +
      +
    • Admin login
    • +
    • Regular user login
    • +
    • Temp user creation (shift signup)
    • +
    +
  6. +
  7. +

    Announce Migration Complete +

    Subject: System Upgrade Complete
    +
    +Our system upgrade is complete! You can now log in at:
    +https://app.cmlite.org
    +
    +Your username and password remain unchanged.
    +
    +New features available:
    +- [List new V2 features]
    +
    +If you experience any issues, please contact support@cmlite.org.
    +

    +
  8. +
+

Rollback Procedures

+

If migration fails, follow these steps:

+

Emergency Rollback (T+0 to T+2hr)

+
# 1. Stop V2 services
+docker compose down
+
+# 2. Restore V1 services
+docker compose -f docker-compose.v1.yml up -d
+
+# 3. Restore V1 database from backup (if modified)
+docker compose -f docker-compose.v1.yml exec -T v1-postgres \
+  psql -U nocodb nocodb < backups/v1-postgres-backup.sql
+
+# 4. Verify V1 operational
+curl -I http://localhost:3333/health
+
+# 5. Revert DNS/tunnel
+
+# 6. Announce rollback
+echo "Migration has been rolled back. V1 is operational." | \
+  mail -s "Migration Rollback" admin@cmlite.org
+
+

Post-Rollback Analysis

+
    +
  1. +

    Review Migration Logs +

    cat migration.log | grep ERROR
    +

    +
  2. +
  3. +

    Identify Root Cause

    +
  4. +
  5. Data transformation errors?
  6. +
  7. Database constraint violations?
  8. +
  9. +

    Application bugs?

    +
  10. +
  11. +

    Fix Issues on Staging

    +
  12. +
  13. Update transformation scripts
  14. +
  15. Test again on staging
  16. +
  17. +

    Validate thoroughly

    +
  18. +
  19. +

    Reschedule Migration

    +
  20. +
  21. New downtime window
  22. +
  23. Communicate lessons learned
  24. +
+

Troubleshooting

+

Issue: Prisma Unique Constraint Violation

+

Error: P2002: Unique constraint failed on the constraint: unique_email

+

Cause: Duplicate emails in merged user data.

+

Solution: +

// Before import, deduplicate
+const users = JSON.parse(await fs.readFile('v2-import/users.json', 'utf-8'));
+const unique = handleDuplicates(users);
+await fs.writeFile('v2-import/users.json', JSON.stringify(unique, null, 2));
+

+

Issue: Foreign Key Constraint Violation

+

Error: P2003: Foreign key constraint failed on the field: createdByUserId

+

Cause: Campaign references user that doesn't exist (import order).

+

Solution: Always import in order: +1. Users first +2. Campaigns (references users) +3. Locations (references users) +4. Shifts, responses, etc.

+

Issue: Bcrypt Hashes Not Working

+

Symptoms: Users can't login after migration despite correct password.

+

Cause: Password field truncated or corrupted.

+

Diagnosis: +

-- Check password hash format
+SELECT email, LEFT(password, 10), LENGTH(password) FROM "User" LIMIT 5;
+
+-- Should be: "$2b$10...", length 60
+

+

Solution: +

# Re-import users, ensure password field is text type
+# Or batch reset passwords:
+docker compose exec api node scripts/reset-all-passwords.js
+

+ + +

Next Steps

+

After successful migration:

+
    +
  1. Configure V2 Settings
  2. +
  3. Site Settings
  4. +
  5. Map Settings
  6. +
  7. +

    Email Configuration

    +
  8. +
  9. +

    Train Administrators

    +
  10. +
  11. Admin Guide
  12. +
  13. Campaign Management
  14. +
  15. +

    Volunteer Canvassing

    +
  16. +
  17. +

    Enable New Features

    +
  18. +
  19. Landing Page Builder
  20. +
  21. Email Templates
  22. +
  23. +

    Media Library

    +
  24. +
  25. +

    Set Up Monitoring

    +
  26. +
  27. Observability Guide
  28. +
  29. Backup Procedures
  30. +
+
+

Migration Complete

+

Congratulations on completing your V2 migration! Welcome to the modern Changemaker Lite platform.

+
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/migration/feature-parity/index.html b/mkdocs/site/v2/migration/feature-parity/index.html new file mode 100644 index 00000000..8b40d8eb --- /dev/null +++ b/mkdocs/site/v2/migration/feature-parity/index.html @@ -0,0 +1,6997 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Feature Parity - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Feature Parity: V1 vs V2

+

This document provides a comprehensive comparison of features between Changemaker Lite V1 and V2, including feature status, implementation differences, and migration priorities.

+

Overview

+

V2 achieves 100% feature parity with V1 core functionality and adds significant new capabilities. Some V1 features are implemented differently (better!) in V2.

+
+

V2 Feature Status

+
    +
  • ✅ All V1 Core Features: Campaigns, locations, shifts, response wall
  • +
  • ✅ Enhanced Features: Multi-provider geocoding, canvassing with GPS, monitoring
  • +
  • ✅ New Features: Landing pages, email templates, media library, NAR import
  • +
+
+

Feature Comparison Matrix

+

Core Features

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureV1V2StatusNotes
Email Advocacy CampaignsEnhancedV2 adds BullMQ queue, Listmonk sync
Representative LookupEnhancedV2 adds caching, multi-level support
Response WallEnhancedV2 adds moderation, upvoting, verification
Location ManagementEnhancedV2 adds structured address, geocoding quality
GeocodingEnhancedV1: Nominatim only → V2: 6 providers
Volunteer ShiftsEnhancedV2 adds cut assignments, status tracking
Public Shift SignupSameV2 creates temp users automatically
User ManagementEnhancedV2 adds unified user model, RBAC
Admin AuthenticationChangedV1: Sessions → V2: JWT
+

Map Features

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureV1V2StatusNotes
Location Map (Public)EnhancedV2 adds color-coded markers, cut overlays
Location Map (Admin)EnhancedV2 adds click-to-add, move mode, geolocate
Cuts (Territories)EnhancedV2 adds drawing mode, point-in-polygon
CSV Import/ExportEnhancedV2 adds flexible column mapping
Bulk GeocodingNewV2 adds bulk geocode endpoint
Reverse GeocodingNewV2 adds lat/lng → address lookup
Walk SheetsNewV2 adds printable walk sheets with QR codes
Cut ExportNewV2 adds printable location reports
NAR ImportNewV2 adds Canadian electoral data import
Data Quality DashboardNewV2 adds geocoding quality metrics
+

Canvassing Features

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureV1V2StatusNotes
Canvassing SystemNewV2 adds full canvassing workflow
GPS TrackingNewV2 adds volunteer GPS trail recording
Walking RoutesNewV2 adds optimized route algorithm
Visit RecordingNewV2 adds outcome tracking, notes
Canvass DashboardNewV2 adds admin analytics, leaderboards
Volunteer PortalNewV2 adds dedicated volunteer interface
Activity HistoryNewV2 adds visit history, stats
+

Content Management

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureV1V2StatusNotes
Landing Page BuilderNewV2 adds GrapesJS editor
Block LibraryNewV2 adds reusable content blocks
MkDocs ExportNewV2 adds static site generation
Email TemplatesNewV2 adds template system with versioning
Template VariablesNewV2 adds dynamic content substitution
+

Media Management

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureV1V2StatusNotes
Video LibraryNewV2 adds video CRUD, categories
Video UploadNewV2 adds upload with metadata extraction
Public GalleryNewV2 adds public video gallery
ReactionsNewV2 adds 6 emoji reactions
Video SharingNewV2 adds lock/unlock system
+

Email & Newsletters

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureV1V2StatusNotes
SMTP Email SendingEnhancedV2 adds BullMQ queue, test mode
Email QueueEnhancedV1: Bull → V2: BullMQ with monitoring
Email TrackingEnhancedV2 adds sent/failed stats per campaign
Listmonk IntegrationNewV2 adds newsletter sync
Subscriber ManagementNewV2 adds campaign participant → list sync
+

Monitoring & DevOps

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureV1V2StatusNotes
Prometheus MetricsNewV2 adds 12 custom cm_* metrics
Grafana DashboardsNewV2 adds 3 pre-configured dashboards
AlertmanagerNewV2 adds alert rules, Gotify integration
Health ChecksNewV2 adds Docker healthchecks (7 services)
Backup ScriptEnhancedV2 adds PostgreSQL + Listmonk + uploads
Observability DashboardNewV2 adds admin observability page
+

Platform Services

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureV1V2StatusNotes
NocoDB✅ (data layer)✅ (read-only)ChangedV2 uses Prisma, NocoDB for browsing
RedisEnhancedV2 adds authentication required
PostgreSQL✅ (NocoDB)✅ (direct)EnhancedV2 uses PostgreSQL 16 directly
MkDocsNewV2 adds documentation site
Code ServerNewV2 adds web-based IDE
n8nNewV2 adds workflow automation
GiteaNewV2 adds Git repository hosting
HomepageNewV2 adds service dashboard
Pangolin TunnelNewV2 adds self-hosted tunnel alternative
Cloudflare TunnelRemovedReplaced by Pangolin
+

Detailed Feature Comparisons

+

1. Email Advocacy Campaigns

+

V1 Implementation

+
Features:
+- Create campaign (title, description, slug)
+- Target representatives via postal code lookup
+- Send emails to representatives (SMTP)
+- Track sent emails
+- Basic campaign listing
+
+Technology:
+- NocoDB tables (campaigns, campaign_emails)
+- Bull job queue for async sending
+- Nodemailer SMTP
+- Represent API integration
+
+

V2 Implementation

+
Features:
+- All V1 features plus:
+  - Highlighted campaigns (featured on homepage)
+  - Response wall toggle per campaign
+  - Custom email subject/body templates
+  - Target filtering (level, position, name, email, postal code)
+  - Email stats dashboard (queued, sent, failed, mailto clicks)
+  - BullMQ queue admin (pause, resume, retry failed)
+  - Listmonk newsletter sync (campaign participants → list)
+  - Public campaign gallery
+  - Public campaign detail page
+
+Technology:
+- Prisma models (Campaign, CampaignEmail, Representative, etc.)
+- BullMQ job queue with monitoring
+- Nodemailer SMTP + MailHog test mode
+- Represent API client with in-memory rate limiter (55/min)
+- Redis cache for representatives (60min TTL)
+
+

Migration Impact: V1 campaigns migrate directly. New fields default to sensible values.

+

2. Representative Lookup

+

V1 Implementation

+
Features:
+- Lookup by postal code (Represent API)
+- Display representative name, email, district, party
+- No caching (every lookup hits API)
+
+Limitations:
+- Rate limit issues (API throttling)
+- Slow response times
+- No offline capability
+
+

V2 Implementation

+
Features:
+- All V1 features plus:
+  - Redis cache (60min TTL)
+  - Fire-and-forget cache writes (non-blocking)
+  - Multi-level support (federal, provincial, municipal)
+  - Representative admin (view cache, stats, delete)
+  - Cache stats (total, by level, by party)
+  - Health check endpoint
+
+Performance:
+- First lookup: ~500ms (API call + cache write)
+- Cached lookup: ~20ms (Redis)
+- Rate limiter: 55 requests/min (Represent API limit)
+
+

Migration Impact: Representative cache can be migrated or rebuilt from API.

+

3. Location Management & Geocoding

+

V1 Implementation

+
Features:
+- Create location (single address field)
+- Geocode via Nominatim (single provider)
+- Support level (string field)
+- Public map display (circle markers)
+
+Limitations:
+- No structured address (city, province separate)
+- Single geocoding provider (Nominatim)
+- No geocoding quality tracking
+- No bulk operations
+
+

V2 Implementation

+
Features:
+- All V1 features plus:
+  - Structured address (street, city, province, postal code, country)
+  - Multi-provider geocoding (6 providers with fallback):
+    1. Nominatim (default, free)
+    2. ArcGIS (enterprise)
+    3. Photon (European focus)
+    4. Mapbox (if API key provided)
+    5. Google (if API key provided)
+    6. OpenCage (if API key provided)
+  - Geocoding metadata (provider, quality, timestamp)
+  - Bulk geocoding endpoint (100 at a time)
+  - Reverse geocoding (lat/lng → address)
+  - CSV import with flexible column mapping
+  - CSV export with filters
+  - Location history (edit trail)
+  - Contact fields (name, phone, email)
+  - NAR import (Canadian electoral data, 50k+ locations)
+  - Data quality dashboard (geocoding success rate by provider)
+  - Click-to-add location on map
+  - Drag-to-move location on map
+  - Geolocate button (browser location)
+  - Fullscreen map mode
+
+Technology:
+- Prisma Location model (structured schema)
+- Multi-provider geocoding service with retry logic
+- PostgreSQL spatial extensions (future: PostGIS)
+- React Leaflet map components
+
+

Migration Impact: V1 single address field parsed into structured fields. Geocoding metadata added.

+

4. Volunteer Shifts

+

V1 Implementation

+
Features:
+- Create shift (name, start/end time, location, capacity)
+- Public signup form
+- Email confirmation
+- Admin view signups
+
+Limitations:
+- No cut assignment (shifts not linked to territories)
+- No signup status tracking
+- No volunteer portal
+
+

V2 Implementation

+
Features:
+- All V1 features plus:
+  - Cut assignment (link shift to territory)
+  - Signup status (PENDING, CONFIRMED, CANCELLED, COMPLETED, NO_SHOW)
+  - Shift requirements field
+  - Temp user creation (public signup creates USER with 30-day expiry)
+  - Signup cancellation (volunteer self-service)
+  - Admin signup management (update status, notes)
+  - Email all signups (broadcast to shift volunteers)
+  - Shift stats (total shifts, upcoming, signups by status)
+  - Volunteer portal (view assigned shifts)
+
+Technology:
+- Prisma models (Shift, ShiftSignup with status enum)
+- TEMP user creation (automatic expiry)
+- Email templates for confirmations
+
+

Migration Impact: V1 shifts migrate. Signups extracted to separate table. Status defaults to CONFIRMED.

+

5. Canvassing System (New in V2)

+

V2 Features

+
Complete canvassing workflow:
+- Start/end canvass session (track volunteer time)
+- GPS tracking (real-time trail recording, 30-day retention)
+- Walking route algorithm (nearest-neighbor with haversine distance)
+- Visit recording (outcome, support level, notes, rate-limited 30/min)
+- Visit outcomes:
+  - CONTACT_MADE
+  - NOT_HOME
+  - REFUSED
+  - MOVED
+  - DECEASED
+  - WRONG_ADDRESS
+- Admin dashboard:
+  - Active sessions
+  - Total visits (today, week)
+  - Activity feed (recent visits)
+  - Cut progress (locations visited vs total)
+  - Leaderboard (top volunteers by visits, period filter)
+- Volunteer portal:
+  - Full-screen canvass map
+  - GPS position tracking
+  - Walking route display
+  - Bottom sheet visit recording
+  - Activity history (my visits)
+  - Route history (past sessions)
+
+Technology:
+- Prisma models (CanvassSession, CanvassVisit, TrackingSession, TrackPoint)
+- React Leaflet map with custom controls
+- Zustand canvass store (client state)
+- Abandoned session cleanup (hourly, ACTIVE > 12h → ABANDONED)
+- Stale tracking cleanup (no data for 2h)
+
+

Migration Impact: New feature, no V1 equivalent.

+

6. Landing Page Builder (New in V2)

+

V2 Features

+
Visual page builder:
+- GrapesJS WYSIWYG editor
+- Drag-and-drop block placement
+- Custom block library (Hero, Features, CTA, etc.)
+- Live preview
+- Desktop-only editor (mobile warning)
+- Save hotkey (Ctrl+S)
+
+Page management:
+- CRUD operations (create, edit, delete, publish)
+- Slug-based routing (/p/:slug)
+- Public rendering
+- MkDocs export (Jinja2 Material theme overrides)
+- Export formats: themed (with header/footer) or standalone
+
+Technology:
+- GrapesJS 0.21+
+- Prisma models (LandingPage, PageBlock)
+- React admin UI
+- MkDocs integration (override templates)
+
+

Migration Impact: New feature, no V1 equivalent.

+

7. Email Templates (New in V2)

+

V2 Features

+
Template management:
+- Create templates (HTML + plain text)
+- Template categories (campaign, shift, response, system)
+- Variable substitution ({{campaignTitle}}, {{userName}}, etc.)
+- Version control (publish creates new version)
+- Live preview
+- Test email sending
+
+Admin features:
+- Template library
+- Version history
+- Rollback to previous version
+- Duplicate template
+- Delete template (soft delete)
+
+Technology:
+- Prisma models (EmailTemplate, EmailTemplateVersion)
+- Handlebars-style variable syntax
+- HTML + plain text variants
+
+

Migration Impact: New feature, no V1 equivalent.

+

8. Media Library (New in V2)

+

V2 Features

+
Video management:
+- Upload videos (MP4, MOV, AVI, MKV, WebM, M4V, FLV up to 10GB)
+- Automatic metadata extraction (FFprobe):
+  - Duration, dimensions, orientation
+  - Video quality (resolution-based)
+  - Audio track detection
+- Bulk operations (delete, lock/unlock)
+- Categories (assign to shared gallery)
+- Lock/unlock system (public visibility control)
+
+Public gallery:
+- Category-based filtering
+- Video detail page
+- Reactions (6 emoji types: like, love, laugh, wow, sad, angry)
+- Comment system (future)
+
+Technology:
+- Fastify microservice (port 4100)
+- Drizzle ORM (separate from Prisma)
+- FFprobe metadata extraction (30s timeout)
+- Dual API architecture (media separate from main API)
+
+

Migration Impact: New feature, no V1 equivalent.

+

9. Monitoring Stack (New in V2)

+

V2 Features

+
Metrics collection:
+- 12 custom cm_* metrics:
+  - cm_api_uptime_seconds
+  - cm_emails_sent_total
+  - cm_emails_failed_total
+  - cm_email_queue_size
+  - cm_email_send_duration_seconds
+  - cm_login_attempts_total
+  - cm_active_sessions
+  - cm_campaign_emails_total
+  - cm_response_submissions_total
+  - cm_canvass_visits_total
+  - cm_active_canvass_sessions
+  - cm_shift_signups_total
+  - cm_external_service_up
+
+Dashboards:
+- System Health (CPU, memory, disk, network)
+- Application Overview (API requests, errors, response times)
+- API Performance (endpoint latency, throughput)
+
+Alerts:
+- High error rate (> 5% for 5min)
+- API down
+- High email queue size (> 1000)
+- External service down (NocoDB, Redis, PostgreSQL)
+
+Technology:
+- Prometheus (metrics collection, 15s scrape)
+- Grafana (visualization, 3 dashboards)
+- Alertmanager (alert routing)
+- Gotify (notification delivery, optional)
+- cAdvisor (container metrics)
+- Node Exporter (host metrics)
+- Redis Exporter (Redis metrics)
+
+

Migration Impact: New feature, no V1 equivalent. Enable with --profile monitoring.

+

Feature Status Summary

+

V1 Features in V2

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureV2 StatusImplementation
Campaigns✅ CompleteEnhanced with highlighting, response wall toggle
Representative Lookup✅ CompleteEnhanced with caching, stats
Response Wall✅ CompleteEnhanced with moderation, upvoting
Locations✅ CompleteEnhanced with structured address, multi-provider geocoding
Shifts✅ CompleteEnhanced with cut assignment, status tracking
Public Shift Signup✅ CompleteSame functionality, improved UX
User Management✅ CompleteEnhanced with unified model, RBAC
Email Sending✅ CompleteEnhanced with BullMQ, monitoring
CSV Import/Export✅ CompleteEnhanced with flexible mapping
+

Result: 100% V1 feature parity achieved

+

V1 Features NOT in V2

+ + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureReasonAlternative
NocoDB as primary data layerReplaced by Prisma ORMNocoDB available as read-only browser
Session-based authenticationReplaced by JWTMore scalable, stateless auth
Separate apps (influence, map)Unified into single APIBetter code reuse, consistency
+

V2-Only Features

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureStatusPhase
Landing Page Builder✅ CompletePhase 12
Email Templates✅ CompletePhase 12
Media Library✅ CompletePhase 12
Canvassing System✅ CompletePhase 13
GPS Tracking✅ CompletePhase 13
Walk Sheets✅ CompletePhase 10
NAR Import✅ CompletePhase 14
Data Quality Dashboard✅ CompletePhase 14
Monitoring Stack✅ CompletePhase 14
Pangolin Tunnel✅ CompletePhase 14
Observability Dashboard✅ CompletePhase 14
+

Migration Priority

+

When migrating from V1 to V2, prioritize features in this order:

+

1. Critical (Must Migrate First)

+
    +
  • User Authentication - Foundational for all access
  • +
  • User Management - Admin accounts
  • +
  • Campaigns - Core advocacy feature
  • +
  • Locations - Core mapping feature
  • +
  • Representative Lookup - Core advocacy feature
  • +
+

2. High Priority (Migrate Early)

+
    +
  • Response Wall - Public engagement
  • +
  • Email Sending - Campaign functionality
  • +
  • Shift Management - Volunteer coordination
  • +
  • Public Shift Signup - Volunteer onboarding
  • +
+

3. Medium Priority (Migrate Mid-Phase)

+
    +
  • Representative Cache - Performance optimization
  • +
  • Postal Code Cache - Performance optimization
  • +
  • Cuts (Territories) - Advanced mapping
  • +
  • CSV Import/Export - Bulk operations
  • +
+

4. Low Priority (Migrate Later)

+
    +
  • Email Queue Monitoring - Admin analytics
  • +
  • Campaign Email Tracking - Admin analytics
  • +
  • Representative Admin - Cache management
  • +
+

5. Optional (New V2 Features)

+
    +
  • Landing Pages - Public content
  • +
  • Email Templates - Email customization
  • +
  • Media Library - Video management
  • +
  • Canvassing - Field operations
  • +
  • Monitoring - System observability
  • +
  • NAR Import - Canadian data
  • +
+

Workarounds for Missing Features

+

If you need a V1 feature not yet migrated:

+

1. Run V1 and V2 in Parallel

+
# Keep V1 running for specific features
+docker compose -f docker-compose.v1.yml up -d
+
+# Run V2 for new features
+docker compose up -d
+
+# Use reverse proxy to route by path:
+# /v1/* → V1 apps
+# /v2/* → V2 API
+
+

2. Manual Data Entry

+

For small datasets, manually re-enter data in V2 admin:

+
    +
  • Campaigns: Use Campaigns page (CRUD)
  • +
  • Locations: Use Locations page or CSV import
  • +
  • Shifts: Use Shifts page (CRUD)
  • +
+

3. Custom Migration Scripts

+

For unique V1 customizations, write custom transformation scripts:

+
// scripts/migrate-custom-fields.js
+const customFieldMapping = {
+  v1Field: 'v2Field',
+  // Add your mappings
+};
+
+// Transform and import
+
+

Future Roadmap

+

Planned for V2 Phase 15+

+
    +
  • Multi-tenancy - Multiple organizations per instance
  • +
  • Mobile apps - iOS/Android native apps
  • +
  • Advanced analytics - Campaign performance, volunteer metrics
  • +
  • AI integration - Campaign suggestions, email drafting
  • +
  • Social media integration - Share campaigns, auto-post
  • +
  • SMS campaigns - Text message advocacy
  • +
  • Phone banking - Call tracking, scripts
  • +
  • Donation tracking - Fundraising integration
  • +
  • Event management - Rally, town hall scheduling
  • +
+

Community Feature Requests

+

Vote on features at: https://github.com/changemaker-lite/v2/discussions

+ + +

Next Steps

+
    +
  1. Review feature matrix - Identify features you use
  2. +
  3. Prioritize migration - Critical features first
  4. +
  5. Test on staging - Verify feature parity
  6. +
  7. Provide feedback - Report missing features
  8. +
  9. Plan new feature adoption - Landing pages, canvassing, etc.
  10. +
+
+

Feature Parity Achieved

+

V2 provides 100% V1 feature parity plus significant new capabilities. No functionality will be lost in migration.

+
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/migration/index.html b/mkdocs/site/v2/migration/index.html new file mode 100644 index 00000000..401bb83a --- /dev/null +++ b/mkdocs/site/v2/migration/index.html @@ -0,0 +1,5947 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Migration Guide: V1 to V2 Overview - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Migration Guide: V1 to V2 Overview

+

This comprehensive guide covers the complete migration process from Changemaker Lite V1 to V2, including architectural changes, data migration, and rollback procedures.

+

Overview

+

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.

+

V1 vs V2 at a Glance

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AspectV1V2
ArchitectureTwo separate Express appsSingle unified Express + Fastify API
Data LayerNocoDB REST APIPrisma ORM + PostgreSQL 16
FrontendEmbedded EJS templatesReact SPA (Vite + Ant Design)
AuthenticationSession cookies + bcryptJWT tokens (access + refresh)
API StyleREST via NocoDBREST with Zod validation
State ManagementServer-side sessionsZustand client state + JWT
Job QueueBull (Redis)BullMQ (Redis)
DatabaseNocoDB tablesPrisma migrations
EmailNodemailer + BullBullMQ + Listmonk integration
Ports3333 (influence), 3000 (map)4000 (API), 3000 (admin)
+

Why Migrate to V2?

+

Technical Benefits

+
    +
  1. Unified Codebase: Single API codebase instead of two separate applications
  2. +
  3. Type Safety: Full TypeScript coverage with Prisma type generation
  4. +
  5. Modern Stack: Latest React, Vite build tooling, Ant Design components
  6. +
  7. Better Performance: Direct database access via Prisma vs REST API abstraction
  8. +
  9. Improved Security: JWT refresh token rotation, RBAC, comprehensive audit trail
  10. +
  11. Scalability: Separation of concerns (dual API architecture for media)
  12. +
  13. Developer Experience: Hot reload, better tooling, comprehensive documentation
  14. +
+

Feature Enhancements

+
    +
  1. New Features:
  2. +
  3. Landing page builder with GrapesJS
  4. +
  5. Email template system with versioning
  6. +
  7. Media library with video uploads and reactions
  8. +
  9. Volunteer canvassing system with GPS tracking
  10. +
  11. Data quality dashboard for geocoding
  12. +
  13. Comprehensive monitoring (Prometheus + Grafana)
  14. +
  15. NAR 2025 electoral data import
  16. +
  17. +

    Pangolin tunnel integration

    +
  18. +
  19. +

    Enhanced Existing Features:

    +
  20. +
  21. Response wall with upvoting and moderation
  22. +
  23. Multi-provider geocoding (6 providers)
  24. +
  25. Advanced shift management with cut assignments
  26. +
  27. Printable walk sheets with QR codes
  28. +
  29. +

    Listmonk newsletter sync

    +
  30. +
  31. +

    Improved Admin Experience:

    +
  32. +
  33. Modern React UI with consistent design
  34. +
  35. Real-time updates with optimistic UI
  36. +
  37. Advanced filtering and search
  38. +
  39. Bulk operations
  40. +
  41. Responsive mobile support
  42. +
+

Migration Timeline

+

Planned Phases (from V2_PLAN.md)

+
    +
  • Phase 1-14: ✅ COMPLETE (Foundation through Monitoring)
  • +
  • Phase 15: 🚧 Testing + Polish (current)
  • +
+

Actual Development Timeline

+
    +
  • 2025-01: V2 rebuild initiated (clean-room approach)
  • +
  • 2025-02: Security audit completed, NAR import, media upload
  • +
  • 2026-02: Phase 14 complete, ready for production migration
  • +
+

Migration Duration Estimate

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Migration StepDurationDowntime Required
V1 data export1-2 hoursNo
Data transformation2-4 hoursNo
V2 database setup30 minutesNo
V2 data import1-3 hoursNo
Testing & validation2-4 hoursNo
DNS/service switchover15 minutesYes
Post-migration verification1 hourNo
Total8-15 hours15 minutes
+
+

Minimize Downtime

+

Perform all data export, transformation, and testing on a separate V2 staging environment. Only switch production traffic after full validation.

+
+

Risk Assessment

+

High Risk Areas

+
    +
  1. Data Loss
  2. +
  3. Risk: Campaign data, locations, or user accounts lost during migration
  4. +
  5. Mitigation: Full V1 backup before migration, validation checksums, rollback plan
  6. +
  7. +

    Impact: High (business-critical data)

    +
  8. +
  9. +

    Authentication Disruption

    +
  10. +
  11. Risk: Users unable to login after migration (password hash incompatibility)
  12. +
  13. Mitigation: Test password migration with sample users, password reset flow ready
  14. +
  15. +

    Impact: High (blocks all access)

    +
  16. +
  17. +

    Email Delivery Failure

    +
  18. +
  19. Risk: Campaign emails stop sending after migration
  20. +
  21. Mitigation: Test SMTP configuration, BullMQ queue verification, MailHog testing
  22. +
  23. Impact: High (core feature)
  24. +
+

Medium Risk Areas

+
    +
  1. Representative Data
  2. +
  3. Risk: Cached representative data doesn't migrate correctly
  4. +
  5. Mitigation: Cache can be rebuilt from Represent API, non-critical
  6. +
  7. +

    Impact: Medium (cacheable data)

    +
  8. +
  9. +

    Location Geocoding

    +
  10. +
  11. Risk: Geocoded coordinates lost or corrupted
  12. +
  13. Mitigation: V2 multi-provider geocoding can re-geocode, bulk geocode endpoint
  14. +
  15. +

    Impact: Medium (can be re-geocoded)

    +
  16. +
  17. +

    Shift Signups

    +
  18. +
  19. Risk: Volunteer shift assignments lost
  20. +
  21. Mitigation: Export signups separately, manual verification, confirmation emails
  22. +
  23. Impact: Medium (time-sensitive data)
  24. +
+

Low Risk Areas

+
    +
  1. Response Wall Data
  2. +
  3. Risk: Public responses or upvotes lost
  4. +
  5. Mitigation: CSV export, manual re-entry if needed
  6. +
  7. +

    Impact: Low (public-facing only)

    +
  8. +
  9. +

    Custom Settings

    +
  10. +
  11. Risk: V1 settings don't map to V2 schema
  12. +
  13. Mitigation: Manual reconfiguration in V2 SettingsPage
  14. +
  15. Impact: Low (quick to reconfigure)
  16. +
+

Rollback Plan

+

If Migration Fails

+
    +
  1. +

    Immediate Actions (within 15 minutes): +

    # Stop V2 services
    +docker compose down
    +
    +# Restore V1 services
    +docker compose -f docker-compose.v1.yml up -d
    +
    +# Restore DNS (point back to V1)
    +# Update tunnel/proxy configuration
    +

    +
  2. +
  3. +

    Data Restoration (if V2 data was modified): +

    # Restore V1 database from backup
    +docker compose -f docker-compose.v1.yml exec -T v1-postgres \
    +  psql -U nocodb nocodb < backups/v1-nocodb-backup.sql
    +
    +# Verify data integrity
    +docker compose -f docker-compose.v1.yml logs -f
    +

    +
  4. +
  5. +

    Verification:

    +
  6. +
  7. Test V1 login
  8. +
  9. Verify campaign data visible
  10. +
  11. Check location map loads
  12. +
  13. Send test campaign email
  14. +
  15. Verify response wall displays
  16. +
+

Rollback Window

+
    +
  • First 24 hours: Simple rollback (V1 backup unchanged)
  • +
  • After 24 hours: Complex rollback (may need to merge V2 changes back to V1)
  • +
  • After 1 week: Rollback not recommended (significant V2 data divergence)
  • +
+
+

Rollback Deadline

+

Plan your migration with a clear rollback deadline. After this window, V2 becomes the source of truth.

+
+

Support Resources

+

Documentation

+ +

Community & Support

+
    +
  • GitHub Issues: Report bugs or migration problems
  • +
  • Discussions: Ask questions, share migration experiences
  • +
  • Email: support@cmlite.org for direct assistance
  • +
+

Professional Services

+

For organizations requiring: +- Custom data migration scripts +- Zero-downtime migration +- Training for administrators +- Priority support during migration

+

Contact: enterprise@cmlite.org

+

Prerequisites

+

Before beginning migration, ensure you have:

+

V1 Environment

+
    +
  • V1 backup completed (database + uploads)
  • +
  • V1 environment variables documented (.env file)
  • +
  • V1 access credentials (NocoDB admin, database passwords)
  • +
  • V1 running and healthy (all services operational)
  • +
  • V1 data export tested (able to export NocoDB tables)
  • +
+

V2 Environment

+
    +
  • V2 repository cloned (git checkout v2)
  • +
  • Docker and Docker Compose installed (20.10+, 2.0+)
  • +
  • PostgreSQL 16 compatible (for V2 database)
  • +
  • 4GB+ RAM available (8GB recommended)
  • +
  • 20GB+ disk space (for database + uploads)
  • +
+

Migration Planning

+
    +
  • Downtime window scheduled (notify users)
  • +
  • Rollback plan reviewed (tested on staging)
  • +
  • Team assigned (minimum 2 people recommended)
  • +
  • Backup storage ready (S3 bucket or local storage)
  • +
  • Testing checklist prepared (critical workflows to verify)
  • +
+

Migration Steps Overview

+

This is a high-level overview. Detailed steps are in Data Migration.

+

Phase 1: Preparation (No Downtime)

+
    +
  1. +

    Export V1 Data +

    # Export all NocoDB tables to JSON
    +./scripts/export-v1-data.sh
    +
    +# Backup file uploads
    +tar -czf v1-uploads.tar.gz ./uploads/
    +

    +
  2. +
  3. +

    Set Up V2 Environment +

    git checkout v2
    +cp .env.example .env
    +# Edit .env with V2 configuration
    +

    +
  4. +
  5. +

    Start V2 Services (parallel to V1) +

    docker compose up -d v2-postgres redis
    +docker compose exec api npx prisma migrate deploy
    +

    +
  6. +
+

Phase 2: Data Transformation (No Downtime)

+
    +
  1. +

    Transform V1 Data for V2 +

    # Run transformation scripts
    +node scripts/transform-users.js
    +node scripts/transform-campaigns.js
    +node scripts/transform-locations.js
    +

    +
  2. +
  3. +

    Import into V2 Database +

    # Import transformed data
    +docker compose exec api node scripts/import-data.js
    +

    +
  4. +
  5. +

    Validate Data Integrity +

    # Compare record counts
    +docker compose exec api node scripts/validate-migration.js
    +

    +
  6. +
+

Phase 3: Testing (No Downtime)

+
    +
  1. Test V2 Functionality
  2. +
  3. Login with test users (verify password migration)
  4. +
  5. View campaigns, locations, shifts
  6. +
  7. Submit test response
  8. +
  9. Send test email
  10. +
  11. +

    Check admin permissions

    +
  12. +
  13. +

    Performance Testing

    +
  14. +
  15. Load campaigns page (check query performance)
  16. +
  17. Geocode sample addresses
  18. +
  19. Test map rendering with all locations
  20. +
  21. Verify Redis caching
  22. +
+

Phase 4: Switchover (15 Minutes Downtime)

+
    +
  1. +

    Enable Maintenance Mode (V1) +

    # Stop V1 services
    +docker compose -f docker-compose.v1.yml down
    +

    +
  2. +
  3. +

    Start V2 Services +

    # Start all V2 services
    +docker compose up -d
    +

    +
  4. +
  5. +

    Update DNS/Proxy

    +
      +
    • Point cmlite.org to V2 nginx
    • +
    • Update Pangolin tunnel endpoint
    • +
    • Verify SSL certificates
    • +
    +
  6. +
+

Phase 5: Verification (Post-Migration)

+
    +
  1. +

    Smoke Tests

    +
      +
    • Admin login works
    • +
    • Campaign list loads
    • +
    • Location map renders
    • +
    • Email sending functional
    • +
    • Response wall displays
    • +
    +
  2. +
  3. +

    Monitor for Issues +

    # Watch logs for errors
    +docker compose logs -f api admin
    +
    +# Check metrics
    +open http://localhost:3001  # Grafana
    +

    +
  4. +
  5. +

    Announce Migration Complete

    +
      +
    • Email all users with V2 login URL
    • +
    • Update documentation links
    • +
    • Monitor support channels
    • +
    +
  6. +
+

Post-Migration Checklist

+

After successful migration, complete these tasks:

+

Immediate (Day 1)

+
    +
  • Verify all user accounts can login
  • +
  • Test campaign email sending (real SMTP, not MailHog)
  • +
  • Confirm location geocoding works
  • +
  • Check shift signup flow (public)
  • +
  • Verify response wall displays correctly
  • +
  • Test admin CRUD operations (create campaign, location, shift)
  • +
  • Monitor error logs for exceptions
  • +
  • Verify Prometheus metrics collecting
  • +
+

First Week

+
    +
  • Review Grafana dashboards for anomalies
  • +
  • Check BullMQ job queue (no stuck jobs)
  • +
  • Verify geocoding cache hit rate
  • +
  • Test all user roles (SUPER_ADMIN, MAP_ADMIN, etc.)
  • +
  • Confirm Listmonk sync working (if enabled)
  • +
  • Validate backup script runs successfully
  • +
  • Review user feedback and support tickets
  • +
+

First Month

+
    +
  • Optimize slow queries (check Prometheus API duration metrics)
  • +
  • Review disk usage (PostgreSQL, uploads, logs)
  • +
  • Audit user permissions (remove temp accounts)
  • +
  • Update documentation based on issues encountered
  • +
  • Train administrators on new V2 features
  • +
  • Plan rollout of new features (landing pages, canvassing)
  • +
  • Schedule security audit
  • +
+

Common Migration Scenarios

+

Scenario 1: Small Organization (< 1000 locations)

+
    +
  • Migration Duration: 4-6 hours
  • +
  • Downtime: 10 minutes
  • +
  • Recommended Approach:
  • +
  • Export V1 data Friday evening
  • +
  • Transform and import over weekend
  • +
  • Test Saturday/Sunday
  • +
  • Switchover Monday morning
  • +
  • Rollback window: 48 hours
  • +
+

Scenario 2: Medium Organization (1000-10000 locations)

+
    +
  • Migration Duration: 8-12 hours
  • +
  • Downtime: 15 minutes
  • +
  • Recommended Approach:
  • +
  • Set up V2 staging environment 1 week prior
  • +
  • Perform test migration on staging
  • +
  • Document issues and solutions
  • +
  • Schedule production migration for low-traffic period
  • +
  • Rollback window: 24 hours
  • +
+

Scenario 3: Large Organization (10000+ locations)

+
    +
  • Migration Duration: 12-20 hours
  • +
  • Downtime: 20-30 minutes
  • +
  • Recommended Approach:
  • +
  • Hire professional services (enterprise@cmlite.org)
  • +
  • Perform multiple test migrations on staging
  • +
  • Use incremental data sync (minimize final catchup)
  • +
  • Blue-green deployment (parallel V1/V2 for 1 week)
  • +
  • Rollback window: 1 week with data sync
  • +
+

Scenario 4: Active Campaign During Migration

+

Problem: Can't afford downtime during critical campaign period.

+

Solution: +1. Set up V2 as read-only mirror (import V1 data, disable writes) +2. Continue using V1 for all active operations +3. Schedule final catchup migration after campaign concludes +4. Or: Use blue-green deployment with manual data sync

+
+

Active Campaign Warning

+

Do NOT migrate during active campaign periods. Schedule migration between campaigns or during organizational downtime.

+
+

Migration Validation Checklist

+

Use this checklist to verify successful migration:

+

Data Integrity

+
    +
  • User count matches: V1 users = V2 users (excluding duplicates)
  • +
  • Campaign count matches: V1 campaigns = V2 campaigns
  • +
  • Location count matches: V1 locations = V2 locations
  • +
  • Shift count matches: V1 shifts = V2 shifts
  • +
  • Response count matches: V1 responses = V2 responses
  • +
  • Representative cache count: V1 reps = V2 reps (approximate, can refresh)
  • +
+

Functional Testing

+
    +
  • Login works: Test with 5 different user accounts
  • +
  • Password authentication: All migrated passwords validate correctly
  • +
  • Campaign email sends: Queue job, verify SMTP delivery
  • +
  • Representative lookup: Postal code returns correct reps
  • +
  • Location geocoding: Bulk geocode 10 addresses successfully
  • +
  • Map rendering: All locations display on map
  • +
  • Shift signup: Public user can sign up for shift
  • +
  • Response submission: Can submit and view responses
  • +
  • Admin CRUD: Create, edit, delete test records
  • +
+

Performance Testing

+
    +
  • Campaign list loads < 2 seconds: 100+ campaigns
  • +
  • Location map loads < 3 seconds: 1000+ locations
  • +
  • Search response time < 500ms: User, campaign, location search
  • +
  • Geocoding batch < 30 seconds: 100 addresses
  • +
  • Email queue processing: 10 emails/minute minimum
  • +
  • No N+1 queries: Check Prisma logs for query count
  • +
+

Security Testing

+
    +
  • JWT authentication works: Access + refresh token flow
  • +
  • RBAC enforced: SUPER_ADMIN vs USER vs TEMP roles
  • +
  • Rate limiting active: Auth endpoints limited to 10/min
  • +
  • Password policy enforced: 12+ chars, complexity requirements
  • +
  • Redis authenticated: Connection requires password
  • +
  • Encryption key set: ENCRYPTION_KEY env var different from JWT secrets
  • +
+

Troubleshooting Migration Issues

+

Common problems and solutions:

+

Issue: User Login Fails After Migration

+

Symptoms: Users receive "Invalid credentials" error despite correct password.

+

Causes: +- Bcrypt hash corruption during export/import +- Password field length truncation +- Character encoding issues

+

Solutions: +

# Check password hash format in V2
+docker compose exec api npx prisma studio
+# User table → password field should start with $2b$
+
+# Reset affected user password
+docker compose exec api node scripts/reset-password.js user@example.com
+

+

Issue: Missing Data After Import

+

Symptoms: User count, campaign count, or location count lower than V1.

+

Causes: +- Incomplete V1 export (pagination issues) +- Transformation script errors (check logs) +- Unique constraint violations (duplicates skipped)

+

Solutions: +

# Compare record counts
+docker compose exec api node scripts/compare-counts.js
+
+# Re-run import for specific table
+docker compose exec api node scripts/import-data.js --table=users
+
+# Check import logs for errors
+docker compose logs api | grep ERROR
+

+

Issue: Geocoding Data Lost

+

Symptoms: Locations missing latitude/longitude coordinates.

+

Causes: +- V1 geocoding provider different from V2 +- Coordinates not exported from V1 +- Transformation script didn't map geocoding fields

+

Solutions: +

# Bulk re-geocode all locations
+curl -X POST http://localhost:4000/api/map/locations/bulk-geocode \
+  -H "Authorization: Bearer $ADMIN_TOKEN"
+
+# Check geocoding provider configuration
+docker compose exec api node scripts/test-geocoding.js
+

+

Issue: Campaign Emails Not Sending

+

Symptoms: BullMQ queue shows "failed" jobs.

+

Causes: +- SMTP configuration incorrect +- EMAIL_TEST_MODE still enabled (sends to MailHog) +- Nodemailer authentication failure

+

Solutions: +

# Check SMTP configuration
+docker compose exec api node scripts/test-smtp.js
+
+# View failed job details
+# Visit http://localhost:4000/api/influence/email-queue/stats
+
+# Retry failed jobs
+curl -X POST http://localhost:4000/api/influence/email-queue/retry-failed \
+  -H "Authorization: Bearer $ADMIN_TOKEN"
+

+

Issue: High Memory Usage After Migration

+

Symptoms: V2 services consuming > 4GB RAM, slow response times.

+

Causes: +- Prisma connection pool too large +- Redis cache not evicting old entries +- Large JSON fields in database (campaign data, page blocks)

+

Solutions: +

# Reduce Prisma connection pool
+# Edit .env: DATABASE_URL="...?connection_limit=5"
+
+# Clear Redis cache
+docker compose exec redis redis-cli FLUSHDB
+
+# Optimize database
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c "VACUUM ANALYZE;"
+

+ +

Migration Guides

+ +

V2 Setup Guides

+ +

V2 Architecture

+ +

Post-Migration

+ +

Next Steps

+

Ready to begin migration?

+
    +
  1. Review Breaking Changes - Understand all V1→V2 differences
  2. +
  3. Plan Data Migration - Create migration timeline
  4. +
  5. Set Up V2 Staging - Test environment
  6. +
  7. Perform Test Migration - Validate process
  8. +
  9. Execute Production Migration - Go live
  10. +
+
+

Migration Support

+

Need help with your migration? Email support@cmlite.org or open a GitHub discussion.

+
+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/troubleshooting/auth-issues/index.html b/mkdocs/site/v2/troubleshooting/auth-issues/index.html new file mode 100644 index 00000000..adc63d99 --- /dev/null +++ b/mkdocs/site/v2/troubleshooting/auth-issues/index.html @@ -0,0 +1,8146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Auth Issues - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Authentication and Authorization Issues

+

This guide covers authentication (who you are) and authorization (what you can do) problems in Changemaker Lite V2.

+

Overview

+

Authentication System

+

Changemaker Lite V2 uses JWT-based authentication:

+
    +
  • Access tokens - Short-lived (15 minutes), stored in memory
  • +
  • Refresh tokens - Long-lived (7 days), stored in database
  • +
  • bcrypt passwords - Hashed with salt (10 rounds)
  • +
  • Token rotation - New refresh token on each refresh
  • +
+

Authorization System

+

Role-based access control (RBAC) with 5 roles:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RoleLevelPermissions
SUPER_ADMIN5Full access to everything
INFLUENCE_ADMIN4Manage campaigns, responses, email queue
MAP_ADMIN3Manage locations, cuts, shifts, canvass
USER2View public content, canvass (if assigned shift)
TEMP1Very limited - shift signup confirmation only
+

Security Features

+
    +
  • Password policy - 12+ chars, uppercase, lowercase, digit
  • +
  • User enumeration prevention - Generic error messages
  • +
  • Rate limiting - 10 requests/min on auth endpoints
  • +
  • Refresh token rotation - Atomic transaction prevents race conditions
  • +
  • Redis authentication - Required password for Redis connection
  • +
+
+

Login Failures

+

Invalid Credentials

+

Severity: 🟡 Medium

+

Symptoms

+
{
+  "error": "Unauthorized",
+  "message": "Invalid credentials"
+}
+
+

Same message for: +- User not found +- Wrong password +- User suspended

+

This is intentional (prevents user enumeration).

+

Common Causes

+
    +
  1. Wrong password - Password incorrect
  2. +
  3. User doesn't exist - Email not registered
  4. +
  5. Typo in email - Email address wrong
  6. +
  7. Account suspended - User marked as suspended
  8. +
+

Solutions

+

Solution 1: Verify user exists

+
# Check database
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT email, role FROM \"User\" WHERE email = 'user@example.com';"
+
+# If no result, user doesn't exist
+
+

Solution 2: Reset password

+
# Generate bcrypt hash for new password
+docker compose exec api node -e "
+const bcrypt = require('bcryptjs');
+const hash = bcrypt.hashSync('NewPassword123!', 10);
+console.log(hash);
+"
+
+# Update password
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "UPDATE \"User\" SET password = '\$2a\$10\$...' WHERE email = 'user@example.com';"
+
+

Solution 3: Create missing user

+
# Via API
+curl -X POST http://localhost:4000/api/auth/register \
+  -H "Content-Type: application/json" \
+  -d '{
+    "email": "user@example.com",
+    "password": "SecurePass123!",
+    "name": "User Name"
+  }'
+
+# Or via admin UI at /app/users
+
+

Solution 4: Check for suspended account

+
# Check user status
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT email, role, \"createdAt\" FROM \"User\" WHERE email = 'user@example.com';"
+
+# If suspended field exists and is true, unsuspend:
+# (Note: V2 doesn't have suspended field yet, but may be added)
+
+

Solution 5: Check password requirements

+

Password must meet requirements: +- 12+ characters +- At least 1 uppercase letter +- At least 1 lowercase letter +- At least 1 digit

+
# Valid examples:
+SecurePass123!
+MyP@ssword99
+Admin12345678
+
+

Prevention

+
    +
  • Password manager - Use password manager to avoid typos
  • +
  • Password reset flow - Implement forgot password feature
  • +
  • Clear requirements - Display password requirements on register
  • +
  • User-friendly errors - Guide users without revealing if email exists
  • +
+
+

User Enumeration

+

The same error message for "user not found" and "wrong password" is intentional security behavior to prevent attackers from discovering valid email addresses.

+
+
+

Account Suspended

+

Severity: 🟠 High

+

Symptoms

+
{
+  "error": "Forbidden",
+  "message": "Account suspended"
+}
+
+

User can't log in even with correct credentials.

+

Common Causes

+
    +
  1. Manual suspension - Admin suspended account
  2. +
  3. Security violation - Account flagged for suspicious activity
  4. +
  5. Terms violation - Account suspended for policy violation
  6. +
+

Solutions

+

Solution 1: Check suspension status

+
# Check if user has suspended flag (if implemented)
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT email, role FROM \"User\" WHERE email = 'user@example.com';"
+
+

Solution 2: Unsuspend account

+
# If suspension field exists:
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "UPDATE \"User\" SET suspended = false WHERE email = 'user@example.com';"
+
+# Or delete user and recreate
+
+

Solution 3: Contact administrator

+

If you're a user: +1. Contact system administrator +2. Provide your email address +3. Wait for account review

+

Prevention

+
    +
  • Suspension policy - Clear policy on suspension reasons
  • +
  • Appeal process - Allow users to appeal suspensions
  • +
  • Audit trail - Log suspension reasons and who suspended
  • +
+
+

V2 Status

+

V2 doesn't currently have a suspended field. This section is for future implementation or if added via custom migration.

+
+
+

Email Not Verified

+

Severity: 🟢 Low

+

Symptoms

+
{
+  "error": "Forbidden",
+  "message": "Email not verified. Please check your email for verification link."
+}
+
+

Common Causes

+
    +
  1. Email not verified - User didn't click verification link
  2. +
  3. Verification email not received - Email went to spam
  4. +
  5. Verification link expired - Link older than 24 hours
  6. +
+

Solutions

+

Solution 1: Check verification status

+
# Check if emailVerified field exists
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT email, \"createdAt\" FROM \"User\" WHERE email = 'user@example.com';"
+
+

Solution 2: Manually verify email

+
# Mark email as verified
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "UPDATE \"User\" SET \"emailVerified\" = true WHERE email = 'user@example.com';"
+
+

Solution 3: Resend verification email

+
# Via API (if endpoint exists)
+curl -X POST http://localhost:4000/api/auth/resend-verification \
+  -H "Content-Type: application/json" \
+  -d '{"email": "user@example.com"}'
+
+

Solution 4: Check spam folder

+

Verification emails may be marked as spam. Check: +1. Spam/Junk folder +2. Promotions tab (Gmail) +3. Email filters

+

Prevention

+
    +
  • Clear instructions - Tell users to check spam
  • +
  • From address - Use recognizable from address
  • +
  • SPF/DKIM/DMARC - Configure email authentication
  • +
  • Resend option - Easy way to resend verification
  • +
+
+

V2 Status

+

V2 doesn't currently require email verification for login. This section is for future implementation.

+
+
+

Token Issues

+

Token Expired

+

Severity: 🟡 Medium

+

Symptoms

+
{
+  "error": "Unauthorized",
+  "message": "Token expired"
+}
+
+

Or:

+
Error: jwt expired
+
+

Common Causes

+
    +
  1. Access token expired - Normal after 15 minutes inactive
  2. +
  3. Refresh token expired - After 7 days
  4. +
  5. System clock skew - Server/client time difference
  6. +
+

Solutions

+

Solution 1: Frontend auto-refresh

+

Frontend automatically refreshes tokens on 401. If this fails:

+
// Check refresh token in localStorage
+const storage = JSON.parse(localStorage.getItem('auth-storage'));
+console.log('Has refresh token:', !!storage?.state?.refreshToken);
+
+// If missing, need to log in again
+
+

Solution 2: Manual refresh

+
# Refresh token via API
+curl -X POST http://localhost:4000/api/auth/refresh \
+  -H "Content-Type: application/json" \
+  -d '{"refreshToken": "YOUR_REFRESH_TOKEN"}'
+
+# Returns new access + refresh tokens
+
+

Solution 3: Check token expiration

+
// Decode JWT to see expiration
+const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
+const payload = JSON.parse(atob(token.split('.')[1]));
+console.log('Expires:', new Date(payload.exp * 1000));
+console.log('Now:', new Date());
+
+

Solution 4: Check system time

+
# On server
+docker compose exec api date
+
+# Should match actual time
+# If not, sync clock:
+sudo ntpdate -s time.nist.gov
+
+

Solution 5: Log in again

+

If refresh token also expired:

+
    +
  1. You'll be redirected to login automatically
  2. +
  3. Log in with email/password
  4. +
  5. New tokens issued
  6. +
+

Prevention

+
    +
  • Sliding sessions - Auto-refresh extends session
  • +
  • Long refresh window - 7-day refresh token validity
  • +
  • Activity tracking - Keep track of last activity
  • +
  • Clock sync - Keep server time accurate
  • +
+
+

Invalid Token

+

Severity: 🟠 High

+

Symptoms

+
{
+  "error": "Unauthorized",
+  "message": "Invalid token"
+}
+
+

Or:

+
Error: jwt malformed
+Error: invalid signature
+Error: invalid algorithm
+
+

Common Causes

+
    +
  1. Corrupted token - LocalStorage corruption
  2. +
  3. Wrong secret - JWT_ACCESS_SECRET changed
  4. +
  5. Tampered token - Someone modified the token
  6. +
  7. Format error - Not a valid JWT
  8. +
+

Solutions

+

Solution 1: Verify JWT format

+

Valid JWT has 3 parts separated by dots:

+
const token = 'header.payload.signature';
+console.log(token.split('.').length); // Should be 3
+
+// Example valid token:
+// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
+// eyJpZCI6IjEyMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInJvbGUiOiJVU0VSIiwiaWF0IjoxNzA4MDAwMDAwLCJleHAiOjE3MDgwMDA5MDB9.
+// signature-here
+
+

Solution 2: Clear localStorage and re-login

+
// In browser console
+localStorage.clear();
+// Then log in again
+
+

Solution 3: Verify JWT_ACCESS_SECRET

+
# Check API .env
+docker compose exec api sh -c 'echo $JWT_ACCESS_SECRET'
+
+# Should be:
+# - At least 32 characters
+# - Never changed (changing invalidates all tokens)
+# - Different from JWT_REFRESH_SECRET
+
+

Solution 4: Test token verification

+
# Test token via API
+curl http://localhost:4000/api/auth/me \
+  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
+
+# If returns user, token is valid
+# If 401, token is invalid
+
+

Solution 5: Check API logs

+
# View token validation errors
+docker compose logs api | grep -i "jwt\|token"
+
+# Common errors:
+# - "jwt malformed" - not a valid JWT format
+# - "invalid signature" - wrong secret or tampered
+# - "invalid algorithm" - algorithm mismatch
+
+

Prevention

+
    +
  • Secure secrets - Use strong, random secrets
  • +
  • Never change secrets - Changing logs out all users
  • +
  • Don't expose secrets - Never commit to git
  • +
  • Token validation - Validate before using
  • +
+
+

Token Not Found in Header

+

Severity: 🟡 Medium

+

Symptoms

+
{
+  "error": "Unauthorized",
+  "message": "No token provided"
+}
+
+

Or:

+
{
+  "error": "Unauthorized",
+  "message": "Invalid authorization header format"
+}
+
+

Common Causes

+
    +
  1. Missing Authorization header - Header not sent
  2. +
  3. Wrong header format - Not "Bearer token"
  4. +
  5. Token not in localStorage - User not logged in
  6. +
  7. API client misconfigured - axios interceptor not working
  8. +
+

Solutions

+

Solution 1: Check if logged in

+
// In browser console
+const storage = JSON.parse(localStorage.getItem('auth-storage'));
+console.log('Access token:', storage?.state?.accessToken);
+
+// If null, not logged in
+
+

Solution 2: Verify header format

+
# Correct format
+curl http://localhost:4000/api/users \
+  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+
+# Wrong formats:
+# -H "Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."  # Missing "Bearer"
+# -H "Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."       # Wrong header name
+# -H "Authorization: Token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."  # Wrong prefix
+
+

Solution 3: Check axios interceptor

+

In admin/src/lib/api.ts:

+
// Request interceptor should add token
+api.interceptors.request.use((config) => {
+  const storage = JSON.parse(localStorage.getItem('auth-storage') || '{}');
+  const token = storage?.state?.accessToken;
+
+  if (token) {
+    config.headers.Authorization = `Bearer ${token}`;
+  }
+
+  return config;
+});
+
+

Solution 4: Test with curl

+
# Get token from localStorage (browser console)
+const token = JSON.parse(localStorage.getItem('auth-storage')).state.accessToken;
+console.log(token);
+
+# Test with curl
+curl http://localhost:4000/api/users \
+  -H "Authorization: Bearer [paste-token-here]"
+
+

Solution 5: Log in again

+
# If all else fails, log out and log in
+localStorage.clear();
+# Navigate to /login
+
+

Prevention

+
    +
  • axios interceptor - Automatically add token to requests
  • +
  • Error handling - Redirect to login on 401
  • +
  • Token persistence - Store token in localStorage
  • +
  • Testing - Test auth flow regularly
  • +
+
+

Refresh Token Invalid

+

Severity: 🟠 High

+

Symptoms

+
{
+  "error": "Unauthorized",
+  "message": "Invalid refresh token"
+}
+
+

Auto-refresh fails, user logged out.

+

Common Causes

+
    +
  1. Refresh token expired - Older than 7 days
  2. +
  3. Token revoked - User logged out explicitly
  4. +
  5. Token not in database - Database was reset
  6. +
  7. Token rotation - Token already used (consumed)
  8. +
+

Solutions

+

Solution 1: Check refresh token in database

+
# Find refresh token
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT id, \"userId\", \"expiresAt\", \"createdAt\" FROM \"RefreshToken\"
+      WHERE token = 'YOUR_REFRESH_TOKEN_HASH';"
+
+# If no result, token doesn't exist (revoked or expired)
+
+

Solution 2: Check expiration

+
# Find all refresh tokens for user
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT id, \"expiresAt\", \"createdAt\" FROM \"RefreshToken\"
+      WHERE \"userId\" = 'USER_UUID'
+      ORDER BY \"createdAt\" DESC;"
+
+# Check if expiresAt < NOW()
+
+

Solution 3: Log in again

+

Refresh token can't be renewed. Must log in with email/password:

+
curl -X POST http://localhost:4000/api/auth/login \
+  -H "Content-Type: application/json" \
+  -d '{
+    "email": "user@example.com",
+    "password": "SecurePass123!"
+  }'
+
+

Solution 4: Clear old refresh tokens

+
# Delete expired refresh tokens
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "DELETE FROM \"RefreshToken\" WHERE \"expiresAt\" < NOW();"
+
+# Or delete all refresh tokens for user (logs out all devices)
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "DELETE FROM \"RefreshToken\" WHERE \"userId\" = 'USER_UUID';"
+
+

Prevention

+
    +
  • Long expiration - 7-day refresh token validity
  • +
  • Token rotation - New refresh token on each refresh
  • +
  • Cleanup job - Delete expired tokens periodically
  • +
  • Multi-device support - Multiple refresh tokens per user
  • +
+
+

Permission Errors

+

Insufficient Permissions

+

Severity: 🟡 Medium

+

Symptoms

+
{
+  "error": "Forbidden",
+  "message": "Insufficient permissions"
+}
+
+

Or role-specific:

+
{
+  "error": "Forbidden",
+  "message": "Requires one of: SUPER_ADMIN, MAP_ADMIN"
+}
+
+

Common Causes

+
    +
  1. Wrong role - User doesn't have required role
  2. +
  3. TEMP user - Temporary user trying to access admin features
  4. +
  5. Feature disabled - Feature flag not enabled
  6. +
  7. Endpoint restricted - Endpoint requires specific role
  8. +
+

Solutions

+

Solution 1: Check user role

+
# View user role
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT email, role FROM \"User\" WHERE email = 'user@example.com';"
+
+# Roles:
+# SUPER_ADMIN - full access
+# INFLUENCE_ADMIN - campaigns/responses
+# MAP_ADMIN - locations/cuts/shifts
+# USER - public content + canvass
+# TEMP - very limited
+
+

Solution 2: Update user role

+
# Via Prisma Studio (recommended)
+docker compose exec api npx prisma studio
+# Navigate to User table, edit role field
+
+# Or via SQL
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "UPDATE \"User\" SET role = 'MAP_ADMIN' WHERE email = 'user@example.com';"
+
+

Solution 3: Check endpoint requirements

+

In API code (api/src/modules/*/routes.ts):

+
// Example from campaigns.routes.ts
+router.post('/',
+  authenticate,  // Must be logged in
+  requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'),  // Must be admin
+  validate(createCampaignSchema),
+  campaignsController.create
+);
+
+

Solution 4: Verify feature flags

+
# Check .env for feature flags
+cat .env | grep ENABLE
+
+# Example:
+ENABLE_MEDIA_FEATURES=true
+LISTMONK_SYNC_ENABLED=true
+
+

Solution 5: Check TEMP user restrictions

+

TEMP users are created during shift signup and have very limited permissions:

+
// TEMP users blocked by requireNonTemp middleware
+router.get('/my-data',
+  authenticate,
+  requireNonTemp,  // Blocks TEMP users
+  controller.getData
+);
+
+

To convert TEMP to USER:

+
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "UPDATE \"User\" SET role = 'USER' WHERE email = 'temp@example.com';"
+
+

Prevention

+
    +
  • Clear role descriptions - Document what each role can do
  • +
  • Role matrix - Table showing role → permission mapping
  • +
  • Upgrade flow - Easy way for users to upgrade from TEMP to USER
  • +
  • Helpful errors - Show which role is required
  • +
+
+

Role Restrictions

+

Severity: 🟢 Low

+

Symptoms

+

User logged in but can't access certain features.

+

Common Causes

+
    +
  1. Not enough permissions - Role too low for feature
  2. +
  3. Feature flag - Feature not enabled
  4. +
  5. TEMP user - Temporary account with restrictions
  6. +
+

Solutions

+

Solution 1: View role permissions

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureSUPER_ADMININFLUENCE_ADMINMAP_ADMINUSERTEMP
User management
Settings
Campaigns
Responses
Email queue
Locations
Cuts
Shifts
Canvass dashboard
Public campaigns
Public shifts
Volunteer canvass
+

Solution 2: Request role upgrade

+

If you need higher permissions: +1. Contact system administrator +2. Explain why you need the role +3. Wait for approval and role change

+

Solution 3: Create admin account

+

For first admin (if none exist):

+
# Connect to database
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2
+
+# Create SUPER_ADMIN
+# (Password must be pre-hashed with bcrypt)
+INSERT INTO "User" (id, email, password, name, role)
+VALUES (
+  gen_random_uuid(),
+  'admin@example.com',
+  '$2a$10$...',  -- bcrypt hash of 'Admin123!'
+  'System Admin',
+  'SUPER_ADMIN'
+);
+
+

Prevention

+
    +
  • Document roles - Clear description of each role
  • +
  • Role request process - Easy way to request role upgrade
  • +
  • Audit trail - Log role changes
  • +
  • Principle of least privilege - Give minimum role needed
  • +
+
+

Session Issues

+

Session Timeout

+

Severity: 🟢 Low

+

Symptoms

+

User inactive for a while, then gets logged out.

+

Current Behavior

+

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

+

Solutions

+

Solution 1: Configure token expiration

+

In .env:

+
# Access token (default: 15m)
+JWT_ACCESS_EXPIRATION=30m
+
+# Refresh token (default: 7d)
+JWT_REFRESH_EXPIRATION=14d
+
+

Restart API:

+
docker compose restart api
+
+

Solution 2: Implement activity tracking

+
// In frontend, track last activity
+const updateActivity = () => {
+  localStorage.setItem('lastActivity', Date.now().toString());
+};
+
+// On any user action
+document.addEventListener('click', updateActivity);
+document.addEventListener('keypress', updateActivity);
+
+// Check on load
+useEffect(() => {
+  const lastActivity = parseInt(localStorage.getItem('lastActivity') || '0');
+  const now = Date.now();
+  const thirtyMinutes = 30 * 60 * 1000;
+
+  if (now - lastActivity > thirtyMinutes) {
+    // Log out due to inactivity
+    authStore.logout();
+  }
+}, []);
+
+

Prevention

+
    +
  • Sliding sessions - Auto-refresh extends session
  • +
  • Long refresh window - 7-day default
  • +
  • Activity tracking - Reset timeout on activity
  • +
  • Warning before logout - Show countdown before timeout
  • +
+
+

Multiple Device Conflicts

+

Severity: 🟢 Low

+

Symptoms

+

User logged in on multiple devices, behavior inconsistent.

+

Current Behavior

+

V2 supports multiple devices: +- Each login creates new refresh token +- All devices stay logged in independently +- No device limit by default

+

Solutions

+

Solution 1: View user's devices

+
# List all refresh tokens for user
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT id, \"createdAt\", \"expiresAt\" FROM \"RefreshToken\"
+      WHERE \"userId\" = 'USER_UUID'
+      ORDER BY \"createdAt\" DESC;"
+
+# Each row = one device/session
+
+

Solution 2: Log out all devices

+
# Delete all refresh tokens for user
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "DELETE FROM \"RefreshToken\" WHERE \"userId\" = 'USER_UUID';"
+
+# User must log in again on all devices
+
+

Solution 3: Log out specific device

+
# Delete specific refresh token
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "DELETE FROM \"RefreshToken\" WHERE id = 'REFRESH_TOKEN_UUID';"
+
+

Solution 4: Implement device limit

+

In api/src/modules/auth/auth.service.ts:

+
// After creating refresh token, limit to 5 devices
+const userTokens = await prisma.refreshToken.findMany({
+  where: { userId },
+  orderBy: { createdAt: 'desc' }
+});
+
+// Delete oldest tokens if > 5
+if (userTokens.length > 5) {
+  await prisma.refreshToken.deleteMany({
+    where: {
+      id: { in: userTokens.slice(5).map(t => t.id) }
+    }
+  });
+}
+
+

Prevention

+
    +
  • Device management UI - Show logged-in devices
  • +
  • Device limit - Max 5-10 devices per user
  • +
  • Device naming - Let users name their devices
  • +
  • Remote logout - Let users log out other devices
  • +
+
+

Password Reset Issues

+ +

Severity: 🟢 Low

+

Symptoms

+
{
+  "error": "Bad Request",
+  "message": "Password reset link expired or invalid"
+}
+
+

Common Causes

+
    +
  1. Link expired - Older than 24 hours
  2. +
  3. Already used - Link can only be used once
  4. +
  5. Wrong token - Token doesn't match database
  6. +
+

Solutions

+

Solution 1: Request new reset link

+
# Via API (if endpoint exists)
+curl -X POST http://localhost:4000/api/auth/forgot-password \
+  -H "Content-Type: application/json" \
+  -d '{"email": "user@example.com"}'
+
+

Solution 2: Manually reset password

+
# Generate bcrypt hash
+docker compose exec api node -e "
+const bcrypt = require('bcryptjs');
+console.log(bcrypt.hashSync('NewPassword123!', 10));
+"
+
+# Update password
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "UPDATE \"User\" SET password = '\$2a\$10\$...' WHERE email = 'user@example.com';"
+
+

Prevention

+
    +
  • Longer expiration - 24-hour expiration is reasonable
  • +
  • Clear messaging - Tell users link expires
  • +
  • Easy re-request - Simple way to request new link
  • +
+
+

V2 Status

+

V2 doesn't currently have password reset flow. This section is for future implementation.

+
+
+

Email Not Received

+

Severity: 🟡 Medium

+

Symptoms

+

User requests password reset but doesn't receive email.

+

Common Causes

+
    +
  1. Email in spam - Filtered to spam folder
  2. +
  3. SMTP issue - Email sending failed
  4. +
  5. Wrong email - Typo in email address
  6. +
  7. Email delay - Taking long to deliver
  8. +
+

Solutions

+

Solution 1: Check spam folder

+
    +
  1. Check Spam/Junk folder
  2. +
  3. Check Promotions tab (Gmail)
  4. +
  5. Check email filters
  6. +
+

Solution 2: Check email logs

+
# API logs show email sending
+docker compose logs api | grep -i "email\|smtp"
+
+# Should show:
+# Email sent to user@example.com: Password Reset
+
+

Solution 3: Check MailHog (dev mode)

+

If EMAIL_TEST_MODE=true:

+
# Open MailHog
+http://localhost:8025
+
+# All emails appear here instead of being sent
+
+

Solution 4: Test SMTP connection

+
# Test SMTP via API
+curl -X POST http://localhost:4000/api/test-email \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -d '{
+    "to": "test@example.com",
+    "subject": "Test",
+    "text": "Test email"
+  }'
+
+

Solution 5: Manually reset password

+

See "Manually reset password" in previous section.

+

Prevention

+
    +
  • Email testing - Test email delivery in production
  • +
  • Clear from address - Use recognizable sender
  • +
  • SPF/DKIM/DMARC - Configure email authentication
  • +
  • Resend option - Easy way to resend email
  • +
+
+

Rate Limiting

+

Too Many Login Attempts

+

Severity: 🟡 Medium

+

Symptoms

+
{
+  "error": "Too Many Requests",
+  "message": "Too many login attempts. Please try again later."
+}
+
+

Common Causes

+
    +
  1. Too many failed logins - More than 10/minute
  2. +
  3. Automated attack - Bot trying to brute-force
  4. +
  5. Shared IP - Multiple users behind same NAT
  6. +
+

Solutions

+

Solution 1: Wait and retry

+

Rate limit is per IP address: +- Limit: 10 requests per minute +- Window: 1 minute +- Action: Wait 1 minute, then try again

+

Solution 2: Check Redis rate limit

+
# Connect to Redis
+docker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD
+
+# Find rate limit keys
+KEYS rl:auth:*
+
+# Check specific IP
+GET rl:auth:192.168.1.100
+
+# Shows number of requests in current window
+
+

Solution 3: Clear rate limit (admin)

+
# Delete rate limit key for IP
+docker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD DEL rl:auth:192.168.1.100
+
+# Or clear all auth rate limits
+docker 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
+
+

Solution 4: Adjust rate limit

+

In api/src/middleware/rate-limit.ts:

+
export const authRateLimit = rateLimit({
+  windowMs: 60 * 1000,  // 1 minute
+  max: 10,  // 10 requests per minute
+  message: 'Too many login attempts. Please try again later.',
+  standardHeaders: true,
+  legacyHeaders: false,
+});
+
+// Increase to 20/minute:
+max: 20,
+
+

Solution 5: Use different IP

+

If behind NAT with many users: +1. Use VPN +2. Use mobile network +3. Contact administrator to whitelist IP

+

Prevention

+
    +
  • Reasonable limits - 10/min is reasonable
  • +
  • Per-account limit - Also limit by email (not just IP)
  • +
  • CAPTCHA - Add CAPTCHA after 3 failed attempts
  • +
  • Account lockout - Lock account after 10 failed attempts
  • +
+
+

Account Temporarily Locked

+

Severity: 🟡 Medium

+

Symptoms

+
{
+  "error": "Forbidden",
+  "message": "Account temporarily locked due to too many failed login attempts. Please try again in 30 minutes."
+}
+
+

Solutions

+

Solution 1: Wait for unlock

+

Accounts auto-unlock after lockout period (default: 30 minutes).

+

Solution 2: Manually unlock (admin)

+
# If lockout implemented in database:
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "UPDATE \"User\" SET \"lockedUntil\" = NULL WHERE email = 'user@example.com';"
+
+

Solution 3: Contact administrator

+

If you're a user: +1. Contact system administrator +2. Verify your identity +3. Request account unlock

+

Prevention

+
    +
  • Reasonable threshold - 10 failed attempts is reasonable
  • +
  • Automatic unlock - Auto-unlock after time period
  • +
  • Email notification - Notify user of lockout
  • +
  • Appeal process - Way to appeal false positive
  • +
+
+

V2 Status

+

V2 doesn't currently have account lockout. This section is for future implementation.

+
+
+

Debugging Auth

+

Checking JWT Payload

+

Severity: 🟢 Low (informational)

+

How to Decode JWT

+
// In browser console
+const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInJvbGUiOiJVU0VSIiwiaWF0IjoxNzA4MDAwMDAwLCJleHAiOjE3MDgwMDA5MDB9.signature';
+
+// Decode header
+const header = JSON.parse(atob(token.split('.')[0]));
+console.log('Header:', header);
+// { alg: 'HS256', typ: 'JWT' }
+
+// Decode payload
+const payload = JSON.parse(atob(token.split('.')[1]));
+console.log('Payload:', payload);
+// {
+//   id: '123',
+//   email: 'test@example.com',
+//   role: 'USER',
+//   iat: 1708000000,  // Issued at (Unix timestamp)
+//   exp: 1708000900   // Expires at (Unix timestamp)
+// }
+
+// Check expiration
+console.log('Issued:', new Date(payload.iat * 1000));
+console.log('Expires:', new Date(payload.exp * 1000));
+console.log('Is expired:', Date.now() > payload.exp * 1000);
+
+
+

Verifying Refresh Tokens

+

Check Refresh Token in Database

+
# Find all refresh tokens for user
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT rt.id, rt.\"createdAt\", rt.\"expiresAt\", u.email
+      FROM \"RefreshToken\" rt
+      JOIN \"User\" u ON rt.\"userId\" = u.id
+      WHERE u.email = 'user@example.com'
+      ORDER BY rt.\"createdAt\" DESC;"
+
+# Output:
+# id                                   | createdAt            | expiresAt            | email
+# uuid-here                            | 2026-02-10 10:00:00 | 2026-02-17 10:00:00 | user@example.com
+
+# Check if expired:
+# SELECT id FROM "RefreshToken" WHERE id = 'uuid' AND "expiresAt" > NOW();
+
+
+

Checking User Status

+

View User Details

+
# Full user details
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT id, email, name, role, \"createdAt\", \"updatedAt\"
+      FROM \"User\"
+      WHERE email = 'user@example.com';"
+
+# Check active sessions
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT COUNT(*) as active_sessions
+      FROM \"RefreshToken\" rt
+      JOIN \"User\" u ON rt.\"userId\" = u.id
+      WHERE u.email = 'user@example.com'
+        AND rt.\"expiresAt\" > NOW();"
+
+
+ +

Authentication Documentation

+ +

Other Troubleshooting

+ +

Security Resources

+ +
+

Last Updated: February 2026 +Version: V2.0 +Status: Complete

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/troubleshooting/common-errors/index.html b/mkdocs/site/v2/troubleshooting/common-errors/index.html new file mode 100644 index 00000000..817eb8f3 --- /dev/null +++ b/mkdocs/site/v2/troubleshooting/common-errors/index.html @@ -0,0 +1,9699 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Common Errors - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Common Errors and Solutions

+

This guide covers the most frequently encountered errors in Changemaker Lite V2 and their solutions.

+

Overview

+

How to Use This Guide

+
    +
  1. Find your error - Use the error code or message to locate the section
  2. +
  3. Diagnose - Read the symptoms and causes
  4. +
  5. Apply solution - Follow step-by-step instructions
  6. +
  7. Prevent recurrence - Implement preventive measures
  8. +
+

Error Severity Levels

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LevelIconMeaningAction
Critical🔴System down or data at riskFix immediately
High🟠Feature unavailableFix within hours
Medium🟡Degraded performanceFix within days
Low🟢Minor inconvenienceFix when convenient
+

Quick Error Lookup

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Error CodeCategoryPage
401AuthenticationLink
403AuthorizationLink
404Not FoundLink
422ValidationLink
500Server ErrorLink
CORSFrontendLink
ECONNREFUSEDDatabaseLink
+
+

Authentication Errors

+

401 Unauthorized

+

Severity: 🟠 High

+

Symptoms

+
{
+  "error": "Unauthorized",
+  "message": "Invalid or missing token"
+}
+
+

Browser console: +

Error: Request failed with status code 401
+

+

Common Causes

+
    +
  1. Missing token - No Authorization header sent
  2. +
  3. Expired token - Access token older than 15 minutes
  4. +
  5. Invalid token - Corrupted or tampered token
  6. +
  7. Wrong environment - Token from dev used in production
  8. +
+

Solutions

+

Solution 1: Check if logged in

+
// In browser console
+console.log(localStorage.getItem('auth-storage'));
+
+

If null or missing accessToken, you need to log in again.

+

Solution 2: Refresh token

+

The frontend automatically refreshes tokens. If this fails:

+
    +
  1. Log out completely
  2. +
  3. Clear localStorage: localStorage.clear()
  4. +
  5. Log in again
  6. +
+

Solution 3: Verify API configuration

+

Check admin/.env:

+
VITE_API_URL=http://localhost:4000  # Must match actual API URL
+
+

Solution 4: Check token expiration

+
// In browser console
+const storage = JSON.parse(localStorage.getItem('auth-storage'));
+const payload = JSON.parse(atob(storage.state.accessToken.split('.')[1]));
+console.log('Token expires:', new Date(payload.exp * 1000));
+console.log('Current time:', new Date());
+
+

If expired, the refresh interceptor should handle this. If not working:

+
# Check API logs
+docker compose logs api | grep "refresh"
+
+

Prevention

+
    +
  • Auto-refresh works - Frontend handles token refresh automatically
  • +
  • Long sessions - Refresh tokens valid for 7 days
  • +
  • Activity-based - Tokens refresh on API calls
  • +
  • Clear error handling - Frontend redirects to login on failure
  • +
+
+

Security Note

+

401 errors may return generic messages to prevent user enumeration. This is intentional security behavior.

+
+
+

403 Forbidden

+

Severity: 🟠 High

+

Symptoms

+
{
+  "error": "Forbidden",
+  "message": "Insufficient permissions"
+}
+
+

Or role-specific: +

{
+  "error": "Forbidden",
+  "message": "Requires one of: SUPER_ADMIN, MAP_ADMIN"
+}
+

+

Common Causes

+
    +
  1. Wrong role - User lacks required role
  2. +
  3. TEMP user - Temporary users restricted from most features
  4. +
  5. Feature disabled - Feature flag not enabled
  6. +
  7. Wrong endpoint - Using admin endpoint as public user
  8. +
+

Solutions

+

Solution 1: Check user role

+
# In database
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT email, role FROM \"User\" WHERE email = 'your@email.com';"
+
+

Solution 2: Update user role

+
-- Via Prisma Studio (recommended)
+docker compose exec api npx prisma studio
+-- Navigate to User table, edit role
+
+-- Or via SQL
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "UPDATE \"User\" SET role = 'MAP_ADMIN' WHERE email = 'your@email.com';"
+
+

Solution 3: Check feature flags

+
# In API logs
+docker compose logs api | grep "ENABLE_"
+
+# Check .env
+cat .env | grep ENABLE
+
+

Solution 4: Verify endpoint permissions

+

Check api/src/modules/*/routes.ts:

+
// Admin endpoint
+router.post('/', authenticate, requireRole('SUPER_ADMIN'), ...);
+
+// Public endpoint (no auth)
+router.get('/public', ...);
+
+

Prevention

+
    +
  • Role-based access control - Clear role hierarchy
  • +
  • Explicit permissions - Each endpoint lists required roles
  • +
  • Audit trail - Track permission changes
  • +
  • Documentation - Role matrix in Access Control
  • +
+
+

Invalid Token

+

Severity: 🟠 High

+

Symptoms

+
{
+  "error": "Unauthorized",
+  "message": "Invalid token"
+}
+
+

Or in API logs: +

Error: jwt malformed
+Error: invalid signature
+Error: jwt must be provided
+

+

Common Causes

+
    +
  1. Corrupted token - LocalStorage corruption
  2. +
  3. Wrong secret - JWT_ACCESS_SECRET changed
  4. +
  5. Modified token - Attempted tampering
  6. +
  7. Format error - Not a valid JWT structure
  8. +
+

Solutions

+

Solution 1: Clear and re-login

+
// In browser console
+localStorage.clear();
+// Then log in again
+
+

Solution 2: Verify JWT structure

+

Valid JWT has 3 parts separated by dots:

+
const token = 'header.payload.signature';
+console.log(token.split('.').length); // Should be 3
+
+

Solution 3: Check secret configuration

+
# In .env
+JWT_ACCESS_SECRET=your-secret-here-32-chars-min
+JWT_REFRESH_SECRET=different-secret-here-32-chars-min
+
+# Secrets must:
+# - Be different from each other
+# - Be at least 32 characters
+# - Remain unchanged (changing invalidates all tokens)
+
+

Solution 4: Verify token in logs

+
# API logs show token validation errors
+docker compose logs api | tail -100 | grep "jwt"
+
+

Prevention

+
    +
  • Secure secrets - Use openssl rand -hex 32
  • +
  • Never commit secrets - Keep in .env (gitignored)
  • +
  • Rotate carefully - Changing secrets logs out all users
  • +
  • Monitor errors - Alert on spike in invalid token errors
  • +
+
+

Token Expired

+

Severity: 🟡 Medium

+

Symptoms

+
{
+  "error": "Unauthorized",
+  "message": "Token expired"
+}
+
+

Or: +

Error: jwt expired
+

+

Common Causes

+
    +
  1. Access token expired - Normal after 15 minutes of inactivity
  2. +
  3. Refresh token expired - Refresh token older than 7 days
  4. +
  5. System clock skew - Server/client time mismatch
  6. +
  7. Refresh failed - Refresh token invalid or revoked
  8. +
+

Solutions

+

Solution 1: Automatic refresh

+

Frontend automatically refreshes tokens on 401. If this fails:

+
// Check refresh token in localStorage
+const storage = JSON.parse(localStorage.getItem('auth-storage'));
+console.log('Has refresh token:', !!storage?.state?.refreshToken);
+
+

Solution 2: Manual login

+

If refresh token expired (after 7 days):

+
    +
  1. You'll be redirected to login automatically
  2. +
  3. Log in with email/password
  4. +
  5. New tokens issued
  6. +
+

Solution 3: Check system time

+
# On server
+date
+
+# Sync if incorrect
+sudo ntpdate -s time.nist.gov
+
+

Solution 4: Verify token expiration

+
# In API logs
+docker compose logs api | grep "expired"
+
+# Check token age
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT email, \"createdAt\", \"expiresAt\" FROM \"RefreshToken\" ORDER BY \"createdAt\" DESC LIMIT 10;"
+
+

Prevention

+
    +
  • Sliding sessions - Tokens auto-refresh on activity
  • +
  • Long refresh window - 7-day refresh token validity
  • +
  • Graceful handling - Automatic re-login redirect
  • +
  • Activity tracking - Monitor token refresh patterns
  • +
+
+

Developer Tip

+

During development, use longer token expiration in .env: +

JWT_ACCESS_EXPIRATION=1d  # Instead of 15m
+JWT_REFRESH_EXPIRATION=30d  # Instead of 7d
+

+
+
+

User Not Found

+

Severity: 🟡 Medium

+

Symptoms

+
{
+  "error": "Unauthorized",
+  "message": "Invalid credentials"
+}
+
+

Note: Same message for both "user not found" and "wrong password" (security feature).

+

Common Causes

+
    +
  1. Wrong email - Typo in email address
  2. +
  3. User deleted - Account removed from database
  4. +
  5. Wrong database - Connected to wrong environment
  6. +
  7. Case sensitivity - Email stored differently
  8. +
+

Solutions

+

Solution 1: Verify user exists

+
# Check database
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT email, role FROM \"User\" WHERE email ILIKE '%search%';"
+
+

Solution 2: Check email format

+

Emails are stored lowercase:

+
-- Find user case-insensitive
+SELECT * FROM "User" WHERE LOWER(email) = LOWER('User@Example.com');
+
+

Solution 3: Create user if missing

+
# Via API
+curl -X POST http://localhost:4000/api/auth/register \
+  -H "Content-Type: application/json" \
+  -d '{
+    "email": "user@example.com",
+    "password": "SecurePass123!",
+    "name": "User Name"
+  }'
+
+# Or via admin UI at /app/users
+
+

Solution 4: Check database connection

+
# Verify correct database
+docker compose exec api npx prisma db pull
+
+# Check DATABASE_URL in .env
+cat .env | grep DATABASE_URL
+
+

Prevention

+
    +
  • Email validation - Enforce valid email format
  • +
  • Case normalization - Store emails lowercase
  • +
  • Soft deletes - Consider flagging instead of deleting
  • +
  • Audit trail - Log user deletions
  • +
+
+

API Errors

+

500 Internal Server Error

+

Severity: 🔴 Critical

+

Symptoms

+
{
+  "error": "Internal Server Error",
+  "message": "An unexpected error occurred"
+}
+
+

Or frontend error: +

Error: Request failed with status code 500
+

+

Common Causes

+
    +
  1. Unhandled exception - Code threw unexpected error
  2. +
  3. Database error - Query failed
  4. +
  5. Missing environment variable - Required config missing
  6. +
  7. Type error - Runtime type mismatch
  8. +
+

Solutions

+

Solution 1: Check API logs

+
# View recent logs
+docker compose logs api --tail=100
+
+# Follow logs in real-time
+docker compose logs -f api
+
+# Search for errors
+docker compose logs api | grep -i error | tail -20
+
+

Solution 2: Common error patterns

+
// Missing environment variable
+Error: SMTP_HOST is required
+// Solution: Add to .env
+
+// Database connection error
+Error: Can't reach database server at `v2-postgres:5432`
+// Solution: Check database is running
+
+// Type error
+TypeError: Cannot read property 'id' of undefined
+// Solution: Check code for null checks
+
+

Solution 3: Restart API

+
# Restart API container
+docker compose restart api
+
+# Or rebuild if code changed
+docker compose up -d --build api
+
+

Solution 4: Enable debug logging

+
# In .env
+LOG_LEVEL=debug
+
+# Restart API
+docker compose restart api
+
+# Check detailed logs
+docker compose logs api
+
+

Prevention

+
    +
  • Error handling - Try/catch in all routes
  • +
  • Input validation - Validate all inputs with Zod
  • +
  • Type safety - Use TypeScript strictly
  • +
  • Health checks - Monitor API health
  • +
  • Alerting - Set up alerts for 500 errors
  • +
+
+

Production Alert

+

500 errors indicate bugs. Always investigate and fix root cause.

+
+
+

400 Bad Request

+

Severity: 🟡 Medium

+

Symptoms

+
{
+  "error": "Bad Request",
+  "message": "Invalid request format"
+}
+
+

Or with validation details: +

{
+  "error": "Bad Request",
+  "message": "Validation failed: 2 errors"
+}
+

+

Common Causes

+
    +
  1. Invalid JSON - Malformed request body
  2. +
  3. Wrong Content-Type - Missing or incorrect header
  4. +
  5. Missing required field - Required parameter not sent
  6. +
  7. Invalid data type - String sent for number field
  8. +
+

Solutions

+

Solution 1: Check request format

+
// Correct format
+const response = await api.post('/api/users', {
+  email: 'user@example.com',
+  password: 'SecurePass123!',
+  name: 'User Name'
+}, {
+  headers: {
+    'Content-Type': 'application/json'
+  }
+});
+
+// Common mistakes:
+// ❌ Missing Content-Type header
+// ❌ Sending FormData to JSON endpoint
+// ❌ Malformed JSON (trailing comma, unquoted keys)
+
+

Solution 2: Validate against schema

+

Check API schema in api/src/modules/*/schemas.ts:

+
// Example: User creation schema
+export const createUserSchema = z.object({
+  email: z.string().email(),
+  password: z.string().min(12),
+  name: z.string().min(1),
+  role: z.enum(['USER', 'MAP_ADMIN', 'INFLUENCE_ADMIN', 'SUPER_ADMIN']).optional()
+});
+
+

Solution 3: Check API logs for details

+
# Logs show validation errors
+docker compose logs api | grep "Validation failed"
+
+# Example output:
+# Validation failed: {
+#   "email": "Invalid email format",
+#   "password": "Must be at least 12 characters"
+# }
+
+

Solution 4: Test with curl

+
# Test request
+curl -X POST http://localhost:4000/api/users \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -d '{
+    "email": "test@example.com",
+    "password": "SecurePass123!",
+    "name": "Test User"
+  }'
+
+

Prevention

+
    +
  • Client-side validation - Validate before sending
  • +
  • TypeScript types - Use generated types from API
  • +
  • Schema documentation - Document all endpoints
  • +
  • Error messages - Clear validation error messages
  • +
+
+

404 Not Found

+

Severity: 🟢 Low to 🟡 Medium

+

Symptoms

+
{
+  "error": "Not Found",
+  "message": "Resource not found"
+}
+
+

Or specific: +

{
+  "error": "Not Found",
+  "message": "Campaign not found"
+}
+

+

Common Causes

+
    +
  1. Wrong ID - Resource doesn't exist
  2. +
  3. Wrong URL - Typo in endpoint path
  4. +
  5. Deleted resource - Resource was deleted
  6. +
  7. Wrong HTTP method - GET instead of POST
  8. +
+

Solutions

+

Solution 1: Verify resource exists

+
# Check database
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT id, name FROM \"Campaign\" WHERE id = 'YOUR_ID';"
+
+

Solution 2: Check URL format

+
// Correct formats
+GET /api/campaigns/:id          // Single campaign
+GET /api/campaigns              // List campaigns
+POST /api/campaigns             // Create campaign
+PUT /api/campaigns/:id          // Update campaign
+DELETE /api/campaigns/:id       // Delete campaign
+
+// Common mistakes:
+// ❌ /api/campaign/:id (singular, should be plural)
+// ❌ /api/campaigns/id/:id (extra 'id/' in path)
+// ❌ /api/campaign (wrong singular/plural)
+
+

Solution 3: Check route registration

+
# API logs show registered routes on startup
+docker compose logs api | grep "Registered route"
+
+# Or check routes file
+cat api/src/modules/*/routes.ts
+
+

Solution 4: Test endpoint

+
# List all campaigns to verify endpoint
+curl http://localhost:4000/api/campaigns
+
+# Test specific ID
+curl http://localhost:4000/api/campaigns/YOUR_ID
+
+

Prevention

+
    +
  • UUID validation - Validate ID format before querying
  • +
  • Soft deletes - Flag as deleted instead of removing
  • +
  • Resource existence checks - Verify before operations
  • +
  • Clear error messages - Specify which resource not found
  • +
+
+

422 Unprocessable Entity

+

Severity: 🟡 Medium

+

Symptoms

+
{
+  "error": "Unprocessable Entity",
+  "message": "Validation failed",
+  "details": {
+    "email": "Email already exists",
+    "password": "Must contain uppercase, lowercase, and digit"
+  }
+}
+
+

Common Causes

+
    +
  1. Business logic violation - Email already exists
  2. +
  3. Data integrity - Foreign key doesn't exist
  4. +
  5. Complex validation - Password requirements not met
  6. +
  7. State conflict - Can't delete resource in use
  8. +
+

Solutions

+

Solution 1: Read validation details

+

The details field shows exactly what's wrong:

+
try {
+  await api.post('/api/users', userData);
+} catch (error) {
+  if (error.response?.status === 422) {
+    console.log('Validation errors:', error.response.data.details);
+    // Show to user field-by-field
+  }
+}
+
+

Solution 2: Check constraints

+
# Email uniqueness
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT email FROM \"User\" WHERE email = 'test@example.com';"
+
+# Foreign key exists
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT id FROM \"Campaign\" WHERE id = 'CAMPAIGN_ID';"
+
+

Solution 3: Fix data

+

Common fixes:

+
// Email already exists → Use different email
+email: 'newuser@example.com'
+
+// Password too weak → Meet requirements
+password: 'SecurePass123!'  // 12+ chars, upper, lower, digit
+
+// Foreign key missing → Create parent first
+// Create campaign before creating email
+
+// Resource in use → Delete dependents first
+// Delete locations before deleting cut
+
+

Solution 4: Check database schema

+
# View constraints
+docker compose exec api npx prisma studio
+# Navigate to model, see unique fields and relations
+
+

Prevention

+
    +
  • Client validation - Check constraints before submitting
  • +
  • Clear requirements - Document validation rules
  • +
  • Helpful messages - Explain how to fix
  • +
  • Cascade deletes - Auto-delete dependents when safe
  • +
+
+

Database Errors

+

Connection Refused

+

Severity: 🔴 Critical

+

Symptoms

+
Error: connect ECONNREFUSED 127.0.0.1:5433
+
+

Or: +

Error: Can't reach database server at `v2-postgres:5432`
+

+

Common Causes

+
    +
  1. Database not running - Container stopped
  2. +
  3. Wrong connection string - Incorrect host/port
  4. +
  5. Network issue - Container can't reach database
  6. +
  7. Port conflict - Port already in use
  8. +
+

Solutions

+

Solution 1: Check database status

+
# List running containers
+docker compose ps
+
+# Database should show as "running"
+# If not:
+docker compose up -d v2-postgres
+
+

Solution 2: Verify connection string

+
# Check .env
+cat .env | grep DATABASE_URL
+
+# Should be (from API container):
+DATABASE_URL="postgresql://changemaker:PASSWORD@v2-postgres:5432/changemaker_v2"
+
+# Or (from host):
+DATABASE_URL="postgresql://changemaker:PASSWORD@localhost:5433/changemaker_v2"
+
+

Solution 3: Check database logs

+
# View database logs
+docker compose logs v2-postgres
+
+# Look for:
+# - "database system is ready to accept connections" (good)
+# - "FATAL: password authentication failed" (bad - wrong password)
+# - "port 5432 already in use" (bad - port conflict)
+
+

Solution 4: Test connection manually

+
# From host
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c "SELECT NOW();"
+
+# Should return current timestamp
+# If fails, database isn't running properly
+
+

Solution 5: Restart database

+
# Restart database container
+docker compose restart v2-postgres
+
+# Or recreate if corrupted
+docker compose down v2-postgres
+docker compose up -d v2-postgres
+
+# Wait for "ready to accept connections" message
+docker compose logs -f v2-postgres
+
+

Prevention

+
    +
  • Health checks - Monitor database availability
  • +
  • Auto-restart - Configure restart policy
  • +
  • Connection pooling - Handle transient failures
  • +
  • Alerting - Alert on connection failures
  • +
+
+

Too Many Connections

+

Severity: 🟠 High

+

Symptoms

+
Error: too many connections for database "changemaker_v2"
+
+

Or: +

Error: Prepared statement "prisma_xxx" already exists
+

+

Common Causes

+
    +
  1. Connection leak - Connections not released
  2. +
  3. Pool too small - Not enough connections for load
  4. +
  5. Long-running queries - Blocking connections
  6. +
  7. Multiple clients - Too many Prisma instances
  8. +
+

Solutions

+

Solution 1: Check active connections

+
# View connections
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT count(*) FROM pg_stat_activity WHERE datname = 'changemaker_v2';"
+
+# PostgreSQL default max: 100 connections
+# Prisma default pool: 10 connections
+
+

Solution 2: Kill idle connections

+
-- Find idle connections
+SELECT pid, usename, state, query_start
+FROM pg_stat_activity
+WHERE datname = 'changemaker_v2' AND state = 'idle';
+
+-- Kill specific connection
+SELECT pg_terminate_backend(PID_HERE);
+
+-- Kill all idle connections (careful!)
+SELECT pg_terminate_backend(pid)
+FROM pg_stat_activity
+WHERE datname = 'changemaker_v2' AND state = 'idle';
+
+

Solution 3: Adjust connection pool

+

In api/prisma/schema.prisma:

+
datasource db {
+  provider = "postgresql"
+  url      = env("DATABASE_URL")
+  // Add connection pool config
+  // connectionLimit = 10  // Default
+}
+
+

Or via DATABASE_URL:

+
DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=20"
+
+

Solution 4: Restart API

+
# Restart releases all connections
+docker compose restart api
+
+# Check if connections cleared
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT count(*) FROM pg_stat_activity WHERE datname = 'changemaker_v2';"
+
+

Solution 5: Increase PostgreSQL max connections

+

In docker-compose.yml:

+
v2-postgres:
+  # ...
+  command: postgres -c max_connections=200
+
+

Then restart:

+
docker compose up -d v2-postgres
+
+

Prevention

+
    +
  • Proper cleanup - Always close Prisma clients
  • +
  • Connection pooling - Use appropriate pool size
  • +
  • Monitor connections - Alert on high usage
  • +
  • Query optimization - Reduce long-running queries
  • +
+
+

Unique Constraint Violation

+

Severity: 🟡 Medium

+

Symptoms

+
Error: Unique constraint failed on the fields: (`email`)
+
+

Or: +

PrismaClientKnownRequestError:
+Unique constraint failed on the constraint: `User_email_key`
+

+

Common Causes

+
    +
  1. Duplicate email - User already exists
  2. +
  3. Race condition - Two creates at same time
  4. +
  5. Case sensitivity - Email differs only in case
  6. +
  7. Retry logic - Request sent multiple times
  8. +
+

Solutions

+

Solution 1: Check existing records

+
# Find duplicate
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT id, email, \"createdAt\" FROM \"User\" WHERE email = 'duplicate@example.com';"
+
+

Solution 2: Update instead of create

+
// Instead of:
+await prisma.user.create({ data: { email, ... } });
+
+// Use upsert:
+await prisma.user.upsert({
+  where: { email },
+  update: { name, ... },
+  create: { email, name, ... }
+});
+
+

Solution 3: Handle error gracefully

+
try {
+  await prisma.user.create({ data });
+} catch (error) {
+  if (error.code === 'P2002') {
+    // Unique constraint violation
+    const field = error.meta?.target?.[0];
+    throw new Error(`${field} already exists`);
+  }
+  throw error;
+}
+
+

Solution 4: Delete duplicate

+
# If truly duplicate, delete one
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "DELETE FROM \"User\" WHERE id = 'ID_TO_DELETE';"
+
+

Prevention

+
    +
  • Check before create - Query first to check existence
  • +
  • Use upsert - Update or create atomically
  • +
  • Unique indexes - Database enforces uniqueness
  • +
  • Case normalization - Store emails lowercase
  • +
+
+

Foreign Key Constraint

+

Severity: 🟡 Medium

+

Symptoms

+
Error: Foreign key constraint failed on the field: `campaignId`
+
+

Or: +

Error: An operation failed because it depends on one or more records that were required but not found. Record to update not found.
+

+

Common Causes

+
    +
  1. Parent doesn't exist - Referenced record missing
  2. +
  3. Wrong ID - Typo in foreign key value
  4. +
  5. Delete order - Trying to delete parent before children
  6. +
  7. Null constraint - Foreign key required but null provided
  8. +
+

Solutions

+

Solution 1: Verify parent exists

+
# Check campaign exists
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT id, name FROM \"Campaign\" WHERE id = 'CAMPAIGN_ID';"
+
+

Solution 2: Create parent first

+
// Create campaign first
+const campaign = await prisma.campaign.create({
+  data: { name: 'My Campaign', ... }
+});
+
+// Then create email with campaignId
+const email = await prisma.campaignEmail.create({
+  data: {
+    campaignId: campaign.id,  // Use created campaign's ID
+    ...
+  }
+});
+
+

Solution 3: Delete children first

+
// Delete all emails in campaign
+await prisma.campaignEmail.deleteMany({
+  where: { campaignId }
+});
+
+// Then delete campaign
+await prisma.campaign.delete({
+  where: { id: campaignId }
+});
+
+// Or use cascade delete in schema:
+// @@relation(onDelete: Cascade)
+
+

Solution 4: Use transactions

+
// Ensure atomicity
+await prisma.$transaction([
+  prisma.campaignEmail.deleteMany({ where: { campaignId } }),
+  prisma.campaign.delete({ where: { id: campaignId } })
+]);
+
+

Prevention

+
    +
  • Cascade deletes - Configure in schema where appropriate
  • +
  • Soft deletes - Flag as deleted instead of removing
  • +
  • Validation - Check foreign keys exist before creating
  • +
  • Transactions - Use for multi-step operations
  • +
+
+

Frontend Errors

+

Network Error

+

Severity: 🟠 High

+

Symptoms

+

Browser console: +

Error: Network Error
+

+

Or: +

AxiosError: Request failed with status code undefined
+

+

User sees: API request fails, loading spinner never stops.

+

Common Causes

+
    +
  1. API down - API container not running
  2. +
  3. Wrong API URL - VITE_API_URL misconfigured
  4. +
  5. CORS issue - Browser blocking request
  6. +
  7. Network timeout - Request taking too long
  8. +
+

Solutions

+

Solution 1: Check API status

+
# Is API running?
+docker compose ps api
+
+# Check API logs
+docker compose logs api --tail=50
+
+# Test API directly
+curl http://localhost:4000/api/health
+
+

Solution 2: Verify API URL

+
# Check admin .env
+cat admin/.env
+
+# Should have:
+VITE_API_URL=http://localhost:4000
+
+# In Docker, use:
+VITE_API_URL=http://api:4000
+
+

Solution 3: Check browser console

+

Press F12, check:

+
    +
  • Network tab - Does request appear? What's the status?
  • +
  • Console tab - Any CORS errors?
  • +
+

Solution 4: Test from different client

+
# From command line
+curl http://localhost:4000/api/campaigns
+
+# If this works but browser doesn't, it's a CORS issue
+
+

Prevention

+
    +
  • Health checks - Monitor API availability
  • +
  • Error boundaries - Catch and display network errors
  • +
  • Retry logic - Auto-retry failed requests
  • +
  • Offline detection - Detect and handle offline state
  • +
+
+

CORS Errors

+

Severity: 🟠 High

+

Symptoms

+

Browser console: +

Access to XMLHttpRequest at 'http://localhost:4000/api/users' from origin
+'http://localhost:3000' has been blocked by CORS policy: No
+'Access-Control-Allow-Origin' header is present on the requested resource.
+

+

Common Causes

+
    +
  1. Missing CORS config - API not configured for CORS
  2. +
  3. Wrong origin - Admin URL not in allowed origins
  4. +
  5. Credentials flag - withCredentials set but not allowed
  6. +
  7. Preflight failure - OPTIONS request failing
  8. +
+

Solutions

+

Solution 1: Check API CORS configuration

+

In api/src/server.ts:

+
app.use(cors({
+  origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'],
+  credentials: true
+}));
+
+

Solution 2: Verify CORS_ORIGINS

+
# Check .env
+cat .env | grep CORS_ORIGINS
+
+# Should include admin URL:
+CORS_ORIGINS=http://localhost:3000,https://app.cmlite.org
+
+

Solution 3: Add origin temporarily

+

For development:

+
# In .env
+CORS_ORIGINS=*  # Allow all origins (dev only!)
+
+# Restart API
+docker compose restart api
+
+

Solution 4: Check preflight request

+

In browser Network tab:

+
    +
  1. Find OPTIONS request before actual request
  2. +
  3. Check if it returns 200 OK
  4. +
  5. Check response headers include:
  6. +
  7. Access-Control-Allow-Origin
  8. +
  9. Access-Control-Allow-Methods
  10. +
  11. Access-Control-Allow-Headers
  12. +
+

Prevention

+
    +
  • Explicit origins - List all allowed origins
  • +
  • Environment-based - Different origins per environment
  • +
  • Credentials support - Enable if using cookies/auth
  • +
  • Preflight caching - Cache OPTIONS responses
  • +
+
+

Security Note

+

Never use CORS_ORIGINS=* in production with credentials enabled.

+
+
+

Module Not Found

+

Severity: 🟡 Medium

+

Symptoms

+
Error: Cannot find module '@/components/MyComponent'
+
+

Or: +

Module not found: Can't resolve 'some-package'
+

+

Common Causes

+
    +
  1. Missing dependency - Package not installed
  2. +
  3. Wrong import path - Typo in path
  4. +
  5. Path alias issue - @ alias not configured
  6. +
  7. Case sensitivity - Wrong case in filename
  8. +
+

Solutions

+

Solution 1: Install missing package

+
cd admin
+
+# Install package
+npm install some-package
+
+# Or if dev dependency
+npm install -D some-package
+
+# Restart dev server
+npm run dev
+
+

Solution 2: Check import path

+
// Wrong:
+import MyComponent from '@/Component/MyComponent';
+
+// Right:
+import MyComponent from '@/components/MyComponent';
+
+// Verify file exists:
+// admin/src/components/MyComponent.tsx
+
+

Solution 3: Verify path alias

+

In admin/vite.config.ts:

+
export default defineConfig({
+  resolve: {
+    alias: {
+      '@': path.resolve(__dirname, './src')
+    }
+  }
+});
+
+

In admin/tsconfig.json:

+
{
+  "compilerOptions": {
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  }
+}
+
+

Solution 4: Clear cache and reinstall

+
cd admin
+
+# Remove node_modules and lock file
+rm -rf node_modules package-lock.json
+
+# Reinstall
+npm install
+
+# Restart
+npm run dev
+
+

Prevention

+
    +
  • Type checking - Use TypeScript for import validation
  • +
  • IDE support - Configure path aliases in IDE
  • +
  • Linting - Use ESLint with import plugin
  • +
  • Documentation - Document custom path aliases
  • +
+
+

Hydration Errors

+

Severity: 🟡 Medium

+

Symptoms

+

Browser console: +

Warning: Text content did not match. Server: "..." Client: "..."
+

+

Or: +

Error: Hydration failed because the initial UI does not match what was
+rendered on the server.
+

+

Common Causes

+
    +
  1. Date formatting - Server/client timezone difference
  2. +
  3. Random values - Using Math.random() or uuid
  4. +
  5. localStorage - Reading from localStorage during render
  6. +
  7. User agent - Checking window.navigator during SSR
  8. +
  9. Third-party scripts - Injected by browser extensions
  10. +
+

Solutions

+

Solution 1: Use useEffect for client-only code

+
// Wrong:
+const Component = () => {
+  const value = localStorage.getItem('key');
+  return <div>{value}</div>;
+};
+
+// Right:
+const Component = () => {
+  const [value, setValue] = useState<string | null>(null);
+
+  useEffect(() => {
+    setValue(localStorage.getItem('key'));
+  }, []);
+
+  return <div>{value}</div>;
+};
+
+

Solution 2: Consistent date formatting

+
// Wrong:
+<div>{new Date().toLocaleString()}</div>  // Varies by locale
+
+// Right:
+import dayjs from 'dayjs';
+<div>{dayjs().format('YYYY-MM-DD HH:mm:ss')}</div>
+
+

Solution 3: suppressHydrationWarning for known mismatches

+
// For values that intentionally differ (like timestamps)
+<time suppressHydrationWarning>
+  {new Date().toISOString()}
+</time>
+
+

Solution 4: Check browser extensions

+

Disable browser extensions temporarily to see if error persists.

+

Prevention

+
    +
  • Avoid client-only APIs during render - Use useEffect
  • +
  • Consistent formatting - Same format server and client
  • +
  • Test without extensions - Regular testing
  • +
  • React DevTools - Use to identify mismatches
  • +
+
+

Changemaker Lite V2

+

Current admin is CSR (Client-Side Rendered) only, so hydration errors shouldn't occur. This section is for future SSR/SSG implementations.

+
+
+

File Upload Errors

+

File Too Large

+

Severity: 🟡 Medium

+

Symptoms

+
{
+  "error": "Payload Too Large",
+  "message": "File size exceeds maximum of 10485760 bytes"
+}
+
+

Or browser: +

Request Entity Too Large
+

+

Common Causes

+
    +
  1. File exceeds limit - Video larger than 10GB
  2. +
  3. Nginx limit - Reverse proxy blocking
  4. +
  5. Wrong content type - Not multipart/form-data
  6. +
  7. Network timeout - Upload taking too long
  8. +
+

Solutions

+

Solution 1: Check file size

+
// Before upload
+const file = event.target.files[0];
+const maxSize = 10 * 1024 * 1024 * 1024; // 10GB
+
+if (file.size > maxSize) {
+  alert(`File too large. Max size: ${maxSize / 1024 / 1024 / 1024}GB`);
+  return;
+}
+
+

Solution 2: Increase limits

+

In api/src/modules/media/routes/upload.routes.ts:

+
fastify.register(multipart, {
+  limits: {
+    fileSize: 10 * 1024 * 1024 * 1024  // 10GB
+  }
+});
+
+

In nginx/conf.d/api.conf:

+
client_max_body_size 10G;
+
+

Solution 3: Use chunked upload

+

For very large files, implement resumable upload:

+
// TODO: Implement chunked upload in Phase 15
+
+

Solution 4: Compress video

+
# Before uploading, compress with ffmpeg
+ffmpeg -i input.mp4 -c:v libx264 -crf 23 -c:a aac output.mp4
+
+

Prevention

+
    +
  • Client validation - Check size before upload
  • +
  • Progress indicator - Show upload progress
  • +
  • Compression - Compress large videos
  • +
  • Chunked uploads - For files > 1GB
  • +
+
+

Invalid File Type

+

Severity: 🟢 Low

+

Symptoms

+
{
+  "error": "Bad Request",
+  "message": "Invalid file type. Allowed: mp4, mov, avi, mkv, webm, m4v, flv"
+}
+
+

Common Causes

+
    +
  1. Wrong extension - File has unsupported extension
  2. +
  3. Missing extension - Filename has no extension
  4. +
  5. Mismatched extension - Extension doesn't match content
  6. +
  7. MIME type issue - Browser sends wrong MIME type
  8. +
+

Solutions

+

Solution 1: Check supported formats

+

Supported video formats:

+
    +
  • MP4 (.mp4)
  • +
  • MOV (.mov)
  • +
  • AVI (.avi)
  • +
  • MKV (.mkv)
  • +
  • WebM (.webm)
  • +
  • M4V (.m4v)
  • +
  • FLV (.flv)
  • +
+

Solution 2: Convert video

+
# Convert to MP4 (most compatible)
+ffmpeg -i input.avi -c:v libx264 -c:a aac output.mp4
+
+

Solution 3: Check file extension

+
const file = event.target.files[0];
+const ext = file.name.split('.').pop().toLowerCase();
+const allowed = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'flv'];
+
+if (!allowed.includes(ext)) {
+  alert(`Invalid file type: .${ext}`);
+  return;
+}
+
+

Solution 4: Verify with file command

+
# Check actual file type
+file video.mp4
+
+# Should show:
+# video.mp4: ISO Media, MP4 v2 [ISO 14496-14]
+
+

Prevention

+
    +
  • Client validation - Check extension before upload
  • +
  • MIME type checking - Validate content type
  • +
  • File magic numbers - Check file signature
  • +
  • Clear documentation - List supported formats
  • +
+
+

Upload Timeout

+

Severity: 🟡 Medium

+

Symptoms

+
Error: timeout of 30000ms exceeded
+
+

Or: +

504 Gateway Timeout
+

+

Common Causes

+
    +
  1. Slow network - Large file, slow connection
  2. +
  3. Server timeout - Request timeout too short
  4. +
  5. Processing delay - FFprobe taking too long
  6. +
  7. Network interruption - Connection dropped
  8. +
+

Solutions

+

Solution 1: Increase timeout

+

In admin/src/lib/media-api.ts:

+
export const mediaApi = axios.create({
+  baseURL: import.meta.env.VITE_MEDIA_API_URL,
+  timeout: 300000  // 5 minutes instead of 30 seconds
+});
+
+

Solution 2: Check upload progress

+
await mediaApi.post('/upload', formData, {
+  onUploadProgress: (progressEvent) => {
+    const percent = (progressEvent.loaded / progressEvent.total) * 100;
+    console.log(`Upload: ${percent.toFixed(2)}%`);
+  }
+});
+
+

Solution 3: Increase nginx timeout

+

In nginx/conf.d/api.conf:

+
proxy_read_timeout 300s;
+proxy_connect_timeout 300s;
+proxy_send_timeout 300s;
+
+

Solution 4: Upload via chunks

+
// TODO: Implement chunked upload for large files
+
+

Prevention

+
    +
  • Progress indicator - Show upload progress
  • +
  • Generous timeouts - Allow enough time for large files
  • +
  • Retry logic - Auto-retry on network errors
  • +
  • Chunked uploads - For files > 1GB
  • +
+
+

Email Errors

+

SMTP Connection Failed

+

Severity: 🔴 Critical

+

Symptoms

+

API logs: +

Error: Connection timeout
+Error: connect ECONNREFUSED 127.0.0.1:587
+

+

Or: +

Error: Invalid login: 535-5.7.8 Username and Password not accepted
+

+

Common Causes

+
    +
  1. SMTP server down - Mail server unreachable
  2. +
  3. Wrong credentials - Invalid username/password
  4. +
  5. Port blocked - Firewall blocking SMTP port
  6. +
  7. TLS/SSL issue - Certificate validation failed
  8. +
+

Solutions

+

Solution 1: Test SMTP connection

+
# Test with telnet
+telnet smtp.gmail.com 587
+
+# Should connect and show:
+# 220 smtp.gmail.com ESMTP...
+
+

Solution 2: Verify SMTP configuration

+
# Check .env
+cat .env | grep SMTP
+
+# Required settings:
+SMTP_HOST=smtp.gmail.com
+SMTP_PORT=587
+SMTP_SECURE=false
+SMTP_USER=your-email@gmail.com
+SMTP_PASS=your-app-password
+SMTP_FROM=your-email@gmail.com
+
+

Solution 3: Use test mode

+
# In .env
+EMAIL_TEST_MODE=true
+
+# Restart API
+docker compose restart api
+
+# Emails now sent to MailHog (http://localhost:8025)
+
+

Solution 4: Check Gmail app password

+

For Gmail:

+
    +
  1. Enable 2-factor authentication
  2. +
  3. Generate app password at https://myaccount.google.com/apppasswords
  4. +
  5. Use app password (not regular password) in SMTP_PASS
  6. +
+

Solution 5: Test with curl

+
# Send test email via API
+curl -X POST http://localhost:4000/api/test-email \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -d '{
+    "to": "test@example.com",
+    "subject": "Test Email",
+    "text": "This is a test"
+  }'
+
+

Prevention

+
    +
  • Test mode for dev - Use MailHog locally
  • +
  • Monitor SMTP health - Alert on connection failures
  • +
  • Fallback providers - Configure backup SMTP server
  • +
  • Queue system - BullMQ retries failed emails
  • +
+
+

Template Not Found

+

Severity: 🟠 High

+

Symptoms

+

API logs: +

Error: Email template not found: campaign-email
+

+

Or: +

Error: ENOENT: no such file or directory, open 'templates/campaign-email.html'
+

+

Common Causes

+
    +
  1. Missing template file - Template not created
  2. +
  3. Wrong template name - Typo in template name
  4. +
  5. Wrong path - Looking in wrong directory
  6. +
  7. Deleted template - Template was removed
  8. +
+

Solutions

+

Solution 1: Check template exists

+
# List all templates
+docker compose exec api ls -la templates/
+
+# Should show:
+# campaign-email.html
+# shift-confirmation.html
+# verification-email.html
+# etc.
+
+

Solution 2: Verify template name

+

In api/src/services/email.service.ts:

+
// Template names must match filenames (without .html)
+await emailService.sendEmail({
+  to: email,
+  subject: 'Campaign Email',
+  template: 'campaign-email',  // Looks for templates/campaign-email.html
+  variables: { ... }
+});
+
+

Solution 3: Create missing template

+
# Create template
+docker compose exec api sh -c 'cat > templates/my-template.html << EOF
+<!DOCTYPE html>
+<html>
+<body>
+  <h1>Hello {{name}}</h1>
+  <p>{{message}}</p>
+</body>
+</html>
+EOF'
+
+

Solution 4: Use email template system

+
# Navigate to admin UI
+http://localhost:3000/app/email-templates
+
+# Create template there (saved to database + file)
+
+

Prevention

+
    +
  • Seed templates - Include in database seed
  • +
  • Template management - Use admin UI to manage
  • +
  • Version control - Keep templates in git
  • +
  • Validation - Check template exists before sending
  • +
+
+

Variable Missing

+

Severity: 🟡 Medium

+

Symptoms

+

Email received with placeholders not replaced: +

Hello {{name}},
+Your campaign {{campaignName}} is ready.
+

+

Or API logs: +

Warning: Template variable 'campaignName' not provided
+

+

Common Causes

+
    +
  1. Variable not passed - Missing from variables object
  2. +
  3. Variable name mismatch - Typo in variable name
  4. +
  5. Wrong template - Using wrong template
  6. +
  7. Case sensitivity - Variable name case mismatch
  8. +
+

Solutions

+

Solution 1: Check template variables

+

In template file:

+
<!-- templates/campaign-email.html -->
+<h1>Hello {{firstName}}</h1>
+<p>Your campaign "{{campaignName}}" is ready.</p>
+<p>Visit: {{campaignUrl}}</p>
+
+

Solution 2: Provide all variables

+
await emailService.sendEmail({
+  to: email,
+  subject: 'Campaign Ready',
+  template: 'campaign-email',
+  variables: {
+    firstName: user.name.split(' ')[0],
+    campaignName: campaign.name,
+    campaignUrl: `${process.env.PUBLIC_URL}/campaigns/${campaign.id}`
+  }
+});
+
+

Solution 3: Use default values

+
<!-- In template, provide fallback -->
+<h1>Hello {{firstName || 'Friend'}}</h1>
+
+

Solution 4: Validate before sending

+
// Check all required variables exist
+const required = ['firstName', 'campaignName', 'campaignUrl'];
+const missing = required.filter(key => !variables[key]);
+
+if (missing.length > 0) {
+  throw new Error(`Missing template variables: ${missing.join(', ')}`);
+}
+
+

Prevention

+
    +
  • Template validation - Check variables on save
  • +
  • TypeScript types - Type template variables
  • +
  • Documentation - Document required variables
  • +
  • Default values - Provide sensible defaults
  • +
+
+

Quick Reference Table

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Error Code/MessageCategoryCommon CauseQuick FixSeverity
401 UnauthorizedAuthToken expiredRe-login🟠
403 ForbiddenAuthWrong roleCheck user role🟠
404 Not FoundAPIWrong URL/IDVerify resource exists🟢
422 UnprocessableValidationConstraint violationCheck validation details🟡
500 Server ErrorAPICode bugCheck API logs🔴
ECONNREFUSEDDatabaseDB not runningStart database🔴
Too many connectionsDatabaseConnection leakRestart API🟠
Unique constraintDatabaseDuplicate recordUse upsert or different value🟡
Foreign key constraintDatabaseParent missingCreate parent first🟡
Network ErrorFrontendAPI downCheck API status🟠
CORS ErrorFrontendOrigin not allowedAdd to CORS_ORIGINS🟠
Module not foundFrontendMissing packagenpm install🟡
File too largeUploadExceeds 10GBCompress or increase limit🟡
Invalid file typeUploadWrong formatConvert to MP4🟢
Upload timeoutUploadSlow networkIncrease timeout🟡
SMTP failedEmailWrong credentialsCheck SMTP config🔴
Template not foundEmailMissing fileCreate template🟠
Variable missingEmailNot providedAdd to variables object🟡
+
+

When to Report Bugs

+

Report These

+

Unexpected behavior - System does something wrong

+
    +
  • 500 errors (unless caused by your config)
  • +
  • Data corruption
  • +
  • Security vulnerabilities
  • +
  • Performance regressions
  • +
+

Missing features - Documented feature doesn't work

+
    +
  • API endpoint returns 404 but is documented
  • +
  • UI button does nothing
  • +
  • Feature flag doesn't enable feature
  • +
+

Unclear documentation - Can't figure out how to do something

+
    +
  • Documentation contradicts actual behavior
  • +
  • Missing setup steps
  • +
  • Confusing error messages
  • +
+

Don't Report These

+

Configuration errors - Your setup is wrong

+
    +
  • Missing .env variables
  • +
  • Wrong database credentials
  • +
  • Port conflicts
  • +
+

Environment issues - Your system is incompatible

+
    +
  • Old Docker version
  • +
  • Missing dependencies
  • +
  • Network restrictions
  • +
+

User errors - Misunderstanding how to use

+
    +
  • Wrong API endpoint used
  • +
  • Invalid data format
  • +
  • Permission errors from lack of role
  • +
+

How to Report

+
    +
  1. Check this troubleshooting guide first
  2. +
  3. Search existing GitHub issues
  4. +
  5. If new, create issue with:
  6. +
  7. Clear title describing problem
  8. +
  9. Steps to reproduce
  10. +
  11. Expected vs actual behavior
  12. +
  13. Relevant logs (sanitize sensitive data)
  14. +
  15. System information (Docker version, OS, etc.)
  16. +
+
+ +

General Documentation

+ +

Specific Troubleshooting

+ +

Support

+ +
+

Last Updated: February 2026 +Version: V2.0 +Status: Complete

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/troubleshooting/database-issues/index.html b/mkdocs/site/v2/troubleshooting/database-issues/index.html new file mode 100644 index 00000000..3e0523cc --- /dev/null +++ b/mkdocs/site/v2/troubleshooting/database-issues/index.html @@ -0,0 +1,8698 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Database Issues - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Database and PostgreSQL Issues

+

This guide covers PostgreSQL and database-related problems in Changemaker Lite V2.

+

Overview

+

Database Architecture

+

Changemaker Lite V2 uses:

+
    +
  • PostgreSQL 16 - Primary database
  • +
  • Prisma ORM - Main API (Express)
  • +
  • Drizzle ORM - Media API (Fastify)
  • +
  • Same database - Shared by both APIs
  • +
  • Separate schemas - Tables owned by different ORMs
  • +
+

Database Connection Info

+
# From API container
+DATABASE_URL="postgresql://changemaker:password@v2-postgres:5432/changemaker_v2"
+
+# From host
+DATABASE_URL="postgresql://changemaker:password@localhost:5433/changemaker_v2"
+
+# Connection details:
+# User: changemaker
+# Password: set in V2_POSTGRES_PASSWORD env var
+# Host: v2-postgres (container) or localhost (host)
+# Port: 5432 (inside Docker), 5433 (host)
+# Database: changemaker_v2
+
+

Essential Commands

+
# Connect to database
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2
+
+# Run single query
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c "SELECT NOW();"
+
+# Run SQL file
+docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < script.sql
+
+# Database logs
+docker compose logs v2-postgres
+
+# Prisma Studio (GUI)
+docker compose exec api npx prisma studio
+
+
+

Connection Errors

+

Connection Refused

+

Severity: 🔴 Critical

+

Symptoms

+

API logs: +

Error: connect ECONNREFUSED 127.0.0.1:5433
+Error: Can't reach database server at `v2-postgres:5432`
+

+

Or direct connection: +

psql: error: connection to server at "localhost" (127.0.0.1), port 5433 failed:
+Connection refused
+

+

Common Causes

+
    +
  1. Database not running - Container stopped
  2. +
  3. Wrong connection string - Incorrect host/port
  4. +
  5. Port not exposed - Missing port mapping
  6. +
  7. Network issue - Container can't reach database
  8. +
+

Solutions

+

Solution 1: Check database status

+
# Is database running?
+docker compose ps v2-postgres
+
+# Should show:
+# NAME                          STATUS
+# changemaker-lite-v2-postgres-1   Up 5 minutes
+
+# If not running:
+docker compose up -d v2-postgres
+
+

Solution 2: Wait for database to be ready

+
# Check logs for "ready to accept connections"
+docker compose logs v2-postgres | grep "ready"
+
+# Should show:
+# database system is ready to accept connections
+
+# If not ready, wait 10-20 seconds and check again
+
+

Solution 3: Verify connection string

+
# Check .env
+cat .env | grep DATABASE_URL
+
+# From API container should use container name:
+DATABASE_URL="postgresql://changemaker:password@v2-postgres:5432/changemaker_v2"
+
+# From host should use localhost:
+DATABASE_URL="postgresql://changemaker:password@localhost:5433/changemaker_v2"
+
+# Common mistakes:
+# ❌ Using localhost from container
+# ❌ Using v2-postgres from host
+# ❌ Wrong port (5432 vs 5433)
+# ❌ Wrong password
+
+

Solution 4: Test connection manually

+
# From API container
+docker compose exec api sh -c 'psql $DATABASE_URL -c "SELECT NOW();"'
+
+# From host
+psql "postgresql://changemaker:password@localhost:5433/changemaker_v2" -c "SELECT NOW();"
+
+# If fails, connection string is wrong
+
+

Solution 5: Check port mapping

+

In docker-compose.yml:

+
v2-postgres:
+  ports:
+    - "5433:5432"  # host:container
+
+

Verify:

+
docker compose ps v2-postgres
+
+# Should show:
+# PORTS: 0.0.0.0:5433->5432/tcp
+
+

Prevention

+
    +
  • Health checks - Wait for database health before starting API
  • +
  • Connection retry - Retry connection on startup
  • +
  • Correct env vars - Validate DATABASE_URL format
  • +
  • Monitoring - Alert on connection failures
  • +
+
+

Too Many Clients

+

Severity: 🟠 High

+

Symptoms

+
FATAL: sorry, too many clients already
+
+

Or:

+
Error: remaining connection slots are reserved for non-replication superuser connections
+
+

Common Causes

+
    +
  1. Connection leak - Connections not closed
  2. +
  3. Pool too large - Connection pool size too high
  4. +
  5. Multiple Prisma instances - Each creates own pool
  6. +
  7. Long-running transactions - Holding connections
  8. +
+

Solutions

+

Solution 1: Check active connections

+
-- View all connections
+SELECT count(*) FROM pg_stat_activity;
+
+-- View connections by state
+SELECT state, count(*)
+FROM pg_stat_activity
+WHERE datname = 'changemaker_v2'
+GROUP BY state;
+
+-- View connection details
+SELECT pid, usename, application_name, state, query_start, query
+FROM pg_stat_activity
+WHERE datname = 'changemaker_v2'
+ORDER BY query_start;
+
+

Solution 2: Kill idle connections

+
-- Find idle connections
+SELECT pid, usename, state, state_change
+FROM pg_stat_activity
+WHERE datname = 'changemaker_v2'
+  AND state = 'idle'
+  AND state_change < NOW() - INTERVAL '5 minutes';
+
+-- Kill specific connection
+SELECT pg_terminate_backend(12345);  -- Replace with actual PID
+
+-- Kill all idle connections (careful!)
+SELECT pg_terminate_backend(pid)
+FROM pg_stat_activity
+WHERE datname = 'changemaker_v2'
+  AND state = 'idle'
+  AND state_change < NOW() - INTERVAL '5 minutes';
+
+

Solution 3: Adjust connection pool

+

In DATABASE_URL:

+
# Limit connection pool size
+DATABASE_URL="postgresql://changemaker:password@v2-postgres:5432/changemaker_v2?connection_limit=10"
+
+

Or in Prisma code:

+
// api/src/config/database.ts
+import { PrismaClient } from '@prisma/client';
+
+export const prisma = new PrismaClient({
+  datasources: {
+    db: {
+      url: process.env.DATABASE_URL
+    }
+  }
+  // Connection pool defaults:
+  // connection_limit: 10
+  // pool_timeout: 10 (seconds)
+});
+
+

Solution 4: Increase max connections

+

In docker-compose.yml:

+
v2-postgres:
+  command: postgres -c max_connections=200
+  # Default is 100
+
+

Restart:

+
docker compose up -d v2-postgres
+
+

Verify:

+
SHOW max_connections;
+
+

Solution 5: Restart API to release connections

+
# Restart API releases all connections
+docker compose restart api
+docker compose restart media-api
+
+# Check connection count dropped
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT count(*) FROM pg_stat_activity WHERE datname = 'changemaker_v2';"
+
+

Prevention

+
    +
  • Proper cleanup - Always close Prisma clients in tests
  • +
  • Appropriate pool size - Balance performance vs connections
  • +
  • Monitor connections - Alert when approaching max
  • +
  • Idle timeout - Automatically close idle connections
  • +
+
+

Connection Math

+

Total connections = (number of API instances) × (connection pool size) + (other clients)

+

Example: +- 2 API instances × 10 pool size = 20 connections +- 1 media API × 5 pool size = 5 connections +- Prisma Studio = 1 connection +- Total = 26 connections

+

Set max_connections to 2-3× expected usage.

+
+
+

Authentication Failed

+

Severity: 🔴 Critical

+

Symptoms

+
FATAL: password authentication failed for user "changemaker"
+
+

Or:

+
FATAL: role "changemaker" does not exist
+
+

Common Causes

+
    +
  1. Wrong password - PASSWORD in DATABASE_URL doesn't match
  2. +
  3. Wrong username - User doesn't exist
  4. +
  5. Password changed - Database password changed but not .env
  6. +
  7. Case sensitivity - PostgreSQL usernames are case-sensitive
  8. +
+

Solutions

+

Solution 1: Verify credentials

+
# Check .env
+cat .env | grep V2_POSTGRES_PASSWORD
+
+# Check DATABASE_URL
+cat .env | grep DATABASE_URL
+
+# Password in DATABASE_URL must match V2_POSTGRES_PASSWORD
+
+

Solution 2: Test connection directly

+
# Test with password
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2
+
+# If prompted for password, enter V2_POSTGRES_PASSWORD
+# If fails, credentials are wrong
+
+

Solution 3: Check user exists

+
# Connect as postgres superuser
+docker compose exec v2-postgres psql -U postgres -c "\du"
+
+# Should show changemaker user:
+# Role name | Attributes
+# changemaker |
+
+# If missing, create user:
+docker compose exec v2-postgres psql -U postgres -c \
+  "CREATE USER changemaker WITH PASSWORD 'your-password';"
+
+docker compose exec v2-postgres psql -U postgres -c \
+  "GRANT ALL PRIVILEGES ON DATABASE changemaker_v2 TO changemaker;"
+
+

Solution 4: Reset password

+
# As postgres superuser
+docker compose exec v2-postgres psql -U postgres -c \
+  "ALTER USER changemaker WITH PASSWORD 'new-password';"
+
+# Update .env
+V2_POSTGRES_PASSWORD=new-password
+DATABASE_URL="postgresql://changemaker:new-password@v2-postgres:5432/changemaker_v2"
+
+# Restart API
+docker compose restart api
+
+

Solution 5: Recreate database

+

If completely broken:

+
# Backup first!
+docker compose exec v2-postgres pg_dump -U postgres changemaker_v2 > backup.sql
+
+# Stop database
+docker compose down v2-postgres
+
+# Remove volume (⚠️ DELETES DATA!)
+docker volume rm changemaker-lite_postgres-data
+
+# Start fresh
+docker compose up -d v2-postgres
+
+# Wait for ready
+docker compose logs -f v2-postgres | grep "ready"
+
+# Run migrations
+docker compose exec api npx prisma migrate deploy
+docker compose exec api npx prisma db seed
+
+

Prevention

+
    +
  • Secure passwords - Strong passwords in .env
  • +
  • Consistent credentials - Same password in all places
  • +
  • Version control .env.example - Template with placeholders
  • +
  • Documentation - Document credential structure
  • +
+
+

Database Does Not Exist

+

Severity: 🟠 High

+

Symptoms

+
FATAL: database "changemaker_v2" does not exist
+
+

Common Causes

+
    +
  1. First run - Database not created yet
  2. +
  3. Wrong database name - Typo in DATABASE_URL
  4. +
  5. Database deleted - Volume was removed
  6. +
  7. Wrong postgres instance - Connected to different database
  8. +
+

Solutions

+

Solution 1: Check database exists

+
# List databases
+docker compose exec v2-postgres psql -U postgres -l
+
+# Should show:
+# Name          | Owner
+# changemaker_v2 | changemaker
+
+# If missing, database wasn't created
+
+

Solution 2: Create database

+
# Create database
+docker compose exec v2-postgres psql -U postgres -c \
+  "CREATE DATABASE changemaker_v2 OWNER changemaker;"
+
+# Verify
+docker compose exec v2-postgres psql -U postgres -l | grep changemaker_v2
+
+

Solution 3: Run migrations

+
# Prisma migrations create tables
+docker compose exec api npx prisma migrate deploy
+
+# Drizzle push creates media tables
+docker compose exec api npx drizzle-kit push
+
+# Seed initial data
+docker compose exec api npx prisma db seed
+
+

Solution 4: Check DATABASE_URL

+
# Verify database name in URL
+cat .env | grep DATABASE_URL
+
+# Should end with /changemaker_v2
+# Not:
+# /changemaker (missing _v2)
+# /postgres (wrong database)
+
+

Solution 5: Full reset

+
# ⚠️ Deletes all data!
+docker compose down -v
+docker compose up -d v2-postgres
+
+# Wait for ready
+sleep 10
+
+# Create and migrate
+docker compose exec api npx prisma migrate deploy
+docker compose exec api npx drizzle-kit push
+docker compose exec api npx prisma db seed
+
+

Prevention

+
    +
  • Initialization scripts - Auto-create database on first run
  • +
  • Health checks - Verify database exists before app starts
  • +
  • Migrations - Run migrations in deployment script
  • +
  • Documentation - Clear setup instructions
  • +
+
+

Migration Errors

+

Migration Conflict

+

Severity: 🟠 High

+

Symptoms

+
Error: Migration failed to apply cleanly to the shadow database.
+Error: P3006 Migration `20260101000000_init` failed to apply cleanly to a temporary database.
+
+

Or:

+
Error: The migration `20260201000000_add_field` cannot be applied to the database:
+- Added the required column `fieldName` to the `User` table without a default value.
+
+

Common Causes

+
    +
  1. Schema drift - Database schema doesn't match Prisma schema
  2. +
  3. Non-nullable column - Adding required field to table with data
  4. +
  5. Conflicting migration - Different migration with same name
  6. +
  7. Shadow database issue - Can't create shadow database
  8. +
+

Solutions

+

Solution 1: Check migration status

+
# View migration history
+docker compose exec api npx prisma migrate status
+
+# Shows:
+# - Applied migrations
+# - Pending migrations
+# - Failed migrations
+
+

Solution 2: Add default value for new field

+

If adding non-nullable column to table with existing data:

+
// In prisma/schema.prisma
+model User {
+  id    String @id @default(uuid())
+  email String @unique
+  name  String @default("")  // Add default for existing rows
+}
+
+

Or use two-step migration:

+
-- Migration 1: Add nullable field
+ALTER TABLE "User" ADD COLUMN "name" TEXT;
+
+-- Migration 2: Make non-nullable (after backfilling)
+UPDATE "User" SET "name" = 'Unknown' WHERE "name" IS NULL;
+ALTER TABLE "User" ALTER COLUMN "name" SET NOT NULL;
+
+

Solution 3: Reset database (dev only)

+
# ⚠️ DELETES ALL DATA!
+docker compose exec api npx prisma migrate reset
+
+# This:
+# 1. Drops database
+# 2. Creates database
+# 3. Applies all migrations
+# 4. Runs seed
+
+

Solution 4: Manually fix schema drift

+
# Compare database schema to Prisma schema
+docker compose exec api npx prisma db pull
+
+# This creates a new schema.prisma from database
+# Compare with your current schema.prisma
+# Manually fix differences
+
+

Solution 5: Mark migration as applied (if already applied manually)

+
# If you manually ran migration SQL, mark as applied:
+docker compose exec api npx prisma migrate resolve --applied "20260201000000_migration_name"
+
+

Prevention

+
    +
  • Development workflow - Use prisma migrate dev in dev
  • +
  • Production workflow - Use prisma migrate deploy in prod
  • +
  • Never edit migrations - Don't modify files in migrations/
  • +
  • Test migrations - Test on copy of prod data first
  • +
+
+

Schema Drift

+

Severity: 🟡 Medium

+

Symptoms

+
Warning: Your database schema is not in sync with your Prisma schema.
+
+

Or:

+
Error: P2021 The table `main.NewTable` does not exist in the current database.
+
+

Common Causes

+
    +
  1. Manual schema changes - Changed database without migration
  2. +
  3. Missing migrations - Migrations not run on this database
  4. +
  5. Different environment - Prod vs dev schema mismatch
  6. +
  7. Failed migration - Migration partially applied
  8. +
+

Solutions

+

Solution 1: Detect drift

+
# Check for drift
+docker compose exec api npx prisma migrate diff \
+  --from-schema-datamodel prisma/schema.prisma \
+  --to-schema-datasource prisma/schema.prisma \
+  --script
+
+# If output is empty, no drift
+# If shows SQL, that's the drift
+
+

Solution 2: Create migration from drift

+
# Generate migration to fix drift
+docker compose exec api npx prisma migrate dev --name fix_drift
+
+# Reviews changes and creates migration
+
+

Solution 3: Pull schema from database

+
# Update Prisma schema from database
+docker compose exec api npx prisma db pull
+
+# This overwrites schema.prisma with actual database schema
+# Review changes before committing
+
+

Solution 4: Deploy missing migrations

+
# Apply all pending migrations
+docker compose exec api npx prisma migrate deploy
+
+# Check status
+docker compose exec api npx prisma migrate status
+
+

Solution 5: Reset and re-migrate (dev only)

+
# ⚠️ DELETES ALL DATA!
+docker compose exec api npx prisma migrate reset
+
+# Applies all migrations fresh
+
+

Prevention

+
    +
  • Never manual schema changes - Always use migrations
  • +
  • Consistent workflow - Same process in all environments
  • +
  • CI/CD validation - Check for drift in CI pipeline
  • +
  • Documentation - Document migration process
  • +
+
+

Failed Migration Rollback

+

Severity: 🔴 Critical

+

Symptoms

+
Error: Migration failed. Cannot rollback without losing data.
+
+

Or:

+
Error: Database is in an inconsistent state after a failed migration
+
+

Common Causes

+
    +
  1. Data migration failed - Migration includes data changes that failed
  2. +
  3. Constraint violation - Migration violates database constraints
  4. +
  5. No rollback - Prisma doesn't support automatic rollback
  6. +
  7. Partial application - Migration partially applied before error
  8. +
+

Solutions

+

Solution 1: Mark migration as rolled back

+
# Mark as failed (doesn't undo changes)
+docker compose exec api npx prisma migrate resolve --rolled-back "20260201000000_migration_name"
+
+

Solution 2: Manually revert changes

+
-- Find what migration did
+cat api/prisma/migrations/20260201000000_migration_name/migration.sql
+
+-- Write reverse SQL
+-- If migration did:
+ALTER TABLE "User" ADD COLUMN "newField" TEXT;
+
+-- Reverse is:
+ALTER TABLE "User" DROP COLUMN "newField";
+
+-- Apply reverse
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c 'ALTER TABLE "User" DROP COLUMN "newField";'
+
+

Solution 3: Restore from backup

+
# If you have backup before migration
+docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup-before-migration.sql
+
+# Then mark migration as rolled back
+docker compose exec api npx prisma migrate resolve --rolled-back "20260201000000_migration_name"
+
+

Solution 4: Fix forward

+

Instead of rolling back, fix the issue and continue:

+
# Fix the issue (e.g., add missing default value)
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c 'ALTER TABLE "User" ALTER COLUMN "newField" SET DEFAULT '\''value'\'';'
+
+# Retry migration
+docker compose exec api npx prisma migrate deploy
+
+

Solution 5: Baseline from current state

+

If database is in unknown state:

+
# Create new migration from current state
+docker compose exec api npx prisma migrate dev --name baseline --create-only
+
+# Review generated migration
+# If it looks correct, apply:
+docker compose exec api npx prisma migrate deploy
+
+

Prevention

+
    +
  • Test migrations - Test on copy of prod data first
  • +
  • Backup before migrate - Always backup before production migration
  • +
  • Reversible migrations - Design migrations to be reversible
  • +
  • Small migrations - Small, focused migrations easier to fix
  • +
+
+

Prisma Doesn't Auto-Rollback

+

Prisma Migrate does NOT automatically rollback failed migrations. You must manually fix issues.

+
+
+

Query Performance

+

Slow Queries

+

Severity: 🟡 Medium to 🟠 High

+

Symptoms

+

API requests taking seconds to respond:

+
GET /api/users - 5000ms
+
+

Database logs show slow queries:

+
LOG: duration: 4521.234 ms statement: SELECT * FROM "User" WHERE ...
+
+

Common Causes

+
    +
  1. Missing indexes - Querying without index
  2. +
  3. Full table scan - WHERE clause doesn't use index
  4. +
  5. N+1 queries - Multiple queries instead of JOIN
  6. +
  7. Large result set - Fetching too many rows
  8. +
  9. Complex query - Too many JOINs or subqueries
  10. +
+

Solutions

+

Solution 1: Enable slow query logging

+

In docker-compose.yml:

+
v2-postgres:
+  command: postgres -c log_min_duration_statement=1000
+  # Logs queries taking > 1 second
+
+

Restart:

+
docker compose up -d v2-postgres
+
+# View slow query log
+docker compose logs v2-postgres | grep "duration:"
+
+

Solution 2: Analyze query

+
-- Use EXPLAIN to see query plan
+EXPLAIN ANALYZE
+SELECT * FROM "User"
+WHERE email LIKE '%@example.com%';
+
+-- Output shows:
+-- Seq Scan on "User"  (cost=0.00..20.00 rows=1000 width=100) (actual time=0.123..5.234 rows=50 loops=1)
+--   Filter: (email ~~ '%@example.com%'::text)
+--   Rows Removed by Filter: 950
+-- Planning Time: 0.456 ms
+-- Execution Time: 5.678 ms
+
+-- "Seq Scan" = full table scan (slow)
+-- "Index Scan" = using index (fast)
+
+

Solution 3: Add indexes

+
// In prisma/schema.prisma
+model User {
+  id    String @id @default(uuid())
+  email String @unique  // Creates index automatically
+  name  String
+
+  @@index([name])  // Add index for name searches
+}
+
+

Create migration:

+
docker compose exec api npx prisma migrate dev --name add_user_name_index
+
+

Verify index used:

+
EXPLAIN SELECT * FROM "User" WHERE name = 'John';
+-- Should show: Index Scan using User_name_idx
+
+

Solution 4: Fix N+1 queries

+
// Bad - N+1 queries
+const campaigns = await prisma.campaign.findMany();
+for (const campaign of campaigns) {
+  const emails = await prisma.campaignEmail.findMany({
+    where: { campaignId: campaign.id }
+  });
+}
+// 1 query for campaigns + N queries for emails = N+1
+
+// Good - single query with include
+const campaigns = await prisma.campaign.findMany({
+  include: {
+    emails: true
+  }
+});
+// 1 query total
+
+

Solution 5: Limit result size

+
// Bad - fetch all users
+const users = await prisma.user.findMany();
+
+// Good - paginate
+const users = await prisma.user.findMany({
+  take: 50,  // Limit to 50 rows
+  skip: page * 50,  // Offset for pagination
+});
+
+

Prevention

+
    +
  • Index frequently queried fields - email, createdAt, etc.
  • +
  • Use includes - Avoid N+1 queries
  • +
  • Paginate results - Never fetch all rows
  • +
  • Monitor query performance - Alert on slow queries
  • +
+
+

Missing Indexes

+

Severity: 🟡 Medium

+

Symptoms

+

Slow queries on filtered/sorted columns:

+
SELECT * FROM "Location" WHERE "postalCode" = 'M5H 2N2';
+-- Slow without index on postalCode
+
+

Common Causes

+
    +
  1. No index on filter column - WHERE clause column not indexed
  2. +
  3. No index on sort column - ORDER BY column not indexed
  4. +
  5. No index on foreign key - JOIN column not indexed
  6. +
  7. Composite index needed - Multiple columns in WHERE
  8. +
+

Solutions

+

Solution 1: Identify missing indexes

+
-- Find tables without indexes
+SELECT schemaname, tablename, indexname
+FROM pg_indexes
+WHERE schemaname = 'public'
+ORDER BY tablename;
+
+-- Find columns used in WHERE but not indexed
+-- (requires pg_stat_statements extension)
+
+

Solution 2: Add single-column index

+
model Location {
+  id         String @id @default(uuid())
+  address    String
+  postalCode String
+
+  @@index([postalCode])  // Add index
+}
+
+

Solution 3: Add composite index

+

For queries filtering on multiple columns:

+
model Location {
+  id         String @id @default(uuid())
+  province   String
+  city       String
+  postalCode String
+
+  @@index([province, city])  // Composite index
+  // Speeds up: WHERE province = 'ON' AND city = 'Toronto'
+  // Also speeds up: WHERE province = 'ON'
+  // Does NOT speed up: WHERE city = 'Toronto' (must start with first column)
+}
+
+

Solution 4: Add index on foreign key

+
model CampaignEmail {
+  id         String @id @default(uuid())
+  campaignId String
+
+  campaign Campaign @relation(fields: [campaignId], references: [id])
+
+  @@index([campaignId])  // Index foreign key for JOINs
+}
+
+

Solution 5: Create migration

+
# Generate migration for index
+docker compose exec api npx prisma migrate dev --name add_indexes
+
+# Apply to production
+docker compose exec api npx prisma migrate deploy
+
+

Prevention

+
    +
  • Index foreign keys - Always index foreign keys
  • +
  • Index filter columns - Index columns used in WHERE
  • +
  • Index sort columns - Index columns used in ORDER BY
  • +
  • Monitor query patterns - Add indexes based on actual usage
  • +
+
+

Index Guidelines

+
    +
  • Unique constraints auto-create indexes
  • +
  • Foreign keys should be indexed
  • +
  • Columns in WHERE/ORDER BY/GROUP BY are candidates
  • +
  • Don't over-index (slows down writes)
  • +
+
+
+

N+1 Queries

+

Severity: 🟠 High

+

Symptoms

+

API slow when fetching related data:

+
GET /api/campaigns - 2000ms
+
+

Database logs show many similar queries:

+
SELECT * FROM "CampaignEmail" WHERE "campaignId" = 'uuid1'
+SELECT * FROM "CampaignEmail" WHERE "campaignId" = 'uuid2'
+SELECT * FROM "CampaignEmail" WHERE "campaignId" = 'uuid3'
+...
+
+

Common Causes

+
    +
  1. No eager loading - Fetching relations in loop
  2. +
  3. Separate queries - Not using include/select
  4. +
  5. Nested loops - Multiple levels of relations
  6. +
+

Solutions

+

Solution 1: Detect N+1 queries

+

Enable query logging:

+
// In api/src/config/database.ts
+export const prisma = new PrismaClient({
+  log: ['query'],  // Log all queries
+});
+
+

Look for repeated patterns:

+
Query: SELECT * FROM "Campaign"
+Query: SELECT * FROM "CampaignEmail" WHERE "campaignId" = '...'
+Query: SELECT * FROM "CampaignEmail" WHERE "campaignId" = '...'
+Query: SELECT * FROM "CampaignEmail" WHERE "campaignId" = '...'
+
+

Solution 2: Use include

+
// Bad - N+1
+const campaigns = await prisma.campaign.findMany();
+for (const campaign of campaigns) {
+  campaign.emails = await prisma.campaignEmail.findMany({
+    where: { campaignId: campaign.id }
+  });
+}
+// 1 + N queries
+
+// Good - single query
+const campaigns = await prisma.campaign.findMany({
+  include: {
+    emails: true
+  }
+});
+// 2 queries (1 for campaigns, 1 for all emails with JOIN)
+
+

Solution 3: Nested includes

+
// Multi-level relations
+const campaigns = await prisma.campaign.findMany({
+  include: {
+    emails: {
+      include: {
+        user: true  // Include user who sent email
+      }
+    },
+    createdBy: true
+  }
+});
+
+

Solution 4: Select only needed fields

+
// Fetch only needed data
+const campaigns = await prisma.campaign.findMany({
+  select: {
+    id: true,
+    name: true,
+    emails: {
+      select: {
+        id: true,
+        sentAt: true
+      }
+    }
+  }
+});
+
+

Solution 5: Use findUnique with include for single record

+
// Bad
+const campaign = await prisma.campaign.findUnique({
+  where: { id }
+});
+const emails = await prisma.campaignEmail.findMany({
+  where: { campaignId: id }
+});
+
+// Good
+const campaign = await prisma.campaign.findUnique({
+  where: { id },
+  include: { emails: true }
+});
+
+

Prevention

+
    +
  • Always use include - Load relations in single query
  • +
  • Enable query logging - Monitor for N+1 patterns
  • +
  • Code review - Check for loops with queries
  • +
  • Testing - Load test with realistic data
  • +
+
+

Connection Pool Exhaustion

+

Severity: 🟠 High

+

Symptoms

+
Error: Timed out fetching a new connection from the connection pool.
+
+

Or:

+
Error: Can't create connection pool - all connections are in use
+
+

API becomes unresponsive.

+

Common Causes

+
    +
  1. Pool too small - Not enough connections for load
  2. +
  3. Connections not released - Long-running transactions
  4. +
  5. Too many workers - BullMQ workers using all connections
  6. +
  7. Connection leak - Connections never closed
  8. +
+

Solutions

+

Solution 1: Check pool size

+
# View DATABASE_URL
+cat .env | grep DATABASE_URL
+
+# Default connection_limit is 10
+# Check if you've set it:
+postgresql://user:pass@host:5432/db?connection_limit=10
+
+

Solution 2: Increase pool size

+
# In .env
+DATABASE_URL="postgresql://changemaker:password@v2-postgres:5432/changemaker_v2?connection_limit=20"
+
+# Restart API
+docker compose restart api
+
+

Solution 3: Check active connections

+
-- View connection pool usage
+SELECT count(*), state
+FROM pg_stat_activity
+WHERE datname = 'changemaker_v2'
+GROUP BY state;
+
+-- Should show:
+-- count | state
+--    5  | active
+--    2  | idle
+--    3  | idle in transaction
+
+

Solution 4: Find long-running transactions

+
-- Find transactions running > 1 minute
+SELECT pid, usename, state, NOW() - xact_start AS duration, query
+FROM pg_stat_activity
+WHERE datname = 'changemaker_v2'
+  AND state = 'idle in transaction'
+  AND NOW() - xact_start > INTERVAL '1 minute';
+
+-- Kill if stuck
+SELECT pg_terminate_backend(pid);
+
+

Solution 5: Configure pool timeout

+
# Increase timeout from 10s to 30s
+DATABASE_URL="postgresql://...?connection_limit=20&pool_timeout=30"
+
+

Prevention

+
    +
  • Appropriate pool size - Size based on load
  • +
  • Release connections - Always close transactions
  • +
  • Monitor pool usage - Alert when near limit
  • +
  • Connection timeout - Kill stuck connections
  • +
+
+

Pool Sizing

+

Recommended pool size = (CPU cores × 2) + effective_spindle_count

+

For most applications: 10-20 connections per API instance

+
+
+

Data Issues

+

Duplicate Records

+

Severity: 🟡 Medium

+

Symptoms

+
Error: Unique constraint failed on the fields: (`email`)
+
+

Or finding multiple records:

+
SELECT email, count(*)
+FROM "User"
+GROUP BY email
+HAVING count(*) > 1;
+-- Returns duplicates
+
+

Common Causes

+
    +
  1. Race condition - Two creates at exact same time
  2. +
  3. Import error - CSV import created duplicates
  4. +
  5. Migration bug - Migration didn't handle duplicates
  6. +
  7. No unique constraint - Database allows duplicates
  8. +
+

Solutions

+

Solution 1: Find duplicates

+
-- Find duplicate emails
+SELECT email, array_agg(id) AS ids, count(*)
+FROM "User"
+GROUP BY email
+HAVING count(*) > 1;
+
+-- Example output:
+-- email              | ids                                    | count
+-- john@example.com   | {uuid1, uuid2}                        | 2
+
+

Solution 2: Delete duplicates (keep oldest)

+
-- Delete newer duplicates, keep oldest
+DELETE FROM "User" u1
+WHERE EXISTS (
+  SELECT 1 FROM "User" u2
+  WHERE u2.email = u1.email
+    AND u2."createdAt" < u1."createdAt"
+);
+
+-- Or keep newest:
+DELETE FROM "User" u1
+WHERE EXISTS (
+  SELECT 1 FROM "User" u2
+  WHERE u2.email = u1.email
+    AND u2."createdAt" > u1."createdAt"
+);
+
+

Solution 3: Merge duplicates

+
-- If duplicates have different data, merge:
+-- 1. Update foreign keys to point to kept record
+UPDATE "Campaign" SET "createdByUserId" = 'uuid-to-keep'
+WHERE "createdByUserId" = 'uuid-to-delete';
+
+-- 2. Delete duplicate
+DELETE FROM "User" WHERE id = 'uuid-to-delete';
+
+

Solution 4: Add unique constraint

+
model User {
+  id    String @id @default(uuid())
+  email String @unique  // Ensures uniqueness
+}
+
+

Create migration:

+
# This will fail if duplicates exist
+# Delete duplicates first (Solution 2)
+docker compose exec api npx prisma migrate dev --name add_unique_email
+
+

Solution 5: Prevent in application code

+
// Use upsert instead of create
+const user = await prisma.user.upsert({
+  where: { email },
+  update: {},  // Don't change if exists
+  create: { email, name, password }
+});
+
+

Prevention

+
    +
  • Unique constraints - Database enforces uniqueness
  • +
  • Use upsert - Update or create atomically
  • +
  • Validation - Check existence before creating
  • +
  • Transaction isolation - Prevent race conditions
  • +
+
+

Constraint Violations

+

Severity: 🟡 Medium

+

Symptoms

+
Error: Foreign key constraint failed on the field: `campaignId`
+
+

Or:

+
Error: Null value in column "name" violates not-null constraint
+
+

Or:

+
Error: Check constraint "positive_age" is violated
+
+

Common Causes

+
    +
  1. Foreign key missing - Referenced record doesn't exist
  2. +
  3. Null in required field - NULL when NOT NULL constraint
  4. +
  5. Check constraint - Value violates CHECK constraint
  6. +
  7. Data type mismatch - Wrong type for column
  8. +
+

Solutions

+

Solution 1: Verify foreign key exists

+
-- Check if campaign exists
+SELECT id FROM "Campaign" WHERE id = 'campaign-uuid';
+
+-- If not found, create parent first
+
+

Solution 2: Provide required fields

+
// Bad - missing required field
+await prisma.user.create({
+  data: {
+    email: 'user@example.com'
+    // Missing: name (required)
+  }
+});
+
+// Good - all required fields
+await prisma.user.create({
+  data: {
+    email: 'user@example.com',
+    name: 'User Name',
+    password: 'hashed-password'
+  }
+});
+
+

Solution 3: Handle check constraints

+
-- If schema has:
+ALTER TABLE "User" ADD CONSTRAINT age_check CHECK (age >= 0);
+
+-- Ensure value meets constraint:
+INSERT INTO "User" (email, age) VALUES ('user@example.com', 25);
+-- Not: VALUES ('user@example.com', -5);
+
+

Solution 4: Fix data type

+
// Bad - passing string for number
+await prisma.location.create({
+  data: {
+    latitude: "43.65" as any  // Wrong type
+  }
+});
+
+// Good - use number
+await prisma.location.create({
+  data: {
+    latitude: 43.65  // Correct type
+  }
+});
+
+

Solution 5: Use transactions for dependent creates

+
// Create parent and child atomically
+await prisma.$transaction(async (tx) => {
+  const campaign = await tx.campaign.create({
+    data: { name: 'My Campaign' }
+  });
+
+  const email = await tx.campaignEmail.create({
+    data: {
+      campaignId: campaign.id,
+      subject: 'Email Subject'
+    }
+  });
+});
+
+

Prevention

+
    +
  • TypeScript types - Catch type errors at compile time
  • +
  • Zod validation - Validate before database operations
  • +
  • Foreign key checks - Verify parent exists
  • +
  • Transactions - Atomic multi-step operations
  • +
+
+

Data Corruption

+

Severity: 🔴 Critical

+

Symptoms

+
    +
  • Invalid JSON in JSON columns
  • +
  • Truncated text
  • +
  • Wrong character encoding
  • +
  • Inconsistent relationships
  • +
+
SELECT * FROM "Campaign" WHERE "settings"::text LIKE '%\\u0000%';
+-- Null bytes in JSON
+
+

Common Causes

+
    +
  1. Bad import - CSV/JSON import with bad data
  2. +
  3. Encoding issues - Wrong character encoding
  4. +
  5. Failed migration - Migration partially applied
  6. +
  7. Application bug - Code writing bad data
  8. +
+

Solutions

+

Solution 1: Detect corruption

+
-- Find invalid JSON
+SELECT id, settings
+FROM "Campaign"
+WHERE settings IS NOT NULL
+  AND settings::text !~ '^[\[\{].*[\]\}]$';
+
+-- Find null bytes
+SELECT id, name
+FROM "Location"
+WHERE name LIKE '%' || chr(0) || '%';
+
+-- Find wrong encoding
+SELECT id, address
+FROM "Location"
+WHERE address ~ '[^\x00-\x7F]' AND address !~ '[À-ÿ]';
+
+

Solution 2: Fix invalid JSON

+
-- Replace invalid JSON with valid default
+UPDATE "Campaign"
+SET settings = '{}'::jsonb
+WHERE settings IS NOT NULL
+  AND settings::text !~ '^[\[\{].*[\]\}]$';
+
+

Solution 3: Fix encoding

+
-- Convert encoding
+UPDATE "Location"
+SET address = convert_from(convert_to(address, 'LATIN1'), 'UTF8')
+WHERE address ~ '[^\x00-\x7F]';
+
+

Solution 4: Restore from backup

+
# If corruption is widespread, restore from backup
+docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup-before-corruption.sql
+
+

Solution 5: Prevent future corruption

+
// Validate data before saving
+import { z } from 'zod';
+
+const settingsSchema = z.object({
+  key: z.string(),
+  value: z.any()
+});
+
+// Before save
+const validated = settingsSchema.parse(settings);
+await prisma.campaign.update({
+  where: { id },
+  data: { settings: validated as any }
+});
+
+

Prevention

+
    +
  • Input validation - Validate all inputs with Zod
  • +
  • UTF-8 encoding - Use UTF-8 everywhere
  • +
  • Regular backups - Daily backups
  • +
  • Data integrity checks - Regular validation scripts
  • +
+
+

Prisma Studio Issues

+

Won't Connect

+

Severity: 🟢 Low

+

Symptoms

+
docker compose exec api npx prisma studio
+
+

Opens browser but shows:

+
Error connecting to database
+
+

Solutions

+

Solution 1: Check DATABASE_URL

+
# Verify DATABASE_URL in container
+docker compose exec api sh -c 'echo $DATABASE_URL'
+
+# Should be valid connection string
+
+

Solution 2: Test connection

+
# Test database connection
+docker compose exec api npx prisma db pull
+
+# If fails, connection string is wrong
+
+

Solution 3: Use correct port

+

Prisma Studio runs on port 5555 by default. If port conflicts:

+
# Use different port
+docker compose exec api npx prisma studio --port 5556
+
+

Solution 4: Check database is running

+
docker compose ps v2-postgres
+# Must be "Up"
+
+
+

Slow Loading

+

Severity: 🟢 Low

+

Symptoms

+

Prisma Studio takes minutes to load tables with many rows.

+

Solutions

+

Solution 1: Limit rows

+

Prisma Studio loads all rows. For large tables, use SQL instead:

+
# Instead of Prisma Studio for large tables
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2
+
+

Solution 2: Add pagination

+
-- In psql, paginate manually
+SELECT * FROM "Location" LIMIT 50 OFFSET 0;
+SELECT * FROM "Location" LIMIT 50 OFFSET 50;
+
+
+

Drizzle Kit Issues

+

Push Failures

+

Severity: 🟠 High

+

Symptoms

+
docker compose exec api npx drizzle-kit push
+
+

Fails with:

+
Error: Failed to push schema changes
+
+

Solutions

+

Solution 1: Check Drizzle config

+
// In api/drizzle.config.ts
+import { defineConfig } from 'drizzle-kit';
+
+export default defineConfig({
+  schema: './src/modules/media/db/schema.ts',
+  out: './drizzle',
+  dialect: 'postgresql',
+  dbCredentials: {
+    url: process.env.DATABASE_URL!
+  }
+});
+
+

Solution 2: Verify schema file

+
# Check schema file exists
+docker compose exec api ls -la src/modules/media/db/schema.ts
+
+# Check for syntax errors
+docker compose exec api npx tsc --noEmit src/modules/media/db/schema.ts
+
+

Solution 3: Check for conflicts with Prisma tables

+

Drizzle and Prisma share same database. Ensure table names don't conflict:

+
// Drizzle tables
+export const videos = pgTable('media_videos', { ... });
+export const reactions = pgTable('media_reactions', { ... });
+
+// Prisma uses: User, Campaign, etc. (no conflict)
+
+

Solution 4: Manually apply schema

+
# Generate SQL
+docker compose exec api npx drizzle-kit generate:pg
+
+# Review SQL in drizzle/ directory
+# Apply manually if needed
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 < drizzle/0000_schema.sql
+
+
+

Backup/Restore Issues

+

pg_dump Errors

+

Severity: 🟠 High

+

Symptoms

+
docker compose exec v2-postgres pg_dump -U changemaker changemaker_v2 > backup.sql
+
+

Fails with:

+
pg_dump: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: No such file or directory
+
+

Solutions

+

Solution 1: Use correct connection

+
# From inside container
+docker compose exec v2-postgres pg_dump -U changemaker changemaker_v2 > backup.sql
+
+# Or specify host explicitly
+docker compose exec v2-postgres pg_dump -U changemaker -h v2-postgres changemaker_v2 > backup.sql
+
+

Solution 2: Backup to file inside container

+
# Dump to file inside container
+docker compose exec v2-postgres pg_dump -U changemaker changemaker_v2 -f /tmp/backup.sql
+
+# Copy to host
+docker cp changemaker-lite-v2-postgres-1:/tmp/backup.sql ./backup.sql
+
+

Solution 3: Use backup script

+
# Use provided backup script
+./scripts/backup.sh
+
+
+

Restore Failures

+

Severity: 🔴 Critical

+

Symptoms

+
docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup.sql
+
+

Fails with errors:

+
ERROR: relation "User" already exists
+ERROR: duplicate key value violates unique constraint
+
+

Solutions

+

Solution 1: Drop database first

+
# ⚠️ DELETES ALL DATA!
+docker compose exec v2-postgres psql -U postgres -c "DROP DATABASE changemaker_v2;"
+docker compose exec v2-postgres psql -U postgres -c "CREATE DATABASE changemaker_v2 OWNER changemaker;"
+
+# Then restore
+docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup.sql
+
+

Solution 2: Use --clean flag

+
# Create backup with clean option
+docker compose exec v2-postgres pg_dump -U changemaker --clean changemaker_v2 > backup.sql
+
+# Restore (drops existing objects first)
+docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup.sql
+
+

Solution 3: Ignore errors for existing objects

+
# Restore and ignore "already exists" errors
+docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup.sql 2>&1 | grep -v "already exists"
+
+
+

Useful Commands

+

Query Database

+
# Connect to database
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2
+
+# Run single query
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c "SELECT NOW();"
+
+# Run SQL file
+docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < script.sql
+
+# Export query results to CSV
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "COPY (SELECT * FROM \"User\") TO STDOUT WITH CSV HEADER" > users.csv
+
+

Database Inspection

+
# List databases
+docker compose exec v2-postgres psql -U postgres -l
+
+# List tables
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c "\dt"
+
+# Describe table
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c "\d \"User\""
+
+# List indexes
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c "\di"
+
+# View table sizes
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c "
+SELECT
+  schemaname,
+  tablename,
+  pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
+FROM pg_tables
+WHERE schemaname = 'public'
+ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
+"
+
+

Performance Analysis

+
# Current activity
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c "
+SELECT pid, usename, application_name, state, query_start, query
+FROM pg_stat_activity
+WHERE datname = 'changemaker_v2'
+ORDER BY query_start;
+"
+
+# Table statistics
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c "
+SELECT schemaname, tablename, n_live_tup, n_dead_tup, last_autovacuum
+FROM pg_stat_user_tables
+ORDER BY n_live_tup DESC;
+"
+
+# Index usage
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c "
+SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read, idx_tup_fetch
+FROM pg_stat_user_indexes
+ORDER BY idx_scan DESC;
+"
+
+# Unused indexes
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 -c "
+SELECT schemaname, tablename, indexname, idx_scan
+FROM pg_stat_user_indexes
+WHERE idx_scan = 0 AND indexname NOT LIKE '%pkey'
+ORDER BY pg_relation_size(indexname::regclass) DESC;
+"
+
+
+ +

Database Documentation

+ +

Other Troubleshooting

+ +

PostgreSQL Resources

+ +
+

Last Updated: February 2026 +Version: V2.0 +Status: Complete

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/troubleshooting/docker-issues/index.html b/mkdocs/site/v2/troubleshooting/docker-issues/index.html new file mode 100644 index 00000000..c2f285bf --- /dev/null +++ b/mkdocs/site/v2/troubleshooting/docker-issues/index.html @@ -0,0 +1,8772 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Docker Issues - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Docker and Container Issues

+

This guide covers Docker-specific problems in Changemaker Lite V2.

+

Overview

+

Docker Troubleshooting Approach

+
    +
  1. Check status - Are containers running?
  2. +
  3. Read logs - What do container logs show?
  4. +
  5. Inspect configuration - Is docker-compose.yml correct?
  6. +
  7. Test connectivity - Can containers communicate?
  8. +
  9. Resource check - Enough CPU/memory/disk?
  10. +
+

Essential Docker Commands

+
# View running containers
+docker compose ps
+
+# View all containers (including stopped)
+docker compose ps -a
+
+# View logs
+docker compose logs [service-name]
+
+# Follow logs in real-time
+docker compose logs -f [service-name]
+
+# Execute command in container
+docker compose exec [service-name] [command]
+
+# Restart service
+docker compose restart [service-name]
+
+# Stop all services
+docker compose down
+
+# Start services
+docker compose up -d
+
+# Rebuild and start
+docker compose up -d --build [service-name]
+
+
+

Container Won't Start

+

Port Already in Use

+

Severity: 🔴 Critical

+

Symptoms

+
Error response from daemon: driver failed programming external connectivity
+on endpoint changemaker-lite-admin-1: Bind for 0.0.0.0:3000 failed:
+port is already allocated
+
+

Or:

+
ERROR: for api  Cannot start service api: Ports are not available:
+exposing port TCP 0.0.0.0:4000 -> 0.0.0.0:0: listen tcp 0.0.0.0:4000:
+bind: address already in use
+
+

Common Causes

+
    +
  1. Another container using port - Different Docker project
  2. +
  3. Host process using port - npm dev server running
  4. +
  5. Previous container not stopped - Old container still running
  6. +
  7. Port conflict in docker-compose.yml - Two services same port
  8. +
+

Solutions

+

Solution 1: Find what's using the port

+
# Linux/Mac
+sudo lsof -i :4000
+
+# Or with netstat
+netstat -tuln | grep :4000
+
+# Windows
+netstat -ano | findstr :4000
+
+

Output shows: +

COMMAND   PID  USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
+node    12345  user   23u  IPv4 123456      0t0  TCP *:4000 (LISTEN)
+

+

Solution 2: Stop conflicting process

+
# Kill process by PID
+kill 12345
+
+# Or kill all node processes (careful!)
+killall node
+
+# Or stop other Docker containers
+docker ps  # List all running containers
+docker stop container-name-or-id
+
+

Solution 3: Change port in docker-compose.yml

+
# In docker-compose.yml
+api:
+  ports:
+    - "4002:4000"  # Changed from 4000:4000
+
+

Then:

+
# Restart with new port
+docker compose up -d api
+
+# Update .env to use new port
+VITE_API_URL=http://localhost:4002
+
+

Solution 4: Stop all and restart

+
# Stop all Changemaker Lite containers
+docker compose down
+
+# Verify nothing running
+docker compose ps
+
+# Start fresh
+docker compose up -d
+
+

Prevention

+
    +
  • Use unique ports - Avoid common ports (3000, 4000, 8000, 8080)
  • +
  • Stop properly - Always use docker compose down
  • +
  • Check before start - Run docker compose ps first
  • +
  • Document ports - Keep port reference updated
  • +
+
+

Volume Mount Errors

+

Severity: 🔴 Critical

+

Symptoms

+
Error response from daemon: invalid mount config for type "bind":
+bind source path does not exist: /home/user/changemaker.lite/uploads
+
+

Or:

+
Error: EACCES: permission denied, open '/media/local/inbox/video.mp4'
+
+

Common Causes

+
    +
  1. Path doesn't exist - Directory not created
  2. +
  3. Permission denied - Container can't access directory
  4. +
  5. Wrong path - Typo in docker-compose.yml
  6. +
  7. SELinux blocking - Linux security policy
  8. +
+

Solutions

+

Solution 1: Create missing directories

+
# Create all required directories
+mkdir -p uploads
+mkdir -p media/local/inbox
+mkdir -p media/local/library
+mkdir -p data
+mkdir -p configs/prometheus
+mkdir -p configs/grafana
+
+# Verify they exist
+ls -la
+
+

Solution 2: Fix permissions

+
# Make directories writable
+chmod -R 777 uploads
+chmod -R 777 media/local/inbox
+
+# Or set ownership to container user
+# Check container user ID
+docker compose exec api id
+# uid=1000(node) gid=1000(node)
+
+# Set ownership
+sudo chown -R 1000:1000 uploads
+sudo chown -R 1000:1000 media
+
+

Solution 3: Check volume configuration

+

In docker-compose.yml:

+
api:
+  volumes:
+    # Correct format:
+    - ./uploads:/app/uploads:rw        # Read-write
+    - ./media:/media:ro                 # Read-only
+
+    # Wrong formats:
+    # - uploads:/app/uploads            # Named volume, not bind mount
+    # - /uploads:/app/uploads           # Absolute path on host
+
+

Solution 4: Disable SELinux (last resort)

+
# Check if SELinux is the issue
+getenforce
+# If "Enforcing":
+
+# Option 1: Add :z flag to volume
+# In docker-compose.yml:
+    - ./uploads:/app/uploads:z
+
+# Option 2: Temporarily disable (not recommended)
+sudo setenforce 0
+
+

Solution 5: Verify mount inside container

+
# Check if mount exists
+docker compose exec api ls -la /app/uploads
+
+# Check permissions
+docker compose exec api ls -ld /app/uploads
+
+# Try creating file
+docker compose exec api touch /app/uploads/test.txt
+
+

Prevention

+
    +
  • Create directories first - Before docker compose up
  • +
  • Set permissions early - In setup script
  • +
  • Use relative paths - Start with ./ in docker-compose.yml
  • +
  • Document requirements - List all required directories
  • +
+
+

Missing Environment Variables

+

Severity: 🔴 Critical

+

Symptoms

+

Container logs show:

+
Error: DATABASE_URL is required
+
+

Or:

+
ZodError: [
+  {
+    "code": "invalid_type",
+    "expected": "string",
+    "received": "undefined",
+    "path": ["SMTP_HOST"],
+    "message": "Required"
+  }
+]
+
+

Or container exits immediately:

+
changemaker-lite-api-1 exited with code 1
+
+

Common Causes

+
    +
  1. .env not found - Missing .env file
  2. +
  3. Variable not set - Missing required variable
  4. +
  5. Wrong .env location - .env not in project root
  6. +
  7. Syntax error - Malformed .env file
  8. +
+

Solutions

+

Solution 1: Check .env exists

+
# Verify .env file
+ls -la .env
+
+# If missing, copy from example
+cp .env.example .env
+
+

Solution 2: Find missing variables

+
# View container logs to see which variable
+docker compose logs api | grep -i "required\|undefined"
+
+# Example output:
+# Error: SMTP_HOST is required
+
+

Solution 3: Add missing variables

+
# Edit .env
+nano .env
+
+# Add missing variable
+SMTP_HOST=smtp.gmail.com
+
+# Save and restart
+docker compose restart api
+
+

Solution 4: Validate .env format

+
# Check for common issues:
+# - No spaces around =
+# - Quotes for values with spaces
+# - No trailing commas
+# - No comments on same line as value
+
+# Good:
+DATABASE_URL="postgresql://user:pass@host:5432/db"
+CORS_ORIGINS=http://localhost:3000,http://localhost:4000
+
+# Bad:
+DATABASE_URL = "postgresql://..."  # Space around =
+CORS_ORIGINS=http://localhost:3000, http://localhost:4000  # Space after comma
+SMTP_HOST=smtp.gmail.com # Gmail  # Comment on same line
+
+

Solution 5: Check which variables are loaded

+
# View environment inside container
+docker compose exec api env | grep -E "DATABASE_URL|SMTP_HOST|JWT_"
+
+# Should show actual values (not undefined)
+
+

Prevention

+
    +
  • Use .env.example - Keep template updated
  • +
  • Validation on startup - Zod validates env in config/env.ts
  • +
  • Documentation - Document all required variables
  • +
  • Setup script - Validate .env before starting
  • +
+
+

Health Check Failures

+

Severity: 🟠 High

+

Symptoms

+
docker compose ps
+
+

Shows:

+
NAME                    STATUS
+api                     Up 30 seconds (unhealthy)
+v2-postgres            Up 1 minute (healthy)
+
+

Or logs show:

+
Health check failed
+
+

Common Causes

+
    +
  1. Service not ready - Still starting up
  2. +
  3. Health check endpoint failing - /health returns error
  4. +
  5. Timeout too short - Service needs more time
  6. +
  7. Dependencies not ready - Database not connected
  8. +
+

Solutions

+

Solution 1: Check health check configuration

+

In docker-compose.yml:

+
api:
+  healthcheck:
+    test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:4000/api/health"]
+    interval: 30s
+    timeout: 10s
+    retries: 3
+    start_period: 40s
+
+

Solution 2: Test health endpoint manually

+
# From inside container
+docker compose exec api wget -O- http://localhost:4000/api/health
+
+# Should return:
+# {"status":"healthy","timestamp":"2026-02-13T..."}
+
+# From host
+curl http://localhost:4000/api/health
+
+

Solution 3: View health check logs

+
# Detailed health check output
+docker inspect changemaker-lite-api-1 --format='{{json .State.Health}}' | jq
+
+# Shows:
+# {
+#   "Status": "unhealthy",
+#   "FailingStreak": 3,
+#   "Log": [
+#     {
+#       "Start": "2026-02-13T...",
+#       "End": "2026-02-13T...",
+#       "ExitCode": 1,
+#       "Output": "Error: Connection refused"
+#     }
+#   ]
+# }
+
+

Solution 4: Increase timeout/interval

+
api:
+  healthcheck:
+    interval: 60s      # Check less frequently
+    timeout: 30s       # Allow more time
+    start_period: 90s  # Wait longer before first check
+
+

Solution 5: Check service logs

+
# Real issue is usually in service logs
+docker compose logs api | tail -50
+
+# Common issues:
+# - Database connection failed
+# - Missing environment variable
+# - Port already in use
+
+

Prevention

+
    +
  • Reasonable timeouts - Allow enough time for startup
  • +
  • Accurate health checks - Check actual readiness
  • +
  • Monitor health - Alert on unhealthy containers
  • +
  • Dependencies - Use depends_on with condition: service_healthy
  • +
+
+

Container Crashes

+

Out of Memory

+

Severity: 🔴 Critical

+

Symptoms

+

Container logs show:

+
<--- Last few GCs --->
+[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
+
+<--- JS stacktrace --->
+FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory
+
+

Or:

+
Killed
+
+

Or docker compose ps shows:

+
api   Exit 137
+
+

Common Causes

+
    +
  1. Memory leak - Application leaking memory
  2. +
  3. Large dataset - Processing too much data
  4. +
  5. Too many connections - Database connection pool too large
  6. +
  7. Container limit - Memory limit too low
  8. +
+

Solutions

+

Solution 1: Check memory usage

+
# View container memory usage
+docker stats
+
+# Shows:
+# CONTAINER           CPU %    MEM USAGE / LIMIT     MEM %
+# api                 15.5%    1.2GiB / 2GiB        60%
+
+

Solution 2: Increase Node.js heap size

+

In docker-compose.yml:

+
api:
+  environment:
+    - NODE_OPTIONS=--max-old-space-size=4096  # 4GB heap
+
+

Or in api/package.json:

+
{
+  "scripts": {
+    "start": "node --max-old-space-size=4096 dist/server.js"
+  }
+}
+
+

Solution 3: Increase container memory limit

+
api:
+  deploy:
+    resources:
+      limits:
+        memory: 4G  # Increase from 2G
+      reservations:
+        memory: 2G
+
+

Solution 4: Find memory leak

+
# Enable heap snapshots
+docker compose exec api node --inspect dist/server.js
+
+# Or use clinic.js
+npm install -g clinic
+clinic doctor -- node dist/server.js
+
+

Solution 5: Reduce memory usage

+
// Reduce database connection pool
+// In prisma/schema.prisma
+datasource db {
+  provider = "postgresql"
+  url      = env("DATABASE_URL")
+  // Add connection limit
+}
+
+// In DATABASE_URL:
+DATABASE_URL="postgresql://...?connection_limit=5"
+
+// Process data in batches
+const users = await prisma.user.findMany({
+  take: 100,  // Limit batch size
+  skip: offset
+});
+
+

Prevention

+
    +
  • Monitor memory - Alert on high usage
  • +
  • Generous limits - Set limits higher than expected usage
  • +
  • Memory profiling - Regular memory audits
  • +
  • Optimize queries - Reduce data fetched
  • +
+
+

Application Errors

+

Severity: 🔴 Critical

+

Symptoms

+

Container exits immediately:

+
api-1 exited with code 1
+
+

Logs show:

+
Error: Cannot find module 'express'
+
+

Or:

+
SyntaxError: Unexpected token 'export'
+
+

Common Causes

+
    +
  1. Missing dependencies - npm install not run
  2. +
  3. Build not run - TypeScript not compiled
  4. +
  5. Syntax error - Code has errors
  6. +
  7. Wrong Node version - Incompatible Node.js version
  8. +
+

Solutions

+

Solution 1: Rebuild container

+
# Rebuild with no cache
+docker compose build --no-cache api
+
+# Start
+docker compose up -d api
+
+# View logs
+docker compose logs -f api
+
+

Solution 2: Check dependencies

+
# Verify package.json and package-lock.json exist
+docker compose exec api ls -la package*.json
+
+# Verify node_modules exists
+docker compose exec api ls -la node_modules | head
+
+# If missing, install
+docker compose exec api npm install
+
+

Solution 3: Verify build

+
# Check if TypeScript compiled
+docker compose exec api ls -la dist/
+
+# If missing, build
+docker compose exec api npm run build
+
+# Or rebuild container
+docker compose up -d --build api
+
+

Solution 4: Check Node version

+
# Check version in container
+docker compose exec api node --version
+
+# Should match Dockerfile
+cat api/Dockerfile | grep "FROM node:"
+
+# Example:
+# FROM node:20-alpine
+
+

Solution 5: Test locally

+
# Test build locally
+cd api
+npm install
+npm run build
+npm start
+
+# If works locally but not in Docker, check:
+# - Dockerfile COPY commands
+# - .dockerignore file
+# - Volume mounts
+
+

Prevention

+
    +
  • Multi-stage builds - Separate build and runtime
  • +
  • Lock files - Commit package-lock.json
  • +
  • CI/CD - Automated build testing
  • +
  • Version pinning - Pin Node.js version
  • +
+
+

Database Connection Failures

+

Severity: 🔴 Critical

+

Symptoms

+

API logs show:

+
Error: Can't reach database server at `v2-postgres:5432`
+Error: connect ECONNREFUSED 172.18.0.2:5432
+
+

Container restarts repeatedly.

+

Common Causes

+
    +
  1. Database not ready - API started before database
  2. +
  3. Wrong host - Incorrect database hostname
  4. +
  5. Network issue - Containers on different networks
  6. +
  7. Database crashed - PostgreSQL container down
  8. +
+

Solutions

+

Solution 1: Check database status

+
# Is database running?
+docker compose ps v2-postgres
+
+# Should show "Up" status
+# If not:
+docker compose up -d v2-postgres
+
+# Check logs
+docker compose logs v2-postgres | tail -50
+
+

Solution 2: Verify DATABASE_URL

+
# Check .env
+cat .env | grep DATABASE_URL
+
+# From API container, should use container name:
+DATABASE_URL="postgresql://changemaker:password@v2-postgres:5432/changemaker_v2"
+
+# From host, use localhost:
+DATABASE_URL="postgresql://changemaker:password@localhost:5433/changemaker_v2"
+
+

Solution 3: Test database connection

+
# From API container
+docker compose exec api sh -c 'psql $DATABASE_URL -c "SELECT NOW();"'
+
+# Should return current timestamp
+# If fails, database connection is broken
+
+

Solution 4: Check Docker network

+
# List networks
+docker network ls
+
+# Inspect changemaker-lite network
+docker network inspect changemaker-lite
+
+# All containers should be on same network
+
+

Solution 5: Use depends_on with health check

+

In docker-compose.yml:

+
api:
+  depends_on:
+    v2-postgres:
+      condition: service_healthy
+  # ...
+
+v2-postgres:
+  healthcheck:
+    test: ["CMD-SHELL", "pg_isready -U changemaker"]
+    interval: 10s
+    timeout: 5s
+    retries: 5
+
+

Prevention

+
    +
  • Health checks - Wait for database to be ready
  • +
  • Retry logic - Retry connection on startup
  • +
  • Connection pooling - Handle connection failures gracefully
  • +
  • Monitoring - Alert on connection failures
  • +
+
+

Networking Issues

+

Containers Can't Communicate

+

Severity: 🔴 Critical

+

Symptoms

+
Error: getaddrinfo ENOTFOUND v2-postgres
+
+

Or:

+
Error: connect EHOSTUNREACH 172.18.0.2:5432
+
+

Containers can't ping each other.

+

Common Causes

+
    +
  1. Different networks - Containers on separate Docker networks
  2. +
  3. Wrong hostname - Using IP instead of container name
  4. +
  5. Firewall - Host firewall blocking
  6. +
  7. DNS issue - Docker DNS not working
  8. +
+

Solutions

+

Solution 1: Verify same network

+
# Check container networks
+docker inspect changemaker-lite-api-1 | grep NetworkMode
+docker inspect changemaker-lite-v2-postgres-1 | grep NetworkMode
+
+# Should both show "changemaker-lite"
+
+

Solution 2: Use container names

+
# Correct - use service names
+api:
+  environment:
+    - DATABASE_URL=postgresql://user:pass@v2-postgres:5432/db
+
+# Wrong - using IPs
+api:
+  environment:
+    - DATABASE_URL=postgresql://user:pass@172.18.0.2:5432/db
+
+

Solution 3: Test connectivity

+
# Ping from one container to another
+docker compose exec api ping v2-postgres
+
+# DNS lookup
+docker compose exec api nslookup v2-postgres
+
+# Telnet to port
+docker compose exec api telnet v2-postgres 5432
+
+

Solution 4: Recreate network

+
# Stop all containers
+docker compose down
+
+# Remove network
+docker network rm changemaker-lite
+
+# Start fresh (network auto-created)
+docker compose up -d
+
+

Solution 5: Check firewall

+
# Temporarily disable firewall (Linux)
+sudo ufw disable
+
+# Test if containers can communicate
+# If yes, firewall is blocking
+
+# Re-enable and add rules
+sudo ufw enable
+sudo ufw allow from 172.18.0.0/16 to any
+
+

Prevention

+
    +
  • Use service names - Never hardcode IPs
  • +
  • Single network - All services on same network
  • +
  • Docker DNS - Rely on Docker's built-in DNS
  • +
  • Health checks - Verify connectivity on startup
  • +
+
+

Port Not Accessible from Host

+

Severity: 🟠 High

+

Symptoms

+

From host:

+
curl http://localhost:4000/api/health
+# curl: (7) Failed to connect to localhost port 4000: Connection refused
+
+

But from inside container:

+
docker compose exec api curl http://localhost:4000/api/health
+# {"status":"healthy"}
+
+

Common Causes

+
    +
  1. Port not published - Missing ports: in docker-compose.yml
  2. +
  3. Bound to 127.0.0.1 - Only listening on localhost inside container
  4. +
  5. Firewall blocking - Host firewall blocking port
  6. +
  7. Wrong port - Trying different port than published
  8. +
+

Solutions

+

Solution 1: Check port publishing

+

In docker-compose.yml:

+
api:
+  ports:
+    - "4000:4000"  # host:container
+
+

Verify:

+
docker compose ps api
+
+# Should show:
+# PORTS: 0.0.0.0:4000->4000/tcp
+
+

Solution 2: Bind to 0.0.0.0

+

In api/src/server.ts:

+
// Wrong - only localhost
+app.listen(4000, '127.0.0.1');
+
+// Right - all interfaces
+app.listen(4000, '0.0.0.0');
+
+// Or just
+app.listen(4000);  // Defaults to 0.0.0.0
+
+

Solution 3: Check firewall

+
# Check if port allowed (Linux)
+sudo ufw status
+
+# Allow port
+sudo ufw allow 4000/tcp
+
+# Or disable temporarily for testing
+sudo ufw disable
+
+

Solution 4: Verify correct port

+
# Check what ports are actually listening
+docker compose exec api netstat -tuln
+
+# Should show:
+# tcp6  0  0  :::4000  :::*  LISTEN
+
+

Solution 5: Restart with port forwarding

+
# Stop container
+docker compose stop api
+
+# Remove container
+docker compose rm -f api
+
+# Start fresh
+docker compose up -d api
+
+# Verify port
+curl http://localhost:4000/api/health
+
+

Prevention

+
    +
  • Always publish ports - In docker-compose.yml
  • +
  • Bind to 0.0.0.0 - Not 127.0.0.1
  • +
  • Test from host - Verify accessibility
  • +
  • Document ports - Keep port reference updated
  • +
+
+

DNS Resolution Failures

+

Severity: 🟠 High

+

Symptoms

+
Error: getaddrinfo ENOTFOUND smtp.gmail.com
+
+

Or:

+
Error: getaddrinfo EAI_AGAIN api.represent.org
+
+

Container can't resolve external hostnames.

+

Common Causes

+
    +
  1. Docker DNS issue - Docker DNS not working
  2. +
  3. No internet - Container has no internet access
  4. +
  5. Firewall blocking DNS - Port 53 blocked
  6. +
  7. Wrong DNS servers - Using invalid DNS servers
  8. +
+

Solutions

+

Solution 1: Test DNS resolution

+
# From inside container
+docker compose exec api nslookup google.com
+
+# Should return IP address
+# If not, DNS is broken
+
+

Solution 2: Check Docker DNS

+
# View container DNS config
+docker compose exec api cat /etc/resolv.conf
+
+# Should show:
+# nameserver 127.0.0.11  # Docker's embedded DNS
+
+

Solution 3: Use custom DNS servers

+

In docker-compose.yml:

+
api:
+  dns:
+    - 8.8.8.8      # Google DNS
+    - 8.8.4.4
+
+

Or in /etc/docker/daemon.json:

+
{
+  "dns": ["8.8.8.8", "8.8.4.4"]
+}
+
+

Then restart Docker:

+
sudo systemctl restart docker
+
+

Solution 4: Check internet connectivity

+
# Ping external host
+docker compose exec api ping -c 3 8.8.8.8
+
+# If fails, no internet access
+# Check host internet connection
+ping -c 3 8.8.8.8
+
+

Solution 5: Restart Docker daemon

+
# Sometimes Docker DNS gets stuck
+sudo systemctl restart docker
+
+# Then restart containers
+docker compose down
+docker compose up -d
+
+

Prevention

+
    +
  • Reliable DNS - Use public DNS servers as backup
  • +
  • Monitor connectivity - Alert on DNS failures
  • +
  • Health checks - Include external connectivity checks
  • +
  • Retry logic - Handle transient DNS failures
  • +
+
+

Volume Issues

+

Permission Denied

+

Severity: 🔴 Critical

+

Symptoms

+
Error: EACCES: permission denied, open '/app/uploads/image.jpg'
+
+

Or:

+
Error: EACCES: permission denied, mkdir '/media/local/inbox'
+
+

File operations fail inside container.

+

Common Causes

+
    +
  1. Wrong ownership - Host directory owned by different user
  2. +
  3. Wrong permissions - Directory not writable
  4. +
  5. SELinux - Linux security policy blocking
  6. +
  7. Read-only mount - Volume mounted as read-only
  8. +
+

Solutions

+

Solution 1: Check ownership

+
# On host
+ls -la uploads/
+
+# Shows:
+# drwxr-xr-x  2 root   root   4096 Feb 13 10:00 uploads
+
+# Check container user
+docker compose exec api id
+# uid=1000(node) gid=1000(node)
+
+# Fix ownership
+sudo chown -R 1000:1000 uploads/
+
+

Solution 2: Fix permissions

+
# Make writable
+chmod -R 755 uploads/
+
+# Or more permissive (dev only)
+chmod -R 777 uploads/
+
+

Solution 3: Check mount mode

+

In docker-compose.yml:

+
api:
+  volumes:
+    - ./uploads:/app/uploads:rw  # Read-write
+    # Not:
+    # - ./uploads:/app/uploads:ro  # Read-only
+
+

Solution 4: SELinux labels

+
# Add :z flag to volume
+# In docker-compose.yml:
+    - ./uploads:/app/uploads:z
+
+# Or relabel directory
+sudo chcon -Rt svirt_sandbox_file_t uploads/
+
+

Solution 5: Run as root (not recommended)

+
# In docker-compose.yml (last resort)
+api:
+  user: "0:0"  # Run as root
+
+

Prevention

+
    +
  • Set permissions early - In setup script
  • +
  • Match UIDs - Container user matches host user
  • +
  • SELinux-aware - Use :z flag on volumes
  • +
  • Document requirements - List permission requirements
  • +
+
+

Volume Not Mounted

+

Severity: 🟠 High

+

Symptoms

+

Container can't see files that exist on host.

+
# On host
+ls uploads/
+# image.jpg  video.mp4
+
+# In container
+docker compose exec api ls /app/uploads/
+# (empty)
+
+

Common Causes

+
    +
  1. Wrong path - Volume path incorrect
  2. +
  3. Typo - Syntax error in docker-compose.yml
  4. +
  5. Not mounted - Volume mount missing
  6. +
  7. Cached old config - Using old container
  8. +
+

Solutions

+

Solution 1: Verify volume configuration

+

In docker-compose.yml:

+
api:
+  volumes:
+    - ./uploads:/app/uploads  # host:container
+
+

Solution 2: Check mounts in running container

+
# Inspect container mounts
+docker inspect changemaker-lite-api-1 | grep -A 10 Mounts
+
+# Should show:
+# "Mounts": [
+#   {
+#     "Type": "bind",
+#     "Source": "/home/user/changemaker.lite/uploads",
+#     "Destination": "/app/uploads",
+#     "Mode": "",
+#     "RW": true,
+#     "Propagation": "rprivate"
+#   }
+# ]
+
+

Solution 3: Recreate container

+
# Stop and remove container
+docker compose down api
+
+# Start fresh
+docker compose up -d api
+
+# Verify mount
+docker compose exec api ls /app/uploads/
+
+

Solution 4: Use absolute path

+
# Sometimes relative paths don't work
+api:
+  volumes:
+    - /home/user/changemaker.lite/uploads:/app/uploads
+
+

Solution 5: Check Docker Compose version

+
# Check version
+docker compose version
+
+# Should be v2+
+# If v1, syntax might differ
+
+

Prevention

+
    +
  • Test mounts - Verify after container start
  • +
  • Use relative paths - Start with ./
  • +
  • Documentation - Document all volume mounts
  • +
  • Health checks - Verify critical files exist
  • +
+
+

Data Persistence Problems

+

Severity: 🔴 Critical

+

Symptoms

+

Data disappears after docker compose down:

+
    +
  • Database data lost
  • +
  • Uploaded files missing
  • +
  • Configuration reset
  • +
+

Common Causes

+
    +
  1. Using containers, not volumes - Data stored in container filesystem
  2. +
  3. Anonymous volumes - Volume not named or bound
  4. +
  5. Deleting volumes - docker compose down -v removes volumes
  6. +
  7. Wrong volume type - tmpfs instead of volume
  8. +
+

Solutions

+

Solution 1: Use named volumes

+

In docker-compose.yml:

+
v2-postgres:
+  volumes:
+    - postgres-data:/var/lib/postgresql/data  # Named volume
+
+volumes:
+  postgres-data:  # Declare named volume
+
+

Solution 2: Use bind mounts

+
v2-postgres:
+  volumes:
+    - ./data/postgres:/var/lib/postgresql/data  # Bind to host directory
+
+

Solution 3: Don't use -v flag

+
# Wrong - deletes volumes
+docker compose down -v
+
+# Right - keeps volumes
+docker compose down
+
+

Solution 4: Check volume exists

+
# List volumes
+docker volume ls
+
+# Should show:
+# changemaker-lite_postgres-data
+
+# Inspect volume
+docker volume inspect changemaker-lite_postgres-data
+
+

Solution 5: Backup before down

+
# Backup database before stopping
+docker compose exec v2-postgres pg_dump -U changemaker changemaker_v2 > backup.sql
+
+# Then safe to:
+docker compose down -v
+
+# Restore after up:
+docker compose up -d v2-postgres
+docker compose exec -T v2-postgres psql -U changemaker changemaker_v2 < backup.sql
+
+

Prevention

+
    +
  • Named volumes - For all persistent data
  • +
  • Regular backups - Automated backup script
  • +
  • Never use -v - Unless intentionally resetting
  • +
  • Documentation - Document what data persists where
  • +
+
+

Performance Issues

+

Slow Container Startup

+

Severity: 🟡 Medium

+

Symptoms

+

Container takes minutes to start:

+
docker compose up -d api
+# Creating api ... (2 minutes)
+# Creating api ... done
+
+

Common Causes

+
    +
  1. Large image - Downloading/extracting large image
  2. +
  3. Many dependencies - npm install taking long
  4. +
  5. Health check delay - Waiting for health checks
  6. +
  7. Slow disk - I/O bottleneck
  8. +
+

Solutions

+

Solution 1: Use pre-built image

+
# Instead of building locally
+api:
+  build: ./api
+
+# Use pre-built image from registry
+api:
+  image: ghcr.io/yourorg/changemaker-api:latest
+
+

Solution 2: Layer caching

+
# In Dockerfile, copy package files first
+COPY package*.json ./
+RUN npm ci
+
+# Then copy code (changes more frequently)
+COPY . .
+RUN npm run build
+
+

Solution 3: Multi-stage builds

+
# Build stage
+FROM node:20-alpine AS builder
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
+# Runtime stage (smaller)
+FROM node:20-alpine
+WORKDIR /app
+COPY --from=builder /app/dist ./dist
+COPY --from=builder /app/node_modules ./node_modules
+COPY package*.json ./
+CMD ["node", "dist/server.js"]
+
+

Solution 4: Increase Docker resources

+

In Docker Desktop settings:

+
    +
  • CPU: 4+ cores
  • +
  • Memory: 8GB+
  • +
  • Disk: Fast SSD
  • +
+

Solution 5: Parallel builds

+
# Build all services in parallel
+docker compose build --parallel
+
+

Prevention

+
    +
  • Optimize Dockerfile - Layer caching, multi-stage
  • +
  • Small base images - Alpine instead of full images
  • +
  • Registry caching - Pull from registry instead of building
  • +
  • Resource allocation - Adequate CPU/memory for Docker
  • +
+
+

High CPU Usage

+

Severity: 🟠 High

+

Symptoms

+
docker stats
+# CONTAINER   CPU %
+# api         95%
+
+

Container consuming excessive CPU.

+

Common Causes

+
    +
  1. Infinite loop - Bug causing tight loop
  2. +
  3. Heavy computation - Processing large dataset
  4. +
  5. Too many workers - Worker threads maxed out
  6. +
  7. Memory thrashing - Swapping due to low memory
  8. +
+

Solutions

+

Solution 1: Identify process

+
# Top inside container
+docker compose exec api top
+
+# Shows process using CPU
+
+

Solution 2: Check for loops

+
# View logs for repeated messages
+docker compose logs api | tail -100
+
+# Restart if stuck
+docker compose restart api
+
+

Solution 3: Limit worker threads

+
// In BullMQ worker
+new Worker('queueName', processor, {
+  concurrency: 2,  // Reduce from 10
+  limiter: {
+    max: 10,
+    duration: 1000  // Max 10 jobs per second
+  }
+});
+
+

Solution 4: Set CPU limits

+
api:
+  deploy:
+    resources:
+      limits:
+        cpus: '2.0'  # Max 2 CPUs
+
+

Solution 5: Profile application

+
# Use Node.js profiler
+docker compose exec api node --prof dist/server.js
+
+# Or clinic.js
+npm install -g clinic
+clinic doctor -- node dist/server.js
+
+

Prevention

+
    +
  • Monitor CPU - Alert on high usage
  • +
  • Rate limiting - Limit request rate
  • +
  • Queue management - Control worker concurrency
  • +
  • Performance testing - Load test regularly
  • +
+
+

High Memory Usage

+

Severity: 🟠 High

+

Symptoms

+
docker stats
+# CONTAINER   MEM USAGE / LIMIT
+# api         3.8GiB / 4GiB
+
+

Memory usage keeps increasing.

+

Common Causes

+
    +
  1. Memory leak - Not releasing memory
  2. +
  3. Large cache - Caching too much data
  4. +
  5. Database connections - Too many open connections
  6. +
  7. Large response bodies - Sending huge payloads
  8. +
+

Solutions

+

Solution 1: Identify memory usage

+
# Memory breakdown inside container
+docker compose exec api sh -c 'cat /proc/meminfo'
+
+# Node.js heap stats
+docker compose exec api node -e "console.log(process.memoryUsage())"
+
+

Solution 2: Restart to free memory

+
# Temporary fix
+docker compose restart api
+
+# Memory should drop
+docker stats api
+
+

Solution 3: Reduce cache size

+
// In Redis cache
+redis.set(key, value, 'EX', 3600);  // Expire after 1 hour
+
+// Limit cache size
+const cache = new LRU({
+  max: 1000,  // Max 1000 entries
+  maxAge: 3600000  // 1 hour
+});
+
+

Solution 4: Set memory limit

+
api:
+  deploy:
+    resources:
+      limits:
+        memory: 2G  # Hard limit
+      reservations:
+        memory: 1G  # Reserved amount
+
+

Solution 5: Find memory leak

+
# Take heap snapshot
+docker compose exec api node --expose-gc --inspect dist/server.js
+
+# Use Chrome DevTools to analyze
+# chrome://inspect
+
+

Prevention

+
    +
  • Monitor memory - Alert on high usage
  • +
  • Memory limits - Prevent runaway processes
  • +
  • Regular restarts - Restart daily if leaking
  • +
  • Memory profiling - Profile in staging
  • +
+
+

Useful Commands

+

Viewing Logs

+
# Last 100 lines
+docker compose logs api --tail=100
+
+# Follow logs (real-time)
+docker compose logs -f api
+
+# All services
+docker compose logs
+
+# Since timestamp
+docker compose logs --since="2026-02-13T10:00:00"
+
+# Filter by keyword
+docker compose logs api | grep -i error
+
+# Save to file
+docker compose logs api > api-logs.txt
+
+

Executing Commands

+
# Run command in running container
+docker compose exec api npm run migrate
+
+# Interactive shell
+docker compose exec api sh
+
+# Run as different user
+docker compose exec -u root api sh
+
+# Run in new container (one-off)
+docker compose run --rm api npm test
+
+

Inspecting Containers

+
# View container details
+docker inspect changemaker-lite-api-1
+
+# View specific field
+docker inspect changemaker-lite-api-1 --format='{{.State.Status}}'
+
+# View environment variables
+docker inspect changemaker-lite-api-1 --format='{{range .Config.Env}}{{println .}}{{end}}'
+
+# View mounts
+docker inspect changemaker-lite-api-1 --format='{{json .Mounts}}' | jq
+
+

Container Management

+
# Start all services
+docker compose up -d
+
+# Start specific service
+docker compose up -d api
+
+# Stop all services
+docker compose stop
+
+# Stop specific service
+docker compose stop api
+
+# Restart service
+docker compose restart api
+
+# Remove stopped containers
+docker compose rm
+
+# Stop and remove
+docker compose down
+
+

Rebuilding

+
# Rebuild single service
+docker compose build api
+
+# Rebuild without cache
+docker compose build --no-cache api
+
+# Build all services
+docker compose build
+
+# Build and start
+docker compose up -d --build
+
+# Force recreate containers
+docker compose up -d --force-recreate
+
+
+

Log Analysis

+

Reading Container Logs

+

Logs follow this pattern:

+
[timestamp] [level] [message]
+2026-02-13T10:30:00.000Z INFO Server started on port 4000
+
+

Common Log Patterns

+

Successful startup:

+
INFO Connecting to database...
+INFO Database connected
+INFO Registered route: GET /api/health
+INFO Registered route: POST /api/auth/login
+INFO Server started on port 4000
+
+

Database connection error:

+
INFO Connecting to database...
+ERROR Can't reach database server at `v2-postgres:5432`
+ERROR Retrying in 5 seconds...
+
+

Missing environment variable:

+
ERROR Environment validation failed:
+ERROR   SMTP_HOST is required
+ERROR   JWT_ACCESS_SECRET is required
+
+

Health check failure:

+
WARN Health check failed: Database not connected
+
+

Filtering Logs

+
# Only errors
+docker compose logs api | grep ERROR
+
+# Only warnings and errors
+docker compose logs api | grep -E "ERROR|WARN"
+
+# Exclude health checks
+docker compose logs api | grep -v "GET /api/health"
+
+# Find specific request
+docker compose logs api | grep "POST /api/users"
+
+# Find by request ID
+docker compose logs api | grep "req-abc123"
+
+
+

Cleanup Commands

+

Remove Stopped Containers

+
# Remove all stopped containers
+docker compose down
+
+# Remove specific service containers
+docker compose rm api
+
+# Force remove running containers
+docker compose rm -f api
+
+

Remove Images

+
# Remove all images for project
+docker compose down --rmi all
+
+# Remove only project-built images (not postgres, redis, etc.)
+docker compose down --rmi local
+
+# Remove specific image
+docker rmi changemaker-lite-api
+
+# Remove dangling images
+docker image prune
+
+

Remove Volumes

+
# ⚠️ WARNING: Deletes all data!
+docker compose down -v
+
+# Remove specific volume
+docker volume rm changemaker-lite_postgres-data
+
+# Remove unused volumes
+docker volume prune
+
+

Remove Networks

+
# Remove project network (containers must be stopped first)
+docker network rm changemaker-lite
+
+# Remove unused networks
+docker network prune
+
+

Full Cleanup

+
# ⚠️ DANGER: Removes everything!
+docker compose down -v --rmi all
+docker system prune -a --volumes
+
+# This deletes:
+# - All containers
+# - All volumes (data lost!)
+# - All images
+# - All networks
+# - All build cache
+
+

Safe Cleanup

+
# Safe cleanup (keeps volumes)
+docker compose down
+docker image prune -a
+docker network prune
+
+# This keeps:
+# - Volumes (data safe)
+# - .env file
+# - Application code
+
+
+ +

Docker Documentation

+ +

Other Troubleshooting

+ +

Docker Resources

+ +
+

Last Updated: February 2026 +Version: V2.0 +Status: Complete

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/troubleshooting/email-issues/index.html b/mkdocs/site/v2/troubleshooting/email-issues/index.html new file mode 100644 index 00000000..ce7a2b93 --- /dev/null +++ b/mkdocs/site/v2/troubleshooting/email-issues/index.html @@ -0,0 +1,7872 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Email Issues - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Email and SMTP Issues

+

This guide covers email sending, SMTP configuration, and template-related problems in Changemaker Lite V2.

+

Overview

+

Email System Architecture

+

Changemaker Lite V2 has dual email systems:

+
    +
  1. Transactional Emails (BullMQ + Nodemailer)
  2. +
  3. Campaign advocacy emails
  4. +
  5. Shift confirmation emails
  6. +
  7. Response verification emails
  8. +
  9. +

    System notifications

    +
  10. +
  11. +

    Newsletter Emails (Listmonk)

    +
  12. +
  13. Marketing campaigns
  14. +
  15. Newsletter broadcasts
  16. +
  17. Subscriber management
  18. +
+

Email Flow

+
User Action → Email Service → BullMQ Queue → Worker → SMTP Server → Recipient
+
+

Key Components

+
    +
  • BullMQ - Job queue for async email sending
  • +
  • Nodemailer - SMTP client library
  • +
  • Redis - Queue backend
  • +
  • MailHog - Development email capture (test mode)
  • +
  • Listmonk - Newsletter platform (optional)
  • +
+
+

SMTP Configuration

+

Connection Refused

+

Severity: 🔴 Critical

+

Symptoms

+

API logs: +

Error: Connection timeout
+Error: connect ECONNREFUSED smtp.gmail.com:587
+Error: Invalid login: 535-5.7.8 Username and Password not accepted
+

+

Emails not sending.

+

Common Causes

+
    +
  1. Wrong SMTP host - Incorrect hostname
  2. +
  3. Port blocked - Firewall blocking port 587/465
  4. +
  5. Wrong credentials - Invalid username/password
  6. +
  7. TLS/SSL mismatch - Wrong secure setting
  8. +
+

Solutions

+

Solution 1: Test SMTP connection

+
# Test with telnet
+telnet smtp.gmail.com 587
+
+# Should show:
+# 220 smtp.gmail.com ESMTP ...
+
+# Or test with openssl (for SSL)
+openssl s_client -connect smtp.gmail.com:465
+
+

Solution 2: Verify SMTP configuration

+

In .env:

+
# Gmail example (requires app password)
+SMTP_HOST=smtp.gmail.com
+SMTP_PORT=587
+SMTP_SECURE=false  # false for STARTTLS on 587, true for SSL on 465
+SMTP_USER=your-email@gmail.com
+SMTP_PASS=your-app-password  # NOT regular password
+SMTP_FROM=your-email@gmail.com
+
+# Office365 example
+SMTP_HOST=smtp.office365.com
+SMTP_PORT=587
+SMTP_SECURE=false
+SMTP_USER=your-email@outlook.com
+SMTP_PASS=your-password
+SMTP_FROM=your-email@outlook.com
+
+# SendGrid example
+SMTP_HOST=smtp.sendgrid.net
+SMTP_PORT=587
+SMTP_SECURE=false
+SMTP_USER=apikey  # Literally "apikey"
+SMTP_PASS=your-sendgrid-api-key
+SMTP_FROM=your-verified-sender@example.com
+
+

Solution 3: Use test mode

+
# In .env
+EMAIL_TEST_MODE=true
+
+# Restart API
+docker compose restart api
+
+# All emails now sent to MailHog
+# View at http://localhost:8025
+
+

Solution 4: Test email sending

+
# Send test email via API
+curl -X POST http://localhost:4000/api/test-email \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -d '{
+    "to": "test@example.com",
+    "subject": "Test Email",
+    "text": "This is a test email from Changemaker Lite"
+  }'
+
+# Check API logs
+docker compose logs api | grep -i "email\|smtp"
+
+

Solution 5: Gmail app password

+

For Gmail (required if 2FA enabled):

+
    +
  1. Go to https://myaccount.google.com/apppasswords
  2. +
  3. Select app: Mail
  4. +
  5. Select device: Other (Changemaker Lite)
  6. +
  7. Click Generate
  8. +
  9. Copy 16-character password
  10. +
  11. Use in SMTP_PASS (no spaces)
  12. +
+

Prevention

+
    +
  • Test mode for dev - Use MailHog locally
  • +
  • Secure credentials - Use app passwords, not real passwords
  • +
  • Environment-specific - Different SMTP per environment
  • +
  • Health checks - Test SMTP on API startup
  • +
+
+

Authentication Failed

+

Severity: 🔴 Critical

+

Symptoms

+
Error: Invalid login: 535-5.7.8 Username and Password not accepted
+Error: 535 Authentication failed
+
+

Common Causes

+
    +
  1. Wrong password - Incorrect password
  2. +
  3. 2FA enabled - Need app password
  4. +
  5. Less secure apps - Gmail blocking
  6. +
  7. Account locked - Too many failed attempts
  8. +
+

Solutions

+

Solution 1: Verify credentials

+
# Check .env
+cat .env | grep SMTP_
+
+# Test login manually (if possible)
+# Gmail doesn't allow this, but some SMTP servers do
+
+

Solution 2: Enable less secure apps (Gmail)

+

⚠️ Not recommended. Use app password instead.

+
    +
  1. Go to https://myaccount.google.com/lesssecureapps
  2. +
  3. Turn on "Allow less secure apps"
  4. +
+

Solution 3: Check account status

+
    +
  1. Try logging into email account via web
  2. +
  3. Check for security alerts
  4. +
  5. Verify account not locked
  6. +
+

Solution 4: Use OAuth2 (advanced)

+

For production Gmail:

+
// In email.service.ts
+const transporter = nodemailer.createTransporter({
+  service: 'gmail',
+  auth: {
+    type: 'OAuth2',
+    user: process.env.SMTP_USER,
+    clientId: process.env.GMAIL_CLIENT_ID,
+    clientSecret: process.env.GMAIL_CLIENT_SECRET,
+    refreshToken: process.env.GMAIL_REFRESH_TOKEN
+  }
+});
+
+

Prevention

+
    +
  • App passwords - Always use app-specific passwords
  • +
  • Test credentials - Verify before deploying
  • +
  • Monitor failures - Alert on auth failures
  • +
  • Backup SMTP - Configure fallback SMTP server
  • +
+
+

Invalid Credentials

+

Severity: 🔴 Critical

+

Symptoms

+
Error: Invalid SMTP credentials
+Error: Username and Password not accepted
+
+

Solutions

+

See "Authentication Failed" section above.

+
+

Port Blocked

+

Severity: 🟠 High

+

Symptoms

+
Error: connect ETIMEDOUT smtp.gmail.com:587
+Error: Connection timeout
+
+

Connection attempt hangs, then times out after 30+ seconds.

+

Common Causes

+
    +
  1. Firewall blocking - Network firewall blocking port
  2. +
  3. ISP blocking - ISP blocks port 25/587
  4. +
  5. Docker network - Container can't reach external SMTP
  6. +
+

Solutions

+

Solution 1: Test port access

+
# From API container
+docker compose exec api telnet smtp.gmail.com 587
+
+# If timeout, port is blocked
+
+

Solution 2: Try alternative port

+
# Try port 465 (SSL) instead of 587 (STARTTLS)
+SMTP_PORT=465
+SMTP_SECURE=true
+
+# Or try port 2525 (some providers)
+SMTP_PORT=2525
+SMTP_SECURE=false
+
+

Solution 3: Check Docker network

+
# Test external connectivity
+docker compose exec api ping -c 3 smtp.gmail.com
+
+# Test DNS resolution
+docker compose exec api nslookup smtp.gmail.com
+
+# If fails, Docker network issue
+
+

Solution 4: Use SMTP relay

+

If ISP blocks SMTP, use relay service: +- SendGrid +- Mailgun +- Amazon SES +- Postmark

+

Solution 5: VPN or proxy

+

As last resort, route SMTP through VPN/proxy.

+

Prevention

+
    +
  • Use relay services - More reliable than direct SMTP
  • +
  • Multiple ports - Try 587, 465, 2525
  • +
  • Test on deploy - Verify SMTP works in production
  • +
  • Documentation - Document network requirements
  • +
+
+

Template Issues

+

Template Not Found

+

Severity: 🟠 High

+

Symptoms

+

API logs: +

Error: Email template not found: campaign-email
+Error: ENOENT: no such file or directory, open 'templates/campaign-email.html'
+

+

Common Causes

+
    +
  1. Template file missing - File doesn't exist
  2. +
  3. Wrong template name - Typo in name
  4. +
  5. Wrong directory - Looking in wrong path
  6. +
  7. Deleted template - Template was removed
  8. +
+

Solutions

+

Solution 1: List available templates

+
# List template files
+docker compose exec api ls -la templates/
+
+# Should show:
+# campaign-email.html
+# shift-confirmation.html
+# verification-email.html
+# response-verification.html
+
+

Solution 2: Create missing template

+
# Create template file
+docker compose exec api sh -c 'cat > templates/my-template.html << EOF
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="UTF-8">
+  <title>{{title}}</title>
+</head>
+<body>
+  <h1>Hello {{name}}</h1>
+  <p>{{message}}</p>
+</body>
+</html>
+EOF'
+
+

Solution 3: Use email template system

+

Navigate to /app/email-templates:

+
    +
  1. Click "Create Template"
  2. +
  3. Fill in details
  4. +
  5. Design template
  6. +
  7. Save (creates file + DB record)
  8. +
+

Solution 4: Check template name

+
// In code, template name must match filename (without .html)
+await emailService.sendEmail({
+  to: email,
+  subject: 'Campaign Email',
+  template: 'campaign-email',  // Looks for templates/campaign-email.html
+  variables: { ... }
+});
+
+

Solution 5: Verify template path

+

In api/src/services/email.service.ts:

+
const templatePath = path.join(__dirname, '../../templates', `${template}.html`);
+// Resolves to: api/templates/campaign-email.html
+
+

Prevention

+
    +
  • Seed templates - Include default templates in seed
  • +
  • Template management - Use admin UI to manage
  • +
  • Version control - Keep templates in git
  • +
  • Validation - Check template exists before sending
  • +
+
+

Variable Not Replaced

+

Severity: 🟡 Medium

+

Symptoms

+

Email received with unreplaced placeholders:

+
Hello {{name}},
+
+Your campaign {{campaignName}} is ready.
+
+

Common Causes

+
    +
  1. Variable not provided - Missing from variables object
  2. +
  3. Typo in variable name - Mismatch between template and code
  4. +
  5. Wrong delimiter - Using ${} instead of {{}}
  6. +
  7. Escaping issue - HTML entities interfering
  8. +
+

Solutions

+

Solution 1: List template variables

+
# Find all variables in template
+docker compose exec api grep -o '{{[^}]*}}' templates/campaign-email.html
+
+# Shows:
+# {{name}}
+# {{campaignName}}
+# {{campaignUrl}}
+
+

Solution 2: Provide all variables

+
await emailService.sendEmail({
+  to: email,
+  subject: 'Campaign Ready',
+  template: 'campaign-email',
+  variables: {
+    name: user.name,  // Must provide ALL variables in template
+    campaignName: campaign.name,
+    campaignUrl: `${process.env.PUBLIC_URL}/campaigns/${campaign.id}`
+  }
+});
+
+

Solution 3: Check variable delimiter

+
<!-- Correct (Handlebars-style) -->
+<h1>Hello {{name}}</h1>
+<p>Your campaign {{campaignName}} is ready.</p>
+
+<!-- Wrong -->
+<h1>Hello ${name}</h1>  <!-- JavaScript template literal -->
+<p>Your campaign {campaignName} is ready.</p>  <!-- Single braces -->
+
+

Solution 4: Test template rendering

+
# Test template rendering
+curl -X POST http://localhost:4000/api/test-template \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -d '{
+    "template": "campaign-email",
+    "variables": {
+      "name": "John",
+      "campaignName": "Save the Planet",
+      "campaignUrl": "https://example.com/campaigns/123"
+    }
+  }'
+
+# Returns rendered HTML
+
+

Solution 5: Use default values

+
<!-- In template, provide fallback -->
+<h1>Hello {{name || "Friend"}}</h1>
+
+

Or in code:

+
const variables = {
+  name: user.name || 'Friend',
+  campaignName: campaign.name || 'Campaign',
+  campaignUrl: campaignUrl || '#'
+};
+
+

Prevention

+
    +
  • Template validation - Check all variables exist
  • +
  • TypeScript types - Type template variables
  • +
  • Default values - Always provide defaults
  • +
  • Testing - Test all templates with sample data
  • +
+
+

Syntax Errors

+

Severity: 🟠 High

+

Symptoms

+
Error: Parse error in template at line 15
+Error: Unexpected token in template
+
+

Email fails to send.

+

Common Causes

+
    +
  1. Invalid HTML - Malformed HTML
  2. +
  3. Unclosed tags - Missing closing tags
  4. +
  5. Special characters - Unescaped < > &
  6. +
  7. Handlebars syntax - Invalid {{}} usage
  8. +
+

Solutions

+

Solution 1: Validate HTML

+
# Use HTML validator
+# Copy template content to https://validator.w3.org/nu/
+
+# Or validate locally
+docker compose exec api npx html-validate templates/campaign-email.html
+
+

Solution 2: Check common errors

+
<!-- Unclosed tag -->
+<div>Content here
+<!-- Should be: -->
+<div>Content here</div>
+
+<!-- Unescaped characters -->
+Price: $50 < $100
+<!-- Should be: -->
+Price: $50 &lt; $100
+
+<!-- Invalid Handlebars -->
+{{if name}}  <!-- No "if" helper by default -->
+<!-- Should be: -->
+{{#if name}}...{{/if}}  <!-- Or don't use if -->
+
+

Solution 3: Escape HTML

+
// In email.service.ts
+import handlebars from 'handlebars';
+
+// Register escape helper
+handlebars.registerHelper('escape', (str) => {
+  return handlebars.escapeExpression(str);
+});
+
+// In template
+<p>Message: {{escape message}}</p>
+
+

Solution 4: Test template compilation

+
// Test if template compiles
+import handlebars from 'handlebars';
+import fs from 'fs';
+
+const templateSource = fs.readFileSync('templates/campaign-email.html', 'utf8');
+try {
+  const template = handlebars.compile(templateSource);
+  console.log('Template compiles successfully');
+} catch (error) {
+  console.error('Template error:', error.message);
+}
+
+

Prevention

+
    +
  • HTML validation - Validate before saving
  • +
  • Linting - Use HTML linter in editor
  • +
  • Simple templates - Keep templates simple
  • +
  • Testing - Test rendering before deploying
  • +
+
+

Queue Issues

+

Queue Stuck

+

Severity: 🟠 High

+

Symptoms

+

Emails queued but not sending. Queue shows jobs but no progress.

+

Solutions

+

Solution 1: Check queue status

+
# View queue stats
+curl http://localhost:4000/api/influence/email-queue/stats \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Shows:
+# {
+#   "waiting": 50,
+#   "active": 0,  # Should be > 0 if processing
+#   "completed": 1000,
+#   "failed": 5
+# }
+
+

Solution 2: Check worker is running

+
# Worker should log processing
+docker compose logs api | grep -i "email worker\|processing email"
+
+# Should show:
+# Email worker started
+# Processing email job for campaign: abc-123
+
+

Solution 3: Restart worker

+
# Restart API (restarts worker)
+docker compose restart api
+
+# Check worker started
+docker compose logs api | grep "Email worker started"
+
+

Solution 4: Check Redis

+
# Test Redis connection
+docker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD ping
+
+# Check queue keys
+docker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD keys "bull:email-queue:*"
+
+

Solution 5: Process stuck jobs

+
# Retry failed jobs
+curl -X POST http://localhost:4000/api/influence/email-queue/retry-failed \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Clean old jobs
+curl -X POST http://localhost:4000/api/influence/email-queue/clean \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -d '{"status": "completed", "grace": 86400000}'  # Clean completed > 1 day
+
+

Prevention

+
    +
  • Health checks - Monitor worker health
  • +
  • Auto-restart - Restart worker if stuck
  • +
  • Alerting - Alert if queue backed up
  • +
  • Dead letter queue - Move repeatedly failed jobs
  • +
+
+

Jobs Failing

+

Severity: 🟠 High

+

Symptoms

+

High failed job count. Emails not reaching recipients.

+

Solutions

+

Solution 1: View failed jobs

+
# Get failed job details
+curl http://localhost:4000/api/influence/email-queue/failed \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Shows:
+# [
+#   {
+#     "id": "123",
+#     "data": { "to": "user@example.com", "subject": "..." },
+#     "failedReason": "SMTP connection failed",
+#     "attemptsMade": 3
+#   }
+# ]
+
+

Solution 2: Check error patterns

+
# Common failure reasons
+docker compose logs api | grep "Email failed" | sort | uniq -c
+
+# Example output:
+#  25 Email failed: Invalid email address
+#  10 Email failed: SMTP connection refused
+#   3 Email failed: Recipient mailbox full
+
+

Solution 3: Retry with fixes

+
# Fix SMTP config if needed
+# Then retry failed jobs
+curl -X POST http://localhost:4000/api/influence/email-queue/retry-failed \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+

Solution 4: Manual intervention

+

For repeatedly failing emails:

+
    +
  1. Check email address validity
  2. +
  3. Verify SMTP configuration
  4. +
  5. Test with different recipient
  6. +
  7. Check if recipient's mailbox full
  8. +
+

Prevention

+
    +
  • Retry logic - Auto-retry with exponential backoff
  • +
  • Email validation - Validate before queuing
  • +
  • Error categorization - Permanent vs transient failures
  • +
  • Bounce handling - Handle bounce notifications
  • +
+
+

Delivery Issues

+

Emails Not Arriving

+

Severity: 🔴 Critical

+

Symptoms

+

Emails sent successfully (no errors) but not received.

+

Common Causes

+
    +
  1. Spam folder - Filtered to spam
  2. +
  3. Email delay - Taking long to deliver
  4. +
  5. Email blocking - Recipient server blocking
  6. +
  7. Wrong address - Typo in email address
  8. +
+

Solutions

+

Solution 1: Check spam folder

+
    +
  1. Check spam/junk folder
  2. +
  3. Check promotions tab (Gmail)
  4. +
  5. Mark as "Not Spam" to whitelist
  6. +
+

Solution 2: Check email logs

+
# Verify email was sent
+docker compose logs api | grep "Email sent"
+
+# Should show:
+# Email sent to user@example.com: Campaign Email
+
+

Solution 3: Use MailHog to test

+
# In .env
+EMAIL_TEST_MODE=true
+
+# Restart API
+docker compose restart api
+
+# Send test email
+curl -X POST http://localhost:4000/api/test-email \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -d '{"to": "test@example.com", "subject": "Test", "text": "Test"}'
+
+# Check MailHog
+# http://localhost:8025
+
+# If appears in MailHog, SMTP working
+# If not appearing in real inbox, delivery issue
+
+

Solution 4: Check email headers

+

In MailHog or received email: +1. View full headers +2. Check "Received" path +3. Look for spam scores +4. Check SPF/DKIM/DMARC status

+

Solution 5: Test with different address

+
# Try sending to different email provider
+# Gmail vs Outlook vs Yahoo
+# If some work and others don't, specific provider blocking
+
+

Prevention

+
    +
  • Email authentication - SPF, DKIM, DMARC
  • +
  • Reputation management - Maintain good sender reputation
  • +
  • Bounce handling - Monitor bounces
  • +
  • Testing - Regular delivery tests
  • +
+
+

Marked as Spam

+

Severity: 🟠 High

+

Symptoms

+

Emails consistently go to spam folder.

+

Solutions

+

Solution 1: Configure SPF

+

Add TXT record to DNS:

+
v=spf1 include:_spf.google.com ~all
+
+

Or for SendGrid:

+
v=spf1 include:sendgrid.net ~all
+
+

Solution 2: Configure DKIM

+
    +
  1. Generate DKIM keys (via email provider)
  2. +
  3. Add DKIM TXT record to DNS
  4. +
  5. Enable DKIM signing in SMTP settings
  6. +
+

Solution 3: Configure DMARC

+

Add TXT record to DNS:

+
v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com
+
+

Solution 4: Improve email content

+
    +
  • Use plain text version alongside HTML
  • +
  • Avoid spam trigger words ("FREE", "CLICK HERE", "ACT NOW")
  • +
  • Proper from/reply-to addresses
  • +
  • Unsubscribe link
  • +
  • Physical address in footer
  • +
+

Solution 5: Warm up IP

+

If using dedicated IP: +1. Start with low volume +2. Gradually increase over weeks +3. Monitor reputation scores

+

Prevention

+
    +
  • Email authentication - SPF, DKIM, DMARC mandatory
  • +
  • Content quality - Professional, non-spammy content
  • +
  • Reputation monitoring - Monitor sender scores
  • +
  • Engagement - High engagement = good reputation
  • +
+
+

Bounce Errors

+

Severity: 🟡 Medium

+

Symptoms

+
Email bounced: user@example.com
+554 Recipient address rejected: User unknown
+
+

Common Causes

+
    +
  1. Invalid address - Email doesn't exist
  2. +
  3. Full mailbox - Recipient mailbox full
  4. +
  5. Temporary failure - Server temporarily unavailable
  6. +
  7. Blocked sender - Your domain/IP blocked
  8. +
+

Solutions

+

Solution 1: Categorize bounces

+

Hard bounces (permanent): +- User unknown +- Domain doesn't exist +- Invalid address format

+

Soft bounces (temporary): +- Mailbox full +- Server temporarily unavailable +- Message too large

+

Solution 2: Handle hard bounces

+
# Remove hard bounce addresses
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "UPDATE \"User\" SET \"emailBounced\" = true
+      WHERE email = 'bounced@example.com';"
+
+# Don't send to bounced addresses
+
+

Solution 3: Retry soft bounces

+
# Retry soft bounces after delay
+curl -X POST http://localhost:4000/api/influence/email-queue/retry-failed \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+

Solution 4: Validate emails before sending

+
import validator from 'validator';
+
+const isValidEmail = validator.isEmail(email);
+if (!isValidEmail) {
+  throw new Error('Invalid email address');
+}
+
+

Prevention

+
    +
  • Email validation - Validate before saving
  • +
  • Bounce tracking - Track bounces per address
  • +
  • Automatic removal - Don't send to bounced addresses
  • +
  • Double opt-in - Confirm email addresses work
  • +
+
+

Listmonk Integration

+

API Connection Failed

+

Severity: 🟠 High

+

Symptoms

+
Error: Failed to connect to Listmonk API
+Error: ECONNREFUSED localhost:9001
+
+

Solutions

+

Solution 1: Check Listmonk is running

+
docker compose ps listmonk
+
+# Should show "Up"
+# If not:
+docker compose up -d listmonk
+
+

Solution 2: Verify API credentials

+
# Check .env
+cat .env | grep LISTMONK_
+
+# Required:
+LISTMONK_URL=http://listmonk:9001
+LISTMONK_ADMIN_USER=admin
+LISTMONK_ADMIN_PASSWORD=password
+
+

Solution 3: Test API connection

+
# From API container
+docker compose exec api curl -u admin:password http://listmonk:9001/api/health
+
+# Should return:
+# {"data": "OK"}
+
+

Solution 4: Check Docker network

+
# Both on same network?
+docker inspect changemaker-lite-api-1 | grep NetworkMode
+docker inspect changemaker-lite-listmonk-1 | grep NetworkMode
+
+# Should both show "changemaker-lite"
+
+

Prevention

+
    +
  • Health checks - Verify Listmonk health on API startup
  • +
  • Proper credentials - Use API user (not web admin)
  • +
  • Network config - Ensure same Docker network
  • +
  • Error handling - Graceful degradation if Listmonk down
  • +
+
+

Sync Errors

+

Severity: 🟡 Medium

+

Symptoms

+
Error: Failed to sync subscribers to Listmonk
+Error: 400 Bad Request: Invalid email format
+
+

Solutions

+

Solution 1: Check sync status

+

Navigate to /app/listmonk:

+
    +
  • View sync statistics
  • +
  • See last sync time
  • +
  • Check error count
  • +
+

Solution 2: View sync logs

+
docker compose logs api | grep -i "listmonk\|sync"
+
+# Shows:
+# Syncing 150 participants to Listmonk
+# Created list: Campaign Participants
+# Added 145 subscribers, 5 failed
+
+

Solution 3: Manual sync

+
# Trigger manual sync
+curl -X POST http://localhost:4000/api/listmonk/sync \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+

Solution 4: Check subscriber data

+
# View failed subscribers
+docker compose logs api | grep "Failed to add subscriber"
+
+# Common issues:
+# - Invalid email format
+# - Email already exists
+# - Missing required fields
+
+

Prevention

+
    +
  • Data validation - Validate before sync
  • +
  • Duplicate handling - Handle existing subscribers
  • +
  • Error logging - Log sync errors
  • +
  • Regular syncs - Automated periodic syncs
  • +
+
+

Performance Issues

+

Slow Email Sending

+

Severity: 🟡 Medium

+

Symptoms

+

Sending emails takes several seconds each. Bulk sends very slow.

+

Solutions

+

Solution 1: Use queue system

+
# Don't send synchronously
+# Queue emails instead
+curl -X POST http://localhost:4000/api/influence/campaigns/CAMPAIGN_ID/send-bulk \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Processes in background via queue
+
+

Solution 2: Increase worker concurrency

+

In api/src/services/email-queue.service.ts:

+
const worker = new Worker('email-queue', processor, {
+  concurrency: 5,  // Process 5 emails at a time (default: 1)
+  limiter: {
+    max: 50,  // Max 50 emails per second
+    duration: 1000
+  }
+});
+
+

Solution 3: Use batch sending

+

For transactional email services:

+
// Some SMTP services support batch sending
+// Send 100 emails in single API call instead of 100 separate calls
+
+

Solution 4: Check SMTP performance

+
# Test SMTP connection speed
+time curl -X POST http://localhost:4000/api/test-email \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -d '{"to": "test@example.com", "subject": "Test", "text": "Test"}'
+
+# Should complete in < 2 seconds
+# If > 5 seconds, SMTP server slow
+
+

Solution 5: Use email service

+

For high volume, use transactional email service: +- SendGrid +- Mailgun +- Amazon SES +- Postmark

+

Faster and more reliable than SMTP.

+

Prevention

+
    +
  • Queue system - Never send synchronously
  • +
  • Worker concurrency - Process multiple at once
  • +
  • Email service - Use dedicated email service
  • +
  • Rate limiting - Respect provider limits
  • +
+
+

Queue Backlog

+

Severity: 🟡 Medium

+

Symptoms

+

Thousands of emails waiting in queue. Taking hours to process.

+

Solutions

+

Solution 1: Increase worker count

+

Start multiple API instances:

+
# In docker-compose.yml
+api:
+  deploy:
+    replicas: 3  # 3 API instances
+
+

Each instance runs its own worker.

+

Solution 2: Increase concurrency

+

See "Slow Email Sending" section above.

+

Solution 3: Pause new emails

+
# Pause queue
+curl -X POST http://localhost:4000/api/influence/email-queue/pause \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Process backlog
+# Resume when caught up
+curl -X POST http://localhost:4000/api/influence/email-queue/resume \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+

Solution 4: Clean old jobs

+
# Remove completed jobs
+curl -X POST http://localhost:4000/api/influence/email-queue/clean \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -d '{"status": "completed", "grace": 3600000}'  # Older than 1 hour
+
+

Prevention

+
    +
  • Monitor queue size - Alert when > 1000 waiting
  • +
  • Rate limiting - Don't queue faster than can process
  • +
  • Capacity planning - Size workers for expected load
  • +
  • Cleanup jobs - Regular cleanup of old jobs
  • +
+
+

Useful Commands

+

Testing Email

+
# Send test email
+curl -X POST http://localhost:4000/api/test-email \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -d '{
+    "to": "test@example.com",
+    "subject": "Test Email",
+    "text": "This is a test email",
+    "html": "<h1>Test Email</h1><p>This is a test email</p>"
+  }'
+
+# Test with template
+curl -X POST http://localhost:4000/api/test-template-email \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -d '{
+    "to": "test@example.com",
+    "subject": "Test Template",
+    "template": "campaign-email",
+    "variables": {
+      "name": "Test User",
+      "campaignName": "Test Campaign"
+    }
+  }'
+
+

Queue Management

+
# Get queue stats
+curl http://localhost:4000/api/influence/email-queue/stats \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Pause queue
+curl -X POST http://localhost:4000/api/influence/email-queue/pause \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Resume queue
+curl -X POST http://localhost:4000/api/influence/email-queue/resume \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Retry failed
+curl -X POST http://localhost:4000/api/influence/email-queue/retry-failed \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Clean completed
+curl -X POST http://localhost:4000/api/influence/email-queue/clean \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -d '{"status": "completed", "grace": 86400000}'
+
+

Listmonk Operations

+
# Test Listmonk connection
+curl -u admin:password http://localhost:9001/api/health
+
+# Get lists
+curl -u admin:password http://localhost:9001/api/lists
+
+# Sync subscribers
+curl -X POST http://localhost:4000/api/listmonk/sync \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Get sync status
+curl http://localhost:4000/api/listmonk/status \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+
+ +

Email Documentation

+ +

Other Troubleshooting

+ +

External Resources

+ +
+

Last Updated: February 2026 +Version: V2.0 +Status: Complete

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/troubleshooting/faq/index.html b/mkdocs/site/v2/troubleshooting/faq/index.html new file mode 100644 index 00000000..202414c2 --- /dev/null +++ b/mkdocs/site/v2/troubleshooting/faq/index.html @@ -0,0 +1,6909 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FAQ - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Frequently Asked Questions (FAQ)

+

Comprehensive answers to common questions about Changemaker Lite V2.

+

General Questions

+

What is Changemaker Lite?

+

Changemaker Lite is a self-hosted political campaign platform that consolidates:

+
    +
  • Advocacy email campaigns - Contact elected representatives
  • +
  • Geographic mapping - Location management and visualization
  • +
  • Canvassing system - Door-to-door volunteer coordination
  • +
  • Volunteer management - Shift scheduling and tracking
  • +
  • Landing pages - Custom campaign pages with GrapesJS editor
  • +
  • Newsletter platform - Listmonk integration for marketing emails
  • +
  • Media library - Video management and public galleries
  • +
  • Admin dashboard - Comprehensive management interface
  • +
+

Key features:

+
    +
  • 100% self-hosted (no external services required except email)
  • +
  • Docker Compose deployment (single command to start)
  • +
  • Full TypeScript stack (type-safe development)
  • +
  • Production-ready security (JWT auth, bcrypt passwords, rate limiting)
  • +
  • Monitoring included (Prometheus + Grafana)
  • +
  • Canadian electoral data support (NAR format)
  • +
+
+

V1 vs V2 Differences

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AspectV1V2
ArchitectureTwo separate Node apps (Influence + Map)Single unified Express API
DatabaseNocoDB REST APIPostgreSQL 16 + Prisma ORM
AuthenticationSessions (express-session)JWT (access + refresh tokens)
FrontendEJS templatesReact + Vite + Ant Design
StateServer-sideZustand (client-side)
EmailBull queuesBullMQ queues
MonitoringBasic loggingPrometheus + Grafana + Alertmanager
SecurityBasicProduction-grade (audit completed)
StatusLegacy (reference only)Current (active development)
+

Migration path: V1 → V2 requires data export/import. See Migration Guide.

+
+

System Requirements

+

Minimum (Development):

+
    +
  • CPU: 2 cores
  • +
  • RAM: 4GB
  • +
  • Disk: 10GB
  • +
  • OS: Linux, macOS, or Windows with WSL2
  • +
  • Docker: 20.10+ and Docker Compose v2+
  • +
+

Recommended (Production):

+
    +
  • CPU: 4+ cores
  • +
  • RAM: 8-16GB
  • +
  • Disk: 50GB+ SSD
  • +
  • OS: Ubuntu 22.04 LTS or similar
  • +
  • Docker: Latest stable version
  • +
+

External services (optional):

+
    +
  • SMTP server (for emails) - can use Gmail, SendGrid, Mailgun, etc.
  • +
  • Pangolin/Cloudflare tunnel (for HTTPS) - or use your own reverse proxy
  • +
+
+

Browser Compatibility

+

Supported browsers:

+
    +
  • ✅ Chrome 90+ (recommended)
  • +
  • ✅ Firefox 88+
  • +
  • ✅ Safari 14+
  • +
  • ✅ Edge 90+
  • +
  • ❌ Internet Explorer (not supported)
  • +
+

Mobile browsers:

+
    +
  • ✅ Chrome on Android
  • +
  • ✅ Safari on iOS
  • +
  • ⚠️ Some features desktop-only (GrapesJS editor, map drawing)
  • +
+

Required features:

+
    +
  • JavaScript enabled
  • +
  • Local Storage enabled
  • +
  • Cookies enabled (for Listmonk only)
  • +
  • WebSockets supported (for real-time features)
  • +
+
+

Installation & Setup

+

How to Install?

+

Quick start:

+
# 1. Clone repository
+git clone <repo-url> changemaker.lite
+cd changemaker.lite
+git checkout v2
+
+# 2. Create environment file
+cp .env.example .env
+nano .env  # Edit and set passwords/secrets
+
+# 3. Start services
+docker compose up -d v2-postgres redis api admin
+
+# 4. Run migrations
+docker compose exec api npx prisma migrate deploy
+docker compose exec api npx prisma db seed
+
+# 5. Access application
+# Admin GUI: http://localhost:3000
+# API: http://localhost:4000
+# Login: admin@example.com / Admin123!
+
+# 6. Change default password immediately
+
+

See Installation Guide for detailed instructions.

+
+

Default Credentials

+

Admin user (created by seed):

+
    +
  • Email: admin@example.com
  • +
  • Password: Admin123!
  • +
  • Role: SUPER_ADMIN
  • +
+

⚠️ IMPORTANT: Change this password immediately after first login!

+

Other services:

+
    +
  • Grafana: admin / admin
  • +
  • NocoDB: Set via NC_ADMIN_EMAIL / NC_ADMIN_PASSWORD in .env
  • +
  • Listmonk: Set via LISTMONK_WEB_ADMIN_USER / LISTMONK_WEB_ADMIN_PASSWORD in .env
  • +
+
+

How to Change Password?

+

Via Admin UI (recommended):

+
    +
  1. Login to admin at http://localhost:3000
  2. +
  3. Navigate to Users (/app/users)
  4. +
  5. Click user row
  6. +
  7. Click Edit
  8. +
  9. Enter new password (12+ chars, uppercase, lowercase, digit)
  10. +
  11. Save
  12. +
+

Via database:

+
# Generate bcrypt hash
+docker compose exec api node -e "
+const bcrypt = require('bcryptjs');
+console.log(bcrypt.hashSync('NewPassword123!', 10));
+"
+
+# Update password
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "UPDATE \"User\" SET password = 'PASTE_HASH_HERE' WHERE email = 'admin@example.com';"
+
+

Password requirements:

+
    +
  • Minimum 12 characters
  • +
  • At least 1 uppercase letter
  • +
  • At least 1 lowercase letter
  • +
  • At least 1 digit
  • +
  • No maximum length
  • +
+
+

How to Enable HTTPS?

+

Changemaker Lite doesn't include HTTPS natively. Use one of these options:

+

Option 1: Pangolin Tunnel (Recommended)

+

Built-in integration:

+
    +
  1. Navigate to /app/pangolin
  2. +
  3. Follow setup wizard
  4. +
  5. Configure tunnel
  6. +
  7. Access via HTTPS URL provided by Pangolin
  8. +
+

See Pangolin Integration.

+

Option 2: Cloudflare Tunnel

+
    +
  1. Install cloudflared
  2. +
  3. Configure tunnel
  4. +
  5. Point to localhost:3000 (admin) and localhost:4000 (API)
  6. +
+

Option 3: Reverse Proxy

+

Add nginx/Caddy in front:

+
# docker-compose.yml
+reverse-proxy:
+  image: nginx:alpine
+  ports:
+    - "443:443"
+  volumes:
+    - ./nginx/ssl.conf:/etc/nginx/nginx.conf
+    - ./ssl:/etc/nginx/ssl  # Your SSL certificates
+
+

Option 4: Hosting Provider

+

Deploy to provider with built-in HTTPS: +- DigitalOcean App Platform +- Heroku +- Railway +- Render

+
+

User Management

+

How to Create Users?

+

Via Admin UI (recommended):

+
    +
  1. Navigate to /app/users
  2. +
  3. Click "Create User"
  4. +
  5. Fill in form:
  6. +
  7. Email (required, unique)
  8. +
  9. Password (required, 12+ chars)
  10. +
  11. Name (required)
  12. +
  13. Role (default: USER)
  14. +
  15. Click "Create"
  16. +
+

Via API:

+
curl -X POST http://localhost:4000/api/auth/register \
+  -H "Content-Type: application/json" \
+  -d '{
+    "email": "newuser@example.com",
+    "password": "SecurePass123!",
+    "name": "New User"
+  }'
+
+

Via database:

+
# Generate password hash first
+docker compose exec api node -e "
+const bcrypt = require('bcryptjs');
+console.log(bcrypt.hashSync('Password123!', 10));
+"
+
+# Create user
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "INSERT INTO \"User\" (id, email, password, name, role)
+      VALUES (gen_random_uuid(), 'user@example.com', 'HASH_HERE', 'User Name', 'USER');"
+
+
+

How to Reset Passwords?

+

Current: V2 doesn't have password reset flow yet (planned for Phase 15).

+

Workaround: Reset manually via database (see "How to Change Password?" above).

+

Future: Will include: +- Forgot password form +- Email with reset link +- 24-hour expiration +- One-time use tokens

+
+

What are the User Roles?

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RoleLevelCapabilities
SUPER_ADMIN5Full access to everything (users, settings, all features)
INFLUENCE_ADMIN4Manage campaigns, responses, email queue
MAP_ADMIN3Manage locations, cuts, shifts, canvassing
USER2View public content, participate in canvassing (if assigned)
TEMP1Very limited - shift signup confirmation only
+

Permission matrix:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureSUPER_ADMININFLUENCE_ADMINMAP_ADMINUSERTEMP
User management
Site settings
Campaigns (admin)
Responses (moderation)
Email queue
Locations (admin)
Cuts (admin)
Shifts (admin)
Canvass dashboard
View public campaigns
View public map
Sign up for shifts
Canvass (volunteer)
+

Login redirects:

+
    +
  • SUPER_ADMIN / INFLUENCE_ADMIN / MAP_ADMIN → /app (admin dashboard)
  • +
  • USER / TEMP → /volunteer (volunteer portal)
  • +
+
+

How to Suspend Users?

+

Current: V2 doesn't have user suspension yet (planned for Phase 15).

+

Workaround: Delete user account or change role to TEMP (limited permissions).

+

Future: Will include: +- Suspended flag on User model +- Suspension reason tracking +- Auto-logout suspended users +- Reactivation workflow

+
+

Campaigns

+

How to Create Campaign?

+
    +
  1. Navigate to /app/influence/campaigns
  2. +
  3. Click "Create Campaign"
  4. +
  5. Fill in form:
  6. +
  7. Name (required) - Campaign title
  8. +
  9. Slug (required, unique) - URL-friendly name
  10. +
  11. Description (optional) - Campaign details
  12. +
  13. Email Subject (optional) - Default email subject
  14. +
  15. Email Body (optional) - Default email template
  16. +
  17. Active (checkbox) - Show on public site
  18. +
  19. Allow Custom Message (checkbox) - Let users edit message
  20. +
  21. Click "Create"
  22. +
  23. Campaign now appears in admin table and public listing (if active)
  24. +
+
+

How to Publish Campaign?

+
    +
  1. Navigate to /app/influence/campaigns
  2. +
  3. Find campaign in table
  4. +
  5. Click row to expand
  6. +
  7. Toggle "Active" switch to ON
  8. +
  9. Campaign now visible at /campaigns (public)
  10. +
+
+

How to Track Emails?

+
    +
  1. Navigate to /app/influence/campaigns
  2. +
  3. Click campaign row
  4. +
  5. Click "View Emails" button
  6. +
  7. Drawer shows:
  8. +
  9. Total emails sent
  10. +
  11. Email list with timestamps
  12. +
  13. Recipient addresses
  14. +
  15. Email status (sent/failed)
  16. +
+

Via Email Queue Page:

+
    +
  1. Navigate to /app/influence/email-queue
  2. +
  3. View stats:
  4. +
  5. Total emails processed
  6. +
  7. Success/fail counts
  8. +
  9. Queue depth
  10. +
  11. View recent jobs
  12. +
  13. Retry failed jobs if needed
  14. +
+
+

How to Moderate Responses?

+
    +
  1. Navigate to /app/influence/responses
  2. +
  3. Table shows all responses with:
  4. +
  5. Participant name/email
  6. +
  7. Campaign
  8. +
  9. Message excerpt
  10. +
  11. Submission timestamp
  12. +
  13. Verification status
  14. +
  15. Filters:
  16. +
  17. Campaign dropdown
  18. +
  19. Verified/unverified toggle
  20. +
  21. Click row to view full response
  22. +
  23. Actions:
  24. +
  25. Verify (if unverified)
  26. +
  27. Delete (if inappropriate)
  28. +
+

Response verification workflow:

+
    +
  1. User submits response → marked unverified
  2. +
  3. User receives verification email
  4. +
  5. User clicks verification link → marked verified
  6. +
  7. Only verified responses show on public response wall
  8. +
+
+

Map & Canvassing

+

How to Import Locations?

+

Via CSV:

+
    +
  1. Navigate to /app/map/locations
  2. +
  3. Click "Import CSV"
  4. +
  5. Prepare CSV with columns: +
    address,city,province,postalCode,notes
    +123 Main St,Toronto,ON,M5H 2N2,Corner house
    +456 Oak Ave,Toronto,ON,M5H 2N3,Blue door
    +
  6. +
  7. Upload file
  8. +
  9. Map columns (if headers don't match exactly)
  10. +
  11. Click "Import"
  12. +
  13. Locations imported, geocoding starts automatically
  14. +
+

Via NAR (Canadian Electoral Data):

+
    +
  1. Obtain NAR data files (Location + Address)
  2. +
  3. Place in /data directory (mapped volume)
  4. +
  5. Navigate to /app/map/locations
  6. +
  7. Click "NAR Import" tab
  8. +
  9. Select province
  10. +
  11. Select dataset
  12. +
  13. Apply filters (city, postal code, cut, residential only)
  14. +
  15. Preview count
  16. +
  17. Click "Import"
  18. +
  19. Import processes in background (can take minutes for large files)
  20. +
+

See NAR Import Guide.

+
+

How to Create Cuts?

+

Via Map Drawing:

+
    +
  1. Navigate to /app/map/cuts
  2. +
  3. Click "Map Drawing" tab
  4. +
  5. Map shows with drawing controls
  6. +
  7. Click "Draw Cut" button
  8. +
  9. Click on map to place vertices
  10. +
  11. Click first vertex again to close polygon (or click "Finish")
  12. +
  13. Fill in form:
  14. +
  15. Name (required)
  16. +
  17. Description (optional)
  18. +
  19. Color (pick color for map display)
  20. +
  21. Click "Save"
  22. +
+

Via GeoJSON Import:

+
    +
  1. Prepare GeoJSON file: +
    {
    +  "type": "Polygon",
    +  "coordinates": [[
    +    [-79.38, 43.65],
    +    [-79.37, 43.65],
    +    [-79.37, 43.64],
    +    [-79.38, 43.64],
    +    [-79.38, 43.65]
    +  ]]
    +}
    +
  2. +
  3. Navigate to /app/map/cuts
  4. +
  5. Click "Create Cut"
  6. +
  7. Paste GeoJSON in geometry field
  8. +
  9. Fill in name/description
  10. +
  11. Click "Create"
  12. +
+
+

How to Organize Shifts?

+
    +
  1. Navigate to /app/map/shifts
  2. +
  3. Click "Create Shift"
  4. +
  5. Fill in form:
  6. +
  7. Title (required) - Shift name
  8. +
  9. Description (optional) - Shift details
  10. +
  11. Start Time (required) - When shift starts
  12. +
  13. End Time (required) - When shift ends
  14. +
  15. Cut (optional) - Assign to specific cut
  16. +
  17. Max Volunteers (optional) - Capacity limit
  18. +
  19. Public (checkbox) - Show on public shifts page
  20. +
  21. Click "Create"
  22. +
  23. Shift appears in admin table and public listing (if public)
  24. +
+

Manage signups:

+
    +
  1. Click shift row in table
  2. +
  3. Click "View Signups"
  4. +
  5. Drawer shows:
  6. +
  7. Signup count
  8. +
  9. List of volunteers
  10. +
  11. Email addresses
  12. +
  13. Actions:
  14. +
  15. "Email All" - Send message to all volunteers
  16. +
  17. Remove individual signups if needed
  18. +
+
+

How to Start Canvassing?

+

For volunteers:

+
    +
  1. Login to volunteer portal
  2. +
  3. Navigate to "My Assignments" (/volunteer/assignments)
  4. +
  5. Find assigned shift
  6. +
  7. Click "Start Canvassing"
  8. +
  9. Full-screen map opens (/volunteer/canvass/:cutId)
  10. +
  11. GPS tracks your location
  12. +
  13. Map shows:
  14. +
  15. Your current position (blue dot)
  16. +
  17. Locations in cut (markers)
  18. +
  19. Walking route (blue line)
  20. +
  21. Legend (outcome colors)
  22. +
  23. Click location marker to record visit:
  24. +
  25. Select outcome (Home, Away, Refused, etc.)
  26. +
  27. Add notes (optional)
  28. +
  29. Save
  30. +
  31. Continue until all locations visited
  32. +
  33. Session auto-saves progress
  34. +
+

For admins (monitoring):

+
    +
  1. Navigate to /app/canvass/dashboard
  2. +
  3. View:
  4. +
  5. Active sessions count
  6. +
  7. Total visits recorded
  8. +
  9. Recent activity feed
  10. +
  11. Cut progress (% complete)
  12. +
  13. Leaderboard (top canvassers)
  14. +
  15. Click activity item to see details
  16. +
+

See Canvassing Guide.

+
+

Technical Questions

+

Which Database?

+

PostgreSQL 16 with two ORMs:

+
    +
  1. Prisma - Main API (Express)
  2. +
  3. Schema: api/prisma/schema.prisma
  4. +
  5. Migrations: api/prisma/migrations/
  6. +
  7. +

    30+ models (User, Campaign, Location, etc.)

    +
  8. +
  9. +

    Drizzle - Media API (Fastify)

    +
  10. +
  11. Schema: api/src/modules/media/db/schema.ts
  12. +
  13. Tables: media_videos, media_reactions, etc.
  14. +
+

Connection:

+
    +
  • Host: v2-postgres (container) or localhost:5433 (host)
  • +
  • Database: changemaker_v2
  • +
  • User: changemaker
  • +
  • Password: V2_POSTGRES_PASSWORD (from .env)
  • +
+

Shared database: Both ORMs use same PostgreSQL database, different tables.

+
+

Which ORM?

+

Prisma for main API:

+
    +
  • Type-safe queries
  • +
  • Auto-generated client
  • +
  • Migrations workflow
  • +
  • Prisma Studio GUI
  • +
+

Drizzle for media API:

+
    +
  • Lightweight
  • +
  • SQL-like API
  • +
  • Schema-first approach
  • +
  • No migration files (push to sync)
  • +
+

Why two ORMs?

+

Media API was added later as separate Fastify microservice. Using Drizzle allowed faster development without modifying main Prisma schema.

+
+

API Architecture?

+

Dual API architecture:

+
    +
  1. Express API (Main)
  2. +
  3. Port: 4000
  4. +
  5. Language: TypeScript
  6. +
  7. ORM: Prisma
  8. +
  9. Features: Auth, campaigns, locations, shifts, canvass, pages
  10. +
  11. +

    Endpoints: /api/*

    +
  12. +
  13. +

    Fastify Media API (Microservice)

    +
  14. +
  15. Port: 4100
  16. +
  17. Language: TypeScript
  18. +
  19. ORM: Drizzle
  20. +
  21. Features: Video library, uploads, reactions
  22. +
  23. Endpoints: /api/media/*
  24. +
+

Shared:

+
    +
  • Same PostgreSQL database
  • +
  • Same Redis instance
  • +
  • Same Docker network
  • +
  • Separate containerization (can scale independently)
  • +
+

Frontend:

+
    +
  • React SPA (Vite)
  • +
  • Port: 3000
  • +
  • State: Zustand
  • +
  • UI: Ant Design
  • +
  • Routing: React Router v6
  • +
+
+

Authentication Method?

+

JWT-based authentication:

+

Tokens:

+
    +
  1. Access Token
  2. +
  3. Duration: 15 minutes
  4. +
  5. Stored: Memory (localStorage)
  6. +
  7. Contains: userId, email, role
  8. +
  9. +

    Used: All authenticated requests

    +
  10. +
  11. +

    Refresh Token

    +
  12. +
  13. Duration: 7 days
  14. +
  15. Stored: Database + localStorage
  16. +
  17. Used: Renew access token
  18. +
  19. Rotation: New refresh token on each refresh
  20. +
+

Flow:

+
    +
  1. Login → Returns access + refresh tokens
  2. +
  3. Store in localStorage (Zustand persist)
  4. +
  5. Add access token to Authorization header
  6. +
  7. Access token expires after 15min
  8. +
  9. Frontend auto-refreshes using refresh token
  10. +
  11. New access + refresh tokens returned
  12. +
  13. Continue seamlessly
  14. +
+

Security features:

+
    +
  • bcrypt password hashing (10 rounds)
  • +
  • Token rotation prevents replay attacks
  • +
  • Refresh tokens stored in database (can revoke)
  • +
  • Rate limiting on auth endpoints (10/min)
  • +
  • User enumeration prevention
  • +
  • Redis authentication required
  • +
+

See Authentication Flow.

+
+

Performance

+

How Many Users Supported?

+

Concurrent users:

+
    +
  • Development: 10-50 users
  • +
  • Production (default config): 100-500 users
  • +
  • Production (optimized): 1000+ users
  • +
+

Factors:

+
    +
  • Database connection pool (default: 10 connections)
  • +
  • API worker concurrency (default: 1 worker)
  • +
  • Server resources (CPU/RAM)
  • +
  • Network bandwidth
  • +
+

Scaling:

+
    +
  • Horizontal: Run multiple API instances
  • +
  • Vertical: Increase server resources
  • +
  • Database: Read replicas for read-heavy loads
  • +
  • Caching: Redis caching for frequently accessed data
  • +
+
+

How to Scale?

+

Horizontal scaling (recommended):

+
# docker-compose.yml
+api:
+  deploy:
+    replicas: 3  # Run 3 API instances
+  # Each instance:
+  # - Handles requests independently
+  # - Connects to same database
+  # - Processes queue jobs
+  # - Shares Redis cache
+
+

Add load balancer in front:

+
nginx:
+  image: nginx:alpine
+  ports:
+    - "80:80"
+  volumes:
+    - ./nginx/lb.conf:/etc/nginx/nginx.conf
+  # Distributes requests across API instances
+
+

Vertical scaling:

+

Increase resources:

+
api:
+  deploy:
+    resources:
+      limits:
+        cpus: '4.0'  # More CPU
+        memory: 8G   # More RAM
+
+

Database scaling:

+
    +
  • Add read replicas for read-heavy queries
  • +
  • Use connection pooler (PgBouncer)
  • +
  • Optimize queries and indexes
  • +
+

Caching:

+
    +
  • Redis caching for geocoding results
  • +
  • Redis caching for representative lookups
  • +
  • HTTP caching headers (Nginx)
  • +
  • Static asset CDN
  • +
+
+

Database Size Limits?

+

PostgreSQL:

+
    +
  • Maximum database size: ~32 TB (theoretical)
  • +
  • Practical limit: Depends on storage and backup strategy
  • +
+

Typical sizes (after 1 year):

+
    +
  • Small campaign: 100MB-500MB (1k locations, 10 campaigns)
  • +
  • Medium campaign: 500MB-2GB (10k locations, 50 campaigns)
  • +
  • Large campaign: 2GB-10GB (100k locations, 200 campaigns)
  • +
+

Storage requirements:

+
    +
  • Database: 1-10GB
  • +
  • Uploads: 5-50GB (videos)
  • +
  • Backups: 2× database size (keep multiple backups)
  • +
  • Logs: 1-5GB/month
  • +
  • Total: 20-100GB recommended
  • +
+

Optimization:

+
    +
  • Regular VACUUM (auto-enabled)
  • +
  • Archive old campaigns
  • +
  • Delete old logs
  • +
  • Compress backups
  • +
+
+

Security

+

Is Data Encrypted?

+

At rest:

+
    +
  • Database: Not encrypted by default (enable PostgreSQL encryption if needed)
  • +
  • Passwords: bcrypt hashed (cannot be decrypted)
  • +
  • Sensitive fields: ENCRYPTION_KEY env var for encrypting secrets
  • +
+

In transit:

+
    +
  • HTTPS: Use Pangolin/Cloudflare tunnel (encrypts all traffic)
  • +
  • Database: PostgreSQL connections within Docker network (isolated)
  • +
  • Redis: Authenticated (password required)
  • +
+

Recommendations:

+
    +
  • Use HTTPS in production
  • +
  • Rotate ENCRYPTION_KEY periodically
  • +
  • Enable PostgreSQL SSL if database exposed
  • +
  • Use strong passwords for all services
  • +
+
+

Password Requirements?

+

Enforced policy:

+
    +
  • Minimum 12 characters
  • +
  • At least 1 uppercase letter (A-Z)
  • +
  • At least 1 lowercase letter (a-z)
  • +
  • At least 1 digit (0-9)
  • +
  • No maximum length
  • +
+

Valid examples:

+
    +
  • SecurePass123!
  • +
  • MyPassword99
  • +
  • Admin12345678
  • +
+

Invalid:

+
    +
  • short (too short)
  • +
  • nouppercase123 (no uppercase)
  • +
  • NOLOWERCASE123 (no lowercase)
  • +
  • NoDigitsHere (no digit)
  • +
+

Storage:

+
    +
  • bcrypt hashed with salt (10 rounds)
  • +
  • Hash stored in database (not plaintext)
  • +
  • Cannot be decrypted (one-way hash)
  • +
+
+

How to Backup?

+

Manual backup:

+
# Use provided script
+./scripts/backup.sh
+
+# Creates:
+# - PostgreSQL dump
+# - Listmonk dump (if enabled)
+# - Uploads archive (videos, images)
+# - Timestamped filename: backup_2026-02-13_100000.tar.gz
+
+

What's included:

+
    +
  • Complete database dump (all tables)
  • +
  • Uploaded files (videos, images, documents)
  • +
  • Listmonk database (if enabled)
  • +
+

What's NOT included:

+
    +
  • Docker images (rebuild from Dockerfile)
  • +
  • .env file (keep separate, has secrets)
  • +
  • Temporary files (logs, cache)
  • +
+

Automated backups:

+

Add cron job:

+
# Run daily at 2 AM
+0 2 * * * cd /path/to/changemaker.lite && ./scripts/backup.sh
+
+# With S3 upload (if configured)
+0 2 * * * cd /path/to/changemaker.lite && ./scripts/backup.sh --upload-s3
+
+

Restore:

+
# Stop services
+docker compose down
+
+# Restore database
+gunzip -c backup.sql.gz | docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2
+
+# Restore uploads
+tar -xzf uploads.tar.gz -C ./uploads
+
+# Start services
+docker compose up -d
+
+

See Backup Guide.

+
+

Troubleshooting

+

Where are Logs?

+

Docker logs:

+
# View API logs
+docker compose logs api
+
+# View all logs
+docker compose logs
+
+# Follow logs (real-time)
+docker compose logs -f api
+
+# Last 100 lines
+docker compose logs api --tail=100
+
+# Since timestamp
+docker compose logs api --since="2026-02-13T10:00:00"
+
+# Save to file
+docker compose logs api > api-logs.txt
+
+

Log locations inside containers:

+
    +
  • API: Console output (docker logs)
  • +
  • PostgreSQL: Console + /var/lib/postgresql/data/log/ (if logging enabled)
  • +
  • Nginx: /var/log/nginx/access.log, /var/log/nginx/error.log
  • +
+

Log levels:

+
    +
  • ERROR: Errors requiring attention
  • +
  • WARN: Warnings (not critical)
  • +
  • INFO: Informational messages
  • +
  • DEBUG: Debugging information (enable with LOG_LEVEL=debug)
  • +
+
+

How to Restart Services?

+

Restart specific service:

+
# Restart API
+docker compose restart api
+
+# Restart multiple services
+docker compose restart api admin v2-postgres
+
+

Restart all services:

+
# Graceful restart (preserves data)
+docker compose restart
+
+# Stop and start (recreates containers)
+docker compose down
+docker compose up -d
+
+

Force recreate:

+
# Rebuild and recreate
+docker compose up -d --build --force-recreate
+
+# Recreate specific service
+docker compose up -d --build --force-recreate api
+
+

Restart single container:

+
# Get container name
+docker compose ps
+
+# Restart by name
+docker restart changemaker-lite-api-1
+
+
+

How to Reset Database?

+

⚠️ WARNING: This deletes ALL data!

+

Full reset:

+
# Stop services
+docker compose down
+
+# Delete database volume
+docker volume rm changemaker-lite_postgres-data
+
+# Start fresh
+docker compose up -d v2-postgres
+
+# Wait for database ready
+sleep 10
+
+# Run migrations
+docker compose exec api npx prisma migrate deploy
+
+# Seed initial data
+docker compose exec api npx prisma db seed
+
+# Default admin: admin@example.com / Admin123!
+
+

Reset specific tables:

+
# Delete all users (keeps schema)
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c 'TRUNCATE "User" CASCADE;'
+
+# Re-seed
+docker compose exec api npx prisma db seed
+
+

Reset without deleting volumes:

+
# Drop and recreate database
+docker compose exec v2-postgres psql -U postgres \
+  -c 'DROP DATABASE changemaker_v2;'
+
+docker compose exec v2-postgres psql -U postgres \
+  -c 'CREATE DATABASE changemaker_v2 OWNER changemaker;'
+
+# Run migrations
+docker compose exec api npx prisma migrate deploy
+docker compose exec api npx prisma db seed
+
+
+

Getting Help

+ +

User Guides:

+ +

Technical Documentation:

+ +

Troubleshooting:

+ +
+

GitHub Issues

+

Before creating issue:

+
    +
  1. Check existing issues
  2. +
  3. Search closed issues (may already be fixed)
  4. +
  5. Check Troubleshooting guides
  6. +
  7. Try latest version (git pull origin v2)
  8. +
+

Creating good issues:

+

Bug reports:

+
**Describe the bug**
+Clear description of what's wrong.
+
+**To Reproduce**
+1. Go to '...'
+2. Click on '...'
+3. See error
+
+**Expected behavior**
+What should happen instead.
+
+**Screenshots**
+If applicable, add screenshots.
+
+**Environment**
+- OS: [e.g. Ubuntu 22.04]
+- Docker version: [e.g. 20.10.21]
+- Browser: [e.g. Chrome 120]
+
+**Logs**
+Paste relevant logs (sanitize sensitive data).
+
+

Feature requests:

+
**Is your feature request related to a problem?**
+Description of problem.
+
+**Describe the solution you'd like**
+Clear description of feature.
+
+**Describe alternatives you've considered**
+Other solutions considered.
+
+**Additional context**
+Any other context or screenshots.
+
+
+

Community Support

+

Official channels:

+
    +
  • GitHub Issues (bugs and features)
  • +
  • GitHub Discussions (questions and ideas)
  • +
  • Documentation (this site)
  • +
+

Response time:

+
    +
  • Bug reports: 1-7 days
  • +
  • Feature requests: Variable (depends on priority)
  • +
  • Questions: 1-3 days
  • +
+

Contributing:

+ +
+

Last Updated: February 2026 +Version: V2.0 +Status: Complete

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/troubleshooting/geocoding-issues/index.html b/mkdocs/site/v2/troubleshooting/geocoding-issues/index.html new file mode 100644 index 00000000..7ae02e33 --- /dev/null +++ b/mkdocs/site/v2/troubleshooting/geocoding-issues/index.html @@ -0,0 +1,8152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Geocoding Issues - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Geocoding and Map Issues

+

This guide covers geocoding, map display, and location-related problems in Changemaker Lite V2.

+

Overview

+

Geocoding System

+

Changemaker Lite V2 uses multi-provider geocoding with automatic fallback:

+
    +
  1. Google Geocoding API - Most accurate, requires API key
  2. +
  3. Mapbox Geocoding API - Good quality, requires API key
  4. +
  5. Nominatim (OpenStreetMap) - Free, no key required
  6. +
  7. ArcGIS Geocoding Service - Good for North America
  8. +
  9. Photon (OpenStreetMap) - Free alternative
  10. +
  11. HERE Geocoding API - Paid option
  12. +
+

Geocoding Queue

+
    +
  • BullMQ queue - Async geocoding for bulk imports
  • +
  • Rate limiting - Respects provider rate limits
  • +
  • Retry logic - Auto-retry failed geocodes
  • +
  • Priority - Manual geocodes prioritized over bulk
  • +
+

Map Display

+
    +
  • Leaflet.js - Open-source map library
  • +
  • OpenStreetMap tiles - Free map tiles
  • +
  • Circle markers - Color-coded by cut assignment
  • +
  • Polygon overlays - Cut boundaries
  • +
  • Geolocate - Find user's current location
  • +
+
+

Geocoding Failures

+

Address Not Found

+

Severity: 🟡 Medium

+

Symptoms

+

Location shows null latitude/longitude after geocoding attempt.

+

API logs: +

WARN Geocoding failed for address: "123 Fake St, Nowhere": No results from any provider
+

+

Common Causes

+
    +
  1. Invalid address - Address doesn't exist
  2. +
  3. Typo - Misspelled street/city/postal code
  4. +
  5. Incomplete address - Missing city or postal code
  6. +
  7. Wrong country - Address in different country
  8. +
  9. Rural address - Not in geocoding databases
  10. +
+

Solutions

+

Solution 1: Verify address format

+
# Good address format (Canadian):
+123 Main Street, Toronto, ON M5H 2N2
+
+# Good address format (US):
+123 Main Street, New York, NY 10001
+
+# Bad formats:
+123 Main  # Missing city/postal
+Main Street  # Missing number
+Toronto  # Too vague
+
+

Solution 2: Test address manually

+
# Test via Nominatim (no API key needed)
+curl "https://nominatim.openstreetmap.org/search?q=123+Main+Street,Toronto,ON&format=json"
+
+# Should return array with results
+# If empty, address not found
+
+

Solution 3: Try alternative formats

+
# If "123 Main Street, Toronto ON M5H 2N2" fails, try:
+# - "123 Main St, Toronto ON M5H2N2" (no space in postal)
+# - "123 Main Street, Toronto Ontario M5H 2N2" (full province)
+# - "123 Main Street, M5H 2N2" (postal code only)
+# - "M5H 2N2" (postal code geocoding)
+
+

Solution 4: Check geocoding logs

+
# View detailed geocoding attempts
+docker compose logs api | grep "Geocoding\|geocode"
+
+# Shows:
+# Trying provider: google
+# Google geocoding failed: Invalid request
+# Trying provider: nominatim
+# Nominatim geocoding succeeded
+
+

Solution 5: Manually set coordinates

+

In admin UI (LocationsPage):

+
    +
  1. Find location in table
  2. +
  3. Click Edit
  4. +
  5. Manually enter lat/lng (from Google Maps)
  6. +
  7. Save
  8. +
+

Or via SQL:

+
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "UPDATE \"Location\" SET latitude = 43.65, longitude = -79.38
+      WHERE address = '123 Main Street';"
+
+

Prevention

+
    +
  • Address validation - Validate format before saving
  • +
  • Postal code lookup - Use postal code if full address fails
  • +
  • Manual review - Flag failed geocodes for manual review
  • +
  • Alternative sources - Try multiple address formats
  • +
+
+

All Providers Failed

+

Severity: 🟠 High

+

Symptoms

+
ERROR Geocoding failed: All providers failed for address: "123 Main St"
+
+

All 6 geocoding providers returned no results or errors.

+

Common Causes

+
    +
  1. Network issue - Can't reach external APIs
  2. +
  3. Rate limits - All providers rate limited
  4. +
  5. Invalid API keys - Google/Mapbox keys invalid
  6. +
  7. Bad address - Address truly doesn't exist
  8. +
  9. Provider outages - Services down
  10. +
+

Solutions

+

Solution 1: Check network connectivity

+
# Test DNS resolution
+docker compose exec api ping -c 3 nominatim.openstreetmap.org
+
+# Test HTTPS connection
+docker compose exec api curl -I https://nominatim.openstreetmap.org
+
+# If fails, network issue
+
+

Solution 2: Test each provider manually

+
# Nominatim (free, no key)
+curl "https://nominatim.openstreetmap.org/search?q=123+Main+Street,Toronto&format=json"
+
+# Google (requires GOOGLE_GEOCODING_API_KEY)
+curl "https://maps.googleapis.com/maps/api/geocode/json?address=123+Main+Street,Toronto&key=YOUR_KEY"
+
+# Mapbox (requires MAPBOX_API_KEY)
+curl "https://api.mapbox.com/geocoding/v5/mapbox.places/123+Main+Street,Toronto.json?access_token=YOUR_KEY"
+
+

Solution 3: Check API keys

+
# Verify API keys in .env
+cat .env | grep -E "GOOGLE_GEOCODING_API_KEY|MAPBOX_API_KEY|HERE_API_KEY"
+
+# Should show non-empty values
+# If empty, providers requiring keys won't work
+
+

Solution 4: Check rate limits

+
# View geocoding stats
+curl http://localhost:4000/api/map/geocoding/stats \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Shows:
+# {
+#   "totalAttempts": 1523,
+#   "successful": 1450,
+#   "failed": 73,
+#   "byProvider": {
+#     "google": { "attempts": 500, "successes": 480 },
+#     "nominatim": { "attempts": 600, "successes": 570 }
+#   }
+# }
+
+

Solution 5: Wait and retry

+

Rate limits reset after time: +- Nominatim: 1 request/second (resets immediately) +- Google: 50 requests/second (resets after 1 second) +- Mapbox: 600 requests/minute (resets after 1 minute)

+
# Retry geocoding after wait
+curl -X POST http://localhost:4000/api/map/locations/LOCATION_ID/geocode \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+

Prevention

+
    +
  • API key monitoring - Alert on API key errors
  • +
  • Rate limit tracking - Monitor usage against limits
  • +
  • Provider rotation - Distribute load across providers
  • +
  • Graceful degradation - Continue with partial results
  • +
+
+

Low Confidence Results

+

Severity: 🟢 Low

+

Symptoms

+

Geocoding succeeds but coordinates seem wrong or imprecise.

+

Example: +- Address: "123 Main Street, Toronto" +- Geocoded to: Center of Toronto (not specific address)

+

Common Causes

+
    +
  1. Ambiguous address - Multiple matches
  2. +
  3. Incomplete address - Missing street number
  4. +
  5. Rural address - Only city-level precision
  6. +
  7. Provider limitation - Provider doesn't have precise data
  8. +
+

Solutions

+

Solution 1: Check geocoding confidence

+
# View location details
+curl http://localhost:4000/api/map/locations/LOCATION_ID \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Response includes:
+# {
+#   "geocodingProvider": "nominatim",
+#   "geocodingConfidence": "low",  # or "high", "medium"
+#   "latitude": 43.65,
+#   "longitude": -79.38
+# }
+
+

Solution 2: Add more detail to address

+
# Low confidence:
+"Main Street, Toronto"
+
+# Higher confidence:
+"123 Main Street, Toronto, ON M5H 2N2"
+
+# Best confidence:
+"123 Main Street, Toronto, Ontario M5H 2N2, Canada"
+
+

Solution 3: Use postal code geocoding

+

For Canadian addresses, postal code is often more accurate:

+
# Update location with postal code
+UPDATE "Location"
+SET "postalCode" = 'M5H 2N2'
+WHERE id = 'LOCATION_ID';
+
+# Re-geocode (will use postal code)
+curl -X POST http://localhost:4000/api/map/locations/LOCATION_ID/geocode \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+

Solution 4: Manually verify on map

+

In LocationsPage: +1. Click location row +2. View on map +3. If wrong, manually drag marker to correct location +4. Save

+

Solution 5: Flag for review

+
# Mark low-confidence results for manual review
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT id, address, \"geocodingConfidence\"
+      FROM \"Location\"
+      WHERE \"geocodingConfidence\" = 'low'
+      ORDER BY \"createdAt\" DESC
+      LIMIT 50;"
+
+

Prevention

+
    +
  • Confidence tracking - Store confidence score
  • +
  • Manual review queue - Review low-confidence results
  • +
  • Address validation - Validate format before geocoding
  • +
  • Postal code priority - Use postal code when available
  • +
+
+

Rate Limit Exceeded

+

Severity: 🟡 Medium

+

Symptoms

+
ERROR Geocoding rate limit exceeded for provider: google
+WARN Retrying with next provider: mapbox
+
+

Or:

+
ERROR 429 Too Many Requests from https://maps.googleapis.com/
+
+

Common Causes

+
    +
  1. Bulk import - Geocoding thousands of addresses at once
  2. +
  3. No API key - Free tier has lower limits
  4. +
  5. Shared IP - Multiple users on same IP
  6. +
  7. Testing - Repeated manual geocodes
  8. +
+

Solutions

+

Solution 1: Check rate limits

+

Per-provider limits:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ProviderFree TierWith API Key
Nominatim1/secN/A
GoogleN/A50/sec (or paid limit)
MapboxN/A600/min
ArcGIS1000/dayVaries
PhotonUnlimitedN/A
HEREN/AVaries by plan
+

Solution 2: Use geocoding queue

+

For bulk operations:

+
# Queue all ungeocoded locations
+curl -X POST http://localhost:4000/api/map/locations/queue-geocoding \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"batchSize": 100}'
+
+# Queue processes at rate-limit-safe speed
+
+

Solution 3: Add API keys

+
# In .env
+GOOGLE_GEOCODING_API_KEY=your-key-here
+MAPBOX_API_KEY=your-key-here
+
+# Restart API
+docker compose restart api
+
+

Solution 4: Distribute across providers

+
# Check provider usage
+curl http://localhost:4000/api/map/geocoding/stats \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# If one provider is overused, system auto-rotates to others
+
+

Solution 5: Wait and retry

+
# Wait for rate limit window to reset
+# Nominatim: 1 second
+# Google: Check quota reset time
+# Mapbox: 1 minute
+
+# Retry failed geocodes
+curl -X POST http://localhost:4000/api/map/locations/retry-failed \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+

Prevention

+
    +
  • API keys - Use paid tiers for higher limits
  • +
  • Queue system - Respect rate limits automatically
  • +
  • Provider rotation - Distribute load
  • +
  • Monitor usage - Alert when approaching limits
  • +
+
+

Map Display Issues

+

Map Not Loading

+

Severity: 🟠 High

+

Symptoms

+

Map container shows blank white/gray box. No tiles loaded.

+

Browser console: +

Error loading tile: https://tile.openstreetmap.org/...
+Failed to load resource: net::ERR_BLOCKED_BY_CLIENT
+

+

Common Causes

+
    +
  1. Ad blocker - Blocking OSM tile requests
  2. +
  3. Network issue - Can't reach tile server
  4. +
  5. CSP headers - Content Security Policy blocking
  6. +
  7. Leaflet CSS missing - Styles not imported
  8. +
+

Solutions

+

Solution 1: Disable ad blocker

+
    +
  1. Disable ad blocker for your site
  2. +
  3. Or whitelist *.openstreetmap.org
  4. +
  5. Refresh page
  6. +
+

Solution 2: Check network

+
# Test tile server
+curl -I https://tile.openstreetmap.org/0/0/0.png
+
+# Should return 200 OK
+# If fails, network or DNS issue
+
+

Solution 3: Verify Leaflet CSS

+

In map component file:

+
// Must import Leaflet CSS
+import 'leaflet/dist/leaflet.css';
+
+

Check in browser DevTools: +- Elements tab → Check if .leaflet-container has styles +- Network tab → Check if leaflet.css loaded

+

Solution 4: Check CSP headers

+

In nginx/conf.d/default.conf:

+
# Allow OSM tiles
+add_header Content-Security-Policy "... img-src 'self' data: https://*.openstreetmap.org;";
+
+

Solution 5: Try alternative tile provider

+
// In map component
+<TileLayer
+  attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
+  url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
+  // Or try Carto:
+  // url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"
+/>
+
+

Prevention

+
    +
  • Ad blocker warning - Detect and show warning
  • +
  • Fallback tiles - Multiple tile providers
  • +
  • Error boundaries - Catch map loading errors
  • +
  • Clear documentation - Document ad blocker issue
  • +
+
+

Markers Not Appearing

+

Severity: 🟡 Medium

+

Symptoms

+

Map loads but location markers don't appear.

+

Common Causes

+
    +
  1. No data - No locations fetched
  2. +
  3. Null coordinates - Locations not geocoded
  4. +
  5. Out of bounds - Markers outside map view
  6. +
  7. Rendering error - React component error
  8. +
+

Solutions

+

Solution 1: Check data loaded

+
// In browser console
+console.log('Locations:', locations);
+
+// Should show array of locations with lat/lng
+// If empty or undefined, data not loaded
+
+

Solution 2: Verify coordinates

+
# Check locations have coordinates
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT COUNT(*) FROM \"Location\" WHERE latitude IS NOT NULL AND longitude IS NOT NULL;"
+
+# If 0, no locations geocoded
+
+

Solution 3: Zoom to markers

+
// In map component, fit bounds to markers
+useEffect(() => {
+  if (locations.length > 0 && mapRef.current) {
+    const bounds = locations
+      .filter(l => l.latitude && l.longitude)
+      .map(l => [l.latitude, l.longitude]);
+
+    if (bounds.length > 0) {
+      mapRef.current.fitBounds(bounds, { padding: [50, 50] });
+    }
+  }
+}, [locations]);
+
+

Solution 4: Check marker rendering

+
// Verify CircleMarker component
+{locations.map((location) => {
+  if (!location.latitude || !location.longitude) return null;
+
+  return (
+    <CircleMarker
+      key={location.id}
+      center={[location.latitude, location.longitude]}
+      radius={8}
+      // ...
+    />
+  );
+})}
+
+

Solution 5: Check browser console

+

Look for React errors: +

Warning: Each child in a list should have a unique "key" prop
+Error: Invalid latitude/longitude
+

+

Prevention

+
    +
  • Data validation - Ensure data has coordinates
  • +
  • Error boundaries - Catch rendering errors
  • +
  • Loading states - Show loading while fetching
  • +
  • Empty states - Show message if no data
  • +
+
+

Cuts Not Rendering

+

Severity: 🟡 Medium

+

Symptoms

+

Cut polygons don't appear on map.

+

Common Causes

+
    +
  1. Invalid GeoJSON - Malformed polygon data
  2. +
  3. Wrong coordinate order - GeoJSON uses [lng, lat], Leaflet uses [lat, lng]
  4. +
  5. Self-intersecting polygon - Invalid polygon geometry
  6. +
  7. Out of bounds - Polygon outside map view
  8. +
+

Solutions

+

Solution 1: Validate GeoJSON

+
# Check cut geometry
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT id, name, ST_AsGeoJSON(geometry) FROM \"Cut\" WHERE id = 'CUT_ID';"
+
+# Verify format:
+# {
+#   "type": "Polygon",
+#   "coordinates": [[[lng1, lat1], [lng2, lat2], ...]]
+# }
+
+

Solution 2: Convert coordinates

+
// GeoJSON uses [lng, lat]
+const geojson = {
+  type: 'Polygon',
+  coordinates: [[[-79.38, 43.65], [-79.37, 43.65], ...]]
+};
+
+// Convert to Leaflet [lat, lng]
+const leafletCoords = geojson.coordinates[0].map(([lng, lat]) => [lat, lng]);
+
+

Solution 3: Check for self-intersection

+
-- Validate polygon geometry
+SELECT id, name, ST_IsValid(geometry) as is_valid
+FROM "Cut"
+WHERE NOT ST_IsValid(geometry);
+
+-- If invalid, show reason
+SELECT id, name, ST_IsValidReason(geometry)
+FROM "Cut"
+WHERE NOT ST_IsValid(geometry);
+
+-- Fix with buffer(0)
+UPDATE "Cut"
+SET geometry = ST_Buffer(geometry, 0)
+WHERE NOT ST_IsValid(geometry);
+
+

Solution 4: Zoom to cut

+
// Fit map to cut bounds
+useEffect(() => {
+  if (cut?.geometry && mapRef.current) {
+    const coords = cut.geometry.coordinates[0].map(([lng, lat]) => [lat, lng]);
+    const bounds = L.latLngBounds(coords);
+    mapRef.current.fitBounds(bounds, { padding: [50, 50] });
+  }
+}, [cut]);
+
+

Solution 5: Check Polygon component

+
// Verify Polygon rendering
+<Polygon
+  positions={coords}  // Array of [lat, lng]
+  pathOptions={{
+    color: '#3498db',
+    fillColor: '#3498db',
+    fillOpacity: 0.2
+  }}
+/>
+
+

Prevention

+
    +
  • Geometry validation - Validate on save
  • +
  • Drawing tools - Use validated drawing library
  • +
  • Import validation - Check imported geometries
  • +
  • Error handling - Gracefully handle invalid geometries
  • +
+
+

GPS Not Working

+

Severity: 🟡 Medium

+

Symptoms

+

Geolocate button doesn't work or shows error.

+

Browser shows permission prompt but location never loads.

+

Common Causes

+
    +
  1. HTTPS required - Geolocation API requires HTTPS (or localhost)
  2. +
  3. Permission denied - User denied location permission
  4. +
  5. GPS unavailable - Device has no GPS
  6. +
  7. Browser doesn't support - Old browser
  8. +
+

Solutions

+

Solution 1: Check HTTPS

+

Geolocation API requires: +- HTTPS (https://) +- OR localhost (http://localhost) +- OR 127.0.0.1 (http://127.0.0.1)

+
# In production, ensure HTTPS
+# Via Pangolin tunnel or Cloudflare
+
+

Solution 2: Grant permission

+
    +
  1. Click lock icon in address bar
  2. +
  3. Location → Allow
  4. +
  5. Refresh page
  6. +
  7. Try geolocate again
  8. +
+

Solution 3: Test geolocation API

+
// In browser console
+navigator.geolocation.getCurrentPosition(
+  (pos) => console.log('Location:', pos.coords),
+  (err) => console.error('Error:', err)
+);
+
+// Errors:
+// PERMISSION_DENIED - User denied
+// POSITION_UNAVAILABLE - GPS unavailable
+// TIMEOUT - Taking too long
+
+

Solution 4: Increase timeout

+
// In geolocate code
+navigator.geolocation.getCurrentPosition(
+  successCallback,
+  errorCallback,
+  {
+    timeout: 10000,  // 10 seconds (default: 5000)
+    enableHighAccuracy: true,
+    maximumAge: 0
+  }
+);
+
+

Solution 5: Fallback to IP geolocation

+
// If GPS fails, use IP-based location
+const fallbackLocation = async () => {
+  const response = await fetch('https://ipapi.co/json/');
+  const data = await response.json();
+  return {
+    latitude: data.latitude,
+    longitude: data.longitude
+  };
+};
+
+

Prevention

+
    +
  • HTTPS in production - Use secure connection
  • +
  • Permission prompts - Clear instructions
  • +
  • Fallback options - IP geolocation as backup
  • +
  • Error handling - User-friendly error messages
  • +
+
+

Coordinate Issues

+

Invalid Lat/Lng

+

Severity: 🟡 Medium

+

Symptoms

+
Error: Invalid latitude/longitude values
+
+

Or markers appear in wrong location (ocean, wrong country).

+

Common Causes

+
    +
  1. Swapped coordinates - Latitude and longitude reversed
  2. +
  3. Out of range - Latitude > 90 or Longitude > 180
  4. +
  5. Wrong sign - Positive instead of negative (or vice versa)
  6. +
  7. Decimal precision - Too many/few decimal places
  8. +
+

Solutions

+

Solution 1: Validate ranges

+

Valid ranges: +- Latitude: -90 to 90 +- Longitude: -180 to 180

+
# Find invalid coordinates
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT id, address, latitude, longitude
+      FROM \"Location\"
+      WHERE latitude < -90 OR latitude > 90
+         OR longitude < -180 OR longitude > 180;"
+
+

Solution 2: Check coordinate order

+
# Common mistake: swapped lat/lng
+# Toronto should be:
+# Latitude: 43.65 (positive, North)
+# Longitude: -79.38 (negative, West)
+
+# If showing as 79.38, -43.65, they're swapped
+
+# Fix:
+UPDATE "Location"
+SET latitude = longitude, longitude = latitude
+WHERE id = 'LOCATION_ID';
+
+

Solution 3: Verify hemisphere

+

For North American locations: +- Latitude: Positive (North) +- Longitude: Negative (West)

+
# If US/Canada location has positive longitude, wrong sign
+UPDATE "Location"
+SET longitude = longitude * -1
+WHERE country = 'Canada' AND longitude > 0;
+
+

Solution 4: Check decimal precision

+
# Good precision (6 decimals ≈ 0.1m accuracy):
+Latitude: 43.651234
+Longitude: -79.381234
+
+# Too few decimals (imprecise):
+Latitude: 43.65
+Longitude: -79.38
+
+# Too many decimals (unnecessary):
+Latitude: 43.651234567890
+Longitude: -79.381234567890
+
+

Solution 5: Visual verification

+
    +
  1. Open Google Maps
  2. +
  3. Enter coordinates: 43.651234, -79.381234
  4. +
  5. Verify location matches address
  6. +
  7. If wrong, get correct coordinates from Google Maps
  8. +
+

Prevention

+
    +
  • Coordinate validation - Check ranges before save
  • +
  • Visual preview - Show on map before save
  • +
  • Import validation - Validate imported coordinates
  • +
  • Decimal precision - Round to 6 decimals
  • +
+
+

Out of Bounds Coordinates

+

Severity: 🟢 Low

+

Symptoms

+

Markers appear outside expected area (different country/continent).

+

Solutions

+

Solution 1: Set map bounds

+
// Limit map to expected region
+const bounds = L.latLngBounds(
+  [41.0, -95.0],  // Southwest corner
+  [50.0, -74.0]   // Northeast corner (covers eastern Canada/US)
+);
+
+<MapContainer
+  maxBounds={bounds}
+  maxBoundsViscosity={1.0}
+  // ...
+/>
+
+

Solution 2: Filter locations by bounds

+
// Only show locations in expected region
+const filteredLocations = locations.filter(location => {
+  return location.latitude >= 41 && location.latitude <= 50 &&
+         location.longitude >= -95 && location.longitude <= -74;
+});
+
+
+

Projection Errors (NAR Data)

+

Severity: 🟡 Medium

+

Symptoms

+

Locations imported from NAR data appear in wrong place.

+

Common Causes

+
    +
  1. Wrong projection - NAR uses EPSG:3347 (Lambert), not WGS84
  2. +
  3. Missing conversion - Coordinates not converted to lat/lng
  4. +
  5. Coordinate swap - BG_X and BG_Y reversed
  6. +
+

Solutions

+

Solution 1: Verify NAR import uses proj4

+

In api/src/modules/map/locations/nar-import.service.ts:

+
import proj4 from 'proj4';
+
+// Define EPSG:3347 (NAR projection)
+proj4.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'
+);
+
+// Convert
+const [longitude, latitude] = proj4('EPSG:3347', 'WGS84', [bgX, bgY]);
+
+

Solution 2: Check coordinate order

+

NAR Address files: +- BG_X: Easting (X coordinate in meters) +- BG_Y: Northing (Y coordinate in meters)

+

Conversion order: [BG_X, BG_Y][longitude, latitude]

+

Solution 3: Verify conversion

+
# Test conversion manually
+docker compose exec api node -e "
+const proj4 = require('proj4');
+proj4.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');
+
+// Example Toronto coordinates in EPSG:3347:
+const [lng, lat] = proj4('EPSG:3347', 'WGS84', [6458123, 3534567]);
+console.log('Lat:', lat, 'Lng:', lng);
+// Should be approximately: Lat: 43.65 Lng: -79.38
+"
+
+

Solution 4: Re-import NAR data

+

If imported incorrectly:

+
# Delete bad data
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "DELETE FROM \"Location\" WHERE \"importSource\" = 'NAR';"
+
+# Re-import with correct projection
+# Via admin UI: /app/map/locations → NAR Import tab
+
+

Prevention

+
    +
  • Projection validation - Test conversion on sample data
  • +
  • Visual verification - Show import preview on map
  • +
  • Documentation - Document NAR projection requirements
  • +
  • Import validation - Check coordinates are in expected range
  • +
+
+

Queue Issues

+

Geocoding Queue Stuck

+

Severity: 🟡 Medium

+

Symptoms

+

Locations remain ungeocoded even though queue is running.

+

Queue shows jobs but they never process.

+

Solutions

+

Solution 1: Check queue status

+
# View queue stats
+curl http://localhost:4000/api/map/geocoding/queue/stats \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Shows:
+# {
+#   "waiting": 150,
+#   "active": 0,  # Should be > 0 if processing
+#   "completed": 2500,
+#   "failed": 25
+# }
+
+

Solution 2: Check worker is running

+
# Worker should log processing
+docker compose logs api | grep -i "geocoding worker\|processing geocode"
+
+# Should show:
+# Geocoding worker started
+# Processing geocode job for location: abc-123
+
+

Solution 3: Restart queue worker

+
# Restart API (restarts worker)
+docker compose restart api
+
+# Check worker started
+docker compose logs api | grep "Geocoding worker started"
+
+

Solution 4: Check Redis connection

+
# Test Redis
+docker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD ping
+# Should return: PONG
+
+# Check queue keys
+docker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD keys "bull:geocoding:*"
+
+

Solution 5: Manually process stuck jobs

+
# Retry failed jobs
+curl -X POST http://localhost:4000/api/map/geocoding/queue/retry-failed \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Clean stuck jobs
+curl -X POST http://localhost:4000/api/map/geocoding/queue/clean \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -d '{"status": "failed", "grace": 86400000}'  # Clean failed jobs older than 1 day
+
+

Prevention

+
    +
  • Health checks - Monitor worker health
  • +
  • Dead letter queue - Move repeatedly failed jobs
  • +
  • Alerting - Alert if queue backed up
  • +
  • Auto-restart - Restart worker if stuck
  • +
+
+

Jobs Failing

+

Severity: 🟡 Medium

+

Symptoms

+

Queue shows high failed job count.

+

Locations remain ungeocoded with error status.

+

Solutions

+

Solution 1: View failed jobs

+
# Get failed job details
+curl http://localhost:4000/api/map/geocoding/queue/failed \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Shows:
+# [
+#   {
+#     "id": "123",
+#     "data": { "locationId": "abc", "address": "..." },
+#     "failedReason": "All providers failed",
+#     "attemptsMade": 3
+#   }
+# ]
+
+

Solution 2: Check error patterns

+
# Common failure reasons
+docker compose logs api | grep "Geocoding failed" | sort | uniq -c
+
+# Example output:
+#  45 Geocoding failed: Rate limit exceeded
+#  12 Geocoding failed: No results found
+#   3 Geocoding failed: Network error
+
+

Solution 3: Retry with different settings

+
# Retry with longer timeout
+curl -X POST http://localhost:4000/api/map/locations/LOCATION_ID/geocode \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"timeout": 30000}'  # 30 seconds
+
+

Solution 4: Manual intervention

+

For repeatedly failing addresses:

+
    +
  1. Open LocationsPage
  2. +
  3. Find failed locations
  4. +
  5. Review address (fix typos)
  6. +
  7. Manually set coordinates if needed
  8. +
  9. Or delete if invalid
  10. +
+

Prevention

+
    +
  • Retry logic - Auto-retry with exponential backoff
  • +
  • Error categorization - Permanent vs transient failures
  • +
  • Manual review queue - Flag for manual review after N attempts
  • +
  • Address validation - Validate before geocoding
  • +
+
+

Performance Issues

+

Slow Geocoding

+

Severity: 🟡 Medium

+

Symptoms

+

Geocoding takes 5-10+ seconds per address.

+

Bulk imports very slow.

+

Solutions

+

Solution 1: Use faster providers first

+

Provider speed (fastest to slowest): +1. Google (with API key) - ~200ms +2. Mapbox (with API key) - ~300ms +3. Nominatim - ~500ms +4. ArcGIS - ~800ms +5. Photon - ~1000ms +6. HERE - ~400ms

+

Configure in api/src/modules/map/geocoding/geocoding.service.ts.

+

Solution 2: Increase concurrency

+

In geocoding queue worker:

+
// Increase concurrent geocoding
+const worker = new Worker('geocoding', processor, {
+  concurrency: 5,  // Process 5 at a time (default: 1)
+  limiter: {
+    max: 50,  // Max 50 jobs per second
+    duration: 1000
+  }
+});
+
+

Solution 3: Use bulk geocoding APIs

+

Some providers offer batch geocoding:

+
# Google Batch Geocoding (requires Business plan)
+# Can geocode up to 100 addresses in one request
+
+

Solution 4: Cache results

+
// Cache geocoding results in Redis
+const cacheKey = `geocode:${address}`;
+const cached = await redis.get(cacheKey);
+
+if (cached) {
+  return JSON.parse(cached);
+}
+
+const result = await geocode(address);
+await redis.setex(cacheKey, 86400, JSON.stringify(result));  // Cache 24h
+return result;
+
+

Solution 5: Parallel processing

+
// Geocode multiple addresses in parallel
+const addresses = ['123 Main St', '456 Oak Ave', ...];
+
+const results = await Promise.all(
+  addresses.map(address => geocode(address))
+);
+
+

Prevention

+
    +
  • Queue system - Don't block UI on geocoding
  • +
  • Paid tiers - Faster with API keys
  • +
  • Caching - Cache frequent addresses
  • +
  • Parallel processing - Process multiple at once
  • +
+
+

Too Many API Calls

+

Severity: 🟡 Medium

+

Symptoms

+

High API usage on Google/Mapbox.

+

Approaching or exceeding quota.

+

Solutions

+

Solution 1: Monitor usage

+
# Check geocoding stats
+curl http://localhost:4000/api/map/geocoding/stats \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Track API costs:
+# Google: $5 per 1000 requests (after 40k free/month)
+# Mapbox: $0.50 per 1000 requests (after 100k free/month)
+
+

Solution 2: Use free providers first

+

Reorder provider priority:

+
// In geocodingService.ts
+const providers = [
+  'nominatim',  // Free (try first)
+  'photon',     // Free
+  'arcgis',     // Free (1000/day)
+  'google',     // Paid (use only if others fail)
+  'mapbox',     // Paid
+  'here'        // Paid
+];
+
+

Solution 3: Cache aggressively

+
// Cache geocoding results permanently
+const cacheKey = `geocode:${normalizeAddress(address)}`;
+const cached = await redis.get(cacheKey);
+
+if (cached) {
+  return JSON.parse(cached);
+}
+
+const result = await geocode(address);
+await redis.set(cacheKey, JSON.stringify(result));  // No expiration
+return result;
+
+

Solution 4: Deduplicate requests

+
# Before geocoding, check if address already geocoded
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT latitude, longitude FROM \"Location\"
+      WHERE LOWER(address) = LOWER('123 Main Street, Toronto')
+        AND latitude IS NOT NULL
+      LIMIT 1;"
+
+# If exists, copy coordinates instead of geocoding again
+
+

Solution 5: Set quota alerts

+

In Google Cloud Console: +1. Navigate to Geocoding API +2. Set quota alerts (e.g., 80% of limit) +3. Receive email before exceeding quota

+

Prevention

+
    +
  • Cache everything - Never geocode same address twice
  • +
  • Free providers first - Use paid only as fallback
  • +
  • Quota monitoring - Alert before exceeding
  • +
  • Cost tracking - Monitor API costs monthly
  • +
+
+

Data Quality

+

Duplicate Locations

+

Severity: 🟢 Low

+

Symptoms

+

Same address appears multiple times in locations table.

+

Solutions

+

Solution 1: Find duplicates

+
# Find duplicate addresses
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT address, COUNT(*), array_agg(id)
+      FROM \"Location\"
+      GROUP BY LOWER(address)
+      HAVING COUNT(*) > 1;"
+
+

Solution 2: Merge duplicates

+
# Keep oldest, delete newer
+# (After reassigning foreign keys to kept record)
+DELETE FROM "Location" AS l1
+WHERE EXISTS (
+  SELECT 1 FROM "Location" AS l2
+  WHERE LOWER(l2.address) = LOWER(l1.address)
+    AND l2."createdAt" < l1."createdAt"
+);
+
+

Solution 3: Add unique constraint

+
model Location {
+  id      String @id @default(uuid())
+  address String
+
+  @@unique([address])  // Prevent duplicates
+}
+
+

Prevention

+
    +
  • Unique constraints - Database prevents duplicates
  • +
  • Upsert logic - Update if exists, create if not
  • +
  • Import validation - Check for duplicates before import
  • +
  • Case-insensitive comparison - Normalize before checking
  • +
+
+

Ungeocoded Locations

+

Severity: 🟡 Medium

+

Symptoms

+

Many locations with null latitude/longitude.

+

Solutions

+

Solution 1: Count ungeocoded

+
docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT COUNT(*) FROM \"Location\" WHERE latitude IS NULL;"
+
+

Solution 2: Queue all ungeocoded

+
# Via API
+curl -X POST http://localhost:4000/api/map/locations/queue-geocoding \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Queues all locations with null coordinates
+
+

Solution 3: View on Data Quality Dashboard

+

Navigate to /app/map/data-quality:

+
    +
  • Shows geocoding rate
  • +
  • Lists ungeocoded locations
  • +
  • Allows bulk geocoding
  • +
+

Solution 4: Export ungeocoded for manual review

+
# Export to CSV
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "COPY (SELECT id, address, city, \"postalCode\" FROM \"Location\"
+            WHERE latitude IS NULL) TO STDOUT WITH CSV HEADER" > ungeocoded.csv
+
+

Prevention

+
    +
  • Geocode on create - Auto-geocode new locations
  • +
  • Required coordinates - Don't allow creating without geocoding
  • +
  • Dashboard monitoring - Track geocoding rate
  • +
  • Regular cleanup - Periodic geocoding of ungeocoded
  • +
+
+

Useful Commands

+

Geocoding Operations

+
# Geocode single location
+curl -X POST http://localhost:4000/api/map/locations/LOCATION_ID/geocode \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Bulk geocode via queue
+curl -X POST http://localhost:4000/api/map/locations/queue-geocoding \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Check geocoding stats
+curl http://localhost:4000/api/map/geocoding/stats \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# Retry failed geocodes
+curl -X POST http://localhost:4000/api/map/geocoding/queue/retry-failed \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+

Database Queries

+
# Count by geocoding status
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT
+        COUNT(*) FILTER (WHERE latitude IS NOT NULL) as geocoded,
+        COUNT(*) FILTER (WHERE latitude IS NULL) as ungeocoded
+      FROM \"Location\";"
+
+# List ungeocoded
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT id, address FROM \"Location\"
+      WHERE latitude IS NULL
+      LIMIT 50;"
+
+# Geocoding provider stats
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \
+  -c "SELECT \"geocodingProvider\", COUNT(*)
+      FROM \"Location\"
+      WHERE \"geocodingProvider\" IS NOT NULL
+      GROUP BY \"geocodingProvider\";"
+
+
+ +

Geocoding Documentation

+ +

Other Troubleshooting

+ +

External Resources

+ +
+

Last Updated: February 2026 +Version: V2.0 +Status: Complete

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/troubleshooting/index.html b/mkdocs/site/v2/troubleshooting/index.html new file mode 100644 index 00000000..05542eb7 --- /dev/null +++ b/mkdocs/site/v2/troubleshooting/index.html @@ -0,0 +1,5159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Troubleshooting Guide - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Troubleshooting Guide

+

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.

+

Quick Reference

+

Common Errors

+

Frequently encountered error messages:

+
    +
  • Error codes and meanings
  • +
  • Stack trace interpretation
  • +
  • Quick fixes
  • +
  • When to escalate
  • +
+

FAQ

+

Frequently asked questions:

+
    +
  • Installation questions
  • +
  • Configuration questions
  • +
  • Feature questions
  • +
  • Troubleshooting tips
  • +
+

Docker Issues

+

Container and orchestration problems:

+
    +
  • Container won't start
  • +
  • Port conflicts
  • +
  • Volume permission errors
  • +
  • Network connectivity
  • +
  • Resource constraints
  • +
+

Database Issues

+

PostgreSQL and Prisma problems:

+
    +
  • Connection errors
  • +
  • Migration failures
  • +
  • Query performance
  • +
  • Data corruption
  • +
  • Backup/restore issues
  • +
+

Authentication Issues

+

Login and permission problems:

+
    +
  • Can't log in
  • +
  • Token expired
  • +
  • Invalid credentials
  • +
  • Role permission denied
  • +
  • Session management
  • +
+

Email Issues

+

Email delivery problems:

+
    +
  • SMTP connection failed
  • +
  • Emails not sending
  • +
  • Queue backed up
  • +
  • Template errors
  • +
  • Test mode not working
  • +
+

Geocoding Issues

+

Address geocoding problems:

+
    +
  • Geocoding fails
  • +
  • Wrong coordinates
  • +
  • Provider errors
  • +
  • Rate limiting
  • +
  • Bulk geocoding stuck
  • +
+

Monitoring Issues

+

Observability and metrics problems:

+
    +
  • Prometheus not scraping
  • +
  • Grafana dashboard errors
  • +
  • Alert not firing
  • +
  • Metrics missing
  • +
  • Service health incorrect
  • +
+

Performance Optimization

+

Speed and efficiency improvements:

+
    +
  • Slow API responses
  • +
  • Database query optimization
  • +
  • Frontend performance
  • +
  • Cache optimization
  • +
  • Resource usage
  • +
+

Common Issues

+

Installation Problems

+

Symptom: Docker containers fail to start

+

Common Causes: +- Port conflicts +- Missing environment variables +- Insufficient resources +- Corrupted volumes

+

Solutions: +1. Check port availability: netstat -tulpn | grep <port> +2. Verify .env file exists and is complete +3. Increase Docker memory/CPU limits +4. Remove volumes: docker compose down -v

+
+

Symptom: Database migration fails

+

Common Causes: +- Database not running +- Connection string incorrect +- Migration conflict +- Permission issues

+

Solutions: +1. Verify PostgreSQL is running: docker compose ps +2. Check DATABASE_URL in .env +3. Reset database (dev only): npx prisma migrate reset +4. Check user permissions

+
+

Symptom: "Cannot connect to Redis"

+

Common Causes: +- Redis not started +- Wrong password +- Port conflict +- Network issue

+

Solutions: +1. Start Redis: docker compose up -d redis +2. Verify REDIS_PASSWORD matches in all services +3. Check port 6379 not in use +4. Test connection: docker compose exec redis redis-cli ping

+

Runtime Problems

+

Symptom: API returns 500 errors

+

Common Causes: +- Unhandled exception +- Database query error +- Service unavailable +- Configuration issue

+

Solutions: +1. Check API logs: docker compose logs -f api +2. Review error stack trace +3. Test database connection +4. Verify environment variables

+
+

Symptom: Frontend shows blank page

+

Common Causes: +- Build error +- API not reachable +- CORS issue +- JavaScript error

+

Solutions: +1. Check browser console (F12) +2. Verify VITE_API_URL in .env +3. Check nginx CORS headers +4. Rebuild admin: docker compose build admin

+
+

Symptom: Emails not sending

+

Common Causes: +- SMTP credentials wrong +- Test mode enabled +- Queue worker not running +- Network blocked

+

Solutions: +1. Check EMAIL_TEST_MODE setting +2. Verify SMTP settings in .env +3. Check email queue: docker compose logs -f api | grep email +4. Test with MailHog (port 8025)

+

Configuration Issues

+

Symptom: Subdomain routing not working

+

Common Causes: +- Nginx config error +- DNS not set up +- Tunnel not configured +- Certificate issue

+

Solutions: +1. Check nginx config: docker compose exec nginx nginx -t +2. Verify DNS records +3. Review tunnel status in Pangolin page +4. Check SSL certificate validity

+
+

Symptom: Feature not working (media, listmonk, etc.)

+

Common Causes: +- Feature flag disabled +- Service not started +- API credentials missing +- Integration not configured

+

Solutions: +1. Check feature flag in .env (e.g., ENABLE_MEDIA_FEATURES) +2. Start required services: docker compose up -d <service> +3. Verify API keys/credentials +4. Complete setup wizard in admin

+

Diagnostic Commands

+

Check Service Status

+
# All services
+docker compose ps
+
+# Specific service
+docker compose ps api
+
+# Service logs
+docker compose logs -f api
+docker compose logs --tail=100 v2-postgres
+
+

Test Connectivity

+
# API health check
+curl http://localhost:4000/health
+
+# Database connection
+docker compose exec api npx prisma db execute --stdin <<< "SELECT 1"
+
+# Redis connection
+docker compose exec redis redis-cli ping
+
+

Database Diagnostics

+
# Open Prisma Studio
+cd api && npx prisma studio
+
+# Check migrations
+cd api && npx prisma migrate status
+
+# View database
+docker compose exec v2-postgres psql -U changemaker -d changemaker_v2
+
+

View Logs

+
# All services
+docker compose logs -f
+
+# Specific service
+docker compose logs -f api
+docker compose logs -f admin
+
+# Error logs only
+docker compose logs -f | grep ERROR
+
+

Resource Usage

+
# Docker stats
+docker stats
+
+# Disk usage
+docker system df
+
+# Container resource limits
+docker compose config | grep mem_limit
+
+

Error Message Reference

+

Database Errors

+

P2002: Unique constraint failed
+
+Cause: Duplicate value for unique field (email, slug, etc.) +Fix: Use different value or update existing record

+

P2025: Record not found
+
+Cause: Trying to access non-existent record +Fix: Verify ID exists, check deletion

+

P2021: Table does not exist
+
+Cause: Missing migration +Fix: Run npx prisma migrate deploy

+

API Errors

+

401 Unauthorized
+
+Cause: Missing/invalid JWT token +Fix: Login again, check token expiration

+

403 Forbidden
+
+Cause: Insufficient permissions +Fix: Check user role, verify RBAC middleware

+

429 Too Many Requests
+
+Cause: Rate limit exceeded +Fix: Wait, reduce request frequency

+

Docker Errors

+

port is already allocated
+
+Cause: Port conflict +Fix: Stop conflicting service, change port in docker-compose.yml

+

no space left on device
+
+Cause: Disk full +Fix: Clean up: docker system prune -a

+

network not found
+
+Cause: Docker network missing +Fix: Recreate: docker compose down && docker compose up -d

+

When to Get Help

+

Escalate to GitHub issues if:

+
    +
  • Error persists after troubleshooting
  • +
  • Data corruption or loss
  • +
  • Security vulnerability discovered
  • +
  • Bug in core functionality
  • +
  • Documentation unclear
  • +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/troubleshooting/monitoring-issues/index.html b/mkdocs/site/v2/troubleshooting/monitoring-issues/index.html new file mode 100644 index 00000000..f8457874 --- /dev/null +++ b/mkdocs/site/v2/troubleshooting/monitoring-issues/index.html @@ -0,0 +1,7333 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Monitoring Issues - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Monitoring and Observability Issues

+

This guide covers Prometheus, Grafana, and observability stack problems in Changemaker Lite V2.

+

Overview

+

Monitoring Stack

+

Changemaker Lite V2 uses profile-based monitoring (optional):

+
# Start with monitoring
+docker compose --profile monitoring up -d
+
+

Components:

+
    +
  • Prometheus - Metrics collection and storage (port 9090)
  • +
  • Grafana - Metrics visualization (port 3001)
  • +
  • Alertmanager - Alert routing and notification (port 9093)
  • +
  • cAdvisor - Container metrics (port 8080)
  • +
  • Node Exporter - Host metrics (port 9100)
  • +
  • Redis Exporter - Redis metrics (port 9121)
  • +
+

Custom Metrics

+

12 custom cm_* Prometheus metrics:

+
    +
  1. cm_api_uptime_seconds - API uptime
  2. +
  3. cm_database_uptime_seconds - Database uptime
  4. +
  5. cm_email_queue_size - Email queue depth
  6. +
  7. cm_geocoding_queue_size - Geocoding queue depth
  8. +
  9. cm_users_total - Total users
  10. +
  11. cm_campaigns_total - Total campaigns
  12. +
  13. cm_locations_total - Total locations
  14. +
  15. cm_geocoded_locations_total - Geocoded locations
  16. +
  17. cm_active_canvass_sessions - Active sessions
  18. +
  19. cm_external_service_up - Service health (0/1)
  20. +
  21. cm_listmonk_subscribers_total - Listmonk subscribers
  22. +
  23. cm_media_videos_total - Total videos
  24. +
+

Plus standard HTTP metrics: +- http_request_duration_seconds +- http_requests_total

+
+

Prometheus Not Scraping

+

Target Down

+

Severity: 🔴 Critical

+

Symptoms

+

Prometheus UI (localhost:9090) shows targets as "DOWN":

+
Target: api (localhost:4000/metrics)
+State: DOWN
+Error: Get "http://api:4000/metrics": connection refused
+
+

No data in Grafana dashboards.

+

Common Causes

+
    +
  1. Service not running - API container stopped
  2. +
  3. Metrics endpoint missing - /metrics endpoint not registered
  4. +
  5. Network issue - Prometheus can't reach service
  6. +
  7. Authentication required - Metrics endpoint requires auth
  8. +
+

Solutions

+

Solution 1: Check service is running

+
# Is API running?
+docker compose ps api
+
+# Should show "Up"
+# If not:
+docker compose up -d api
+
+

Solution 2: Test metrics endpoint

+
# From host
+curl http://localhost:4000/metrics
+
+# Should return Prometheus metrics:
+# # HELP cm_api_uptime_seconds API uptime in seconds
+# # TYPE cm_api_uptime_seconds gauge
+# cm_api_uptime_seconds 123.45
+
+# From Prometheus container
+docker compose exec prometheus wget -O- http://api:4000/metrics
+
+

Solution 3: Check Prometheus config

+

In configs/prometheus/prometheus.yml:

+
scrape_configs:
+  - job_name: 'api'
+    static_configs:
+      - targets: ['api:4000']  # Use service name, not localhost
+
+

Solution 4: Verify network

+
# Both on same network?
+docker inspect changemaker-lite-prometheus-1 | grep NetworkMode
+docker inspect changemaker-lite-api-1 | grep NetworkMode
+
+# Should both show "changemaker-lite"
+
+

Solution 5: Check metrics are registered

+

In API logs:

+
docker compose logs api | grep -i "metrics\|prometheus"
+
+# Should show:
+# Metrics endpoint registered at /metrics
+# Prometheus metrics initialized
+
+

Prevention

+
    +
  • Health checks - Monitor Prometheus target health
  • +
  • Service dependencies - Ensure services start in order
  • +
  • Network config - Use Docker service names
  • +
  • Testing - Test /metrics endpoint on deploy
  • +
+
+

Scrape Timeout

+

Severity: 🟡 Medium

+

Symptoms

+
Target: api
+State: UP
+Last Scrape: 5.2s (slow)
+Last Error: context deadline exceeded
+
+

Scrapes taking too long or timing out.

+

Solutions

+

Solution 1: Increase scrape timeout

+

In configs/prometheus/prometheus.yml:

+
global:
+  scrape_interval: 15s
+  scrape_timeout: 10s  # Increase from 10s to 30s
+
+scrape_configs:
+  - job_name: 'api'
+    scrape_interval: 30s  # Scrape less frequently
+    scrape_timeout: 20s
+    static_configs:
+      - targets: ['api:4000']
+
+

Reload config:

+
# Reload Prometheus config
+docker compose exec prometheus kill -HUP 1
+
+# Or restart
+docker compose restart prometheus
+
+

Solution 2: Optimize metrics generation

+
// In api/src/utils/metrics.ts
+// Cache expensive metrics
+let cachedUserCount = 0;
+let lastUserCountUpdate = 0;
+
+register.registerMetric(new Gauge({
+  name: 'cm_users_total',
+  help: 'Total number of users',
+  async collect() {
+    const now = Date.now();
+    // Only query database every 60 seconds
+    if (now - lastUserCountUpdate > 60000) {
+      cachedUserCount = await prisma.user.count();
+      lastUserCountUpdate = now;
+    }
+    this.set(cachedUserCount);
+  }
+}));
+
+

Solution 3: Reduce metric cardinality

+
// Bad - high cardinality (creates metric per user)
+new Counter({
+  name: 'requests_by_user',
+  labelNames: ['userId']  // Don't do this!
+});
+
+// Good - low cardinality
+new Counter({
+  name: 'requests_by_role',
+  labelNames: ['role']  // Only 5 roles
+});
+
+

Prevention

+
    +
  • Cache expensive metrics - Don't query DB on every scrape
  • +
  • Reasonable timeouts - 10-30s timeouts
  • +
  • Low cardinality - Avoid high-cardinality labels
  • +
  • Optimize queries - Fast metric queries
  • +
+
+

Authentication Errors

+

Severity: 🟡 Medium

+

Symptoms

+
Error: 401 Unauthorized when scraping /metrics
+
+

Solutions

+

Changemaker Lite V2 metrics endpoint is public (no auth required).

+

If you see auth errors:

+

Solution 1: Remove auth middleware from /metrics

+

In api/src/server.ts:

+
// Metrics endpoint should be BEFORE authenticate middleware
+app.get('/metrics', async (req, res) => {
+  res.set('Content-Type', register.contentType);
+  res.end(await register.metrics());
+});
+
+// Auth middleware comes after
+app.use(authenticate);
+
+

Solution 2: Configure basic auth in Prometheus

+

If you DO want to protect /metrics:

+

In configs/prometheus/prometheus.yml:

+
scrape_configs:
+  - job_name: 'api'
+    static_configs:
+      - targets: ['api:4000']
+    basic_auth:
+      username: 'prometheus'
+      password: 'your-password'
+
+

Prevention

+
    +
  • Public metrics - Keep /metrics public for simplicity
  • +
  • Network isolation - Use Docker networks for security
  • +
  • IP whitelist - Only allow Prometheus IP
  • +
+
+

Grafana Issues

+

Dashboards Not Loading

+

Severity: 🟠 High

+

Symptoms

+

Grafana shows blank dashboards or "No data" panels.

+

Solutions

+

Solution 1: Check Grafana is running

+
docker compose --profile monitoring ps grafana
+
+# Should show "Up"
+# If not:
+docker compose --profile monitoring up -d grafana
+
+

Solution 2: Verify Prometheus datasource

+
    +
  1. Open Grafana: http://localhost:3001
  2. +
  3. Login (admin/admin)
  4. +
  5. Settings → Data Sources
  6. +
  7. Click Prometheus
  8. +
  9. URL should be: http://prometheus:9090
  10. +
  11. Click "Save & Test"
  12. +
  13. Should show "Data source is working"
  14. +
+

Solution 3: Check dashboard provisioning

+
# List provisioned dashboards
+docker compose exec grafana ls -la /etc/grafana/provisioning/dashboards/
+
+# Should show:
+# dashboard-provider.yml
+# changemaker-api.json
+# changemaker-queue.json
+# changemaker-external-services.json
+
+

Solution 4: Import dashboard manually

+

If auto-provisioning fails:

+
    +
  1. Grafana → Dashboards → Import
  2. +
  3. Upload JSON from configs/grafana/dashboards/
  4. +
  5. Select Prometheus datasource
  6. +
  7. Click Import
  8. +
+

Solution 5: Check for data

+
# Test query in Grafana Explore
+# Query: cm_api_uptime_seconds
+
+# Or test in Prometheus:
+curl 'http://localhost:9090/api/v1/query?query=cm_api_uptime_seconds'
+
+

Prevention

+
    +
  • Dashboard versioning - Keep dashboards in git
  • +
  • Auto-provisioning - Use provisioning instead of manual import
  • +
  • Testing - Test dashboards after changes
  • +
  • Documentation - Document dashboard variables
  • +
+
+

Datasource Errors

+

Severity: 🟠 High

+

Symptoms

+
Error: Failed to query Prometheus
+Error: connection refused
+
+

Red error bars on Grafana panels.

+

Solutions

+

Solution 1: Test Prometheus connection

+
# From Grafana container
+docker compose exec grafana wget -O- http://prometheus:9090/api/v1/query?query=up
+
+# Should return JSON:
+# {"status":"success","data":{"resultType":"vector","result":[...]}}
+
+

Solution 2: Check Prometheus is running

+
docker compose --profile monitoring ps prometheus
+
+# Should show "Up"
+
+

Solution 3: Verify datasource URL

+

In Grafana datasource settings: +- URL: http://prometheus:9090 (NOT http://localhost:9090) +- Access: Server (NOT Browser)

+

Solution 4: Check Docker network

+
# Same network?
+docker inspect changemaker-lite-grafana-1 | grep NetworkMode
+docker inspect changemaker-lite-prometheus-1 | grep NetworkMode
+
+

Prevention

+
    +
  • Health checks - Monitor datasource health
  • +
  • Service dependencies - Start Prometheus before Grafana
  • +
  • Error handling - Graceful error messages
  • +
+
+

Query Errors

+

Severity: 🟡 Medium

+

Symptoms

+
Error executing query: parse error at char X: unexpected identifier
+
+

Panel shows "Error loading data".

+

Solutions

+

Solution 1: Validate PromQL syntax

+

Common errors:

+
# Bad - missing {}
+cm_api_uptime_seconds{job=api}
+
+# Good
+cm_api_uptime_seconds{job="api"}
+
+# Bad - wrong function
+average(cm_api_uptime_seconds)
+
+# Good
+avg(cm_api_uptime_seconds)
+
+

Solution 2: Test query in Explore

+
    +
  1. Grafana → Explore
  2. +
  3. Enter query
  4. +
  5. Run
  6. +
  7. Fix errors before adding to dashboard
  8. +
+

Solution 3: Check metric exists

+
# List all metrics
+curl http://localhost:9090/api/v1/label/__name__/values | jq
+
+# Search for metric
+curl http://localhost:9090/api/v1/label/__name__/values | jq '.data[]' | grep cm_
+
+

Solution 4: Use metric browser

+

In Grafana query editor: +1. Click "Metrics" button +2. Browse available metrics +3. Select metric (auto-fills query)

+

Prevention

+
    +
  • Query validation - Validate before saving
  • +
  • Testing - Test queries in Explore
  • +
  • Documentation - Document available metrics
  • +
  • Examples - Provide query examples
  • +
+
+

Alertmanager Issues

+

Alerts Not Firing

+

Severity: 🟠 High

+

Symptoms

+

Conditions met but alert not triggering.

+

Solutions

+

Solution 1: Check alert rules

+

In Prometheus UI (localhost:9090):

+
    +
  1. Click "Alerts"
  2. +
  3. Find your alert
  4. +
  5. Check state:
  6. +
  7. Inactive: Condition not met
  8. +
  9. Pending: Met but waiting for for: duration
  10. +
  11. Firing: Alert active
  12. +
+

Solution 2: Verify alert rule syntax

+

In configs/prometheus/alerts.yml:

+
groups:
+  - name: changemaker_alerts
+    interval: 30s
+    rules:
+      - alert: APIDown
+        expr: up{job="api"} == 0
+        for: 1m  # Must be down for 1 minute before firing
+        labels:
+          severity: critical
+        annotations:
+          summary: "API is down"
+          description: "API has been down for 1 minute"
+
+

Solution 3: Check Alertmanager config

+
# Test Alertmanager
+curl http://localhost:9093/api/v1/alerts
+
+# Should return alert list
+
+

Solution 4: View Prometheus logs

+
docker compose logs prometheus | grep -i alert
+
+# Shows:
+# Loaded alert rules
+# Alert X is firing
+
+

Solution 5: Reload alert rules

+
# Reload Prometheus config
+docker compose exec prometheus kill -HUP 1
+
+# Check rules loaded
+curl http://localhost:9090/api/v1/rules
+
+

Prevention

+
    +
  • Test alert conditions - Trigger manually to test
  • +
  • Reasonable thresholds - Not too sensitive or too lenient
  • +
  • Documentation - Document alert thresholds
  • +
  • Regular review - Review alert effectiveness
  • +
+
+

Notifications Not Sent

+

Severity: 🟡 Medium

+

Symptoms

+

Alert firing in Prometheus but no notification received.

+

Solutions

+

Solution 1: Check Alertmanager config

+

In configs/alertmanager/alertmanager.yml:

+
route:
+  receiver: 'email'
+  group_wait: 30s
+  group_interval: 5m
+  repeat_interval: 12h
+
+receivers:
+  - name: 'email'
+    email_configs:
+      - to: 'alerts@example.com'
+        from: 'alertmanager@example.com'
+        smarthost: 'smtp.gmail.com:587'
+        auth_username: 'your-email@gmail.com'
+        auth_password: 'your-app-password'
+
+

Solution 2: Test Alertmanager notification

+
# Send test alert
+curl -X POST http://localhost:9093/api/v1/alerts \
+  -H 'Content-Type: application/json' \
+  -d '[{
+    "labels": {
+      "alertname": "Test",
+      "severity": "critical"
+    },
+    "annotations": {
+      "summary": "Test alert"
+    }
+  }]'
+
+# Check if notification sent
+docker compose logs alertmanager | grep -i "notification\|email"
+
+

Solution 3: Check SMTP config

+

See Email Issues for SMTP troubleshooting.

+

Solution 4: Use alternative notification channels

+
receivers:
+  - name: 'slack'
+    slack_configs:
+      - api_url: 'https://hooks.slack.com/services/...'
+        channel: '#alerts'
+
+  - name: 'webhook'
+    webhook_configs:
+      - url: 'http://your-webhook-url.com/alerts'
+
+

Prevention

+
    +
  • Test notifications - Regular notification tests
  • +
  • Multiple channels - Email + Slack + webhook
  • +
  • Fallback receivers - Backup notification method
  • +
  • Documentation - Document notification setup
  • +
+
+

Routing Errors

+

Severity: 🟡 Medium

+

Symptoms

+

Alerts going to wrong receiver or being silenced incorrectly.

+

Solutions

+

Solution 1: Check routing rules

+

In configs/alertmanager/alertmanager.yml:

+
route:
+  receiver: 'default'
+  routes:
+    - match:
+        severity: critical
+      receiver: 'pager'
+    - match:
+        severity: warning
+      receiver: 'email'
+
+

Solution 2: Test routing

+
# Use amtool to test routing
+docker compose exec alertmanager amtool config routes test \
+  --config.file=/etc/alertmanager/alertmanager.yml \
+  alertname=TestAlert severity=critical
+
+# Shows which receiver will be used
+
+

Solution 3: View active silences

+

In Alertmanager UI (localhost:9093):

+
    +
  1. Click "Silences"
  2. +
  3. Check if alert is silenced
  4. +
  5. Expire or delete silence if wrong
  6. +
+

Solution 4: Check inhibition rules

+
inhibit_rules:
+  - source_match:
+      severity: critical
+    target_match:
+      severity: warning
+    equal: ['alertname', 'instance']
+# Critical alerts inhibit warnings for same instance
+
+

Prevention

+
    +
  • Clear routing logic - Simple, understandable rules
  • +
  • Test routing - Test before deploying
  • +
  • Documentation - Document routing rules
  • +
  • Regular review - Review silences and inhibitions
  • +
+
+

Metrics Issues

+

Missing Metrics

+

Severity: 🟡 Medium

+

Symptoms

+

Expected metric not appearing in Prometheus or Grafana.

+

Solutions

+

Solution 1: Check metric is registered

+

In API code (api/src/utils/metrics.ts):

+
import { Counter } from 'prom-client';
+
+const requestCounter = new Counter({
+  name: 'cm_my_metric_total',
+  help: 'Description of metric'
+});
+
+register.registerMetric(requestCounter);  // Must register!
+
+

Solution 2: Check metric is collected

+
# Test /metrics endpoint
+curl http://localhost:4000/metrics | grep cm_my_metric
+
+# Should show:
+# # HELP cm_my_metric_total Description of metric
+# # TYPE cm_my_metric_total counter
+# cm_my_metric_total 42
+
+

Solution 3: Check scrape config

+

In configs/prometheus/prometheus.yml:

+
scrape_configs:
+  - job_name: 'api'
+    static_configs:
+      - targets: ['api:4000']
+    metric_relabel_configs:  # Don't accidentally drop metric
+      - source_labels: [__name__]
+        regex: 'cm_.*'  # Keep cm_* metrics
+        action: keep
+
+

Solution 4: Verify metric type

+
// Counter - only increases (counts)
+const counter = new Counter({ name: 'cm_requests_total' });
+counter.inc();  // Increment
+
+// Gauge - can go up or down (current value)
+const gauge = new Gauge({ name: 'cm_queue_size' });
+gauge.set(42);  // Set value
+
+// Histogram - distribution of values
+const histogram = new Histogram({ name: 'cm_request_duration_seconds' });
+histogram.observe(0.5);  // Record duration
+
+

Prevention

+
    +
  • Register all metrics - Don't forget register.registerMetric()
  • +
  • Test endpoint - Check /metrics shows metric
  • +
  • Naming convention - Use cm_* prefix for custom metrics
  • +
  • Documentation - Document all custom metrics
  • +
+
+

Incorrect Values

+

Severity: 🟡 Medium

+

Symptoms

+

Metric showing wrong or unexpected values.

+

Solutions

+

Solution 1: Check metric logic

+
// Wrong - gauge not updated
+const gauge = new Gauge({ name: 'cm_users_total' });
+// Never set, always 0
+
+// Right - gauge updated
+const gauge = new Gauge({
+  name: 'cm_users_total',
+  async collect() {
+    const count = await prisma.user.count();
+    this.set(count);
+  }
+});
+
+

Solution 2: Check metric type

+
// Wrong - using Counter for value that can decrease
+const queueSize = new Counter({ name: 'cm_queue_size' });
+queueSize.inc(50);  // Add 50
+queueSize.inc(-20);  // Try to subtract 20 - ERROR!
+
+// Right - use Gauge
+const queueSize = new Gauge({ name: 'cm_queue_size' });
+queueSize.set(50);  // Set to 50
+queueSize.set(30);  // Set to 30 (can decrease)
+
+

Solution 3: Check label values

+
// Labels must match exactly
+const counter = new Counter({
+  name: 'requests_total',
+  labelNames: ['method', 'status']
+});
+
+counter.inc({ method: 'GET', status: '200' });
+// Creates: requests_total{method="GET",status="200"} 1
+
+counter.inc({ method: 'GET', status: 200 });  // Wrong - number not string
+// Creates separate metric: requests_total{method="GET",status=200} 1
+
+

Solution 4: Check query aggregation

+
# Wrong - sums across all labels
+sum(cm_requests_total)
+
+# Right - sum by specific label
+sum by (status) (cm_requests_total)
+
+

Prevention

+
    +
  • Correct metric type - Counter vs Gauge vs Histogram
  • +
  • Type consistency - Label values always same type
  • +
  • Testing - Test metric values with sample data
  • +
  • Validation - Validate metric values are reasonable
  • +
+
+

Stale Metrics

+

Severity: 🟢 Low

+

Symptoms

+

Metric values not updating, showing old data.

+

Solutions

+

Solution 1: Check collection frequency

+
// Metrics only updated when scraped
+const gauge = new Gauge({
+  name: 'cm_queue_size',
+  async collect() {
+    // This runs on every Prometheus scrape (every 15s)
+    const size = await getQueueSize();
+    this.set(size);
+  }
+});
+
+

Solution 2: Force metric update

+
// Update metric on event, not just scrape
+eventEmitter.on('queueSizeChanged', (size) => {
+  queueSizeGauge.set(size);
+});
+
+

Solution 3: Check scrape interval

+

In configs/prometheus/prometheus.yml:

+
global:
+  scrape_interval: 15s  # Scrape every 15 seconds
+
+# Increase for more frequent updates
+global:
+  scrape_interval: 5s  # Scrape every 5 seconds
+
+

Prevention

+
    +
  • Appropriate intervals - Balance freshness vs overhead
  • +
  • Event-driven updates - Update on change, not just scrape
  • +
  • Cache expensive metrics - Don't query DB every scrape
  • +
  • Staleness markers - Set metrics to NaN when stale
  • +
+
+

Performance Issues

+

High Memory Usage

+

Severity: 🟠 High

+

Symptoms

+

Prometheus container using excessive memory (multiple GB).

+

Solutions

+

Solution 1: Reduce retention period

+

In docker-compose.yml:

+
prometheus:
+  command:
+    - '--config.file=/etc/prometheus/prometheus.yml'
+    - '--storage.tsdb.retention.time=7d'  # Reduce from 15d to 7d
+    - '--storage.tsdb.retention.size=10GB'  # Add size limit
+
+

Restart:

+
docker compose --profile monitoring restart prometheus
+
+

Solution 2: Reduce metric cardinality

+
// Bad - creates metric per user (thousands)
+new Counter({
+  name: 'requests_by_user',
+  labelNames: ['userId']
+});
+
+// Good - creates metric per role (5)
+new Counter({
+  name: 'requests_by_role',
+  labelNames: ['role']
+});
+
+

Solution 3: Drop unnecessary metrics

+

In configs/prometheus/prometheus.yml:

+
scrape_configs:
+  - job_name: 'api'
+    static_configs:
+      - targets: ['api:4000']
+    metric_relabel_configs:
+      # Drop metrics we don't use
+      - source_labels: [__name__]
+        regex: 'go_.*|process_.*'  # Drop Go/process metrics
+        action: drop
+
+

Solution 4: Increase memory limit

+
prometheus:
+  deploy:
+    resources:
+      limits:
+        memory: 4G  # Increase from 2G
+
+

Prevention

+
    +
  • Low cardinality - Avoid high-cardinality labels
  • +
  • Appropriate retention - 7-30 days is usually enough
  • +
  • Regular cleanup - Drop unused metrics
  • +
  • Monitor memory - Alert on high usage
  • +
+
+

Slow Queries

+

Severity: 🟡 Medium

+

Symptoms

+

Grafana dashboards slow to load. Queries taking 10+ seconds.

+

Solutions

+

Solution 1: Optimize query

+
# Slow - calculates rate for all time
+rate(cm_requests_total[1y])
+
+# Fast - only last 5 minutes
+rate(cm_requests_total[5m])
+
+# Slow - many time series
+sum(rate(cm_requests_total[5m]))
+
+# Faster - aggregate before rate
+sum(increase(cm_requests_total[5m])) / 300
+
+

Solution 2: Use recording rules

+

In configs/prometheus/alerts.yml:

+
groups:
+  - name: recording_rules
+    interval: 30s
+    rules:
+      # Pre-calculate expensive query every 30s
+      - record: job:cm_request_rate:sum
+        expr: sum(rate(cm_requests_total[5m])) by (job)
+
+# Then use in dashboard:
+# job:cm_request_rate:sum  # Fast!
+
+

Solution 3: Reduce time range

+

In Grafana: +- Change dashboard time range from "Last 30 days" to "Last 24 hours" +- Queries are faster with less data

+

Solution 4: Increase Prometheus resources

+
prometheus:
+  deploy:
+    resources:
+      limits:
+        cpus: '2.0'  # More CPU for queries
+        memory: 4G
+
+

Prevention

+
    +
  • Efficient queries - Keep queries simple
  • +
  • Recording rules - Pre-calculate expensive queries
  • +
  • Appropriate time ranges - Don't query months of data
  • +
  • Indexing - Prometheus auto-indexes, but cardinality affects performance
  • +
+
+

Useful Commands

+

Prometheus Operations

+
# Check targets
+curl http://localhost:9090/api/v1/targets
+
+# Query metric
+curl 'http://localhost:9090/api/v1/query?query=cm_api_uptime_seconds'
+
+# Query range
+curl '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'
+
+# Reload config
+docker compose exec prometheus kill -HUP 1
+
+# Check config
+docker compose exec prometheus promtool check config /etc/prometheus/prometheus.yml
+
+# Check rules
+docker compose exec prometheus promtool check rules /etc/prometheus/alerts.yml
+
+

Grafana Operations

+
# Test datasource
+curl http://admin:admin@localhost:3001/api/datasources/1/health
+
+# List dashboards
+curl http://admin:admin@localhost:3001/api/search?type=dash-db
+
+# Export dashboard
+curl http://admin:admin@localhost:3001/api/dashboards/uid/YOUR_UID | jq .dashboard > dashboard.json
+
+# Import dashboard
+curl -X POST http://admin:admin@localhost:3001/api/dashboards/db \
+  -H "Content-Type: application/json" \
+  -d @dashboard.json
+
+

Alertmanager Operations

+
# Check alerts
+curl http://localhost:9093/api/v1/alerts
+
+# Send test alert
+curl -X POST http://localhost:9093/api/v1/alerts \
+  -H 'Content-Type: application/json' \
+  -d '[{"labels":{"alertname":"Test","severity":"critical"},"annotations":{"summary":"Test"}}]'
+
+# List silences
+curl http://localhost:9093/api/v1/silences
+
+# Create silence
+curl -X POST http://localhost:9093/api/v1/silences \
+  -H 'Content-Type: application/json' \
+  -d '{"matchers":[{"name":"alertname","value":"Test"}],"startsAt":"2026-02-13T00:00:00Z","endsAt":"2026-02-14T00:00:00Z","createdBy":"admin","comment":"Test silence"}'
+
+
+ +

Monitoring Documentation

+ +

Other Troubleshooting

+ +

External Resources

+ +
+

Last Updated: February 2026 +Version: V2.0 +Status: Complete

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/troubleshooting/performance-optimization/index.html b/mkdocs/site/v2/troubleshooting/performance-optimization/index.html new file mode 100644 index 00000000..4cb66385 --- /dev/null +++ b/mkdocs/site/v2/troubleshooting/performance-optimization/index.html @@ -0,0 +1,6658 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Performance Optimization - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Performance Optimization

+

This guide covers performance tuning and optimization strategies for Changemaker Lite V2.

+

Overview

+

Performance Areas

+
    +
  1. Database - Query optimization, indexing, connection pooling
  2. +
  3. API - Caching, rate limiting, pagination
  4. +
  5. Frontend - Code splitting, lazy loading, bundling
  6. +
  7. Docker - Resource limits, multi-stage builds
  8. +
  9. Nginx - Compression, caching, keep-alive
  10. +
  11. Email Queue - Worker count, batch processing
  12. +
  13. Monitoring - Prometheus metrics, Grafana dashboards
  14. +
+

Performance Metrics

+

Target performance:

+
    +
  • API response time: < 200ms (p95)
  • +
  • Database query time: < 50ms (p95)
  • +
  • Frontend load time: < 2s (initial)
  • +
  • Email sending: 100+ emails/minute
  • +
  • Concurrent users: 500+
  • +
+
+

Database Optimization

+

Index Optimization

+

Find missing indexes:

+
-- Find tables without indexes
+SELECT schemaname, tablename, indexname
+FROM pg_indexes
+WHERE schemaname = 'public'
+ORDER BY tablename;
+
+-- Find columns used in WHERE but not indexed
+SELECT *
+FROM pg_stat_user_tables
+WHERE schemaname = 'public'
+  AND seq_scan > 1000
+  AND seq_tup_read / seq_scan > 10000
+ORDER BY seq_scan DESC;
+
+

Add indexes to frequently queried columns:

+
model Location {
+  id         String @id @default(uuid())
+  address    String
+  city       String
+  province   String
+  postalCode String
+  createdAt  DateTime @default(now())
+
+  // Add indexes for WHERE clauses
+  @@index([postalCode])  // WHERE postalCode = '...'
+  @@index([city])        // WHERE city = '...'
+  @@index([province])    // WHERE province = '...'
+  @@index([createdAt])   // ORDER BY createdAt
+
+  // Composite index for multi-column queries
+  @@index([province, city])  // WHERE province = '...' AND city = '...'
+}
+
+

Create migration:

+
docker compose exec api npx prisma migrate dev --name add_location_indexes
+
+

Verify index usage:

+
EXPLAIN ANALYZE
+SELECT * FROM "Location"
+WHERE "postalCode" = 'M5H 2N2';
+
+-- Should show:
+-- Index Scan using Location_postalCode_idx
+-- NOT: Seq Scan on "Location"
+
+
+

Query Optimization

+

Use select instead of fetching all fields:

+
// Bad - fetches all fields
+const users = await prisma.user.findMany();
+// Returns: id, email, password, name, role, createdAt, updatedAt, ...
+
+// Good - only needed fields
+const users = await prisma.user.findMany({
+  select: {
+    id: true,
+    email: true,
+    name: true,
+    role: true
+  }
+});
+
+

Use include instead of separate queries:

+
// Bad - N+1 queries
+const campaigns = await prisma.campaign.findMany();
+for (const campaign of campaigns) {
+  const emails = await prisma.campaignEmail.findMany({
+    where: { campaignId: campaign.id }
+  });
+  campaign.emails = emails;
+}
+
+// Good - single query with join
+const campaigns = await prisma.campaign.findMany({
+  include: {
+    emails: true
+  }
+});
+
+

Paginate large result sets:

+
// Bad - fetch all
+const locations = await prisma.location.findMany();
+// Returns 10,000+ rows
+
+// Good - paginate
+const locations = await prisma.location.findMany({
+  take: 50,  // Limit
+  skip: page * 50,  // Offset
+  orderBy: { createdAt: 'desc' }
+});
+
+

Use aggregations efficiently:

+
// Bad - count all then filter
+const allUsers = await prisma.user.findMany();
+const activeCount = allUsers.filter(u => u.role !== 'TEMP').length;
+
+// Good - count in database
+const activeCount = await prisma.user.count({
+  where: {
+    role: { not: 'TEMP' }
+  }
+});
+
+
+

Connection Pooling

+

Configure pool size:

+
# In .env
+DATABASE_URL="postgresql://changemaker:password@v2-postgres:5432/changemaker_v2?connection_limit=20&pool_timeout=30"
+
+# connection_limit: Max connections (default: 10)
+# pool_timeout: Max wait time in seconds (default: 10)
+
+

Recommended pool sizes:

+
    +
  • Development: 5-10 connections
  • +
  • Production (1 API instance): 10-20 connections
  • +
  • Production (3 API instances): 5-10 per instance
  • +
+

Formula:

+
Total connections = (API instances × pool size) + overhead
+Overhead = Prisma Studio (1) + other clients (5)
+
+Example:
+3 instances × 10 pool + 6 overhead = 36 connections
+Set PostgreSQL max_connections = 50 (1.4× usage)
+
+

Monitor pool usage:

+
-- View active connections
+SELECT count(*), state
+FROM pg_stat_activity
+WHERE datname = 'changemaker_v2'
+GROUP BY state;
+
+-- Alert if nearing limit
+SELECT count(*) FROM pg_stat_activity WHERE datname = 'changemaker_v2';
+-- If > 80% of max_connections, increase limit or reduce pool size
+
+
+

Read Replicas

+

For read-heavy workloads, add read replicas:

+
# docker-compose.yml
+v2-postgres-read:
+  image: postgres:16-alpine
+  environment:
+    POSTGRES_DB: changemaker_v2
+    POSTGRES_USER: changemaker
+    POSTGRES_PASSWORD: ${V2_POSTGRES_PASSWORD}
+  command: postgres -c wal_level=replica -c max_wal_senders=3
+
+

Configure replication in Prisma:

+
// Use read replica for read queries
+const readPrisma = new PrismaClient({
+  datasources: {
+    db: { url: process.env.READ_DATABASE_URL }
+  }
+});
+
+// Read from replica
+const users = await readPrisma.user.findMany();
+
+// Write to primary
+const user = await prisma.user.create({ data: { ... } });
+
+
+

API Optimization

+

Caching Strategies

+

Redis caching:

+
// Cache expensive operations
+import { redis } from './config/redis';
+
+export const getCampaigns = async () => {
+  // Check cache
+  const cacheKey = 'campaigns:all';
+  const cached = await redis.get(cacheKey);
+
+  if (cached) {
+    return JSON.parse(cached);
+  }
+
+  // Query database
+  const campaigns = await prisma.campaign.findMany({
+    include: { emails: true }
+  });
+
+  // Cache for 5 minutes
+  await redis.setex(cacheKey, 300, JSON.stringify(campaigns));
+
+  return campaigns;
+};
+
+

Invalidate cache on updates:

+
export const updateCampaign = async (id: string, data: any) => {
+  // Update database
+  const campaign = await prisma.campaign.update({
+    where: { id },
+    data
+  });
+
+  // Invalidate cache
+  await redis.del('campaigns:all');
+  await redis.del(`campaign:${id}`);
+
+  return campaign;
+};
+
+

Cache patterns:

+
    +
  • Cache-aside: Check cache, fetch from DB if miss
  • +
  • Write-through: Update DB and cache simultaneously
  • +
  • Write-behind: Update cache, async update DB
  • +
  • TTL: Set expiration time (5min-1hour typical)
  • +
+
+

Rate Limiting

+

Configure rate limits:

+
// In api/src/middleware/rate-limit.ts
+import rateLimit from 'express-rate-limit';
+
+// General API
+export const apiRateLimit = rateLimit({
+  windowMs: 60 * 1000,  // 1 minute
+  max: 100,  // 100 requests per minute
+  standardHeaders: true,
+  legacyHeaders: false,
+});
+
+// Auth endpoints (stricter)
+export const authRateLimit = rateLimit({
+  windowMs: 60 * 1000,
+  max: 10,  // 10 requests per minute
+  message: 'Too many login attempts. Please try again later.'
+});
+
+// Public endpoints (more lenient)
+export const publicRateLimit = rateLimit({
+  windowMs: 60 * 1000,
+  max: 200  // 200 requests per minute
+});
+
+

Apply to routes:

+
// In server.ts
+app.use('/api/auth', authRateLimit);
+app.use('/api', apiRateLimit);
+app.use('/public', publicRateLimit);
+
+
+

Pagination

+

Implement cursor-based pagination:

+
// api/src/modules/users/users.controller.ts
+export const getUsers = async (req: Request, res: Response) => {
+  const { cursor, limit = 50 } = req.query;
+
+  const users = await prisma.user.findMany({
+    take: Number(limit) + 1,  // Fetch one extra to check if more
+    skip: cursor ? 1 : 0,
+    cursor: cursor ? { id: cursor as string } : undefined,
+    orderBy: { createdAt: 'desc' }
+  });
+
+  const hasMore = users.length > Number(limit);
+  if (hasMore) users.pop();  // Remove extra
+
+  res.json({
+    data: users,
+    cursor: hasMore ? users[users.length - 1].id : null,
+    hasMore
+  });
+};
+
+

Frontend pagination:

+
// admin/src/pages/UsersPage.tsx
+const [users, setUsers] = useState([]);
+const [cursor, setCursor] = useState<string | null>(null);
+const [hasMore, setHasMore] = useState(true);
+
+const loadMore = async () => {
+  const response = await api.get('/api/users', {
+    params: { cursor, limit: 50 }
+  });
+
+  setUsers([...users, ...response.data.data]);
+  setCursor(response.data.cursor);
+  setHasMore(response.data.hasMore);
+};
+
+
+

Response Compression

+

Enable gzip compression:

+
// In server.ts
+import compression from 'compression';
+
+app.use(compression({
+  level: 6,  // Compression level (0-9)
+  threshold: 1024  // Only compress responses > 1KB
+}));
+
+
+

Frontend Optimization

+

Code Splitting

+

Route-based splitting:

+
// admin/src/App.tsx
+import { lazy, Suspense } from 'react';
+
+// Lazy load pages
+const UsersPage = lazy(() => import('./pages/UsersPage'));
+const CampaignsPage = lazy(() => import('./pages/CampaignsPage'));
+const LocationsPage = lazy(() => import('./pages/LocationsPage'));
+
+function App() {
+  return (
+    <Suspense fallback={<Spin />}>
+      <Routes>
+        <Route path="/app/users" element={<UsersPage />} />
+        <Route path="/app/campaigns" element={<CampaignsPage />} />
+        <Route path="/app/locations" element={<LocationsPage />} />
+      </Routes>
+    </Suspense>
+  );
+}
+
+

Component splitting:

+
// Lazy load heavy components
+const MapView = lazy(() => import('./components/MapView'));
+
+function Page() {
+  return (
+    <Suspense fallback={<Spin />}>
+      <MapView />
+    </Suspense>
+  );
+}
+
+
+

Lazy Loading

+

Images:

+
<img
+  src={imageUrl}
+  loading="lazy"  // Native lazy loading
+  alt="Description"
+/>
+
+

Large libraries:

+
// Don't import large libs at top level
+import dayjs from 'dayjs';  // ❌ Always loads
+
+// Import only when needed
+const formatDate = async (date: Date) => {
+  const dayjs = (await import('dayjs')).default;  // ✅ Loads on demand
+  return dayjs(date).format('YYYY-MM-DD');
+};
+
+
+

Bundle Optimization

+

Analyze bundle size:

+
cd admin
+npm run build
+npx vite-bundle-visualizer
+
+

Tree shaking:

+
// Import only what you need
+import { Button } from 'antd';  // ❌ Imports all of antd
+
+import Button from 'antd/es/button';  // ✅ Only button
+
+

Configure Vite:

+
// admin/vite.config.ts
+export default defineConfig({
+  build: {
+    rollupOptions: {
+      output: {
+        manualChunks: {
+          vendor: ['react', 'react-dom', 'react-router-dom'],
+          antd: ['antd'],
+          maps: ['leaflet', 'react-leaflet']
+        }
+      }
+    },
+    chunkSizeWarningLimit: 1000
+  }
+});
+
+
+

Memoization

+

React.memo for expensive components:

+
import { memo } from 'react';
+
+const LocationMarker = memo(({ location }) => {
+  return (
+    <CircleMarker
+      center={[location.latitude, location.longitude]}
+      radius={8}
+    />
+  );
+}, (prev, next) => {
+  // Only re-render if location changed
+  return prev.location.id === next.location.id;
+});
+
+

useMemo for expensive calculations:

+
import { useMemo } from 'react';
+
+function MapView({ locations }) {
+  // Only recalculate when locations change
+  const bounds = useMemo(() => {
+    if (!locations.length) return null;
+    const coords = locations.map(l => [l.latitude, l.longitude]);
+    return L.latLngBounds(coords);
+  }, [locations]);
+
+  return <MapContainer bounds={bounds} />;
+}
+
+

useCallback for stable functions:

+
import { useCallback } from 'react';
+
+function Table({ data }) {
+  // Stable reference for row click handler
+  const handleRowClick = useCallback((row) => {
+    console.log('Clicked:', row.id);
+  }, []);
+
+  return <Table data={data} onRowClick={handleRowClick} />;
+}
+
+
+

Docker Optimization

+

Resource Limits

+
# docker-compose.yml
+api:
+  deploy:
+    resources:
+      limits:
+        cpus: '2.0'  # Max 2 CPU cores
+        memory: 4G   # Max 4GB RAM
+      reservations:
+        cpus: '0.5'  # Reserve 0.5 cores
+        memory: 1G   # Reserve 1GB
+
+

Monitor resource usage:

+
docker stats
+
+# Shows:
+# CONTAINER   CPU %   MEM USAGE / LIMIT   MEM %
+# api         15%     1.2GB / 4GB         30%
+
+
+

Multi-Stage Builds

+

Optimize Dockerfile:

+
# Build stage
+FROM node:20-alpine AS builder
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci --only=production
+COPY . .
+RUN npm run build
+
+# Runtime stage (smaller)
+FROM node:20-alpine
+WORKDIR /app
+COPY --from=builder /app/dist ./dist
+COPY --from=builder /app/node_modules ./node_modules
+COPY package*.json ./
+
+CMD ["node", "dist/server.js"]
+
+

Benefits:

+
    +
  • Smaller final image (no build tools)
  • +
  • Faster deployment
  • +
  • Better security (fewer packages)
  • +
+
+

Volume Performance

+

Use cached volumes for dependencies:

+
api:
+  volumes:
+    - ./api:/app
+    - /app/node_modules  # Don't bind-mount node_modules
+    - api-build:/app/dist:cached  # Cache build output
+
+

For macOS/Windows:

+
api:
+  volumes:
+    - ./api:/app:cached  # Cached mode for better performance
+
+
+

Nginx Optimization

+

Gzip Compression

+
# nginx/nginx.conf
+http {
+  gzip on;
+  gzip_vary on;
+  gzip_min_length 1024;
+  gzip_comp_level 6;
+  gzip_types
+    text/plain
+    text/css
+    text/xml
+    text/javascript
+    application/json
+    application/javascript
+    application/xml+rss
+    application/atom+xml
+    image/svg+xml;
+}
+
+
+

Caching

+

Static assets:

+
# nginx/conf.d/default.conf
+location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
+  expires 1y;
+  add_header Cache-Control "public, immutable";
+}
+
+

API responses:

+
location /api/ {
+  proxy_cache api_cache;
+  proxy_cache_valid 200 5m;  # Cache 200 responses for 5 minutes
+  proxy_cache_bypass $http_cache_control;  # Honor Cache-Control header
+  add_header X-Cache-Status $upstream_cache_status;
+
+  proxy_pass http://api:4000;
+}
+
+
+

Keep-Alive

+
# nginx/nginx.conf
+http {
+  keepalive_timeout 65;
+  keepalive_requests 100;
+
+  upstream api {
+    server api:4000;
+    keepalive 32;  # Keep 32 connections alive to backend
+  }
+}
+
+
+

Email Queue Optimization

+

Worker Concurrency

+

Increase parallel processing:

+
// api/src/services/email-queue.service.ts
+const worker = new Worker('email-queue', emailProcessor, {
+  connection: redis,
+  concurrency: 5,  // Process 5 emails simultaneously
+  limiter: {
+    max: 50,  // Max 50 jobs per second
+    duration: 1000
+  }
+});
+
+

Recommended concurrency:

+
    +
  • Development: 1-2
  • +
  • Production (low volume): 3-5
  • +
  • Production (high volume): 10-20
  • +
+
+

Batch Processing

+

Process emails in batches:

+
export const sendBulkEmails = async (emails: Email[]) => {
+  const batchSize = 100;
+
+  for (let i = 0; i < emails.length; i += batchSize) {
+    const batch = emails.slice(i, i + batchSize);
+
+    // Add batch to queue
+    await emailQueue.addBulk(
+      batch.map(email => ({
+        name: 'send-email',
+        data: email
+      }))
+    );
+  }
+};
+
+
+

Rate Limiting

+

Respect SMTP provider limits:

+
const worker = new Worker('email-queue', emailProcessor, {
+  limiter: {
+    // Gmail: 500 emails/day (free), 2000/day (workspace)
+    max: 100,  // 100 emails per hour
+    duration: 3600 * 1000  // 1 hour
+  }
+});
+
+
+

Monitoring Performance

+

Prometheus Metrics

+

Track response times:

+
import { Histogram } from 'prom-client';
+
+const httpRequestDuration = new Histogram({
+  name: 'http_request_duration_seconds',
+  help: 'HTTP request duration in seconds',
+  labelNames: ['method', 'route', 'status'],
+  buckets: [0.01, 0.05, 0.1, 0.5, 1, 5]
+});
+
+// Middleware to track duration
+app.use((req, res, next) => {
+  const start = Date.now();
+
+  res.on('finish', () => {
+    const duration = (Date.now() - start) / 1000;
+    httpRequestDuration
+      .labels(req.method, req.route?.path || req.path, res.statusCode.toString())
+      .observe(duration);
+  });
+
+  next();
+});
+
+

Track query counts:

+
const dbQueries = new Counter({
+  name: 'cm_database_queries_total',
+  help: 'Total database queries',
+  labelNames: ['model', 'operation']
+});
+
+// In Prisma middleware
+prisma.$use(async (params, next) => {
+  dbQueries.labels(params.model, params.action).inc();
+  return next(params);
+});
+
+
+

Grafana Dashboards

+

Create performance dashboard:

+
# API response time (p95)
+histogram_quantile(0.95,
+  rate(http_request_duration_seconds_bucket[5m])
+)
+
+# Database query rate
+rate(cm_database_queries_total[5m])
+
+# Cache hit rate
+rate(cm_cache_hits_total[5m]) /
+(rate(cm_cache_hits_total[5m]) + rate(cm_cache_misses_total[5m]))
+
+
+

Slow Query Log

+

Enable in PostgreSQL:

+
# docker-compose.yml
+v2-postgres:
+  command: postgres -c log_min_duration_statement=100
+  # Logs queries taking > 100ms
+
+

View slow queries:

+
docker compose logs v2-postgres | grep "duration:"
+
+# Output:
+# LOG:  duration: 523.456 ms  statement: SELECT * FROM "Location" WHERE ...
+
+
+

Load Testing

+

k6 Load Testing

+

Install k6:

+
# macOS
+brew install k6
+
+# Linux
+sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
+echo "deb https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
+sudo apt-get update
+sudo apt-get install k6
+
+

Create test script:

+
// load-test.js
+import http from 'k6/http';
+import { check, sleep } from 'k6';
+
+export const options = {
+  stages: [
+    { duration: '2m', target: 100 },  // Ramp up to 100 users
+    { duration: '5m', target: 100 },  // Stay at 100 users
+    { duration: '2m', target: 0 },    // Ramp down
+  ],
+  thresholds: {
+    http_req_duration: ['p(95)<500'],  // 95% of requests < 500ms
+  },
+};
+
+export default function () {
+  // Test login
+  const loginRes = http.post('http://localhost:4000/api/auth/login', {
+    email: 'admin@example.com',
+    password: 'Admin123!',
+  });
+  check(loginRes, { 'login succeeded': (r) => r.status === 200 });
+
+  const token = loginRes.json('accessToken');
+
+  // Test API endpoints
+  const headers = { Authorization: `Bearer ${token}` };
+
+  const campaignsRes = http.get('http://localhost:4000/api/campaigns', { headers });
+  check(campaignsRes, { 'campaigns loaded': (r) => r.status === 200 });
+
+  const locationsRes = http.get('http://localhost:4000/api/map/locations', { headers });
+  check(locationsRes, { 'locations loaded': (r) => r.status === 200 });
+
+  sleep(1);
+}
+
+

Run test:

+
k6 run load-test.js
+
+

Interpret results:

+
     ✓ login succeeded
+     ✓ campaigns loaded
+     ✓ locations loaded
+
+     checks.........................: 100.00%
+     data_received..................: 8.2 MB
+     data_sent......................: 1.1 MB
+     http_req_duration..............: avg=145ms  min=12ms  med=89ms  max=2.1s  p(95)=423ms
+     http_reqs......................: 12450
+     vus............................: 100
+     vus_max........................: 100
+
+
+

Apache Bench

+

Quick load test:

+
# 1000 requests, 10 concurrent
+ab -n 1000 -c 10 http://localhost:4000/api/health
+
+# With authentication
+ab -n 1000 -c 10 -H "Authorization: Bearer TOKEN" http://localhost:4000/api/campaigns
+
+
+

Performance Checklist

+

Database

+
    +
  • Indexes on frequently queried columns
  • +
  • Composite indexes for multi-column queries
  • +
  • Connection pool sized appropriately
  • +
  • Slow query log enabled
  • +
  • VACUUM run regularly (auto by default)
  • +
  • Read replicas for read-heavy loads
  • +
+

API

+
    +
  • Redis caching for expensive operations
  • +
  • Rate limiting on all endpoints
  • +
  • Pagination on list endpoints
  • +
  • Response compression enabled
  • +
  • N+1 queries eliminated
  • +
  • Select only needed fields
  • +
+

Frontend

+
    +
  • Route-based code splitting
  • +
  • Lazy loading for heavy components
  • +
  • Images optimized and lazy-loaded
  • +
  • Bundle size < 500KB (gzipped)
  • +
  • React.memo for expensive components
  • +
  • useCallback/useMemo for stable references
  • +
+

Docker

+
    +
  • Multi-stage builds
  • +
  • Resource limits set
  • +
  • Health checks configured
  • +
  • Volumes optimized
  • +
  • Images use Alpine base
  • +
+

Nginx

+
    +
  • Gzip compression enabled
  • +
  • Static asset caching (1 year)
  • +
  • Keep-alive connections
  • +
  • Worker processes = CPU cores
  • +
  • Access logs rotated
  • +
+

Email Queue

+
    +
  • Worker concurrency optimized
  • +
  • Rate limiting respects SMTP limits
  • +
  • Batch processing for bulk sends
  • +
  • Failed jobs retry with backoff
  • +
  • Queue size monitored
  • +
+

Monitoring

+
    +
  • Prometheus metrics collected
  • +
  • Grafana dashboards created
  • +
  • Alerts configured
  • +
  • Slow queries logged
  • +
  • Resource usage tracked
  • +
+
+ +

Performance Documentation

+ + + +

External Resources

+ +
+

Last Updated: February 2026 +Version: V2.0 +Status: Complete

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/user-guides/admin-guide/index.html b/mkdocs/site/v2/user-guides/admin-guide/index.html new file mode 100644 index 00000000..efc72c15 --- /dev/null +++ b/mkdocs/site/v2/user-guides/admin-guide/index.html @@ -0,0 +1,8009 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Admin Guide - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Administrator Guide

+

Overview

+

The Administrator role is the highest-level role in Changemaker Lite. As an administrator, you have complete control over the platform, including:

+
    +
  • User management: Create, edit, suspend, and delete user accounts
  • +
  • Campaign oversight: Manage all advocacy campaigns and moderate responses
  • +
  • Location and mapping: Import locations, create territorial cuts, and organize canvassing efforts
  • +
  • Volunteer coordination: Create shifts, manage signups, and monitor canvassing activity
  • +
  • Site configuration: Configure global settings, themes, email, and feature toggles
  • +
  • Content management: Create landing pages, edit email templates, and manage media library
  • +
  • Monitoring: View queue status, geocoding quality, and system health
  • +
+

This guide will walk you through all administrative functions in Changemaker Lite V2.

+
+

Getting Started

+

First Login

+

When you first access Changemaker Lite, you'll log in at the admin portal URL:

+
https://app.cmlite.org
+
+

Or if running locally:

+
http://localhost:3000
+
+

Default credentials (change immediately after first login):

+
    +
  • Email: admin@example.com
  • +
  • Password: Admin123!
  • +
+
+

Security Critical

+

The default password is publicly known. Change it immediately after your first login to prevent unauthorized access.

+
+

Screenshot placeholder: Login page showing email/password fields and "Remember me" checkbox

+

Dashboard Overview

+

After logging in, you'll see the Administrator Dashboard, which provides an at-a-glance overview of your platform:

+

Key dashboard sections:

+
    +
  1. Statistics Cards
  2. +
  3. Total users (breakdown by role)
  4. +
  5. Active campaigns
  6. +
  7. Total locations
  8. +
  9. +

    Active canvass sessions

    +
  10. +
  11. +

    Recent Activity Feed

    +
  12. +
  13. New user registrations
  14. +
  15. Campaign responses
  16. +
  17. Shift signups
  18. +
  19. +

    Canvass visits

    +
  20. +
  21. +

    Quick Actions

    +
  22. +
  23. Create new campaign
  24. +
  25. Import locations
  26. +
  27. Create volunteer shift
  28. +
  29. +

    View email queue

    +
  30. +
  31. +

    System Health

    +
  32. +
  33. API status
  34. +
  35. Database connectivity
  36. +
  37. Redis cache status
  38. +
  39. Queue worker status
  40. +
+

Screenshot placeholder: Dashboard showing statistics cards, activity feed, and quick action buttons

+

Changing Your Password

+
+

Required First Step

+

You must change the default password before performing any other administrative tasks.

+
+

To change your password:

+
    +
  1. Click your email address in the top-right corner
  2. +
  3. Select "Change Password" from the dropdown
  4. +
  5. Enter your current password
  6. +
  7. Enter new password (must meet requirements below)
  8. +
  9. Confirm new password
  10. +
  11. Click "Update Password"
  12. +
+

Password requirements:

+
    +
  • Minimum 12 characters
  • +
  • At least one uppercase letter (A-Z)
  • +
  • At least one lowercase letter (a-z)
  • +
  • At least one digit (0-9)
  • +
  • Cannot reuse recent passwords
  • +
+

Screenshot placeholder: Change password modal showing current/new password fields and requirements checklist

+ +

The admin interface uses a sidebar navigation with the following sections:

+

Main Navigation:

+
    +
  • Dashboard — Overview and quick actions
  • +
  • Influence — Campaigns, responses, representatives, email queue
  • +
  • Map — Locations, cuts, shifts, map settings, data quality
  • +
  • Canvass — Dashboard, sessions, activity reports
  • +
  • Content — Landing pages, email templates, media library
  • +
  • Services — Listmonk, Pangolin, docs, integrations
  • +
  • Observability — Monitoring, metrics, alerts
  • +
  • Users — User management
  • +
  • Settings — Global site settings
  • +
+

Screenshot placeholder: Sidebar navigation showing expanded Influence and Map sections

+
+

User Management

+

Creating Users

+

To create a new user:

+
    +
  1. Navigate to Users in the sidebar
  2. +
  3. Click "Create User" button (top-right)
  4. +
  5. Fill in user details:
  6. +
  7. Email: User's email address (must be unique)
  8. +
  9. Name: User's full name
  10. +
  11. Password: Temporary password (user should change on first login)
  12. +
  13. Role: Select from dropdown (see roles below)
  14. +
  15. Status: ACTIVE or SUSPENDED
  16. +
  17. Click "Create"
  18. +
+

The new user will receive a welcome email (if email is configured) with their login credentials.

+

Screenshot placeholder: Create User modal showing email, name, password, role dropdown, and status toggle

+

Understanding Roles

+

Changemaker Lite has five user roles with different permission levels:

+

1. SUPER_ADMIN (You)

+
    +
  • Access: Everything
  • +
  • Capabilities: All administrative functions, user management, site configuration
  • +
  • Use case: Primary administrator(s)
  • +
+

2. INFLUENCE_ADMIN

+
    +
  • Access: Influence module only
  • +
  • Capabilities:
  • +
  • Create and manage campaigns
  • +
  • Moderate responses
  • +
  • View representative cache
  • +
  • Monitor email queue
  • +
  • Restrictions: Cannot manage users, locations, or site settings
  • +
  • Use case: Campaign managers who don't need full admin access
  • +
+

3. MAP_ADMIN

+
    +
  • Access: Map module only
  • +
  • Capabilities:
  • +
  • Import and manage locations
  • +
  • Create cuts
  • +
  • Organize shifts
  • +
  • Monitor canvassing
  • +
  • Restrictions: Cannot manage users, campaigns, or site settings
  • +
  • Use case: Field organizers, volunteer coordinators
  • +
+

4. USER

+
    +
  • Access: Volunteer portal only
  • +
  • Capabilities:
  • +
  • View assigned shifts
  • +
  • Start canvassing sessions
  • +
  • Record door visits
  • +
  • View own activity
  • +
  • Restrictions: Cannot access admin areas
  • +
  • Use case: Regular volunteers
  • +
+

5. TEMP

+
    +
  • Access: Very limited, volunteer portal only
  • +
  • Capabilities:
  • +
  • Sign up for public shifts (creates TEMP account automatically)
  • +
  • Cannot start canvassing sessions
  • +
  • Restrictions: Cannot access most features until upgraded to USER
  • +
  • Use case: Anonymous shift signups (converted to USER by admin)
  • +
+
+

Role Upgrading

+

You can upgrade TEMP users to USER role to give them full volunteer access. This is common after a volunteer attends their first shift.

+
+

Screenshot placeholder: User list table showing users with different roles and color-coded role badges

+

Managing Existing Users

+

The Users page shows all user accounts in a searchable, filterable table.

+

Table columns:

+
    +
  • Name — User's full name
  • +
  • Email — Login email
  • +
  • Role — Current role (color-coded badge)
  • +
  • Status — ACTIVE (green) or SUSPENDED (red)
  • +
  • Last Login — Most recent login timestamp
  • +
  • Created — Account creation date
  • +
  • Actions — Edit, suspend/activate, delete
  • +
+

Available filters:

+
    +
  • Search: Search by name or email
  • +
  • Role filter: Show only specific roles
  • +
  • Status filter: Active, suspended, or all
  • +
  • Date range: Filter by creation date
  • +
+

Screenshot placeholder: Users table with search bar, role filter dropdown, and action buttons

+

Editing Users

+

To edit a user:

+
    +
  1. Click the Edit icon (pencil) in the Actions column
  2. +
  3. Modify any of:
  4. +
  5. Name
  6. +
  7. Email (must remain unique)
  8. +
  9. Role (change permissions)
  10. +
  11. Status (activate/suspend)
  12. +
  13. Click "Save"
  14. +
+
+

Email Changes

+

Changing a user's email will require them to log in with the new email address. Notify them before making this change.

+
+

Suspending Users

+

To temporarily disable a user account:

+
    +
  1. Find the user in the table
  2. +
  3. Click "Suspend" in the Actions column
  4. +
  5. Confirm suspension
  6. +
+

Suspended users:

+
    +
  • Cannot log in
  • +
  • Existing sessions are invalidated immediately
  • +
  • Can be reactivated at any time
  • +
  • Data and history are preserved
  • +
+

When to suspend:

+
    +
  • Volunteer is temporarily unavailable
  • +
  • Security concerns (investigate before deleting)
  • +
  • User requests account pause
  • +
+

Screenshot placeholder: Suspend confirmation dialog explaining effects

+

Password Resets

+

To reset a user's password:

+
    +
  1. Edit the user
  2. +
  3. Click "Reset Password"
  4. +
  5. Choose one of:
  6. +
  7. Generate temporary password (shown on screen, expires in 24 hours)
  8. +
  9. Send reset email (user clicks link to set new password)
  10. +
  11. Provide temporary password to user securely (not via email)
  12. +
+
+

Security Best Practice

+

Always use "Send reset email" option when possible. Only generate temporary passwords for in-person support scenarios.

+
+

Deleting Users

+
+

Permanent Action

+

Deleting a user is permanent and cannot be undone. All associated data (canvass visits, responses, etc.) will be anonymized.

+
+

To delete a user:

+
    +
  1. Click the Delete icon (trash) in the Actions column
  2. +
  3. Type the user's email to confirm
  4. +
  5. Click "Delete Permanently"
  6. +
+

When deletion is appropriate:

+
    +
  • Duplicate accounts
  • +
  • Test accounts in production
  • +
  • User requests account deletion (GDPR compliance)
  • +
+

Data handling on deletion:

+
    +
  • User account record is deleted
  • +
  • Associated content (responses, visits) remains but user reference is nullified
  • +
  • Email queue jobs remain (email address is preserved for audit)
  • +
+

Viewing Login Activity

+

To see recent login activity:

+
    +
  1. Navigate to Users
  2. +
  3. Check the "Last Login" column
  4. +
  5. Click on a user to see detailed login history (if audit logging is enabled)
  6. +
+

Screenshot placeholder: User detail view showing login history table with timestamps and IP addresses

+
+

Campaign Management

+

Campaign Overview

+

Campaigns are at the heart of the Influence module. A campaign allows citizens to:

+
    +
  1. Enter their postal code
  2. +
  3. Find their elected representatives
  4. +
  5. Send advocacy emails
  6. +
  7. Share their story on a public response wall
  8. +
+

As an administrator, you can create, configure, publish, and monitor campaigns.

+

Creating a Campaign

+

To create a new campaign:

+
    +
  1. Navigate to Influence > Campaigns
  2. +
  3. Click "Create Campaign" (top-right)
  4. +
  5. Fill in the campaign form (see fields below)
  6. +
  7. Click "Create"
  8. +
+

Required fields:

+

Basic Information:

+
    +
  • Title: Campaign name (shown to public)
  • +
  • Example: "Protect Our Climate"
  • +
  • Slug: URL-friendly identifier (auto-generated from title)
  • +
  • Example: protect-our-climate
  • +
  • Used in public URL: /campaigns/protect-our-climate
  • +
  • Description: Campaign overview (supports HTML)
  • +
  • Shown on campaign listing page
  • +
  • Recommended: 2-3 sentences
  • +
+

Email Configuration:

+
    +
  • Email Subject: Subject line for advocacy emails
  • +
  • Example: "Please support climate action legislation"
  • +
  • Variables supported: {{USER_NAME}}, {{REP_NAME}}
  • +
  • Email Body: The email message citizens send
  • +
  • HTML editor available
  • +
  • Variables: {{USER_NAME}}, {{USER_EMAIL}}, {{REP_NAME}}, {{REP_EMAIL}}, {{USER_MESSAGE}}
  • +
  • Preview before publishing
  • +
+

Targeting:

+
    +
  • Government Level: FEDERAL, PROVINCIAL, or MUNICIPAL
  • +
  • Determines which representatives are looked up
  • +
  • Can select multiple levels
  • +
+

Screenshot placeholder: Create Campaign form showing title, slug, description, email subject, and body editor

+

Understanding Feature Flags

+

Campaigns have 12 feature flags that control functionality:

+

Core Features

+
    +
  1. Published
  2. +
  3. Controls public visibility
  4. +
  5. Unpublished campaigns only visible to admins
  6. +
  7. +

    Toggle to launch/pause campaign

    +
  8. +
  9. +

    Featured

    +
  10. +
  11. Featured campaigns appear at top of listing page
  12. +
  13. Use for high-priority campaigns
  14. +
  15. +

    Limit to 2-3 featured campaigns

    +
  16. +
  17. +

    Has Response Wall

    +
  18. +
  19. Enables public response wall
  20. +
  21. Citizens can share their story after emailing
  22. +
  23. +

    Responses require admin approval (unless auto_approve_responses)

    +
  24. +
  25. +

    Collect Phone Numbers

    +
  26. +
  27. Adds optional phone number field
  28. +
  29. Used for call-in campaigns
  30. +
  31. +

    Numbers stored for admin use

    +
  32. +
  33. +

    Track Calls

    +
  34. +
  35. Adds "I called my representative" button
  36. +
  37. Tracks call attempts separately from emails
  38. +
  39. Good for blended campaigns
  40. +
+

Advanced Features

+
    +
  1. Require Verification
  2. +
  3. Sends verification email before submitting
  4. +
  5. Prevents spam and bot submissions
  6. +
  7. +

    Recommended for public campaigns

    +
  8. +
  9. +

    Auto Approve Responses

    +
  10. +
  11. Response wall submissions appear immediately
  12. +
  13. No admin moderation required
  14. +
  15. +

    Only use for trusted campaigns

    +
  16. +
  17. +

    Allow Anonymous

    +
  18. +
  19. Citizens can submit without creating account
  20. +
  21. Reduces friction but limits tracking
  22. +
  23. +

    Good for privacy-sensitive topics

    +
  24. +
  25. +

    Custom Recipients

    +
  26. +
  27. Override representative lookup
  28. +
  29. Send to specific email addresses
  30. +
  31. +

    Use for non-government campaigns

    +
  32. +
  33. +

    Show Progress Bar

    +
      +
    • Displays email count goal and progress
    • +
    • Motivates participation
    • +
    • Requires setting email_goal field
    • +
    +
  34. +
  35. +

    Disable After Date

    +
      +
    • Automatically unpublish after specified date
    • +
    • Good for time-sensitive campaigns
    • +
    • Requires setting disable_date field
    • +
    +
  36. +
  37. +

    Enable Comments

    +
      +
    • Allow comments on response wall entries
    • +
    • Creates discussion threads
    • +
    • Requires moderation
    • +
    +
  38. +
+

Screenshot placeholder: Campaign feature flags showing toggles for all 12 flags with descriptive labels

+
+

Recommended Defaults

+

For most campaigns, enable: Published, Has Response Wall, Require Verification. Leave others off unless specifically needed.

+
+

Configuring Email Template

+

The email template is what citizens send to their representatives. Make it:

+

Effective email guidelines:

+
    +
  • Personal: Use variables like {{USER_NAME}} to personalize
  • +
  • Clear: State the ask in first paragraph
  • +
  • Specific: Reference specific legislation or issue
  • +
  • Respectful: Professional tone, even if issue is urgent
  • +
  • Actionable: Tell representatives exactly what you want them to do
  • +
+

Example template:

+
Subject: Please vote YES on Bill C-123 for climate action
+
+Dear {{REP_NAME}},
+
+My 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.
+
+Climate change is the defining issue of our generation. This bill provides a realistic pathway to reduce emissions while protecting jobs and supporting workers.
+
+I'm specifically asking you to:
+1. Vote YES on Bill C-123 when it comes to the floor
+2. Speak publicly in support of climate action
+3. Oppose any amendments that weaken the bill
+
+Thank you for considering my views. I look forward to your response.
+
+Sincerely,
+{{USER_NAME}}
+{{USER_EMAIL}}
+
+---
+{{USER_MESSAGE}}
+
+

Available variables:

+
    +
  • {{USER_NAME}} — Citizen's full name
  • +
  • {{USER_EMAIL}} — Citizen's email address
  • +
  • {{USER_PHONE}} — Citizen's phone (if collected)
  • +
  • {{REP_NAME}} — Representative's name
  • +
  • {{REP_EMAIL}} — Representative's email
  • +
  • {{REP_TITLE}} — Representative's title (MP, MPP, Councillor)
  • +
  • {{USER_MESSAGE}} — Custom message from citizen (optional field)
  • +
+

Screenshot placeholder: Email template editor showing subject and body fields with variable insertion dropdown

+

Publishing a Campaign

+

Before publishing, verify:

+
    +
  • Email template is proofread (send test email to yourself)
  • +
  • Feature flags are configured correctly
  • +
  • Representative lookup is working (test with your postal code)
  • +
  • Response wall moderation is ready (if enabled)
  • +
+

To publish:

+
    +
  1. Edit the campaign
  2. +
  3. Toggle "Published" flag to ON
  4. +
  5. Click "Save"
  6. +
+

The campaign is now live at /campaigns/[slug].

+

Promoting your campaign:

+
    +
  • Share direct link: https://yourdomain.org/campaigns/protect-our-climate
  • +
  • Embed in email newsletter
  • +
  • Post on social media
  • +
  • Add to landing page
  • +
+

Screenshot placeholder: Published campaign card on public campaigns listing page

+

Monitoring Email Sends

+

To view email statistics:

+
    +
  1. Navigate to Influence > Campaigns
  2. +
  3. Click "Emails" button in the Actions column for your campaign
  4. +
+

The Campaign Emails drawer shows:

+

Statistics:

+
    +
  • Total emails sent
  • +
  • Successful deliveries
  • +
  • Failed deliveries
  • +
  • Emails waiting in queue
  • +
+

Email list table:

+
    +
  • Recipient name and email
  • +
  • Status (PENDING, SENT, FAILED)
  • +
  • Sent timestamp
  • +
  • Representative targeted
  • +
  • Error message (if failed)
  • +
+

Actions:

+
    +
  • Retry failed: Re-queue failed emails
  • +
  • Export CSV: Download full email list
  • +
+

Screenshot placeholder: Campaign Emails drawer showing statistics cards and email list table

+

Managing the Email Queue

+

The email queue processes advocacy emails asynchronously using BullMQ.

+

To monitor queue health:

+
    +
  1. Navigate to Influence > Email Queue
  2. +
+

Queue statistics:

+
    +
  • Waiting: Emails queued but not yet processing
  • +
  • Active: Emails currently being sent
  • +
  • Completed: Successfully sent emails (last 24 hours)
  • +
  • Failed: Failed emails requiring retry
  • +
  • Delayed: Scheduled for future sending
  • +
+

Queue controls:

+
    +
  • Pause Queue: Stop processing new emails (emergencies only)
  • +
  • Resume Queue: Restart after pause
  • +
  • Clean Completed: Remove old completed jobs (frees memory)
  • +
  • Retry Failed: Re-queue all failed emails
  • +
+
+

Queue Pausing

+

Only pause the queue during system maintenance or if email configuration is broken. Citizens expect immediate sends.

+
+

Screenshot placeholder: Email Queue page showing statistics cards, job counts, and control buttons

+

Moderating Responses

+

If your campaign has "Has Response Wall" enabled, citizens can share their stories publicly.

+

To moderate responses:

+
    +
  1. Navigate to Influence > Responses
  2. +
  3. Use filters to find pending responses
  4. +
  5. Review each response
  6. +
  7. Approve or reject
  8. +
+

Response filters:

+
    +
  • Campaign: Filter by specific campaign
  • +
  • Status: PENDING, APPROVED, REJECTED
  • +
  • Search: Search response text
  • +
  • Date range: Filter by submission date
  • +
+

Response table columns:

+
    +
  • Name: Citizen's name
  • +
  • Campaign: Which campaign
  • +
  • Status: Approval status (color-coded)
  • +
  • Upvotes: Number of upvotes received
  • +
  • Submitted: Submission date
  • +
  • Actions: View, approve, reject, delete
  • +
+

Screenshot placeholder: Responses table with filter controls and status badges

+

To review a response:

+
    +
  1. Click "View" in Actions column
  2. +
  3. Read full response text
  4. +
  5. Decide:
  6. +
  7. Approve: Make public (appears on response wall)
  8. +
  9. Reject: Hide from public (not deleted)
  10. +
  11. Delete: Permanently remove
  12. +
+

Moderation guidelines:

+

Approve responses that:

+
    +
  • Are authentic personal stories
  • +
  • Relate to the campaign issue
  • +
  • Use respectful language
  • +
  • Add value to the public conversation
  • +
+

Reject responses that:

+
    +
  • Contain profanity or hate speech
  • +
  • Are spam or off-topic
  • +
  • Violate privacy (include private information about others)
  • +
  • Are duplicate submissions
  • +
+

Screenshot placeholder: Response detail modal showing full text, citizen info, and approve/reject buttons

+
+

Location Management

+

Location Data Overview

+

Locations represent physical addresses where canvassing occurs. Each location has:

+
    +
  • Address: Street address, city, province, postal code
  • +
  • Coordinates: Latitude/longitude (from geocoding)
  • +
  • Metadata: Building type, federal district, unit count
  • +
  • Cut assignment: Which territorial cut it belongs to
  • +
  • Canvass history: Visits, outcomes, support levels
  • +
+

Importing Locations from CSV

+

To import locations:

+
    +
  1. Navigate to Map > Locations
  2. +
  3. Click "Import CSV" button
  4. +
  5. Upload CSV file
  6. +
  7. Map CSV columns to location fields
  8. +
  9. Click "Import"
  10. +
+

Required CSV columns:

+
    +
  • address — Full street address
  • +
  • city — City name
  • +
  • province — Province/state code (e.g., "ON", "BC")
  • +
  • postalCode — Postal code (e.g., "K1A 0B1")
  • +
+

Optional columns:

+
    +
  • latitude — Pre-geocoded latitude
  • +
  • longitude — Pre-geocoded longitude
  • +
  • buildingType — RESIDENTIAL, APARTMENT, BUSINESS
  • +
  • unitCount — Number of units in building
  • +
  • federalDistrict — Electoral district
  • +
  • notes — Internal notes
  • +
+

CSV example:

+
address,city,province,postalCode,buildingType
+"123 Main St","Ottawa","ON","K1A 0B1","RESIDENTIAL"
+"456 Queen St E","Toronto","ON","M5A 1T1","APARTMENT"
+"789 Granville St","Vancouver","BC","V6Z 1K3","BUSINESS"
+
+
+

Excel to CSV

+

If your data is in Excel, use "Save As" > "CSV (Comma delimited)" to export.

+
+

Screenshot placeholder: CSV import dialog showing file upload, column mapping interface, and preview table

+

NAR Import (Canadian Electoral Data)

+

For Canadian campaigns, you can import official electoral data from Elections Canada NAR (National Address Register) files.

+

To import NAR data:

+
    +
  1. Navigate to Map > Locations
  2. +
  3. Click "NAR Import" button
  4. +
  5. Select province
  6. +
  7. Choose NAR dataset (year)
  8. +
  9. Apply filters:
  10. +
  11. City filter (optional)
  12. +
  13. Postal code filter (optional)
  14. +
  15. Cut filter (assign to specific cut)
  16. +
  17. Residential only (exclude commercial)
  18. +
  19. Click "Start Import"
  20. +
+

The import runs server-side and can take several minutes for large provinces.

+

NAR data includes:

+
    +
  • Precise civic addresses (from Address files)
  • +
  • Geocoded coordinates (from Location files)
  • +
  • Federal electoral districts
  • +
  • Building use (residential, commercial, institutional)
  • +
+

Screenshot placeholder: NAR Import modal showing province selector, dataset picker, and filter options

+
+

NAR Data Source

+

NAR data must be obtained from Elections Canada and placed in the /data directory on the server. Contact your system administrator.

+
+

Geocoding Addresses

+

Geocoding converts addresses to latitude/longitude coordinates for map display.

+

Automatic geocoding:

+
    +
  • CSV imports without lat/lng are automatically geocoded
  • +
  • NAR imports include pre-geocoded coordinates
  • +
  • Manual location creation triggers geocoding
  • +
+

Manual geocoding:

+
    +
  1. Navigate to Map > Locations
  2. +
  3. Filter for "Ungeocoded" locations
  4. +
  5. Select locations to geocode
  6. +
  7. Click "Geocode Selected" (bulk action)
  8. +
+

Geocoding providers (tried in order):

+
    +
  1. Nominatim (OpenStreetMap) — Free, no API key required
  2. +
  3. ArcGIS — Free tier, accurate for North America
  4. +
  5. Photon — Free, Europe-focused
  6. +
  7. Mapbox — Requires API key, very accurate
  8. +
  9. Google — Requires API key, most accurate
  10. +
  11. LocationIQ — Requires API key, Nominatim-based
  12. +
+
+

Geocoding Quality

+

Check Map > Data Quality to review geocoding confidence levels. Re-geocode low-confidence addresses.

+
+

Screenshot placeholder: Locations table with "Geocode Selected" button and geocoding status column

+

Creating Cuts

+

Cuts are geographic areas (wards, neighborhoods, districts) used to organize canvassing.

+

To create a cut:

+
    +
  1. Navigate to Map > Cuts
  2. +
  3. Click the "Map Drawing" tab
  4. +
  5. Click "Start Drawing"
  6. +
  7. Click on the map to add polygon vertices
  8. +
  9. Close the polygon (click near first point)
  10. +
  11. Fill in cut details:
  12. +
  13. Name: Cut identifier (e.g., "Ward 5", "Downtown")
  14. +
  15. Category: WARD, NEIGHBORHOOD, DISTRICT, or CUSTOM
  16. +
  17. Color: Display color on map
  18. +
  19. Description: Internal notes
  20. +
  21. Click "Save Cut"
  22. +
+

Cut best practices:

+
    +
  • Size: 200-500 locations per cut (manageable for canvassing)
  • +
  • Boundaries: Use natural boundaries (roads, rivers, parks)
  • +
  • Naming: Use official ward/district names when available
  • +
  • Colors: Use distinct colors for adjacent cuts
  • +
+

Screenshot placeholder: Cut drawing map interface showing polygon being drawn with vertex markers

+

Assigning Locations to Cuts

+

Automatic assignment (during cut creation):

+
    +
  • Locations inside polygon are automatically assigned
  • +
  • Uses point-in-polygon algorithm
  • +
+

Manual assignment:

+
    +
  1. Navigate to Map > Locations
  2. +
  3. Select locations to assign
  4. +
  5. Choose "Assign to Cut" from bulk actions
  6. +
  7. Select target cut
  8. +
  9. Click "Assign"
  10. +
+

Viewing cut assignments:

+
    +
  • Location table has "Cut" column
  • +
  • Filter locations by cut using dropdown
  • +
+

Screenshot placeholder: Bulk action modal showing "Assign to Cut" with cut selector dropdown

+

Managing Locations

+

To edit a location:

+
    +
  1. Navigate to Map > Locations
  2. +
  3. Click "Edit" in Actions column
  4. +
  5. Modify fields:
  6. +
  7. Address details
  8. +
  9. Coordinates (manually adjust map pin)
  10. +
  11. Building type
  12. +
  13. Unit count
  14. +
  15. Notes
  16. +
  17. Cut assignment
  18. +
  19. Click "Save"
  20. +
+

To delete locations:

+
    +
  1. Select locations in table
  2. +
  3. Choose "Delete" from bulk actions
  4. +
  5. Confirm deletion
  6. +
+
+

Canvass History

+

Deleting a location preserves associated canvass visits (visits are linked to coordinates, not location records).

+
+

Screenshot placeholder: Edit Location modal showing address fields, map with draggable pin, and metadata fields

+

Exporting Walk Sheets

+

Walk sheets are printable lists of addresses for door-to-door canvassing.

+

To generate a walk sheet:

+
    +
  1. Navigate to Map > Locations
  2. +
  3. Filter to specific cut
  4. +
  5. Click "Walk Sheet" in the cut's action menu
  6. +
+

OR:

+
    +
  1. Navigate to Canvass > Walk Sheet
  2. +
  3. Select cut from dropdown
  4. +
  5. Configure settings (see below)
  6. +
  7. Click "Print"
  8. +
+

Walk sheet settings (from Map > Map Settings):

+
    +
  • Header text: Organization name, campaign info
  • +
  • Instructions: How to use the walk sheet
  • +
  • QR code: Include QR code linking to volunteer canvass map
  • +
  • Sorting: Sort by street name or walking route
  • +
  • Include map: Embed cut map on first page
  • +
+

Walk sheet contents:

+
    +
  • Cut name and statistics
  • +
  • QR code (volunteers scan to start canvass session)
  • +
  • Location table:
  • +
  • Address
  • +
  • Unit count
  • +
  • Last visit date
  • +
  • Last outcome
  • +
  • Notes field (blank for volunteers to fill)
  • +
+

Screenshot placeholder: Walk sheet PDF preview showing header, QR code, and address table

+
+

Volunteer Management

+

Creating Shifts

+

Shifts are scheduled volunteer canvassing sessions assigned to specific cuts.

+

To create a shift:

+
    +
  1. Navigate to Map > Shifts
  2. +
  3. Click "Create Shift"
  4. +
  5. Fill in shift details:
  6. +
  7. Title: Shift name (e.g., "Saturday Morning Canvass - Ward 5")
  8. +
  9. Description: Additional details for volunteers
  10. +
  11. Start Time: Shift start date and time
  12. +
  13. End Time: Shift end date and time
  14. +
  15. Cut: Which cut to canvass (optional, but recommended)
  16. +
  17. Max Signups: Capacity limit (0 = unlimited)
  18. +
  19. Meeting Location: Where volunteers should meet
  20. +
  21. Click "Create"
  22. +
+

Screenshot placeholder: Create Shift modal showing date/time picker, cut selector, and capacity field

+
+

Cut Assignment

+

Shifts assigned to a cut appear in the volunteer portal under "My Assignments" for volunteers who signed up. Volunteers can start canvassing directly from their dashboard.

+
+

Managing Shift Signups

+

To view shift signups:

+
    +
  1. Navigate to Map > Shifts
  2. +
  3. Click "Signups" in Actions column
  4. +
+

The signups drawer shows:

+
    +
  • Total signups vs capacity
  • +
  • Signup list: Name, email, role, signup date
  • +
  • Actions: Remove signup, upgrade TEMP users to USER
  • +
+

Signup sources:

+
    +
  • Public signup form: /shifts page (creates TEMP users)
  • +
  • Admin-created: You manually add volunteers
  • +
  • Volunteer portal: USER-role volunteers sign up themselves
  • +
+

Screenshot placeholder: Shift Signups drawer showing capacity gauge and signup list table

+

Emailing Shift Volunteers

+

To email all volunteers in a shift:

+
    +
  1. Navigate to Map > Shifts
  2. +
  3. Click "Signups" for the shift
  4. +
  5. Click "Email All" button
  6. +
  7. Compose email:
  8. +
  9. Subject: Email subject line
  10. +
  11. Body: Message (supports HTML)
  12. +
  13. Variables: Use {{NAME}}, {{SHIFT_TITLE}}, {{SHIFT_START}}
  14. +
  15. Click "Send"
  16. +
+

Common email scenarios:

+
    +
  • Reminder: Day before shift
  • +
  • Cancellation: Weather or other issues
  • +
  • Location change: Meeting point updated
  • +
  • Follow-up: Thank you after shift
  • +
+

Screenshot placeholder: Email Volunteers modal showing subject, body editor, and variable insertion buttons

+

Monitoring Canvass Sessions

+

To view active canvass sessions:

+
    +
  1. Navigate to Canvass > Dashboard
  2. +
+

The dashboard shows:

+

Statistics cards:

+
    +
  • Active sessions: Currently in progress
  • +
  • Total visits today: Doors knocked
  • +
  • Completed sessions: Finished today
  • +
  • Average session duration
  • +
+

Activity feed:

+
    +
  • Real-time visit stream
  • +
  • Shows: Volunteer name, address, outcome, timestamp
  • +
  • Updates every 30 seconds
  • +
+

Cut progress table:

+
    +
  • Progress by cut (% of locations visited)
  • +
  • Session count per cut
  • +
  • Visit count per cut
  • +
+

Leaderboard:

+
    +
  • Top volunteers by visit count
  • +
  • Session count
  • +
  • Success rate (SPOKE_WITH outcomes)
  • +
+

Screenshot placeholder: Canvass Dashboard showing stats cards, activity feed, and leaderboard

+

Viewing Canvass Activity Reports

+

To see detailed canvassing data:

+
    +
  1. Navigate to Canvass > Dashboard
  2. +
  3. Use filters:
  4. +
  5. Date range: Last 7 days, last 30 days, custom
  6. +
  7. Cut: Specific cut or all
  8. +
  9. Volunteer: Specific volunteer or all
  10. +
  11. Outcome: Filter by visit outcome
  12. +
+

Exportable reports:

+
    +
  • Visit history CSV: All visits with outcomes, notes, timestamps
  • +
  • Support levels CSV: LEVEL_1 through LEVEL_4 breakdown
  • +
  • Session summary CSV: Session duration, visit count, volunteer info
  • +
+

Screenshot placeholder: Activity report filters and export buttons

+
+

Site Configuration

+

Site Settings

+

To configure global site settings:

+
    +
  1. Navigate to Settings (gear icon in sidebar)
  2. +
+

Available settings:

+

Branding:

+
    +
  • Site Name: Your organization name
  • +
  • Site URL: Public website URL
  • +
  • Logo URL: URL to your logo image
  • +
  • Primary Color: Brand color (hex code)
  • +
  • Secondary Color: Accent color
  • +
+

Email Configuration:

+
    +
  • From Name: Sender name for system emails
  • +
  • From Email: Sender email address
  • +
  • SMTP Host: Email server hostname
  • +
  • SMTP Port: Usually 587 (TLS) or 465 (SSL)
  • +
  • SMTP Username: SMTP authentication username
  • +
  • SMTP Password: SMTP authentication password
  • +
  • Test Mode: Send to MailHog instead of real SMTP (dev only)
  • +
+

Representative API:

+
    +
  • Represent API Base URL: Usually https://represent.opennorth.ca
  • +
  • API Key: If required by provider
  • +
  • Cache TTL: How long to cache representative data (hours)
  • +
+

Feature Toggles:

+
    +
  • Enable Media Features: Enable video library and media management
  • +
  • Enable Listmonk Sync: Sync contacts to Listmonk newsletter platform
  • +
  • Allow Public Shift Signup: Anyone can sign up for shifts (creates TEMP users)
  • +
  • Require Email Verification: Campaign responses require email confirmation
  • +
+

Screenshot placeholder: Settings page showing branding, email, and feature toggle sections

+
+

Test Email Configuration

+

After changing SMTP settings, click "Send Test Email" to verify configuration before publishing campaigns.

+
+

Map Settings

+

To configure map defaults:

+
    +
  1. Navigate to Map > Map Settings
  2. +
+

Map Configuration:

+
    +
  • Default Center: Latitude/longitude for map center
  • +
  • Used on public map and admin map
  • +
  • Usually your city center
  • +
  • Default Zoom: Zoom level (1-18)
  • +
  • 12 = city-wide view
  • +
  • 15 = neighborhood view
  • +
  • Enable Fullscreen: Allow fullscreen button on public map
  • +
  • Enable Geolocation: Allow "Find My Location" button
  • +
+

Walk Sheet Configuration:

+
    +
  • Header Text: Appears at top of walk sheets
  • +
  • Footer Text: Appears at bottom
  • +
  • Include QR Code: Add QR code linking to volunteer map
  • +
  • QR Code Size: Small, medium, or large
  • +
  • Instructions: Text explaining how to use walk sheet
  • +
+

Screenshot placeholder: Map Settings page showing map center picker and walk sheet config

+

Feature Toggles

+

Feature toggles allow you to enable/disable major platform features without code changes.

+

To manage feature toggles:

+
    +
  1. Navigate to Settings
  2. +
  3. Scroll to Feature Toggles section
  4. +
  5. Toggle features on/off
  6. +
  7. Click "Save"
  8. +
+

Available toggles:

+

ENABLE_MEDIA_FEATURES

+
    +
  • Enables Media Library and video management
  • +
  • Shows Media menu in sidebar
  • +
  • Allows video uploads and public media gallery
  • +
  • Requires media-api service running
  • +
+

ENABLE_LISTMONK_SYNC

+
    +
  • Enables newsletter integration
  • +
  • Syncs campaign participants to Listmonk lists
  • +
  • Shows Listmonk menu in sidebar
  • +
  • Requires Listmonk service configured
  • +
+

ALLOW_PUBLIC_SHIFT_SIGNUP

+
    +
  • Public can sign up for shifts at /shifts
  • +
  • Creates TEMP user accounts automatically
  • +
  • Shows shifts on public pages
  • +
  • Disable for invitation-only volunteering
  • +
+

REQUIRE_EMAIL_VERIFICATION

+
    +
  • Campaign responses require email verification
  • +
  • Prevents spam and fake submissions
  • +
  • Sends verification link before recording response
  • +
  • Recommended for public campaigns
  • +
+

Screenshot placeholder: Feature Toggles section showing four toggles with descriptions

+
+

Media Features

+

Enabling media features requires the media-api Docker container to be running. Check with your system administrator.

+
+
+

Email Templates

+

Understanding Email Templates

+

Changemaker Lite uses email templates for system-generated emails:

+

System templates:

+
    +
  • Welcome Email: Sent to new users
  • +
  • Password Reset: Sent when user requests password reset
  • +
  • Shift Confirmation: Sent when volunteer signs up for shift
  • +
  • Shift Reminder: Sent day before shift
  • +
  • Response Verification: Sent to verify campaign response
  • +
+

Custom templates:

+
    +
  • You can create custom templates for specific needs
  • +
  • Use in shift emails, campaign follow-ups, etc.
  • +
+

Editing Templates

+

To edit an email template:

+
    +
  1. Navigate to Content > Email Templates
  2. +
  3. Click "Edit" for the template
  4. +
  5. Modify:
  6. +
  7. Subject: Email subject line
  8. +
  9. HTML Body: Rich email content
  10. +
  11. Plain Text Body: Fallback for text-only clients
  12. +
  13. Use variables (e.g., {{USER_NAME}}, {{SHIFT_TITLE}})
  14. +
  15. Click "Preview" to see rendered email
  16. +
  17. Click "Save"
  18. +
+

Screenshot placeholder: Email Template Editor showing subject field, HTML editor, and variable buttons

+

Available Variables

+

Templates support variable interpolation:

+

User variables:

+
    +
  • {{USER_NAME}} — User's full name
  • +
  • {{USER_EMAIL}} — User's email address
  • +
+

Shift variables:

+
    +
  • {{SHIFT_TITLE}} — Shift name
  • +
  • {{SHIFT_START}} — Start date/time
  • +
  • {{SHIFT_END}} — End date/time
  • +
  • {{SHIFT_LOCATION}} — Meeting location
  • +
  • {{SHIFT_CUT}} — Cut name
  • +
+

Campaign variables:

+
    +
  • {{CAMPAIGN_TITLE}} — Campaign name
  • +
  • {{CAMPAIGN_URL}} — Link to campaign page
  • +
+

System variables:

+
    +
  • {{SITE_NAME}} — Your organization name
  • +
  • {{SITE_URL}} — Website URL
  • +
+

Screenshot placeholder: Variable reference table in template editor sidebar

+

Testing Templates

+

To test an email template:

+
    +
  1. Edit the template
  2. +
  3. Click "Send Test Email"
  4. +
  5. Enter your email address
  6. +
  7. Click "Send"
  8. +
+

You'll receive the email with sample data filled in for variables.

+
+

Always Test

+

Test templates before using them in production. Check both HTML and plain text versions.

+
+
+

Media Library

+
+

Optional Feature

+

Media features must be enabled via Settings > Feature Toggles > ENABLE_MEDIA_FEATURES. Requires media-api service.

+
+

Uploading Videos

+

To upload a video:

+
    +
  1. Navigate to Content > Media > Library
  2. +
  3. Click "Upload Video"
  4. +
  5. Either:
  6. +
  7. Drag and drop video file
  8. +
  9. Click to browse and select file
  10. +
  11. Fill in metadata:
  12. +
  13. Title: Video title
  14. +
  15. Description: Video description
  16. +
  17. Producer: Organization or creator
  18. +
  19. Creator: Individual creator/director
  20. +
  21. Tags: Comma-separated tags
  22. +
  23. Directory: Organize into folders
  24. +
  25. Click "Upload"
  26. +
+

Supported formats:

+
    +
  • MP4 (recommended)
  • +
  • MOV
  • +
  • AVI
  • +
  • MKV
  • +
  • WebM
  • +
  • M4V
  • +
  • FLV
  • +
+

File size limit: 10 GB per file

+

Screenshot placeholder: Upload Video modal showing drag-drop area, metadata form, and progress bar

+

Automatic Metadata Extraction

+

When you upload a video, the system automatically extracts:

+
    +
  • Duration: Length in seconds
  • +
  • Dimensions: Width x height in pixels
  • +
  • Orientation: PORTRAIT, LANDSCAPE, or SQUARE
  • +
  • Quality: SD, HD, FULL_HD, or 4K
  • +
  • Has Audio: Boolean
  • +
  • File Size: Bytes
  • +
+

This metadata is used for filtering and organizing videos.

+

Organizing the Library

+

Directory structure:

+
    +
  • Create directories to organize videos
  • +
  • Directories are simple text paths (e.g., "events/2024", "testimonials")
  • +
  • Set directory when uploading or editing
  • +
+

Filtering videos:

+
    +
  • Search: Search title, description, tags
  • +
  • Directory: Filter by directory
  • +
  • Quality: Filter by SD, HD, etc.
  • +
  • Orientation: Portrait, landscape, square
  • +
  • Locked: Show only locked or unlocked
  • +
+

Sorting:

+
    +
  • Upload date (newest first)
  • +
  • Title (A-Z)
  • +
  • Duration (shortest first)
  • +
+

Screenshot placeholder: Media Library showing directory tree, filters, and video grid

+

Sharing Videos Publicly

+

To make videos public:

+
    +
  1. Navigate to Content > Media > Shared Media
  2. +
  3. Select videos from library
  4. +
  5. Choose category:
  6. +
  7. TESTIMONIAL
  8. +
  9. EVENT
  10. +
  11. EDUCATIONAL
  12. +
  13. PROMOTIONAL
  14. +
  15. Click "Share"
  16. +
+

Shared videos appear on the public media gallery at /media.

+

To unshare videos:

+
    +
  1. Go to Shared Media
  2. +
  3. Select videos
  4. +
  5. Click "Unshare"
  6. +
+

Screenshot placeholder: Shared Media page showing category filter and share/unshare buttons

+

Locking Videos

+

Locked videos cannot be deleted or moved. Use locks to protect important content.

+

To lock a video:

+
    +
  1. Select video in library
  2. +
  3. Click "Lock" (padlock icon)
  4. +
+

To unlock:

+
    +
  1. Select locked video
  2. +
  3. Click "Unlock"
  4. +
+
+

Lock Before Sharing

+

Lock videos before sharing publicly to prevent accidental deletion.

+
+
+

Monitoring & Reports

+

Viewing Queue Status

+

To monitor the email queue:

+
    +
  1. Navigate to Influence > Email Queue
  2. +
+

Key metrics:

+
    +
  • Waiting: Emails queued for sending
  • +
  • High number = slow processing (check SMTP)
  • +
  • Active: Currently processing
  • +
  • Should be 1-5 (concurrent workers)
  • +
  • Completed: Sent in last 24 hours
  • +
  • Failed: Delivery failures
  • +
  • Click "View Failed" to see error messages
  • +
+

Queue health indicators:

+
    +
  • Green: < 50 waiting, < 5 failed
  • +
  • Yellow: 50-200 waiting, 5-20 failed
  • +
  • Red: > 200 waiting, > 20 failed
  • +
+

Screenshot placeholder: Email Queue dashboard showing job counts with color-coded health indicators

+

Geocoding Quality Dashboard

+

To review geocoding quality:

+
    +
  1. Navigate to Map > Data Quality
  2. +
+

Quality metrics:

+
    +
  • Total locations: All location records
  • +
  • Geocoded: Have lat/lng coordinates
  • +
  • Ungeocoded: Missing coordinates
  • +
  • Low confidence: Confidence < 0.5
  • +
  • Medium confidence: 0.5-0.8
  • +
  • High confidence: > 0.8
  • +
+

Quality breakdown:

+
    +
  • Provider distribution: Which geocoding service was used
  • +
  • Confidence histogram: Distribution of confidence scores
  • +
  • Error analysis: Common geocoding failures
  • +
+

Actions:

+
    +
  • Re-geocode low confidence: Retry with different provider
  • +
  • Export ungeocoded: CSV of failed addresses
  • +
  • Manual review: Edit addresses and re-geocode
  • +
+

Screenshot placeholder: Data Quality Dashboard showing geocoding statistics and confidence distribution chart

+

Canvass Completion Statistics

+

To view canvass progress:

+
    +
  1. Navigate to Canvass > Dashboard
  2. +
+

Completion metrics:

+
    +
  • Locations visited: Total unique addresses visited
  • +
  • Visit rate: Visits per day/week
  • +
  • Completion by cut: % of each cut visited
  • +
  • Outcome breakdown: % NOT_HOME, REFUSED, SPOKE_WITH, etc.
  • +
+

Support level analysis:

+
    +
  • LEVEL_1 (Strong support): Count and percentage
  • +
  • LEVEL_2 (Leaning support): Count and percentage
  • +
  • LEVEL_3 (Undecided): Count and percentage
  • +
  • LEVEL_4 (Opposition): Count and percentage
  • +
+

Volunteer performance:

+
    +
  • Sessions per volunteer: Distribution histogram
  • +
  • Visits per volunteer: Leaderboard
  • +
  • Average session duration: Time spent canvassing
  • +
+

Screenshot placeholder: Canvass statistics showing completion gauges, outcome pie chart, and support level breakdown

+

Observability Dashboard

+

To monitor system health:

+
    +
  1. Navigate to Observability
  2. +
+

The observability dashboard has three tabs:

+

Metrics Tab

+
    +
  • Custom metrics: 12 cm_* Prometheus metrics
  • +
  • API uptime
  • +
  • Request counts
  • +
  • Email queue size
  • +
  • Active sessions
  • +
  • Geocoding success rate
  • +
  • HTTP metrics: Request duration, status codes
  • +
  • System metrics: CPU, memory, disk
  • +
+

Screenshot placeholder: Metrics tab showing API uptime gauge and request count graph

+

Dashboards Tab

+
    +
  • Links to Grafana dashboards:
  • +
  • API Health (uptime, response times, error rates)
  • +
  • Queue Monitoring (email queue, geocoding queue)
  • +
  • Canvassing Activity (sessions, visits, outcomes)
  • +
  • Click dashboard name to open in Grafana
  • +
+

Screenshot placeholder: Dashboards tab showing three dashboard cards with "Open" buttons

+

Alerts Tab

+
    +
  • Active alerts: Currently firing alerts
  • +
  • Alert history: Recent resolved alerts
  • +
  • Alert rules: Configured thresholds
  • +
  • Silence alerts: Temporarily mute alerts
  • +
+

Common alerts:

+
    +
  • API Down: API not responding
  • +
  • High Error Rate: > 5% requests failing
  • +
  • Queue Backed Up: > 1000 emails waiting
  • +
  • Disk Space Low: < 10% free space
  • +
+

Screenshot placeholder: Alerts tab showing active alert for "Queue Backed Up" with severity and details

+
+

Troubleshooting

+

Common Admin Issues

+

Issue: Cannot Log In

+

Symptoms: "Invalid credentials" error

+

Solutions:

+
    +
  1. Verify email address: Check for typos, spaces
  2. +
  3. Try password reset: Use "Forgot Password" link
  4. +
  5. Check account status: Ask another admin if account is suspended
  6. +
  7. Check browser console: Look for API errors
  8. +
+

Issue: Emails Not Sending

+

Symptoms: Emails stuck in "Waiting" status

+

Solutions:

+
    +
  1. Check SMTP configuration:
  2. +
  3. Navigate to Settings
  4. +
  5. Verify SMTP host, port, username, password
  6. +
  7. Click "Send Test Email"
  8. +
  9. Check email queue:
  10. +
  11. Navigate to Influence > Email Queue
  12. +
  13. Look for error messages in failed jobs
  14. +
  15. Check email test mode:
  16. +
  17. If EMAIL_TEST_MODE=true, emails go to MailHog (not real recipients)
  18. +
  19. Change in environment settings
  20. +
  21. Restart queue worker:
  22. +
  23. Ask system administrator to restart api service
  24. +
+

Issue: CSV Import Fails

+

Symptoms: Error during CSV upload

+

Solutions:

+
    +
  1. Check CSV format:
  2. +
  3. Must be valid CSV (comma-separated)
  4. +
  5. First row must be headers
  6. +
  7. Required columns: address, city, province, postalCode
  8. +
  9. Check file encoding:
  10. +
  11. Use UTF-8 encoding
  12. +
  13. Excel users: "Save As" > "CSV UTF-8"
  14. +
  15. Check file size:
  16. +
  17. Maximum 10,000 rows per import
  18. +
  19. Split large files
  20. +
  21. Check for special characters:
  22. +
  23. Remove emoji, unusual symbols
  24. +
  25. Use standard quotes ("not "" or '')
  26. +
+

Issue: Geocoding Fails

+

Symptoms: Addresses remain ungeocoded after import

+

Solutions:

+
    +
  1. Check address format:
  2. +
  3. Include full civic address
  4. +
  5. Include city and postal code
  6. +
  7. Use standard abbreviations (St, Ave, Rd)
  8. +
  9. Check geocoding providers:
  10. +
  11. Navigate to Map > Data Quality
  12. +
  13. See which providers are responding
  14. +
  15. Try manual geocoding:
  16. +
  17. Edit location
  18. +
  19. Click and drag map pin to correct position
  20. +
  21. Save
  22. +
  23. Use NAR data (Canada only):
  24. +
  25. NAR import includes pre-geocoded coordinates
  26. +
  27. More reliable than automatic geocoding
  28. +
+

Issue: Map Not Loading

+

Symptoms: Blank map or loading spinner

+

Solutions:

+
    +
  1. Check browser console: Look for JavaScript errors
  2. +
  3. Check internet connection: Map tiles require network
  4. +
  5. Try different browser: Test in Chrome, Firefox
  6. +
  7. Clear browser cache: Hard refresh (Ctrl+Shift+R)
  8. +
  9. Check locations:
  10. +
  11. Navigate to Map > Locations
  12. +
  13. Verify locations have coordinates
  14. +
  15. At least one location needed to display map
  16. +
+

Issue: Campaign Not Appearing Publicly

+

Symptoms: Campaign visible in admin but not on /campaigns

+

Solutions:

+
    +
  1. Check "Published" flag:
  2. +
  3. Edit campaign
  4. +
  5. Ensure "Published" toggle is ON
  6. +
  7. Save
  8. +
  9. Check URL:
  10. +
  11. Campaign URL is /campaigns/[slug]
  12. +
  13. Slug is auto-generated from title
  14. +
  15. Must be unique
  16. +
  17. Clear browser cache: Public pages may be cached
  18. +
  19. Check representative lookup:
  20. +
  21. Test with your postal code
  22. +
  23. If lookup fails, campaign won't display form
  24. +
+

Issue: Volunteer Cannot Start Canvass Session

+

Symptoms: Error when volunteer clicks "Start Canvassing"

+

Solutions:

+
    +
  1. Check shift assignment:
  2. +
  3. Navigate to Map > Shifts
  4. +
  5. Verify shift has a cut assigned
  6. +
  7. Shifts without cuts cannot be canvassed
  8. +
  9. Check volunteer role:
  10. +
  11. Navigate to Users
  12. +
  13. Verify volunteer is USER role (not TEMP)
  14. +
  15. Upgrade TEMP users to USER
  16. +
  17. Check cut locations:
  18. +
  19. Navigate to Map > Cuts
  20. +
  21. Verify cut has locations assigned
  22. +
  23. Empty cuts cannot be canvassed
  24. +
  25. Check for existing session:
  26. +
  27. Volunteer may have abandoned session
  28. +
  29. Ask admin to close abandoned session
  30. +
+

Getting Help

+

Documentation:

+
    +
  • Feature docs: /docs/v2/features/ (detailed feature guides)
  • +
  • API reference: /docs/v2/api/ (API endpoint documentation)
  • +
  • User guides: /docs/v2/user-guides/ (this guide and others)
  • +
  • Deployment: /docs/v2/deployment/ (server setup, Docker, backups)
  • +
+

Support channels:

+
    +
  • GitHub Issues: Report bugs, request features
  • +
  • Community Forum: Ask questions, share tips
  • +
  • Email Support: Contact your system administrator
  • +
+

Before asking for help:

+
    +
  1. Check browser console for errors (F12)
  2. +
  3. Try in different browser
  4. +
  5. Check server logs (if you have access)
  6. +
  7. Document steps to reproduce issue
  8. +
+
+ + +
+

Last updated: February 2026 (V2 complete)

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/user-guides/campaign-manager-guide/index.html b/mkdocs/site/v2/user-guides/campaign-manager-guide/index.html new file mode 100644 index 00000000..85cd9559 --- /dev/null +++ b/mkdocs/site/v2/user-guides/campaign-manager-guide/index.html @@ -0,0 +1,7883 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Campaign Manager Guide - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Campaign Manager Guide

+

Overview

+

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:

+
    +
  • Plan effective campaigns: Set goals, define targets, craft messaging
  • +
  • Configure campaigns: Set up email templates, feature flags, and targeting
  • +
  • Launch campaigns: Publish and promote to maximize participation
  • +
  • Monitor performance: Track email sends, response rates, and engagement
  • +
  • Optimize results: A/B test messaging, improve conversion, encourage responses
  • +
  • Moderate content: Review and approve response wall submissions
  • +
+

Whether you're running a small local campaign or a national advocacy push, this guide provides strategies and best practices for success.

+
+

Understanding Campaign Roles

+

You may have one of two roles that allow campaign management:

+

SUPER_ADMIN

+
    +
  • Access: Full platform access
  • +
  • Capabilities: All campaign functions plus user management, site settings, etc.
  • +
  • Use case: Primary administrator
  • +
+

INFLUENCE_ADMIN

+
    +
  • Access: Influence module only
  • +
  • Capabilities:
  • +
  • Create and edit campaigns
  • +
  • Moderate responses
  • +
  • Monitor email queue
  • +
  • View representative cache
  • +
  • Restrictions: Cannot manage users, locations, or site settings
  • +
  • Use case: Dedicated campaign manager without full admin access
  • +
+
+

Role Specialization

+

If you only manage campaigns (not volunteers or locations), ask for INFLUENCE_ADMIN role. This keeps the interface focused on your work.

+
+
+

Planning a Campaign

+

Defining Campaign Goals

+

Before creating a campaign in the system, clarify your objectives:

+

Advocacy goals:

+
    +
  1. Awareness: Educate the public about an issue
  2. +
  3. Pressure: Generate constituent contact to influence decision-makers
  4. +
  5. Mobilization: Build a list of supporters for future action
  6. +
  7. Visibility: Demonstrate public support through response wall
  8. +
+

Measurable targets:

+
    +
  • Email goal: How many emails do you want sent?
  • +
  • Example: "1,000 emails to MPs by end of month"
  • +
  • Response goal: How many public responses?
  • +
  • Example: "100 personal stories shared on response wall"
  • +
  • Conversion rate: What % of visitors should take action?
  • +
  • Benchmark: 5-15% is typical for advocacy campaigns
  • +
  • Timeline: When does the campaign start/end?
  • +
  • Align with legislative calendar, events, deadlines
  • +
+

Example campaign plan:

+
Campaign: Stop Bill 123 - Protect Clean Water
+Goal: Generate 5,000 emails to provincial MPPs before second reading vote
+Target audience: Ontario residents (all 124 ridings)
+Timeline: 3 weeks (Feb 1-22)
+Success metrics:
+- 5,000+ emails sent
+- 500+ response wall submissions
+- 10% conversion rate (visitors → emails sent)
+- 50% email delivery success rate
+
+

Understanding Your Target Audience

+

Who are you trying to reach?

+

By government level:

+
    +
  • Federal campaigns: Target MPs (Members of Parliament)
  • +
  • Use for: National legislation, federal regulations, federal budgets
  • +
  • +

    Example: "Urge your MP to support climate action"

    +
  • +
  • +

    Provincial campaigns: Target MPPs/MLAs (provincial legislators)

    +
  • +
  • Use for: Provincial laws, education, healthcare, transportation
  • +
  • +

    Example: "Tell your MPP to fund public transit"

    +
  • +
  • +

    Municipal campaigns: Target city councillors, mayors

    +
  • +
  • Use for: Local zoning, development, city services
  • +
  • Example: "Ask your councillor to protect the park"
  • +
+

By geography:

+
    +
  • National: All postal codes
  • +
  • Provincial: Specific province(s)
  • +
  • Municipal: Specific city or ward
  • +
  • Custom: Specific ridings or districts
  • +
+

By demographics (requires custom targeting):

+
    +
  • Age groups
  • +
  • Interests
  • +
  • Previous engagement
  • +
+
+

Representative Lookup

+

Changemaker Lite uses postal codes to look up representatives via the Represent API. Ensure your target government level has postal code coverage.

+
+

Crafting Your Message

+

Your campaign email is the core of your advocacy effort. It should be:

+

1. Personal

+
    +
  • Written in first person ("I am writing to...")
  • +
  • Uses resident's name and contact info
  • +
  • Mentions specific representative's name
  • +
+

2. Clear and Specific

+
    +
  • States the ask in the first paragraph
  • +
  • References specific legislation (bill number, name)
  • +
  • Explains what you want the representative to do
  • +
+

3. Compelling

+
    +
  • Explains why the issue matters
  • +
  • Uses facts and statistics (credibly sourced)
  • +
  • Includes emotional appeal (stories, impacts)
  • +
+

4. Actionable

+
    +
  • Numbered list of specific requests
  • +
  • Clear deadline (if applicable)
  • +
  • Follow-up mechanism (reply, meeting, public statement)
  • +
+

5. Respectful

+
    +
  • Professional tone
  • +
  • Acknowledges representative's position
  • +
  • Thanks them for considering your views
  • +
+

Example effective email:

+
Subject: Vote YES on Bill C-234 to Support Family Farms
+
+Dear [Representative Name],
+
+My name is [Your Name], and I am a constituent in [Riding]. I'm writing
+to urge you to vote YES on Bill C-234, which would exempt farmers from
+the carbon tax on natural gas and propane used for farming.
+
+Family farms are the backbone of our food system, yet they face rising
+costs that threaten their viability. This bill would save farmers an
+average of $14,000 per year, helping them stay in business and keep
+food prices stable.
+
+I'm specifically asking you to:
+1. Vote YES when Bill C-234 comes to the floor
+2. Speak publicly in support of family farms
+3. Oppose any amendments that weaken the bill
+
+Farming is already a low-margin business. Every dollar counts. Please
+support our farmers by supporting this bill.
+
+Thank you for considering my views. I look forward to hearing your
+position on this important issue.
+
+Sincerely,
+[Your Name]
+[Your Email]
+[Your Phone]
+
+

What makes this email effective:

+
    +
  • ✅ Specific bill number (C-234)
  • +
  • ✅ Clear ask (vote YES)
  • +
  • ✅ Compelling reason (saves $14k/year)
  • +
  • ✅ Numbered action items
  • +
  • ✅ Respectful tone
  • +
  • ✅ Personal voice
  • +
+
+

Creating a Campaign

+

Basic Campaign Setup

+

To create a new campaign:

+
    +
  1. Navigate to Influence > Campaigns
  2. +
  3. Click "Create Campaign"
  4. +
  5. Fill in the form (detailed below)
  6. +
  7. Click "Create"
  8. +
+

Your campaign starts in DRAFT status (not published).

+

Campaign Fields

+

Title

+

What it is: Public-facing campaign name

+

Best practices:

+
    +
  • Keep it short (3-7 words)
  • +
  • Make it action-oriented
  • +
  • Include the issue/goal
  • +
  • Avoid jargon or acronyms
  • +
+

Examples:

+
    +
  • ✅ "Protect Our Forests from Logging"
  • +
  • ✅ "Fund Public Transit Now"
  • +
  • ✅ "Stop Bill 123"
  • +
  • ❌ "Environmental Advocacy Initiative 2024" (too vague)
  • +
  • ❌ "FPTA Campaign" (acronym unclear)
  • +
+

Slug

+

What it is: URL-friendly identifier, auto-generated from title

+

Format: lowercase, hyphens for spaces, no special characters

+

Examples:

+
    +
  • Title: "Protect Our Forests" → Slug: protect-our-forests
  • +
  • Title: "Fund Public Transit" → Slug: fund-public-transit
  • +
+

Used in URL: https://yoursite.org/campaigns/protect-our-forests

+
+

Slug Uniqueness

+

Slugs must be unique. If you try to use a duplicate, the system will add a number (e.g., protect-our-forests-2).

+
+

Description

+

What it is: Campaign overview shown on listing page and campaign detail page

+

Best practices:

+
    +
  • 2-3 sentences
  • +
  • Explain the issue briefly
  • +
  • Explain why it matters
  • +
  • Include call to action
  • +
  • HTML supported (bold, links, etc.)
  • +
+

Example:

+
<p>Ancient forests in our region are being clear-cut at an alarming rate.
+These forests provide habitat for endangered species, clean our air and
+water, and offer recreational spaces for our communities.</p>
+
+<p><strong>Tell your MPP to enact a moratorium on old-growth logging
+until sustainable forestry practices are in place.</strong></p>
+
+

Government Level

+

What it is: Which level of government to target for representative lookup

+

Options:

+
    +
  • FEDERAL: MPs (Members of Parliament)
  • +
  • PROVINCIAL: MPPs/MLAs (provincial/territorial legislators)
  • +
  • MUNICIPAL: City councillors, mayors
  • +
+

You can select multiple levels if your issue spans jurisdictions.

+

Example scenarios:

+
    +
  • Climate legislation → FEDERAL only
  • +
  • Education funding → PROVINCIAL only
  • +
  • Park development → MUNICIPAL only
  • +
  • Transit expansion → PROVINCIAL + MUNICIPAL (both levels involved)
  • +
+

Email Subject

+

What it is: Subject line for emails citizens send to representatives

+

Best practices:

+
    +
  • Keep under 60 characters (avoids truncation)
  • +
  • Start with action verb (Support, Oppose, Protect, Fund)
  • +
  • Include specific bill/issue name
  • +
  • Use variables for personalization
  • +
+

Variables available:

+
    +
  • {{USER_NAME}} — Sender's name
  • +
  • {{REP_NAME}} — Representative's name
  • +
  • {{REP_TITLE}} — Representative's title (MP, MPP, Councillor)
  • +
+

Examples:

+
    +
  • ✅ "Please support Bill C-234 for family farms"
  • +
  • ✅ "Vote YES on climate action legislation"
  • +
  • ✅ "Oppose the proposed park development"
  • +
  • ❌ "Your constituent has an important message for you" (too vague)
  • +
  • ❌ "I am writing to express my concern about the environmental degradation..." (too long)
  • +
+

Email Body

+

What it is: The email message template citizens send

+

Structure:

+
Greeting (uses {{REP_NAME}})
+
+Opening paragraph: Who I am, why I'm writing
+
+Body paragraphs: Issue explanation, impact, evidence
+
+Specific asks: Numbered list of actions
+
+Closing: Thank you, request for response
+
+Signature (uses {{USER_NAME}}, {{USER_EMAIL}}, etc.)
+
+Optional: User's personal message ({{USER_MESSAGE}})
+
+

Variables available:

+
    +
  • {{USER_NAME}} — Citizen's full name
  • +
  • {{USER_EMAIL}} — Citizen's email
  • +
  • {{USER_PHONE}} — Citizen's phone (if collected)
  • +
  • {{REP_NAME}} — Representative's name
  • +
  • {{REP_EMAIL}} — Representative's email
  • +
  • {{REP_TITLE}} — Representative's title (MP, MPP, Councillor)
  • +
  • {{USER_MESSAGE}} — Citizen's custom message (optional field on form)
  • +
+

Tips:

+
    +
  • Use HTML editor for formatting (bold, lists, links)
  • +
  • Include {{USER_MESSAGE}} at the end so citizens can add personal stories
  • +
  • Keep base template to 200-400 words (short enough to read, detailed enough to be persuasive)
  • +
  • Preview before publishing (send test email to yourself)
  • +
+

Cover Photo (Optional)

+

What it is: Image shown on campaign listing and detail pages

+

Best practices:

+
    +
  • Use high-quality image (at least 1200x630 px)
  • +
  • Relevant to issue (photo of forest for forestry campaign, etc.)
  • +
  • Not too busy (text overlays should be readable)
  • +
  • Use your own photos or Creative Commons licensed images
  • +
+

Upload: Provide URL to image (must host image externally or use media library)

+
+

Configuring Feature Flags

+

Feature flags control campaign functionality. Here's a detailed guide on when to use each:

+

Core Feature Flags

+

1. Published

+

What it does: Makes campaign visible on public listing page

+

When to enable:

+
    +
  • ✅ Campaign is ready to launch
  • +
  • ✅ Email template is proofread and tested
  • +
  • ✅ Representative lookup is working
  • +
+

When to disable:

+
    +
  • ❌ Campaign is still being built (draft)
  • +
  • ❌ Campaign has ended (or use disable_after_date)
  • +
  • ❌ Need to make changes (unpublish temporarily)
  • +
+
+

Unpublishing

+

Unpublishing a campaign removes it from the public listing but preserves all data (emails sent, responses, etc.). The campaign page URL still works for anyone with a direct link.

+
+ +

What it does: Displays campaign prominently at top of listing page

+

When to enable:

+
    +
  • ✅ Highest priority campaign
  • +
  • ✅ Time-sensitive (vote happening soon)
  • +
  • ✅ Major organizational focus
  • +
+

Best practices:

+
    +
  • Limit to 2-3 featured campaigns
  • +
  • Rotate featured status based on priority
  • +
  • Feature new campaigns for first week to boost initial signups
  • +
+

3. Has Response Wall

+

What it does: Allows citizens to share personal stories publicly after emailing

+

When to enable:

+
    +
  • ✅ You want to showcase public support
  • +
  • ✅ You have capacity for moderation (unless auto-approve)
  • +
  • ✅ Issue benefits from personal stories
  • +
+

When to disable:

+
    +
  • ❌ Privacy concerns (sensitive issues)
  • +
  • ❌ No moderation capacity
  • +
  • ❌ Campaign is purely about email volume (not stories)
  • +
+

Moderation required: Unless auto_approve_responses is enabled, all responses must be manually approved.

+

Advanced Feature Flags

+

4. Collect Phone Numbers

+

What it does: Adds optional phone number field to campaign form

+

When to enable:

+
    +
  • ✅ Running a blended email + phone campaign
  • +
  • ✅ Want to follow up with phone calls
  • +
  • ✅ Building contact list for future outreach
  • +
+

When to disable:

+
    +
  • ❌ Privacy concerns (reduces conversion)
  • +
  • ❌ No plan to use phone numbers
  • +
+

Data usage: Phone numbers are stored in campaign responses and visible to admins.

+

5. Track Calls

+

What it does: Adds "I called my representative" button and tracks call attempts

+

When to enable:

+
    +
  • ✅ Running a call-in campaign
  • +
  • ✅ Encouraging both emails and calls
  • +
  • ✅ Want to track total contact attempts (emails + calls)
  • +
+

How it works:

+
    +
  • After sending email, user sees "I also called" button
  • +
  • Clicking increments call counter
  • +
  • Calls tracked separately from emails
  • +
+

6. Require Verification

+

What it does: Sends verification email before recording email send

+

When to enable:

+
    +
  • ✅ Public campaigns (prevents spam)
  • +
  • ✅ High-profile campaigns (media attention)
  • +
  • ✅ Need accurate email counts
  • +
+

When to disable:

+
    +
  • ❌ Internal campaigns (trusted users only)
  • +
  • ❌ Want to reduce friction (lowers completion rate by ~20%)
  • +
+

How it works:

+
    +
  1. User fills out form and clicks "Send"
  2. +
  3. System sends verification email
  4. +
  5. User clicks link in email
  6. +
  7. Email to representative is sent
  8. +
  9. Response is recorded
  10. +
+
+

Recommended

+

Enable verification for all public campaigns to prevent spam and ensure data quality.

+
+

7. Auto Approve Responses

+

What it does: Response wall submissions appear immediately without moderation

+

When to enable:

+
    +
  • ✅ Trusted audience (members-only campaign)
  • +
  • ✅ Low-risk issue (unlikely to attract trolls)
  • +
  • ✅ No moderation capacity
  • +
+

When to disable:

+
    +
  • ❌ Public campaigns (risk of spam/abuse)
  • +
  • ❌ Controversial issues (may attract hostile responses)
  • +
  • ❌ Need quality control
  • +
+
+

Moderation Recommended

+

Most public campaigns should NOT auto-approve. Manual moderation ensures quality and prevents abuse.

+
+

8. Allow Anonymous

+

What it does: Citizens can send emails without creating an account

+

When to enable:

+
    +
  • ✅ Want to maximize participation
  • +
  • ✅ Privacy-sensitive issue
  • +
  • ✅ One-time campaign (no need to track individuals)
  • +
+

When to disable:

+
    +
  • ❌ Building supporter list (want account creation)
  • +
  • ❌ Need to prevent duplicate submissions
  • +
  • ❌ Want to track individual engagement over time
  • +
+

Trade-offs:

+
    +
  • ✅ Higher conversion (less friction)
  • +
  • ❌ Cannot prevent duplicate emails from same person
  • +
  • ❌ No account to re-engage supporters later
  • +
+

9. Custom Recipients

+

What it does: Override representative lookup and send to specific email addresses

+

When to enable:

+
    +
  • ✅ Targeting non-government decision-makers (corporate executives, university presidents)
  • +
  • ✅ Representative lookup doesn't cover your target (small municipalities)
  • +
  • ✅ Want to target specific individuals regardless of postal code
  • +
+

How to use:

+
    +
  1. Enable flag
  2. +
  3. Enter comma-separated email addresses in custom_recipient_emails field
  4. +
  5. Optionally enter custom recipient names in custom_recipient_names field
  6. +
+

Example:

+
custom_recipient_emails: ceo@corporation.com,president@university.edu
+custom_recipient_names: CEO John Smith,University President Jane Doe
+
+

All emails will go to these addresses instead of postal code lookup.

+

10. Show Progress Bar

+

What it does: Displays progress bar showing emails sent toward goal

+

When to enable:

+
    +
  • ✅ Have a specific email goal
  • +
  • ✅ Want to motivate participation ("We're 75% to our goal!")
  • +
  • ✅ Creating urgency
  • +
+

How to use:

+
    +
  1. Enable flag
  2. +
  3. Set email_goal field (e.g., 1000)
  4. +
  5. Progress bar appears on campaign page showing current count / goal
  6. +
+

Example display:

+
[=========>           ] 734 / 1,000 emails sent (73%)
+
+
+

Set Realistic Goals

+

Research similar campaigns to set achievable goals. Falling short publicly can be demotivating.

+
+

11. Disable After Date

+

What it does: Automatically unpublish campaign after specified date

+

When to enable:

+
    +
  • ✅ Time-sensitive campaign (vote deadline)
  • +
  • ✅ Want campaign to auto-close
  • +
  • ✅ Don't want to manually unpublish
  • +
+

How to use:

+
    +
  1. Enable flag
  2. +
  3. Set disable_date field (date picker)
  4. +
  5. Campaign automatically unpublishes at midnight on that date
  6. +
+

Example:

+

Legislative vote is March 15. Set disable_date to March 15, 2024. Campaign automatically closes that day.

+

12. Enable Comments

+

What it does: Allows comments on response wall entries (discussion threads)

+

When to enable:

+
    +
  • ✅ Want to encourage discussion
  • +
  • ✅ Have moderation capacity for comments
  • +
  • ✅ Building community
  • +
+

When to disable:

+
    +
  • ❌ No comment moderation capacity
  • +
  • ❌ Risk of hostile/off-topic discussion
  • +
  • ❌ Prefer clean, simple response wall
  • +
+
+

Experimental Feature

+

Comments require additional moderation. Consider carefully before enabling.

+
+
+

Email Template Best Practices

+

Writing Effective Subject Lines

+

Do:

+
    +
  • ✅ Keep under 60 characters
  • +
  • ✅ Start with action verb (Vote, Support, Oppose, Protect)
  • +
  • ✅ Include bill number or issue name
  • +
  • ✅ Create urgency (if appropriate)
  • +
+

Don't:

+
    +
  • ❌ Use ALL CAPS (looks like spam)
  • +
  • ❌ Use excessive punctuation (!!!)
  • +
  • ❌ Make false claims or exaggerations
  • +
  • ❌ Use clickbait ("You won't believe...")
  • +
+

Examples:

+ + + + + + + + + + + + + + + + + + + + + +
GoodWhy
"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
+ + + + + + + + + + + + + + + + + + + + + +
BadWhy
"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
+

Structuring the Email Body

+

Recommended structure:

+
1. Greeting
+   Dear {{REP_NAME}},
+
+2. Introduction (1 sentence)
+   Who you are, where you live
+
+3. Main ask (1 sentence)
+   What you want them to do
+
+4. Context (2-3 sentences)
+   Why it matters, impact, urgency
+
+5. Evidence (2-3 sentences)
+   Facts, statistics, expert opinions
+
+6. Specific actions (numbered list)
+   Exactly what you want them to do
+
+7. Closing (1-2 sentences)
+   Thank you, request for response
+
+8. Signature
+   {{USER_NAME}}
+   {{USER_EMAIL}}
+
+9. Personal message (optional)
+   {{USER_MESSAGE}}
+
+

Using Variables Effectively

+

Available variables:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDescriptionExample Output
{{USER_NAME}}Sender's full name"John Smith"
{{USER_EMAIL}}Sender's email"john@example.com"
{{USER_PHONE}}Sender's phone"555-1234"
{{REP_NAME}}Representative's name"Hon. Jane Doe"
{{REP_EMAIL}}Representative's email"jane.doe@parl.gc.ca"
{{REP_TITLE}}Representative's title"Member of Parliament"
{{USER_MESSAGE}}Custom message(whatever user typed)
+

Best practices:

+
    +
  1. Always use {{REP_NAME}} in greeting — Personalizes email
  2. +
  3. Include {{USER_NAME}} in signature — Shows it's from a real person
  4. +
  5. Add {{USER_MESSAGE}} at end — Allows personalization
  6. +
  7. Use {{REP_TITLE}} for variety — Avoid repeating "Member of Parliament"
  8. +
+

Example usage:

+
Dear {{REP_NAME}},
+
+My name is {{USER_NAME}}, and I am a constituent in your riding. As a
+{{REP_TITLE}}, you have the power to make a difference on this issue.
+
+[... campaign message ...]
+
+I look forward to hearing your position on this matter. You can reach me
+at {{USER_EMAIL}}.
+
+Sincerely,
+{{USER_NAME}}
+
+---
+
+{{USER_MESSAGE}}
+
+

HTML Formatting Tips

+

The email editor supports HTML. Use formatting to improve readability:

+

Headings:

+
<h3>Why This Matters</h3>
+
+

Bold text:

+
<strong>Vote YES on Bill C-123</strong>
+
+

Lists:

+
<p>I'm asking you to:</p>
+<ol>
+  <li>Vote YES when the bill comes to the floor</li>
+  <li>Speak publicly in support</li>
+  <li>Oppose weakening amendments</li>
+</ol>
+
+

Links:

+
<a href="https://example.com/research">Read the full study here</a>
+
+

Line breaks:

+
<p>First paragraph.</p>
+<p>Second paragraph.</p>
+
+
+

Email Client Compatibility

+

Avoid complex CSS or JavaScript. Stick to basic HTML tags (p, strong, em, ul, ol, a). Many email clients strip advanced formatting.

+
+
+

Publishing Your Campaign

+

Pre-Launch Checklist

+

Before publishing, verify:

+
    +
  • Email template proofread — No typos, grammar errors
  • +
  • Variables working — Test with your own postal code
  • +
  • Representative lookup functional — Test multiple postal codes
  • +
  • Feature flags configured — Review all 12 flags
  • +
  • Cover photo uploaded — Image displays correctly
  • +
  • Response wall ready — Moderation plan in place (if enabled)
  • +
  • Email goal set — If using progress bar
  • +
  • Disable date set — If time-sensitive campaign
  • +
  • Test email sent — Send to yourself, verify formatting
  • +
+

To send a test email:

+
    +
  1. Edit the campaign
  2. +
  3. Scroll to email section
  4. +
  5. Click "Send Test Email"
  6. +
  7. Enter your email address
  8. +
  9. Check your inbox
  10. +
+

The test email uses sample data for variables.

+

Publishing

+

To publish:

+
    +
  1. Edit the campaign
  2. +
  3. Toggle "Published" flag to ON
  4. +
  5. Click "Save"
  6. +
+

The campaign is now live at /campaigns/[slug].

+

Promoting Your Campaign

+

Promotion channels:

+
    +
  1. Direct link: Share https://yoursite.org/campaigns/protect-our-forests
  2. +
  3. Email newsletter: Include in your regular newsletter
  4. +
  5. Social media: Post on Facebook, Twitter, Instagram with link
  6. +
  7. Website: Add to your main website's homepage or action page
  8. +
  9. Partner organizations: Ask allies to share
  10. +
  11. Earned media: Pitch to journalists, bloggers
  12. +
+

Sample social media post:

+
🌲 Our forests are in danger. Tell your MPP to stop old-growth logging.
+
+📧 Send an email in under 2 minutes: [link]
+
+So far, [X] people have taken action. Will you join them?
+
+#ProtectOurForests #ClimateAction
+
+

Sample email newsletter:

+
Subject: Take Action: Protect Our Forests
+
+Hi [Name],
+
+Ancient forests in our region are being clear-cut at an alarming rate.
+But we can stop this.
+
+[Your MPP's name] has the power to enact a moratorium on old-growth
+logging. We need you to tell them this matters to you.
+
+[CALL TO ACTION BUTTON: Send Your Email Now]
+
+It takes less than 2 minutes. Over [X] people have already sent emails.
+Together, we can make a difference.
+
+Thank you for taking action,
+[Your organization]
+
+
+

Monitoring Performance

+

Campaign Email Statistics

+

To view email stats:

+
    +
  1. Navigate to Influence > Campaigns
  2. +
  3. Click "Emails" button for your campaign
  4. +
+

The drawer shows:

+

Overall statistics:

+
    +
  • Total emails sent: All emails successfully delivered
  • +
  • Emails waiting: Queued but not yet sent
  • +
  • Failed emails: Delivery failures
  • +
  • Success rate: Sent / (Sent + Failed)
  • +
+

Email list table:

+
    +
  • Sender name and email
  • +
  • Recipient representative
  • +
  • Status (PENDING, SENT, FAILED)
  • +
  • Sent timestamp
  • +
  • Error message (if failed)
  • +
+

Screenshot placeholder: Campaign Emails drawer showing statistics and email list

+

Understanding Email Status

+

PENDING:

+
    +
  • Email is queued for sending
  • +
  • Usually sent within minutes
  • +
  • If stuck for > 1 hour, check queue (see below)
  • +
+

SENT:

+
    +
  • Email successfully delivered to representative
  • +
  • Does NOT guarantee representative read it (that's on them)
  • +
+

FAILED:

+
    +
  • Email delivery failed
  • +
  • Common reasons:
  • +
  • Invalid recipient email (representative email wrong in database)
  • +
  • SMTP error (email server rejected)
  • +
  • Network timeout
  • +
+

Retry failed emails:

+
    +
  1. Click "Retry Failed" button
  2. +
  3. System re-queues failed emails
  4. +
  5. Check again in 10 minutes
  6. +
+
+

Representative Emails

+

Representative email addresses come from the Represent API. If many emails fail to a specific representative, the database may be outdated. Contact Represent API maintainers.

+
+

Response Wall Statistics

+

To view response wall stats:

+
    +
  1. Navigate to Influence > Responses
  2. +
  3. Filter by your campaign
  4. +
+

Metrics:

+
    +
  • Total responses: All submissions (approved + pending + rejected)
  • +
  • Approved: Visible on public response wall
  • +
  • Pending: Awaiting moderation
  • +
  • Rejected: Hidden from public
  • +
  • Upvotes: Total upvotes across all responses
  • +
+

Response rate:

+
Response rate = Responses / Emails sent
+
+

Typical response rates:

+
    +
  • 5-10% — Good response rate
  • +
  • 10-20% — Excellent response rate
  • +
  • < 5% — Low engagement (consider improving response wall CTA)
  • +
+

Email Queue Health

+

To monitor the queue:

+
    +
  1. Navigate to Influence > Email Queue
  2. +
+

Key metrics:

+
    +
  • Waiting: Emails in queue, not yet processing
  • +
  • Normal: < 50
  • +
  • Concerning: 50-200
  • +
  • +

    Critical: > 200 (likely queue backup)

    +
  • +
  • +

    Active: Emails currently being sent

    +
  • +
  • +

    Normal: 1-5 (concurrent workers)

    +
  • +
  • +

    Completed (last 24 hours): Successfully sent

    +
  • +
  • +

    Failed: Delivery failures

    +
  • +
  • Normal: < 5% of sent
  • +
  • Concerning: 5-20%
  • +
  • Critical: > 20% (SMTP issue)
  • +
+

Queue controls:

+
    +
  • Pause Queue: Emergency stop (only use during SMTP issues)
  • +
  • Resume Queue: Restart after pause
  • +
  • Retry Failed: Re-queue all failed emails
  • +
  • Clean Completed: Remove old completed jobs (frees memory)
  • +
+
+

Queue Pausing

+

Only pause the queue if SMTP is broken or you're changing email configuration. Citizens expect immediate sends.

+
+
+

Moderating Responses

+

Response Moderation Workflow

+

To moderate responses:

+
    +
  1. Navigate to Influence > Responses
  2. +
  3. Filter to Status: PENDING
  4. +
  5. Review each response
  6. +
  7. Approve or reject
  8. +
+

Moderation decisions:

+

Approve if:

+
    +
  • ✅ Authentic personal story
  • +
  • ✅ Relates to campaign issue
  • +
  • ✅ Respectful language
  • +
  • ✅ Adds value to public conversation
  • +
+

Reject if:

+
    +
  • ❌ Spam or bot submission
  • +
  • ❌ Profanity, hate speech, or harassment
  • +
  • ❌ Off-topic or unrelated to campaign
  • +
  • ❌ Contains personal information about others (privacy violation)
  • +
  • ❌ Duplicate submission (approve one, reject others)
  • +
+

Delete if:

+
    +
  • Illegal content
  • +
  • Severe harassment or threats
  • +
  • Privacy violation (doxxing)
  • +
+

Reviewing a Response

+

To review in detail:

+
    +
  1. Click "View" in Actions column
  2. +
  3. Read full response text
  4. +
  5. Check submitter info (name, email, timestamp)
  6. +
  7. Decide: Approve, Reject, or Delete
  8. +
+

Response detail shows:

+
    +
  • Full text of response
  • +
  • Submitter name and email (not public)
  • +
  • Submission timestamp
  • +
  • Associated campaign
  • +
  • Current status
  • +
  • Upvote count (if already approved)
  • +
+

Actions:

+
    +
  • Approve: Make public (appears on response wall)
  • +
  • Reject: Hide from public (not deleted, can reverse later)
  • +
  • Delete: Permanently remove (cannot undo)
  • +
  • Edit: Fix typos or formatting (use sparingly)
  • +
+
+

Editing Responses

+

Only edit responses to fix obvious typos or remove sensitive info (phone numbers, addresses). Don't change meaning.

+
+

Moderation Best Practices

+

Speed matters:

+
    +
  • Review pending responses daily (at minimum)
  • +
  • For time-sensitive campaigns, review 2-3x per day
  • +
  • Long moderation delays reduce participation (people won't share if they never see results)
  • +
+

Consistency:

+
    +
  • Use same criteria for all responses
  • +
  • Document your moderation guidelines
  • +
  • If multiple moderators, ensure they're aligned
  • +
+

Encourage quality:

+
    +
  • Spotlight particularly good responses (if feature available)
  • +
  • Share excellent responses on social media
  • +
  • Thank respondents for sharing their stories
  • +
+

Handle edge cases:

+
    +
  • Political/controversial: Allow diverse viewpoints as long as respectful
  • +
  • Emotional language: Allow passion, reject profanity
  • +
  • Minor inaccuracies: Approve (you're not fact-checking everything)
  • +
  • Self-promotion: Reject if primary purpose is advertising
  • +
+

Responding to Moderation Issues

+

If you accidentally reject a good response:

+
    +
  1. Find the response in table
  2. +
  3. Change status from REJECTED to APPROVED
  4. +
  5. Response immediately appears on response wall
  6. +
+

If inappropriate content slips through:

+
    +
  1. Find the response
  2. +
  3. Change status from APPROVED to REJECTED (or delete)
  4. +
  5. Response immediately removed from public view
  6. +
+

If user complains about rejection:

+
    +
  1. Review the response again
  2. +
  3. If rejection was correct, explain your moderation policy
  4. +
  5. If rejection was incorrect, approve and apologize
  6. +
  7. Consider revising moderation guidelines to prevent future issues
  8. +
+
+

Optimization Strategies

+

Improving Email Conversion Rates

+

Conversion rate = Emails sent / Page visitors

+

Typical conversion rates:

+
    +
  • 5-10% — Average for advocacy campaigns
  • +
  • 10-20% — Good (well-designed campaign)
  • +
  • 20%+ — Excellent (highly motivated audience)
  • +
+

Tactics to improve conversion:

+

1. Simplify the Form

+
    +
  • Remove optional fields (phone number, custom message)
  • +
  • Use postal code autofill
  • +
  • Pre-fill email for logged-in users
  • +
+

2. Reduce Friction

+
    +
  • Disable email verification (if spam isn't an issue)
  • +
  • Allow anonymous submissions (no account required)
  • +
  • Use clear, simple language
  • +
+

3. Strengthen the Call to Action

+
    +
  • Use large, prominent "Send Email" button
  • +
  • Add urgency ("Vote is tomorrow — act now!")
  • +
  • Show social proof ("Join 1,234 others who've sent emails")
  • +
+

4. Improve Email Template

+
    +
  • Make it personal (use variables)
  • +
  • Keep it short (200-300 words)
  • +
  • Include specific ask (bill number, action)
  • +
  • Allow personalization ({{USER_MESSAGE}})
  • +
+

5. Add Trust Signals

+
    +
  • Show organization logo
  • +
  • Display privacy policy link
  • +
  • Explain what happens after they send ("Your representative will receive this email within minutes")
  • +
+

A/B Testing

+

Test different versions of your campaign to find what works best.

+

Elements to test:

+
    +
  1. Email subject line
  2. +
  3. Action-oriented vs question
  4. +
  5. Include bill number vs generic
  6. +
  7. +

    Urgent vs neutral tone

    +
  8. +
  9. +

    Call to action

    +
  10. +
  11. "Send Email" vs "Take Action" vs "Email Your MP"
  12. +
  13. Button color (blue vs red vs green)
  14. +
  15. +

    Button size

    +
  16. +
  17. +

    Campaign description

    +
  18. +
  19. Short (1 sentence) vs detailed (3 paragraphs)
  20. +
  21. Emotional appeal vs factual
  22. +
  23. +

    Include statistics vs stories

    +
  24. +
  25. +

    Feature flags

    +
  26. +
  27. Email verification ON vs OFF
  28. +
  29. Response wall ON vs OFF
  30. +
  31. Progress bar ON vs OFF
  32. +
+

How to A/B test:

+
    +
  1. Create two versions of the campaign (duplicate the campaign)
  2. +
  3. Change ONE variable (e.g., subject line)
  4. +
  5. Send 50% of traffic to each version (promote both equally)
  6. +
  7. After 100+ emails sent per version, compare conversion rates
  8. +
  9. Keep the winner, discard the loser
  10. +
+

Sample A/B test:

+
Version A: Subject line "Support Bill C-123 for climate action"
+Result: 100 emails sent from 1,000 visitors = 10% conversion
+
+Version B: Subject line "Vote YES on climate action — your MP is listening"
+Result: 150 emails sent from 1,000 visitors = 15% conversion
+
+Winner: Version B (50% improvement)
+Action: Update Version A subject to match Version B
+
+

Encouraging Response Wall Participation

+

Response wall benefits:

+
    +
  • Shows public support visibly
  • +
  • Creates peer pressure ("If they can share, so can I")
  • +
  • Provides human stories for media and decision-makers
  • +
+

Tactics to increase responses:

+

1. Highlight the Response Wall

+
    +
  • Add text after email send: "Share your story with the community"
  • +
  • Show recent responses on campaign page
  • +
  • Feature excellent responses on social media
  • +
+

2. Reduce Friction

+
    +
  • Auto-approve responses (if audience is trusted)
  • +
  • Pre-fill response form with email content
  • +
  • Allow anonymous responses
  • +
+

3. Provide Examples

+
    +
  • Seed the response wall with 3-5 initial responses (from staff/volunteers)
  • +
  • Show variety of response types (personal story, factual argument, emotional appeal)
  • +
+

4. Incentivize Participation

+
    +
  • Run a contest (best response wins a prize)
  • +
  • Feature responses in newsletter
  • +
  • Invite top responders to speak at event
  • +
+

5. Moderate Quickly

+
    +
  • Approve responses within hours (not days)
  • +
  • People won't share if they never see results
  • +
+

Boosting Upvotes

+

Upvotes signal which responses resonate most with your community.

+

Tactics:

+
    +
  1. Make upvoting easy: One-click, no login required
  2. +
  3. Show upvote counts: Create competition
  4. +
  5. Promote top responses: Share high-upvote responses on social
  6. +
  7. Create urgency: "Most upvoted response will be featured in our newsletter"
  8. +
+
+

Reporting and Analytics

+

Campaign Performance Report

+

Key metrics to track:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricFormulaBenchmark
Total emails sentCount of SENT statusN/A (goal-dependent)
Conversion rateEmails / Page visitors5-15%
Response rateResponses / Emails sent5-15%
Upvote rateUpvotes / Responses20-40%
Email success rateSENT / (SENT + FAILED)> 95%
Avg time to sendQueue wait time< 5 minutes
+

Exporting Data

+

To export campaign data:

+
    +
  1. Navigate to Influence > Campaigns
  2. +
  3. Click "Emails" for your campaign
  4. +
  5. Click "Export CSV"
  6. +
+

CSV includes:

+
    +
  • Sender name and email
  • +
  • Recipient representative
  • +
  • Email sent timestamp
  • +
  • Status (SENT, FAILED, PENDING)
  • +
  • Error message (if failed)
  • +
+

Use cases:

+
    +
  • Analyze email volume by date (chart over time)
  • +
  • Identify which representatives received most emails (top targets)
  • +
  • Follow up with failed sends
  • +
  • Import into CRM or email tool
  • +
+

Response wall export:

+
    +
  1. Navigate to Influence > Responses
  2. +
  3. Filter by campaign
  4. +
  5. Click "Export CSV"
  6. +
+

CSV includes:

+
    +
  • Respondent name and email
  • +
  • Response text
  • +
  • Submission date
  • +
  • Status (APPROVED, PENDING, REJECTED)
  • +
  • Upvote count
  • +
+

Use cases:

+
    +
  • Analyze themes in responses (word cloud, sentiment analysis)
  • +
  • Share stories with media or decision-makers
  • +
  • Feature responses in reports or presentations
  • +
+
+

Troubleshooting

+

Low Email Conversion Rate

+

Symptoms: Few people sending emails despite high traffic

+

Diagnostic questions:

+
    +
  1. Is representative lookup working?
  2. +
  3. Test with multiple postal codes
  4. +
  5. +

    Check representative cache (Influence > Representatives)

    +
  6. +
  7. +

    Is the form too complex?

    +
  8. +
  9. Remove optional fields
  10. +
  11. Simplify email template
  12. +
  13. +

    Disable verification

    +
  14. +
  15. +

    Is the call to action clear?

    +
  16. +
  17. Review campaign description
  18. +
  19. Check button text and prominence
  20. +
  21. +

    Add urgency or social proof

    +
  22. +
  23. +

    Is trust an issue?

    +
  24. +
  25. Add organization branding
  26. +
  27. Display privacy policy
  28. +
  29. Explain what happens after they send
  30. +
+

Solutions:

+
    +
  • A/B test simpler version
  • +
  • Add trust signals (logo, privacy link)
  • +
  • Reduce form fields
  • +
  • Strengthen CTA
  • +
+

Low Response Wall Participation

+

Symptoms: Emails being sent but few response wall submissions

+

Possible causes:

+
    +
  1. Response wall not prominent
  2. +
  3. Add section on campaign page highlighting response wall
  4. +
  5. +

    Show recent responses below email form

    +
  6. +
  7. +

    Friction too high

    +
  8. +
  9. Require verification → people abandon
  10. +
  11. +

    Long approval delay → people think it didn't work

    +
  12. +
  13. +

    No examples/social proof

    +
  14. +
  15. Empty response wall → people don't know what to share
  16. +
  17. Seed with initial responses
  18. +
+

Solutions:

+
    +
  • Auto-approve responses (if trusted audience)
  • +
  • Add examples/prompts ("Share why this issue matters to you")
  • +
  • Feature excellent responses on social media (encourages others)
  • +
+

Emails Stuck in Queue

+

Symptoms: Emails remain in PENDING status for > 1 hour

+

Diagnostic steps:

+
    +
  1. Check queue status: Influence > Email Queue
  2. +
  3. Check SMTP configuration: Settings > Email Configuration
  4. +
  5. Test email send: Settings > Send Test Email
  6. +
+

Common causes:

+
    +
  1. Queue worker not running
  2. +
  3. Contact system administrator
  4. +
  5. +

    Restart api service

    +
  6. +
  7. +

    SMTP credentials wrong

    +
  8. +
  9. Verify username/password in Settings
  10. +
  11. +

    Send test email to verify

    +
  12. +
  13. +

    SMTP server rejecting

    +
  14. +
  15. Check spam/rate limits on SMTP server
  16. +
  17. +

    Contact email service provider

    +
  18. +
  19. +

    Network issue

    +
  20. +
  21. Check API server connectivity
  22. +
  23. Try different SMTP provider
  24. +
+

Emergency solution:

+
    +
  • If queue is badly backed up, pause queue
  • +
  • Fix SMTP issue
  • +
  • Resume queue
  • +
  • Retry failed
  • +
+

High Email Failure Rate

+

Symptoms: Many emails with FAILED status

+

Check error messages:

+
    +
  1. "Invalid recipient email"
  2. +
  3. Representative email is wrong in database
  4. +
  5. Contact Represent API maintainers
  6. +
  7. +

    Use custom recipients as workaround

    +
  8. +
  9. +

    "SMTP authentication failed"

    +
  10. +
  11. Wrong SMTP username/password
  12. +
  13. +

    Update in Settings > Email Configuration

    +
  14. +
  15. +

    "Connection timeout"

    +
  16. +
  17. Network issue between API server and SMTP
  18. +
  19. +

    Contact system administrator

    +
  20. +
  21. +

    "Mailbox full"

    +
  22. +
  23. Representative's email inbox is full
  24. +
  25. +

    Nothing you can do (contact representative's office)

    +
  26. +
  27. +

    "Spam filter rejected"

    +
  28. +
  29. Email looks like spam
  30. +
  31. Revise email template (less spammy language)
  32. +
  33. Contact SMTP provider about reputation
  34. +
+

Solutions:

+
    +
  • Fix SMTP configuration
  • +
  • Update representative emails
  • +
  • Retry failed emails after fixing
  • +
+
+ + +
+

Last updated: February 2026 (V2 complete)

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/user-guides/content-editor-guide/index.html b/mkdocs/site/v2/user-guides/content-editor-guide/index.html new file mode 100644 index 00000000..268b8beb --- /dev/null +++ b/mkdocs/site/v2/user-guides/content-editor-guide/index.html @@ -0,0 +1,7226 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Content Editor Guide - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Content Editor Guide

+

Overview

+

As a Content Editor, you're responsible for creating and managing public-facing content in Changemaker Lite, including:

+
    +
  • Landing pages: Custom web pages using the visual editor
  • +
  • Email templates: System email templates (welcome, password reset, shift reminders)
  • +
  • Media library: Video uploads and organization (if enabled)
  • +
+

This guide will help you create professional, engaging content that drives participation in your campaigns and volunteer activities.

+
+

Getting Started

+

Content Editor Access

+

Content editing features are available to:

+
    +
  • SUPER_ADMIN: Full access to all content features
  • +
  • INFLUENCE_ADMIN: Email templates (for campaign-related emails)
  • +
  • MAP_ADMIN: Email templates (for shift-related emails)
  • +
+

Landing pages and media library are typically managed by SUPER_ADMIN only.

+

Content Areas

+

1. Landing Pages (/app/pages)

+
    +
  • Custom public pages at /p/[slug]
  • +
  • Visual editor (GrapesJS) or code editor
  • +
  • Use for: Campaign pages, donation pages, event pages
  • +
+

2. Email Templates (/app/email-templates)

+
    +
  • System email templates
  • +
  • HTML + plain text versions
  • +
  • Use for: Welcome emails, shift reminders, password resets
  • +
+

3. Media Library (/app/media/library, if enabled)

+
    +
  • Video uploads and organization
  • +
  • Shareable public gallery
  • +
  • Use for: Testimonials, events, educational content
  • +
+
+

Creating Landing Pages

+

Landing Page Overview

+

Landing pages are custom web pages published at /p/[slug]. Use them for:

+
    +
  • Campaign-specific pages: Dedicated page for a major campaign
  • +
  • Event registration: Custom signup forms for events
  • +
  • Donation pages: Integrated donation forms
  • +
  • About pages: "About Us", "Our Team", "Our Mission"
  • +
  • Volunteer recruitment: Custom volunteer signup pages
  • +
+

Creating a New Page

+

To create a landing page:

+
    +
  1. Navigate to Content > Landing Pages
  2. +
  3. Click "Create Page"
  4. +
  5. Fill in page details:
  6. +
  7. Title: Page title (shown in browser tab, used for SEO)
  8. +
  9. Slug: URL identifier (e.g., about-us/p/about-us)
  10. +
  11. Description: Meta description for SEO (160 characters max)
  12. +
  13. Status: DRAFT or PUBLISHED
  14. +
  15. Click "Create"
  16. +
  17. Click "Edit" to open the page editor
  18. +
+

Screenshot placeholder: Create Page modal showing title, slug, description, and status fields

+

Page Editor Overview

+

The page editor has two modes:

+

Visual Mode (default):

+
    +
  • Drag-and-drop interface (GrapesJS)
  • +
  • No coding required
  • +
  • What-you-see-is-what-you-get (WYSIWYG)
  • +
  • Best for: Non-technical users, quick page creation
  • +
+

Code Mode:

+
    +
  • HTML/CSS editor
  • +
  • Full control over markup
  • +
  • Best for: Experienced users, complex layouts
  • +
+

Switch modes using the tabs at the top of the editor.

+

Screenshot placeholder: Page editor showing Visual/Code mode tabs and toolbar

+
+

Desktop Only

+

The page editor is designed for desktop use (minimum 1024px width). Mobile users will see a warning to switch to desktop.

+
+
+

Using the Visual Editor

+

Editor Interface

+

The visual editor has three main areas:

+

1. Canvas (center):

+
    +
  • Preview of your page
  • +
  • Click blocks to select
  • +
  • Drag to reposition
  • +
+

2. Block Toolbar (left):

+
    +
  • Drag blocks onto canvas
  • +
  • Categories: Layout, Text, Media, Forms, Components
  • +
+

3. Settings Panel (right):

+
    +
  • Style selected block
  • +
  • Adjust colors, fonts, spacing
  • +
  • Configure block settings
  • +
+

Screenshot placeholder: Visual editor showing block toolbar, canvas, and settings panel

+

Adding Blocks

+

To add a block:

+
    +
  1. Find block in left toolbar (or search)
  2. +
  3. Drag block onto canvas
  4. +
  5. Drop where you want it
  6. +
+

Available block categories:

+

Layout:

+
    +
  • Section: Full-width container
  • +
  • Container: Centered content wrapper
  • +
  • Row: Multi-column row
  • +
  • Column: Single column within row
  • +
+

Text:

+
    +
  • Text: Paragraph text
  • +
  • Heading: H1, H2, H3 headings
  • +
  • Quote: Blockquote
  • +
  • List: Bulleted or numbered list
  • +
+

Media:

+
    +
  • Image: Single image
  • +
  • Video: Embedded video (YouTube, Vimeo, or self-hosted)
  • +
  • Icon: Font Awesome icon
  • +
+

Forms:

+
    +
  • Form: Form container
  • +
  • Input: Text input field
  • +
  • Textarea: Multi-line text input
  • +
  • Button: Submit button
  • +
+

Components (custom blocks):

+
    +
  • Hero: Large header with background image and CTA
  • +
  • Features: Three-column feature grid
  • +
  • Testimonial: Quote with author photo
  • +
  • Call to Action: Centered CTA with button
  • +
  • Stats: Number counter grid
  • +
+

Screenshot placeholder: Block toolbar showing categories and block preview thumbnails

+

Configuring Blocks

+

To configure a block:

+
    +
  1. Click the block on canvas (selects it)
  2. +
  3. Settings panel opens on right
  4. +
  5. Adjust settings (varies by block type)
  6. +
+

Common settings:

+

Style tab:

+
    +
  • Typography: Font family, size, weight, color
  • +
  • Spacing: Margin, padding
  • +
  • Background: Color, image, gradient
  • +
  • Border: Width, color, radius
  • +
  • Dimensions: Width, height
  • +
+

Settings tab (varies by block):

+
    +
  • Image: URL, alt text, link
  • +
  • Video: Video URL, autoplay, controls
  • +
  • Button: Text, link, style
  • +
  • Form: Action URL, method
  • +
+

Screenshot placeholder: Settings panel showing style options for a selected heading block

+

Styling Blocks

+

To change text color:

+
    +
  1. Select text block
  2. +
  3. Settings panel > Style tab
  4. +
  5. Color picker under Typography
  6. +
  7. Choose color or enter hex code
  8. +
+

To change background:

+
    +
  1. Select section or container block
  2. +
  3. Settings panel > Style tab
  4. +
  5. Background section
  6. +
  7. Choose color, image, or gradient
  8. +
+

To adjust spacing:

+
    +
  1. Select block
  2. +
  3. Settings panel > Style tab
  4. +
  5. Margin/Padding section
  6. +
  7. Adjust top, right, bottom, left values
  8. +
+

Screenshot placeholder: Background settings showing color picker, image upload, and gradient options

+

Using Pre-Built Components

+

Changemaker Lite includes pre-built components for common page sections:

+

Hero Component

+

What it is: Large header section with background image, headline, and call-to-action button

+

How to use:

+
    +
  1. Drag Hero block from Components category
  2. +
  3. Click headline to edit text
  4. +
  5. Click button to edit text and link
  6. +
  7. Select block, then in settings:
  8. +
  9. Upload background image
  10. +
  11. Adjust overlay opacity
  12. +
  13. Change text color
  14. +
+

Screenshot placeholder: Hero component on canvas showing headline, subheading, and CTA button

+

Features Component

+

What it is: Three-column grid showcasing features or benefits

+

How to use:

+
    +
  1. Drag Features block onto canvas
  2. +
  3. Click each feature to edit:
  4. +
  5. Icon (Font Awesome icon name)
  6. +
  7. Heading
  8. +
  9. Description
  10. +
  11. Adjust colors and spacing in settings panel
  12. +
+

Screenshot placeholder: Features component showing three columns with icons, headings, and text

+

Testimonial Component

+

What it is: Quote with author photo and name

+

How to use:

+
    +
  1. Drag Testimonial block onto canvas
  2. +
  3. Click quote text to edit
  4. +
  5. Click author name to edit
  6. +
  7. Upload author photo in settings panel
  8. +
+

Call to Action Component

+

What it is: Centered section with headline and button

+

How to use:

+
    +
  1. Drag Call to Action block onto canvas
  2. +
  3. Edit headline and description
  4. +
  5. Edit button text and link
  6. +
  7. Adjust background color
  8. +
+

Saving Your Page

+

To save changes:

+

Method 1: Keyboard shortcut

+
    +
  • Press Ctrl+S (Windows/Linux) or Cmd+S (Mac)
  • +
+

Method 2: Save button

+
    +
  • Click "Save" button in editor toolbar
  • +
+

Auto-save:

+
    +
  • Changes are NOT auto-saved
  • +
  • Save frequently to avoid losing work
  • +
+
+

Save Often

+

Use Ctrl+S frequently. Browser crashes or network issues can cause unsaved work to be lost.

+
+

Screenshot placeholder: Save button in editor toolbar

+
+

Using the Code Editor

+

Switching to Code Mode

+

To switch to code editor:

+
    +
  1. Click "Code" tab at top of editor
  2. +
  3. HTML code appears in text editor
  4. +
  5. Edit HTML directly
  6. +
  7. Click "Visual" tab to return to visual mode
  8. +
+

When to use code mode:

+
    +
  • Need precise control over HTML structure
  • +
  • Adding custom CSS or JavaScript
  • +
  • Copying HTML from another source
  • +
  • Working with complex layouts
  • +
+

Screenshot placeholder: Code editor showing HTML markup in text editor

+

HTML Structure

+

Basic page structure:

+
<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <title>Page Title</title>
+  <style>
+    /* CSS goes here */
+  </style>
+</head>
+<body>
+  <!-- Page content goes here -->
+</body>
+</html>
+
+

Recommended structure:

+
<body>
+  <!-- Hero Section -->
+  <section class="hero">
+    <h1>Welcome to Our Campaign</h1>
+    <p>Join us in making a difference.</p>
+    <a href="/campaigns/climate-action" class="btn">Take Action</a>
+  </section>
+
+  <!-- Features Section -->
+  <section class="features">
+    <div class="container">
+      <div class="row">
+        <div class="col">
+          <h3>Easy to Use</h3>
+          <p>Send emails in under 2 minutes.</p>
+        </div>
+        <div class="col">
+          <h3>High Impact</h3>
+          <p>Your voice reaches decision-makers.</p>
+        </div>
+        <div class="col">
+          <h3>Community</h3>
+          <p>Join thousands of advocates.</p>
+        </div>
+      </div>
+    </div>
+  </section>
+</body>
+
+

Adding Custom CSS

+

To add custom styles:

+
    +
  1. In code mode, add a <style> block in the <head>:
  2. +
+
<head>
+  <style>
+    .hero {
+      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+      color: white;
+      padding: 100px 20px;
+      text-align: center;
+    }
+
+    .hero h1 {
+      font-size: 3rem;
+      margin-bottom: 20px;
+    }
+
+    .btn {
+      background: #ff6b6b;
+      color: white;
+      padding: 15px 40px;
+      text-decoration: none;
+      border-radius: 5px;
+      display: inline-block;
+      margin-top: 20px;
+    }
+
+    .btn:hover {
+      background: #ee5a52;
+    }
+  </style>
+</head>
+
+

Using Variables

+

Landing pages support variable interpolation:

+

Available variables:

+
    +
  • {{SITE_NAME}} — Organization name (from settings)
  • +
  • {{SITE_URL}} — Website URL
  • +
  • {{USER_NAME}} — Logged-in user's name (if authenticated)
  • +
+

Example usage:

+
<p>Welcome to {{SITE_NAME}}, {{USER_NAME}}!</p>
+
+

Renders as:

+
Welcome to Community Action Network, John Smith!
+
+

Keyboard Shortcuts in Code Mode

+
    +
  • Ctrl+S / Cmd+S: Save
  • +
  • Ctrl+F / Cmd+F: Find
  • +
  • Ctrl+H / Cmd+H: Find and replace
  • +
  • Tab: Indent
  • +
  • Shift+Tab: Unindent
  • +
+
+

Publishing Pages

+

Publishing Workflow

+

Draft → Published:

+
    +
  1. Create page (status: DRAFT)
  2. +
  3. Build page in editor
  4. +
  5. Preview page (see below)
  6. +
  7. Publish page (change status to PUBLISHED)
  8. +
+

Draft pages:

+
    +
  • Not visible to public
  • +
  • Only accessible to admins via direct URL
  • +
  • Use for: Work in progress, testing
  • +
+

Published pages:

+
    +
  • Visible at /p/[slug]
  • +
  • Accessible to anyone
  • +
  • Indexed by search engines (if SEO configured)
  • +
+

Previewing Pages

+

To preview a page before publishing:

+
    +
  1. Save the page (Ctrl+S)
  2. +
  3. Click "Preview" button in editor toolbar
  4. +
  5. Page opens in new tab at /p/[slug]?preview=true
  6. +
+

OR:

+
    +
  1. Navigate to Content > Landing Pages
  2. +
  3. Click page title to view published version
  4. +
+

Screenshot placeholder: Preview button in editor toolbar

+

Publishing a Page

+

To publish a draft page:

+
    +
  1. Navigate to Content > Landing Pages
  2. +
  3. Find the page in the table
  4. +
  5. Click "Edit" in Actions column
  6. +
  7. Change status from DRAFT to PUBLISHED
  8. +
  9. Click "Save"
  10. +
+

To unpublish a page:

+
    +
  1. Change status from PUBLISHED to DRAFT
  2. +
  3. Save
  4. +
+

Unpublishing removes the page from public access but preserves all content.

+

SEO Settings

+

To optimize for search engines:

+
    +
  1. Edit the page
  2. +
  3. Fill in SEO fields:
  4. +
  5. Title: Page title (shown in search results, max 60 characters)
  6. +
  7. Description: Meta description (shown in search results, max 160 characters)
  8. +
  9. Keywords: Comma-separated keywords (e.g., "climate action, advocacy, environment")
  10. +
  11. OG Image: Social media share image (Facebook, Twitter)
  12. +
+

Best practices:

+
    +
  • Title: Include primary keyword near beginning
  • +
  • Description: Compelling, action-oriented, includes keyword
  • +
  • Keywords: 5-10 relevant keywords
  • +
  • OG Image: 1200x630 px, high-quality, relevant to page content
  • +
+

Screenshot placeholder: SEO settings form showing title, description, keywords, and OG image fields

+

MkDocs Export

+

What it is: Export landing page as Jinja2 template for MkDocs (static site generator)

+

Use case: Publish landing pages on your static documentation site

+

To export:

+
    +
  1. Navigate to Content > Landing Pages
  2. +
  3. Click "Export" in Actions column
  4. +
  5. Choose export format:
  6. +
  7. Jinja2 Template: Wraps HTML in MkDocs Material theme layout
  8. +
  9. Standalone HTML: Raw HTML (no wrapper)
  10. +
  11. File is saved to MkDocs docs/overrides/ directory
  12. +
  13. Access via MkDocs site navigation
  14. +
+

Screenshot placeholder: Export modal showing Jinja2/Standalone options

+
+

Managing Email Templates

+

Email Template Overview

+

Email templates control the content and formatting of system-generated emails:

+

System templates:

+
    +
  • Welcome Email: Sent to new users after registration
  • +
  • Password Reset: Sent when user requests password reset
  • +
  • Shift Confirmation: Sent when volunteer signs up for shift
  • +
  • Shift Reminder: Sent day before shift
  • +
  • Response Verification: Sent to verify campaign response
  • +
+

Custom templates:

+
    +
  • Create custom templates for specific campaigns or events
  • +
  • Use in shift emails, follow-up campaigns, etc.
  • +
+

Email Template Structure

+

Each template has three parts:

+

1. Subject Line

+
    +
  • Text shown in email inbox
  • +
  • Supports variables (e.g., {{USER_NAME}}, {{SHIFT_TITLE}})
  • +
  • Keep under 60 characters
  • +
+

2. HTML Body

+
    +
  • Rich-formatted email (colors, images, links)
  • +
  • What users see in modern email clients
  • +
  • Supports variables
  • +
+

3. Plain Text Body

+
    +
  • Unformatted text version
  • +
  • Fallback for old email clients or user preference
  • +
  • Should convey same information as HTML
  • +
+

Editing an Email Template

+

To edit a template:

+
    +
  1. Navigate to Content > Email Templates
  2. +
  3. Click "Edit" for the template you want to modify
  4. +
  5. Edit subject, HTML body, and/or plain text body
  6. +
  7. Click "Preview" to see rendered email
  8. +
  9. Click "Save"
  10. +
+

Screenshot placeholder: Email template editor showing subject field, HTML editor, and plain text editor

+

Using Variables in Templates

+

Variables are placeholders that get replaced with real data when the email is sent.

+

Available variables:

+

User variables:

+
    +
  • {{USER_NAME}} — User's full name
  • +
  • {{USER_EMAIL}} — User's email address
  • +
+

Shift variables:

+
    +
  • {{SHIFT_TITLE}} — Shift name
  • +
  • {{SHIFT_START}} — Start date/time (formatted)
  • +
  • {{SHIFT_END}} — End date/time (formatted)
  • +
  • {{SHIFT_LOCATION}} — Meeting location
  • +
  • {{SHIFT_CUT}} — Cut name
  • +
+

Campaign variables:

+
    +
  • {{CAMPAIGN_TITLE}} — Campaign name
  • +
  • {{CAMPAIGN_URL}} — Link to campaign page
  • +
+

System variables:

+
    +
  • {{SITE_NAME}} — Organization name (from settings)
  • +
  • {{SITE_URL}} — Website URL
  • +
  • {{RESET_LINK}} — Password reset link (password reset emails only)
  • +
  • {{VERIFICATION_LINK}} — Verification link (response verification emails only)
  • +
+

Example template:

+

Subject:

+
Welcome to {{SITE_NAME}}, {{USER_NAME}}!
+
+

HTML Body:

+
<h1>Welcome, {{USER_NAME}}!</h1>
+
+<p>Thank you for joining {{SITE_NAME}}. We're excited to have you as part of our community.</p>
+
+<p>Here's what you can do next:</p>
+<ul>
+  <li><a href="{{SITE_URL}}/campaigns">Take action on a campaign</a></li>
+  <li><a href="{{SITE_URL}}/shifts">Sign up for a volunteer shift</a></li>
+  <li><a href="{{SITE_URL}}/app">Explore your dashboard</a></li>
+</ul>
+
+<p>If you have questions, reply to this email or visit our <a href="{{SITE_URL}}/docs">help center</a>.</p>
+
+<p>Together, we can make a difference!</p>
+
+<p>— The {{SITE_NAME}} Team</p>
+
+

Plain Text Body:

+
Welcome, {{USER_NAME}}!
+
+Thank you for joining {{SITE_NAME}}. We're excited to have you as part of our community.
+
+Here's what you can do next:
+- Take action on a campaign: {{SITE_URL}}/campaigns
+- Sign up for a volunteer shift: {{SITE_URL}}/shifts
+- Explore your dashboard: {{SITE_URL}}/app
+
+If you have questions, reply to this email or visit our help center: {{SITE_URL}}/docs.
+
+Together, we can make a difference!
+
+— The {{SITE_NAME}} Team
+
+

HTML Email Best Practices

+

Do:

+
    +
  • ✅ Use inline CSS (not external stylesheets)
  • +
  • ✅ Use tables for layout (old email clients don't support flexbox/grid)
  • +
  • ✅ Test in multiple email clients (Gmail, Outlook, Apple Mail)
  • +
  • ✅ Include alt text for images
  • +
  • ✅ Use web-safe fonts (Arial, Verdana, Georgia)
  • +
  • ✅ Keep width under 600px (mobile friendly)
  • +
  • ✅ Always provide plain text version
  • +
+

Don't:

+
    +
  • ❌ Use JavaScript (email clients strip it)
  • +
  • ❌ Use CSS positioning (absolute, fixed)
  • +
  • ❌ Use background images (not universally supported)
  • +
  • ❌ Rely on external resources (may be blocked)
  • +
  • ❌ Use tiny fonts (< 14px)
  • +
+

Testing Email Templates

+

To test a template:

+
    +
  1. Click "Send Test Email" button in editor
  2. +
  3. Enter your email address
  4. +
  5. Click "Send"
  6. +
  7. Check your inbox (may take 1-2 minutes)
  8. +
+

The test email uses sample data for variables:

+
    +
  • {{USER_NAME}} → "Test User"
  • +
  • {{SHIFT_TITLE}} → "Sample Shift"
  • +
  • etc.
  • +
+

Test in multiple email clients:

+
    +
  • Gmail (web)
  • +
  • Outlook (Windows)
  • +
  • Apple Mail (Mac/iOS)
  • +
  • Outlook.com (web)
  • +
+

Look for:

+
    +
  • ✅ Formatting intact (no broken layout)
  • +
  • ✅ Images loading
  • +
  • ✅ Links working
  • +
  • ✅ Variables replaced correctly
  • +
  • ✅ Readable on mobile (check phone)
  • +
+

Screenshot placeholder: Send Test Email modal showing email address input and send button

+
+

Managing the Media Library

+
+

Optional Feature

+

Media features must be enabled via Settings > Feature Toggles > ENABLE_MEDIA_FEATURES. Contact your administrator if this option is not visible.

+
+

Media Library Overview

+

The media library allows you to:

+
    +
  • Upload videos: MP4, MOV, AVI, MKV, WebM, M4V, FLV
  • +
  • Organize by directory: Folder structure for categorization
  • +
  • Edit metadata: Title, description, producer, creator, tags
  • +
  • Share publicly: Publish videos to public gallery at /media
  • +
  • Lock videos: Prevent accidental deletion of important content
  • +
+

Use cases:

+
    +
  • Event recordings (rallies, town halls, speeches)
  • +
  • Testimonials (supporter stories)
  • +
  • Educational content (issue explainers, how-to guides)
  • +
  • Promotional videos (recruitment, fundraising appeals)
  • +
+

Uploading Videos

+

To upload a video:

+
    +
  1. Navigate to Content > Media > Library
  2. +
  3. Click "Upload Video" button (top-right)
  4. +
  5. Either:
  6. +
  7. Drag and drop video file into upload area, OR
  8. +
  9. Click to browse and select file
  10. +
  11. Fill in metadata (see below)
  12. +
  13. Click "Upload"
  14. +
+

Screenshot placeholder: Upload Video modal showing drag-drop area and metadata form

+

Supported formats:

+
    +
  • MP4 (recommended, best compatibility)
  • +
  • MOV (Apple QuickTime)
  • +
  • AVI (older format, large file size)
  • +
  • MKV (Matroska, open format)
  • +
  • WebM (web-optimized)
  • +
  • M4V (Apple iTunes)
  • +
  • FLV (Flash video, legacy)
  • +
+

File size limit: 10 GB per file

+

Upload time: Varies by file size and connection speed. A 1 GB file takes ~5-10 minutes on typical broadband.

+

Video Metadata

+

Metadata fields:

+

Title (required):

+
    +
  • Video title
  • +
  • Displayed in library and public gallery
  • +
  • Example: "Climate Rally - June 2024"
  • +
+

Description (optional):

+
    +
  • Longer description of video content
  • +
  • Supports HTML (bold, links, etc.)
  • +
  • Displayed on video detail page
  • +
+

Producer (optional):

+
    +
  • Organization or individual who produced the video
  • +
  • Example: "Community Action Network"
  • +
+

Creator (optional):

+
    +
  • Videographer or director
  • +
  • Example: "John Smith"
  • +
+

Tags (optional):

+
    +
  • Comma-separated keywords for search/filtering
  • +
  • Example: "climate, rally, 2024, toronto"
  • +
+

Directory (optional):

+
    +
  • Folder path for organization
  • +
  • Use forward slashes for nested folders
  • +
  • Examples: "events/2024", "testimonials", "educational"
  • +
+

Screenshot placeholder: Metadata form showing title, description, producer, creator, tags, and directory fields

+

Automatic Metadata Extraction

+

When you upload a video, the system automatically extracts:

+
    +
  • Duration: Length in seconds (shown as MM:SS)
  • +
  • Dimensions: Width x height in pixels (e.g., 1920x1080)
  • +
  • Orientation: PORTRAIT, LANDSCAPE, or SQUARE
  • +
  • Quality: SD, HD, FULL_HD, or 4K
  • +
  • Has Audio: Boolean (detected from audio track)
  • +
  • File Size: Bytes (shown as MB/GB)
  • +
+

Quality detection:

+
    +
  • SD (Standard Definition): Height < 720px
  • +
  • HD (High Definition): Height 720-1079px
  • +
  • FULL_HD (1080p): Height 1080-2159px
  • +
  • 4K (Ultra HD): Height ≥ 2160px
  • +
+

Orientation detection:

+
    +
  • PORTRAIT: Height > Width (e.g., 1080x1920, vertical phone video)
  • +
  • LANDSCAPE: Width > Height (e.g., 1920x1080, standard video)
  • +
  • SQUARE: Width = Height (e.g., 1080x1080, Instagram video)
  • +
+

You cannot edit these fields manually—they're extracted automatically.

+

Organizing Videos

+

Directory structure:

+

Use directories to organize videos by:

+
    +
  • Type: "events", "testimonials", "educational", "promotional"
  • +
  • Year: "2024", "2023"
  • +
  • Campaign: "climate-campaign", "housing-campaign"
  • +
  • Combination: "events/2024", "testimonials/climate"
  • +
+

Example directory structure:

+
events/
+  2024/
+    rally-june.mp4
+    townhall-july.mp4
+  2023/
+    rally-september.mp4
+testimonials/
+  climate/
+    jane-smith.mp4
+    john-doe.mp4
+  housing/
+    maria-garcia.mp4
+educational/
+  climate-101.mp4
+  how-to-canvass.mp4
+
+

To move videos between directories:

+
    +
  1. Select videos in library (checkboxes)
  2. +
  3. Choose "Move" from bulk actions
  4. +
  5. Enter new directory path
  6. +
  7. Click "Move"
  8. +
+

Screenshot placeholder: Library showing directory tree sidebar and video grid

+

Filtering and Searching Videos

+

To find videos:

+

Search:

+
    +
  • Enter keywords in search box
  • +
  • Searches title, description, tags, producer, creator
  • +
+

Filters:

+
    +
  • Directory: Show only videos in specific directory
  • +
  • Quality: Filter by SD, HD, FULL_HD, 4K
  • +
  • Orientation: Filter by portrait, landscape, square
  • +
  • Locked: Show only locked or unlocked videos
  • +
+

Sort:

+
    +
  • Upload date (newest first, oldest first)
  • +
  • Title (A-Z, Z-A)
  • +
  • Duration (shortest first, longest first)
  • +
+

Screenshot placeholder: Library filters showing directory dropdown, quality checkboxes, and sort options

+

Editing Video Metadata

+

To edit a video:

+
    +
  1. Click on video thumbnail (or click "Edit" in actions menu)
  2. +
  3. Edit metadata fields
  4. +
  5. Click "Save"
  6. +
+

Editable fields:

+
    +
  • Title
  • +
  • Description
  • +
  • Producer
  • +
  • Creator
  • +
  • Tags
  • +
  • Directory
  • +
+

Non-editable fields (auto-extracted):

+
    +
  • Duration
  • +
  • Dimensions
  • +
  • Orientation
  • +
  • Quality
  • +
  • Has Audio
  • +
  • File Size
  • +
+

Deleting Videos

+

To delete a video:

+
    +
  1. Select video in library
  2. +
  3. Click "Delete" (trash icon)
  4. +
  5. Confirm deletion
  6. +
+
+

Permanent Deletion

+

Deleting a video is permanent. The video file is removed from the server and cannot be recovered.

+
+

Locked videos cannot be deleted (unlock first).

+

Locking Videos

+

What is locking?

+

Locked videos cannot be:

+
    +
  • Deleted
  • +
  • Moved to a different directory
  • +
  • Unshared from public gallery (if already shared)
  • +
+

When to lock:

+
    +
  • ✅ Important historical videos
  • +
  • ✅ Videos currently shared publicly
  • +
  • ✅ Videos linked from landing pages or campaigns
  • +
+

To lock a video:

+
    +
  1. Select video
  2. +
  3. Click "Lock" (padlock icon)
  4. +
+

To unlock:

+
    +
  1. Select locked video
  2. +
  3. Click "Unlock"
  4. +
+

Screenshot placeholder: Video card showing lock icon badge

+
+

Sharing Videos Publicly

+ +

The public media gallery (/media) showcases videos to the public. It's organized by categories.

+

Categories:

+
    +
  • TESTIMONIAL: Personal stories from supporters
  • +
  • EVENT: Rally videos, town halls, speeches
  • +
  • EDUCATIONAL: Issue explainers, how-to guides
  • +
  • PROMOTIONAL: Recruitment, fundraising appeals
  • +
+

Sharing Videos

+

To share videos publicly:

+
    +
  1. Navigate to Content > Media > Shared Media
  2. +
  3. Click "Share Videos" button
  4. +
  5. Select videos from library (search, filter, select)
  6. +
  7. Choose category (TESTIMONIAL, EVENT, EDUCATIONAL, PROMOTIONAL)
  8. +
  9. Click "Share"
  10. +
+

Videos immediately appear on public gallery at /media.

+

Screenshot placeholder: Share Videos modal showing library selector, category dropdown, and share button

+

Managing Shared Media

+

To view shared videos:

+
    +
  1. Navigate to Content > Media > Shared Media
  2. +
+

Table shows:

+
    +
  • Video title
  • +
  • Category
  • +
  • Shared date
  • +
  • View count (if tracking enabled)
  • +
  • Actions: Unshare, change category
  • +
+

To unshare videos:

+
    +
  1. Select videos in table
  2. +
  3. Click "Unshare"
  4. +
  5. Confirm
  6. +
+

Videos are removed from public gallery but remain in library.

+

To change category:

+
    +
  1. Click "Edit" for video
  2. +
  3. Select new category
  4. +
  5. Click "Save"
  6. +
+ +

Gallery settings (managed by admin):

+
    +
  • Gallery title (e.g., "Our Videos")
  • +
  • Category order
  • +
  • Videos per page
  • +
  • Allow reactions (like, love, etc.)
  • +
+

Ask your administrator to configure these settings.

+
+

Content Best Practices

+

Writing for the Web

+

Scannable:

+
    +
  • Use headings and subheadings
  • +
  • Short paragraphs (2-3 sentences)
  • +
  • Bulleted lists
  • +
  • Bold key points
  • +
+

Actionable:

+
    +
  • Clear call to action on every page
  • +
  • Tell users what to do next
  • +
  • Use action verbs (Join, Donate, Sign Up, Learn More)
  • +
+

Accessible:

+
    +
  • Use alt text for images
  • +
  • Sufficient color contrast (WCAG AA: 4.5:1 for text)
  • +
  • Descriptive link text (not "click here")
  • +
  • Readable font size (≥ 16px)
  • +
+

Mobile Optimization

+

Mobile traffic is 50-70% of web traffic. Optimize for mobile:

+

Responsive design:

+
    +
  • Use mobile-friendly templates
  • +
  • Test on actual phones (not just desktop browser resize)
  • +
+

Touch targets:

+
    +
  • Buttons at least 44x44 px
  • +
  • Adequate spacing between links (avoid accidental taps)
  • +
+

Load time:

+
    +
  • Compress images (use tools like TinyPNG)
  • +
  • Minimize video file sizes
  • +
  • Avoid large background images
  • +
+

Readability:

+
    +
  • Large font (≥ 16px)
  • +
  • Short paragraphs
  • +
  • Simple navigation
  • +
+

SEO Optimization

+

On-page SEO:

+
    +
  1. Title tag: Include primary keyword, under 60 characters
  2. +
  3. Meta description: Compelling, includes keyword, under 160 characters
  4. +
  5. Headings: Use H1 for main title, H2 for sections, H3 for subsections
  6. +
  7. Keywords: Use naturally in content (don't stuff)
  8. +
  9. Internal links: Link to other pages on your site
  10. +
  11. External links: Link to authoritative sources
  12. +
  13. Image alt text: Describe images for screen readers and SEO
  14. +
+

Technical SEO:

+
    +
  • Fast load time (< 3 seconds)
  • +
  • Mobile-friendly
  • +
  • HTTPS (secure)
  • +
  • Clean URLs (e.g., /p/about-us, not /p/page?id=123)
  • +
+

Accessibility

+

WCAG 2.1 Level AA compliance:

+

Perceivable:

+
    +
  • Alt text for images
  • +
  • Captions for videos
  • +
  • Color contrast (4.5:1 for text, 3:1 for large text)
  • +
+

Operable:

+
    +
  • Keyboard navigation (all interactive elements reachable via Tab)
  • +
  • Skip links (skip to main content)
  • +
  • No keyboard traps
  • +
+

Understandable:

+
    +
  • Clear language (avoid jargon)
  • +
  • Consistent navigation
  • +
  • Error messages explain how to fix
  • +
+

Robust:

+
    +
  • Valid HTML (no unclosed tags, proper nesting)
  • +
  • Semantic markup (use <nav>, <main>, <article>, not just <div>)
  • +
+
+

Troubleshooting

+

Landing Pages

+

Issue: Page editor won't load

+

Solutions:

+
    +
  1. Check browser console for errors (F12)
  2. +
  3. Try different browser (Chrome recommended)
  4. +
  5. Clear browser cache (Ctrl+Shift+Delete)
  6. +
  7. Disable browser extensions (ad blockers may interfere)
  8. +
+
+

Issue: Changes not saving

+

Solutions:

+
    +
  1. Check internet connection
  2. +
  3. Try Ctrl+S (keyboard shortcut)
  4. +
  5. Check browser console for errors
  6. +
  7. Try refreshing and re-editing
  8. +
+
+

Issue: Page looks different when published

+

Causes:

+
    +
  • Preview mode shows editor styles (not exact public view)
  • +
  • Browser caching old version
  • +
+

Solutions:

+
    +
  1. Hard refresh published page (Ctrl+Shift+R)
  2. +
  3. Test in incognito/private window
  4. +
  5. Clear browser cache
  6. +
+
+

Email Templates

+

Issue: Variables not replacing

+

Symptoms: Email shows {{USER_NAME}} instead of actual name

+

Causes:

+
    +
  • Variable name misspelled
  • +
  • Variable not supported in this template type
  • +
  • Email sent via test (test uses sample data)
  • +
+

Solutions:

+
    +
  1. Check variable spelling (case-sensitive)
  2. +
  3. Consult variable reference (see "Using Variables" above)
  4. +
  5. Send real email (not test) to see actual data
  6. +
+
+

Issue: Email looks broken in Outlook

+

Causes: Outlook uses Microsoft Word rendering engine (poor CSS support)

+

Solutions:

+
    +
  1. Use table-based layout (not flexbox/grid)
  2. +
  3. Use inline CSS (not external styles)
  4. +
  5. Test specifically in Outlook (use Litmus or Email on Acid)
  6. +
+
+

Media Library

+

Issue: Video won't upload

+

Solutions:

+
    +
  1. Check file size (max 10 GB)
  2. +
  3. Check file format (must be MP4, MOV, AVI, MKV, WebM, M4V, or FLV)
  4. +
  5. Check internet connection (large files need stable connection)
  6. +
  7. Try different browser
  8. +
+
+

Issue: Metadata extraction failed

+

Symptoms: Duration shows "Unknown", quality shows "N/A"

+

Causes:

+
    +
  • Video file is corrupted
  • +
  • Unsupported codec
  • +
  • FFprobe service not running (server issue)
  • +
+

Solutions:

+
    +
  1. Try re-encoding video (use HandBrake or similar)
  2. +
  3. Convert to MP4 with H.264 codec (most compatible)
  4. +
  5. Contact administrator (may be server configuration issue)
  6. +
+
+

Issue: Video won't play on public gallery

+

Causes:

+
    +
  • Video not shared (still in library only)
  • +
  • Unsupported codec in browser
  • +
  • Video file missing (deleted from server)
  • +
+

Solutions:

+
    +
  1. Verify video is shared (Content > Media > Shared Media)
  2. +
  3. Re-encode as H.264 MP4 (best browser compatibility)
  4. +
  5. Check server logs (ask administrator)
  6. +
+
+ + +
+

Last updated: February 2026 (V2 complete)

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/user-guides/index.html b/mkdocs/site/v2/user-guides/index.html new file mode 100644 index 00000000..45bfd2bd --- /dev/null +++ b/mkdocs/site/v2/user-guides/index.html @@ -0,0 +1,5261 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + User Guides - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

User Guides

+

This section provides step-by-step guides for different user roles and common tasks. Each guide is tailored to specific workflows and responsibilities.

+

Role-Based Guides

+

Admin Guide

+

For system administrators and site managers:

+
    +
  • Initial setup and configuration
  • +
  • User management
  • +
  • Site settings
  • +
  • Service integration
  • +
  • Monitoring and maintenance
  • +
  • Security best practices
  • +
+

Target Audience: SUPER_ADMIN role

+

Campaign Manager Guide

+

For advocacy campaign coordinators:

+
    +
  • Creating campaigns
  • +
  • Managing representatives
  • +
  • Email template design
  • +
  • Response moderation
  • +
  • Campaign analytics
  • +
  • Email queue monitoring
  • +
+

Target Audience: INFLUENCE_ADMIN role

+

Map Organizer Guide

+

For field organizing coordinators:

+
    +
  • Location management
  • +
  • Importing data (CSV, NAR)
  • +
  • Creating geographic cuts
  • +
  • Scheduling volunteer shifts
  • +
  • Monitoring canvassing progress
  • +
  • Printing walk sheets
  • +
+

Target Audience: MAP_ADMIN role

+

Volunteer Guide

+

For field canvassers:

+
    +
  • Viewing shift assignments
  • +
  • Starting canvass session
  • +
  • Using GPS map
  • +
  • Recording visit outcomes
  • +
  • Tracking personal activity
  • +
  • Best practices for canvassing
  • +
+

Target Audience: USER role

+

Content Editor Guide

+

For content creators:

+
    +
  • Creating landing pages
  • +
  • Using GrapesJS editor
  • +
  • Email template creation
  • +
  • Managing media library
  • +
  • Publishing content
  • +
  • SEO best practices
  • +
+

Target Audience: SUPER_ADMIN role

+

Common Tasks

+

Getting Started

+
    +
  1. First Login
  2. +
  3. Navigate to http://your-domain.com or http://localhost:3000
  4. +
  5. Login with credentials
  6. +
  7. Change default password
  8. +
  9. +

    Explore dashboard

    +
  10. +
  11. +

    User Role Redirection

    +
  12. +
  13. Admin roles/app/dashboard
  14. +
  15. User/volunteer roles/volunteer/dashboard
  16. +
+

Campaign Workflow

+
    +
  1. Create Campaign
  2. +
  3. Navigate to /app/influence/campaigns
  4. +
  5. Click "New Campaign"
  6. +
  7. Fill in details
  8. +
  9. +

    Save campaign

    +
  10. +
  11. +

    Design Email Template

    +
  12. +
  13. Set email subject
  14. +
  15. Write email body
  16. +
  17. Use variable placeholders
  18. +
  19. +

    Preview template

    +
  20. +
  21. +

    Launch Campaign

    +
  22. +
  23. Set to published
  24. +
  25. Share public URL
  26. +
  27. Monitor responses
  28. +
+

Location Workflow

+
    +
  1. Import Locations
  2. +
  3. Prepare CSV file
  4. +
  5. Navigate to /app/map/locations
  6. +
  7. Click "Import CSV"
  8. +
  9. Map columns
  10. +
  11. +

    Import data

    +
  12. +
  13. +

    Geocode Addresses

    +
  14. +
  15. Select ungeocode locations
  16. +
  17. Click "Geocode Selected"
  18. +
  19. Monitor progress
  20. +
  21. +

    Review quality metrics

    +
  22. +
  23. +

    Create Geographic Cuts

    +
  24. +
  25. Navigate to /app/map/cuts
  26. +
  27. Click "Draw on Map"
  28. +
  29. Draw polygon
  30. +
  31. Save cut
  32. +
  33. Assign locations
  34. +
+

Volunteer Canvassing Workflow

+
    +
  1. View Assignments
  2. +
  3. Login as volunteer
  4. +
  5. Navigate to /volunteer/assignments
  6. +
  7. +

    View upcoming shifts

    +
  8. +
  9. +

    Start Canvassing

    +
  10. +
  11. Click "Start Canvass"
  12. +
  13. Grant GPS permissions
  14. +
  15. Follow walking route
  16. +
  17. +

    Visit locations

    +
  18. +
  19. +

    Record Visits

    +
  20. +
  21. Click location marker
  22. +
  23. Select outcome
  24. +
  25. Add notes
  26. +
  27. +

    Submit

    +
  28. +
  29. +

    End Session

    +
  30. +
  31. Click "End Session"
  32. +
  33. Review statistics
  34. +
  35. View in activity history
  36. +
+

Task Guides

+

Import Canadian Electoral Data (NAR)

+
    +
  1. Prepare Data
  2. +
  3. Download NAR 2025 data
  4. +
  5. Place in /data directory
  6. +
  7. +

    Ensure Address + Location files present

    +
  8. +
  9. +

    Import via Admin

    +
  10. +
  11. Navigate to /app/map/locations
  12. +
  13. Click "Import NAR"
  14. +
  15. Select province
  16. +
  17. Apply filters
  18. +
  19. +

    Start import

    +
  20. +
  21. +

    Review Import

    +
  22. +
  23. Check location count
  24. +
  25. Verify geocoding
  26. +
  27. Review quality dashboard
  28. +
+

Set Up Public Campaign Page

+
    +
  1. Create Campaign
  2. +
  3. Configure targeting (federal/provincial)
  4. +
  5. Write email template
  6. +
  7. +

    Set to published

    +
  8. +
  9. +

    Share URL

    +
  10. +
  11. Copy public URL: /campaigns/:id
  12. +
  13. Share on social media
  14. +
  15. +

    Embed in website

    +
  16. +
  17. +

    Monitor Engagement

    +
  18. +
  19. View email statistics
  20. +
  21. Moderate responses
  22. +
  23. Check response wall
  24. +
+

Configure Newsletter Sync

+
    +
  1. Enable Listmonk
  2. +
  3. Set LISTMONK_SYNC_ENABLED=true
  4. +
  5. Configure API credentials
  6. +
  7. +

    Restart services

    +
  8. +
  9. +

    Initialize Sync

    +
  10. +
  11. Navigate to /app/services/listmonk
  12. +
  13. Click "Test Connection"
  14. +
  15. +

    Click "Sync Participants"

    +
  16. +
  17. +

    Manage Lists

    +
  18. +
  19. View list statistics
  20. +
  21. Configure sync settings
  22. +
  23. Monitor sync status
  24. +
+

Set Up Public Tunnel

+
    +
  1. Create Pangolin Account
  2. +
  3. Sign up at pangolin.bnkserve.org
  4. +
  5. +

    Generate API key

    +
  6. +
  7. +

    Configure Tunnel

    +
  8. +
  9. Navigate to /app/services/pangolin
  10. +
  11. Enter API key
  12. +
  13. Follow setup wizard
  14. +
  15. +

    Deploy Newt container

    +
  16. +
  17. +

    Test Public Access

    +
  18. +
  19. Visit public URL
  20. +
  21. Verify subdomain routing
  22. +
  23. Check SSL/TLS
  24. +
+

Create Landing Page

+
    +
  1. Start New Page
  2. +
  3. Navigate to /app/pages
  4. +
  5. Click "New Page"
  6. +
  7. +

    Enter title and slug

    +
  8. +
  9. +

    Design Page

    +
  10. +
  11. Click "Edit"
  12. +
  13. Use GrapesJS editor
  14. +
  15. Drag blocks
  16. +
  17. Customize content
  18. +
  19. +

    Save (Ctrl+S)

    +
  20. +
  21. +

    Publish

    +
  22. +
  23. Set to published
  24. +
  25. View at /p/:slug
  26. +
  27. Share URL
  28. +
+

Best Practices

+

Campaign Management

+
    +
  • Use clear, action-oriented language
  • +
  • Test email templates before launch
  • +
  • Monitor response rates
  • +
  • Moderate responses promptly
  • +
  • Follow up with engaged supporters
  • +
+

Field Organizing

+
    +
  • Clean location data before import
  • +
  • Create manageable cut sizes (100-200 locations)
  • +
  • Assign volunteers to familiar areas
  • +
  • Print walk sheets in advance
  • +
  • Review canvass progress daily
  • +
+

Content Creation

+
    +
  • Write mobile-responsive pages
  • +
  • Use SEO-friendly titles and descriptions
  • +
  • Test pages on multiple devices
  • +
  • Keep content concise
  • +
  • Include clear calls-to-action
  • +
+

System Administration

+
    +
  • Change default passwords immediately
  • +
  • Enable monitoring stack
  • +
  • Set up automated backups
  • +
  • Review security audit findings
  • +
  • Keep services updated
  • +
+

Mobile Usage

+

Volunteer Canvassing

+

Best on mobile devices:

+
    +
  • Full-screen map
  • +
  • GPS tracking
  • +
  • Touch-friendly controls
  • +
  • Offline support (future)
  • +
+

Admin Tasks

+

Best on desktop:

+
    +
  • Content editing (GrapesJS, email templates)
  • +
  • Data import/export
  • +
  • Configuration
  • +
  • Monitoring dashboards
  • +
+

Keyboard Shortcuts

+

Page Editor

+
    +
  • Ctrl+S - Save page
  • +
  • Ctrl+Z - Undo
  • +
  • Ctrl+Y - Redo
  • +
+

General

+
    +
  • / - Focus search (tables)
  • +
  • Esc - Close modal/drawer
  • +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/user-guides/map-organizer-guide/index.html b/mkdocs/site/v2/user-guides/map-organizer-guide/index.html new file mode 100644 index 00000000..c35b7ffe --- /dev/null +++ b/mkdocs/site/v2/user-guides/map-organizer-guide/index.html @@ -0,0 +1,7268 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Map Organizer Guide - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Map Organizer Guide

+

Overview

+

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:

+
    +
  • Import and manage locations: Build your canvassing database from CSV or NAR data
  • +
  • Create territorial cuts: Divide your area into manageable canvassing zones
  • +
  • Organize volunteer shifts: Schedule and coordinate door-to-door canvassing
  • +
  • Monitor canvass progress: Track coverage, outcomes, and volunteer performance
  • +
  • Ensure data quality: Review geocoding accuracy and fix issues
  • +
  • Generate walk sheets: Create printable canvassing materials
  • +
+

Whether you're organizing a local ward campaign or a city-wide canvass, this guide provides strategies for effective territory management.

+
+

Understanding Map Roles

+

You may have one of two roles for map management:

+

SUPER_ADMIN

+
    +
  • Access: Full platform access
  • +
  • Capabilities: All map functions plus user management, campaigns, site settings
  • +
  • Use case: Primary administrator
  • +
+

MAP_ADMIN

+
    +
  • Access: Map module only
  • +
  • Capabilities:
  • +
  • Import and manage locations
  • +
  • Create cuts
  • +
  • Organize shifts
  • +
  • Monitor canvassing
  • +
  • Generate walk sheets
  • +
  • Restrictions: Cannot manage users (except shift assignments), campaigns, or site settings
  • +
  • Use case: Dedicated field organizer without full admin access
  • +
+
+

Role Specialization

+

If you only manage field operations (not campaigns), ask for MAP_ADMIN role. This keeps the interface focused on your work.

+
+
+

Understanding Location Data

+

What is a Location?

+

A location is a physical address where canvassing occurs. Each location represents:

+
    +
  • A single-family home, OR
  • +
  • An apartment/condo building (multi-unit), OR
  • +
  • A business address (if canvassing businesses)
  • +
+

Location data includes:

+
    +
  • Address: Full civic address (street, city, province, postal code)
  • +
  • Coordinates: Latitude and longitude (from geocoding)
  • +
  • Building type: RESIDENTIAL, APARTMENT, BUSINESS
  • +
  • Unit count: Number of dwelling units (1 for houses, 10+ for apartments)
  • +
  • Cut assignment: Which territorial cut the location belongs to
  • +
  • Canvass history: Past visits, outcomes, support levels
  • +
+

Building vs Unit Level

+

Building-level data (recommended):

+
    +
  • One location record per building
  • +
  • unitCount field indicates multi-unit buildings
  • +
  • Example: "123 Main St" with unitCount: 24 (apartment building)
  • +
+

Unit-level data (alternative):

+
    +
  • One location record per unit
  • +
  • Example: "123 Main St, Unit 1", "123 Main St, Unit 2", etc.
  • +
  • More granular but creates more records
  • +
+
+

Recommended Approach

+

Use building-level data for apartments (one record with unitCount). This reduces database size and simplifies canvassing (volunteers visit building once, not once per unit).

+
+

Data Sources

+

1. CSV Import — Your own data

+
    +
  • Volunteer sign-up forms
  • +
  • Voter registration data
  • +
  • Membership lists
  • +
  • Custom databases
  • +
+

2. NAR Import — Canadian electoral data

+
    +
  • Elections Canada National Address Register
  • +
  • All residential addresses in Canada
  • +
  • Pre-geocoded coordinates
  • +
  • Federal electoral districts
  • +
+

3. Manual Entry — Individual addresses

+
    +
  • Add one location at a time via admin interface
  • +
  • Click-to-add on map
  • +
+
+

Importing Locations from CSV

+

Preparing Your CSV File

+

Required columns:

+
    +
  • address — Full street address (e.g., "123 Main St")
  • +
  • city — City name (e.g., "Ottawa")
  • +
  • province — Province/state code (e.g., "ON", "BC")
  • +
  • postalCode — Postal code (e.g., "K1A 0B1")
  • +
+

Optional columns:

+
    +
  • latitude — Pre-geocoded latitude (decimal degrees)
  • +
  • longitude — Pre-geocoded longitude (decimal degrees)
  • +
  • buildingType — RESIDENTIAL, APARTMENT, or BUSINESS
  • +
  • unitCount — Number of units (integer, default: 1)
  • +
  • federalDistrict — Electoral district name
  • +
  • notes — Internal notes
  • +
+

CSV example:

+
address,city,province,postalCode,buildingType,unitCount
+"123 Main St","Ottawa","ON","K1A 0B1","RESIDENTIAL",1
+"456 Queen St E, Unit 5","Toronto","ON","M5A 1T1","APARTMENT",36
+"789 Granville St","Vancouver","BC","V6Z 1K3","RESIDENTIAL",1
+
+

CSV formatting tips:

+
    +
  1. Use quotes around addresses with commas
  2. +
  3. Remove special characters (emoji, unusual symbols)
  4. +
  5. Use UTF-8 encoding (not Windows-1252 or ASCII)
  6. +
  7. One header row (first row = column names)
  8. +
  9. No blank rows (delete empty rows at end)
  10. +
  11. Consistent province codes (use 2-letter abbreviations)
  12. +
+

Excel to CSV:

+
    +
  1. Open your Excel file
  2. +
  3. File > Save As
  4. +
  5. Format: "CSV UTF-8 (Comma delimited) (*.csv)"
  6. +
  7. Save
  8. +
+

Importing the CSV

+

To import locations:

+
    +
  1. Navigate to Map > Locations
  2. +
  3. Click "Import CSV" button (top-right)
  4. +
  5. Upload your CSV file (drag-drop or browse)
  6. +
  7. Map CSV columns to location fields
  8. +
  9. Preview imported data (first 10 rows shown)
  10. +
  11. Click "Import"
  12. +
+

Screenshot placeholder: CSV import dialog showing file upload area and column mapping interface

+

Column mapping:

+

The system tries to auto-detect columns, but verify:

+
    +
  • CSV "address" → Location "address"
  • +
  • CSV "city" → Location "city"
  • +
  • CSV "province" → Location "province"
  • +
  • CSV "postalCode" → Location "postalCode"
  • +
+

If your CSV uses different column names (e.g., "Street Address" instead of "address"), map manually using the dropdowns.

+

What happens during import:

+
    +
  1. System validates each row (checks required fields)
  2. +
  3. Skips invalid rows (logs errors)
  4. +
  5. Creates location records
  6. +
  7. Geocodes addresses (if lat/lng not provided)
  8. +
  9. Shows summary: X imported, Y skipped
  10. +
+

Import limits:

+
    +
  • Maximum 10,000 rows per import
  • +
  • For larger datasets, split into multiple files
  • +
+

Troubleshooting Import Issues

+

Issue: "Invalid CSV format"

+

Causes:

+
    +
  • File is not actually CSV (e.g., Excel .xlsx)
  • +
  • Missing header row
  • +
  • Inconsistent column count (some rows have more/fewer columns)
  • +
+

Solutions:

+
    +
  • Save as CSV UTF-8 from Excel
  • +
  • Ensure first row is headers
  • +
  • Remove blank rows and columns
  • +
+
+

Issue: "Missing required field"

+

Causes:

+
    +
  • CSV missing required column (address, city, province, or postalCode)
  • +
  • Column name doesn't match (e.g., "Street" instead of "address")
  • +
+

Solutions:

+
    +
  • Add missing column to CSV
  • +
  • Use column mapping to map "Street" → "address"
  • +
+
+

Issue: "Geocoding failed for X addresses"

+

Causes:

+
    +
  • Addresses are invalid (typos, wrong format)
  • +
  • Addresses are too vague ("Main Street" without number)
  • +
  • Geocoding service is down
  • +
+

Solutions:

+
    +
  • Review failed addresses in Data Quality dashboard
  • +
  • Fix typos and re-import those rows
  • +
  • Manually place locations on map (see below)
  • +
+
+

NAR Import (Canadian Electoral Data)

+

What is NAR Data?

+

NAR (National Address Register) is Elections Canada's official database of all residential addresses in Canada. It includes:

+
    +
  • Precise civic addresses (from Address files)
  • +
  • Geocoded coordinates (from Location files)
  • +
  • Federal electoral districts
  • +
  • Building use classification (residential, commercial, institutional)
  • +
+

Advantages:

+
    +
  • ✅ Comprehensive (all Canadian addresses)
  • +
  • ✅ Pre-geocoded (high accuracy)
  • +
  • ✅ Includes federal district data
  • +
  • ✅ Updated regularly by Elections Canada
  • +
+

Disadvantages:

+
    +
  • ❌ Canada only (not available for other countries)
  • +
  • ❌ Requires server access to install data files
  • +
  • ❌ Large file size (multi-GB for provinces like Ontario)
  • +
+

Obtaining NAR Data

+

NAR data must be obtained from Elections Canada:

+
    +
  1. Contact Elections Canada Open Data team
  2. +
  3. Request latest NAR dataset (e.g., "NAR 2025 Server")
  4. +
  5. Download Address and Location files
  6. +
  7. Provide files to your system administrator
  8. +
+

Files needed:

+
    +
  • Address_[province]_part_[X].csv — Civic addresses
  • +
  • Location_[province].csv — Geocoded coordinates
  • +
+

System administrator places files in /data directory on server.

+

Importing NAR Data

+

To import NAR data:

+
    +
  1. Navigate to Map > Locations
  2. +
  3. Click "NAR Import" button
  4. +
  5. Select province (e.g., Ontario)
  6. +
  7. Choose dataset (if multiple years available)
  8. +
  9. Apply filters (see below)
  10. +
  11. Click "Start Import"
  12. +
+

Screenshot placeholder: NAR Import modal showing province selector, dataset picker, and filter options

+

Import filters:

+

Province filter (required):

+
    +
  • Select province to import (ON, BC, AB, etc.)
  • +
  • Each province has separate Address/Location files
  • +
+

City filter (optional):

+
    +
  • Import only specific cities
  • +
  • Example: "Toronto,Ottawa,Mississauga" (comma-separated)
  • +
  • Leave blank to import entire province
  • +
+

Postal code filter (optional):

+
    +
  • Import only specific postal code prefixes
  • +
  • Example: "K1A,K1B,K1C" (forward sortation areas)
  • +
  • Useful for targeting specific neighborhoods
  • +
+

Cut filter (optional):

+
    +
  • Assign imported locations to a specific cut
  • +
  • If left blank, locations are imported without cut assignment
  • +
  • You can assign to cuts later
  • +
+

Residential only (toggle):

+
    +
  • ON: Import only residential buildings (exclude commercial, institutional)
  • +
  • OFF: Import all buildings
  • +
  • Recommended: ON (unless you're canvassing businesses)
  • +
+

What happens during NAR import:

+
    +
  1. System scans NAR files for selected province
  2. +
  3. Joins Address and Location files on LOC_GUID (internal Elections Canada ID)
  4. +
  5. Filters by city, postal code (if specified)
  6. +
  7. Converts coordinates from EPSG:3347 (Lambert projection) to WGS84 (lat/lng)
  8. +
  9. Creates location records
  10. +
  11. Shows progress (can take several minutes for large provinces)
  12. +
+

Import performance:

+
    +
  • Small municipality (10k addresses): ~30 seconds
  • +
  • Large city (500k addresses): ~5 minutes
  • +
  • Full province (3M addresses): ~20 minutes
  • +
+
+

Server-Side Processing

+

NAR import runs on the server (not in your browser). Do not close the modal during import—wait for completion message.

+
+

NAR Data Fields

+

NAR import populates these location fields:

+
    +
  • address — From Address file: CIVIC_NO + OFFICIAL_STREET_NAME + STREET_TYPE + STREET_DIRECTION
  • +
  • city — From Address file: MUNICIPALITY_NAME
  • +
  • province — From province code
  • +
  • postalCode — From Address file: POSTAL_CODE
  • +
  • latitude — From Location file: BG_LATITUDE (converted to WGS84)
  • +
  • longitude — From Location file: BG_LONGITUDE (converted to WGS84)
  • +
  • federalDistrict — From Location file: FED_NUM (district number) + name lookup
  • +
  • buildingUse — From Address file: BUILDING_USE (RESIDENTIAL, COMMERCIAL, INSTITUTIONAL)
  • +
+
+

Creating and Managing Cuts

+

What is a Cut?

+

A cut is a geographic area used to organize canvassing. Cuts are polygons drawn on a map.

+

Common cut types:

+
    +
  • WARD: Municipal electoral ward
  • +
  • NEIGHBORHOOD: Informal neighborhood (e.g., "Downtown", "Riverside")
  • +
  • DISTRICT: Federal or provincial electoral district
  • +
  • CUSTOM: Any other boundary (e.g., "North of Highway", "Priority Zone")
  • +
+

Why use cuts?

+
    +
  • Assign territories to volunteers: "You canvass Ward 5"
  • +
  • Track progress by area: "Ward 5 is 75% complete"
  • +
  • Generate walk sheets: Print addresses for Ward 5 only
  • +
  • Prevent duplication: Volunteers know their boundaries
  • +
+

Cut Best Practices

+

Size:

+
    +
  • Recommended: 200-500 locations per cut
  • +
  • Too small (< 100): Inefficient (volunteers finish too quickly)
  • +
  • Too large (> 1000): Overwhelming (takes many sessions to complete)
  • +
+

Boundaries:

+
    +
  • Use natural boundaries: Roads, rivers, parks, rail lines
  • +
  • Avoid cutting through neighborhoods arbitrarily
  • +
  • Use official boundaries when available (ward maps, district maps)
  • +
+

Naming:

+
    +
  • Use official names when available ("Ward 5", "Riverdale")
  • +
  • Be consistent (don't mix "Ward 5" and "Fifth Ward")
  • +
  • Avoid abbreviations unless universally understood
  • +
+

Colors:

+
    +
  • Use distinct colors for adjacent cuts
  • +
  • Use color coding meaningfully (e.g., priority cuts in red)
  • +
  • Ensure colors are visible on both light and dark backgrounds
  • +
+

Creating a Cut (Drawing on Map)

+

To create a cut:

+
    +
  1. Navigate to Map > Cuts
  2. +
  3. Click the "Map Drawing" tab
  4. +
  5. Click "Start Drawing"
  6. +
  7. Click on the map to add polygon vertices
  8. +
  9. Close the polygon (click near first vertex)
  10. +
  11. Fill in cut details (see form below)
  12. +
  13. Click "Save Cut"
  14. +
+

Screenshot placeholder: Cut drawing interface showing map with polygon being drawn

+

Drawing tips:

+
    +
  1. Start at a corner: Begin at a distinct landmark (intersection, park corner)
  2. +
  3. Follow roads: Click along roads and boundaries
  4. +
  5. Use zoom: Zoom in for precision, out for overview
  6. +
  7. Closing detection: System detects when you're near the first point and offers to close
  8. +
  9. Undo: Click "Undo Last Point" if you make a mistake
  10. +
+

Cut form fields:

+

Name (required):

+
    +
  • Cut identifier (e.g., "Ward 5", "Downtown")
  • +
  • Displayed on map, walk sheets, volunteer portal
  • +
+

Category (required):

+
    +
  • WARD, NEIGHBORHOOD, DISTRICT, or CUSTOM
  • +
  • Used for filtering and organizing
  • +
+

Color (required):

+
    +
  • Display color on map
  • +
  • Use color picker or enter hex code (#FF5733)
  • +
+

Description (optional):

+
    +
  • Internal notes about the cut
  • +
  • Example: "Priority area, high support expected"
  • +
+

Screenshot placeholder: Cut creation form showing name, category, color picker, and description

+

Automatic Location Assignment

+

When you save a cut, the system automatically:

+
    +
  1. Checks which locations fall inside the polygon (point-in-polygon algorithm)
  2. +
  3. Assigns those locations to the cut
  4. +
  5. Shows count: "X locations assigned"
  6. +
+

Re-assignment:

+
    +
  • Locations can only belong to one cut
  • +
  • If you draw overlapping cuts, later cuts override earlier assignments
  • +
  • Review location table to verify assignments
  • +
+

Editing Cuts

+

To edit a cut:

+
    +
  1. Navigate to Map > Cuts
  2. +
  3. Click "Edit" in Actions column
  4. +
  5. Modify name, category, color, or description
  6. +
  7. Click "Save"
  8. +
+

Note: You cannot edit the polygon shape after creation. To change boundaries, delete the cut and redraw.

+

To delete a cut:

+
    +
  1. Click "Delete" in Actions column
  2. +
  3. Confirm deletion
  4. +
+

What happens to locations?

+
    +
  • Cut assignment is removed (locations become unassigned)
  • +
  • Locations are NOT deleted
  • +
  • Historical canvass data is preserved (visits remain linked to coordinates)
  • +
+
+

Managing Locations

+

Viewing and Filtering Locations

+

To view all locations:

+
    +
  1. Navigate to Map > Locations
  2. +
+

The locations table shows:

+
    +
  • Address: Full civic address
  • +
  • City: City name
  • +
  • Cut: Assigned cut (if any)
  • +
  • Geocoded: ✅ (has coordinates) or ❌ (needs geocoding)
  • +
  • Last Visit: Date of most recent canvass visit
  • +
  • Actions: Edit, delete
  • +
+

Filters:

+
    +
  • Search: Search by address or postal code
  • +
  • Cut: Filter to specific cut
  • +
  • Geocoded: Show only geocoded or ungeocoded
  • +
  • Building Type: Filter by RESIDENTIAL, APARTMENT, BUSINESS
  • +
  • Date Added: Filter by import/creation date
  • +
+

Screenshot placeholder: Locations table with search bar, cut filter, and geocoded status column

+

Editing a Location

+

To edit a location:

+
    +
  1. Click "Edit" in Actions column
  2. +
  3. Modify fields (see below)
  4. +
  5. Click "Save"
  6. +
+

Editable fields:

+

Address details:

+
    +
  • Street address
  • +
  • City
  • +
  • Province
  • +
  • Postal code
  • +
+

Coordinates:

+
    +
  • Latitude (decimal degrees, e.g., 45.4215)
  • +
  • Longitude (decimal degrees, e.g., -75.6972)
  • +
  • Drag map pin to adjust visually
  • +
+

Metadata:

+
    +
  • Building type (RESIDENTIAL, APARTMENT, BUSINESS)
  • +
  • Unit count (integer)
  • +
  • Federal district (text)
  • +
  • Notes (internal notes)
  • +
+

Cut assignment:

+
    +
  • Select cut from dropdown
  • +
  • Or leave blank (unassigned)
  • +
+

Screenshot placeholder: Edit Location modal showing address fields, map with draggable pin, and metadata fields

+

Manually Placing Locations on Map

+

If geocoding fails, you can manually place a location:

+
    +
  1. Edit the location
  2. +
  3. Use the map at the bottom of the form
  4. +
  5. Drag the red pin to the correct position
  6. +
  7. Latitude and longitude fields update automatically
  8. +
  9. Click "Save"
  10. +
+

Tip: Use satellite view or street view to identify exact building location.

+

Bulk Operations

+

To perform bulk actions:

+
    +
  1. Select locations (checkboxes in table)
  2. +
  3. Choose action from "Bulk Actions" dropdown:
  4. +
  5. Assign to Cut: Assign selected locations to a cut
  6. +
  7. Geocode: Re-geocode selected locations
  8. +
  9. Delete: Delete selected locations
  10. +
  11. Confirm action
  12. +
+

Screenshot placeholder: Bulk actions dropdown with selected locations and action buttons

+

Deleting Locations

+

To delete locations:

+
    +
  1. Select locations in table (or filter and select all)
  2. +
  3. Choose "Delete" from bulk actions
  4. +
  5. Confirm deletion
  6. +
+
+

Canvass History Preserved

+

Deleting a location removes the address record but preserves canvass visit data (visits are linked to coordinates, not location IDs). Historical data remains for reporting.

+
+
+

Geocoding and Data Quality

+

Understanding Geocoding

+

Geocoding converts addresses to latitude/longitude coordinates for map display.

+

Why geocoding matters:

+
    +
  • Locations without coordinates cannot appear on map
  • +
  • Inaccurate coordinates place locations in wrong areas
  • +
  • Poor geocoding affects canvassing efficiency (volunteers can't find addresses)
  • +
+

Geocoding Providers

+

Changemaker Lite tries multiple geocoding providers in order:

+
    +
  1. Nominatim (OpenStreetMap) — Free, no API key, global coverage
  2. +
  3. ArcGIS — Free tier, accurate for North America
  4. +
  5. Photon — Free, Europe-focused
  6. +
  7. Mapbox — Requires API key, very accurate
  8. +
  9. Google Geocoding — Requires API key, most accurate
  10. +
  11. LocationIQ — Requires API key, Nominatim-based
  12. +
+

How it works:

+
    +
  • System tries Nominatim first
  • +
  • If confidence < 0.5, tries next provider
  • +
  • If all fail, location remains ungeocoded
  • +
+

API keys (optional, configured by admin):

+
    +
  • Mapbox: MAPBOX_API_KEY
  • +
  • Google: GOOGLE_MAPS_API_KEY
  • +
  • LocationIQ: LOCATIONIQ_API_KEY
  • +
+

Without API keys, only free providers (Nominatim, ArcGIS, Photon) are used.

+

Geocode Confidence Levels

+

Each geocoded location has a confidence score (0.0 to 1.0):

+
    +
  • 0.9-1.0: High confidence (exact address match)
  • +
  • 0.7-0.9: Medium-high confidence (likely correct)
  • +
  • 0.5-0.7: Medium confidence (street or area match)
  • +
  • 0.3-0.5: Low confidence (approximate)
  • +
  • 0.0-0.3: Very low confidence (city or region only)
  • +
+

Confidence affects accuracy:

+
    +
  • High confidence → Pin is at exact building
  • +
  • Low confidence → Pin may be at street midpoint or city center
  • +
+

Data Quality Dashboard

+

To review geocoding quality:

+
    +
  1. Navigate to Map > Data Quality
  2. +
+

The dashboard shows:

+

Statistics cards:

+
    +
  • Total locations: All location records
  • +
  • Geocoded: Locations with coordinates
  • +
  • Ungeocoded: Locations without coordinates
  • +
  • Low confidence: Confidence < 0.5
  • +
  • Medium confidence: Confidence 0.5-0.8
  • +
  • High confidence: Confidence > 0.8
  • +
+

Geocoding provider breakdown:

+
    +
  • Chart showing which providers geocoded how many locations
  • +
  • Example: 60% Nominatim, 30% ArcGIS, 10% Mapbox
  • +
+

Confidence distribution:

+
    +
  • Histogram showing confidence score distribution
  • +
  • Identify patterns (many low-confidence addresses?)
  • +
+

Action items:

+
    +
  • Re-geocode low confidence: Button to retry with different provider
  • +
  • Export ungeocoded: CSV of failed addresses
  • +
  • Manual review: Link to locations table filtered for low confidence
  • +
+

Screenshot placeholder: Data Quality Dashboard showing statistics cards, provider pie chart, and confidence histogram

+

Improving Geocoding Quality

+

Strategy 1: Fix Address Typos

+
    +
  1. Export ungeocoded locations (CSV)
  2. +
  3. Review addresses in Excel
  4. +
  5. Fix typos, formatting errors
  6. +
  7. Re-import corrected CSV
  8. +
+

Common issues:

+
    +
  • Missing civic number ("Main Street" → "123 Main Street")
  • +
  • Misspelled street name ("Mane St" → "Main St")
  • +
  • Wrong province ("ON" → "BC")
  • +
+
+

Strategy 2: Re-geocode with Better Provider

+
    +
  1. Configure API keys for Mapbox or Google (ask admin)
  2. +
  3. Select low-confidence locations
  4. +
  5. Click "Geocode Selected" (bulk action)
  6. +
  7. System retries with all available providers
  8. +
+
+

Strategy 3: Manually Place Locations

+
    +
  1. Filter locations with confidence < 0.5
  2. +
  3. Edit each location
  4. +
  5. Find correct position on map (use satellite view)
  6. +
  7. Drag pin to correct location
  8. +
  9. Save
  10. +
+
+

Strategy 4: Use NAR Data (Canada Only)

+

NAR data includes pre-geocoded coordinates with very high accuracy. If you imported from CSV and have poor geocoding, consider switching to NAR import.

+
+

Organizing Volunteer Shifts

+

What is a Shift?

+

A shift is a scheduled volunteer canvassing session. Shifts have:

+
    +
  • Title: Name of the canvass (e.g., "Saturday Morning Canvass - Ward 5")
  • +
  • Start/End Time: When volunteers should arrive and finish
  • +
  • Cut Assignment: Which area to canvass (optional but recommended)
  • +
  • Max Signups: Capacity limit (0 = unlimited)
  • +
  • Meeting Location: Where volunteers meet before canvassing
  • +
+

Why shifts matter:

+
    +
  • Coordinate volunteers: Everyone knows when and where to show up
  • +
  • Track assignments: Volunteers see "their" shifts in portal
  • +
  • Enable canvassing: Volunteers can only start canvass sessions if they have a shift
  • +
  • Measure progress: See which shifts generated most visits
  • +
+

Creating a Shift

+

To create a shift:

+
    +
  1. Navigate to Map > Shifts
  2. +
  3. Click "Create Shift"
  4. +
  5. Fill in shift details (see below)
  6. +
  7. Click "Create"
  8. +
+

Shift fields:

+

Title (required):

+
    +
  • Descriptive name
  • +
  • Include date, time, and area
  • +
  • Example: "Saturday Morning Canvass - Ward 5"
  • +
+

Description (optional):

+
    +
  • Additional details for volunteers
  • +
  • Example: "Bring water, comfortable shoes. We'll provide clipboards and walk sheets."
  • +
+

Start Time (required):

+
    +
  • Date and time picker
  • +
  • When volunteers should arrive
  • +
+

End Time (required):

+
    +
  • Expected end time
  • +
  • Helps volunteers plan their day
  • +
+

Cut (optional but recommended):

+
    +
  • Select which cut to canvass
  • +
  • Volunteers assigned to this shift will see this cut in their portal
  • +
  • Shifts without cuts cannot be canvassed
  • +
+
+

Cut Assignment Required for Canvassing

+

Volunteers can only start canvass sessions for shifts assigned to a cut. Always assign a cut unless the shift is for training or other non-canvassing purposes.

+
+

Max Signups (optional):

+
    +
  • Capacity limit (e.g., 10 volunteers)
  • +
  • Set to 0 for unlimited
  • +
  • Useful for managing group size
  • +
+

Meeting Location (optional):

+
    +
  • Address or description of meeting point
  • +
  • Example: "Community Centre, 123 Main St" or "Corner of Main & Oak"
  • +
+

Screenshot placeholder: Create Shift form showing date/time picker, cut dropdown, capacity field, and meeting location

+

Managing Shift Signups

+

To view shift signups:

+
    +
  1. Navigate to Map > Shifts
  2. +
  3. Click "Signups" in Actions column for a shift
  4. +
+

The signups drawer shows:

+

Capacity gauge:

+
    +
  • Current signups / Max signups
  • +
  • Example: "8 / 10 signups (80% full)"
  • +
+

Signup list:

+
    +
  • Volunteer name
  • +
  • Email
  • +
  • Role (USER or TEMP)
  • +
  • Signup date
  • +
  • Actions: Remove signup, upgrade TEMP to USER
  • +
+

Signup sources:

+
    +
  1. Public signup form (/shifts page):
  2. +
  3. Anyone can sign up
  4. +
  5. Creates TEMP user account automatically
  6. +
  7. +

    Sends confirmation email

    +
  8. +
  9. +

    Admin-added:

    +
  10. +
  11. You manually add volunteers
  12. +
  13. +

    Select existing users or create new

    +
  14. +
  15. +

    Volunteer portal:

    +
  16. +
  17. USER-role volunteers sign up themselves
  18. +
  19. See My Shifts page in their portal
  20. +
+

Screenshot placeholder: Shift Signups drawer showing capacity gauge and signup list

+

Adding Volunteers to a Shift

+

To manually add a volunteer:

+
    +
  1. Click "Signups" for the shift
  2. +
  3. Click "Add Volunteer"
  4. +
  5. Select existing user from dropdown (or click "Create New User")
  6. +
  7. Click "Add"
  8. +
+

Upgrading TEMP users to USER:

+

After a TEMP user attends their first shift:

+
    +
  1. Open shift signups
  2. +
  3. Find the TEMP user
  4. +
  5. Click "Upgrade to USER"
  6. +
  7. Confirm
  8. +
+

This gives them full canvassing access for future shifts.

+

Emailing Shift Volunteers

+

To email all volunteers in a shift:

+
    +
  1. Click "Signups" for the shift
  2. +
  3. Click "Email All"
  4. +
  5. Compose email:
  6. +
  7. Subject
  8. +
  9. Body (HTML supported)
  10. +
  11. Variables: {{NAME}}, {{SHIFT_TITLE}}, {{SHIFT_START}}, {{MEETING_LOCATION}}
  12. +
  13. Click "Send"
  14. +
+

Common email scenarios:

+

Reminder (day before shift):

+
Subject: Reminder: Tomorrow's Canvass - {{SHIFT_TITLE}}
+
+Hi {{NAME}},
+
+This is a reminder about tomorrow's canvass:
+
+Shift: {{SHIFT_TITLE}}
+Time: {{SHIFT_START}}
+Meeting Point: {{MEETING_LOCATION}}
+
+Please arrive 10 minutes early. We'll provide walk sheets and materials.
+
+Looking forward to seeing you there!
+
+

Cancellation (weather, etc.):

+
Subject: CANCELLED: {{SHIFT_TITLE}}
+
+Hi {{NAME}},
+
+Unfortunately, we need to cancel tomorrow's canvass due to severe weather.
+
+We'll reschedule and send you a new date soon. Thank you for your understanding.
+
+

Follow-up (after shift):

+
Subject: Thank you for canvassing!
+
+Hi {{NAME}},
+
+Thank you for participating in {{SHIFT_TITLE}}! Your efforts made a real difference.
+
+Together, we knocked on [X] doors and spoke with [Y] residents.
+
+See you at the next shift!
+
+

Screenshot placeholder: Email Shift Volunteers modal showing subject, body editor, and variable buttons

+
+

Generating Walk Sheets

+

What is a Walk Sheet?

+

A walk sheet is a printed list of addresses for door-to-door canvassing. It includes:

+
    +
  • Cut name and statistics
  • +
  • QR code (volunteers scan to start canvass session)
  • +
  • List of addresses in walking order
  • +
  • Fields for volunteers to record outcomes
  • +
+

Walk Sheet Settings

+

To configure walk sheet defaults:

+
    +
  1. Navigate to Map > Map Settings
  2. +
  3. Scroll to "Walk Sheet Configuration"
  4. +
  5. Set:
  6. +
  7. Header Text: Organization name, campaign info
  8. +
  9. Footer Text: Contact info, instructions
  10. +
  11. Include QR Code: Toggle ON/OFF
  12. +
  13. QR Code Size: Small, medium, large
  14. +
  15. Instructions: How to use the walk sheet
  16. +
+

Example header:

+
Community Action Network
+Fall 2024 Canvass
+Contact: organizer@example.com | (555) 123-4567
+
+

Example footer:

+
Record outcomes: NH (Not Home), R (Refused), SW (Spoke With), S1-S4 (Support Level)
+Return completed walk sheets to the office by end of week.
+
+

Screenshot placeholder: Map Settings page showing walk sheet configuration section

+

Generating a Walk Sheet

+

To generate a walk sheet for a cut:

+
    +
  1. Navigate to Canvass > Walk Sheet
  2. +
  3. Select cut from dropdown
  4. +
  5. Click "Generate"
  6. +
  7. Review PDF preview
  8. +
  9. Click "Print" or "Download PDF"
  10. +
+

OR:

+
    +
  1. Navigate to Map > Locations
  2. +
  3. Filter to specific cut
  4. +
  5. Click "Walk Sheet" button (top-right)
  6. +
+

Walk sheet contents:

+

Page 1:

+
    +
  • Header (from settings)
  • +
  • Cut name and statistics:
  • +
  • Total locations
  • +
  • Last visit summary
  • +
  • Completion percentage
  • +
  • QR code (links to /volunteer/canvass/[cutId])
  • +
  • Instructions (from settings)
  • +
  • Cut map (small overview map)
  • +
+

Subsequent pages:

+
    +
  • Address table:
  • +
  • Street address
  • +
  • Unit count (if apartment building)
  • +
  • Last visit date (if previously canvassed)
  • +
  • Last outcome (if previously canvassed)
  • +
  • Blank fields for volunteers to fill:
      +
    • Date visited
    • +
    • Outcome
    • +
    • Support level
    • +
    • Notes
    • +
    +
  • +
+

Screenshot placeholder: Walk sheet PDF showing header, QR code, map, and address table

+

Walking Order Optimization

+

Walk sheets sort addresses in walking order to minimize backtracking.

+

Algorithm:

+
    +
  1. Start at center of cut
  2. +
  3. Find nearest unvisited address
  4. +
  5. Move to that address
  6. +
  7. Repeat until all addresses covered
  8. +
+

This creates an efficient route similar to the GPS route in the volunteer portal.

+

Using Walk Sheets in the Field

+

Distribute to volunteers:

+
    +
  1. Print one walk sheet per volunteer (or per pair, if canvassing in pairs)
  2. +
  3. Bring clipboards and pens
  4. +
  5. Brief volunteers on how to record outcomes
  6. +
+

Volunteers record:

+
    +
  • Date visited
  • +
  • Outcome code (NH, R, SW, etc.)
  • +
  • Support level (S1-S4 if spoke with)
  • +
  • Notes (brief comments)
  • +
+

After the canvass:

+
    +
  1. Collect completed walk sheets
  2. +
  3. Enter data into system (or scan QR code during canvass for automatic recording)
  4. +
+
+

Monitoring Canvass Progress

+

Canvass Dashboard

+

To view overall canvass progress:

+
    +
  1. Navigate to Canvass > Dashboard
  2. +
+

The dashboard shows:

+

Statistics cards:

+
    +
  • Active sessions: Volunteers currently canvassing
  • +
  • Total visits today: Doors knocked today
  • +
  • Completed sessions: Finished sessions today
  • +
  • Average session duration: Time spent canvassing
  • +
+

Activity feed:

+
    +
  • Real-time stream of visits
  • +
  • Shows: Volunteer name, address, outcome, timestamp
  • +
  • Updates every 30 seconds
  • +
+

Cut progress table:

+
    +
  • Progress by cut (% of locations visited)
  • +
  • Session count per cut
  • +
  • Visit count per cut
  • +
  • Click cut name to view details
  • +
+

Leaderboard:

+
    +
  • Top volunteers by visit count
  • +
  • Session count
  • +
  • Success rate (% SPOKE_WITH outcomes)
  • +
+

Screenshot placeholder: Canvass Dashboard showing stats cards, activity feed, cut progress table, and leaderboard

+

Cut-Level Progress

+

To view progress for a specific cut:

+
    +
  1. Navigate to Canvass > Dashboard
  2. +
  3. Click cut name in cut progress table
  4. +
+

Cut detail view shows:

+
    +
  • Completion gauge: % of locations visited
  • +
  • Outcome breakdown: Pie chart of outcomes (NOT_HOME, REFUSED, SPOKE_WITH, etc.)
  • +
  • Support levels: Count of LEVEL_1 through LEVEL_4
  • +
  • Visit history: Recent visits in this cut
  • +
  • Active sessions: Volunteers currently canvassing this cut
  • +
+

Export cut data:

+
    +
  • Click "Export CSV" to download all visits for this cut
  • +
  • Use for analysis, reporting, follow-up planning
  • +
+

Session Monitoring

+

To view active canvass sessions:

+
    +
  1. Navigate to Canvass > Dashboard
  2. +
  3. Scroll to "Active Sessions" section
  4. +
+

Each active session shows:

+
    +
  • Volunteer name
  • +
  • Cut being canvassed
  • +
  • Start time
  • +
  • Visit count
  • +
  • Last activity (how long since last visit)
  • +
+

Warning signs:

+
    +
  • ⚠️ No activity for > 30 minutes (volunteer may be stuck or abandoned session)
  • +
  • ⚠️ Very low visit rate (volunteer may need help)
  • +
+

Actions:

+
    +
  • Contact volunteer to check in
  • +
  • Manually end session if abandoned
  • +
+
+

Data Analysis and Reporting

+

Outcome Analysis

+

To understand canvassing results:

+
    +
  1. Navigate to Canvass > Dashboard
  2. +
  3. View Outcome Breakdown chart
  4. +
+

Outcome categories:

+
    +
  • NOT_HOME: Nobody answered (typical: 40-60% of visits)
  • +
  • REFUSED: Refused to talk (typical: 5-15%)
  • +
  • SPOKE_WITH: Had a conversation (typical: 20-40%)
  • +
  • MOVED_AWAY: Resident moved (typical: 2-5%)
  • +
  • WRONG_ADDRESS: Address doesn't exist (typical: 1-3%)
  • +
  • DO_NOT_CONTACT: Requested no contact (typical: < 1%)
  • +
  • OTHER: Other situation (typical: < 5%)
  • +
+

Interpreting outcomes:

+

High NOT_HOME rate (> 60%):

+
    +
  • Canvassing at wrong time (try evenings or weekends)
  • +
  • Multi-unit buildings (hard to access)
  • +
+

High REFUSED rate (> 20%):

+
    +
  • Issue is unpopular or controversial
  • +
  • Volunteers may need better training on approach
  • +
  • Consider different messaging
  • +
+

Low SPOKE_WITH rate (< 20%):

+
    +
  • See above (related to NOT_HOME and REFUSED)
  • +
  • Canvassing at wrong time
  • +
  • Poor volunteer approach
  • +
+

High WRONG_ADDRESS (> 5%):

+
    +
  • Data quality issues
  • +
  • Need to clean location database
  • +
+

Support Level Analysis

+

To understand voter sentiment:

+
    +
  1. View Support Levels on Canvass Dashboard
  2. +
+

Support level breakdown:

+
    +
  • LEVEL_1 (Strong support): Target for GOTV (Get Out The Vote)
  • +
  • LEVEL_2 (Leaning support): Persuasion targets
  • +
  • LEVEL_3 (Undecided): Persuasion targets
  • +
  • LEVEL_4 (Opposition): Deprioritize future contact
  • +
+

Targeting strategy:

+

For GOTV:

+
    +
  • Focus on LEVEL_1 (strong support)
  • +
  • Ensure they vote (door knock day before election, offer rides)
  • +
+

For persuasion:

+
    +
  • Focus on LEVEL_2 and LEVEL_3 (undecided, leaning)
  • +
  • Provide information, answer questions, invite to events
  • +
+

For opposition:

+
    +
  • LEVEL_4: Don't waste time (respect their decision)
  • +
+

Volunteer Performance

+

To evaluate volunteer effectiveness:

+
    +
  1. View Leaderboard on Canvass Dashboard
  2. +
+

Metrics:

+
    +
  • Visit count: Total doors knocked
  • +
  • Session count: Number of canvassing sessions
  • +
  • Success rate: % of visits that resulted in SPOKE_WITH outcome
  • +
  • Average session duration: Time spent canvassing
  • +
+

Identifying top performers:

+
    +
  • High visit count + high success rate = Star volunteer (recognize publicly, ask to mentor others)
  • +
  • High visit count + low success rate = May be rushing (provide feedback)
  • +
  • Low visit count + high success rate = Quality over quantity (consider assigning harder areas)
  • +
+

Coaching opportunities:

+
    +
  • Low success rate: Offer training on approach, scripting
  • +
  • Short sessions: Ask why (time constraints? Lack of confidence?)
  • +
  • High REFUSED rate: Review volunteer's approach (too pushy? Poor messaging?)
  • +
+
+

Troubleshooting

+

Geocoding Issues

+

Issue: Many locations ungeocoded after import

+

Solutions:

+
    +
  1. Review ungeocoded addresses (Data Quality > Export Ungeocoded)
  2. +
  3. Fix typos and re-import
  4. +
  5. Configure additional geocoding API keys (Mapbox, Google)
  6. +
  7. Manually place locations on map
  8. +
+
+

Issue: Locations geocoded to wrong area

+

Symptoms: Locations appear far from where they should be

+

Solutions:

+
    +
  1. Check confidence score (likely low confidence)
  2. +
  3. Edit location and manually place on map
  4. +
  5. Re-geocode with better provider (if API key available)
  6. +
+
+

Cut Issues

+

Issue: Locations not assigning to cut

+

Symptoms: Locations inside polygon not assigned after cut creation

+

Solutions:

+
    +
  1. Verify polygon is properly closed (check vertices)
  2. +
  3. Check for very complex polygons (may hit algorithm limits)
  4. +
  5. Manually assign locations using bulk action
  6. +
+
+

Issue: Overlapping cuts

+

Symptoms: Some locations assigned to wrong cut

+

Cause: Multiple cuts cover the same area

+

Solution:

+
    +
  • Locations can only belong to one cut
  • +
  • Later cuts override earlier assignments
  • +
  • Redraw cuts to avoid overlap, OR
  • +
  • Accept overlap and use manual assignment for edge cases
  • +
+
+

Shift Issues

+

Issue: Volunteer cannot start canvass session

+

Symptoms: "No active shift found" error

+

Solutions:

+
    +
  1. Verify shift date is today
  2. +
  3. Verify volunteer is signed up for shift
  4. +
  5. Verify shift has a cut assigned (required for canvassing)
  6. +
  7. Verify volunteer role is USER (not TEMP)
  8. +
+
+

Issue: Shift signups not appearing

+

Symptoms: Public signup form doesn't show shift

+

Solutions:

+
    +
  1. Check shift start time (past shifts don't appear)
  2. +
  3. Check max signups (if full, shift is hidden)
  4. +
  5. Check feature toggle (Settings > Allow Public Shift Signup must be ON)
  6. +
+
+

Canvassing Issues

+

Issue: Walking route not updating

+

Symptoms: Route doesn't change after completing visits

+

Solutions:

+
    +
  1. Route updates every 30 seconds (wait a moment)
  2. +
  3. Refresh volunteer's map (pull down)
  4. +
  5. Check internet connection (route calculation requires server)
  6. +
+
+

Issue: Visit won't save

+

Symptoms: Volunteer reports "Save Visit" doesn't work

+

Solutions:

+
    +
  1. Check internet connection (visits save to server)
  2. +
  3. Verify outcome is selected (required field)
  4. +
  5. Check for abandoned session (volunteer may need to start new session)
  6. +
+
+ + +
+

Last updated: February 2026 (V2 complete)

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/v2/user-guides/volunteer-guide/index.html b/mkdocs/site/v2/user-guides/volunteer-guide/index.html new file mode 100644 index 00000000..117a7759 --- /dev/null +++ b/mkdocs/site/v2/user-guides/volunteer-guide/index.html @@ -0,0 +1,7471 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Volunteer Guide - Changemaker Lite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

Volunteer Guide

+

Overview

+

Welcome to Changemaker Lite! As a volunteer, you'll use the volunteer portal to:

+
    +
  • View your assigned shifts: See upcoming canvassing shifts you've signed up for
  • +
  • Canvas neighborhoods: Go door-to-door talking to voters
  • +
  • Record visit outcomes: Track who you spoke with and their responses
  • +
  • Navigate efficiently: Use GPS and walking routes to cover your territory
  • +
  • Track your activity: View your canvassing history and statistics
  • +
+

This guide will help you get started and make the most of your canvassing time.

+
+

Getting Started

+

Creating Your Account

+

There are two ways to get a volunteer account:

+

Option 1: Sign Up for a Shift (Creates Temporary Account)

+
    +
  1. Visit the public shifts page (your organizer will send you the link)
  2. +
  3. Find a shift that works for your schedule
  4. +
  5. Click "Sign Up"
  6. +
  7. Fill in:
  8. +
  9. Your name
  10. +
  11. Your email address
  12. +
  13. Phone number (optional)
  14. +
  15. Click "Confirm Signup"
  16. +
+

You'll receive a confirmation email with your temporary login credentials.

+
+

Temporary Accounts

+

When you sign up for a shift publicly, you get a TEMP account. This gives you limited access. After your first shift, an administrator will upgrade you to a full USER account with canvassing access.

+
+

Option 2: Admin Creates Your Account

+

Your organizer may create an account for you directly. You'll receive a welcome email with:

+
    +
  • Your login email address
  • +
  • A temporary password
  • +
  • Instructions to change your password on first login
  • +
+

Screenshot placeholder: Shift signup form showing name, email, and phone fields

+

Logging In

+

To access the volunteer portal:

+
    +
  1. Go to your organization's login page (usually https://app.yourorg.org)
  2. +
  3. Enter your email address
  4. +
  5. Enter your password
  6. +
  7. Click "Log In"
  8. +
+

After logging in, you'll be automatically redirected to the volunteer dashboard at /volunteer.

+
+

Remember Me

+

Check "Remember me" to stay logged in for 7 days. Only do this on your personal device.

+
+

Screenshot placeholder: Login page with email/password fields and "Remember me" checkbox

+

First Login: Change Your Password

+

If you received a temporary password, change it immediately:

+
    +
  1. After logging in, click your email in the top-right corner
  2. +
  3. Select "Change Password"
  4. +
  5. Enter your temporary password
  6. +
  7. Enter new password (must meet requirements)
  8. +
  9. Confirm new password
  10. +
  11. Click "Update Password"
  12. +
+

Password requirements:

+
    +
  • Minimum 12 characters
  • +
  • At least one uppercase letter (A-Z)
  • +
  • At least one lowercase letter (a-z)
  • +
  • At least one digit (0-9)
  • +
+

Screenshot placeholder: Change password modal showing current/new password fields

+

Volunteer Dashboard Overview

+

Your volunteer dashboard shows:

+

Top Navigation:

+
    +
  • Dashboard — Overview and quick stats
  • +
  • My Shifts — Upcoming and past shifts
  • +
  • My Activity — Canvassing history and statistics
  • +
  • My Routes — Maps of areas you've canvassed
  • +
+

Dashboard Cards:

+
    +
  • Upcoming Shifts: Next 3 shifts you're signed up for
  • +
  • Your Statistics: Total visits, doors knocked, support found
  • +
  • Recent Activity: Last 10 visits you recorded
  • +
  • Quick Start: Button to start canvassing if you have an active shift
  • +
+

Screenshot placeholder: Volunteer dashboard showing statistics cards and upcoming shifts list

+
+

Viewing Your Shifts

+

My Shifts Page

+

To view all your shifts:

+
    +
  1. Click "My Shifts" in the top navigation
  2. +
+

The shifts page shows two tabs:

+

Upcoming Shifts

+

Shows shifts you're signed up for that haven't happened yet.

+

Each shift card shows:

+
    +
  • Shift title: Name of the canvass
  • +
  • Date and time: When to arrive
  • +
  • Meeting location: Where to meet (address or description)
  • +
  • Cut assignment: Which area you'll be canvassing
  • +
  • Other volunteers: Who else signed up (if visible)
  • +
  • Actions: Cancel signup, view details, get directions
  • +
+

Screenshot placeholder: Upcoming shifts showing three shift cards with date, time, and location

+

Past Shifts

+

Shows shifts you've completed or that have passed.

+

Each past shift shows:

+
    +
  • Shift details
  • +
  • Your attendance (if tracked)
  • +
  • Number of visits you recorded
  • +
  • Session duration
  • +
+

Screenshot placeholder: Past shifts showing completed shift cards with visit counts

+

Shift Details

+

To view shift details:

+
    +
  1. Click on a shift card
  2. +
  3. View:
  4. +
  5. Full description
  6. +
  7. Map of the cut you'll canvass
  8. +
  9. List of other volunteers (if visible)
  10. +
  11. Instructions from organizer
  12. +
  13. QR code to start canvassing (if you arrive early)
  14. +
+

Screenshot placeholder: Shift detail modal showing map, description, and volunteer list

+

Canceling a Signup

+

To cancel a shift signup:

+
    +
  1. Find the shift in My Shifts > Upcoming
  2. +
  3. Click "Cancel Signup"
  4. +
  5. Confirm cancellation
  6. +
+
+

Cancel Early

+

Please cancel at least 24 hours before the shift if possible. Your organizer needs time to find a replacement.

+
+

You'll receive a confirmation email when you cancel.

+
+

Canvassing

+

Starting a Canvass Session

+

You can start canvassing in two ways:

+

Method 1: From Dashboard (If Shift is Today)

+
    +
  1. Go to Volunteer Dashboard
  2. +
  3. If you have a shift today, you'll see a "Start Canvassing" button
  4. +
  5. Click the button
  6. +
  7. Select which shift you're canvassing for (if you have multiple)
  8. +
  9. Click "Start Session"
  10. +
+

Method 2: From My Shifts

+
    +
  1. Go to My Shifts
  2. +
  3. Find today's shift
  4. +
  5. Click "Start Canvassing"
  6. +
+

Method 3: Scan QR Code (Walk Sheet)

+

If your organizer gave you a printed walk sheet:

+
    +
  1. Open your phone's camera app
  2. +
  3. Point at the QR code on the walk sheet
  4. +
  5. Tap the notification that appears
  6. +
  7. Your browser will open and start the session automatically
  8. +
+

Screenshot placeholder: Start canvassing button on dashboard with shift selector dropdown

+
+

One Session at a Time

+

You can only have one active session. Finish your current session before starting a new one.

+
+

Understanding the Canvass Map

+

When you start a session, you'll see a full-screen map with:

+

Map Elements:

+
    +
  1. Your location (blue dot with accuracy circle)
  2. +
  3. Updates as you move
  4. +
  5. +

    Accuracy circle shows GPS precision

    +
  6. +
  7. +

    Locations to visit (house icons)

    +
  8. +
  9. Gray house: Not visited yet
  10. +
  11. Yellow house: You visited, outcome recorded
  12. +
  13. Red house: Refused to talk
  14. +
  15. Green house: Supportive (LEVEL_1 or LEVEL_2)
  16. +
  17. +

    Blue house: Not home

    +
  18. +
  19. +

    Walking route (purple line)

    +
  20. +
  21. Suggested path connecting unvisited locations
  22. +
  23. Updates as you complete visits
  24. +
  25. +

    Follow the line for efficient canvassing

    +
  26. +
  27. +

    Cut boundary (colored polygon)

    +
  28. +
  29. Your assigned territory
  30. +
  31. Don't canvass outside this area
  32. +
+

Screenshot placeholder: Canvass map showing blue location dot, house icons in different colors, and purple walking route

+

Map Controls

+

Top-left controls:

+
    +
  • Menu (hamburger icon): Open navigation drawer
  • +
  • Center on me (target icon): Re-center map on your location
  • +
  • Fullscreen (expand icon): Enter fullscreen mode
  • +
+

Bottom toolbar:

+
    +
  • Session timer: Shows how long you've been canvassing
  • +
  • Visit counter: Number of doors you've knocked
  • +
  • Next door button: Navigate to nearest unvisited location
  • +
+

Screenshot placeholder: Map controls showing timer, visit counter, and "Next Door" button

+

Following Your Walking Route

+

The purple line on the map is your suggested walking route.

+

How the route works:

+
    +
  1. Starts at your current location
  2. +
  3. Connects to nearest unvisited location
  4. +
  5. Then to next nearest unvisited location
  6. +
  7. And so on, minimizing backtracking
  8. +
+

To follow the route:

+
    +
  1. Look at the map
  2. +
  3. Walk toward the first location on the purple line
  4. +
  5. Your blue dot will move as you walk
  6. +
  7. When you reach a location, tap the house icon
  8. +
  9. Record your visit (see next section)
  10. +
  11. The route automatically updates to skip that location
  12. +
+
+

Use Turn-by-Turn Navigation

+

For long distances, tap a location and select "Get Directions" to open Google Maps for turn-by-turn navigation.

+
+

Screenshot placeholder: Walking route showing path from current location through several unvisited houses

+

Recording Visits

+

To record a visit:

+
    +
  1. Knock on the door (or ring doorbell)
  2. +
  3. Wait 20-30 seconds
  4. +
  5. If someone answers, have your conversation
  6. +
  7. After the interaction (or non-interaction), tap the house icon on the map
  8. +
  9. A bottom sheet slides up with the visit recording form
  10. +
+

Screenshot placeholder: Bottom sheet showing visit recording form with outcome buttons

+

Visit Outcomes

+

You must select one of seven outcomes:

+

1. NOT_HOME (Nobody Answered)

+

When to use:

+
    +
  • Nobody answered the door
  • +
  • Waited 20-30 seconds
  • +
  • No signs of activity
  • +
+

What happens:

+
    +
  • Location marked as "not home"
  • +
  • Could try again later
  • +
  • No other details needed
  • +
+

2. REFUSED (Refused to Talk)

+

When to use:

+
    +
  • Someone answered but declined to talk
  • +
  • "Not interested"
  • +
  • Closed door immediately
  • +
+

What happens:

+
    +
  • Location marked as "refused"
  • +
  • Don't visit again (respect their wishes)
  • +
  • Optional: Add notes about interaction
  • +
+

3. SPOKE_WITH (Had a Conversation)

+

When to use:

+
    +
  • Had a conversation (any length)
  • +
  • Discussed campaign issues
  • +
  • May or may not be supportive
  • +
+

What happens:

+
    +
  • Prompts for support level (see below)
  • +
  • Can add notes about conversation
  • +
  • Can request sign placement
  • +
+

Most important outcome — this is your goal!

+

4. MOVED_AWAY (Resident Moved)

+

When to use:

+
    +
  • Current resident says previous resident moved
  • +
  • For sale / for rent sign
  • +
  • Mailbox indicates new occupant
  • +
+

What happens:

+
    +
  • Location marked as outdated
  • +
  • Helps organizer update database
  • +
+

5. WRONG_ADDRESS (Location Doesn't Exist)

+

When to use:

+
    +
  • Address doesn't exist (vacant lot, wrong number)
  • +
  • Building demolished
  • +
  • Address is commercial, not residential
  • +
+

What happens:

+
    +
  • Flags location for removal from database
  • +
+

6. DO_NOT_CONTACT (Asked Not to Be Contacted)

+

When to use:

+
    +
  • Resident explicitly asks not to be contacted again
  • +
  • "Please remove me from your list"
  • +
  • Hostile response
  • +
+

What happens:

+
    +
  • Location permanently marked "do not contact"
  • +
  • Will never appear on future walk sheets
  • +
+
+

Respect Privacy

+

Always honor "do not contact" requests immediately. It's legally required in many jurisdictions.

+
+

7. OTHER (Something Else)

+

When to use:

+
    +
  • Situation doesn't fit other categories
  • +
  • Special circumstances
  • +
+

What happens:

+
    +
  • Prompts you to add notes explaining situation
  • +
+

Screenshot placeholder: Outcome buttons showing seven options with icons

+

Support Levels

+

When you select SPOKE_WITH, you'll be asked to rate the resident's support level.

+

Support Level Guide:

+

LEVEL_1: Strong Support

+
    +
  • Definition: Enthusiastically supports your cause
  • +
  • Indicators:
  • +
  • "Absolutely, I'm with you 100%"
  • +
  • Asks how they can help
  • +
  • Already familiar with the issue
  • +
  • Wants to volunteer
  • +
  • Action: Ask if they want a yard sign, ask for volunteer signup
  • +
+

LEVEL_2: Leaning Support

+
    +
  • Definition: Generally supportive but not highly engaged
  • +
  • Indicators:
  • +
  • "Yeah, I agree with that"
  • +
  • Positive but brief response
  • +
  • Willing to listen
  • +
  • May have some questions
  • +
  • Action: Provide information, ask if they want updates
  • +
+

LEVEL_3: Undecided / Neutral

+
    +
  • Definition: Hasn't made up their mind
  • +
  • Indicators:
  • +
  • "I need to think about it"
  • +
  • Sees both sides of the issue
  • +
  • Doesn't have strong opinion
  • +
  • Wants more information
  • +
  • Action: Provide balanced information, offer to follow up
  • +
+

LEVEL_4: Opposition

+
    +
  • Definition: Opposed to your cause
  • +
  • Indicators:
  • +
  • "I disagree with that"
  • +
  • Supports opposing position
  • +
  • Strong opinions against
  • +
  • Action: Thank them for their time, respectfully end conversation
  • +
+
+

Be Honest

+

Record the support level as accurately as possible. This data helps your organizer understand the community and plan strategy.

+
+

Screenshot placeholder: Support level selector showing LEVEL_1 through LEVEL_4 with descriptions

+

Requesting Signs

+

If the resident is supportive (LEVEL_1 or LEVEL_2), you can mark that they want a yard sign.

+

To record a sign request:

+
    +
  1. After selecting support level
  2. +
  3. Toggle "Wants Sign" to ON
  4. +
  5. Optionally add notes (e.g., "Prefers small sign", "Needs post")
  6. +
+

Your organizer will see this request and arrange sign delivery.

+

Screenshot placeholder: Sign request toggle and notes field in visit form

+

Taking Notes and Photos

+

Notes field:

+

Use the notes field to record:

+
    +
  • Key points from the conversation
  • +
  • Specific concerns the resident mentioned
  • +
  • Contact information (if they want follow-up)
  • +
  • Delivery instructions for signs
  • +
  • Any special circumstances
  • +
+

Example notes:

+
    +
  • "Very concerned about climate change. Has two kids. Wants to receive newsletter."
  • +
  • "Undecided on issue. Worried about cost. Wants more info on funding."
  • +
  • "Strong support. Already signed petition. Wants to volunteer. Email: john@example.com"
  • +
+

Photo upload (optional):

+

Some organizations enable photo upload. You might take photos of:

+
    +
  • Yard sign placements
  • +
  • Location identifiers (helps future canvassers)
  • +
  • Special notes left by resident
  • +
+
+

Privacy

+

Never take photos of people without permission. Only photograph property/signs if allowed by your organizer.

+
+

Screenshot placeholder: Notes textarea and photo upload button in visit form

+

Saving a Visit

+

To save the visit:

+
    +
  1. Select outcome
  2. +
  3. Select support level (if spoke with resident)
  4. +
  5. Add notes (optional)
  6. +
  7. Toggle sign request (if applicable)
  8. +
  9. Click "Save Visit"
  10. +
+

The bottom sheet closes, the location icon changes color, and your visit counter increments.

+

Screenshot placeholder: Complete visit form with all fields filled and "Save Visit" button highlighted

+

Skipping a Location

+

If you need to skip a location:

+
    +
  1. Don't tap the house icon
  2. +
  3. Walk to the next location on your route
  4. +
+

Reasons to skip:

+
    +
  • Dangerous dog
  • +
  • Unsafe approach (icy steps, etc.)
  • +
  • Location is inaccessible
  • +
+

You can come back to skipped locations later in the session.

+
+

Using GPS Navigation

+

Enabling Location Permissions

+

To allow location access:

+

On iPhone:

+
    +
  1. When app requests location, tap "Allow While Using App"
  2. +
  3. Or go to Settings > Safari > Location > Allow
  4. +
+

On Android:

+
    +
  1. When prompted, tap "Allow"
  2. +
  3. Or go to Settings > Apps > Chrome > Permissions > Location > Allow
  4. +
+
+

Location Required

+

The canvassing map requires location access to show your position and update the walking route.

+
+

Screenshot placeholder: Location permission prompt on mobile browser

+

Improving GPS Accuracy

+

Tips for better GPS:

+
    +
  1. Enable high accuracy mode
  2. +
  3. iPhone: Settings > Privacy > Location Services > System Services > Improve Location
  4. +
  5. +

    Android: Settings > Location > Google Location Accuracy > ON

    +
  6. +
  7. +

    Ensure clear sky view

    +
  8. +
  9. GPS works best outdoors
  10. +
  11. Move away from tall buildings if possible
  12. +
  13. +

    Trees and structures reduce accuracy

    +
  14. +
  15. +

    Wait for signal

    +
  16. +
  17. When you start session, GPS may take 30-60 seconds to lock
  18. +
  19. +

    Blue circle will shrink as accuracy improves

    +
  20. +
  21. +

    Keep phone unlocked

    +
  22. +
  23. Some browsers pause location updates when screen is locked
  24. +
  25. +

    Consider increasing screen timeout

    +
  26. +
  27. +

    Use Wi-Fi

    +
  28. +
  29. Even if not connected, enabling Wi-Fi improves location accuracy
  30. +
  31. Wi-Fi scanning helps triangulate position
  32. +
+

Screenshot placeholder: Map showing blue location dot with large accuracy circle (poor) vs small circle (good)

+

"Next Door" Button

+

The "Next Door" button at the bottom of the map automatically:

+
    +
  1. Finds the nearest unvisited location
  2. +
  3. Centers map on that location
  4. +
  5. Highlights the location (pulses)
  6. +
+

When to use it:

+
    +
  • You've finished a visit and want to know where to go next
  • +
  • You got turned around and need to reorient
  • +
  • You want to skip the current location and find the next one
  • +
+

Screenshot placeholder: "Next Door" button highlighted with arrow pointing to nearest unvisited location

+

GPS Troubleshooting

+

If GPS isn't working:

+
    +
  1. Refresh the page: Pull down to refresh
  2. +
  3. Check permissions: Make sure location is allowed
  4. +
  5. Toggle location off/on: In phone settings
  6. +
  7. Restart browser: Close and reopen
  8. +
  9. Try airplane mode toggle: Turn on/off to reset radios
  10. +
  11. Check battery saver: Some battery saver modes disable GPS
  12. +
  13. Contact your organizer: They can manually mark your visits
  14. +
+
+

Ending Your Session

+

Finishing Canvassing

+

When you're done canvassing:

+
    +
  1. Open the menu (hamburger icon, top-left)
  2. +
  3. Tap "End Session"
  4. +
  5. Review your session summary:
  6. +
  7. Total visits
  8. +
  9. Breakdown by outcome
  10. +
  11. Session duration
  12. +
  13. Support levels found
  14. +
  15. Tap "Confirm End Session"
  16. +
+

Screenshot placeholder: End session confirmation showing session statistics

+

Session Summary

+

After ending, you'll see a summary screen with:

+

Your results:

+
    +
  • Total visits: Doors you knocked
  • +
  • Spoke with: Conversations had
  • +
  • Support found: LEVEL_1 and LEVEL_2 residents
  • +
  • Sign requests: Signs to deliver
  • +
  • Session time: How long you canvassed
  • +
+

What happens next:

+
    +
  • Your visits are saved to the database
  • +
  • Your organizer can see your results
  • +
  • You can view your activity history in My Activity
  • +
+
+

Share Your Results

+

Take a screenshot of your summary to share on social media and encourage other volunteers!

+
+

Screenshot placeholder: Session summary screen showing statistics and "Share Results" button

+

Abandoned Sessions

+

If you forget to end your session, don't worry:

+
    +
  • Sessions older than 12 hours are automatically closed
  • +
  • Your visit data is preserved
  • +
  • Next time you log in, you can start a new session
  • +
+
+

Viewing Your Activity

+

My Activity Page

+

To view your canvassing history:

+
    +
  1. Click "My Activity" in the top navigation
  2. +
+

The activity page shows:

+

Statistics cards:

+
    +
  • Total visits: All-time visit count
  • +
  • Doors knocked: Total locations visited
  • +
  • Support found: LEVEL_1 and LEVEL_2 combined
  • +
  • Signs requested: Total sign requests
  • +
+

Outcome breakdown chart:

+
    +
  • Pie chart showing % of each outcome
  • +
  • NOT_HOME, REFUSED, SPOKE_WITH, etc.
  • +
  • Helps you see patterns
  • +
+

Visit history table:

+
    +
  • Date and time
  • +
  • Address visited
  • +
  • Outcome
  • +
  • Support level
  • +
  • Notes
  • +
  • Associated shift
  • +
+

Screenshot placeholder: My Activity page showing statistics, pie chart, and visit history table

+

Filtering Your Activity

+

Available filters:

+
    +
  • Date range: Last 7 days, last 30 days, all time, custom
  • +
  • Outcome: Show only specific outcomes
  • +
  • Support level: Show only specific support levels
  • +
  • Shift: Show only specific shifts
  • +
+

Screenshot placeholder: Activity filters showing date range picker and outcome dropdown

+

Exporting Your Data

+

To export your activity:

+
    +
  1. Go to My Activity
  2. +
  3. Apply filters (optional)
  4. +
  5. Click "Export CSV"
  6. +
  7. Open the file in Excel or Google Sheets
  8. +
+

The export includes all visible visits with full details.

+
+

My Routes

+

Viewing Past Routes

+

To see where you've canvassed:

+
    +
  1. Click "My Routes" in the top navigation
  2. +
+

Each past session shows:

+
    +
  • Map of the cut you canvassed
  • +
  • Your path (GPS track, if available)
  • +
  • Visited locations (colored by outcome)
  • +
  • Session details: Date, duration, visit count
  • +
+

Screenshot placeholder: My Routes showing map with GPS track and visited location markers

+

Route Statistics

+

For each route, you can see:

+
    +
  • Distance traveled: Estimated walking distance
  • +
  • Coverage: % of cut visited
  • +
  • Average time per visit: How long each interaction took
  • +
  • Efficiency: Visits per hour
  • +
+

This helps you improve your canvassing technique over time.

+
+

Mobile Tips

+

Battery Saving

+

Canvassing uses GPS continuously, which drains battery. To conserve:

+
    +
  1. Lower screen brightness: Adjust in quick settings
  2. +
  3. Enable battery saver (after GPS locks): Reduces background activity
  4. +
  5. Close other apps: Free up resources
  6. +
  7. Bring portable charger: Essential for long sessions
  8. +
  9. Use low power mode (cautiously): May reduce GPS accuracy
  10. +
+

Expected battery life:

+
    +
  • 2-3 hours of continuous canvassing
  • +
  • Bring charger for sessions longer than 2 hours
  • +
+

Screenshot placeholder: Phone battery settings showing low power mode and brightness slider

+

Offline Considerations

+

The canvassing app requires internet connection for:

+
    +
  • Loading the map
  • +
  • Saving visits to the server
  • +
  • Updating the walking route
  • +
+
+

No Offline Mode

+

Currently, there's no offline mode. Ensure you have cellular data or Wi-Fi before starting.

+
+

If you lose connection:

+
    +
  • Your current location still updates (GPS works offline)
  • +
  • You can still record visits (they're saved locally)
  • +
  • Visits will sync when connection returns
  • +
  • Map tiles may not load in new areas
  • +
+

Tips:

+
    +
  • Check signal strength before starting session
  • +
  • Start session while connected (loads map data)
  • +
  • If rural area, load map of cut before leaving Wi-Fi
  • +
+

Network Connectivity

+

Minimum requirements:

+
    +
  • 3G cellular data or better
  • +
  • Low latency (< 500ms ping)
  • +
+

Recommended:

+
    +
  • 4G/LTE or better
  • +
  • Wi-Fi for starting session (loads initial data faster)
  • +
+

Data usage:

+
    +
  • ~5-10 MB per hour of canvassing
  • +
  • Map tiles are the largest data use
  • +
  • Visit recording uses minimal data
  • +
+
+

Safety & Privacy

+

Personal Safety Tips

+

Before you go:

+
    +
  1. Let someone know: Tell a friend/family where you'll be canvassing
  2. +
  3. Bring a buddy: Canvass in pairs if possible
  4. +
  5. Charge your phone: Essential for emergencies
  6. +
  7. Wear comfortable shoes: You'll be walking a lot
  8. +
  9. Check the weather: Dress appropriately
  10. +
+

While canvassing:

+
    +
  1. Stay in public view: Don't enter homes or yards
  2. +
  3. Trust your instincts: Skip locations that feel unsafe
  4. +
  5. Avoid aggressive dogs: Use the "skip" function
  6. +
  7. Stay hydrated: Bring water, especially in summer
  8. +
  9. Take breaks: Rest every hour
  10. +
  11. Be aware of traffic: Look both ways before crossing streets
  12. +
+

If you feel unsafe:

+
    +
  1. Leave the area immediately
  2. +
  3. Mark the location with outcome "OTHER" and note the safety concern
  4. +
  5. Contact your organizer
  6. +
  7. Call 911 if there's an emergency
  8. +
+
+

Safety First

+

Never prioritize completing visits over your personal safety. It's always okay to skip a location or end your session early.

+
+

Screenshot placeholder: Safety checklist infographic

+

Privacy of Resident Information

+

What you can do with resident data:

+
    +
  • Use it during your canvass session
  • +
  • Record visit outcomes and notes
  • +
  • Share relevant information with your organizer
  • +
+

What you cannot do:

+
    +
  • Share resident information on social media
  • +
  • Use contact info for personal purposes
  • +
  • Sell or distribute the data
  • +
  • Contact residents outside official campaign activities
  • +
+

Legal obligations:

+
    +
  • Respect "do not contact" requests immediately
  • +
  • Don't photograph residents without permission
  • +
  • Don't share personal details residents tell you (unless they explicitly allow)
  • +
+

Data you record is used for:

+
    +
  • Campaign strategy and planning
  • +
  • Follow-up contact (official campaign only)
  • +
  • Sign delivery coordination
  • +
  • Voter outreach statistics
  • +
+
+

Confidentiality

+

Treat all resident information as confidential. Violating privacy can result in legal consequences and harm the campaign.

+
+
+

FAQs

+

Account & Login

+

Q: I forgot my password. How do I reset it?

+

A: Click "Forgot Password" on the login page, enter your email, and check your email for reset instructions.

+

Q: My email says I have a TEMP account. What does that mean?

+

A: TEMP accounts are created when you sign up for a shift publicly. After your first shift, an admin will upgrade you to a USER account with full access.

+

Q: Can I change my email address?

+

A: Contact your organizer to change your email. You cannot change it yourself.

+
+

Shifts

+

Q: I signed up for a shift but didn't receive a confirmation email.

+

A: Check your spam folder. If still not there, contact your organizer to verify your signup.

+

Q: Can I sign up a friend for a shift?

+

A: Use the public signup form (one signup per person). Or ask your organizer to create accounts for multiple people.

+

Q: What if I'm running late to a shift?

+

A: Contact your organizer as soon as possible. You can still start canvassing when you arrive.

+

Q: I don't see any shifts. When will more be added?

+

A: Your organizer creates shifts as needed. Check back regularly or ask when the next shift will be scheduled.

+
+

Canvassing

+

Q: What should I say at the door?

+

A: Your organizer will provide a script or talking points. Generally: +1. Introduce yourself and your organization +2. Briefly explain why you're canvassing +3. Ask if they have time to talk +4. Respect their answer (yes or no)

+

Q: What if someone gets angry?

+

A: Stay calm, polite, and respectful. Say "I understand, thank you for your time" and leave. Mark as REFUSED. If threatened, leave immediately and report to your organizer.

+

Q: Can I canvass outside my assigned cut?

+

A: No, stick to your assigned territory. Other volunteers may be assigned to other cuts, and visiting outside your area creates duplication.

+

Q: What if I make a mistake recording a visit?

+

A: Contact your organizer. They can edit visit records in the admin panel.

+

Q: The walking route seems inefficient. Can I change it?

+

A: The route is generated automatically. You can visit locations in any order you prefer—the route is just a suggestion.

+

Q: What if it starts raining?

+

A: Your safety comes first. End your session and seek shelter. You can resume canvassing later.

+
+

Technical Issues

+

Q: The map won't load.

+

A: +1. Check your internet connection +2. Refresh the page (pull down) +3. Try logging out and back in +4. Try a different browser +5. Contact your organizer if still not working

+

Q: My location is wrong on the map.

+

A: +1. Make sure location permissions are enabled +2. Move to an area with clear sky view +3. Wait 1-2 minutes for GPS to improve +4. Toggle airplane mode off/on to reset GPS

+

Q: I can't save a visit.

+

A: +1. Check your internet connection (visit saves to server) +2. Make sure you selected an outcome +3. Try refreshing the page +4. If offline, visit will save when connection returns

+

Q: The app is slow.

+

A: +1. Close other apps (frees up memory) +2. Restart your browser +3. Clear browser cache (Settings > Safari/Chrome > Clear Cache) +4. Update your browser to latest version

+

Q: I accidentally ended my session. Can I resume?

+

A: No, sessions cannot be resumed. Start a new session to continue canvassing.

+
+

Data & Privacy

+

Q: What data do you collect about me?

+

A: We collect: +- Your name and email (account info) +- GPS location (only during canvassing sessions) +- Visit records (outcomes, notes you enter) +- Session statistics (time, visit count)

+

Q: Is my location tracked when I'm not canvassing?

+

A: No, location is only accessed when you have an active canvassing session. Close your browser when done to ensure no tracking.

+

Q: Can other volunteers see my activity?

+

A: Other volunteers cannot see your activity. Only administrators can view visit records and statistics.

+

Q: Can I delete my account?

+

A: Contact your organizer to request account deletion. This will remove your personal information but preserve anonymized visit records for campaign statistics.

+

Q: What happens to the data I collect?

+

A: Visit data is used for: +- Campaign strategy (identifying support levels) +- Volunteer coordination (tracking coverage) +- Sign delivery (fulfilling requests) +- Follow-up outreach (contacting supportive residents)

+

Data is never sold or shared with third parties.

+
+

Troubleshooting

+

Common Issues

+

Cannot Start Canvass Session

+

Error: "No active shift found"

+

Solution: You need a shift assigned to you for today. Check My Shifts to see if you have any upcoming shifts. If not, sign up for a shift or contact your organizer.

+
+

Error: "Shift has no cut assigned"

+

Solution: The shift you signed up for doesn't have a territory assigned. Contact your organizer to assign a cut to the shift.

+
+

Error: "You already have an active session"

+

Solution: You have an abandoned session from a previous canvass. Contact your organizer to close the old session, or wait 12 hours for automatic cleanup.

+
+

GPS Not Working

+

Symptoms: Blue location dot doesn't appear or doesn't move

+

Solutions:

+
    +
  1. Enable location permissions:
  2. +
  3. iPhone: Settings > Safari > Location Services > While Using
  4. +
  5. Android: Settings > Apps > Chrome > Permissions > Location > Allow
  6. +
  7. Refresh the page: Pull down to refresh
  8. +
  9. Check GPS signal: Move to an area with clear sky view
  10. +
  11. Restart location services: Toggle location off/on in phone settings
  12. +
  13. Try a different browser: Some browsers have better GPS support
  14. +
+
+

Walking Route Not Updating

+

Symptoms: Purple line doesn't change after completing visits

+

Solutions:

+
    +
  1. Refresh the map: Pull down to refresh
  2. +
  3. Check internet connection: Route updates require server communication
  4. +
  5. Wait 30 seconds: Updates may be delayed
  6. +
  7. Manually navigate: Use "Next Door" button instead of following line
  8. +
+
+

Visit Won't Save

+

Symptoms: "Save Visit" button doesn't work or shows error

+

Solutions:

+
    +
  1. Check required fields: Make sure you selected an outcome
  2. +
  3. Check internet connection: Visits save to server (requires connection)
  4. +
  5. Try again: Close bottom sheet and tap location again
  6. +
  7. Refresh page: Pull down to refresh
  8. +
  9. Record offline: If persistently failing, write down visit details and report to organizer later
  10. +
+
+

Bottom Sheet Won't Close

+

Symptoms: Visit recording form stays open after saving

+

Solutions:

+
    +
  1. Swipe down: Swipe bottom sheet downward to close
  2. +
  3. Tap outside: Tap on the map area
  4. +
  5. Refresh page: Pull down to refresh
  6. +
+
+

Getting Help

+

If you have technical issues during canvassing:

+
    +
  1. Try basic troubleshooting: Refresh page, check connection
  2. +
  3. Continue canvassing: Use "Next Door" button and visual map
  4. +
  5. Take notes: Write down visit details if app fails
  6. +
  7. Report to organizer: After session, explain what happened
  8. +
+

If you have questions about canvassing technique:

+
    +
  1. Ask your organizer: Before the shift
  2. +
  3. Consult the script: Your organizer should provide talking points
  4. +
  5. Watch experienced volunteers: Learn by observing
  6. +
+

If you have account or scheduling issues:

+
    +
  1. Contact your organizer: They have admin access to fix account problems
  2. +
  3. Check your email: Look for notifications about shift changes
  4. +
  5. Review this guide: Many common questions are answered here
  6. +
+
+ + +
+

Last updated: February 2026 (V2 complete)

+

Need help? Contact your organizer or visit the documentation at /docs.

+ + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file